( filePath: string, line?: number, )
| 79 | * Returns true if the editor was launched, false if no editor is available. |
| 80 | */ |
| 81 | export function openFileInExternalEditor( |
| 82 | filePath: string, |
| 83 | line?: number, |
| 84 | ): boolean { |
| 85 | const editor = getExternalEditor() |
| 86 | if (!editor) return false |
| 87 | |
| 88 | // Spawn the user's actual binary (preserves code-insiders, abs paths, etc.). |
| 89 | // Split into binary + extra args so multi-word values like 'start /wait |
| 90 | // notepad' or 'code --wait' propagate all tokens to spawn. |
| 91 | const parts = editor.split(' ') |
| 92 | const base = parts[0] ?? editor |
| 93 | const editorArgs = parts.slice(1) |
| 94 | const guiFamily = classifyGuiEditor(editor) |
| 95 | |
| 96 | if (guiFamily) { |
| 97 | const gotoArgv = guiGotoArgv(guiFamily, filePath, line) |
| 98 | const detachedOpts: SpawnOptions = { detached: true, stdio: 'ignore' } |
| 99 | let child |
| 100 | if (process.platform === 'win32') { |
| 101 | // shell: true on win32 so code.cmd / cursor.cmd / windsurf.cmd resolve — |
| 102 | // CreateProcess can't execute .cmd/.bat directly. Assemble quoted command |
| 103 | // string; cmd.exe doesn't expand $() or backticks inside double quotes. |
| 104 | // Quote each arg so paths with spaces survive the shell join. |
| 105 | const gotoStr = gotoArgv.map(a => `"${a}"`).join(' ') |
| 106 | child = spawn(`${editor} ${gotoStr}`, { ...detachedOpts, shell: true }) |
| 107 | } else { |
| 108 | // POSIX: argv array with no shell — injection-safe. shell: true would |
| 109 | // expand $() / backticks inside double quotes, and filePath is |
| 110 | // filesystem-sourced (possible RCE from a malicious repo filename). |
| 111 | child = spawn(base, [...editorArgs, ...gotoArgv], detachedOpts) |
| 112 | } |
| 113 | // spawn() emits ENOENT asynchronously. ENOENT on $VISUAL/$EDITOR is a |
| 114 | // user-config error, not an internal bug — don't pollute error telemetry. |
| 115 | child.on('error', e => |
| 116 | logForDebugging(`editor spawn failed: ${e}`, { level: 'error' }), |
| 117 | ) |
| 118 | child.unref() |
| 119 | return true |
| 120 | } |
| 121 | |
| 122 | // Terminal editor — needs alt-screen handoff since it takes over the |
| 123 | // terminal. Blocks until the editor exits. |
| 124 | const inkInstance = instances.get(process.stdout) |
| 125 | if (!inkInstance) return false |
| 126 | // Only prepend +N for editors known to support it — notepad treats +42 as a |
| 127 | // filename to open. Test basename so /home/vim/bin/kak doesn't match 'vim' |
| 128 | // via the directory segment. |
| 129 | const useGotoLine = line && PLUS_N_EDITORS.test(basename(base)) |
| 130 | inkInstance.enterAlternateScreen() |
| 131 | try { |
| 132 | const syncOpts: SpawnSyncOptions = { stdio: 'inherit' } |
| 133 | let result |
| 134 | if (process.platform === 'win32') { |
| 135 | // On Windows use shell: true so cmd.exe builtins like `start` resolve. |
| 136 | // shell: true joins args unquoted, so assemble the command string with |
| 137 | // explicit quoting ourselves (matching promptEditor.ts:74). spawnSync |
| 138 | // returns errors in .error rather than throwing. |
no test coverage detected