feat(app): open in <app> button (#12322)

This commit is contained in:
Adam
2026-02-05 13:55:49 -06:00
committed by GitHub
parent 83646e0366
commit b738d88ec4
24 changed files with 452 additions and 11 deletions

View File

@@ -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">