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";
33pub const BOOKMARKS_FILE: &str = "bookmarks.toml";
36
37const DEFAULT_CONFIG: &str = include_str!("../default-config.toml");
43
44pub const DEFAULT_STYLESHEET: &str = include_str!("../style/main.css");
47
48const STYLE_STRUCTURE_MARKER: &str =
52 "/* --- structure (theme-independent; system themes replace the colors above) --- */";
53
54pub 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
65fn 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#[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
112fn 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
127fn 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
135fn app_path(base: &Path, file: &str) -> PathBuf {
137 base.join(APP_DIR).join(file)
138}
139
140fn 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
148fn 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
155pub fn config_file() -> PathBuf {
157 app_path(&config_home_from_env(), CONFIG_FILE)
158}
159
160pub fn state_file() -> PathBuf {
162 app_path(&state_home_from_env(), STATE_FILE)
163}
164
165pub fn style_file() -> PathBuf {
167 app_path(&config_home_from_env(), STYLE_FILE)
168}
169
170pub fn bookmark_file_path(configured: &Path) -> PathBuf {
174 resolve_bookmarks_path(&config_home_from_env().join(APP_DIR), configured)
175}
176
177fn resolve_bookmarks_path(base: &Path, configured: &Path) -> PathBuf {
179 if configured.is_absolute() { configured.to_path_buf() } else { base.join(configured) }
180}
181
182pub 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
191pub 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#[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 pub const ALL: [PanelSide; 2] = [PanelSide::Left, PanelSide::Right];
217
218 pub fn opposite(&self) -> PanelSide {
220 match self {
221 PanelSide::Left => PanelSide::Right,
222 PanelSide::Right => PanelSide::Left,
223 }
224 }
225
226 pub fn name(&self) -> &'static str {
228 match self {
229 PanelSide::Left => "left",
230 PanelSide::Right => "right",
231 }
232 }
233}
234
235#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
249#[serde(rename_all = "lowercase")]
250pub enum ThemeMode {
251 #[default]
253 Custom,
254 System,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
261#[serde(rename_all = "lowercase")]
262pub enum SystemTheme {
263 #[default]
265 Omarchy,
266}
267
268#[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
282pub const PANEL_MIN_WIDTH: u32 = 160;
284pub const PANEL_MAX_WIDTH: u32 = 2000;
286
287fn 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#[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 #[serde(default = "default_panel_width")]
345 pub width: u32,
346 #[serde(default)]
348 pub margin: u32,
349 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct TreeConfig {
364 #[serde(default = "default_dirs_first")]
368 pub dirs_first: bool,
369 #[serde(default = "default_sort_key")]
371 pub sort_key: String,
372 #[serde(default = "default_sort_ascending")]
374 pub sort_ascending: bool,
375 #[serde(default)]
377 pub show_hidden: bool,
378 #[serde(default = "default_font_size")]
380 pub font_size: u32,
381 #[serde(default = "default_icon_size")]
383 pub icon_size: u32,
384 #[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 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
416pub enum StartupRoot {
417 #[default]
419 Home,
420 Last,
422 Path(String),
424 Bookmarks,
426}
427
428impl StartupRoot {
429 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 StartupRoot::Bookmarks => None,
438 }
439 }
440
441 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
488pub 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 None => {
503 let path = PathBuf::from(path);
504 Some(std::path::absolute(&path).unwrap_or(path))
505 }
506 }
507}
508
509#[derive(Debug, Clone)]
514pub enum ContextMatch {
515 Dir,
517 NoExt,
520 Fallback,
522 Multi,
526 Ext(String),
530 Regex(Regex),
535}
536
537impl ContextMatch {
538 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 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 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 let after_sep = name.rsplit(['/', '\\']).next().unwrap_or("");
590 !after_sep[1..].contains('.')
591 }
592 }
593 ContextMatch::Fallback => true,
594 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
644pub enum BuiltinAction {
645 Open,
647 OpenSplit,
649 InNewPanel,
651 InOppositePanel,
654 OpenWith,
656 OpenWithDefault,
659 ViewThumbnail,
662 NewFile,
664 NewFolder,
666 Rename,
668 Duplicate,
670 CopyPath,
672 CopyRelativePath,
674 AddBookmark,
676 Cut,
678 Copy,
680 Paste,
683 CreateLink,
685 Properties,
687 Trash,
689 DeletePermanently,
691 ToggleHidden,
693 SortByName,
695 SortBySize,
697 SortByModified,
699 SortByType,
701 ToggleSortAscending,
703 OpenFolder,
705 Filter,
707 SplitView,
709 Up,
711 Back,
713 Forward,
715 Collapse,
717 ClosePane,
719 ToggleBookmarks,
721 MoveToPreviousWorkspace,
723 MoveToNextWorkspace,
725 MoveToWorkspace,
728 EditBookmark,
730 DeleteBookmark,
732 NewBookmark,
734 NewBookmarkFolder,
736 Separator,
738}
739
740impl BuiltinAction {
741 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 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
981pub struct CommandAction {
982 pub command: String,
983 #[serde(default, skip_serializing_if = "Option::is_none")]
985 pub label: Option<String>,
986 #[serde(default, skip_serializing_if = "Option::is_none")]
990 pub shortcut: Option<String>,
991 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
994 pub hidden: bool,
995}
996
997#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1000pub struct BuiltinEntry {
1001 pub action: BuiltinAction,
1002 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub label: Option<String>,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1008 pub shortcut: Option<String>,
1009 #[serde(default, skip_serializing_if = "Option::is_none")]
1012 pub workspace: Option<String>,
1013 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1015 pub hidden: bool,
1016}
1017
1018#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1022pub struct Submenu {
1023 pub label: String,
1025 pub items: Vec<ContextAction>,
1027 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1030 pub hidden: bool,
1031}
1032
1033impl Submenu {
1034 pub fn new(label: impl Into<String>, items: Vec<ContextAction>) -> Self {
1036 Self { label: label.into(), items, hidden: false }
1037 }
1038}
1039
1040#[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#[derive(Debug, Clone, PartialEq, Eq)]
1057pub enum WorkspaceMove {
1058 Previous,
1060 Next,
1062 Named(String),
1064}
1065
1066#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1069#[serde(untagged)]
1070pub enum ShortcutTarget {
1071 Builtin(BuiltinAction),
1072 Command(CommandAction),
1073}
1074
1075impl ShortcutTarget {
1076 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 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 pub fn is_submenu(&self) -> bool {
1104 matches!(self, ContextAction::Submenu(_))
1105 }
1106
1107 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 pub fn submenu_items(&self) -> Option<&[ContextAction]> {
1121 match self {
1122 ContextAction::Submenu(sub) => Some(&sub.items),
1123 _ => None,
1124 }
1125 }
1126
1127 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 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
1178 pub include: Option<PathBuf>,
1179}
1180
1181impl ContextRule {
1182 pub fn rule(matches: Vec<ContextMatch>, items: Vec<ContextAction>) -> Self {
1184 ContextRule { matches, items, include: None }
1185 }
1186
1187 pub fn include(path: PathBuf) -> Self {
1189 ContextRule { matches: Vec::new(), items: Vec::new(), include: Some(path) }
1190 }
1191}
1192
1193#[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 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 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 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 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
1274fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1312pub struct PaneMenu {
1313 pub items: Vec<ContextAction>,
1314}
1315
1316impl PaneMenu {
1317 pub fn labels(&self) -> Vec<String> {
1319 self.items.iter().map(ContextAction::label).collect()
1320 }
1321
1322 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1342pub struct BookmarksConfig {
1343 #[serde(default = "default_bookmarks_file")]
1346 pub file: PathBuf,
1347 #[serde(default = "default_bookmarks_menu")]
1350 pub menu: Vec<ContextAction>,
1351 #[serde(default = "default_bookmarks_context")]
1355 pub context: Vec<ContextAction>,
1356 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1371pub struct Bookmark {
1372 pub name: String,
1373 #[serde(default, skip_serializing_if = "Option::is_none")]
1375 pub path: Option<PathBuf>,
1376 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1379 pub items: Vec<Bookmark>,
1380 #[serde(default, skip_serializing_if = "is_false")]
1382 pub expanded: bool,
1383}
1384
1385fn is_false(value: &bool) -> bool {
1387 !*value
1388}
1389
1390impl Bookmark {
1391 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 pub fn leaf(name: String, path: PathBuf) -> Self {
1402 Bookmark { name, path: Some(path), items: Vec::new(), expanded: false }
1403 }
1404
1405 pub fn is_folder(&self) -> bool {
1409 self.path.is_none() || !self.items.is_empty()
1410 }
1411
1412 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 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 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 #[allow(clippy::ptr_arg)] pub fn remove(entries: &mut Vec<Bookmark>, index_path: &[usize]) -> bool {
1445 Bookmark::take(entries, index_path).is_some()
1446 }
1447
1448 #[allow(clippy::ptr_arg)] 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 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#[derive(Debug, Default, Serialize, Deserialize)]
1486struct BookmarksFile {
1487 #[serde(default)]
1488 bookmarks: Vec<Bookmark>,
1489}
1490
1491#[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 fn default() -> Self {
1509 builtin().clone()
1510 }
1511}
1512
1513impl Config {
1514 pub fn load() -> LoadResult {
1523 Self::load_and_ensure(&config_file())
1524 }
1525
1526 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 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 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 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 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#[derive(Debug, Clone)]
1603pub enum LoadProblem {
1604 Io(PathBuf, std::io::ErrorKind),
1605 Parse(PathBuf, toml::de::Error),
1606 SystemTheme(String),
1609}
1610
1611#[derive(Debug, Clone)]
1614pub struct LoadResult {
1615 pub config: Config,
1616 pub problem: Option<LoadProblem>,
1617}
1618
1619#[derive(Debug, Clone)]
1621pub struct Stylesheet {
1622 pub css: String,
1625 pub source: StyleSource,
1627 pub problem: Option<LoadProblem>,
1630}
1631
1632#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1634pub enum StyleSource {
1635 User,
1637 Builtin,
1639 System,
1641}
1642
1643pub fn load_stylesheet(theme: &ThemeConfig) -> Stylesheet {
1651 match theme.mode {
1652 ThemeMode::Custom => {
1653 let path = style_file();
1654 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
1665fn 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
1687fn 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 fn parse(raw: &str, path: &Path) -> LoadResult {
1714 match toml::from_str::<Config>(raw) {
1715 Ok(mut config) => {
1716 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 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
1737const MAX_INCLUDE_DEPTH: usize = 8;
1740
1741pub fn load_bookmarks(config: &BookmarksConfig) -> (Vec<Bookmark>, Option<LoadProblem>) {
1746 load_bookmarks_from_path(&bookmark_file_path(&config.file))
1747}
1748
1749pub 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
1761pub 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
1777fn 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
1800fn 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
1823fn 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
1842pub 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
1852pub 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#[derive(Deserialize)]
1878struct IncludedRules {
1879 #[serde(default)]
1880 context_menu: ContextMenu,
1881}
1882
1883fn 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 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
1934fn 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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1966pub struct SessionState {
1967 pub last_root: Option<PathBuf>,
1969 pub left_width: Option<u32>,
1972 pub right_width: Option<u32>,
1974}
1975
1976impl SessionState {
1977 pub fn load() -> Self {
1980 Self::load_from_path(&state_file())
1981 }
1982
1983 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 pub fn save(&self) -> std::io::Result<()> {
1993 Self::save_to_path(&state_file(), self)
1994 }
1995
1996 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
2014pub 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 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 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 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 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 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 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 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 let written = std::fs::read_to_string(&path).unwrap();
2184 assert_eq!(written, DEFAULT_CONFIG);
2185
2186 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 assert_eq!(
2209 xdg_config_home(Some("/opt/cfg"), Some("/home/u")),
2210 PathBuf::from("/opt/cfg")
2211 );
2212 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 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 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 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 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 for dropped in ["In split view", "In opposite panel"] {
2304 assert!(!file_labels.contains(&dropped.to_owned()), "{dropped} present: {file_labels:?}");
2305 }
2306 assert!(file_labels.contains(&"With Default".to_owned()));
2308 assert!(file_labels.contains(&"With...".to_owned()));
2309 assert!(file_labels.len() > 15);
2310
2311 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 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 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 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 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 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 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 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 assert_eq!(menu.items[0].label(), "Up One Level");
2551 assert_eq!(menu.items[1].shortcut().as_deref(), Some("Ctrl+g"));
2552 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 let plain: One = toml::from_str(r#"item = "Cut""#).unwrap();
2582 assert!(!plain.item.is_hidden());
2583 #[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 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 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 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 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 assert!(
2701 shipped
2702 .context
2703 .iter()
2704 .any(|a| a.shortcut().is_none()
2705 && matches!(a, ContextAction::Builtin(BuiltinAction::EditBookmark)))
2706 );
2707 assert!(
2709 shipped
2710 .blank
2711 .iter()
2712 .any(|a| matches!(a, ContextAction::Builtin(BuiltinAction::NewBookmark)))
2713 );
2714
2715 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 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 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 assert!(Bookmark::remove(&mut list, &[1, 0]));
2791 assert!(!Bookmark::contains_path(&list, Path::new("/b")));
2792 assert!(Bookmark::remove(&mut list, &[1]));
2794 assert_eq!(list.len(), 2);
2795 assert!(Bookmark::remove(&mut list, &[0]));
2797 assert!(!Bookmark::contains_path(&list, Path::new("/a")));
2798 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 assert!(text.starts_with("bookmarks = [\n"), "{text}");
2829 assert!(!text.contains("[[bookmarks]]"), "{text}");
2830 assert!(!text.contains("[[bookmarks.items]]"), "{text}");
2831 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 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 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 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 assert_eq!(Config::default().theme.mode, ThemeMode::Custom);
2884 assert_eq!(Config::default().theme.system, SystemTheme::Omarchy);
2885 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 assert_eq!(Config::default().startup, StartupRoot::Bookmarks);
2903 assert!(
2905 Config::parse("startup = \"bogus\"\n", Path::new("test.toml"))
2906 .problem
2907 .is_some()
2908 );
2909 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 assert!(ContextMatch::parse(r"regex:\.md$").unwrap().matches(&md));
2946 assert!(!ContextMatch::parse(r"regex:\.rs$").unwrap().matches(&md));
2947
2948 let exact = format!("regex:^{}$", regex::escape(dir.path().to_str().unwrap()));
2950 assert!(ContextMatch::parse(&exact).unwrap().matches(dir.path()));
2951
2952 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 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 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 ensure_default_stylesheet(&path).unwrap();
2988 assert_eq!(std::fs::read_to_string(&path).unwrap(), DEFAULT_STYLESHEET);
2989
2990 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 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 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 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 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 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 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 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))), "{:?}", result.problem);
3106 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 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 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 assert_eq!(menu.actions_for_selection(dir.path(), 1), builtin_items![Open]);
3178 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 let single = menu.actions_for_selection(&file, 1);
3198 assert_eq!(single, menu.actions_for(&file));
3199
3200 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 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 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}