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