Skip to main content

tree_space/
config.rs

1//! Configuration and XDG persistence.
2//!
3//! This module owns two concerns:
4//!
5//! * **`Config`** — the user-facing TOML file at
6//!   `$XDG_CONFIG_HOME/tree-space/config.toml` (`~/.config/tree-space/config.toml`).
7//!   Every field has a sane default via `#[serde(default)]`, so a minimal file
8//!   (or none at all) is valid. Those defaults are not written here: they are
9//!   parsed from the shipped `default-config.toml` (see [`builtin`]), which is
10//!   the single source of truth for every default value.
11//! * **`SessionState`** — small runtime state (last opened root) kept in
12//!   `$XDG_STATE_HOME/tree-space/state.toml`. Kept separate from the config so
13//!   that re-exporting the config as a "default" for users stays clean.
14//!
15//! The XDG resolution helpers are pure functions over environment-style inputs
16//! so they can be tested without mutating process-global environment variables.
17
18use std::env;
19use std::path::{Path, PathBuf};
20use std::sync::OnceLock;
21
22use regex::Regex;
23use serde::{Deserialize, Serialize};
24
25/// The directory name used below all XDG base dirs.
26pub const APP_DIR: &str = "tree-space";
27/// Name of the config file inside [`APP_DIR`].
28pub const CONFIG_FILE: &str = "config.toml";
29/// Name of the state file inside [`APP_DIR`].
30pub const STATE_FILE: &str = "state.toml";
31/// Name of the user stylesheet inside [`APP_DIR`].
32pub const STYLE_FILE: &str = "main.css";
33/// Default name of the bookmarks list file inside [`APP_DIR`]. The config's
34/// `[bookmarks] file` may point elsewhere.
35pub const BOOKMARKS_FILE: &str = "bookmarks.toml";
36
37/// The shipping default config, written out on first launch. This is the
38/// **single source of truth** for every default value: [`Config::default`] and
39/// each section's `Default` are parsed from it (see [`builtin`]). It is kept as
40/// a real file (rather than generated by serialization) so it can be commented
41/// and laid out for humans.
42const DEFAULT_CONFIG: &str = include_str!("../default-config.toml");
43
44/// The shipped default stylesheet, written out beside the config on first
45/// launch and used as a fallback when the user's own is missing or unreadable.
46pub const DEFAULT_STYLESHEET: &str = include_str!("../style/main.css");
47
48/// The parsed `default-config.toml`, built once. Every `Default` impl delegates
49/// here so the file, not this module, decides the defaults.
50///
51/// Parsing uses [`ShippedRaw`], whose section fields are `Option`s with
52/// *(field-level)* `#[serde(default)]`. That keeps the parse free of any call
53/// back into a section's own `Default` — and therefore out of [`builtin`]
54/// itself — because the shipped file always populates every section. A missing
55/// section (or scalar) falls back to the literal schema default on its type,
56/// which likewise never re-enters [`builtin`].
57fn builtin() -> &'static Config {
58    static ONCE: OnceLock<Config> = OnceLock::new();
59    ONCE.get_or_init(|| {
60        let raw: ShippedRaw = toml::from_str(DEFAULT_CONFIG)
61            .expect("default-config.toml must be valid TOML");
62        Config {
63            panel: raw.panel.unwrap_or_default(),
64            tree: raw.tree.unwrap_or_default(),
65            startup: raw.startup.unwrap_or_default(),
66            context_menu: raw.context_menu.unwrap_or_default(),
67            pane_menu: raw.pane_menu.unwrap_or_default(),
68            bookmarks: raw.bookmarks.unwrap_or_default(),
69        }
70    })
71}
72
73/// A recursion-free view of `default-config.toml`: every section is optional
74/// and defaults to `None`, so deserializing the shipped file never touches a
75/// section's `Default` (which would re-enter [`builtin`]).
76#[derive(Deserialize)]
77struct ShippedRaw {
78    #[serde(default)]
79    panel: Option<PanelConfig>,
80    #[serde(default)]
81    tree: Option<TreeConfig>,
82    #[serde(default)]
83    startup: Option<StartupRoot>,
84    #[serde(default)]
85    context_menu: Option<ContextMenu>,
86    #[serde(default)]
87    pane_menu: Option<PaneMenu>,
88    #[serde(default)]
89    bookmarks: Option<BookmarksConfig>,
90}
91
92// ---------------------------------------------------------------------------
93// XDG path resolution
94// ---------------------------------------------------------------------------
95
96/// Resolve `$XDG_CONFIG_HOME` / `$HOME/.config` from explicit inputs.
97///
98/// `xdg` is the value of `XDG_CONFIG_HOME`, `home` the value of `HOME`. When
99/// neither is present the XDG spec mandates falling back to `~/.config`.
100fn xdg_config_home(xdg: Option<&str>, home: Option<&str>) -> PathBuf {
101    match xdg.filter(|s| !s.is_empty()) {
102        Some(dir) => PathBuf::from(dir),
103        None => home.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")).join(".config"),
104    }
105}
106
107/// Resolve `$XDG_STATE_HOME` / `$HOME/.local/state` from explicit inputs.
108fn xdg_state_home(xdg: Option<&str>, home: Option<&str>) -> PathBuf {
109    match xdg.filter(|s| !s.is_empty()) {
110        Some(dir) => PathBuf::from(dir),
111        None => home.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")).join(".local/state"),
112    }
113}
114
115/// Path of `<app dir>/<file>` below an XDG base directory.
116fn app_path(base: &Path, file: &str) -> PathBuf {
117    base.join(APP_DIR).join(file)
118}
119
120/// Read `XDG_CONFIG_HOME`/`HOME` from the environment. Replaced by
121/// [`xdg_config_home`] in tests where determinism matters.
122fn config_home_from_env() -> PathBuf {
123    let xdg = env::var_os("XDG_CONFIG_HOME").map(|v| v.to_string_lossy().into_owned());
124    let home = env::var_os("HOME").map(|v| v.to_string_lossy().into_owned());
125    xdg_config_home(xdg.as_deref(), home.as_deref())
126}
127
128/// Read `XDG_STATE_HOME`/`HOME` from the environment.
129fn state_home_from_env() -> PathBuf {
130    let xdg = env::var_os("XDG_STATE_HOME").map(|v| v.to_string_lossy().into_owned());
131    let home = env::var_os("HOME").map(|v| v.to_string_lossy().into_owned());
132    xdg_state_home(xdg.as_deref(), home.as_deref())
133}
134
135/// Absolute path of the config file for the current environment.
136pub fn config_file() -> PathBuf {
137    app_path(&config_home_from_env(), CONFIG_FILE)
138}
139
140/// Absolute path of the state file for the current environment.
141pub fn state_file() -> PathBuf {
142    app_path(&state_home_from_env(), STATE_FILE)
143}
144
145/// Absolute path of the user stylesheet for the current environment.
146pub fn style_file() -> PathBuf {
147    app_path(&config_home_from_env(), STYLE_FILE)
148}
149
150/// Absolute path of the bookmarks list for the current environment, given the
151/// configured `[bookmarks] file` (relative to the config directory, or
152/// absolute).
153pub fn bookmark_file_path(configured: &Path) -> PathBuf {
154    resolve_bookmarks_path(&config_home_from_env().join(APP_DIR), configured)
155}
156
157/// Resolve a `[bookmarks] file` against `base` (the config file's directory).
158fn resolve_bookmarks_path(base: &Path, configured: &Path) -> PathBuf {
159    if configured.is_absolute() { configured.to_path_buf() } else { base.join(configured) }
160}
161
162/// Expand a leading `~` in a bookmark path (a no-op for ordinary paths). Stored
163/// paths are absolute in practice, but a hand-written `~/notes` still works.
164pub fn expand_bookmark_path(path: &Path) -> PathBuf {
165    match path.to_str().and_then(expand_tilde) {
166        Some(expanded) => expanded,
167        None => path.to_path_buf(),
168    }
169}
170
171/// The bookmark list a fresh install starts with: one entry for the home
172/// directory. Empty only when `$HOME` is unknown.
173pub fn default_bookmarks() -> Vec<Bookmark> {
174    std::env::var_os("HOME")
175        .map(PathBuf::from)
176        .filter(|home| !home.as_os_str().is_empty())
177        .map(|home| vec![Bookmark::leaf(String::from("Home"), home)])
178        .unwrap_or_default()
179}
180
181// ---------------------------------------------------------------------------
182// Config values
183// ---------------------------------------------------------------------------
184
185/// Which edge of the screen the panel docks to.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
187#[serde(rename_all = "lowercase")]
188pub enum PanelSide {
189    #[default]
190    Left,
191    Right,
192}
193
194impl PanelSide {
195    /// Both sides, left then right.
196    pub const ALL: [PanelSide; 2] = [PanelSide::Left, PanelSide::Right];
197
198    /// The other side ("right" for left, and vice versa).
199    pub fn opposite(&self) -> PanelSide {
200        match self {
201            PanelSide::Left => PanelSide::Right,
202            PanelSide::Right => PanelSide::Left,
203        }
204    }
205
206    /// The lowercase word used in user-facing labels ("left" / "right").
207    pub fn name(&self) -> &'static str {
208        match self {
209            PanelSide::Left => "left",
210            PanelSide::Right => "right",
211        }
212    }
213}
214
215/// The wlr-layer-shell layer the panel lives in. `Bottom` keeps it behind
216/// normal windows (the Ormachy/Hyprland "stays out of the way" mode).
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
218#[serde(rename_all = "lowercase")]
219pub enum PanelLayer {
220    Background,
221    #[default]
222    Bottom,
223    Top,
224    Overlay,
225}
226
227/// Smallest allowed panel width, in pixels.
228pub const PANEL_MIN_WIDTH: u32 = 160;
229/// Largest allowed panel width, in pixels.
230pub const PANEL_MAX_WIDTH: u32 = 2000;
231
232// Field-level serde defaults. These run only for a field omitted from a
233// *user's* partial table (the shipped file lists every field, so they are never
234// reached while [`builtin`] is being initialized). Each returns the shipped
235// value, so an omitted key still matches `default-config.toml`.
236fn default_panel_side() -> PanelSide {
237    builtin().panel.side
238}
239fn default_panel_layer() -> PanelLayer {
240    builtin().panel.layer
241}
242fn default_panel_width() -> u32 {
243    builtin().panel.width
244}
245fn default_panel_nav_toolbar() -> bool {
246    builtin().panel.nav_toolbar
247}
248fn default_dirs_first() -> bool {
249    builtin().tree.dirs_first
250}
251fn default_sort_key() -> String {
252    builtin().tree.sort_key.clone()
253}
254fn default_sort_ascending() -> bool {
255    builtin().tree.sort_ascending
256}
257fn default_font_size() -> u32 {
258    builtin().tree.font_size
259}
260fn default_icon_size() -> u32 {
261    builtin().tree.icon_size
262}
263fn default_confirm_drop_move() -> bool {
264    builtin().tree.confirm_drop_move
265}
266fn default_bookmarks_file() -> PathBuf {
267    builtin().bookmarks.file.clone()
268}
269fn default_bookmarks_menu() -> Vec<ContextAction> {
270    builtin().bookmarks.menu.clone()
271}
272fn default_bookmarks_context() -> Vec<ContextAction> {
273    builtin().bookmarks.context.clone()
274}
275fn default_bookmarks_blank() -> Vec<ContextAction> {
276    builtin().bookmarks.blank.clone()
277}
278
279/// Dock/panel configuration. Field-level serde defaults keep a partially
280/// written `[panel]` table valid without consulting `Self::default()` (which
281/// parses the shipped file — see [`builtin`]).
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283pub struct PanelConfig {
284    #[serde(default = "default_panel_side")]
285    pub side: PanelSide,
286    #[serde(default = "default_panel_layer")]
287    pub layer: PanelLayer,
288    /// Panel width in pixels.
289    #[serde(default = "default_panel_width")]
290    pub width: u32,
291    /// Pixel margin around the panel within the screen edge.
292    #[serde(default)]
293    pub margin: u32,
294    /// Show a navigation toolbar (up one level, back, forward) directly below
295    /// the path bar. Off by default; the same actions stay on `[pane_menu]`.
296    #[serde(default = "default_panel_nav_toolbar")]
297    pub nav_toolbar: bool,
298}
299
300impl Default for PanelConfig {
301    fn default() -> Self {
302        builtin().panel
303    }
304}
305
306/// Sorting and visibility rules for the tree.
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct TreeConfig {
309    /// When true, directories are listed before files; otherwise everything is
310    /// interleaved and only alphabetically sorted. Names are always sorted
311    /// alphabetically (case-insensitively).
312    #[serde(default = "default_dirs_first")]
313    pub dirs_first: bool,
314    /// Primary sort key: `name`, `size`, `modified`, or `type`.
315    #[serde(default = "default_sort_key")]
316    pub sort_key: String,
317    /// Sort ascending (A→Z, smallest first) when true; descending otherwise.
318    #[serde(default = "default_sort_ascending")]
319    pub sort_ascending: bool,
320    /// Show dotfiles. When false, hidden entries are never read into the model.
321    #[serde(default)]
322    pub show_hidden: bool,
323    /// Base font size in px applied to the tree.
324    #[serde(default = "default_font_size")]
325    pub font_size: u32,
326    /// Pixel size of file/directory icons in the tree.
327    #[serde(default = "default_icon_size")]
328    pub icon_size: u32,
329    /// When true, dropping a dragged selection onto a directory asks for
330    /// confirmation before moving; when false (the default) the move happens
331    /// immediately. Copying (Ctrl+drag) never asks.
332    #[serde(default = "default_confirm_drop_move")]
333    pub confirm_drop_move: bool,
334}
335
336impl Default for TreeConfig {
337    fn default() -> Self {
338        builtin().tree.clone()
339    }
340}
341
342impl TreeConfig {
343    /// Translate the configured sort fields into a [`SortOptions`]. Unknown
344    /// `sort_key` values fall back to name.
345    pub fn sort_options(&self) -> crate::fs::SortOptions {
346        crate::fs::SortOptions {
347            dirs_first: self.dirs_first,
348            key: crate::fs::SortKey::parse(&self.sort_key).unwrap_or(crate::fs::SortKey::Name),
349            ascending: self.sort_ascending,
350        }
351    }
352}
353
354/// Where the panel opens when launched without an explicit path argument.
355///
356/// In TOML this is a single value: `"home"` (the default), `"last"`,
357/// `"bookmarks"` (open with the bookmarks section showing and no directory
358/// pane), or a table naming a fixed directory — `{ path = "/some/dir" }`. A
359/// leading `~` in the path expands to `$HOME`.
360#[derive(Debug, Clone, PartialEq, Eq, Default)]
361pub enum StartupRoot {
362    /// Always open the user's home directory.
363    #[default]
364    Home,
365    /// Reopen the most recently used directory (persisted between launches).
366    Last,
367    /// Always open the named directory.
368    Path(String),
369    /// Open with the bookmarks section as the initial view and no directory pane.
370    Bookmarks,
371}
372
373impl StartupRoot {
374    /// Resolve the directory to open, given the last-used directory from session
375    /// state. `None` means "fall back to home" (unknown/unusable path).
376    pub fn resolve(&self, last: Option<PathBuf>) -> Option<PathBuf> {
377        match self {
378            StartupRoot::Last => last,
379            StartupRoot::Home => None,
380            StartupRoot::Path(path) => expand_tilde(path),
381            // Bookmarks opens no directory pane; the app shows the section.
382            StartupRoot::Bookmarks => None,
383        }
384    }
385
386    /// Whether this selects the bookmarks view rather than a directory.
387    pub fn is_bookmarks(&self) -> bool {
388        matches!(self, StartupRoot::Bookmarks)
389    }
390}
391
392impl Serialize for StartupRoot {
393    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
394        use serde::ser::SerializeMap;
395        match self {
396            StartupRoot::Last => serializer.serialize_str("last"),
397            StartupRoot::Home => serializer.serialize_str("home"),
398            StartupRoot::Bookmarks => serializer.serialize_str("bookmarks"),
399            StartupRoot::Path(path) => {
400                let mut map = serializer.serialize_map(Some(1))?;
401                map.serialize_entry("path", path)?;
402                map.end()
403            }
404        }
405    }
406}
407
408impl<'de> Deserialize<'de> for StartupRoot {
409    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
410        use serde::de::Error as _;
411
412        #[derive(Deserialize)]
413        #[serde(untagged)]
414        enum Repr {
415            Keyword(String),
416            Path { path: String },
417        }
418
419        match Repr::deserialize(deserializer)? {
420            Repr::Keyword(word) => match word.to_ascii_lowercase().as_str() {
421                "last" => Ok(StartupRoot::Last),
422                "home" => Ok(StartupRoot::Home),
423                "bookmarks" => Ok(StartupRoot::Bookmarks),
424                other => Err(D::Error::custom(format!(
425                    "unknown startup root {other:?} (expected \"last\", \"home\", \"bookmarks\", or {{ path = \"...\" }})"
426                ))),
427            },
428            Repr::Path { path } => Ok(StartupRoot::Path(path)),
429        }
430    }
431}
432
433/// Expand a leading `~` in `path` to `$HOME`. An empty string stays empty.
434pub fn expand_tilde(path: &str) -> Option<PathBuf> {
435    if path.is_empty() {
436        return None;
437    }
438    match path.strip_prefix('~') {
439        Some(rest) => std::env::var_os("HOME").map(|home| {
440            let mut p = PathBuf::from(home);
441            p.push(rest.strip_prefix('/').unwrap_or(rest));
442            p
443        }),
444        // A relative configured path is resolved against the working directory
445        // (matching how CLI arguments are normalized), so the pane and its path
446        // entry never show a bare relative spelling.
447        None => {
448            let path = PathBuf::from(path);
449            Some(std::path::absolute(&path).unwrap_or(path))
450        }
451    }
452}
453
454/// Which rows a context-menu rule applies to. Checked in order of rule
455/// precedence: [`Dir`](ContextMatch::Dir) first, then extension, then
456/// no-extension, then [`Regex`](ContextMatch::Regex) / [`Fallback`](ContextMatch::Fallback)
457/// for anything else.
458#[derive(Debug, Clone)]
459pub enum ContextMatch {
460    /// A directory.
461    Dir,
462    /// A file with no extension (the name has no `.` after the last path
463    /// separator).
464    NoExt,
465    /// Every path that was not claimed by a more specific rule.
466    Fallback,
467    /// A multi-row selection (more than one row selected). Not a property of a
468    /// single path, so [`Self::matches`] always returns false for it; it only
469    /// participates in [`ContextMenu::actions_for_selection`].
470    Multi,
471    /// A file whose *name* ends with the extension, case-insensitively:
472    /// `Ext("py")` matches both `x.py` and `x.tar.gz` (multi-part suffixes are
473    /// matched from the end, so more specific rules should come first).
474    Ext(String),
475    /// The full row path matched against a regex. Applies to files and
476    /// directories alike; case-sensitive unless the pattern opts out with
477    /// `(?i)`. Matched against the whole path, so name-only patterns should
478    /// anchor with `.*\.NAME$`.
479    Regex(Regex),
480}
481
482impl ContextMatch {
483    /// The canonical configuration spelling.
484    pub fn as_str(&self) -> String {
485        match self {
486            ContextMatch::Dir => "dir".to_owned(),
487            ContextMatch::NoExt => "noext".to_owned(),
488            ContextMatch::Fallback => "fallback".to_owned(),
489            ContextMatch::Multi => "multi".to_owned(),
490            ContextMatch::Ext(ext) => format!("ext:{ext}"),
491            ContextMatch::Regex(re) => format!("regex:{}", re.as_str()),
492        }
493    }
494
495    /// Parse a config spelling. `"*"` is an alias for `"fallback"`.
496    pub fn parse(value: &str) -> Result<Self, String> {
497        let value = value.trim();
498        match value {
499            "dir" | "directory" => Ok(ContextMatch::Dir),
500            "noext" => Ok(ContextMatch::NoExt),
501            "fallback" | "*" | "all" => Ok(ContextMatch::Fallback),
502            "multi" | "multi-select" | "selection" => Ok(ContextMatch::Multi),
503            _ => {
504                if let Some(ext) = value.strip_prefix("ext:") {
505                    return Ok(ContextMatch::Ext(ext.to_owned()));
506                }
507                if let Some(pattern) = value.strip_prefix("regex:") {
508                    return Regex::new(pattern)
509                        .map(ContextMatch::Regex)
510                        .map_err(|err| {
511                            format!(
512                                "invalid regex {pattern:?} in context menu matcher {value:?}: {err}"
513                            )
514                        });
515                }
516                Err(format!("unknown context menu matcher {value:?}"))
517            }
518        }
519    }
520
521    /// Whether `path` is claimed by this matcher. Extension and no-extention
522    /// matchers only ever claim files.
523    pub fn matches(&self, path: &Path) -> bool {
524        match self {
525            ContextMatch::Dir => path.is_dir(),
526            ContextMatch::NoExt => {
527                let Some(name) = path.file_name() else {
528                    return false;
529                };
530                let name = name.to_string_lossy();
531                !path.is_dir() && !name.ends_with('.') && {
532                    // No dot past the last path separators that isn't the
533                    // leading dot of a hidden file name.
534                    let after_sep = name.rsplit(['/', '\\']).next().unwrap_or("");
535                    !after_sep[1..].contains('.')
536                }
537            }
538            ContextMatch::Fallback => true,
539            // Selection-level, never a property of one path.
540            ContextMatch::Multi => false,
541            ContextMatch::Ext(ext) => {
542                if path.is_dir() || ext.is_empty() {
543                    return false;
544                }
545                let Some(name) = path.file_name() else {
546                    return false;
547                };
548                let name = name.to_string_lossy().to_lowercase();
549                name.trim_start_matches('.').ends_with(&format!(".{}", ext.trim().to_lowercase()))
550            }
551            ContextMatch::Regex(re) => re.is_match(&path.to_string_lossy()),
552        }
553    }
554}
555
556impl PartialEq for ContextMatch {
557    fn eq(&self, other: &Self) -> bool {
558        match (self, other) {
559            (ContextMatch::Dir, ContextMatch::Dir) => true,
560            (ContextMatch::NoExt, ContextMatch::NoExt) => true,
561            (ContextMatch::Fallback, ContextMatch::Fallback) => true,
562            (ContextMatch::Multi, ContextMatch::Multi) => true,
563            (ContextMatch::Ext(a), ContextMatch::Ext(b)) => a == b,
564            (ContextMatch::Regex(a), ContextMatch::Regex(b)) => a.as_str() == b.as_str(),
565            _ => false,
566        }
567    }
568}
569
570impl Eq for ContextMatch {}
571
572impl Serialize for ContextMatch {
573    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
574        serializer.serialize_str(&self.as_str())
575    }
576}
577
578impl<'de> Deserialize<'de> for ContextMatch {
579    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
580        let raw = String::deserialize(deserializer)?;
581        ContextMatch::parse(&raw).map_err(serde::de::Error::custom)
582    }
583}
584
585/// A built-in context-menu action: everything the panel can do to a row that
586/// is not a custom command. A [`Separator`](BuiltinAction::Separator) is only a
587/// menu divider; it has no action.
588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589pub enum BuiltinAction {
590    /// Open: expand/collapse a directory, or open a file in its default app.
591    Open,
592    /// Open the directory as a new split pane *in the current dock*.
593    OpenSplit,
594    /// Open the directory as a new pane in the *same* panel (like a split view).
595    InNewPanel,
596    /// Open the directory in the *opposite* panel (the dock on the other side
597    /// of the screen). The menu label names that side: "In right panel".
598    InOppositePanel,
599    /// Pick an application for the row and open it with that app.
600    OpenWith,
601    /// Open a file with its default application (a sibling of
602    /// [`Self::Open`]; the label shows the default app's name).
603    OpenWithDefault,
604    /// Show or hide an inline thumbnail preview: below an image row, or below
605    /// every image inside a directory. Purely visual and non-persistent.
606    ViewThumbnail,
607    /// Create a new file in the row's target directory.
608    NewFile,
609    /// Create a new folder in the row's target directory.
610    NewFolder,
611    /// Inline-rename the row.
612    Rename,
613    /// Duplicate the row.
614    Duplicate,
615    /// Copy the row's absolute path.
616    CopyPath,
617    /// Copy the row's path relative to the tree root.
618    CopyRelativePath,
619    /// Add the directory row to the bookmarks list.
620    AddBookmark,
621    /// Cut the selection (moved on the next paste).
622    Cut,
623    /// Copy the selection (duplicated on the next paste).
624    Copy,
625    /// Paste the clipboard into the row's directory (the row itself for a
626    /// directory, its parent for a file).
627    Paste,
628    /// Create a symlink to the row next to it.
629    CreateLink,
630    /// Show the row's properties.
631    Properties,
632    /// Move the row to the trash.
633    Trash,
634    /// Permanently delete the row.
635    DeletePermanently,
636    /// Toggle visibility of dotfiles in the current tree.
637    ToggleHidden,
638    /// Sort entries by name.
639    SortByName,
640    /// Sort entries by size.
641    SortBySize,
642    /// Sort entries by modification time.
643    SortByModified,
644    /// Sort entries by type/extension.
645    SortByType,
646    /// Flip ascending/descending order.
647    ToggleSortAscending,
648    /// Pane-level: pick a folder to browse (the hamburger menu).
649    OpenFolder,
650    /// Pane-level: open the filter bar.
651    Filter,
652    /// Pane-level: split the pane.
653    SplitView,
654    /// Pane-level: open the current directory's parent (the enclosing folder).
655    Up,
656    /// Pane-level: go back to the previously shown directory in this pane.
657    Back,
658    /// Pane-level: go forward again after going back.
659    Forward,
660    /// Pane-level: hide this pane's whole side.
661    Collapse,
662    /// Pane-level: close this pane.
663    ClosePane,
664    /// Pane-level: show or hide the bookmarks section in this dock.
665    ToggleBookmarks,
666    /// Bookmark-only: edit the clicked bookmark (name/path).
667    EditBookmark,
668    /// Bookmark-only: delete the clicked bookmark.
669    DeleteBookmark,
670    /// Bookmarks view: create a new leaf bookmark.
671    NewBookmark,
672    /// Bookmarks view: create a new (empty) bookmark folder.
673    NewBookmarkFolder,
674    /// A menu divider; never does anything.
675    Separator,
676}
677
678impl BuiltinAction {
679    /// The configuration string identifying this action (also its menu label,
680    /// unless the menu uses a dynamic label).
681    pub fn as_str(&self) -> &'static str {
682        match self {
683            BuiltinAction::Open => "Open",
684            BuiltinAction::OpenSplit => "Open in Split View",
685            BuiltinAction::InNewPanel => "In new panel",
686            BuiltinAction::InOppositePanel => "In opposite panel",
687            BuiltinAction::OpenWith => "Open With...",
688            BuiltinAction::OpenWithDefault => "Open With Default",
689            BuiltinAction::ViewThumbnail => "View Thumbnail",
690            BuiltinAction::NewFile => "New File",
691            BuiltinAction::NewFolder => "New Folder",
692            BuiltinAction::Rename => "Rename",
693            BuiltinAction::Duplicate => "Duplicate",
694            BuiltinAction::CopyPath => "Copy Path",
695            BuiltinAction::CopyRelativePath => "Copy Relative Path",
696            BuiltinAction::AddBookmark => "Add Bookmark",
697            BuiltinAction::Cut => "Cut",
698            BuiltinAction::Copy => "Copy",
699            BuiltinAction::Paste => "Paste",
700            BuiltinAction::CreateLink => "Create Link",
701            BuiltinAction::Properties => "Properties",
702            BuiltinAction::Trash => "Move to Trash",
703            BuiltinAction::DeletePermanently => "Delete Permanently",
704            BuiltinAction::ToggleHidden => "Toggle Hidden Files",
705            BuiltinAction::SortByName => "Sort by Name",
706            BuiltinAction::SortBySize => "Sort by Size",
707            BuiltinAction::SortByModified => "Sort by Modified",
708            BuiltinAction::SortByType => "Sort by Type",
709            BuiltinAction::ToggleSortAscending => "Reverse Sort Order",
710            BuiltinAction::OpenFolder => "Open Folder...",
711            BuiltinAction::Filter => "Filter...",
712            BuiltinAction::SplitView => "Split View",
713            BuiltinAction::Up => "Up One Level",
714            BuiltinAction::Back => "Back",
715            BuiltinAction::Forward => "Forward",
716            BuiltinAction::Collapse => "Collapse",
717            BuiltinAction::ClosePane => "Close Pane",
718            BuiltinAction::ToggleBookmarks => "Bookmarks",
719            BuiltinAction::EditBookmark => "Edit Bookmark",
720            BuiltinAction::DeleteBookmark => "Delete Bookmark",
721            BuiltinAction::NewBookmark => "New Bookmark",
722            BuiltinAction::NewBookmarkFolder => "New Bookmark Folder",
723            BuiltinAction::Separator => "---",
724        }
725    }
726
727    /// The shortcut that a bare string item implies, when the user does not
728    /// spell one out. `None` for actions without a default key.
729    pub fn default_shortcut(&self) -> Option<&'static str> {
730        match self {
731            BuiltinAction::Cut => Some("Ctrl+x"),
732            BuiltinAction::Copy => Some("Ctrl+c"),
733            BuiltinAction::Paste => Some("Ctrl+v"),
734            BuiltinAction::Rename => Some("F2"),
735            BuiltinAction::Duplicate => Some("Ctrl+d"),
736            BuiltinAction::NewFile => Some("Ctrl+n"),
737            BuiltinAction::NewFolder => Some("Ctrl+Shift+n"),
738            BuiltinAction::CreateLink => Some("Ctrl+Shift+m"),
739            BuiltinAction::Trash => Some("Delete"),
740            BuiltinAction::DeletePermanently => Some("Shift+Delete"),
741            BuiltinAction::ViewThumbnail => Some("Ctrl+t"),
742            BuiltinAction::ToggleHidden => Some("Ctrl+h"),
743            BuiltinAction::SortByName => Some("Ctrl+1"),
744            BuiltinAction::SortBySize => Some("Ctrl+2"),
745            BuiltinAction::SortByModified => Some("Ctrl+3"),
746            BuiltinAction::SortByType => Some("Ctrl+4"),
747            BuiltinAction::ToggleSortAscending => Some("Ctrl+Shift+r"),
748            BuiltinAction::SplitView => Some("Ctrl+s"),
749            BuiltinAction::Up => Some("Alt+Up"),
750            BuiltinAction::Back => Some("Alt+Left"),
751            BuiltinAction::Forward => Some("Alt+Right"),
752            BuiltinAction::ClosePane => Some("Ctrl+w"),
753            BuiltinAction::ToggleBookmarks => Some("Ctrl+b"),
754            _ => None,
755        }
756    }
757
758    /// Whether this action operates on a single row only, and so should be
759    /// hidden from a context menu opened over a multi-row selection. Actions
760    /// that fan out over the selection (trash, delete, copy, cut, properties,
761    /// copy path) are not listed here.
762    pub fn is_single_row_only(&self) -> bool {
763        matches!(
764            self,
765            BuiltinAction::Open
766                | BuiltinAction::OpenSplit
767                | BuiltinAction::InNewPanel
768                | BuiltinAction::InOppositePanel
769                | BuiltinAction::OpenWith
770                | BuiltinAction::OpenWithDefault
771                | BuiltinAction::ViewThumbnail
772                | BuiltinAction::NewFile
773                | BuiltinAction::NewFolder
774                | BuiltinAction::Rename
775                | BuiltinAction::Duplicate
776                | BuiltinAction::CreateLink
777                | BuiltinAction::AddBookmark
778        )
779    }
780
781    /// Whether this action only makes sense for directories (the menu drops
782    /// it from file rows).
783    pub fn is_directory_only(&self) -> bool {
784        matches!(
785            self,
786            BuiltinAction::OpenSplit
787                | BuiltinAction::InNewPanel
788                | BuiltinAction::InOppositePanel
789                | BuiltinAction::AddBookmark
790        )
791    }
792
793    /// Whether this is a pane-level action (open a folder, filter, split,
794    /// collapse, close) that the app, not a tree, must perform. Pane actions
795    /// belong in the hamburger menu; a shortcut bound to one is routed upward.
796    pub fn is_pane_action(&self) -> bool {
797        matches!(
798            self,
799            BuiltinAction::OpenFolder
800                | BuiltinAction::Filter
801                | BuiltinAction::SplitView
802                | BuiltinAction::Up
803                | BuiltinAction::Back
804                | BuiltinAction::Forward
805                | BuiltinAction::Collapse
806                | BuiltinAction::ClosePane
807                | BuiltinAction::ToggleBookmarks
808                | BuiltinAction::NewBookmark
809                | BuiltinAction::NewBookmarkFolder
810        )
811    }
812
813    /// Whether this action only makes sense inside the bookmarks view's own
814    /// menu (it needs a bookmark entry as its target, or creates one).
815    pub fn is_bookmark_only(&self) -> bool {
816        matches!(
817            self,
818            BuiltinAction::EditBookmark
819                | BuiltinAction::DeleteBookmark
820                | BuiltinAction::NewBookmark
821                | BuiltinAction::NewBookmarkFolder
822        )
823    }
824
825    /// Parse a configuration string, case-insensitively. `"---"` and
826    /// `"separator"` both spell a menu divider.
827    ///
828    /// The typographic ellipsis (`…`, U+2026) is accepted as a synonym for
829    /// three ASCII dots (`...`) in labels, so configs written before the switch
830    /// to plain ASCII still load.
831    pub fn parse(name: &str) -> Option<Self> {
832        let name = name.trim();
833        let name = if name.contains('\u{2026}') {
834            name.replace('\u{2026}', "...")
835        } else {
836            name.to_owned()
837        };
838        let name = name.as_str();
839        [
840            Self::Open,
841            Self::OpenSplit,
842            Self::InNewPanel,
843            Self::InOppositePanel,
844            Self::OpenWith,
845            Self::OpenWithDefault,
846            Self::ViewThumbnail,
847            Self::NewFile,
848            Self::NewFolder,
849            Self::Rename,
850            Self::Duplicate,
851            Self::CopyPath,
852            Self::CopyRelativePath,
853            Self::AddBookmark,
854            Self::Cut,
855            Self::Copy,
856            Self::Paste,
857            Self::CreateLink,
858            Self::Properties,
859            Self::Trash,
860            Self::DeletePermanently,
861            Self::ToggleHidden,
862            Self::SortByName,
863            Self::SortBySize,
864            Self::SortByModified,
865            Self::SortByType,
866            Self::ToggleSortAscending,
867            Self::OpenFolder,
868            Self::Filter,
869            Self::SplitView,
870            Self::Up,
871            Self::Back,
872            Self::Forward,
873            Self::Collapse,
874            Self::ClosePane,
875            Self::ToggleBookmarks,
876            Self::EditBookmark,
877            Self::DeleteBookmark,
878            Self::NewBookmark,
879            Self::NewBookmarkFolder,
880            Self::Separator,
881        ]
882        .into_iter()
883        .find(|a| {
884            a.as_str().eq_ignore_ascii_case(name)
885                || (matches!(a, Self::Separator) && name.eq_ignore_ascii_case("separator"))
886        })
887    }
888}
889
890impl Serialize for BuiltinAction {
891    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
892        serializer.serialize_str(self.as_str())
893    }
894}
895
896impl<'de> Deserialize<'de> for BuiltinAction {
897    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
898        let raw = String::deserialize(deserializer)?;
899        BuiltinAction::parse(&raw).ok_or_else(|| {
900            serde::de::Error::custom(format!("unknown context menu builtin action {raw:?}"))
901        })
902    }
903}
904
905/// A custom command run with the row's path. The `{path}` marker is replaced
906/// with the row's full path; `{dir}` with the directory itself for a directory
907/// row or its parent for a file. With no marker, the path is appended as the
908/// final argument.
909#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
910pub struct CommandAction {
911    pub command: String,
912    /// Optional menu label; defaults to the command text.
913    #[serde(default, skip_serializing_if = "Option::is_none")]
914    pub label: Option<String>,
915    /// Optional accelerator (GTK syntax: `"Ctrl+Shift+m"`, `"F2"`, `"Delete"`).
916    /// While the tree is focused, the key fires this command against the row
917    /// under the keyboard cursor.
918    #[serde(default, skip_serializing_if = "Option::is_none")]
919    pub shortcut: Option<String>,
920    /// Hide this item from the rendered menu while keeping its shortcut live —
921    /// a way to bind a key without cluttering the menu.
922    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
923    pub hidden: bool,
924}
925
926/// A builtin menu item written out in table form, so a label override and/or a
927/// shortcut can be attached to it: `{ action = "Cut", shortcut = "Ctrl+x" }`.
928#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
929pub struct BuiltinEntry {
930    pub action: BuiltinAction,
931    /// Optional menu label override; defaults to the action's name.
932    #[serde(default, skip_serializing_if = "Option::is_none")]
933    pub label: Option<String>,
934    /// Optional accelerator override; defaults to the action's default
935    /// shortcut (see [`BuiltinAction::default_shortcut`]).
936    #[serde(default, skip_serializing_if = "Option::is_none")]
937    pub shortcut: Option<String>,
938    /// Hide this item from the rendered menu while keeping its shortcut live.
939    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
940    pub hidden: bool,
941}
942
943/// A nested submenu: a labelled row that opens a child menu of its own items.
944/// The items may be anything a top-level menu item can be, including further
945/// submenus.
946#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
947pub struct Submenu {
948    /// The row's label.
949    pub label: String,
950    /// The child menu's items, in order.
951    pub items: Vec<ContextAction>,
952    /// Hide this submenu from the rendered menu while keeping the shortcuts of
953    /// its children live.
954    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
955    pub hidden: bool,
956}
957
958impl Submenu {
959    /// Wrap `items` in a submenu with the given `label`.
960    pub fn new(label: impl Into<String>, items: Vec<ContextAction>) -> Self {
961        Self { label: label.into(), items, hidden: false }
962    }
963}
964
965/// One entry in a context-menu rule:
966///   * a builtin action, written as a plain string (`"Cut"`), which gets its
967///     action's default shortcut;
968///   * a builtin in table form (`{ action = "Cut", shortcut = "Ctrl+x" }`);
969///   * a custom command (`{ command = "...", label = "...", shortcut = "..." }`);
970///   * a submenu (`{ label = "More", items = [ ... ] }`).
971#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
972#[serde(untagged)]
973pub enum ContextAction {
974    Builtin(BuiltinAction),
975    Entry(BuiltinEntry),
976    Command(CommandAction),
977    Submenu(Submenu),
978}
979
980/// The action bound by a shortcut: a builtin, or a custom command that is run
981/// against the current row.
982#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
983#[serde(untagged)]
984pub enum ShortcutTarget {
985    Builtin(BuiltinAction),
986    Command(CommandAction),
987}
988
989impl ShortcutTarget {
990    /// Resolve a menu item to the target a shortcut would invoke. Submenus have
991    /// no action of their own, so they yield `None`.
992    pub fn from_action(action: &ContextAction) -> Option<Self> {
993        Some(match action {
994            ContextAction::Builtin(a) => ShortcutTarget::Builtin(*a),
995            ContextAction::Entry(e) => ShortcutTarget::Builtin(e.action),
996            ContextAction::Command(cmd) => ShortcutTarget::Command(cmd.clone()),
997            ContextAction::Submenu(_) => return None,
998        })
999    }
1000}
1001
1002impl ContextAction {
1003    /// The menu item's label (the *base* label; a few actions use a dynamic
1004    /// label that depends on the row, resolved in the UI layer).
1005    pub fn label(&self) -> String {
1006        match self {
1007            ContextAction::Builtin(action) => action.as_str().to_owned(),
1008            ContextAction::Entry(entry) => {
1009                entry.label.clone().unwrap_or_else(|| entry.action.as_str().to_owned())
1010            }
1011            ContextAction::Command(cmd) => cmd.label.clone().unwrap_or_else(|| cmd.command.clone()),
1012            ContextAction::Submenu(sub) => sub.label.clone(),
1013        }
1014    }
1015
1016    /// Whether this item opens a submenu rather than dispatching an action.
1017    pub fn is_submenu(&self) -> bool {
1018        matches!(self, ContextAction::Submenu(_))
1019    }
1020
1021    /// Whether this item is hidden from the rendered menu. A hidden item keeps
1022    /// its shortcut live; only its row is skipped. A plain-string builtin
1023    /// cannot be hidden (write it as a table with `hidden = true` instead).
1024    pub fn is_hidden(&self) -> bool {
1025        match self {
1026            ContextAction::Builtin(_) => false,
1027            ContextAction::Entry(entry) => entry.hidden,
1028            ContextAction::Command(cmd) => cmd.hidden,
1029            ContextAction::Submenu(sub) => sub.hidden,
1030        }
1031    }
1032
1033    /// The child items when this is a submenu.
1034    pub fn submenu_items(&self) -> Option<&[ContextAction]> {
1035        match self {
1036            ContextAction::Submenu(sub) => Some(&sub.items),
1037            _ => None,
1038        }
1039    }
1040
1041    /// The shortcut attached to this item, if any: an explicit override when
1042    /// written in table form, otherwise the builtin's default shortcut.
1043    pub fn shortcut(&self) -> Option<String> {
1044        match self {
1045            ContextAction::Builtin(action) => action
1046                .default_shortcut()
1047                .map(str::to_owned),
1048            ContextAction::Entry(entry) => entry
1049                .shortcut
1050                .clone()
1051                .or_else(|| entry.action.default_shortcut().map(str::to_owned)),
1052            ContextAction::Command(cmd) => cmd.shortcut.clone(),
1053            ContextAction::Submenu(_) => None,
1054        }
1055    }
1056}
1057
1058/// A context-menu rule: rows claimed by any `matches` entry get exactly
1059/// `items`. Rules are evaluated top-down; the first match wins.
1060///
1061/// A rule may instead be an **include**: `include = "rules.d"` (no `matches`)
1062/// pulls in the `[[context_menu.rules]]` of every `*.toml` file in that
1063/// directory, spliced in at the include's position. This lets specific rules
1064/// live in their own files yet still be checked *before* a later catch-all
1065/// `fallback` rule. See [`expand_includes`].
1066#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1067pub struct ContextRule {
1068    #[serde(default)]
1069    pub matches: Vec<ContextMatch>,
1070    #[serde(default)]
1071    pub items: Vec<ContextAction>,
1072    /// Directory (relative to the config file, or absolute) whose `*.toml`
1073    /// files supply rules to splice in here. Mutually exclusive with `matches`.
1074    #[serde(default, skip_serializing_if = "Option::is_none")]
1075    pub include: Option<PathBuf>,
1076}
1077
1078impl ContextRule {
1079    /// A plain matching rule.
1080    pub fn rule(matches: Vec<ContextMatch>, items: Vec<ContextAction>) -> Self {
1081        ContextRule { matches, items, include: None }
1082    }
1083
1084    /// An include placeholder.
1085    pub fn include(path: PathBuf) -> Self {
1086        ContextRule { matches: Vec::new(), items: Vec::new(), include: Some(path) }
1087    }
1088}
1089
1090/// Configurable per-row context menus.
1091///
1092/// With no configuration the built-in rules produce the classic menu; the
1093/// built-in defaults are listed below so users can copy and adapt them.
1094#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1095pub struct ContextMenu {
1096    pub rules: Vec<ContextRule>,
1097}
1098
1099#[cfg(test)]
1100macro_rules! builtin_items {
1101    ($($name:ident),* $(,)?) => {
1102        vec![
1103            $(ContextAction::Builtin(BuiltinAction::$name),)*
1104        ]
1105    };
1106}
1107
1108impl ContextMenu {
1109    /// The menu for a row no rule claims. Sourced from the first `dir`/`fallback`
1110    /// rule of the shipped default (so `default-config.toml` also decides the
1111    /// fallback); an empty menu when neither is present.
1112    pub fn fallback_actions(path: &Path) -> Vec<ContextAction> {
1113        let wanted = if path.is_dir() { ContextMatch::Dir } else { ContextMatch::Fallback };
1114        builtin()
1115            .context_menu
1116            .rules
1117            .iter()
1118            .find(|rule| rule.matches.contains(&wanted))
1119            .map(|rule| rule.items.clone())
1120            .unwrap_or_default()
1121    }
1122
1123    /// The menu actions for `path`, in display order.
1124    ///
1125    /// Resolution is top-down over [`Self::rules`]; the first rule with at
1126    /// least one claimed matcher provides the whole menu. Rows no rule claims
1127    /// get [`Self::fallback_actions`].
1128    pub fn actions_for(&self, path: &Path) -> Vec<ContextAction> {
1129        for rule in &self.rules {
1130            if rule.matches.iter().any(|m| m.matches(path)) {
1131                return rule.items.clone();
1132            }
1133        }
1134        Self::fallback_actions(path)
1135    }
1136
1137    /// The menu actions for a context menu opened over the given selection.
1138    ///
1139    /// When more than one row is selected the first rule carrying the
1140    /// [`Multi`](ContextMatch::Multi) matcher provides the whole menu (so a
1141    /// `multi` rule takes precedence over the per-path rules). For a single
1142    /// selection this is exactly [`Self::actions_for`].
1143    pub fn actions_for_selection(&self, path: &Path, selection_len: usize) -> Vec<ContextAction> {
1144        if selection_len > 1 {
1145            for rule in &self.rules {
1146                if rule.matches.contains(&ContextMatch::Multi) {
1147                    return rule.items.clone();
1148                }
1149            }
1150        }
1151        self.actions_for(path)
1152    }
1153
1154    /// Every configured shortcut, in rule order, deduplicated (first wins).
1155    ///
1156    /// These drive the tree's keyboard handling: while the tree is focused, a
1157    /// pressed key fires the bound action against the row under the cursor.
1158    /// Shortcuts are collected from *all* rules (not just the one that claims
1159    /// the current row), so a shortcut stays live wherever its action is
1160    /// reachable in some menu.
1161    pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
1162        let mut seen = std::collections::HashSet::new();
1163        let mut out = Vec::new();
1164        for rule in &self.rules {
1165            collect_shortcuts(&rule.items, &mut seen, &mut out);
1166        }
1167        out
1168    }
1169}
1170
1171/// Recursively collect shortcuts from `actions`, descending into submenus.
1172/// `seen` deduplicates by accelerator so the first binding wins.
1173fn collect_shortcuts(
1174    actions: &[ContextAction],
1175    seen: &mut std::collections::HashSet<String>,
1176    out: &mut Vec<(String, ShortcutTarget)>,
1177) {
1178    for action in actions {
1179        if let Some(items) = action.submenu_items() {
1180            collect_shortcuts(items, seen, out);
1181            continue;
1182        }
1183        let Some(shortcut) = action.shortcut() else {
1184            continue;
1185        };
1186        if !seen.insert(shortcut.clone()) {
1187            continue;
1188        }
1189        if let Some(target) = ShortcutTarget::from_action(action) {
1190            out.push((shortcut, target));
1191        }
1192    }
1193}
1194
1195impl Default for ContextMenu {
1196    fn default() -> Self {
1197        builtin().context_menu.clone()
1198    }
1199}
1200
1201/// The configurable hamburger ("pane") menu that opens from the toolbar.
1202///
1203/// Unlike the per-row [`ContextMenu`] there are no match rules — the pane menu
1204/// is one flat list of items that apply to the whole pane (open a folder, toggle
1205/// hidden files, change the sort order, close the pane, ...). Items reuse the same
1206/// [`ContextAction`] vocabulary as the context menu, so builtins, table entries
1207/// with a shortcut, and custom commands all work here too.
1208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1209pub struct PaneMenu {
1210    pub items: Vec<ContextAction>,
1211}
1212
1213impl PaneMenu {
1214    /// The label for each item, in display order (used by tests and the UI).
1215    pub fn labels(&self) -> Vec<String> {
1216        self.items.iter().map(ContextAction::label).collect()
1217    }
1218
1219    /// Every shortcut attached to a pane-menu item, in order, deduplicated
1220    /// (first wins). Merged with the context-menu shortcuts so pane actions
1221    /// (`Ctrl+h`, `Ctrl+1`...) work while the tree is focused.
1222    pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
1223        let mut seen = std::collections::HashSet::new();
1224        let mut out = Vec::new();
1225        collect_shortcuts(&self.items, &mut seen, &mut out);
1226        out
1227    }
1228}
1229
1230impl Default for PaneMenu {
1231    fn default() -> Self {
1232        builtin().pane_menu.clone()
1233    }
1234}
1235
1236/// The `[bookmarks]` options table. Bookmarks are a pane view (a "new panel"
1237/// showing directory shortcuts to jump from).
1238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1239pub struct BookmarksConfig {
1240    /// The bookmarks list file, relative to the config directory (or absolute).
1241    /// Created with a single home bookmark when missing.
1242    #[serde(default = "default_bookmarks_file")]
1243    pub file: PathBuf,
1244    /// The hamburger menu shown while the bookmarks view is active, in the same
1245    /// item syntax as `[pane_menu]`.
1246    #[serde(default = "default_bookmarks_menu")]
1247    pub menu: Vec<ContextAction>,
1248    /// Extra items appended to a bookmark's right-click menu, after the menu
1249    /// inherited from the bookmarked directory. `Edit Bookmark` and
1250    /// `Delete Bookmark` are the bookmark-specific actions.
1251    #[serde(default = "default_bookmarks_context")]
1252    pub context: Vec<ContextAction>,
1253    /// The context menu opened by right-clicking the empty area below the
1254    /// bookmarks (usually the "new bookmark" items).
1255    #[serde(default = "default_bookmarks_blank")]
1256    pub blank: Vec<ContextAction>,
1257}
1258
1259impl Default for BookmarksConfig {
1260    fn default() -> Self {
1261        builtin().bookmarks.clone()
1262    }
1263}
1264
1265/// One entry in the bookmarks list: either a directory shortcut (has a `path`),
1266/// a folder grouping nested entries (non-empty `items`), or both.
1267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1268pub struct Bookmark {
1269    pub name: String,
1270    /// The directory this entry opens. `None` for a pure folder.
1271    #[serde(default, skip_serializing_if = "Option::is_none")]
1272    pub path: Option<PathBuf>,
1273    /// Nested entries. A non-empty list makes this a folder, expanded or
1274    /// collapsed with `expanded`.
1275    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1276    pub items: Vec<Bookmark>,
1277    /// Whether a folder starts expanded. Serialized only when true.
1278    #[serde(default, skip_serializing_if = "is_false")]
1279    pub expanded: bool,
1280}
1281
1282/// `skip_serializing_if` helper: omit `false` booleans from the bookmarks file.
1283fn is_false(value: &bool) -> bool {
1284    !*value
1285}
1286
1287impl Bookmark {
1288    /// The default label for a bookmark pointing at `path`: the directory's own
1289    /// name (falling back to the full path for roots and odd spellings).
1290    pub fn default_name(path: &Path) -> String {
1291        path.file_name()
1292            .map(|name| name.to_string_lossy().into_owned())
1293            .filter(|name| !name.is_empty())
1294            .unwrap_or_else(|| path.display().to_string())
1295    }
1296
1297    /// A leaf bookmark pointing at `path`.
1298    pub fn leaf(name: String, path: PathBuf) -> Self {
1299        Bookmark { name, path: Some(path), items: Vec::new(), expanded: false }
1300    }
1301
1302    /// Whether this entry is a folder. Anything without a path is a folder
1303    /// (including an empty one just created); an entry with both a path and
1304    /// children is both clickable and a container.
1305    pub fn is_folder(&self) -> bool {
1306        self.path.is_none() || !self.items.is_empty()
1307    }
1308
1309    /// Whether any entry in the tree points at `path`.
1310    pub fn contains_path(entries: &[Bookmark], path: &Path) -> bool {
1311        entries.iter().any(|entry| {
1312            entry.path.as_deref() == Some(path) || Bookmark::contains_path(&entry.items, path)
1313        })
1314    }
1315
1316    /// The entry at an index path (each element indexes into the next level).
1317    pub fn get<'a>(entries: &'a [Bookmark], index_path: &[usize]) -> Option<&'a Bookmark> {
1318        let (head, rest) = index_path.split_first()?;
1319        let entry = entries.get(*head)?;
1320        if rest.is_empty() {
1321            Some(entry)
1322        } else {
1323            Bookmark::get(&entry.items, rest)
1324        }
1325    }
1326
1327    /// The entry at an index path (each element indexes into the next level),
1328    /// mutably.
1329    pub fn get_mut<'a>(entries: &'a mut [Bookmark], index_path: &[usize]) -> Option<&'a mut Bookmark> {
1330        let (head, rest) = index_path.split_first()?;
1331        let entry = entries.get_mut(*head)?;
1332        if rest.is_empty() {
1333            Some(entry)
1334        } else {
1335            Bookmark::get_mut(&mut entry.items, rest)
1336        }
1337    }
1338
1339    /// Remove the entry at `index_path`, returning whether it existed.
1340    #[allow(clippy::ptr_arg)] // removal needs `Vec`, not a slice
1341    pub fn remove(entries: &mut Vec<Bookmark>, index_path: &[usize]) -> bool {
1342        Bookmark::take(entries, index_path).is_some()
1343    }
1344
1345    /// Remove and return the entry at `index_path` (for moving it elsewhere).
1346    #[allow(clippy::ptr_arg)] // removal needs `Vec`, not a slice
1347    pub fn take(entries: &mut Vec<Bookmark>, index_path: &[usize]) -> Option<Bookmark> {
1348        let (last, parents) = index_path.split_last()?;
1349        if parents.is_empty() {
1350            (*last < entries.len()).then(|| entries.remove(*last))
1351        } else if let Some(parent) = Bookmark::get_mut(entries, parents) {
1352            (*last < parent.items.len()).then(|| parent.items.remove(*last))
1353        } else {
1354            None
1355        }
1356    }
1357
1358    /// Insert `entry` into the list at `parent` (empty = top level), at `index`
1359    /// clamped to the list length.
1360    pub fn insert(
1361        entries: &mut Vec<Bookmark>,
1362        parent: &[usize],
1363        index: usize,
1364        entry: Bookmark,
1365    ) -> bool {
1366        if parent.is_empty() {
1367            entries.insert(index.min(entries.len()), entry);
1368            true
1369        } else if let Some(folder) = Bookmark::get_mut(entries, parent) {
1370            folder.items.insert(index.min(folder.items.len()), entry);
1371            true
1372        } else {
1373            false
1374        }
1375    }
1376}
1377
1378/// The on-disk shape of the bookmarks file: a top-level `bookmarks` array. The
1379/// reader accepts both the compact `bookmarks = [{ ... }]` form written by
1380/// [`bookmarks_to_toml`] and the older `[[bookmarks]]` / `[[bookmarks.items]]`
1381/// tables.
1382#[derive(Debug, Default, Serialize, Deserialize)]
1383struct BookmarksFile {
1384    #[serde(default)]
1385    bookmarks: Vec<Bookmark>,
1386}
1387
1388/// Top-level configuration.
1389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1390#[serde(default)]
1391pub struct Config {
1392    pub panel: PanelConfig,
1393    pub tree: TreeConfig,
1394    pub startup: StartupRoot,
1395    pub context_menu: ContextMenu,
1396    pub pane_menu: PaneMenu,
1397    pub bookmarks: BookmarksConfig,
1398}
1399
1400impl Default for Config {
1401    /// The shipped defaults, parsed from `default-config.toml` (see [`builtin`]).
1402    /// `#[serde(default)]` above uses this to fill any section a user's file
1403    /// omits, so editing `default-config.toml` changes both.
1404    fn default() -> Self {
1405        builtin().clone()
1406    }
1407}
1408
1409impl Config {
1410    /// Load the config file for the current environment.
1411    ///
1412    /// On first launch — when neither the config file nor its `tree-space`
1413    /// directory exists — the default config is written out to disk first, so
1414    /// the user always has a file to edit afterwards.
1415    ///
1416    /// Unparsable files fall back to defaults with an error report (a broken
1417    /// dotfile cannot take down the panel, and is never clobbered).
1418    pub fn load() -> LoadResult {
1419        Self::load_and_ensure(&config_file())
1420    }
1421
1422    /// [`Self::load`] against an explicit path (tests).
1423    fn load_and_ensure(path: &Path) -> LoadResult {
1424        let result = Self::load_from_path(path);
1425        if result.problem.is_none()
1426            && !path.exists()
1427            && let Err(problem) = Self::ensure_default_file(path)
1428        {
1429            return LoadResult { config: result.config, problem: Some(problem) };
1430        }
1431        result
1432    }
1433
1434    /// Write the default config to `<path>`, creating parent directories. Used
1435    /// on first launch so a config file always exists. Best-effort: failures
1436    /// are reported but never prevent startup.
1437    ///
1438    /// Skips the attempt entirely when neither the config directory nor the
1439    /// file exists *and* the parent directory is not writable: a read-only
1440    /// `$XDG_CONFIG_HOME` then produces no error on every launch (the panel
1441    /// simply uses defaults).
1442    fn ensure_default_file(path: &Path) -> Result<(), LoadProblem> {
1443        let Some(parent) = path.parent() else {
1444            return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1445        };
1446        if !parent.exists()
1447            && let Err(err) = std::fs::create_dir_all(parent)
1448        {
1449            return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1450        }
1451        // A read-only directory is a normal, expected setup (e.g. a Nix or
1452        // immutable-home install); treat it as "no config to write" rather
1453        // than an error.
1454        if let Ok(metadata) = std::fs::metadata(parent)
1455            && metadata.permissions().readonly()
1456        {
1457            return Ok(());
1458        }
1459        std::fs::write(path, DEFAULT_CONFIG)
1460            .map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1461    }
1462
1463    /// Parse a config from a file, returning any problem encountered.
1464    pub fn load_from_path(path: &Path) -> LoadResult {
1465        let raw = match std::fs::read_to_string(path) {
1466            Ok(raw) => raw,
1467            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1468                return LoadResult {
1469                    config: Config::default(),
1470                    problem: None,
1471                };
1472            }
1473            Err(err) => {
1474                return LoadResult {
1475                    config: Config::default(),
1476                    problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
1477                };
1478            }
1479        };
1480        Self::parse(&raw, path)
1481    }
1482
1483    /// Validate and normalize a parsed config.
1484    fn normalize(mut self) -> Self {
1485        self.panel.width = self
1486            .panel
1487            .width
1488            .clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
1489        self.panel.margin = self.panel.margin.clamp(0, 200);
1490        self.tree.font_size = self.tree.font_size.clamp(6, 40);
1491        self.tree.icon_size = self.tree.icon_size.clamp(12, 128);
1492        self
1493    }
1494}
1495
1496/// Why a config could not be loaded cleanly. Absence of a problem (/ defaults
1497/// used because the file doesn't exist) is not reported.
1498#[derive(Debug, Clone)]
1499pub enum LoadProblem {
1500    Io(PathBuf, std::io::ErrorKind),
1501    Parse(PathBuf, toml::de::Error),
1502}
1503
1504/// The outcome of loading a config: always a usable config plus an optional
1505/// problem description for the status bar.
1506#[derive(Debug, Clone)]
1507pub struct LoadResult {
1508    pub config: Config,
1509    pub problem: Option<LoadProblem>,
1510}
1511
1512/// The outcome of loading the user stylesheet.
1513#[derive(Debug, Clone)]
1514pub struct Stylesheet {
1515    /// The CSS to apply: the user's file when present and readable, otherwise
1516    /// the shipped default.
1517    pub css: String,
1518    /// Where the CSS came from, for a status message or diagnostics.
1519    pub source: StyleSource,
1520    /// A read problem, when the file existed but could not be read. The `css`
1521    /// then falls back to the shipped default.
1522    pub problem: Option<LoadProblem>,
1523}
1524
1525/// Which stylesheet [`load_stylesheet`] actually returned.
1526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1527pub enum StyleSource {
1528    /// The user's `main.css`.
1529    User,
1530    /// The compiled-in default (no user file, or it could not be read).
1531    Builtin,
1532}
1533
1534/// Load the user stylesheet for the current environment.
1535///
1536/// Prefers `$XDG_CONFIG_HOME/tree-space/main.css`; when that is missing the
1537/// shipped default is used. On first launch the default is written out first
1538/// (see [`ensure_default_stylesheet`]) so a file always exists to edit.
1539pub fn load_stylesheet() -> Stylesheet {
1540    let path = style_file();
1541    // Materialize the default on first launch, mirroring the config file, so
1542    // there is always something to edit.
1543    let _ = ensure_default_stylesheet(&path);
1544    load_stylesheet_from_path(&path)
1545}
1546
1547/// Read the stylesheet at `path`, falling back to the built-in default when it
1548/// is missing or unreadable.
1549fn load_stylesheet_from_path(path: &Path) -> Stylesheet {
1550    match std::fs::read_to_string(path) {
1551        Ok(css) => Stylesheet {
1552            css,
1553            source: StyleSource::User,
1554            problem: None,
1555        },
1556        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Stylesheet {
1557            css: DEFAULT_STYLESHEET.to_owned(),
1558            source: StyleSource::Builtin,
1559            problem: None,
1560        },
1561        Err(err) => Stylesheet {
1562            css: DEFAULT_STYLESHEET.to_owned(),
1563            source: StyleSource::Builtin,
1564            problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
1565        },
1566    }
1567}
1568
1569/// Write the shipped stylesheet to `path` when it does not exist yet, creating
1570/// parent directories. Best-effort, like [`Config::ensure_default_file`]:
1571/// a read-only config directory simply means "no file to write", not an error.
1572fn ensure_default_stylesheet(path: &Path) -> Result<(), LoadProblem> {
1573    if path.exists() {
1574        return Ok(());
1575    }
1576    let Some(parent) = path.parent() else {
1577        return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1578    };
1579    if !parent.exists()
1580        && let Err(err) = std::fs::create_dir_all(parent)
1581    {
1582        return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1583    }
1584    if let Ok(metadata) = std::fs::metadata(parent)
1585        && metadata.permissions().readonly()
1586    {
1587        return Ok(());
1588    }
1589    std::fs::write(path, DEFAULT_STYLESHEET)
1590        .map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1591}
1592
1593impl Config {
1594    /// Parse TOML text, reporting parse problems without aborting.
1595    fn parse(raw: &str, path: &Path) -> LoadResult {
1596        match toml::from_str::<Config>(raw) {
1597            Ok(mut config) => {
1598                // Resolve `include = "..."` rules relative to the config file.
1599                let base = path.parent().unwrap_or_else(|| Path::new("."));
1600                let mut problems = Vec::new();
1601                config.context_menu.rules =
1602                    expand_includes(&config.context_menu.rules, base, 0, &mut problems);
1603                let config = config.normalize();
1604                LoadResult {
1605                    config,
1606                    // Only the first problem is surfaced, matching the single
1607                    // status-line slot; the config is still fully usable.
1608                    problem: problems.into_iter().next(),
1609                }
1610            }
1611            Err(err) => LoadResult {
1612                config: Config::default(),
1613                problem: Some(LoadProblem::Parse(path.to_path_buf(), err)),
1614            },
1615        }
1616    }
1617}
1618
1619/// Maximum include nesting, so a cycle (`a.toml` inside the directory it
1620/// includes) cannot recurse forever.
1621const MAX_INCLUDE_DEPTH: usize = 8;
1622
1623/// Read the bookmarks list named by `config` for the current environment. A
1624/// missing file yields [`default_bookmarks`] with no problem; an unreadable or
1625/// unparsable one yields the default list plus the problem, so a broken file
1626/// never takes the section down.
1627pub fn load_bookmarks(config: &BookmarksConfig) -> (Vec<Bookmark>, Option<LoadProblem>) {
1628    load_bookmarks_from_path(&bookmark_file_path(&config.file))
1629}
1630
1631/// Read a bookmarks list from an explicit path (tests).
1632pub fn load_bookmarks_from_path(path: &Path) -> (Vec<Bookmark>, Option<LoadProblem>) {
1633    match std::fs::read_to_string(path) {
1634        Ok(text) => match toml::from_str::<BookmarksFile>(&text) {
1635            Ok(file) => (file.bookmarks, None),
1636            Err(err) => (default_bookmarks(), Some(LoadProblem::Parse(path.to_path_buf(), err))),
1637        },
1638        Err(err) if err.kind() == std::io::ErrorKind::NotFound => (default_bookmarks(), None),
1639        Err(err) => (default_bookmarks(), Some(LoadProblem::Io(path.to_path_buf(), err.kind()))),
1640    }
1641}
1642
1643/// Write `bookmarks` to `path` atomically, creating parent directories. Mirrors
1644/// [`SessionState::save_to_path`]: temp file plus rename, so a crash mid-write
1645/// never truncates the existing list.
1646pub fn save_bookmarks_to_path(path: &Path, bookmarks: &[Bookmark]) -> Result<(), LoadProblem> {
1647    if let Some(parent) = path.parent()
1648        && !parent.as_os_str().is_empty()
1649        && let Err(err) = std::fs::create_dir_all(parent)
1650    {
1651        return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1652    }
1653    let body = bookmarks_to_toml(bookmarks);
1654    let tmp = path.with_extension("toml.tmp");
1655    std::fs::write(&tmp, body).map_err(|err| LoadProblem::Io(tmp.clone(), err.kind()))?;
1656    std::fs::rename(&tmp, path).map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1657}
1658
1659/// Render the bookmarks list as compact TOML:
1660///
1661/// ```toml
1662/// bookmarks = [
1663///     { name = "Home", path = "/home/me" },
1664///     { name = "Work", expanded = true, items = [
1665///         { name = "Repo", path = "/srv/repo" },
1666///     ] },
1667/// ]
1668/// ```
1669///
1670/// The reader accepts this and the older `[[bookmarks]]` / `[[bookmarks.items]]`
1671/// form alike, so only the writer needs to know the shape.
1672fn bookmarks_to_toml(bookmarks: &[Bookmark]) -> String {
1673    let mut out = String::from("bookmarks = [\n");
1674    for bookmark in bookmarks {
1675        out.push_str(&bookmark_to_toml(bookmark, 4));
1676        out.push_str(",\n");
1677    }
1678    out.push_str("]\n");
1679    out
1680}
1681
1682/// One bookmarks entry as an inline table, indented by `indent` spaces. Nested
1683/// folders render as an `items = [ ... ]` array on the same line.
1684fn bookmark_to_toml(bookmark: &Bookmark, indent: usize) -> String {
1685    let pad = " ".repeat(indent);
1686    let mut fields = vec![format!("name = {}", toml_string(&bookmark.name))];
1687    if let Some(path) = &bookmark.path {
1688        fields.push(format!("path = {}", toml_string(&path.to_string_lossy())));
1689    }
1690    if bookmark.expanded {
1691        fields.push("expanded = true".to_owned());
1692    }
1693    if !bookmark.items.is_empty() {
1694        let mut items = String::from("items = [\n");
1695        for child in &bookmark.items {
1696            items.push_str(&bookmark_to_toml(child, indent + 4));
1697            items.push_str(",\n");
1698        }
1699        items.push_str(&format!("{pad}]"));
1700        fields.push(items);
1701    }
1702    format!("{pad}{{ {} }}", fields.join(", "))
1703}
1704
1705/// A double-quoted TOML basic string with the standard escapes.
1706fn toml_string(value: &str) -> String {
1707    let mut out = String::with_capacity(value.len() + 2);
1708    out.push('"');
1709    for ch in value.chars() {
1710        match ch {
1711            '"' => out.push_str("\\\""),
1712            '\\' => out.push_str("\\\\"),
1713            '\n' => out.push_str("\\n"),
1714            '\r' => out.push_str("\\r"),
1715            '\t' => out.push_str("\\t"),
1716            ch if (ch as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", ch as u32)),
1717            ch => out.push(ch),
1718        }
1719    }
1720    out.push('"');
1721    out
1722}
1723
1724/// Create the bookmarks file for `config` (containing `list`) on first launch,
1725/// so the user has something to edit. Never clobbers an existing file; a
1726/// read-only directory is treated as "nothing to write" (as with the config).
1727pub fn ensure_bookmarks_file(
1728    config: &BookmarksConfig,
1729    list: &[Bookmark],
1730) -> Result<(), LoadProblem> {
1731    ensure_bookmarks_at(&bookmark_file_path(&config.file), list)
1732}
1733
1734/// [`ensure_bookmarks_file`] against an explicit path (tests).
1735pub fn ensure_bookmarks_at(path: &Path, list: &[Bookmark]) -> Result<(), LoadProblem> {
1736    if path.exists() {
1737        return Ok(());
1738    }
1739    let Some(parent) = path.parent() else {
1740        return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1741    };
1742    if !parent.as_os_str().is_empty()
1743        && !parent.exists()
1744        && let Err(err) = std::fs::create_dir_all(parent)
1745    {
1746        return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1747    }
1748    if let Ok(metadata) = std::fs::metadata(parent)
1749        && metadata.permissions().readonly()
1750    {
1751        return Ok(());
1752    }
1753    save_bookmarks_to_path(path, list)
1754}
1755
1756
1757/// The shape of a drop-in file pulled in by an include rule: the same
1758/// `[[context_menu.rules]]` table as the main config, nothing else.
1759#[derive(Deserialize)]
1760struct IncludedRules {
1761    #[serde(default)]
1762    context_menu: ContextMenu,
1763}
1764
1765/// Replace every include rule in `rules` with the rules of the `*.toml` files
1766/// in its directory, spliced in at the include's position.
1767///
1768/// The directory is resolved relative to `base` (the containing config file's
1769/// directory). Files are read in filename order (so `10-a.toml` sorts before
1770/// `20-b.toml`), and each file's `[[context_menu.rules]]` are expanded
1771/// recursively. A missing directory contributes nothing; an unreadable or
1772/// unparsable file is added to `problems` and skipped, so one bad drop-in never
1773/// breaks the rest of the menu.
1774fn expand_includes(
1775    rules: &[ContextRule],
1776    base: &Path,
1777    depth: usize,
1778    problems: &mut Vec<LoadProblem>,
1779) -> Vec<ContextRule> {
1780    let mut out = Vec::new();
1781    for rule in rules {
1782        let Some(include) = &rule.include else {
1783            out.push(rule.clone());
1784            continue;
1785        };
1786        if depth >= MAX_INCLUDE_DEPTH {
1787            continue;
1788        }
1789        let dir = if include.is_absolute() {
1790            include.clone()
1791        } else {
1792            base.join(include)
1793        };
1794        for file in toml_files_in(&dir, problems) {
1795            // Nested includes resolve relative to *this* file's directory.
1796            let nested_base = file.parent().unwrap_or(base);
1797            match std::fs::read_to_string(&file) {
1798                Ok(text) => match toml::from_str::<IncludedRules>(&text) {
1799                    Ok(included) => {
1800                        out.extend(expand_includes(
1801                            &included.context_menu.rules,
1802                            nested_base,
1803                            depth + 1,
1804                            problems,
1805                        ));
1806                    }
1807                    Err(err) => problems.push(LoadProblem::Parse(file.clone(), err)),
1808                },
1809                Err(err) => problems.push(LoadProblem::Io(file.clone(), err.kind())),
1810            }
1811        }
1812    }
1813    out
1814}
1815
1816/// The `*.toml` files directly inside `dir`, sorted by filename. A missing
1817/// directory (or one that cannot be read) yields an empty list.
1818fn toml_files_in(dir: &Path, problems: &mut Vec<LoadProblem>) -> Vec<PathBuf> {
1819    let entries = match std::fs::read_dir(dir) {
1820        Ok(entries) => entries,
1821        // A missing include directory is normal: contribute nothing.
1822        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
1823        Err(err) => {
1824            problems.push(LoadProblem::Io(dir.to_path_buf(), err.kind()));
1825            return Vec::new();
1826        }
1827    };
1828    let mut files: Vec<PathBuf> = entries
1829        .filter_map(Result::ok)
1830        .map(|entry| entry.path())
1831        .filter(|path| {
1832            path.is_file()
1833                && path
1834                    .extension()
1835                    .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
1836        })
1837        .collect();
1838    files.sort();
1839    files
1840}
1841
1842// ---------------------------------------------------------------------------
1843// Session state
1844// ---------------------------------------------------------------------------
1845
1846/// Small runtime state persisted between launches.
1847#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1848pub struct SessionState {
1849    /// The most recently opened root directory, if any.
1850    pub last_root: Option<PathBuf>,
1851    /// Left dock width from the last interactive resize, if any. Overrides the
1852    /// configured `[panel] width` on the next launch.
1853    pub left_width: Option<u32>,
1854    /// Right dock width from the last interactive resize, if any.
1855    pub right_width: Option<u32>,
1856}
1857
1858impl SessionState {
1859    /// Load state from the environment's state file. Missing or broken files
1860    /// yield an empty default.
1861    pub fn load() -> Self {
1862        Self::load_from_path(&state_file())
1863    }
1864
1865    /// Load state from an explicit path.
1866    pub fn load_from_path(path: &Path) -> Self {
1867        match std::fs::read_to_string(path) {
1868            Ok(raw) => toml::from_str(&raw).unwrap_or_default(),
1869            Err(_) => Self::default(),
1870        }
1871    }
1872
1873    /// Persist state to the environment's state file, creating directories.
1874    pub fn save(&self) -> std::io::Result<()> {
1875        Self::save_to_path(&state_file(), self)
1876    }
1877
1878    /// Persist state to an explicit path, creating parent directories.
1879    ///
1880    /// Atomic: the serialized bytes are written to a sibling temp file first and
1881    /// then `rename`d over the target, so a crash mid-write can never leave a
1882    /// truncated `state.toml` behind (the old file stays intact until the swap).
1883    pub fn save_to_path(path: &Path, state: &Self) -> std::io::Result<()> {
1884        if let Some(parent) = path.parent() {
1885            std::fs::create_dir_all(parent)?;
1886        }
1887        let body = toml::to_string(state).map_err(|e| {
1888            std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
1889        })?;
1890        let tmp = path.with_extension("toml.tmp");
1891        std::fs::write(&tmp, body)?;
1892        std::fs::rename(&tmp, path)
1893    }
1894}
1895
1896/// Build a command line for a custom context-menu action on `path`.
1897///
1898/// The template is tokenized on whitespace.
1899/// Literal `{path}` is replaced with the row's full path and `{dir}` with the
1900/// directory row itself (or, for a file row, its parent). When the template has
1901/// neither marker, the row's path is appended as the trailing argument.
1902pub fn action_command(template: &str, path: &Path) -> Vec<String> {
1903    let path_s = path.to_string_lossy();
1904    let dir = if path.is_dir() {
1905        path.to_string_lossy()
1906    } else {
1907        path.parent().unwrap_or(path).to_string_lossy()
1908    };
1909    let has_path = template.contains("{path}");
1910    let has_dir = template.contains("{dir}");
1911    let mut tokens: Vec<String> = template
1912        .split_whitespace()
1913        .map(|t| t.replace("{path}", &path_s).replace("{dir}", &dir))
1914        .filter(|t| !t.is_empty())
1915        .collect();
1916    if !has_path && !has_dir {
1917        tokens.push(path_s.into_owned());
1918    }
1919    tokens
1920}
1921
1922#[cfg(test)]
1923mod tests {
1924    use super::*;
1925
1926    fn parse(raw: &str) -> Config {
1927        Config::parse(raw, Path::new("test.toml")).config
1928    }
1929
1930    #[test]
1931    fn defaults_are_sane() {
1932        // Values themselves live in `default-config.toml` (the single source of
1933        // truth); this only checks they are structurally usable, so it does not
1934        // become a second copy of the defaults.
1935        let config = Config::default();
1936        assert!((PANEL_MIN_WIDTH..=PANEL_MAX_WIDTH).contains(&config.panel.width));
1937        assert!(config.tree.icon_size > 0);
1938        assert!(config.tree.font_size > 0);
1939        assert!(!config.context_menu.rules.is_empty());
1940        assert!(!config.pane_menu.items.is_empty());
1941    }
1942
1943    #[test]
1944    fn shipping_default_file_parses_and_is_current() {
1945        // `default-config.toml` is the single source of truth; `Config::default`
1946        // parses it. This guards that the embedded copy still parses cleanly and
1947        // matches the file on disk (so an un-rebuilt stale include is caught).
1948        assert_eq!(parse(DEFAULT_CONFIG), Config::default());
1949
1950        let written = std::fs::read_to_string(concat!(
1951            env!("CARGO_MANIFEST_DIR"),
1952            "/default-config.toml"
1953        ))
1954        .unwrap();
1955        assert_eq!(DEFAULT_CONFIG, written);
1956    }
1957
1958    #[test]
1959    fn empty_document_uses_defaults() {
1960        let config = parse("");
1961        assert_eq!(config, Config::default());
1962    }
1963
1964    #[test]
1965    fn full_document_round_trips() {
1966        let raw = r#"
1967[panel]
1968side = "right"
1969layer = "top"
1970width = 420
1971margin = 8
1972
1973[tree]
1974dirs_first = false
1975show_hidden = true
1976font_size = 14
1977icon_size = 20
1978"#;
1979        let config = parse(raw);
1980        assert_eq!(config.panel.side, PanelSide::Right);
1981        assert_eq!(config.panel.layer, PanelLayer::Top);
1982        assert_eq!(config.panel.width, 420);
1983        assert!(!config.tree.dirs_first);
1984        assert!(config.tree.show_hidden);
1985
1986        // Round-trip through TOML serialization.
1987        let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
1988        assert_eq!(config, reparsed);
1989    }
1990
1991    #[test]
1992    fn partial_document_is_merged_with_defaults() {
1993        let config = parse("[panel]\nside = \"right\"\n");
1994        assert_eq!(config.panel.side, PanelSide::Right);
1995        assert_eq!(config.panel.width, Config::default().panel.width);
1996        assert_eq!(config.tree, Config::default().tree);
1997    }
1998
1999    #[test]
2000    fn nav_toolbar_defaults_off_and_can_be_enabled() {
2001        // Off unless the user asks for it, and a partial `[panel]` table that
2002        // omits it keeps the shipped default (off).
2003        assert!(!Config::default().panel.nav_toolbar);
2004        assert!(!parse("[panel]\nside = \"right\"\n").panel.nav_toolbar);
2005        assert!(parse("[panel]\nnav_toolbar = true\n").panel.nav_toolbar);
2006    }
2007
2008    #[test]
2009    fn defaults_come_from_the_shipped_file() {
2010        // Every section's default (not just the whole config) must be the value
2011        // parsed from `default-config.toml`, so editing that file is sufficient.
2012        let shipped = parse(DEFAULT_CONFIG);
2013        assert_eq!(Config::default(), shipped);
2014        assert_eq!(PanelConfig::default(), shipped.panel);
2015        assert_eq!(TreeConfig::default(), shipped.tree);
2016        assert_eq!(ContextMenu::default(), shipped.context_menu);
2017        assert_eq!(PaneMenu::default(), shipped.pane_menu);
2018        assert_eq!(BookmarksConfig::default(), shipped.bookmarks);
2019        // A partial *section* falls back to the shipped value of the missing key.
2020        let partial = parse("[tree]\nfont_size = 21\n");
2021        assert_eq!(partial.tree.font_size, 21);
2022        assert_eq!(partial.tree.icon_size, shipped.tree.icon_size);
2023        assert_eq!(partial.tree.sort_key, shipped.tree.sort_key);
2024        assert_eq!(partial.tree.confirm_drop_move, shipped.tree.confirm_drop_move);
2025        // Drop-to-move is immediate by default; confirmation is opt-in.
2026        assert!(!shipped.tree.confirm_drop_move);
2027    }
2028
2029    #[test]
2030    fn out_of_range_values_are_clamped() {
2031        let config = parse(
2032            "[panel]\nwidth = 100000\nmargin = 9000\n\n[tree]\nfont_size = 2\nicon_size = 2\n",
2033        );
2034        assert_eq!(config.panel.width, 2000);
2035        assert_eq!(config.panel.margin, 200);
2036        assert_eq!(config.tree.font_size, 6);
2037        assert_eq!(config.tree.icon_size, 12);
2038    }
2039
2040    #[test]
2041    fn invalid_toml_falls_back_to_defaults_with_problem() {
2042        let result = Config::parse("panel = ", Path::new("broken.toml"));
2043        assert_eq!(result.config, Config::default());
2044        assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
2045    }
2046
2047    #[test]
2048    fn missing_file_yields_defaults_without_problem() {
2049        let result = Config::load_from_path(Path::new("/definitely/not/here/config.toml"));
2050        assert_eq!(result.config, Config::default());
2051        assert!(result.problem.is_none());
2052    }
2053
2054    #[test]
2055    fn first_launch_materializes_default_config_file() {
2056        let dir = tempfile::tempdir().unwrap();
2057        let path = dir.path().join("tree-space/config.toml");
2058        assert!(!path.exists());
2059
2060        let result = Config::load_and_ensure(&path);
2061        assert_eq!(result.config, Config::default());
2062        assert!(result.problem.is_none());
2063
2064        // The directory and file were created with the shipped template.
2065        let written = std::fs::read_to_string(&path).unwrap();
2066        assert_eq!(written, DEFAULT_CONFIG);
2067
2068        // A second load reads it back cleanly.
2069        let reloaded = Config::load_and_ensure(&path);
2070        assert_eq!(reloaded.config, Config::default());
2071        assert!(reloaded.problem.is_none());
2072    }
2073
2074    #[test]
2075    fn existing_file_is_never_overwritten() {
2076        let dir = tempfile::tempdir().unwrap();
2077        let path = dir.path().join("tree-space/config.toml");
2078        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2079        std::fs::write(&path, "[panel]\nwidth = 500\n").unwrap();
2080
2081        let result = Config::load_and_ensure(&path);
2082        assert_eq!(result.config.panel.width, 500);
2083        assert!(result.problem.is_none());
2084        assert_eq!(std::fs::read_to_string(&path).unwrap(), "[panel]\nwidth = 500\n");
2085    }
2086
2087    #[test]
2088    fn xdg_config_resolution_follows_spec() {
2089        // Explicit override wins.
2090        assert_eq!(
2091            xdg_config_home(Some("/opt/cfg"), Some("/home/u")),
2092            PathBuf::from("/opt/cfg")
2093        );
2094        // Falls back below HOME.
2095        assert_eq!(
2096            xdg_config_home(None, Some("/home/u")),
2097            PathBuf::from("/home/u/.config")
2098        );
2099        assert_eq!(
2100            xdg_state_home(None, Some("/home/u")),
2101            PathBuf::from("/home/u/.local/state")
2102        );
2103        // Empty XDG values are ignored per the spec.
2104        assert_eq!(xdg_config_home(Some(""), Some("/h")), PathBuf::from("/h/.config"));
2105    }
2106
2107    #[test]
2108    fn app_path_joins_app_dir() {
2109        assert_eq!(
2110            app_path(&PathBuf::from("/cfg"), STATE_FILE),
2111            PathBuf::from("/cfg/tree-space/state.toml")
2112        );
2113    }
2114
2115    #[test]
2116    fn session_state_round_trips_through_disk() {
2117        let dir = tempfile::tempdir().unwrap();
2118        let path = dir.path().join("state.toml");
2119        let state = SessionState {
2120            last_root: Some(PathBuf::from("/home/user/projects/foo")),
2121            left_width: Some(420),
2122            right_width: None,
2123        };
2124        SessionState::save_to_path(&path, &state).unwrap();
2125        assert_eq!(SessionState::load_from_path(&path), state);
2126    }
2127
2128    #[test]
2129    fn missing_or_broken_state_defaults() {
2130        let dir = tempfile::tempdir().unwrap();
2131        let broken = dir.path().join("broken.toml");
2132        std::fs::write(&broken, "not [ valid").unwrap();
2133        assert_eq!(SessionState::load_from_path(&broken), SessionState::default());
2134        assert_eq!(
2135            SessionState::load_from_path(&dir.path().join("missing.toml")),
2136            SessionState::default()
2137        );
2138    }
2139
2140    /// Flatten every label in a menu, descending into submenus, so tests can
2141    /// assert against items regardless of nesting.
2142    fn all_labels(actions: &[ContextAction]) -> Vec<String> {
2143        let mut out = Vec::new();
2144        for action in actions {
2145            match action.submenu_items() {
2146                Some(items) => out.extend(all_labels(items)),
2147                None => out.push(action.label()),
2148            }
2149        }
2150        out
2151    }
2152
2153    #[test]
2154    fn default_context_menu_reproduces_classic_items() {
2155        let menu = ContextMenu::default();
2156        let dir = tempfile::tempdir().unwrap();
2157        let file = dir.path().join("notes.txt");
2158        std::fs::write(&file, "").unwrap();
2159        std::fs::write(dir.path().join("photo.jpg"), "").unwrap();
2160
2161        let dir_labels = all_labels(&menu.actions_for(dir.path()));
2162        // Classic items still present (now reachable through the "Open"/"New"
2163        // submenus)...
2164        for classic in [
2165            "In same panel",
2166            "In split view",
2167            "Folder",
2168            "File",
2169            "Copy Path",
2170            "Move to Trash",
2171            "Delete Permanently",
2172        ] {
2173            assert!(dir_labels.contains(&classic.to_owned()), "missing {classic}: {dir_labels:?}");
2174        }
2175        // ...plus the file-manager parity additions.
2176        for parity in ["In opposite panel", "With...", "Cut", "Copy", "Paste", "Create Link", "Rename", "Properties"] {
2177            assert!(
2178                dir_labels.contains(&parity.to_owned()),
2179                "missing {parity}: {dir_labels:?}"
2180            );
2181        }
2182
2183        let file_labels = all_labels(&menu.actions_for(&file));
2184        // Directory-only items are dropped for files.
2185        for dropped in ["In split view", "In opposite panel"] {
2186            assert!(!file_labels.contains(&dropped.to_owned()), "{dropped} present: {file_labels:?}");
2187        }
2188        // The file "Open" submenu offers both the default app and "With...".
2189        assert!(file_labels.contains(&"With Default".to_owned()));
2190        assert!(file_labels.contains(&"With...".to_owned()));
2191        assert!(file_labels.len() > 15);
2192
2193        // Shortcuts are spelled out on the default actions (submenu items too).
2194        let menus = menu.actions_for(dir.path());
2195        let cut = menus
2196            .iter()
2197            .flat_map(flatten_actions)
2198            .find(|a| a.label() == "Cut")
2199            .expect("Cut present");
2200        assert_eq!(cut.shortcut().as_deref(), Some("Ctrl+x"));
2201        let trash = menus
2202            .iter()
2203            .flat_map(flatten_actions)
2204            .find(|a| a.label() == "Move to Trash")
2205            .expect("Trash present");
2206        assert_eq!(trash.shortcut().as_deref(), Some("Delete"));
2207    }
2208
2209    /// Borrow every leaf action in a menu, descending into submenus.
2210    fn flatten_actions(action: &ContextAction) -> Vec<&ContextAction> {
2211        match action.submenu_items() {
2212            Some(items) => items.iter().flat_map(flatten_actions).collect(),
2213            None => vec![action],
2214        }
2215    }
2216
2217    #[test]
2218    fn default_shortcuts_follow_conventions() {
2219        assert_eq!(BuiltinAction::Cut.default_shortcut(), Some("Ctrl+x"));
2220        assert_eq!(BuiltinAction::Copy.default_shortcut(), Some("Ctrl+c"));
2221        assert_eq!(BuiltinAction::Rename.default_shortcut(), Some("F2"));
2222        assert_eq!(BuiltinAction::CreateLink.default_shortcut(), Some("Ctrl+Shift+m"));
2223        assert_eq!(BuiltinAction::Trash.default_shortcut(), Some("Delete"));
2224        assert_eq!(BuiltinAction::DeletePermanently.default_shortcut(), Some("Shift+Delete"));
2225        assert_eq!(BuiltinAction::ViewThumbnail.default_shortcut(), Some("Ctrl+t"));
2226        assert_eq!(BuiltinAction::Open.default_shortcut(), None);
2227        assert_eq!(BuiltinAction::Separator.default_shortcut(), None);
2228    }
2229
2230    #[test]
2231    fn shortcuts_are_collected_top_down_and_deduplicated() {
2232        let menu = ContextMenu {
2233            rules: vec![
2234                ContextRule::rule(
2235                    vec![ContextMatch::Dir],
2236                    vec![
2237                        ContextAction::Builtin(BuiltinAction::Open),
2238                        ContextAction::Entry(BuiltinEntry {
2239                            action: BuiltinAction::Rename,
2240                            label: None,
2241                            shortcut: Some("F2".into()),
2242                            hidden: false,
2243                        }),
2244                        ContextAction::Command(CommandAction {
2245                            command: "code {path}".into(),
2246                            label: None,
2247                            shortcut: Some("Ctrl+Shift+e".into()),
2248                            hidden: false,
2249                        }),
2250                    ],
2251                ),
2252                ContextRule::rule(
2253                    vec![ContextMatch::Fallback],
2254                    vec![ContextAction::Builtin(BuiltinAction::Trash)],
2255                ),
2256            ],
2257        };
2258        let shortcuts = menu.shortcuts();
2259        // Open has no shortcut; separators would contribute none either.
2260        assert_eq!(
2261            shortcuts,
2262            vec![
2263                ("F2".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Rename)),
2264                ("Ctrl+Shift+e".to_owned(), ShortcutTarget::Command(CommandAction {
2265                    command: "code {path}".into(),
2266                    label: None,
2267                    shortcut: Some("Ctrl+Shift+e".into()),
2268                    hidden: false,
2269                })),
2270                ("Delete".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Trash)),
2271            ]
2272        );
2273    }
2274
2275    #[test]
2276    fn table_form_spells_out_shortcuts_and_labels() {
2277        let raw = r#"
2278[[context_menu.rules]]
2279matches = ["fallback"]
2280items = [
2281    { action = "Cut", shortcut = "Ctrl+Alt+x", label = "Cut here" },
2282    { command = "sh {path}", shortcut = "Ctrl+Alt+s" },
2283    "---",
2284]
2285"#;
2286        let config = parse(raw);
2287        let actions = &config.context_menu.rules[0].items;
2288        assert_eq!(actions.len(), 3);
2289
2290        match &actions[0] {
2291            ContextAction::Entry(entry) => {
2292                assert_eq!(entry.action, BuiltinAction::Cut);
2293                assert_eq!(entry.shortcut.as_deref(), Some("Ctrl+Alt+x"));
2294                assert_eq!(entry.label.as_deref(), Some("Cut here"));
2295                assert_eq!(actions[0].label(), "Cut here");
2296                assert_eq!(actions[0].shortcut().as_deref(), Some("Ctrl+Alt+x"));
2297            }
2298            other => panic!("expected Entry, got {other:?}"),
2299        }
2300        match &actions[1] {
2301            ContextAction::Command(cmd) => {
2302                assert_eq!(cmd.shortcut.as_deref(), Some("Ctrl+Alt+s"));
2303            }
2304            other => panic!("expected Command, got {other:?}"),
2305        }
2306        match &actions[2] {
2307            ContextAction::Builtin(BuiltinAction::Separator) => {}
2308            other => panic!("expected Separator, got {other:?}"),
2309        }
2310
2311        let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
2312        assert_eq!(config, reparsed);
2313    }
2314
2315    #[test]
2316    fn pane_actions_are_classified() {
2317        for action in [
2318            BuiltinAction::OpenFolder,
2319            BuiltinAction::Filter,
2320            BuiltinAction::SplitView,
2321            BuiltinAction::Collapse,
2322            BuiltinAction::ClosePane,
2323        ] {
2324            assert!(action.is_pane_action(), "{action:?} should be a pane action");
2325        }
2326        for action in [
2327            BuiltinAction::Open,
2328            BuiltinAction::OpenSplit,
2329            BuiltinAction::ToggleHidden,
2330            BuiltinAction::SortByName,
2331            BuiltinAction::Separator,
2332        ] {
2333            assert!(!action.is_pane_action(), "{action:?} is not a pane action");
2334        }
2335    }
2336
2337    #[test]
2338    fn separator_matches_both_spellings() {
2339        assert_eq!(BuiltinAction::parse("---"), Some(BuiltinAction::Separator));
2340        assert_eq!(BuiltinAction::parse("separator"), Some(BuiltinAction::Separator));
2341        assert_eq!(BuiltinAction::parse("SEPARATOR"), Some(BuiltinAction::Separator));
2342        assert_eq!(BuiltinAction::Separator.as_str(), "---");
2343    }
2344
2345    #[test]
2346    fn ellipsis_labels_use_ascii_and_accept_the_typographic_form() {
2347        // The canonical spellings are plain ASCII, so users can type them.
2348        assert_eq!(BuiltinAction::OpenWith.as_str(), "Open With...");
2349        assert_eq!(BuiltinAction::OpenFolder.as_str(), "Open Folder...");
2350        assert_eq!(BuiltinAction::Filter.as_str(), "Filter...");
2351        // The typographic ellipsis is still accepted as a synonym.
2352        assert_eq!(BuiltinAction::parse("Open With…"), Some(BuiltinAction::OpenWith));
2353        assert_eq!(BuiltinAction::parse("Open Folder…"), Some(BuiltinAction::OpenFolder));
2354        assert_eq!(BuiltinAction::parse("Filter…"), Some(BuiltinAction::Filter));
2355        // And the ASCII form parses too.
2356        assert_eq!(BuiltinAction::parse("open folder..."), Some(BuiltinAction::OpenFolder));
2357    }
2358
2359    #[test]
2360    fn pane_menu_defaults_include_view_and_pane_actions() {
2361        let menu = PaneMenu::default();
2362        let labels = menu.labels();
2363        assert!(labels.contains(&"Open Folder...".to_owned()));
2364        assert!(labels.contains(&"Split View".to_owned()));
2365        assert!(labels.contains(&"Close Pane".to_owned()));
2366        assert!(labels.contains(&"Toggle Hidden Files".to_owned()));
2367        assert!(labels.contains(&"Sort by Size".to_owned()));
2368        // The view actions are NOT in the row context menus.
2369        let dir_menu = ContextMenu::default();
2370        let dir_labels: Vec<String> = dir_menu
2371            .rules
2372            .iter()
2373            .flat_map(|rule| rule.items.iter())
2374            .map(ContextAction::label)
2375            .collect();
2376        assert!(!dir_labels.contains(&"Toggle Hidden Files".to_owned()));
2377    }
2378
2379    #[test]
2380    fn pane_menu_shortcuts_are_collected() {
2381        let menu = PaneMenu::default();
2382        let shortcuts = menu.shortcuts();
2383        assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+h"));
2384        assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+1"));
2385        // Separators and shortcut-less pane actions contribute nothing.
2386        assert!(shortcuts.iter().all(|(k, _)| !k.is_empty()));
2387    }
2388
2389    #[test]
2390    fn pane_menu_parses_custom_commands() {
2391        let toml = r#"
2392            items = [
2393                "Split View",
2394                "---",
2395                { command = "echo hi", label = "Say hi" },
2396            ]
2397        "#;
2398        let menu: PaneMenu = toml::from_str(toml).unwrap();
2399        assert_eq!(menu.labels(), vec!["Split View", "---", "Say hi"]);
2400    }
2401
2402    #[test]
2403    fn navigation_builtins_round_trip_and_are_pane_actions() {
2404        for action in [BuiltinAction::Up, BuiltinAction::Back, BuiltinAction::Forward] {
2405            let s = action.as_str();
2406            assert_eq!(BuiltinAction::parse(s), Some(action), "{s} should parse");
2407            assert!(action.is_pane_action(), "{s} should be a pane action");
2408            assert!(action.default_shortcut().is_some(), "{s} needs a default key");
2409        }
2410        assert_eq!(BuiltinAction::Up.default_shortcut(), Some("Alt+Up"));
2411        assert_eq!(BuiltinAction::Back.default_shortcut(), Some("Alt+Left"));
2412        assert_eq!(BuiltinAction::Forward.default_shortcut(), Some("Alt+Right"));
2413    }
2414
2415    #[test]
2416    fn hidden_items_parse_and_keep_their_shortcut() {
2417        let toml = r#"
2418            items = [
2419                { action = "Up One Level", hidden = true },
2420                { command = "echo hi", label = "Say hi", shortcut = "Ctrl+g", hidden = true },
2421                { label = "More", hidden = true, items = [ "Copy Path" ] },
2422                "Open",
2423            ]
2424        "#;
2425        let menu: PaneMenu = toml::from_str(toml).unwrap();
2426        assert!(menu.items[0].is_hidden());
2427        assert!(menu.items[1].is_hidden());
2428        assert!(menu.items[2].is_hidden());
2429        assert!(!menu.items[3].is_hidden());
2430        // Hidden items are still parsed with their labels and shortcuts.
2431        assert_eq!(menu.items[0].label(), "Up One Level");
2432        assert_eq!(menu.items[1].shortcut().as_deref(), Some("Ctrl+g"));
2433        // And hidden items contribute their shortcuts exactly like visible ones.
2434        let shortcuts = menu.shortcuts();
2435        assert!(shortcuts.iter().any(|(k, _)| k == "Alt+Up"));
2436        assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+g"));
2437    }
2438
2439    #[test]
2440    fn hidden_submenu_children_keep_their_shortcuts() {
2441        let toml = r#"
2442            items = [
2443                { label = "More", hidden = true, items = [
2444                    { action = "Rename", shortcut = "Ctrl+Shift+r" },
2445                ] },
2446            ]
2447        "#;
2448        let menu: PaneMenu = toml::from_str(toml).unwrap();
2449        assert!(menu.items[0].is_hidden());
2450        assert!(menu.shortcuts().iter().any(|(k, _)| k == "Ctrl+Shift+r"));
2451    }
2452
2453    #[test]
2454    fn hidden_defaults_to_false_and_is_not_serialized() {
2455        #[derive(serde::Deserialize)]
2456        struct One {
2457            item: ContextAction,
2458        }
2459        let visible: One = toml::from_str(r#"item = { action = "Cut", shortcut = "Ctrl+x" }"#).unwrap();
2460        assert!(!visible.item.is_hidden());
2461        // A plain-string builtin can never be hidden.
2462        let plain: One = toml::from_str(r#"item = "Cut""#).unwrap();
2463        assert!(!plain.item.is_hidden());
2464        // Round-trips omit the flag when it is false.
2465        #[derive(serde::Serialize)]
2466        struct OneOut {
2467            item: ContextAction,
2468        }
2469        let encoded = toml::to_string(&OneOut { item: visible.item }).unwrap();
2470        assert!(!encoded.contains("hidden"), "{encoded}");
2471    }
2472
2473    #[test]
2474    fn menu_items_parse_submenus_recursively() {
2475        let toml = r#"
2476            items = [
2477                "Open",
2478                { label = "More", items = [
2479                    { action = "Copy Path", shortcut = "Ctrl+Shift+c" },
2480                    { label = "Deeper", items = [ { command = "echo hi", label = "Hi" } ] },
2481                ] },
2482            ]
2483        "#;
2484        let menu: PaneMenu = toml::from_str(toml).unwrap();
2485        assert_eq!(menu.labels(), vec!["Open", "More"]);
2486        // A submenu reports its children and no shortcut of its own.
2487        let sub = &menu.items[1];
2488        assert!(sub.is_submenu());
2489        assert!(sub.shortcut().is_none());
2490        let children = sub.submenu_items().unwrap();
2491        assert_eq!(children.len(), 2);
2492        assert_eq!(children[0].label(), "Copy Path");
2493        assert!(children[1].is_submenu());
2494        // Shortcuts inside submenus (at any depth) stay live.
2495        let shortcuts = menu.shortcuts();
2496        assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
2497    }
2498
2499    #[test]
2500    fn context_menu_collects_nested_shortcuts() {
2501        let raw = r#"
2502            [[context_menu.rules]]
2503            matches = ["dir"]
2504            items = [
2505                "Open",
2506                { label = "More", items = [
2507                    { action = "Copy Path", shortcut = "Ctrl+Shift+c" },
2508                ] },
2509            ]
2510            [[context_menu.rules]]
2511            matches = ["fallback"]
2512            items = ["Open"]
2513        "#;
2514        let config = parse(raw);
2515        let shortcuts = config.context_menu.shortcuts();
2516        assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
2517    }
2518
2519    #[test]
2520    fn startup_last_uses_session_root() {
2521        let root = StartupRoot::Last;
2522        let last = Some(PathBuf::from("/tmp/last"));
2523        assert_eq!(root.resolve(last.clone()), last);
2524        assert_eq!(root.resolve(None), None);
2525    }
2526
2527    #[test]
2528    fn startup_home_ignores_session_root() {
2529        let root = StartupRoot::Home;
2530        assert_eq!(root.resolve(Some(PathBuf::from("/tmp/last"))), None);
2531    }
2532
2533    #[test]
2534    fn startup_path_uses_configured_directory() {
2535        let root = StartupRoot::Path("/tmp/fixed".to_owned());
2536        assert_eq!(
2537            root.resolve(Some(PathBuf::from("/tmp/last"))),
2538            Some(PathBuf::from("/tmp/fixed"))
2539        );
2540        // An empty path resolves to nothing, so callers fall back to home.
2541        assert_eq!(StartupRoot::Path(String::new()).resolve(None), None);
2542    }
2543
2544    #[test]
2545    fn startup_bookmarks_selects_the_bookmarks_view() {
2546        let root = parse("startup = \"bookmarks\"\n").startup;
2547        assert_eq!(root, StartupRoot::Bookmarks);
2548        assert!(root.is_bookmarks());
2549        // It opens no directory pane, even with a usable last root.
2550        assert_eq!(root.resolve(Some(PathBuf::from("/tmp/last"))), None);
2551    }
2552
2553    #[test]
2554    fn bookmark_builtins_parse_and_classify() {
2555        assert_eq!(BuiltinAction::parse("Add Bookmark"), Some(BuiltinAction::AddBookmark));
2556        assert!(BuiltinAction::AddBookmark.is_directory_only());
2557        assert!(BuiltinAction::AddBookmark.is_single_row_only());
2558        assert_eq!(BuiltinAction::parse("Bookmarks"), Some(BuiltinAction::ToggleBookmarks));
2559        assert!(BuiltinAction::ToggleBookmarks.is_pane_action());
2560        assert_eq!(BuiltinAction::parse("Edit Bookmark"), Some(BuiltinAction::EditBookmark));
2561        assert_eq!(BuiltinAction::parse("Delete Bookmark"), Some(BuiltinAction::DeleteBookmark));
2562        assert_eq!(BuiltinAction::parse("New Bookmark"), Some(BuiltinAction::NewBookmark));
2563        assert_eq!(
2564            BuiltinAction::parse("New Bookmark Folder"),
2565            Some(BuiltinAction::NewBookmarkFolder)
2566        );
2567        assert!(BuiltinAction::EditBookmark.is_bookmark_only());
2568        assert!(BuiltinAction::DeleteBookmark.is_bookmark_only());
2569        assert!(BuiltinAction::NewBookmark.is_bookmark_only());
2570        assert!(BuiltinAction::NewBookmarkFolder.is_bookmark_only());
2571        assert!(BuiltinAction::NewBookmark.is_pane_action());
2572    }
2573
2574    #[test]
2575    fn bookmarks_menu_and_context_parse_from_the_shipped_file() {
2576        let shipped = Config::default().bookmarks;
2577        assert!(!shipped.menu.is_empty());
2578        assert!(!shipped.context.is_empty());
2579        assert!(!shipped.blank.is_empty());
2580        // The shipped context extras are the bookmark-only actions.
2581        assert!(
2582            shipped
2583                .context
2584                .iter()
2585                .any(|a| a.shortcut().is_none()
2586                    && matches!(a, ContextAction::Builtin(BuiltinAction::EditBookmark)))
2587        );
2588        // The blank menu offers the "new" bookmark actions.
2589        assert!(
2590            shipped
2591                .blank
2592                .iter()
2593                .any(|a| matches!(a, ContextAction::Builtin(BuiltinAction::NewBookmark)))
2594        );
2595
2596        // A partial `[bookmarks]` keeps the shipped menu/context/blank.
2597        let partial = parse("[bookmarks]\nfile = \"bm.toml\"\n").bookmarks;
2598        assert_eq!(partial.menu, shipped.menu);
2599        assert_eq!(partial.context, shipped.context);
2600        assert_eq!(partial.blank, shipped.blank);
2601
2602        // And they can be overridden.
2603        let custom = parse(
2604            "[bookmarks]\nmenu = [\"Open Folder...\"]\ncontext = [\"Delete Bookmark\"]\nblank = [\"New Bookmark\"]\n",
2605        )
2606        .bookmarks;
2607        assert_eq!(custom.menu.len(), 1);
2608        assert_eq!(custom.context.len(), 1);
2609        assert_eq!(custom.blank.len(), 1);
2610    }
2611
2612    #[test]
2613    fn bookmarks_list_round_trips_through_its_file() {
2614        let dir = tempfile::tempdir().unwrap();
2615        let path = dir.path().join("bookmarks.toml");
2616        let list = vec![
2617            Bookmark::leaf("Home".to_owned(), PathBuf::from("/home/x")),
2618            Bookmark::leaf("Code".to_owned(), PathBuf::from("/srv/code")),
2619        ];
2620        save_bookmarks_to_path(&path, &list).unwrap();
2621        let (loaded, problem) = load_bookmarks_from_path(&path);
2622        assert!(problem.is_none());
2623        assert_eq!(loaded, list);
2624    }
2625
2626    #[test]
2627    fn bookmark_folders_round_trip_and_are_searched() {
2628        let dir = tempfile::tempdir().unwrap();
2629        let path = dir.path().join("bookmarks.toml");
2630        let list = vec![Bookmark {
2631            name: "Work".to_owned(),
2632            path: None,
2633            items: vec![
2634                Bookmark::leaf("Repo".to_owned(), PathBuf::from("/srv/repo")),
2635                Bookmark {
2636                    name: "Nested".to_owned(),
2637                    path: None,
2638                    items: vec![Bookmark::leaf("Deep".to_owned(), PathBuf::from("/srv/deep"))],
2639                    expanded: true,
2640                },
2641            ],
2642            expanded: false,
2643        }];
2644        save_bookmarks_to_path(&path, &list).unwrap();
2645        let (loaded, problem) = load_bookmarks_from_path(&path);
2646        assert!(problem.is_none());
2647        assert_eq!(loaded, list);
2648
2649        // Nested paths are found and removable by index path.
2650        assert!(Bookmark::contains_path(&loaded, Path::new("/srv/deep")));
2651        assert!(!Bookmark::contains_path(&loaded, Path::new("/nope")));
2652        let mut mutable = loaded.clone();
2653        assert!(Bookmark::remove(&mut mutable, &[0, 1, 0]));
2654        assert!(!Bookmark::contains_path(&mutable, Path::new("/srv/deep")));
2655        assert!(!Bookmark::remove(&mut mutable, &[0, 9]));
2656    }
2657
2658    #[test]
2659    fn remove_deletes_top_level_and_nested_entries() {
2660        let mut list = vec![
2661            Bookmark::leaf("A".to_owned(), PathBuf::from("/a")),
2662            Bookmark {
2663                name: "F".to_owned(),
2664                path: None,
2665                items: vec![Bookmark::leaf("B".to_owned(), PathBuf::from("/b"))],
2666                expanded: false,
2667            },
2668            Bookmark::leaf("C".to_owned(), PathBuf::from("/c")),
2669        ];
2670        // A nested leaf.
2671        assert!(Bookmark::remove(&mut list, &[1, 0]));
2672        assert!(!Bookmark::contains_path(&list, Path::new("/b")));
2673        // A top-level folder (this was the regression).
2674        assert!(Bookmark::remove(&mut list, &[1]));
2675        assert_eq!(list.len(), 2);
2676        // A top-level leaf.
2677        assert!(Bookmark::remove(&mut list, &[0]));
2678        assert!(!Bookmark::contains_path(&list, Path::new("/a")));
2679        // An empty or out-of-range path removes nothing.
2680        assert!(!Bookmark::remove(&mut list, &[]));
2681        assert!(!Bookmark::remove(&mut list, &[9]));
2682    }
2683
2684    #[test]
2685    fn missing_bookmarks_file_falls_back_to_default_without_problem() {
2686        let dir = tempfile::tempdir().unwrap();
2687        let path = dir.path().join("nope.toml");
2688        let (loaded, problem) = load_bookmarks_from_path(&path);
2689        assert!(problem.is_none());
2690        assert_eq!(loaded, default_bookmarks());
2691    }
2692
2693    #[test]
2694    fn bookmarks_file_is_written_compactly() {
2695        let dir = tempfile::tempdir().unwrap();
2696        let path = dir.path().join("bookmarks.toml");
2697        let list = vec![
2698            Bookmark::leaf("Home".to_owned(), PathBuf::from("/home/me")),
2699            Bookmark {
2700                name: "Work".to_owned(),
2701                path: None,
2702                items: vec![Bookmark::leaf("Repo".to_owned(), PathBuf::from("/srv/repo"))],
2703                expanded: true,
2704            },
2705        ];
2706        save_bookmarks_to_path(&path, &list).unwrap();
2707        let text = std::fs::read_to_string(&path).unwrap();
2708        // The compact array-of-inline-tables form, not `[[bookmarks]]` tables.
2709        assert!(text.starts_with("bookmarks = [\n"), "{text}");
2710        assert!(!text.contains("[[bookmarks]]"), "{text}");
2711        assert!(!text.contains("[[bookmarks.items]]"), "{text}");
2712        // And it reads back identically.
2713        let (loaded, problem) = load_bookmarks_from_path(&path);
2714        assert!(problem.is_none());
2715        assert_eq!(loaded, list);
2716    }
2717
2718    #[test]
2719    fn bookmark_strings_are_quoted_and_escaped() {
2720        assert_eq!(toml_string("plain"), "\"plain\"");
2721        assert_eq!(toml_string("a\"b\\c\n\t"), "\"a\\\"b\\\\c\\n\\t\"");
2722    }
2723
2724    #[test]
2725    fn ensure_bookmarks_creates_and_never_clobbers() {
2726        let dir = tempfile::tempdir().unwrap();
2727        let path = dir.path().join("bookmarks.toml");
2728        let list = vec![Bookmark::leaf("Home".to_owned(), PathBuf::from("/home/x"))];
2729        ensure_bookmarks_at(&path, &list).unwrap();
2730        let (loaded, _) = load_bookmarks_from_path(&path);
2731        assert_eq!(loaded, list);
2732        // A second call must leave the existing file untouched.
2733        let other = vec![Bookmark::leaf("Other".to_owned(), PathBuf::from("/tmp"))];
2734        ensure_bookmarks_at(&path, &other).unwrap();
2735        let (loaded, _) = load_bookmarks_from_path(&path);
2736        assert_eq!(loaded, list);
2737    }
2738
2739    #[test]
2740    fn bookmark_name_defaults_to_the_directory_name() {
2741        assert_eq!(Bookmark::default_name(Path::new("/home/eolu/notes")), "notes");
2742        assert_eq!(Bookmark::default_name(Path::new("/")), "/");
2743    }
2744
2745    #[test]
2746    fn startup_tilde_expands_to_home() {
2747        // SAFETY: single-threaded test setup; HOME is read-only here.
2748        unsafe { std::env::set_var("HOME", "/home/tester") };
2749        let root = StartupRoot::Path("~/notes".to_owned());
2750        assert_eq!(root.resolve(None), Some(PathBuf::from("/home/tester/notes")));
2751        assert_eq!(expand_tilde("~"), Some(PathBuf::from("/home/tester")));
2752        assert_eq!(expand_tilde("/abs/path"), Some(PathBuf::from("/abs/path")));
2753        assert_eq!(expand_tilde(""), None);
2754        // A relative configured path is made absolute (against the cwd), so it
2755        // never surfaces as a bare relative spelling in the UI.
2756        let rel = expand_tilde("some/relative/dir").unwrap();
2757        assert!(rel.is_absolute(), "{rel:?} should be absolute");
2758        assert!(rel.ends_with("some/relative/dir"), "{rel:?}");
2759    }
2760
2761    #[test]
2762    fn startup_parses_from_toml() {
2763        assert_eq!(parse("startup = \"home\"\n").startup, StartupRoot::Home);
2764        assert_eq!(parse("startup = \"last\"\n").startup, StartupRoot::Last);
2765        assert_eq!(parse("startup = \"bookmarks\"\n").startup, StartupRoot::Bookmarks);
2766        assert_eq!(
2767            parse("startup = { path = \"/srv\" }\n").startup,
2768            StartupRoot::Path("/srv".to_owned())
2769        );
2770        // The shipped default opens the bookmarks view.
2771        assert_eq!(Config::default().startup, StartupRoot::Bookmarks);
2772        // An unknown keyword is rejected (whole config falls back to defaults).
2773        assert!(
2774            Config::parse("startup = \"bogus\"\n", Path::new("test.toml"))
2775                .problem
2776                .is_some()
2777        );
2778        // Round-trips through serialization.
2779        let cfg = Config { startup: StartupRoot::Path("/srv".to_owned()), ..Config::default() };
2780        let text = toml::to_string(&cfg).unwrap();
2781        assert_eq!(toml::from_str::<Config>(&text).unwrap().startup, cfg.startup);
2782    }
2783
2784    #[test]
2785    fn context_matches_classify_rows() {
2786        let dir = tempfile::tempdir().unwrap();
2787        let file = dir.path().join("report.md");
2788        std::fs::write(&file, "").unwrap();
2789        let noext = dir.path().join("Makefile");
2790        std::fs::write(&noext, "").unwrap();
2791        let tarball = dir.path().join("app.tar.gz");
2792        std::fs::write(&tarball, "").unwrap();
2793
2794        assert!(ContextMatch::parse("dir").unwrap().matches(dir.path()));
2795        assert!(!ContextMatch::parse("dir").unwrap().matches(&file));
2796        assert!(ContextMatch::parse("noext").unwrap().matches(&noext));
2797        assert!(!ContextMatch::parse("noext").unwrap().matches(&file));
2798        assert!(ContextMatch::parse("ext:md").unwrap().matches(&file));
2799        assert!(ContextMatch::parse("ext:MD").unwrap().matches(&file));
2800        assert!(ContextMatch::parse("ext:gz").unwrap().matches(&tarball));
2801        assert!(!ContextMatch::parse("ext:md").unwrap().matches(dir.path()));
2802        assert_eq!(ContextMatch::parse("directory"), Ok(ContextMatch::Dir));
2803        assert_eq!(ContextMatch::parse("*"), Ok(ContextMatch::Fallback));
2804        assert!(ContextMatch::parse("bogus").is_err());
2805    }
2806
2807    #[test]
2808    fn regex_matcher_matches_the_full_path() {
2809        let dir = tempfile::tempdir().unwrap();
2810        let md = dir.path().join("guide.md");
2811        std::fs::write(&md, "").unwrap();
2812
2813        // Anchored at the end of the full path.
2814        assert!(ContextMatch::parse(r"regex:\.md$").unwrap().matches(&md));
2815        assert!(!ContextMatch::parse(r"regex:\.rs$").unwrap().matches(&md));
2816
2817        // Matching the directory path itself (regexes claim directories too).
2818        let exact = format!("regex:^{}$", regex::escape(dir.path().to_str().unwrap()));
2819        assert!(ContextMatch::parse(&exact).unwrap().matches(dir.path()));
2820
2821        // Spelling round-trips through serialization.
2822        let parsed = ContextMatch::parse(r"regex:\.rs$").unwrap();
2823        assert_eq!(parsed.as_str(), r"regex:\.rs$");
2824        let reparsed = ContextMatch::parse(&parsed.as_str()).unwrap();
2825        assert_eq!(parsed, reparsed);
2826    }
2827
2828    #[test]
2829    fn invalid_regex_is_a_parse_error() {
2830        let err = ContextMatch::parse("regex:(").unwrap_err();
2831        assert!(err.contains("invalid regex"), "got: {err}");
2832    }
2833
2834    #[test]
2835    fn stylesheet_prefers_the_user_file_and_materializes_a_default() {
2836        let dir = tempfile::tempdir().unwrap();
2837        let path = dir.path().join("tree-space/main.css");
2838
2839        // No file yet: the built-in default is returned (and no problem).
2840        let first = load_stylesheet_from_path(&path);
2841        assert_eq!(first.source, StyleSource::Builtin);
2842        assert!(first.problem.is_none());
2843        assert_eq!(first.css, DEFAULT_STYLESHEET);
2844
2845        // Materializing writes the shipped default to the path.
2846        ensure_default_stylesheet(&path).unwrap();
2847        assert_eq!(std::fs::read_to_string(&path).unwrap(), DEFAULT_STYLESHEET);
2848
2849        // A user edit is then picked up verbatim.
2850        std::fs::write(&path, ".tree-row { color: red; }").unwrap();
2851        let user = load_stylesheet_from_path(&path);
2852        assert_eq!(user.source, StyleSource::User);
2853        assert!(user.problem.is_none());
2854        assert_eq!(user.css, ".tree-row { color: red; }");
2855    }
2856
2857    #[test]
2858    fn materialize_never_overwrites_an_existing_stylesheet() {
2859        let dir = tempfile::tempdir().unwrap();
2860        let path = dir.path().join("tree-space/main.css");
2861        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2862        std::fs::write(&path, "/* mine */").unwrap();
2863
2864        ensure_default_stylesheet(&path).unwrap();
2865        assert_eq!(std::fs::read_to_string(&path).unwrap(), "/* mine */");
2866    }
2867
2868    #[test]
2869    fn unreadable_stylesheet_falls_back_to_builtin_with_a_problem() {
2870        let dir = tempfile::tempdir().unwrap();
2871        // A directory where a file is expected: `read_to_string` fails with a
2872        // non-NotFound error, so we fall back and report it.
2873        let path = dir.path().join("main.css");
2874        std::fs::create_dir(&path).unwrap();
2875
2876        let stylesheet = load_stylesheet_from_path(&path);
2877        assert_eq!(stylesheet.source, StyleSource::Builtin);
2878        assert_eq!(stylesheet.css, DEFAULT_STYLESHEET);
2879        assert!(matches!(stylesheet.problem, Some(LoadProblem::Io(..))));
2880    }
2881
2882    #[test]
2883    fn include_rule_splices_files_in_place_and_name_sorted() {        let dir = tempfile::tempdir().unwrap();
2884        let rules_dir = dir.path().join("rules.d");
2885        std::fs::create_dir_all(&rules_dir).unwrap();
2886        // Two drop-ins, deliberately named so name-sort decides their order.
2887        std::fs::write(
2888            rules_dir.join("20-second.toml"),
2889            "[[context_menu.rules]]\nmatches = [\"ext:b\"]\nitems = [\"Properties\"]\n",
2890        )
2891        .unwrap();
2892        std::fs::write(
2893            rules_dir.join("10-first.toml"),
2894            "[[context_menu.rules]]\nmatches = [\"ext:a\"]\nitems = [\"Properties\", \"Copy Path\"]\n",
2895        )
2896        .unwrap();
2897        // A non-TOML file in the directory is ignored.
2898        std::fs::write(rules_dir.join("README.md"), "not config").unwrap();
2899
2900        let main = dir.path().join("config.toml");
2901        std::fs::write(
2902            &main,
2903            r#"
2904[[context_menu.rules]]
2905matches = ["dir"]
2906items = ["Open"]
2907
2908[[context_menu.rules]]
2909include = "rules.d"
2910
2911[[context_menu.rules]]
2912matches = ["fallback"]
2913items = ["Move to Trash"]
2914"#,
2915        )
2916        .unwrap();
2917
2918        let config = Config::load_from_path(&main);
2919        assert!(config.problem.is_none(), "{:?}", config.problem);
2920        let rules = &config.config.context_menu.rules;
2921        // dir, 10-first, 20-second, fallback — included rules land in place.
2922        assert_eq!(rules.len(), 4);
2923        assert_eq!(rules[0].matches, vec![ContextMatch::Dir]);
2924        assert_eq!(rules[1].matches, vec![ContextMatch::Ext("a".to_owned())]);
2925        assert_eq!(rules[2].matches, vec![ContextMatch::Ext("b".to_owned())]);
2926        assert_eq!(rules[3].matches, vec![ContextMatch::Fallback]);
2927        // The include placeholder itself is gone.
2928        assert!(rules.iter().all(|r| r.include.is_none()));
2929    }
2930
2931    #[test]
2932    fn include_of_a_missing_directory_contributes_nothing() {
2933        let dir = tempfile::tempdir().unwrap();
2934        let main = dir.path().join("config.toml");
2935        std::fs::write(
2936            &main,
2937            "[[context_menu.rules]]\ninclude = \"nope.d\"\n\n[[context_menu.rules]]\nmatches = [\"fallback\"]\nitems = [\"Move to Trash\"]\n",
2938        )
2939        .unwrap();
2940        let result = Config::load_from_path(&main);
2941        // A missing include directory is not an error.
2942        assert!(result.problem.is_none(), "{:?}", result.problem);
2943        assert_eq!(result.config.context_menu.rules.len(), 1);
2944        assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Fallback]);
2945    }
2946
2947    #[test]
2948    fn include_of_an_invalid_file_is_reported_but_others_still_load() {
2949        let dir = tempfile::tempdir().unwrap();
2950        let rules_dir = dir.path().join("rules.d");
2951        std::fs::create_dir_all(&rules_dir).unwrap();
2952        std::fs::write(rules_dir.join("10-good.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
2953        std::fs::write(rules_dir.join("20-bad.toml"), "this is not = valid toml [").unwrap();
2954
2955        let main = dir.path().join("config.toml");
2956        std::fs::write(
2957            &main,
2958            "[[context_menu.rules]]\ninclude = \"rules.d\"\n",
2959        )
2960        .unwrap();
2961
2962        let result = Config::load_from_path(&main);
2963        // The bad file is reported...
2964        assert!(matches!(result.problem, Some(LoadProblem::Parse(..))), "{:?}", result.problem);
2965        // ...but the good file's rule still made it in.
2966        assert_eq!(result.config.context_menu.rules.len(), 1);
2967        assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
2968    }
2969
2970    #[test]
2971    fn included_rules_are_expanded_recursively() {
2972        let dir = tempfile::tempdir().unwrap();
2973        let outer = dir.path().join("outer.d");
2974        let inner = outer.join("inner.d");
2975        std::fs::create_dir_all(&inner).unwrap();
2976        std::fs::write(inner.join("nested.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
2977        std::fs::write(
2978            outer.join("mid.toml"),
2979            "[[context_menu.rules]]\ninclude = \"inner.d\"\n",
2980        )
2981        .unwrap();
2982
2983        let main = dir.path().join("config.toml");
2984        std::fs::write(&main, "[[context_menu.rules]]\ninclude = \"outer.d\"\n").unwrap();
2985
2986        let result = Config::load_from_path(&main);
2987        assert!(result.problem.is_none(), "{:?}", result.problem);
2988        assert_eq!(result.config.context_menu.rules.len(), 1);
2989        assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
2990    }
2991
2992    #[test]
2993    fn first_matching_rule_wins() {
2994        let menu = ContextMenu {
2995            rules: vec![
2996                ContextRule::rule(
2997                    vec![ContextMatch::Ext("md".to_owned())],
2998                    builtin_items![CopyPath],
2999                ),
3000                ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
3001            ],
3002        };
3003        let dir = tempfile::tempdir().unwrap();
3004        let md = dir.path().join("a.md");
3005        std::fs::write(&md, "").unwrap();
3006        let txt = dir.path().join("a.txt");
3007        std::fs::write(&txt, "").unwrap();
3008
3009        assert_eq!(menu.actions_for(&md), builtin_items![CopyPath]);
3010        // txt matches the second (fallback) rule, so it gets that rule's menu.
3011        assert_eq!(menu.actions_for(&txt), builtin_items![Open]);
3012    }
3013
3014    #[test]
3015    fn multi_matcher_parses_and_never_matches_a_single_path() {
3016        assert_eq!(ContextMatch::parse("multi").unwrap(), ContextMatch::Multi);
3017        assert_eq!(ContextMatch::parse("multi-select").unwrap(), ContextMatch::Multi);
3018        assert_eq!(ContextMatch::Multi.as_str(), "multi");
3019        // A selection-level matcher is never true for one path.
3020        let dir = tempfile::tempdir().unwrap();
3021        assert!(!ContextMatch::Multi.matches(dir.path()));
3022    }
3023
3024    #[test]
3025    fn multi_rule_wins_for_a_multi_selection_only() {
3026        let menu = ContextMenu {
3027            rules: vec![
3028                ContextRule::rule(vec![ContextMatch::Multi], builtin_items![Trash, CopyPath]),
3029                ContextRule::rule(vec![ContextMatch::Dir], builtin_items![Open]),
3030                ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
3031            ],
3032        };
3033        let dir = tempfile::tempdir().unwrap();
3034
3035        // One selected path: the per-path rule applies.
3036        assert_eq!(menu.actions_for_selection(dir.path(), 1), builtin_items![Open]);
3037        // Two or more: the multi rule applies instead.
3038        assert_eq!(
3039            menu.actions_for_selection(dir.path(), 2),
3040            builtin_items![Trash, CopyPath]
3041        );
3042        assert_eq!(
3043            menu.actions_for_selection(dir.path(), 7),
3044            builtin_items![Trash, CopyPath]
3045        );
3046    }
3047
3048    #[test]
3049    fn shipped_multi_rule_offers_selection_actions() {
3050        let menu = ContextMenu::default();
3051        let dir = tempfile::tempdir().unwrap();
3052        let file = dir.path().join("a.txt");
3053        std::fs::write(&file, "").unwrap();
3054
3055        // One row: the multi rule does not intervene.
3056        let single = menu.actions_for_selection(&file, 1);
3057        assert_eq!(single, menu.actions_for(&file));
3058
3059        // Two or more rows: the shipped `multi` rule supplies the menu, with the
3060        // fan-out actions present and single-row actions absent.
3061        let labels = all_labels(&menu.actions_for_selection(&file, 3));
3062        assert!(labels.contains(&"Move to Trash".to_owned()), "{labels:?}");
3063        assert!(labels.contains(&"Copy Path".to_owned()), "{labels:?}");
3064        assert!(labels.contains(&"Properties".to_owned()), "{labels:?}");
3065        assert!(!labels.contains(&"Rename".to_owned()), "{labels:?}");
3066        assert!(!labels.contains(&"Duplicate".to_owned()), "{labels:?}");
3067        assert!(!labels.contains(&"With...".to_owned()), "{labels:?}");
3068    }
3069
3070    #[test]
3071    fn single_row_only_actions_are_classified() {
3072        assert!(BuiltinAction::Rename.is_single_row_only());
3073        assert!(BuiltinAction::Open.is_single_row_only());
3074        assert!(BuiltinAction::Duplicate.is_single_row_only());
3075        assert!(!BuiltinAction::Trash.is_single_row_only());
3076        assert!(!BuiltinAction::DeletePermanently.is_single_row_only());
3077        assert!(!BuiltinAction::Copy.is_single_row_only());
3078        assert!(!BuiltinAction::Cut.is_single_row_only());
3079        assert!(!BuiltinAction::Properties.is_single_row_only());
3080        assert!(!BuiltinAction::CopyPath.is_single_row_only());
3081    }
3082
3083    #[test]
3084    fn empty_rule_list_falls_back_to_classic_menu() {
3085        let menu = ContextMenu { rules: Vec::new() };
3086        let dir = tempfile::tempdir().unwrap();
3087        assert_eq!(menu.actions_for(dir.path()), ContextMenu::fallback_actions(dir.path()));
3088    }
3089
3090    #[test]
3091    fn custom_context_menu_round_trips() {
3092        let raw = r#"
3093[[context_menu.rules]]
3094matches = ["ext:rs", "ext:toml"]
3095items = ["Open", { command = "cargo fmt", label = "Fmt" }]
3096
3097[[context_menu.rules]]
3098matches = ["dir"]
3099items = [{ command = "foot -D {dir}", label = "Open in Terminal" }, "New Folder"]
3100
3101[[context_menu.rules]]
3102matches = ["noext"]
3103items = [{ command = "sh {path}" }]
3104
3105[[context_menu.rules]]
3106matches = ['regex:.*\.lock$']
3107items = ["Open", { command = "rm {path}", label = "Discard lock" }]
3108"#;
3109        let config = parse(raw);
3110        assert_eq!(config.context_menu.rules.len(), 4);
3111
3112        let first = &config.context_menu.rules[0];
3113        assert_eq!(
3114            first.matches,
3115            vec![ContextMatch::Ext("rs".to_owned()), ContextMatch::Ext("toml".to_owned())]
3116        );
3117        assert!(matches!(first.items[0], ContextAction::Builtin(BuiltinAction::Open)));
3118        assert!(matches!(
3119            &first.items[1],
3120            ContextAction::Command(cmd) if cmd.command == "cargo fmt" && cmd.label.as_deref() == Some("Fmt")
3121        ));
3122
3123        let last = &config.context_menu.rules[3];
3124        assert_eq!(
3125            last.matches,
3126            vec![ContextMatch::parse(r"regex:.*\.lock$").unwrap()]
3127        );
3128
3129        let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
3130        assert_eq!(config, reparsed);
3131    }
3132
3133    #[test]
3134    fn unknown_builtin_action_is_a_parse_problem() {
3135        let result = Config::parse(
3136            "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Frobnicate\"]\n",
3137            Path::new("bad-menu.toml"),
3138        );
3139        assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
3140        assert_eq!(result.config, Config::default());
3141    }
3142
3143    #[test]
3144    fn unknown_context_matcher_is_a_parse_problem() {
3145        let result = Config::parse(
3146            "[[context_menu.rules]]\nmatches = [\"gibberish\"]\nitems = [\"Open\"]\n",
3147            Path::new("bad-matcher.toml"),
3148        );
3149        assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
3150        assert_eq!(result.config, Config::default());
3151    }
3152
3153    #[test]
3154    fn action_command_substitutes_markers() {
3155        let dir = tempfile::tempdir().unwrap();
3156        let dir_s = dir.path().to_string_lossy().into_owned();
3157        assert_eq!(
3158            action_command("code {path}", dir.path()),
3159            vec!["code".to_owned(), dir_s.clone()]
3160        );
3161        assert_eq!(
3162            action_command("code {dir}", &dir.path().join("x.rs")),
3163            vec!["code".to_owned(), dir_s]
3164        );
3165    }
3166
3167    #[test]
3168    fn action_command_appends_path_without_marker() {
3169        assert_eq!(
3170            action_command("sh", Path::new("/tmp/s.sh")),
3171            vec!["sh".to_owned(), "/tmp/s.sh".to_owned()]
3172        );
3173    }
3174}