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: Bookmark::default_name(&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_show() -> bool {
264 builtin().bookmarks.show
265}
266fn default_bookmarks_position() -> BookmarkPosition {
267 builtin().bookmarks.position
268}
269fn default_bookmarks_file() -> PathBuf {
270 builtin().bookmarks.file.clone()
271}
272
273#[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 Separator,
658}
659
660impl BuiltinAction {
661 pub fn as_str(&self) -> &'static str {
664 match self {
665 BuiltinAction::Open => "Open",
666 BuiltinAction::OpenSplit => "Open in Split View",
667 BuiltinAction::InNewPanel => "In new panel",
668 BuiltinAction::InOppositePanel => "In opposite panel",
669 BuiltinAction::OpenWith => "Open With...",
670 BuiltinAction::OpenWithDefault => "Open With Default",
671 BuiltinAction::ViewThumbnail => "View Thumbnail",
672 BuiltinAction::NewFile => "New File",
673 BuiltinAction::NewFolder => "New Folder",
674 BuiltinAction::Rename => "Rename",
675 BuiltinAction::Duplicate => "Duplicate",
676 BuiltinAction::CopyPath => "Copy Path",
677 BuiltinAction::CopyRelativePath => "Copy Relative Path",
678 BuiltinAction::AddBookmark => "Add Bookmark",
679 BuiltinAction::Cut => "Cut",
680 BuiltinAction::Copy => "Copy",
681 BuiltinAction::Paste => "Paste",
682 BuiltinAction::CreateLink => "Create Link",
683 BuiltinAction::Properties => "Properties",
684 BuiltinAction::Trash => "Move to Trash",
685 BuiltinAction::DeletePermanently => "Delete Permanently",
686 BuiltinAction::ToggleHidden => "Toggle Hidden Files",
687 BuiltinAction::SortByName => "Sort by Name",
688 BuiltinAction::SortBySize => "Sort by Size",
689 BuiltinAction::SortByModified => "Sort by Modified",
690 BuiltinAction::SortByType => "Sort by Type",
691 BuiltinAction::ToggleSortAscending => "Reverse Sort Order",
692 BuiltinAction::OpenFolder => "Open Folder...",
693 BuiltinAction::Filter => "Filter...",
694 BuiltinAction::SplitView => "Split View",
695 BuiltinAction::Up => "Up One Level",
696 BuiltinAction::Back => "Back",
697 BuiltinAction::Forward => "Forward",
698 BuiltinAction::Collapse => "Collapse",
699 BuiltinAction::ClosePane => "Close Pane",
700 BuiltinAction::ToggleBookmarks => "Bookmarks",
701 BuiltinAction::Separator => "---",
702 }
703 }
704
705 pub fn default_shortcut(&self) -> Option<&'static str> {
708 match self {
709 BuiltinAction::Cut => Some("Ctrl+x"),
710 BuiltinAction::Copy => Some("Ctrl+c"),
711 BuiltinAction::Paste => Some("Ctrl+v"),
712 BuiltinAction::Rename => Some("F2"),
713 BuiltinAction::Duplicate => Some("Ctrl+d"),
714 BuiltinAction::NewFile => Some("Ctrl+n"),
715 BuiltinAction::NewFolder => Some("Ctrl+Shift+n"),
716 BuiltinAction::CreateLink => Some("Ctrl+Shift+m"),
717 BuiltinAction::Trash => Some("Delete"),
718 BuiltinAction::DeletePermanently => Some("Shift+Delete"),
719 BuiltinAction::ViewThumbnail => Some("Ctrl+t"),
720 BuiltinAction::ToggleHidden => Some("Ctrl+h"),
721 BuiltinAction::SortByName => Some("Ctrl+1"),
722 BuiltinAction::SortBySize => Some("Ctrl+2"),
723 BuiltinAction::SortByModified => Some("Ctrl+3"),
724 BuiltinAction::SortByType => Some("Ctrl+4"),
725 BuiltinAction::ToggleSortAscending => Some("Ctrl+Shift+r"),
726 BuiltinAction::SplitView => Some("Ctrl+s"),
727 BuiltinAction::Up => Some("Alt+Up"),
728 BuiltinAction::Back => Some("Alt+Left"),
729 BuiltinAction::Forward => Some("Alt+Right"),
730 BuiltinAction::ClosePane => Some("Ctrl+w"),
731 BuiltinAction::ToggleBookmarks => Some("Ctrl+b"),
732 _ => None,
733 }
734 }
735
736 pub fn is_single_row_only(&self) -> bool {
741 matches!(
742 self,
743 BuiltinAction::Open
744 | BuiltinAction::OpenSplit
745 | BuiltinAction::InNewPanel
746 | BuiltinAction::InOppositePanel
747 | BuiltinAction::OpenWith
748 | BuiltinAction::OpenWithDefault
749 | BuiltinAction::ViewThumbnail
750 | BuiltinAction::NewFile
751 | BuiltinAction::NewFolder
752 | BuiltinAction::Rename
753 | BuiltinAction::Duplicate
754 | BuiltinAction::CreateLink
755 | BuiltinAction::AddBookmark
756 )
757 }
758
759 pub fn is_directory_only(&self) -> bool {
762 matches!(
763 self,
764 BuiltinAction::OpenSplit
765 | BuiltinAction::InNewPanel
766 | BuiltinAction::InOppositePanel
767 | BuiltinAction::AddBookmark
768 )
769 }
770
771 pub fn is_pane_action(&self) -> bool {
775 matches!(
776 self,
777 BuiltinAction::OpenFolder
778 | BuiltinAction::Filter
779 | BuiltinAction::SplitView
780 | BuiltinAction::Up
781 | BuiltinAction::Back
782 | BuiltinAction::Forward
783 | BuiltinAction::Collapse
784 | BuiltinAction::ClosePane
785 | BuiltinAction::ToggleBookmarks
786 )
787 }
788
789 pub fn parse(name: &str) -> Option<Self> {
796 let name = name.trim();
797 let name = if name.contains('\u{2026}') {
798 name.replace('\u{2026}', "...")
799 } else {
800 name.to_owned()
801 };
802 let name = name.as_str();
803 [
804 Self::Open,
805 Self::OpenSplit,
806 Self::InNewPanel,
807 Self::InOppositePanel,
808 Self::OpenWith,
809 Self::OpenWithDefault,
810 Self::ViewThumbnail,
811 Self::NewFile,
812 Self::NewFolder,
813 Self::Rename,
814 Self::Duplicate,
815 Self::CopyPath,
816 Self::CopyRelativePath,
817 Self::AddBookmark,
818 Self::Cut,
819 Self::Copy,
820 Self::Paste,
821 Self::CreateLink,
822 Self::Properties,
823 Self::Trash,
824 Self::DeletePermanently,
825 Self::ToggleHidden,
826 Self::SortByName,
827 Self::SortBySize,
828 Self::SortByModified,
829 Self::SortByType,
830 Self::ToggleSortAscending,
831 Self::OpenFolder,
832 Self::Filter,
833 Self::SplitView,
834 Self::Up,
835 Self::Back,
836 Self::Forward,
837 Self::Collapse,
838 Self::ClosePane,
839 Self::ToggleBookmarks,
840 Self::Separator,
841 ]
842 .into_iter()
843 .find(|a| {
844 a.as_str().eq_ignore_ascii_case(name)
845 || (matches!(a, Self::Separator) && name.eq_ignore_ascii_case("separator"))
846 })
847 }
848}
849
850impl Serialize for BuiltinAction {
851 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
852 serializer.serialize_str(self.as_str())
853 }
854}
855
856impl<'de> Deserialize<'de> for BuiltinAction {
857 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
858 let raw = String::deserialize(deserializer)?;
859 BuiltinAction::parse(&raw).ok_or_else(|| {
860 serde::de::Error::custom(format!("unknown context menu builtin action {raw:?}"))
861 })
862 }
863}
864
865#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
870pub struct CommandAction {
871 pub command: String,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub label: Option<String>,
875 #[serde(default, skip_serializing_if = "Option::is_none")]
879 pub shortcut: Option<String>,
880 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
883 pub hidden: bool,
884}
885
886#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
889pub struct BuiltinEntry {
890 pub action: BuiltinAction,
891 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub label: Option<String>,
894 #[serde(default, skip_serializing_if = "Option::is_none")]
897 pub shortcut: Option<String>,
898 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
900 pub hidden: bool,
901}
902
903#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
907pub struct Submenu {
908 pub label: String,
910 pub items: Vec<ContextAction>,
912 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
915 pub hidden: bool,
916}
917
918impl Submenu {
919 pub fn new(label: impl Into<String>, items: Vec<ContextAction>) -> Self {
921 Self { label: label.into(), items, hidden: false }
922 }
923}
924
925#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
932#[serde(untagged)]
933pub enum ContextAction {
934 Builtin(BuiltinAction),
935 Entry(BuiltinEntry),
936 Command(CommandAction),
937 Submenu(Submenu),
938}
939
940#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
943#[serde(untagged)]
944pub enum ShortcutTarget {
945 Builtin(BuiltinAction),
946 Command(CommandAction),
947}
948
949impl ShortcutTarget {
950 pub fn from_action(action: &ContextAction) -> Option<Self> {
953 Some(match action {
954 ContextAction::Builtin(a) => ShortcutTarget::Builtin(*a),
955 ContextAction::Entry(e) => ShortcutTarget::Builtin(e.action),
956 ContextAction::Command(cmd) => ShortcutTarget::Command(cmd.clone()),
957 ContextAction::Submenu(_) => return None,
958 })
959 }
960}
961
962impl ContextAction {
963 pub fn label(&self) -> String {
966 match self {
967 ContextAction::Builtin(action) => action.as_str().to_owned(),
968 ContextAction::Entry(entry) => {
969 entry.label.clone().unwrap_or_else(|| entry.action.as_str().to_owned())
970 }
971 ContextAction::Command(cmd) => cmd.label.clone().unwrap_or_else(|| cmd.command.clone()),
972 ContextAction::Submenu(sub) => sub.label.clone(),
973 }
974 }
975
976 pub fn is_submenu(&self) -> bool {
978 matches!(self, ContextAction::Submenu(_))
979 }
980
981 pub fn is_hidden(&self) -> bool {
985 match self {
986 ContextAction::Builtin(_) => false,
987 ContextAction::Entry(entry) => entry.hidden,
988 ContextAction::Command(cmd) => cmd.hidden,
989 ContextAction::Submenu(sub) => sub.hidden,
990 }
991 }
992
993 pub fn submenu_items(&self) -> Option<&[ContextAction]> {
995 match self {
996 ContextAction::Submenu(sub) => Some(&sub.items),
997 _ => None,
998 }
999 }
1000
1001 pub fn shortcut(&self) -> Option<String> {
1004 match self {
1005 ContextAction::Builtin(action) => action
1006 .default_shortcut()
1007 .map(str::to_owned),
1008 ContextAction::Entry(entry) => entry
1009 .shortcut
1010 .clone()
1011 .or_else(|| entry.action.default_shortcut().map(str::to_owned)),
1012 ContextAction::Command(cmd) => cmd.shortcut.clone(),
1013 ContextAction::Submenu(_) => None,
1014 }
1015 }
1016}
1017
1018#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1027pub struct ContextRule {
1028 #[serde(default)]
1029 pub matches: Vec<ContextMatch>,
1030 #[serde(default)]
1031 pub items: Vec<ContextAction>,
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1035 pub include: Option<PathBuf>,
1036}
1037
1038impl ContextRule {
1039 pub fn rule(matches: Vec<ContextMatch>, items: Vec<ContextAction>) -> Self {
1041 ContextRule { matches, items, include: None }
1042 }
1043
1044 pub fn include(path: PathBuf) -> Self {
1046 ContextRule { matches: Vec::new(), items: Vec::new(), include: Some(path) }
1047 }
1048}
1049
1050#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1055pub struct ContextMenu {
1056 pub rules: Vec<ContextRule>,
1057}
1058
1059#[cfg(test)]
1060macro_rules! builtin_items {
1061 ($($name:ident),* $(,)?) => {
1062 vec![
1063 $(ContextAction::Builtin(BuiltinAction::$name),)*
1064 ]
1065 };
1066}
1067
1068impl ContextMenu {
1069 pub fn fallback_actions(path: &Path) -> Vec<ContextAction> {
1073 let wanted = if path.is_dir() { ContextMatch::Dir } else { ContextMatch::Fallback };
1074 builtin()
1075 .context_menu
1076 .rules
1077 .iter()
1078 .find(|rule| rule.matches.contains(&wanted))
1079 .map(|rule| rule.items.clone())
1080 .unwrap_or_default()
1081 }
1082
1083 pub fn actions_for(&self, path: &Path) -> Vec<ContextAction> {
1089 for rule in &self.rules {
1090 if rule.matches.iter().any(|m| m.matches(path)) {
1091 return rule.items.clone();
1092 }
1093 }
1094 Self::fallback_actions(path)
1095 }
1096
1097 pub fn actions_for_selection(&self, path: &Path, selection_len: usize) -> Vec<ContextAction> {
1104 if selection_len > 1 {
1105 for rule in &self.rules {
1106 if rule.matches.contains(&ContextMatch::Multi) {
1107 return rule.items.clone();
1108 }
1109 }
1110 }
1111 self.actions_for(path)
1112 }
1113
1114 pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
1122 let mut seen = std::collections::HashSet::new();
1123 let mut out = Vec::new();
1124 for rule in &self.rules {
1125 collect_shortcuts(&rule.items, &mut seen, &mut out);
1126 }
1127 out
1128 }
1129}
1130
1131fn collect_shortcuts(
1134 actions: &[ContextAction],
1135 seen: &mut std::collections::HashSet<String>,
1136 out: &mut Vec<(String, ShortcutTarget)>,
1137) {
1138 for action in actions {
1139 if let Some(items) = action.submenu_items() {
1140 collect_shortcuts(items, seen, out);
1141 continue;
1142 }
1143 let Some(shortcut) = action.shortcut() else {
1144 continue;
1145 };
1146 if !seen.insert(shortcut.clone()) {
1147 continue;
1148 }
1149 if let Some(target) = ShortcutTarget::from_action(action) {
1150 out.push((shortcut, target));
1151 }
1152 }
1153}
1154
1155impl Default for ContextMenu {
1156 fn default() -> Self {
1157 builtin().context_menu.clone()
1158 }
1159}
1160
1161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1169pub struct PaneMenu {
1170 pub items: Vec<ContextAction>,
1171}
1172
1173impl PaneMenu {
1174 pub fn labels(&self) -> Vec<String> {
1176 self.items.iter().map(ContextAction::label).collect()
1177 }
1178
1179 pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
1183 let mut seen = std::collections::HashSet::new();
1184 let mut out = Vec::new();
1185 collect_shortcuts(&self.items, &mut seen, &mut out);
1186 out
1187 }
1188}
1189
1190impl Default for PaneMenu {
1191 fn default() -> Self {
1192 builtin().pane_menu.clone()
1193 }
1194}
1195
1196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1198#[serde(rename_all = "lowercase")]
1199pub enum BookmarkPosition {
1200 #[default]
1202 Top,
1203 Bottom,
1205}
1206
1207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1210pub struct BookmarksConfig {
1211 #[serde(default = "default_bookmarks_show")]
1214 pub show: bool,
1215 #[serde(default = "default_bookmarks_position")]
1217 pub position: BookmarkPosition,
1218 #[serde(default = "default_bookmarks_file")]
1221 pub file: PathBuf,
1222}
1223
1224impl Default for BookmarksConfig {
1225 fn default() -> Self {
1226 builtin().bookmarks.clone()
1227 }
1228}
1229
1230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1232pub struct Bookmark {
1233 pub name: String,
1234 pub path: PathBuf,
1235}
1236
1237impl Bookmark {
1238 pub fn default_name(path: &Path) -> String {
1241 path.file_name()
1242 .map(|name| name.to_string_lossy().into_owned())
1243 .filter(|name| !name.is_empty())
1244 .unwrap_or_else(|| path.display().to_string())
1245 }
1246}
1247
1248#[derive(Debug, Default, Serialize, Deserialize)]
1250struct BookmarksFile {
1251 #[serde(default)]
1252 bookmarks: Vec<Bookmark>,
1253}
1254
1255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1257#[serde(default)]
1258pub struct Config {
1259 pub panel: PanelConfig,
1260 pub tree: TreeConfig,
1261 pub startup: StartupRoot,
1262 pub context_menu: ContextMenu,
1263 pub pane_menu: PaneMenu,
1264 pub bookmarks: BookmarksConfig,
1265}
1266
1267impl Default for Config {
1268 fn default() -> Self {
1272 builtin().clone()
1273 }
1274}
1275
1276impl Config {
1277 pub fn load() -> LoadResult {
1286 Self::load_and_ensure(&config_file())
1287 }
1288
1289 fn load_and_ensure(path: &Path) -> LoadResult {
1291 let result = Self::load_from_path(path);
1292 if result.problem.is_none()
1293 && !path.exists()
1294 && let Err(problem) = Self::ensure_default_file(path)
1295 {
1296 return LoadResult { config: result.config, problem: Some(problem) };
1297 }
1298 result
1299 }
1300
1301 fn ensure_default_file(path: &Path) -> Result<(), LoadProblem> {
1310 let Some(parent) = path.parent() else {
1311 return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1312 };
1313 if !parent.exists()
1314 && let Err(err) = std::fs::create_dir_all(parent)
1315 {
1316 return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1317 }
1318 if let Ok(metadata) = std::fs::metadata(parent)
1322 && metadata.permissions().readonly()
1323 {
1324 return Ok(());
1325 }
1326 std::fs::write(path, DEFAULT_CONFIG)
1327 .map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1328 }
1329
1330 pub fn load_from_path(path: &Path) -> LoadResult {
1332 let raw = match std::fs::read_to_string(path) {
1333 Ok(raw) => raw,
1334 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1335 return LoadResult {
1336 config: Config::default(),
1337 problem: None,
1338 };
1339 }
1340 Err(err) => {
1341 return LoadResult {
1342 config: Config::default(),
1343 problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
1344 };
1345 }
1346 };
1347 Self::parse(&raw, path)
1348 }
1349
1350 fn normalize(mut self) -> Self {
1352 self.panel.width = self
1353 .panel
1354 .width
1355 .clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
1356 self.panel.margin = self.panel.margin.clamp(0, 200);
1357 self.tree.font_size = self.tree.font_size.clamp(6, 40);
1358 self.tree.icon_size = self.tree.icon_size.clamp(12, 128);
1359 self
1360 }
1361}
1362
1363#[derive(Debug, Clone)]
1366pub enum LoadProblem {
1367 Io(PathBuf, std::io::ErrorKind),
1368 Parse(PathBuf, toml::de::Error),
1369}
1370
1371#[derive(Debug, Clone)]
1374pub struct LoadResult {
1375 pub config: Config,
1376 pub problem: Option<LoadProblem>,
1377}
1378
1379#[derive(Debug, Clone)]
1381pub struct Stylesheet {
1382 pub css: String,
1385 pub source: StyleSource,
1387 pub problem: Option<LoadProblem>,
1390}
1391
1392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1394pub enum StyleSource {
1395 User,
1397 Builtin,
1399}
1400
1401pub fn load_stylesheet() -> Stylesheet {
1407 let path = style_file();
1408 let _ = ensure_default_stylesheet(&path);
1411 load_stylesheet_from_path(&path)
1412}
1413
1414fn load_stylesheet_from_path(path: &Path) -> Stylesheet {
1417 match std::fs::read_to_string(path) {
1418 Ok(css) => Stylesheet {
1419 css,
1420 source: StyleSource::User,
1421 problem: None,
1422 },
1423 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Stylesheet {
1424 css: DEFAULT_STYLESHEET.to_owned(),
1425 source: StyleSource::Builtin,
1426 problem: None,
1427 },
1428 Err(err) => Stylesheet {
1429 css: DEFAULT_STYLESHEET.to_owned(),
1430 source: StyleSource::Builtin,
1431 problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
1432 },
1433 }
1434}
1435
1436fn ensure_default_stylesheet(path: &Path) -> Result<(), LoadProblem> {
1440 if path.exists() {
1441 return Ok(());
1442 }
1443 let Some(parent) = path.parent() else {
1444 return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1445 };
1446 if !parent.exists()
1447 && let Err(err) = std::fs::create_dir_all(parent)
1448 {
1449 return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1450 }
1451 if let Ok(metadata) = std::fs::metadata(parent)
1452 && metadata.permissions().readonly()
1453 {
1454 return Ok(());
1455 }
1456 std::fs::write(path, DEFAULT_STYLESHEET)
1457 .map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1458}
1459
1460impl Config {
1461 fn parse(raw: &str, path: &Path) -> LoadResult {
1463 match toml::from_str::<Config>(raw) {
1464 Ok(mut config) => {
1465 let base = path.parent().unwrap_or_else(|| Path::new("."));
1467 let mut problems = Vec::new();
1468 config.context_menu.rules =
1469 expand_includes(&config.context_menu.rules, base, 0, &mut problems);
1470 let config = config.normalize();
1471 LoadResult {
1472 config,
1473 problem: problems.into_iter().next(),
1476 }
1477 }
1478 Err(err) => LoadResult {
1479 config: Config::default(),
1480 problem: Some(LoadProblem::Parse(path.to_path_buf(), err)),
1481 },
1482 }
1483 }
1484}
1485
1486const MAX_INCLUDE_DEPTH: usize = 8;
1489
1490pub fn load_bookmarks(config: &BookmarksConfig) -> (Vec<Bookmark>, Option<LoadProblem>) {
1495 load_bookmarks_from_path(&bookmark_file_path(&config.file))
1496}
1497
1498pub fn load_bookmarks_from_path(path: &Path) -> (Vec<Bookmark>, Option<LoadProblem>) {
1500 match std::fs::read_to_string(path) {
1501 Ok(text) => match toml::from_str::<BookmarksFile>(&text) {
1502 Ok(file) => (file.bookmarks, None),
1503 Err(err) => (default_bookmarks(), Some(LoadProblem::Parse(path.to_path_buf(), err))),
1504 },
1505 Err(err) if err.kind() == std::io::ErrorKind::NotFound => (default_bookmarks(), None),
1506 Err(err) => (default_bookmarks(), Some(LoadProblem::Io(path.to_path_buf(), err.kind()))),
1507 }
1508}
1509
1510pub fn save_bookmarks_to_path(path: &Path, bookmarks: &[Bookmark]) -> Result<(), LoadProblem> {
1514 if let Some(parent) = path.parent()
1515 && !parent.as_os_str().is_empty()
1516 && let Err(err) = std::fs::create_dir_all(parent)
1517 {
1518 return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1519 }
1520 let body = toml::to_string_pretty(&BookmarksFile { bookmarks: bookmarks.to_vec() })
1521 .map_err(|_| LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::InvalidData))?;
1522 let tmp = path.with_extension("toml.tmp");
1523 std::fs::write(&tmp, body).map_err(|err| LoadProblem::Io(tmp.clone(), err.kind()))?;
1524 std::fs::rename(&tmp, path).map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1525}
1526
1527pub fn ensure_bookmarks_file(
1531 config: &BookmarksConfig,
1532 list: &[Bookmark],
1533) -> Result<(), LoadProblem> {
1534 ensure_bookmarks_at(&bookmark_file_path(&config.file), list)
1535}
1536
1537pub fn ensure_bookmarks_at(path: &Path, list: &[Bookmark]) -> Result<(), LoadProblem> {
1539 if path.exists() {
1540 return Ok(());
1541 }
1542 let Some(parent) = path.parent() else {
1543 return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1544 };
1545 if !parent.as_os_str().is_empty()
1546 && !parent.exists()
1547 && let Err(err) = std::fs::create_dir_all(parent)
1548 {
1549 return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1550 }
1551 if let Ok(metadata) = std::fs::metadata(parent)
1552 && metadata.permissions().readonly()
1553 {
1554 return Ok(());
1555 }
1556 save_bookmarks_to_path(path, list)
1557}
1558
1559
1560#[derive(Deserialize)]
1563struct IncludedRules {
1564 #[serde(default)]
1565 context_menu: ContextMenu,
1566}
1567
1568fn expand_includes(
1578 rules: &[ContextRule],
1579 base: &Path,
1580 depth: usize,
1581 problems: &mut Vec<LoadProblem>,
1582) -> Vec<ContextRule> {
1583 let mut out = Vec::new();
1584 for rule in rules {
1585 let Some(include) = &rule.include else {
1586 out.push(rule.clone());
1587 continue;
1588 };
1589 if depth >= MAX_INCLUDE_DEPTH {
1590 continue;
1591 }
1592 let dir = if include.is_absolute() {
1593 include.clone()
1594 } else {
1595 base.join(include)
1596 };
1597 for file in toml_files_in(&dir, problems) {
1598 let nested_base = file.parent().unwrap_or(base);
1600 match std::fs::read_to_string(&file) {
1601 Ok(text) => match toml::from_str::<IncludedRules>(&text) {
1602 Ok(included) => {
1603 out.extend(expand_includes(
1604 &included.context_menu.rules,
1605 nested_base,
1606 depth + 1,
1607 problems,
1608 ));
1609 }
1610 Err(err) => problems.push(LoadProblem::Parse(file.clone(), err)),
1611 },
1612 Err(err) => problems.push(LoadProblem::Io(file.clone(), err.kind())),
1613 }
1614 }
1615 }
1616 out
1617}
1618
1619fn toml_files_in(dir: &Path, problems: &mut Vec<LoadProblem>) -> Vec<PathBuf> {
1622 let entries = match std::fs::read_dir(dir) {
1623 Ok(entries) => entries,
1624 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
1626 Err(err) => {
1627 problems.push(LoadProblem::Io(dir.to_path_buf(), err.kind()));
1628 return Vec::new();
1629 }
1630 };
1631 let mut files: Vec<PathBuf> = entries
1632 .filter_map(Result::ok)
1633 .map(|entry| entry.path())
1634 .filter(|path| {
1635 path.is_file()
1636 && path
1637 .extension()
1638 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
1639 })
1640 .collect();
1641 files.sort();
1642 files
1643}
1644
1645#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1651pub struct SessionState {
1652 pub last_root: Option<PathBuf>,
1654 pub left_width: Option<u32>,
1657 pub right_width: Option<u32>,
1659}
1660
1661impl SessionState {
1662 pub fn load() -> Self {
1665 Self::load_from_path(&state_file())
1666 }
1667
1668 pub fn load_from_path(path: &Path) -> Self {
1670 match std::fs::read_to_string(path) {
1671 Ok(raw) => toml::from_str(&raw).unwrap_or_default(),
1672 Err(_) => Self::default(),
1673 }
1674 }
1675
1676 pub fn save(&self) -> std::io::Result<()> {
1678 Self::save_to_path(&state_file(), self)
1679 }
1680
1681 pub fn save_to_path(path: &Path, state: &Self) -> std::io::Result<()> {
1687 if let Some(parent) = path.parent() {
1688 std::fs::create_dir_all(parent)?;
1689 }
1690 let body = toml::to_string(state).map_err(|e| {
1691 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
1692 })?;
1693 let tmp = path.with_extension("toml.tmp");
1694 std::fs::write(&tmp, body)?;
1695 std::fs::rename(&tmp, path)
1696 }
1697}
1698
1699pub fn action_command(template: &str, path: &Path) -> Vec<String> {
1706 let path_s = path.to_string_lossy();
1707 let dir = if path.is_dir() {
1708 path.to_string_lossy()
1709 } else {
1710 path.parent().unwrap_or(path).to_string_lossy()
1711 };
1712 let has_path = template.contains("{path}");
1713 let has_dir = template.contains("{dir}");
1714 let mut tokens: Vec<String> = template
1715 .split_whitespace()
1716 .map(|t| t.replace("{path}", &path_s).replace("{dir}", &dir))
1717 .filter(|t| !t.is_empty())
1718 .collect();
1719 if !has_path && !has_dir {
1720 tokens.push(path_s.into_owned());
1721 }
1722 tokens
1723}
1724
1725#[cfg(test)]
1726mod tests {
1727 use super::*;
1728
1729 fn parse(raw: &str) -> Config {
1730 Config::parse(raw, Path::new("test.toml")).config
1731 }
1732
1733 #[test]
1734 fn defaults_are_sane() {
1735 let config = Config::default();
1739 assert!((PANEL_MIN_WIDTH..=PANEL_MAX_WIDTH).contains(&config.panel.width));
1740 assert!(config.tree.icon_size > 0);
1741 assert!(config.tree.font_size > 0);
1742 assert!(!config.context_menu.rules.is_empty());
1743 assert!(!config.pane_menu.items.is_empty());
1744 }
1745
1746 #[test]
1747 fn shipping_default_file_parses_and_is_current() {
1748 assert_eq!(parse(DEFAULT_CONFIG), Config::default());
1752
1753 let written = std::fs::read_to_string(concat!(
1754 env!("CARGO_MANIFEST_DIR"),
1755 "/default-config.toml"
1756 ))
1757 .unwrap();
1758 assert_eq!(DEFAULT_CONFIG, written);
1759 }
1760
1761 #[test]
1762 fn empty_document_uses_defaults() {
1763 let config = parse("");
1764 assert_eq!(config, Config::default());
1765 }
1766
1767 #[test]
1768 fn full_document_round_trips() {
1769 let raw = r#"
1770[panel]
1771side = "right"
1772layer = "top"
1773width = 420
1774margin = 8
1775
1776[tree]
1777dirs_first = false
1778show_hidden = true
1779font_size = 14
1780icon_size = 20
1781"#;
1782 let config = parse(raw);
1783 assert_eq!(config.panel.side, PanelSide::Right);
1784 assert_eq!(config.panel.layer, PanelLayer::Top);
1785 assert_eq!(config.panel.width, 420);
1786 assert!(!config.tree.dirs_first);
1787 assert!(config.tree.show_hidden);
1788
1789 let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
1791 assert_eq!(config, reparsed);
1792 }
1793
1794 #[test]
1795 fn partial_document_is_merged_with_defaults() {
1796 let config = parse("[panel]\nside = \"right\"\n");
1797 assert_eq!(config.panel.side, PanelSide::Right);
1798 assert_eq!(config.panel.width, Config::default().panel.width);
1799 assert_eq!(config.tree, Config::default().tree);
1800 }
1801
1802 #[test]
1803 fn defaults_come_from_the_shipped_file() {
1804 let shipped = parse(DEFAULT_CONFIG);
1807 assert_eq!(Config::default(), shipped);
1808 assert_eq!(PanelConfig::default(), shipped.panel);
1809 assert_eq!(TreeConfig::default(), shipped.tree);
1810 assert_eq!(ContextMenu::default(), shipped.context_menu);
1811 assert_eq!(PaneMenu::default(), shipped.pane_menu);
1812 assert_eq!(BookmarksConfig::default(), shipped.bookmarks);
1813 let partial = parse("[tree]\nfont_size = 21\n");
1815 assert_eq!(partial.tree.font_size, 21);
1816 assert_eq!(partial.tree.icon_size, shipped.tree.icon_size);
1817 assert_eq!(partial.tree.sort_key, shipped.tree.sort_key);
1818 assert_eq!(partial.tree.confirm_drop_move, shipped.tree.confirm_drop_move);
1819 assert!(!shipped.tree.confirm_drop_move);
1821 }
1822
1823 #[test]
1824 fn out_of_range_values_are_clamped() {
1825 let config = parse(
1826 "[panel]\nwidth = 100000\nmargin = 9000\n\n[tree]\nfont_size = 2\nicon_size = 2\n",
1827 );
1828 assert_eq!(config.panel.width, 2000);
1829 assert_eq!(config.panel.margin, 200);
1830 assert_eq!(config.tree.font_size, 6);
1831 assert_eq!(config.tree.icon_size, 12);
1832 }
1833
1834 #[test]
1835 fn invalid_toml_falls_back_to_defaults_with_problem() {
1836 let result = Config::parse("panel = ", Path::new("broken.toml"));
1837 assert_eq!(result.config, Config::default());
1838 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
1839 }
1840
1841 #[test]
1842 fn missing_file_yields_defaults_without_problem() {
1843 let result = Config::load_from_path(Path::new("/definitely/not/here/config.toml"));
1844 assert_eq!(result.config, Config::default());
1845 assert!(result.problem.is_none());
1846 }
1847
1848 #[test]
1849 fn first_launch_materializes_default_config_file() {
1850 let dir = tempfile::tempdir().unwrap();
1851 let path = dir.path().join("tree-space/config.toml");
1852 assert!(!path.exists());
1853
1854 let result = Config::load_and_ensure(&path);
1855 assert_eq!(result.config, Config::default());
1856 assert!(result.problem.is_none());
1857
1858 let written = std::fs::read_to_string(&path).unwrap();
1860 assert_eq!(written, DEFAULT_CONFIG);
1861
1862 let reloaded = Config::load_and_ensure(&path);
1864 assert_eq!(reloaded.config, Config::default());
1865 assert!(reloaded.problem.is_none());
1866 }
1867
1868 #[test]
1869 fn existing_file_is_never_overwritten() {
1870 let dir = tempfile::tempdir().unwrap();
1871 let path = dir.path().join("tree-space/config.toml");
1872 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1873 std::fs::write(&path, "[panel]\nwidth = 500\n").unwrap();
1874
1875 let result = Config::load_and_ensure(&path);
1876 assert_eq!(result.config.panel.width, 500);
1877 assert!(result.problem.is_none());
1878 assert_eq!(std::fs::read_to_string(&path).unwrap(), "[panel]\nwidth = 500\n");
1879 }
1880
1881 #[test]
1882 fn xdg_config_resolution_follows_spec() {
1883 assert_eq!(
1885 xdg_config_home(Some("/opt/cfg"), Some("/home/u")),
1886 PathBuf::from("/opt/cfg")
1887 );
1888 assert_eq!(
1890 xdg_config_home(None, Some("/home/u")),
1891 PathBuf::from("/home/u/.config")
1892 );
1893 assert_eq!(
1894 xdg_state_home(None, Some("/home/u")),
1895 PathBuf::from("/home/u/.local/state")
1896 );
1897 assert_eq!(xdg_config_home(Some(""), Some("/h")), PathBuf::from("/h/.config"));
1899 }
1900
1901 #[test]
1902 fn app_path_joins_app_dir() {
1903 assert_eq!(
1904 app_path(&PathBuf::from("/cfg"), STATE_FILE),
1905 PathBuf::from("/cfg/tree-space/state.toml")
1906 );
1907 }
1908
1909 #[test]
1910 fn session_state_round_trips_through_disk() {
1911 let dir = tempfile::tempdir().unwrap();
1912 let path = dir.path().join("state.toml");
1913 let state = SessionState {
1914 last_root: Some(PathBuf::from("/home/user/projects/foo")),
1915 left_width: Some(420),
1916 right_width: None,
1917 };
1918 SessionState::save_to_path(&path, &state).unwrap();
1919 assert_eq!(SessionState::load_from_path(&path), state);
1920 }
1921
1922 #[test]
1923 fn missing_or_broken_state_defaults() {
1924 let dir = tempfile::tempdir().unwrap();
1925 let broken = dir.path().join("broken.toml");
1926 std::fs::write(&broken, "not [ valid").unwrap();
1927 assert_eq!(SessionState::load_from_path(&broken), SessionState::default());
1928 assert_eq!(
1929 SessionState::load_from_path(&dir.path().join("missing.toml")),
1930 SessionState::default()
1931 );
1932 }
1933
1934 fn all_labels(actions: &[ContextAction]) -> Vec<String> {
1937 let mut out = Vec::new();
1938 for action in actions {
1939 match action.submenu_items() {
1940 Some(items) => out.extend(all_labels(items)),
1941 None => out.push(action.label()),
1942 }
1943 }
1944 out
1945 }
1946
1947 #[test]
1948 fn default_context_menu_reproduces_classic_items() {
1949 let menu = ContextMenu::default();
1950 let dir = tempfile::tempdir().unwrap();
1951 let file = dir.path().join("notes.txt");
1952 std::fs::write(&file, "").unwrap();
1953 std::fs::write(dir.path().join("photo.jpg"), "").unwrap();
1954
1955 let dir_labels = all_labels(&menu.actions_for(dir.path()));
1956 for classic in [
1959 "In same panel",
1960 "In split view",
1961 "Folder",
1962 "File",
1963 "Copy Path",
1964 "Move to Trash",
1965 "Delete Permanently",
1966 ] {
1967 assert!(dir_labels.contains(&classic.to_owned()), "missing {classic}: {dir_labels:?}");
1968 }
1969 for parity in ["In opposite panel", "With...", "Cut", "Copy", "Paste", "Create Link", "Rename", "Properties"] {
1971 assert!(
1972 dir_labels.contains(&parity.to_owned()),
1973 "missing {parity}: {dir_labels:?}"
1974 );
1975 }
1976
1977 let file_labels = all_labels(&menu.actions_for(&file));
1978 for dropped in ["In split view", "In opposite panel"] {
1980 assert!(!file_labels.contains(&dropped.to_owned()), "{dropped} present: {file_labels:?}");
1981 }
1982 assert!(file_labels.contains(&"With Default".to_owned()));
1984 assert!(file_labels.contains(&"With...".to_owned()));
1985 assert!(file_labels.len() > 15);
1986
1987 let menus = menu.actions_for(dir.path());
1989 let cut = menus
1990 .iter()
1991 .flat_map(flatten_actions)
1992 .find(|a| a.label() == "Cut")
1993 .expect("Cut present");
1994 assert_eq!(cut.shortcut().as_deref(), Some("Ctrl+x"));
1995 let trash = menus
1996 .iter()
1997 .flat_map(flatten_actions)
1998 .find(|a| a.label() == "Move to Trash")
1999 .expect("Trash present");
2000 assert_eq!(trash.shortcut().as_deref(), Some("Delete"));
2001 }
2002
2003 fn flatten_actions(action: &ContextAction) -> Vec<&ContextAction> {
2005 match action.submenu_items() {
2006 Some(items) => items.iter().flat_map(flatten_actions).collect(),
2007 None => vec![action],
2008 }
2009 }
2010
2011 #[test]
2012 fn default_shortcuts_follow_conventions() {
2013 assert_eq!(BuiltinAction::Cut.default_shortcut(), Some("Ctrl+x"));
2014 assert_eq!(BuiltinAction::Copy.default_shortcut(), Some("Ctrl+c"));
2015 assert_eq!(BuiltinAction::Rename.default_shortcut(), Some("F2"));
2016 assert_eq!(BuiltinAction::CreateLink.default_shortcut(), Some("Ctrl+Shift+m"));
2017 assert_eq!(BuiltinAction::Trash.default_shortcut(), Some("Delete"));
2018 assert_eq!(BuiltinAction::DeletePermanently.default_shortcut(), Some("Shift+Delete"));
2019 assert_eq!(BuiltinAction::ViewThumbnail.default_shortcut(), Some("Ctrl+t"));
2020 assert_eq!(BuiltinAction::Open.default_shortcut(), None);
2021 assert_eq!(BuiltinAction::Separator.default_shortcut(), None);
2022 }
2023
2024 #[test]
2025 fn shortcuts_are_collected_top_down_and_deduplicated() {
2026 let menu = ContextMenu {
2027 rules: vec![
2028 ContextRule::rule(
2029 vec![ContextMatch::Dir],
2030 vec![
2031 ContextAction::Builtin(BuiltinAction::Open),
2032 ContextAction::Entry(BuiltinEntry {
2033 action: BuiltinAction::Rename,
2034 label: None,
2035 shortcut: Some("F2".into()),
2036 hidden: false,
2037 }),
2038 ContextAction::Command(CommandAction {
2039 command: "code {path}".into(),
2040 label: None,
2041 shortcut: Some("Ctrl+Shift+e".into()),
2042 hidden: false,
2043 }),
2044 ],
2045 ),
2046 ContextRule::rule(
2047 vec![ContextMatch::Fallback],
2048 vec![ContextAction::Builtin(BuiltinAction::Trash)],
2049 ),
2050 ],
2051 };
2052 let shortcuts = menu.shortcuts();
2053 assert_eq!(
2055 shortcuts,
2056 vec![
2057 ("F2".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Rename)),
2058 ("Ctrl+Shift+e".to_owned(), ShortcutTarget::Command(CommandAction {
2059 command: "code {path}".into(),
2060 label: None,
2061 shortcut: Some("Ctrl+Shift+e".into()),
2062 hidden: false,
2063 })),
2064 ("Delete".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Trash)),
2065 ]
2066 );
2067 }
2068
2069 #[test]
2070 fn table_form_spells_out_shortcuts_and_labels() {
2071 let raw = r#"
2072[[context_menu.rules]]
2073matches = ["fallback"]
2074items = [
2075 { action = "Cut", shortcut = "Ctrl+Alt+x", label = "Cut here" },
2076 { command = "sh {path}", shortcut = "Ctrl+Alt+s" },
2077 "---",
2078]
2079"#;
2080 let config = parse(raw);
2081 let actions = &config.context_menu.rules[0].items;
2082 assert_eq!(actions.len(), 3);
2083
2084 match &actions[0] {
2085 ContextAction::Entry(entry) => {
2086 assert_eq!(entry.action, BuiltinAction::Cut);
2087 assert_eq!(entry.shortcut.as_deref(), Some("Ctrl+Alt+x"));
2088 assert_eq!(entry.label.as_deref(), Some("Cut here"));
2089 assert_eq!(actions[0].label(), "Cut here");
2090 assert_eq!(actions[0].shortcut().as_deref(), Some("Ctrl+Alt+x"));
2091 }
2092 other => panic!("expected Entry, got {other:?}"),
2093 }
2094 match &actions[1] {
2095 ContextAction::Command(cmd) => {
2096 assert_eq!(cmd.shortcut.as_deref(), Some("Ctrl+Alt+s"));
2097 }
2098 other => panic!("expected Command, got {other:?}"),
2099 }
2100 match &actions[2] {
2101 ContextAction::Builtin(BuiltinAction::Separator) => {}
2102 other => panic!("expected Separator, got {other:?}"),
2103 }
2104
2105 let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
2106 assert_eq!(config, reparsed);
2107 }
2108
2109 #[test]
2110 fn pane_actions_are_classified() {
2111 for action in [
2112 BuiltinAction::OpenFolder,
2113 BuiltinAction::Filter,
2114 BuiltinAction::SplitView,
2115 BuiltinAction::Collapse,
2116 BuiltinAction::ClosePane,
2117 ] {
2118 assert!(action.is_pane_action(), "{action:?} should be a pane action");
2119 }
2120 for action in [
2121 BuiltinAction::Open,
2122 BuiltinAction::OpenSplit,
2123 BuiltinAction::ToggleHidden,
2124 BuiltinAction::SortByName,
2125 BuiltinAction::Separator,
2126 ] {
2127 assert!(!action.is_pane_action(), "{action:?} is not a pane action");
2128 }
2129 }
2130
2131 #[test]
2132 fn separator_matches_both_spellings() {
2133 assert_eq!(BuiltinAction::parse("---"), Some(BuiltinAction::Separator));
2134 assert_eq!(BuiltinAction::parse("separator"), Some(BuiltinAction::Separator));
2135 assert_eq!(BuiltinAction::parse("SEPARATOR"), Some(BuiltinAction::Separator));
2136 assert_eq!(BuiltinAction::Separator.as_str(), "---");
2137 }
2138
2139 #[test]
2140 fn ellipsis_labels_use_ascii_and_accept_the_typographic_form() {
2141 assert_eq!(BuiltinAction::OpenWith.as_str(), "Open With...");
2143 assert_eq!(BuiltinAction::OpenFolder.as_str(), "Open Folder...");
2144 assert_eq!(BuiltinAction::Filter.as_str(), "Filter...");
2145 assert_eq!(BuiltinAction::parse("Open With…"), Some(BuiltinAction::OpenWith));
2147 assert_eq!(BuiltinAction::parse("Open Folder…"), Some(BuiltinAction::OpenFolder));
2148 assert_eq!(BuiltinAction::parse("Filter…"), Some(BuiltinAction::Filter));
2149 assert_eq!(BuiltinAction::parse("open folder..."), Some(BuiltinAction::OpenFolder));
2151 }
2152
2153 #[test]
2154 fn pane_menu_defaults_include_view_and_pane_actions() {
2155 let menu = PaneMenu::default();
2156 let labels = menu.labels();
2157 assert!(labels.contains(&"Open Folder...".to_owned()));
2158 assert!(labels.contains(&"Split View".to_owned()));
2159 assert!(labels.contains(&"Close Pane".to_owned()));
2160 assert!(labels.contains(&"Toggle Hidden Files".to_owned()));
2161 assert!(labels.contains(&"Sort by Size".to_owned()));
2162 let dir_menu = ContextMenu::default();
2164 let dir_labels: Vec<String> = dir_menu
2165 .rules
2166 .iter()
2167 .flat_map(|rule| rule.items.iter())
2168 .map(ContextAction::label)
2169 .collect();
2170 assert!(!dir_labels.contains(&"Toggle Hidden Files".to_owned()));
2171 }
2172
2173 #[test]
2174 fn pane_menu_shortcuts_are_collected() {
2175 let menu = PaneMenu::default();
2176 let shortcuts = menu.shortcuts();
2177 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+h"));
2178 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+1"));
2179 assert!(shortcuts.iter().all(|(k, _)| !k.is_empty()));
2181 }
2182
2183 #[test]
2184 fn pane_menu_parses_custom_commands() {
2185 let toml = r#"
2186 items = [
2187 "Split View",
2188 "---",
2189 { command = "echo hi", label = "Say hi" },
2190 ]
2191 "#;
2192 let menu: PaneMenu = toml::from_str(toml).unwrap();
2193 assert_eq!(menu.labels(), vec!["Split View", "---", "Say hi"]);
2194 }
2195
2196 #[test]
2197 fn navigation_builtins_round_trip_and_are_pane_actions() {
2198 for action in [BuiltinAction::Up, BuiltinAction::Back, BuiltinAction::Forward] {
2199 let s = action.as_str();
2200 assert_eq!(BuiltinAction::parse(s), Some(action), "{s} should parse");
2201 assert!(action.is_pane_action(), "{s} should be a pane action");
2202 assert!(action.default_shortcut().is_some(), "{s} needs a default key");
2203 }
2204 assert_eq!(BuiltinAction::Up.default_shortcut(), Some("Alt+Up"));
2205 assert_eq!(BuiltinAction::Back.default_shortcut(), Some("Alt+Left"));
2206 assert_eq!(BuiltinAction::Forward.default_shortcut(), Some("Alt+Right"));
2207 }
2208
2209 #[test]
2210 fn hidden_items_parse_and_keep_their_shortcut() {
2211 let toml = r#"
2212 items = [
2213 { action = "Up One Level", hidden = true },
2214 { command = "echo hi", label = "Say hi", shortcut = "Ctrl+g", hidden = true },
2215 { label = "More", hidden = true, items = [ "Copy Path" ] },
2216 "Open",
2217 ]
2218 "#;
2219 let menu: PaneMenu = toml::from_str(toml).unwrap();
2220 assert!(menu.items[0].is_hidden());
2221 assert!(menu.items[1].is_hidden());
2222 assert!(menu.items[2].is_hidden());
2223 assert!(!menu.items[3].is_hidden());
2224 assert_eq!(menu.items[0].label(), "Up One Level");
2226 assert_eq!(menu.items[1].shortcut().as_deref(), Some("Ctrl+g"));
2227 let shortcuts = menu.shortcuts();
2229 assert!(shortcuts.iter().any(|(k, _)| k == "Alt+Up"));
2230 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+g"));
2231 }
2232
2233 #[test]
2234 fn hidden_submenu_children_keep_their_shortcuts() {
2235 let toml = r#"
2236 items = [
2237 { label = "More", hidden = true, items = [
2238 { action = "Rename", shortcut = "Ctrl+Shift+r" },
2239 ] },
2240 ]
2241 "#;
2242 let menu: PaneMenu = toml::from_str(toml).unwrap();
2243 assert!(menu.items[0].is_hidden());
2244 assert!(menu.shortcuts().iter().any(|(k, _)| k == "Ctrl+Shift+r"));
2245 }
2246
2247 #[test]
2248 fn hidden_defaults_to_false_and_is_not_serialized() {
2249 #[derive(serde::Deserialize)]
2250 struct One {
2251 item: ContextAction,
2252 }
2253 let visible: One = toml::from_str(r#"item = { action = "Cut", shortcut = "Ctrl+x" }"#).unwrap();
2254 assert!(!visible.item.is_hidden());
2255 let plain: One = toml::from_str(r#"item = "Cut""#).unwrap();
2257 assert!(!plain.item.is_hidden());
2258 #[derive(serde::Serialize)]
2260 struct OneOut {
2261 item: ContextAction,
2262 }
2263 let encoded = toml::to_string(&OneOut { item: visible.item }).unwrap();
2264 assert!(!encoded.contains("hidden"), "{encoded}");
2265 }
2266
2267 #[test]
2268 fn menu_items_parse_submenus_recursively() {
2269 let toml = r#"
2270 items = [
2271 "Open",
2272 { label = "More", items = [
2273 { action = "Copy Path", shortcut = "Ctrl+Shift+c" },
2274 { label = "Deeper", items = [ { command = "echo hi", label = "Hi" } ] },
2275 ] },
2276 ]
2277 "#;
2278 let menu: PaneMenu = toml::from_str(toml).unwrap();
2279 assert_eq!(menu.labels(), vec!["Open", "More"]);
2280 let sub = &menu.items[1];
2282 assert!(sub.is_submenu());
2283 assert!(sub.shortcut().is_none());
2284 let children = sub.submenu_items().unwrap();
2285 assert_eq!(children.len(), 2);
2286 assert_eq!(children[0].label(), "Copy Path");
2287 assert!(children[1].is_submenu());
2288 let shortcuts = menu.shortcuts();
2290 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
2291 }
2292
2293 #[test]
2294 fn context_menu_collects_nested_shortcuts() {
2295 let raw = r#"
2296 [[context_menu.rules]]
2297 matches = ["dir"]
2298 items = [
2299 "Open",
2300 { label = "More", items = [
2301 { action = "Copy Path", shortcut = "Ctrl+Shift+c" },
2302 ] },
2303 ]
2304 [[context_menu.rules]]
2305 matches = ["fallback"]
2306 items = ["Open"]
2307 "#;
2308 let config = parse(raw);
2309 let shortcuts = config.context_menu.shortcuts();
2310 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
2311 }
2312
2313 #[test]
2314 fn startup_last_uses_session_root() {
2315 let root = StartupRoot::Last;
2316 let last = Some(PathBuf::from("/tmp/last"));
2317 assert_eq!(root.resolve(last.clone()), last);
2318 assert_eq!(root.resolve(None), None);
2319 }
2320
2321 #[test]
2322 fn startup_home_ignores_session_root() {
2323 let root = StartupRoot::Home;
2324 assert_eq!(root.resolve(Some(PathBuf::from("/tmp/last"))), None);
2325 }
2326
2327 #[test]
2328 fn startup_path_uses_configured_directory() {
2329 let root = StartupRoot::Path("/tmp/fixed".to_owned());
2330 assert_eq!(
2331 root.resolve(Some(PathBuf::from("/tmp/last"))),
2332 Some(PathBuf::from("/tmp/fixed"))
2333 );
2334 assert_eq!(StartupRoot::Path(String::new()).resolve(None), None);
2336 }
2337
2338 #[test]
2339 fn startup_bookmarks_selects_the_bookmarks_view() {
2340 let root = parse("startup = \"bookmarks\"\n").startup;
2341 assert_eq!(root, StartupRoot::Bookmarks);
2342 assert!(root.is_bookmarks());
2343 assert_eq!(root.resolve(Some(PathBuf::from("/tmp/last"))), None);
2345 }
2346
2347 #[test]
2348 fn bookmark_builtins_parse_and_classify() {
2349 assert_eq!(BuiltinAction::parse("Add Bookmark"), Some(BuiltinAction::AddBookmark));
2350 assert!(BuiltinAction::AddBookmark.is_directory_only());
2351 assert!(BuiltinAction::AddBookmark.is_single_row_only());
2352 assert_eq!(BuiltinAction::parse("Bookmarks"), Some(BuiltinAction::ToggleBookmarks));
2353 assert!(BuiltinAction::ToggleBookmarks.is_pane_action());
2354 }
2355
2356 #[test]
2357 fn bookmarks_list_round_trips_through_its_file() {
2358 let dir = tempfile::tempdir().unwrap();
2359 let path = dir.path().join("bookmarks.toml");
2360 let list = vec![
2361 Bookmark { name: "Home".to_owned(), path: PathBuf::from("/home/x") },
2362 Bookmark { name: "Code".to_owned(), path: PathBuf::from("/srv/code") },
2363 ];
2364 save_bookmarks_to_path(&path, &list).unwrap();
2365 let (loaded, problem) = load_bookmarks_from_path(&path);
2366 assert!(problem.is_none());
2367 assert_eq!(loaded, list);
2368 }
2369
2370 #[test]
2371 fn missing_bookmarks_file_falls_back_to_default_without_problem() {
2372 let dir = tempfile::tempdir().unwrap();
2373 let path = dir.path().join("nope.toml");
2374 let (loaded, problem) = load_bookmarks_from_path(&path);
2375 assert!(problem.is_none());
2376 assert_eq!(loaded, default_bookmarks());
2377 }
2378
2379 #[test]
2380 fn ensure_bookmarks_creates_and_never_clobbers() {
2381 let dir = tempfile::tempdir().unwrap();
2382 let path = dir.path().join("bookmarks.toml");
2383 let list = vec![Bookmark { name: "Home".to_owned(), path: PathBuf::from("/home/x") }];
2384 ensure_bookmarks_at(&path, &list).unwrap();
2385 let (loaded, _) = load_bookmarks_from_path(&path);
2386 assert_eq!(loaded, list);
2387 let other = vec![Bookmark { name: "Other".to_owned(), path: PathBuf::from("/tmp") }];
2389 ensure_bookmarks_at(&path, &other).unwrap();
2390 let (loaded, _) = load_bookmarks_from_path(&path);
2391 assert_eq!(loaded, list);
2392 }
2393
2394 #[test]
2395 fn bookmark_name_defaults_to_the_directory_name() {
2396 assert_eq!(Bookmark::default_name(Path::new("/home/eolu/notes")), "notes");
2397 assert_eq!(Bookmark::default_name(Path::new("/")), "/");
2398 }
2399
2400 #[test]
2401 fn startup_tilde_expands_to_home() {
2402 unsafe { std::env::set_var("HOME", "/home/tester") };
2404 let root = StartupRoot::Path("~/notes".to_owned());
2405 assert_eq!(root.resolve(None), Some(PathBuf::from("/home/tester/notes")));
2406 assert_eq!(expand_tilde("~"), Some(PathBuf::from("/home/tester")));
2407 assert_eq!(expand_tilde("/abs/path"), Some(PathBuf::from("/abs/path")));
2408 assert_eq!(expand_tilde(""), None);
2409 let rel = expand_tilde("some/relative/dir").unwrap();
2412 assert!(rel.is_absolute(), "{rel:?} should be absolute");
2413 assert!(rel.ends_with("some/relative/dir"), "{rel:?}");
2414 }
2415
2416 #[test]
2417 fn startup_parses_from_toml() {
2418 assert_eq!(parse("startup = \"home\"\n").startup, StartupRoot::Home);
2419 assert_eq!(parse("startup = \"last\"\n").startup, StartupRoot::Last);
2420 assert_eq!(parse("startup = \"bookmarks\"\n").startup, StartupRoot::Bookmarks);
2421 assert_eq!(
2422 parse("startup = { path = \"/srv\" }\n").startup,
2423 StartupRoot::Path("/srv".to_owned())
2424 );
2425 assert_eq!(Config::default().startup, StartupRoot::Home);
2427 assert!(
2429 Config::parse("startup = \"bogus\"\n", Path::new("test.toml"))
2430 .problem
2431 .is_some()
2432 );
2433 let cfg = Config { startup: StartupRoot::Path("/srv".to_owned()), ..Config::default() };
2435 let text = toml::to_string(&cfg).unwrap();
2436 assert_eq!(toml::from_str::<Config>(&text).unwrap().startup, cfg.startup);
2437 }
2438
2439 #[test]
2440 fn context_matches_classify_rows() {
2441 let dir = tempfile::tempdir().unwrap();
2442 let file = dir.path().join("report.md");
2443 std::fs::write(&file, "").unwrap();
2444 let noext = dir.path().join("Makefile");
2445 std::fs::write(&noext, "").unwrap();
2446 let tarball = dir.path().join("app.tar.gz");
2447 std::fs::write(&tarball, "").unwrap();
2448
2449 assert!(ContextMatch::parse("dir").unwrap().matches(dir.path()));
2450 assert!(!ContextMatch::parse("dir").unwrap().matches(&file));
2451 assert!(ContextMatch::parse("noext").unwrap().matches(&noext));
2452 assert!(!ContextMatch::parse("noext").unwrap().matches(&file));
2453 assert!(ContextMatch::parse("ext:md").unwrap().matches(&file));
2454 assert!(ContextMatch::parse("ext:MD").unwrap().matches(&file));
2455 assert!(ContextMatch::parse("ext:gz").unwrap().matches(&tarball));
2456 assert!(!ContextMatch::parse("ext:md").unwrap().matches(dir.path()));
2457 assert_eq!(ContextMatch::parse("directory"), Ok(ContextMatch::Dir));
2458 assert_eq!(ContextMatch::parse("*"), Ok(ContextMatch::Fallback));
2459 assert!(ContextMatch::parse("bogus").is_err());
2460 }
2461
2462 #[test]
2463 fn regex_matcher_matches_the_full_path() {
2464 let dir = tempfile::tempdir().unwrap();
2465 let md = dir.path().join("guide.md");
2466 std::fs::write(&md, "").unwrap();
2467
2468 assert!(ContextMatch::parse(r"regex:\.md$").unwrap().matches(&md));
2470 assert!(!ContextMatch::parse(r"regex:\.rs$").unwrap().matches(&md));
2471
2472 let exact = format!("regex:^{}$", regex::escape(dir.path().to_str().unwrap()));
2474 assert!(ContextMatch::parse(&exact).unwrap().matches(dir.path()));
2475
2476 let parsed = ContextMatch::parse(r"regex:\.rs$").unwrap();
2478 assert_eq!(parsed.as_str(), r"regex:\.rs$");
2479 let reparsed = ContextMatch::parse(&parsed.as_str()).unwrap();
2480 assert_eq!(parsed, reparsed);
2481 }
2482
2483 #[test]
2484 fn invalid_regex_is_a_parse_error() {
2485 let err = ContextMatch::parse("regex:(").unwrap_err();
2486 assert!(err.contains("invalid regex"), "got: {err}");
2487 }
2488
2489 #[test]
2490 fn stylesheet_prefers_the_user_file_and_materializes_a_default() {
2491 let dir = tempfile::tempdir().unwrap();
2492 let path = dir.path().join("tree-space/main.css");
2493
2494 let first = load_stylesheet_from_path(&path);
2496 assert_eq!(first.source, StyleSource::Builtin);
2497 assert!(first.problem.is_none());
2498 assert_eq!(first.css, DEFAULT_STYLESHEET);
2499
2500 ensure_default_stylesheet(&path).unwrap();
2502 assert_eq!(std::fs::read_to_string(&path).unwrap(), DEFAULT_STYLESHEET);
2503
2504 std::fs::write(&path, ".tree-row { color: red; }").unwrap();
2506 let user = load_stylesheet_from_path(&path);
2507 assert_eq!(user.source, StyleSource::User);
2508 assert!(user.problem.is_none());
2509 assert_eq!(user.css, ".tree-row { color: red; }");
2510 }
2511
2512 #[test]
2513 fn materialize_never_overwrites_an_existing_stylesheet() {
2514 let dir = tempfile::tempdir().unwrap();
2515 let path = dir.path().join("tree-space/main.css");
2516 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2517 std::fs::write(&path, "/* mine */").unwrap();
2518
2519 ensure_default_stylesheet(&path).unwrap();
2520 assert_eq!(std::fs::read_to_string(&path).unwrap(), "/* mine */");
2521 }
2522
2523 #[test]
2524 fn unreadable_stylesheet_falls_back_to_builtin_with_a_problem() {
2525 let dir = tempfile::tempdir().unwrap();
2526 let path = dir.path().join("main.css");
2529 std::fs::create_dir(&path).unwrap();
2530
2531 let stylesheet = load_stylesheet_from_path(&path);
2532 assert_eq!(stylesheet.source, StyleSource::Builtin);
2533 assert_eq!(stylesheet.css, DEFAULT_STYLESHEET);
2534 assert!(matches!(stylesheet.problem, Some(LoadProblem::Io(..))));
2535 }
2536
2537 #[test]
2538 fn include_rule_splices_files_in_place_and_name_sorted() { let dir = tempfile::tempdir().unwrap();
2539 let rules_dir = dir.path().join("rules.d");
2540 std::fs::create_dir_all(&rules_dir).unwrap();
2541 std::fs::write(
2543 rules_dir.join("20-second.toml"),
2544 "[[context_menu.rules]]\nmatches = [\"ext:b\"]\nitems = [\"Properties\"]\n",
2545 )
2546 .unwrap();
2547 std::fs::write(
2548 rules_dir.join("10-first.toml"),
2549 "[[context_menu.rules]]\nmatches = [\"ext:a\"]\nitems = [\"Properties\", \"Copy Path\"]\n",
2550 )
2551 .unwrap();
2552 std::fs::write(rules_dir.join("README.md"), "not config").unwrap();
2554
2555 let main = dir.path().join("config.toml");
2556 std::fs::write(
2557 &main,
2558 r#"
2559[[context_menu.rules]]
2560matches = ["dir"]
2561items = ["Open"]
2562
2563[[context_menu.rules]]
2564include = "rules.d"
2565
2566[[context_menu.rules]]
2567matches = ["fallback"]
2568items = ["Move to Trash"]
2569"#,
2570 )
2571 .unwrap();
2572
2573 let config = Config::load_from_path(&main);
2574 assert!(config.problem.is_none(), "{:?}", config.problem);
2575 let rules = &config.config.context_menu.rules;
2576 assert_eq!(rules.len(), 4);
2578 assert_eq!(rules[0].matches, vec![ContextMatch::Dir]);
2579 assert_eq!(rules[1].matches, vec![ContextMatch::Ext("a".to_owned())]);
2580 assert_eq!(rules[2].matches, vec![ContextMatch::Ext("b".to_owned())]);
2581 assert_eq!(rules[3].matches, vec![ContextMatch::Fallback]);
2582 assert!(rules.iter().all(|r| r.include.is_none()));
2584 }
2585
2586 #[test]
2587 fn include_of_a_missing_directory_contributes_nothing() {
2588 let dir = tempfile::tempdir().unwrap();
2589 let main = dir.path().join("config.toml");
2590 std::fs::write(
2591 &main,
2592 "[[context_menu.rules]]\ninclude = \"nope.d\"\n\n[[context_menu.rules]]\nmatches = [\"fallback\"]\nitems = [\"Move to Trash\"]\n",
2593 )
2594 .unwrap();
2595 let result = Config::load_from_path(&main);
2596 assert!(result.problem.is_none(), "{:?}", result.problem);
2598 assert_eq!(result.config.context_menu.rules.len(), 1);
2599 assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Fallback]);
2600 }
2601
2602 #[test]
2603 fn include_of_an_invalid_file_is_reported_but_others_still_load() {
2604 let dir = tempfile::tempdir().unwrap();
2605 let rules_dir = dir.path().join("rules.d");
2606 std::fs::create_dir_all(&rules_dir).unwrap();
2607 std::fs::write(rules_dir.join("10-good.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
2608 std::fs::write(rules_dir.join("20-bad.toml"), "this is not = valid toml [").unwrap();
2609
2610 let main = dir.path().join("config.toml");
2611 std::fs::write(
2612 &main,
2613 "[[context_menu.rules]]\ninclude = \"rules.d\"\n",
2614 )
2615 .unwrap();
2616
2617 let result = Config::load_from_path(&main);
2618 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))), "{:?}", result.problem);
2620 assert_eq!(result.config.context_menu.rules.len(), 1);
2622 assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
2623 }
2624
2625 #[test]
2626 fn included_rules_are_expanded_recursively() {
2627 let dir = tempfile::tempdir().unwrap();
2628 let outer = dir.path().join("outer.d");
2629 let inner = outer.join("inner.d");
2630 std::fs::create_dir_all(&inner).unwrap();
2631 std::fs::write(inner.join("nested.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
2632 std::fs::write(
2633 outer.join("mid.toml"),
2634 "[[context_menu.rules]]\ninclude = \"inner.d\"\n",
2635 )
2636 .unwrap();
2637
2638 let main = dir.path().join("config.toml");
2639 std::fs::write(&main, "[[context_menu.rules]]\ninclude = \"outer.d\"\n").unwrap();
2640
2641 let result = Config::load_from_path(&main);
2642 assert!(result.problem.is_none(), "{:?}", result.problem);
2643 assert_eq!(result.config.context_menu.rules.len(), 1);
2644 assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
2645 }
2646
2647 #[test]
2648 fn first_matching_rule_wins() {
2649 let menu = ContextMenu {
2650 rules: vec![
2651 ContextRule::rule(
2652 vec![ContextMatch::Ext("md".to_owned())],
2653 builtin_items![CopyPath],
2654 ),
2655 ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
2656 ],
2657 };
2658 let dir = tempfile::tempdir().unwrap();
2659 let md = dir.path().join("a.md");
2660 std::fs::write(&md, "").unwrap();
2661 let txt = dir.path().join("a.txt");
2662 std::fs::write(&txt, "").unwrap();
2663
2664 assert_eq!(menu.actions_for(&md), builtin_items![CopyPath]);
2665 assert_eq!(menu.actions_for(&txt), builtin_items![Open]);
2667 }
2668
2669 #[test]
2670 fn multi_matcher_parses_and_never_matches_a_single_path() {
2671 assert_eq!(ContextMatch::parse("multi").unwrap(), ContextMatch::Multi);
2672 assert_eq!(ContextMatch::parse("multi-select").unwrap(), ContextMatch::Multi);
2673 assert_eq!(ContextMatch::Multi.as_str(), "multi");
2674 let dir = tempfile::tempdir().unwrap();
2676 assert!(!ContextMatch::Multi.matches(dir.path()));
2677 }
2678
2679 #[test]
2680 fn multi_rule_wins_for_a_multi_selection_only() {
2681 let menu = ContextMenu {
2682 rules: vec![
2683 ContextRule::rule(vec![ContextMatch::Multi], builtin_items![Trash, CopyPath]),
2684 ContextRule::rule(vec![ContextMatch::Dir], builtin_items![Open]),
2685 ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
2686 ],
2687 };
2688 let dir = tempfile::tempdir().unwrap();
2689
2690 assert_eq!(menu.actions_for_selection(dir.path(), 1), builtin_items![Open]);
2692 assert_eq!(
2694 menu.actions_for_selection(dir.path(), 2),
2695 builtin_items![Trash, CopyPath]
2696 );
2697 assert_eq!(
2698 menu.actions_for_selection(dir.path(), 7),
2699 builtin_items![Trash, CopyPath]
2700 );
2701 }
2702
2703 #[test]
2704 fn shipped_multi_rule_offers_selection_actions() {
2705 let menu = ContextMenu::default();
2706 let dir = tempfile::tempdir().unwrap();
2707 let file = dir.path().join("a.txt");
2708 std::fs::write(&file, "").unwrap();
2709
2710 let single = menu.actions_for_selection(&file, 1);
2712 assert_eq!(single, menu.actions_for(&file));
2713
2714 let labels = all_labels(&menu.actions_for_selection(&file, 3));
2717 assert!(labels.contains(&"Move to Trash".to_owned()), "{labels:?}");
2718 assert!(labels.contains(&"Copy Path".to_owned()), "{labels:?}");
2719 assert!(labels.contains(&"Properties".to_owned()), "{labels:?}");
2720 assert!(!labels.contains(&"Rename".to_owned()), "{labels:?}");
2721 assert!(!labels.contains(&"Duplicate".to_owned()), "{labels:?}");
2722 assert!(!labels.contains(&"With...".to_owned()), "{labels:?}");
2723 }
2724
2725 #[test]
2726 fn single_row_only_actions_are_classified() {
2727 assert!(BuiltinAction::Rename.is_single_row_only());
2728 assert!(BuiltinAction::Open.is_single_row_only());
2729 assert!(BuiltinAction::Duplicate.is_single_row_only());
2730 assert!(!BuiltinAction::Trash.is_single_row_only());
2731 assert!(!BuiltinAction::DeletePermanently.is_single_row_only());
2732 assert!(!BuiltinAction::Copy.is_single_row_only());
2733 assert!(!BuiltinAction::Cut.is_single_row_only());
2734 assert!(!BuiltinAction::Properties.is_single_row_only());
2735 assert!(!BuiltinAction::CopyPath.is_single_row_only());
2736 }
2737
2738 #[test]
2739 fn empty_rule_list_falls_back_to_classic_menu() {
2740 let menu = ContextMenu { rules: Vec::new() };
2741 let dir = tempfile::tempdir().unwrap();
2742 assert_eq!(menu.actions_for(dir.path()), ContextMenu::fallback_actions(dir.path()));
2743 }
2744
2745 #[test]
2746 fn custom_context_menu_round_trips() {
2747 let raw = r#"
2748[[context_menu.rules]]
2749matches = ["ext:rs", "ext:toml"]
2750items = ["Open", { command = "cargo fmt", label = "Fmt" }]
2751
2752[[context_menu.rules]]
2753matches = ["dir"]
2754items = [{ command = "foot -D {dir}", label = "Open in Terminal" }, "New Folder"]
2755
2756[[context_menu.rules]]
2757matches = ["noext"]
2758items = [{ command = "sh {path}" }]
2759
2760[[context_menu.rules]]
2761matches = ['regex:.*\.lock$']
2762items = ["Open", { command = "rm {path}", label = "Discard lock" }]
2763"#;
2764 let config = parse(raw);
2765 assert_eq!(config.context_menu.rules.len(), 4);
2766
2767 let first = &config.context_menu.rules[0];
2768 assert_eq!(
2769 first.matches,
2770 vec![ContextMatch::Ext("rs".to_owned()), ContextMatch::Ext("toml".to_owned())]
2771 );
2772 assert!(matches!(first.items[0], ContextAction::Builtin(BuiltinAction::Open)));
2773 assert!(matches!(
2774 &first.items[1],
2775 ContextAction::Command(cmd) if cmd.command == "cargo fmt" && cmd.label.as_deref() == Some("Fmt")
2776 ));
2777
2778 let last = &config.context_menu.rules[3];
2779 assert_eq!(
2780 last.matches,
2781 vec![ContextMatch::parse(r"regex:.*\.lock$").unwrap()]
2782 );
2783
2784 let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
2785 assert_eq!(config, reparsed);
2786 }
2787
2788 #[test]
2789 fn unknown_builtin_action_is_a_parse_problem() {
2790 let result = Config::parse(
2791 "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Frobnicate\"]\n",
2792 Path::new("bad-menu.toml"),
2793 );
2794 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
2795 assert_eq!(result.config, Config::default());
2796 }
2797
2798 #[test]
2799 fn unknown_context_matcher_is_a_parse_problem() {
2800 let result = Config::parse(
2801 "[[context_menu.rules]]\nmatches = [\"gibberish\"]\nitems = [\"Open\"]\n",
2802 Path::new("bad-matcher.toml"),
2803 );
2804 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
2805 assert_eq!(result.config, Config::default());
2806 }
2807
2808 #[test]
2809 fn action_command_substitutes_markers() {
2810 let dir = tempfile::tempdir().unwrap();
2811 let dir_s = dir.path().to_string_lossy().into_owned();
2812 assert_eq!(
2813 action_command("code {path}", dir.path()),
2814 vec!["code".to_owned(), dir_s.clone()]
2815 );
2816 assert_eq!(
2817 action_command("code {dir}", &dir.path().join("x.rs")),
2818 vec!["code".to_owned(), dir_s]
2819 );
2820 }
2821
2822 #[test]
2823 fn action_command_appends_path_without_marker() {
2824 assert_eq!(
2825 action_command("sh", Path::new("/tmp/s.sh")),
2826 vec!["sh".to_owned(), "/tmp/s.sh".to_owned()]
2827 );
2828 }
2829}