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