1use std::collections::{HashMap, HashSet};
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
8
9use gravityfile_analyze::{AgeReport, DuplicateReport};
10use gravityfile_core::FileTree;
11use gravityfile_ops::{ConflictRequest, OperationProgress, OperationType};
12use gravityfile_scan::ScanProgress;
13
14use crate::ui::{MillerState, TreeState, TreemapState};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum AppMode {
21 #[default]
22 Normal,
23 Help,
24 Search,
26 ConfirmDelete,
28 Deleting,
30 Copying,
32 Moving,
34 Undoing,
36 UpdatingEntry,
38 Renaming,
40 CreatingFile,
42 CreatingDirectory,
44 Taking,
46 GoingTo,
48 ConflictResolution,
50 Command,
52 Settings,
54 SettingBookmark,
56 JumpingToBookmark,
58 BookmarkList,
60 ConfirmBulkRename,
62 BulkRenaming,
64 Visual,
66 Quit,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum LayoutMode {
73 #[default]
75 Tree,
76 Miller,
78}
79
80impl LayoutMode {
81 pub fn toggle(self) -> Self {
83 match self {
84 Self::Tree => Self::Miller,
85 Self::Miller => Self::Tree,
86 }
87 }
88
89 pub fn name(self) -> &'static str {
91 match self {
92 Self::Tree => "tree",
93 Self::Miller => "miller",
94 }
95 }
96}
97
98#[derive(Debug, Clone, Default)]
100pub struct VisualState {
101 pub anchor: usize,
103 pub cursor: usize,
105}
106
107impl VisualState {
108 pub fn new(start_index: usize) -> Self {
110 Self {
111 anchor: start_index,
112 cursor: start_index,
113 }
114 }
115
116 pub fn selection_range(&self) -> (usize, usize) {
118 let start = self.anchor.min(self.cursor);
119 let end = self.anchor.max(self.cursor);
120 (start, end)
121 }
122
123 pub fn move_up(&mut self) {
125 self.cursor = self.cursor.saturating_sub(1);
126 }
127
128 pub fn move_down(&mut self, max: usize) {
130 if max > 0 && self.cursor < max - 1 {
131 self.cursor += 1;
132 }
133 }
134
135 pub fn page_up(&mut self, page_size: usize) {
137 self.cursor = self.cursor.saturating_sub(page_size);
138 }
139
140 pub fn page_down(&mut self, page_size: usize, max: usize) {
142 if max > 0 {
143 self.cursor = (self.cursor + page_size).min(max - 1);
144 }
145 }
146
147 pub fn jump_to_top(&mut self) {
149 self.cursor = 0;
150 }
151
152 pub fn jump_to_bottom(&mut self, max: usize) {
154 if max > 0 {
155 self.cursor = max - 1;
156 }
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
162pub enum ClipboardMode {
163 #[default]
165 Empty,
166 Copy,
168 Cut,
170}
171
172#[derive(Debug, Clone, Default)]
174pub struct ClipboardState {
175 pub paths: Vec<PathBuf>,
177 pub mode: ClipboardMode,
179 pub source_root: Option<PathBuf>,
181}
182
183impl ClipboardState {
184 pub fn yank(&mut self, paths: impl IntoIterator<Item = PathBuf>, source_root: PathBuf) {
186 self.paths = paths.into_iter().collect();
187 self.mode = ClipboardMode::Copy;
188 self.source_root = Some(source_root);
189 }
190
191 pub fn cut(&mut self, paths: impl IntoIterator<Item = PathBuf>, source_root: PathBuf) {
193 self.paths = paths.into_iter().collect();
194 self.mode = ClipboardMode::Cut;
195 self.source_root = Some(source_root);
196 }
197
198 pub fn clear(&mut self) {
200 self.paths.clear();
201 self.mode = ClipboardMode::Empty;
202 self.source_root = None;
203 }
204
205 pub fn is_empty(&self) -> bool {
207 self.paths.is_empty()
208 }
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, EnumIter, FromRepr)]
215pub enum SortMode {
216 #[default]
218 #[strum(to_string = "Size ↓")]
219 SizeDescending,
220 #[strum(to_string = "Size ↑")]
222 SizeAscending,
223 #[strum(to_string = "Name ↑")]
225 NameAscending,
226 #[strum(to_string = "Name ↓")]
228 NameDescending,
229 #[strum(to_string = "Date ↓")]
231 ModifiedDescending,
232 #[strum(to_string = "Date ↑")]
234 ModifiedAscending,
235 #[strum(to_string = "Count ↓")]
237 CountDescending,
238 #[strum(to_string = "Count ↑")]
240 CountAscending,
241}
242
243impl SortMode {
244 pub fn next(self) -> Self {
246 let current = self as usize;
247 let next = (current + 1) % Self::iter().count();
248 Self::from_repr(next).unwrap_or_default()
249 }
250
251 pub fn reverse(self) -> Self {
253 match self {
254 Self::SizeDescending => Self::SizeAscending,
255 Self::SizeAscending => Self::SizeDescending,
256 Self::NameAscending => Self::NameDescending,
257 Self::NameDescending => Self::NameAscending,
258 Self::ModifiedDescending => Self::ModifiedAscending,
259 Self::ModifiedAscending => Self::ModifiedDescending,
260 Self::CountDescending => Self::CountAscending,
261 Self::CountAscending => Self::CountDescending,
262 }
263 }
264
265 pub fn short_label(&self) -> &'static str {
268 match self {
269 Self::SizeDescending => "SZ↓",
270 Self::SizeAscending => "SZ↑",
271 Self::NameAscending => "NM↑",
272 Self::NameDescending => "NM↓",
273 Self::ModifiedDescending => "DT↓",
274 Self::ModifiedAscending => "DT↑",
275 Self::CountDescending => "CT↓",
276 Self::CountAscending => "CT↑",
277 }
278 }
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, EnumIter, FromRepr)]
283pub enum View {
284 #[default]
285 Explorer,
286 Duplicates,
287 Age,
288 Errors,
289 Treemap,
290}
291
292impl View {
293 pub fn next(self) -> Self {
295 let current = self as usize;
296 let next = (current + 1) % Self::iter().count();
297 Self::from_repr(next).unwrap_or_default()
298 }
299
300 pub fn prev(self) -> Self {
302 let current = self as usize;
303 let count = Self::iter().count();
304 let prev = (current + count - 1) % count;
305 Self::from_repr(prev).unwrap_or_default()
306 }
307}
308
309#[derive(Debug, Clone)]
311pub struct DeletionProgress {
312 pub total: usize,
314 pub deleted: usize,
316 pub failed: usize,
318 pub bytes_freed: u64,
320 pub current: Option<PathBuf>,
322}
323
324impl DeletionProgress {
325 #[cfg(test)]
327 pub fn new(total: usize) -> Self {
328 Self {
329 total,
330 deleted: 0,
331 failed: 0,
332 bytes_freed: 0,
333 current: None,
334 }
335 }
336
337 pub fn percentage(&self) -> u16 {
339 if self.total > 0 {
340 ((self.deleted + self.failed) as f64 / self.total as f64 * 100.0) as u16
341 } else {
342 0
343 }
344 }
345}
346
347pub enum ScanResult {
349 Progress(ScanProgress),
350 ScanComplete(Result<FileTree, gravityfile_scan::ScanError>),
351 AnalysisComplete {
352 duplicates: DuplicateReport,
353 age_report: AgeReport,
354 duplicate_scope: (PathBuf, Vec<(usize, bool)>),
356 },
357 AnalysisFailed(String),
358 AnalysisCancelled,
359 DeletionProgress(DeletionProgress),
361 DeletionComplete {
363 mode: gravityfile_ops::DeleteMode,
364 deleted: Vec<PathBuf>,
366 failed: usize,
367 bytes_freed: u64,
368 errors: Vec<gravityfile_ops::OperationError>,
369 },
370 OperationProgress(OperationProgress),
372 OperationConflict(ConflictRequest),
374 TakeComplete {
376 path: PathBuf,
377 result: Result<bool, String>,
378 },
379 BulkRenameComplete(Result<Vec<(std::path::PathBuf, std::path::PathBuf)>, String>),
381 OperationComplete {
383 cancelled: bool,
384 operation_type: OperationType,
385 succeeded: usize,
386 failed: usize,
387 skipped: usize,
388 bytes_processed: u64,
389 errors: Vec<gravityfile_ops::OperationError>,
390 undo_info: Option<gravityfile_ops::UndoableOperation>,
392 undo_remaining: Option<gravityfile_ops::UndoEntry>,
394 touched: Vec<PathBuf>,
396 },
397}
398
399pub struct Refreshed {
401 pub path: PathBuf,
402 pub entry: Result<Option<gravityfile_scan::ScannedEntry>, String>,
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
408pub enum ListingFor {
409 Children,
411 Parent,
413}
414
415pub struct Listed {
417 pub dir: PathBuf,
418 pub purpose: ListingFor,
419 pub result: Result<FileTree, String>,
420}
421
422#[derive(Debug, Clone)]
424pub struct SelectedInfo {
425 pub name: String,
426 pub path: PathBuf,
427 pub size: u64,
428 pub file_count: u64,
429 pub dir_count: u64,
430 pub modified: std::time::SystemTime,
431 pub is_dir: bool,
432}
433
434#[derive(Debug, Clone, Default)]
436pub struct DuplicatesViewState {
437 pub selected_group: usize,
439 pub expanded_groups: std::collections::HashSet<usize>,
441 pub selected_in_group: std::collections::HashMap<usize, usize>,
444}
445
446impl DuplicatesViewState {
447 pub fn new() -> Self {
449 Self::default()
450 }
451
452 pub fn toggle_expand(&mut self) {
454 if self.expanded_groups.contains(&self.selected_group) {
455 self.expanded_groups.remove(&self.selected_group);
456 self.selected_in_group.remove(&self.selected_group);
457 } else {
458 self.expanded_groups.insert(self.selected_group);
459 self.selected_in_group.insert(self.selected_group, 0);
460 }
461 }
462
463 pub fn is_expanded(&self, group_index: usize) -> bool {
465 self.expanded_groups.contains(&group_index)
466 }
467
468 pub fn selected_item(&self, group_index: usize) -> usize {
470 self.selected_in_group
471 .get(&group_index)
472 .copied()
473 .unwrap_or(0)
474 }
475
476 pub fn move_up(&mut self, _group_count: usize, get_file_count: impl Fn(usize) -> usize) {
478 if self.is_expanded(self.selected_group) {
479 let selected = self.selected_item(self.selected_group);
480 if selected > 0 {
481 self.selected_in_group
483 .insert(self.selected_group, selected - 1);
484 return;
485 }
486 }
487 if self.selected_group > 0 {
489 self.selected_group -= 1;
490 if self.is_expanded(self.selected_group) {
492 let file_count = get_file_count(self.selected_group);
493 self.selected_in_group
494 .insert(self.selected_group, file_count);
495 }
496 }
497 }
498
499 pub fn move_down(&mut self, group_count: usize, get_file_count: impl Fn(usize) -> usize) {
501 if self.is_expanded(self.selected_group) {
502 let file_count = get_file_count(self.selected_group);
503 let selected = self.selected_item(self.selected_group);
504 if selected < file_count {
505 self.selected_in_group
507 .insert(self.selected_group, selected + 1);
508 return;
509 }
510 }
511 if self.selected_group < group_count.saturating_sub(1) {
513 self.selected_group += 1;
514 if self.is_expanded(self.selected_group) {
516 self.selected_in_group.insert(self.selected_group, 0);
517 }
518 }
519 }
520
521 pub fn reset(&mut self) {
523 *self = Self::default();
524 }
525
526 pub fn selected_file_path<'a>(
528 &self,
529 groups: &'a [&gravityfile_analyze::DuplicateGroup],
530 ) -> Option<&'a PathBuf> {
531 let group = groups.get(self.selected_group)?;
532 let selected = self.selected_item(self.selected_group);
533 if selected == 0 || !self.is_expanded(self.selected_group) {
534 None } else {
536 group.paths.get(selected - 1)
537 }
538 }
539}
540
541#[derive(Debug, Clone, PartialEq, Eq)]
543pub struct Visit {
544 pub dir: PathBuf,
546 pub selected: Option<PathBuf>,
549 pub expanded: HashSet<PathBuf>,
551}
552
553#[derive(Debug, Clone, Default)]
556pub struct Navigation {
557 pub view_root: PathBuf,
559 pub tree_state: TreeState,
561 pub miller_state: MillerState,
563 pub treemap_state: TreemapState,
565 pub back: Vec<Visit>,
567 pub forward: Vec<Visit>,
569}
570
571impl Navigation {
572 pub fn new(view_root: PathBuf) -> Self {
574 Self {
575 tree_state: TreeState::new(view_root.clone()),
576 view_root,
577 ..Self::default()
578 }
579 }
580
581 pub fn label(&self) -> String {
583 self.view_root
584 .file_name()
585 .unwrap_or(self.view_root.as_os_str())
586 .to_string_lossy()
587 .into_owned()
588 }
589}
590
591#[derive(Debug)]
595pub struct TabManager {
596 stashed: Vec<Navigation>,
598 active: usize,
600 max_tabs: usize,
602}
603
604impl TabManager {
605 pub fn new(max_tabs: usize) -> Self {
607 Self {
608 stashed: vec![Navigation::default()],
609 active: 0,
610 max_tabs,
611 }
612 }
613
614 pub fn len(&self) -> usize {
616 self.stashed.len()
617 }
618
619 pub fn active_index(&self) -> usize {
621 self.active
622 }
623
624 pub fn labels(&self, current: &Navigation) -> Vec<String> {
626 (0..self.stashed.len())
627 .map(|index| {
628 if index == self.active {
629 current.label()
630 } else {
631 self.stashed[index].label()
632 }
633 })
634 .collect()
635 }
636
637 pub fn open(&mut self, current: &mut Navigation, navigation: Navigation) -> bool {
640 if self.stashed.len() >= self.max_tabs {
641 return false;
642 }
643 self.stashed.push(navigation);
644 self.switch(current, self.stashed.len() - 1)
645 }
646
647 pub fn close_active(&mut self, current: &mut Navigation) -> bool {
650 if self.stashed.len() <= 1 {
651 return false;
652 }
653 self.stashed.remove(self.active);
654 self.active = self.active.min(self.stashed.len() - 1);
655 std::mem::swap(current, &mut self.stashed[self.active]);
656 true
657 }
658
659 pub fn switch(&mut self, current: &mut Navigation, index: usize) -> bool {
661 if index == self.active || index >= self.stashed.len() {
662 return false;
663 }
664 std::mem::swap(current, &mut self.stashed[self.active]);
665 self.active = index;
666 std::mem::swap(current, &mut self.stashed[self.active]);
667 true
668 }
669
670 pub fn next(&mut self, current: &mut Navigation) -> bool {
672 self.switch(current, (self.active + 1) % self.stashed.len())
673 }
674
675 pub fn prev(&mut self, current: &mut Navigation) -> bool {
677 let count = self.stashed.len();
678 self.switch(current, (self.active + count - 1) % count)
679 }
680}
681
682#[derive(Debug, Clone, Default, Serialize, Deserialize)]
685pub struct Bookmarks {
686 pub marks: HashMap<char, PathBuf>,
688}
689
690impl Bookmarks {
691 pub fn with_defaults() -> Self {
693 use gravityfile_core::paths;
694 let mut marks = HashMap::new();
695 marks.insert('r', PathBuf::from("/"));
696 if let Some(config) = paths::config_root() {
697 marks.insert('c', config);
698 }
699 if let Some(home) = paths::home() {
700 for (key, name) in [('d', "Downloads"), ('o', "Documents")] {
701 let directory = home.join(name);
702 if directory.is_dir() {
703 marks.insert(key, directory);
704 }
705 }
706 marks.insert('h', home);
707 }
708 Self { marks }
709 }
710
711 pub fn set(&mut self, key: char, path: PathBuf) {
713 self.marks.insert(key, path);
714 }
715
716 pub fn get(&self, key: char) -> Option<&PathBuf> {
718 self.marks.get(&key)
719 }
720
721 pub fn remove(&mut self, key: char) -> Option<PathBuf> {
723 self.marks.remove(&key)
724 }
725
726 pub fn sorted_entries(&self) -> Vec<(char, &PathBuf)> {
728 let mut entries: Vec<_> = self.marks.iter().map(|(k, v)| (*k, v)).collect();
729 entries.sort_by_key(|(k, _)| *k);
730 entries
731 }
732
733 pub fn is_valid_key(key: char) -> bool {
735 key.is_ascii_alphanumeric()
736 }
737}
738
739#[derive(Debug, Clone, Default)]
741pub struct BookmarkListState {
742 pub selected: usize,
744 entries_count: usize,
746}
747
748impl BookmarkListState {
749 pub fn new(bookmarks: &Bookmarks) -> Self {
751 Self {
752 selected: 0,
753 entries_count: bookmarks.marks.len(),
754 }
755 }
756
757 pub fn update_count(&mut self, bookmarks: &Bookmarks) {
759 self.entries_count = bookmarks.marks.len();
760 if self.selected >= self.entries_count && self.entries_count > 0 {
761 self.selected = self.entries_count - 1;
762 }
763 }
764
765 pub fn move_up(&mut self) {
767 if self.selected > 0 {
768 self.selected -= 1;
769 }
770 }
771
772 pub fn move_down(&mut self) {
774 if self.entries_count > 0 && self.selected < self.entries_count - 1 {
775 self.selected += 1;
776 }
777 }
778}
779
780#[derive(Debug, Clone)]
782pub struct RenameEntry {
783 pub original: PathBuf,
785 pub new_name: String,
787}
788
789#[derive(Debug, Clone, Default)]
791pub struct BulkRenameState {
792 pub entries: Vec<RenameEntry>,
794 pub selected: usize,
796 pub error: Option<String>,
798}
799
800impl BulkRenameState {
801 pub fn new(original_paths: Vec<PathBuf>, new_names: Vec<String>) -> Self {
803 let entries: Vec<RenameEntry> = original_paths
804 .into_iter()
805 .zip(new_names)
806 .filter_map(|(original, new_name)| {
807 let old_name = original.file_name()?.to_string_lossy().to_string();
809 if old_name != new_name && !new_name.is_empty() {
810 Some(RenameEntry { original, new_name })
811 } else {
812 None
813 }
814 })
815 .collect();
816
817 Self {
818 entries,
819 selected: 0,
820 error: None,
821 }
822 }
823
824 pub fn is_empty(&self) -> bool {
826 self.entries.is_empty()
827 }
828
829 pub fn move_up(&mut self) {
831 if self.selected > 0 {
832 self.selected -= 1;
833 }
834 }
835
836 pub fn move_down(&mut self) {
838 if !self.entries.is_empty() && self.selected < self.entries.len() - 1 {
839 self.selected += 1;
840 }
841 }
842
843 pub fn validate(&mut self) -> bool {
845 let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
847 for entry in &self.entries {
848 if seen_names.contains(&entry.new_name) {
849 self.error = Some(format!("Duplicate name: {}", entry.new_name));
850 return false;
851 }
852 seen_names.insert(entry.new_name.clone());
853
854 if let Err(error) = gravityfile_ops::validate_filename(&entry.new_name) {
855 self.error = Some(format!("{}: {error}", entry.new_name));
856 return false;
857 }
858 }
859 self.error = None;
860 true
861 }
862}
863
864#[derive(Debug, Clone, Serialize, Deserialize)]
866#[serde(default)]
867pub struct UserSettings {
868 #[serde(default)]
871 pub max_scan_entries: Option<u64>,
872 pub scan_on_startup: bool,
874 pub show_hidden: bool,
876 pub default_layout: LayoutMode,
878 pub openers: FileOpeners,
880 #[serde(default)]
882 pub editor: opensesame::EditorConfig,
883 #[serde(default = "Bookmarks::with_defaults")]
885 pub bookmarks: Bookmarks,
886 #[serde(default = "default_true")]
893 pub image_previews: bool,
894 #[serde(default = "default_theme")]
896 pub theme: String,
897 #[serde(default = "default_true")]
900 pub record_history: bool,
901}
902
903fn default_true() -> bool {
904 true
905}
906
907fn default_theme() -> String {
908 crate::theme::Palette::GESTALT.name.to_string()
909}
910
911impl Default for UserSettings {
912 fn default() -> Self {
913 Self {
914 max_scan_entries: None,
915 scan_on_startup: true, show_hidden: false,
917 default_layout: LayoutMode::Tree,
918 openers: FileOpeners::default(),
919 editor: opensesame::EditorConfig::default(),
920 bookmarks: Bookmarks::with_defaults(),
921 image_previews: true,
922 theme: default_theme(),
923 record_history: true,
924 }
925 }
926}
927
928#[derive(Debug, Clone, Serialize, Deserialize)]
930#[serde(default)]
931pub struct FileOpeners {
932 pub md: String,
934 pub text: String,
937 pub default: String,
939}
940
941impl Default for FileOpeners {
942 fn default() -> Self {
943 Self {
944 md: "treemd".to_string(),
946 text: "editor".to_string(),
948 default: "open".to_string(),
950 }
951 }
952}
953
954impl UserSettings {
955 pub fn config_path() -> Option<PathBuf> {
957 if cfg!(test) {
959 return None;
960 }
961 gravityfile_core::paths::Dirs::discover().map(|dirs| dirs.settings_file())
962 }
963
964 pub fn try_load() -> std::io::Result<Self> {
967 let Some(path) = Self::config_path() else {
968 return Ok(Self::default());
969 };
970 Self::load_from(&path).map_err(|error| {
971 std::io::Error::new(error.kind(), format!("{}: {error}", path.display()))
972 })
973 }
974
975 fn load_from(path: &std::path::Path) -> std::io::Result<Self> {
976 let Some(content) = crate::config::read(path)? else {
977 return Ok(Self::default());
978 };
979 toml::from_str(&content)
980 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))
981 }
982
983 pub fn save(&self) -> std::io::Result<()> {
988 let path = Self::config_path().ok_or_else(|| {
989 std::io::Error::new(std::io::ErrorKind::NotFound, "No config directory")
990 })?;
991 self.save_to(&path)
992 }
993
994 fn save_to(&self, path: &std::path::Path) -> std::io::Result<()> {
995 self.save_to_with_directory_sync(path, sync_settings_directory)
996 }
997
998 fn save_to_with_directory_sync(
999 &self,
1000 path: &std::path::Path,
1001 sync_directory: impl FnOnce(&std::path::Path) -> std::io::Result<()>,
1002 ) -> std::io::Result<()> {
1003 use std::io::Write;
1004 let content = toml::to_string_pretty(self)
1005 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1006 if content.len() as u64 > crate::config::MAX_BYTES {
1007 return Err(std::io::Error::new(
1008 std::io::ErrorKind::InvalidData,
1009 "settings exceed 1 MiB",
1010 ));
1011 }
1012 let parent = path
1013 .parent()
1014 .filter(|path| !path.as_os_str().is_empty())
1015 .unwrap_or(std::path::Path::new("."));
1016 std::fs::create_dir_all(parent)?;
1017 let mut staged = tempfile::Builder::new()
1018 .prefix(".gravityfile-settings-")
1019 .tempfile_in(parent)?;
1020 staged.write_all(content.as_bytes())?;
1021 staged.as_file().sync_all()?;
1022 staged.persist(path).map_err(|error| error.error)?;
1023 sync_directory(parent).map_err(|error| {
1024 std::io::Error::new(error.kind(), format!(
1025 "Settings installed, but directory sync failed; durability is uncertain: {error}"
1026 ))
1027 })?;
1028 Ok(())
1029 }
1030}
1031
1032fn sync_settings_directory(path: &std::path::Path) -> std::io::Result<()> {
1033 #[cfg(unix)]
1034 {
1035 use std::os::unix::fs::OpenOptionsExt;
1036 std::fs::OpenOptions::new()
1037 .read(true)
1038 .custom_flags(libc::O_DIRECTORY | libc::O_NONBLOCK)
1039 .open(path)?
1040 .sync_all()
1041 }
1042 #[cfg(not(unix))]
1043 {
1044 let _ = path;
1045 Ok(())
1046 }
1047}
1048
1049#[derive(Debug, Clone, Default)]
1051pub struct SettingsState {
1052 pub selected: usize,
1054 pub settings: UserSettings,
1056 pub dirty: bool,
1058}
1059
1060impl SettingsState {
1061 pub fn new(settings: UserSettings) -> Self {
1063 Self {
1064 selected: 0,
1065 settings,
1066 dirty: false,
1067 }
1068 }
1069
1070 pub fn item_count(&self) -> usize {
1072 4 }
1074
1075 pub fn move_up(&mut self) {
1077 if self.selected > 0 {
1078 self.selected -= 1;
1079 }
1080 }
1081
1082 pub fn move_down(&mut self) {
1084 if self.selected < self.item_count() - 1 {
1085 self.selected += 1;
1086 }
1087 }
1088
1089 pub fn toggle_selected(&mut self) {
1091 match self.selected {
1092 0 => {
1093 self.settings.scan_on_startup = !self.settings.scan_on_startup;
1094 self.dirty = true;
1095 }
1096 1 => {
1097 self.settings.show_hidden = !self.settings.show_hidden;
1098 self.dirty = true;
1099 }
1100 2 => {
1101 self.settings.default_layout = self.settings.default_layout.toggle();
1103 self.dirty = true;
1104 }
1105 3 => {
1106 let palettes = crate::theme::Palette::ALL;
1107 let next = palettes
1108 .iter()
1109 .position(|palette| palette.name == self.settings.theme)
1110 .map_or(0, |index| (index + 1) % palettes.len());
1111 self.settings.theme = palettes[next].name.to_string();
1112 self.dirty = true;
1113 }
1114 _ => {}
1115 }
1116 }
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121 #[test]
1122 fn saved_entry_budget_roundtrips_without_changing_legacy_defaults() {
1123 use super::UserSettings;
1124 assert_eq!(
1125 toml::from_str::<UserSettings>("").unwrap().max_scan_entries,
1126 None
1127 );
1128 let directory = tempfile::tempdir().unwrap();
1129 let path = directory.path().join("settings.toml");
1130 for limit in [0, 1, 100_000] {
1131 let settings = UserSettings {
1132 max_scan_entries: Some(limit),
1133 ..UserSettings::default()
1134 };
1135 settings.save_to(&path).unwrap();
1136 assert_eq!(
1137 UserSettings::load_from(&path).unwrap().max_scan_entries,
1138 Some(limit)
1139 );
1140 }
1141 assert!(toml::from_str::<UserSettings>("max_scan_entries = -1").is_err());
1142 }
1143 #[test]
1144 fn tab_labels_fit_terminal_columns_without_unicode_panics() {
1145 use unicode_segmentation::UnicodeSegmentation;
1146 use unicode_width::UnicodeWidthStr;
1147 for label in [
1148 "",
1149 "documents",
1150 "éééééé",
1151 "日本語資料",
1152 "🗂️📁📂",
1153 "👩💻🇺🇸1️⃣",
1154 "e\u{301}e\u{301}",
1155 ] {
1156 for width in 0..=32 {
1157 for slice in [
1158 crate::ui::truncate_to_width(label, width),
1159 crate::ui::truncate_left_to_width(label, width),
1160 ] {
1161 assert!(slice.width() <= width);
1162 let boundaries: Vec<_> = label
1163 .grapheme_indices(true)
1164 .map(|(index, _)| index)
1165 .chain(std::iter::once(label.len()))
1166 .collect();
1167 let start = slice.as_ptr() as usize - label.as_ptr() as usize;
1168 assert!(boundaries.contains(&start));
1169 assert!(boundaries.contains(&(start + slice.len())));
1170 }
1171 assert!(crate::ui::fit_left_to_width(label, width).width() <= width);
1172 let shortened = crate::ui::fit_to_width(label, width);
1173 assert!(shortened.width() <= width, "{label:?}, width {width}");
1174 if label.width() <= width {
1175 assert_eq!(shortened, label);
1176 }
1177 if width == 0 {
1178 assert!(shortened.is_empty());
1179 }
1180 }
1181 }
1182 }
1183 use super::*;
1184
1185 #[test]
1186 fn settings_load_missing_defaults_and_existing_values() {
1187 let directory = tempfile::tempdir().unwrap();
1188 let path = directory.path().join("settings.toml");
1189 assert!(UserSettings::load_from(&path).unwrap().scan_on_startup);
1190 std::fs::write(&path, "scan_on_startup = false\nimage_previews = false\n").unwrap();
1191 let settings = UserSettings::load_from(&path).unwrap();
1192 assert!(!settings.scan_on_startup);
1193 assert!(!settings.image_previews);
1194 }
1195
1196 #[test]
1197 fn settings_load_rejects_invalid_and_oversized_files() {
1198 let directory = tempfile::tempdir().unwrap();
1199 let path = directory.path().join("settings.toml");
1200 for content in [b"scan_on_startup = [".as_slice(), &[0xff]] {
1201 std::fs::write(&path, content).unwrap();
1202 assert_eq!(
1203 UserSettings::load_from(&path).unwrap_err().kind(),
1204 std::io::ErrorKind::InvalidData
1205 );
1206 }
1207 let file = std::fs::File::create(&path).unwrap();
1208 file.set_len(crate::config::MAX_BYTES + 1).unwrap();
1209 assert_eq!(
1210 UserSettings::load_from(&path).unwrap_err().kind(),
1211 std::io::ErrorKind::InvalidData
1212 );
1213 assert!(UserSettings::load_from(directory.path()).is_err());
1214 }
1215
1216 #[test]
1217 fn settings_oversized_save_preserves_existing_file() {
1218 let directory = tempfile::tempdir().unwrap();
1219 let path = directory.path().join("settings.toml");
1220 std::fs::write(&path, b"original").unwrap();
1221 let settings = UserSettings {
1222 openers: FileOpeners {
1223 md: "x".repeat(crate::config::MAX_BYTES as usize),
1224 ..FileOpeners::default()
1225 },
1226 ..UserSettings::default()
1227 };
1228 assert_eq!(
1229 settings.save_to(&path).unwrap_err().kind(),
1230 std::io::ErrorKind::InvalidData
1231 );
1232 assert_eq!(std::fs::read(&path).unwrap(), b"original");
1233 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
1234 }
1235
1236 #[cfg(unix)]
1237 #[test]
1238 fn settings_load_accepts_regular_symlink_and_rejects_fifo() {
1239 use std::os::unix::{ffi::OsStrExt, fs::symlink};
1240 let directory = tempfile::tempdir().unwrap();
1241 let target = directory.path().join("target");
1242 let path = directory.path().join("settings.toml");
1243 std::fs::write(&target, "image_previews = false\n").unwrap();
1244 symlink(&target, &path).unwrap();
1245 assert!(!UserSettings::load_from(&path).unwrap().image_previews);
1246 let fifo = directory.path().join("fifo");
1247 let native = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap();
1248 assert_eq!(unsafe { libc::mkfifo(native.as_ptr(), 0o600) }, 0);
1250 assert_eq!(
1251 UserSettings::load_from(&fifo).unwrap_err().kind(),
1252 std::io::ErrorKind::InvalidData
1253 );
1254 }
1255
1256 #[test]
1257 fn settings_save_creates_parents_and_replaces_complete_document() {
1258 let directory = tempfile::tempdir().unwrap();
1259 let path = directory.path().join("config/settings.toml");
1260 UserSettings::default().save_to(&path).unwrap();
1261 let settings = UserSettings {
1262 scan_on_startup: false,
1263 default_layout: LayoutMode::Miller,
1264 ..UserSettings::default()
1265 };
1266 settings.save_to(&path).unwrap();
1267 let saved = std::fs::read_to_string(&path).unwrap();
1268 assert!(saved.contains("default_layout = \"miller\""), "{saved}");
1269 let restored: UserSettings = toml::from_str(&saved).unwrap();
1270 assert!(!restored.scan_on_startup);
1271 assert_eq!(restored.default_layout, LayoutMode::Miller);
1272 assert_eq!(
1273 std::fs::read_dir(path.parent().unwrap()).unwrap().count(),
1274 1
1275 );
1276 }
1277
1278 #[test]
1279 fn settings_publication_failure_preserves_destination_and_removes_staging() {
1280 let directory = tempfile::tempdir().unwrap();
1281 let path = directory.path().join("settings.toml");
1282 std::fs::create_dir(&path).unwrap();
1283 std::fs::write(path.join("existing"), b"preserve me").unwrap();
1284 assert!(UserSettings::default().save_to(&path).is_err());
1285 assert_eq!(
1286 std::fs::read(path.join("existing")).unwrap(),
1287 b"preserve me"
1288 );
1289 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
1290 }
1291
1292 #[test]
1293 fn settings_directory_sync_failure_reports_installed_document_and_allows_retry() {
1294 let directory = tempfile::tempdir().unwrap();
1295 let path = directory.path().join("settings.toml");
1296 UserSettings::default().save_to(&path).unwrap();
1297 let settings = UserSettings {
1298 scan_on_startup: false,
1299 ..UserSettings::default()
1300 };
1301 let error = settings
1302 .save_to_with_directory_sync(&path, |parent| {
1303 assert_eq!(parent, directory.path());
1304 assert!(!UserSettings::load_from(&path).unwrap().scan_on_startup);
1305 Err(std::io::Error::other("injected sync failure"))
1306 })
1307 .unwrap_err();
1308 assert!(error.to_string().contains("Settings installed"));
1309 assert!(error.to_string().contains("durability is uncertain"));
1310 assert!(!UserSettings::load_from(&path).unwrap().scan_on_startup);
1311 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
1312 settings.save_to(&path).unwrap();
1313 assert!(!UserSettings::load_from(&path).unwrap().scan_on_startup);
1314 }
1315
1316 #[cfg(unix)]
1317 #[test]
1318 fn settings_directory_sync_rejects_regular_files() {
1319 let directory = tempfile::tempdir().unwrap();
1320 let file = directory.path().join("file");
1321 std::fs::write(&file, b"keep").unwrap();
1322 assert!(sync_settings_directory(&file).is_err());
1323 sync_settings_directory(directory.path()).unwrap();
1324 assert_eq!(std::fs::read(file).unwrap(), b"keep");
1325 }
1326
1327 #[cfg(unix)]
1328 #[test]
1329 fn settings_save_replaces_leaf_symlink_with_private_file() {
1330 use std::os::unix::fs::{PermissionsExt, symlink};
1331 let directory = tempfile::tempdir().unwrap();
1332 let target = directory.path().join("target");
1333 let path = directory.path().join("settings.toml");
1334 std::fs::write(&target, b"untouched").unwrap();
1335 symlink(&target, &path).unwrap();
1336 UserSettings::default().save_to(&path).unwrap();
1337 assert_eq!(std::fs::read(&target).unwrap(), b"untouched");
1338 let metadata = std::fs::symlink_metadata(&path).unwrap();
1339 assert!(metadata.is_file());
1340 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
1341 }
1342
1343 #[test]
1344 fn test_visual_state_creation() {
1345 let state = VisualState::new(5);
1346 assert_eq!(state.anchor, 5);
1347 assert_eq!(state.cursor, 5);
1348 }
1349
1350 #[test]
1351 fn test_visual_state_selection_range() {
1352 let mut state = VisualState::new(5);
1353 assert_eq!(state.selection_range(), (5, 5));
1354
1355 state.cursor = 8;
1356 assert_eq!(state.selection_range(), (5, 8));
1357
1358 state.cursor = 2;
1359 assert_eq!(state.selection_range(), (2, 5));
1360 }
1361
1362 #[test]
1363 fn test_visual_state_move_down_bounds() {
1364 let mut state = VisualState::new(0);
1365
1366 state.move_down(0);
1368 assert_eq!(state.cursor, 0);
1369
1370 state.move_down(1);
1372 assert_eq!(state.cursor, 0);
1373
1374 state.move_down(5);
1376 assert_eq!(state.cursor, 1);
1377
1378 state.cursor = 4;
1380 state.move_down(5);
1381 assert_eq!(state.cursor, 4); }
1383
1384 #[test]
1385 fn test_visual_state_move_up() {
1386 let mut state = VisualState::new(5);
1387 state.move_up();
1388 assert_eq!(state.cursor, 4);
1389
1390 state.cursor = 0;
1392 state.move_up();
1393 assert_eq!(state.cursor, 0); }
1395
1396 #[test]
1397 fn test_visual_state_page_up() {
1398 let mut state = VisualState::new(10);
1399 state.page_up(5);
1400 assert_eq!(state.cursor, 5);
1401
1402 state.page_up(10);
1403 assert_eq!(state.cursor, 0); }
1405
1406 #[test]
1407 fn test_visual_state_page_down_bounds() {
1408 let mut state = VisualState::new(0);
1409
1410 state.page_down(5, 0);
1412 assert_eq!(state.cursor, 0);
1413
1414 state.page_down(5, 10);
1416 assert_eq!(state.cursor, 5);
1417
1418 state.page_down(10, 10);
1419 assert_eq!(state.cursor, 9); }
1421
1422 #[test]
1423 fn test_visual_state_jump_to_top() {
1424 let mut state = VisualState::new(50);
1425 state.jump_to_top();
1426 assert_eq!(state.cursor, 0);
1427 }
1428
1429 #[test]
1430 fn test_visual_state_jump_to_bottom_bounds() {
1431 let mut state = VisualState::new(0);
1432
1433 state.jump_to_bottom(0);
1435 assert_eq!(state.cursor, 0);
1436
1437 state.jump_to_bottom(10);
1439 assert_eq!(state.cursor, 9);
1440 }
1441}