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::leaf(String::from("Home"), 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_panel_nav_toolbar() -> bool {
246 builtin().panel.nav_toolbar
247}
248fn default_dirs_first() -> bool {
249 builtin().tree.dirs_first
250}
251fn default_sort_key() -> String {
252 builtin().tree.sort_key.clone()
253}
254fn default_sort_ascending() -> bool {
255 builtin().tree.sort_ascending
256}
257fn default_font_size() -> u32 {
258 builtin().tree.font_size
259}
260fn default_icon_size() -> u32 {
261 builtin().tree.icon_size
262}
263fn default_confirm_drop_move() -> bool {
264 builtin().tree.confirm_drop_move
265}
266fn default_bookmarks_file() -> PathBuf {
267 builtin().bookmarks.file.clone()
268}
269fn default_bookmarks_menu() -> Vec<ContextAction> {
270 builtin().bookmarks.menu.clone()
271}
272fn default_bookmarks_context() -> Vec<ContextAction> {
273 builtin().bookmarks.context.clone()
274}
275fn default_bookmarks_blank() -> Vec<ContextAction> {
276 builtin().bookmarks.blank.clone()
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283pub struct PanelConfig {
284 #[serde(default = "default_panel_side")]
285 pub side: PanelSide,
286 #[serde(default = "default_panel_layer")]
287 pub layer: PanelLayer,
288 #[serde(default = "default_panel_width")]
290 pub width: u32,
291 #[serde(default)]
293 pub margin: u32,
294 #[serde(default = "default_panel_nav_toolbar")]
297 pub nav_toolbar: bool,
298}
299
300impl Default for PanelConfig {
301 fn default() -> Self {
302 builtin().panel
303 }
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct TreeConfig {
309 #[serde(default = "default_dirs_first")]
313 pub dirs_first: bool,
314 #[serde(default = "default_sort_key")]
316 pub sort_key: String,
317 #[serde(default = "default_sort_ascending")]
319 pub sort_ascending: bool,
320 #[serde(default)]
322 pub show_hidden: bool,
323 #[serde(default = "default_font_size")]
325 pub font_size: u32,
326 #[serde(default = "default_icon_size")]
328 pub icon_size: u32,
329 #[serde(default = "default_confirm_drop_move")]
333 pub confirm_drop_move: bool,
334}
335
336impl Default for TreeConfig {
337 fn default() -> Self {
338 builtin().tree.clone()
339 }
340}
341
342impl TreeConfig {
343 pub fn sort_options(&self) -> crate::fs::SortOptions {
346 crate::fs::SortOptions {
347 dirs_first: self.dirs_first,
348 key: crate::fs::SortKey::parse(&self.sort_key).unwrap_or(crate::fs::SortKey::Name),
349 ascending: self.sort_ascending,
350 }
351 }
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Default)]
361pub enum StartupRoot {
362 #[default]
364 Home,
365 Last,
367 Path(String),
369 Bookmarks,
371}
372
373impl StartupRoot {
374 pub fn resolve(&self, last: Option<PathBuf>) -> Option<PathBuf> {
377 match self {
378 StartupRoot::Last => last,
379 StartupRoot::Home => None,
380 StartupRoot::Path(path) => expand_tilde(path),
381 StartupRoot::Bookmarks => None,
383 }
384 }
385
386 pub fn is_bookmarks(&self) -> bool {
388 matches!(self, StartupRoot::Bookmarks)
389 }
390}
391
392impl Serialize for StartupRoot {
393 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
394 use serde::ser::SerializeMap;
395 match self {
396 StartupRoot::Last => serializer.serialize_str("last"),
397 StartupRoot::Home => serializer.serialize_str("home"),
398 StartupRoot::Bookmarks => serializer.serialize_str("bookmarks"),
399 StartupRoot::Path(path) => {
400 let mut map = serializer.serialize_map(Some(1))?;
401 map.serialize_entry("path", path)?;
402 map.end()
403 }
404 }
405 }
406}
407
408impl<'de> Deserialize<'de> for StartupRoot {
409 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
410 use serde::de::Error as _;
411
412 #[derive(Deserialize)]
413 #[serde(untagged)]
414 enum Repr {
415 Keyword(String),
416 Path { path: String },
417 }
418
419 match Repr::deserialize(deserializer)? {
420 Repr::Keyword(word) => match word.to_ascii_lowercase().as_str() {
421 "last" => Ok(StartupRoot::Last),
422 "home" => Ok(StartupRoot::Home),
423 "bookmarks" => Ok(StartupRoot::Bookmarks),
424 other => Err(D::Error::custom(format!(
425 "unknown startup root {other:?} (expected \"last\", \"home\", \"bookmarks\", or {{ path = \"...\" }})"
426 ))),
427 },
428 Repr::Path { path } => Ok(StartupRoot::Path(path)),
429 }
430 }
431}
432
433pub fn expand_tilde(path: &str) -> Option<PathBuf> {
435 if path.is_empty() {
436 return None;
437 }
438 match path.strip_prefix('~') {
439 Some(rest) => std::env::var_os("HOME").map(|home| {
440 let mut p = PathBuf::from(home);
441 p.push(rest.strip_prefix('/').unwrap_or(rest));
442 p
443 }),
444 None => {
448 let path = PathBuf::from(path);
449 Some(std::path::absolute(&path).unwrap_or(path))
450 }
451 }
452}
453
454#[derive(Debug, Clone)]
459pub enum ContextMatch {
460 Dir,
462 NoExt,
465 Fallback,
467 Multi,
471 Ext(String),
475 Regex(Regex),
480}
481
482impl ContextMatch {
483 pub fn as_str(&self) -> String {
485 match self {
486 ContextMatch::Dir => "dir".to_owned(),
487 ContextMatch::NoExt => "noext".to_owned(),
488 ContextMatch::Fallback => "fallback".to_owned(),
489 ContextMatch::Multi => "multi".to_owned(),
490 ContextMatch::Ext(ext) => format!("ext:{ext}"),
491 ContextMatch::Regex(re) => format!("regex:{}", re.as_str()),
492 }
493 }
494
495 pub fn parse(value: &str) -> Result<Self, String> {
497 let value = value.trim();
498 match value {
499 "dir" | "directory" => Ok(ContextMatch::Dir),
500 "noext" => Ok(ContextMatch::NoExt),
501 "fallback" | "*" | "all" => Ok(ContextMatch::Fallback),
502 "multi" | "multi-select" | "selection" => Ok(ContextMatch::Multi),
503 _ => {
504 if let Some(ext) = value.strip_prefix("ext:") {
505 return Ok(ContextMatch::Ext(ext.to_owned()));
506 }
507 if let Some(pattern) = value.strip_prefix("regex:") {
508 return Regex::new(pattern)
509 .map(ContextMatch::Regex)
510 .map_err(|err| {
511 format!(
512 "invalid regex {pattern:?} in context menu matcher {value:?}: {err}"
513 )
514 });
515 }
516 Err(format!("unknown context menu matcher {value:?}"))
517 }
518 }
519 }
520
521 pub fn matches(&self, path: &Path) -> bool {
524 match self {
525 ContextMatch::Dir => path.is_dir(),
526 ContextMatch::NoExt => {
527 let Some(name) = path.file_name() else {
528 return false;
529 };
530 let name = name.to_string_lossy();
531 !path.is_dir() && !name.ends_with('.') && {
532 let after_sep = name.rsplit(['/', '\\']).next().unwrap_or("");
535 !after_sep[1..].contains('.')
536 }
537 }
538 ContextMatch::Fallback => true,
539 ContextMatch::Multi => false,
541 ContextMatch::Ext(ext) => {
542 if path.is_dir() || ext.is_empty() {
543 return false;
544 }
545 let Some(name) = path.file_name() else {
546 return false;
547 };
548 let name = name.to_string_lossy().to_lowercase();
549 name.trim_start_matches('.').ends_with(&format!(".{}", ext.trim().to_lowercase()))
550 }
551 ContextMatch::Regex(re) => re.is_match(&path.to_string_lossy()),
552 }
553 }
554}
555
556impl PartialEq for ContextMatch {
557 fn eq(&self, other: &Self) -> bool {
558 match (self, other) {
559 (ContextMatch::Dir, ContextMatch::Dir) => true,
560 (ContextMatch::NoExt, ContextMatch::NoExt) => true,
561 (ContextMatch::Fallback, ContextMatch::Fallback) => true,
562 (ContextMatch::Multi, ContextMatch::Multi) => true,
563 (ContextMatch::Ext(a), ContextMatch::Ext(b)) => a == b,
564 (ContextMatch::Regex(a), ContextMatch::Regex(b)) => a.as_str() == b.as_str(),
565 _ => false,
566 }
567 }
568}
569
570impl Eq for ContextMatch {}
571
572impl Serialize for ContextMatch {
573 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
574 serializer.serialize_str(&self.as_str())
575 }
576}
577
578impl<'de> Deserialize<'de> for ContextMatch {
579 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
580 let raw = String::deserialize(deserializer)?;
581 ContextMatch::parse(&raw).map_err(serde::de::Error::custom)
582 }
583}
584
585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589pub enum BuiltinAction {
590 Open,
592 OpenSplit,
594 InNewPanel,
596 InOppositePanel,
599 OpenWith,
601 OpenWithDefault,
604 ViewThumbnail,
607 NewFile,
609 NewFolder,
611 Rename,
613 Duplicate,
615 CopyPath,
617 CopyRelativePath,
619 AddBookmark,
621 Cut,
623 Copy,
625 Paste,
628 CreateLink,
630 Properties,
632 Trash,
634 DeletePermanently,
636 ToggleHidden,
638 SortByName,
640 SortBySize,
642 SortByModified,
644 SortByType,
646 ToggleSortAscending,
648 OpenFolder,
650 Filter,
652 SplitView,
654 Up,
656 Back,
658 Forward,
660 Collapse,
662 ClosePane,
664 ToggleBookmarks,
666 EditBookmark,
668 DeleteBookmark,
670 NewBookmark,
672 NewBookmarkFolder,
674 Separator,
676}
677
678impl BuiltinAction {
679 pub fn as_str(&self) -> &'static str {
682 match self {
683 BuiltinAction::Open => "Open",
684 BuiltinAction::OpenSplit => "Open in Split View",
685 BuiltinAction::InNewPanel => "In new panel",
686 BuiltinAction::InOppositePanel => "In opposite panel",
687 BuiltinAction::OpenWith => "Open With...",
688 BuiltinAction::OpenWithDefault => "Open With Default",
689 BuiltinAction::ViewThumbnail => "View Thumbnail",
690 BuiltinAction::NewFile => "New File",
691 BuiltinAction::NewFolder => "New Folder",
692 BuiltinAction::Rename => "Rename",
693 BuiltinAction::Duplicate => "Duplicate",
694 BuiltinAction::CopyPath => "Copy Path",
695 BuiltinAction::CopyRelativePath => "Copy Relative Path",
696 BuiltinAction::AddBookmark => "Add Bookmark",
697 BuiltinAction::Cut => "Cut",
698 BuiltinAction::Copy => "Copy",
699 BuiltinAction::Paste => "Paste",
700 BuiltinAction::CreateLink => "Create Link",
701 BuiltinAction::Properties => "Properties",
702 BuiltinAction::Trash => "Move to Trash",
703 BuiltinAction::DeletePermanently => "Delete Permanently",
704 BuiltinAction::ToggleHidden => "Toggle Hidden Files",
705 BuiltinAction::SortByName => "Sort by Name",
706 BuiltinAction::SortBySize => "Sort by Size",
707 BuiltinAction::SortByModified => "Sort by Modified",
708 BuiltinAction::SortByType => "Sort by Type",
709 BuiltinAction::ToggleSortAscending => "Reverse Sort Order",
710 BuiltinAction::OpenFolder => "Open Folder...",
711 BuiltinAction::Filter => "Filter...",
712 BuiltinAction::SplitView => "Split View",
713 BuiltinAction::Up => "Up One Level",
714 BuiltinAction::Back => "Back",
715 BuiltinAction::Forward => "Forward",
716 BuiltinAction::Collapse => "Collapse",
717 BuiltinAction::ClosePane => "Close Pane",
718 BuiltinAction::ToggleBookmarks => "Bookmarks",
719 BuiltinAction::EditBookmark => "Edit Bookmark",
720 BuiltinAction::DeleteBookmark => "Delete Bookmark",
721 BuiltinAction::NewBookmark => "New Bookmark",
722 BuiltinAction::NewBookmarkFolder => "New Bookmark Folder",
723 BuiltinAction::Separator => "---",
724 }
725 }
726
727 pub fn default_shortcut(&self) -> Option<&'static str> {
730 match self {
731 BuiltinAction::Cut => Some("Ctrl+x"),
732 BuiltinAction::Copy => Some("Ctrl+c"),
733 BuiltinAction::Paste => Some("Ctrl+v"),
734 BuiltinAction::Rename => Some("F2"),
735 BuiltinAction::Duplicate => Some("Ctrl+d"),
736 BuiltinAction::NewFile => Some("Ctrl+n"),
737 BuiltinAction::NewFolder => Some("Ctrl+Shift+n"),
738 BuiltinAction::CreateLink => Some("Ctrl+Shift+m"),
739 BuiltinAction::Trash => Some("Delete"),
740 BuiltinAction::DeletePermanently => Some("Shift+Delete"),
741 BuiltinAction::ViewThumbnail => Some("Ctrl+t"),
742 BuiltinAction::ToggleHidden => Some("Ctrl+h"),
743 BuiltinAction::SortByName => Some("Ctrl+1"),
744 BuiltinAction::SortBySize => Some("Ctrl+2"),
745 BuiltinAction::SortByModified => Some("Ctrl+3"),
746 BuiltinAction::SortByType => Some("Ctrl+4"),
747 BuiltinAction::ToggleSortAscending => Some("Ctrl+Shift+r"),
748 BuiltinAction::SplitView => Some("Ctrl+s"),
749 BuiltinAction::Up => Some("Alt+Up"),
750 BuiltinAction::Back => Some("Alt+Left"),
751 BuiltinAction::Forward => Some("Alt+Right"),
752 BuiltinAction::ClosePane => Some("Ctrl+w"),
753 BuiltinAction::ToggleBookmarks => Some("Ctrl+b"),
754 _ => None,
755 }
756 }
757
758 pub fn is_single_row_only(&self) -> bool {
763 matches!(
764 self,
765 BuiltinAction::Open
766 | BuiltinAction::OpenSplit
767 | BuiltinAction::InNewPanel
768 | BuiltinAction::InOppositePanel
769 | BuiltinAction::OpenWith
770 | BuiltinAction::OpenWithDefault
771 | BuiltinAction::ViewThumbnail
772 | BuiltinAction::NewFile
773 | BuiltinAction::NewFolder
774 | BuiltinAction::Rename
775 | BuiltinAction::Duplicate
776 | BuiltinAction::CreateLink
777 | BuiltinAction::AddBookmark
778 )
779 }
780
781 pub fn is_directory_only(&self) -> bool {
784 matches!(
785 self,
786 BuiltinAction::OpenSplit
787 | BuiltinAction::InNewPanel
788 | BuiltinAction::InOppositePanel
789 | BuiltinAction::AddBookmark
790 )
791 }
792
793 pub fn is_pane_action(&self) -> bool {
797 matches!(
798 self,
799 BuiltinAction::OpenFolder
800 | BuiltinAction::Filter
801 | BuiltinAction::SplitView
802 | BuiltinAction::Up
803 | BuiltinAction::Back
804 | BuiltinAction::Forward
805 | BuiltinAction::Collapse
806 | BuiltinAction::ClosePane
807 | BuiltinAction::ToggleBookmarks
808 | BuiltinAction::NewBookmark
809 | BuiltinAction::NewBookmarkFolder
810 )
811 }
812
813 pub fn is_bookmark_only(&self) -> bool {
816 matches!(
817 self,
818 BuiltinAction::EditBookmark
819 | BuiltinAction::DeleteBookmark
820 | BuiltinAction::NewBookmark
821 | BuiltinAction::NewBookmarkFolder
822 )
823 }
824
825 pub fn parse(name: &str) -> Option<Self> {
832 let name = name.trim();
833 let name = if name.contains('\u{2026}') {
834 name.replace('\u{2026}', "...")
835 } else {
836 name.to_owned()
837 };
838 let name = name.as_str();
839 [
840 Self::Open,
841 Self::OpenSplit,
842 Self::InNewPanel,
843 Self::InOppositePanel,
844 Self::OpenWith,
845 Self::OpenWithDefault,
846 Self::ViewThumbnail,
847 Self::NewFile,
848 Self::NewFolder,
849 Self::Rename,
850 Self::Duplicate,
851 Self::CopyPath,
852 Self::CopyRelativePath,
853 Self::AddBookmark,
854 Self::Cut,
855 Self::Copy,
856 Self::Paste,
857 Self::CreateLink,
858 Self::Properties,
859 Self::Trash,
860 Self::DeletePermanently,
861 Self::ToggleHidden,
862 Self::SortByName,
863 Self::SortBySize,
864 Self::SortByModified,
865 Self::SortByType,
866 Self::ToggleSortAscending,
867 Self::OpenFolder,
868 Self::Filter,
869 Self::SplitView,
870 Self::Up,
871 Self::Back,
872 Self::Forward,
873 Self::Collapse,
874 Self::ClosePane,
875 Self::ToggleBookmarks,
876 Self::EditBookmark,
877 Self::DeleteBookmark,
878 Self::NewBookmark,
879 Self::NewBookmarkFolder,
880 Self::Separator,
881 ]
882 .into_iter()
883 .find(|a| {
884 a.as_str().eq_ignore_ascii_case(name)
885 || (matches!(a, Self::Separator) && name.eq_ignore_ascii_case("separator"))
886 })
887 }
888}
889
890impl Serialize for BuiltinAction {
891 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
892 serializer.serialize_str(self.as_str())
893 }
894}
895
896impl<'de> Deserialize<'de> for BuiltinAction {
897 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
898 let raw = String::deserialize(deserializer)?;
899 BuiltinAction::parse(&raw).ok_or_else(|| {
900 serde::de::Error::custom(format!("unknown context menu builtin action {raw:?}"))
901 })
902 }
903}
904
905#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
910pub struct CommandAction {
911 pub command: String,
912 #[serde(default, skip_serializing_if = "Option::is_none")]
914 pub label: Option<String>,
915 #[serde(default, skip_serializing_if = "Option::is_none")]
919 pub shortcut: Option<String>,
920 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
923 pub hidden: bool,
924}
925
926#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
929pub struct BuiltinEntry {
930 pub action: BuiltinAction,
931 #[serde(default, skip_serializing_if = "Option::is_none")]
933 pub label: Option<String>,
934 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub shortcut: Option<String>,
938 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
940 pub hidden: bool,
941}
942
943#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
947pub struct Submenu {
948 pub label: String,
950 pub items: Vec<ContextAction>,
952 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
955 pub hidden: bool,
956}
957
958impl Submenu {
959 pub fn new(label: impl Into<String>, items: Vec<ContextAction>) -> Self {
961 Self { label: label.into(), items, hidden: false }
962 }
963}
964
965#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
972#[serde(untagged)]
973pub enum ContextAction {
974 Builtin(BuiltinAction),
975 Entry(BuiltinEntry),
976 Command(CommandAction),
977 Submenu(Submenu),
978}
979
980#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
983#[serde(untagged)]
984pub enum ShortcutTarget {
985 Builtin(BuiltinAction),
986 Command(CommandAction),
987}
988
989impl ShortcutTarget {
990 pub fn from_action(action: &ContextAction) -> Option<Self> {
993 Some(match action {
994 ContextAction::Builtin(a) => ShortcutTarget::Builtin(*a),
995 ContextAction::Entry(e) => ShortcutTarget::Builtin(e.action),
996 ContextAction::Command(cmd) => ShortcutTarget::Command(cmd.clone()),
997 ContextAction::Submenu(_) => return None,
998 })
999 }
1000}
1001
1002impl ContextAction {
1003 pub fn label(&self) -> String {
1006 match self {
1007 ContextAction::Builtin(action) => action.as_str().to_owned(),
1008 ContextAction::Entry(entry) => {
1009 entry.label.clone().unwrap_or_else(|| entry.action.as_str().to_owned())
1010 }
1011 ContextAction::Command(cmd) => cmd.label.clone().unwrap_or_else(|| cmd.command.clone()),
1012 ContextAction::Submenu(sub) => sub.label.clone(),
1013 }
1014 }
1015
1016 pub fn is_submenu(&self) -> bool {
1018 matches!(self, ContextAction::Submenu(_))
1019 }
1020
1021 pub fn is_hidden(&self) -> bool {
1025 match self {
1026 ContextAction::Builtin(_) => false,
1027 ContextAction::Entry(entry) => entry.hidden,
1028 ContextAction::Command(cmd) => cmd.hidden,
1029 ContextAction::Submenu(sub) => sub.hidden,
1030 }
1031 }
1032
1033 pub fn submenu_items(&self) -> Option<&[ContextAction]> {
1035 match self {
1036 ContextAction::Submenu(sub) => Some(&sub.items),
1037 _ => None,
1038 }
1039 }
1040
1041 pub fn shortcut(&self) -> Option<String> {
1044 match self {
1045 ContextAction::Builtin(action) => action
1046 .default_shortcut()
1047 .map(str::to_owned),
1048 ContextAction::Entry(entry) => entry
1049 .shortcut
1050 .clone()
1051 .or_else(|| entry.action.default_shortcut().map(str::to_owned)),
1052 ContextAction::Command(cmd) => cmd.shortcut.clone(),
1053 ContextAction::Submenu(_) => None,
1054 }
1055 }
1056}
1057
1058#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1067pub struct ContextRule {
1068 #[serde(default)]
1069 pub matches: Vec<ContextMatch>,
1070 #[serde(default)]
1071 pub items: Vec<ContextAction>,
1072 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub include: Option<PathBuf>,
1076}
1077
1078impl ContextRule {
1079 pub fn rule(matches: Vec<ContextMatch>, items: Vec<ContextAction>) -> Self {
1081 ContextRule { matches, items, include: None }
1082 }
1083
1084 pub fn include(path: PathBuf) -> Self {
1086 ContextRule { matches: Vec::new(), items: Vec::new(), include: Some(path) }
1087 }
1088}
1089
1090#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1095pub struct ContextMenu {
1096 pub rules: Vec<ContextRule>,
1097}
1098
1099#[cfg(test)]
1100macro_rules! builtin_items {
1101 ($($name:ident),* $(,)?) => {
1102 vec![
1103 $(ContextAction::Builtin(BuiltinAction::$name),)*
1104 ]
1105 };
1106}
1107
1108impl ContextMenu {
1109 pub fn fallback_actions(path: &Path) -> Vec<ContextAction> {
1113 let wanted = if path.is_dir() { ContextMatch::Dir } else { ContextMatch::Fallback };
1114 builtin()
1115 .context_menu
1116 .rules
1117 .iter()
1118 .find(|rule| rule.matches.contains(&wanted))
1119 .map(|rule| rule.items.clone())
1120 .unwrap_or_default()
1121 }
1122
1123 pub fn actions_for(&self, path: &Path) -> Vec<ContextAction> {
1129 for rule in &self.rules {
1130 if rule.matches.iter().any(|m| m.matches(path)) {
1131 return rule.items.clone();
1132 }
1133 }
1134 Self::fallback_actions(path)
1135 }
1136
1137 pub fn actions_for_selection(&self, path: &Path, selection_len: usize) -> Vec<ContextAction> {
1144 if selection_len > 1 {
1145 for rule in &self.rules {
1146 if rule.matches.contains(&ContextMatch::Multi) {
1147 return rule.items.clone();
1148 }
1149 }
1150 }
1151 self.actions_for(path)
1152 }
1153
1154 pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
1162 let mut seen = std::collections::HashSet::new();
1163 let mut out = Vec::new();
1164 for rule in &self.rules {
1165 collect_shortcuts(&rule.items, &mut seen, &mut out);
1166 }
1167 out
1168 }
1169}
1170
1171fn collect_shortcuts(
1174 actions: &[ContextAction],
1175 seen: &mut std::collections::HashSet<String>,
1176 out: &mut Vec<(String, ShortcutTarget)>,
1177) {
1178 for action in actions {
1179 if let Some(items) = action.submenu_items() {
1180 collect_shortcuts(items, seen, out);
1181 continue;
1182 }
1183 let Some(shortcut) = action.shortcut() else {
1184 continue;
1185 };
1186 if !seen.insert(shortcut.clone()) {
1187 continue;
1188 }
1189 if let Some(target) = ShortcutTarget::from_action(action) {
1190 out.push((shortcut, target));
1191 }
1192 }
1193}
1194
1195impl Default for ContextMenu {
1196 fn default() -> Self {
1197 builtin().context_menu.clone()
1198 }
1199}
1200
1201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1209pub struct PaneMenu {
1210 pub items: Vec<ContextAction>,
1211}
1212
1213impl PaneMenu {
1214 pub fn labels(&self) -> Vec<String> {
1216 self.items.iter().map(ContextAction::label).collect()
1217 }
1218
1219 pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
1223 let mut seen = std::collections::HashSet::new();
1224 let mut out = Vec::new();
1225 collect_shortcuts(&self.items, &mut seen, &mut out);
1226 out
1227 }
1228}
1229
1230impl Default for PaneMenu {
1231 fn default() -> Self {
1232 builtin().pane_menu.clone()
1233 }
1234}
1235
1236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1239pub struct BookmarksConfig {
1240 #[serde(default = "default_bookmarks_file")]
1243 pub file: PathBuf,
1244 #[serde(default = "default_bookmarks_menu")]
1247 pub menu: Vec<ContextAction>,
1248 #[serde(default = "default_bookmarks_context")]
1252 pub context: Vec<ContextAction>,
1253 #[serde(default = "default_bookmarks_blank")]
1256 pub blank: Vec<ContextAction>,
1257}
1258
1259impl Default for BookmarksConfig {
1260 fn default() -> Self {
1261 builtin().bookmarks.clone()
1262 }
1263}
1264
1265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1268pub struct Bookmark {
1269 pub name: String,
1270 #[serde(default, skip_serializing_if = "Option::is_none")]
1272 pub path: Option<PathBuf>,
1273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1276 pub items: Vec<Bookmark>,
1277 #[serde(default, skip_serializing_if = "is_false")]
1279 pub expanded: bool,
1280}
1281
1282fn is_false(value: &bool) -> bool {
1284 !*value
1285}
1286
1287impl Bookmark {
1288 pub fn default_name(path: &Path) -> String {
1291 path.file_name()
1292 .map(|name| name.to_string_lossy().into_owned())
1293 .filter(|name| !name.is_empty())
1294 .unwrap_or_else(|| path.display().to_string())
1295 }
1296
1297 pub fn leaf(name: String, path: PathBuf) -> Self {
1299 Bookmark { name, path: Some(path), items: Vec::new(), expanded: false }
1300 }
1301
1302 pub fn is_folder(&self) -> bool {
1306 self.path.is_none() || !self.items.is_empty()
1307 }
1308
1309 pub fn contains_path(entries: &[Bookmark], path: &Path) -> bool {
1311 entries.iter().any(|entry| {
1312 entry.path.as_deref() == Some(path) || Bookmark::contains_path(&entry.items, path)
1313 })
1314 }
1315
1316 pub fn get<'a>(entries: &'a [Bookmark], index_path: &[usize]) -> Option<&'a Bookmark> {
1318 let (head, rest) = index_path.split_first()?;
1319 let entry = entries.get(*head)?;
1320 if rest.is_empty() {
1321 Some(entry)
1322 } else {
1323 Bookmark::get(&entry.items, rest)
1324 }
1325 }
1326
1327 pub fn get_mut<'a>(entries: &'a mut [Bookmark], index_path: &[usize]) -> Option<&'a mut Bookmark> {
1330 let (head, rest) = index_path.split_first()?;
1331 let entry = entries.get_mut(*head)?;
1332 if rest.is_empty() {
1333 Some(entry)
1334 } else {
1335 Bookmark::get_mut(&mut entry.items, rest)
1336 }
1337 }
1338
1339 #[allow(clippy::ptr_arg)] pub fn remove(entries: &mut Vec<Bookmark>, index_path: &[usize]) -> bool {
1342 Bookmark::take(entries, index_path).is_some()
1343 }
1344
1345 #[allow(clippy::ptr_arg)] pub fn take(entries: &mut Vec<Bookmark>, index_path: &[usize]) -> Option<Bookmark> {
1348 let (last, parents) = index_path.split_last()?;
1349 if parents.is_empty() {
1350 (*last < entries.len()).then(|| entries.remove(*last))
1351 } else if let Some(parent) = Bookmark::get_mut(entries, parents) {
1352 (*last < parent.items.len()).then(|| parent.items.remove(*last))
1353 } else {
1354 None
1355 }
1356 }
1357
1358 pub fn insert(
1361 entries: &mut Vec<Bookmark>,
1362 parent: &[usize],
1363 index: usize,
1364 entry: Bookmark,
1365 ) -> bool {
1366 if parent.is_empty() {
1367 entries.insert(index.min(entries.len()), entry);
1368 true
1369 } else if let Some(folder) = Bookmark::get_mut(entries, parent) {
1370 folder.items.insert(index.min(folder.items.len()), entry);
1371 true
1372 } else {
1373 false
1374 }
1375 }
1376}
1377
1378#[derive(Debug, Default, Serialize, Deserialize)]
1383struct BookmarksFile {
1384 #[serde(default)]
1385 bookmarks: Vec<Bookmark>,
1386}
1387
1388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1390#[serde(default)]
1391pub struct Config {
1392 pub panel: PanelConfig,
1393 pub tree: TreeConfig,
1394 pub startup: StartupRoot,
1395 pub context_menu: ContextMenu,
1396 pub pane_menu: PaneMenu,
1397 pub bookmarks: BookmarksConfig,
1398}
1399
1400impl Default for Config {
1401 fn default() -> Self {
1405 builtin().clone()
1406 }
1407}
1408
1409impl Config {
1410 pub fn load() -> LoadResult {
1419 Self::load_and_ensure(&config_file())
1420 }
1421
1422 fn load_and_ensure(path: &Path) -> LoadResult {
1424 let result = Self::load_from_path(path);
1425 if result.problem.is_none()
1426 && !path.exists()
1427 && let Err(problem) = Self::ensure_default_file(path)
1428 {
1429 return LoadResult { config: result.config, problem: Some(problem) };
1430 }
1431 result
1432 }
1433
1434 fn ensure_default_file(path: &Path) -> Result<(), LoadProblem> {
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)
1455 && metadata.permissions().readonly()
1456 {
1457 return Ok(());
1458 }
1459 std::fs::write(path, DEFAULT_CONFIG)
1460 .map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1461 }
1462
1463 pub fn load_from_path(path: &Path) -> LoadResult {
1465 let raw = match std::fs::read_to_string(path) {
1466 Ok(raw) => raw,
1467 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1468 return LoadResult {
1469 config: Config::default(),
1470 problem: None,
1471 };
1472 }
1473 Err(err) => {
1474 return LoadResult {
1475 config: Config::default(),
1476 problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
1477 };
1478 }
1479 };
1480 Self::parse(&raw, path)
1481 }
1482
1483 fn normalize(mut self) -> Self {
1485 self.panel.width = self
1486 .panel
1487 .width
1488 .clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
1489 self.panel.margin = self.panel.margin.clamp(0, 200);
1490 self.tree.font_size = self.tree.font_size.clamp(6, 40);
1491 self.tree.icon_size = self.tree.icon_size.clamp(12, 128);
1492 self
1493 }
1494}
1495
1496#[derive(Debug, Clone)]
1499pub enum LoadProblem {
1500 Io(PathBuf, std::io::ErrorKind),
1501 Parse(PathBuf, toml::de::Error),
1502}
1503
1504#[derive(Debug, Clone)]
1507pub struct LoadResult {
1508 pub config: Config,
1509 pub problem: Option<LoadProblem>,
1510}
1511
1512#[derive(Debug, Clone)]
1514pub struct Stylesheet {
1515 pub css: String,
1518 pub source: StyleSource,
1520 pub problem: Option<LoadProblem>,
1523}
1524
1525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1527pub enum StyleSource {
1528 User,
1530 Builtin,
1532}
1533
1534pub fn load_stylesheet() -> Stylesheet {
1540 let path = style_file();
1541 let _ = ensure_default_stylesheet(&path);
1544 load_stylesheet_from_path(&path)
1545}
1546
1547fn load_stylesheet_from_path(path: &Path) -> Stylesheet {
1550 match std::fs::read_to_string(path) {
1551 Ok(css) => Stylesheet {
1552 css,
1553 source: StyleSource::User,
1554 problem: None,
1555 },
1556 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Stylesheet {
1557 css: DEFAULT_STYLESHEET.to_owned(),
1558 source: StyleSource::Builtin,
1559 problem: None,
1560 },
1561 Err(err) => Stylesheet {
1562 css: DEFAULT_STYLESHEET.to_owned(),
1563 source: StyleSource::Builtin,
1564 problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
1565 },
1566 }
1567}
1568
1569fn ensure_default_stylesheet(path: &Path) -> Result<(), LoadProblem> {
1573 if path.exists() {
1574 return Ok(());
1575 }
1576 let Some(parent) = path.parent() else {
1577 return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1578 };
1579 if !parent.exists()
1580 && let Err(err) = std::fs::create_dir_all(parent)
1581 {
1582 return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1583 }
1584 if let Ok(metadata) = std::fs::metadata(parent)
1585 && metadata.permissions().readonly()
1586 {
1587 return Ok(());
1588 }
1589 std::fs::write(path, DEFAULT_STYLESHEET)
1590 .map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1591}
1592
1593impl Config {
1594 fn parse(raw: &str, path: &Path) -> LoadResult {
1596 match toml::from_str::<Config>(raw) {
1597 Ok(mut config) => {
1598 let base = path.parent().unwrap_or_else(|| Path::new("."));
1600 let mut problems = Vec::new();
1601 config.context_menu.rules =
1602 expand_includes(&config.context_menu.rules, base, 0, &mut problems);
1603 let config = config.normalize();
1604 LoadResult {
1605 config,
1606 problem: problems.into_iter().next(),
1609 }
1610 }
1611 Err(err) => LoadResult {
1612 config: Config::default(),
1613 problem: Some(LoadProblem::Parse(path.to_path_buf(), err)),
1614 },
1615 }
1616 }
1617}
1618
1619const MAX_INCLUDE_DEPTH: usize = 8;
1622
1623pub fn load_bookmarks(config: &BookmarksConfig) -> (Vec<Bookmark>, Option<LoadProblem>) {
1628 load_bookmarks_from_path(&bookmark_file_path(&config.file))
1629}
1630
1631pub fn load_bookmarks_from_path(path: &Path) -> (Vec<Bookmark>, Option<LoadProblem>) {
1633 match std::fs::read_to_string(path) {
1634 Ok(text) => match toml::from_str::<BookmarksFile>(&text) {
1635 Ok(file) => (file.bookmarks, None),
1636 Err(err) => (default_bookmarks(), Some(LoadProblem::Parse(path.to_path_buf(), err))),
1637 },
1638 Err(err) if err.kind() == std::io::ErrorKind::NotFound => (default_bookmarks(), None),
1639 Err(err) => (default_bookmarks(), Some(LoadProblem::Io(path.to_path_buf(), err.kind()))),
1640 }
1641}
1642
1643pub fn save_bookmarks_to_path(path: &Path, bookmarks: &[Bookmark]) -> Result<(), LoadProblem> {
1647 if let Some(parent) = path.parent()
1648 && !parent.as_os_str().is_empty()
1649 && let Err(err) = std::fs::create_dir_all(parent)
1650 {
1651 return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1652 }
1653 let body = bookmarks_to_toml(bookmarks);
1654 let tmp = path.with_extension("toml.tmp");
1655 std::fs::write(&tmp, body).map_err(|err| LoadProblem::Io(tmp.clone(), err.kind()))?;
1656 std::fs::rename(&tmp, path).map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1657}
1658
1659fn bookmarks_to_toml(bookmarks: &[Bookmark]) -> String {
1673 let mut out = String::from("bookmarks = [\n");
1674 for bookmark in bookmarks {
1675 out.push_str(&bookmark_to_toml(bookmark, 4));
1676 out.push_str(",\n");
1677 }
1678 out.push_str("]\n");
1679 out
1680}
1681
1682fn bookmark_to_toml(bookmark: &Bookmark, indent: usize) -> String {
1685 let pad = " ".repeat(indent);
1686 let mut fields = vec![format!("name = {}", toml_string(&bookmark.name))];
1687 if let Some(path) = &bookmark.path {
1688 fields.push(format!("path = {}", toml_string(&path.to_string_lossy())));
1689 }
1690 if bookmark.expanded {
1691 fields.push("expanded = true".to_owned());
1692 }
1693 if !bookmark.items.is_empty() {
1694 let mut items = String::from("items = [\n");
1695 for child in &bookmark.items {
1696 items.push_str(&bookmark_to_toml(child, indent + 4));
1697 items.push_str(",\n");
1698 }
1699 items.push_str(&format!("{pad}]"));
1700 fields.push(items);
1701 }
1702 format!("{pad}{{ {} }}", fields.join(", "))
1703}
1704
1705fn toml_string(value: &str) -> String {
1707 let mut out = String::with_capacity(value.len() + 2);
1708 out.push('"');
1709 for ch in value.chars() {
1710 match ch {
1711 '"' => out.push_str("\\\""),
1712 '\\' => out.push_str("\\\\"),
1713 '\n' => out.push_str("\\n"),
1714 '\r' => out.push_str("\\r"),
1715 '\t' => out.push_str("\\t"),
1716 ch if (ch as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", ch as u32)),
1717 ch => out.push(ch),
1718 }
1719 }
1720 out.push('"');
1721 out
1722}
1723
1724pub fn ensure_bookmarks_file(
1728 config: &BookmarksConfig,
1729 list: &[Bookmark],
1730) -> Result<(), LoadProblem> {
1731 ensure_bookmarks_at(&bookmark_file_path(&config.file), list)
1732}
1733
1734pub fn ensure_bookmarks_at(path: &Path, list: &[Bookmark]) -> Result<(), LoadProblem> {
1736 if path.exists() {
1737 return Ok(());
1738 }
1739 let Some(parent) = path.parent() else {
1740 return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1741 };
1742 if !parent.as_os_str().is_empty()
1743 && !parent.exists()
1744 && let Err(err) = std::fs::create_dir_all(parent)
1745 {
1746 return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1747 }
1748 if let Ok(metadata) = std::fs::metadata(parent)
1749 && metadata.permissions().readonly()
1750 {
1751 return Ok(());
1752 }
1753 save_bookmarks_to_path(path, list)
1754}
1755
1756
1757#[derive(Deserialize)]
1760struct IncludedRules {
1761 #[serde(default)]
1762 context_menu: ContextMenu,
1763}
1764
1765fn expand_includes(
1775 rules: &[ContextRule],
1776 base: &Path,
1777 depth: usize,
1778 problems: &mut Vec<LoadProblem>,
1779) -> Vec<ContextRule> {
1780 let mut out = Vec::new();
1781 for rule in rules {
1782 let Some(include) = &rule.include else {
1783 out.push(rule.clone());
1784 continue;
1785 };
1786 if depth >= MAX_INCLUDE_DEPTH {
1787 continue;
1788 }
1789 let dir = if include.is_absolute() {
1790 include.clone()
1791 } else {
1792 base.join(include)
1793 };
1794 for file in toml_files_in(&dir, problems) {
1795 let nested_base = file.parent().unwrap_or(base);
1797 match std::fs::read_to_string(&file) {
1798 Ok(text) => match toml::from_str::<IncludedRules>(&text) {
1799 Ok(included) => {
1800 out.extend(expand_includes(
1801 &included.context_menu.rules,
1802 nested_base,
1803 depth + 1,
1804 problems,
1805 ));
1806 }
1807 Err(err) => problems.push(LoadProblem::Parse(file.clone(), err)),
1808 },
1809 Err(err) => problems.push(LoadProblem::Io(file.clone(), err.kind())),
1810 }
1811 }
1812 }
1813 out
1814}
1815
1816fn toml_files_in(dir: &Path, problems: &mut Vec<LoadProblem>) -> Vec<PathBuf> {
1819 let entries = match std::fs::read_dir(dir) {
1820 Ok(entries) => entries,
1821 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
1823 Err(err) => {
1824 problems.push(LoadProblem::Io(dir.to_path_buf(), err.kind()));
1825 return Vec::new();
1826 }
1827 };
1828 let mut files: Vec<PathBuf> = entries
1829 .filter_map(Result::ok)
1830 .map(|entry| entry.path())
1831 .filter(|path| {
1832 path.is_file()
1833 && path
1834 .extension()
1835 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
1836 })
1837 .collect();
1838 files.sort();
1839 files
1840}
1841
1842#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1848pub struct SessionState {
1849 pub last_root: Option<PathBuf>,
1851 pub left_width: Option<u32>,
1854 pub right_width: Option<u32>,
1856}
1857
1858impl SessionState {
1859 pub fn load() -> Self {
1862 Self::load_from_path(&state_file())
1863 }
1864
1865 pub fn load_from_path(path: &Path) -> Self {
1867 match std::fs::read_to_string(path) {
1868 Ok(raw) => toml::from_str(&raw).unwrap_or_default(),
1869 Err(_) => Self::default(),
1870 }
1871 }
1872
1873 pub fn save(&self) -> std::io::Result<()> {
1875 Self::save_to_path(&state_file(), self)
1876 }
1877
1878 pub fn save_to_path(path: &Path, state: &Self) -> std::io::Result<()> {
1884 if let Some(parent) = path.parent() {
1885 std::fs::create_dir_all(parent)?;
1886 }
1887 let body = toml::to_string(state).map_err(|e| {
1888 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
1889 })?;
1890 let tmp = path.with_extension("toml.tmp");
1891 std::fs::write(&tmp, body)?;
1892 std::fs::rename(&tmp, path)
1893 }
1894}
1895
1896pub fn action_command(template: &str, path: &Path) -> Vec<String> {
1903 let path_s = path.to_string_lossy();
1904 let dir = if path.is_dir() {
1905 path.to_string_lossy()
1906 } else {
1907 path.parent().unwrap_or(path).to_string_lossy()
1908 };
1909 let has_path = template.contains("{path}");
1910 let has_dir = template.contains("{dir}");
1911 let mut tokens: Vec<String> = template
1912 .split_whitespace()
1913 .map(|t| t.replace("{path}", &path_s).replace("{dir}", &dir))
1914 .filter(|t| !t.is_empty())
1915 .collect();
1916 if !has_path && !has_dir {
1917 tokens.push(path_s.into_owned());
1918 }
1919 tokens
1920}
1921
1922#[cfg(test)]
1923mod tests {
1924 use super::*;
1925
1926 fn parse(raw: &str) -> Config {
1927 Config::parse(raw, Path::new("test.toml")).config
1928 }
1929
1930 #[test]
1931 fn defaults_are_sane() {
1932 let config = Config::default();
1936 assert!((PANEL_MIN_WIDTH..=PANEL_MAX_WIDTH).contains(&config.panel.width));
1937 assert!(config.tree.icon_size > 0);
1938 assert!(config.tree.font_size > 0);
1939 assert!(!config.context_menu.rules.is_empty());
1940 assert!(!config.pane_menu.items.is_empty());
1941 }
1942
1943 #[test]
1944 fn shipping_default_file_parses_and_is_current() {
1945 assert_eq!(parse(DEFAULT_CONFIG), Config::default());
1949
1950 let written = std::fs::read_to_string(concat!(
1951 env!("CARGO_MANIFEST_DIR"),
1952 "/default-config.toml"
1953 ))
1954 .unwrap();
1955 assert_eq!(DEFAULT_CONFIG, written);
1956 }
1957
1958 #[test]
1959 fn empty_document_uses_defaults() {
1960 let config = parse("");
1961 assert_eq!(config, Config::default());
1962 }
1963
1964 #[test]
1965 fn full_document_round_trips() {
1966 let raw = r#"
1967[panel]
1968side = "right"
1969layer = "top"
1970width = 420
1971margin = 8
1972
1973[tree]
1974dirs_first = false
1975show_hidden = true
1976font_size = 14
1977icon_size = 20
1978"#;
1979 let config = parse(raw);
1980 assert_eq!(config.panel.side, PanelSide::Right);
1981 assert_eq!(config.panel.layer, PanelLayer::Top);
1982 assert_eq!(config.panel.width, 420);
1983 assert!(!config.tree.dirs_first);
1984 assert!(config.tree.show_hidden);
1985
1986 let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
1988 assert_eq!(config, reparsed);
1989 }
1990
1991 #[test]
1992 fn partial_document_is_merged_with_defaults() {
1993 let config = parse("[panel]\nside = \"right\"\n");
1994 assert_eq!(config.panel.side, PanelSide::Right);
1995 assert_eq!(config.panel.width, Config::default().panel.width);
1996 assert_eq!(config.tree, Config::default().tree);
1997 }
1998
1999 #[test]
2000 fn nav_toolbar_defaults_off_and_can_be_enabled() {
2001 assert!(!Config::default().panel.nav_toolbar);
2004 assert!(!parse("[panel]\nside = \"right\"\n").panel.nav_toolbar);
2005 assert!(parse("[panel]\nnav_toolbar = true\n").panel.nav_toolbar);
2006 }
2007
2008 #[test]
2009 fn defaults_come_from_the_shipped_file() {
2010 let shipped = parse(DEFAULT_CONFIG);
2013 assert_eq!(Config::default(), shipped);
2014 assert_eq!(PanelConfig::default(), shipped.panel);
2015 assert_eq!(TreeConfig::default(), shipped.tree);
2016 assert_eq!(ContextMenu::default(), shipped.context_menu);
2017 assert_eq!(PaneMenu::default(), shipped.pane_menu);
2018 assert_eq!(BookmarksConfig::default(), shipped.bookmarks);
2019 let partial = parse("[tree]\nfont_size = 21\n");
2021 assert_eq!(partial.tree.font_size, 21);
2022 assert_eq!(partial.tree.icon_size, shipped.tree.icon_size);
2023 assert_eq!(partial.tree.sort_key, shipped.tree.sort_key);
2024 assert_eq!(partial.tree.confirm_drop_move, shipped.tree.confirm_drop_move);
2025 assert!(!shipped.tree.confirm_drop_move);
2027 }
2028
2029 #[test]
2030 fn out_of_range_values_are_clamped() {
2031 let config = parse(
2032 "[panel]\nwidth = 100000\nmargin = 9000\n\n[tree]\nfont_size = 2\nicon_size = 2\n",
2033 );
2034 assert_eq!(config.panel.width, 2000);
2035 assert_eq!(config.panel.margin, 200);
2036 assert_eq!(config.tree.font_size, 6);
2037 assert_eq!(config.tree.icon_size, 12);
2038 }
2039
2040 #[test]
2041 fn invalid_toml_falls_back_to_defaults_with_problem() {
2042 let result = Config::parse("panel = ", Path::new("broken.toml"));
2043 assert_eq!(result.config, Config::default());
2044 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
2045 }
2046
2047 #[test]
2048 fn missing_file_yields_defaults_without_problem() {
2049 let result = Config::load_from_path(Path::new("/definitely/not/here/config.toml"));
2050 assert_eq!(result.config, Config::default());
2051 assert!(result.problem.is_none());
2052 }
2053
2054 #[test]
2055 fn first_launch_materializes_default_config_file() {
2056 let dir = tempfile::tempdir().unwrap();
2057 let path = dir.path().join("tree-space/config.toml");
2058 assert!(!path.exists());
2059
2060 let result = Config::load_and_ensure(&path);
2061 assert_eq!(result.config, Config::default());
2062 assert!(result.problem.is_none());
2063
2064 let written = std::fs::read_to_string(&path).unwrap();
2066 assert_eq!(written, DEFAULT_CONFIG);
2067
2068 let reloaded = Config::load_and_ensure(&path);
2070 assert_eq!(reloaded.config, Config::default());
2071 assert!(reloaded.problem.is_none());
2072 }
2073
2074 #[test]
2075 fn existing_file_is_never_overwritten() {
2076 let dir = tempfile::tempdir().unwrap();
2077 let path = dir.path().join("tree-space/config.toml");
2078 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2079 std::fs::write(&path, "[panel]\nwidth = 500\n").unwrap();
2080
2081 let result = Config::load_and_ensure(&path);
2082 assert_eq!(result.config.panel.width, 500);
2083 assert!(result.problem.is_none());
2084 assert_eq!(std::fs::read_to_string(&path).unwrap(), "[panel]\nwidth = 500\n");
2085 }
2086
2087 #[test]
2088 fn xdg_config_resolution_follows_spec() {
2089 assert_eq!(
2091 xdg_config_home(Some("/opt/cfg"), Some("/home/u")),
2092 PathBuf::from("/opt/cfg")
2093 );
2094 assert_eq!(
2096 xdg_config_home(None, Some("/home/u")),
2097 PathBuf::from("/home/u/.config")
2098 );
2099 assert_eq!(
2100 xdg_state_home(None, Some("/home/u")),
2101 PathBuf::from("/home/u/.local/state")
2102 );
2103 assert_eq!(xdg_config_home(Some(""), Some("/h")), PathBuf::from("/h/.config"));
2105 }
2106
2107 #[test]
2108 fn app_path_joins_app_dir() {
2109 assert_eq!(
2110 app_path(&PathBuf::from("/cfg"), STATE_FILE),
2111 PathBuf::from("/cfg/tree-space/state.toml")
2112 );
2113 }
2114
2115 #[test]
2116 fn session_state_round_trips_through_disk() {
2117 let dir = tempfile::tempdir().unwrap();
2118 let path = dir.path().join("state.toml");
2119 let state = SessionState {
2120 last_root: Some(PathBuf::from("/home/user/projects/foo")),
2121 left_width: Some(420),
2122 right_width: None,
2123 };
2124 SessionState::save_to_path(&path, &state).unwrap();
2125 assert_eq!(SessionState::load_from_path(&path), state);
2126 }
2127
2128 #[test]
2129 fn missing_or_broken_state_defaults() {
2130 let dir = tempfile::tempdir().unwrap();
2131 let broken = dir.path().join("broken.toml");
2132 std::fs::write(&broken, "not [ valid").unwrap();
2133 assert_eq!(SessionState::load_from_path(&broken), SessionState::default());
2134 assert_eq!(
2135 SessionState::load_from_path(&dir.path().join("missing.toml")),
2136 SessionState::default()
2137 );
2138 }
2139
2140 fn all_labels(actions: &[ContextAction]) -> Vec<String> {
2143 let mut out = Vec::new();
2144 for action in actions {
2145 match action.submenu_items() {
2146 Some(items) => out.extend(all_labels(items)),
2147 None => out.push(action.label()),
2148 }
2149 }
2150 out
2151 }
2152
2153 #[test]
2154 fn default_context_menu_reproduces_classic_items() {
2155 let menu = ContextMenu::default();
2156 let dir = tempfile::tempdir().unwrap();
2157 let file = dir.path().join("notes.txt");
2158 std::fs::write(&file, "").unwrap();
2159 std::fs::write(dir.path().join("photo.jpg"), "").unwrap();
2160
2161 let dir_labels = all_labels(&menu.actions_for(dir.path()));
2162 for classic in [
2165 "In same panel",
2166 "In split view",
2167 "Folder",
2168 "File",
2169 "Copy Path",
2170 "Move to Trash",
2171 "Delete Permanently",
2172 ] {
2173 assert!(dir_labels.contains(&classic.to_owned()), "missing {classic}: {dir_labels:?}");
2174 }
2175 for parity in ["In opposite panel", "With...", "Cut", "Copy", "Paste", "Create Link", "Rename", "Properties"] {
2177 assert!(
2178 dir_labels.contains(&parity.to_owned()),
2179 "missing {parity}: {dir_labels:?}"
2180 );
2181 }
2182
2183 let file_labels = all_labels(&menu.actions_for(&file));
2184 for dropped in ["In split view", "In opposite panel"] {
2186 assert!(!file_labels.contains(&dropped.to_owned()), "{dropped} present: {file_labels:?}");
2187 }
2188 assert!(file_labels.contains(&"With Default".to_owned()));
2190 assert!(file_labels.contains(&"With...".to_owned()));
2191 assert!(file_labels.len() > 15);
2192
2193 let menus = menu.actions_for(dir.path());
2195 let cut = menus
2196 .iter()
2197 .flat_map(flatten_actions)
2198 .find(|a| a.label() == "Cut")
2199 .expect("Cut present");
2200 assert_eq!(cut.shortcut().as_deref(), Some("Ctrl+x"));
2201 let trash = menus
2202 .iter()
2203 .flat_map(flatten_actions)
2204 .find(|a| a.label() == "Move to Trash")
2205 .expect("Trash present");
2206 assert_eq!(trash.shortcut().as_deref(), Some("Delete"));
2207 }
2208
2209 fn flatten_actions(action: &ContextAction) -> Vec<&ContextAction> {
2211 match action.submenu_items() {
2212 Some(items) => items.iter().flat_map(flatten_actions).collect(),
2213 None => vec![action],
2214 }
2215 }
2216
2217 #[test]
2218 fn default_shortcuts_follow_conventions() {
2219 assert_eq!(BuiltinAction::Cut.default_shortcut(), Some("Ctrl+x"));
2220 assert_eq!(BuiltinAction::Copy.default_shortcut(), Some("Ctrl+c"));
2221 assert_eq!(BuiltinAction::Rename.default_shortcut(), Some("F2"));
2222 assert_eq!(BuiltinAction::CreateLink.default_shortcut(), Some("Ctrl+Shift+m"));
2223 assert_eq!(BuiltinAction::Trash.default_shortcut(), Some("Delete"));
2224 assert_eq!(BuiltinAction::DeletePermanently.default_shortcut(), Some("Shift+Delete"));
2225 assert_eq!(BuiltinAction::ViewThumbnail.default_shortcut(), Some("Ctrl+t"));
2226 assert_eq!(BuiltinAction::Open.default_shortcut(), None);
2227 assert_eq!(BuiltinAction::Separator.default_shortcut(), None);
2228 }
2229
2230 #[test]
2231 fn shortcuts_are_collected_top_down_and_deduplicated() {
2232 let menu = ContextMenu {
2233 rules: vec![
2234 ContextRule::rule(
2235 vec![ContextMatch::Dir],
2236 vec![
2237 ContextAction::Builtin(BuiltinAction::Open),
2238 ContextAction::Entry(BuiltinEntry {
2239 action: BuiltinAction::Rename,
2240 label: None,
2241 shortcut: Some("F2".into()),
2242 hidden: false,
2243 }),
2244 ContextAction::Command(CommandAction {
2245 command: "code {path}".into(),
2246 label: None,
2247 shortcut: Some("Ctrl+Shift+e".into()),
2248 hidden: false,
2249 }),
2250 ],
2251 ),
2252 ContextRule::rule(
2253 vec![ContextMatch::Fallback],
2254 vec![ContextAction::Builtin(BuiltinAction::Trash)],
2255 ),
2256 ],
2257 };
2258 let shortcuts = menu.shortcuts();
2259 assert_eq!(
2261 shortcuts,
2262 vec![
2263 ("F2".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Rename)),
2264 ("Ctrl+Shift+e".to_owned(), ShortcutTarget::Command(CommandAction {
2265 command: "code {path}".into(),
2266 label: None,
2267 shortcut: Some("Ctrl+Shift+e".into()),
2268 hidden: false,
2269 })),
2270 ("Delete".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Trash)),
2271 ]
2272 );
2273 }
2274
2275 #[test]
2276 fn table_form_spells_out_shortcuts_and_labels() {
2277 let raw = r#"
2278[[context_menu.rules]]
2279matches = ["fallback"]
2280items = [
2281 { action = "Cut", shortcut = "Ctrl+Alt+x", label = "Cut here" },
2282 { command = "sh {path}", shortcut = "Ctrl+Alt+s" },
2283 "---",
2284]
2285"#;
2286 let config = parse(raw);
2287 let actions = &config.context_menu.rules[0].items;
2288 assert_eq!(actions.len(), 3);
2289
2290 match &actions[0] {
2291 ContextAction::Entry(entry) => {
2292 assert_eq!(entry.action, BuiltinAction::Cut);
2293 assert_eq!(entry.shortcut.as_deref(), Some("Ctrl+Alt+x"));
2294 assert_eq!(entry.label.as_deref(), Some("Cut here"));
2295 assert_eq!(actions[0].label(), "Cut here");
2296 assert_eq!(actions[0].shortcut().as_deref(), Some("Ctrl+Alt+x"));
2297 }
2298 other => panic!("expected Entry, got {other:?}"),
2299 }
2300 match &actions[1] {
2301 ContextAction::Command(cmd) => {
2302 assert_eq!(cmd.shortcut.as_deref(), Some("Ctrl+Alt+s"));
2303 }
2304 other => panic!("expected Command, got {other:?}"),
2305 }
2306 match &actions[2] {
2307 ContextAction::Builtin(BuiltinAction::Separator) => {}
2308 other => panic!("expected Separator, got {other:?}"),
2309 }
2310
2311 let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
2312 assert_eq!(config, reparsed);
2313 }
2314
2315 #[test]
2316 fn pane_actions_are_classified() {
2317 for action in [
2318 BuiltinAction::OpenFolder,
2319 BuiltinAction::Filter,
2320 BuiltinAction::SplitView,
2321 BuiltinAction::Collapse,
2322 BuiltinAction::ClosePane,
2323 ] {
2324 assert!(action.is_pane_action(), "{action:?} should be a pane action");
2325 }
2326 for action in [
2327 BuiltinAction::Open,
2328 BuiltinAction::OpenSplit,
2329 BuiltinAction::ToggleHidden,
2330 BuiltinAction::SortByName,
2331 BuiltinAction::Separator,
2332 ] {
2333 assert!(!action.is_pane_action(), "{action:?} is not a pane action");
2334 }
2335 }
2336
2337 #[test]
2338 fn separator_matches_both_spellings() {
2339 assert_eq!(BuiltinAction::parse("---"), Some(BuiltinAction::Separator));
2340 assert_eq!(BuiltinAction::parse("separator"), Some(BuiltinAction::Separator));
2341 assert_eq!(BuiltinAction::parse("SEPARATOR"), Some(BuiltinAction::Separator));
2342 assert_eq!(BuiltinAction::Separator.as_str(), "---");
2343 }
2344
2345 #[test]
2346 fn ellipsis_labels_use_ascii_and_accept_the_typographic_form() {
2347 assert_eq!(BuiltinAction::OpenWith.as_str(), "Open With...");
2349 assert_eq!(BuiltinAction::OpenFolder.as_str(), "Open Folder...");
2350 assert_eq!(BuiltinAction::Filter.as_str(), "Filter...");
2351 assert_eq!(BuiltinAction::parse("Open With…"), Some(BuiltinAction::OpenWith));
2353 assert_eq!(BuiltinAction::parse("Open Folder…"), Some(BuiltinAction::OpenFolder));
2354 assert_eq!(BuiltinAction::parse("Filter…"), Some(BuiltinAction::Filter));
2355 assert_eq!(BuiltinAction::parse("open folder..."), Some(BuiltinAction::OpenFolder));
2357 }
2358
2359 #[test]
2360 fn pane_menu_defaults_include_view_and_pane_actions() {
2361 let menu = PaneMenu::default();
2362 let labels = menu.labels();
2363 assert!(labels.contains(&"Open Folder...".to_owned()));
2364 assert!(labels.contains(&"Split View".to_owned()));
2365 assert!(labels.contains(&"Close Pane".to_owned()));
2366 assert!(labels.contains(&"Toggle Hidden Files".to_owned()));
2367 assert!(labels.contains(&"Sort by Size".to_owned()));
2368 let dir_menu = ContextMenu::default();
2370 let dir_labels: Vec<String> = dir_menu
2371 .rules
2372 .iter()
2373 .flat_map(|rule| rule.items.iter())
2374 .map(ContextAction::label)
2375 .collect();
2376 assert!(!dir_labels.contains(&"Toggle Hidden Files".to_owned()));
2377 }
2378
2379 #[test]
2380 fn pane_menu_shortcuts_are_collected() {
2381 let menu = PaneMenu::default();
2382 let shortcuts = menu.shortcuts();
2383 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+h"));
2384 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+1"));
2385 assert!(shortcuts.iter().all(|(k, _)| !k.is_empty()));
2387 }
2388
2389 #[test]
2390 fn pane_menu_parses_custom_commands() {
2391 let toml = r#"
2392 items = [
2393 "Split View",
2394 "---",
2395 { command = "echo hi", label = "Say hi" },
2396 ]
2397 "#;
2398 let menu: PaneMenu = toml::from_str(toml).unwrap();
2399 assert_eq!(menu.labels(), vec!["Split View", "---", "Say hi"]);
2400 }
2401
2402 #[test]
2403 fn navigation_builtins_round_trip_and_are_pane_actions() {
2404 for action in [BuiltinAction::Up, BuiltinAction::Back, BuiltinAction::Forward] {
2405 let s = action.as_str();
2406 assert_eq!(BuiltinAction::parse(s), Some(action), "{s} should parse");
2407 assert!(action.is_pane_action(), "{s} should be a pane action");
2408 assert!(action.default_shortcut().is_some(), "{s} needs a default key");
2409 }
2410 assert_eq!(BuiltinAction::Up.default_shortcut(), Some("Alt+Up"));
2411 assert_eq!(BuiltinAction::Back.default_shortcut(), Some("Alt+Left"));
2412 assert_eq!(BuiltinAction::Forward.default_shortcut(), Some("Alt+Right"));
2413 }
2414
2415 #[test]
2416 fn hidden_items_parse_and_keep_their_shortcut() {
2417 let toml = r#"
2418 items = [
2419 { action = "Up One Level", hidden = true },
2420 { command = "echo hi", label = "Say hi", shortcut = "Ctrl+g", hidden = true },
2421 { label = "More", hidden = true, items = [ "Copy Path" ] },
2422 "Open",
2423 ]
2424 "#;
2425 let menu: PaneMenu = toml::from_str(toml).unwrap();
2426 assert!(menu.items[0].is_hidden());
2427 assert!(menu.items[1].is_hidden());
2428 assert!(menu.items[2].is_hidden());
2429 assert!(!menu.items[3].is_hidden());
2430 assert_eq!(menu.items[0].label(), "Up One Level");
2432 assert_eq!(menu.items[1].shortcut().as_deref(), Some("Ctrl+g"));
2433 let shortcuts = menu.shortcuts();
2435 assert!(shortcuts.iter().any(|(k, _)| k == "Alt+Up"));
2436 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+g"));
2437 }
2438
2439 #[test]
2440 fn hidden_submenu_children_keep_their_shortcuts() {
2441 let toml = r#"
2442 items = [
2443 { label = "More", hidden = true, items = [
2444 { action = "Rename", shortcut = "Ctrl+Shift+r" },
2445 ] },
2446 ]
2447 "#;
2448 let menu: PaneMenu = toml::from_str(toml).unwrap();
2449 assert!(menu.items[0].is_hidden());
2450 assert!(menu.shortcuts().iter().any(|(k, _)| k == "Ctrl+Shift+r"));
2451 }
2452
2453 #[test]
2454 fn hidden_defaults_to_false_and_is_not_serialized() {
2455 #[derive(serde::Deserialize)]
2456 struct One {
2457 item: ContextAction,
2458 }
2459 let visible: One = toml::from_str(r#"item = { action = "Cut", shortcut = "Ctrl+x" }"#).unwrap();
2460 assert!(!visible.item.is_hidden());
2461 let plain: One = toml::from_str(r#"item = "Cut""#).unwrap();
2463 assert!(!plain.item.is_hidden());
2464 #[derive(serde::Serialize)]
2466 struct OneOut {
2467 item: ContextAction,
2468 }
2469 let encoded = toml::to_string(&OneOut { item: visible.item }).unwrap();
2470 assert!(!encoded.contains("hidden"), "{encoded}");
2471 }
2472
2473 #[test]
2474 fn menu_items_parse_submenus_recursively() {
2475 let toml = r#"
2476 items = [
2477 "Open",
2478 { label = "More", items = [
2479 { action = "Copy Path", shortcut = "Ctrl+Shift+c" },
2480 { label = "Deeper", items = [ { command = "echo hi", label = "Hi" } ] },
2481 ] },
2482 ]
2483 "#;
2484 let menu: PaneMenu = toml::from_str(toml).unwrap();
2485 assert_eq!(menu.labels(), vec!["Open", "More"]);
2486 let sub = &menu.items[1];
2488 assert!(sub.is_submenu());
2489 assert!(sub.shortcut().is_none());
2490 let children = sub.submenu_items().unwrap();
2491 assert_eq!(children.len(), 2);
2492 assert_eq!(children[0].label(), "Copy Path");
2493 assert!(children[1].is_submenu());
2494 let shortcuts = menu.shortcuts();
2496 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
2497 }
2498
2499 #[test]
2500 fn context_menu_collects_nested_shortcuts() {
2501 let raw = r#"
2502 [[context_menu.rules]]
2503 matches = ["dir"]
2504 items = [
2505 "Open",
2506 { label = "More", items = [
2507 { action = "Copy Path", shortcut = "Ctrl+Shift+c" },
2508 ] },
2509 ]
2510 [[context_menu.rules]]
2511 matches = ["fallback"]
2512 items = ["Open"]
2513 "#;
2514 let config = parse(raw);
2515 let shortcuts = config.context_menu.shortcuts();
2516 assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
2517 }
2518
2519 #[test]
2520 fn startup_last_uses_session_root() {
2521 let root = StartupRoot::Last;
2522 let last = Some(PathBuf::from("/tmp/last"));
2523 assert_eq!(root.resolve(last.clone()), last);
2524 assert_eq!(root.resolve(None), None);
2525 }
2526
2527 #[test]
2528 fn startup_home_ignores_session_root() {
2529 let root = StartupRoot::Home;
2530 assert_eq!(root.resolve(Some(PathBuf::from("/tmp/last"))), None);
2531 }
2532
2533 #[test]
2534 fn startup_path_uses_configured_directory() {
2535 let root = StartupRoot::Path("/tmp/fixed".to_owned());
2536 assert_eq!(
2537 root.resolve(Some(PathBuf::from("/tmp/last"))),
2538 Some(PathBuf::from("/tmp/fixed"))
2539 );
2540 assert_eq!(StartupRoot::Path(String::new()).resolve(None), None);
2542 }
2543
2544 #[test]
2545 fn startup_bookmarks_selects_the_bookmarks_view() {
2546 let root = parse("startup = \"bookmarks\"\n").startup;
2547 assert_eq!(root, StartupRoot::Bookmarks);
2548 assert!(root.is_bookmarks());
2549 assert_eq!(root.resolve(Some(PathBuf::from("/tmp/last"))), None);
2551 }
2552
2553 #[test]
2554 fn bookmark_builtins_parse_and_classify() {
2555 assert_eq!(BuiltinAction::parse("Add Bookmark"), Some(BuiltinAction::AddBookmark));
2556 assert!(BuiltinAction::AddBookmark.is_directory_only());
2557 assert!(BuiltinAction::AddBookmark.is_single_row_only());
2558 assert_eq!(BuiltinAction::parse("Bookmarks"), Some(BuiltinAction::ToggleBookmarks));
2559 assert!(BuiltinAction::ToggleBookmarks.is_pane_action());
2560 assert_eq!(BuiltinAction::parse("Edit Bookmark"), Some(BuiltinAction::EditBookmark));
2561 assert_eq!(BuiltinAction::parse("Delete Bookmark"), Some(BuiltinAction::DeleteBookmark));
2562 assert_eq!(BuiltinAction::parse("New Bookmark"), Some(BuiltinAction::NewBookmark));
2563 assert_eq!(
2564 BuiltinAction::parse("New Bookmark Folder"),
2565 Some(BuiltinAction::NewBookmarkFolder)
2566 );
2567 assert!(BuiltinAction::EditBookmark.is_bookmark_only());
2568 assert!(BuiltinAction::DeleteBookmark.is_bookmark_only());
2569 assert!(BuiltinAction::NewBookmark.is_bookmark_only());
2570 assert!(BuiltinAction::NewBookmarkFolder.is_bookmark_only());
2571 assert!(BuiltinAction::NewBookmark.is_pane_action());
2572 }
2573
2574 #[test]
2575 fn bookmarks_menu_and_context_parse_from_the_shipped_file() {
2576 let shipped = Config::default().bookmarks;
2577 assert!(!shipped.menu.is_empty());
2578 assert!(!shipped.context.is_empty());
2579 assert!(!shipped.blank.is_empty());
2580 assert!(
2582 shipped
2583 .context
2584 .iter()
2585 .any(|a| a.shortcut().is_none()
2586 && matches!(a, ContextAction::Builtin(BuiltinAction::EditBookmark)))
2587 );
2588 assert!(
2590 shipped
2591 .blank
2592 .iter()
2593 .any(|a| matches!(a, ContextAction::Builtin(BuiltinAction::NewBookmark)))
2594 );
2595
2596 let partial = parse("[bookmarks]\nfile = \"bm.toml\"\n").bookmarks;
2598 assert_eq!(partial.menu, shipped.menu);
2599 assert_eq!(partial.context, shipped.context);
2600 assert_eq!(partial.blank, shipped.blank);
2601
2602 let custom = parse(
2604 "[bookmarks]\nmenu = [\"Open Folder...\"]\ncontext = [\"Delete Bookmark\"]\nblank = [\"New Bookmark\"]\n",
2605 )
2606 .bookmarks;
2607 assert_eq!(custom.menu.len(), 1);
2608 assert_eq!(custom.context.len(), 1);
2609 assert_eq!(custom.blank.len(), 1);
2610 }
2611
2612 #[test]
2613 fn bookmarks_list_round_trips_through_its_file() {
2614 let dir = tempfile::tempdir().unwrap();
2615 let path = dir.path().join("bookmarks.toml");
2616 let list = vec![
2617 Bookmark::leaf("Home".to_owned(), PathBuf::from("/home/x")),
2618 Bookmark::leaf("Code".to_owned(), PathBuf::from("/srv/code")),
2619 ];
2620 save_bookmarks_to_path(&path, &list).unwrap();
2621 let (loaded, problem) = load_bookmarks_from_path(&path);
2622 assert!(problem.is_none());
2623 assert_eq!(loaded, list);
2624 }
2625
2626 #[test]
2627 fn bookmark_folders_round_trip_and_are_searched() {
2628 let dir = tempfile::tempdir().unwrap();
2629 let path = dir.path().join("bookmarks.toml");
2630 let list = vec![Bookmark {
2631 name: "Work".to_owned(),
2632 path: None,
2633 items: vec![
2634 Bookmark::leaf("Repo".to_owned(), PathBuf::from("/srv/repo")),
2635 Bookmark {
2636 name: "Nested".to_owned(),
2637 path: None,
2638 items: vec![Bookmark::leaf("Deep".to_owned(), PathBuf::from("/srv/deep"))],
2639 expanded: true,
2640 },
2641 ],
2642 expanded: false,
2643 }];
2644 save_bookmarks_to_path(&path, &list).unwrap();
2645 let (loaded, problem) = load_bookmarks_from_path(&path);
2646 assert!(problem.is_none());
2647 assert_eq!(loaded, list);
2648
2649 assert!(Bookmark::contains_path(&loaded, Path::new("/srv/deep")));
2651 assert!(!Bookmark::contains_path(&loaded, Path::new("/nope")));
2652 let mut mutable = loaded.clone();
2653 assert!(Bookmark::remove(&mut mutable, &[0, 1, 0]));
2654 assert!(!Bookmark::contains_path(&mutable, Path::new("/srv/deep")));
2655 assert!(!Bookmark::remove(&mut mutable, &[0, 9]));
2656 }
2657
2658 #[test]
2659 fn remove_deletes_top_level_and_nested_entries() {
2660 let mut list = vec![
2661 Bookmark::leaf("A".to_owned(), PathBuf::from("/a")),
2662 Bookmark {
2663 name: "F".to_owned(),
2664 path: None,
2665 items: vec![Bookmark::leaf("B".to_owned(), PathBuf::from("/b"))],
2666 expanded: false,
2667 },
2668 Bookmark::leaf("C".to_owned(), PathBuf::from("/c")),
2669 ];
2670 assert!(Bookmark::remove(&mut list, &[1, 0]));
2672 assert!(!Bookmark::contains_path(&list, Path::new("/b")));
2673 assert!(Bookmark::remove(&mut list, &[1]));
2675 assert_eq!(list.len(), 2);
2676 assert!(Bookmark::remove(&mut list, &[0]));
2678 assert!(!Bookmark::contains_path(&list, Path::new("/a")));
2679 assert!(!Bookmark::remove(&mut list, &[]));
2681 assert!(!Bookmark::remove(&mut list, &[9]));
2682 }
2683
2684 #[test]
2685 fn missing_bookmarks_file_falls_back_to_default_without_problem() {
2686 let dir = tempfile::tempdir().unwrap();
2687 let path = dir.path().join("nope.toml");
2688 let (loaded, problem) = load_bookmarks_from_path(&path);
2689 assert!(problem.is_none());
2690 assert_eq!(loaded, default_bookmarks());
2691 }
2692
2693 #[test]
2694 fn bookmarks_file_is_written_compactly() {
2695 let dir = tempfile::tempdir().unwrap();
2696 let path = dir.path().join("bookmarks.toml");
2697 let list = vec![
2698 Bookmark::leaf("Home".to_owned(), PathBuf::from("/home/me")),
2699 Bookmark {
2700 name: "Work".to_owned(),
2701 path: None,
2702 items: vec![Bookmark::leaf("Repo".to_owned(), PathBuf::from("/srv/repo"))],
2703 expanded: true,
2704 },
2705 ];
2706 save_bookmarks_to_path(&path, &list).unwrap();
2707 let text = std::fs::read_to_string(&path).unwrap();
2708 assert!(text.starts_with("bookmarks = [\n"), "{text}");
2710 assert!(!text.contains("[[bookmarks]]"), "{text}");
2711 assert!(!text.contains("[[bookmarks.items]]"), "{text}");
2712 let (loaded, problem) = load_bookmarks_from_path(&path);
2714 assert!(problem.is_none());
2715 assert_eq!(loaded, list);
2716 }
2717
2718 #[test]
2719 fn bookmark_strings_are_quoted_and_escaped() {
2720 assert_eq!(toml_string("plain"), "\"plain\"");
2721 assert_eq!(toml_string("a\"b\\c\n\t"), "\"a\\\"b\\\\c\\n\\t\"");
2722 }
2723
2724 #[test]
2725 fn ensure_bookmarks_creates_and_never_clobbers() {
2726 let dir = tempfile::tempdir().unwrap();
2727 let path = dir.path().join("bookmarks.toml");
2728 let list = vec![Bookmark::leaf("Home".to_owned(), PathBuf::from("/home/x"))];
2729 ensure_bookmarks_at(&path, &list).unwrap();
2730 let (loaded, _) = load_bookmarks_from_path(&path);
2731 assert_eq!(loaded, list);
2732 let other = vec![Bookmark::leaf("Other".to_owned(), PathBuf::from("/tmp"))];
2734 ensure_bookmarks_at(&path, &other).unwrap();
2735 let (loaded, _) = load_bookmarks_from_path(&path);
2736 assert_eq!(loaded, list);
2737 }
2738
2739 #[test]
2740 fn bookmark_name_defaults_to_the_directory_name() {
2741 assert_eq!(Bookmark::default_name(Path::new("/home/eolu/notes")), "notes");
2742 assert_eq!(Bookmark::default_name(Path::new("/")), "/");
2743 }
2744
2745 #[test]
2746 fn startup_tilde_expands_to_home() {
2747 unsafe { std::env::set_var("HOME", "/home/tester") };
2749 let root = StartupRoot::Path("~/notes".to_owned());
2750 assert_eq!(root.resolve(None), Some(PathBuf::from("/home/tester/notes")));
2751 assert_eq!(expand_tilde("~"), Some(PathBuf::from("/home/tester")));
2752 assert_eq!(expand_tilde("/abs/path"), Some(PathBuf::from("/abs/path")));
2753 assert_eq!(expand_tilde(""), None);
2754 let rel = expand_tilde("some/relative/dir").unwrap();
2757 assert!(rel.is_absolute(), "{rel:?} should be absolute");
2758 assert!(rel.ends_with("some/relative/dir"), "{rel:?}");
2759 }
2760
2761 #[test]
2762 fn startup_parses_from_toml() {
2763 assert_eq!(parse("startup = \"home\"\n").startup, StartupRoot::Home);
2764 assert_eq!(parse("startup = \"last\"\n").startup, StartupRoot::Last);
2765 assert_eq!(parse("startup = \"bookmarks\"\n").startup, StartupRoot::Bookmarks);
2766 assert_eq!(
2767 parse("startup = { path = \"/srv\" }\n").startup,
2768 StartupRoot::Path("/srv".to_owned())
2769 );
2770 assert_eq!(Config::default().startup, StartupRoot::Bookmarks);
2772 assert!(
2774 Config::parse("startup = \"bogus\"\n", Path::new("test.toml"))
2775 .problem
2776 .is_some()
2777 );
2778 let cfg = Config { startup: StartupRoot::Path("/srv".to_owned()), ..Config::default() };
2780 let text = toml::to_string(&cfg).unwrap();
2781 assert_eq!(toml::from_str::<Config>(&text).unwrap().startup, cfg.startup);
2782 }
2783
2784 #[test]
2785 fn context_matches_classify_rows() {
2786 let dir = tempfile::tempdir().unwrap();
2787 let file = dir.path().join("report.md");
2788 std::fs::write(&file, "").unwrap();
2789 let noext = dir.path().join("Makefile");
2790 std::fs::write(&noext, "").unwrap();
2791 let tarball = dir.path().join("app.tar.gz");
2792 std::fs::write(&tarball, "").unwrap();
2793
2794 assert!(ContextMatch::parse("dir").unwrap().matches(dir.path()));
2795 assert!(!ContextMatch::parse("dir").unwrap().matches(&file));
2796 assert!(ContextMatch::parse("noext").unwrap().matches(&noext));
2797 assert!(!ContextMatch::parse("noext").unwrap().matches(&file));
2798 assert!(ContextMatch::parse("ext:md").unwrap().matches(&file));
2799 assert!(ContextMatch::parse("ext:MD").unwrap().matches(&file));
2800 assert!(ContextMatch::parse("ext:gz").unwrap().matches(&tarball));
2801 assert!(!ContextMatch::parse("ext:md").unwrap().matches(dir.path()));
2802 assert_eq!(ContextMatch::parse("directory"), Ok(ContextMatch::Dir));
2803 assert_eq!(ContextMatch::parse("*"), Ok(ContextMatch::Fallback));
2804 assert!(ContextMatch::parse("bogus").is_err());
2805 }
2806
2807 #[test]
2808 fn regex_matcher_matches_the_full_path() {
2809 let dir = tempfile::tempdir().unwrap();
2810 let md = dir.path().join("guide.md");
2811 std::fs::write(&md, "").unwrap();
2812
2813 assert!(ContextMatch::parse(r"regex:\.md$").unwrap().matches(&md));
2815 assert!(!ContextMatch::parse(r"regex:\.rs$").unwrap().matches(&md));
2816
2817 let exact = format!("regex:^{}$", regex::escape(dir.path().to_str().unwrap()));
2819 assert!(ContextMatch::parse(&exact).unwrap().matches(dir.path()));
2820
2821 let parsed = ContextMatch::parse(r"regex:\.rs$").unwrap();
2823 assert_eq!(parsed.as_str(), r"regex:\.rs$");
2824 let reparsed = ContextMatch::parse(&parsed.as_str()).unwrap();
2825 assert_eq!(parsed, reparsed);
2826 }
2827
2828 #[test]
2829 fn invalid_regex_is_a_parse_error() {
2830 let err = ContextMatch::parse("regex:(").unwrap_err();
2831 assert!(err.contains("invalid regex"), "got: {err}");
2832 }
2833
2834 #[test]
2835 fn stylesheet_prefers_the_user_file_and_materializes_a_default() {
2836 let dir = tempfile::tempdir().unwrap();
2837 let path = dir.path().join("tree-space/main.css");
2838
2839 let first = load_stylesheet_from_path(&path);
2841 assert_eq!(first.source, StyleSource::Builtin);
2842 assert!(first.problem.is_none());
2843 assert_eq!(first.css, DEFAULT_STYLESHEET);
2844
2845 ensure_default_stylesheet(&path).unwrap();
2847 assert_eq!(std::fs::read_to_string(&path).unwrap(), DEFAULT_STYLESHEET);
2848
2849 std::fs::write(&path, ".tree-row { color: red; }").unwrap();
2851 let user = load_stylesheet_from_path(&path);
2852 assert_eq!(user.source, StyleSource::User);
2853 assert!(user.problem.is_none());
2854 assert_eq!(user.css, ".tree-row { color: red; }");
2855 }
2856
2857 #[test]
2858 fn materialize_never_overwrites_an_existing_stylesheet() {
2859 let dir = tempfile::tempdir().unwrap();
2860 let path = dir.path().join("tree-space/main.css");
2861 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2862 std::fs::write(&path, "/* mine */").unwrap();
2863
2864 ensure_default_stylesheet(&path).unwrap();
2865 assert_eq!(std::fs::read_to_string(&path).unwrap(), "/* mine */");
2866 }
2867
2868 #[test]
2869 fn unreadable_stylesheet_falls_back_to_builtin_with_a_problem() {
2870 let dir = tempfile::tempdir().unwrap();
2871 let path = dir.path().join("main.css");
2874 std::fs::create_dir(&path).unwrap();
2875
2876 let stylesheet = load_stylesheet_from_path(&path);
2877 assert_eq!(stylesheet.source, StyleSource::Builtin);
2878 assert_eq!(stylesheet.css, DEFAULT_STYLESHEET);
2879 assert!(matches!(stylesheet.problem, Some(LoadProblem::Io(..))));
2880 }
2881
2882 #[test]
2883 fn include_rule_splices_files_in_place_and_name_sorted() { let dir = tempfile::tempdir().unwrap();
2884 let rules_dir = dir.path().join("rules.d");
2885 std::fs::create_dir_all(&rules_dir).unwrap();
2886 std::fs::write(
2888 rules_dir.join("20-second.toml"),
2889 "[[context_menu.rules]]\nmatches = [\"ext:b\"]\nitems = [\"Properties\"]\n",
2890 )
2891 .unwrap();
2892 std::fs::write(
2893 rules_dir.join("10-first.toml"),
2894 "[[context_menu.rules]]\nmatches = [\"ext:a\"]\nitems = [\"Properties\", \"Copy Path\"]\n",
2895 )
2896 .unwrap();
2897 std::fs::write(rules_dir.join("README.md"), "not config").unwrap();
2899
2900 let main = dir.path().join("config.toml");
2901 std::fs::write(
2902 &main,
2903 r#"
2904[[context_menu.rules]]
2905matches = ["dir"]
2906items = ["Open"]
2907
2908[[context_menu.rules]]
2909include = "rules.d"
2910
2911[[context_menu.rules]]
2912matches = ["fallback"]
2913items = ["Move to Trash"]
2914"#,
2915 )
2916 .unwrap();
2917
2918 let config = Config::load_from_path(&main);
2919 assert!(config.problem.is_none(), "{:?}", config.problem);
2920 let rules = &config.config.context_menu.rules;
2921 assert_eq!(rules.len(), 4);
2923 assert_eq!(rules[0].matches, vec![ContextMatch::Dir]);
2924 assert_eq!(rules[1].matches, vec![ContextMatch::Ext("a".to_owned())]);
2925 assert_eq!(rules[2].matches, vec![ContextMatch::Ext("b".to_owned())]);
2926 assert_eq!(rules[3].matches, vec![ContextMatch::Fallback]);
2927 assert!(rules.iter().all(|r| r.include.is_none()));
2929 }
2930
2931 #[test]
2932 fn include_of_a_missing_directory_contributes_nothing() {
2933 let dir = tempfile::tempdir().unwrap();
2934 let main = dir.path().join("config.toml");
2935 std::fs::write(
2936 &main,
2937 "[[context_menu.rules]]\ninclude = \"nope.d\"\n\n[[context_menu.rules]]\nmatches = [\"fallback\"]\nitems = [\"Move to Trash\"]\n",
2938 )
2939 .unwrap();
2940 let result = Config::load_from_path(&main);
2941 assert!(result.problem.is_none(), "{:?}", result.problem);
2943 assert_eq!(result.config.context_menu.rules.len(), 1);
2944 assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Fallback]);
2945 }
2946
2947 #[test]
2948 fn include_of_an_invalid_file_is_reported_but_others_still_load() {
2949 let dir = tempfile::tempdir().unwrap();
2950 let rules_dir = dir.path().join("rules.d");
2951 std::fs::create_dir_all(&rules_dir).unwrap();
2952 std::fs::write(rules_dir.join("10-good.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
2953 std::fs::write(rules_dir.join("20-bad.toml"), "this is not = valid toml [").unwrap();
2954
2955 let main = dir.path().join("config.toml");
2956 std::fs::write(
2957 &main,
2958 "[[context_menu.rules]]\ninclude = \"rules.d\"\n",
2959 )
2960 .unwrap();
2961
2962 let result = Config::load_from_path(&main);
2963 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))), "{:?}", result.problem);
2965 assert_eq!(result.config.context_menu.rules.len(), 1);
2967 assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
2968 }
2969
2970 #[test]
2971 fn included_rules_are_expanded_recursively() {
2972 let dir = tempfile::tempdir().unwrap();
2973 let outer = dir.path().join("outer.d");
2974 let inner = outer.join("inner.d");
2975 std::fs::create_dir_all(&inner).unwrap();
2976 std::fs::write(inner.join("nested.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
2977 std::fs::write(
2978 outer.join("mid.toml"),
2979 "[[context_menu.rules]]\ninclude = \"inner.d\"\n",
2980 )
2981 .unwrap();
2982
2983 let main = dir.path().join("config.toml");
2984 std::fs::write(&main, "[[context_menu.rules]]\ninclude = \"outer.d\"\n").unwrap();
2985
2986 let result = Config::load_from_path(&main);
2987 assert!(result.problem.is_none(), "{:?}", result.problem);
2988 assert_eq!(result.config.context_menu.rules.len(), 1);
2989 assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
2990 }
2991
2992 #[test]
2993 fn first_matching_rule_wins() {
2994 let menu = ContextMenu {
2995 rules: vec![
2996 ContextRule::rule(
2997 vec![ContextMatch::Ext("md".to_owned())],
2998 builtin_items![CopyPath],
2999 ),
3000 ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
3001 ],
3002 };
3003 let dir = tempfile::tempdir().unwrap();
3004 let md = dir.path().join("a.md");
3005 std::fs::write(&md, "").unwrap();
3006 let txt = dir.path().join("a.txt");
3007 std::fs::write(&txt, "").unwrap();
3008
3009 assert_eq!(menu.actions_for(&md), builtin_items![CopyPath]);
3010 assert_eq!(menu.actions_for(&txt), builtin_items![Open]);
3012 }
3013
3014 #[test]
3015 fn multi_matcher_parses_and_never_matches_a_single_path() {
3016 assert_eq!(ContextMatch::parse("multi").unwrap(), ContextMatch::Multi);
3017 assert_eq!(ContextMatch::parse("multi-select").unwrap(), ContextMatch::Multi);
3018 assert_eq!(ContextMatch::Multi.as_str(), "multi");
3019 let dir = tempfile::tempdir().unwrap();
3021 assert!(!ContextMatch::Multi.matches(dir.path()));
3022 }
3023
3024 #[test]
3025 fn multi_rule_wins_for_a_multi_selection_only() {
3026 let menu = ContextMenu {
3027 rules: vec![
3028 ContextRule::rule(vec![ContextMatch::Multi], builtin_items![Trash, CopyPath]),
3029 ContextRule::rule(vec![ContextMatch::Dir], builtin_items![Open]),
3030 ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
3031 ],
3032 };
3033 let dir = tempfile::tempdir().unwrap();
3034
3035 assert_eq!(menu.actions_for_selection(dir.path(), 1), builtin_items![Open]);
3037 assert_eq!(
3039 menu.actions_for_selection(dir.path(), 2),
3040 builtin_items![Trash, CopyPath]
3041 );
3042 assert_eq!(
3043 menu.actions_for_selection(dir.path(), 7),
3044 builtin_items![Trash, CopyPath]
3045 );
3046 }
3047
3048 #[test]
3049 fn shipped_multi_rule_offers_selection_actions() {
3050 let menu = ContextMenu::default();
3051 let dir = tempfile::tempdir().unwrap();
3052 let file = dir.path().join("a.txt");
3053 std::fs::write(&file, "").unwrap();
3054
3055 let single = menu.actions_for_selection(&file, 1);
3057 assert_eq!(single, menu.actions_for(&file));
3058
3059 let labels = all_labels(&menu.actions_for_selection(&file, 3));
3062 assert!(labels.contains(&"Move to Trash".to_owned()), "{labels:?}");
3063 assert!(labels.contains(&"Copy Path".to_owned()), "{labels:?}");
3064 assert!(labels.contains(&"Properties".to_owned()), "{labels:?}");
3065 assert!(!labels.contains(&"Rename".to_owned()), "{labels:?}");
3066 assert!(!labels.contains(&"Duplicate".to_owned()), "{labels:?}");
3067 assert!(!labels.contains(&"With...".to_owned()), "{labels:?}");
3068 }
3069
3070 #[test]
3071 fn single_row_only_actions_are_classified() {
3072 assert!(BuiltinAction::Rename.is_single_row_only());
3073 assert!(BuiltinAction::Open.is_single_row_only());
3074 assert!(BuiltinAction::Duplicate.is_single_row_only());
3075 assert!(!BuiltinAction::Trash.is_single_row_only());
3076 assert!(!BuiltinAction::DeletePermanently.is_single_row_only());
3077 assert!(!BuiltinAction::Copy.is_single_row_only());
3078 assert!(!BuiltinAction::Cut.is_single_row_only());
3079 assert!(!BuiltinAction::Properties.is_single_row_only());
3080 assert!(!BuiltinAction::CopyPath.is_single_row_only());
3081 }
3082
3083 #[test]
3084 fn empty_rule_list_falls_back_to_classic_menu() {
3085 let menu = ContextMenu { rules: Vec::new() };
3086 let dir = tempfile::tempdir().unwrap();
3087 assert_eq!(menu.actions_for(dir.path()), ContextMenu::fallback_actions(dir.path()));
3088 }
3089
3090 #[test]
3091 fn custom_context_menu_round_trips() {
3092 let raw = r#"
3093[[context_menu.rules]]
3094matches = ["ext:rs", "ext:toml"]
3095items = ["Open", { command = "cargo fmt", label = "Fmt" }]
3096
3097[[context_menu.rules]]
3098matches = ["dir"]
3099items = [{ command = "foot -D {dir}", label = "Open in Terminal" }, "New Folder"]
3100
3101[[context_menu.rules]]
3102matches = ["noext"]
3103items = [{ command = "sh {path}" }]
3104
3105[[context_menu.rules]]
3106matches = ['regex:.*\.lock$']
3107items = ["Open", { command = "rm {path}", label = "Discard lock" }]
3108"#;
3109 let config = parse(raw);
3110 assert_eq!(config.context_menu.rules.len(), 4);
3111
3112 let first = &config.context_menu.rules[0];
3113 assert_eq!(
3114 first.matches,
3115 vec![ContextMatch::Ext("rs".to_owned()), ContextMatch::Ext("toml".to_owned())]
3116 );
3117 assert!(matches!(first.items[0], ContextAction::Builtin(BuiltinAction::Open)));
3118 assert!(matches!(
3119 &first.items[1],
3120 ContextAction::Command(cmd) if cmd.command == "cargo fmt" && cmd.label.as_deref() == Some("Fmt")
3121 ));
3122
3123 let last = &config.context_menu.rules[3];
3124 assert_eq!(
3125 last.matches,
3126 vec![ContextMatch::parse(r"regex:.*\.lock$").unwrap()]
3127 );
3128
3129 let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
3130 assert_eq!(config, reparsed);
3131 }
3132
3133 #[test]
3134 fn unknown_builtin_action_is_a_parse_problem() {
3135 let result = Config::parse(
3136 "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Frobnicate\"]\n",
3137 Path::new("bad-menu.toml"),
3138 );
3139 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
3140 assert_eq!(result.config, Config::default());
3141 }
3142
3143 #[test]
3144 fn unknown_context_matcher_is_a_parse_problem() {
3145 let result = Config::parse(
3146 "[[context_menu.rules]]\nmatches = [\"gibberish\"]\nitems = [\"Open\"]\n",
3147 Path::new("bad-matcher.toml"),
3148 );
3149 assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
3150 assert_eq!(result.config, Config::default());
3151 }
3152
3153 #[test]
3154 fn action_command_substitutes_markers() {
3155 let dir = tempfile::tempdir().unwrap();
3156 let dir_s = dir.path().to_string_lossy().into_owned();
3157 assert_eq!(
3158 action_command("code {path}", dir.path()),
3159 vec!["code".to_owned(), dir_s.clone()]
3160 );
3161 assert_eq!(
3162 action_command("code {dir}", &dir.path().join("x.rs")),
3163 vec!["code".to_owned(), dir_s]
3164 );
3165 }
3166
3167 #[test]
3168 fn action_command_appends_path_without_marker() {
3169 assert_eq!(
3170 action_command("sh", Path::new("/tmp/s.sh")),
3171 vec!["sh".to_owned(), "/tmp/s.sh".to_owned()]
3172 );
3173 }
3174}