Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions apps/desktop/src/main/ipc/shell.ts
Original file line number Diff line number Diff line change
@@ -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<void>;

export async function openInEditor(path: string, runCli: CliRunner): Promise<void> {
try {
await runCli(['--new-window', path]);
} catch {
await shell.openExternal(`vscode://file${path}`);
}
}

function spawnCode(args: readonly string[]): Promise<void> {
return new Promise<void>((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);
});
}
38 changes: 38 additions & 0 deletions apps/desktop/tests/main/shell-ipc.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading