/ src / utils / editor.ts
editor.ts
  1  import {
  2    type SpawnOptions,
  3    type SpawnSyncOptions,
  4    spawn,
  5    spawnSync,
  6  } from 'child_process'
  7  import memoize from 'lodash-es/memoize.js'
  8  import { basename } from 'path'
  9  import instances from '../ink/instances.js'
 10  import { logForDebugging } from './debug.js'
 11  import { whichSync } from './which.js'
 12  
 13  function isCommandAvailable(command: string): boolean {
 14    return !!whichSync(command)
 15  }
 16  
 17  // GUI editors that open in a separate window and can be spawned detached
 18  // without fighting the TUI for stdin. VS Code forks (cursor, windsurf, codium)
 19  // are listed explicitly since none contain 'code' as a substring.
 20  const GUI_EDITORS = [
 21    'code',
 22    'cursor',
 23    'windsurf',
 24    'codium',
 25    'subl',
 26    'atom',
 27    'gedit',
 28    'notepad++',
 29    'notepad',
 30  ]
 31  
 32  // Editors that accept +N as a goto-line argument. The Windows default
 33  // ('start /wait notepad') does not — notepad treats +42 as a filename.
 34  const PLUS_N_EDITORS = /\b(vi|vim|nvim|nano|emacs|pico|micro|helix|hx)\b/
 35  
 36  // VS Code and forks use -g file:line. subl uses bare file:line (no -g).
 37  const VSCODE_FAMILY = new Set(['code', 'cursor', 'windsurf', 'codium'])
 38  
 39  /**
 40   * Classify the editor as GUI or not. Returns the matched GUI family name
 41   * for goto-line argv selection, or undefined for terminal editors.
 42   * Note: this is classification only — spawn the user's actual binary, not
 43   * this return value, so `code-insiders` / absolute paths are preserved.
 44   *
 45   * Uses basename so /home/alice/code/bin/nvim doesn't match 'code' via the
 46   * directory component. code-insiders → still matches 'code', /usr/bin/code →
 47   * 'code' → matches.
 48   */
 49  export function classifyGuiEditor(editor: string): string | undefined {
 50    const base = basename(editor.split(' ')[0] ?? '')
 51    return GUI_EDITORS.find(g => base.includes(g))
 52  }
 53  
 54  /**
 55   * Build goto-line argv for a GUI editor. VS Code family uses -g file:line;
 56   * subl uses bare file:line; others don't support goto-line.
 57   */
 58  function guiGotoArgv(
 59    guiFamily: string,
 60    filePath: string,
 61    line: number | undefined,
 62  ): string[] {
 63    if (!line) return [filePath]
 64    if (VSCODE_FAMILY.has(guiFamily)) return ['-g', `${filePath}:${line}`]
 65    if (guiFamily === 'subl') return [`${filePath}:${line}`]
 66    return [filePath]
 67  }
 68  
 69  /**
 70   * Launch a file in the user's external editor.
 71   *
 72   * For GUI editors (code, subl, etc.): spawns detached — the editor opens
 73   * in a separate window and Claude Code stays interactive.
 74   *
 75   * For terminal editors (vim, nvim, nano, etc.): blocks via Ink's alt-screen
 76   * handoff until the editor exits. This is the same dance as editFileInEditor()
 77   * in promptEditor.ts, minus the read-back.
 78   *
 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.
139        const lineArg = useGotoLine ? `+${line} ` : ''
140        result = spawnSync(`${editor} ${lineArg}"${filePath}"`, {
141          ...syncOpts,
142          shell: true,
143        })
144      } else {
145        // POSIX: spawn directly (no shell), argv array is quote-safe.
146        const args = [
147          ...editorArgs,
148          ...(useGotoLine ? [`+${line}`, filePath] : [filePath]),
149        ]
150        result = spawnSync(base, args, syncOpts)
151      }
152      if (result.error) {
153        logForDebugging(`editor spawn failed: ${result.error}`, {
154          level: 'error',
155        })
156        return false
157      }
158      return true
159    } finally {
160      inkInstance.exitAlternateScreen()
161    }
162  }
163  
164  export const getExternalEditor = memoize((): string | undefined => {
165    // Prioritize environment variables
166    if (process.env.VISUAL?.trim()) {
167      return process.env.VISUAL.trim()
168    }
169  
170    if (process.env.EDITOR?.trim()) {
171      return process.env.EDITOR.trim()
172    }
173  
174    // `isCommandAvailable` breaks the claude process' stdin on Windows
175    // as a bandaid, we skip it
176    if (process.platform === 'win32') {
177      return 'start /wait notepad'
178    }
179  
180    // Search for available editors in order of preference
181    const editors = ['code', 'vi', 'nano']
182    return editors.find(command => isCommandAvailable(command))
183  })