mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +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,
|
||||
}))
|
||||
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')
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '所有文件',
|
||||
|
||||
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 { 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)
|
||||
|
||||
|
||||
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