diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 65e322b434..631dab6079 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -319,7 +319,7 @@ export function DialogConnectProvider(props: { provider: string }) { onMount(() => { if (store.authorization?.method === "code" && store.authorization?.url) { - platform.openLink(store.authorization.url) + void platform.openLink(store.authorization.url).catch(() => undefined) } }) @@ -396,7 +396,7 @@ export function DialogConnectProvider(props: { provider: string }) { onMount(() => { void (async () => { if (store.authorization?.url) { - platform.openLink(store.authorization.url) + void platform.openLink(store.authorization.url).catch(() => undefined) } const result = await globalSDK.client.provider.oauth diff --git a/packages/app/src/components/link.tsx b/packages/app/src/components/link.tsx index e13c313304..6a6ca69c33 100644 --- a/packages/app/src/components/link.tsx +++ b/packages/app/src/components/link.tsx @@ -10,7 +10,11 @@ export function Link(props: LinkProps) { const [local, rest] = splitProps(props, ["href", "children"]) return ( - ) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 5b00f80c05..90b5b68b25 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -19,6 +19,7 @@ import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" import { Keybind } from "@opencode-ai/ui/keybind" import { StatusPopover } from "../status-popover" +import { SessionOpenMenu } from "./session-open-menu" export function SessionHeader() { const globalSDK = useGlobalSDK() @@ -117,7 +118,7 @@ export function SessionHeader() { function viewShare() { const url = shareUrl() if (!url) return - platform.openLink(url) + void platform.openLink(url).catch(() => undefined) } const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) @@ -150,6 +151,7 @@ export function SessionHeader() { {(mount) => (
+
diff --git a/packages/app/src/components/session/session-open-menu.tsx b/packages/app/src/components/session/session-open-menu.tsx new file mode 100644 index 0000000000..b43d0c4e9c --- /dev/null +++ b/packages/app/src/components/session/session-open-menu.tsx @@ -0,0 +1,110 @@ +import { createMemo, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" +import { Button } from "@opencode-ai/ui/button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { FileTypeIcon } from "@opencode-ai/ui/file-type-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" + +export function SessionOpenMenu(props: { dir: string }) { + const platform = usePlatform() + const server = useServer() + const language = useLanguage() + + const enabled = createMemo( + () => platform.platform === "desktop" && platform.os === "macos" && server.isLocal() && !!props.dir, + ) + + const open = (app?: string) => { + if (!props.dir) return + void platform.openLink(props.dir, app).catch((error) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }) + }) + } + + const copy = () => { + if (!props.dir) return + navigator.clipboard + .writeText(props.dir) + .then(() => { + showToast({ + variant: "success", + icon: "check", + title: language.t("session.header.copyPath.copied"), + }) + }) + .catch(() => { + showToast({ + variant: "error", + title: language.t("session.header.copyPath.copyFailed"), + }) + }) + } + + return ( + + + {language.t("session.header.open")} + + + + + + + {language.t("session.header.openIn")} + open("Visual Studio Code")}> + + VS Code + + open("Cursor")}> + + Cursor + + open("Finder")}> + + Finder + + open("Terminal")}> + + Terminal + + open("iTerm")}> + + iTerm2 + + open("Ghostty")}> + + Ghostty + + open("Xcode")}> + + Xcode + + open("Android Studio")}> + + Android Studio + + + + + + + {language.t("session.header.copyPath")} + + + + + ) +} diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 591bd9c9fa..6c68ff5aa8 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -12,8 +12,8 @@ export type Platform = { /** App version */ version?: string - /** Open a URL in the default browser */ - openLink(url: string): void + /** Open a URL/path using the OS (optionally with a specific app) */ + openLink(url: string, openWith?: string): Promise /** Restart the app */ restart(): Promise diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index aa52fa1e7c..6eb1ed86fd 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -28,7 +28,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { const platform: Platform = { platform: "web", version: pkg.version, - openLink(url: string) { + async openLink(url: string, _openWith?: string) { window.open(url, "_blank") }, back() { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index fb71a578af..6d7cf84e39 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -469,6 +469,11 @@ export const dict = { "session.header.search.placeholder": "Search {{project}}", "session.header.searchFiles": "Search files", + "session.header.open": "Open", + "session.header.openIn": "Open in", + "session.header.copyPath": "Copy Path", + "session.header.copyPath.copied": "Copied path", + "session.header.copyPath.copyFailed": "Failed to copy path to clipboard", "status.popover.trigger": "Status", "status.popover.ariaLabel": "Server configurations", diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 6d6faf6fa3..53d9bf507c 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -269,14 +269,14 @@ export const ErrorPage: Component = (props) => {
{language.t("error.page.report.prefix")} - +
{(version) => ( diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 46c9c9154f..5e6545a11e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2995,7 +2995,7 @@ export default function Layout(props: ParentProps) { icon="help" variant="ghost" size="large" - onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")} + onClick={() => void platform.openLink("https://opencode.ai/desktop-feedback").catch(() => undefined)} aria-label={language.t("sidebar.help")} /> diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 66f068af8b..d3309d5fad 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -6,6 +6,10 @@ "permissions": [ "core:default", "opener:default", + { + "identifier": "opener:allow-open-path", + "allow": [{ "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 440e138b4f..eb5498fa68 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 9ef680ed86..a2baeb57d4 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -4,7 +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 { open as shellOpen } from "@tauri-apps/plugin-shell" +import { openPath, openUrl } from "@tauri-apps/plugin-opener" import { type as ostype } from "@tauri-apps/plugin-os" import { check, Update } from "@tauri-apps/plugin-updater" import { getCurrentWindow } from "@tauri-apps/api/window" @@ -94,8 +94,10 @@ const createPlatform = (password: Accessor): Platform => ({ return result }, - openLink(url: string) { - void shellOpen(url).catch(() => undefined) + openLink(url: string, openWith?: string) { + const isUrl = /^(https?:|mailto:|tel:|opencode:)/.test(url) + if (isUrl) return openUrl(url, openWith) + return openPath(url, openWith) }, back() { @@ -359,7 +361,7 @@ render(() => { const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null if (link?.href) { e.preventDefault() - platform.openLink(link.href) + void platform.openLink(link.href).catch(() => undefined) } } diff --git a/packages/ui/src/components/file-type-icon.tsx b/packages/ui/src/components/file-type-icon.tsx new file mode 100644 index 0000000000..6d3696db53 --- /dev/null +++ b/packages/ui/src/components/file-type-icon.tsx @@ -0,0 +1,24 @@ +import type { Component, JSX } from "solid-js" +import { splitProps } from "solid-js" +import sprite from "./file-icons/sprite.svg" +import type { IconName } from "./file-icons/types" + +export type FileTypeIconProps = JSX.SVGElementTags["svg"] & { + id: IconName +} + +export const FileTypeIcon: Component = (props) => { + const [local, rest] = splitProps(props, ["id", "class", "classList"]) + return ( + + + + ) +}