1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum AppMode {
18 #[default]
19 Normal,
20 Help,
21 Search,
23 ConfirmDelete,
25 Deleting,
27 Copying,
29 Moving,
31 Renaming,
33 CreatingFile,
35 CreatingDirectory,
37 Taking,
39 GoingTo,
41 ConflictResolution,
43 Command,
45 Settings,
47 Quit,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum LayoutMode {
53 #[default]
55 Tree,
56 Miller,
58}
59
60impl LayoutMode {
61 pub fn toggle(self) -> Self {
63 match self {
64 Self::Tree => Self::Miller,
65 Self::Miller => Self::Tree,
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum ClipboardMode {
73 #[default]
75 Empty,
76 Copy,
78 Cut,
80}
81
82#[derive(Debug, Clone, Default)]
84pub struct ClipboardState {
85 pub paths: Vec<PathBuf>,
87 pub mode: ClipboardMode,
89 pub source_root: Option<PathBuf>,
91}
92
93impl ClipboardState {
94 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 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 pub fn clear(&mut self) {
110 self.paths.clear();
111 self.mode = ClipboardMode::Empty;
112 self.source_root = None;
113 }
114
115 pub fn is_empty(&self) -> bool {
117 self.paths.is_empty()
118 }
119
120 pub fn len(&self) -> usize {
122 self.paths.len()
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, EnumIter, FromRepr)]
128pub enum SortMode {
129 #[default]
131 #[strum(to_string = "Size ↓")]
132 SizeDescending,
133 #[strum(to_string = "Size ↑")]
135 SizeAscending,
136 #[strum(to_string = "Name ↓")]
138 NameAscending,
139 #[strum(to_string = "Name ↑")]
141 NameDescending,
142 #[strum(to_string = "Date ↓")]
144 ModifiedDescending,
145 #[strum(to_string = "Date ↑")]
147 ModifiedAscending,
148 #[strum(to_string = "Count ↓")]
150 CountDescending,
151 #[strum(to_string = "Count ↑")]
153 CountAscending,
154}
155
156impl SortMode {
157 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 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 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#[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 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 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#[derive(Debug, Clone)]
223pub struct DeletionProgress {
224 pub total: usize,
226 pub deleted: usize,
228 pub failed: usize,
230 pub bytes_freed: u64,
232 pub current: Option<PathBuf>,
234}
235
236impl DeletionProgress {
237 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 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
258pub enum ScanResult {
260 Progress(ScanProgress),
261 #[allow(dead_code)] Warning(gravityfile_core::ScanWarning),
263 PartialTree(FileTree),
265 ScanComplete(Result<FileTree, gravityfile_scan::ScanError>),
266 AnalysisComplete {
267 duplicates: DuplicateReport,
268 age_report: AgeReport,
269 },
270 DeletionProgress(DeletionProgress),
272 DeletionComplete {
274 deleted: usize,
275 failed: usize,
276 bytes_freed: u64,
277 },
278 OperationProgress(OperationProgress),
280 OperationConflict(Conflict),
282 OperationComplete {
284 operation_type: OperationType,
285 succeeded: usize,
286 failed: usize,
287 bytes_processed: u64,
288 },
289}
290
291#[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#[derive(Debug, Clone)]
305pub enum PendingOperation {
306 Paste {
308 sources: Vec<PathBuf>,
309 destination: PathBuf,
310 mode: ClipboardMode,
311 },
312}
313
314#[derive(Debug, Clone, Default)]
316pub struct DuplicatesViewState {
317 pub selected_group: usize,
319 pub expanded_groups: std::collections::HashSet<usize>,
321 pub selected_in_group: std::collections::HashMap<usize, usize>,
324 pub scroll_offset: usize,
326}
327
328impl DuplicatesViewState {
329 pub fn new() -> Self {
331 Self::default()
332 }
333
334 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 pub fn is_expanded(&self, group_index: usize) -> bool {
347 self.expanded_groups.contains(&group_index)
348 }
349
350 pub fn selected_item(&self, group_index: usize) -> usize {
352 self.selected_in_group.get(&group_index).copied().unwrap_or(0)
353 }
354
355 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 self.selected_in_group.insert(self.selected_group, selected - 1);
362 return;
363 }
364 }
365 if self.selected_group > 0 {
367 self.selected_group -= 1;
368 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 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 self.selected_in_group.insert(self.selected_group, selected + 1);
384 return;
385 }
386 }
387 if self.selected_group < group_count.saturating_sub(1) {
389 self.selected_group += 1;
390 if self.is_expanded(self.selected_group) {
392 self.selected_in_group.insert(self.selected_group, 0);
393 }
394 }
395 }
396
397 pub fn reset(&mut self) {
399 *self = Self::default();
400 }
401
402 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 } else {
412 group.paths.get(selected - 1)
413 }
414 }
415
416 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#[derive(Debug, Clone)]
427pub struct Tab {
428 pub id: usize,
430 pub label: String,
432 pub path: PathBuf,
434 pub view_root: PathBuf,
436 pub history: Vec<(PathBuf, usize, std::collections::HashSet<PathBuf>)>,
438}
439
440impl Tab {
441 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 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#[derive(Debug)]
469pub struct TabManager {
470 tabs: Vec<Tab>,
472 active: usize,
474 next_id: usize,
476 max_tabs: usize,
478}
479
480impl Default for TabManager {
481 fn default() -> Self {
482 Self::new(10)
483 }
484}
485
486impl TabManager {
487 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 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 pub fn len(&self) -> usize {
506 self.tabs.len()
507 }
508
509 pub fn is_empty(&self) -> bool {
511 self.tabs.is_empty()
512 }
513
514 pub fn active_index(&self) -> usize {
516 self.active
517 }
518
519 pub fn active_tab(&self) -> Option<&Tab> {
521 self.tabs.get(self.active)
522 }
523
524 pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
526 self.tabs.get_mut(self.active)
527 }
528
529 pub fn tabs(&self) -> &[Tab] {
531 &self.tabs
532 }
533
534 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 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 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 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 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 pub fn switch_to(&mut self, index: usize) {
597 if index < self.tabs.len() {
598 self.active = index;
599 }
600 }
601
602 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
622#[serde(default)]
623pub struct UserSettings {
624 pub scan_on_startup: bool,
626 pub show_hidden: bool,
628 pub default_layout: String,
630 pub openers: FileOpeners,
632 #[serde(default)]
634 pub editor: opensesame::EditorConfig,
635}
636
637impl Default for UserSettings {
638 fn default() -> Self {
639 Self {
640 scan_on_startup: true, show_hidden: false,
642 default_layout: "tree".to_string(),
643 openers: FileOpeners::default(),
644 editor: opensesame::EditorConfig::default(),
645 }
646 }
647}
648
649#[derive(Debug, Clone, Serialize, Deserialize)]
651#[serde(default)]
652pub struct FileOpeners {
653 pub md: String,
655 pub text: String,
658 pub default: String,
660}
661
662impl Default for FileOpeners {
663 fn default() -> Self {
664 Self {
665 md: "treemd".to_string(),
667 text: "editor".to_string(),
669 default: "open".to_string(),
671 }
672 }
673}
674
675impl UserSettings {
676 pub fn config_path() -> Option<PathBuf> {
678 dirs::config_dir().map(|d| d.join("gravityfile").join("settings.toml"))
679 }
680
681 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 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 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#[derive(Debug, Clone, Default)]
709pub struct SettingsState {
710 pub selected: usize,
712 pub settings: UserSettings,
714 pub dirty: bool,
716}
717
718impl SettingsState {
719 pub fn new(settings: UserSettings) -> Self {
721 Self {
722 selected: 0,
723 settings,
724 dirty: false,
725 }
726 }
727
728 pub fn item_count(&self) -> usize {
730 3 }
732
733 pub fn move_up(&mut self) {
735 if self.selected > 0 {
736 self.selected -= 1;
737 }
738 }
739
740 pub fn move_down(&mut self) {
742 if self.selected < self.item_count() - 1 {
743 self.selected += 1;
744 }
745 }
746
747 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 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}