diff --git a/src/main/external-editor-launch.test.ts b/src/main/external-editor-launch.test.ts index 507e9b5700d..abb1e0fb757 100644 --- a/src/main/external-editor-launch.test.ts +++ b/src/main/external-editor-launch.test.ts @@ -1,8 +1,22 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { resolveCliCommandMock } = vi.hoisted(() => ({ + resolveCliCommandMock: vi.fn((command: string) => command) +})) + +vi.mock('./codex-cli/command', () => ({ + resolveCliCommand: resolveCliCommandMock +})) + import { getCmdExePath } from './win32-utils' import { resolveExternalEditorLaunchSpec } from './external-editor-launch' describe('resolveExternalEditorLaunchSpec', () => { + beforeEach(() => { + resolveCliCommandMock.mockReset() + resolveCliCommandMock.mockImplementation((command: string) => command) + }) + it('keeps simple CLI commands on the executable launch path', () => { const spec = resolveExternalEditorLaunchSpec('cursor', '/tmp/workspace', { platform: 'darwin' @@ -121,4 +135,113 @@ describe('resolveExternalEditorLaunchSpec', () => { spawnArgs: ['/d', '/s', '/c', 'nvim --clean C:\\workspaces\\orca'] }) }) + + it('opens modern WSL UNC workspaces in the matching VS Code remote', () => { + expect( + resolveExternalEditorLaunchSpec('code', '\\\\wsl.localhost\\Ubuntu\\home\\aliuq\\project', { + platform: 'win32' + }).spawnArgs + ).toEqual(['--remote', 'wsl+Ubuntu', '/home/aliuq/project']) + }) + + it.each([ + [ + 'legacy WSL UNC workspace', + '\\\\wsl$\\Debian\\home\\ada\\project', + 'wsl+Debian', + '/home/ada/project' + ], + ['modern WSL distro root', '\\\\wsl.localhost\\Ubuntu', 'wsl+Ubuntu', '/'], + ['legacy WSL distro root', '\\\\wsl$\\Debian', 'wsl+Debian', '/'] + ])('opens a %s in the matching VS Code remote', (_label, pathValue, authority, linuxPath) => { + expect( + resolveExternalEditorLaunchSpec('code', pathValue, { platform: 'win32' }).spawnArgs + ).toEqual(['--remote', authority, linuxPath]) + }) + + it.each([ + 'C:\\Program Files\\Microsoft VS Code\\Code.exe', + 'C:\\Tools\\CODE.CMD', + 'C:\\Tools\\code.bat' + ])('recognizes the direct Windows VS Code launcher %s', (editorCommand) => { + expect( + resolveExternalEditorLaunchSpec( + editorCommand, + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project', + { platform: 'win32' } + ).spawnArgs + ).toEqual(['--remote', 'wsl+Ubuntu', '/home/ada/project']) + }) + + it.each(['C:\\Tools\\CODE.CMD', 'C:\\Tools\\code.bat'])( + 'recognizes the resolved Windows VS Code launcher %s', + (resolvedCommand) => { + resolveCliCommandMock.mockReturnValueOnce(resolvedCommand) + + expect( + resolveExternalEditorLaunchSpec('code', '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project', { + platform: 'win32' + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: resolvedCommand, + spawnArgs: ['--remote', 'wsl+Ubuntu', '/home/ada/project'] + }) + } + ) + + it('preserves spaces in WSL distro and folder arguments', () => { + expect( + resolveExternalEditorLaunchSpec( + 'code', + '\\\\wsl.localhost\\Ubuntu Preview\\home\\Ada Lovelace\\project', + { platform: 'win32' } + ).spawnArgs + ).toEqual(['--remote', 'wsl+Ubuntu Preview', '/home/Ada Lovelace/project']) + }) + + it.each(['darwin', 'linux'] as const)('keeps WSL-looking paths local on %s', (platform) => { + const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' + expect(resolveExternalEditorLaunchSpec('code', pathValue, { platform }).spawnArgs).toEqual([ + pathValue + ]) + }) + + it.each(['C:\\workspaces\\orca', '\\\\server\\share\\project'])( + 'keeps the non-WSL Windows path %s local', + (pathValue) => { + expect( + resolveExternalEditorLaunchSpec('code', pathValue, { platform: 'win32' }).spawnArgs + ).toEqual([pathValue]) + } + ) + + it('does not add VS Code remote arguments to other editors', () => { + const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' + + expect( + resolveExternalEditorLaunchSpec('C:\\Tools\\cursor.exe', pathValue, { + platform: 'win32' + }).spawnArgs + ).toEqual(['--new-window', pathValue]) + expect( + resolveExternalEditorLaunchSpec('C:\\Tools\\codium.exe', pathValue, { + platform: 'win32' + }).spawnArgs + ).toEqual([pathValue]) + }) + + it('does not rewrite compound VS Code commands', () => { + const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' + + expect( + resolveExternalEditorLaunchSpec('code --reuse-window', pathValue, { platform: 'win32' }) + ).toEqual({ + kind: 'shell', + hideWindowsConsole: true, + spawnCmd: getCmdExePath(), + spawnArgs: ['/d', '/s', '/c', `code --reuse-window ${pathValue}`] + }) + }) }) diff --git a/src/main/external-editor-launch.ts b/src/main/external-editor-launch.ts index 8e5c4a0b397..6a7badc9f82 100644 --- a/src/main/external-editor-launch.ts +++ b/src/main/external-editor-launch.ts @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs' import { basename, posix, win32 } from 'node:path' +import { parseWslUncPath } from '../shared/wsl-paths' import { resolveCliCommand } from './codex-cli/command' import { getCmdExePath } from './win32-utils' @@ -106,12 +107,23 @@ function shouldShowWindowsConsole( return platform === 'win32' && WINDOWS_CONSOLE_EDITORS.has(getLauncherBaseName(command, options)) } -function buildExecutableArgs(editorCommand: string, pathValue: string): string[] { +function buildExecutableArgs( + editorCommand: string, + pathValue: string, + platform: NodeJS.Platform +): string[] { if (getLauncherBaseName(editorCommand) === 'cursor') { // Why: Cursor can route bare folder launches through the last active // workbench. A new window keeps "Open in Cursor" scoped to this worktree. return ['--new-window', pathValue] } + if (platform === 'win32' && getLauncherBaseName(editorCommand) === 'code') { + const wslPath = parseWslUncPath(pathValue) + if (wslPath) { + // Why: VS Code otherwise treats a WSL UNC path as a local Windows folder. + return ['--remote', `wsl+${wslPath.distro}`, wslPath.linuxPath] + } + } return [pathValue] } @@ -156,7 +168,7 @@ export function resolveExternalEditorLaunchSpec( kind: 'executable', hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), spawnCmd: editorCommand, - spawnArgs: buildExecutableArgs(editorCommand, pathValue) + spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) } } @@ -169,6 +181,6 @@ export function resolveExternalEditorLaunchSpec( kind: 'executable', hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), spawnCmd: editorCommand, - spawnArgs: buildExecutableArgs(editorCommand, pathValue) + spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) } } diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 2e1b842904d..253d6fa3a9e 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -321,6 +321,28 @@ describe('registerShellHandlers', () => { ]) }) + it('forwards WSL remote arguments with spaces through the Windows launcher shim', async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const workspacePath = '\\\\wsl.localhost\\Ubuntu Preview\\home\\Ada Lovelace\\project' + const codeShim = 'C:\\Tools\\CODE.CMD' + resolveCliCommandMock.mockReturnValueOnce(codeShim) + const handler = getHandler('shell:openInExternalEditor') + + try { + await expect(handler({}, workspacePath, 'code')).resolves.toEqual({ ok: true }) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(codeShim, [ + '--remote', + 'wsl+Ubuntu Preview', + '/home/Ada Lovelace/project' + ]) + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor) + } + } + }) + it('shows the Windows console for NeoVim executable launchers on Windows', async () => { const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index d291198a0e0..0a6d381d3c0 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { + getCmdExePath, getSpawnArgsForWindows, isPermissionError, isWindowsBatchScript, @@ -65,6 +66,26 @@ describe('getSpawnArgsForWindows', () => { } }) + it('preserves VS Code WSL remote arguments with spaces through .cmd launchers', () => { + withPlatform('win32', () => { + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\code.cmd', [ + '--remote', + 'wsl+Ubuntu Preview', + '/home/Ada Lovelace/project' + ]) + + expect(spawnCmd).toBe(getCmdExePath()) + expect(spawnArgs).toEqual([ + '/d', + '/c', + 'C:\\tools\\code.cmd', + '--remote', + 'wsl+Ubuntu Preview', + '/home/Ada Lovelace/project' + ]) + }) + }) + it('passes .exe through unchanged on win32', () => { withPlatform('win32', () => { const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\codex.exe', ['login'])