fix(win32): extend IDE launcher console suppression to direct paths

Support IDE paths stored directly in settings (e.g., idea.exe,
webstorm.cmd). Detect console idea.exe stubs alongside batch shims
for upgrade to GUI *64.exe. Fix start command title escaping: use
empty string instead of '""' to prevent libuv re-quoting.
This commit is contained in:
Jinjing
2026-08-01 13:07:19 -07:00
parent 74a8118a4d
commit bc17d4e57f
6 changed files with 161 additions and 34 deletions
+71 -1
View File
@@ -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']],
+25 -8
View File
@@ -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)) {
+9 -4
View File
@@ -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
+43 -17
View File
@@ -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<Record<string, string>> = {
// 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<Record<string, string>> = {
idea: 'idea64',
webstorm: 'webstorm64',
pycharm: 'pycharm64',
@@ -20,42 +24,64 @@ const JETBRAINS_WINDOWS_GUI_LAUNCHERS: Readonly<Record<string, string>> = {
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' &&
+4 -1
View File
@@ -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)
})
})
+9 -3
View File
@@ -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] }