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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 14:56:19 +08:00
parent aa21e67d14
commit db7432a39a
14 changed files with 1342 additions and 0 deletions

View File

@ -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<OpenTargetList>('/api/open-targets')
},
open(targetId: string, path: string) {
return api.post<OpenTargetOpenResponse>('/api/open-targets/open', { targetId, path })
},
}

View File

@ -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<string, string | number>) => {
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(<OpenProjectMenu path="/repo" />)
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(<OpenProjectMenu path="/repo" />)
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(<OpenProjectMenu path={null} />)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -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 <FolderOpen size={17} strokeWidth={1.9} />
}
return <Code2 size={17} strokeWidth={1.9} />
}
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<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(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 (
<div className="relative flex items-center">
<button
ref={buttonRef}
type="button"
aria-label={buttonLabel}
aria-haspopup={hasMenu ? 'menu' : undefined}
aria-expanded={hasMenu ? open : undefined}
title={buttonLabel}
onClick={() => {
if (hasMenu) {
setOpen((value) => !value)
return
}
void handleOpenTarget(primaryTarget.id)
}}
className={`inline-flex h-8 items-center justify-center gap-1 rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-tertiary)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
hasMenu
? 'min-w-[2.75rem] px-2 hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
: 'w-8 hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
{getTargetIcon(primaryTarget.kind)}
{hasMenu && <ChevronDown size={14} strokeWidth={1.9} />}
</button>
{open && hasMenu && rect ? createPortal(
<div
ref={menuRef}
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: rect.bottom + 6, right: Math.max(12, window.innerWidth - rect.right) }}
>
{targets.map((target) => (
<button
key={target.id}
type="button"
role="menuitem"
onClick={() => void handleOpenTarget(target.id)}
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)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]"
>
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]">
{getTargetIcon(target.kind)}
</span>
<span className="min-w-0 truncate">{target.label}</span>
</button>
))}
</div>,
document.body,
) : null}
</div>
)
}

View File

@ -10,6 +10,9 @@ const windowControlsMock = vi.hoisted(() => ({
show: true,
}))
const scrollIntoViewMock = vi.hoisted(() => vi.fn())
const openProjectMenuMock = vi.hoisted(() => ({
paths: [] as Array<string | null | undefined>,
}))
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 <div data-testid="open-project-menu">{path}</div>
},
}))
vi.mock('./WindowControls', () => ({
WindowControls: () => (windowControlsMock.show ? <div data-testid="window-controls" /> : 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<ReturnType<typeof useChatStore.getState>>)
useSessionStore.setState({
sessions: [],
activeSessionId: null,
isLoading: false,
error: null,
selectedProjects: [],
availableProjects: [],
isBatchMode: false,
selectedSessionIds: new Set(),
} as Partial<ReturnType<typeof useSessionStore.getState>>)
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<ReturnType<typeof useChatStore.getState>>)
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(<TabBar />)
})
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<ReturnType<typeof useChatStore.getState>>)
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(<TabBar />)
})
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<ReturnType<typeof useChatStore.getState>>)
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(<TabBar />)
})
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')

View File

@ -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() {
</div>
<div className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border)]/70 px-2">
{isTauri && isActiveSessionTab && (
<OpenProjectMenu path={openProjectPath} />
)}
<ToolbarIconButton
icon={<SquareTerminal size={17} strokeWidth={1.9} />}
label={t('tabs.openTerminal')}

View File

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

View File

@ -56,6 +56,11 @@ export const zh: Record<TranslationKey, string> = {
'titlebar.terminal': '终端',
'titlebar.history': '历史',
// ─── Open Project ──────────────────────────────────────
'openProject.openProject': '打开项目',
'openProject.openIn': '用 {target} 打开',
'openProject.openFailed': '无法打开项目',
// ─── Workspace Panel ───────────────────────────────
'workspace.changedFiles': '已更改文件',
'workspace.allFiles': '所有文件',

View File

@ -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')
})
})

View File

@ -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<void>
refreshTargets: () => Promise<void>
openTarget: (targetId: string, path: string) => Promise<void>
}
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<OpenTargetState>((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
}
},
}))

View File

@ -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<typeof spyOn> | undefined
let openTargetSpy: ReturnType<typeof spyOn> | undefined
function makeRequest(
method: string,
urlStr: string,
body?: Record<string, unknown> | 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',
})
})
})

View File

@ -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<string, boolean>
paths?: Record<string, boolean>
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 })
}
})
})

View File

@ -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<Response> {
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<Record<string, unknown>> {
try {
const body = await req.json()
return body && typeof body === 'object' ? body as Record<string, unknown> : {}
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
}

View File

@ -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<Response> {
const path = url.pathname
@ -107,6 +108,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'activity-stats':
return handleActivityStatsApi(req, url, segments)
case 'open-targets':
return handleOpenTargetsApi(req, url, segments)
case 'filesystem':
return handleFilesystemRoute(url.pathname, url)

View File

@ -0,0 +1,425 @@
import { execFile as execFileCallback } from 'node:child_process'
import { stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { ApiError } from '../middleware/errorHandler.js'
const execFile = promisify(execFileCallback)
const DEFAULT_TTL_MS = 30_000
export type OpenTargetPlatform = NodeJS.Platform
export type OpenTargetKind = 'ide' | 'file_manager'
export type OpenTarget = {
id: string
kind: OpenTargetKind
label: string
icon: string
platform: OpenTargetPlatform
}
export type OpenTargetList = {
platform: OpenTargetPlatform
targets: OpenTarget[]
primaryTargetId: string | null
cachedAt: number
ttlMs: number
}
export type OpenTargetLaunchResult = {
code: number
stdout: string
stderr: string
}
type Runtime = {
platform: OpenTargetPlatform
ttlMs: number
now: () => number
commandExists: (command: string) => Promise<boolean>
pathExists: (targetPath: string) => Promise<boolean>
launch: (command: string, args: string[]) => Promise<OpenTargetLaunchResult>
}
type LaunchPlan = {
command: string
args: string[]
}
type TargetDefinition = {
id: string
kind: OpenTargetKind
label: string
icon: string
platforms: OpenTargetPlatform[]
commands?: Partial<Record<OpenTargetPlatform, string[]>>
appPaths?: Partial<Record<OpenTargetPlatform, string[]>>
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<boolean> {
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<boolean> {
try {
const entry = await stat(targetPath)
return entry.isFile() || entry.isDirectory()
} catch {
return false
}
}
async function defaultLaunch(command: string, args: string[]): Promise<OpenTargetLaunchResult> {
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<boolean> {
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<LaunchPlan | null> {
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<string> {
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<Runtime> = {}) {
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<OpenTargetList> {
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()