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, enable_raw_mode,
13 is_raw_mode_enabled,
14};
15use tempfile::NamedTempFile;
16use tracing::debug;
17use vtcode_commons::{EditorTarget, MultiErrors};
18
19const TERMINAL_EVENT_DRAIN_WAIT: Duration = Duration::from_millis(10);
20
21#[derive(Debug)]
23pub struct TerminalAppResult {
24 pub exit_code: i32,
26 pub success: bool,
28}
29
30#[derive(Debug, Clone)]
32pub struct EditorLaunchConfig {
33 pub preferred_editor: Option<String>,
35 pub wait_for_editor: bool,
37}
38
39impl Default for EditorLaunchConfig {
40 fn default() -> Self {
41 Self { preferred_editor: None, wait_for_editor: true }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum TerminalCommandStrategy {
47 Shell,
48 PowerShell,
49}
50
51pub struct TerminalAppLauncher {
53 workspace_root: PathBuf,
54}
55
56impl TerminalAppLauncher {
57 pub fn new(workspace_root: PathBuf) -> Self {
59 Self { workspace_root }
60 }
61
62 pub fn launch_editor(&self, file: Option<PathBuf>) -> Result<Option<String>> {
74 self.launch_editor_with_config(file, EditorLaunchConfig::default())
75 }
76
77 pub fn launch_editor_with_config(
81 &self,
82 file: Option<PathBuf>,
83 config: EditorLaunchConfig,
84 ) -> Result<Option<String>> {
85 let target = file.map(|path| EditorTarget::new(path, None));
86 self.launch_editor_target_with_config(target, config)
87 }
88
89 pub fn launch_editor_target_with_config(
93 &self,
94 target: Option<EditorTarget>,
95 config: EditorLaunchConfig,
96 ) -> Result<Option<String>> {
97 self.launch_editor_target_with_config_and_reuse(target, config, false)
98 }
99
100 pub fn launch_editor_target_non_waiting(
105 &self,
106 target: EditorTarget,
107 preferred_editor: Option<String>,
108 ) -> Result<()> {
109 self.launch_editor_target_with_config_and_reuse(
110 Some(target),
111 EditorLaunchConfig { preferred_editor, wait_for_editor: false },
112 true,
113 )
114 .map(|_| ())
115 }
116
117 fn launch_editor_target_with_config_and_reuse(
118 &self,
119 target: Option<EditorTarget>,
120 config: EditorLaunchConfig,
121 reuse_existing_window: bool,
122 ) -> Result<Option<String>> {
123 let (target, is_temp) = if let Some(target) = target {
124 (target, false)
125 } else {
126 let temp = NamedTempFile::new().context("failed to create temporary file for editing")?;
128 let (_, path) = temp.keep().context("failed to persist temporary file")?;
130 (EditorTarget::new(path, None), true)
131 };
132 let file_path = target.path().to_path_buf();
133 let mut wait_for_editor = is_temp || config.wait_for_editor;
134 let preferred_editor = config
135 .preferred_editor
136 .as_deref()
137 .map(str::trim)
138 .filter(|value| !value.is_empty())
139 .map(ToOwned::to_owned);
140
141 debug!(
142 path = %file_path.display(),
143 wait_for_editor,
144 "launching editor"
145 );
146
147 let mut cmd = if let Some(preferred) = preferred_editor.as_deref() {
148 debug!("using configured preferred editor command: {}", preferred);
149 Self::build_editor_command_from_string_with_reuse(
150 preferred,
151 &target,
152 wait_for_editor,
153 reuse_existing_window,
154 )
155 .with_context(|| format!("failed to parse tools.editor.preferred_editor '{preferred}'"))?
156 } else if let Some(env_command) = Self::editor_command_from_env() {
157 debug!("using editor command from environment: {}", env_command);
158 Self::build_editor_command_from_string_with_reuse(
159 &env_command,
160 &target,
161 wait_for_editor,
162 reuse_existing_window,
163 )
164 .with_context(|| format!("failed to parse editor command '{env_command}'"))?
165 } else {
166 debug!("EDITOR/VISUAL not set, searching for available editors");
168 Self::try_common_editors(&target, wait_for_editor, reuse_existing_window).context(
169 "failed to detect editor: set tools.editor.preferred_editor, \
170 or set EDITOR/VISUAL, or install an editor in PATH",
171 )?
172 };
173
174 if !wait_for_editor {
175 let program = cmd.get_program().to_string_lossy().to_string();
176 if Self::program_requires_terminal(&program) {
177 debug!(
178 program = %program,
179 "forcing synchronous launch for terminal-based editor"
180 );
181 wait_for_editor = true;
182 }
183 }
184
185 if wait_for_editor {
186 self.suspend_terminal_for_command(|| {
187 let status = cmd
188 .current_dir(&self.workspace_root)
189 .status()
190 .context("failed to spawn editor")?;
191
192 if !status.success() {
193 return Err(anyhow!("editor exited with non-zero status: {}", status.code().unwrap_or(-1)));
194 }
195
196 Ok(())
197 })?;
198 } else {
199 let mut child = cmd
200 .current_dir(&self.workspace_root)
201 .stdin(Stdio::null())
202 .stdout(Stdio::null())
203 .stderr(Stdio::null())
204 .spawn()
205 .context("failed to spawn editor")?;
206 std::thread::Builder::new()
207 .name("vtcode-editor-reaper".to_string())
208 .spawn(move || {
209 if let Err(error) = child.wait() {
210 debug!(%error, "failed to reap detached editor process");
211 }
212 })
213 .context("failed to supervise detached editor process")?;
214 }
215
216 let content = if is_temp {
218 let content = read_file_with_context_sync(&file_path, "edited temporary file")
219 .context("failed to read edited content from temporary file")?;
220 fs::remove_file(&file_path).context("failed to remove temporary file")?;
221 Some(content)
222 } else {
223 None
224 };
225
226 Ok(content)
227 }
228
229 #[cfg(test)]
230 fn build_editor_command_from_string(
231 command: &str,
232 target: &EditorTarget,
233 wait_for_editor: bool,
234 ) -> Result<Command> {
235 Self::build_editor_command_from_string_with_reuse(command, target, wait_for_editor, false)
236 }
237
238 fn build_editor_command_from_string_with_reuse(
239 command: &str,
240 target: &EditorTarget,
241 wait_for_editor: bool,
242 reuse_existing_window: bool,
243 ) -> Result<Command> {
244 let tokens = shell_words::split(command).with_context(|| format!("invalid editor command: {command}"))?;
245 let (program, args) = tokens.split_first().ok_or_else(|| anyhow!("editor command cannot be empty"))?;
246 let adapter = EditorAdapter::from_program(program);
247 let mut cmd = Command::new(program);
248 cmd.args(editor_args_with_wait_policy(adapter, args, wait_for_editor));
249 if reuse_existing_window
250 && matches!(adapter, EditorAdapter::Vscode)
251 && !args.iter().any(|arg| arg == "--reuse-window")
252 {
253 cmd.arg("--reuse-window");
254 }
255 Self::append_editor_target_args(&mut cmd, program, target);
256 Ok(cmd)
257 }
258
259 fn try_common_editors(
261 target: &EditorTarget,
262 wait_for_editor: bool,
263 reuse_existing_window: bool,
264 ) -> Result<Command> {
265 let candidate = Self::common_editor_command().ok_or_else(|| {
266 anyhow!(
267 "no editor found in PATH. Install an editor (e.g. nvim, code, zed, emacs), \
268 or configure tools.editor.preferred_editor"
269 )
270 })?;
271 debug!("found fallback editor: {}", candidate);
272 Self::build_editor_command_from_string_with_reuse(candidate, target, wait_for_editor, reuse_existing_window)
273 }
274
275 fn editor_command_from_env() -> Option<String> {
276 ["VISUAL", "EDITOR"]
277 .into_iter()
278 .find_map(|key| std::env::var(key).ok())
279 .map(|value| value.trim().to_string())
280 .filter(|value| !value.is_empty())
281 }
282
283 pub fn editor_command_requires_terminal(command: Option<&str>) -> bool {
285 if let Some(command) = command.filter(|value| !value.trim().is_empty()) {
286 return Self::program_from_command(command)
287 .is_some_and(|program| Self::program_requires_terminal(&program));
288 }
289
290 if let Some(environment_command) = Self::editor_command_from_env() {
291 return Self::program_from_command(&environment_command)
292 .is_some_and(|program| Self::program_requires_terminal(&program));
293 }
294
295 Self::common_editor_command()
296 .and_then(Self::program_from_command)
297 .is_some_and(|program| Self::program_requires_terminal(&program))
298 }
299
300 fn common_editor_command() -> Option<&'static str> {
301 common_editor_candidates().iter().copied().find(|candidate| {
302 Self::program_from_command(candidate).is_some_and(|program| which::which(program).is_ok())
303 })
304 }
305
306 fn program_from_command(command: &str) -> Option<String> {
307 shell_words::split(command).ok()?.into_iter().next()
308 }
309
310 fn program_requires_terminal(program: &str) -> bool {
311 let normalized = Path::new(program)
312 .file_name()
313 .and_then(|name| name.to_str())
314 .unwrap_or(program)
315 .to_ascii_lowercase();
316
317 matches!(normalized.as_str(), "vi" | "vim" | "nvim" | "nano" | "emacs" | "pico" | "hx" | "helix")
318 }
319
320 fn append_editor_target_args(cmd: &mut Command, program: &str, target: &EditorTarget) {
321 let adapter = EditorAdapter::from_program(program);
322 let file_path = target.path();
323
324 match (adapter, target.point()) {
325 (EditorAdapter::Vscode, Some(point)) => {
326 cmd.arg("-g");
327 cmd.arg(format_location_arg(file_path, point.line, point.column));
328 }
329 (EditorAdapter::ColonLocation, Some(point)) => {
330 cmd.arg(format_location_arg(file_path, point.line, point.column));
331 }
332 (EditorAdapter::Vim, Some(point)) => {
333 if let Some(column) = point.column {
334 cmd.arg(format!("+call cursor({},{})", point.line, column));
335 } else {
336 cmd.arg(format!("+{}", point.line));
337 }
338 cmd.arg(file_path);
339 }
340 _ => {
341 cmd.arg(file_path);
342 }
343 }
344 }
345
346 fn suspend_terminal_for_command<F, T>(&self, f: F) -> Result<T>
367 where
368 F: FnOnce() -> Result<T>,
369 {
370 let was_raw_mode = match is_raw_mode_enabled() {
371 Ok(enabled) => enabled,
372 Err(error) => {
373 debug!(%error, "failed to query raw mode status; assuming non-raw terminal state");
374 false
375 }
376 };
377
378 if was_raw_mode {
379 let _ = io::stdout().execute(Clear(ClearType::All));
384 io::stdout()
386 .execute(LeaveAlternateScreen)
387 .context("failed to leave alternate screen")?;
388
389 drain_terminal_events();
393
394 disable_raw_mode().context("failed to disable raw mode")?;
396 }
397
398 let result = f();
400
401 if was_raw_mode {
402 let mut restore_errors: MultiErrors<String> = MultiErrors::new();
405
406 if let Err(error) = enable_raw_mode() {
407 restore_errors.push(format!("failed to re-enable raw mode: {error}"));
408 }
409
410 if let Err(error) = io::stdout().execute(EnterAlternateScreen) {
411 restore_errors.push(format!("failed to re-enter alternate screen: {error}"));
412 }
413
414 drain_terminal_events();
418
419 if let Err(error) = io::stdout().execute(Clear(ClearType::All)) {
422 restore_errors.push(format!("failed to clear terminal: {error}"));
423 }
424
425 if !restore_errors.is_empty() {
426 return match result {
427 Ok(_) => Err(anyhow!("terminal restore failed: {restore_errors}")),
428 Err(command_error) => {
429 Err(command_error.context(format!("terminal restore also failed: {restore_errors}")))
430 }
431 };
432 }
433 }
434
435 result
436 }
437
438 pub fn run_command_with_strategy(
439 &self,
440 command: &str,
441 strategy: TerminalCommandStrategy,
442 ) -> Result<TerminalAppResult> {
443 self.suspend_terminal_for_command(|| {
444 let mut cmd = match strategy {
445 TerminalCommandStrategy::Shell => {
446 #[cfg(target_os = "windows")]
447 {
448 let mut command_builder = Command::new("cmd");
449 command_builder.arg("/C").arg(command);
450 command_builder
451 }
452 #[cfg(not(target_os = "windows"))]
453 {
454 let mut command_builder = Command::new("/bin/sh");
455 command_builder.arg("-lc").arg(command);
456 command_builder
457 }
458 }
459 TerminalCommandStrategy::PowerShell => {
460 let mut command_builder = if cfg!(target_os = "windows") {
461 Command::new("powershell")
462 } else {
463 Command::new("pwsh")
464 };
465 command_builder.arg("-NoLogo").arg("-NoProfile").arg("-Command").arg(command);
466 command_builder
467 }
468 };
469
470 let status = cmd
471 .current_dir(&self.workspace_root)
472 .status()
473 .with_context(|| format!("failed to spawn update command: {command}"))?;
474
475 Ok(TerminalAppResult {
476 exit_code: status.code().unwrap_or(-1),
477 success: status.success(),
478 })
479 })
480 }
481
482 pub fn launch_git_interface(&self) -> Result<()> {
491 self.suspend_terminal_for_command(|| {
492 let git_cmd = if which::which("lazygit").is_ok() {
493 "lazygit"
494 } else {
495 "git"
496 };
497
498 let status = Command::new(git_cmd)
499 .current_dir(&self.workspace_root)
500 .status()
501 .with_context(|| format!("failed to spawn {git_cmd}"))?;
502
503 if !status.success() {
504 return Err(anyhow!("{} exited with non-zero status: {}", git_cmd, status.code().unwrap_or(-1)));
505 }
506
507 Ok(())
508 })
509 }
510}
511
512fn drain_terminal_events() {
513 if event::poll(TERMINAL_EVENT_DRAIN_WAIT).unwrap_or(false) {
514 let _ = event::read();
515 }
516
517 while event::poll(Duration::from_millis(0)).unwrap_or(false) {
518 let _ = event::read();
519 }
520}
521
522#[derive(Debug, Clone, Copy, PartialEq, Eq)]
523enum EditorAdapter {
524 Plain,
525 Vscode,
526 ColonLocation,
527 Mate,
528 MacOpen,
529 Vim,
530}
531
532impl EditorAdapter {
533 fn from_program(program: &str) -> Self {
534 let program = Path::new(program)
535 .file_name()
536 .and_then(|name| name.to_str())
537 .unwrap_or(program)
538 .to_ascii_lowercase();
539
540 match program.as_str() {
541 "code" | "code-insiders" => Self::Vscode,
542 "zed" | "subl" => Self::ColonLocation,
543 "mate" => Self::Mate,
544 "open" => Self::MacOpen,
545 "nvim" | "vim" | "vi" => Self::Vim,
546 _ => Self::Plain,
547 }
548 }
549
550 fn wait_flags(self) -> &'static [&'static str] {
555 match self {
556 EditorAdapter::Vscode | EditorAdapter::ColonLocation | EditorAdapter::Mate => &["--wait", "-w"],
557 EditorAdapter::MacOpen => &["-W"],
558 EditorAdapter::Plain | EditorAdapter::Vim => &[],
559 }
560 }
561
562 fn default_wait_flag(self) -> Option<&'static str> {
567 self.wait_flags().first().copied()
568 }
569}
570
571fn editor_args_with_wait_policy(adapter: EditorAdapter, args: &[String], wait_for_editor: bool) -> Vec<String> {
578 if !wait_for_editor {
579 return args.iter().filter(|arg| !matches_wait_flag(adapter, arg)).cloned().collect();
580 }
581
582 let mut effective = args.to_vec();
583 if !args.iter().any(|arg| matches_wait_flag(adapter, arg))
584 && let Some(flag) = adapter.default_wait_flag()
585 {
586 effective.push(flag.to_string());
587 }
588 effective
589}
590
591fn common_editor_candidates() -> &'static [&'static str] {
592 if cfg!(target_os = "windows") {
593 &[
594 "code --wait",
595 "code",
596 "zed --wait",
597 "zed",
598 "subl -w",
599 "subl",
600 "notepad++",
601 "notepad",
602 ]
603 } else if cfg!(target_os = "macos") {
604 &[
605 "code --wait",
606 "code",
607 "zed --wait",
608 "zed",
609 "subl -w",
610 "subl",
611 "mate -w",
612 "mate",
613 "open -a TextEdit",
614 "nvim",
615 "vim",
616 "vi",
617 "nano",
618 "emacs",
619 ]
620 } else {
621 &[
622 "code --wait",
623 "code",
624 "zed --wait",
625 "zed",
626 "subl -w",
627 "subl",
628 "mate -w",
629 "mate",
630 "nvim",
631 "vim",
632 "vi",
633 "nano",
634 "emacs",
635 ]
636 }
637}
638
639fn matches_wait_flag(adapter: EditorAdapter, arg: &str) -> bool {
640 adapter.wait_flags().contains(&arg)
641}
642
643fn format_location_arg(path: &Path, line: usize, column: Option<usize>) -> String {
644 let column = column.unwrap_or(1);
645 format!("{}:{}:{}", path.display(), line, column)
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use std::ffi::OsStr;
652
653 #[test]
654 fn test_launcher_creation() {
655 let launcher = TerminalAppLauncher::new(PathBuf::from("/tmp"));
656 assert_eq!(launcher.workspace_root, PathBuf::from("/tmp"));
658 }
659
660 #[test]
661 fn test_build_editor_command_supports_arguments() {
662 let command = TerminalAppLauncher::build_editor_command_from_string(
663 "code --wait",
664 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
665 true,
666 )
667 .expect("command should parse");
668 let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
669
670 assert_eq!(command.get_program(), OsStr::new("code"));
671 assert_eq!(args, vec!["--wait".to_string(), "/tmp/test.rs".to_string()]);
672 }
673
674 #[test]
675 fn test_build_editor_command_rejects_empty_string() {
676 let result = TerminalAppLauncher::build_editor_command_from_string(
677 " ",
678 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
679 true,
680 );
681 result.unwrap_err();
682 }
683
684 #[test]
685 fn test_build_editor_command_uses_vscode_go_to_location() {
686 let command = TerminalAppLauncher::build_editor_command_from_string(
687 "code --wait",
688 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
689 true,
690 )
691 .expect("command should parse");
692 let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
693
694 assert_eq!(args, vec!["--wait".to_string(), "-g".to_string(), "/tmp/test.rs:12:4".to_string()]);
695 }
696
697 #[test]
698 fn test_build_editor_command_uses_colon_location_for_zed() {
699 let command = TerminalAppLauncher::build_editor_command_from_string(
700 "zed",
701 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12".to_string())),
702 true,
703 )
704 .expect("command should parse");
705 let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
706
707 assert_eq!(args, vec!["--wait".to_string(), "/tmp/test.rs:12:1".to_string()]);
710 }
711
712 #[test]
713 fn test_build_editor_command_uses_cursor_command_for_vim() {
714 let command = TerminalAppLauncher::build_editor_command_from_string(
715 "nvim",
716 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
717 true,
718 )
719 .expect("command should parse");
720 let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
721
722 assert_eq!(args, vec!["+call cursor(12,4)".to_string(), "/tmp/test.rs".to_string()]);
723 }
724
725 #[test]
726 fn test_build_editor_command_degrades_unknown_commands_to_file_only() {
727 let command = TerminalAppLauncher::build_editor_command_from_string(
728 "custom-editor --flag",
729 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
730 true,
731 )
732 .expect("command should parse");
733 let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
734
735 assert_eq!(args, vec!["--flag".to_string(), "/tmp/test.rs".to_string()]);
736 }
737
738 #[test]
739 fn test_build_editor_command_strips_vscode_wait_flag_when_not_waiting() {
740 let command = TerminalAppLauncher::build_editor_command_from_string(
741 "code --wait",
742 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
743 false,
744 )
745 .expect("command should parse");
746 let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
747
748 assert_eq!(args, vec!["-g".to_string(), "/tmp/test.rs:12:4".to_string()]);
749 }
750
751 #[test]
752 fn test_build_editor_command_injects_wait_flag_for_bare_vscode() {
753 let command = TerminalAppLauncher::build_editor_command_from_string(
754 "code",
755 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
756 true,
757 )
758 .expect("command should parse");
759 let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
760
761 assert_eq!(args, vec!["--wait".to_string(), "/tmp/test.rs".to_string()]);
762 }
763
764 #[test]
765 fn test_build_editor_command_keeps_short_wait_flag_without_duplicating() {
766 let command = TerminalAppLauncher::build_editor_command_from_string(
767 "code -w",
768 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
769 true,
770 )
771 .expect("command should parse");
772 let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
773
774 assert_eq!(args, vec!["-w".to_string(), "/tmp/test.rs".to_string()]);
775 }
776
777 #[test]
778 fn test_build_editor_command_strips_short_wait_flag_when_not_waiting() {
779 let command = TerminalAppLauncher::build_editor_command_from_string(
780 "code -w",
781 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
782 false,
783 )
784 .expect("command should parse");
785 let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
786
787 assert_eq!(args, vec!["/tmp/test.rs".to_string()]);
788 }
789
790 #[test]
791 fn test_build_editor_command_reuses_vscode_window_for_non_waiting_opens() {
792 let command = TerminalAppLauncher::build_editor_command_from_string_with_reuse(
793 "code --wait",
794 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
795 false,
796 true,
797 )
798 .expect("command should parse");
799 let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
800
801 assert_eq!(
802 args,
803 vec![
804 "--reuse-window".to_string(),
805 "-g".to_string(),
806 "/tmp/test.rs:12:4".to_string()
807 ]
808 );
809 }
810
811 #[test]
812 fn test_build_editor_command_does_not_add_reuse_flags_to_custom_editors() {
813 let command = TerminalAppLauncher::build_editor_command_from_string_with_reuse(
814 "custom-editor --focus",
815 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), Some(":12:4".to_string())),
816 false,
817 true,
818 )
819 .expect("command should parse");
820 let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
821
822 assert_eq!(args, vec!["--focus".to_string(), "/tmp/test.rs".to_string()]);
823 }
824
825 #[test]
826 fn test_build_editor_command_strips_sublime_wait_flag_when_not_waiting() {
827 let command = TerminalAppLauncher::build_editor_command_from_string(
828 "subl -w",
829 &EditorTarget::new(PathBuf::from("/tmp/test.rs"), None),
830 false,
831 )
832 .expect("command should parse");
833 let args: Vec<String> = command.get_args().map(|value| value.to_string_lossy().to_string()).collect();
834
835 assert_eq!(args, vec!["/tmp/test.rs".to_string()]);
836 }
837
838 #[test]
839 fn test_program_requires_terminal_detects_terminal_editors() {
840 assert!(TerminalAppLauncher::program_requires_terminal("nvim"));
841 assert!(TerminalAppLauncher::program_requires_terminal("/usr/bin/vim"));
842 assert!(TerminalAppLauncher::program_requires_terminal("helix"));
843 assert!(!TerminalAppLauncher::program_requires_terminal("code"));
844 assert!(!TerminalAppLauncher::program_requires_terminal("zed"));
845 }
846}