返回 CodeWhale
external_editor.rs
根目录 / crates / tui / src / tui / external_editor.rs
1 //! External editor support for the composer.
2 //!
3 //! Spawns `$VISUAL`/`$EDITOR` (fallback `vi`) on a temp file pre-populated with
4 //! the composer's current contents. The TUI is suspended for the duration of
5 //! the edit and re-entered on return. The temp file is cleaned up in all paths
6 //! (success, editor failure, IO error) via [`tempfile::NamedTempFile`].
7 //!
8 //! Reference: codex-rs's `tui/src/external_editor.rs` — the design here mirrors
9 //! that approach but is synchronous (called inline from the TUI event loop) and
10 //! handles its own raw-mode toggling rather than relying on the caller.
11
12 use std::env;
13 use std::fs;
14 use std::io::{self, Stdout, Write};
15 use std::process::Command;
16
17 use crossterm::{
18 event::DisableFocusChange,
19 execute,
20 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
21 };
22 use ratatui::Terminal;
23 use tempfile::Builder;
24
25 use super::color_compat::ColorCompatBackend;
26
27 /// Outcome of a single external-editor invocation.
28 #[derive(Debug, PartialEq, Eq)]
29 pub enum EditorOutcome {
30 /// Editor exited cleanly and the file contents differ from the seed.
31 Edited(String),
32 /// Editor exited cleanly but the contents are unchanged (or empty after
33 /// trimming). The composer should be left as-is.
34 Unchanged,
35 /// Editor exited non-zero or could not be spawned. The composer should be
36 /// left as-is and a status toast shown.
37 Cancelled,
38 }
39
40 /// Resolve the editor command, preferring `$VISUAL` over `$EDITOR`, falling
41 /// back to `vi`. Returns the raw string for the test path; `spawn_editor`
42 /// splits it via `shlex` (Unix) so users can set `EDITOR="code --wait"`.
43 fn resolve_editor() -> String {
44 env::var("VISUAL")
45 .ok()
46 .filter(|s| !s.trim().is_empty())
47 .or_else(|| env::var("EDITOR").ok().filter(|s| !s.trim().is_empty()))
48 .unwrap_or_else(|| "vi".to_string())
49 }
50
51 #[cfg(unix)]
52 fn split_command(raw: &str) -> Option<Vec<String>> {
53 shlex::split(raw)
54 }
55
56 #[cfg(not(unix))]
57 fn split_command(raw: &str) -> Option<Vec<String>> {
58 // On Windows we do not support shell-quoted editor commands; treat the
59 // full string as the program name.
60 if raw.trim().is_empty() {
61 None
62 } else {
63 Some(vec![raw.to_string()])
64 }
65 }
66
67 /// Run the external editor without touching terminal state. Exposed for tests.
68 ///
69 /// Returns:
70 /// - `Ok(EditorOutcome::Edited(new))` if the editor exited cleanly and the
71 /// contents differ from `seed`.
72 /// - `Ok(EditorOutcome::Unchanged)` if the editor exited cleanly but the
73 /// contents match `seed`.
74 /// - `Ok(EditorOutcome::Cancelled)` if the editor exited non-zero or could not
75 /// be spawned.
76 ///
77 /// The temp file is removed on every path because [`tempfile::NamedTempFile`]
78 /// is dropped at the end of the function.
79 pub fn run_editor_raw(seed: &str) -> io::Result<EditorOutcome> {
80 let mut tmp = Builder::new()
81 .prefix("deepseek-edit-")
82 .suffix(".md")
83 .tempfile()?;
84 tmp.write_all(seed.as_bytes())?;
85 tmp.flush()?;
86 let path = tmp.path().to_path_buf();
87
88 let raw = resolve_editor();
89 let parts = match split_command(&raw) {
90 Some(p) if !p.is_empty() => p,
91 _ => return Ok(EditorOutcome::Cancelled),
92 };
93
94 let mut cmd = Command::new(&parts[0]);
95 if parts.len() > 1 {
96 cmd.args(&parts[1..]);
97 }
98 cmd.arg(&path);
99
100 let status = match cmd.status() {
101 Ok(s) => s,
102 Err(_) => return Ok(EditorOutcome::Cancelled),
103 };
104 if !status.success() {
105 return Ok(EditorOutcome::Cancelled);
106 }
107
108 let new = fs::read_to_string(&path)?;
109 // tmp goes out of scope here — file is unlinked.
110 if new == seed {
111 Ok(EditorOutcome::Unchanged)
112 } else {
113 Ok(EditorOutcome::Edited(new))
114 }
115 }
116
117 /// Suspend the TUI, run the external editor on `current`, then re-enter the
118 /// TUI. Returns the new composer text iff the user saved changes.
119 ///
120 /// On any error (raw-mode toggle, IO, editor spawn failure), the function
121 /// still attempts to fully restore the terminal before returning.
122 pub(crate) fn spawn_editor_for_input(
123 terminal: &mut Terminal<ColorCompatBackend<Stdout>>,
124 use_alt_screen: bool,
125 use_mouse_capture: bool,
126 use_bracketed_paste: bool,
127 current: &str,
128 ) -> io::Result<EditorOutcome> {
129 // 1. Suspend.
130 // #443: pop keyboard enhancement flags first so the editor
131 // process doesn't inherit a half-configured input mode. Best-
132 // effort — matches the shutdown / panic paths in main.rs.
133 // Use the Windows-aware helper: the raw crossterm execute!() is a
134 // no-op on Windows and would leave the editor process in Kitty mode.
135 suspend_tui_child_modes(
136 terminal.backend_mut(),
137 use_mouse_capture,
138 use_bracketed_paste,
139 );
140 let _ = disable_raw_mode();
141 if use_alt_screen {
142 let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
143 }
144
145 // 2. Run the editor (synchronous; inherits stdio).
146 let result = run_editor_raw(current);
147
148 // 3. Resume — best-effort restoration regardless of `result`.
149 let _ = enable_raw_mode();
150 if use_alt_screen {
151 let _ = execute!(terminal.backend_mut(), EnterAlternateScreen);
152 }
153 super::ui::recover_terminal_modes(
154 terminal.backend_mut(),
155 use_mouse_capture,
156 use_bracketed_paste,
157 );
158 // Force a full repaint so a SIGWINCH during the edit doesn't leave the
159 // viewport stale.
160 let _ = terminal.clear();
161
162 result
163 }
164
165 fn suspend_tui_child_modes<W: Write>(
166 writer: &mut W,
167 use_mouse_capture: bool,
168 use_bracketed_paste: bool,
169 ) {
170 super::ui::pop_keyboard_enhancement_flags(writer);
171 super::ui::disable_alternate_scroll_mode(writer);
172 let _ = execute!(writer, DisableFocusChange);
173 if use_mouse_capture {
174 disable_mouse_capture_for_child(writer);
175 }
176 if use_bracketed_paste {
177 super::ui::disable_bracketed_paste_mode(writer);
178 }
179 let _ = writer.flush();
180 }
181
182 fn disable_mouse_capture_for_child<W: Write>(writer: &mut W) {
183 // Crossterm's mouse-capture command takes a WinAPI path on Windows and
184 // does not emit bytes into PTY-style terminals such as mintty. External
185 // editors inherit the PTY state, so send the xterm reset sequences
186 // directly here.
187 const DISABLE_MOUSE_CAPTURE: &[u8] = b"\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
188 if let Err(err) = writer.write_all(DISABLE_MOUSE_CAPTURE) {
189 tracing::debug!(?err, "DisableMouseCapture direct reset ignored");
190 }
191 }
192
193 #[cfg(test)]
194 mod tests {
195 use super::*;
196 use std::ffi::OsString;
197 use std::sync::Mutex;
198
199 /// Serialize tests that mutate process-global env vars.
200 static ENV_LOCK: Mutex<()> = Mutex::new(());
201
202 struct EnvGuard {
203 keys: Vec<(&'static str, Option<OsString>)>,
204 }
205 impl EnvGuard {
206 fn new(keys: &[&'static str]) -> Self {
207 let saved: Vec<_> = keys.iter().map(|k| (*k, env::var_os(k))).collect();
208 Self { keys: saved }
209 }
210 }
211 impl Drop for EnvGuard {
212 fn drop(&mut self) {
213 for (k, v) in &self.keys {
214 match v {
215 Some(val) => unsafe { env::set_var(k, val) },
216 None => unsafe { env::remove_var(k) },
217 }
218 }
219 }
220 }
221
222 #[test]
223 fn resolve_editor_prefers_visual_over_editor() {
224 let _lock = ENV_LOCK.lock().unwrap();
225 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
226 unsafe {
227 env::set_var("VISUAL", "vis-cmd");
228 env::set_var("EDITOR", "ed-cmd");
229 }
230 assert_eq!(resolve_editor(), "vis-cmd");
231 }
232
233 #[test]
234 fn resolve_editor_falls_back_to_vi() {
235 let _lock = ENV_LOCK.lock().unwrap();
236 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
237 unsafe {
238 env::remove_var("VISUAL");
239 env::remove_var("EDITOR");
240 }
241 assert_eq!(resolve_editor(), "vi");
242 }
243
244 /// Editor that immediately exits 0 without touching the file ⇒ Unchanged.
245 #[test]
246 #[cfg(unix)]
247 fn run_editor_unchanged_when_editor_is_noop() {
248 let _lock = ENV_LOCK.lock().unwrap();
249 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
250 unsafe {
251 env::remove_var("VISUAL");
252 env::set_var("EDITOR", "true");
253 }
254 let out = run_editor_raw("seed text").expect("editor ok");
255 assert_eq!(out, EditorOutcome::Unchanged);
256 }
257
258 /// Editor that exits non-zero ⇒ Cancelled.
259 #[test]
260 #[cfg(unix)]
261 fn run_editor_cancelled_on_nonzero_exit() {
262 let _lock = ENV_LOCK.lock().unwrap();
263 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
264 unsafe {
265 env::remove_var("VISUAL");
266 env::set_var("EDITOR", "false");
267 }
268 let out = run_editor_raw("seed").expect("call ok");
269 assert_eq!(out, EditorOutcome::Cancelled);
270 }
271
272 /// Spawning an editor binary that doesn't exist ⇒ Cancelled (graceful).
273 #[test]
274 #[cfg(unix)]
275 fn run_editor_cancelled_when_editor_missing() {
276 let _lock = ENV_LOCK.lock().unwrap();
277 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
278 unsafe {
279 env::remove_var("VISUAL");
280 env::set_var("EDITOR", "/nonexistent/codewhale-test-editor");
281 }
282 let out = run_editor_raw("seed").expect("call ok");
283 assert_eq!(out, EditorOutcome::Cancelled);
284 }
285
286 /// Editor that rewrites the file ⇒ Edited(new).
287 #[test]
288 #[cfg(unix)]
289 fn run_editor_returns_edited_contents() {
290 use std::os::unix::fs::PermissionsExt;
291
292 let _lock = ENV_LOCK.lock().unwrap();
293 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
294 let dir = tempfile::tempdir().unwrap();
295 let script = dir.path().join("ed.sh");
296 fs::write(&script, "#!/bin/sh\nprintf 'edited body' > \"$1\"\n").unwrap();
297 let mut perms = fs::metadata(&script).unwrap().permissions();
298 perms.set_mode(0o755);
299 fs::set_permissions(&script, perms).unwrap();
300
301 unsafe {
302 env::remove_var("VISUAL");
303 env::set_var("EDITOR", script.to_string_lossy().to_string());
304 }
305 let out = run_editor_raw("seed body").expect("editor ok");
306 assert_eq!(out, EditorOutcome::Edited("edited body".to_string()));
307 }
308
309 /// Verify that the temp file is unlinked after `run_editor_raw` returns,
310 /// regardless of outcome. We test the success path with a script that
311 /// echoes the file path to a side channel before exiting.
312 #[test]
313 #[cfg(unix)]
314 fn run_editor_cleans_up_temp_file() {
315 use std::os::unix::fs::PermissionsExt;
316
317 let _lock = ENV_LOCK.lock().unwrap();
318 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
319 let dir = tempfile::tempdir().unwrap();
320 let path_capture = dir.path().join("capture.txt");
321 let script = dir.path().join("ed.sh");
322 fs::write(
323 &script,
324 format!(
325 "#!/bin/sh\nprintf '%s' \"$1\" > \"{}\"\nprintf 'x' > \"$1\"\n",
326 path_capture.display()
327 ),
328 )
329 .unwrap();
330 let mut perms = fs::metadata(&script).unwrap().permissions();
331 perms.set_mode(0o755);
332 fs::set_permissions(&script, perms).unwrap();
333
334 unsafe {
335 env::remove_var("VISUAL");
336 env::set_var("EDITOR", script.to_string_lossy().to_string());
337 }
338 let _ = run_editor_raw("seed").expect("editor ok");
339
340 let captured = fs::read_to_string(&path_capture).expect("captured path");
341 assert!(!captured.is_empty(), "editor should have received a path");
342 assert!(
343 !std::path::Path::new(&captured).exists(),
344 "temp file {captured:?} should be cleaned up after run_editor_raw returns"
345 );
346 }
347
348 #[test]
349 fn suspend_tui_child_modes_disables_every_inherited_mode() {
350 let mut out = Vec::new();
351
352 suspend_tui_child_modes(&mut out, true, true);
353
354 let seq = String::from_utf8_lossy(&out);
355 assert!(
356 seq.contains("\x1b[?1007l"),
357 "external editor suspend must disable alternate-scroll mode: {seq:?}"
358 );
359 assert!(
360 seq.contains("\x1b[?1004l"),
361 "external editor suspend must disable focus events: {seq:?}"
362 );
363 assert!(
364 seq.contains("\x1b[?2004l"),
365 "external editor suspend must disable bracketed paste: {seq:?}"
366 );
367 assert!(
368 seq.contains("\x1b[?1000l"),
369 "external editor suspend must disable mouse capture when active: {seq:?}"
370 );
371 }
372
373 #[test]
374 fn suspend_tui_child_modes_leaves_mouse_capture_alone_when_inactive() {
375 let mut out = Vec::new();
376
377 suspend_tui_child_modes(&mut out, false, true);
378
379 let seq = String::from_utf8_lossy(&out);
380 assert!(
381 !seq.contains("\x1b[?1000l"),
382 "external editor suspend must not emit mouse-capture reset when inactive: {seq:?}"
383 );
384 }
385
386 #[test]
387 fn resume_tui_child_modes_reenables_shared_terminal_modes() {
388 let mut out = Vec::new();
389
390 crate::tui::ui::recover_terminal_modes(&mut out, true, true);
391
392 let seq = String::from_utf8_lossy(&out);
393 assert!(
394 !seq.contains("\x1b[?1007h"),
395 "must not enable alternate-scroll"
396 );
397 assert!(seq.contains("\x1b[?1007l"), "must reset alternate-scroll");
398 assert!(
399 seq.contains("\x1b[?1004h"),
400 "external editor resume must restore focus events: {seq:?}"
401 );
402 assert!(
403 seq.contains("\x1b[?2004h"),
404 "external editor resume must restore bracketed paste: {seq:?}"
405 );
406 }
407
408 #[test]
409 fn resume_tui_child_modes_leaves_alternate_scroll_off_when_mouse_capture_inactive() {
410 let mut out = Vec::new();
411
412 crate::tui::ui::recover_terminal_modes(&mut out, false, true);
413
414 let seq = String::from_utf8_lossy(&out);
415 assert!(
416 !seq.contains("\x1b[?1007h"),
417 "external editor resume must not enable alternate-scroll without mouse capture: {seq:?}"
418 );
419 assert!(
420 seq.contains("\x1b[?1007l"),
421 "external editor resume must reset alternate-scroll without mouse capture: {seq:?}"
422 );
423 assert!(
424 seq.contains("\x1b[?1004h"),
425 "external editor resume must still restore focus events: {seq:?}"
426 );
427 assert!(
428 seq.contains("\x1b[?2004h"),
429 "external editor resume must still restore bracketed paste: {seq:?}"
430 );
431 }
432 }
433
433 lines RUST