Skip to main content

gravityfile_tui/app/
state.rs

1//! Application state types and enums.
2
3use 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/// Application mode representing the current UI state.
17/// Note: Scanning is NOT a mode - scanning happens in the background
18/// while the user can still interact with the UI in Normal mode.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum AppMode {
21    #[default]
22    Normal,
23    Help,
24    /// Fuzzy search mode for quick navigation.
25    Search,
26    /// Confirming deletion of marked items.
27    ConfirmDelete,
28    /// Deletion in progress.
29    Deleting,
30    /// Copy operation in progress.
31    Copying,
32    /// Move operation in progress.
33    Moving,
34    /// Undo is running, including while cancellation is being completed.
35    Undoing,
36    /// A single-entry creation or rename is awaiting its worker result.
37    UpdatingEntry,
38    /// Renaming a file or directory (text input mode).
39    Renaming,
40    /// Creating a new file (text input mode).
41    CreatingFile,
42    /// Creating a new directory (text input mode).
43    CreatingDirectory,
44    /// Taking (create directory and cd into it) - text input mode.
45    Taking,
46    /// Going to a directory (path input mode).
47    GoingTo,
48    /// Waiting for conflict resolution.
49    ConflictResolution,
50    /// Command palette input mode (vim-style :command).
51    Command,
52    /// Settings modal.
53    Settings,
54    /// Setting a bookmark (waiting for key input).
55    SettingBookmark,
56    /// Jumping to a bookmark (waiting for key input).
57    JumpingToBookmark,
58    /// Viewing the bookmarks list modal.
59    BookmarkList,
60    /// Confirming bulk rename changes.
61    ConfirmBulkRename,
62    /// A bulk rename is running to completion or rollback.
63    BulkRenaming,
64    /// Visual selection mode (vim-style range selection).
65    Visual,
66    Quit,
67}
68
69/// Layout mode for the explorer view.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum LayoutMode {
73    /// Tree view (default, current behavior).
74    #[default]
75    Tree,
76    /// Miller columns (ranger-style three-pane).
77    Miller,
78}
79
80impl LayoutMode {
81    /// Toggle between layout modes.
82    pub fn toggle(self) -> Self {
83        match self {
84            Self::Tree => Self::Miller,
85            Self::Miller => Self::Tree,
86        }
87    }
88
89    /// The name used in settings.
90    pub fn name(self) -> &'static str {
91        match self {
92            Self::Tree => "tree",
93            Self::Miller => "miller",
94        }
95    }
96}
97
98/// State for visual selection mode (vim-style range selection).
99#[derive(Debug, Clone, Default)]
100pub struct VisualState {
101    /// Anchor index where visual selection started.
102    pub anchor: usize,
103    /// Current cursor index (selection extends from anchor to cursor).
104    pub cursor: usize,
105}
106
107impl VisualState {
108    /// Create a new visual state starting at the given index.
109    pub fn new(start_index: usize) -> Self {
110        Self {
111            anchor: start_index,
112            cursor: start_index,
113        }
114    }
115
116    /// Get the selection range as (start, end) inclusive.
117    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    /// Move the cursor up.
124    pub fn move_up(&mut self) {
125        self.cursor = self.cursor.saturating_sub(1);
126    }
127
128    /// Move the cursor down with a maximum bound.
129    pub fn move_down(&mut self, max: usize) {
130        if max > 0 && self.cursor < max - 1 {
131            self.cursor += 1;
132        }
133    }
134
135    /// Move the cursor up by a page.
136    pub fn page_up(&mut self, page_size: usize) {
137        self.cursor = self.cursor.saturating_sub(page_size);
138    }
139
140    /// Move the cursor down by a page with a maximum bound.
141    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    /// Jump cursor to the top.
148    pub fn jump_to_top(&mut self) {
149        self.cursor = 0;
150    }
151
152    /// Jump cursor to the bottom.
153    pub fn jump_to_bottom(&mut self, max: usize) {
154        if max > 0 {
155            self.cursor = max - 1;
156        }
157    }
158}
159
160/// Clipboard mode determines paste behavior.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
162pub enum ClipboardMode {
163    /// Clipboard is empty.
164    #[default]
165    Empty,
166    /// Items were yanked (copy).
167    Copy,
168    /// Items were cut (move).
169    Cut,
170}
171
172/// Clipboard state for file operations.
173#[derive(Debug, Clone, Default)]
174pub struct ClipboardState {
175    /// Paths currently in the clipboard.
176    pub paths: Vec<PathBuf>,
177    /// The clipboard mode (copy or cut).
178    pub mode: ClipboardMode,
179    /// The root directory where items were copied/cut from.
180    pub source_root: Option<PathBuf>,
181}
182
183impl ClipboardState {
184    /// Yank (copy) paths to the clipboard.
185    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    /// Cut (move) paths to the clipboard.
192    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    /// Clear the clipboard.
199    pub fn clear(&mut self) {
200        self.paths.clear();
201        self.mode = ClipboardMode::Empty;
202        self.source_root = None;
203    }
204
205    /// Check if the clipboard is empty.
206    pub fn is_empty(&self) -> bool {
207        self.paths.is_empty()
208    }
209}
210
211/// Sort mode for file listings.
212///
213/// Arrow convention: ↑ = ascending (A→Z, small→large), ↓ = descending (Z→A, large→small).
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, EnumIter, FromRepr)]
215pub enum SortMode {
216    /// Size descending (largest first) - default for disk usage analyzer.
217    #[default]
218    #[strum(to_string = "Size ↓")]
219    SizeDescending,
220    /// Size ascending (smallest first).
221    #[strum(to_string = "Size ↑")]
222    SizeAscending,
223    /// Name ascending (A-Z).
224    #[strum(to_string = "Name ↑")]
225    NameAscending,
226    /// Name descending (Z-A).
227    #[strum(to_string = "Name ↓")]
228    NameDescending,
229    /// Modified date descending (newest first).
230    #[strum(to_string = "Date ↓")]
231    ModifiedDescending,
232    /// Modified date ascending (oldest first).
233    #[strum(to_string = "Date ↑")]
234    ModifiedAscending,
235    /// Child count descending (most children first).
236    #[strum(to_string = "Count ↓")]
237    CountDescending,
238    /// Child count ascending (fewest children first).
239    #[strum(to_string = "Count ↑")]
240    CountAscending,
241}
242
243impl SortMode {
244    /// Cycle to the next sort mode.
245    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    /// Reverse the current sort direction.
252    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    /// Get a short label for display in the status bar.
266    /// ↑ = ascending (A→Z, small→large), ↓ = descending (Z→A, large→small).
267    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/// Active view/tab during normal mode.
282#[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    /// Move to next view (cyclic).
294    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    /// Move to previous view (cyclic).
301    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/// Progress during deletion operation.
310#[derive(Debug, Clone)]
311pub struct DeletionProgress {
312    /// Total items to delete.
313    pub total: usize,
314    /// Items deleted so far.
315    pub deleted: usize,
316    /// Items that failed to delete.
317    pub failed: usize,
318    /// Bytes freed so far.
319    pub bytes_freed: u64,
320    /// Current item being deleted.
321    pub current: Option<PathBuf>,
322}
323
324impl DeletionProgress {
325    /// Create new deletion progress.
326    #[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    /// Get completion percentage.
338    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
347/// Result from a background scan operation.
348pub enum ScanResult {
349    Progress(ScanProgress),
350    ScanComplete(Result<FileTree, gravityfile_scan::ScanError>),
351    AnalysisComplete {
352        duplicates: DuplicateReport,
353        age_report: AgeReport,
354        /// Per-group root count and validated path ordering, prepared off-thread.
355        duplicate_scope: (PathBuf, Vec<(usize, bool)>),
356    },
357    AnalysisFailed(String),
358    AnalysisCancelled,
359    /// Progress update during deletion.
360    DeletionProgress(DeletionProgress),
361    /// Deletion completed.
362    DeletionComplete {
363        mode: gravityfile_ops::DeleteMode,
364        /// Paths that were removed.
365        deleted: Vec<PathBuf>,
366        failed: usize,
367        bytes_freed: u64,
368        errors: Vec<gravityfile_ops::OperationError>,
369    },
370    /// Progress update during file operations (copy/move/etc).
371    OperationProgress(OperationProgress),
372    /// A conflict was encountered during file operation.
373    OperationConflict(ConflictRequest),
374    /// Create-and-enter completed: true means this task created the directory.
375    TakeComplete {
376        path: PathBuf,
377        result: Result<bool, String>,
378    },
379    /// Bulk rename completed or failed after rollback attempts.
380    BulkRenameComplete(Result<Vec<(std::path::PathBuf, std::path::PathBuf)>, String>),
381    /// File operation completed.
382    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        /// Information for the undo log.
391        undo_info: Option<gravityfile_ops::UndoableOperation>,
392        /// Items an undo could not reverse, to push back for a retry.
393        undo_remaining: Option<gravityfile_ops::UndoEntry>,
394        /// Paths whose contents may have changed, to refresh in the tree.
395        touched: Vec<PathBuf>,
396    },
397}
398
399/// The state of one changed path, scanned again.
400pub struct Refreshed {
401    pub path: PathBuf,
402    /// `None` when the path no longer exists.
403    pub entry: Result<Option<gravityfile_scan::ScannedEntry>, String>,
404}
405
406/// What a directory listing was read for.
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
408pub enum ListingFor {
409    /// The entries of a directory the tree has not read yet.
410    Children,
411    /// The Miller parent column at the scan root.
412    Parent,
413}
414
415/// A directory listed off the UI thread.
416pub struct Listed {
417    pub dir: PathBuf,
418    pub purpose: ListingFor,
419    pub result: Result<FileTree, String>,
420}
421
422/// Information about the currently selected item.
423#[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/// State for the duplicates view with expandable groups.
435#[derive(Debug, Clone, Default)]
436pub struct DuplicatesViewState {
437    /// Index of the selected group.
438    pub selected_group: usize,
439    /// Set of expanded group indices.
440    pub expanded_groups: std::collections::HashSet<usize>,
441    /// When a group is expanded, the selected file index within that group (0 = group header).
442    /// Key is group index, value is selected item (0 = header, 1..=n = files).
443    pub selected_in_group: std::collections::HashMap<usize, usize>,
444}
445
446impl DuplicatesViewState {
447    /// Create a new duplicates view state.
448    pub fn new() -> Self {
449        Self::default()
450    }
451
452    /// Toggle expansion of the selected group.
453    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    /// Check if a group is expanded.
464    pub fn is_expanded(&self, group_index: usize) -> bool {
465        self.expanded_groups.contains(&group_index)
466    }
467
468    /// Get the selected item within a group (0 = header, 1+ = file index).
469    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    /// Move selection up.
477    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                // Move within group
482                self.selected_in_group
483                    .insert(self.selected_group, selected - 1);
484                return;
485            }
486        }
487        // Move to previous group
488        if self.selected_group > 0 {
489            self.selected_group -= 1;
490            // If previous group is expanded, select its last item
491            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    /// Move selection down.
500    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                // Move within group
506                self.selected_in_group
507                    .insert(self.selected_group, selected + 1);
508                return;
509            }
510        }
511        // Move to next group
512        if self.selected_group < group_count.saturating_sub(1) {
513            self.selected_group += 1;
514            // Start at header of next group
515            if self.is_expanded(self.selected_group) {
516                self.selected_in_group.insert(self.selected_group, 0);
517            }
518        }
519    }
520
521    /// Reset the state.
522    pub fn reset(&mut self) {
523        *self = Self::default();
524    }
525
526    /// Get the currently selected file path, if any.
527    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 // Header selected, not a specific file
535        } else {
536            group.paths.get(selected - 1)
537        }
538    }
539}
540
541/// A place in a tab's back/forward history.
542#[derive(Debug, Clone, PartialEq, Eq)]
543pub struct Visit {
544    /// The directory viewed.
545    pub dir: PathBuf,
546    /// The selected entry. Kept as a path rather than a row so returning
547    /// finds it in any layout, and after the listing has changed.
548    pub selected: Option<PathBuf>,
549    /// The directories expanded in the tree layout.
550    pub expanded: HashSet<PathBuf>,
551}
552
553/// What one tab is looking at and how it got there. The active tab's
554/// navigation is owned by the app; [`TabManager`] keeps the others.
555#[derive(Debug, Clone, Default)]
556pub struct Navigation {
557    /// The directory being viewed.
558    pub view_root: PathBuf,
559    /// Selection, scroll and expanded directories in the tree layout.
560    pub tree_state: TreeState,
561    /// Selection and scroll in the Miller layout.
562    pub miller_state: MillerState,
563    /// Selection in the treemap.
564    pub treemap_state: TreemapState,
565    /// Earlier places, most recent last.
566    pub back: Vec<Visit>,
567    /// Places left by going back, most recent last.
568    pub forward: Vec<Visit>,
569}
570
571impl Navigation {
572    /// Start viewing `view_root` with it expanded and no history.
573    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    /// The tab label: the viewed directory's name.
582    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/// The open directory tabs. The active tab's [`Navigation`] is held by the
592/// app and swapped back in here when another tab becomes active, so each tab
593/// keeps its own place, selection and history.
594#[derive(Debug)]
595pub struct TabManager {
596    /// One entry per tab; the active tab's entry is a placeholder.
597    stashed: Vec<Navigation>,
598    /// Index of the active tab.
599    active: usize,
600    /// Maximum number of tabs.
601    max_tabs: usize,
602}
603
604impl TabManager {
605    /// One tab, the active one, whose navigation the caller holds.
606    pub fn new(max_tabs: usize) -> Self {
607        Self {
608            stashed: vec![Navigation::default()],
609            active: 0,
610            max_tabs,
611        }
612    }
613
614    /// The number of open tabs.
615    pub fn len(&self) -> usize {
616        self.stashed.len()
617    }
618
619    /// The active tab's index.
620    pub fn active_index(&self) -> usize {
621        self.active
622    }
623
624    /// Every tab's label, in order, taking the active one from `current`.
625    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    /// Open a tab showing `navigation` and make it active. Returns false at
638    /// the tab limit.
639    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    /// Close the active tab and activate its neighbour. Returns false for the
648    /// last tab.
649    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    /// Make tab `index` active. Returns whether the active tab changed.
660    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    /// Activate the next tab, wrapping around.
671    pub fn next(&mut self, current: &mut Navigation) -> bool {
672        self.switch(current, (self.active + 1) % self.stashed.len())
673    }
674
675    /// Activate the previous tab, wrapping around.
676    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/// Directory bookmarks for quick navigation.
683/// Bookmarks are single-character keys mapped to directory paths.
684#[derive(Debug, Clone, Default, Serialize, Deserialize)]
685pub struct Bookmarks {
686    /// Map of bookmark keys to directory paths.
687    pub marks: HashMap<char, PathBuf>,
688}
689
690impl Bookmarks {
691    /// Create bookmarks with sensible defaults.
692    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    /// Set a bookmark.
712    pub fn set(&mut self, key: char, path: PathBuf) {
713        self.marks.insert(key, path);
714    }
715
716    /// Get a bookmark.
717    pub fn get(&self, key: char) -> Option<&PathBuf> {
718        self.marks.get(&key)
719    }
720
721    /// Remove a bookmark.
722    pub fn remove(&mut self, key: char) -> Option<PathBuf> {
723        self.marks.remove(&key)
724    }
725
726    /// Get all bookmarks sorted by key.
727    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    /// Check if a bookmark key is valid (alphanumeric).
734    pub fn is_valid_key(key: char) -> bool {
735        key.is_ascii_alphanumeric()
736    }
737}
738
739/// State for the bookmark list modal.
740#[derive(Debug, Clone, Default)]
741pub struct BookmarkListState {
742    /// Currently selected bookmark index.
743    pub selected: usize,
744    /// Cached sorted entries for display.
745    entries_count: usize,
746}
747
748impl BookmarkListState {
749    /// Create a new bookmark list state.
750    pub fn new(bookmarks: &Bookmarks) -> Self {
751        Self {
752            selected: 0,
753            entries_count: bookmarks.marks.len(),
754        }
755    }
756
757    /// Update the entries count.
758    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    /// Move selection up.
766    pub fn move_up(&mut self) {
767        if self.selected > 0 {
768            self.selected -= 1;
769        }
770    }
771
772    /// Move selection down.
773    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/// A single rename operation in bulk rename.
781#[derive(Debug, Clone)]
782pub struct RenameEntry {
783    /// Original file path.
784    pub original: PathBuf,
785    /// New name (just the file name, not the full path).
786    pub new_name: String,
787}
788
789/// State for bulk rename confirmation modal.
790#[derive(Debug, Clone, Default)]
791pub struct BulkRenameState {
792    /// List of rename operations to perform.
793    pub entries: Vec<RenameEntry>,
794    /// Currently selected entry in the confirmation modal.
795    pub selected: usize,
796    /// Error message if validation failed.
797    pub error: Option<String>,
798}
799
800impl BulkRenameState {
801    /// Create a new bulk rename state from original paths and new names.
802    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                // Only include entries where the name actually changed
808                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    /// Check if empty.
825    pub fn is_empty(&self) -> bool {
826        self.entries.is_empty()
827    }
828
829    /// Move selection up.
830    pub fn move_up(&mut self) {
831        if self.selected > 0 {
832            self.selected -= 1;
833        }
834    }
835
836    /// Move selection down.
837    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    /// Validate all rename entries.
844    pub fn validate(&mut self) -> bool {
845        // Check for duplicate new names
846        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/// Persistent user settings stored in config file.
865#[derive(Debug, Clone, Serialize, Deserialize)]
866#[serde(default)]
867pub struct UserSettings {
868    /// Descendant-entry budget for quick listings and recursive scans.
869    /// None preserves unlimited traversal; zero permits an empty scope.
870    #[serde(default)]
871    pub max_scan_entries: Option<u64>,
872    /// Whether to automatically start scanning on startup.
873    pub scan_on_startup: bool,
874    /// Show hidden files by default.
875    pub show_hidden: bool,
876    /// Layout the explorer opens in.
877    pub default_layout: LayoutMode,
878    /// File opener configuration.
879    pub openers: FileOpeners,
880    /// Editor configuration for opensesame.
881    #[serde(default)]
882    pub editor: opensesame::EditorConfig,
883    /// Directory bookmarks.
884    #[serde(default = "Bookmarks::with_defaults")]
885    pub bookmarks: Bookmarks,
886    /// Render image files in the preview pane using the terminal's graphics
887    /// protocol.
888    ///
889    /// Enabling this costs one capability query at startup. Terminals answer it
890    /// in microseconds, but one that never answers delays the first frame by
891    /// the query timeout, so this can be turned off.
892    #[serde(default = "default_true")]
893    pub image_previews: bool,
894    /// Colour theme: gestalt, latte, nord, dracula or ansi.
895    #[serde(default = "default_theme")]
896    pub theme: String,
897    /// Record each full scan's totals for `grav history` and the header's
898    /// change since last time.
899    #[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, // Enabled by default
916            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/// Configuration for file openers by extension.
929#[derive(Debug, Clone, Serialize, Deserialize)]
930#[serde(default)]
931pub struct FileOpeners {
932    /// Opener for markdown files (.md).
933    pub md: String,
934    /// Opener for text/code files (uses $EDITOR via opensesame).
935    /// Set to "editor" to use opensesame, or a specific command.
936    pub text: String,
937    /// Opener for other files (system default).
938    pub default: String,
939}
940
941impl Default for FileOpeners {
942    fn default() -> Self {
943        Self {
944            // Use treemd for markdown if available, otherwise system open
945            md: "treemd".to_string(),
946            // Use $EDITOR via opensesame for text files
947            text: "editor".to_string(),
948            // Use system open command for other files
949            default: "open".to_string(),
950        }
951    }
952}
953
954impl UserSettings {
955    /// Get the config file path.
956    pub fn config_path() -> Option<PathBuf> {
957        // Unit tests must never read or write the user's real settings.
958        if cfg!(test) {
959            return None;
960        }
961        gravityfile_core::paths::Dirs::discover().map(|dirs| dirs.settings_file())
962    }
963
964    /// Load settings, using defaults when no settings file exists.
965    /// Other read and parse failures are returned to the caller.
966    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    /// Sync a private sibling file, atomically replace settings, and on Unix sync
984    /// the containing directory. A post-replacement sync error explicitly reports
985    /// that settings were installed but durability is uncertain.
986    /// Existing file permissions are not preserved; Unix files are private (0600).
987    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/// State for the settings modal.
1050#[derive(Debug, Clone, Default)]
1051pub struct SettingsState {
1052    /// Currently selected setting index.
1053    pub selected: usize,
1054    /// Temporary copy of settings being edited.
1055    pub settings: UserSettings,
1056    /// Whether settings have been modified.
1057    pub dirty: bool,
1058}
1059
1060impl SettingsState {
1061    /// Create a new settings state with current settings.
1062    pub fn new(settings: UserSettings) -> Self {
1063        Self {
1064            selected: 0,
1065            settings,
1066            dirty: false,
1067        }
1068    }
1069
1070    /// Number of settings items.
1071    pub fn item_count(&self) -> usize {
1072        4 // scan_on_startup, show_hidden, default_layout, theme
1073    }
1074
1075    /// Move selection up.
1076    pub fn move_up(&mut self) {
1077        if self.selected > 0 {
1078            self.selected -= 1;
1079        }
1080    }
1081
1082    /// Move selection down.
1083    pub fn move_down(&mut self) {
1084        if self.selected < self.item_count() - 1 {
1085            self.selected += 1;
1086        }
1087    }
1088
1089    /// Toggle the currently selected boolean setting.
1090    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                // Cycle layout mode
1102                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        // SAFETY: `native` is a valid, NUL-terminated path for this call.
1249        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        // Test with max = 0 (empty list)
1367        state.move_down(0);
1368        assert_eq!(state.cursor, 0);
1369
1370        // Test with max = 1 (single item)
1371        state.move_down(1);
1372        assert_eq!(state.cursor, 0);
1373
1374        // Test normal movement
1375        state.move_down(5);
1376        assert_eq!(state.cursor, 1);
1377
1378        // Test at boundary
1379        state.cursor = 4;
1380        state.move_down(5);
1381        assert_eq!(state.cursor, 4); // Should not exceed max-1
1382    }
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        // Test at zero
1391        state.cursor = 0;
1392        state.move_up();
1393        assert_eq!(state.cursor, 0); // Should not go negative
1394    }
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); // Saturating sub
1404    }
1405
1406    #[test]
1407    fn test_visual_state_page_down_bounds() {
1408        let mut state = VisualState::new(0);
1409
1410        // Test with max = 0
1411        state.page_down(5, 0);
1412        assert_eq!(state.cursor, 0);
1413
1414        // Test with max = 10
1415        state.page_down(5, 10);
1416        assert_eq!(state.cursor, 5);
1417
1418        state.page_down(10, 10);
1419        assert_eq!(state.cursor, 9); // Max - 1
1420    }
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        // Test with max = 0
1434        state.jump_to_bottom(0);
1435        assert_eq!(state.cursor, 0);
1436
1437        // Test with max = 10
1438        state.jump_to_bottom(10);
1439        assert_eq!(state.cursor, 9);
1440    }
1441}