Skip to main content

worktree_io/
opener.rs

1use anyhow::{bail, Context, Result};
2use std::path::Path;
3use std::process::Command;
4
5/// Open the workspace path in the configured editor.
6/// `command` is a shell template, e.g. `"code ."` or `"nvim ."`.
7/// The trailing `.` (or any `.`) in the command is replaced by the actual path.
8pub fn open_in_editor(path: &Path, command: &str) -> Result<()> {
9    let path_str = path
10        .to_str()
11        .context("Workspace path contains non-UTF-8 characters")?;
12
13    // Replace standalone `.` tokens with the actual path, or append it
14    let cmd_str = if command.contains(" . ") || command.ends_with(" .") || command == "." {
15        command.replacen(" .", &format!(" {path_str}"), 1)
16    } else {
17        format!("{command} {path_str}")
18    };
19
20    run_shell_command(&cmd_str)
21        .with_context(|| format!("Failed to open editor with command: {cmd_str}"))
22}
23
24/// Open the workspace path in the platform file explorer.
25pub fn open_in_explorer(path: &Path) -> Result<()> {
26    platform_open_in_explorer(path)
27}
28
29#[cfg(target_os = "macos")]
30fn platform_open_in_explorer(path: &Path) -> Result<()> {
31    Command::new("open")
32        .arg(path)
33        .spawn()
34        .context("Failed to open Finder")?;
35    Ok(())
36}
37
38#[cfg(target_os = "linux")]
39fn platform_open_in_explorer(path: &Path) -> Result<()> {
40    Command::new("xdg-open")
41        .arg(path)
42        .spawn()
43        .context("Failed to open file manager")?;
44    Ok(())
45}
46
47#[cfg(target_os = "windows")]
48fn platform_open_in_explorer(path: &Path) -> Result<()> {
49    Command::new("explorer")
50        .arg(path)
51        .spawn()
52        .context("Failed to open Explorer")?;
53    Ok(())
54}
55
56#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
57fn platform_open_in_explorer(_path: &Path) -> Result<()> {
58    bail!("open_in_explorer is not implemented for this platform")
59}
60
61/// Open a terminal window in the workspace path.
62/// If `command` is provided it is run as a shell template (same `.` replacement as editor).
63/// Otherwise a platform-appropriate default terminal is used.
64pub fn open_in_terminal(path: &Path, command: Option<&str>) -> Result<()> {
65    if let Some(cmd) = command {
66        let path_str = path
67            .to_str()
68            .context("Workspace path contains non-UTF-8 characters")?;
69        let cmd_str = if cmd.contains(" . ") || cmd.ends_with(" .") || cmd == "." {
70            cmd.replacen(" .", &format!(" {path_str}"), 1)
71        } else {
72            format!("{cmd} {path_str}")
73        };
74        return run_shell_command(&cmd_str)
75            .with_context(|| format!("Failed to open terminal with command: {cmd_str}"));
76    }
77
78    open_default_terminal(path)
79}
80
81#[cfg(target_os = "macos")]
82fn open_default_terminal(path: &Path) -> Result<()> {
83    let path_str = path
84        .to_str()
85        .context("Workspace path contains non-UTF-8 characters")?;
86    // Escape single quotes in the path for AppleScript
87    let escaped = path_str.replace('\'', "'\\''");
88    let script = format!(
89        r#"tell application "Terminal"
90    activate
91    do script "cd '{escaped}'"
92end tell"#
93    );
94    Command::new("osascript")
95        .arg("-e")
96        .arg(&script)
97        .spawn()
98        .context("Failed to open Terminal.app via osascript")?;
99    Ok(())
100}
101
102#[cfg(target_os = "linux")]
103fn open_default_terminal(path: &Path) -> Result<()> {
104    // Try common terminal emulators in order
105    let terminals: &[&[&str]] = &[
106        &["gnome-terminal", "--working-directory"],
107        &["xterm", "-e", "bash -c 'cd \"$1\" && exec bash' -- "],
108        &["konsole", "--workdir"],
109        &["xfce4-terminal", "--working-directory"],
110    ];
111
112    for args in terminals {
113        let (prog, rest) = args.split_first().unwrap();
114        let mut cmd = Command::new(prog);
115        for arg in rest {
116            cmd.arg(arg);
117        }
118        cmd.arg(path);
119        if cmd.spawn().is_ok() {
120            return Ok(());
121        }
122    }
123
124    bail!("No suitable terminal emulator found on this Linux system")
125}
126
127#[cfg(target_os = "windows")]
128fn open_default_terminal(path: &Path) -> Result<()> {
129    // Try Windows Terminal first, then cmd.exe
130    let result = Command::new("wt")
131        .args(["--startingDirectory"])
132        .arg(path)
133        .spawn();
134
135    if result.is_ok() {
136        return Ok(());
137    }
138
139    Command::new("cmd")
140        .args(["/c", "start", "cmd.exe", "/k"])
141        .arg(format!("cd /d \"{}\"", path.display()))
142        .spawn()
143        .context("Failed to open cmd.exe")?;
144
145    Ok(())
146}
147
148#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
149fn open_default_terminal(_path: &Path) -> Result<()> {
150    bail!("open_in_terminal is not implemented for this platform")
151}
152
153/// Split a command string on whitespace and run it.
154fn run_shell_command(cmd: &str) -> Result<()> {
155    let mut parts = shlex_split(cmd);
156    if parts.is_empty() {
157        bail!("Empty command");
158    }
159    let program = parts.remove(0);
160    Command::new(&program)
161        .args(&parts)
162        .spawn()
163        .with_context(|| format!("Failed to spawn {program}"))?;
164    Ok(())
165}
166
167/// Very simple whitespace-based command splitter that respects double-quoted strings.
168fn shlex_split(s: &str) -> Vec<String> {
169    let mut parts = Vec::new();
170    let mut current = String::new();
171    let mut in_quotes = false;
172    let mut chars = s.chars().peekable();
173
174    while let Some(c) = chars.next() {
175        match c {
176            '"' => in_quotes = !in_quotes,
177            ' ' | '\t' if !in_quotes => {
178                if !current.is_empty() {
179                    parts.push(current.clone());
180                    current.clear();
181                }
182            }
183            _ => current.push(c),
184        }
185    }
186    if !current.is_empty() {
187        parts.push(current);
188    }
189    parts
190}