Przeglądaj źródła

wip(app): open button

Adam 2 miesięcy temu
rodzic
commit
2264c93b6b

+ 2 - 2
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

+ 5 - 1
packages/app/src/components/link.tsx

@@ -10,7 +10,11 @@ export function Link(props: LinkProps) {
   const [local, rest] = splitProps(props, ["href", "children"])
 
   return (
-    <button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
+    <button
+      class="text-text-strong underline"
+      onClick={() => void platform.openLink(local.href).catch(() => undefined)}
+      {...rest}
+    >
       {local.children}
     </button>
   )

+ 3 - 1
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) => (
           <Portal mount={mount()}>
             <div class="flex items-center gap-3">
+              <SessionOpenMenu dir={projectDirectory()} />
               <StatusPopover />
               <Show when={showShare()}>
                 <div class="flex items-center">

+ 110 - 0
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 (
+    <DropdownMenu modal={false}>
+      <DropdownMenu.Trigger
+        as={Button}
+        variant="ghost"
+        icon="folder"
+        class="rounded-sm h-[24px] py-1.5 pr-2 pl-2 gap-1.5 border-none shadow-none data-[expanded]:bg-surface-raised-base-active"
+        aria-label={language.t("session.header.open")}
+      >
+        <span class="text-12-regular text-text-strong">{language.t("session.header.open")}</span>
+        <Icon name="chevron-down" size="small" class="icon-base" />
+      </DropdownMenu.Trigger>
+      <DropdownMenu.Portal>
+        <DropdownMenu.Content class="mt-1 w-60">
+          <Show when={enabled()}>
+            <DropdownMenu.Group>
+              <DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
+              <DropdownMenu.Item onSelect={() => open("Visual Studio Code")}>
+                <FileTypeIcon id="Vscode" class="size-5" />
+                <DropdownMenu.ItemLabel>VS Code</DropdownMenu.ItemLabel>
+              </DropdownMenu.Item>
+              <DropdownMenu.Item onSelect={() => open("Cursor")}>
+                <FileTypeIcon id="Cursor" class="size-5" />
+                <DropdownMenu.ItemLabel>Cursor</DropdownMenu.ItemLabel>
+              </DropdownMenu.Item>
+              <DropdownMenu.Item onSelect={() => open("Finder")}>
+                <Icon name="folder" size="small" class="icon-base shrink-0" />
+                <DropdownMenu.ItemLabel>Finder</DropdownMenu.ItemLabel>
+              </DropdownMenu.Item>
+              <DropdownMenu.Item onSelect={() => open("Terminal")}>
+                <FileTypeIcon id="Console" class="size-5" />
+                <DropdownMenu.ItemLabel>Terminal</DropdownMenu.ItemLabel>
+              </DropdownMenu.Item>
+              <DropdownMenu.Item onSelect={() => open("iTerm")}>
+                <FileTypeIcon id="Console" class="size-5" />
+                <DropdownMenu.ItemLabel>iTerm2</DropdownMenu.ItemLabel>
+              </DropdownMenu.Item>
+              <DropdownMenu.Item onSelect={() => open("Ghostty")}>
+                <FileTypeIcon id="Console" class="size-5" />
+                <DropdownMenu.ItemLabel>Ghostty</DropdownMenu.ItemLabel>
+              </DropdownMenu.Item>
+              <DropdownMenu.Item onSelect={() => open("Xcode")}>
+                <FileTypeIcon id="Swift" class="size-5" />
+                <DropdownMenu.ItemLabel>Xcode</DropdownMenu.ItemLabel>
+              </DropdownMenu.Item>
+              <DropdownMenu.Item onSelect={() => open("Android Studio")}>
+                <FileTypeIcon id="Android" class="size-5" />
+                <DropdownMenu.ItemLabel>Android Studio</DropdownMenu.ItemLabel>
+              </DropdownMenu.Item>
+            </DropdownMenu.Group>
+            <DropdownMenu.Separator />
+          </Show>
+          <DropdownMenu.Item onSelect={copy}>
+            <Icon name="copy" size="small" class="icon-base shrink-0" />
+            <DropdownMenu.ItemLabel>{language.t("session.header.copyPath")}</DropdownMenu.ItemLabel>
+          </DropdownMenu.Item>
+        </DropdownMenu.Content>
+      </DropdownMenu.Portal>
+    </DropdownMenu>
+  )
+}

+ 2 - 2
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<void>
 
   /** Restart the app  */
   restart(): Promise<void>

+ 1 - 1
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() {

+ 5 - 0
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",

+ 8 - 8
packages/app/src/pages/error.tsx

@@ -269,14 +269,14 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
         <div class="flex flex-col items-center gap-2">
           <div class="flex items-center justify-center gap-1">
             {language.t("error.page.report.prefix")}
-            <button
-              type="button"
-              class="flex items-center text-text-interactive-base gap-1"
-              onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
-            >
-              <div>{language.t("error.page.report.discord")}</div>
-              <Icon name="discord" class="text-text-interactive-base" />
-            </button>
+              <button
+                type="button"
+                class="flex items-center text-text-interactive-base gap-1"
+                onClick={() => void platform.openLink("https://opencode.ai/desktop-feedback").catch(() => undefined)}
+              >
+                <div>{language.t("error.page.report.discord")}</div>
+                <Icon name="discord" class="text-text-interactive-base" />
+              </button>
           </div>
           <Show when={platform.version}>
             {(version) => (

+ 1 - 1
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")}
               />
             </Tooltip>

+ 4 - 0
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",

+ 12 - 11
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<void>("kill_sidecar"),
-  installCli: () => __TAURI_INVOKE<string>("install_cli"),
-  ensureServerReady: () => __TAURI_INVOKE<ServerReadyData>("ensure_server_ready"),
-  getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
-  setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
-  parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
-}
+	killSidecar: () => __TAURI_INVOKE<void>("kill_sidecar"),
+	installCli: () => __TAURI_INVOKE<string>("install_cli"),
+	ensureServerReady: () => __TAURI_INVOKE<ServerReadyData>("ensure_server_ready"),
+	getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
+	setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
+	parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
+};
 
 /* Types */
 export type ServerReadyData = {
-  url: string
-  password: string | null
-}
+		url: string,
+		password: string | null,
+	};
+

+ 6 - 4
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<string | null>): 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)
     }
   }
 

+ 24 - 0
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<FileTypeIconProps> = (props) => {
+  const [local, rest] = splitProps(props, ["id", "class", "classList"])
+  return (
+    <svg
+      data-component="file-type-icon"
+      {...rest}
+      classList={{
+        ...(local.classList ?? {}),
+        [local.class ?? ""]: !!local.class,
+      }}
+    >
+      <use href={`${sprite}#${local.id}`} />
+    </svg>
+  )
+}