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