diff --git a/desktop/src/components/chat/OpenWithMenu.test.tsx b/desktop/src/components/chat/OpenWithMenu.test.tsx deleted file mode 100644 index 30f93c00..00000000 --- a/desktop/src/components/chat/OpenWithMenu.test.tsx +++ /dev/null @@ -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() - 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') - }) -}) diff --git a/desktop/src/components/chat/OpenWithMenu.tsx b/desktop/src/components/chat/OpenWithMenu.tsx deleted file mode 100644 index d4b49c0e..00000000 --- a/desktop/src/components/chat/OpenWithMenu.tsx +++ /dev/null @@ -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 ( - - - {open && ( -
- - -
- )} -
- ) -} diff --git a/desktop/src/components/common/OpenWithMenu.test.tsx b/desktop/src/components/common/OpenWithMenu.test.tsx new file mode 100644 index 00000000..5015e818 --- /dev/null +++ b/desktop/src/components/common/OpenWithMenu.test.tsx @@ -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() + 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() + expect(screen.getByRole('menu')).toBeInTheDocument() + }) + + it('renders menuitems for each item', () => { + const onClose = vi.fn() + render() + 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() + 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() + fireEvent.click(screen.getByText('System browser')) + expect(onSelect2).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('pressing Escape calls onClose', () => { + const onClose = vi.fn() + render() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('mousedown outside the menu calls onClose', () => { + const onClose = vi.fn() + render() + // Simulate mousedown on document body (outside the menu) + fireEvent.mouseDown(document.body) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/desktop/src/components/common/OpenWithMenu.tsx b/desktop/src/components/common/OpenWithMenu.tsx new file mode 100644 index 00000000..b56b739f --- /dev/null +++ b/desktop/src/components/common/OpenWithMenu.tsx @@ -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 + if (item.icon === 'in-app-browser') return + if (item.icon === 'preview') return + return +} + +export function OpenWithMenu({ items, anchor, onClose }: Props) { + const ref = useRef(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 +}