diff --git a/src/main/external-editor-launch.test.ts b/src/main/external-editor-launch.test.ts index 7a9928422c2..638e1ff02f7 100644 --- a/src/main/external-editor-launch.test.ts +++ b/src/main/external-editor-launch.test.ts @@ -88,7 +88,7 @@ describe('resolveExternalEditorLaunchSpec', () => { }) }) - it('detaches a directly configured JetBrains shim path', () => { + it('detaches a directly configured JetBrains shim path when no GUI exe is beside it', () => { expect( resolveExternalEditorLaunchSpec('C:\\Tools\\WebStorm\\bin\\webstorm.bat', 'C:\\ws', { platform: 'win32', @@ -103,6 +103,76 @@ describe('resolveExternalEditorLaunchSpec', () => { }) }) + it('upgrades a direct JetBrains .cmd path to the colocated *64.exe', () => { + const installBin = 'C:\\Program Files\\JetBrains\\WebStorm\\bin' + expect( + resolveExternalEditorLaunchSpec(`${installBin}\\webstorm.cmd`, 'C:\\ws', { + platform: 'win32', + fileExists: (candidate) => candidate === `${installBin}\\webstorm64.exe` + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: `${installBin}\\webstorm64.exe`, + spawnArgs: ['C:\\ws'] + }) + }) + + it('upgrades a short-name console idea.exe stub to the colocated idea64.exe', () => { + const installBin = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin' + expect( + resolveExternalEditorLaunchSpec(`${installBin}\\idea.exe`, 'C:\\ws', { + platform: 'win32', + fileExists: (candidate) => candidate === `${installBin}\\idea64.exe` + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: `${installBin}\\idea64.exe`, + spawnArgs: ['C:\\ws'] + }) + }) + + it('leaves a direct idea64.exe path unchanged even when a sibling exists', () => { + const guiExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe' + expect( + resolveExternalEditorLaunchSpec(guiExe, 'C:\\ws', { + platform: 'win32', + fileExists: () => true + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: guiExe, + spawnArgs: ['C:\\ws'] + }) + }) + + it('does not rewrite non-JetBrains .exe launchers via colocation', () => { + const codeExe = 'C:\\Program Files\\Microsoft VS Code\\Code.exe' + expect( + resolveExternalEditorLaunchSpec(codeExe, 'C:\\ws', { + platform: 'win32', + fileExists: () => true + }).spawnCmd + ).toBe(codeExe) + }) + + it('keeps idea.exe when no colocated idea64.exe exists', () => { + const consoleExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea.exe' + expect( + resolveExternalEditorLaunchSpec(consoleExe, 'C:\\ws', { + platform: 'win32', + fileExists: () => false + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: consoleExe, + spawnArgs: ['C:\\ws'] + }) + }) + // Why: `start` re-parses argv, so only JetBrains shims may take that path. it.each([ ['code', 'C:\\Tools\\code.cmd', ['C:\\workspaces\\orca']], diff --git a/src/main/external-editor-launch.ts b/src/main/external-editor-launch.ts index e9504e80dbd..6fa0e867508 100644 --- a/src/main/external-editor-launch.ts +++ b/src/main/external-editor-launch.ts @@ -23,8 +23,8 @@ export type ExternalEditorExecutableLaunchSpec = { hideWindowsConsole: boolean /** * Set only for Windows batch shims that would otherwise leave a Command - * Prompt behind; routes the spawn through `start "" /B`. Left unset - * elsewhere because `start` re-parses quoted argv. + * Prompt behind; routes the spawn through `start` with an empty title and + * `/B`. Left unset elsewhere because `start` re-parses quoted argv. */ detachedGui?: boolean spawnCmd: string @@ -118,16 +118,27 @@ function isCompoundShellCommand(command: string): boolean { return /\s/.test(command) } +function preferJetBrainsGuiExecutable( + editorCommand: string, + platform: NodeJS.Platform, + fileExists: (path: string) => boolean +): string { + if (platform !== 'win32') { + return editorCommand + } + return resolveColocatedJetBrainsGuiExecutable(editorCommand, fileExists) ?? editorCommand +} + function resolveSimpleEditorCommand( command: string, platform: NodeJS.Platform, fileExists: (path: string) => boolean ): string { - const resolved = resolveCliCommand(command, { platform }) - if (platform !== 'win32') { - return resolved - } - return resolveColocatedJetBrainsGuiExecutable(resolved, fileExists) ?? resolved + return preferJetBrainsGuiExecutable( + resolveCliCommand(command, { platform }), + platform, + fileExists + ) } function buildExecutableLaunchSpec( @@ -184,7 +195,13 @@ export function resolveExternalEditorLaunchSpec( const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND if (isDirectExecutablePath(trimmed, platform, fileExists)) { - return buildExecutableLaunchSpec(stripMatchingQuotes(trimmed), pathValue, platform) + // Why: settings often store a full path to idea.cmd / idea.exe; upgrade + // those the same way as PATH-resolved names when *64.exe is colocated. + return buildExecutableLaunchSpec( + preferJetBrainsGuiExecutable(stripMatchingQuotes(trimmed), platform, fileExists), + pathValue, + platform + ) } if (isCompoundShellCommand(trimmed)) { diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 922a25438c6..9671eb7a09c 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -342,15 +342,20 @@ describe('registerShellHandlers', () => { }) it('uses a provided launcher command', async () => { + resolveCliCommandMock.mockReturnValueOnce('custom-editor') const workspacePath = resolve('workspace') const handler = getHandler('shell:openInExternalEditor') - await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({ - ok: true + await expect(handler({}, { path: workspacePath, command: 'custom-editor' })).resolves.toEqual( + { + ok: true + } + ) + expect(resolveCliCommandMock).toHaveBeenCalledWith('custom-editor', { + platform: process.platform }) - expect(resolveCliCommandMock).toHaveBeenCalledWith('cursor', { platform: process.platform }) expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( - 'editor-cli', + 'custom-editor', [normalize(workspacePath)], { detachedGui: false diff --git a/src/main/jetbrains-windows-gui-launchers.ts b/src/main/jetbrains-windows-gui-launchers.ts index 6a871d6fe5d..900b1339745 100644 --- a/src/main/jetbrains-windows-gui-launchers.ts +++ b/src/main/jetbrains-windows-gui-launchers.ts @@ -1,12 +1,16 @@ import { win32 } from 'node:path' -import { getLauncherBaseName, isWindowsBatchLauncher } from './editor-launcher-name' +import { + getLauncherBaseName, + isWindowsBatchLauncher, + stripMatchingQuotes +} from './editor-launcher-name' // Why: JetBrains installers ship `*64.exe` GUI-subsystem binaries. The -// Toolbox/installer `.cmd`/`.bat` shims chain through console helpers -// (`java.exe`) that allocate a visible Command Prompt even when the parent -// spawn uses windowsHide — the STA-3040 "prompt window" loop on Windows. -// Maps each console launcher name to its GUI sibling. -const JETBRAINS_WINDOWS_GUI_LAUNCHERS: Readonly> = { +// Toolbox/installer `.cmd`/`.bat` shims (and short-name console `idea.exe` +// stubs) chain through helpers that allocate a visible Command Prompt even +// when the parent spawn uses windowsHide — the STA-3040 prompt-window loop. +// Maps each short console launcher name to its GUI sibling. +const JETBRAINS_WINDOWS_CONSOLE_TO_GUI: Readonly> = { idea: 'idea64', webstorm: 'webstorm64', pycharm: 'pycharm64', @@ -20,42 +24,64 @@ const JETBRAINS_WINDOWS_GUI_LAUNCHERS: Readonly> = { studio: 'studio64' } -const JETBRAINS_WINDOWS_GUI_EXECUTABLES = new Set(Object.values(JETBRAINS_WINDOWS_GUI_LAUNCHERS)) +const JETBRAINS_WINDOWS_GUI_NAMES = new Set(Object.values(JETBRAINS_WINDOWS_CONSOLE_TO_GUI)) +/** GUI binary basename for a console or GUI launcher name, or null if unknown. */ function getGuiExecutableName(launcherBaseName: string): string | null { - if (JETBRAINS_WINDOWS_GUI_EXECUTABLES.has(launcherBaseName)) { + if (JETBRAINS_WINDOWS_GUI_NAMES.has(launcherBaseName)) { return launcherBaseName } - return JETBRAINS_WINDOWS_GUI_LAUNCHERS[launcherBaseName] ?? null + return JETBRAINS_WINDOWS_CONSOLE_TO_GUI[launcherBaseName] ?? null } /** - * The GUI `*64.exe` beside an already-resolved console shim, or null. + * The GUI `*64.exe` beside an already-resolved console entry point, or null. * * Why sibling-only: a bare PATH lookup for `idea64` can land in a different, * stale install, and it costs a second full PATH walk on Electron's main * thread. Toolbox script directories ship no exe, so those keep the shim and * rely on the detached-GUI spawn instead. + * + * Upgrades: + * - `idea.cmd` / `idea.bat` / `idea64.cmd` → colocated `idea64.exe` when present + * - short-name console stubs `idea.exe` → colocated `idea64.exe` when present + * - leaves `idea64.exe` and non-JetBrains launchers unchanged */ export function resolveColocatedJetBrainsGuiExecutable( resolvedCommand: string, fileExists: (path: string) => boolean ): string | null { - if (!isWindowsBatchLauncher(resolvedCommand)) { + const unquoted = stripMatchingQuotes(resolvedCommand) + const baseName = getLauncherBaseName(unquoted) + const isBatch = /\.(?:cmd|bat)$/i.test(unquoted) + const isExe = /\.exe$/i.test(unquoted) + if (!isBatch && !isExe) { return null } - const guiName = getGuiExecutableName(getLauncherBaseName(resolvedCommand)) + + // .exe: only short console names (idea.exe), never the GUI binary itself. + if (isExe) { + const guiName = JETBRAINS_WINDOWS_CONSOLE_TO_GUI[baseName] + if (!guiName) { + return null + } + const candidate = win32.join(win32.dirname(unquoted), `${guiName}.exe`) + return fileExists(candidate) ? candidate : null + } + + // .cmd/.bat: idea.cmd and idea64.cmd both prefer idea64.exe when colocated. + const guiName = getGuiExecutableName(baseName) if (!guiName) { return null } - const candidate = win32.join(win32.dirname(resolvedCommand), `${guiName}.exe`) + const candidate = win32.join(win32.dirname(unquoted), `${guiName}.exe`) return fileExists(candidate) ? candidate : null } -// Why: only JetBrains shims chain through console helpers that outlive the -// batch and re-open a prompt. Other GUI shims (code.cmd, cursor.cmd) exit on -// their own, and running them under `start` would re-parse their quoted -// WSL/SSH remote argv. +// Why: only JetBrains batch shims chain through console helpers that outlive +// the batch and re-open a prompt. Other GUI shims (code.cmd, cursor.cmd) exit +// on their own, and running them under `start` would re-parse their quoted +// WSL/SSH remote argv. detachedGui only rewrites batch targets. export function isJetBrainsConsoleShim(command: string, platform: NodeJS.Platform): boolean { return ( platform === 'win32' && diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index b1e34075a05..381fef6643e 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -92,11 +92,12 @@ describe('getSpawnArgsForWindows', () => { expect(spawnCmd).toBe(getCmdExePath()) // Why: `start` runs a batch target under a nested `cmd /K` that never // exits; the inner `cmd /d /c` is what keeps the hidden shell from leaking. + // Title is empty string so libuv emits `""` — not the two-char `'""'`. expect(spawnArgs).toEqual([ '/d', '/c', 'start', - '""', + '', '/B', getCmdExePath(), '/d', @@ -104,7 +105,9 @@ describe('getSpawnArgsForWindows', () => { 'C:\\Tools\\idea.cmd', 'C:\\workspaces\\orca' ]) + expect(spawnArgs[3]).toBe('') expect(spawnArgs).not.toContain('/K') + expect(spawnArgs).not.toContain('""') expect(spawnArgs[spawnArgs.indexOf('/B') + 1]).not.toMatch(/\.(?:cmd|bat)$/i) }) }) diff --git a/src/shared/windows-batch-spawn.ts b/src/shared/windows-batch-spawn.ts index c421c744658..d8312d2ec27 100644 --- a/src/shared/windows-batch-spawn.ts +++ b/src/shared/windows-batch-spawn.ts @@ -69,12 +69,18 @@ export function getSpawnArgsForWindows( // Why: `start` launches a batch target through a nested `cmd /K`, which // stays resident after the script ends — `/B` only suppresses a *new* // console, so the shim leaks a hidden cmd.exe. Handing `start` an inner - // `cmd /d /c` makes that interpreter exit with the script. The empty - // title (`""`) keeps `start` from eating a quoted path as a window title. + // `cmd /d /c` makes that interpreter exit with the script. + // + // Window title must be an *empty argv entry* (`''`). libuv's Windows + // quoter turns empty into `""` on the CreateProcess command line — the + // empty title `start` requires so a later quoted path is not eaten as + // the title. The two-character string `'""'` is wrong: libuv re-escapes + // it to `"\"\""`. (Default ComSpec has no spaces, so the bad form often + // still "works"; quoted Program Files paths are where it breaks.) const cmdExePath = getCmdExePath() return { spawnCmd: cmdExePath, - spawnArgs: ['/d', '/c', 'start', '""', '/B', cmdExePath, '/d', '/c', command, ...args] + spawnArgs: ['/d', '/c', 'start', '', '/B', cmdExePath, '/d', '/c', command, ...args] } } return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] }