diff --git a/apps/desktop/src/main/ipc/shell.ts b/apps/desktop/src/main/ipc/shell.ts index ebbdb47..c9da8cb 100644 --- a/apps/desktop/src/main/ipc/shell.ts +++ b/apps/desktop/src/main/ipc/shell.ts @@ -1,16 +1,36 @@ -/** - * `registerShellIpc` — tiny wrapper around Electron's `shell.showItemInFolder` - * so the renderer can reveal a scaffolded project (or its log file) in the - * OS file manager. Kept separate so the success / failure views can reach - * the same IPC channel. - */ +import { spawn } from 'node:child_process'; import { ipcMain, shell } from 'electron'; +export type CliRunner = (args: readonly string[]) => Promise; + +export async function openInEditor(path: string, runCli: CliRunner): Promise { + try { + await runCli(['--new-window', path]); + } catch { + await shell.openExternal(`vscode://file${path}`); + } +} + +function spawnCode(args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('code', [...args], { detached: true, stdio: 'ignore' }); + proc.on('error', reject); + proc.on('close', (exitCode) => { + if (exitCode === 0) { + resolve(); + } else { + reject(new Error(`code exited with ${exitCode ?? 'signal'}`)); + } + }); + proc.unref(); + }); +} + export function registerShellIpc(): void { ipcMain.handle('shell:reveal', (_evt, path: string) => { shell.showItemInFolder(path); }); ipcMain.handle('shell:open-in-editor', async (_evt, path: string) => { - await shell.openExternal(`vscode://file${path}`); + await openInEditor(path, spawnCode); }); } diff --git a/apps/desktop/tests/main/shell-ipc.test.ts b/apps/desktop/tests/main/shell-ipc.test.ts new file mode 100644 index 0000000..c7c8204 --- /dev/null +++ b/apps/desktop/tests/main/shell-ipc.test.ts @@ -0,0 +1,38 @@ +import { openInEditor } from '@main/ipc/shell'; +import { shell } from 'electron'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + shell: { openExternal: vi.fn().mockResolvedValue(undefined) }, + ipcMain: { handle: vi.fn() }, +})); + +describe('openInEditor', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('opens the path in a new VS Code window via the CLI runner', async () => { + const runCli = vi.fn(async () => undefined); + await openInEditor('/projects/my-app', runCli); + expect(runCli).toHaveBeenCalledWith(['--new-window', '/projects/my-app']); + }); + + it('does not fall back to shell.openExternal when CLI runner succeeds', async () => { + const runCli = vi.fn(async () => undefined); + await openInEditor('/projects/my-app', runCli); + expect(shell.openExternal).not.toHaveBeenCalled(); + }); + + it('falls back to shell.openExternal when the CLI runner throws', async () => { + const runCli = vi.fn().mockRejectedValue(new Error('ENOENT: code not found')); + await openInEditor('/projects/my-app', runCli); + expect(shell.openExternal).toHaveBeenCalledWith('vscode://file/projects/my-app'); + }); + + it('passes the path verbatim to the CLI runner', async () => { + const runCli = vi.fn(async () => undefined); + await openInEditor('/path/with spaces/project', runCli); + expect(runCli).toHaveBeenCalledWith(['--new-window', '/path/with spaces/project']); + }); +});