Explorar o código

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

Adam hai 2 semanas
pai
achega
b738d88ec4

+ 187 - 0
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">

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

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

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

+ 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,
+	};
+

+ 5 - 0
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<string | null>): Platform => ({
     void shellOpen(url).catch(() => undefined)
   },
 
+  openPath(path: string, app?: string) {
+    return openerOpenPath(path, app)
+  },
+
   back() {
     window.history.back()
   },

+ 1 - 0
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/*"
   },

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
packages/ui/src/assets/icons/app/android-studio.svg


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
packages/ui/src/assets/icons/app/antigravity.svg


+ 1 - 0
packages/ui/src/assets/icons/app/cursor.svg

@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg id="cursor_light__Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09"><!--Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9)--><defs><style>.cursor_light__st0{fill:#26251e}</style></defs><path class="cursor_light__st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/></svg>

BIN=BIN
packages/ui/src/assets/icons/app/finder.png


+ 1 - 0
packages/ui/src/assets/icons/app/ghostty.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 27 32"><path fill="#3551F3" d="M20.395 32a6.35 6.35 0 0 1-3.516-1.067A6.355 6.355 0 0 1 13.362 32c-1.249 0-2.48-.375-3.516-1.067A6.265 6.265 0 0 1 6.372 32h-.038a6.255 6.255 0 0 1-4.5-1.906 6.377 6.377 0 0 1-1.836-4.482v-12.25C0 5.995 5.994 0 13.362 0c7.369 0 13.363 5.994 13.363 13.363v12.253c0 3.393-2.626 6.192-5.978 6.375-.117.007-.234.009-.352.009Z"/><path fill="#000" d="M20.395 30.593a4.932 4.932 0 0 1-3.08-1.083.656.656 0 0 0-.42-.145.784.784 0 0 0-.487.176 4.939 4.939 0 0 1-3.046 1.055 4.939 4.939 0 0 1-3.045-1.055.751.751 0 0 0-.942 0 4.883 4.883 0 0 1-3.01 1.055h-.033a4.852 4.852 0 0 1-3.49-1.482 4.982 4.982 0 0 1-1.436-3.498V13.367c0-6.597 5.364-11.96 11.957-11.96 6.592 0 11.956 5.363 11.956 11.956v12.253c0 2.645-2.042 4.827-4.65 4.97a5.342 5.342 0 0 1-.274.007Z"/><path fill="#fff" d="M23.912 13.363v12.253c0 1.876-1.447 3.463-3.32 3.566a3.503 3.503 0 0 1-2.398-.769c-.778-.626-1.873-.598-2.658.021a3.5 3.5 0 0 1-2.176.753 3.494 3.494 0 0 1-2.173-.753 2.153 2.153 0 0 0-2.684 0 3.498 3.498 0 0 1-2.15.753c-1.948.014-3.54-1.627-3.54-3.575v-12.25c0-5.825 4.724-10.549 10.55-10.549 5.825 0 10.549 4.724 10.549 10.55Z"/><path fill="#000" d="m11.28 12.437-3.93-2.27a1.072 1.072 0 0 0-1.463.392 1.072 1.072 0 0 0 .391 1.463l2.326 1.343-2.326 1.343a1.072 1.072 0 0 0 1.071 1.855l3.932-2.27a1.071 1.071 0 0 0 0-1.854v-.002ZM20.182 12.291h-5.164a1.071 1.071 0 1 0 0 2.143h5.164a1.071 1.071 0 1 0 0-2.143Z"/></svg>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 7 - 0
packages/ui/src/assets/icons/app/iterm2.svg


+ 1 - 0
packages/ui/src/assets/icons/app/powershell.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 128 128"><linearGradient id="powershell__a" x1="96.306" x2="25.454" y1="35.144" y2="98.431" gradientTransform="matrix(1 0 0 -1 0 128)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a9c8ff"/><stop offset="1" stop-color="#c7e6ff"/></linearGradient><path fill="url(#powershell__a)" fill-rule="evenodd" d="M7.2 110.5c-1.7 0-3.1-.7-4.1-1.9-1-1.2-1.3-2.9-.9-4.6l18.6-80.5c.8-3.4 4-6 7.4-6h92.6c1.7 0 3.1.7 4.1 1.9 1 1.2 1.3 2.9.9 4.6l-18.6 80.5c-.8 3.4-4 6-7.4 6H7.2z" clip-rule="evenodd" opacity=".8"/><linearGradient id="powershell__b" x1="25.336" x2="94.569" y1="98.33" y2="36.847" gradientTransform="matrix(1 0 0 -1 0 128)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2d4664"/><stop offset=".169" stop-color="#29405b"/><stop offset=".445" stop-color="#1e2f43"/><stop offset=".79" stop-color="#0c131b"/><stop offset="1"/></linearGradient><path fill="url(#powershell__b)" fill-rule="evenodd" d="M120.3 18.5H28.5c-2.9 0-5.7 2.3-6.4 5.2L3.7 104.3c-.7 2.9 1.1 5.2 4 5.2h91.8c2.9 0 5.7-2.3 6.4-5.2l18.4-80.5c.7-2.9-1.1-5.3-4-5.3z" clip-rule="evenodd"/><path fill="#2C5591" fill-rule="evenodd" d="M64.2 88.3h22.3c2.6 0 4.7 2.2 4.7 4.9s-2.1 4.9-4.7 4.9H64.2c-2.6 0-4.7-2.2-4.7-4.9s2.1-4.9 4.7-4.9zM78.7 66.5c-.4.8-1.2 1.6-2.6 2.6L34.6 98.9c-2.3 1.6-5.5 1-7.3-1.4-1.7-2.4-1.3-5.7.9-7.3l37.4-27.1v-.6l-23.5-25c-1.9-2-1.7-5.3.4-7.4 2.2-2 5.5-2 7.4 0l28.2 30c1.7 1.9 1.8 4.5.6 6.4z" clip-rule="evenodd"/><path fill="#FFF" fill-rule="evenodd" d="M77.6 65.5c-.4.8-1.2 1.6-2.6 2.6L33.6 97.9c-2.3 1.6-5.5 1-7.3-1.4-1.7-2.4-1.3-5.7.9-7.3l37.4-27.1v-.6l-23.5-25c-1.9-2-1.7-5.3.4-7.4 2.2-2 5.5-2 7.4 0l28.2 30c1.7 1.8 1.8 4.4.5 6.4zM63.5 87.8h22.3c2.6 0 4.7 2.1 4.7 4.6 0 2.6-2.1 4.6-4.7 4.6H63.5c-2.6 0-4.7-2.1-4.7-4.6 0-2.6 2.1-4.6 4.7-4.6z" clip-rule="evenodd"/></svg>

BIN=BIN
packages/ui/src/assets/icons/app/terminal.png


BIN=BIN
packages/ui/src/assets/icons/app/textmate.png


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
packages/ui/src/assets/icons/app/vscode.svg


BIN=BIN
packages/ui/src/assets/icons/app/xcode.png


+ 1 - 0
packages/ui/src/assets/icons/app/zed.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96"><g clip-path="url(#zed_light__a)"><path fill="currentColor" fill-rule="evenodd" d="M9 6a3 3 0 0 0-3 3v66H0V9a9 9 0 0 1 9-9h80.379c4.009 0 6.016 4.847 3.182 7.682L43.055 57.187H57V51h6v7.688a4.5 4.5 0 0 1-4.5 4.5H37.055L26.743 73.5H73.5V36h6v37.5a6 6 0 0 1-6 6H20.743L10.243 90H87a3 3 0 0 0 3-3V21h6v66a9 9 0 0 1-9 9H6.621c-4.009 0-6.016-4.847-3.182-7.682L52.757 39H39v6h-6v-7.5a4.5 4.5 0 0 1 4.5-4.5h21.257l10.5-10.5H22.5V60h-6V22.5a6 6 0 0 1 6-6h52.757L85.757 6H9Z" clip-rule="evenodd"/></g><defs><clipPath id="zed_light__a"><path fill="#fff" d="M0 0h96v96H0z"/></clipPath></defs></svg>

+ 9 - 0
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;
+}

+ 52 - 0
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<IconName, string>
+
+export type AppIconProps = Omit<ComponentProps<"img">, "src"> & {
+  id: IconName
+}
+
+export const AppIcon: Component<AppIconProps> = (props) => {
+  const [local, rest] = splitProps(props, ["id", "class", "classList", "alt", "draggable"])
+  return (
+    <img
+      data-component="app-icon"
+      {...rest}
+      src={icons[local.id]}
+      alt={local.alt ?? ""}
+      draggable={local.draggable ?? false}
+      classList={{
+        ...(local.classList ?? {}),
+        [local.class ?? ""]: !!local.class,
+      }}
+    />
+  )
+}

+ 114 - 0
packages/ui/src/components/app-icons/sprite.svg

@@ -0,0 +1,114 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="0" height="0">
+  <defs>
+    <symbol id="vscode" viewBox="0 0 24 24">
+      <rect width="24" height="24" rx="5" fill="#007ACC" />
+      <g transform="scale(1.5)">
+        <path
+          fill="#fff"
+          d="M11.5 11.19V4.8L7.3 7.99M1.17 6.07a.6.6 0 0 1-.01-.81L2 4.48c.14-.13.48-.18.73 0l2.39 1.83 5.55-5.09c.22-.22.61-.32 1.05-.08l2.8 1.34c.25.15.49.38.49.81v9.49c0 .28-.2.58-.42.7l-3.08 1.48c-.22.09-.64 0-.79-.14L5.11 9.69l-2.38 1.83c-.27.18-.6.13-.74 0l-.84-.77c-.22-.23-.2-.61.04-.84l2.1-1.9"
+        />
+      </g>
+    </symbol>
+
+    <symbol id="cursor" viewBox="0 0 24 24">
+      <rect width="24" height="24" rx="5" fill="#111827" />
+      <path
+        fill="#fff"
+        d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23"
+      />
+    </symbol>
+
+    <symbol id="zed" viewBox="0 0 24 24">
+      <rect width="24" height="24" rx="5" fill="#084CCF" />
+      <g transform="translate(12 12) scale(0.9) translate(-12 -12)">
+        <path
+          fill="#fff"
+          d="M2.25 1.5a.75.75 0 0 0-.75.75v16.5H0V2.25A2.25 2.25 0 0 1 2.25 0h20.095c1.002 0 1.504 1.212.795 1.92L10.764 14.298h3.486V12.75h1.5v1.922a1.125 1.125 0 0 1-1.125 1.125H9.264l-2.578 2.578h11.689V9h1.5v9.375a1.5 1.5 0 0 1-1.5 1.5H5.185L2.562 22.5H21.75a.75.75 0 0 0 .75-.75V5.25H24v16.5A2.25 2.25 0 0 1 21.75 24H1.655C.653 24 .151 22.788.86 22.08L13.19 9.75H9.75v1.5h-1.5V9.375A1.125 1.125 0 0 1 9.375 8.25h5.314l2.625-2.625H5.625V15h-1.5V5.625a1.5 1.5 0 0 1 1.5-1.5h13.19L21.438 1.5z"
+        />
+      </g>
+    </symbol>
+
+    <symbol id="finder" viewBox="0 0 24 24">
+      <rect width="24" height="24" rx="5" fill="#8ED0FF" />
+      <path d="M12 0H19a5 5 0 0 1 5 5V19a5 5 0 0 1-5 5H12Z" fill="#2D7BF7" />
+      <path d="M12 3v18" stroke="#0B2A4A" stroke-opacity="0.35" stroke-width="1.5" />
+      <circle cx="8.3" cy="9.2" r="1.1" fill="#0B2A4A" />
+      <circle cx="15.7" cy="9.2" r="1.1" fill="#0B2A4A" />
+      <path
+        d="M7.3 15c1.2 1.55 2.9 2.4 4.7 2.4s3.5-.85 4.7-2.4"
+        stroke="#0B2A4A"
+        stroke-width="1.5"
+        fill="none"
+        stroke-linecap="round"
+      />
+    </symbol>
+
+    <symbol id="terminal" viewBox="0 0 24 24">
+      <rect width="24" height="24" rx="5" fill="#111827" />
+      <rect
+        x="3.5"
+        y="4.5"
+        width="17"
+        height="15"
+        rx="2.5"
+        fill="#0B1220"
+        stroke="#334155"
+        stroke-opacity="0.5"
+      />
+      <path
+        d="M7.8 9.2 11 12 7.8 14.8"
+        stroke="#34D399"
+        stroke-width="1.8"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+      />
+      <rect x="12.2" y="14.2" width="5.4" height="1.6" rx="0.8" fill="#34D399" />
+    </symbol>
+
+    <symbol id="iterm2" viewBox="0 0 24 24">
+      <rect width="24" height="24" rx="5" fill="#0B0B0B" />
+      <rect x="3.2" y="4.2" width="17.6" height="15.6" rx="2.4" fill="#000" stroke="#60A5FA" stroke-width="1.2" />
+      <circle cx="5.5" cy="6.3" r="0.75" fill="#F87171" />
+      <circle cx="7.6" cy="6.3" r="0.75" fill="#FBBF24" />
+      <circle cx="9.7" cy="6.3" r="0.75" fill="#34D399" />
+      <path
+        d="M7.9 10.2 10.6 12 7.9 13.8"
+        stroke="#34D399"
+        stroke-width="1.6"
+        stroke-linecap="round"
+        stroke-linejoin="round"
+      />
+      <rect x="11.6" y="13.3" width="5" height="1.4" rx="0.7" fill="#34D399" />
+    </symbol>
+
+    <symbol id="ghostty" viewBox="0 0 24 24">
+      <rect width="24" height="24" rx="5" fill="#3551F3" />
+      <g transform="translate(12 12) scale(0.9) translate(-12 -12)">
+        <path
+          fill="#fff"
+          d="M12 0C6.7 0 2.4 4.3 2.4 9.6v11.146c0 1.772 1.45 3.267 3.222 3.254a3.18 3.18 0 0 0 1.955-.686 1.96 1.96 0 0 1 2.444 0 3.18 3.18 0 0 0 1.976.686c.75 0 1.436-.257 1.98-.686.715-.563 1.71-.587 2.419-.018.59.476 1.355.743 2.182.699 1.705-.094 3.022-1.537 3.022-3.244V9.601C21.6 4.3 17.302 0 12 0M6.069 6.562a1 1 0 0 1 .46.131l3.578 2.065v.002a.974.974 0 0 1 0 1.687L6.53 12.512a.975.975 0 0 1-.976-1.687L7.67 9.602 5.553 8.38a.975.975 0 0 1 .515-1.818m7.438 2.063h4.7a.975.975 0 1 1 0 1.95h-4.7a.975.975 0 0 1 0-1.95"
+        />
+      </g>
+    </symbol>
+
+    <symbol id="xcode" viewBox="0 0 24 24">
+      <rect width="24" height="24" rx="5" fill="#147EFB" />
+      <path d="M6 8H18" stroke="#fff" stroke-opacity="0.18" stroke-width="1.2" stroke-linecap="round" />
+      <path d="M8 6V18" stroke="#fff" stroke-opacity="0.18" stroke-width="1.2" stroke-linecap="round" />
+      <path d="M6 18H18" stroke="#fff" stroke-opacity="0.18" stroke-width="1.2" stroke-linecap="round" />
+      <path d="M18 6V18" stroke="#fff" stroke-opacity="0.18" stroke-width="1.2" stroke-linecap="round" />
+      <g transform="translate(12 12) rotate(-35) translate(-12 -12)">
+        <rect x="11.1" y="6.2" width="2" height="12.6" rx="1" fill="#fff" />
+        <rect x="9.2" y="5.3" width="5.6" height="2.7" rx="1" fill="#fff" />
+      </g>
+    </symbol>
+
+    <symbol id="android-studio" viewBox="0 0 24 24">
+      <rect width="24" height="24" rx="5" fill="#3DDC84" />
+      <circle cx="12" cy="12.2" r="6.8" fill="#3B82F6" />
+      <circle cx="12" cy="12.2" r="4.8" fill="none" stroke="#fff" stroke-width="1.6" />
+      <path d="M12 9.4l2.2 5-2.2-1.3-2.2 1.3z" fill="#fff" />
+      <circle cx="12" cy="12.2" r="0.9" fill="#fff" />
+    </symbol>
+  </defs>
+</svg>

+ 18 - 0
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]

+ 1 - 0
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);

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio