From b738d88ec4c49efdf37ecf09058e70f1c3574b6b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:55:49 -0600 Subject: feat(app): open in button (#12322) --- .../app/src/components/session/session-header.tsx | 187 +++++++++++++++++++++ packages/app/src/context/platform.tsx | 3 + packages/app/src/i18n/en.ts | 5 + 3 files changed, 195 insertions(+) (limited to 'packages/app/src') diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index f2bfc8d25..43057d63b 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -6,18 +6,23 @@ import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" import { getFilename } from "@opencode-ai/util/path" import { decode64 } from "@/utils/base64" +import { Persist, persisted } from "@/utils/persist" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" +import { AppIcon } from "@opencode-ai/ui/app-icon" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" import { Keybind } from "@opencode-ai/ui/keybind" +import { showToast } from "@opencode-ai/ui/toast" import { StatusPopover } from "../status-popover" export function SessionHeader() { @@ -25,6 +30,7 @@ export function SessionHeader() { const layout = useLayout() const params = useParams() const command = useCommand() + const server = useServer() const sync = useSync() const platform = usePlatform() const language = useLanguage() @@ -48,6 +54,117 @@ export function SessionHeader() { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey)) + const OPEN_APPS = [ + "vscode", + "cursor", + "zed", + "textmate", + "antigravity", + "finder", + "terminal", + "iterm2", + "ghostty", + "xcode", + "android-studio", + "powershell", + ] as const + type OpenApp = (typeof OPEN_APPS)[number] + + const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => { + if (platform.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" + }) + + const options = createMemo(() => { + if (os() === "macos") { + return [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, + { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" }, + { id: "finder", label: "Finder", icon: "finder" }, + { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, + { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" }, + ] as const + } + + if (os() === "windows") { + return [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "finder", label: "File Explorer", icon: "finder" }, + { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" }, + ] as const + } + + return [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "finder", label: "File Manager", icon: "finder" }, + ] as const + }) + + const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) + + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) + + createEffect(() => { + if (platform.platform !== "desktop") return + const value = prefs.app + if (options().some((o) => o.id === value)) return + setPrefs("app", options()[0]?.id ?? "finder") + }) + + const openDir = (app: OpenApp) => { + const directory = projectDirectory() + if (!directory) return + if (!canOpen()) return + + const item = options().find((o) => o.id === app) + const openWith = item && "openWith" in item ? item.openWith : undefined + Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + }) + } + + const copyPath = () => { + const directory = projectDirectory() + if (!directory) return + navigator.clipboard + .writeText(directory) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.share.copy.copied"), + description: directory, + }) + }) + .catch((err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + }) + } + const [state, setState] = createStore({ share: false, unshare: false, @@ -150,6 +267,76 @@ export function SessionHeader() { {(mount) => (
+ + + + {language.t("session.header.open.copyPath")} + + } + > +
+ + + + + + + {language.t("session.header.openIn")} + { + if (!OPEN_APPS.includes(value as OpenApp)) return + setPrefs("app", value as OpenApp) + }} + > + {options().map((o) => ( + openDir(o.id)}> + + {o.label} + + + + + ))} + + + + + + + {language.t("session.header.open.copyPath")} + + + + + +
+
+
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 591bd9c9f..f5d20ff8e 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -15,6 +15,9 @@ export type Platform = { /** Open a URL in the default browser */ openLink(url: string): void + /** Open a local path in a local app (desktop only) */ + openPath?(path: string, app?: string): Promise + /** Restart the app */ restart(): Promise diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7c4d3a44a..32c4695db 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -470,6 +470,11 @@ export const dict = { "session.header.search.placeholder": "Search {{project}}", "session.header.searchFiles": "Search files", + "session.header.openIn": "Open in", + "session.header.open.action": "Open {{app}}", + "session.header.open.ariaLabel": "Open in {{app}}", + "session.header.open.menu": "Open options", + "session.header.open.copyPath": "Copy Path", "status.popover.trigger": "Status", "status.popover.ariaLabel": "Server configurations", -- cgit v1.2.3