From db7432a39a154dc98b7fe1f280b83df366811ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 13 May 2026 14:56:19 +0800 Subject: [PATCH] Add desktop project open targets Desktop sessions need a fast local escape hatch that opens the same materialized cwd the agent is editing, without showing unavailable IDE choices or persisting detection state. This adds a local open-targets API with silent in-memory detection for common IDEs and platform file managers, then wires a compact Codex-style toolbar menu into the desktop TabBar for active session workdirs. Constraint: The first version is local IDE/editor and Finder/Explorer/file-manager only, no terminal targets or IDE plugin integration. Constraint: The opened path must come from the active session workDir so isolated worktrees open the actual agent editing surface. Rejected: Persisting detected applications | detection is cheap and temporary state avoids stale app inventory. Rejected: Rendering unavailable IDEs as disabled menu rows | the user asked to show only detected targets and fall back to Finder/Explorer when no IDE is available. Confidence: high Scope-risk: moderate Directive: Keep this path session-workdir based; do not switch it to repository root without proving isolated worktree behavior. Tested: bun test src/server/__tests__/open-target-service.test.ts src/server/__tests__/open-target-api.test.ts Tested: cd desktop && bun run test -- src/stores/openTargetStore.test.ts src/components/layout/OpenProjectMenu.test.tsx src/components/layout/TabBar.test.tsx Tested: cd desktop && bun run lint Tested: bun run check:desktop Tested: bun test src/server/__tests__/h5-access-auth.test.ts -t 'allows local desktop H5 access settings under explicit server auth with a valid bearer' Not-tested: bun run check:server full suite had one unrelated H5 auth integration timeout in the full concurrent run; the timed-out test passed when rerun alone. --- desktop/src/api/openTargets.ts | 34 ++ .../layout/OpenProjectMenu.test.tsx | 106 +++++ .../src/components/layout/OpenProjectMenu.tsx | 134 ++++++ desktop/src/components/layout/TabBar.test.tsx | 140 ++++++ desktop/src/components/layout/TabBar.tsx | 11 + desktop/src/i18n/locales/en.ts | 5 + desktop/src/i18n/locales/zh.ts | 5 + desktop/src/stores/openTargetStore.test.ts | 52 +++ desktop/src/stores/openTargetStore.ts | 76 ++++ src/server/__tests__/open-target-api.test.ts | 107 +++++ .../__tests__/open-target-service.test.ts | 190 ++++++++ src/server/api/open-targets.ts | 53 +++ src/server/router.ts | 4 + src/server/services/openTargetService.ts | 425 ++++++++++++++++++ 14 files changed, 1342 insertions(+) create mode 100644 desktop/src/api/openTargets.ts create mode 100644 desktop/src/components/layout/OpenProjectMenu.test.tsx create mode 100644 desktop/src/components/layout/OpenProjectMenu.tsx create mode 100644 desktop/src/stores/openTargetStore.test.ts create mode 100644 desktop/src/stores/openTargetStore.ts create mode 100644 src/server/__tests__/open-target-api.test.ts create mode 100644 src/server/__tests__/open-target-service.test.ts create mode 100644 src/server/api/open-targets.ts create mode 100644 src/server/services/openTargetService.ts diff --git a/desktop/src/api/openTargets.ts b/desktop/src/api/openTargets.ts new file mode 100644 index 00000000..0d80d30e --- /dev/null +++ b/desktop/src/api/openTargets.ts @@ -0,0 +1,34 @@ +import { api } from './client' + +export type OpenTargetKind = 'ide' | 'file_manager' + +export type OpenTarget = { + id: string + kind: OpenTargetKind + label: string + icon: string + platform: string +} + +export type OpenTargetList = { + platform: string + targets: OpenTarget[] + primaryTargetId: string | null + cachedAt: number + ttlMs: number +} + +export type OpenTargetOpenResponse = { + ok: true + targetId: string + path: string +} + +export const openTargetsApi = { + list() { + return api.get('/api/open-targets') + }, + open(targetId: string, path: string) { + return api.post('/api/open-targets/open', { targetId, path }) + }, +} diff --git a/desktop/src/components/layout/OpenProjectMenu.test.tsx b/desktop/src/components/layout/OpenProjectMenu.test.tsx new file mode 100644 index 00000000..bcbaa623 --- /dev/null +++ b/desktop/src/components/layout/OpenProjectMenu.test.tsx @@ -0,0 +1,106 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import '@testing-library/jest-dom' + +vi.mock('../../i18n', () => ({ + useTranslation: () => (key: string, params?: Record) => { + const template = { + 'openProject.openIn': 'Open in {target}', + 'openProject.openProject': 'Open project', + 'openProject.openFailed': 'Could not open project', + }[key] ?? key + + if (!params) return template + return Object.entries(params).reduce( + (text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), + template, + ) + }, +})) + +const storeMocks = vi.hoisted(() => ({ + ensureTargets: vi.fn(), + openTarget: vi.fn(), + state: { + targets: [] as Array<{ + id: string + kind: 'ide' | 'file_manager' + label: string + icon: string + platform: string + }>, + primaryTargetId: null as string | null, + loading: false, + error: null as string | null, + }, +})) + +vi.mock('../../stores/openTargetStore', () => ({ + useOpenTargetStore: ( + selector: (state: typeof storeMocks.state & { + ensureTargets: typeof storeMocks.ensureTargets + openTarget: typeof storeMocks.openTarget + }) => unknown, + ) => selector({ + ...storeMocks.state, + ensureTargets: storeMocks.ensureTargets, + openTarget: storeMocks.openTarget, + }), +})) + +import { OpenProjectMenu } from './OpenProjectMenu' + +describe('OpenProjectMenu', () => { + beforeEach(() => { + storeMocks.ensureTargets.mockReset() + storeMocks.openTarget.mockReset() + storeMocks.state = { + targets: [], + primaryTargetId: null, + loading: false, + error: null, + } + }) + + it('renders a single Finder action when only file manager is detected', async () => { + storeMocks.state.targets = [{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' }] + storeMocks.state.primaryTargetId = 'finder' + storeMocks.openTarget.mockResolvedValue(undefined) + + render() + + await waitFor(() => expect(storeMocks.ensureTargets).toHaveBeenCalled()) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Open in Finder' })) + }) + + expect(storeMocks.openTarget).toHaveBeenCalledWith('finder', '/repo') + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) + + it('renders a dropdown with detected IDEs and Finder', async () => { + storeMocks.state.targets = [ + { id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' }, + { id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' }, + ] + storeMocks.state.primaryTargetId = 'vscode' + storeMocks.openTarget.mockResolvedValue(undefined) + + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Open project' })) + }) + expect(screen.getByRole('menu')).toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByRole('menuitem', { name: 'Finder' })) + }) + + expect(storeMocks.openTarget).toHaveBeenCalledWith('finder', '/repo') + }) + + it('does not render without a path', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/desktop/src/components/layout/OpenProjectMenu.tsx b/desktop/src/components/layout/OpenProjectMenu.tsx new file mode 100644 index 00000000..20b60ecf --- /dev/null +++ b/desktop/src/components/layout/OpenProjectMenu.tsx @@ -0,0 +1,134 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { ChevronDown, Code2, FolderOpen } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useOpenTargetStore } from '../../stores/openTargetStore' + +type Props = { + path: string | null | undefined +} + +function getTargetIcon(kind: 'ide' | 'file_manager') { + if (kind === 'file_manager') { + return + } + return +} + +export function OpenProjectMenu({ path }: Props) { + const t = useTranslation() + const targets = useOpenTargetStore((state) => state.targets) + const primaryTargetId = useOpenTargetStore((state) => state.primaryTargetId) + const ensureTargets = useOpenTargetStore((state) => state.ensureTargets) + const openTarget = useOpenTargetStore((state) => state.openTarget) + const [open, setOpen] = useState(false) + const buttonRef = useRef(null) + const menuRef = useRef(null) + + useEffect(() => { + if (!path) { + setOpen(false) + return + } + void ensureTargets() + }, [ensureTargets, path]) + + useEffect(() => { + if (!open) return + + const handleDocumentMouseDown = (event: MouseEvent) => { + const target = event.target as Node + if (buttonRef.current?.contains(target) || menuRef.current?.contains(target)) return + setOpen(false) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false) + } + + document.addEventListener('mousedown', handleDocumentMouseDown) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handleDocumentMouseDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, [open]) + + const primaryTarget = useMemo( + () => targets.find((target) => target.id === primaryTargetId) ?? targets[0] ?? null, + [primaryTargetId, targets], + ) + const hasMenu = targets.length > 1 + + const handleOpenTarget = async (targetId: string) => { + if (!path) return + try { + await openTarget(targetId, path) + } catch { + // Store state already records the failure; keep the control responsive. + } finally { + setOpen(false) + } + } + + if (!path || !primaryTarget) return null + + const buttonLabel = hasMenu + ? t('openProject.openProject') + : t('openProject.openIn', { target: primaryTarget.label }) + + const rect = buttonRef.current?.getBoundingClientRect() + + return ( +
+ + + {open && hasMenu && rect ? createPortal( +
+ {targets.map((target) => ( + + ))} +
, + document.body, + ) : null} +
+ ) +} diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 4d2bde3b..42544b27 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -10,6 +10,9 @@ const windowControlsMock = vi.hoisted(() => ({ show: true, })) const scrollIntoViewMock = vi.hoisted(() => vi.fn()) +const openProjectMenuMock = vi.hoisted(() => ({ + paths: [] as Array, +})) vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: getCurrentWindowMock, @@ -30,6 +33,9 @@ vi.mock('../../i18n', () => ({ 'tabs.openTerminal': 'Open Terminal', 'tabs.showWorkspace': 'Show Workspace', 'tabs.hideWorkspace': 'Hide Workspace', + 'openProject.openProject': 'Open project', + 'openProject.openIn': 'Open in {target}', + 'openProject.openFailed': 'Could not open project', 'common.cancel': 'Cancel', } @@ -37,6 +43,14 @@ vi.mock('../../i18n', () => ({ }, })) +vi.mock('./OpenProjectMenu', () => ({ + OpenProjectMenu: ({ path }: { path: string | null | undefined }) => { + if (!path) return null + openProjectMenuMock.paths.push(path) + return
{path}
+ }, +})) + vi.mock('./WindowControls', () => ({ WindowControls: () => (windowControlsMock.show ?
: null), get showWindowControls() { @@ -73,6 +87,7 @@ describe('TabBar', () => { startDraggingMock.mockClear() getCurrentWindowMock.mockClear() scrollIntoViewMock.mockClear() + openProjectMenuMock.paths = [] windowControlsMock.show = true vi.resetModules() }) @@ -82,6 +97,7 @@ describe('TabBar', () => { const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') + const { useSessionStore } = await import('../../stores/sessionStore') const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore') @@ -89,6 +105,16 @@ describe('TabBar', () => { useChatStore.setState({ sessions: {}, } as Partial>) + useSessionStore.setState({ + sessions: [], + activeSessionId: null, + isLoading: false, + error: null, + selectedProjects: [], + availableProjects: [], + isBatchMode: false, + selectedSessionIds: new Set(), + } as Partial>) useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) useTerminalPanelStore.setState(useTerminalPanelStore.getInitialState(), true) @@ -230,6 +256,120 @@ describe('TabBar', () => { expect(screen.getByTestId('tab-bar-drag-gutter')).toHaveAttribute('data-tauri-drag-region') }) + it('passes the active session workdir into the open-project control', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + const { useSessionStore } = await import('../../stores/sessionStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'Workspace Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + useSessionStore.setState({ + sessions: [{ + id: 'tab-1', + title: 'Workspace Session', + createdAt: '2026-05-13T00:00:00.000Z', + modifiedAt: '2026-05-13T00:00:00.000Z', + messageCount: 0, + projectPath: '/repo', + workDir: '/repo/worktree', + workDirExists: true, + }], + activeSessionId: 'tab-1', + }) + + await act(async () => { + render() + }) + + expect(screen.getByTestId('open-project-menu')).toHaveTextContent('/repo/worktree') + expect(openProjectMenuMock.paths[openProjectMenuMock.paths.length - 1]).toBe('/repo/worktree') + }) + + it('hides the open-project control when the active session workdir is unavailable', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + const { useSessionStore } = await import('../../stores/sessionStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'Workspace Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + useSessionStore.setState({ + sessions: [{ + id: 'tab-1', + title: 'Workspace Session', + createdAt: '2026-05-13T00:00:00.000Z', + modifiedAt: '2026-05-13T00:00:00.000Z', + messageCount: 0, + projectPath: '/repo', + workDir: '/repo/worktree', + workDirExists: false, + }], + activeSessionId: 'tab-1', + }) + + await act(async () => { + render() + }) + + expect(screen.queryByTestId('open-project-menu')).not.toBeInTheDocument() + }) + + it('hides the open-project control outside the desktop shell', async () => { + delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__ + + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + const { useSessionStore } = await import('../../stores/sessionStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'Workspace Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + useSessionStore.setState({ + sessions: [{ + id: 'tab-1', + title: 'Workspace Session', + createdAt: '2026-05-13T00:00:00.000Z', + modifiedAt: '2026-05-13T00:00:00.000Z', + messageCount: 0, + projectPath: '/repo', + workDir: '/repo/worktree', + workDirExists: true, + }], + activeSessionId: 'tab-1', + }) + + await act(async () => { + render() + }) + + expect(screen.queryByTestId('open-project-menu')).not.toBeInTheDocument() + }) + it('starts dragging when clicking the empty tab-bar gutter', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index b29a587b..08c55727 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -7,10 +7,12 @@ import { type Tab, } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' +import { useSessionStore } from '../../stores/sessionStore' import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { useTerminalPanelStore } from '../../stores/terminalPanelStore' import { useTranslation } from '../../i18n' import { WindowControls, showWindowControls } from './WindowControls' +import { OpenProjectMenu } from './OpenProjectMenu' import { Folder, FolderOpen, SquareTerminal } from 'lucide-react' const TAB_WIDTH = 180 @@ -40,6 +42,12 @@ export function TabBar() { const disconnectSession = useChatStore((s) => s.disconnectSession) const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null const isActiveSessionTab = isSessionTab(activeTab) || isSessionTabId(activeTabId) + const activeSession = useSessionStore((state) => + activeTabId ? state.sessions.find((session) => session.id === activeTabId) : undefined, + ) + const openProjectPath = isActiveSessionTab && activeSession?.workDirExists !== false + ? activeSession?.workDir ?? null + : null const isWorkspacePanelOpen = useWorkspacePanelStore((state) => activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false, ) @@ -319,6 +327,9 @@ export function TabBar() {
+ {isTauri && isActiveSessionTab && ( + + )} } label={t('tabs.openTerminal')} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 06fa4cb5..be8da7be 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -54,6 +54,11 @@ export const en = { 'titlebar.terminal': 'Terminal', 'titlebar.history': 'History', + // ─── Open Project ────────────────────────────────────── + 'openProject.openProject': 'Open project', + 'openProject.openIn': 'Open in {target}', + 'openProject.openFailed': 'Could not open project', + // ─── 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 dc7bf437..8abdc689 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -56,6 +56,11 @@ export const zh: Record = { 'titlebar.terminal': '终端', 'titlebar.history': '历史', + // ─── Open Project ────────────────────────────────────── + 'openProject.openProject': '打开项目', + 'openProject.openIn': '用 {target} 打开', + 'openProject.openFailed': '无法打开项目', + // ─── Workspace Panel ─────────────────────────────── 'workspace.changedFiles': '已更改文件', 'workspace.allFiles': '所有文件', diff --git a/desktop/src/stores/openTargetStore.test.ts b/desktop/src/stores/openTargetStore.test.ts new file mode 100644 index 00000000..036e1c1d --- /dev/null +++ b/desktop/src/stores/openTargetStore.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const apiMocks = vi.hoisted(() => ({ + list: vi.fn(), + open: vi.fn(), +})) + +vi.mock('../api/openTargets', () => ({ + openTargetsApi: apiMocks, +})) + +describe('openTargetStore', () => { + beforeEach(async () => { + vi.resetModules() + apiMocks.list.mockReset() + apiMocks.open.mockReset() + }) + + it('caches detected targets inside the TTL', async () => { + const { useOpenTargetStore } = await import('./openTargetStore') + apiMocks.list.mockResolvedValue({ + platform: 'darwin', + targets: [{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' }], + primaryTargetId: 'finder', + cachedAt: 1, + ttlMs: 60_000, + }) + + await useOpenTargetStore.getState().refreshTargets() + await useOpenTargetStore.getState().ensureTargets() + + expect(apiMocks.list).toHaveBeenCalledTimes(1) + expect(useOpenTargetStore.getState().primaryTargetId).toBe('finder') + }) + + it('remembers the last successful target for this runtime', async () => { + const { useOpenTargetStore } = await import('./openTargetStore') + apiMocks.list.mockResolvedValue({ + platform: 'darwin', + targets: [{ id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' }], + primaryTargetId: 'vscode', + cachedAt: 1, + ttlMs: 60_000, + }) + apiMocks.open.mockResolvedValue({ ok: true, targetId: 'vscode', path: '/repo' }) + + await useOpenTargetStore.getState().refreshTargets() + await useOpenTargetStore.getState().openTarget('vscode', '/repo') + + expect(useOpenTargetStore.getState().lastSuccessfulTargetId).toBe('vscode') + }) +}) diff --git a/desktop/src/stores/openTargetStore.ts b/desktop/src/stores/openTargetStore.ts new file mode 100644 index 00000000..e49142d2 --- /dev/null +++ b/desktop/src/stores/openTargetStore.ts @@ -0,0 +1,76 @@ +import { create } from 'zustand' +import { openTargetsApi, type OpenTarget } from '../api/openTargets' + +const CLIENT_CACHE_TTL_MS = 60_000 + +type OpenTargetState = { + targets: OpenTarget[] + platform: string | null + primaryTargetId: string | null + lastSuccessfulTargetId: string | null + loading: boolean + error: string | null + fetchedAt: number + ensureTargets: () => Promise + refreshTargets: () => Promise + openTarget: (targetId: string, path: string) => Promise +} + +function choosePrimaryTarget(targets: OpenTarget[], apiPrimary: string | null, lastSuccessful: string | null) { + if (lastSuccessful && targets.some((target) => target.id === lastSuccessful)) return lastSuccessful + if (apiPrimary && targets.some((target) => target.id === apiPrimary)) return apiPrimary + return targets[0]?.id ?? null +} + +export const useOpenTargetStore = create((set, get) => ({ + targets: [], + platform: null, + primaryTargetId: null, + lastSuccessfulTargetId: null, + loading: false, + error: null, + fetchedAt: 0, + + ensureTargets: async () => { + const state = get() + if (state.loading) return + if (state.fetchedAt > 0 && Date.now() - state.fetchedAt < CLIENT_CACHE_TTL_MS) return + await get().refreshTargets() + }, + + refreshTargets: async () => { + set({ loading: true, error: null }) + try { + const result = await openTargetsApi.list() + const primaryTargetId = choosePrimaryTarget( + result.targets, + result.primaryTargetId, + get().lastSuccessfulTargetId, + ) + set({ + targets: result.targets, + platform: result.platform, + primaryTargetId, + fetchedAt: Date.now(), + loading: false, + error: null, + }) + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : String(error), + }) + } + }, + + openTarget: async (targetId, path) => { + try { + await openTargetsApi.open(targetId, path) + set({ lastSuccessfulTargetId: targetId, primaryTargetId: targetId, error: null }) + } catch (error) { + await get().refreshTargets() + set({ error: error instanceof Error ? error.message : String(error) }) + throw error + } + }, +})) diff --git a/src/server/__tests__/open-target-api.test.ts b/src/server/__tests__/open-target-api.test.ts new file mode 100644 index 00000000..2a337b12 --- /dev/null +++ b/src/server/__tests__/open-target-api.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, spyOn } from 'bun:test' +import { handleOpenTargetsApi } from '../api/open-targets.js' +import { openTargetService } from '../services/openTargetService.js' + +let listTargetsSpy: ReturnType | undefined +let openTargetSpy: ReturnType | undefined + +function makeRequest( + method: string, + urlStr: string, + body?: Record | string, +): { req: Request; url: URL; segments: string[] } { + const url = new URL(urlStr, 'http://localhost:3456') + const init: RequestInit = { method } + if (body !== undefined) { + init.headers = { 'Content-Type': 'application/json' } + init.body = typeof body === 'string' ? body : JSON.stringify(body) + } + const req = new Request(url.toString(), init) + return { + req, + url, + segments: url.pathname.split('/').filter(Boolean), + } +} + +describe('open-targets API', () => { + afterEach(() => { + listTargetsSpy?.mockRestore() + listTargetsSpy = undefined + openTargetSpy?.mockRestore() + openTargetSpy = undefined + }) + + it('returns detected targets from GET /api/open-targets', async () => { + listTargetsSpy = spyOn(openTargetService, 'listTargets').mockResolvedValue({ + platform: 'darwin', + targets: [ + { id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' }, + ], + primaryTargetId: 'vscode', + cachedAt: 123, + ttlMs: 1_000, + }) + + const { req, url, segments } = makeRequest('GET', '/api/open-targets') + const res = await handleOpenTargetsApi(req, url, segments) + + expect(res.status).toBe(200) + expect(listTargetsSpy).toHaveBeenCalledTimes(1) + await expect(res.json()).resolves.toMatchObject({ + platform: 'darwin', + primaryTargetId: 'vscode', + targets: [{ id: 'vscode', kind: 'ide' }], + }) + }) + + it('opens an allowed target from POST /api/open-targets/open', async () => { + openTargetSpy = spyOn(openTargetService, 'openTarget').mockResolvedValue({ + ok: true, + targetId: 'vscode', + path: '/Users/nanmi/project', + }) + + const { req, url, segments } = makeRequest('POST', '/api/open-targets/open', { + targetId: 'vscode', + path: '/Users/nanmi/project', + }) + const res = await handleOpenTargetsApi(req, url, segments) + + expect(res.status).toBe(200) + expect(openTargetSpy).toHaveBeenCalledWith({ + targetId: 'vscode', + path: '/Users/nanmi/project', + }) + await expect(res.json()).resolves.toMatchObject({ + ok: true, + targetId: 'vscode', + path: '/Users/nanmi/project', + }) + }) + + it('rejects invalid request bodies before opening', async () => { + openTargetSpy = spyOn(openTargetService, 'openTarget') + + const { req, url, segments } = makeRequest('POST', '/api/open-targets/open', { targetId: 'vscode' }) + const res = await handleOpenTargetsApi(req, url, segments) + + expect(res.status).toBe(400) + expect(openTargetSpy).not.toHaveBeenCalled() + await expect(res.json()).resolves.toMatchObject({ + error: 'BAD_REQUEST', + message: 'Missing or invalid "path" in request body', + }) + }) + + it('rejects invalid JSON bodies', async () => { + const { req, url, segments } = makeRequest('POST', '/api/open-targets/open', '{not json') + const res = await handleOpenTargetsApi(req, url, segments) + + expect(res.status).toBe(400) + await expect(res.json()).resolves.toMatchObject({ + error: 'BAD_REQUEST', + message: 'Invalid JSON body', + }) + }) +}) diff --git a/src/server/__tests__/open-target-service.test.ts b/src/server/__tests__/open-target-service.test.ts new file mode 100644 index 00000000..7047fc7e --- /dev/null +++ b/src/server/__tests__/open-target-service.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createOpenTargetService } from '../services/openTargetService.js' + +async function makeDir(prefix = 'cc-haha-open-target-') { + return mkdtemp(join(tmpdir(), prefix)) +} + +function createService( + platform: NodeJS.Platform, + options: { + commands?: Record + paths?: Record + launchResult?: { code: number; stdout: string; stderr: string } + ttlMs?: number + now?: { value: number } + } = {}, +) { + const launched: Array<{ command: string; args: string[] }> = [] + let commandProbes = 0 + let pathProbes = 0 + const now = options.now ?? { value: 100 } + + const service = createOpenTargetService({ + platform, + ttlMs: options.ttlMs ?? 1_000, + now: () => now.value, + commandExists: async (command) => { + commandProbes += 1 + return options.commands?.[command] === true + }, + pathExists: async (targetPath) => { + pathProbes += 1 + return options.paths?.[targetPath] === true + }, + launch: async (command, args) => { + launched.push({ command, args }) + return options.launchResult ?? { code: 0, stdout: '', stderr: '' } + }, + }) + + return { + service, + launched, + now, + get commandProbes() { + return commandProbes + }, + get pathProbes() { + return pathProbes + }, + } +} + +describe('openTargetService', () => { + it('returns only detected IDE targets plus Finder on macOS', async () => { + const { service } = createService('darwin', { + commands: { code: true }, + paths: { + '/Applications/Sublime Text.app': true, + }, + }) + + const result = await service.listTargets() + + expect(result.platform).toBe('darwin') + expect(result.targets.map((target) => target.id)).toEqual([ + 'vscode', + 'sublime', + 'finder', + ]) + expect(result.primaryTargetId).toBe('vscode') + expect(result.targets.find((target) => target.id === 'finder')?.kind).toBe('file_manager') + }) + + it('falls back to Explorer when no Windows IDE is detected', async () => { + const { service } = createService('win32') + + const result = await service.listTargets() + + expect(result.targets.map((target) => target.id)).toEqual(['explorer']) + expect(result.primaryTargetId).toBe('explorer') + }) + + it('only includes the Linux file-manager fallback when xdg-open is available', async () => { + const withoutXdg = createService('linux') + expect((await withoutXdg.service.listTargets()).targets).toEqual([]) + + const withXdg = createService('linux', { + commands: { 'xdg-open': true }, + }) + expect((await withXdg.service.listTargets()).targets.map((target) => target.id)).toEqual([ + 'file-manager', + ]) + }) + + it('caches detection results until the TTL expires', async () => { + const now = { value: 100 } + const state = createService('darwin', { + commands: { code: true }, + now, + }) + + await state.service.listTargets() + const initialProbes = state.commandProbes + expect(initialProbes).toBeGreaterThan(0) + + await state.service.listTargets() + expect(state.commandProbes).toBe(initialProbes) + + now.value = 5_000 + await state.service.listTargets() + expect(state.commandProbes).toBeGreaterThan(initialProbes) + }) + + it('rejects unknown targets', async () => { + const dir = await makeDir() + const { service } = createService('darwin', { commands: { code: true } }) + + try { + await expect(service.openTarget({ targetId: 'terminal', path: dir })) + .rejects.toMatchObject({ code: 'OPEN_TARGET_UNKNOWN' }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('rejects non-directory paths', async () => { + const dir = await makeDir() + const file = join(dir, 'note.txt') + await writeFile(file, 'not a directory') + const { service } = createService('darwin', { commands: { code: true } }) + + try { + await expect(service.openTarget({ targetId: 'vscode', path: file })) + .rejects.toMatchObject({ code: 'OPEN_TARGET_PATH_NOT_DIRECTORY' }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('launches with argument arrays and the path as one argument', async () => { + const dir = await makeDir('cc-haha open-target-') + const { service, launched } = createService('darwin', { + commands: { code: true }, + }) + + try { + await service.openTarget({ targetId: 'vscode', path: dir }) + + expect(launched).toEqual([{ command: 'code', args: [dir] }]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('opens macOS app bundles through open -a when no command is present', async () => { + const dir = await makeDir() + const { service, launched } = createService('darwin', { + paths: { '/Applications/Cursor.app': true }, + }) + + try { + await service.openTarget({ targetId: 'cursor', path: dir }) + + expect(launched).toEqual([ + { command: 'open', args: ['-a', '/Applications/Cursor.app', dir] }, + ]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('reports launch failures instead of returning success', async () => { + const dir = await makeDir() + const { service } = createService('darwin', { + commands: { code: true }, + launchResult: { code: 1, stdout: '', stderr: 'failed' }, + }) + + try { + await expect(service.openTarget({ targetId: 'vscode', path: dir })) + .rejects.toMatchObject({ code: 'OPEN_TARGET_LAUNCH_FAILED' }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/server/api/open-targets.ts b/src/server/api/open-targets.ts new file mode 100644 index 00000000..b7436d25 --- /dev/null +++ b/src/server/api/open-targets.ts @@ -0,0 +1,53 @@ +import { openTargetService } from '../services/openTargetService.js' +import { ApiError, errorResponse } from '../middleware/errorHandler.js' + +export async function handleOpenTargetsApi( + req: Request, + url: URL, + segments: string[], +): Promise { + try { + const action = segments[2] + + if (!action) { + if (req.method !== 'GET') { + throw new ApiError(405, `Method ${req.method} not allowed`, 'METHOD_NOT_ALLOWED') + } + + return Response.json(await openTargetService.listTargets()) + } + + if (action === 'open') { + if (req.method !== 'POST') { + throw new ApiError(405, `Method ${req.method} not allowed`, 'METHOD_NOT_ALLOWED') + } + + const body = await parseJsonBody(req) + const targetId = typeof body.targetId === 'string' ? body.targetId.trim() : '' + const path = typeof body.path === 'string' ? body.path : '' + + if (!targetId) { + throw ApiError.badRequest('Missing or invalid "targetId" in request body') + } + + if (!path || !path.trim()) { + throw ApiError.badRequest('Missing or invalid "path" in request body') + } + + return Response.json(await openTargetService.openTarget({ targetId, path })) + } + + throw ApiError.notFound(`Unknown open-targets endpoint: ${action}`) + } catch (error) { + return errorResponse(error) + } +} + +async function parseJsonBody(req: Request): Promise> { + try { + const body = await req.json() + return body && typeof body === 'object' ? body as Record : {} + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} diff --git a/src/server/router.ts b/src/server/router.ts index 5db9be15..7085ba36 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -24,6 +24,7 @@ import { handleDiagnosticsApi } from './api/diagnostics.js' import { handleDoctorApi } from './api/doctor.js' import { handleH5AccessApi } from './api/h5-access.js' import { handleActivityStatsApi } from './api/activityStats.js' +import { handleOpenTargetsApi } from './api/open-targets.js' export async function handleApiRequest(req: Request, url: URL): Promise { const path = url.pathname @@ -107,6 +108,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise number + commandExists: (command: string) => Promise + pathExists: (targetPath: string) => Promise + launch: (command: string, args: string[]) => Promise +} + +type LaunchPlan = { + command: string + args: string[] +} + +type TargetDefinition = { + id: string + kind: OpenTargetKind + label: string + icon: string + platforms: OpenTargetPlatform[] + commands?: Partial> + appPaths?: Partial> + fallback?: boolean +} + +const TARGET_DEFINITIONS: TargetDefinition[] = [ + { + id: 'vscode', + kind: 'ide', + label: 'VS Code', + icon: 'vscode', + platforms: ['darwin', 'win32', 'linux'], + commands: { + darwin: ['code'], + win32: ['code.cmd', 'code.exe'], + linux: ['code'], + }, + appPaths: { + darwin: [ + '/Applications/Visual Studio Code.app', + join(homedir(), 'Applications', 'Visual Studio Code.app'), + ], + }, + }, + { + id: 'cursor', + kind: 'ide', + label: 'Cursor', + icon: 'cursor', + platforms: ['darwin', 'win32', 'linux'], + commands: { + darwin: ['cursor'], + win32: ['cursor.cmd', 'cursor.exe'], + linux: ['cursor'], + }, + appPaths: { + darwin: ['/Applications/Cursor.app', join(homedir(), 'Applications', 'Cursor.app')], + }, + }, + { + id: 'sublime', + kind: 'ide', + label: 'Sublime Text', + icon: 'sublime', + platforms: ['darwin', 'win32', 'linux'], + commands: { + darwin: ['subl'], + win32: ['subl.exe', 'subl'], + linux: ['subl'], + }, + appPaths: { + darwin: ['/Applications/Sublime Text.app', join(homedir(), 'Applications', 'Sublime Text.app')], + }, + }, + { + id: 'antigravity', + kind: 'ide', + label: 'Antigravity', + icon: 'antigravity', + platforms: ['darwin'], + commands: { + darwin: ['antigravity'], + }, + appPaths: { + darwin: ['/Applications/Antigravity.app', join(homedir(), 'Applications', 'Antigravity.app')], + }, + }, + { + id: 'goland', + kind: 'ide', + label: 'GoLand', + icon: 'goland', + platforms: ['darwin', 'win32', 'linux'], + commands: { + darwin: ['goland'], + win32: ['goland64.exe', 'goland.cmd'], + linux: ['goland'], + }, + appPaths: { + darwin: ['/Applications/GoLand.app', join(homedir(), 'Applications', 'GoLand.app')], + }, + }, + { + id: 'pycharm', + kind: 'ide', + label: 'PyCharm', + icon: 'pycharm', + platforms: ['darwin', 'win32', 'linux'], + commands: { + darwin: ['pycharm'], + win32: ['pycharm64.exe', 'pycharm.cmd'], + linux: ['pycharm'], + }, + appPaths: { + darwin: ['/Applications/PyCharm.app', join(homedir(), 'Applications', 'PyCharm.app')], + }, + }, + { + id: 'finder', + kind: 'file_manager', + label: 'Finder', + icon: 'finder', + platforms: ['darwin'], + fallback: true, + }, + { + id: 'explorer', + kind: 'file_manager', + label: 'Explorer', + icon: 'folder', + platforms: ['win32'], + fallback: true, + }, + { + id: 'file-manager', + kind: 'file_manager', + label: 'File Manager', + icon: 'folder', + platforms: ['linux'], + fallback: true, + }, +] + +function openTargetError(statusCode: number, message: string, code: string): ApiError { + return new ApiError(statusCode, message, code) +} + +async function defaultCommandExists(command: string): Promise { + const probe = process.platform === 'win32' ? 'where' : 'which' + try { + await execFile(probe, [command], { + timeout: 3_000, + windowsHide: true, + }) + return true + } catch { + return false + } +} + +async function defaultPathExists(targetPath: string): Promise { + try { + const entry = await stat(targetPath) + return entry.isFile() || entry.isDirectory() + } catch { + return false + } +} + +async function defaultLaunch(command: string, args: string[]): Promise { + try { + const { stdout, stderr } = await execFile(command, args, { + timeout: 10_000, + windowsHide: true, + }) + return { + code: 0, + stdout: String(stdout ?? ''), + stderr: String(stderr ?? ''), + } + } catch (error) { + const err = error as { + code?: unknown + stdout?: unknown + stderr?: unknown + message?: string + } + return { + code: typeof err.code === 'number' ? err.code : 1, + stdout: String(err.stdout ?? ''), + stderr: String(err.stderr ?? err.message ?? ''), + } + } +} + +function buildOpenTarget(definition: TargetDefinition, platform: OpenTargetPlatform): OpenTarget { + return { + id: definition.id, + kind: definition.kind, + label: definition.label, + icon: definition.icon, + platform, + } +} + +function isSupportedOnPlatform(definition: TargetDefinition, platform: OpenTargetPlatform): boolean { + return definition.platforms.includes(platform) +} + +async function isDetected(definition: TargetDefinition, runtime: Runtime): Promise { + if (!isSupportedOnPlatform(definition, runtime.platform)) { + return false + } + + if (definition.fallback) { + if (runtime.platform === 'linux') { + return runtime.commandExists('xdg-open') + } + return true + } + + for (const appPath of definition.appPaths?.[runtime.platform] ?? []) { + if (await runtime.pathExists(appPath)) { + return true + } + } + + for (const command of definition.commands?.[runtime.platform] ?? []) { + if (await runtime.commandExists(command)) { + return true + } + } + + return false +} + +async function resolveLaunchPlan( + definition: TargetDefinition, + runtime: Runtime, + targetPath: string, +): Promise { + if (!isSupportedOnPlatform(definition, runtime.platform)) { + return null + } + + if (definition.fallback) { + switch (runtime.platform) { + case 'darwin': + return { command: 'open', args: [targetPath] } + case 'win32': + return { command: 'explorer.exe', args: [targetPath] } + case 'linux': + return { command: 'xdg-open', args: [targetPath] } + default: + return null + } + } + + for (const command of definition.commands?.[runtime.platform] ?? []) { + if (await runtime.commandExists(command)) { + return { command, args: [targetPath] } + } + } + + if (runtime.platform !== 'darwin') { + return null + } + + for (const appPath of definition.appPaths?.darwin ?? []) { + if (await runtime.pathExists(appPath)) { + return { command: 'open', args: ['-a', appPath, targetPath] } + } + } + + return null +} + +async function validateDirectory(targetPath: string): Promise { + const resolvedPath = resolve(targetPath) + let entry + try { + entry = await stat(resolvedPath) + } catch { + throw openTargetError( + 400, + `Directory does not exist: ${resolvedPath}`, + 'OPEN_TARGET_PATH_MISSING', + ) + } + + if (!entry.isDirectory()) { + throw openTargetError( + 400, + `Path is not a directory: ${resolvedPath}`, + 'OPEN_TARGET_PATH_NOT_DIRECTORY', + ) + } + + return resolvedPath +} + +export function createOpenTargetService(overrides: Partial = {}) { + const runtime: Runtime = { + platform: overrides.platform ?? process.platform, + ttlMs: overrides.ttlMs ?? DEFAULT_TTL_MS, + now: overrides.now ?? Date.now, + commandExists: overrides.commandExists ?? defaultCommandExists, + pathExists: overrides.pathExists ?? defaultPathExists, + launch: overrides.launch ?? defaultLaunch, + } + + let cache: OpenTargetList | null = null + + async function listTargets(forceRefresh = false): Promise { + if (!forceRefresh && cache && runtime.now() - cache.cachedAt < runtime.ttlMs) { + return cache + } + + const targets: OpenTarget[] = [] + for (const definition of TARGET_DEFINITIONS) { + if (await isDetected(definition, runtime)) { + targets.push(buildOpenTarget(definition, runtime.platform)) + } + } + + cache = { + platform: runtime.platform, + targets, + primaryTargetId: targets[0]?.id ?? null, + cachedAt: runtime.now(), + ttlMs: runtime.ttlMs, + } + + return cache + } + + async function openTarget(input: { targetId: string; path: string }) { + const definition = TARGET_DEFINITIONS.find((candidate) => candidate.id === input.targetId) + if (!definition) { + throw openTargetError( + 400, + `Unknown open target: ${input.targetId}`, + 'OPEN_TARGET_UNKNOWN', + ) + } + + const targets = await listTargets() + const target = targets.targets.find((candidate) => candidate.id === input.targetId) + if (!target) { + throw openTargetError( + 400, + `Open target is not available on ${runtime.platform}: ${input.targetId}`, + 'OPEN_TARGET_UNAVAILABLE', + ) + } + + const resolvedPath = await validateDirectory(input.path) + const launchPlan = await resolveLaunchPlan(definition, runtime, resolvedPath) + if (!launchPlan) { + throw openTargetError( + 400, + `Unable to launch open target: ${input.targetId}`, + 'OPEN_TARGET_UNAVAILABLE', + ) + } + + const launchResult = await runtime.launch(launchPlan.command, launchPlan.args) + if (launchResult.code !== 0) { + throw openTargetError( + 500, + `Failed to launch open target: ${input.targetId}`, + 'OPEN_TARGET_LAUNCH_FAILED', + ) + } + + return { + ok: true as const, + targetId: target.id, + path: resolvedPath, + } + } + + return { + listTargets, + openTarget, + } +} + +export const openTargetService = createOpenTargetService()