feat(desktop): add reusable open-with menu + items builder (IDE/Finder/browser)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 15:02:05 +08:00
parent c99472222a
commit 55b9ccc276
10 changed files with 339 additions and 71 deletions

View File

@ -1,17 +0,0 @@
import '@testing-library/jest-dom'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { OpenWithMenu } from './OpenWithMenu'
describe('OpenWithMenu', () => {
it('offers in-app and system browser choices', () => {
const onInApp = vi.fn(); const onSystem = vi.fn()
render(<OpenWithMenu url="https://example.com" onOpenInApp={onInApp} onOpenSystem={onSystem} />)
fireEvent.click(screen.getByLabelText('打开方式'))
fireEvent.click(screen.getByText('应用内浏览器'))
expect(onInApp).toHaveBeenCalledWith('https://example.com')
fireEvent.click(screen.getByLabelText('打开方式'))
fireEvent.click(screen.getByText('系统浏览器'))
expect(onSystem).toHaveBeenCalledWith('https://example.com')
})
})

View File

@ -1,21 +0,0 @@
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
type Props = { url: string; onOpenInApp: (url: string) => void; onOpenSystem: (url: string) => void }
export function OpenWithMenu({ url, onOpenInApp, onOpenSystem }: Props) {
const [open, setOpen] = useState(false)
return (
<span className="relative inline-block">
<button aria-label="打开方式" onClick={() => setOpen((v) => !v)} className="inline-flex items-center gap-0.5 text-xs">
<ChevronDown size={12} />
</button>
{open && (
<div className="absolute z-10 mt-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 text-xs shadow">
<button className="block w-full px-3 py-1 text-left hover:bg-[var(--color-surface-hover)]" onClick={() => { setOpen(false); onOpenInApp(url) }}></button>
<button className="block w-full px-3 py-1 text-left hover:bg-[var(--color-surface-hover)]" onClick={() => { setOpen(false); onOpenSystem(url) }}></button>
</div>
)}
</span>
)
}

View File

@ -0,0 +1,71 @@
import '@testing-library/jest-dom'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { OpenWithMenu } from './OpenWithMenu'
import type { OpenWithItem } from '../../lib/openWithItems'
const anchor = { top: 100, bottom: 110, left: 20, right: 120 }
function makeItems(onSelect1 = vi.fn(), onSelect2 = vi.fn(), onSelect3 = vi.fn()): OpenWithItem[] {
return [
{ id: 'in-app', label: 'In-app browser', icon: 'in-app-browser', onSelect: onSelect1 },
{ id: 'system', label: 'System browser', icon: 'system', onSelect: onSelect2 },
{ id: 'preview', label: 'Workspace preview', icon: 'preview', onSelect: onSelect3 },
]
}
describe('OpenWithMenu', () => {
it('renders all item labels', () => {
const onClose = vi.fn()
render(<OpenWithMenu items={makeItems()} anchor={anchor} onClose={onClose} />)
expect(screen.getByText('In-app browser')).toBeInTheDocument()
expect(screen.getByText('System browser')).toBeInTheDocument()
expect(screen.getByText('Workspace preview')).toBeInTheDocument()
})
it('renders a menu role', () => {
const onClose = vi.fn()
render(<OpenWithMenu items={makeItems()} anchor={anchor} onClose={onClose} />)
expect(screen.getByRole('menu')).toBeInTheDocument()
})
it('renders menuitems for each item', () => {
const onClose = vi.fn()
render(<OpenWithMenu items={makeItems()} anchor={anchor} onClose={onClose} />)
const menuItems = screen.getAllByRole('menuitem')
expect(menuItems).toHaveLength(3)
})
it('clicking an item calls its onSelect and onClose', () => {
const onClose = vi.fn()
const onSelect1 = vi.fn()
render(<OpenWithMenu items={makeItems(onSelect1)} anchor={anchor} onClose={onClose} />)
fireEvent.click(screen.getByText('In-app browser'))
expect(onSelect1).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('clicking the second item calls its onSelect and onClose', () => {
const onClose = vi.fn()
const onSelect2 = vi.fn()
render(<OpenWithMenu items={makeItems(vi.fn(), onSelect2)} anchor={anchor} onClose={onClose} />)
fireEvent.click(screen.getByText('System browser'))
expect(onSelect2).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('pressing Escape calls onClose', () => {
const onClose = vi.fn()
render(<OpenWithMenu items={makeItems()} anchor={anchor} onClose={onClose} />)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('mousedown outside the menu calls onClose', () => {
const onClose = vi.fn()
render(<OpenWithMenu items={makeItems()} anchor={anchor} onClose={onClose} />)
// Simulate mousedown on document body (outside the menu)
fireEvent.mouseDown(document.body)
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,52 @@
import { createPortal } from 'react-dom'
import { useEffect, useRef } from 'react'
import { Globe, ExternalLink, FileText } from 'lucide-react'
import { TargetIcon } from './TargetIcon'
import type { OpenWithItem } from '../../lib/openWithItems'
type Props = {
items: OpenWithItem[]
anchor: { top: number; bottom: number; left: number; right: number }
onClose: () => void
}
function ItemIcon({ item }: { item: OpenWithItem }) {
if ((item.icon === 'ide' || item.icon === 'file-manager') && item.target) return <TargetIcon target={item.target} size={20} />
if (item.icon === 'in-app-browser') return <Globe size={18} strokeWidth={1.9} />
if (item.icon === 'preview') return <FileText size={18} strokeWidth={1.9} />
return <ExternalLink size={18} strokeWidth={1.9} />
}
export function OpenWithMenu({ items, anchor, onClose }: Props) {
const ref = useRef<HTMLDivElement>(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(
<div
ref={ref}
role="menu"
className="fixed z-50 min-w-[220px] overflow-hidden rounded-[12px] border border-[var(--color-border)] bg-[var(--color-surface)] py-1 shadow-[var(--shadow-dropdown)]"
style={{ top: anchor.bottom + 6, left: Math.min(anchor.left, window.innerWidth - 240) }}
>
{items.map((item) => (
<button
key={item.id}
type="button"
role="menuitem"
onClick={() => { item.onSelect(); onClose() }}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="flex h-6 w-6 items-center justify-center text-[var(--color-text-secondary)]"><ItemIcon item={item} /></span>
<span className="min-w-0 truncate">{item.label}</span>
</button>
))}
</div>,
document.body,
)
}

View File

@ -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 <FolderOpen size={size} strokeWidth={1.9} />
}
return <Code2 size={size} strokeWidth={1.9} />
}
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 (
<img
src={target.iconUrl}
alt=""
aria-hidden="true"
draggable={false}
onError={() => setFailed(true)}
className="block shrink-0 object-contain"
style={{ width: size, height: size }}
/>
)
}
return getFallbackIcon(target.kind, Math.max(16, size - 1))
}

View File

@ -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 <FolderOpen size={size} strokeWidth={1.9} />
}
return <Code2 size={size} strokeWidth={1.9} />
}
function TargetIcon({ target, size = 18 }: { target: OpenTarget; size?: number }) {
const [failed, setFailed] = useState(false)
useEffect(() => {
setFailed(false)
}, [target.iconUrl])
if (target.iconUrl && !failed) {
return (
<img
src={target.iconUrl}
alt=""
aria-hidden="true"
draggable={false}
onError={() => 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)

View File

@ -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',

View File

@ -93,6 +93,14 @@ export const zh: Record<TranslationKey, string> = {
'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': '所有文件',

View File

@ -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<string, string>) =>
vars?.target != null ? `${key}:${vars.target}` : key
}
function makeDeps(overrides?: Partial<OpenWithDeps>): 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)
})
})

View File

@ -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, string>) => 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
}