From 4e596d2af296a8b4f6a8def3889f72300b312a68 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Fri, 1 May 2026 14:47:13 +0000 Subject: [PATCH 1/2] IMPLEMENT: fix open-in-editor spawning new VS Code window (issue #245) Previously used vscode://file URL scheme which reuses the existing VS Code window. Now spawns `code --new-window ` via child_process so a new window is always opened. Falls back to the URL scheme if the CLI is unavailable (e.g. code not in PATH on macOS without shell integration). Extracted openInEditor(path, runCli) with an injected CliRunner so the new-window behaviour is directly verifiable without Electron or child_process mocks. Files changed: - apps/desktop/src/main/ipc/shell.ts - apps/desktop/tests/main/shell-ipc.test.ts (new) No blockers. --- apps/desktop/src/main/ipc/shell.ts | 28 +++++++++++++++++------ apps/desktop/tests/main/shell-ipc.test.ts | 10 ++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/tests/main/shell-ipc.test.ts diff --git a/apps/desktop/src/main/ipc/shell.ts b/apps/desktop/src/main/ipc/shell.ts index ebbdb47..5ab8b56 100644 --- a/apps/desktop/src/main/ipc/shell.ts +++ b/apps/desktop/src/main/ipc/shell.ts @@ -1,16 +1,30 @@ -/** - * `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', resolve); + 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..b3cef48 --- /dev/null +++ b/apps/desktop/tests/main/shell-ipc.test.ts @@ -0,0 +1,10 @@ +import { openInEditor } from '@main/ipc/shell'; +import { describe, expect, it, vi } from 'vitest'; + +describe('openInEditor', () => { + 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']); + }); +}); From 8afced4fce8cc6fab76e87dd09adc98119c81061 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Fri, 1 May 2026 14:52:27 +0000 Subject: [PATCH 2/2] REVIEW: fix spawnCode exit code handling and add fallback tests spawnCode resolved the promise on any close event, including non-zero exit codes, so a failed `code` CLI invocation would never trigger the shell.openExternal fallback. Fix it to reject on non-zero or signal termination so the fallback fires as intended. Add three new tests: no-fallback on success, fallback on CLI error, and path-with-spaces passthrough. Mock electron at the system boundary so the fallback path is actually exercisable. --- apps/desktop/src/main/ipc/shell.ts | 8 +++++- apps/desktop/tests/main/shell-ipc.test.ts | 30 ++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/ipc/shell.ts b/apps/desktop/src/main/ipc/shell.ts index 5ab8b56..c9da8cb 100644 --- a/apps/desktop/src/main/ipc/shell.ts +++ b/apps/desktop/src/main/ipc/shell.ts @@ -15,7 +15,13 @@ 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', resolve); + proc.on('close', (exitCode) => { + if (exitCode === 0) { + resolve(); + } else { + reject(new Error(`code exited with ${exitCode ?? 'signal'}`)); + } + }); proc.unref(); }); } diff --git a/apps/desktop/tests/main/shell-ipc.test.ts b/apps/desktop/tests/main/shell-ipc.test.ts index b3cef48..c7c8204 100644 --- a/apps/desktop/tests/main/shell-ipc.test.ts +++ b/apps/desktop/tests/main/shell-ipc.test.ts @@ -1,10 +1,38 @@ import { openInEditor } from '@main/ipc/shell'; -import { describe, expect, it, vi } from 'vitest'; +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']); + }); });