1use std::env;
19use std::path::{Path, PathBuf};
20use std::sync::OnceLock;
21
22use regex::Regex;
23use serde::{Deserialize, Serialize};
24
25pub const APP_DIR: &str = "tree-space";
27pub const CONFIG_FILE: &str = "config.toml";
29pub const STATE_FILE: &str = "state.toml";
31pub const STYLE_FILE: &str = "main.css";
33
34const DEFAULT_CONFIG: &str = include_str!("../default-config.toml");
40
41pub const DEFAULT_STYLESHEET: &str = include_str!("../style/main.css");
44
45fn builtin() -> &'static Config {
55 static ONCE: OnceLock<Config> = OnceLock::new();
56 ONCE.get_or_init(|| {
57 let raw: ShippedRaw = toml::from_str(DEFAULT_CONFIG)
58 .expect("default-config.toml must be valid TOML");
59 Config {
60 panel: raw.panel.unwrap_or_default(),
61 tree: raw.tree.unwrap_or_default(),
62 startup: raw.startup.unwrap_or_default(),
63 context_menu: raw.context_menu.unwrap_or_default(),
64 pane_menu: raw.pane_menu.unwrap_or_default(),
65 }
66 })
67}
68
69#[derive(Deserialize)]
73struct ShippedRaw {
74 #[serde(default)]
75 panel: Option<PanelConfig>,
76 #[serde(default)]
77 tree: Option<TreeConfig>,
78 #[serde(default)]
79 startup: Option<StartupRoot>,
80 #[serde(default)]
81 context_menu: Option<ContextMenu>,
82 #[serde(default)]
83 pane_menu: Option<PaneMenu>,
84}
85
86fn xdg_config_home(xdg: Option<&str>, home: Option<&str>) -> PathBuf {
95 match xdg.filter(|s| !s.is_empty()) {
96 Some(dir) => PathBuf::from(dir),
97 None => home.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")).join(".config"),
98 }
99}
100
101fn xdg_state_home(xdg: Option<&str>, home: Option<&str>) -> PathBuf {
103 match xdg.filter(|s| !s.is_empty()) {
104 Some(dir) => PathBuf::from(dir),
105 None => home.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")).join(".local/state"),
106 }
107}
108
109fn app_path(base: &Path, file: &str) -> PathBuf {
111 base.join(APP_DIR).join(file)
112}
113
114fn config_home_from_env() -> PathBuf {
117 let xdg = env::var_os("XDG_CONFIG_HOME").map(|v| v.to_string_lossy().into_owned());
118 let home = env::var_os("HOME").map(|v| v.to_string_lossy().into_owned());
119 xdg_config_home(xdg.as_deref(), home.as_deref())
120}
121
122fn state_home_from_env() -> PathBuf {
124 let xdg = env::var_os("XDG_STATE_HOME").map(|v| v.to_string_lossy().into_owned());
125 let home = env::var_os("HOME").map(|v| v.to_string_lossy().into_owned());
126 xdg_state_home(xdg.as_deref(), home.as_deref())
127}
128
129pub fn config_file() -> PathBuf {
131 app_path(&config_home_from_env(), CONFIG_FILE)
132}
133
134pub fn state_file() -> PathBuf {
136 app_path(&state_home_from_env(), STATE_FILE)
137}
138
139pub fn style_file() -> PathBuf {
141 app_path(&config_home_from_env(), STYLE_FILE)
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
150#[serde(rename_all = "lowercase")]
151pub enum PanelSide {
152 #[default]
153 Left,
154 Right,
155}
156
157impl PanelSide {
158 pub const ALL: [PanelSide; 2] = [PanelSide::Left, PanelSide::Right];
160
161 pub fn opposite(&self) -> PanelSide {
163 match self {
164 PanelSide::Left => PanelSide::Right,
165 PanelSide::Right => PanelSide::Left,
166 }
167 }
168
169 pub fn name(&self) -> &'static str {
171 match self {
172 PanelSide::Left => "left",
173 PanelSide::Right => "right",
174 }
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
181#[serde(rename_all = "lowercase")]
182pub enum PanelLayer {
183 Background,
184 #[default]
185 Bottom,
186 Top,
187 Overlay,
188}
189
190pub const PANEL_MIN_WIDTH: u32 = 160;
192pub const PANEL_MAX_WIDTH: u32 = 2000;
194
195fn default_panel_side() -> PanelSide {
200 builtin().panel.side
201}
202fn default_panel_layer() -> PanelLayer {
203 builtin().panel.layer
204}
205fn default_panel_width() -> u32 {
206 builtin().panel.width
207}
208fn default_dirs_first() -> bool {
209 builtin().tree.dirs_first
210}
211fn default_sort_key() -> String {
212 builtin().tree.sort_key.clone()
213}
214fn default_sort_ascending() -> bool {
215 builtin().tree.sort_ascending
216}
217fn default_font_size() -> u32 {
218 builtin().tree.font_size
219}
220fn default_icon_size() -> u32 {
221 builtin().tree.icon_size
222}
223fn default_confirm_drop_move() -> bool {
224 builtin().tree.confirm_drop_move
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
231pub struct PanelConfig {
232 #[serde(default = "default_panel_side")]
233 pub side: PanelSide,
234 #[serde(default = "default_panel_layer")]
235 pub layer: PanelLayer,
236 #[serde(default = "default_panel_width")]
238 pub width: u32,
239 #[serde(default)]
241 pub margin: u32,
242}
243
244impl Default for PanelConfig {
245 fn default() -> Self {
246 builtin().panel
247 }
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct TreeConfig {
253 #[serde(default = "default_dirs_first")]
257 pub dirs_first: bool,
258 #[serde(default = "default_sort_key")]
260 pub sort_key: String,
261 #[serde(default = "default_sort_ascending")]
263 pub sort_ascending: bool,
264 #[serde(default)]
266 pub show_hidden: bool,
267 #[serde(default = "default_font_size")]
269 pub font_size: u32,
270 #[serde(default = "default_icon_size")]
272 pub icon_size: u32,
273 #[serde(default = "default_confirm_drop_move")]
277 pub confirm_drop_move: bool,
278}
279
280impl Default for TreeConfig {
281 fn default() -> Self {
282 builtin().tree.clone()
283 }
284}
285
286impl TreeConfig {
287 pub fn sort_options(&self) -> crate::fs::SortOptions {
290 crate::fs::SortOptions {
291 dirs_first: self.dirs_first,
292 key: crate::fs::SortKey::parse(&self.sort_key).unwrap_or(crate::fs::SortKey::Name),
293 ascending: self.sort_ascending,
294 }
295 }
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Default)]
304pub enum StartupRoot {
305 #[default]
307 Home,
308 Last,
310 Path(String),
312}
313
314impl StartupRoot {
315 pub fn resolve(&self, last: Option<PathBuf>) -> Option<PathBuf> {
318 match self {
319 StartupRoot::Last => last,
320 StartupRoot::Home => None,
321 StartupRoot::Path(path) => expand_tilde(path),
322 }
323 }
324}
325
326impl Serialize for StartupRoot {
327 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
328 use serde::ser::SerializeMap;
329 match self {
330 StartupRoot::Last => serializer.serialize_str("last"),
331 StartupRoot::Home => serializer.serialize_str("home"),
332 StartupRoot::Path(path) => {
333 let mut map = serializer.serialize_map(Some(1))?;
334 map.serialize_entry("path", path)?;
335 map.end()
336 }
337 }
338 }
339}
340
341impl<'de> Deserialize<'de> for StartupRoot {
342 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
343 use serde::de::Error as _;
344
345 #[derive(Deserialize)]
346 #[serde(untagged)]
347 enum Repr {
348 Keyword(String),
349 Path { path: String },
350 }
351
352 match Repr::deserialize(deserializer)? {
353 Repr::Keyword(word) => match word.to_ascii_lowercase().as_str() {
354 "last" => Ok(StartupRoot::Last),
355 "home" => Ok(StartupRoot::Home),
356 other => Err(D::Error::custom(format!(
357 "unknown startup root {other:?} (expected \"last\", \"home\", or {{ path = \"...\" }})"
358 ))),
359 },
360 Repr::Path { path } => Ok(StartupRoot::Path(path)),
361 }
362 }
363}
364
365pub fn expand_tilde(path: &str) -> Option<PathBuf> {
367 if path.is_empty() {
368 return None;
369 }
370 match path.strip_prefix('~') {
371 Some(rest) => std::env::var_os("HOME").map(|home| {
372 let mut p = PathBuf::from(home);
373 p.push(rest.strip_prefix('/').unwrap_or(rest));
374 p
375 }),
376 None => {
380 let path = PathBuf::from(path);
381 Some(std::path::absolute(&path).unwrap_or(path))
382 }
383 }
384}
385
386#[derive(Debug, Clone)]
391pub enum ContextMatch {
392 Dir,
394 NoExt,
397 Fallback,
399 Multi,
403 Ext(String),
407 Regex(Regex),
412}
413
414impl ContextMatch {
415 pub fn as_str(&self) -> String {
417 match self {
418 ContextMatch::Dir => "dir".to_owned(),
419 ContextMatch::NoExt => "noext".to_owned(),
420 ContextMatch::Fallback => "fallback".to_owned(),
421 ContextMatch::Multi => "multi".to_owned(),
422 ContextMatch::Ext(ext) => format!("ext:{ext}"),
423 ContextMatch::Regex(re) => format!("regex:{}", re.as_str()),
424 }
425 }
426
427 pub fn parse(value: &str) -> Result<Self, String> {
429 let value = value.trim();
430 match value {
431 "dir" | "directory" => Ok(ContextMatch::Dir),
432 "noext" => Ok(ContextMatch::NoExt),
433 "fallback" | "*" | "all" => Ok(ContextMatch::Fallback),
434 "multi" | "multi-select" | "selection" => Ok(ContextMatch::Multi),
435 _ => {
436 if let Some(ext) = value.strip_prefix("ext:") {
437 return Ok(ContextMatch::Ext(ext.to_owned()));
438 }
439 if let Some(pattern) = value.strip_prefix("regex:") {
440 return Regex::new(pattern)
441 .map(ContextMatch::Regex)
442 .map_err(|err| {
443 format!(
444 "invalid regex {pattern:?} in context menu matcher {value:?}: {err}"
445 )
446 });
447 }
448 Err(format!("unknown context menu matcher {value:?}"))
449 }
450 }
451 }
452
453 pub fn matches(&self, path: &Path) -> bool {
456 match self {
457 ContextMatch::Dir => path.is_dir(),
458 ContextMatch::NoExt => {
459 let Some(name) = path.file_name() else {
460 return false;
461 };
462 let name = name.to_string_lossy();
463 !path.is_dir() && !name.ends_with('.') && {
464 let after_sep = name.rsplit(['/', '\\']).next().unwrap_or("");
467 !after_sep[1..].contains('.')
468 }
469 }
470 ContextMatch::Fallback => true,
471 ContextMatch::Multi => false,
473 ContextMatch::Ext(ext) => {
474 if path.is_dir() || ext.is_empty() {
475 return false;
476 }
477 let Some(name) = path.file_name() else {
478 return false;
479 };
480 let name = name.to_string_lossy().to_lowercase();
481 name.trim_start_matches('.').ends_with(&format!(".{}", ext.trim().to_lowercase()))
482 }
483 ContextMatch::Regex(re) => re.is_match(&path.to_string_lossy()),
484 }
485 }
486}
487
488impl PartialEq for ContextMatch {
489 fn eq(&self, other: &Self) -> bool {
490 match (self, other) {
491 (ContextMatch::Dir, ContextMatch::Dir) => true,
492 (ContextMatch::NoExt, ContextMatch::NoExt) => true,
493 (ContextMatch::Fallback, ContextMatch::Fallback) => true,
494 (ContextMatch::Multi, ContextMatch::Multi) => true,
495 (ContextMatch::Ext(a), ContextMatch::Ext(b)) => a == b,
496 (ContextMatch::Regex(a), ContextMatch::Regex(b)) => a.as_str() == b.as_str(),
497 _ => false,
498 }
499 }
500}
501
502impl Eq for ContextMatch {}
503
504impl Serialize for ContextMatch {
505 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
506 serializer.serialize_str(&self.as_str())
507 }
508}
509
510impl<'de> Deserialize<'de> for ContextMatch {
511 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
512 let raw = String::deserialize(deserializer)?;
513 ContextMatch::parse(&raw).map_err(serde::de::Error::custom)
514 }
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
521pub enum BuiltinAction {
522 Open,
524 OpenSplit,
526 InNewPanel,
528 InOppositePanel,
531 OpenWith,
533 OpenWithDefault,
536 ViewThumbnail,
539 NewFile,
541 NewFolder,
543 Rename,
545 Duplicate,
547 CopyPath,
549 CopyRelativePath,
551 Cut,
553 Copy,
555 Paste,
558 CreateLink,
560 Properties,
562 Trash,
564 DeletePermanently,
566 ToggleHidden,
568 SortByName,
570 SortBySize,
572 SortByModified,
574 SortByType,
576 ToggleSortAscending,
578 OpenFolder,
580 Filter,
582 SplitView,
584 Up,
586 Back,
588 Forward,
590 Collapse,
592 ClosePane,
594 Separator,
596}
597
598impl BuiltinAction {
599 pub fn as_str(&self) -> &'static str {
602 match self {
603 BuiltinAction::Open => "Open",
604 BuiltinAction::OpenSplit => "Open in Split View",
605 BuiltinAction::InNewPanel => "In new panel",
606 BuiltinAction::InOppositePanel => "In opposite panel",
607 BuiltinAction::OpenWith => "Open With...",
608 BuiltinAction::OpenWithDefault => "Open With Default",
609 BuiltinAction::ViewThumbnail => "View Thumbnail",
610 BuiltinAction::NewFile => "New File",
611 BuiltinAction::NewFolder => "New Folder",
612 BuiltinAction::Rename => "Rename",
613 BuiltinAction::Duplicate => "Duplicate",
614 BuiltinAction::CopyPath => "Copy Path",
615 BuiltinAction::CopyRelativePath => "Copy Relative Path",
616 BuiltinAction::Cut => "Cut",
617 BuiltinAction::Copy => "Copy",
618 BuiltinAction::Paste => "Paste",
619 BuiltinAction::CreateLink => "Create Link",
620 BuiltinAction::Properties => "Properties",
621 BuiltinAction::Trash => "Move to Trash",
622 BuiltinAction::DeletePermanently => "Delete Permanently",
623 BuiltinAction::ToggleHidden => "Toggle Hidden Files",
624 BuiltinAction::SortByName => "Sort by Name",
625 BuiltinAction::SortBySize => "Sort by Size",
626 BuiltinAction::SortByModified => "Sort by Modified",
627 BuiltinAction::SortByType => "Sort by Type",
628 BuiltinAction::ToggleSortAscending => "Reverse Sort Order",
629 BuiltinAction::OpenFolder => "Open Folder...",
630 BuiltinAction::Filter => "Filter...",
631 BuiltinAction::SplitView => "Split View",
632 BuiltinAction::Up => "Up One Level",
633 BuiltinAction::Back => "Back",
634 BuiltinAction::Forward => "Forward",
635 BuiltinAction::Collapse => "Collapse",
636 BuiltinAction::ClosePane => "Close Pane",
637 BuiltinAction::Separator => "---",
638 }
639 }
640
641 pub fn default_shortcut(&self) -> Option<&'static str> {
644 match self {
645 BuiltinAction::Cut => Some("Ctrl+x"),
646 BuiltinAction::Copy => Some("Ctrl+c"),
647 BuiltinAction::Paste => Some("Ctrl+v"),
648 BuiltinAction::Rename => Some("F2"),
649 BuiltinAction::Duplicate => Some("Ctrl+d"),
650 BuiltinAction::NewFile => Some("Ctrl+n"),
651 BuiltinAction::NewFolder => Some("Ctrl+Shift+n"),
652 BuiltinAction::CreateLink => Some("Ctrl+Shift+m"),
653 BuiltinAction::Trash => Some("Delete"),
654 BuiltinAction::DeletePermanently => Some("Shift+Delete"),
655 BuiltinAction::ViewThumbnail => Some("Ctrl+t"),
656 BuiltinAction::ToggleHidden => Some("Ctrl+h"),
657 BuiltinAction::SortByName => Some("Ctrl+1"),
658 BuiltinAction::SortBySize => Some("Ctrl+2"),
659 BuiltinAction::SortByModified => Some("Ctrl+3"),
660 BuiltinAction::SortByType => Some("Ctrl+4"),
661 BuiltinAction::ToggleSortAscending => Some("Ctrl+Shift+r"),
662 BuiltinAction::SplitView => Some("Ctrl+s"),
663 BuiltinAction::Up => Some("Alt+Up"),
664 BuiltinAction::Back => Some("Alt+Left"),
665 BuiltinAction::Forward => Some("Alt+Right"),
666 BuiltinAction::ClosePane => Some("Ctrl+w"),
667 _ => None,
668 }
669 }
670
671 pub fn is_single_row_only(&self) -> bool {
676 matches!(
677 self,
678 BuiltinAction::Open
679 | BuiltinAction::OpenSplit
680 | BuiltinAction::InNewPanel
681 | BuiltinAction::InOppositePanel
682 | BuiltinAction::OpenWith
683 | BuiltinAction::OpenWithDefault
684 | BuiltinAction::ViewThumbnail
685 | BuiltinAction::NewFile
686 | BuiltinAction::NewFolder
687 | BuiltinAction::Rename
688 | BuiltinAction::Duplicate
689 | BuiltinAction::CreateLink
690 )
691 }
692
693 pub fn is_directory_only(&self) -> bool {
696 matches!(self, BuiltinAction::OpenSplit | BuiltinAction::InNewPanel | BuiltinAction::InOppositePanel)
697 }
698
699 pub fn is_pane_action(&self) -> bool {
703 matches!(
704 self,
705 BuiltinAction::OpenFolder
706 | BuiltinAction::Filter
707 | BuiltinAction::SplitView
708 | BuiltinAction::Up
709 | BuiltinAction::Back
710 | BuiltinAction::Forward
711 | BuiltinAction::Collapse
712 | BuiltinAction::ClosePane
713 )
714 }
715
716 pub fn parse(name: &str) -> Option<Self> {
723 let name = name.trim();
724 let name = if name.contains('\u{2026}') {
725 name.replace('\u{2026}', "...")
726 } else {
727 name.to_owned()
728 };
729 let name = name.as_str();
730 [
731 Self::Open,
732 Self::OpenSplit,
733 Self::InNewPanel,
734 Self::InOppositePanel,
735 Self::OpenWith,
736 Self::OpenWithDefault,
737 Self::ViewThumbnail,
738 Self::NewFile,
739 Self::NewFolder,
740 Self::Rename,
741 Self::Duplicate,
742 Self::CopyPath,
743 Self::CopyRelativePath,
744 Self::Cut,
745 Self::Copy,
746 Self::Paste,
747 Self::CreateLink,
748 Self::Properties,
749 Self::Trash,
750 Self::DeletePermanently,
751 Self::ToggleHidden,
752 Self::SortByName,
753 Self::SortBySize,
754 Self::SortByModified,
755 Self::SortByType,
756 Self::ToggleSortAscending,
757 Self::OpenFolder,
758 Self::Filter,
759 Self::SplitView,
760 Self::Up,
761 Self::Back,
762 Self::Forward,
763 Self::Collapse,
764 Self::ClosePane,
765 Self::Separator,
766 ]
767 .into_iter()
768 .find(|a| {
769 a.as_str().eq_ignore_ascii_case(name)
770 || (matches!(a, Self::Separator) && name.eq_ignore_ascii_case("separator"))
771 })
772 }
773}
774
775impl Serialize for BuiltinAction {
776 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
777 serializer.serialize_str(self.as_str())
778 }
779}
780
781impl<'de> Deserialize<'de> for BuiltinAction {
782 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
783 let raw = String::deserialize(deserializer)?;
784 BuiltinAction::parse(&raw).ok_or_else(|| {
785 serde::de::Error::custom(format!("unknown context menu builtin action {raw:?}"))
786 })
787 }
788}
789
790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
795pub struct CommandAction {
796 pub command: String,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub label: Option<String>,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
804 pub shortcut: Option<String>,
805 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
808 pub hidden: bool,
809}
810
811#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
814pub struct BuiltinEntry {
815 pub action: BuiltinAction,
816 #[serde(default, skip_serializing_if = "Option::is_none")]
818 pub label: Option<String>,
819 #[serde(default, skip_serializing_if = "Option::is_none")]
822 pub shortcut: Option<String>,
823 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
825 pub hidden: bool,
826}
827
828#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
832pub struct Submenu {
833 pub label: String,
835 pub items: Vec<ContextAction>,
837 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
840 pub hidden: bool,
841}
842
843impl Submenu {
844 pub fn new(label: impl Into<String>, items: Vec<ContextAction>) -> Self {
846 Self { label: label.into(), items, hidden: false }
847 }
848}
849
850#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
857#[serde(untagged)]
858pub enum ContextAction {
859 Builtin(BuiltinAction),
860 Entry(BuiltinEntry),
861 Command(CommandAction),
862 Submenu(Submenu),
863}
864
865#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
868#[serde(untagged)]
869pub enum ShortcutTarget {
870 Builtin(BuiltinAction),
871 Command(CommandAction),
872}
873
874impl ShortcutTarget {
875 pub fn from_action(action: &ContextAction) -> Option<Self> {
878 Some(match action {
879 ContextAction::Builtin(a) => ShortcutTarget::Builtin(*a),
880 ContextAction::Entry(e) => ShortcutTarget::Builtin(e.action),
881 ContextAction::Command(cmd) => ShortcutTarget::Command(cmd.clone()),
882 ContextAction::Submenu(_) => return None,
883 })
884 }
885}
886
887impl ContextAction {
888 pub fn label(&self) -> String {
891 match self {
892 ContextAction::Builtin(action) => action.as_str().to_owned(),
893 ContextAction::Entry(entry) => {
894 entry.label.clone().unwrap_or_else(|| entry.action.as_str().to_owned())
895 }
896 ContextAction::Command(cmd) => cmd.label.clone().unwrap_or_else(|| cmd.command.clone()),
897 ContextAction::Submenu(sub) => sub.label.clone(),
898 }
899 }
900
901 pub fn is_submenu(&self) -> bool {
903 matches!(self, ContextAction::Submenu(_))
904 }
905
906 pub fn is_hidden(&self) -> bool {
910 match self {
911 ContextAction::Builtin(_) => false,
912 ContextAction::Entry(entry) => entry.hidden,
913 ContextAction::Command(cmd) => cmd.hidden,
914 ContextAction::Submenu(sub) => sub.hidden,
915 }
916 }
917
918 pub fn submenu_items(&self) -> Option<&[ContextAction]> {
920 match self {
921 ContextAction::Submenu(sub) => Some(&sub.items),
922 _ => None,
923 }
924 }
925
926 pub fn shortcut(&self) -> Option<String> {
929 match self {
930 ContextAction::Builtin(action) => action
931 .default_shortcut()
932 .map(str::to_owned),
933 ContextAction::Entry(entry) => entry
934 .shortcut
935 .clone()
936 .or_else(|| entry.action.default_shortcut().map(str::to_owned)),
937 ContextAction::Command(cmd) => cmd.shortcut.clone(),
938 ContextAction::Submenu(_) => None,
939 }
940 }
941}
942
943#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
952pub struct ContextRule {
953 #[serde(default)]
954 pub matches: Vec<ContextMatch>,
955 #[serde(default)]
956 pub items: Vec<ContextAction>,
957 #[serde(default, skip_serializing_if = "Option::is_none")]
960 pub include: Option<PathBuf>,
961}
962
963impl ContextRule {
964 pub fn rule(matches: Vec<ContextMatch>, items: Vec<ContextAction>) -> Self {
966 ContextRule { matches, items, include: None }
967 }
968
969 pub fn include(path: PathBuf) -> Self {
971 ContextRule { matches: Vec::new(), items: Vec::new(), include: Some(path) }
972 }
973}
974
975#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
980pub struct ContextMenu {
981 pub rules: Vec<ContextRule>,
982}
983
984#[cfg(test)]
985macro_rules! builtin_items {
986 ($($name:ident),* $(,)?) => {
987 vec![
988 $(ContextAction::Builtin(BuiltinAction::$name),)*
989 ]
990 };
991}
992
993impl ContextMenu {
994 pub fn fallback_actions(path: &Path) -> Vec<ContextAction> {
998 let wanted = if path.is_dir() { ContextMatch::Dir } else { ContextMatch::Fallback };
999 builtin()
1000 .context_menu
1001 .rules
1002 .iter()
1003 .find(|rule| rule.matches.contains(&wanted))
1004 .map(|rule| rule.items.clone())
1005 .unwrap_or_default()
1006 }
1007
1008 pub fn actions_for(&self, path: &Path) -> Vec<ContextAction> {
1014 for rule in &self.rules {
1015 if rule.matches.iter().any(|m| m.matches(path)) {
1016 return rule.items.clone();
1017 }
1018 }
1019 Self::fallback_actions(path)
1020 }
1021
1022 pub fn actions_for_selection(&self, path: &Path, selection_len: usize) -> Vec<ContextAction> {
1029 if selection_len > 1 {
1030 for rule in &self.rules {
1031 if rule.matches.contains(&ContextMatch::Multi) {
1032 return rule.items.clone();
1033 }
1034 }
1035 }
1036 self.actions_for(path)
1037 }
1038
1039 pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
1047 let mut seen = std::collections::HashSet::new();
1048 let mut out = Vec::new();
1049 for rule in &self.rules {
1050 collect_shortcuts(&rule.items, &mut seen, &mut out);
1051 }
1052 out
1053 }
1054}
1055
1056fn collect_shortcuts(
1059 actions: &[ContextAction],
1060 seen: &mut std::collections::HashSet<String>,
1061 out: &mut Vec<(String, ShortcutTarget)>,
1062) {
1063 for action in actions {
1064 if let Some(items) = action.submenu_items() {
1065 collect_shortcuts(items, seen, out);
1066 continue;
1067 }
1068 let Some(shortcut) = action.shortcut() else {
1069 continue;
1070 };
1071 if !seen.insert(shortcut.clone()) {
1072 continue;
1073 }
1074 if let Some(target) = ShortcutTarget::from_action(action) {
1075 out.push((shortcut, target));
1076 }
1077 }
1078}
1079
1080impl Default for ContextMenu {
1081 fn default() -> Self {
1082 builtin().context_menu.clone()
1083 }
1084}
1085
1086#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1094pub struct PaneMenu {
1095 pub items: Vec<ContextAction>,
1096}
1097
1098impl PaneMenu {
1099 pub fn labels(&self) -> Vec<String> {
1101 self.items.iter().map(ContextAction::label).collect()
1102 }
1103
1104 pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
1108 let mut seen = std::collections::HashSet::new();
1109 let mut out = Vec::new();
1110 collect_shortcuts(&self.items, &mut seen, &mut out);
1111 out
1112 }
1113}
1114
1115impl Default for PaneMenu {
1116 fn default() -> Self {
1117 builtin().pane_menu.clone()
1118 }
1119}
1120
1121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1123#[serde(default)]
1124pub struct Config {
1125 pub panel: PanelConfig,
1126 pub tree: TreeConfig,
1127 pub startup: StartupRoot,
1128 pub context_menu: ContextMenu,
1129 pub pane_menu: PaneMenu,
1130}
1131
1132impl Default for Config {
1133 fn default() -> Self {
1137 builtin().clone()
1138 }
1139}
1140
1141impl Config {
1142 pub fn load() -> LoadResult {
1151 Self::load_and_ensure(&config_file())
1152 }
1153
1154 fn load_and_ensure(path: &Path) -> LoadResult {
1156 let result = Self::load_from_path(path);
1157 if result.problem.is_none()
1158 && !path.exists()
1159 && let Err(problem) = Self::ensure_default_file(path)
1160 {
1161 return LoadResult { config: result.config, problem: Some(problem) };
1162 }
1163 result
1164 }
1165
1166 fn ensure_default_file(path: &Path) -> Result<(), LoadProblem> {
1175 let Some(parent) = path.parent() else {
1176 return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1177 };
1178 if !parent.exists()
1179 && let Err(err) = std::fs::create_dir_all(parent)
1180 {
1181 return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1182 }
1183 if let Ok(metadata) = std::fs::metadata(parent)
1187 && metadata.permissions().readonly()
1188 {
1189 return Ok(());
1190 }
1191 std::fs::write(path, DEFAULT_CONFIG)
1192 .map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1193 }
1194
1195 pub fn load_from_path(path: &Path) -> LoadResult {
1197 let raw = match std::fs::read_to_string(path) {
1198 Ok(raw) => raw,
1199 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1200 return LoadResult {
1201 config: Config::default(),
1202 problem: None,
1203 };
1204 }
1205 Err(err) => {
1206 return LoadResult {
1207 config: Config::default(),
1208 problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
1209 };
1210 }
1211 };
1212 Self::parse(&raw, path)
1213 }
1214
1215 fn normalize(mut self) -> Self {
1217 self.panel.width = self
1218 .panel
1219 .width
1220 .clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
1221 self.panel.margin = self.panel.margin.clamp(0, 200);
1222 self.tree.font_size = self.tree.font_size.clamp(6, 40);
1223 self.tree.icon_size = self.tree.icon_size.clamp(12, 128);
1224 self
1225 }
1226}
1227
1228#[derive(Debug, Clone)]
1231pub enum LoadProblem {
1232 Io(PathBuf, std::io::ErrorKind),
1233 Parse(PathBuf, toml::de::Error),
1234}
1235
1236#[derive(Debug, Clone)]
1239pub struct LoadResult {
1240 pub config: Config,
1241 pub problem: Option<LoadProblem>,
1242}
1243
1244#[derive(Debug, Clone)]
1246pub struct Stylesheet {
1247 pub css: String,
1250 pub source: StyleSource,
1252 pub problem: Option<LoadProblem>,
1255}
1256
1257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1259pub enum StyleSource {
1260 User,
1262 Builtin,
1264}
1265
1266pub fn load_stylesheet() -> Stylesheet {
1272 let path = style_file();
1273 let _ = ensure_default_stylesheet(&path);
1276 load_stylesheet_from_path(&path)
1277}
1278
1279fn load_stylesheet_from_path(path: &Path) -> Stylesheet {
1282 match std::fs::read_to_string(path) {
1283 Ok(css) => Stylesheet {
1284 css,
1285 source: StyleSource::User,
1286 problem: None,
1287 },
1288 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Stylesheet {
1289 css: DEFAULT_STYLESHEET.to_owned(),
1290 source: StyleSource::Builtin,
1291 problem: None,
1292 },
1293 Err(err) => Stylesheet {
1294 css: DEFAULT_STYLESHEET.to_owned(),
1295 source: StyleSource::Builtin,
1296 problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
1297 },
1298 }
1299}
1300
1301fn ensure_default_stylesheet(path: &Path) -> Result<(), LoadProblem> {
1305 if path.exists() {
1306 return Ok(());
1307 }
1308 let Some(parent) = path.parent() else {
1309 return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1310 };
1311 if !parent.exists()
1312 && let Err(err) = std::fs::create_dir_all(parent)
1313 {
1314 return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1315 }
1316 if let Ok(metadata) = std::fs::metadata(parent)
1317 && metadata.permissions().readonly()
1318 {
1319 return Ok(());
1320 }
1321 std::fs::write(path, DEFAULT_STYLESHEET)
1322 .map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1323}
1324
1325impl Config {
1326 fn parse(raw: &str, path: &Path) -> LoadResult {
1328 match toml::from_str::<Config>(raw) {
1329 Ok(mut config) => {
1330 let base = path.parent().unwrap_or_else(|| Path::new("."));
1332 let mut problems = Vec::new();
1333 config.context_menu.rules =
1334 expand_includes(&config.context_menu.rules, base, 0, &mut problems);
1335 let config = config.normalize();
1336 LoadResult {
1337 config,
1338 problem: problems.into_iter().next(),
1341 }
1342 }
1343 Err(err) => LoadResult {
1344 config: Config::default(),
1345 problem: Some(LoadProblem::Parse(path.to_path_buf(), err)),
1346 },
1347 }
1348 }
1349}
1350
1351const MAX_INCLUDE_DEPTH: usize = 8;
1354
1355#[derive(Deserialize)]
1358struct IncludedRules {
1359 #[serde(default)]
1360 context_menu: ContextMenu,
1361}
1362
1363fn expand_includes(
1373 rules: &[ContextRule],
1374 base: &Path,
1375 depth: usize,
1376 problems: &mut Vec<LoadProblem>,
1377) -> Vec<ContextRule> {
1378 let mut out = Vec::new();
1379 for rule in rules {
1380 let Some(include) = &rule.include else {
1381 out.push(rule.clone());
1382 continue;
1383 };
1384 if depth >= MAX_INCLUDE_DEPTH {
1385 continue;
1386 }
1387 let dir = if include.is_absolute() {
1388 include.clone()
1389 } else {
1390 base.join(include)
1391 };
1392 for file in toml_files_in(&dir, problems) {
1393 let nested_base = file.parent().unwrap_or(base);
1395 match std::fs::read_to_string(&file) {
1396 Ok(text) => match toml::from_str::<IncludedRules>(&text) {
1397 Ok(included) => {
1398 out.extend(expand_includes(
1399 &included.context_menu.rules,
1400 nested_base,
1401 depth + 1,
1402 problems,
1403 ));
1404 }
1405 Err(err) => problems.push(LoadProblem::Parse(file.clone(), err)),
1406 },
1407 Err(err) => problems.push(LoadProblem::Io(file.clone(), err.kind())),
1408 }
1409 }
1410 }
1411 out
1412}
1413
1414fn toml_files_in(dir: &Path, problems: &mut Vec<LoadProblem>) -> Vec<PathBuf> {
1417 let entries = match std::fs::read_dir(dir) {
1418 Ok(entries) => entries,
1419 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
1421 Err(err) => {
1422 problems.push(LoadProblem::Io(dir.to_path_buf(), err.kind()));
1423 return Vec::new();
1424 }
1425 };
1426 let mut files: Vec<PathBuf> = entries
1427 .filter_map(Result::ok)
1428 .map(|entry| entry.path())
1429 .filter(|path| {
1430 path.is_file()
1431 && path
1432 .extension()
1433 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
1434 })
1435 .collect();
1436 files.sort();
1437 files
1438}
1439
1440#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1446pub struct SessionState {
1447 pub last_root: Option<PathBuf>,
1449 pub left_width: Option<u32>,
1452 pub right_width: Option<u32>,
1454}
1455
1456impl SessionState {
1457 pub fn load() -> Self {
1460 Self::load_from_path(&state_file())
1461 }
1462
1463 pub fn load_from_path(path: &Path) -> Self {
1465 match std::fs::read_to_string(path) {
1466 Ok(raw) => toml::from_str(&raw).unwrap_or_default(),
1467 Err(_) => Self::default(),
1468 }
1469 }
1470
1471 pub fn save(&self) -> std::io::Result<()> {
1473 Self::save_to_path(&state_file(), self)
1474 }
1475
1476 pub fn save_to_path(path: &Path, state: &Self) -> std::io::Result<()> {
1482 if let Some(parent) = path.parent() {
1483 std::fs::create_dir_all(parent)?;
1484 }
1485 let body = toml::to_string(state).map_err(|e| {
1486 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
1487 })?;
1488 let tmp = path.with_extension("toml.tmp");
1489 std::fs::write(&tmp, body)?;
1490 std::fs::rename(&tmp, path)
1491 }
1492}
1493
1494pub fn action_command(template: &str, path: &Path) -> Vec<String> {
1501 let path_s = path.to_string_lossy();
1502 let dir = if path.is_dir() {
1503 path.to_string_lossy()
1504 } else {
1505 path.parent().unwrap_or(path).to_string_lossy()
1506 };
1507 let has_path = template.contains("{path}");
1508 let has_dir = template.contains("{dir}");
1509 let mut tokens: Vec<String> = template
1510 .split_whitespace()
1511 .map(|t| t.replace("{path}", &path_s).replace("{dir}", &dir))
1512 .filter(|t| !t.is_empty())
1513 .collect();
1514 if !has_path && !has_dir {
1515 tokens.push(path_s.into_owned());
1516 }
1517 tokens
1518}
1519
1520#[cfg(test)]
1521mod tests {
1522 use super::*;
1523
1524 fn parse(raw: &str) -> Config {
1525 Config::parse(raw, Path::new("test.toml")).config
1526 }
1527
1528 #[test]
1529 fn defaults_are_sane() {
1530 let config = Config::default();
1534 assert!((PANEL_MIN_WIDTH..=PANEL_MAX_WIDTH).contains(&config.panel.width));
1535 assert!(config.tree.icon_size > 0);
1536 assert!(config.tree.font_size > 0);
1537 assert!(!config.context_menu.rules.is_empty());
1538 assert!(!config.pane_menu.items.is_empty());
1539 }
1540
1541 #[test]
1542 fn shipping_default_file_parses_and_is_current() {
1543 assert_eq!(parse(DEFAULT_CONFIG), Config::default());
1547
1548 let written = std::fs::read_to_string(concat!(
1549 env!("CARGO_MANIFEST_DIR"),
1550 "/default-config.toml"
1551 ))
1552 .unwrap();
1553 assert_eq!(DEFAULT_CONFIG, written);
1554 }
1555
1556 #[test]
1557 fn empty_document_uses_defaults() {
1558 let config = parse("");
1559 assert_eq!(config, Config::default());
1560 }
1561
1562 #[test]
1563 fn full_document_round_trips() {
1564 let raw = r#"
1565[panel]
1566side = "right"
1567layer = "top"
1568width = 420
1569margin = 8
1570
1571[tree]
1572dirs_first = false
1573show_hidden = true
1574font_size = 14
1575icon_size = 20
1576"#;
1577 let config = parse(raw);
1578 assert_eq!(config.panel.side, PanelSide::Right);
1579 assert_eq!(config.panel.layer, PanelLayer::Top);
1580 assert_eq!(config.panel.width, 420);
1581 assert!(!config.tree.dirs_first);
1582 assert!(config.tree.show_hidden);
1583
1584 let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
1586 assert_eq!(config, reparsed);
1587 }
1588
1589 #[test]
1590 fn partial_document_is_merged_with_defaults() {
1591 let config = parse("[panel]\nside = \"right\"\n");
1592 assert_eq!(config.panel.side, PanelSide::Right);
1593 assert_eq!(config.panel.width, Config::default().panel.width);
1594 assert_eq!(config.tree, Config::default().tree);
1595 }
1596
1597 #[test]
1598 fn defaults_come_from_the_shipped_file() {
1599 let shipped = parse(DEFAULT_CONFIG);
1602 assert_eq!(Config::default(), shipped);
1603 assert_eq!(PanelConfig::default(), shipped.panel);
1604 assert_eq!(TreeConfig::default(), shipped.tree);
1605 assert_eq!(ContextMenu::default(), shipped.context_menu);
1606 assert_eq!(PaneMenu::default(), shipped.pane_menu);
1607 let partial = parse("[tree]\nfont_size = 21\n");
1609 assert_eq!(partial.tree.font_size, 21);
1610 assert_eq!(partial.tree.icon_size, shipped.tree.icon_size);
1611 assert_eq!(partial.tree.sort_key, shipped.tree.sort_key);
1612 assert_eq!(partial.tree.confirm_drop_move, shipped.tree.confirm_drop_move);
1613 assert!(!shipped.tree.confirm_drop_move);
1615 }
1616
1617 #[test]
1618 fn out_of_range_values_are_clamped() {
1619 let config = parse(
1620 "[panel]\nwidth = 100000\nmargin = 9000\n\n[tree]\nfont_size = 2\nicon_size = 2\n",
1621 );
1622 assert_eq!(config.panel.width, 2000);
1623 assert_eq!(config.panel.margin, 200);
1624 assert_eq!(config.tree.font_size, 6);
1625 assert_eq!(config.tree.icon_size, 12);
1626 }
1627
1628 #[test]
1629 fn invalid_toml_falls_back_to_defaults_with_problem() {
1630 let result = Config::parse("panel = ", Path::new("broken.toml"));
1631 assert_eq!(result.config, Config::default());
1632 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
1633 }
1634
1635 #[test]
1636 fn missing_file_yields_defaults_without_problem() {
1637 let result = Config::load_from_path(Path::new("/definitely/not/here/config.toml"));
1638 assert_eq!(result.config, Config::default());
1639 assert!(result.problem.is_none());
1640 }
1641
1642 #[test]
1643 fn first_launch_materializes_default_config_file() {
1644 let dir = tempfile::tempdir().unwrap();
1645 let path = dir.path().join("tree-space/config.toml");
1646 assert!(!path.exists());
1647
1648 let result = Config::load_and_ensure(&path);
1649 assert_eq!(result.config, Config::default());
1650 assert!(result.problem.is_none());
1651
1652 let written = std::fs::read_to_string(&path).unwrap();
1654 assert_eq!(written, DEFAULT_CONFIG);
1655
1656 let reloaded = Config::load_and_ensure(&path);
1658 assert_eq!(reloaded.config, Config::default());
1659 assert!(reloaded.problem.is_none());
1660 }
1661
1662 #[test]
1663 fn existing_file_is_never_overwritten() {
1664 let dir = tempfile::tempdir().unwrap();
1665 let path = dir.path().join("tree-space/config.toml");
1666 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1667 std::fs::write(&path, "[panel]\nwidth = 500\n").unwrap();
1668
1669 let result = Config::load_and_ensure(&path);
1670 assert_eq!(result.config.panel.width, 500);
1671 assert!(result.problem.is_none());
1672 assert_eq!(std::fs::read_to_string(&path).unwrap(), "[panel]\nwidth = 500\n");
1673 }
1674
1675 #[test]
1676 fn xdg_config_resolution_follows_spec() {
1677 assert_eq!(
1679 xdg_config_home(Some("/opt/cfg"), Some("/home/u")),
1680 PathBuf::from("/opt/cfg")
1681 );
1682 assert_eq!(
1684 xdg_config_home(None, Some("/home/u")),
1685 PathBuf::from("/home/u/.config")
1686 );
1687 assert_eq!(
1688 xdg_state_home(None, Some("/home/u")),
1689 PathBuf::from("/home/u/.local/state")
1690 );
1691 assert_eq!(xdg_config_home(Some(""), Some("/h")), PathBuf::from("/h/.config"));
1693 }
1694
1695 #[test]
1696 fn app_path_joins_app_dir() {
1697 assert_eq!(
1698 app_path(&PathBuf::from("/cfg"), STATE_FILE),
1699 PathBuf::from("/cfg/tree-space/state.toml")
1700 );
1701 }
1702
1703 #[test]
1704 fn session_state_round_trips_through_disk() {
1705 let dir = tempfile::tempdir().unwrap();
1706 let path = dir.path().join("state.toml");
1707 let state = SessionState {
1708 last_root: Some(PathBuf::from("/home/user/projects/foo")),
1709 left_width: Some(420),
1710 right_width: None,
1711 };
1712 SessionState::save_to_path(&path, &state).unwrap();
1713 assert_eq!(SessionState::load_from_path(&path), state);
1714 }
1715
1716 #[test]
1717 fn missing_or_broken_state_defaults() {
1718 let dir = tempfile::tempdir().unwrap();
1719 let broken = dir.path().join("broken.toml");
1720 std::fs::write(&broken, "not [ valid").unwrap();
1721 assert_eq!(SessionState::load_from_path(&broken), SessionState::default());
1722 assert_eq!(
1723 SessionState::load_from_path(&dir.path().join("missing.toml")),
1724 SessionState::default()
1725 );
1726 }
1727
1728 fn all_labels(actions: &[ContextAction]) -> Vec<String> {
1731 let mut out = Vec::new();
1732 for action in actions {
1733 match action.submenu_items() {
1734 Some(items) => out.extend(all_labels(items)),
1735 None => out.push(action.label()),
1736 }
1737 }
1738 out
1739 }
1740
1741 #[test]
1742 fn default_context_menu_reproduces_classic_items() {
1743 let menu = ContextMenu::default();
1744 let dir = tempfile::tempdir().unwrap();
1745 let file = dir.path().join("notes.txt");
1746 std::fs::write(&file, "").unwrap();
1747 std::fs::write(dir.path().join("photo.jpg"), "").unwrap();
1748
1749 let dir_labels = all_labels(&menu.actions_for(dir.path()));
1750 for classic in [
1753 "In same panel",
1754 "In split view",
1755 "Folder",
1756 "File",
1757 "Copy Path",
1758 "Move to Trash",
1759 "Delete Permanently",
1760 ] {
1761 assert!(dir_labels.contains(&classic.to_owned()), "missing {classic}: {dir_labels:?}");
1762 }
1763 for parity in ["In opposite panel", "With...", "Cut", "Copy", "Paste", "Create Link", "Rename", "Properties"] {
1765 assert!(
1766 dir_labels.contains(&parity.to_owned()),
1767 "missing {parity}: {dir_labels:?}"
1768 );
1769 }
1770
1771 let file_labels = all_labels(&menu.actions_for(&file));
1772 for dropped in ["In split view", "In opposite panel"] {
1774 assert!(!file_labels.contains(&dropped.to_owned()), "{dropped} present: {file_labels:?}");
1775 }
1776 assert!(file_labels.contains(&"With Default".to_owned()));
1778 assert!(file_labels.contains(&"With...".to_owned()));
1779 assert!(file_labels.len() > 15);
1780
1781 let menus = menu.actions_for(dir.path());
1783 let cut = menus
1784 .iter()
1785 .flat_map(flatten_actions)
1786 .find(|a| a.label() == "Cut")
1787 .expect("Cut present");
1788 assert_eq!(cut.shortcut().as_deref(), Some("Ctrl+x"));
1789 let trash = menus
1790 .iter()
1791 .flat_map(flatten_actions)
1792 .find(|a| a.label() == "Move to Trash")
1793 .expect("Trash present");
1794 assert_eq!(trash.shortcut().as_deref(), Some("Delete"));
1795 }
1796
1797 fn flatten_actions(action: &ContextAction) -> Vec<&ContextAction> {
1799 match action.submenu_items() {
1800 Some(items) => items.iter().flat_map(flatten_actions).collect(),
1801 None => vec![action],
1802 }
1803 }
1804
1805 #[test]
1806 fn default_shortcuts_follow_conventions() {
1807 assert_eq!(BuiltinAction::Cut.default_shortcut(), Some("Ctrl+x"));
1808 assert_eq!(BuiltinAction::Copy.default_shortcut(), Some("Ctrl+c"));
1809 assert_eq!(BuiltinAction::Rename.default_shortcut(), Some("F2"));
1810 assert_eq!(BuiltinAction::CreateLink.default_shortcut(), Some("Ctrl+Shift+m"));
1811 assert_eq!(BuiltinAction::Trash.default_shortcut(), Some("Delete"));
1812 assert_eq!(BuiltinAction::DeletePermanently.default_shortcut(), Some("Shift+Delete"));
1813 assert_eq!(BuiltinAction::ViewThumbnail.default_shortcut(), Some("Ctrl+t"));
1814 assert_eq!(BuiltinAction::Open.default_shortcut(), None);
1815 assert_eq!(BuiltinAction::Separator.default_shortcut(), None);
1816 }
1817
1818 #[test]
1819 fn shortcuts_are_collected_top_down_and_deduplicated() {
1820 let menu = ContextMenu {
1821 rules: vec![
1822 ContextRule::rule(
1823 vec![ContextMatch::Dir],
1824 vec![
1825 ContextAction::Builtin(BuiltinAction::Open),
1826 ContextAction::Entry(BuiltinEntry {
1827 action: BuiltinAction::Rename,
1828 label: None,
1829 shortcut: Some("F2".into()),
1830 hidden: false,
1831 }),
1832 ContextAction::Command(CommandAction {
1833 command: "code {path}".into(),
1834 label: None,
1835 shortcut: Some("Ctrl+Shift+e".into()),
1836 hidden: false,
1837 }),
1838 ],
1839 ),
1840 ContextRule::rule(
1841 vec![ContextMatch::Fallback],
1842 vec![ContextAction::Builtin(BuiltinAction::Trash)],
1843 ),
1844 ],
1845 };
1846 let shortcuts = menu.shortcuts();
1847 assert_eq!(
1849 shortcuts,
1850 vec![
1851 ("F2".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Rename)),
1852 ("Ctrl+Shift+e".to_owned(), ShortcutTarget::Command(CommandAction {
1853 command: "code {path}".into(),
1854 label: None,
1855 shortcut: Some("Ctrl+Shift+e".into()),
1856 hidden: false,
1857 })),
1858 ("Delete".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Trash)),
1859 ]
1860 );
1861 }
1862
1863 #[test]
1864 fn table_form_spells_out_shortcuts_and_labels() {
1865 let raw = r#"
1866[[context_menu.rules]]
1867matches = ["fallback"]
1868items = [
1869 { action = "Cut", shortcut = "Ctrl+Alt+x", label = "Cut here" },
1870 { command = "sh {path}", shortcut = "Ctrl+Alt+s" },
1871 "---",
1872]
1873"#;
1874 let config = parse(raw);
1875 let actions = &config.context_menu.rules[0].items;
1876 assert_eq!(actions.len(), 3);
1877
1878 match &actions[0] {
1879 ContextAction::Entry(entry) => {
1880 assert_eq!(entry.action, BuiltinAction::Cut);
1881 assert_eq!(entry.shortcut.as_deref(), Some("Ctrl+Alt+x"));
1882 assert_eq!(entry.label.as_deref(), Some("Cut here"));
1883 assert_eq!(actions[0].label(), "Cut here");
1884 assert_eq!(actions[0].shortcut().as_deref(), Some("Ctrl+Alt+x"));
1885 }
1886 other => panic!("expected Entry, got {other:?}"),
1887 }
1888 match &actions[1] {
1889 ContextAction::Command(cmd) => {
1890 assert_eq!(cmd.shortcut.as_deref(), Some("Ctrl+Alt+s"));
1891 }
1892 other => panic!("expected Command, got {other:?}"),
1893 }
1894 match &actions[2] {
1895 ContextAction::Builtin(BuiltinAction::Separator) => {}
1896 other => panic!("expected Separator, got {other:?}"),
1897 }
1898
1899 let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
1900 assert_eq!(config, reparsed);
1901 }
1902
1903 #[test]
1904 fn pane_actions_are_classified() {
1905 for action in [
1906 BuiltinAction::OpenFolder,
1907 BuiltinAction::Filter,
1908 BuiltinAction::SplitView,
1909 BuiltinAction::Collapse,
1910 BuiltinAction::ClosePane,
1911 ] {
1912 assert!(action.is_pane_action(), "{action:?} should be a pane action");
1913 }
1914 for action in [
1915 BuiltinAction::Open,
1916 BuiltinAction::OpenSplit,
1917 BuiltinAction::ToggleHidden,
1918 BuiltinAction::SortByName,
1919 BuiltinAction::Separator,
1920 ] {
1921 assert!(!action.is_pane_action(), "{action:?} is not a pane action");
1922 }
1923 }
1924
1925 #[test]
1926 fn separator_matches_both_spellings() {
1927 assert_eq!(BuiltinAction::parse("---"), Some(BuiltinAction::Separator));
1928 assert_eq!(BuiltinAction::parse("separator"), Some(BuiltinAction::Separator));
1929 assert_eq!(BuiltinAction::parse("SEPARATOR"), Some(BuiltinAction::Separator));
1930 assert_eq!(BuiltinAction::Separator.as_str(), "---");
1931 }
1932
1933 #[test]
1934 fn ellipsis_labels_use_ascii_and_accept_the_typographic_form() {
1935 assert_eq!(BuiltinAction::OpenWith.as_str(), "Open With...");
1937 assert_eq!(BuiltinAction::OpenFolder.as_str(), "Open Folder...");
1938 assert_eq!(BuiltinAction::Filter.as_str(), "Filter...");
1939 assert_eq!(BuiltinAction::parse("Open With…"), Some(BuiltinAction::OpenWith));
1941 assert_eq!(BuiltinAction::parse("Open Folder…"), Some(BuiltinAction::OpenFolder));
1942 assert_eq!(BuiltinAction::parse("Filter…"), Some(BuiltinAction::Filter));
1943 assert_eq!(BuiltinAction::parse("open folder..."), Some(BuiltinAction::OpenFolder));
1945 }
1946
1947 #[test]
1948 fn pane_menu_defaults_include_view_and_pane_actions() {
1949 let menu = PaneMenu::default();
1950 let labels = menu.labels();
1951 assert!(labels.contains(&"Open Folder...".to_owned()));
1952 assert!(labels.contains(&"Split View".to_owned()));
1953 assert!(labels.contains(&"Close Pane".to_owned()));
1954 assert!(labels.contains(&"Toggle Hidden Files".to_owned()));
1955 assert!(labels.contains(&"Sort by Size".to_owned()));
1956 let dir_menu = ContextMenu::default();
1958 let dir_labels: Vec<String> = dir_menu
1959 .rules
1960 .iter()
1961 .flat_map(|rule| rule.items.iter())
1962 .map(ContextAction::label)
1963 .collect();
1964 assert!(!dir_labels.contains(&"Toggle Hidden Files".to_owned()));
1965 }
1966
1967 #[test]
1968 fn pane_menu_shortcuts_are_collected() {
1969 let menu = PaneMenu::default();
1970 let shortcuts = menu.shortcuts();
1971 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+h"));
1972 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+1"));
1973 assert!(shortcuts.iter().all(|(k, _)| !k.is_empty()));
1975 }
1976
1977 #[test]
1978 fn pane_menu_parses_custom_commands() {
1979 let toml = r#"
1980 items = [
1981 "Split View",
1982 "---",
1983 { command = "echo hi", label = "Say hi" },
1984 ]
1985 "#;
1986 let menu: PaneMenu = toml::from_str(toml).unwrap();
1987 assert_eq!(menu.labels(), vec!["Split View", "---", "Say hi"]);
1988 }
1989
1990 #[test]
1991 fn navigation_builtins_round_trip_and_are_pane_actions() {
1992 for action in [BuiltinAction::Up, BuiltinAction::Back, BuiltinAction::Forward] {
1993 let s = action.as_str();
1994 assert_eq!(BuiltinAction::parse(s), Some(action), "{s} should parse");
1995 assert!(action.is_pane_action(), "{s} should be a pane action");
1996 assert!(action.default_shortcut().is_some(), "{s} needs a default key");
1997 }
1998 assert_eq!(BuiltinAction::Up.default_shortcut(), Some("Alt+Up"));
1999 assert_eq!(BuiltinAction::Back.default_shortcut(), Some("Alt+Left"));
2000 assert_eq!(BuiltinAction::Forward.default_shortcut(), Some("Alt+Right"));
2001 }
2002
2003 #[test]
2004 fn hidden_items_parse_and_keep_their_shortcut() {
2005 let toml = r#"
2006 items = [
2007 { action = "Up One Level", hidden = true },
2008 { command = "echo hi", label = "Say hi", shortcut = "Ctrl+g", hidden = true },
2009 { label = "More", hidden = true, items = [ "Copy Path" ] },
2010 "Open",
2011 ]
2012 "#;
2013 let menu: PaneMenu = toml::from_str(toml).unwrap();
2014 assert!(menu.items[0].is_hidden());
2015 assert!(menu.items[1].is_hidden());
2016 assert!(menu.items[2].is_hidden());
2017 assert!(!menu.items[3].is_hidden());
2018 assert_eq!(menu.items[0].label(), "Up One Level");
2020 assert_eq!(menu.items[1].shortcut().as_deref(), Some("Ctrl+g"));
2021 let shortcuts = menu.shortcuts();
2023 assert!(shortcuts.iter().any(|(k, _)| k == "Alt+Up"));
2024 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+g"));
2025 }
2026
2027 #[test]
2028 fn hidden_submenu_children_keep_their_shortcuts() {
2029 let toml = r#"
2030 items = [
2031 { label = "More", hidden = true, items = [
2032 { action = "Rename", shortcut = "Ctrl+Shift+r" },
2033 ] },
2034 ]
2035 "#;
2036 let menu: PaneMenu = toml::from_str(toml).unwrap();
2037 assert!(menu.items[0].is_hidden());
2038 assert!(menu.shortcuts().iter().any(|(k, _)| k == "Ctrl+Shift+r"));
2039 }
2040
2041 #[test]
2042 fn hidden_defaults_to_false_and_is_not_serialized() {
2043 #[derive(serde::Deserialize)]
2044 struct One {
2045 item: ContextAction,
2046 }
2047 let visible: One = toml::from_str(r#"item = { action = "Cut", shortcut = "Ctrl+x" }"#).unwrap();
2048 assert!(!visible.item.is_hidden());
2049 let plain: One = toml::from_str(r#"item = "Cut""#).unwrap();
2051 assert!(!plain.item.is_hidden());
2052 #[derive(serde::Serialize)]
2054 struct OneOut {
2055 item: ContextAction,
2056 }
2057 let encoded = toml::to_string(&OneOut { item: visible.item }).unwrap();
2058 assert!(!encoded.contains("hidden"), "{encoded}");
2059 }
2060
2061 #[test]
2062 fn menu_items_parse_submenus_recursively() {
2063 let toml = r#"
2064 items = [
2065 "Open",
2066 { label = "More", items = [
2067 { action = "Copy Path", shortcut = "Ctrl+Shift+c" },
2068 { label = "Deeper", items = [ { command = "echo hi", label = "Hi" } ] },
2069 ] },
2070 ]
2071 "#;
2072 let menu: PaneMenu = toml::from_str(toml).unwrap();
2073 assert_eq!(menu.labels(), vec!["Open", "More"]);
2074 let sub = &menu.items[1];
2076 assert!(sub.is_submenu());
2077 assert!(sub.shortcut().is_none());
2078 let children = sub.submenu_items().unwrap();
2079 assert_eq!(children.len(), 2);
2080 assert_eq!(children[0].label(), "Copy Path");
2081 assert!(children[1].is_submenu());
2082 let shortcuts = menu.shortcuts();
2084 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
2085 }
2086
2087 #[test]
2088 fn context_menu_collects_nested_shortcuts() {
2089 let raw = r#"
2090 [[context_menu.rules]]
2091 matches = ["dir"]
2092 items = [
2093 "Open",
2094 { label = "More", items = [
2095 { action = "Copy Path", shortcut = "Ctrl+Shift+c" },
2096 ] },
2097 ]
2098 [[context_menu.rules]]
2099 matches = ["fallback"]
2100 items = ["Open"]
2101 "#;
2102 let config = parse(raw);
2103 let shortcuts = config.context_menu.shortcuts();
2104 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
2105 }
2106
2107 #[test]
2108 fn startup_last_uses_session_root() {
2109 let root = StartupRoot::Last;
2110 let last = Some(PathBuf::from("/tmp/last"));
2111 assert_eq!(root.resolve(last.clone()), last);
2112 assert_eq!(root.resolve(None), None);
2113 }
2114
2115 #[test]
2116 fn startup_home_ignores_session_root() {
2117 let root = StartupRoot::Home;
2118 assert_eq!(root.resolve(Some(PathBuf::from("/tmp/last"))), None);
2119 }
2120
2121 #[test]
2122 fn startup_path_uses_configured_directory() {
2123 let root = StartupRoot::Path("/tmp/fixed".to_owned());
2124 assert_eq!(
2125 root.resolve(Some(PathBuf::from("/tmp/last"))),
2126 Some(PathBuf::from("/tmp/fixed"))
2127 );
2128 assert_eq!(StartupRoot::Path(String::new()).resolve(None), None);
2130 }
2131
2132 #[test]
2133 fn startup_tilde_expands_to_home() {
2134 unsafe { std::env::set_var("HOME", "/home/tester") };
2136 let root = StartupRoot::Path("~/notes".to_owned());
2137 assert_eq!(root.resolve(None), Some(PathBuf::from("/home/tester/notes")));
2138 assert_eq!(expand_tilde("~"), Some(PathBuf::from("/home/tester")));
2139 assert_eq!(expand_tilde("/abs/path"), Some(PathBuf::from("/abs/path")));
2140 assert_eq!(expand_tilde(""), None);
2141 let rel = expand_tilde("some/relative/dir").unwrap();
2144 assert!(rel.is_absolute(), "{rel:?} should be absolute");
2145 assert!(rel.ends_with("some/relative/dir"), "{rel:?}");
2146 }
2147
2148 #[test]
2149 fn startup_parses_from_toml() {
2150 assert_eq!(parse("startup = \"home\"\n").startup, StartupRoot::Home);
2151 assert_eq!(parse("startup = \"last\"\n").startup, StartupRoot::Last);
2152 assert_eq!(
2153 parse("startup = { path = \"/srv\" }\n").startup,
2154 StartupRoot::Path("/srv".to_owned())
2155 );
2156 assert_eq!(Config::default().startup, StartupRoot::Home);
2158 assert!(
2160 Config::parse("startup = \"bogus\"\n", Path::new("test.toml"))
2161 .problem
2162 .is_some()
2163 );
2164 let cfg = Config { startup: StartupRoot::Path("/srv".to_owned()), ..Config::default() };
2166 let text = toml::to_string(&cfg).unwrap();
2167 assert_eq!(toml::from_str::<Config>(&text).unwrap().startup, cfg.startup);
2168 }
2169
2170 #[test]
2171 fn context_matches_classify_rows() {
2172 let dir = tempfile::tempdir().unwrap();
2173 let file = dir.path().join("report.md");
2174 std::fs::write(&file, "").unwrap();
2175 let noext = dir.path().join("Makefile");
2176 std::fs::write(&noext, "").unwrap();
2177 let tarball = dir.path().join("app.tar.gz");
2178 std::fs::write(&tarball, "").unwrap();
2179
2180 assert!(ContextMatch::parse("dir").unwrap().matches(dir.path()));
2181 assert!(!ContextMatch::parse("dir").unwrap().matches(&file));
2182 assert!(ContextMatch::parse("noext").unwrap().matches(&noext));
2183 assert!(!ContextMatch::parse("noext").unwrap().matches(&file));
2184 assert!(ContextMatch::parse("ext:md").unwrap().matches(&file));
2185 assert!(ContextMatch::parse("ext:MD").unwrap().matches(&file));
2186 assert!(ContextMatch::parse("ext:gz").unwrap().matches(&tarball));
2187 assert!(!ContextMatch::parse("ext:md").unwrap().matches(dir.path()));
2188 assert_eq!(ContextMatch::parse("directory"), Ok(ContextMatch::Dir));
2189 assert_eq!(ContextMatch::parse("*"), Ok(ContextMatch::Fallback));
2190 assert!(ContextMatch::parse("bogus").is_err());
2191 }
2192
2193 #[test]
2194 fn regex_matcher_matches_the_full_path() {
2195 let dir = tempfile::tempdir().unwrap();
2196 let md = dir.path().join("guide.md");
2197 std::fs::write(&md, "").unwrap();
2198
2199 assert!(ContextMatch::parse(r"regex:\.md$").unwrap().matches(&md));
2201 assert!(!ContextMatch::parse(r"regex:\.rs$").unwrap().matches(&md));
2202
2203 let exact = format!("regex:^{}$", regex::escape(dir.path().to_str().unwrap()));
2205 assert!(ContextMatch::parse(&exact).unwrap().matches(dir.path()));
2206
2207 let parsed = ContextMatch::parse(r"regex:\.rs$").unwrap();
2209 assert_eq!(parsed.as_str(), r"regex:\.rs$");
2210 let reparsed = ContextMatch::parse(&parsed.as_str()).unwrap();
2211 assert_eq!(parsed, reparsed);
2212 }
2213
2214 #[test]
2215 fn invalid_regex_is_a_parse_error() {
2216 let err = ContextMatch::parse("regex:(").unwrap_err();
2217 assert!(err.contains("invalid regex"), "got: {err}");
2218 }
2219
2220 #[test]
2221 fn stylesheet_prefers_the_user_file_and_materializes_a_default() {
2222 let dir = tempfile::tempdir().unwrap();
2223 let path = dir.path().join("tree-space/main.css");
2224
2225 let first = load_stylesheet_from_path(&path);
2227 assert_eq!(first.source, StyleSource::Builtin);
2228 assert!(first.problem.is_none());
2229 assert_eq!(first.css, DEFAULT_STYLESHEET);
2230
2231 ensure_default_stylesheet(&path).unwrap();
2233 assert_eq!(std::fs::read_to_string(&path).unwrap(), DEFAULT_STYLESHEET);
2234
2235 std::fs::write(&path, ".tree-row { color: red; }").unwrap();
2237 let user = load_stylesheet_from_path(&path);
2238 assert_eq!(user.source, StyleSource::User);
2239 assert!(user.problem.is_none());
2240 assert_eq!(user.css, ".tree-row { color: red; }");
2241 }
2242
2243 #[test]
2244 fn materialize_never_overwrites_an_existing_stylesheet() {
2245 let dir = tempfile::tempdir().unwrap();
2246 let path = dir.path().join("tree-space/main.css");
2247 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2248 std::fs::write(&path, "/* mine */").unwrap();
2249
2250 ensure_default_stylesheet(&path).unwrap();
2251 assert_eq!(std::fs::read_to_string(&path).unwrap(), "/* mine */");
2252 }
2253
2254 #[test]
2255 fn unreadable_stylesheet_falls_back_to_builtin_with_a_problem() {
2256 let dir = tempfile::tempdir().unwrap();
2257 let path = dir.path().join("main.css");
2260 std::fs::create_dir(&path).unwrap();
2261
2262 let stylesheet = load_stylesheet_from_path(&path);
2263 assert_eq!(stylesheet.source, StyleSource::Builtin);
2264 assert_eq!(stylesheet.css, DEFAULT_STYLESHEET);
2265 assert!(matches!(stylesheet.problem, Some(LoadProblem::Io(..))));
2266 }
2267
2268 #[test]
2269 fn include_rule_splices_files_in_place_and_name_sorted() { let dir = tempfile::tempdir().unwrap();
2270 let rules_dir = dir.path().join("rules.d");
2271 std::fs::create_dir_all(&rules_dir).unwrap();
2272 std::fs::write(
2274 rules_dir.join("20-second.toml"),
2275 "[[context_menu.rules]]\nmatches = [\"ext:b\"]\nitems = [\"Properties\"]\n",
2276 )
2277 .unwrap();
2278 std::fs::write(
2279 rules_dir.join("10-first.toml"),
2280 "[[context_menu.rules]]\nmatches = [\"ext:a\"]\nitems = [\"Properties\", \"Copy Path\"]\n",
2281 )
2282 .unwrap();
2283 std::fs::write(rules_dir.join("README.md"), "not config").unwrap();
2285
2286 let main = dir.path().join("config.toml");
2287 std::fs::write(
2288 &main,
2289 r#"
2290[[context_menu.rules]]
2291matches = ["dir"]
2292items = ["Open"]
2293
2294[[context_menu.rules]]
2295include = "rules.d"
2296
2297[[context_menu.rules]]
2298matches = ["fallback"]
2299items = ["Move to Trash"]
2300"#,
2301 )
2302 .unwrap();
2303
2304 let config = Config::load_from_path(&main);
2305 assert!(config.problem.is_none(), "{:?}", config.problem);
2306 let rules = &config.config.context_menu.rules;
2307 assert_eq!(rules.len(), 4);
2309 assert_eq!(rules[0].matches, vec![ContextMatch::Dir]);
2310 assert_eq!(rules[1].matches, vec![ContextMatch::Ext("a".to_owned())]);
2311 assert_eq!(rules[2].matches, vec![ContextMatch::Ext("b".to_owned())]);
2312 assert_eq!(rules[3].matches, vec![ContextMatch::Fallback]);
2313 assert!(rules.iter().all(|r| r.include.is_none()));
2315 }
2316
2317 #[test]
2318 fn include_of_a_missing_directory_contributes_nothing() {
2319 let dir = tempfile::tempdir().unwrap();
2320 let main = dir.path().join("config.toml");
2321 std::fs::write(
2322 &main,
2323 "[[context_menu.rules]]\ninclude = \"nope.d\"\n\n[[context_menu.rules]]\nmatches = [\"fallback\"]\nitems = [\"Move to Trash\"]\n",
2324 )
2325 .unwrap();
2326 let result = Config::load_from_path(&main);
2327 assert!(result.problem.is_none(), "{:?}", result.problem);
2329 assert_eq!(result.config.context_menu.rules.len(), 1);
2330 assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Fallback]);
2331 }
2332
2333 #[test]
2334 fn include_of_an_invalid_file_is_reported_but_others_still_load() {
2335 let dir = tempfile::tempdir().unwrap();
2336 let rules_dir = dir.path().join("rules.d");
2337 std::fs::create_dir_all(&rules_dir).unwrap();
2338 std::fs::write(rules_dir.join("10-good.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
2339 std::fs::write(rules_dir.join("20-bad.toml"), "this is not = valid toml [").unwrap();
2340
2341 let main = dir.path().join("config.toml");
2342 std::fs::write(
2343 &main,
2344 "[[context_menu.rules]]\ninclude = \"rules.d\"\n",
2345 )
2346 .unwrap();
2347
2348 let result = Config::load_from_path(&main);
2349 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))), "{:?}", result.problem);
2351 assert_eq!(result.config.context_menu.rules.len(), 1);
2353 assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
2354 }
2355
2356 #[test]
2357 fn included_rules_are_expanded_recursively() {
2358 let dir = tempfile::tempdir().unwrap();
2359 let outer = dir.path().join("outer.d");
2360 let inner = outer.join("inner.d");
2361 std::fs::create_dir_all(&inner).unwrap();
2362 std::fs::write(inner.join("nested.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
2363 std::fs::write(
2364 outer.join("mid.toml"),
2365 "[[context_menu.rules]]\ninclude = \"inner.d\"\n",
2366 )
2367 .unwrap();
2368
2369 let main = dir.path().join("config.toml");
2370 std::fs::write(&main, "[[context_menu.rules]]\ninclude = \"outer.d\"\n").unwrap();
2371
2372 let result = Config::load_from_path(&main);
2373 assert!(result.problem.is_none(), "{:?}", result.problem);
2374 assert_eq!(result.config.context_menu.rules.len(), 1);
2375 assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
2376 }
2377
2378 #[test]
2379 fn first_matching_rule_wins() {
2380 let menu = ContextMenu {
2381 rules: vec![
2382 ContextRule::rule(
2383 vec![ContextMatch::Ext("md".to_owned())],
2384 builtin_items![CopyPath],
2385 ),
2386 ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
2387 ],
2388 };
2389 let dir = tempfile::tempdir().unwrap();
2390 let md = dir.path().join("a.md");
2391 std::fs::write(&md, "").unwrap();
2392 let txt = dir.path().join("a.txt");
2393 std::fs::write(&txt, "").unwrap();
2394
2395 assert_eq!(menu.actions_for(&md), builtin_items![CopyPath]);
2396 assert_eq!(menu.actions_for(&txt), builtin_items![Open]);
2398 }
2399
2400 #[test]
2401 fn multi_matcher_parses_and_never_matches_a_single_path() {
2402 assert_eq!(ContextMatch::parse("multi").unwrap(), ContextMatch::Multi);
2403 assert_eq!(ContextMatch::parse("multi-select").unwrap(), ContextMatch::Multi);
2404 assert_eq!(ContextMatch::Multi.as_str(), "multi");
2405 let dir = tempfile::tempdir().unwrap();
2407 assert!(!ContextMatch::Multi.matches(dir.path()));
2408 }
2409
2410 #[test]
2411 fn multi_rule_wins_for_a_multi_selection_only() {
2412 let menu = ContextMenu {
2413 rules: vec![
2414 ContextRule::rule(vec![ContextMatch::Multi], builtin_items![Trash, CopyPath]),
2415 ContextRule::rule(vec![ContextMatch::Dir], builtin_items![Open]),
2416 ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
2417 ],
2418 };
2419 let dir = tempfile::tempdir().unwrap();
2420
2421 assert_eq!(menu.actions_for_selection(dir.path(), 1), builtin_items![Open]);
2423 assert_eq!(
2425 menu.actions_for_selection(dir.path(), 2),
2426 builtin_items![Trash, CopyPath]
2427 );
2428 assert_eq!(
2429 menu.actions_for_selection(dir.path(), 7),
2430 builtin_items![Trash, CopyPath]
2431 );
2432 }
2433
2434 #[test]
2435 fn shipped_multi_rule_offers_selection_actions() {
2436 let menu = ContextMenu::default();
2437 let dir = tempfile::tempdir().unwrap();
2438 let file = dir.path().join("a.txt");
2439 std::fs::write(&file, "").unwrap();
2440
2441 let single = menu.actions_for_selection(&file, 1);
2443 assert_eq!(single, menu.actions_for(&file));
2444
2445 let labels = all_labels(&menu.actions_for_selection(&file, 3));
2448 assert!(labels.contains(&"Move to Trash".to_owned()), "{labels:?}");
2449 assert!(labels.contains(&"Copy Path".to_owned()), "{labels:?}");
2450 assert!(labels.contains(&"Properties".to_owned()), "{labels:?}");
2451 assert!(!labels.contains(&"Rename".to_owned()), "{labels:?}");
2452 assert!(!labels.contains(&"Duplicate".to_owned()), "{labels:?}");
2453 assert!(!labels.contains(&"With...".to_owned()), "{labels:?}");
2454 }
2455
2456 #[test]
2457 fn single_row_only_actions_are_classified() {
2458 assert!(BuiltinAction::Rename.is_single_row_only());
2459 assert!(BuiltinAction::Open.is_single_row_only());
2460 assert!(BuiltinAction::Duplicate.is_single_row_only());
2461 assert!(!BuiltinAction::Trash.is_single_row_only());
2462 assert!(!BuiltinAction::DeletePermanently.is_single_row_only());
2463 assert!(!BuiltinAction::Copy.is_single_row_only());
2464 assert!(!BuiltinAction::Cut.is_single_row_only());
2465 assert!(!BuiltinAction::Properties.is_single_row_only());
2466 assert!(!BuiltinAction::CopyPath.is_single_row_only());
2467 }
2468
2469 #[test]
2470 fn empty_rule_list_falls_back_to_classic_menu() {
2471 let menu = ContextMenu { rules: Vec::new() };
2472 let dir = tempfile::tempdir().unwrap();
2473 assert_eq!(menu.actions_for(dir.path()), ContextMenu::fallback_actions(dir.path()));
2474 }
2475
2476 #[test]
2477 fn custom_context_menu_round_trips() {
2478 let raw = r#"
2479[[context_menu.rules]]
2480matches = ["ext:rs", "ext:toml"]
2481items = ["Open", { command = "cargo fmt", label = "Fmt" }]
2482
2483[[context_menu.rules]]
2484matches = ["dir"]
2485items = [{ command = "foot -D {dir}", label = "Open in Terminal" }, "New Folder"]
2486
2487[[context_menu.rules]]
2488matches = ["noext"]
2489items = [{ command = "sh {path}" }]
2490
2491[[context_menu.rules]]
2492matches = ['regex:.*\.lock$']
2493items = ["Open", { command = "rm {path}", label = "Discard lock" }]
2494"#;
2495 let config = parse(raw);
2496 assert_eq!(config.context_menu.rules.len(), 4);
2497
2498 let first = &config.context_menu.rules[0];
2499 assert_eq!(
2500 first.matches,
2501 vec![ContextMatch::Ext("rs".to_owned()), ContextMatch::Ext("toml".to_owned())]
2502 );
2503 assert!(matches!(first.items[0], ContextAction::Builtin(BuiltinAction::Open)));
2504 assert!(matches!(
2505 &first.items[1],
2506 ContextAction::Command(cmd) if cmd.command == "cargo fmt" && cmd.label.as_deref() == Some("Fmt")
2507 ));
2508
2509 let last = &config.context_menu.rules[3];
2510 assert_eq!(
2511 last.matches,
2512 vec![ContextMatch::parse(r"regex:.*\.lock$").unwrap()]
2513 );
2514
2515 let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
2516 assert_eq!(config, reparsed);
2517 }
2518
2519 #[test]
2520 fn unknown_builtin_action_is_a_parse_problem() {
2521 let result = Config::parse(
2522 "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Frobnicate\"]\n",
2523 Path::new("bad-menu.toml"),
2524 );
2525 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
2526 assert_eq!(result.config, Config::default());
2527 }
2528
2529 #[test]
2530 fn unknown_context_matcher_is_a_parse_problem() {
2531 let result = Config::parse(
2532 "[[context_menu.rules]]\nmatches = [\"gibberish\"]\nitems = [\"Open\"]\n",
2533 Path::new("bad-matcher.toml"),
2534 );
2535 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
2536 assert_eq!(result.config, Config::default());
2537 }
2538
2539 #[test]
2540 fn action_command_substitutes_markers() {
2541 let dir = tempfile::tempdir().unwrap();
2542 let dir_s = dir.path().to_string_lossy().into_owned();
2543 assert_eq!(
2544 action_command("code {path}", dir.path()),
2545 vec!["code".to_owned(), dir_s.clone()]
2546 );
2547 assert_eq!(
2548 action_command("code {dir}", &dir.path().join("x.rs")),
2549 vec!["code".to_owned(), dir_s]
2550 );
2551 }
2552
2553 #[test]
2554 fn action_command_appends_path_without_marker() {
2555 assert_eq!(
2556 action_command("sh", Path::new("/tmp/s.sh")),
2557 vec!["sh".to_owned(), "/tmp/s.sh".to_owned()]
2558 );
2559 }
2560}