Files
orca/src/main/external-editor-launch.ts
T
Jinjing 786d7048a1 fix(win32): suppress Command Prompt window on IDE launches (#11907)
* fix(win32): suppress Command Prompt window on IDE launches

- Prefer JetBrains GUI executables (`*64.exe`) over `.cmd` shims to avoid
  console allocation (STA-3040).
- Use `start "" /B` when launching GUI apps via batch scripts; shims chain
  through console helpers that allocate a visible prompt even with
  `windowsHide`. `start /B` returns immediately, preventing the lingering window.

* fix(win32): suppress Command Prompt window on IDE launches

Prevent lingering Command Prompt windows when launching JetBrains IDEs
on Windows. Use `start "" /B cmd /d /c` so the nested shell exits with
the batch script, but only for JetBrains shims—VS Code and Cursor keep
the waiting form because `start` re-parses arguments and breaks remote
paths with spaces. Prefer colocated `*64.exe` executables beside the
resolved `.cmd` shim over PATH lookups to avoid stale installations.

* 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.
2026-08-01 13:38:45 -07:00

297 lines
9.2 KiB
TypeScript

import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { 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 {
getLauncherBaseName,
hasMatchingOuterQuotes,
stripMatchingQuotes
} from './editor-launcher-name'
import {
isJetBrainsConsoleShim,
resolveColocatedJetBrainsGuiExecutable
} from './jetbrains-windows-gui-launchers'
import { getCmdExePath, getSpawnArgsForWindows } from './win32-utils'
export const EXTERNAL_EDITOR_CLI_COMMAND = 'code'
const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim'])
export type ExternalEditorExecutableLaunchSpec = {
kind: 'executable'
hideWindowsConsole: boolean
/**
* Set only for Windows batch shims that would otherwise leave a Command
* 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
spawnArgs: string[]
}
export type ExternalEditorShellLaunchSpec = {
kind: 'shell'
hideWindowsConsole: boolean
spawnCmd: string
spawnArgs: string[]
}
export type ExternalEditorLaunchSpec =
| ExternalEditorExecutableLaunchSpec
| ExternalEditorShellLaunchSpec
function escapePosixPathForShell(pathValue: string): string {
if (/^[a-zA-Z0-9_./@:-]+$/.test(pathValue)) {
return pathValue
}
return `'${pathValue.replace(/'/g, "'\\''")}'`
}
function escapeWindowsPathForShell(pathValue: string): string {
return /^[a-zA-Z0-9_./@:\\-]+$/.test(pathValue) ? pathValue : `"${pathValue}"`
}
function escapePathForShell(pathValue: string, platform: NodeJS.Platform): string {
return platform === 'win32'
? escapeWindowsPathForShell(pathValue)
: escapePosixPathForShell(pathValue)
}
function isWindowsExecutablePath(command: string): boolean {
return win32.isAbsolute(command) && /\.(?:cmd|exe|bat|com)$/i.test(command)
}
function isDirectExecutablePath(
command: string,
platform: NodeJS.Platform,
fileExists: (path: string) => boolean
): boolean {
const unquoted = stripMatchingQuotes(command)
if (!/[\\/]/.test(unquoted)) {
return false
}
const isAbsolutePath =
platform === 'win32' ? win32.isAbsolute(unquoted) : posix.isAbsolute(unquoted)
if (!isAbsolutePath) {
return false
}
if (!/\s/.test(unquoted) || hasMatchingOuterQuotes(command)) {
return true
}
// Why: unquoted POSIX paths can contain spaces, but so can shell commands
// with arguments. Only an existing path is safe to treat as one executable.
return platform === 'win32' ? isWindowsExecutablePath(unquoted) : fileExists(unquoted)
}
function shouldShowWindowsConsole(
command: string,
platform: NodeJS.Platform,
options: { shellCommand?: boolean } = {}
): boolean {
return platform === 'win32' && WINDOWS_CONSOLE_EDITORS.has(getLauncherBaseName(command, options))
}
function buildExecutableArgs(
editorCommand: string,
pathValue: string,
platform: NodeJS.Platform
): string[] {
const launcherBaseName = getLauncherBaseName(editorCommand)
if (launcherBaseName === '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' && isVsCodeLauncherExecutable(editorCommand)) {
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]
}
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 {
return preferJetBrainsGuiExecutable(
resolveCliCommand(command, { platform }),
platform,
fileExists
)
}
function buildExecutableLaunchSpec(
editorCommand: string,
pathValue: string,
platform: NodeJS.Platform
): ExternalEditorExecutableLaunchSpec {
const spec: ExternalEditorExecutableLaunchSpec = {
kind: 'executable',
hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform),
spawnCmd: editorCommand,
spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform)
}
if (spec.hideWindowsConsole && isJetBrainsConsoleShim(editorCommand, platform)) {
spec.detachedGui = true
}
return spec
}
function buildShellLaunchSpec(
command: string,
pathValue: string,
platform: NodeJS.Platform
): ExternalEditorLaunchSpec {
const shellCommand = `${command} ${escapePathForShell(pathValue, platform)}`
if (platform === 'win32') {
// Why: no `start` wrap here. `start` re-parses the line — it swallows the
// first quoted operand as a window title and mangles remote paths with
// spaces — and on a batch target it leaves a resident nested `cmd /K`.
// User-authored compound commands run verbatim; users who want a detached
// launch already write `start` themselves.
return {
kind: 'shell',
hideWindowsConsole: !shouldShowWindowsConsole(command, platform, { shellCommand: true }),
spawnCmd: getCmdExePath(),
spawnArgs: ['/d', '/s', '/c', shellCommand]
}
}
return {
kind: 'shell',
hideWindowsConsole: true,
spawnCmd: '/bin/sh',
spawnArgs: ['-c', shellCommand]
}
}
export function resolveExternalEditorLaunchSpec(
command: string | undefined,
pathValue: string,
options: { platform?: NodeJS.Platform; fileExists?: (path: string) => boolean } = {}
): ExternalEditorLaunchSpec {
const platform = options.platform ?? process.platform
const fileExists = options.fileExists ?? existsSync
const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND
if (isDirectExecutablePath(trimmed, platform, fileExists)) {
// 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)) {
return buildShellLaunchSpec(trimmed, pathValue, platform)
}
return buildExecutableLaunchSpec(
resolveSimpleEditorCommand(trimmed, platform, fileExists),
pathValue,
platform
)
}
export function resolveVsCodeRemoteSshLaunchSpec(
command: string | undefined,
pathValue: string,
authority: string,
options: { platform?: NodeJS.Platform; fileExists?: (path: string) => boolean } = {}
): ExternalEditorLaunchSpec | null {
const platform = options.platform ?? process.platform
const fileExists = options.fileExists ?? existsSync
const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND
let editorCommand: string
if (isDirectExecutablePath(trimmed, platform, fileExists)) {
editorCommand = stripMatchingQuotes(trimmed)
} else {
if (isCompoundShellCommand(trimmed)) {
return null
}
editorCommand = resolveCliCommand(trimmed, { platform })
}
if (!isVsCodeLauncherExecutable(editorCommand)) {
return null
}
return {
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: editorCommand,
spawnArgs: ['--remote', `ssh-remote+${authority}`, pathValue]
}
}
function resolveExternalEditorSpawn(launchSpec: ExternalEditorLaunchSpec): {
spawnCmd: string
spawnArgs: string[]
windowsHide: boolean
} {
// Why: only shims flagged during resolution (JetBrains) take the start /B
// detach; every other launcher keeps the waiting form so its argv survives.
if (launchSpec.kind === 'executable') {
const spawned = getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs, {
detachedGui: launchSpec.detachedGui === true
})
return { ...spawned, windowsHide: launchSpec.hideWindowsConsole }
}
return {
spawnCmd: launchSpec.spawnCmd,
spawnArgs: launchSpec.spawnArgs,
windowsHide: launchSpec.hideWindowsConsole
}
}
export async function launchExternalEditor(launchSpec: ExternalEditorLaunchSpec): Promise<void> {
const { spawnCmd, spawnArgs, windowsHide } = resolveExternalEditorSpawn(launchSpec)
await new Promise<void>((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)
})
}