Skip to main content

tree_space/
cmd.rs

1//! Launch arguments and the IPC wire format for controlling a running panel.
2//!
3//! A tree-space invocation parses into a [`Command`]:
4//!
5//! * `tree-space`                     → toggle every dock together
6//! * `tree-space --side right`        → toggle just the right dock
7//! * `tree-space <dir>...`              → open the given directory (pane) root(s)
8//! * `tree-space --side left <dir>...`  → open the given root(s) in the left dock
9//! * `tree-space <file>...`             → reveal each file in its parent directory
10//! * `tree-space --select <path>`     → reveal an explicit path (file or dir)
11//! * `tree-space --hidden`            → launch/keep hidden (never shows)
12//! * `tree-space --width 420`         → set the panel width (absolute pixels)
13//! * `tree-space --width +40`         → widen the running panel by 40px (`-40` narrows)
14//!
15//! If a tree-space server is already running, the new invocation serializes
16//! its [`Command`] to the instance socket and exits; the server feeds it back
17//! to the app via [`crate::ui::app::AppMsg::LaunchRequest`]. The wire format is
18//! deliberately simple: `side=`/`root=`/`hidden=`/`width=` tokens joined with
19//! NULs (impossible in Unix path components) and terminated by a newline.
20
21use std::ffi::OsString;
22use std::path::PathBuf;
23
24use crate::config::PanelSide;
25
26/// Turn a possibly-relative path (e.g. `.`, `sub/dir`) into an absolute one by
27/// resolving it against the current working directory, without touching the
28/// filesystem (no symlink resolution, and it works for paths that do not exist
29/// yet). A path that is already absolute is returned unchanged.
30///
31/// This is applied at parse time so every downstream consumer — deduplication,
32/// the session file, and the toolbar's path entry — works with one canonical
33/// spelling instead of the raw argument.
34fn absolutize(path: PathBuf) -> PathBuf {
35    std::path::absolute(&path).unwrap_or(path)
36}
37
38/// The value of a `--width` argument: an absolute width or a signed delta.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum WidthArg {
41    /// Set the width to exactly this many pixels.
42    To(u32),
43    /// Change the width by this many pixels (negative narrows).
44    By(i32),
45}
46
47impl WidthArg {
48    /// Parse a raw `--width` value: a leading `+`/`-` makes it a delta,
49    /// anything else an absolute pixel width.
50    fn parse(value: &str) -> Option<Self> {
51        let value = value.trim();
52        if value.starts_with(['+', '-']) {
53            value.parse::<i32>().ok().map(Self::By)
54        } else {
55            value.parse::<u32>().ok().map(Self::To)
56        }
57    }
58
59    fn encode(self) -> String {
60        match self {
61            Self::To(px) => px.to_string(),
62            Self::By(delta) if delta >= 0 => format!("+{delta}"),
63            Self::By(delta) => delta.to_string(),
64        }
65    }
66}
67
68/// What a tree-space invocation wants the panel to do.
69#[derive(Debug, Clone, PartialEq, Eq, Default)]
70pub struct Command {
71    /// An explicit `--side` override. Absent means "use the configured side".
72    pub side: Option<PanelSide>,
73    /// Directory roots to open as panes. Empty means this is a show/toggle.
74    pub roots: Vec<PathBuf>,
75    /// Files (or paths) to reveal: each opens a pane rooted at its containing
76    /// directory and selects the path once the pane is loaded. Populated by
77    /// `--select PATH` and by positional arguments that name a non-directory.
78    pub reveal: Vec<PathBuf>,
79    /// `--hidden`: do everything else as usual, but ensure the panel ends up
80    /// hidden — hide it if showing, leave it hidden if already hidden, and
81    /// start hidden when this launches the panel.
82    pub hidden: bool,
83    /// `--width`: resize the panel (absolute or relative) without touching its
84    /// visibility.
85    pub width: Option<WidthArg>,
86}
87
88impl Command {
89    /// Parse the arguments of a tree-space invocation (everything after
90    /// argv[0], as `OsString`s). Only arguments that are existing directories
91    /// become roots; `--side left|right` (or `--side=left|right`) selects the
92    /// side override. Unknown flags are ignored for forward compatibility.
93    pub fn parse<I, S>(args: I) -> Self
94    where
95        I: IntoIterator<Item = S>,
96        S: Into<OsString>,
97    {
98        let mut side = None;
99        let mut roots: Vec<PathBuf> = Vec::new();
100        let mut reveal: Vec<PathBuf> = Vec::new();
101        let mut hidden = false;
102        let mut width = None;
103        let mut args = args.into_iter().map(Into::into);
104        while let Some(arg) = args.next() {
105            let text = arg.to_string_lossy();
106            let mut split = text.splitn(2, '=');
107            let (flag, inline_value) = (split.next().unwrap_or(""), split.next());
108            match flag {
109                "--side" | "-s" => {
110                    let value = match inline_value {
111                        Some(v) => Some(v.to_string()),
112                        None => args.next().map(|v| v.to_string_lossy().into_owned()),
113                    };
114                    match value.as_deref() {
115                        Some("left") => side = Some(PanelSide::Left),
116                        Some("right") => side = Some(PanelSide::Right),
117                        _ => {} // invalid side value is ignored
118                    }
119                }
120                "--hidden" | "--hide" | "-H" => hidden = true,
121                "--width" | "-w" => {
122                    let value = match inline_value {
123                        Some(v) => Some(v.to_string()),
124                        None => args.next().map(|v| v.to_string_lossy().into_owned()),
125                    };
126                    if let Some(parsed) = value.as_deref().and_then(WidthArg::parse) {
127                        width = Some(parsed);
128                    }
129                }
130                "--select" => {
131                    // Accept `--select PATH` and `--select=PATH`; a lone
132                    // `--select` (with no value) is ignored.
133                    let value = match inline_value {
134                        Some(v) => Some(v.to_string()),
135                        None => args.next().map(|v| v.to_string_lossy().into_owned()),
136                    };
137                    if let Some(value) = value
138                        && !value.is_empty()
139                    {
140                        reveal.push(absolutize(PathBuf::from(value)));
141                    }
142                }
143                _ => {
144                    if arg.to_str().is_some_and(|s| s.starts_with('-')) {
145                        continue;
146                    }
147                    let path = absolutize(PathBuf::from(&arg));
148                    if path.is_dir() {
149                        roots.push(path);
150                    } else {
151                        // A file (or any non-directory path): reveal it in a
152                        // pane rooted at its parent.
153                        reveal.push(path);
154                    }
155                }
156            }
157        }
158        Command { side, roots, reveal, hidden, width }
159    }
160
161    /// Encode into the IPC wire format (a single line, NUL-separated tokens).
162    pub fn encode(&self) -> String {
163        let mut parts: Vec<String> = Vec::new();
164        match self.side {
165            Some(PanelSide::Left) => parts.push("side=left".to_owned()),
166            Some(PanelSide::Right) => parts.push("side=right".to_owned()),
167            None => {}
168        }
169        for root in &self.roots {
170            parts.push(format!("root={}", root.to_string_lossy()));
171        }
172        for path in &self.reveal {
173            parts.push(format!("select={}", path.to_string_lossy()));
174        }
175        if self.hidden {
176            parts.push("hidden=1".to_owned());
177        }
178        if let Some(width) = self.width {
179            parts.push(format!("width={}", width.encode()));
180        }
181        parts.join("\0")
182    }
183
184    /// Decode a line from the IPC wire format back into a [`Command`]. An empty
185    /// (or whitespace-only) line decodes to a plain toggle.
186    pub fn decode(line: &str) -> Option<Command> {
187        let mut side = None;
188        let mut roots: Vec<PathBuf> = Vec::new();
189        let mut reveal: Vec<PathBuf> = Vec::new();
190        let mut hidden = false;
191        let mut width = None;
192        for token in line.split('\0') {
193            if token.is_empty() {
194                continue;
195            }
196            let (name, value) = token.split_once('=')?;
197            match name {
198                "side" => match value {
199                    "left" => side = Some(PanelSide::Left),
200                    "right" => side = Some(PanelSide::Right),
201                    _ => return None,
202                },
203                "root" => roots.push(PathBuf::from(value)),
204                "select" => reveal.push(PathBuf::from(value)),
205                "hidden" => hidden = value == "1" || value.eq_ignore_ascii_case("true"),
206                "width" => width = Some(WidthArg::parse(value)?),
207                _ => return None,
208            }
209        }
210        Some(Command { side, roots, reveal, hidden, width })
211    }
212
213    /// Does this command carry no directories and ask for the normal toggle
214    /// (neither a side override, `--hidden`, nor `--width`)?
215    pub fn is_toggle(&self) -> bool {
216        self.roots.is_empty()
217            && self.reveal.is_empty()
218            && self.side.is_none()
219            && !self.hidden
220            && self.width.is_none()
221    }
222
223    /// Resolve each reveal path into a pane root plus an optional child to
224    /// select. A directory (or the current directory `.`) opens as its own
225    /// root with nothing to select; a file opens a pane rooted at its parent
226    /// directory and selects the file. A path with no parent component (e.g.
227    /// `/`) is skipped.
228    pub fn reveal_targets(&self) -> Vec<(PathBuf, Option<PathBuf>)> {
229        self.reveal
230            .iter()
231            .filter_map(|path| {
232                if path.is_dir() {
233                    Some((path.clone(), None))
234                } else {
235                    let parent = path.parent().filter(|p| !p.as_os_str().is_empty())?;
236                    Some((parent.to_path_buf(), Some(path.clone())))
237                }
238            })
239            .collect()
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn parse_str(args: &[&str]) -> Command {
248        Command::parse(args.iter().map(|s| OsString::from(*s)))
249    }
250
251    #[test]
252    fn no_args_is_a_toggle() {
253        let cmd = parse_str(&[]);
254        assert!(cmd.side.is_none());
255        assert!(cmd.is_toggle());
256    }
257
258    #[test]
259    fn side_flag_parses_both_inline_and_adjacent() {
260        assert_eq!(parse_str(&["--side", "right"]).side, Some(PanelSide::Right));
261        assert_eq!(parse_str(&["--side=left"]).side, Some(PanelSide::Left));
262        assert_eq!(parse_str(&["-s", "right"]).side, Some(PanelSide::Right));
263        assert_eq!(parse_str(&["-s=left"]).side, Some(PanelSide::Left));
264    }
265
266    #[test]
267    fn invalid_side_value_is_ignored() {
268        assert!(parse_str(&["--side", "up"]).side.is_none());
269        assert!(parse_str(&["--side=diagonal"]).side.is_none());
270    }
271
272    #[test]
273    fn directories_become_roots_and_flags_are_skipped() {
274        let cmd = parse_str(&["--side", "left", "/etc", "--bogus", "/usr"]);
275        assert_eq!(cmd.side, Some(PanelSide::Left));
276        assert_eq!(cmd.roots, vec![PathBuf::from("/etc"), PathBuf::from("/usr")]);
277    }
278
279    #[test]
280    fn non_directory_arguments_become_reveals() {
281        // A path that is not an existing directory is revealed, not rooted.
282        let cmd = parse_str(&["/definitely/not/a/dir"]);
283        assert!(cmd.roots.is_empty());
284        assert_eq!(cmd.reveal, vec![PathBuf::from("/definitely/not/a/dir")]);
285        assert!(!cmd.is_toggle());
286    }
287
288    #[test]
289    fn relative_paths_are_made_absolute() {
290        // `.` names the current directory; it must be stored as an absolute
291        // path so dedup and the toolbar agree on one spelling.
292        let cmd = parse_str(&["."]);
293        assert_eq!(cmd.roots.len(), 1, "`.` should resolve to an existing dir");
294        assert!(cmd.roots[0].is_absolute(), "{:?} should be absolute", cmd.roots[0]);
295
296        // A relative *reveal* is likewise absolutized (parent of "." is the
297        // parent directory, and the target is the absolute cwd).
298        let cmd = parse_str(&["--select", "."]);
299        assert!(cmd.reveal[0].is_absolute(), "{:?} should be absolute", cmd.reveal[0]);
300    }
301
302    #[test]
303    fn select_flag_parses_adjacent_inline_and_repeated() {
304        assert_eq!(
305            parse_str(&["--select", "/tmp/f.txt"]).reveal,
306            vec![PathBuf::from("/tmp/f.txt")]
307        );
308        assert_eq!(parse_str(&["--select=/tmp/f.txt"]).reveal, vec![PathBuf::from("/tmp/f.txt")]);
309        let two = parse_str(&["--select", "/a", "--select", "/b"]);
310        assert_eq!(two.reveal, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
311        // A lone `--select` with no value is ignored.
312        assert!(parse_str(&["--select"]).reveal.is_empty());
313    }
314
315    #[test]
316    fn encode_decode_reveal() {
317        let cmd = Command {
318            reveal: vec![PathBuf::from("/home/eolu/notes.md")],
319            ..Command::default()
320        };
321        assert_eq!(cmd.encode(), "select=/home/eolu/notes.md");
322        assert_eq!(Command::decode(&cmd.encode()), Some(cmd));
323    }
324
325    #[test]
326    fn reveal_targets_root_at_the_parent_for_files() {
327        let dir = tempfile::tempdir().unwrap();
328        let file = dir.path().join("notes.md");
329        std::fs::write(&file, "").unwrap();
330        let sub = dir.path().join("photos");
331        std::fs::create_dir(&sub).unwrap();
332
333        let cmd = Command {
334            reveal: vec![file.clone(), sub.clone()],
335            ..Command::default()
336        };
337        let targets = cmd.reveal_targets();
338        // A file reveals in its parent, selecting the file.
339        assert_eq!(targets[0], (dir.path().to_path_buf(), Some(file)));
340        // A directory opens as its own root with nothing to select.
341        assert_eq!(targets[1], (sub, None));
342    }
343
344    #[test]
345    fn reveal_targets_skip_rootless_files() {
346        // A bare relative file name has an empty parent, so there is no
347        // containing folder to open and it is skipped.
348        let cmd = Command {
349            reveal: vec![PathBuf::from("notes.md")],
350            ..Command::default()
351        };
352        assert!(cmd.reveal_targets().is_empty());
353    }
354
355    #[test]
356    fn hidden_flag_parses() {
357        assert!(parse_str(&["--hidden"]).hidden);
358        assert!(parse_str(&["--hide"]).hidden);
359        assert!(parse_str(&["-H"]).hidden);
360        assert!(!parse_str(&[]).hidden);
361        // Hidden is orthogonal to side and roots.
362        let cmd = parse_str(&["--hidden", "--side", "right", "/etc"]);
363        assert!(cmd.hidden);
364        assert_eq!(cmd.side, Some(PanelSide::Right));
365        assert_eq!(cmd.roots, vec![PathBuf::from("/etc")]);
366        assert!(!cmd.is_toggle());
367    }
368
369    #[test]
370    fn encode_decode_round_trips() {
371        let cmd = Command {
372            side: Some(PanelSide::Right),
373            roots: vec![PathBuf::from("/home/eolu/Projects"), PathBuf::from("/tmp")],
374            reveal: vec![PathBuf::from("/home/eolu/some file.txt")],
375            hidden: false,
376            width: None,
377        };
378        assert_eq!(Command::decode(&cmd.encode()), Some(cmd));
379    }
380
381    #[test]
382    fn encode_decode_hidden() {
383        let cmd = Command { hidden: true, ..Command::default() };
384        assert_eq!(Command::decode(&cmd.encode()), Some(cmd.clone()));
385        assert_eq!(Command::decode("hidden=1"), Some(cmd));
386    }
387
388    #[test]
389    fn encode_decode_toggle_and_empty() {
390        let cmd = Command::default();
391        assert_eq!(Command::decode(&cmd.encode()), Some(Command::default()));
392        assert_eq!(Command::decode(""), Some(Command::default()));
393        assert_eq!(Command::decode("   "), None);
394    }
395
396    #[test]
397    fn decode_rejects_unknown_tokens() {
398        assert_eq!(Command::decode("bogus=x"), None);
399        assert_eq!(Command::decode("side=up"), None);
400    }
401
402    #[test]
403    fn decode_keeps_paths_with_spaces() {
404        let cmd = Command {
405            side: None,
406            roots: vec![PathBuf::from("/home/eolu/My Projects")],
407            hidden: false,
408            width: None,
409            ..Command::default()
410        };
411        assert_eq!(Command::decode(&cmd.encode()), Some(cmd));
412    }
413
414    #[test]
415    fn width_flag_parses_absolute_and_relative() {
416        assert_eq!(parse_str(&["--width", "420"]).width, Some(WidthArg::To(420)));
417        assert_eq!(parse_str(&["--width=420"]).width, Some(WidthArg::To(420)));
418        assert_eq!(parse_str(&["-w", "+40"]).width, Some(WidthArg::By(40)));
419        assert_eq!(parse_str(&["--width", "-40"]).width, Some(WidthArg::By(-40)));
420        assert_eq!(parse_str(&["--width=garbage"]).width, None);
421        assert!(parse_str(&["--width", "420"]).width.is_some());
422        // A width change is never a plain toggle.
423        assert!(!parse_str(&["--width", "+40"]).is_toggle());
424    }
425
426    #[test]
427    fn encode_decode_width() {
428        let grow = Command { width: Some(WidthArg::By(40)), ..Command::default() };
429        assert_eq!(grow.encode(), "width=+40");
430        assert_eq!(Command::decode(&grow.encode()), Some(grow));
431
432        let shrink = Command { width: Some(WidthArg::By(-40)), ..Command::default() };
433        assert_eq!(shrink.encode(), "width=-40");
434        assert_eq!(Command::decode(&shrink.encode()), Some(shrink));
435
436        let exact = Command { width: Some(WidthArg::To(420)), ..Command::default() };
437        assert_eq!(exact.encode(), "width=420");
438        assert_eq!(Command::decode(&exact.encode()), Some(exact));
439
440        assert_eq!(Command::decode("width=+oops"), None);
441    }
442}