Fix Windows NeoVim open-in launcher (#6026)
This commit is contained in:
parent
36bdd06aa8
commit
d9b2d89406
|
|
@ -9,6 +9,7 @@ describe('resolveExternalEditorLaunchSpec', () => {
|
|||
})
|
||||
expect(spec).toEqual({
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: true,
|
||||
spawnCmd: expect.any(String),
|
||||
spawnArgs: ['--new-window', '/tmp/workspace']
|
||||
})
|
||||
|
|
@ -21,6 +22,7 @@ describe('resolveExternalEditorLaunchSpec', () => {
|
|||
})
|
||||
).toEqual({
|
||||
kind: 'shell',
|
||||
hideWindowsConsole: true,
|
||||
spawnCmd: '/bin/sh',
|
||||
spawnArgs: ['-c', "open -a \"Typora\" '/tmp/note'\\''s.md'"]
|
||||
})
|
||||
|
|
@ -31,6 +33,7 @@ describe('resolveExternalEditorLaunchSpec', () => {
|
|||
resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\note.md', { platform: 'win32' })
|
||||
).toEqual({
|
||||
kind: 'shell',
|
||||
hideWindowsConsole: true,
|
||||
spawnCmd: getCmdExePath(),
|
||||
spawnArgs: ['/d', '/s', '/c', 'start "" notepad C:\\note.md']
|
||||
})
|
||||
|
|
@ -41,8 +44,52 @@ describe('resolveExternalEditorLaunchSpec', () => {
|
|||
resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\my notes.md', { platform: 'win32' })
|
||||
).toEqual({
|
||||
kind: 'shell',
|
||||
hideWindowsConsole: true,
|
||||
spawnCmd: getCmdExePath(),
|
||||
spawnArgs: ['/d', '/s', '/c', 'start "" notepad "C:\\my notes.md"']
|
||||
})
|
||||
})
|
||||
|
||||
it('treats unquoted Windows executable paths with spaces as executable launchers', () => {
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec(
|
||||
'C:\\Program Files\\Neovim\\bin\\nvim.exe',
|
||||
'C:\\workspaces\\orca',
|
||||
{ platform: 'win32' }
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: false,
|
||||
spawnCmd: 'C:\\Program Files\\Neovim\\bin\\nvim.exe',
|
||||
spawnArgs: ['C:\\workspaces\\orca']
|
||||
})
|
||||
})
|
||||
|
||||
it('treats quoted Windows executable paths with spaces as executable launchers', () => {
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec(
|
||||
'"C:\\Program Files\\Neovim\\bin\\nvim.exe"',
|
||||
'C:\\workspaces\\orca',
|
||||
{ platform: 'win32' }
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: false,
|
||||
spawnCmd: 'C:\\Program Files\\Neovim\\bin\\nvim.exe',
|
||||
spawnArgs: ['C:\\workspaces\\orca']
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the Windows console for NeoVim shell commands with arguments', () => {
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec('nvim --clean', 'C:\\workspaces\\orca', {
|
||||
platform: 'win32'
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'shell',
|
||||
hideWindowsConsole: false,
|
||||
spawnCmd: getCmdExePath(),
|
||||
spawnArgs: ['/d', '/s', '/c', 'nvim --clean C:\\workspaces\\orca']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import { basename, win32 } from 'node:path'
|
||||
import { basename, posix, win32 } from 'node:path'
|
||||
import { resolveCliCommand } from './codex-cli/command'
|
||||
import { getCmdExePath } from './win32-utils'
|
||||
|
||||
export const EXTERNAL_EDITOR_CLI_COMMAND = 'code'
|
||||
const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim'])
|
||||
|
||||
export type ExternalEditorLaunchSpec =
|
||||
| {
|
||||
kind: 'executable'
|
||||
hideWindowsConsole: boolean
|
||||
spawnCmd: string
|
||||
spawnArgs: string[]
|
||||
}
|
||||
| {
|
||||
kind: 'shell'
|
||||
hideWindowsConsole: boolean
|
||||
spawnCmd: string
|
||||
spawnArgs: string[]
|
||||
}
|
||||
|
|
@ -33,11 +36,51 @@ function escapePathForShell(pathValue: string, platform: NodeJS.Platform): strin
|
|||
: escapePosixPathForShell(pathValue)
|
||||
}
|
||||
|
||||
function getLauncherBaseName(command: string): string {
|
||||
const name = command.includes('\\') ? win32.basename(command) : basename(command)
|
||||
function getLauncherBaseName(command: string, options: { shellCommand?: boolean } = {}): string {
|
||||
const normalized = options.shellCommand
|
||||
? getLeadingShellCommandToken(command)
|
||||
: stripMatchingQuotes(command)
|
||||
const name = normalized.includes('\\') ? win32.basename(normalized) : basename(normalized)
|
||||
return name.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase()
|
||||
}
|
||||
|
||||
function getLeadingShellCommandToken(command: string): string {
|
||||
const trimmed = command.trim()
|
||||
const quote = trimmed[0]
|
||||
if (quote === '"' || quote === "'") {
|
||||
const closingIndex = trimmed.indexOf(quote, 1)
|
||||
if (closingIndex > 0) {
|
||||
return trimmed.slice(1, closingIndex)
|
||||
}
|
||||
}
|
||||
return trimmed.split(/\s+/, 1)[0] ?? ''
|
||||
}
|
||||
|
||||
function stripMatchingQuotes(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
const quote = trimmed[0]
|
||||
if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function isDirectExecutablePath(command: string, platform: NodeJS.Platform): boolean {
|
||||
const unquoted = stripMatchingQuotes(command)
|
||||
if (!/[\\/]/.test(unquoted)) {
|
||||
return false
|
||||
}
|
||||
return platform === 'win32' ? win32.isAbsolute(unquoted) : posix.isAbsolute(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): string[] {
|
||||
if (getLauncherBaseName(editorCommand) === 'cursor') {
|
||||
// Why: Cursor can route bare folder launches through the last active
|
||||
|
|
@ -60,12 +103,14 @@ function buildShellLaunchSpec(
|
|||
if (platform === 'win32') {
|
||||
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]
|
||||
}
|
||||
|
|
@ -79,6 +124,16 @@ export function resolveExternalEditorLaunchSpec(
|
|||
const platform = options.platform ?? process.platform
|
||||
const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND
|
||||
|
||||
if (isDirectExecutablePath(trimmed, platform)) {
|
||||
const editorCommand = stripMatchingQuotes(trimmed)
|
||||
return {
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform),
|
||||
spawnCmd: editorCommand,
|
||||
spawnArgs: buildExecutableArgs(editorCommand, pathValue)
|
||||
}
|
||||
}
|
||||
|
||||
if (isCompoundShellCommand(trimmed)) {
|
||||
return buildShellLaunchSpec(trimmed, pathValue, platform)
|
||||
}
|
||||
|
|
@ -86,6 +141,7 @@ export function resolveExternalEditorLaunchSpec(
|
|||
const editorCommand = resolveCliCommand(trimmed, { platform })
|
||||
return {
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform),
|
||||
spawnCmd: editorCommand,
|
||||
spawnArgs: buildExecutableArgs(editorCommand, pathValue)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ vi.mock('../codex-cli/command', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('../win32-utils', () => ({
|
||||
getCmdExePath: () => 'C:\\Windows\\System32\\cmd.exe',
|
||||
getSpawnArgsForWindows: getSpawnArgsForWindowsMock
|
||||
}))
|
||||
|
||||
|
|
@ -305,6 +306,31 @@ describe('registerShellHandlers', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('shows the Windows console for NeoVim executable launchers on Windows', async () => {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const workspacePath = resolve('workspace')
|
||||
const handler = getHandler('shell:openInExternalEditor')
|
||||
const nvimPath = 'C:\\Program Files\\Neovim\\bin\\nvim.exe'
|
||||
|
||||
try {
|
||||
await expect(handler({}, workspacePath, nvimPath)).resolves.toEqual({ ok: true })
|
||||
expect(resolveCliCommandMock).not.toHaveBeenCalled()
|
||||
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(nvimPath, [
|
||||
normalize(workspacePath)
|
||||
])
|
||||
expect(spawnMock).toHaveBeenCalledWith(nvimPath, [normalize(workspacePath)], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false
|
||||
})
|
||||
} finally {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', platformDescriptor)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('forces Cursor launcher folders into a new window', async () => {
|
||||
resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/cursor')
|
||||
const workspacePath = resolve('workspace')
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ async function launchExternalEditor(pathValue: string, command?: string): Promis
|
|||
const child = spawn(spawnCmd, spawnArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
// 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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue