gravityfile_tui/app/
state.rs

1//! Application state types and enums.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
7
8use gravityfile_analyze::{AgeReport, DuplicateReport};
9use gravityfile_core::FileTree;
10use gravityfile_ops::{Conflict, OperationProgress, OperationType};
11use gravityfile_scan::ScanProgress;
12
13/// Application mode representing the current UI state.
14/// Note: Scanning is NOT a mode - scanning happens in the background
15/// while the user can still interact with the UI in Normal mode.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum AppMode {
18    #[default]
19    Normal,
20    Help,
21    /// Fuzzy search mode for quick navigation.
22    Search,
23    /// Confirming deletion of marked items.
24    ConfirmDelete,
25    /// Deletion in progress.
26    Deleting,
27    /// Copy operation in progress.
28    Copying,
29    /// Move operation in progress.
30    Moving,
31    /// Renaming a file or directory (text input mode).
32    Renaming,
33    /// Creating a new file (text input mode).
34    CreatingFile,
35    /// Creating a new directory (text input mode).
36    CreatingDirectory,
37    /// Taking (create directory and cd into it) - text input mode.
38    Taking,
39    /// Going to a directory (path input mode).
40    GoingTo,
41    /// Waiting for conflict resolution.
42    ConflictResolution,
43    /// Command palette input mode (vim-style :command).
44    Command,
45    /// Settings modal.
46    Settings,
47    Quit,
48}
49
50/// Layout mode for the explorer view.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum LayoutMode {
53    /// Tree view (default, current behavior).
54    #[default]
55    Tree,
56    /// Miller columns (ranger-style three-pane).
57    Miller,
58}
59
60impl LayoutMode {
61    /// Toggle between layout modes.
62    pub fn toggle(self) -> Self {
63        match self {
64            Self::Tree => Self::Miller,
65            Self::Miller => Self::Tree,
66        }
67    }
68}
69
70/// Clipboard mode determines paste behavior.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum ClipboardMode {
73    /// Clipboard is empty.
74    #[default]
75    Empty,
76    /// Items were yanked (copy).
77    Copy,
78    /// Items were cut (move).
79    Cut,
80}
81
82/// Clipboard state for file operations.
83#[derive(Debug, Clone, Default)]
84pub struct ClipboardState {
85    /// Paths currently in the clipboard.
86    pub paths: Vec<PathBuf>,
87    /// The clipboard mode (copy or cut).
88    pub mode: ClipboardMode,
89    /// The root directory where items were copied/cut from.
90    pub source_root: Option<PathBuf>,
91}
92
93impl ClipboardState {
94    /// Yank (copy) paths to the clipboard.
95    pub fn yank(&mut self, paths: impl IntoIterator<Item = PathBuf>, source_root: PathBuf) {
96        self.paths = paths.into_iter().collect();
97        self.mode = ClipboardMode::Copy;
98        self.source_root = Some(source_root);
99    }
100
101    /// Cut (move) paths to the clipboard.
102    pub fn cut(&mut self, paths: impl IntoIterator<Item = PathBuf>, source_root: PathBuf) {
103        self.paths = paths.into_iter().collect();
104        self.mode = ClipboardMode::Cut;
105        self.source_root = Some(source_root);
106    }
107
108    /// Clear the clipboard.
109    pub fn clear(&mut self) {
110        self.paths.clear();
111        self.mode = ClipboardMode::Empty;
112        self.source_root = None;
113    }
114
115    /// Check if the clipboard is empty.
116    pub fn is_empty(&self) -> bool {
117        self.paths.is_empty()
118    }
119
120    /// Get the number of items in the clipboard.
121    pub fn len(&self) -> usize {
122        self.paths.len()
123    }
124}
125
126/// Sort mode for file listings.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, EnumIter, FromRepr)]
128pub enum SortMode {
129    /// Size descending (largest first) - default for disk usage analyzer.
130    #[default]
131    #[strum(to_string = "Size ↓")]
132    SizeDescending,
133    /// Size ascending (smallest first).
134    #[strum(to_string = "Size ↑")]
135    SizeAscending,
136    /// Name ascending (A-Z).
137    #[strum(to_string = "Name ↓")]
138    NameAscending,
139    /// Name descending (Z-A).
140    #[strum(to_string = "Name ↑")]
141    NameDescending,
142    /// Modified date descending (newest first).
143    #[strum(to_string = "Date ↓")]
144    ModifiedDescending,
145    /// Modified date ascending (oldest first).
146    #[strum(to_string = "Date ↑")]
147    ModifiedAscending,
148    /// Child count descending (most children first).
149    #[strum(to_string = "Count ↓")]
150    CountDescending,
151    /// Child count ascending (fewest children first).
152    #[strum(to_string = "Count ↑")]
153    CountAscending,
154}
155
156impl SortMode {
157    /// Cycle to the next sort mode.
158    pub fn next(self) -> Self {
159        let current = self as usize;
160        let next = (current + 1) % Self::iter().count();
161        Self::from_repr(next).unwrap_or_default()
162    }
163
164    /// Reverse the current sort direction.
165    pub fn reverse(self) -> Self {
166        match self {
167            Self::SizeDescending => Self::SizeAscending,
168            Self::SizeAscending => Self::SizeDescending,
169            Self::NameAscending => Self::NameDescending,
170            Self::NameDescending => Self::NameAscending,
171            Self::ModifiedDescending => Self::ModifiedAscending,
172            Self::ModifiedAscending => Self::ModifiedDescending,
173            Self::CountDescending => Self::CountAscending,
174            Self::CountAscending => Self::CountDescending,
175        }
176    }
177
178    /// Get a short label for display in the status bar.
179    pub fn short_label(&self) -> &'static str {
180        match self {
181            Self::SizeDescending => "SZ↓",
182            Self::SizeAscending => "SZ↑",
183            Self::NameAscending => "NM↓",
184            Self::NameDescending => "NM↑",
185            Self::ModifiedDescending => "DT↓",
186            Self::ModifiedAscending => "DT↑",
187            Self::CountDescending => "CT↓",
188            Self::CountAscending => "CT↑",
189        }
190    }
191}
192
193/// Active view/tab during normal mode.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, EnumIter, FromRepr)]
195pub enum View {
196    #[default]
197    Explorer,
198    Duplicates,
199    Age,
200    Errors,
201}
202
203impl View {
204    /// Move to next view (cyclic).
205    pub fn next(self) -> Self {
206        let current = self as usize;
207        let next = (current + 1) % Self::iter().count();
208        Self::from_repr(next).unwrap_or_default()
209    }
210
211    /// Move to previous view (cyclic).
212    pub fn prev(self) -> Self {
213        let current = self as usize;
214        let count = Self::iter().count();
215        let prev = (current + count - 1) % count;
216        Self::from_repr(prev).unwrap_or_default()
217    }
218}
219
220
221/// Progress during deletion operation.
222#[derive(Debug, Clone)]
223pub struct DeletionProgress {
224    /// Total items to delete.
225    pub total: usize,
226    /// Items deleted so far.
227    pub deleted: usize,
228    /// Items that failed to delete.
229    pub failed: usize,
230    /// Bytes freed so far.
231    pub bytes_freed: u64,
232    /// Current item being deleted.
233    pub current: Option<PathBuf>,
234}
235
236impl DeletionProgress {
237    /// Create new deletion progress.
238    pub fn new(total: usize) -> Self {
239        Self {
240            total,
241            deleted: 0,
242            failed: 0,
243            bytes_freed: 0,
244            current: None,
245        }
246    }
247
248    /// Get completion percentage.
249    pub fn percentage(&self) -> u16 {
250        if self.total > 0 {
251            ((self.deleted + self.failed) as f64 / self.total as f64 * 100.0) as u16
252        } else {
253            0
254        }
255    }
256}
257
258/// Result from a background scan operation.
259pub enum ScanResult {
260    Progress(ScanProgress),
261    #[allow(dead_code)] // For future real-time warning streaming
262    Warning(gravityfile_core::ScanWarning),
263    /// Partial tree snapshot for real-time updates during scanning.
264    PartialTree(FileTree),
265    ScanComplete(Result<FileTree, gravityfile_scan::ScanError>),
266    AnalysisComplete {
267        duplicates: DuplicateReport,
268        age_report: AgeReport,
269    },
270    /// Progress update during deletion.
271    DeletionProgress(DeletionProgress),
272    /// Deletion completed.
273    DeletionComplete {
274        deleted: usize,
275        failed: usize,
276        bytes_freed: u64,
277    },
278    /// Progress update during file operations (copy/move/etc).
279    OperationProgress(OperationProgress),
280    /// A conflict was encountered during file operation.
281    OperationConflict(Conflict),
282    /// File operation completed.
283    OperationComplete {
284        operation_type: OperationType,
285        succeeded: usize,
286        failed: usize,
287        bytes_processed: u64,
288    },
289}
290
291/// Information about the currently selected item.
292#[derive(Debug, Clone)]
293pub struct SelectedInfo {
294    pub name: String,
295    pub path: PathBuf,
296    pub size: u64,
297    pub file_count: u64,
298    pub dir_count: u64,
299    pub modified: std::time::SystemTime,
300    pub is_dir: bool,
301}
302
303/// A pending file operation waiting for conflict resolution.
304#[derive(Debug, Clone)]
305pub enum PendingOperation {
306    /// Paste operation (copy or move).
307    Paste {
308        sources: Vec<PathBuf>,
309        destination: PathBuf,
310        mode: ClipboardMode,
311    },
312}
313
314/// State for the duplicates view with expandable groups.
315#[derive(Debug, Clone, Default)]
316pub struct DuplicatesViewState {
317    /// Index of the selected group.
318    pub selected_group: usize,
319    /// Set of expanded group indices.
320    pub expanded_groups: std::collections::HashSet<usize>,
321    /// When a group is expanded, the selected file index within that group (0 = group header).
322    /// Key is group index, value is selected item (0 = header, 1..=n = files).
323    pub selected_in_group: std::collections::HashMap<usize, usize>,
324    /// Scroll offset for the view.
325    pub scroll_offset: usize,
326}
327
328impl DuplicatesViewState {
329    /// Create a new duplicates view state.
330    pub fn new() -> Self {
331        Self::default()
332    }
333
334    /// Toggle expansion of the selected group.
335    pub fn toggle_expand(&mut self) {
336        if self.expanded_groups.contains(&self.selected_group) {
337            self.expanded_groups.remove(&self.selected_group);
338            self.selected_in_group.remove(&self.selected_group);
339        } else {
340            self.expanded_groups.insert(self.selected_group);
341            self.selected_in_group.insert(self.selected_group, 0);
342        }
343    }
344
345    /// Check if a group is expanded.
346    pub fn is_expanded(&self, group_index: usize) -> bool {
347        self.expanded_groups.contains(&group_index)
348    }
349
350    /// Get the selected item within a group (0 = header, 1+ = file index).
351    pub fn selected_item(&self, group_index: usize) -> usize {
352        self.selected_in_group.get(&group_index).copied().unwrap_or(0)
353    }
354
355    /// Move selection up.
356    pub fn move_up(&mut self, _group_count: usize, get_file_count: impl Fn(usize) -> usize) {
357        if self.is_expanded(self.selected_group) {
358            let selected = self.selected_item(self.selected_group);
359            if selected > 0 {
360                // Move within group
361                self.selected_in_group.insert(self.selected_group, selected - 1);
362                return;
363            }
364        }
365        // Move to previous group
366        if self.selected_group > 0 {
367            self.selected_group -= 1;
368            // If previous group is expanded, select its last item
369            if self.is_expanded(self.selected_group) {
370                let file_count = get_file_count(self.selected_group);
371                self.selected_in_group.insert(self.selected_group, file_count);
372            }
373        }
374    }
375
376    /// Move selection down.
377    pub fn move_down(&mut self, group_count: usize, get_file_count: impl Fn(usize) -> usize) {
378        if self.is_expanded(self.selected_group) {
379            let file_count = get_file_count(self.selected_group);
380            let selected = self.selected_item(self.selected_group);
381            if selected < file_count {
382                // Move within group
383                self.selected_in_group.insert(self.selected_group, selected + 1);
384                return;
385            }
386        }
387        // Move to next group
388        if self.selected_group < group_count.saturating_sub(1) {
389            self.selected_group += 1;
390            // Start at header of next group
391            if self.is_expanded(self.selected_group) {
392                self.selected_in_group.insert(self.selected_group, 0);
393            }
394        }
395    }
396
397    /// Reset the state.
398    pub fn reset(&mut self) {
399        *self = Self::default();
400    }
401
402    /// Get the currently selected file path, if any.
403    pub fn selected_file_path<'a>(
404        &self,
405        groups: &'a [&gravityfile_analyze::DuplicateGroup],
406    ) -> Option<&'a PathBuf> {
407        let group = groups.get(self.selected_group)?;
408        let selected = self.selected_item(self.selected_group);
409        if selected == 0 || !self.is_expanded(self.selected_group) {
410            None // Header selected, not a specific file
411        } else {
412            group.paths.get(selected - 1)
413        }
414    }
415
416    /// Get all file paths in the selected group (for bulk operations).
417    pub fn selected_group_paths<'a>(
418        &self,
419        groups: &'a [&gravityfile_analyze::DuplicateGroup],
420    ) -> Option<&'a [PathBuf]> {
421        groups.get(self.selected_group).map(|g| g.paths.as_slice())
422    }
423}
424
425/// A single tab representing an independent directory exploration context.
426#[derive(Debug, Clone)]
427pub struct Tab {
428    /// Unique identifier for this tab.
429    pub id: usize,
430    /// Display label for the tab (typically the directory name).
431    pub label: String,
432    /// Root path being explored in this tab.
433    pub path: PathBuf,
434    /// Current view root (may be different from path after drilling down).
435    pub view_root: PathBuf,
436    /// Navigation history for back navigation.
437    pub history: Vec<(PathBuf, usize, std::collections::HashSet<PathBuf>)>,
438}
439
440impl Tab {
441    /// Create a new tab for a given path.
442    pub fn new(id: usize, path: PathBuf) -> Self {
443        let label = path
444            .file_name()
445            .map(|n| n.to_string_lossy().to_string())
446            .unwrap_or_else(|| path.to_string_lossy().to_string());
447
448        Self {
449            id,
450            label,
451            path: path.clone(),
452            view_root: path,
453            history: Vec::new(),
454        }
455    }
456
457    /// Get a short label for display (truncated if too long).
458    pub fn short_label(&self, max_len: usize) -> String {
459        if self.label.len() <= max_len {
460            self.label.clone()
461        } else {
462            format!("{}…", &self.label[..max_len.saturating_sub(1)])
463        }
464    }
465}
466
467/// Manager for multiple tabs.
468#[derive(Debug)]
469pub struct TabManager {
470    /// All open tabs.
471    tabs: Vec<Tab>,
472    /// Index of the currently active tab.
473    active: usize,
474    /// Next tab ID to assign.
475    next_id: usize,
476    /// Maximum number of tabs allowed.
477    max_tabs: usize,
478}
479
480impl Default for TabManager {
481    fn default() -> Self {
482        Self::new(10)
483    }
484}
485
486impl TabManager {
487    /// Create a new tab manager with a maximum tab limit.
488    pub fn new(max_tabs: usize) -> Self {
489        Self {
490            tabs: Vec::new(),
491            active: 0,
492            next_id: 0,
493            max_tabs,
494        }
495    }
496
497    /// Create a tab manager with an initial tab.
498    pub fn with_initial_tab(path: PathBuf, max_tabs: usize) -> Self {
499        let mut manager = Self::new(max_tabs);
500        manager.new_tab(path);
501        manager
502    }
503
504    /// Get the number of open tabs.
505    pub fn len(&self) -> usize {
506        self.tabs.len()
507    }
508
509    /// Check if there are no tabs.
510    pub fn is_empty(&self) -> bool {
511        self.tabs.is_empty()
512    }
513
514    /// Get the active tab index.
515    pub fn active_index(&self) -> usize {
516        self.active
517    }
518
519    /// Get a reference to the active tab.
520    pub fn active_tab(&self) -> Option<&Tab> {
521        self.tabs.get(self.active)
522    }
523
524    /// Get a mutable reference to the active tab.
525    pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
526        self.tabs.get_mut(self.active)
527    }
528
529    /// Get all tabs.
530    pub fn tabs(&self) -> &[Tab] {
531        &self.tabs
532    }
533
534    /// Create a new tab with the given path.
535    /// Returns true if the tab was created, false if at max capacity.
536    pub fn new_tab(&mut self, path: PathBuf) -> bool {
537        if self.tabs.len() >= self.max_tabs {
538            return false;
539        }
540
541        let tab = Tab::new(self.next_id, path);
542        self.next_id += 1;
543        self.tabs.push(tab);
544        self.active = self.tabs.len() - 1;
545        true
546    }
547
548    /// Close the active tab.
549    /// Returns true if a tab was closed, false if it's the last tab.
550    pub fn close_active_tab(&mut self) -> bool {
551        if self.tabs.len() <= 1 {
552            return false;
553        }
554
555        self.tabs.remove(self.active);
556        if self.active >= self.tabs.len() {
557            self.active = self.tabs.len().saturating_sub(1);
558        }
559        true
560    }
561
562    /// Close a tab by index.
563    pub fn close_tab(&mut self, index: usize) -> bool {
564        if self.tabs.len() <= 1 || index >= self.tabs.len() {
565            return false;
566        }
567
568        self.tabs.remove(index);
569        if self.active >= self.tabs.len() {
570            self.active = self.tabs.len().saturating_sub(1);
571        } else if self.active > index {
572            self.active -= 1;
573        }
574        true
575    }
576
577    /// Switch to the next tab.
578    pub fn next_tab(&mut self) {
579        if !self.tabs.is_empty() {
580            self.active = (self.active + 1) % self.tabs.len();
581        }
582    }
583
584    /// Switch to the previous tab.
585    pub fn prev_tab(&mut self) {
586        if !self.tabs.is_empty() {
587            self.active = if self.active == 0 {
588                self.tabs.len() - 1
589            } else {
590                self.active - 1
591            };
592        }
593    }
594
595    /// Switch to a specific tab by index.
596    pub fn switch_to(&mut self, index: usize) {
597        if index < self.tabs.len() {
598            self.active = index;
599        }
600    }
601
602    /// Switch to a tab by number (1-indexed, for Alt+1 through Alt+9).
603    pub fn switch_to_number(&mut self, number: usize) {
604        if number > 0 && number <= self.tabs.len() {
605            self.active = number - 1;
606        }
607    }
608
609    /// Duplicate the active tab (open same directory in new tab).
610    pub fn duplicate_active_tab(&mut self) -> bool {
611        if let Some(tab) = self.active_tab() {
612            let path = tab.view_root.clone();
613            self.new_tab(path)
614        } else {
615            false
616        }
617    }
618}
619
620/// Persistent user settings stored in config file.
621#[derive(Debug, Clone, Serialize, Deserialize)]
622#[serde(default)]
623pub struct UserSettings {
624    /// Whether to automatically start scanning on startup.
625    pub scan_on_startup: bool,
626    /// Show hidden files by default.
627    pub show_hidden: bool,
628    /// Default layout mode.
629    pub default_layout: String,
630    /// File opener configuration.
631    pub openers: FileOpeners,
632    /// Editor configuration for opensesame.
633    #[serde(default)]
634    pub editor: opensesame::EditorConfig,
635}
636
637impl Default for UserSettings {
638    fn default() -> Self {
639        Self {
640            scan_on_startup: true,  // Enabled by default
641            show_hidden: false,
642            default_layout: "tree".to_string(),
643            openers: FileOpeners::default(),
644            editor: opensesame::EditorConfig::default(),
645        }
646    }
647}
648
649/// Configuration for file openers by extension.
650#[derive(Debug, Clone, Serialize, Deserialize)]
651#[serde(default)]
652pub struct FileOpeners {
653    /// Opener for markdown files (.md).
654    pub md: String,
655    /// Opener for text/code files (uses $EDITOR via opensesame).
656    /// Set to "editor" to use opensesame, or a specific command.
657    pub text: String,
658    /// Opener for other files (system default).
659    pub default: String,
660}
661
662impl Default for FileOpeners {
663    fn default() -> Self {
664        Self {
665            // Use treemd for markdown if available, otherwise system open
666            md: "treemd".to_string(),
667            // Use $EDITOR via opensesame for text files
668            text: "editor".to_string(),
669            // Use system open command for other files
670            default: "open".to_string(),
671        }
672    }
673}
674
675impl UserSettings {
676    /// Get the config file path.
677    pub fn config_path() -> Option<PathBuf> {
678        dirs::config_dir().map(|d| d.join("gravityfile").join("settings.toml"))
679    }
680
681    /// Load settings from disk, or return defaults.
682    pub fn load() -> Self {
683        Self::config_path()
684            .and_then(|path| std::fs::read_to_string(&path).ok())
685            .and_then(|content| toml::from_str(&content).ok())
686            .unwrap_or_default()
687    }
688
689    /// Save settings to disk.
690    pub fn save(&self) -> std::io::Result<()> {
691        let path = Self::config_path().ok_or_else(|| {
692            std::io::Error::new(std::io::ErrorKind::NotFound, "No config directory")
693        })?;
694
695        // Create parent directory if needed
696        if let Some(parent) = path.parent() {
697            std::fs::create_dir_all(parent)?;
698        }
699
700        let content = toml::to_string_pretty(self)
701            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
702
703        std::fs::write(&path, content)
704    }
705}
706
707/// State for the settings modal.
708#[derive(Debug, Clone, Default)]
709pub struct SettingsState {
710    /// Currently selected setting index.
711    pub selected: usize,
712    /// Temporary copy of settings being edited.
713    pub settings: UserSettings,
714    /// Whether settings have been modified.
715    pub dirty: bool,
716}
717
718impl SettingsState {
719    /// Create a new settings state with current settings.
720    pub fn new(settings: UserSettings) -> Self {
721        Self {
722            selected: 0,
723            settings,
724            dirty: false,
725        }
726    }
727
728    /// Number of settings items.
729    pub fn item_count(&self) -> usize {
730        3 // scan_on_startup, show_hidden, default_layout
731    }
732
733    /// Move selection up.
734    pub fn move_up(&mut self) {
735        if self.selected > 0 {
736            self.selected -= 1;
737        }
738    }
739
740    /// Move selection down.
741    pub fn move_down(&mut self) {
742        if self.selected < self.item_count() - 1 {
743            self.selected += 1;
744        }
745    }
746
747    /// Toggle the currently selected boolean setting.
748    pub fn toggle_selected(&mut self) {
749        match self.selected {
750            0 => {
751                self.settings.scan_on_startup = !self.settings.scan_on_startup;
752                self.dirty = true;
753            }
754            1 => {
755                self.settings.show_hidden = !self.settings.show_hidden;
756                self.dirty = true;
757            }
758            2 => {
759                // Cycle layout mode
760                self.settings.default_layout = match self.settings.default_layout.as_str() {
761                    "tree" => "miller".to_string(),
762                    "miller" => "tree".to_string(),
763                    _ => "tree".to_string(),
764                };
765                self.dirty = true;
766            }
767            _ => {}
768        }
769    }
770}