1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use std::time::Duration;
6
7use crate::utils::file_utils::read_file_with_context_sync;
8use anyhow::{Context, Result, anyhow};
9use ratatui::crossterm::ExecutableCommand;
10use ratatui::crossterm::event;
11use ratatui::crossterm::terminal::{
12 Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
13 enable_raw_mode, is_raw_mode_enabled,
14};
15use tempfile::NamedTempFile;
16use tracing::debug;
17use vtcode_commons::EditorTarget;
18
19#[derive(Debug)]
21pub struct TerminalAppResult {
22 pub exit_code: i32,
24 pub success: bool,
26}
27
28#[derive(Debug, Clone)]
30pub struct EditorLaunchConfig {
31 pub preferred_editor: Option<String>,
33 pub wait_for_editor: bool,
35}
36
37impl Default for EditorLaunchConfig {
38 fn default() -> Self {
39 Self {
40 preferred_editor: None,
41 wait_for_editor: true,
42 }
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum TerminalCommandStrategy {
48 Shell,
49 PowerShell,
50}
51
52pub struct TerminalAppLauncher {
54 workspace_root: PathBuf,
55}
56
57impl TerminalAppLauncher {
58 pub fn new(workspace_root: PathBuf) -> Self {
60 Self { workspace_root }
61 }
62
63 pub fn launch_editor(&self, file: Option<PathBuf>) -> Result<Option<String>> {
75 self.launch_editor_with_config(file, EditorLaunchConfig::default())
76 }
77
78 pub fn launch_editor_with_config(
82 &self,
83 file: Option<PathBuf>,
84 config: EditorLaunchConfig,
85 ) -> Result<Option<String>> {
86 let target = file.map(|path| EditorTarget::new(path, None));
87 self.launch_editor_target_with_config(target, config)
88 }
89
90 pub fn launch_editor_target_with_config(
94 &self,
95 target: Option<EditorTarget>,
96 config: EditorLaunchConfig,
97 ) -> Result<Option<String>> {
98 let (target, is_temp) = if let Some(target) = target {
99 (target, false)
100 } else {
101 let temp =
103 NamedTempFile::new().context("failed to create temporary file for editing")?;
104 let (_, path) = temp.keep().context("failed to persist temporary file")?;
106 (EditorTarget::new(path, None), true)
107 };
108 let file_path = target.path().to_path_buf();
109 let mut wait_for_editor = is_temp || config.wait_for_editor;
110 let preferred_editor = config
111 .preferred_editor
112 .as_deref()
113 .map(str::trim)
114 .filter(|value| !value.is_empty())
115 .map(ToOwned::to_owned);
116
117 debug!(
118 path = %file_path.display(),
119 wait_for_editor,
120 "launching editor"
121 );
122
123 let mut cmd = if let Some(preferred) = preferred_editor.as_deref() {
124 debug!("using configured preferred editor command: {}", preferred);
125 Self::build_editor_command_from_string(preferred, &target, wait_for_editor)
126 .with_context(|| {
127 format!(
128 "failed to parse tools.editor.preferred_editor '{}'",
129 preferred
130 )
131 })?
132 } else if let Some(env_command) = Self::editor_command_from_env() {
133 debug!("using editor command from environment: {}", env_command);
134 Self::build_editor_command_from_string(&env_command, &target, wait_for_editor)
135 .with_context(|| format!("failed to parse editor command '{}'", env_command))?
136 } else {
137 debug!("EDITOR/VISUAL not set, searching for available editors");
139 Self::try_common_editors(&target, wait_for_editor).context(
140 "failed to detect editor: set tools.editor.preferred_editor, \
141 or set EDITOR/VISUAL, or install an editor in PATH",
142 )?
143 };
144
145 if !wait_for_editor {
146 let program = cmd.get_program().to_string_lossy().to_string();
147 if Self::program_requires_terminal(&program) {
148 debug!(
149 program = %program,
150 "forcing synchronous launch for terminal-based editor"
151 );
152 wait_for_editor = true;
153 }
154 }
155
156 if wait_for_editor {
157 self.suspend_terminal_for_command(|| {
158 let status = cmd
159 .current_dir(&self.workspace_root)
160 .status()
161 .context("failed to spawn editor")?;
162
163 if !status.success() {
164 return Err(anyhow!(
165 "editor exited with non-zero status: {}",
166 status.code().unwrap_or(-1)
167 ));
168 }
169
170 Ok(())
171 })?;
172 } else {
173 cmd.current_dir(&self.workspace_root)
174 .stdin(Stdio::null())
175 .stdout(Stdio::null())
176 .stderr(Stdio::null())
177 .spawn()
178 .context("failed to spawn editor")?;
179 }
180
181 let content = if is_temp {
183 let content = read_file_with_context_sync(&file_path, "edited temporary file")
184 .context("failed to read edited content from temporary file")?;
185 fs::remove_file(&file_path).context("failed to remove temporary file")?;
186 Some(content)
187 } else {
188 None
189 };
190
191 Ok(content)
192 }
193
194 fn build_editor_command_from_string(
195 command: &str,
196 target: &EditorTarget,
197 wait_for_editor: bool,
198 ) -> Result<Command> {
199 let tokens = shell_words::split(command)
200 .with_context(|| format!("invalid editor command: {}", command))?;
201 let (program, args) = tokens
202 .split_first()
203 .ok_or_else(|| anyhow!("editor command cannot be empty"))?;
204 let adapter = EditorAdapter::from_program(program);
205 let mut cmd = Command::new(program);
206 cmd.args(filtered_editor_args(adapter, args, wait_for_editor));
207 Self::append_editor_target_args(&mut cmd, program, target);
208 Ok(cmd)
209 }
210
211 fn try_common_editors(target: &EditorTarget, wait_for_editor: bool) -> Result<Command> {
213 let candidates = if cfg!(target_os = "windows") {
214 vec![
215 "code --wait",
216 "code",
217 "zed --wait",
218 "zed",
219 "subl -w",
220 "subl",
221 "notepad++",
222 "notepad",
223 ]
224 } else if cfg!(target_os = "macos") {
225 vec![
226 "code --wait",
227 "code",
228 "zed --wait",
229 "zed",
230 "subl -w",
231 "subl",
232 "mate -w",
233 "mate",
234 "open -a TextEdit",
235 "nvim",
236 "vim",
237 "vi",
238 "nano",
239 "emacs",
240 ]
241 } else {
242 vec![
243 "code --wait",
244 "code",
245 "zed --wait",
246 "zed",
247 "subl -w",
248 "subl",
249 "mate -w",
250 "mate",
251 "nvim",
252 "vim",
253 "vi",
254 "nano",
255 "emacs",
256 ]
257 };
258
259 for candidate in candidates {
260 let tokens = match shell_words::split(candidate) {
261 Ok(tokens) => tokens,
262 Err(_) => continue,
263 };
264 let Some(program) = tokens.first() else {
265 continue;
266 };
267 if which::which(program).is_ok() {
268 debug!("found fallback editor: {}", candidate);
269 return Self::build_editor_command_from_string(candidate, target, wait_for_editor);
270 }
271 }
272
273 Err(anyhow!(
274 "no editor found in PATH. Install an editor (e.g. nvim, code, zed, emacs), \
275 or configure tools.editor.preferred_editor"
276 ))
277 }
278
279 fn editor_command_from_env() -> Option<String> {
280 ["VISUAL", "EDITOR"]
281 .into_iter()
282 .find_map(|key| std::env::var(key).ok())
283 .map(|value| value.trim().to_string())
284 .filter(|value| !value.is_empty())
285 }
286
287 fn program_requires_terminal(program: &str) -> bool {
288 let normalized = Path::new(program)
289 .file_name()
290 .and_then(|name| name.to_str())
291 .unwrap_or(program)
292 .to_ascii_lowercase();
293
294 matches!(
295 normalized.as_str(),
296 "vi" | "vim" | "nvim" | "nano" | "emacs" | "pico" | "hx" | "helix"
297 )
298 }
299
300 fn append_editor_target_args(cmd: &mut Command, program: &str, target: &EditorTarget) {
301 let adapter = EditorAdapter::from_program(program);
302 let file_path = target.path();
303
304 match (adapter, target.point()) {
305 (EditorAdapter::Vscode, Some(point)) => {
306 cmd.arg("-g");
307 cmd.arg(format_location_arg(file_path, point.line, point.column));
308 }
309 (EditorAdapter::ColonLocation, Some(point)) => {
310 cmd.arg(format_location_arg(file_path, point.line, point.column));
311 }
312 (EditorAdapter::Vim, Some(point)) => {
313 if let Some(column) = point.column {
314 cmd.arg(format!("+call cursor({},{})", point.line, column));
315 } else {
316 cmd.arg(format!("+{}", point.line));
317 }
318 cmd.arg(file_path);
319 }
320 _ => {
321 cmd.arg(file_path);
322 }
323 }
324 }
325
326 fn suspend_terminal_for_command<F, T>(&self, f: F) -> Result<T>
347 where
348 F: FnOnce() -> Result<T>,
349 {
350 let was_raw_mode = match is_raw_mode_enabled() {
351 Ok(enabled) => enabled,
352 Err(error) => {
353 debug!(%error, "failed to query raw mode status; assuming non-raw terminal state");
354 false
355 }
356 };
357
358 if was_raw_mode {
359 io::stdout()
361 .execute(LeaveAlternateScreen)
362 .context("failed to leave alternate screen")?;
363
364 while event::poll(Duration::from_millis(0)).unwrap_or(false) {
368 let _ = event::read();
369 }
370
371 disable_raw_mode().context("failed to disable raw mode")?;
373 }
374
375 let result = f();
377
378 if was_raw_mode {
379 let mut restore_errors = Vec::new();
382
383 if let Err(error) = enable_raw_mode() {
384 restore_errors.push(format!("failed to re-enable raw mode: {}", error));
385 }
386
387 if let Err(error) = io::stdout().execute(EnterAlternateScreen) {
388 restore_errors.push(format!("failed to re-enter alternate screen: {}", error));
389 }
390
391 if let Err(error) = io::stdout().execute(Clear(ClearType::All)) {
394 restore_errors.push(format!("failed to clear terminal: {}", error));
395 }
396
397 if !restore_errors.is_empty() {
398 let restore_summary = restore_errors.join("; ");
399 return match result {
400 Ok(_) => Err(anyhow!("terminal restore failed: {}", restore_summary)),
401 Err(command_error) => Err(command_error
402 .context(format!("terminal restore also failed: {}", restore_summary))),
403 };
404 }
405 }
406
407 result
408 }
409
410 pub fn run_command_with_strategy(
411 &self,
412 command: &str,
413 strategy: TerminalCommandStrategy,
414 ) -> Result<TerminalAppResult> {
415 self.suspend_terminal_for_command(|| {
416 let mut cmd = match strategy {
417 TerminalCommandStrategy::Shell => {
418 #[cfg(target_os = "windows")]
419 {
420 let mut command_builder = Command::new("cmd");
421 command_builder.arg("/C").arg(command);
422 command_builder
423 }
424 #[cfg(not(target_os = "windows"))]
425 {
426 let mut command_builder = Command::new("/bin/sh");
427 command_builder.arg("-lc").arg(command);
428 command_builder
429 }
430 }
431 TerminalCommandStrategy::PowerShell => {
432 let mut command_builder = if cfg!(target_os = "windows") {
433 Command::new("powershell")
434 } else {
435 Command::new("pwsh")
436 };
437 command_builder
438 .arg("-NoLogo")
439 .arg("-NoProfile")
440 .arg("-Command")
441 .arg(command);
442 command_builder
443 }
444 };
445
446 let status = cmd
447 .current_dir(&self.workspace_root)
448 .status()
449 .with_context(|| format!("failed to spawn update command: {}", command))?;
450
451 Ok(TerminalAppResult {
452 exit_code: status.code().unwrap_or(-1),
453 success: status.success(),
454 })
455 })
456 }
457
458 pub fn launch_git_interface(&self) -> Result<()> {
467 self.suspend_terminal_for_command(|| {
468 let git_cmd = if which::which("lazygit").is_ok() {
469 "lazygit"
470 } else {
471 "git"
472 };
473
474 let status = Command::new(git_cmd)
475 .current_dir(&self.workspace_root)
476 .status()
477 .with_context(|| format!("failed to spawn {}", git_cmd))?;
478
479 if !status.success() {
480 return Err(anyhow!(
481 "{} exited with non-zero status: {}",
482 git_cmd,
483 status.code().unwrap_or(-1)
484 ));
485 }
486
487 Ok(())
488 })
489 }
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq)]
493enum EditorAdapter {
494 Plain,
495 Vscode,
496 ColonLocation,
497 Mate,
498 MacOpen,
499 Vim,
500}
501
502impl EditorAdapter {
503 fn from_program(program: &str) -> Self {
504 let program = Path::new(program)
505 .file_name()
506 .and_then(|name| name.to_str())
507 .unwrap_or(program)
508 .to_ascii_lowercase();
509
510 match program.as_str() {
511 "code" | "code-insiders" => Self::Vscode,
512 "zed" | "subl" => Self::ColonLocation,
513 "mate" => Self::Mate,
514 "open" => Self::MacOpen,
515 "nvim" | "vim" | "vi" => Self::Vim,
516 _ => Self::Plain,
517 }
518 }
519}
520
521fn filtered_editor_args(
522 adapter: EditorAdapter,
523 args: &[String],
524 wait_for_editor: bool,
525) -> Vec<String> {
526 if wait_for_editor {
527 return args.to_vec();
528 }
529
530 args.iter()
531 .filter(|arg| !matches_wait_flag(adapter, arg))
532 .cloned()
533 .collect()
534}
535
536fn matches_wait_flag(adapter: EditorAdapter, arg: &str) -> bool {
537 match adapter {
538 EditorAdapter::Vscode => arg == "--wait",
539 EditorAdapter::ColonLocation | EditorAdapter::Mate => arg == "--wait" || arg == "-w",
540 EditorAdapter::MacOpen => arg == "-W",
541 EditorAdapter::Plain | EditorAdapter::Vim => false,
542 }
543}
544
545fn format_location_arg(path: &Path, line: usize, column: Option<usize>) -> String {
546 let column = column.unwrap_or(1);
547 format!("{}:{}:{}", path.display(), line, column)
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553 use std::ffi::OsStr;
554
555 #[test]
556 fn test_launcher_creation() {
557 let launcher = TerminalAppLauncher::new(PathBuf::from("/tmp"));
558 assert_eq!(launcher.workspace_root, PathBuf::from("/tmp"));
560 }
561
562 #[test]
563 fn test_build_editor_command_supports_arguments() {
564 let command = TerminalAppLauncher::build_editor_command_from_string(
565 "code --wait",
566 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
567 true,
568 )
569 .expect("command should parse");
570 let args: Vec<String> = command
571 .get_args()
572 .map(|value| value.to_string_lossy().to_string())
573 .collect();
574
575 assert_eq!(command.get_program(), OsStr::new("code"));
576 assert_eq!(args, vec!["--wait".to_string(), "/tmp/test.rs".to_string()]);
577 }
578
579 #[test]
580 fn test_build_editor_command_rejects_empty_string() {
581 let result = TerminalAppLauncher::build_editor_command_from_string(
582 " ",
583 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
584 true,
585 );
586 result.unwrap_err();
587 }
588
589 #[test]
590 fn test_build_editor_command_uses_vscode_go_to_location() {
591 let command = TerminalAppLauncher::build_editor_command_from_string(
592 "code --wait",
593 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
594 true,
595 )
596 .expect("command should parse");
597 let args: Vec<String> = command
598 .get_args()
599 .map(|value| value.to_string_lossy().to_string())
600 .collect();
601
602 assert_eq!(
603 args,
604 vec![
605 "--wait".to_string(),
606 "-g".to_string(),
607 "/tmp/test.rs:12:4".to_string()
608 ]
609 );
610 }
611
612 #[test]
613 fn test_build_editor_command_uses_colon_location_for_zed() {
614 let command = TerminalAppLauncher::build_editor_command_from_string(
615 "zed",
616 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12".to_string())),
617 true,
618 )
619 .expect("command should parse");
620 let args: Vec<String> = command
621 .get_args()
622 .map(|value| value.to_string_lossy().to_string())
623 .collect();
624
625 assert_eq!(args, vec!["/tmp/test.rs:12:1".to_string()]);
626 }
627
628 #[test]
629 fn test_build_editor_command_uses_cursor_command_for_vim() {
630 let command = TerminalAppLauncher::build_editor_command_from_string(
631 "nvim",
632 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
633 true,
634 )
635 .expect("command should parse");
636 let args: Vec<String> = command
637 .get_args()
638 .map(|value| value.to_string_lossy().to_string())
639 .collect();
640
641 assert_eq!(
642 args,
643 vec!["+call cursor(12,4)".to_string(), "/tmp/test.rs".to_string()]
644 );
645 }
646
647 #[test]
648 fn test_build_editor_command_degrades_unknown_commands_to_file_only() {
649 let command = TerminalAppLauncher::build_editor_command_from_string(
650 "custom-editor --flag",
651 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
652 true,
653 )
654 .expect("command should parse");
655 let args: Vec<String> = command
656 .get_args()
657 .map(|value| value.to_string_lossy().to_string())
658 .collect();
659
660 assert_eq!(args, vec!["--flag".to_string(), "/tmp/test.rs".to_string()]);
661 }
662
663 #[test]
664 fn test_build_editor_command_strips_vscode_wait_flag_when_not_waiting() {
665 let command = TerminalAppLauncher::build_editor_command_from_string(
666 "code --wait",
667 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
668 false,
669 )
670 .expect("command should parse");
671 let args: Vec<String> = command
672 .get_args()
673 .map(|value| value.to_string_lossy().to_string())
674 .collect();
675
676 assert_eq!(
677 args,
678 vec!["-g".to_string(), "/tmp/test.rs:12:4".to_string()]
679 );
680 }
681
682 #[test]
683 fn test_build_editor_command_strips_sublime_wait_flag_when_not_waiting() {
684 let command = TerminalAppLauncher::build_editor_command_from_string(
685 "subl -w",
686 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
687 false,
688 )
689 .expect("command should parse");
690 let args: Vec<String> = command
691 .get_args()
692 .map(|value| value.to_string_lossy().to_string())
693 .collect();
694
695 assert_eq!(args, vec!["/tmp/test.rs".to_string()]);
696 }
697
698 #[test]
699 fn test_program_requires_terminal_detects_terminal_editors() {
700 assert!(TerminalAppLauncher::program_requires_terminal("nvim"));
701 assert!(TerminalAppLauncher::program_requires_terminal(
702 "/usr/bin/vim"
703 ));
704 assert!(TerminalAppLauncher::program_requires_terminal("helix"));
705 assert!(!TerminalAppLauncher::program_requires_terminal("code"));
706 assert!(!TerminalAppLauncher::program_requires_terminal("zed"));
707 }
708}