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