mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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:
parent
aa21e67d14
commit
db7432a39a
34
desktop/src/api/openTargets.ts
Normal file
34
desktop/src/api/openTargets.ts
Normal 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 })
|
||||||
|
},
|
||||||
|
}
|
||||||
106
desktop/src/components/layout/OpenProjectMenu.test.tsx
Normal file
106
desktop/src/components/layout/OpenProjectMenu.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
134
desktop/src/components/layout/OpenProjectMenu.tsx
Normal file
134
desktop/src/components/layout/OpenProjectMenu.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -10,6 +10,9 @@ const windowControlsMock = vi.hoisted(() => ({
|
|||||||
show: true,
|
show: true,
|
||||||
}))
|
}))
|
||||||
const scrollIntoViewMock = vi.hoisted(() => vi.fn())
|
const scrollIntoViewMock = vi.hoisted(() => vi.fn())
|
||||||
|
const openProjectMenuMock = vi.hoisted(() => ({
|
||||||
|
paths: [] as Array<string | null | undefined>,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@tauri-apps/api/window', () => ({
|
vi.mock('@tauri-apps/api/window', () => ({
|
||||||
getCurrentWindow: getCurrentWindowMock,
|
getCurrentWindow: getCurrentWindowMock,
|
||||||
@ -30,6 +33,9 @@ vi.mock('../../i18n', () => ({
|
|||||||
'tabs.openTerminal': 'Open Terminal',
|
'tabs.openTerminal': 'Open Terminal',
|
||||||
'tabs.showWorkspace': 'Show Workspace',
|
'tabs.showWorkspace': 'Show Workspace',
|
||||||
'tabs.hideWorkspace': 'Hide Workspace',
|
'tabs.hideWorkspace': 'Hide Workspace',
|
||||||
|
'openProject.openProject': 'Open project',
|
||||||
|
'openProject.openIn': 'Open in {target}',
|
||||||
|
'openProject.openFailed': 'Could not open project',
|
||||||
'common.cancel': 'Cancel',
|
'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', () => ({
|
vi.mock('./WindowControls', () => ({
|
||||||
WindowControls: () => (windowControlsMock.show ? <div data-testid="window-controls" /> : null),
|
WindowControls: () => (windowControlsMock.show ? <div data-testid="window-controls" /> : null),
|
||||||
get showWindowControls() {
|
get showWindowControls() {
|
||||||
@ -73,6 +87,7 @@ describe('TabBar', () => {
|
|||||||
startDraggingMock.mockClear()
|
startDraggingMock.mockClear()
|
||||||
getCurrentWindowMock.mockClear()
|
getCurrentWindowMock.mockClear()
|
||||||
scrollIntoViewMock.mockClear()
|
scrollIntoViewMock.mockClear()
|
||||||
|
openProjectMenuMock.paths = []
|
||||||
windowControlsMock.show = true
|
windowControlsMock.show = true
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
})
|
})
|
||||||
@ -82,6 +97,7 @@ describe('TabBar', () => {
|
|||||||
|
|
||||||
const { useTabStore } = await import('../../stores/tabStore')
|
const { useTabStore } = await import('../../stores/tabStore')
|
||||||
const { useChatStore } = await import('../../stores/chatStore')
|
const { useChatStore } = await import('../../stores/chatStore')
|
||||||
|
const { useSessionStore } = await import('../../stores/sessionStore')
|
||||||
const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore')
|
const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore')
|
||||||
const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore')
|
const { useTerminalPanelStore } = await import('../../stores/terminalPanelStore')
|
||||||
|
|
||||||
@ -89,6 +105,16 @@ describe('TabBar', () => {
|
|||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
sessions: {},
|
sessions: {},
|
||||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
} 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)
|
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
||||||
useTerminalPanelStore.setState(useTerminalPanelStore.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')
|
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 () => {
|
it('starts dragging when clicking the empty tab-bar gutter', async () => {
|
||||||
const { TabBar } = await import('./TabBar')
|
const { TabBar } = await import('./TabBar')
|
||||||
const { useTabStore } = await import('../../stores/tabStore')
|
const { useTabStore } = await import('../../stores/tabStore')
|
||||||
|
|||||||
@ -7,10 +7,12 @@ import {
|
|||||||
type Tab,
|
type Tab,
|
||||||
} from '../../stores/tabStore'
|
} from '../../stores/tabStore'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||||
import { useTerminalPanelStore } from '../../stores/terminalPanelStore'
|
import { useTerminalPanelStore } from '../../stores/terminalPanelStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { WindowControls, showWindowControls } from './WindowControls'
|
import { WindowControls, showWindowControls } from './WindowControls'
|
||||||
|
import { OpenProjectMenu } from './OpenProjectMenu'
|
||||||
import { Folder, FolderOpen, SquareTerminal } from 'lucide-react'
|
import { Folder, FolderOpen, SquareTerminal } from 'lucide-react'
|
||||||
|
|
||||||
const TAB_WIDTH = 180
|
const TAB_WIDTH = 180
|
||||||
@ -40,6 +42,12 @@ export function TabBar() {
|
|||||||
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
||||||
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null
|
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null
|
||||||
const isActiveSessionTab = isSessionTab(activeTab) || isSessionTabId(activeTabId)
|
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) =>
|
const isWorkspacePanelOpen = useWorkspacePanelStore((state) =>
|
||||||
activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false,
|
activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false,
|
||||||
)
|
)
|
||||||
@ -319,6 +327,9 @@ export function TabBar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border)]/70 px-2">
|
<div className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border)]/70 px-2">
|
||||||
|
{isTauri && isActiveSessionTab && (
|
||||||
|
<OpenProjectMenu path={openProjectPath} />
|
||||||
|
)}
|
||||||
<ToolbarIconButton
|
<ToolbarIconButton
|
||||||
icon={<SquareTerminal size={17} strokeWidth={1.9} />}
|
icon={<SquareTerminal size={17} strokeWidth={1.9} />}
|
||||||
label={t('tabs.openTerminal')}
|
label={t('tabs.openTerminal')}
|
||||||
|
|||||||
@ -54,6 +54,11 @@ export const en = {
|
|||||||
'titlebar.terminal': 'Terminal',
|
'titlebar.terminal': 'Terminal',
|
||||||
'titlebar.history': 'History',
|
'titlebar.history': 'History',
|
||||||
|
|
||||||
|
// ─── Open Project ──────────────────────────────────────
|
||||||
|
'openProject.openProject': 'Open project',
|
||||||
|
'openProject.openIn': 'Open in {target}',
|
||||||
|
'openProject.openFailed': 'Could not open project',
|
||||||
|
|
||||||
// ─── Workspace Panel ───────────────────────────────
|
// ─── Workspace Panel ───────────────────────────────
|
||||||
'workspace.changedFiles': 'Changed files',
|
'workspace.changedFiles': 'Changed files',
|
||||||
'workspace.allFiles': 'All files',
|
'workspace.allFiles': 'All files',
|
||||||
|
|||||||
@ -56,6 +56,11 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'titlebar.terminal': '终端',
|
'titlebar.terminal': '终端',
|
||||||
'titlebar.history': '历史',
|
'titlebar.history': '历史',
|
||||||
|
|
||||||
|
// ─── Open Project ──────────────────────────────────────
|
||||||
|
'openProject.openProject': '打开项目',
|
||||||
|
'openProject.openIn': '用 {target} 打开',
|
||||||
|
'openProject.openFailed': '无法打开项目',
|
||||||
|
|
||||||
// ─── Workspace Panel ───────────────────────────────
|
// ─── Workspace Panel ───────────────────────────────
|
||||||
'workspace.changedFiles': '已更改文件',
|
'workspace.changedFiles': '已更改文件',
|
||||||
'workspace.allFiles': '所有文件',
|
'workspace.allFiles': '所有文件',
|
||||||
|
|||||||
52
desktop/src/stores/openTargetStore.test.ts
Normal file
52
desktop/src/stores/openTargetStore.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
76
desktop/src/stores/openTargetStore.ts
Normal file
76
desktop/src/stores/openTargetStore.ts
Normal 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
107
src/server/__tests__/open-target-api.test.ts
Normal file
107
src/server/__tests__/open-target-api.test.ts
Normal 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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
190
src/server/__tests__/open-target-service.test.ts
Normal file
190
src/server/__tests__/open-target-service.test.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
53
src/server/api/open-targets.ts
Normal file
53
src/server/api/open-targets.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@ import { handleDiagnosticsApi } from './api/diagnostics.js'
|
|||||||
import { handleDoctorApi } from './api/doctor.js'
|
import { handleDoctorApi } from './api/doctor.js'
|
||||||
import { handleH5AccessApi } from './api/h5-access.js'
|
import { handleH5AccessApi } from './api/h5-access.js'
|
||||||
import { handleActivityStatsApi } from './api/activityStats.js'
|
import { handleActivityStatsApi } from './api/activityStats.js'
|
||||||
|
import { handleOpenTargetsApi } from './api/open-targets.js'
|
||||||
|
|
||||||
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
@ -107,6 +108,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
|||||||
case 'activity-stats':
|
case 'activity-stats':
|
||||||
return handleActivityStatsApi(req, url, segments)
|
return handleActivityStatsApi(req, url, segments)
|
||||||
|
|
||||||
|
case 'open-targets':
|
||||||
|
return handleOpenTargetsApi(req, url, segments)
|
||||||
|
|
||||||
case 'filesystem':
|
case 'filesystem':
|
||||||
return handleFilesystemRoute(url.pathname, url)
|
return handleFilesystemRoute(url.pathname, url)
|
||||||
|
|
||||||
|
|||||||
425
src/server/services/openTargetService.ts
Normal file
425
src/server/services/openTargetService.ts
Normal 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()
|
||||||
Loading…
x
Reference in New Issue
Block a user