返回 DeepSeek-TUI-2026
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::{
19 DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
20 PopKeyboardEnhancementFlags,
21 },
22 execute,
23 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
24 };
25 use ratatui::Terminal;
26 use tempfile::Builder;
27
28 use super::color_compat::ColorCompatBackend;
29
30 /// Outcome of a single external-editor invocation.
31 #[derive(Debug, PartialEq, Eq)]
32 pub enum EditorOutcome {
33 /// Editor exited cleanly and the file contents differ from the seed.
34 Edited(String),
35 /// Editor exited cleanly but the contents are unchanged (or empty after
36 /// trimming). The composer should be left as-is.
37 Unchanged,
38 /// Editor exited non-zero or could not be spawned. The composer should be
39 /// left as-is and a status toast shown.
40 Cancelled,
41 }
42
43 /// Resolve the editor command, preferring `$VISUAL` over `$EDITOR`, falling
44 /// back to `vi`. Returns the raw string for the test path; `spawn_editor`
45 /// splits it via `shlex` (Unix) so users can set `EDITOR="code --wait"`.
46 fn resolve_editor() -> String {
47 env::var("VISUAL")
48 .ok()
49 .filter(|s| !s.trim().is_empty())
50 .or_else(|| env::var("EDITOR").ok().filter(|s| !s.trim().is_empty()))
51 .unwrap_or_else(|| "vi".to_string())
52 }
53
54 #[cfg(unix)]
55 fn split_command(raw: &str) -> Option<Vec<String>> {
56 shlex::split(raw)
57 }
58
59 #[cfg(not(unix))]
60 fn split_command(raw: &str) -> Option<Vec<String>> {
61 // On Windows we do not support shell-quoted editor commands; treat the
62 // full string as the program name.
63 if raw.trim().is_empty() {
64 None
65 } else {
66 Some(vec![raw.to_string()])
67 }
68 }
69
70 /// Run the external editor without touching terminal state. Exposed for tests.
71 ///
72 /// Returns:
73 /// - `Ok(EditorOutcome::Edited(new))` if the editor exited cleanly and the
74 /// contents differ from `seed`.
75 /// - `Ok(EditorOutcome::Unchanged)` if the editor exited cleanly but the
76 /// contents match `seed`.
77 /// - `Ok(EditorOutcome::Cancelled)` if the editor exited non-zero or could not
78 /// be spawned.
79 ///
80 /// The temp file is removed on every path because [`tempfile::NamedTempFile`]
81 /// is dropped at the end of the function.
82 pub fn run_editor_raw(seed: &str) -> io::Result<EditorOutcome> {
83 let mut tmp = Builder::new()
84 .prefix("deepseek-edit-")
85 .suffix(".md")
86 .tempfile()?;
87 tmp.write_all(seed.as_bytes())?;
88 tmp.flush()?;
89 let path = tmp.path().to_path_buf();
90
91 let raw = resolve_editor();
92 let parts = match split_command(&raw) {
93 Some(p) if !p.is_empty() => p,
94 _ => return Ok(EditorOutcome::Cancelled),
95 };
96
97 let mut cmd = Command::new(&parts[0]);
98 if parts.len() > 1 {
99 cmd.args(&parts[1..]);
100 }
101 cmd.arg(&path);
102
103 let status = match cmd.status() {
104 Ok(s) => s,
105 Err(_) => return Ok(EditorOutcome::Cancelled),
106 };
107 if !status.success() {
108 return Ok(EditorOutcome::Cancelled);
109 }
110
111 let new = fs::read_to_string(&path)?;
112 // tmp goes out of scope here — file is unlinked.
113 if new == seed {
114 Ok(EditorOutcome::Unchanged)
115 } else {
116 Ok(EditorOutcome::Edited(new))
117 }
118 }
119
120 /// Suspend the TUI, run the external editor on `current`, then re-enter the
121 /// TUI. Returns the new composer text iff the user saved changes.
122 ///
123 /// On any error (raw-mode toggle, IO, editor spawn failure), the function
124 /// still attempts to fully restore the terminal before returning.
125 pub(crate) fn spawn_editor_for_input(
126 terminal: &mut Terminal<ColorCompatBackend<Stdout>>,
127 use_alt_screen: bool,
128 use_mouse_capture: bool,
129 use_bracketed_paste: bool,
130 current: &str,
131 ) -> io::Result<EditorOutcome> {
132 // 1. Suspend.
133 // #443: pop keyboard enhancement flags first so the editor
134 // process doesn't inherit a half-configured input mode. Best-
135 // effort — matches the shutdown / panic paths in main.rs.
136 let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
137 let _ = disable_raw_mode();
138 if use_bracketed_paste {
139 let _ = execute!(terminal.backend_mut(), DisableBracketedPaste);
140 }
141 if use_mouse_capture {
142 let _ = execute!(terminal.backend_mut(), DisableMouseCapture);
143 }
144 if use_alt_screen {
145 let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
146 }
147
148 // 2. Run the editor (synchronous; inherits stdio).
149 let result = run_editor_raw(current);
150
151 // 3. Resume — best-effort restoration regardless of `result`.
152 if use_alt_screen {
153 let _ = execute!(terminal.backend_mut(), EnterAlternateScreen);
154 }
155 if use_mouse_capture {
156 let _ = execute!(terminal.backend_mut(), EnableMouseCapture);
157 }
158 if use_bracketed_paste {
159 let _ = execute!(terminal.backend_mut(), EnableBracketedPaste);
160 }
161 let _ = enable_raw_mode();
162 // Force a full repaint so a SIGWINCH during the edit doesn't leave the
163 // viewport stale.
164 let _ = terminal.clear();
165
166 result
167 }
168
169 #[cfg(test)]
170 mod tests {
171 use super::*;
172 use std::ffi::OsString;
173 use std::sync::Mutex;
174
175 /// Serialize tests that mutate process-global env vars.
176 static ENV_LOCK: Mutex<()> = Mutex::new(());
177
178 struct EnvGuard {
179 keys: Vec<(&'static str, Option<OsString>)>,
180 }
181 impl EnvGuard {
182 fn new(keys: &[&'static str]) -> Self {
183 let saved: Vec<_> = keys.iter().map(|k| (*k, env::var_os(k))).collect();
184 Self { keys: saved }
185 }
186 }
187 impl Drop for EnvGuard {
188 fn drop(&mut self) {
189 for (k, v) in &self.keys {
190 match v {
191 Some(val) => unsafe { env::set_var(k, val) },
192 None => unsafe { env::remove_var(k) },
193 }
194 }
195 }
196 }
197
198 #[test]
199 fn resolve_editor_prefers_visual_over_editor() {
200 let _lock = ENV_LOCK.lock().unwrap();
201 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
202 unsafe {
203 env::set_var("VISUAL", "vis-cmd");
204 env::set_var("EDITOR", "ed-cmd");
205 }
206 assert_eq!(resolve_editor(), "vis-cmd");
207 }
208
209 #[test]
210 fn resolve_editor_falls_back_to_vi() {
211 let _lock = ENV_LOCK.lock().unwrap();
212 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
213 unsafe {
214 env::remove_var("VISUAL");
215 env::remove_var("EDITOR");
216 }
217 assert_eq!(resolve_editor(), "vi");
218 }
219
220 /// Editor that immediately exits 0 without touching the file ⇒ Unchanged.
221 #[test]
222 #[cfg(unix)]
223 fn run_editor_unchanged_when_editor_is_noop() {
224 let _lock = ENV_LOCK.lock().unwrap();
225 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
226 unsafe {
227 env::remove_var("VISUAL");
228 env::set_var("EDITOR", "true");
229 }
230 let out = run_editor_raw("seed text").expect("editor ok");
231 assert_eq!(out, EditorOutcome::Unchanged);
232 }
233
234 /// Editor that exits non-zero ⇒ Cancelled.
235 #[test]
236 #[cfg(unix)]
237 fn run_editor_cancelled_on_nonzero_exit() {
238 let _lock = ENV_LOCK.lock().unwrap();
239 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
240 unsafe {
241 env::remove_var("VISUAL");
242 env::set_var("EDITOR", "false");
243 }
244 let out = run_editor_raw("seed").expect("call ok");
245 assert_eq!(out, EditorOutcome::Cancelled);
246 }
247
248 /// Spawning an editor binary that doesn't exist ⇒ Cancelled (graceful).
249 #[test]
250 #[cfg(unix)]
251 fn run_editor_cancelled_when_editor_missing() {
252 let _lock = ENV_LOCK.lock().unwrap();
253 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
254 unsafe {
255 env::remove_var("VISUAL");
256 env::set_var("EDITOR", "/nonexistent/deepseek-tui-test-editor");
257 }
258 let out = run_editor_raw("seed").expect("call ok");
259 assert_eq!(out, EditorOutcome::Cancelled);
260 }
261
262 /// Editor that rewrites the file ⇒ Edited(new).
263 #[test]
264 #[cfg(unix)]
265 fn run_editor_returns_edited_contents() {
266 use std::os::unix::fs::PermissionsExt;
267
268 let _lock = ENV_LOCK.lock().unwrap();
269 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
270 let dir = tempfile::tempdir().unwrap();
271 let script = dir.path().join("ed.sh");
272 fs::write(&script, "#!/bin/sh\nprintf 'edited body' > \"$1\"\n").unwrap();
273 let mut perms = fs::metadata(&script).unwrap().permissions();
274 perms.set_mode(0o755);
275 fs::set_permissions(&script, perms).unwrap();
276
277 unsafe {
278 env::remove_var("VISUAL");
279 env::set_var("EDITOR", script.to_string_lossy().to_string());
280 }
281 let out = run_editor_raw("seed body").expect("editor ok");
282 assert_eq!(out, EditorOutcome::Edited("edited body".to_string()));
283 }
284
285 /// Verify that the temp file is unlinked after `run_editor_raw` returns,
286 /// regardless of outcome. We test the success path with a script that
287 /// echoes the file path to a side channel before exiting.
288 #[test]
289 #[cfg(unix)]
290 fn run_editor_cleans_up_temp_file() {
291 use std::os::unix::fs::PermissionsExt;
292
293 let _lock = ENV_LOCK.lock().unwrap();
294 let _g = EnvGuard::new(&["VISUAL", "EDITOR"]);
295 let dir = tempfile::tempdir().unwrap();
296 let path_capture = dir.path().join("capture.txt");
297 let script = dir.path().join("ed.sh");
298 fs::write(
299 &script,
300 format!(
301 "#!/bin/sh\nprintf '%s' \"$1\" > \"{}\"\nprintf 'x' > \"$1\"\n",
302 path_capture.display()
303 ),
304 )
305 .unwrap();
306 let mut perms = fs::metadata(&script).unwrap().permissions();
307 perms.set_mode(0o755);
308 fs::set_permissions(&script, perms).unwrap();
309
310 unsafe {
311 env::remove_var("VISUAL");
312 env::set_var("EDITOR", script.to_string_lossy().to_string());
313 }
314 let _ = run_editor_raw("seed").expect("editor ok");
315
316 let captured = fs::read_to_string(&path_capture).expect("captured path");
317 assert!(!captured.is_empty(), "editor should have received a path");
318 assert!(
319 !std::path::Path::new(&captured).exists(),
320 "temp file {captured:?} should be cleaned up after run_editor_raw returns"
321 );
322 }
323 }
324
324 lines RUST