(null)
+ useEffect(() => {
+ const onDown = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) onClose() }
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
+ document.addEventListener('mousedown', onDown)
+ document.addEventListener('keydown', onKey)
+ return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) }
+ }, [onClose])
+
+ return createPortal(
+
+ {items.map((item) => (
+
+ ))}
+
,
+ document.body,
+ )
+}
diff --git a/desktop/src/components/common/TargetIcon.tsx b/desktop/src/components/common/TargetIcon.tsx
new file mode 100644
index 00000000..097c4be5
--- /dev/null
+++ b/desktop/src/components/common/TargetIcon.tsx
@@ -0,0 +1,34 @@
+import { useEffect, useState } from 'react'
+import { Code2, FolderOpen } from 'lucide-react'
+import type { OpenTarget } from '../../stores/openTargetStore'
+
+export function getFallbackIcon(kind: 'ide' | 'file_manager', size = 17) {
+ if (kind === 'file_manager') {
+ return
+ }
+ return
+}
+
+export function TargetIcon({ target, size = 18 }: { target: OpenTarget; size?: number }) {
+ const [failed, setFailed] = useState(false)
+
+ useEffect(() => {
+ setFailed(false)
+ }, [target.iconUrl])
+
+ if (target.iconUrl && !failed) {
+ return (
+
setFailed(true)}
+ className="block shrink-0 object-contain"
+ style={{ width: size, height: size }}
+ />
+ )
+ }
+
+ return getFallbackIcon(target.kind, Math.max(16, size - 1))
+}
diff --git a/desktop/src/components/layout/OpenProjectMenu.tsx b/desktop/src/components/layout/OpenProjectMenu.tsx
index 69abe11e..d0699bbe 100644
--- a/desktop/src/components/layout/OpenProjectMenu.tsx
+++ b/desktop/src/components/layout/OpenProjectMenu.tsx
@@ -1,44 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
-import { ChevronDown, Code2, FolderOpen } from 'lucide-react'
+import { ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
-import { useOpenTargetStore, type OpenTarget } from '../../stores/openTargetStore'
+import { useOpenTargetStore } from '../../stores/openTargetStore'
+import { TargetIcon } from '../common/TargetIcon'
type Props = {
path: string | null | undefined
}
-function getFallbackIcon(kind: 'ide' | 'file_manager', size = 17) {
- if (kind === 'file_manager') {
- return
- }
- return
-}
-
-function TargetIcon({ target, size = 18 }: { target: OpenTarget; size?: number }) {
- const [failed, setFailed] = useState(false)
-
- useEffect(() => {
- setFailed(false)
- }, [target.iconUrl])
-
- if (target.iconUrl && !failed) {
- return (
-
setFailed(true)}
- className="block shrink-0 object-contain"
- style={{ width: size, height: size }}
- />
- )
- }
-
- return getFallbackIcon(target.kind, Math.max(16, size - 1))
-}
-
export function OpenProjectMenu({ path }: Props) {
const t = useTranslation()
const targets = useOpenTargetStore((state) => state.targets)
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index f0948667..b61c9928 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -91,6 +91,14 @@ export const en = {
'openProject.openIn': 'Open in {target}',
'openProject.openFailed': 'Could not open project',
+ // ─── Open With ─────────────────────────────────────
+ 'openWith.inAppBrowser': 'In-app browser',
+ 'openWith.systemBrowser': 'System browser',
+ 'openWith.systemDefault': 'Open with system default',
+ 'openWith.workspacePreview': 'Workspace preview',
+ 'openWith.openInTarget': 'Open in {target}',
+ 'openWith.revealInTarget': 'Reveal in {target}',
+
// ─── Workspace Panel ───────────────────────────────
'workspace.changedFiles': 'Changed files',
'workspace.allFiles': 'All files',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 7b2f39f6..f361d783 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -93,6 +93,14 @@ export const zh: Record = {
'openProject.openIn': '用 {target} 打开',
'openProject.openFailed': '无法打开项目',
+ // ─── Open With ─────────────────────────────────────
+ 'openWith.inAppBrowser': '应用内浏览器',
+ 'openWith.systemBrowser': '系统浏览器',
+ 'openWith.systemDefault': '系统默认打开',
+ 'openWith.workspacePreview': '工作台预览',
+ 'openWith.openInTarget': '用 {target} 打开',
+ 'openWith.revealInTarget': '在 {target} 中显示',
+
// ─── Workspace Panel ───────────────────────────────
'workspace.changedFiles': '已更改文件',
'workspace.allFiles': '所有文件',
diff --git a/desktop/src/lib/openWithItems.test.ts b/desktop/src/lib/openWithItems.test.ts
new file mode 100644
index 00000000..05756db1
--- /dev/null
+++ b/desktop/src/lib/openWithItems.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it, vi } from 'vitest'
+import { buildOpenWithItems, type OpenWithContext, type OpenWithDeps } from './openWithItems'
+import type { OpenTarget } from '../stores/openTargetStore'
+
+function makeT() {
+ return (key: string, vars?: Record) =>
+ vars?.target != null ? `${key}:${vars.target}` : key
+}
+
+function makeDeps(overrides?: Partial): OpenWithDeps {
+ return {
+ openInAppBrowser: vi.fn(),
+ openSystem: vi.fn(),
+ openWorkspacePreview: vi.fn(),
+ openTarget: vi.fn(),
+ t: makeT(),
+ ...overrides,
+ }
+}
+
+const ideTarget: OpenTarget = { id: 'code', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' }
+const fmTarget: OpenTarget = { id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' }
+
+describe('buildOpenWithItems – url context', () => {
+ it('returns exactly [in-app, system] for a url context', () => {
+ const deps = makeDeps()
+ const ctx: OpenWithContext = { kind: 'url', url: 'https://example.com' }
+ const items = buildOpenWithItems(ctx, [], deps)
+ expect(items.map((i) => i.id)).toEqual(['in-app', 'system'])
+ })
+
+ it('in-app calls openInAppBrowser with url', () => {
+ const deps = makeDeps()
+ const ctx: OpenWithContext = { kind: 'url', url: 'https://example.com' }
+ const items = buildOpenWithItems(ctx, [], deps)
+ items[0]!.onSelect()
+ expect(deps.openInAppBrowser).toHaveBeenCalledWith('https://example.com')
+ expect(deps.openSystem).not.toHaveBeenCalled()
+ })
+
+ it('system calls openSystem with url', () => {
+ const deps = makeDeps()
+ const ctx: OpenWithContext = { kind: 'url', url: 'https://example.com' }
+ const items = buildOpenWithItems(ctx, [], deps)
+ items[1]!.onSelect()
+ expect(deps.openSystem).toHaveBeenCalledWith('https://example.com')
+ expect(deps.openInAppBrowser).not.toHaveBeenCalled()
+ })
+})
+
+describe('buildOpenWithItems – file context with targets', () => {
+ it('returns [preview, ide:code, fm:finder, system] ids', () => {
+ const deps = makeDeps()
+ const ctx: OpenWithContext = { kind: 'file', absolutePath: '/w/a.md', relPath: 'a.md', previewable: true }
+ const items = buildOpenWithItems(ctx, [ideTarget, fmTarget], deps)
+ expect(items.map((i) => i.id)).toEqual(['preview', 'ide:code', 'fm:finder', 'system'])
+ })
+
+ it('preview calls openWorkspacePreview with relPath', () => {
+ const deps = makeDeps()
+ const ctx: OpenWithContext = { kind: 'file', absolutePath: '/w/a.md', relPath: 'a.md', previewable: true }
+ const items = buildOpenWithItems(ctx, [ideTarget, fmTarget], deps)
+ const preview = items.find((i) => i.id === 'preview')!
+ preview.onSelect()
+ expect(deps.openWorkspacePreview).toHaveBeenCalledWith('a.md')
+ })
+
+ it('ide:code calls openTarget with correct args', () => {
+ const deps = makeDeps()
+ const ctx: OpenWithContext = { kind: 'file', absolutePath: '/w/a.md', relPath: 'a.md', previewable: true }
+ const items = buildOpenWithItems(ctx, [ideTarget, fmTarget], deps)
+ const ideItem = items.find((i) => i.id === 'ide:code')!
+ ideItem.onSelect()
+ expect(deps.openTarget).toHaveBeenCalledWith('code', '/w/a.md')
+ })
+
+ it('system calls openSystem with absolutePath', () => {
+ const deps = makeDeps()
+ const ctx: OpenWithContext = { kind: 'file', absolutePath: '/w/a.md', relPath: 'a.md', previewable: true }
+ const items = buildOpenWithItems(ctx, [ideTarget, fmTarget], deps)
+ const sys = items.find((i) => i.id === 'system')!
+ sys.onSelect()
+ expect(deps.openSystem).toHaveBeenCalledWith('/w/a.md')
+ })
+
+ it('ide item carries the target object', () => {
+ const deps = makeDeps()
+ const ctx: OpenWithContext = { kind: 'file', absolutePath: '/w/a.md', relPath: 'a.md', previewable: true }
+ const items = buildOpenWithItems(ctx, [ideTarget, fmTarget], deps)
+ const ideItem = items.find((i) => i.id === 'ide:code')!
+ expect(ideItem.target).toBe(ideTarget)
+ })
+})
+
+describe('buildOpenWithItems – file context with inAppBrowserUrl (no previewable)', () => {
+ it('returns [in-app, system] ids for no targets + inAppBrowserUrl', () => {
+ const deps = makeDeps()
+ const ctx: OpenWithContext = {
+ kind: 'file',
+ absolutePath: '/w/page.html',
+ inAppBrowserUrl: 'http://127.0.0.1:4321/preview-fs/s1/page.html',
+ }
+ const items = buildOpenWithItems(ctx, [], deps)
+ expect(items.map((i) => i.id)).toEqual(['in-app', 'system'])
+ })
+
+ it('in-app calls openInAppBrowser with inAppBrowserUrl', () => {
+ const deps = makeDeps()
+ const inAppBrowserUrl = 'http://127.0.0.1:4321/preview-fs/s1/page.html'
+ const ctx: OpenWithContext = { kind: 'file', absolutePath: '/w/page.html', inAppBrowserUrl }
+ const items = buildOpenWithItems(ctx, [], deps)
+ items.find((i) => i.id === 'in-app')!.onSelect()
+ expect(deps.openInAppBrowser).toHaveBeenCalledWith(inAppBrowserUrl)
+ })
+})
diff --git a/desktop/src/lib/openWithItems.ts b/desktop/src/lib/openWithItems.ts
new file mode 100644
index 00000000..b749a473
--- /dev/null
+++ b/desktop/src/lib/openWithItems.ts
@@ -0,0 +1,48 @@
+import type { OpenTarget } from '../stores/openTargetStore'
+
+export type OpenWithIcon = 'in-app-browser' | 'system' | 'ide' | 'file-manager' | 'preview'
+
+export type OpenWithItem = {
+ id: string
+ label: string
+ icon: OpenWithIcon
+ target?: OpenTarget // present for ide/file-manager items (to render its favicon)
+ onSelect: () => void
+}
+
+export type OpenWithDeps = {
+ openInAppBrowser: (url: string) => void
+ openSystem: (urlOrPath: string) => void
+ openWorkspacePreview: (relPath: string) => void
+ openTarget: (targetId: string, absolutePath: string) => void
+ t: (key: string, vars?: Record) => string
+}
+
+export type OpenWithContext =
+ | { kind: 'url'; url: string }
+ | { kind: 'file'; absolutePath: string; relPath?: string; previewable?: boolean; inAppBrowserUrl?: string }
+
+export function buildOpenWithItems(ctx: OpenWithContext, targets: OpenTarget[], deps: OpenWithDeps): OpenWithItem[] {
+ const items: OpenWithItem[] = []
+ if (ctx.kind === 'url') {
+ items.push({ id: 'in-app', label: deps.t('openWith.inAppBrowser'), icon: 'in-app-browser', onSelect: () => deps.openInAppBrowser(ctx.url) })
+ items.push({ id: 'system', label: deps.t('openWith.systemBrowser'), icon: 'system', onSelect: () => deps.openSystem(ctx.url) })
+ return items
+ }
+ if (ctx.previewable && ctx.relPath != null) {
+ const relPath = ctx.relPath
+ items.push({ id: 'preview', label: deps.t('openWith.workspacePreview'), icon: 'preview', onSelect: () => deps.openWorkspacePreview(relPath) })
+ }
+ if (ctx.inAppBrowserUrl) {
+ const url = ctx.inAppBrowserUrl
+ items.push({ id: 'in-app', label: deps.t('openWith.inAppBrowser'), icon: 'in-app-browser', onSelect: () => deps.openInAppBrowser(url) })
+ }
+ for (const target of targets.filter((x) => x.kind === 'ide')) {
+ items.push({ id: `ide:${target.id}`, label: deps.t('openWith.openInTarget', { target: target.label }), icon: 'ide', target, onSelect: () => deps.openTarget(target.id, ctx.absolutePath) })
+ }
+ for (const target of targets.filter((x) => x.kind === 'file_manager')) {
+ items.push({ id: `fm:${target.id}`, label: deps.t('openWith.revealInTarget', { target: target.label }), icon: 'file-manager', target, onSelect: () => deps.openTarget(target.id, ctx.absolutePath) })
+ }
+ items.push({ id: 'system', label: deps.t('openWith.systemDefault'), icon: 'system', onSelect: () => deps.openSystem(ctx.absolutePath) })
+ return items
+}