diff --git a/src/main/external-editor-launch.test.ts b/src/main/external-editor-launch.test.ts index 5422267f6e1..9f1e202c267 100644 --- a/src/main/external-editor-launch.test.ts +++ b/src/main/external-editor-launch.test.ts @@ -32,6 +32,50 @@ describe('resolveExternalEditorLaunchSpec', () => { }) }) + it('prefers JetBrains *64.exe GUI launchers over idea.cmd on Windows', () => { + resolveCliCommandMock.mockImplementation((command: string) => { + if (command === 'idea64') { + return 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe' + } + if (command === 'idea') { + return 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd' + } + return command + }) + + expect( + resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', { platform: 'win32' }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe', + spawnArgs: ['C:\\workspaces\\orca'] + }) + expect(resolveCliCommandMock).toHaveBeenCalledWith('idea64', { platform: 'win32' }) + expect(resolveCliCommandMock).not.toHaveBeenCalledWith('idea', expect.anything()) + }) + + it('falls back to the idea.cmd shim when idea64 is not installed', () => { + resolveCliCommandMock.mockImplementation((command: string) => { + if (command === 'idea64') { + return 'idea64' + } + if (command === 'idea') { + return 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd' + } + return command + }) + + expect( + resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', { platform: 'win32' }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd', + spawnArgs: ['C:\\workspaces\\orca'] + }) + }) + it('appends escaped paths to compound macOS open commands', () => { expect( resolveExternalEditorLaunchSpec('open -a "Typora"', "/tmp/note's.md", { @@ -85,6 +129,19 @@ describe('resolveExternalEditorLaunchSpec', () => { }) }) + it('wraps non-start GUI compound Windows commands with start /B', () => { + expect( + resolveExternalEditorLaunchSpec('code --reuse-window', 'C:\\workspaces\\orca', { + platform: 'win32' + }) + ).toEqual({ + kind: 'shell', + hideWindowsConsole: true, + spawnCmd: getCmdExePath(), + spawnArgs: ['/d', '/s', '/c', 'start "" /B code --reuse-window C:\\workspaces\\orca'] + }) + }) + it('quotes Windows paths with spaces in compound commands', () => { expect( resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\my notes.md', { platform: 'win32' }) @@ -245,7 +302,7 @@ describe('resolveExternalEditorLaunchSpec', () => { ).toEqual([pathValue]) }) - it('does not rewrite compound VS Code commands', () => { + it('does not rewrite compound VS Code commands into WSL remote args', () => { const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' expect( @@ -254,7 +311,7 @@ describe('resolveExternalEditorLaunchSpec', () => { kind: 'shell', hideWindowsConsole: true, spawnCmd: getCmdExePath(), - spawnArgs: ['/d', '/s', '/c', `code --reuse-window ${pathValue}`] + spawnArgs: ['/d', '/s', '/c', `start "" /B code --reuse-window ${pathValue}`] }) }) }) diff --git a/src/main/external-editor-launch.ts b/src/main/external-editor-launch.ts index a0283b18f46..6d6d12d2946 100644 --- a/src/main/external-editor-launch.ts +++ b/src/main/external-editor-launch.ts @@ -1,13 +1,32 @@ +import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { basename, posix, win32 } from 'node:path' import { parseWslUncPath } from '../shared/wsl-paths' import { isVsCodeLauncherExecutable } from '../shared/vscode-remote-ssh-launcher' import { resolveCliCommand } from './codex-cli/command' -import { getCmdExePath } from './win32-utils' +import { getCmdExePath, getSpawnArgsForWindows } from './win32-utils' export const EXTERNAL_EDITOR_CLI_COMMAND = 'code' const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim']) +// Why: JetBrains installers ship `*64.exe` GUI-subsystem binaries. The +// Toolbox/installer `.cmd`/`.bat` shims often chain through console helpers +// (`java.exe`), which allocate a visible Command Prompt even when the parent +// spawn uses windowsHide — the STA-3040 "prompt window" loop on Windows. +const JETBRAINS_WINDOWS_GUI_LAUNCHERS: Readonly> = { + idea: 'idea64', + webstorm: 'webstorm64', + pycharm: 'pycharm64', + phpstorm: 'phpstorm64', + goland: 'goland64', + rider: 'rider64', + clion: 'clion64', + rubymine: 'rubymine64', + datagrip: 'datagrip64', + rustrover: 'rustrover64', + studio: 'studio64' +} + export type ExternalEditorLaunchSpec = | { kind: 'executable' @@ -133,6 +152,20 @@ function isCompoundShellCommand(command: string): boolean { return /\s/.test(command) } +function resolveSimpleEditorCommand(command: string, platform: NodeJS.Platform): string { + if (platform === 'win32') { + const jetbrainsGui = JETBRAINS_WINDOWS_GUI_LAUNCHERS[command.toLowerCase()] + if (jetbrainsGui) { + const resolvedGui = resolveCliCommand(jetbrainsGui, { platform }) + // Why: resolveCliCommand returns the input name when nothing is found. + if (resolvedGui !== jetbrainsGui) { + return resolvedGui + } + } + } + return resolveCliCommand(command, { platform }) +} + function buildShellLaunchSpec( command: string, pathValue: string, @@ -140,11 +173,19 @@ function buildShellLaunchSpec( ): ExternalEditorLaunchSpec { const shellCommand = `${command} ${escapePathForShell(pathValue, platform)}` if (platform === 'win32') { + const hideWindowsConsole = !shouldShowWindowsConsole(command, platform, { + shellCommand: true + }) + // Why: GUI compound commands (not terminal editors) use start /B so a + // Command Prompt does not linger; skip when the user already used `start`. + const alreadyUsesStart = /^\s*start(\.exe)?\b/i.test(command) + const wrappedCommand = + hideWindowsConsole && !alreadyUsesStart ? `start "" /B ${shellCommand}` : shellCommand return { kind: 'shell', - hideWindowsConsole: !shouldShowWindowsConsole(command, platform, { shellCommand: true }), + hideWindowsConsole, spawnCmd: getCmdExePath(), - spawnArgs: ['/d', '/s', '/c', shellCommand] + spawnArgs: ['/d', '/s', '/c', wrappedCommand] } } return { @@ -178,7 +219,7 @@ export function resolveExternalEditorLaunchSpec( return buildShellLaunchSpec(trimmed, pathValue, platform) } - const editorCommand = resolveCliCommand(trimmed, { platform }) + const editorCommand = resolveSimpleEditorCommand(trimmed, platform) return { kind: 'executable', hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), @@ -217,3 +258,52 @@ export function resolveVsCodeRemoteSshLaunchSpec( spawnArgs: ['--remote', `ssh-remote+${authority}`, pathValue] } } + +function resolveExternalEditorSpawn(launchSpec: ExternalEditorLaunchSpec): { + spawnCmd: string + spawnArgs: string[] + windowsHide: boolean +} { + // Why: GUI Open In .cmd shims (idea.cmd) use start /B so no Command Prompt + // lingers; terminal editors keep the waiting form (nvim needs a console). + if (launchSpec.kind === 'executable') { + const spawned = getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs, { + detachedGui: launchSpec.hideWindowsConsole + }) + return { ...spawned, windowsHide: launchSpec.hideWindowsConsole } + } + return { + spawnCmd: launchSpec.spawnCmd, + spawnArgs: launchSpec.spawnArgs, + windowsHide: launchSpec.hideWindowsConsole + } +} + +export async function launchExternalEditor(launchSpec: ExternalEditorLaunchSpec): Promise { + const { spawnCmd, spawnArgs, windowsHide } = resolveExternalEditorSpawn(launchSpec) + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(spawnCmd, spawnArgs, { detached: true, stdio: 'ignore', windowsHide }) + let settled = false + function cleanup(): void { + child.off('error', onError) + child.off('spawn', onSpawn) + } + function settle(callback: () => void): void { + if (settled) { + return + } + settled = true + cleanup() + callback() + } + function onError(error: Error): void { + settle(() => rejectPromise(error)) + } + function onSpawn(): void { + child.unref() + settle(resolvePromise) + } + child.once('error', onError) + child.once('spawn', onSpawn) + }) +} diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 7193da4beaa..a3ffc7824a1 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -297,9 +297,13 @@ describe('registerShellHandlers', () => { expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, { platform: process.platform }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'editor-cli', + [normalize(workspacePath)], + { + detachedGui: true + } + ) expect(spawnMock).toHaveBeenCalledWith('editor-cli', [normalize(workspacePath)], { detached: true, stdio: 'ignore', @@ -320,9 +324,13 @@ describe('registerShellHandlers', () => { expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, { platform: process.platform }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'editor-cli', + [normalize(workspacePath)], + { + detachedGui: true + } + ) expect(spawnMock).toHaveBeenCalledWith('editor-cli', [normalize(workspacePath)], { detached: true, stdio: 'ignore', @@ -341,9 +349,13 @@ describe('registerShellHandlers', () => { ok: true }) expect(resolveCliCommandMock).toHaveBeenCalledWith('cursor', { platform: process.platform }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'editor-cli', + [normalize(workspacePath)], + { + detachedGui: true + } + ) }) it.runIf(process.platform === 'win32')( @@ -357,11 +369,13 @@ describe('registerShellHandlers', () => { await expect(handler({}, { path: workspacePath, command: 'code' })).resolves.toEqual({ ok: true }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(codeShim, [ - '--remote', - 'wsl+Ubuntu Preview', - '/home/Ada Lovelace/project' - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + codeShim, + ['--remote', 'wsl+Ubuntu Preview', '/home/Ada Lovelace/project'], + { + detachedGui: true + } + ) } ) @@ -377,9 +391,13 @@ describe('registerShellHandlers', () => { ok: true }) expect(resolveCliCommandMock).not.toHaveBeenCalled() - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(nvimPath, [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + nvimPath, + [normalize(workspacePath)], + { + detachedGui: false + } + ) expect(spawnMock).toHaveBeenCalledWith(nvimPath, [normalize(workspacePath)], { detached: true, stdio: 'ignore', @@ -400,18 +418,24 @@ describe('registerShellHandlers', () => { await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({ ok: true }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/cursor', [ - '--new-window', - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + '/usr/local/bin/cursor', + ['--new-window', normalize(workspacePath)], + { + detachedGui: true + } + ) resolveCliCommandMock.mockReturnValueOnce('C:\\Cursor\\cursor.cmd') await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({ ok: true }) - expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith('C:\\Cursor\\cursor.cmd', [ - '--new-window', - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith( + 'C:\\Cursor\\cursor.cmd', + ['--new-window', normalize(workspacePath)], + { + detachedGui: true + } + ) }) it('falls back to VS Code when command is blank', async () => { @@ -438,9 +462,13 @@ describe('registerShellHandlers', () => { expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, { platform: process.platform }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'editor-cli', + [normalize(workspacePath)], + { + detachedGui: true + } + ) expect(spawnMock).toHaveBeenCalledWith('platform-runner', ['platform-arg'], { detached: true, stdio: 'ignore', @@ -510,11 +538,13 @@ describe('registerShellHandlers', () => { handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' }) ).resolves.toEqual({ ok: true }) expect(statMock).not.toHaveBeenCalled() - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/code', [ - '--remote', - 'ssh-remote+builder', - remotePath - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + '/usr/local/bin/code', + ['--remote', 'ssh-remote+builder', remotePath], + { + detachedGui: true + } + ) }) it('preserves Windows-form SSH paths and uses the manual port-22 authority', async () => { @@ -535,11 +565,13 @@ describe('registerShellHandlers', () => { handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' }) ).resolves.toEqual({ ok: true }) expect(statMock).not.toHaveBeenCalled() - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('C:\\Tools\\code.cmd', [ - '--remote', - 'ssh-remote+Ada@win-builder.example.com', - remotePath - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'C:\\Tools\\code.cmd', + ['--remote', 'ssh-remote+Ada@win-builder.example.com', remotePath], + { + detachedGui: true + } + ) }) it('opens a manual port-22 target with a host-only authority when username is blank', async () => { @@ -558,11 +590,13 @@ describe('registerShellHandlers', () => { await expect( handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' }) ).resolves.toEqual({ ok: true }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/code', [ - '--remote', - 'ssh-remote+builder.example.com', - '/srv/project' - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + '/usr/local/bin/code', + ['--remote', 'ssh-remote+builder.example.com', '/srv/project'], + { + detachedGui: true + } + ) }) it('rejects relative SSH paths before resolving or spawning a launcher', async () => { diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 760b32eed2a..80f02552f18 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -1,5 +1,4 @@ import { ipcMain, shell, dialog } from 'electron' -import { spawn } from 'node:child_process' import { constants, copyFile, readFile, stat } from 'node:fs/promises' import { basename, extname, isAbsolute, normalize, posix, win32 } from 'node:path' import { fileURLToPath } from 'node:url' @@ -10,12 +9,11 @@ import type { } from '../../shared/shell-open-types' import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon' import type { Store } from '../persistence' -import { getSpawnArgsForWindows } from '../win32-utils' import { EXTERNAL_EDITOR_CLI_COMMAND, + launchExternalEditor, resolveExternalEditorLaunchSpec, - resolveVsCodeRemoteSshLaunchSpec, - type ExternalEditorLaunchSpec + resolveVsCodeRemoteSshLaunchSpec } from '../external-editor-launch' import { resolveVsCodeSshAuthority } from '../ssh/vscode-ssh-authority' @@ -72,49 +70,6 @@ async function openInFileManager( } } -async function launchExternalEditor(launchSpec: ExternalEditorLaunchSpec): Promise { - const { spawnCmd, spawnArgs } = - launchSpec.kind === 'executable' - ? getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs) - : { spawnCmd: launchSpec.spawnCmd, spawnArgs: launchSpec.spawnArgs } - - await new Promise((resolvePromise, rejectPromise) => { - const child = spawn(spawnCmd, spawnArgs, { - detached: true, - stdio: 'ignore', - // Why: terminal editors such as nvim need a visible console on Windows; - // GUI editor launches stay hidden to avoid command-shim flashes. - windowsHide: launchSpec.hideWindowsConsole - }) - let settled = false - - function cleanup(): void { - child.off('error', onError) - child.off('spawn', onSpawn) - } - - function settle(callback: () => void): void { - if (settled) { - return - } - settled = true - cleanup() - callback() - } - - function onError(error: Error): void { - settle(() => rejectPromise(error)) - } - - function onSpawn(): void { - child.unref() - settle(resolvePromise) - } - child.once('error', onError) - child.once('spawn', onSpawn) - }) -} - async function openInExternalEditor( store: Store, request: ShellOpenExternalEditorRequest diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index 4159d7d89b4..f523e9dc9b0 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -82,6 +82,26 @@ describe('getSpawnArgsForWindows', () => { } }) + it('routes GUI Open In .cmd launches through start /B so no prompt window lingers', () => { + withPlatform('win32', () => { + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows( + 'C:\\Tools\\idea.cmd', + ['C:\\workspaces\\orca'], + { detachedGui: true } + ) + expect(spawnCmd).toBe(getCmdExePath()) + expect(spawnArgs).toEqual([ + '/d', + '/c', + 'start', + '""', + '/B', + 'C:\\Tools\\idea.cmd', + 'C:\\workspaces\\orca' + ]) + }) + }) + it('preserves VS Code WSL remote arguments with spaces through .cmd launchers', () => { withPlatform('win32', () => { const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\code.cmd', [ diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index c1031c7c55e..151dc9306f0 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -8,7 +8,8 @@ export { isWindowsBatchScript, WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL, - UnsafeWindowsBatchArgumentsError + UnsafeWindowsBatchArgumentsError, + type GetSpawnArgsForWindowsOptions } from '../shared/windows-batch-spawn' function execFileWithoutBlocking( diff --git a/src/shared/windows-batch-spawn.ts b/src/shared/windows-batch-spawn.ts index 4afddccae73..e791025746c 100644 --- a/src/shared/windows-batch-spawn.ts +++ b/src/shared/windows-batch-spawn.ts @@ -39,9 +39,19 @@ function hasUnsafeWindowsBatchSyntax(value: string): boolean { return UNSAFE_WINDOWS_BATCH_SYNTAX.test(value) } +export type GetSpawnArgsForWindowsOptions = { + /** + * GUI launchers (Open In apps) should not leave a lingering Command Prompt. + * `start "" /B` returns immediately and keeps console-subsystem children of + * `.cmd`/`.bat` shims from allocating a fresh visible prompt window. + */ + detachedGui?: boolean +} + export function getSpawnArgsForWindows( command: string, - args: string[] + args: string[], + options: GetSpawnArgsForWindowsOptions = {} ): { spawnCmd: string; spawnArgs: string[] } { if (isWindowsBatchScript(command)) { for (const value of [command, ...args]) { @@ -51,6 +61,14 @@ export function getSpawnArgsForWindows( } // Why: separate argv entries let Node quote spaces without breaking cmd. + if (options.detachedGui) { + // Why: empty title (`""`) is required so `start` does not treat the first + // quoted path as a window title; `/B` avoids a new console window. + return { + spawnCmd: getCmdExePath(), + spawnArgs: ['/d', '/c', 'start', '""', '/B', command, ...args] + } + } return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] } } return { spawnCmd: command, spawnArgs: args }