summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-05 13:55:49 -0600
committerGitHub <[email protected]>2026-02-05 19:55:49 +0000
commitb738d88ec4c49efdf37ecf09058e70f1c3574b6b (patch)
tree524219619c6e5ce0159679073183e2c3930c4ce6 /packages/app/src/components
parent83646e0366c47a3bccb5135d40628176a6776f33 (diff)
downloadopencode-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.tsx187
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">