Skip to main content

worktree_io/
opener.rs

1use anyhow::{bail, Context, Result};
2use std::path::Path;
3use std::process::{Command, Stdio};
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/// Split a command string on whitespace and run it.
25fn run_shell_command(cmd: &str) -> Result<()> {
26    let mut parts = shlex_split(cmd);
27    if parts.is_empty() {
28        bail!("Empty command");
29    }
30    let program = parts.remove(0);
31    Command::new(&program)
32        .args(&parts)
33        .env("PATH", augmented_path())
34        .stdin(Stdio::null())
35        .stdout(Stdio::null())
36        .stderr(Stdio::null())
37        .spawn()
38        .with_context(|| format!("Failed to spawn {program}"))?;
39    Ok(())
40}
41
42/// Return a PATH that includes common binary directories that GUI-launched
43/// processes (e.g. via AppleScript `do shell script`) typically lack.
44fn augmented_path() -> String {
45    let current = std::env::var("PATH").unwrap_or_default();
46    let extras = [
47        "/usr/local/bin",
48        "/opt/homebrew/bin",
49        "/opt/homebrew/sbin",
50    ];
51    let mut parts: Vec<&str> = extras.iter().copied().collect();
52    for p in current.split(':').filter(|s| !s.is_empty()) {
53        if !parts.contains(&p) {
54            parts.push(p);
55        }
56    }
57    parts.join(":")
58}
59
60/// Very simple whitespace-based command splitter that respects double-quoted strings.
61fn shlex_split(s: &str) -> Vec<String> {
62    let mut parts = Vec::new();
63    let mut current = String::new();
64    let mut in_quotes = false;
65    let mut chars = s.chars().peekable();
66
67    while let Some(c) = chars.next() {
68        match c {
69            '"' => in_quotes = !in_quotes,
70            ' ' | '\t' if !in_quotes => {
71                if !current.is_empty() {
72                    parts.push(current.clone());
73                    current.clear();
74                }
75            }
76            _ => current.push(c),
77        }
78    }
79    if !current.is_empty() {
80        parts.push(current);
81    }
82    parts
83}