返回 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::{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 /// Append the file (and optional line) arguments in the spelling `program`
118 /// understands.
119 ///
120 /// Every caller that opens a real file goes through here so the three paths
121 /// cannot drift on how a line number is spelled. Without a line this is exactly
122 /// `cmd.arg(path)`, which is what it has always been.
123 ///
124 /// `file_stem` rather than the whole program name, so an absolute path and a
125 /// Windows `.exe` suffix both still match. An editor we do not recognize gets
126 /// the bare path: opening the right file at the wrong line beats a spurious
127 /// argument the editor treats as a second file to open.
128 fn push_target_args(cmd: &mut Command, program: &str, path: &std::path::Path, line: Option<u32>) {
129 let Some(line) = line else {
130 cmd.arg(path);
131 return;
132 };
133 let stem = std::path::Path::new(program)
134 .file_stem()
135 .and_then(|s| s.to_str())
136 .unwrap_or(program)
137 .to_ascii_lowercase();
138
139 match stem.as_str() {
140 // The vi family and the editors that copied its `+N` convention.
141 "vi" | "vim" | "nvim" | "view" | "gvim" | "nano" | "pico" | "emacs" | "emacsclient"
142 | "kak" | "micro" | "joe" => {
143 cmd.arg(format!("+{line}"));
144 cmd.arg(path);
145 }
146 // VS Code and its forks need an explicit flag before `file:line`.
147 "code" | "code-insiders" | "codium" | "vscodium" | "cursor" | "windsurf" => {
148 cmd.arg("--goto");
149 cmd.arg(format!("{}:{line}", path.display()));
150 }
151 // Sublime, Zed and JetBrains launchers all take `file:line` directly.
152 "subl" | "sublime_text" | "zed" | "idea" | "pycharm" | "goland" | "clion" | "rustrover"
153 | "webstorm" => {
154 cmd.arg(format!("{}:{line}", path.display()));
155 }
156 _ => {
157 cmd.arg(path);
158 }
159 }
160 }
161
162 /// Run the external editor on a real file, in place.
163 ///
164 /// Unlike [`run_editor_raw`] there is no temp file and no seed: the file on
165 /// disk *is* the document, so a `hooks.toml` the user edits stays edited even
166 /// if the editor exits non-zero. The outcome only reports whether the bytes
167 /// moved, which is what the caller needs in order to decide whether to reload.
168 pub fn run_editor_on_path(path: &std::path::Path, line: Option<u32>) -> io::Result<EditorOutcome> {
169 let before = fs::read_to_string(path).unwrap_or_default();
170
171 let raw = resolve_editor();
172 let parts = match split_command(&raw) {
173 Some(p) if !p.is_empty() => p,
174 _ => return Ok(EditorOutcome::Cancelled),
175 };
176 let mut cmd = Command::new(&parts[0]);
177 if parts.len() > 1 {
178 cmd.args(&parts[1..]);
179 }
180 push_target_args(&mut cmd, &parts[0], path, line);
181 let status = match cmd.status() {
182 Ok(status) => status,
183 Err(_) => return Ok(EditorOutcome::Cancelled),
184 };
185
186 let after = fs::read_to_string(path).unwrap_or_default();
187 if after == before {
188 // A non-zero exit with no change is the editor being quit; a
189 // non-zero exit that *did* change the file still changed the file.
190 return Ok(if status.success() {
191 EditorOutcome::Unchanged
192 } else {
193 EditorOutcome::Cancelled
194 });
195 }
196 Ok(EditorOutcome::Edited(after))
197 }
198
199 /// Suspend the TUI, run the external editor on `path`, then re-enter it.
200 ///
201 /// The suspend/resume dance is [`spawn_editor_for_input`]'s, factored so the
202 /// composer and a file editor cannot drift on terminal-mode restoration.
203 pub(crate) fn spawn_editor_for_path(
204 terminal: &mut Terminal<ColorCompatBackend<Stdout>>,
205 use_alt_screen: bool,
206 use_mouse_capture: bool,
207 use_bracketed_paste: bool,
208 path: &std::path::Path,
209 line: Option<u32>,
210 ) -> io::Result<EditorOutcome> {
211 with_suspended_tui(
212 terminal,
213 use_alt_screen,
214 use_mouse_capture,
215 use_bracketed_paste,
216 || run_editor_on_path(path, line),
217 )
218 }
219
220 /// Suspend the TUI, run the external editor on `current`, then re-enter the
221 /// TUI. Returns the new composer text iff the user saved changes.
222 ///
223 /// On any error (raw-mode toggle, IO, editor spawn failure), the function
224 /// still attempts to fully restore the terminal before returning.
225 pub(crate) fn spawn_editor_for_input(
226 terminal: &mut Terminal<ColorCompatBackend<Stdout>>,
227 use_alt_screen: bool,
228 use_mouse_capture: bool,
229 use_bracketed_paste: bool,
230 current: &str,
231 ) -> io::Result<EditorOutcome> {
232 with_suspended_tui(
233 terminal,
234 use_alt_screen,
235 use_mouse_capture,
236 use_bracketed_paste,
237 || run_editor_raw(current),
238 )
239 }
240
241 /// Hand the terminal to a child, run `body`, and restore the TUI.
242 ///
243 /// Restoration is best-effort and runs on every path, including a `body` that
244 /// failed: leaving raw mode or the alt screen wrong is worse than the error
245 /// being reported.
246 fn with_suspended_tui(
247 terminal: &mut Terminal<ColorCompatBackend<Stdout>>,
248 use_alt_screen: bool,
249 use_mouse_capture: bool,
250 use_bracketed_paste: bool,
251 body: impl FnOnce() -> io::Result<EditorOutcome>,
252 ) -> io::Result<EditorOutcome> {
253 // 0. Stop reading the tty.
254 // #6165: suspending crossterm state is not enough. The input pump runs on
255 // its own thread and keeps calling `event::read()` whatever mode the
256 // terminal is in, so a child launched without this pause competes with
257 // Codewhale for every keystroke — the editor cannot be quit and the
258 // fragments land in the composer. Pausing here, rather than at each call
259 // site, is what makes `/hooks edit` and the composer editor correct by
260 // the same construction. Fail closed: a pump that will not stop means the
261 // handoff would reproduce the defect, so the editor does not run.
262 let input_pause = crate::tui::ui::pause_terminal_input_for_child()?;
263
264 // 1. Suspend.
265 // Focus reporting is about to be disabled. Fail closed to the quiet state
266 // so a stale FocusLost cannot authorize a surprise notification while an
267 // external editor owns the terminal.
268 crate::tui::notifications::set_terminal_focused(true);
269 // #443: pop keyboard enhancement flags first so the editor
270 // process doesn't inherit a half-configured input mode. Best-
271 // effort — matches the shutdown / panic paths in main.rs.
272 // Use the Windows-aware helper: the raw crossterm execute!() is a
273 // no-op on Windows and would leave the editor process in Kitty mode.
274 suspend_tui_child_modes(
275 terminal.backend_mut(),
276 use_mouse_capture,
277 use_bracketed_paste,
278 );
279 let _ = disable_raw_mode();
280 if use_alt_screen {
281 let _ = super::ui::leave_alt_screen(terminal.backend_mut());
282 }
283
284 // 2. Run the child (synchronous; inherits stdio).
285 let result = body();
286
287 // 3. Resume — best-effort restoration regardless of `result`.
288 let _ = enable_raw_mode();
289 if use_alt_screen {
290 let _ = super::ui::enter_alt_screen(terminal.backend_mut());
291 }
292 super::ui::recover_terminal_modes(
293 terminal.backend_mut(),
294 use_mouse_capture,
295 use_bracketed_paste,
296 );
297 // Reporting was unavailable during the handoff. Resume focused and wait
298 // for a fresh FocusLost before background-only delivery is eligible.
299 crate::tui::notifications::set_terminal_focused(true);
300 // Force a full repaint so a SIGWINCH during the edit doesn't leave the
301 // viewport stale.
302 let _ = terminal.clear();
303
304 // 4. Take the tty back, after the modes it reads under are restored.
305 drop(input_pause);
306
307 result
308 }
309
310 fn suspend_tui_child_modes<W: Write>(
311 writer: &mut W,
312 use_mouse_capture: bool,
313 use_bracketed_paste: bool,
314 ) {
315 super::ui::pop_keyboard_enhancement_flags(writer);
316 super::ui::disable_alternate_scroll_mode(writer);
317 let _ = execute!(writer, DisableFocusChange);
318 if use_mouse_capture {
319 disable_mouse_capture_for_child(writer);
320 }
321 if use_bracketed_paste {
322 super::ui::disable_bracketed_paste_mode(writer);
323 }
324 let _ = writer.flush();
325 }
326
327 fn disable_mouse_capture_for_child<W: Write>(writer: &mut W) {
328 // Crossterm's mouse-capture command takes a WinAPI path on Windows and
329 // does not emit bytes into PTY-style terminals such as mintty. External
330 // editors inherit the PTY state, so send the xterm reset sequences
331 // directly here.
332 const DISABLE_MOUSE_CAPTURE: &[u8] = b"\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
333 if let Err(err) = writer.write_all(DISABLE_MOUSE_CAPTURE) {
334 tracing::debug!(?err, "DisableMouseCapture direct reset ignored");
335 }
336 }
337
338 #[cfg(test)]
339 mod tests {
340 use super::*;
341 use std::ffi::OsString;
342 use std::sync::Mutex;
343
344 /// Serialize tests that mutate process-global env vars.
345 static ENV_LOCK: Mutex<()> = Mutex::new(());
346
347 struct EnvGuard {
348 keys: Vec<(&'static str, Option<OsString>)>,
349 }
350 impl EnvGuard {
351 fn new(keys: &[&'static str]) -> Self {
352 let saved: Vec<_> = keys.iter().map(|k| (*k, env::var_os(k))).collect();
353 Self { keys: saved }
354 }
355 }
356 impl Drop for EnvGuard {
357 fn drop(&mut self) {
358 for (k, v) in &self.keys {
359 match v {
360 Some(val) => unsafe { env::set_var(k, val) },
361 None => unsafe { env::remove_var(k) },
362 }
363 }
364 }
365 }
366
367 /// The file on disk is the document: a `hooks.toml` the user edits stays
368 /// edited, and the outcome only reports whether the bytes moved.
369 #[test]
370 #[cfg(unix)]
371 fn editing_a_path_in_place_reports_only_whether_the_bytes_moved() {
372 let _lock = ENV_LOCK.lock().unwrap();
373 let _guard = EnvGuard::new(&["VISUAL", "EDITOR"]);
374 let dir = tempfile::tempdir().unwrap();
375 let path = dir.path().join("hooks.toml");
376 fs::write(&path, "# seed\n").unwrap();
377
378 // An editor that saves nothing.
379 unsafe { env::set_var("VISUAL", "true") };
380 unsafe { env::remove_var("EDITOR") };
381 assert_eq!(
382 run_editor_on_path(&path, None).unwrap(),
383 EditorOutcome::Unchanged,
384 "an editor that changes nothing must not trigger a reload"
385 );
386
387 // An editor that appends a line.
388 let script = dir.path().join("append.sh");
389 fs::write(&script, "#!/bin/sh\nprintf 'x\\n' >> \"$1\"\n").unwrap();
390 use std::os::unix::fs::PermissionsExt as _;
391 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
392 unsafe { env::set_var("VISUAL", script.to_str().unwrap()) };
393 match run_editor_on_path(&path, None).unwrap() {
394 EditorOutcome::Edited(text) => assert!(text.contains("# seed") && text.contains('x')),
395 other => panic!("expected Edited, got {other:?}"),
396 }
397 assert!(
398 fs::read_to_string(&path).unwrap().contains('x'),
399 "the edit belongs to the real file, not a temp copy"
400 );
401 }
402
403 /// The line argument is spelled per editor family, and an unknown editor
404 /// gets the bare path rather than an argument it would treat as a file.
405 #[test]
406 fn push_target_args_spells_the_line_per_editor_family() {
407 use std::ffi::OsStr;
408
409 let path = std::path::Path::new("/w/src/main.rs");
410 let args_for = |program: &str, line: Option<u32>| -> Vec<String> {
411 let mut cmd = Command::new(program);
412 push_target_args(&mut cmd, program, path, line);
413 cmd.get_args()
414 .map(OsStr::to_string_lossy)
415 .map(|s| s.into_owned())
416 .collect()
417 };
418
419 // No line: byte-identical to the historical `cmd.arg(path)`.
420 assert_eq!(args_for("vim", None), vec!["/w/src/main.rs"]);
421 assert_eq!(args_for("code", None), vec!["/w/src/main.rs"]);
422
423 // vi family, including an absolute path and a .exe suffix.
424 assert_eq!(args_for("vim", Some(12)), vec!["+12", "/w/src/main.rs"]);
425 assert_eq!(args_for("nano", Some(3)), vec!["+3", "/w/src/main.rs"]);
426 assert_eq!(
427 args_for("/usr/bin/nvim", Some(9)),
428 vec!["+9", "/w/src/main.rs"]
429 );
430 // `.exe` is stripped by `file_stem` on every platform. A backslashed
431 // Windows *path* only splits on Windows, so it is not asserted here.
432 assert_eq!(args_for("vim.exe", Some(5)), vec!["+5", "/w/src/main.rs"]);
433
434 // VS Code and forks need the flag; Zed/Sublime/JetBrains take file:line.
435 assert_eq!(
436 args_for("code", Some(42)),
437 vec!["--goto", "/w/src/main.rs:42"]
438 );
439 assert_eq!(args_for("zed", Some(42)), vec!["/w/src/main.rs:42"]);
440
441 // Unknown editor: open the right file, never an invented argument.
442 assert_eq!(args_for("my-editor", Some(42)), vec!["/w/src/main.rs"]);
443 }
444
445 #[test]
446 fn resolve_editor_prefers_visual_over_editor() {
447 let _lock = ENV_LOCK.lock().unwrap();
448 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
449 unsafe {
450 env::set_var("VISUAL", "vis-cmd");
451 env::set_var("EDITOR", "ed-cmd");
452 }
453 assert_eq!(resolve_editor(), "vis-cmd");
454 }
455
456 #[test]
457 fn resolve_editor_falls_back_to_vi() {
458 let _lock = ENV_LOCK.lock().unwrap();
459 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
460 unsafe {
461 env::remove_var("VISUAL");
462 env::remove_var("EDITOR");
463 }
464 assert_eq!(resolve_editor(), "vi");
465 }
466
467 /// Editor that immediately exits 0 without touching the file ⇒ Unchanged.
468 #[test]
469 #[cfg(unix)]
470 fn run_editor_unchanged_when_editor_is_noop() {
471 let _lock = ENV_LOCK.lock().unwrap();
472 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
473 unsafe {
474 env::remove_var("VISUAL");
475 env::set_var("EDITOR", "true");
476 }
477 let out = run_editor_raw("seed text").expect("editor ok");
478 assert_eq!(out, EditorOutcome::Unchanged);
479 }
480
481 /// Editor that exits non-zero ⇒ Cancelled.
482 #[test]
483 #[cfg(unix)]
484 fn run_editor_cancelled_on_nonzero_exit() {
485 let _lock = ENV_LOCK.lock().unwrap();
486 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
487 unsafe {
488 env::remove_var("VISUAL");
489 env::set_var("EDITOR", "false");
490 }
491 let out = run_editor_raw("seed").expect("call ok");
492 assert_eq!(out, EditorOutcome::Cancelled);
493 }
494
495 /// Spawning an editor binary that doesn't exist ⇒ Cancelled (graceful).
496 #[test]
497 #[cfg(unix)]
498 fn run_editor_cancelled_when_editor_missing() {
499 let _lock = ENV_LOCK.lock().unwrap();
500 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
501 unsafe {
502 env::remove_var("VISUAL");
503 env::set_var("EDITOR", "/nonexistent/codewhale-test-editor");
504 }
505 let out = run_editor_raw("seed").expect("call ok");
506 assert_eq!(out, EditorOutcome::Cancelled);
507 }
508
509 /// Editor that rewrites the file ⇒ Edited(new).
510 #[test]
511 #[cfg(unix)]
512 fn run_editor_returns_edited_contents() {
513 use std::os::unix::fs::PermissionsExt;
514
515 let _lock = ENV_LOCK.lock().unwrap();
516 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
517 let dir = tempfile::tempdir().unwrap();
518 let script = dir.path().join("ed.sh");
519 fs::write(&script, "#!/bin/sh\nprintf 'edited body' > \"$1\"\n").unwrap();
520 let mut perms = fs::metadata(&script).unwrap().permissions();
521 perms.set_mode(0o755);
522 fs::set_permissions(&script, perms).unwrap();
523
524 unsafe {
525 env::remove_var("VISUAL");
526 env::set_var("EDITOR", script.to_string_lossy().to_string());
527 }
528 let out = run_editor_raw("seed body").expect("editor ok");
529 assert_eq!(out, EditorOutcome::Edited("edited body".to_string()));
530 }
531
532 /// Verify that the temp file is unlinked after `run_editor_raw` returns,
533 /// regardless of outcome. We test the success path with a script that
534 /// echoes the file path to a side channel before exiting.
535 #[test]
536 #[cfg(unix)]
537 fn run_editor_cleans_up_temp_file() {
538 use std::os::unix::fs::PermissionsExt;
539
540 let _lock = ENV_LOCK.lock().unwrap();
541 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
542 let dir = tempfile::tempdir().unwrap();
543 let path_capture = dir.path().join("capture.txt");
544 let script = dir.path().join("ed.sh");
545 fs::write(
546 &script,
547 format!(
548 "#!/bin/sh\nprintf '%s' \"$1\" > \"{}\"\nprintf 'x' > \"$1\"\n",
549 path_capture.display()
550 ),
551 )
552 .unwrap();
553 let mut perms = fs::metadata(&script).unwrap().permissions();
554 perms.set_mode(0o755);
555 fs::set_permissions(&script, perms).unwrap();
556
557 unsafe {
558 env::remove_var("VISUAL");
559 env::set_var("EDITOR", script.to_string_lossy().to_string());
560 }
561 let _ = run_editor_raw("seed").expect("editor ok");
562
563 let captured = fs::read_to_string(&path_capture).expect("captured path");
564 assert!(!captured.is_empty(), "editor should have received a path");
565 assert!(
566 !std::path::Path::new(&captured).exists(),
567 "temp file {captured:?} should be cleaned up after run_editor_raw returns"
568 );
569 }
570
571 #[test]
572 fn suspend_tui_child_modes_disables_every_inherited_mode() {
573 let mut out = Vec::new();
574
575 suspend_tui_child_modes(&mut out, true, true);
576
577 let seq = String::from_utf8_lossy(&out);
578 assert!(
579 seq.contains("\x1b[?1007l"),
580 "external editor suspend must disable alternate-scroll mode: {seq:?}"
581 );
582 assert!(
583 seq.contains("\x1b[?1004l"),
584 "external editor suspend must disable focus events: {seq:?}"
585 );
586 assert!(
587 seq.contains("\x1b[?2004l"),
588 "external editor suspend must disable bracketed paste: {seq:?}"
589 );
590 assert!(
591 seq.contains("\x1b[?1000l"),
592 "external editor suspend must disable mouse capture when active: {seq:?}"
593 );
594 }
595
596 #[test]
597 fn suspend_tui_child_modes_leaves_mouse_capture_alone_when_inactive() {
598 let mut out = Vec::new();
599
600 suspend_tui_child_modes(&mut out, false, true);
601
602 let seq = String::from_utf8_lossy(&out);
603 assert!(
604 !seq.contains("\x1b[?1000l"),
605 "external editor suspend must not emit mouse-capture reset when inactive: {seq:?}"
606 );
607 }
608
609 #[test]
610 fn resume_tui_child_modes_reenables_shared_terminal_modes() {
611 let mut out = Vec::new();
612
613 crate::tui::ui::recover_terminal_modes(&mut out, true, true);
614
615 let seq = String::from_utf8_lossy(&out);
616 assert!(
617 !seq.contains("\x1b[?1007h"),
618 "must not enable alternate-scroll"
619 );
620 assert!(seq.contains("\x1b[?1007l"), "must reset alternate-scroll");
621 assert!(
622 seq.contains("\x1b[?1004h"),
623 "external editor resume must restore focus events: {seq:?}"
624 );
625 assert!(
626 seq.contains("\x1b[?2004h"),
627 "external editor resume must restore bracketed paste: {seq:?}"
628 );
629 }
630
631 #[test]
632 fn resume_tui_child_modes_leaves_alternate_scroll_off_when_mouse_capture_inactive() {
633 let mut out = Vec::new();
634
635 crate::tui::ui::recover_terminal_modes(&mut out, false, true);
636
637 let seq = String::from_utf8_lossy(&out);
638 assert!(
639 !seq.contains("\x1b[?1007h"),
640 "external editor resume must not enable alternate-scroll without mouse capture: {seq:?}"
641 );
642 assert!(
643 seq.contains("\x1b[?1007l"),
644 "external editor resume must reset alternate-scroll without mouse capture: {seq:?}"
645 );
646 assert!(
647 seq.contains("\x1b[?1004h"),
648 "external editor resume must still restore focus events: {seq:?}"
649 );
650 assert!(
651 seq.contains("\x1b[?2004h"),
652 "external editor resume must still restore bracketed paste: {seq:?}"
653 );
654 }
655 }
656
656 lines RUST