Skip to main content

worktree_io/
opener.rs

1use anyhow::{bail, Context, Result};
2use std::path::Path;
3use std::process::{Command, Stdio};
4
5/// Write a bootstrap script (hook + `exec "${SHELL:-sh}"`) to a temp file and
6/// spawn the terminal running it. Returns `true` if the command was recognised
7/// as a terminal emulator, `false` otherwise (IDE / unknown command).
8fn try_terminal_with_init(path: &Path, command: &str, init_script: &str) -> Result<bool> {
9    let path_str = path
10        .to_str()
11        .context("Workspace path contains non-UTF-8 characters")?;
12
13    // Escape single quotes for use inside a single-quoted sh string.
14    let path_escaped = path_str.replace('\'', "'\\''");
15
16    let bootstrap = format!(
17        "#!/bin/sh\ncd '{}'\n{}\nexec \"${{SHELL:-sh}}\"\n",
18        path_escaped, init_script
19    );
20
21    let tmp_path = std::env::temp_dir()
22        .join(format!("worktree-hook-open-{}.sh", std::process::id()));
23    std::fs::write(&tmp_path, bootstrap.as_bytes())?;
24
25    #[cfg(unix)]
26    {
27        use std::os::unix::fs::PermissionsExt;
28        std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
29    }
30
31    let tmp_str = tmp_path
32        .to_str()
33        .context("Temp path contains non-UTF-8 characters")?;
34    let cmd_lower = command.to_ascii_lowercase();
35
36    if cmd_lower.contains("iterm") {
37        let script = format!(
38            r#"tell application "iTerm2" to create window with default profile command "sh {}""#,
39            tmp_str
40        );
41        Command::new("osascript")
42            .args(["-e", &script])
43            .stdin(Stdio::null())
44            .stdout(Stdio::null())
45            .stderr(Stdio::null())
46            .spawn()?;
47        Ok(true)
48    } else if cmd_lower.contains("open -a terminal") {
49        Command::new("open")
50            .args(["-a", "Terminal", tmp_str])
51            .stdin(Stdio::null())
52            .stdout(Stdio::null())
53            .stderr(Stdio::null())
54            .spawn()?;
55        Ok(true)
56    } else if cmd_lower.starts_with("alacritty") {
57        Command::new("alacritty")
58            .args(["--working-directory", path_str, "-e", "sh", tmp_str])
59            .stdin(Stdio::null())
60            .stdout(Stdio::null())
61            .stderr(Stdio::null())
62            .spawn()?;
63        Ok(true)
64    } else if cmd_lower.starts_with("kitty") {
65        Command::new("kitty")
66            .args(["--directory", path_str, "sh", tmp_str])
67            .stdin(Stdio::null())
68            .stdout(Stdio::null())
69            .stderr(Stdio::null())
70            .spawn()?;
71        Ok(true)
72    } else if cmd_lower.starts_with("wezterm") {
73        Command::new("wezterm")
74            .args(["start", "--cwd", path_str, "--", "sh", tmp_str])
75            .stdin(Stdio::null())
76            .stdout(Stdio::null())
77            .stderr(Stdio::null())
78            .spawn()?;
79        Ok(true)
80    } else {
81        Ok(false)
82    }
83}
84
85/// Check whether a macOS application bundle is installed.
86fn app_exists(name: &str) -> bool {
87    std::path::Path::new(&format!("/Applications/{name}.app")).exists()
88        || std::path::Path::new(&format!("/System/Applications/{name}.app")).exists()
89}
90
91/// For the IDE case: find an available terminal app and run `init_script` inside it.
92/// Probes in order: iTerm → Warp → Ghostty → Terminal.app.
93/// Returns `true` if a terminal window was opened.
94fn open_hook_in_auto_terminal(path: &Path, init_script: &str) -> Result<bool> {
95    let candidates: &[(&str, &str)] = &[
96        ("iTerm", "open -a iTerm ."),
97        ("Warp", "open -a Warp ."),
98        ("Ghostty", "open -a Ghostty ."),
99        ("Terminal", "open -a Terminal ."),
100    ];
101    for &(app, cmd) in candidates {
102        if app_exists(app) && try_terminal_with_init(path, cmd, init_script)? {
103            return Ok(true);
104        }
105    }
106    Ok(false)
107}
108
109/// Open `path` with `command` and run `init_script` inside the resulting window.
110/// Returns `true` when the hook ran inside a terminal window, `false` when an
111/// IDE was opened and no terminal was available (caller should run the hook as
112/// a fallback).
113pub fn open_with_hook(path: &Path, command: &str, init_script: &str) -> Result<bool> {
114    if try_terminal_with_init(path, command, init_script)? {
115        return Ok(true);
116    }
117    // IDE path: open the editor then try to show the hook in a separate terminal.
118    open_in_editor(path, command)?;
119    open_hook_in_auto_terminal(path, init_script)
120}
121
122/// Open the workspace path in the configured editor.
123/// `command` is a shell template, e.g. `"code ."` or `"nvim ."`.
124/// The trailing `.` (or any `.`) in the command is replaced by the actual path.
125pub fn open_in_editor(path: &Path, command: &str) -> Result<()> {
126    let path_str = path
127        .to_str()
128        .context("Workspace path contains non-UTF-8 characters")?;
129
130    // Replace standalone `.` tokens with the actual path, or append it
131    let cmd_str = if command.contains(" . ") || command.ends_with(" .") || command == "." {
132        command.replacen(" .", &format!(" {path_str}"), 1)
133    } else {
134        format!("{command} {path_str}")
135    };
136
137    run_shell_command(&cmd_str)
138        .with_context(|| format!("Failed to open editor with command: {cmd_str}"))
139}
140
141/// Split a command string on whitespace and run it.
142fn run_shell_command(cmd: &str) -> Result<()> {
143    let mut parts = shlex_split(cmd);
144    if parts.is_empty() {
145        bail!("Empty command");
146    }
147    let program = parts.remove(0);
148    Command::new(&program)
149        .args(&parts)
150        .env("PATH", augmented_path())
151        .stdin(Stdio::null())
152        .stdout(Stdio::null())
153        .stderr(Stdio::null())
154        .spawn()
155        .with_context(|| format!("Failed to spawn {program}"))?;
156    Ok(())
157}
158
159/// Return a PATH that includes common binary directories that GUI-launched
160/// processes (e.g. via AppleScript `do shell script`) typically lack.
161pub fn augmented_path() -> String {
162    let current = std::env::var("PATH").unwrap_or_default();
163    let extras = [
164        "/usr/local/bin",
165        "/opt/homebrew/bin",
166        "/opt/homebrew/sbin",
167    ];
168    let mut parts: Vec<&str> = extras.iter().copied().collect();
169    for p in current.split(':').filter(|s| !s.is_empty()) {
170        if !parts.contains(&p) {
171            parts.push(p);
172        }
173    }
174    parts.join(":")
175}
176
177/// Very simple whitespace-based command splitter that respects double-quoted strings.
178fn shlex_split(s: &str) -> Vec<String> {
179    let mut parts = Vec::new();
180    let mut current = String::new();
181    let mut in_quotes = false;
182    let mut chars = s.chars().peekable();
183
184    while let Some(c) = chars.next() {
185        match c {
186            '"' => in_quotes = !in_quotes,
187            ' ' | '\t' if !in_quotes => {
188                if !current.is_empty() {
189                    parts.push(current.clone());
190                    current.clear();
191                }
192            }
193            _ => current.push(c),
194        }
195    }
196    if !current.is_empty() {
197        parts.push(current);
198    }
199    parts
200}