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", diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 66f068af8..e895cdf78 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -6,6 +6,19 @@ "permissions": [ "core:default", "opener:default", + { + "identifier": "opener:allow-open-path", + "allow": [ + { "path": "**/*" }, + { "path": "/**/*" }, + { "path": "**/.*/*/**" }, + { "path": "/**/.*/*/**" }, + { "path": "**/*", "app": true }, + { "path": "/**/*", "app": true }, + { "path": "**/.*/*/**", "app": true }, + { "path": "/**/.*/*/**", "app": true } + ] + }, "deep-link:default", "core:window:allow-start-dragging", "core:window:allow-set-theme", diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 440e138b4..eb5498fa6 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -1,19 +1,20 @@ // This file has been generated by Tauri Specta. Do not edit this file manually. -import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core" +import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core'; /** Commands */ export const commands = { - killSidecar: () => __TAURI_INVOKE("kill_sidecar"), - installCli: () => __TAURI_INVOKE("install_cli"), - ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), - getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), - setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), - parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), -} + killSidecar: () => __TAURI_INVOKE("kill_sidecar"), + installCli: () => __TAURI_INVOKE("install_cli"), + ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), + getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), + setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), +}; /* Types */ export type ServerReadyData = { - url: string - password: string | null -} + url: string, + password: string | null, + }; + diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index b54e1f79f..30cb7ba7a 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -4,6 +4,7 @@ import { render } from "solid-js/web" import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app" import { open, save } from "@tauri-apps/plugin-dialog" import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link" +import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener" import { open as shellOpen } from "@tauri-apps/plugin-shell" import { type as ostype } from "@tauri-apps/plugin-os" import { check, Update } from "@tauri-apps/plugin-updater" @@ -87,6 +88,10 @@ const createPlatform = (password: Accessor): Platform => ({ void shellOpen(url).catch(() => undefined) }, + openPath(path: string, app?: string) { + return openerOpenPath(path, app) + }, + back() { window.history.back() }, diff --git a/packages/ui/package.json b/packages/ui/package.json index c2462c489..cdc8baa0b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,6 +18,7 @@ "./theme/context": "./src/theme/context.tsx", "./icons/provider": "./src/components/provider-icons/types.ts", "./icons/file-type": "./src/components/file-icons/types.ts", + "./icons/app": "./src/components/app-icons/types.ts", "./fonts/*": "./src/assets/fonts/*", "./audio/*": "./src/assets/audio/*" }, diff --git a/packages/ui/src/assets/icons/app/android-studio.svg b/packages/ui/src/assets/icons/app/android-studio.svg new file mode 100644 index 000000000..6b545e27a --- /dev/null +++ b/packages/ui/src/assets/icons/app/android-studio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/antigravity.svg b/packages/ui/src/assets/icons/app/antigravity.svg new file mode 100644 index 000000000..3c3554af7 --- /dev/null +++ b/packages/ui/src/assets/icons/app/antigravity.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/cursor.svg b/packages/ui/src/assets/icons/app/cursor.svg new file mode 100644 index 000000000..c2c8c1819 --- /dev/null +++ b/packages/ui/src/assets/icons/app/cursor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/finder.png b/packages/ui/src/assets/icons/app/finder.png new file mode 100644 index 000000000..4edf53bca Binary files /dev/null and b/packages/ui/src/assets/icons/app/finder.png differ diff --git a/packages/ui/src/assets/icons/app/ghostty.svg b/packages/ui/src/assets/icons/app/ghostty.svg new file mode 100644 index 000000000..1dc652aac --- /dev/null +++ b/packages/ui/src/assets/icons/app/ghostty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/iterm2.svg b/packages/ui/src/assets/icons/app/iterm2.svg new file mode 100644 index 000000000..0b00a1b7a --- /dev/null +++ b/packages/ui/src/assets/icons/app/iterm2.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/src/assets/icons/app/powershell.svg b/packages/ui/src/assets/icons/app/powershell.svg new file mode 100644 index 000000000..fa0c70f0b --- /dev/null +++ b/packages/ui/src/assets/icons/app/powershell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/terminal.png b/packages/ui/src/assets/icons/app/terminal.png new file mode 100644 index 000000000..43857b632 Binary files /dev/null and b/packages/ui/src/assets/icons/app/terminal.png differ diff --git a/packages/ui/src/assets/icons/app/textmate.png b/packages/ui/src/assets/icons/app/textmate.png new file mode 100644 index 000000000..1eee73c5e Binary files /dev/null and b/packages/ui/src/assets/icons/app/textmate.png differ diff --git a/packages/ui/src/assets/icons/app/vscode.svg b/packages/ui/src/assets/icons/app/vscode.svg new file mode 100644 index 000000000..aba7e19f6 --- /dev/null +++ b/packages/ui/src/assets/icons/app/vscode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/icons/app/xcode.png b/packages/ui/src/assets/icons/app/xcode.png new file mode 100644 index 000000000..c37d9f176 Binary files /dev/null and b/packages/ui/src/assets/icons/app/xcode.png differ diff --git a/packages/ui/src/assets/icons/app/zed.svg b/packages/ui/src/assets/icons/app/zed.svg new file mode 100644 index 000000000..7c9a0e591 --- /dev/null +++ b/packages/ui/src/assets/icons/app/zed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/components/app-icon.css b/packages/ui/src/components/app-icon.css new file mode 100644 index 000000000..edcdbcceb --- /dev/null +++ b/packages/ui/src/components/app-icon.css @@ -0,0 +1,9 @@ +img[data-component="app-icon"] { + display: block; + box-sizing: border-box; + padding: 2px; + border-radius: 0.125rem; + background: var(--smoke-light-2); + border: 1px solid var(--smoke-light-alpha-4); + object-fit: contain; +} diff --git a/packages/ui/src/components/app-icon.tsx b/packages/ui/src/components/app-icon.tsx new file mode 100644 index 000000000..f58b5d38c --- /dev/null +++ b/packages/ui/src/components/app-icon.tsx @@ -0,0 +1,52 @@ +import type { Component, ComponentProps } from "solid-js" +import { splitProps } from "solid-js" +import type { IconName } from "./app-icons/types" + +import androidStudio from "../assets/icons/app/android-studio.svg" +import antigravity from "../assets/icons/app/antigravity.svg" +import cursor from "../assets/icons/app/cursor.svg" +import finder from "../assets/icons/app/finder.png" +import ghostty from "../assets/icons/app/ghostty.svg" +import iterm2 from "../assets/icons/app/iterm2.svg" +import powershell from "../assets/icons/app/powershell.svg" +import terminal from "../assets/icons/app/terminal.png" +import textmate from "../assets/icons/app/textmate.png" +import vscode from "../assets/icons/app/vscode.svg" +import xcode from "../assets/icons/app/xcode.png" +import zed from "../assets/icons/app/zed.svg" + +const icons = { + vscode, + cursor, + zed, + finder, + terminal, + iterm2, + ghostty, + xcode, + "android-studio": androidStudio, + antigravity, + textmate, + powershell, +} satisfies Record + +export type AppIconProps = Omit, "src"> & { + id: IconName +} + +export const AppIcon: Component = (props) => { + const [local, rest] = splitProps(props, ["id", "class", "classList", "alt", "draggable"]) + return ( + {local.alt + ) +} diff --git a/packages/ui/src/components/app-icons/sprite.svg b/packages/ui/src/components/app-icons/sprite.svg new file mode 100644 index 000000000..68361f413 --- /dev/null +++ b/packages/ui/src/components/app-icons/sprite.svg @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/src/components/app-icons/types.ts b/packages/ui/src/components/app-icons/types.ts new file mode 100644 index 000000000..81964b8da --- /dev/null +++ b/packages/ui/src/components/app-icons/types.ts @@ -0,0 +1,18 @@ +// This file is generated by icon spritesheet generator + +export const iconNames = [ + "vscode", + "cursor", + "zed", + "finder", + "terminal", + "iterm2", + "ghostty", + "xcode", + "android-studio", + "antigravity", + "textmate", + "powershell", +] as const + +export type IconName = (typeof iconNames)[number] diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index c038f69f6..c85df7ba3 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -7,6 +7,7 @@ @import "katex/dist/katex.min.css" layer(base); @import "../components/accordion.css" layer(components); +@import "../components/app-icon.css" layer(components); @import "../components/avatar.css" layer(components); @import "../components/basic-tool.css" layer(components); @import "../components/button.css" layer(components);