mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 16:02:38 +00:00
* 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.
297 lines
9.2 KiB
TypeScript
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)
|
|
})
|
|
}
|