diff options
| author | Adam <[email protected]> | 2026-02-05 13:55:49 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-05 19:55:49 +0000 |
| commit | b738d88ec4c49efdf37ecf09058e70f1c3574b6b (patch) | |
| tree | 524219619c6e5ce0159679073183e2c3930c4ce6 /packages/app/src/components | |
| parent | 83646e0366c47a3bccb5135d40628176a6776f33 (diff) | |
| download | opencode-b738d88ec4c49efdf37ecf09058e70f1c3574b6b.tar.gz opencode-b738d88ec4c49efdf37ecf09058e70f1c3574b6b.zip | |
feat(app): open in <app> button (#12322)
Diffstat (limited to 'packages/app/src/components')
| -rw-r--r-- | packages/app/src/components/session/session-header.tsx | 187 |
1 files changed, 187 insertions, 0 deletions
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) => ( <Portal mount={mount()}> <div class="flex items-center gap-3"> + <Show when={projectDirectory()}> + <Show + when={canOpen()} + fallback={ + <Button + variant="ghost" + class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none" + onClick={copyPath} + aria-label={language.t("session.header.open.copyPath")} + > + <Icon name="copy" size="small" class="text-icon-base" /> + <span class="text-12-regular text-text-strong">{language.t("session.header.open.copyPath")}</span> + </Button> + } + > + <div class="flex items-center"> + <Button + variant="ghost" + class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none rounded-r-none" + onClick={() => openDir(current().id)} + aria-label={language.t("session.header.open.ariaLabel", { app: current().label })} + > + <AppIcon id={current().icon} class="size-5" /> + <span class="text-12-regular text-text-strong"> + {language.t("session.header.open.action", { app: current().label })} + </span> + </Button> + <DropdownMenu> + <DropdownMenu.Trigger + as={IconButton} + icon="chevron-down" + variant="ghost" + class="rounded-sm h-[24px] w-auto px-1.5 border-none shadow-none rounded-l-none data-[expanded]:bg-surface-raised-base-active" + aria-label={language.t("session.header.open.menu")} + /> + <DropdownMenu.Portal> + <DropdownMenu.Content placement="bottom-end" gutter={6}> + <DropdownMenu.Group> + <DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel> + <DropdownMenu.RadioGroup + value={prefs.app} + onChange={(value) => { + if (!OPEN_APPS.includes(value as OpenApp)) return + setPrefs("app", value as OpenApp) + }} + > + {options().map((o) => ( + <DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}> + <AppIcon id={o.icon} class="size-5" /> + <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel> + <DropdownMenu.ItemIndicator> + <Icon name="check-small" size="small" class="text-icon-weak" /> + </DropdownMenu.ItemIndicator> + </DropdownMenu.RadioItem> + ))} + </DropdownMenu.RadioGroup> + </DropdownMenu.Group> + <DropdownMenu.Separator /> + <DropdownMenu.Item onSelect={copyPath}> + <Icon name="copy" size="small" class="text-icon-weak" /> + <DropdownMenu.ItemLabel> + {language.t("session.header.open.copyPath")} + </DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + </div> + </Show> + </Show> <StatusPopover /> <Show when={showShare()}> <div class="flex items-center"> |
