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
48fn builtin() -> &'static Config {
58 static ONCE: OnceLock<Config> = OnceLock::new();
59 ONCE.get_or_init(|| {
60 let raw: ShippedRaw = toml::from_str(DEFAULT_CONFIG)
61 .expect("default-config.toml must be valid TOML");
62 Config {
63 panel: raw.panel.unwrap_or_default(),
64 tree: raw.tree.unwrap_or_default(),
65 startup: raw.startup.unwrap_or_default(),
66 context_menu: raw.context_menu.unwrap_or_default(),
67 pane_menu: raw.pane_menu.unwrap_or_default(),
68 bookmarks: raw.bookmarks.unwrap_or_default(),
69 }
70 })
71}
72
73#[derive(Deserialize)]
77struct ShippedRaw {
78 #[serde(default)]
79 panel: Option<PanelConfig>,
80 #[serde(default)]
81 tree: Option<TreeConfig>,
82 #[serde(default)]
83 startup: Option<StartupRoot>,
84 #[serde(default)]
85 context_menu: Option<ContextMenu>,
86 #[serde(default)]
87 pane_menu: Option<PaneMenu>,
88 #[serde(default)]
89 bookmarks: Option<BookmarksConfig>,
90}
91
92fn xdg_config_home(xdg: Option<&str>, home: Option<&str>) -> PathBuf {
101 match xdg.filter(|s| !s.is_empty()) {
102 Some(dir) => PathBuf::from(dir),
103 None => home.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")).join(".config"),
104 }
105}
106
107fn xdg_state_home(xdg: Option<&str>, home: Option<&str>) -> PathBuf {
109 match xdg.filter(|s| !s.is_empty()) {
110 Some(dir) => PathBuf::from(dir),
111 None => home.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")).join(".local/state"),
112 }
113}
114
115fn app_path(base: &Path, file: &str) -> PathBuf {
117 base.join(APP_DIR).join(file)
118}
119
120fn config_home_from_env() -> PathBuf {
123 let xdg = env::var_os("XDG_CONFIG_HOME").map(|v| v.to_string_lossy().into_owned());
124 let home = env::var_os("HOME").map(|v| v.to_string_lossy().into_owned());
125 xdg_config_home(xdg.as_deref(), home.as_deref())
126}
127
128fn state_home_from_env() -> PathBuf {
130 let xdg = env::var_os("XDG_STATE_HOME").map(|v| v.to_string_lossy().into_owned());
131 let home = env::var_os("HOME").map(|v| v.to_string_lossy().into_owned());
132 xdg_state_home(xdg.as_deref(), home.as_deref())
133}
134
135pub fn config_file() -> PathBuf {
137 app_path(&config_home_from_env(), CONFIG_FILE)
138}
139
140pub fn state_file() -> PathBuf {
142 app_path(&state_home_from_env(), STATE_FILE)
143}
144
145pub fn style_file() -> PathBuf {
147 app_path(&config_home_from_env(), STYLE_FILE)
148}
149
150pub fn bookmark_file_path(configured: &Path) -> PathBuf {
154 resolve_bookmarks_path(&config_home_from_env().join(APP_DIR), configured)
155}
156
157fn resolve_bookmarks_path(base: &Path, configured: &Path) -> PathBuf {
159 if configured.is_absolute() { configured.to_path_buf() } else { base.join(configured) }
160}
161
162pub fn expand_bookmark_path(path: &Path) -> PathBuf {
165 match path.to_str().and_then(expand_tilde) {
166 Some(expanded) => expanded,
167 None => path.to_path_buf(),
168 }
169}
170
171pub fn default_bookmarks() -> Vec<Bookmark> {
174 std::env::var_os("HOME")
175 .map(PathBuf::from)
176 .filter(|home| !home.as_os_str().is_empty())
177 .map(|home| vec![Bookmark { name: String::from("Home"), path: home }])
178 .unwrap_or_default()
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
187#[serde(rename_all = "lowercase")]
188pub enum PanelSide {
189 #[default]
190 Left,
191 Right,
192}
193
194impl PanelSide {
195 pub const ALL: [PanelSide; 2] = [PanelSide::Left, PanelSide::Right];
197
198 pub fn opposite(&self) -> PanelSide {
200 match self {
201 PanelSide::Left => PanelSide::Right,
202 PanelSide::Right => PanelSide::Left,
203 }
204 }
205
206 pub fn name(&self) -> &'static str {
208 match self {
209 PanelSide::Left => "left",
210 PanelSide::Right => "right",
211 }
212 }
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
218#[serde(rename_all = "lowercase")]
219pub enum PanelLayer {
220 Background,
221 #[default]
222 Bottom,
223 Top,
224 Overlay,
225}
226
227pub const PANEL_MIN_WIDTH: u32 = 160;
229pub const PANEL_MAX_WIDTH: u32 = 2000;
231
232fn default_panel_side() -> PanelSide {
237 builtin().panel.side
238}
239fn default_panel_layer() -> PanelLayer {
240 builtin().panel.layer
241}
242fn default_panel_width() -> u32 {
243 builtin().panel.width
244}
245fn default_dirs_first() -> bool {
246 builtin().tree.dirs_first
247}
248fn default_sort_key() -> String {
249 builtin().tree.sort_key.clone()
250}
251fn default_sort_ascending() -> bool {
252 builtin().tree.sort_ascending
253}
254fn default_font_size() -> u32 {
255 builtin().tree.font_size
256}
257fn default_icon_size() -> u32 {
258 builtin().tree.icon_size
259}
260fn default_confirm_drop_move() -> bool {
261 builtin().tree.confirm_drop_move
262}
263fn default_bookmarks_file() -> PathBuf {
264 builtin().bookmarks.file.clone()
265}
266fn default_bookmarks_menu() -> Vec<ContextAction> {
267 builtin().bookmarks.menu.clone()
268}
269fn default_bookmarks_context() -> Vec<ContextAction> {
270 builtin().bookmarks.context.clone()
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
277pub struct PanelConfig {
278 #[serde(default = "default_panel_side")]
279 pub side: PanelSide,
280 #[serde(default = "default_panel_layer")]
281 pub layer: PanelLayer,
282 #[serde(default = "default_panel_width")]
284 pub width: u32,
285 #[serde(default)]
287 pub margin: u32,
288}
289
290impl Default for PanelConfig {
291 fn default() -> Self {
292 builtin().panel
293 }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct TreeConfig {
299 #[serde(default = "default_dirs_first")]
303 pub dirs_first: bool,
304 #[serde(default = "default_sort_key")]
306 pub sort_key: String,
307 #[serde(default = "default_sort_ascending")]
309 pub sort_ascending: bool,
310 #[serde(default)]
312 pub show_hidden: bool,
313 #[serde(default = "default_font_size")]
315 pub font_size: u32,
316 #[serde(default = "default_icon_size")]
318 pub icon_size: u32,
319 #[serde(default = "default_confirm_drop_move")]
323 pub confirm_drop_move: bool,
324}
325
326impl Default for TreeConfig {
327 fn default() -> Self {
328 builtin().tree.clone()
329 }
330}
331
332impl TreeConfig {
333 pub fn sort_options(&self) -> crate::fs::SortOptions {
336 crate::fs::SortOptions {
337 dirs_first: self.dirs_first,
338 key: crate::fs::SortKey::parse(&self.sort_key).unwrap_or(crate::fs::SortKey::Name),
339 ascending: self.sort_ascending,
340 }
341 }
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Default)]
351pub enum StartupRoot {
352 #[default]
354 Home,
355 Last,
357 Path(String),
359 Bookmarks,
361}
362
363impl StartupRoot {
364 pub fn resolve(&self, last: Option<PathBuf>) -> Option<PathBuf> {
367 match self {
368 StartupRoot::Last => last,
369 StartupRoot::Home => None,
370 StartupRoot::Path(path) => expand_tilde(path),
371 StartupRoot::Bookmarks => None,
373 }
374 }
375
376 pub fn is_bookmarks(&self) -> bool {
378 matches!(self, StartupRoot::Bookmarks)
379 }
380}
381
382impl Serialize for StartupRoot {
383 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
384 use serde::ser::SerializeMap;
385 match self {
386 StartupRoot::Last => serializer.serialize_str("last"),
387 StartupRoot::Home => serializer.serialize_str("home"),
388 StartupRoot::Bookmarks => serializer.serialize_str("bookmarks"),
389 StartupRoot::Path(path) => {
390 let mut map = serializer.serialize_map(Some(1))?;
391 map.serialize_entry("path", path)?;
392 map.end()
393 }
394 }
395 }
396}
397
398impl<'de> Deserialize<'de> for StartupRoot {
399 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
400 use serde::de::Error as _;
401
402 #[derive(Deserialize)]
403 #[serde(untagged)]
404 enum Repr {
405 Keyword(String),
406 Path { path: String },
407 }
408
409 match Repr::deserialize(deserializer)? {
410 Repr::Keyword(word) => match word.to_ascii_lowercase().as_str() {
411 "last" => Ok(StartupRoot::Last),
412 "home" => Ok(StartupRoot::Home),
413 "bookmarks" => Ok(StartupRoot::Bookmarks),
414 other => Err(D::Error::custom(format!(
415 "unknown startup root {other:?} (expected \"last\", \"home\", \"bookmarks\", or {{ path = \"...\" }})"
416 ))),
417 },
418 Repr::Path { path } => Ok(StartupRoot::Path(path)),
419 }
420 }
421}
422
423pub fn expand_tilde(path: &str) -> Option<PathBuf> {
425 if path.is_empty() {
426 return None;
427 }
428 match path.strip_prefix('~') {
429 Some(rest) => std::env::var_os("HOME").map(|home| {
430 let mut p = PathBuf::from(home);
431 p.push(rest.strip_prefix('/').unwrap_or(rest));
432 p
433 }),
434 None => {
438 let path = PathBuf::from(path);
439 Some(std::path::absolute(&path).unwrap_or(path))
440 }
441 }
442}
443
444#[derive(Debug, Clone)]
449pub enum ContextMatch {
450 Dir,
452 NoExt,
455 Fallback,
457 Multi,
461 Ext(String),
465 Regex(Regex),
470}
471
472impl ContextMatch {
473 pub fn as_str(&self) -> String {
475 match self {
476 ContextMatch::Dir => "dir".to_owned(),
477 ContextMatch::NoExt => "noext".to_owned(),
478 ContextMatch::Fallback => "fallback".to_owned(),
479 ContextMatch::Multi => "multi".to_owned(),
480 ContextMatch::Ext(ext) => format!("ext:{ext}"),
481 ContextMatch::Regex(re) => format!("regex:{}", re.as_str()),
482 }
483 }
484
485 pub fn parse(value: &str) -> Result<Self, String> {
487 let value = value.trim();
488 match value {
489 "dir" | "directory" => Ok(ContextMatch::Dir),
490 "noext" => Ok(ContextMatch::NoExt),
491 "fallback" | "*" | "all" => Ok(ContextMatch::Fallback),
492 "multi" | "multi-select" | "selection" => Ok(ContextMatch::Multi),
493 _ => {
494 if let Some(ext) = value.strip_prefix("ext:") {
495 return Ok(ContextMatch::Ext(ext.to_owned()));
496 }
497 if let Some(pattern) = value.strip_prefix("regex:") {
498 return Regex::new(pattern)
499 .map(ContextMatch::Regex)
500 .map_err(|err| {
501 format!(
502 "invalid regex {pattern:?} in context menu matcher {value:?}: {err}"
503 )
504 });
505 }
506 Err(format!("unknown context menu matcher {value:?}"))
507 }
508 }
509 }
510
511 pub fn matches(&self, path: &Path) -> bool {
514 match self {
515 ContextMatch::Dir => path.is_dir(),
516 ContextMatch::NoExt => {
517 let Some(name) = path.file_name() else {
518 return false;
519 };
520 let name = name.to_string_lossy();
521 !path.is_dir() && !name.ends_with('.') && {
522 let after_sep = name.rsplit(['/', '\\']).next().unwrap_or("");
525 !after_sep[1..].contains('.')
526 }
527 }
528 ContextMatch::Fallback => true,
529 ContextMatch::Multi => false,
531 ContextMatch::Ext(ext) => {
532 if path.is_dir() || ext.is_empty() {
533 return false;
534 }
535 let Some(name) = path.file_name() else {
536 return false;
537 };
538 let name = name.to_string_lossy().to_lowercase();
539 name.trim_start_matches('.').ends_with(&format!(".{}", ext.trim().to_lowercase()))
540 }
541 ContextMatch::Regex(re) => re.is_match(&path.to_string_lossy()),
542 }
543 }
544}
545
546impl PartialEq for ContextMatch {
547 fn eq(&self, other: &Self) -> bool {
548 match (self, other) {
549 (ContextMatch::Dir, ContextMatch::Dir) => true,
550 (ContextMatch::NoExt, ContextMatch::NoExt) => true,
551 (ContextMatch::Fallback, ContextMatch::Fallback) => true,
552 (ContextMatch::Multi, ContextMatch::Multi) => true,
553 (ContextMatch::Ext(a), ContextMatch::Ext(b)) => a == b,
554 (ContextMatch::Regex(a), ContextMatch::Regex(b)) => a.as_str() == b.as_str(),
555 _ => false,
556 }
557 }
558}
559
560impl Eq for ContextMatch {}
561
562impl Serialize for ContextMatch {
563 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
564 serializer.serialize_str(&self.as_str())
565 }
566}
567
568impl<'de> Deserialize<'de> for ContextMatch {
569 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
570 let raw = String::deserialize(deserializer)?;
571 ContextMatch::parse(&raw).map_err(serde::de::Error::custom)
572 }
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
579pub enum BuiltinAction {
580 Open,
582 OpenSplit,
584 InNewPanel,
586 InOppositePanel,
589 OpenWith,
591 OpenWithDefault,
594 ViewThumbnail,
597 NewFile,
599 NewFolder,
601 Rename,
603 Duplicate,
605 CopyPath,
607 CopyRelativePath,
609 AddBookmark,
611 Cut,
613 Copy,
615 Paste,
618 CreateLink,
620 Properties,
622 Trash,
624 DeletePermanently,
626 ToggleHidden,
628 SortByName,
630 SortBySize,
632 SortByModified,
634 SortByType,
636 ToggleSortAscending,
638 OpenFolder,
640 Filter,
642 SplitView,
644 Up,
646 Back,
648 Forward,
650 Collapse,
652 ClosePane,
654 ToggleBookmarks,
656 EditBookmark,
658 DeleteBookmark,
660 Separator,
662}
663
664impl BuiltinAction {
665 pub fn as_str(&self) -> &'static str {
668 match self {
669 BuiltinAction::Open => "Open",
670 BuiltinAction::OpenSplit => "Open in Split View",
671 BuiltinAction::InNewPanel => "In new panel",
672 BuiltinAction::InOppositePanel => "In opposite panel",
673 BuiltinAction::OpenWith => "Open With...",
674 BuiltinAction::OpenWithDefault => "Open With Default",
675 BuiltinAction::ViewThumbnail => "View Thumbnail",
676 BuiltinAction::NewFile => "New File",
677 BuiltinAction::NewFolder => "New Folder",
678 BuiltinAction::Rename => "Rename",
679 BuiltinAction::Duplicate => "Duplicate",
680 BuiltinAction::CopyPath => "Copy Path",
681 BuiltinAction::CopyRelativePath => "Copy Relative Path",
682 BuiltinAction::AddBookmark => "Add Bookmark",
683 BuiltinAction::Cut => "Cut",
684 BuiltinAction::Copy => "Copy",
685 BuiltinAction::Paste => "Paste",
686 BuiltinAction::CreateLink => "Create Link",
687 BuiltinAction::Properties => "Properties",
688 BuiltinAction::Trash => "Move to Trash",
689 BuiltinAction::DeletePermanently => "Delete Permanently",
690 BuiltinAction::ToggleHidden => "Toggle Hidden Files",
691 BuiltinAction::SortByName => "Sort by Name",
692 BuiltinAction::SortBySize => "Sort by Size",
693 BuiltinAction::SortByModified => "Sort by Modified",
694 BuiltinAction::SortByType => "Sort by Type",
695 BuiltinAction::ToggleSortAscending => "Reverse Sort Order",
696 BuiltinAction::OpenFolder => "Open Folder...",
697 BuiltinAction::Filter => "Filter...",
698 BuiltinAction::SplitView => "Split View",
699 BuiltinAction::Up => "Up One Level",
700 BuiltinAction::Back => "Back",
701 BuiltinAction::Forward => "Forward",
702 BuiltinAction::Collapse => "Collapse",
703 BuiltinAction::ClosePane => "Close Pane",
704 BuiltinAction::ToggleBookmarks => "Bookmarks",
705 BuiltinAction::EditBookmark => "Edit Bookmark",
706 BuiltinAction::DeleteBookmark => "Delete Bookmark",
707 BuiltinAction::Separator => "---",
708 }
709 }
710
711 pub fn default_shortcut(&self) -> Option<&'static str> {
714 match self {
715 BuiltinAction::Cut => Some("Ctrl+x"),
716 BuiltinAction::Copy => Some("Ctrl+c"),
717 BuiltinAction::Paste => Some("Ctrl+v"),
718 BuiltinAction::Rename => Some("F2"),
719 BuiltinAction::Duplicate => Some("Ctrl+d"),
720 BuiltinAction::NewFile => Some("Ctrl+n"),
721 BuiltinAction::NewFolder => Some("Ctrl+Shift+n"),
722 BuiltinAction::CreateLink => Some("Ctrl+Shift+m"),
723 BuiltinAction::Trash => Some("Delete"),
724 BuiltinAction::DeletePermanently => Some("Shift+Delete"),
725 BuiltinAction::ViewThumbnail => Some("Ctrl+t"),
726 BuiltinAction::ToggleHidden => Some("Ctrl+h"),
727 BuiltinAction::SortByName => Some("Ctrl+1"),
728 BuiltinAction::SortBySize => Some("Ctrl+2"),
729 BuiltinAction::SortByModified => Some("Ctrl+3"),
730 BuiltinAction::SortByType => Some("Ctrl+4"),
731 BuiltinAction::ToggleSortAscending => Some("Ctrl+Shift+r"),
732 BuiltinAction::SplitView => Some("Ctrl+s"),
733 BuiltinAction::Up => Some("Alt+Up"),
734 BuiltinAction::Back => Some("Alt+Left"),
735 BuiltinAction::Forward => Some("Alt+Right"),
736 BuiltinAction::ClosePane => Some("Ctrl+w"),
737 BuiltinAction::ToggleBookmarks => Some("Ctrl+b"),
738 _ => None,
739 }
740 }
741
742 pub fn is_single_row_only(&self) -> bool {
747 matches!(
748 self,
749 BuiltinAction::Open
750 | BuiltinAction::OpenSplit
751 | BuiltinAction::InNewPanel
752 | BuiltinAction::InOppositePanel
753 | BuiltinAction::OpenWith
754 | BuiltinAction::OpenWithDefault
755 | BuiltinAction::ViewThumbnail
756 | BuiltinAction::NewFile
757 | BuiltinAction::NewFolder
758 | BuiltinAction::Rename
759 | BuiltinAction::Duplicate
760 | BuiltinAction::CreateLink
761 | BuiltinAction::AddBookmark
762 )
763 }
764
765 pub fn is_directory_only(&self) -> bool {
768 matches!(
769 self,
770 BuiltinAction::OpenSplit
771 | BuiltinAction::InNewPanel
772 | BuiltinAction::InOppositePanel
773 | BuiltinAction::AddBookmark
774 )
775 }
776
777 pub fn is_pane_action(&self) -> bool {
781 matches!(
782 self,
783 BuiltinAction::OpenFolder
784 | BuiltinAction::Filter
785 | BuiltinAction::SplitView
786 | BuiltinAction::Up
787 | BuiltinAction::Back
788 | BuiltinAction::Forward
789 | BuiltinAction::Collapse
790 | BuiltinAction::ClosePane
791 | BuiltinAction::ToggleBookmarks
792 )
793 }
794
795 pub fn is_bookmark_only(&self) -> bool {
798 matches!(self, BuiltinAction::EditBookmark | BuiltinAction::DeleteBookmark)
799 }
800
801 pub fn parse(name: &str) -> Option<Self> {
808 let name = name.trim();
809 let name = if name.contains('\u{2026}') {
810 name.replace('\u{2026}', "...")
811 } else {
812 name.to_owned()
813 };
814 let name = name.as_str();
815 [
816 Self::Open,
817 Self::OpenSplit,
818 Self::InNewPanel,
819 Self::InOppositePanel,
820 Self::OpenWith,
821 Self::OpenWithDefault,
822 Self::ViewThumbnail,
823 Self::NewFile,
824 Self::NewFolder,
825 Self::Rename,
826 Self::Duplicate,
827 Self::CopyPath,
828 Self::CopyRelativePath,
829 Self::AddBookmark,
830 Self::Cut,
831 Self::Copy,
832 Self::Paste,
833 Self::CreateLink,
834 Self::Properties,
835 Self::Trash,
836 Self::DeletePermanently,
837 Self::ToggleHidden,
838 Self::SortByName,
839 Self::SortBySize,
840 Self::SortByModified,
841 Self::SortByType,
842 Self::ToggleSortAscending,
843 Self::OpenFolder,
844 Self::Filter,
845 Self::SplitView,
846 Self::Up,
847 Self::Back,
848 Self::Forward,
849 Self::Collapse,
850 Self::ClosePane,
851 Self::ToggleBookmarks,
852 Self::EditBookmark,
853 Self::DeleteBookmark,
854 Self::Separator,
855 ]
856 .into_iter()
857 .find(|a| {
858 a.as_str().eq_ignore_ascii_case(name)
859 || (matches!(a, Self::Separator) && name.eq_ignore_ascii_case("separator"))
860 })
861 }
862}
863
864impl Serialize for BuiltinAction {
865 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
866 serializer.serialize_str(self.as_str())
867 }
868}
869
870impl<'de> Deserialize<'de> for BuiltinAction {
871 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
872 let raw = String::deserialize(deserializer)?;
873 BuiltinAction::parse(&raw).ok_or_else(|| {
874 serde::de::Error::custom(format!("unknown context menu builtin action {raw:?}"))
875 })
876 }
877}
878
879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
884pub struct CommandAction {
885 pub command: String,
886 #[serde(default, skip_serializing_if = "Option::is_none")]
888 pub label: Option<String>,
889 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub shortcut: Option<String>,
894 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
897 pub hidden: bool,
898}
899
900#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
903pub struct BuiltinEntry {
904 pub action: BuiltinAction,
905 #[serde(default, skip_serializing_if = "Option::is_none")]
907 pub label: Option<String>,
908 #[serde(default, skip_serializing_if = "Option::is_none")]
911 pub shortcut: Option<String>,
912 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
914 pub hidden: bool,
915}
916
917#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
921pub struct Submenu {
922 pub label: String,
924 pub items: Vec<ContextAction>,
926 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
929 pub hidden: bool,
930}
931
932impl Submenu {
933 pub fn new(label: impl Into<String>, items: Vec<ContextAction>) -> Self {
935 Self { label: label.into(), items, hidden: false }
936 }
937}
938
939#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
946#[serde(untagged)]
947pub enum ContextAction {
948 Builtin(BuiltinAction),
949 Entry(BuiltinEntry),
950 Command(CommandAction),
951 Submenu(Submenu),
952}
953
954#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
957#[serde(untagged)]
958pub enum ShortcutTarget {
959 Builtin(BuiltinAction),
960 Command(CommandAction),
961}
962
963impl ShortcutTarget {
964 pub fn from_action(action: &ContextAction) -> Option<Self> {
967 Some(match action {
968 ContextAction::Builtin(a) => ShortcutTarget::Builtin(*a),
969 ContextAction::Entry(e) => ShortcutTarget::Builtin(e.action),
970 ContextAction::Command(cmd) => ShortcutTarget::Command(cmd.clone()),
971 ContextAction::Submenu(_) => return None,
972 })
973 }
974}
975
976impl ContextAction {
977 pub fn label(&self) -> String {
980 match self {
981 ContextAction::Builtin(action) => action.as_str().to_owned(),
982 ContextAction::Entry(entry) => {
983 entry.label.clone().unwrap_or_else(|| entry.action.as_str().to_owned())
984 }
985 ContextAction::Command(cmd) => cmd.label.clone().unwrap_or_else(|| cmd.command.clone()),
986 ContextAction::Submenu(sub) => sub.label.clone(),
987 }
988 }
989
990 pub fn is_submenu(&self) -> bool {
992 matches!(self, ContextAction::Submenu(_))
993 }
994
995 pub fn is_hidden(&self) -> bool {
999 match self {
1000 ContextAction::Builtin(_) => false,
1001 ContextAction::Entry(entry) => entry.hidden,
1002 ContextAction::Command(cmd) => cmd.hidden,
1003 ContextAction::Submenu(sub) => sub.hidden,
1004 }
1005 }
1006
1007 pub fn submenu_items(&self) -> Option<&[ContextAction]> {
1009 match self {
1010 ContextAction::Submenu(sub) => Some(&sub.items),
1011 _ => None,
1012 }
1013 }
1014
1015 pub fn shortcut(&self) -> Option<String> {
1018 match self {
1019 ContextAction::Builtin(action) => action
1020 .default_shortcut()
1021 .map(str::to_owned),
1022 ContextAction::Entry(entry) => entry
1023 .shortcut
1024 .clone()
1025 .or_else(|| entry.action.default_shortcut().map(str::to_owned)),
1026 ContextAction::Command(cmd) => cmd.shortcut.clone(),
1027 ContextAction::Submenu(_) => None,
1028 }
1029 }
1030}
1031
1032#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1041pub struct ContextRule {
1042 #[serde(default)]
1043 pub matches: Vec<ContextMatch>,
1044 #[serde(default)]
1045 pub items: Vec<ContextAction>,
1046 #[serde(default, skip_serializing_if = "Option::is_none")]
1049 pub include: Option<PathBuf>,
1050}
1051
1052impl ContextRule {
1053 pub fn rule(matches: Vec<ContextMatch>, items: Vec<ContextAction>) -> Self {
1055 ContextRule { matches, items, include: None }
1056 }
1057
1058 pub fn include(path: PathBuf) -> Self {
1060 ContextRule { matches: Vec::new(), items: Vec::new(), include: Some(path) }
1061 }
1062}
1063
1064#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1069pub struct ContextMenu {
1070 pub rules: Vec<ContextRule>,
1071}
1072
1073#[cfg(test)]
1074macro_rules! builtin_items {
1075 ($($name:ident),* $(,)?) => {
1076 vec![
1077 $(ContextAction::Builtin(BuiltinAction::$name),)*
1078 ]
1079 };
1080}
1081
1082impl ContextMenu {
1083 pub fn fallback_actions(path: &Path) -> Vec<ContextAction> {
1087 let wanted = if path.is_dir() { ContextMatch::Dir } else { ContextMatch::Fallback };
1088 builtin()
1089 .context_menu
1090 .rules
1091 .iter()
1092 .find(|rule| rule.matches.contains(&wanted))
1093 .map(|rule| rule.items.clone())
1094 .unwrap_or_default()
1095 }
1096
1097 pub fn actions_for(&self, path: &Path) -> Vec<ContextAction> {
1103 for rule in &self.rules {
1104 if rule.matches.iter().any(|m| m.matches(path)) {
1105 return rule.items.clone();
1106 }
1107 }
1108 Self::fallback_actions(path)
1109 }
1110
1111 pub fn actions_for_selection(&self, path: &Path, selection_len: usize) -> Vec<ContextAction> {
1118 if selection_len > 1 {
1119 for rule in &self.rules {
1120 if rule.matches.contains(&ContextMatch::Multi) {
1121 return rule.items.clone();
1122 }
1123 }
1124 }
1125 self.actions_for(path)
1126 }
1127
1128 pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
1136 let mut seen = std::collections::HashSet::new();
1137 let mut out = Vec::new();
1138 for rule in &self.rules {
1139 collect_shortcuts(&rule.items, &mut seen, &mut out);
1140 }
1141 out
1142 }
1143}
1144
1145fn collect_shortcuts(
1148 actions: &[ContextAction],
1149 seen: &mut std::collections::HashSet<String>,
1150 out: &mut Vec<(String, ShortcutTarget)>,
1151) {
1152 for action in actions {
1153 if let Some(items) = action.submenu_items() {
1154 collect_shortcuts(items, seen, out);
1155 continue;
1156 }
1157 let Some(shortcut) = action.shortcut() else {
1158 continue;
1159 };
1160 if !seen.insert(shortcut.clone()) {
1161 continue;
1162 }
1163 if let Some(target) = ShortcutTarget::from_action(action) {
1164 out.push((shortcut, target));
1165 }
1166 }
1167}
1168
1169impl Default for ContextMenu {
1170 fn default() -> Self {
1171 builtin().context_menu.clone()
1172 }
1173}
1174
1175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1183pub struct PaneMenu {
1184 pub items: Vec<ContextAction>,
1185}
1186
1187impl PaneMenu {
1188 pub fn labels(&self) -> Vec<String> {
1190 self.items.iter().map(ContextAction::label).collect()
1191 }
1192
1193 pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
1197 let mut seen = std::collections::HashSet::new();
1198 let mut out = Vec::new();
1199 collect_shortcuts(&self.items, &mut seen, &mut out);
1200 out
1201 }
1202}
1203
1204impl Default for PaneMenu {
1205 fn default() -> Self {
1206 builtin().pane_menu.clone()
1207 }
1208}
1209
1210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1213pub struct BookmarksConfig {
1214 #[serde(default = "default_bookmarks_file")]
1217 pub file: PathBuf,
1218 #[serde(default = "default_bookmarks_menu")]
1221 pub menu: Vec<ContextAction>,
1222 #[serde(default = "default_bookmarks_context")]
1226 pub context: Vec<ContextAction>,
1227}
1228
1229impl Default for BookmarksConfig {
1230 fn default() -> Self {
1231 builtin().bookmarks.clone()
1232 }
1233}
1234
1235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1237pub struct Bookmark {
1238 pub name: String,
1239 pub path: PathBuf,
1240}
1241
1242impl Bookmark {
1243 pub fn default_name(path: &Path) -> String {
1246 path.file_name()
1247 .map(|name| name.to_string_lossy().into_owned())
1248 .filter(|name| !name.is_empty())
1249 .unwrap_or_else(|| path.display().to_string())
1250 }
1251}
1252
1253#[derive(Debug, Default, Serialize, Deserialize)]
1255struct BookmarksFile {
1256 #[serde(default)]
1257 bookmarks: Vec<Bookmark>,
1258}
1259
1260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1262#[serde(default)]
1263pub struct Config {
1264 pub panel: PanelConfig,
1265 pub tree: TreeConfig,
1266 pub startup: StartupRoot,
1267 pub context_menu: ContextMenu,
1268 pub pane_menu: PaneMenu,
1269 pub bookmarks: BookmarksConfig,
1270}
1271
1272impl Default for Config {
1273 fn default() -> Self {
1277 builtin().clone()
1278 }
1279}
1280
1281impl Config {
1282 pub fn load() -> LoadResult {
1291 Self::load_and_ensure(&config_file())
1292 }
1293
1294 fn load_and_ensure(path: &Path) -> LoadResult {
1296 let result = Self::load_from_path(path);
1297 if result.problem.is_none()
1298 && !path.exists()
1299 && let Err(problem) = Self::ensure_default_file(path)
1300 {
1301 return LoadResult { config: result.config, problem: Some(problem) };
1302 }
1303 result
1304 }
1305
1306 fn ensure_default_file(path: &Path) -> Result<(), LoadProblem> {
1315 let Some(parent) = path.parent() else {
1316 return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1317 };
1318 if !parent.exists()
1319 && let Err(err) = std::fs::create_dir_all(parent)
1320 {
1321 return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1322 }
1323 if let Ok(metadata) = std::fs::metadata(parent)
1327 && metadata.permissions().readonly()
1328 {
1329 return Ok(());
1330 }
1331 std::fs::write(path, DEFAULT_CONFIG)
1332 .map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1333 }
1334
1335 pub fn load_from_path(path: &Path) -> LoadResult {
1337 let raw = match std::fs::read_to_string(path) {
1338 Ok(raw) => raw,
1339 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1340 return LoadResult {
1341 config: Config::default(),
1342 problem: None,
1343 };
1344 }
1345 Err(err) => {
1346 return LoadResult {
1347 config: Config::default(),
1348 problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
1349 };
1350 }
1351 };
1352 Self::parse(&raw, path)
1353 }
1354
1355 fn normalize(mut self) -> Self {
1357 self.panel.width = self
1358 .panel
1359 .width
1360 .clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
1361 self.panel.margin = self.panel.margin.clamp(0, 200);
1362 self.tree.font_size = self.tree.font_size.clamp(6, 40);
1363 self.tree.icon_size = self.tree.icon_size.clamp(12, 128);
1364 self
1365 }
1366}
1367
1368#[derive(Debug, Clone)]
1371pub enum LoadProblem {
1372 Io(PathBuf, std::io::ErrorKind),
1373 Parse(PathBuf, toml::de::Error),
1374}
1375
1376#[derive(Debug, Clone)]
1379pub struct LoadResult {
1380 pub config: Config,
1381 pub problem: Option<LoadProblem>,
1382}
1383
1384#[derive(Debug, Clone)]
1386pub struct Stylesheet {
1387 pub css: String,
1390 pub source: StyleSource,
1392 pub problem: Option<LoadProblem>,
1395}
1396
1397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1399pub enum StyleSource {
1400 User,
1402 Builtin,
1404}
1405
1406pub fn load_stylesheet() -> Stylesheet {
1412 let path = style_file();
1413 let _ = ensure_default_stylesheet(&path);
1416 load_stylesheet_from_path(&path)
1417}
1418
1419fn load_stylesheet_from_path(path: &Path) -> Stylesheet {
1422 match std::fs::read_to_string(path) {
1423 Ok(css) => Stylesheet {
1424 css,
1425 source: StyleSource::User,
1426 problem: None,
1427 },
1428 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Stylesheet {
1429 css: DEFAULT_STYLESHEET.to_owned(),
1430 source: StyleSource::Builtin,
1431 problem: None,
1432 },
1433 Err(err) => Stylesheet {
1434 css: DEFAULT_STYLESHEET.to_owned(),
1435 source: StyleSource::Builtin,
1436 problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
1437 },
1438 }
1439}
1440
1441fn ensure_default_stylesheet(path: &Path) -> Result<(), LoadProblem> {
1445 if path.exists() {
1446 return Ok(());
1447 }
1448 let Some(parent) = path.parent() else {
1449 return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1450 };
1451 if !parent.exists()
1452 && let Err(err) = std::fs::create_dir_all(parent)
1453 {
1454 return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1455 }
1456 if let Ok(metadata) = std::fs::metadata(parent)
1457 && metadata.permissions().readonly()
1458 {
1459 return Ok(());
1460 }
1461 std::fs::write(path, DEFAULT_STYLESHEET)
1462 .map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1463}
1464
1465impl Config {
1466 fn parse(raw: &str, path: &Path) -> LoadResult {
1468 match toml::from_str::<Config>(raw) {
1469 Ok(mut config) => {
1470 let base = path.parent().unwrap_or_else(|| Path::new("."));
1472 let mut problems = Vec::new();
1473 config.context_menu.rules =
1474 expand_includes(&config.context_menu.rules, base, 0, &mut problems);
1475 let config = config.normalize();
1476 LoadResult {
1477 config,
1478 problem: problems.into_iter().next(),
1481 }
1482 }
1483 Err(err) => LoadResult {
1484 config: Config::default(),
1485 problem: Some(LoadProblem::Parse(path.to_path_buf(), err)),
1486 },
1487 }
1488 }
1489}
1490
1491const MAX_INCLUDE_DEPTH: usize = 8;
1494
1495pub fn load_bookmarks(config: &BookmarksConfig) -> (Vec<Bookmark>, Option<LoadProblem>) {
1500 load_bookmarks_from_path(&bookmark_file_path(&config.file))
1501}
1502
1503pub fn load_bookmarks_from_path(path: &Path) -> (Vec<Bookmark>, Option<LoadProblem>) {
1505 match std::fs::read_to_string(path) {
1506 Ok(text) => match toml::from_str::<BookmarksFile>(&text) {
1507 Ok(file) => (file.bookmarks, None),
1508 Err(err) => (default_bookmarks(), Some(LoadProblem::Parse(path.to_path_buf(), err))),
1509 },
1510 Err(err) if err.kind() == std::io::ErrorKind::NotFound => (default_bookmarks(), None),
1511 Err(err) => (default_bookmarks(), Some(LoadProblem::Io(path.to_path_buf(), err.kind()))),
1512 }
1513}
1514
1515pub fn save_bookmarks_to_path(path: &Path, bookmarks: &[Bookmark]) -> Result<(), LoadProblem> {
1519 if let Some(parent) = path.parent()
1520 && !parent.as_os_str().is_empty()
1521 && let Err(err) = std::fs::create_dir_all(parent)
1522 {
1523 return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1524 }
1525 let body = toml::to_string_pretty(&BookmarksFile { bookmarks: bookmarks.to_vec() })
1526 .map_err(|_| LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::InvalidData))?;
1527 let tmp = path.with_extension("toml.tmp");
1528 std::fs::write(&tmp, body).map_err(|err| LoadProblem::Io(tmp.clone(), err.kind()))?;
1529 std::fs::rename(&tmp, path).map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1530}
1531
1532pub fn ensure_bookmarks_file(
1536 config: &BookmarksConfig,
1537 list: &[Bookmark],
1538) -> Result<(), LoadProblem> {
1539 ensure_bookmarks_at(&bookmark_file_path(&config.file), list)
1540}
1541
1542pub fn ensure_bookmarks_at(path: &Path, list: &[Bookmark]) -> Result<(), LoadProblem> {
1544 if path.exists() {
1545 return Ok(());
1546 }
1547 let Some(parent) = path.parent() else {
1548 return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1549 };
1550 if !parent.as_os_str().is_empty()
1551 && !parent.exists()
1552 && let Err(err) = std::fs::create_dir_all(parent)
1553 {
1554 return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1555 }
1556 if let Ok(metadata) = std::fs::metadata(parent)
1557 && metadata.permissions().readonly()
1558 {
1559 return Ok(());
1560 }
1561 save_bookmarks_to_path(path, list)
1562}
1563
1564
1565#[derive(Deserialize)]
1568struct IncludedRules {
1569 #[serde(default)]
1570 context_menu: ContextMenu,
1571}
1572
1573fn expand_includes(
1583 rules: &[ContextRule],
1584 base: &Path,
1585 depth: usize,
1586 problems: &mut Vec<LoadProblem>,
1587) -> Vec<ContextRule> {
1588 let mut out = Vec::new();
1589 for rule in rules {
1590 let Some(include) = &rule.include else {
1591 out.push(rule.clone());
1592 continue;
1593 };
1594 if depth >= MAX_INCLUDE_DEPTH {
1595 continue;
1596 }
1597 let dir = if include.is_absolute() {
1598 include.clone()
1599 } else {
1600 base.join(include)
1601 };
1602 for file in toml_files_in(&dir, problems) {
1603 let nested_base = file.parent().unwrap_or(base);
1605 match std::fs::read_to_string(&file) {
1606 Ok(text) => match toml::from_str::<IncludedRules>(&text) {
1607 Ok(included) => {
1608 out.extend(expand_includes(
1609 &included.context_menu.rules,
1610 nested_base,
1611 depth + 1,
1612 problems,
1613 ));
1614 }
1615 Err(err) => problems.push(LoadProblem::Parse(file.clone(), err)),
1616 },
1617 Err(err) => problems.push(LoadProblem::Io(file.clone(), err.kind())),
1618 }
1619 }
1620 }
1621 out
1622}
1623
1624fn toml_files_in(dir: &Path, problems: &mut Vec<LoadProblem>) -> Vec<PathBuf> {
1627 let entries = match std::fs::read_dir(dir) {
1628 Ok(entries) => entries,
1629 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
1631 Err(err) => {
1632 problems.push(LoadProblem::Io(dir.to_path_buf(), err.kind()));
1633 return Vec::new();
1634 }
1635 };
1636 let mut files: Vec<PathBuf> = entries
1637 .filter_map(Result::ok)
1638 .map(|entry| entry.path())
1639 .filter(|path| {
1640 path.is_file()
1641 && path
1642 .extension()
1643 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
1644 })
1645 .collect();
1646 files.sort();
1647 files
1648}
1649
1650#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1656pub struct SessionState {
1657 pub last_root: Option<PathBuf>,
1659 pub left_width: Option<u32>,
1662 pub right_width: Option<u32>,
1664}
1665
1666impl SessionState {
1667 pub fn load() -> Self {
1670 Self::load_from_path(&state_file())
1671 }
1672
1673 pub fn load_from_path(path: &Path) -> Self {
1675 match std::fs::read_to_string(path) {
1676 Ok(raw) => toml::from_str(&raw).unwrap_or_default(),
1677 Err(_) => Self::default(),
1678 }
1679 }
1680
1681 pub fn save(&self) -> std::io::Result<()> {
1683 Self::save_to_path(&state_file(), self)
1684 }
1685
1686 pub fn save_to_path(path: &Path, state: &Self) -> std::io::Result<()> {
1692 if let Some(parent) = path.parent() {
1693 std::fs::create_dir_all(parent)?;
1694 }
1695 let body = toml::to_string(state).map_err(|e| {
1696 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
1697 })?;
1698 let tmp = path.with_extension("toml.tmp");
1699 std::fs::write(&tmp, body)?;
1700 std::fs::rename(&tmp, path)
1701 }
1702}
1703
1704pub fn action_command(template: &str, path: &Path) -> Vec<String> {
1711 let path_s = path.to_string_lossy();
1712 let dir = if path.is_dir() {
1713 path.to_string_lossy()
1714 } else {
1715 path.parent().unwrap_or(path).to_string_lossy()
1716 };
1717 let has_path = template.contains("{path}");
1718 let has_dir = template.contains("{dir}");
1719 let mut tokens: Vec<String> = template
1720 .split_whitespace()
1721 .map(|t| t.replace("{path}", &path_s).replace("{dir}", &dir))
1722 .filter(|t| !t.is_empty())
1723 .collect();
1724 if !has_path && !has_dir {
1725 tokens.push(path_s.into_owned());
1726 }
1727 tokens
1728}
1729
1730#[cfg(test)]
1731mod tests {
1732 use super::*;
1733
1734 fn parse(raw: &str) -> Config {
1735 Config::parse(raw, Path::new("test.toml")).config
1736 }
1737
1738 #[test]
1739 fn defaults_are_sane() {
1740 let config = Config::default();
1744 assert!((PANEL_MIN_WIDTH..=PANEL_MAX_WIDTH).contains(&config.panel.width));
1745 assert!(config.tree.icon_size > 0);
1746 assert!(config.tree.font_size > 0);
1747 assert!(!config.context_menu.rules.is_empty());
1748 assert!(!config.pane_menu.items.is_empty());
1749 }
1750
1751 #[test]
1752 fn shipping_default_file_parses_and_is_current() {
1753 assert_eq!(parse(DEFAULT_CONFIG), Config::default());
1757
1758 let written = std::fs::read_to_string(concat!(
1759 env!("CARGO_MANIFEST_DIR"),
1760 "/default-config.toml"
1761 ))
1762 .unwrap();
1763 assert_eq!(DEFAULT_CONFIG, written);
1764 }
1765
1766 #[test]
1767 fn empty_document_uses_defaults() {
1768 let config = parse("");
1769 assert_eq!(config, Config::default());
1770 }
1771
1772 #[test]
1773 fn full_document_round_trips() {
1774 let raw = r#"
1775[panel]
1776side = "right"
1777layer = "top"
1778width = 420
1779margin = 8
1780
1781[tree]
1782dirs_first = false
1783show_hidden = true
1784font_size = 14
1785icon_size = 20
1786"#;
1787 let config = parse(raw);
1788 assert_eq!(config.panel.side, PanelSide::Right);
1789 assert_eq!(config.panel.layer, PanelLayer::Top);
1790 assert_eq!(config.panel.width, 420);
1791 assert!(!config.tree.dirs_first);
1792 assert!(config.tree.show_hidden);
1793
1794 let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
1796 assert_eq!(config, reparsed);
1797 }
1798
1799 #[test]
1800 fn partial_document_is_merged_with_defaults() {
1801 let config = parse("[panel]\nside = \"right\"\n");
1802 assert_eq!(config.panel.side, PanelSide::Right);
1803 assert_eq!(config.panel.width, Config::default().panel.width);
1804 assert_eq!(config.tree, Config::default().tree);
1805 }
1806
1807 #[test]
1808 fn defaults_come_from_the_shipped_file() {
1809 let shipped = parse(DEFAULT_CONFIG);
1812 assert_eq!(Config::default(), shipped);
1813 assert_eq!(PanelConfig::default(), shipped.panel);
1814 assert_eq!(TreeConfig::default(), shipped.tree);
1815 assert_eq!(ContextMenu::default(), shipped.context_menu);
1816 assert_eq!(PaneMenu::default(), shipped.pane_menu);
1817 assert_eq!(BookmarksConfig::default(), shipped.bookmarks);
1818 let partial = parse("[tree]\nfont_size = 21\n");
1820 assert_eq!(partial.tree.font_size, 21);
1821 assert_eq!(partial.tree.icon_size, shipped.tree.icon_size);
1822 assert_eq!(partial.tree.sort_key, shipped.tree.sort_key);
1823 assert_eq!(partial.tree.confirm_drop_move, shipped.tree.confirm_drop_move);
1824 assert!(!shipped.tree.confirm_drop_move);
1826 }
1827
1828 #[test]
1829 fn out_of_range_values_are_clamped() {
1830 let config = parse(
1831 "[panel]\nwidth = 100000\nmargin = 9000\n\n[tree]\nfont_size = 2\nicon_size = 2\n",
1832 );
1833 assert_eq!(config.panel.width, 2000);
1834 assert_eq!(config.panel.margin, 200);
1835 assert_eq!(config.tree.font_size, 6);
1836 assert_eq!(config.tree.icon_size, 12);
1837 }
1838
1839 #[test]
1840 fn invalid_toml_falls_back_to_defaults_with_problem() {
1841 let result = Config::parse("panel = ", Path::new("broken.toml"));
1842 assert_eq!(result.config, Config::default());
1843 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
1844 }
1845
1846 #[test]
1847 fn missing_file_yields_defaults_without_problem() {
1848 let result = Config::load_from_path(Path::new("/definitely/not/here/config.toml"));
1849 assert_eq!(result.config, Config::default());
1850 assert!(result.problem.is_none());
1851 }
1852
1853 #[test]
1854 fn first_launch_materializes_default_config_file() {
1855 let dir = tempfile::tempdir().unwrap();
1856 let path = dir.path().join("tree-space/config.toml");
1857 assert!(!path.exists());
1858
1859 let result = Config::load_and_ensure(&path);
1860 assert_eq!(result.config, Config::default());
1861 assert!(result.problem.is_none());
1862
1863 let written = std::fs::read_to_string(&path).unwrap();
1865 assert_eq!(written, DEFAULT_CONFIG);
1866
1867 let reloaded = Config::load_and_ensure(&path);
1869 assert_eq!(reloaded.config, Config::default());
1870 assert!(reloaded.problem.is_none());
1871 }
1872
1873 #[test]
1874 fn existing_file_is_never_overwritten() {
1875 let dir = tempfile::tempdir().unwrap();
1876 let path = dir.path().join("tree-space/config.toml");
1877 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1878 std::fs::write(&path, "[panel]\nwidth = 500\n").unwrap();
1879
1880 let result = Config::load_and_ensure(&path);
1881 assert_eq!(result.config.panel.width, 500);
1882 assert!(result.problem.is_none());
1883 assert_eq!(std::fs::read_to_string(&path).unwrap(), "[panel]\nwidth = 500\n");
1884 }
1885
1886 #[test]
1887 fn xdg_config_resolution_follows_spec() {
1888 assert_eq!(
1890 xdg_config_home(Some("/opt/cfg"), Some("/home/u")),
1891 PathBuf::from("/opt/cfg")
1892 );
1893 assert_eq!(
1895 xdg_config_home(None, Some("/home/u")),
1896 PathBuf::from("/home/u/.config")
1897 );
1898 assert_eq!(
1899 xdg_state_home(None, Some("/home/u")),
1900 PathBuf::from("/home/u/.local/state")
1901 );
1902 assert_eq!(xdg_config_home(Some(""), Some("/h")), PathBuf::from("/h/.config"));
1904 }
1905
1906 #[test]
1907 fn app_path_joins_app_dir() {
1908 assert_eq!(
1909 app_path(&PathBuf::from("/cfg"), STATE_FILE),
1910 PathBuf::from("/cfg/tree-space/state.toml")
1911 );
1912 }
1913
1914 #[test]
1915 fn session_state_round_trips_through_disk() {
1916 let dir = tempfile::tempdir().unwrap();
1917 let path = dir.path().join("state.toml");
1918 let state = SessionState {
1919 last_root: Some(PathBuf::from("/home/user/projects/foo")),
1920 left_width: Some(420),
1921 right_width: None,
1922 };
1923 SessionState::save_to_path(&path, &state).unwrap();
1924 assert_eq!(SessionState::load_from_path(&path), state);
1925 }
1926
1927 #[test]
1928 fn missing_or_broken_state_defaults() {
1929 let dir = tempfile::tempdir().unwrap();
1930 let broken = dir.path().join("broken.toml");
1931 std::fs::write(&broken, "not [ valid").unwrap();
1932 assert_eq!(SessionState::load_from_path(&broken), SessionState::default());
1933 assert_eq!(
1934 SessionState::load_from_path(&dir.path().join("missing.toml")),
1935 SessionState::default()
1936 );
1937 }
1938
1939 fn all_labels(actions: &[ContextAction]) -> Vec<String> {
1942 let mut out = Vec::new();
1943 for action in actions {
1944 match action.submenu_items() {
1945 Some(items) => out.extend(all_labels(items)),
1946 None => out.push(action.label()),
1947 }
1948 }
1949 out
1950 }
1951
1952 #[test]
1953 fn default_context_menu_reproduces_classic_items() {
1954 let menu = ContextMenu::default();
1955 let dir = tempfile::tempdir().unwrap();
1956 let file = dir.path().join("notes.txt");
1957 std::fs::write(&file, "").unwrap();
1958 std::fs::write(dir.path().join("photo.jpg"), "").unwrap();
1959
1960 let dir_labels = all_labels(&menu.actions_for(dir.path()));
1961 for classic in [
1964 "In same panel",
1965 "In split view",
1966 "Folder",
1967 "File",
1968 "Copy Path",
1969 "Move to Trash",
1970 "Delete Permanently",
1971 ] {
1972 assert!(dir_labels.contains(&classic.to_owned()), "missing {classic}: {dir_labels:?}");
1973 }
1974 for parity in ["In opposite panel", "With...", "Cut", "Copy", "Paste", "Create Link", "Rename", "Properties"] {
1976 assert!(
1977 dir_labels.contains(&parity.to_owned()),
1978 "missing {parity}: {dir_labels:?}"
1979 );
1980 }
1981
1982 let file_labels = all_labels(&menu.actions_for(&file));
1983 for dropped in ["In split view", "In opposite panel"] {
1985 assert!(!file_labels.contains(&dropped.to_owned()), "{dropped} present: {file_labels:?}");
1986 }
1987 assert!(file_labels.contains(&"With Default".to_owned()));
1989 assert!(file_labels.contains(&"With...".to_owned()));
1990 assert!(file_labels.len() > 15);
1991
1992 let menus = menu.actions_for(dir.path());
1994 let cut = menus
1995 .iter()
1996 .flat_map(flatten_actions)
1997 .find(|a| a.label() == "Cut")
1998 .expect("Cut present");
1999 assert_eq!(cut.shortcut().as_deref(), Some("Ctrl+x"));
2000 let trash = menus
2001 .iter()
2002 .flat_map(flatten_actions)
2003 .find(|a| a.label() == "Move to Trash")
2004 .expect("Trash present");
2005 assert_eq!(trash.shortcut().as_deref(), Some("Delete"));
2006 }
2007
2008 fn flatten_actions(action: &ContextAction) -> Vec<&ContextAction> {
2010 match action.submenu_items() {
2011 Some(items) => items.iter().flat_map(flatten_actions).collect(),
2012 None => vec![action],
2013 }
2014 }
2015
2016 #[test]
2017 fn default_shortcuts_follow_conventions() {
2018 assert_eq!(BuiltinAction::Cut.default_shortcut(), Some("Ctrl+x"));
2019 assert_eq!(BuiltinAction::Copy.default_shortcut(), Some("Ctrl+c"));
2020 assert_eq!(BuiltinAction::Rename.default_shortcut(), Some("F2"));
2021 assert_eq!(BuiltinAction::CreateLink.default_shortcut(), Some("Ctrl+Shift+m"));
2022 assert_eq!(BuiltinAction::Trash.default_shortcut(), Some("Delete"));
2023 assert_eq!(BuiltinAction::DeletePermanently.default_shortcut(), Some("Shift+Delete"));
2024 assert_eq!(BuiltinAction::ViewThumbnail.default_shortcut(), Some("Ctrl+t"));
2025 assert_eq!(BuiltinAction::Open.default_shortcut(), None);
2026 assert_eq!(BuiltinAction::Separator.default_shortcut(), None);
2027 }
2028
2029 #[test]
2030 fn shortcuts_are_collected_top_down_and_deduplicated() {
2031 let menu = ContextMenu {
2032 rules: vec![
2033 ContextRule::rule(
2034 vec![ContextMatch::Dir],
2035 vec![
2036 ContextAction::Builtin(BuiltinAction::Open),
2037 ContextAction::Entry(BuiltinEntry {
2038 action: BuiltinAction::Rename,
2039 label: None,
2040 shortcut: Some("F2".into()),
2041 hidden: false,
2042 }),
2043 ContextAction::Command(CommandAction {
2044 command: "code {path}".into(),
2045 label: None,
2046 shortcut: Some("Ctrl+Shift+e".into()),
2047 hidden: false,
2048 }),
2049 ],
2050 ),
2051 ContextRule::rule(
2052 vec![ContextMatch::Fallback],
2053 vec![ContextAction::Builtin(BuiltinAction::Trash)],
2054 ),
2055 ],
2056 };
2057 let shortcuts = menu.shortcuts();
2058 assert_eq!(
2060 shortcuts,
2061 vec![
2062 ("F2".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Rename)),
2063 ("Ctrl+Shift+e".to_owned(), ShortcutTarget::Command(CommandAction {
2064 command: "code {path}".into(),
2065 label: None,
2066 shortcut: Some("Ctrl+Shift+e".into()),
2067 hidden: false,
2068 })),
2069 ("Delete".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Trash)),
2070 ]
2071 );
2072 }
2073
2074 #[test]
2075 fn table_form_spells_out_shortcuts_and_labels() {
2076 let raw = r#"
2077[[context_menu.rules]]
2078matches = ["fallback"]
2079items = [
2080 { action = "Cut", shortcut = "Ctrl+Alt+x", label = "Cut here" },
2081 { command = "sh {path}", shortcut = "Ctrl+Alt+s" },
2082 "---",
2083]
2084"#;
2085 let config = parse(raw);
2086 let actions = &config.context_menu.rules[0].items;
2087 assert_eq!(actions.len(), 3);
2088
2089 match &actions[0] {
2090 ContextAction::Entry(entry) => {
2091 assert_eq!(entry.action, BuiltinAction::Cut);
2092 assert_eq!(entry.shortcut.as_deref(), Some("Ctrl+Alt+x"));
2093 assert_eq!(entry.label.as_deref(), Some("Cut here"));
2094 assert_eq!(actions[0].label(), "Cut here");
2095 assert_eq!(actions[0].shortcut().as_deref(), Some("Ctrl+Alt+x"));
2096 }
2097 other => panic!("expected Entry, got {other:?}"),
2098 }
2099 match &actions[1] {
2100 ContextAction::Command(cmd) => {
2101 assert_eq!(cmd.shortcut.as_deref(), Some("Ctrl+Alt+s"));
2102 }
2103 other => panic!("expected Command, got {other:?}"),
2104 }
2105 match &actions[2] {
2106 ContextAction::Builtin(BuiltinAction::Separator) => {}
2107 other => panic!("expected Separator, got {other:?}"),
2108 }
2109
2110 let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
2111 assert_eq!(config, reparsed);
2112 }
2113
2114 #[test]
2115 fn pane_actions_are_classified() {
2116 for action in [
2117 BuiltinAction::OpenFolder,
2118 BuiltinAction::Filter,
2119 BuiltinAction::SplitView,
2120 BuiltinAction::Collapse,
2121 BuiltinAction::ClosePane,
2122 ] {
2123 assert!(action.is_pane_action(), "{action:?} should be a pane action");
2124 }
2125 for action in [
2126 BuiltinAction::Open,
2127 BuiltinAction::OpenSplit,
2128 BuiltinAction::ToggleHidden,
2129 BuiltinAction::SortByName,
2130 BuiltinAction::Separator,
2131 ] {
2132 assert!(!action.is_pane_action(), "{action:?} is not a pane action");
2133 }
2134 }
2135
2136 #[test]
2137 fn separator_matches_both_spellings() {
2138 assert_eq!(BuiltinAction::parse("---"), Some(BuiltinAction::Separator));
2139 assert_eq!(BuiltinAction::parse("separator"), Some(BuiltinAction::Separator));
2140 assert_eq!(BuiltinAction::parse("SEPARATOR"), Some(BuiltinAction::Separator));
2141 assert_eq!(BuiltinAction::Separator.as_str(), "---");
2142 }
2143
2144 #[test]
2145 fn ellipsis_labels_use_ascii_and_accept_the_typographic_form() {
2146 assert_eq!(BuiltinAction::OpenWith.as_str(), "Open With...");
2148 assert_eq!(BuiltinAction::OpenFolder.as_str(), "Open Folder...");
2149 assert_eq!(BuiltinAction::Filter.as_str(), "Filter...");
2150 assert_eq!(BuiltinAction::parse("Open With…"), Some(BuiltinAction::OpenWith));
2152 assert_eq!(BuiltinAction::parse("Open Folder…"), Some(BuiltinAction::OpenFolder));
2153 assert_eq!(BuiltinAction::parse("Filter…"), Some(BuiltinAction::Filter));
2154 assert_eq!(BuiltinAction::parse("open folder..."), Some(BuiltinAction::OpenFolder));
2156 }
2157
2158 #[test]
2159 fn pane_menu_defaults_include_view_and_pane_actions() {
2160 let menu = PaneMenu::default();
2161 let labels = menu.labels();
2162 assert!(labels.contains(&"Open Folder...".to_owned()));
2163 assert!(labels.contains(&"Split View".to_owned()));
2164 assert!(labels.contains(&"Close Pane".to_owned()));
2165 assert!(labels.contains(&"Toggle Hidden Files".to_owned()));
2166 assert!(labels.contains(&"Sort by Size".to_owned()));
2167 let dir_menu = ContextMenu::default();
2169 let dir_labels: Vec<String> = dir_menu
2170 .rules
2171 .iter()
2172 .flat_map(|rule| rule.items.iter())
2173 .map(ContextAction::label)
2174 .collect();
2175 assert!(!dir_labels.contains(&"Toggle Hidden Files".to_owned()));
2176 }
2177
2178 #[test]
2179 fn pane_menu_shortcuts_are_collected() {
2180 let menu = PaneMenu::default();
2181 let shortcuts = menu.shortcuts();
2182 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+h"));
2183 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+1"));
2184 assert!(shortcuts.iter().all(|(k, _)| !k.is_empty()));
2186 }
2187
2188 #[test]
2189 fn pane_menu_parses_custom_commands() {
2190 let toml = r#"
2191 items = [
2192 "Split View",
2193 "---",
2194 { command = "echo hi", label = "Say hi" },
2195 ]
2196 "#;
2197 let menu: PaneMenu = toml::from_str(toml).unwrap();
2198 assert_eq!(menu.labels(), vec!["Split View", "---", "Say hi"]);
2199 }
2200
2201 #[test]
2202 fn navigation_builtins_round_trip_and_are_pane_actions() {
2203 for action in [BuiltinAction::Up, BuiltinAction::Back, BuiltinAction::Forward] {
2204 let s = action.as_str();
2205 assert_eq!(BuiltinAction::parse(s), Some(action), "{s} should parse");
2206 assert!(action.is_pane_action(), "{s} should be a pane action");
2207 assert!(action.default_shortcut().is_some(), "{s} needs a default key");
2208 }
2209 assert_eq!(BuiltinAction::Up.default_shortcut(), Some("Alt+Up"));
2210 assert_eq!(BuiltinAction::Back.default_shortcut(), Some("Alt+Left"));
2211 assert_eq!(BuiltinAction::Forward.default_shortcut(), Some("Alt+Right"));
2212 }
2213
2214 #[test]
2215 fn hidden_items_parse_and_keep_their_shortcut() {
2216 let toml = r#"
2217 items = [
2218 { action = "Up One Level", hidden = true },
2219 { command = "echo hi", label = "Say hi", shortcut = "Ctrl+g", hidden = true },
2220 { label = "More", hidden = true, items = [ "Copy Path" ] },
2221 "Open",
2222 ]
2223 "#;
2224 let menu: PaneMenu = toml::from_str(toml).unwrap();
2225 assert!(menu.items[0].is_hidden());
2226 assert!(menu.items[1].is_hidden());
2227 assert!(menu.items[2].is_hidden());
2228 assert!(!menu.items[3].is_hidden());
2229 assert_eq!(menu.items[0].label(), "Up One Level");
2231 assert_eq!(menu.items[1].shortcut().as_deref(), Some("Ctrl+g"));
2232 let shortcuts = menu.shortcuts();
2234 assert!(shortcuts.iter().any(|(k, _)| k == "Alt+Up"));
2235 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+g"));
2236 }
2237
2238 #[test]
2239 fn hidden_submenu_children_keep_their_shortcuts() {
2240 let toml = r#"
2241 items = [
2242 { label = "More", hidden = true, items = [
2243 { action = "Rename", shortcut = "Ctrl+Shift+r" },
2244 ] },
2245 ]
2246 "#;
2247 let menu: PaneMenu = toml::from_str(toml).unwrap();
2248 assert!(menu.items[0].is_hidden());
2249 assert!(menu.shortcuts().iter().any(|(k, _)| k == "Ctrl+Shift+r"));
2250 }
2251
2252 #[test]
2253 fn hidden_defaults_to_false_and_is_not_serialized() {
2254 #[derive(serde::Deserialize)]
2255 struct One {
2256 item: ContextAction,
2257 }
2258 let visible: One = toml::from_str(r#"item = { action = "Cut", shortcut = "Ctrl+x" }"#).unwrap();
2259 assert!(!visible.item.is_hidden());
2260 let plain: One = toml::from_str(r#"item = "Cut""#).unwrap();
2262 assert!(!plain.item.is_hidden());
2263 #[derive(serde::Serialize)]
2265 struct OneOut {
2266 item: ContextAction,
2267 }
2268 let encoded = toml::to_string(&OneOut { item: visible.item }).unwrap();
2269 assert!(!encoded.contains("hidden"), "{encoded}");
2270 }
2271
2272 #[test]
2273 fn menu_items_parse_submenus_recursively() {
2274 let toml = r#"
2275 items = [
2276 "Open",
2277 { label = "More", items = [
2278 { action = "Copy Path", shortcut = "Ctrl+Shift+c" },
2279 { label = "Deeper", items = [ { command = "echo hi", label = "Hi" } ] },
2280 ] },
2281 ]
2282 "#;
2283 let menu: PaneMenu = toml::from_str(toml).unwrap();
2284 assert_eq!(menu.labels(), vec!["Open", "More"]);
2285 let sub = &menu.items[1];
2287 assert!(sub.is_submenu());
2288 assert!(sub.shortcut().is_none());
2289 let children = sub.submenu_items().unwrap();
2290 assert_eq!(children.len(), 2);
2291 assert_eq!(children[0].label(), "Copy Path");
2292 assert!(children[1].is_submenu());
2293 let shortcuts = menu.shortcuts();
2295 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
2296 }
2297
2298 #[test]
2299 fn context_menu_collects_nested_shortcuts() {
2300 let raw = r#"
2301 [[context_menu.rules]]
2302 matches = ["dir"]
2303 items = [
2304 "Open",
2305 { label = "More", items = [
2306 { action = "Copy Path", shortcut = "Ctrl+Shift+c" },
2307 ] },
2308 ]
2309 [[context_menu.rules]]
2310 matches = ["fallback"]
2311 items = ["Open"]
2312 "#;
2313 let config = parse(raw);
2314 let shortcuts = config.context_menu.shortcuts();
2315 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
2316 }
2317
2318 #[test]
2319 fn startup_last_uses_session_root() {
2320 let root = StartupRoot::Last;
2321 let last = Some(PathBuf::from("/tmp/last"));
2322 assert_eq!(root.resolve(last.clone()), last);
2323 assert_eq!(root.resolve(None), None);
2324 }
2325
2326 #[test]
2327 fn startup_home_ignores_session_root() {
2328 let root = StartupRoot::Home;
2329 assert_eq!(root.resolve(Some(PathBuf::from("/tmp/last"))), None);
2330 }
2331
2332 #[test]
2333 fn startup_path_uses_configured_directory() {
2334 let root = StartupRoot::Path("/tmp/fixed".to_owned());
2335 assert_eq!(
2336 root.resolve(Some(PathBuf::from("/tmp/last"))),
2337 Some(PathBuf::from("/tmp/fixed"))
2338 );
2339 assert_eq!(StartupRoot::Path(String::new()).resolve(None), None);
2341 }
2342
2343 #[test]
2344 fn startup_bookmarks_selects_the_bookmarks_view() {
2345 let root = parse("startup = \"bookmarks\"\n").startup;
2346 assert_eq!(root, StartupRoot::Bookmarks);
2347 assert!(root.is_bookmarks());
2348 assert_eq!(root.resolve(Some(PathBuf::from("/tmp/last"))), None);
2350 }
2351
2352 #[test]
2353 fn bookmark_builtins_parse_and_classify() {
2354 assert_eq!(BuiltinAction::parse("Add Bookmark"), Some(BuiltinAction::AddBookmark));
2355 assert!(BuiltinAction::AddBookmark.is_directory_only());
2356 assert!(BuiltinAction::AddBookmark.is_single_row_only());
2357 assert_eq!(BuiltinAction::parse("Bookmarks"), Some(BuiltinAction::ToggleBookmarks));
2358 assert!(BuiltinAction::ToggleBookmarks.is_pane_action());
2359 assert_eq!(BuiltinAction::parse("Edit Bookmark"), Some(BuiltinAction::EditBookmark));
2360 assert_eq!(BuiltinAction::parse("Delete Bookmark"), Some(BuiltinAction::DeleteBookmark));
2361 assert!(BuiltinAction::EditBookmark.is_bookmark_only());
2362 assert!(BuiltinAction::DeleteBookmark.is_bookmark_only());
2363 }
2364
2365 #[test]
2366 fn bookmarks_menu_and_context_parse_from_the_shipped_file() {
2367 let shipped = Config::default().bookmarks;
2368 assert!(!shipped.menu.is_empty());
2369 assert!(!shipped.context.is_empty());
2370 assert!(
2372 shipped
2373 .context
2374 .iter()
2375 .any(|a| a.shortcut().is_none()
2376 && matches!(a, ContextAction::Builtin(BuiltinAction::EditBookmark)))
2377 );
2378
2379 let partial = parse("[bookmarks]\nfile = \"bm.toml\"\n").bookmarks;
2381 assert_eq!(partial.menu, shipped.menu);
2382 assert_eq!(partial.context, shipped.context);
2383
2384 let custom = parse(
2386 "[bookmarks]\nmenu = [\"Open Folder...\"]\ncontext = [\"Delete Bookmark\"]\n",
2387 )
2388 .bookmarks;
2389 assert_eq!(custom.menu.len(), 1);
2390 assert_eq!(custom.context.len(), 1);
2391 }
2392
2393 #[test]
2394 fn bookmarks_list_round_trips_through_its_file() {
2395 let dir = tempfile::tempdir().unwrap();
2396 let path = dir.path().join("bookmarks.toml");
2397 let list = vec![
2398 Bookmark { name: "Home".to_owned(), path: PathBuf::from("/home/x") },
2399 Bookmark { name: "Code".to_owned(), path: PathBuf::from("/srv/code") },
2400 ];
2401 save_bookmarks_to_path(&path, &list).unwrap();
2402 let (loaded, problem) = load_bookmarks_from_path(&path);
2403 assert!(problem.is_none());
2404 assert_eq!(loaded, list);
2405 }
2406
2407 #[test]
2408 fn missing_bookmarks_file_falls_back_to_default_without_problem() {
2409 let dir = tempfile::tempdir().unwrap();
2410 let path = dir.path().join("nope.toml");
2411 let (loaded, problem) = load_bookmarks_from_path(&path);
2412 assert!(problem.is_none());
2413 assert_eq!(loaded, default_bookmarks());
2414 }
2415
2416 #[test]
2417 fn ensure_bookmarks_creates_and_never_clobbers() {
2418 let dir = tempfile::tempdir().unwrap();
2419 let path = dir.path().join("bookmarks.toml");
2420 let list = vec![Bookmark { name: "Home".to_owned(), path: PathBuf::from("/home/x") }];
2421 ensure_bookmarks_at(&path, &list).unwrap();
2422 let (loaded, _) = load_bookmarks_from_path(&path);
2423 assert_eq!(loaded, list);
2424 let other = vec![Bookmark { name: "Other".to_owned(), path: PathBuf::from("/tmp") }];
2426 ensure_bookmarks_at(&path, &other).unwrap();
2427 let (loaded, _) = load_bookmarks_from_path(&path);
2428 assert_eq!(loaded, list);
2429 }
2430
2431 #[test]
2432 fn bookmark_name_defaults_to_the_directory_name() {
2433 assert_eq!(Bookmark::default_name(Path::new("/home/eolu/notes")), "notes");
2434 assert_eq!(Bookmark::default_name(Path::new("/")), "/");
2435 }
2436
2437 #[test]
2438 fn startup_tilde_expands_to_home() {
2439 unsafe { std::env::set_var("HOME", "/home/tester") };
2441 let root = StartupRoot::Path("~/notes".to_owned());
2442 assert_eq!(root.resolve(None), Some(PathBuf::from("/home/tester/notes")));
2443 assert_eq!(expand_tilde("~"), Some(PathBuf::from("/home/tester")));
2444 assert_eq!(expand_tilde("/abs/path"), Some(PathBuf::from("/abs/path")));
2445 assert_eq!(expand_tilde(""), None);
2446 let rel = expand_tilde("some/relative/dir").unwrap();
2449 assert!(rel.is_absolute(), "{rel:?} should be absolute");
2450 assert!(rel.ends_with("some/relative/dir"), "{rel:?}");
2451 }
2452
2453 #[test]
2454 fn startup_parses_from_toml() {
2455 assert_eq!(parse("startup = \"home\"\n").startup, StartupRoot::Home);
2456 assert_eq!(parse("startup = \"last\"\n").startup, StartupRoot::Last);
2457 assert_eq!(parse("startup = \"bookmarks\"\n").startup, StartupRoot::Bookmarks);
2458 assert_eq!(
2459 parse("startup = { path = \"/srv\" }\n").startup,
2460 StartupRoot::Path("/srv".to_owned())
2461 );
2462 assert_eq!(Config::default().startup, StartupRoot::Bookmarks);
2464 assert!(
2466 Config::parse("startup = \"bogus\"\n", Path::new("test.toml"))
2467 .problem
2468 .is_some()
2469 );
2470 let cfg = Config { startup: StartupRoot::Path("/srv".to_owned()), ..Config::default() };
2472 let text = toml::to_string(&cfg).unwrap();
2473 assert_eq!(toml::from_str::<Config>(&text).unwrap().startup, cfg.startup);
2474 }
2475
2476 #[test]
2477 fn context_matches_classify_rows() {
2478 let dir = tempfile::tempdir().unwrap();
2479 let file = dir.path().join("report.md");
2480 std::fs::write(&file, "").unwrap();
2481 let noext = dir.path().join("Makefile");
2482 std::fs::write(&noext, "").unwrap();
2483 let tarball = dir.path().join("app.tar.gz");
2484 std::fs::write(&tarball, "").unwrap();
2485
2486 assert!(ContextMatch::parse("dir").unwrap().matches(dir.path()));
2487 assert!(!ContextMatch::parse("dir").unwrap().matches(&file));
2488 assert!(ContextMatch::parse("noext").unwrap().matches(&noext));
2489 assert!(!ContextMatch::parse("noext").unwrap().matches(&file));
2490 assert!(ContextMatch::parse("ext:md").unwrap().matches(&file));
2491 assert!(ContextMatch::parse("ext:MD").unwrap().matches(&file));
2492 assert!(ContextMatch::parse("ext:gz").unwrap().matches(&tarball));
2493 assert!(!ContextMatch::parse("ext:md").unwrap().matches(dir.path()));
2494 assert_eq!(ContextMatch::parse("directory"), Ok(ContextMatch::Dir));
2495 assert_eq!(ContextMatch::parse("*"), Ok(ContextMatch::Fallback));
2496 assert!(ContextMatch::parse("bogus").is_err());
2497 }
2498
2499 #[test]
2500 fn regex_matcher_matches_the_full_path() {
2501 let dir = tempfile::tempdir().unwrap();
2502 let md = dir.path().join("guide.md");
2503 std::fs::write(&md, "").unwrap();
2504
2505 assert!(ContextMatch::parse(r"regex:\.md$").unwrap().matches(&md));
2507 assert!(!ContextMatch::parse(r"regex:\.rs$").unwrap().matches(&md));
2508
2509 let exact = format!("regex:^{}$", regex::escape(dir.path().to_str().unwrap()));
2511 assert!(ContextMatch::parse(&exact).unwrap().matches(dir.path()));
2512
2513 let parsed = ContextMatch::parse(r"regex:\.rs$").unwrap();
2515 assert_eq!(parsed.as_str(), r"regex:\.rs$");
2516 let reparsed = ContextMatch::parse(&parsed.as_str()).unwrap();
2517 assert_eq!(parsed, reparsed);
2518 }
2519
2520 #[test]
2521 fn invalid_regex_is_a_parse_error() {
2522 let err = ContextMatch::parse("regex:(").unwrap_err();
2523 assert!(err.contains("invalid regex"), "got: {err}");
2524 }
2525
2526 #[test]
2527 fn stylesheet_prefers_the_user_file_and_materializes_a_default() {
2528 let dir = tempfile::tempdir().unwrap();
2529 let path = dir.path().join("tree-space/main.css");
2530
2531 let first = load_stylesheet_from_path(&path);
2533 assert_eq!(first.source, StyleSource::Builtin);
2534 assert!(first.problem.is_none());
2535 assert_eq!(first.css, DEFAULT_STYLESHEET);
2536
2537 ensure_default_stylesheet(&path).unwrap();
2539 assert_eq!(std::fs::read_to_string(&path).unwrap(), DEFAULT_STYLESHEET);
2540
2541 std::fs::write(&path, ".tree-row { color: red; }").unwrap();
2543 let user = load_stylesheet_from_path(&path);
2544 assert_eq!(user.source, StyleSource::User);
2545 assert!(user.problem.is_none());
2546 assert_eq!(user.css, ".tree-row { color: red; }");
2547 }
2548
2549 #[test]
2550 fn materialize_never_overwrites_an_existing_stylesheet() {
2551 let dir = tempfile::tempdir().unwrap();
2552 let path = dir.path().join("tree-space/main.css");
2553 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2554 std::fs::write(&path, "/* mine */").unwrap();
2555
2556 ensure_default_stylesheet(&path).unwrap();
2557 assert_eq!(std::fs::read_to_string(&path).unwrap(), "/* mine */");
2558 }
2559
2560 #[test]
2561 fn unreadable_stylesheet_falls_back_to_builtin_with_a_problem() {
2562 let dir = tempfile::tempdir().unwrap();
2563 let path = dir.path().join("main.css");
2566 std::fs::create_dir(&path).unwrap();
2567
2568 let stylesheet = load_stylesheet_from_path(&path);
2569 assert_eq!(stylesheet.source, StyleSource::Builtin);
2570 assert_eq!(stylesheet.css, DEFAULT_STYLESHEET);
2571 assert!(matches!(stylesheet.problem, Some(LoadProblem::Io(..))));
2572 }
2573
2574 #[test]
2575 fn include_rule_splices_files_in_place_and_name_sorted() { let dir = tempfile::tempdir().unwrap();
2576 let rules_dir = dir.path().join("rules.d");
2577 std::fs::create_dir_all(&rules_dir).unwrap();
2578 std::fs::write(
2580 rules_dir.join("20-second.toml"),
2581 "[[context_menu.rules]]\nmatches = [\"ext:b\"]\nitems = [\"Properties\"]\n",
2582 )
2583 .unwrap();
2584 std::fs::write(
2585 rules_dir.join("10-first.toml"),
2586 "[[context_menu.rules]]\nmatches = [\"ext:a\"]\nitems = [\"Properties\", \"Copy Path\"]\n",
2587 )
2588 .unwrap();
2589 std::fs::write(rules_dir.join("README.md"), "not config").unwrap();
2591
2592 let main = dir.path().join("config.toml");
2593 std::fs::write(
2594 &main,
2595 r#"
2596[[context_menu.rules]]
2597matches = ["dir"]
2598items = ["Open"]
2599
2600[[context_menu.rules]]
2601include = "rules.d"
2602
2603[[context_menu.rules]]
2604matches = ["fallback"]
2605items = ["Move to Trash"]
2606"#,
2607 )
2608 .unwrap();
2609
2610 let config = Config::load_from_path(&main);
2611 assert!(config.problem.is_none(), "{:?}", config.problem);
2612 let rules = &config.config.context_menu.rules;
2613 assert_eq!(rules.len(), 4);
2615 assert_eq!(rules[0].matches, vec![ContextMatch::Dir]);
2616 assert_eq!(rules[1].matches, vec![ContextMatch::Ext("a".to_owned())]);
2617 assert_eq!(rules[2].matches, vec![ContextMatch::Ext("b".to_owned())]);
2618 assert_eq!(rules[3].matches, vec![ContextMatch::Fallback]);
2619 assert!(rules.iter().all(|r| r.include.is_none()));
2621 }
2622
2623 #[test]
2624 fn include_of_a_missing_directory_contributes_nothing() {
2625 let dir = tempfile::tempdir().unwrap();
2626 let main = dir.path().join("config.toml");
2627 std::fs::write(
2628 &main,
2629 "[[context_menu.rules]]\ninclude = \"nope.d\"\n\n[[context_menu.rules]]\nmatches = [\"fallback\"]\nitems = [\"Move to Trash\"]\n",
2630 )
2631 .unwrap();
2632 let result = Config::load_from_path(&main);
2633 assert!(result.problem.is_none(), "{:?}", result.problem);
2635 assert_eq!(result.config.context_menu.rules.len(), 1);
2636 assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Fallback]);
2637 }
2638
2639 #[test]
2640 fn include_of_an_invalid_file_is_reported_but_others_still_load() {
2641 let dir = tempfile::tempdir().unwrap();
2642 let rules_dir = dir.path().join("rules.d");
2643 std::fs::create_dir_all(&rules_dir).unwrap();
2644 std::fs::write(rules_dir.join("10-good.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
2645 std::fs::write(rules_dir.join("20-bad.toml"), "this is not = valid toml [").unwrap();
2646
2647 let main = dir.path().join("config.toml");
2648 std::fs::write(
2649 &main,
2650 "[[context_menu.rules]]\ninclude = \"rules.d\"\n",
2651 )
2652 .unwrap();
2653
2654 let result = Config::load_from_path(&main);
2655 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))), "{:?}", result.problem);
2657 assert_eq!(result.config.context_menu.rules.len(), 1);
2659 assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
2660 }
2661
2662 #[test]
2663 fn included_rules_are_expanded_recursively() {
2664 let dir = tempfile::tempdir().unwrap();
2665 let outer = dir.path().join("outer.d");
2666 let inner = outer.join("inner.d");
2667 std::fs::create_dir_all(&inner).unwrap();
2668 std::fs::write(inner.join("nested.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
2669 std::fs::write(
2670 outer.join("mid.toml"),
2671 "[[context_menu.rules]]\ninclude = \"inner.d\"\n",
2672 )
2673 .unwrap();
2674
2675 let main = dir.path().join("config.toml");
2676 std::fs::write(&main, "[[context_menu.rules]]\ninclude = \"outer.d\"\n").unwrap();
2677
2678 let result = Config::load_from_path(&main);
2679 assert!(result.problem.is_none(), "{:?}", result.problem);
2680 assert_eq!(result.config.context_menu.rules.len(), 1);
2681 assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
2682 }
2683
2684 #[test]
2685 fn first_matching_rule_wins() {
2686 let menu = ContextMenu {
2687 rules: vec![
2688 ContextRule::rule(
2689 vec![ContextMatch::Ext("md".to_owned())],
2690 builtin_items![CopyPath],
2691 ),
2692 ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
2693 ],
2694 };
2695 let dir = tempfile::tempdir().unwrap();
2696 let md = dir.path().join("a.md");
2697 std::fs::write(&md, "").unwrap();
2698 let txt = dir.path().join("a.txt");
2699 std::fs::write(&txt, "").unwrap();
2700
2701 assert_eq!(menu.actions_for(&md), builtin_items![CopyPath]);
2702 assert_eq!(menu.actions_for(&txt), builtin_items![Open]);
2704 }
2705
2706 #[test]
2707 fn multi_matcher_parses_and_never_matches_a_single_path() {
2708 assert_eq!(ContextMatch::parse("multi").unwrap(), ContextMatch::Multi);
2709 assert_eq!(ContextMatch::parse("multi-select").unwrap(), ContextMatch::Multi);
2710 assert_eq!(ContextMatch::Multi.as_str(), "multi");
2711 let dir = tempfile::tempdir().unwrap();
2713 assert!(!ContextMatch::Multi.matches(dir.path()));
2714 }
2715
2716 #[test]
2717 fn multi_rule_wins_for_a_multi_selection_only() {
2718 let menu = ContextMenu {
2719 rules: vec![
2720 ContextRule::rule(vec![ContextMatch::Multi], builtin_items![Trash, CopyPath]),
2721 ContextRule::rule(vec![ContextMatch::Dir], builtin_items![Open]),
2722 ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
2723 ],
2724 };
2725 let dir = tempfile::tempdir().unwrap();
2726
2727 assert_eq!(menu.actions_for_selection(dir.path(), 1), builtin_items![Open]);
2729 assert_eq!(
2731 menu.actions_for_selection(dir.path(), 2),
2732 builtin_items![Trash, CopyPath]
2733 );
2734 assert_eq!(
2735 menu.actions_for_selection(dir.path(), 7),
2736 builtin_items![Trash, CopyPath]
2737 );
2738 }
2739
2740 #[test]
2741 fn shipped_multi_rule_offers_selection_actions() {
2742 let menu = ContextMenu::default();
2743 let dir = tempfile::tempdir().unwrap();
2744 let file = dir.path().join("a.txt");
2745 std::fs::write(&file, "").unwrap();
2746
2747 let single = menu.actions_for_selection(&file, 1);
2749 assert_eq!(single, menu.actions_for(&file));
2750
2751 let labels = all_labels(&menu.actions_for_selection(&file, 3));
2754 assert!(labels.contains(&"Move to Trash".to_owned()), "{labels:?}");
2755 assert!(labels.contains(&"Copy Path".to_owned()), "{labels:?}");
2756 assert!(labels.contains(&"Properties".to_owned()), "{labels:?}");
2757 assert!(!labels.contains(&"Rename".to_owned()), "{labels:?}");
2758 assert!(!labels.contains(&"Duplicate".to_owned()), "{labels:?}");
2759 assert!(!labels.contains(&"With...".to_owned()), "{labels:?}");
2760 }
2761
2762 #[test]
2763 fn single_row_only_actions_are_classified() {
2764 assert!(BuiltinAction::Rename.is_single_row_only());
2765 assert!(BuiltinAction::Open.is_single_row_only());
2766 assert!(BuiltinAction::Duplicate.is_single_row_only());
2767 assert!(!BuiltinAction::Trash.is_single_row_only());
2768 assert!(!BuiltinAction::DeletePermanently.is_single_row_only());
2769 assert!(!BuiltinAction::Copy.is_single_row_only());
2770 assert!(!BuiltinAction::Cut.is_single_row_only());
2771 assert!(!BuiltinAction::Properties.is_single_row_only());
2772 assert!(!BuiltinAction::CopyPath.is_single_row_only());
2773 }
2774
2775 #[test]
2776 fn empty_rule_list_falls_back_to_classic_menu() {
2777 let menu = ContextMenu { rules: Vec::new() };
2778 let dir = tempfile::tempdir().unwrap();
2779 assert_eq!(menu.actions_for(dir.path()), ContextMenu::fallback_actions(dir.path()));
2780 }
2781
2782 #[test]
2783 fn custom_context_menu_round_trips() {
2784 let raw = r#"
2785[[context_menu.rules]]
2786matches = ["ext:rs", "ext:toml"]
2787items = ["Open", { command = "cargo fmt", label = "Fmt" }]
2788
2789[[context_menu.rules]]
2790matches = ["dir"]
2791items = [{ command = "foot -D {dir}", label = "Open in Terminal" }, "New Folder"]
2792
2793[[context_menu.rules]]
2794matches = ["noext"]
2795items = [{ command = "sh {path}" }]
2796
2797[[context_menu.rules]]
2798matches = ['regex:.*\.lock$']
2799items = ["Open", { command = "rm {path}", label = "Discard lock" }]
2800"#;
2801 let config = parse(raw);
2802 assert_eq!(config.context_menu.rules.len(), 4);
2803
2804 let first = &config.context_menu.rules[0];
2805 assert_eq!(
2806 first.matches,
2807 vec![ContextMatch::Ext("rs".to_owned()), ContextMatch::Ext("toml".to_owned())]
2808 );
2809 assert!(matches!(first.items[0], ContextAction::Builtin(BuiltinAction::Open)));
2810 assert!(matches!(
2811 &first.items[1],
2812 ContextAction::Command(cmd) if cmd.command == "cargo fmt" && cmd.label.as_deref() == Some("Fmt")
2813 ));
2814
2815 let last = &config.context_menu.rules[3];
2816 assert_eq!(
2817 last.matches,
2818 vec![ContextMatch::parse(r"regex:.*\.lock$").unwrap()]
2819 );
2820
2821 let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
2822 assert_eq!(config, reparsed);
2823 }
2824
2825 #[test]
2826 fn unknown_builtin_action_is_a_parse_problem() {
2827 let result = Config::parse(
2828 "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Frobnicate\"]\n",
2829 Path::new("bad-menu.toml"),
2830 );
2831 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
2832 assert_eq!(result.config, Config::default());
2833 }
2834
2835 #[test]
2836 fn unknown_context_matcher_is_a_parse_problem() {
2837 let result = Config::parse(
2838 "[[context_menu.rules]]\nmatches = [\"gibberish\"]\nitems = [\"Open\"]\n",
2839 Path::new("bad-matcher.toml"),
2840 );
2841 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
2842 assert_eq!(result.config, Config::default());
2843 }
2844
2845 #[test]
2846 fn action_command_substitutes_markers() {
2847 let dir = tempfile::tempdir().unwrap();
2848 let dir_s = dir.path().to_string_lossy().into_owned();
2849 assert_eq!(
2850 action_command("code {path}", dir.path()),
2851 vec!["code".to_owned(), dir_s.clone()]
2852 );
2853 assert_eq!(
2854 action_command("code {dir}", &dir.path().join("x.rs")),
2855 vec!["code".to_owned(), dir_s]
2856 );
2857 }
2858
2859 #[test]
2860 fn action_command_appends_path_without_marker() {
2861 assert_eq!(
2862 action_command("sh", Path::new("/tmp/s.sh")),
2863 vec!["sh".to_owned(), "/tmp/s.sh".to_owned()]
2864 );
2865 }
2866}