use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
use gravityfile_analyze::{AgeReport, DuplicateReport};
use gravityfile_core::FileTree;
use gravityfile_ops::{Conflict, OperationProgress, OperationType};
use gravityfile_scan::ScanProgress;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AppMode {
#[default]
Normal,
Help,
Search,
ConfirmDelete,
Deleting,
Copying,
Moving,
Renaming,
CreatingFile,
CreatingDirectory,
Taking,
GoingTo,
ConflictResolution,
Command,
Settings,
SettingBookmark,
JumpingToBookmark,
BookmarkList,
ConfirmBulkRename,
Visual,
Quit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LayoutMode {
#[default]
Tree,
Miller,
}
impl LayoutMode {
pub fn toggle(self) -> Self {
match self {
Self::Tree => Self::Miller,
Self::Miller => Self::Tree,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct VisualState {
pub anchor: usize,
pub cursor: usize,
}
impl VisualState {
pub fn new(start_index: usize) -> Self {
Self {
anchor: start_index,
cursor: start_index,
}
}
pub fn selection_range(&self) -> (usize, usize) {
let start = self.anchor.min(self.cursor);
let end = self.anchor.max(self.cursor);
(start, end)
}
pub fn is_selected(&self, index: usize) -> bool {
let (start, end) = self.selection_range();
index >= start && index <= end
}
pub fn selection_count(&self) -> usize {
let (start, end) = self.selection_range();
end - start + 1
}
pub fn move_up(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub fn move_down(&mut self, max: usize) {
if max > 0 && self.cursor < max - 1 {
self.cursor += 1;
}
}
pub fn page_up(&mut self, page_size: usize) {
self.cursor = self.cursor.saturating_sub(page_size);
}
pub fn page_down(&mut self, page_size: usize, max: usize) {
if max > 0 {
self.cursor = (self.cursor + page_size).min(max - 1);
}
}
pub fn jump_to_top(&mut self) {
self.cursor = 0;
}
pub fn jump_to_bottom(&mut self, max: usize) {
if max > 0 {
self.cursor = max - 1;
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ClipboardMode {
#[default]
Empty,
Copy,
Cut,
}
#[derive(Debug, Clone, Default)]
pub struct ClipboardState {
pub paths: Vec<PathBuf>,
pub mode: ClipboardMode,
pub source_root: Option<PathBuf>,
}
impl ClipboardState {
pub fn yank(&mut self, paths: impl IntoIterator<Item = PathBuf>, source_root: PathBuf) {
self.paths = paths.into_iter().collect();
self.mode = ClipboardMode::Copy;
self.source_root = Some(source_root);
}
pub fn cut(&mut self, paths: impl IntoIterator<Item = PathBuf>, source_root: PathBuf) {
self.paths = paths.into_iter().collect();
self.mode = ClipboardMode::Cut;
self.source_root = Some(source_root);
}
pub fn clear(&mut self) {
self.paths.clear();
self.mode = ClipboardMode::Empty;
self.source_root = None;
}
pub fn is_empty(&self) -> bool {
self.paths.is_empty()
}
pub fn len(&self) -> usize {
self.paths.len()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, EnumIter, FromRepr)]
pub enum SortMode {
#[default]
#[strum(to_string = "Size ↓")]
SizeDescending,
#[strum(to_string = "Size ↑")]
SizeAscending,
#[strum(to_string = "Name ↑")]
NameAscending,
#[strum(to_string = "Name ↓")]
NameDescending,
#[strum(to_string = "Date ↓")]
ModifiedDescending,
#[strum(to_string = "Date ↑")]
ModifiedAscending,
#[strum(to_string = "Count ↓")]
CountDescending,
#[strum(to_string = "Count ↑")]
CountAscending,
}
impl SortMode {
pub fn next(self) -> Self {
let current = self as usize;
let next = (current + 1) % Self::iter().count();
Self::from_repr(next).unwrap_or_default()
}
pub fn reverse(self) -> Self {
match self {
Self::SizeDescending => Self::SizeAscending,
Self::SizeAscending => Self::SizeDescending,
Self::NameAscending => Self::NameDescending,
Self::NameDescending => Self::NameAscending,
Self::ModifiedDescending => Self::ModifiedAscending,
Self::ModifiedAscending => Self::ModifiedDescending,
Self::CountDescending => Self::CountAscending,
Self::CountAscending => Self::CountDescending,
}
}
pub fn short_label(&self) -> &'static str {
match self {
Self::SizeDescending => "SZ↓",
Self::SizeAscending => "SZ↑",
Self::NameAscending => "NM↑",
Self::NameDescending => "NM↓",
Self::ModifiedDescending => "DT↓",
Self::ModifiedAscending => "DT↑",
Self::CountDescending => "CT↓",
Self::CountAscending => "CT↑",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, EnumIter, FromRepr)]
pub enum View {
#[default]
Explorer,
Duplicates,
Age,
Errors,
Treemap,
}
impl View {
pub fn next(self) -> Self {
let current = self as usize;
let next = (current + 1) % Self::iter().count();
Self::from_repr(next).unwrap_or_default()
}
pub fn prev(self) -> Self {
let current = self as usize;
let count = Self::iter().count();
let prev = (current + count - 1) % count;
Self::from_repr(prev).unwrap_or_default()
}
}
#[derive(Debug, Clone)]
pub struct DeletionProgress {
pub total: usize,
pub deleted: usize,
pub failed: usize,
pub bytes_freed: u64,
pub current: Option<PathBuf>,
}
impl DeletionProgress {
pub fn new(total: usize) -> Self {
Self {
total,
deleted: 0,
failed: 0,
bytes_freed: 0,
current: None,
}
}
pub fn percentage(&self) -> u16 {
if self.total > 0 {
((self.deleted + self.failed) as f64 / self.total as f64 * 100.0) as u16
} else {
0
}
}
}
pub enum ScanResult {
Progress(ScanProgress),
#[allow(dead_code)] Warning(gravityfile_core::ScanWarning),
PartialTree(FileTree),
ScanComplete(Result<FileTree, gravityfile_scan::ScanError>),
AnalysisComplete {
duplicates: DuplicateReport,
age_report: AgeReport,
},
DeletionProgress(DeletionProgress),
DeletionComplete {
deleted: usize,
failed: usize,
bytes_freed: u64,
},
OperationProgress(OperationProgress),
OperationConflict(Conflict),
OperationComplete {
operation_type: OperationType,
succeeded: usize,
failed: usize,
bytes_processed: u64,
},
}
#[derive(Debug, Clone)]
pub struct SelectedInfo {
pub name: String,
pub path: PathBuf,
pub size: u64,
pub file_count: u64,
pub dir_count: u64,
pub modified: std::time::SystemTime,
pub is_dir: bool,
}
#[derive(Debug, Clone)]
pub enum PendingOperation {
Paste {
sources: Vec<PathBuf>,
destination: PathBuf,
mode: ClipboardMode,
},
}
#[derive(Debug, Clone, Default)]
pub struct DuplicatesViewState {
pub selected_group: usize,
pub expanded_groups: std::collections::HashSet<usize>,
pub selected_in_group: std::collections::HashMap<usize, usize>,
pub scroll_offset: usize,
}
impl DuplicatesViewState {
pub fn new() -> Self {
Self::default()
}
pub fn toggle_expand(&mut self) {
if self.expanded_groups.contains(&self.selected_group) {
self.expanded_groups.remove(&self.selected_group);
self.selected_in_group.remove(&self.selected_group);
} else {
self.expanded_groups.insert(self.selected_group);
self.selected_in_group.insert(self.selected_group, 0);
}
}
pub fn is_expanded(&self, group_index: usize) -> bool {
self.expanded_groups.contains(&group_index)
}
pub fn selected_item(&self, group_index: usize) -> usize {
self.selected_in_group
.get(&group_index)
.copied()
.unwrap_or(0)
}
pub fn move_up(&mut self, _group_count: usize, get_file_count: impl Fn(usize) -> usize) {
if self.is_expanded(self.selected_group) {
let selected = self.selected_item(self.selected_group);
if selected > 0 {
self.selected_in_group
.insert(self.selected_group, selected - 1);
return;
}
}
if self.selected_group > 0 {
self.selected_group -= 1;
if self.is_expanded(self.selected_group) {
let file_count = get_file_count(self.selected_group);
self.selected_in_group
.insert(self.selected_group, file_count);
}
}
}
pub fn move_down(&mut self, group_count: usize, get_file_count: impl Fn(usize) -> usize) {
if self.is_expanded(self.selected_group) {
let file_count = get_file_count(self.selected_group);
let selected = self.selected_item(self.selected_group);
if selected < file_count {
self.selected_in_group
.insert(self.selected_group, selected + 1);
return;
}
}
if self.selected_group < group_count.saturating_sub(1) {
self.selected_group += 1;
if self.is_expanded(self.selected_group) {
self.selected_in_group.insert(self.selected_group, 0);
}
}
}
pub fn reset(&mut self) {
*self = Self::default();
}
pub fn selected_file_path<'a>(
&self,
groups: &'a [&gravityfile_analyze::DuplicateGroup],
) -> Option<&'a PathBuf> {
let group = groups.get(self.selected_group)?;
let selected = self.selected_item(self.selected_group);
if selected == 0 || !self.is_expanded(self.selected_group) {
None } else {
group.paths.get(selected - 1)
}
}
pub fn selected_group_paths<'a>(
&self,
groups: &'a [&gravityfile_analyze::DuplicateGroup],
) -> Option<&'a [PathBuf]> {
groups.get(self.selected_group).map(|g| g.paths.as_slice())
}
}
#[derive(Debug, Clone)]
pub struct Tab {
pub id: usize,
pub label: String,
pub path: PathBuf,
pub view_root: PathBuf,
pub history: Vec<(PathBuf, usize, std::collections::HashSet<PathBuf>)>,
}
impl Tab {
pub fn new(id: usize, path: PathBuf) -> Self {
let label = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
Self {
id,
label,
path: path.clone(),
view_root: path,
history: Vec::new(),
}
}
pub fn short_label(&self, max_len: usize) -> String {
if self.label.len() <= max_len {
self.label.clone()
} else {
format!("{}…", &self.label[..max_len.saturating_sub(1)])
}
}
}
#[derive(Debug)]
pub struct TabManager {
tabs: Vec<Tab>,
active: usize,
next_id: usize,
max_tabs: usize,
}
impl Default for TabManager {
fn default() -> Self {
Self::new(10)
}
}
impl TabManager {
pub fn new(max_tabs: usize) -> Self {
Self {
tabs: Vec::new(),
active: 0,
next_id: 0,
max_tabs,
}
}
pub fn with_initial_tab(path: PathBuf, max_tabs: usize) -> Self {
let mut manager = Self::new(max_tabs);
manager.new_tab(path);
manager
}
pub fn len(&self) -> usize {
self.tabs.len()
}
pub fn is_empty(&self) -> bool {
self.tabs.is_empty()
}
pub fn active_index(&self) -> usize {
self.active
}
pub fn active_tab(&self) -> Option<&Tab> {
self.tabs.get(self.active)
}
pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
self.tabs.get_mut(self.active)
}
pub fn tabs(&self) -> &[Tab] {
&self.tabs
}
pub fn new_tab(&mut self, path: PathBuf) -> bool {
if self.tabs.len() >= self.max_tabs {
return false;
}
let tab = Tab::new(self.next_id, path);
self.next_id += 1;
self.tabs.push(tab);
self.active = self.tabs.len() - 1;
true
}
pub fn close_active_tab(&mut self) -> bool {
if self.tabs.len() <= 1 {
return false;
}
self.tabs.remove(self.active);
if self.active >= self.tabs.len() {
self.active = self.tabs.len().saturating_sub(1);
}
true
}
pub fn close_tab(&mut self, index: usize) -> bool {
if self.tabs.len() <= 1 || index >= self.tabs.len() {
return false;
}
self.tabs.remove(index);
if self.active >= self.tabs.len() {
self.active = self.tabs.len().saturating_sub(1);
} else if self.active > index {
self.active -= 1;
}
true
}
pub fn next_tab(&mut self) {
if !self.tabs.is_empty() {
self.active = (self.active + 1) % self.tabs.len();
}
}
pub fn prev_tab(&mut self) {
if !self.tabs.is_empty() {
self.active = if self.active == 0 {
self.tabs.len() - 1
} else {
self.active - 1
};
}
}
pub fn switch_to(&mut self, index: usize) {
if index < self.tabs.len() {
self.active = index;
}
}
pub fn switch_to_number(&mut self, number: usize) {
if number > 0 && number <= self.tabs.len() {
self.active = number - 1;
}
}
pub fn duplicate_active_tab(&mut self) -> bool {
if let Some(tab) = self.active_tab() {
let path = tab.view_root.clone();
self.new_tab(path)
} else {
false
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Bookmarks {
pub marks: HashMap<char, PathBuf>,
}
impl Bookmarks {
pub fn with_defaults() -> Self {
let mut marks = HashMap::new();
if let Some(home) = dirs::home_dir() {
marks.insert('h', home);
}
marks.insert('r', PathBuf::from("/"));
if let Some(config) = dirs::config_dir() {
marks.insert('c', config);
}
if let Some(downloads) = dirs::download_dir() {
marks.insert('d', downloads);
}
if let Some(documents) = dirs::document_dir() {
marks.insert('o', documents);
}
Self { marks }
}
pub fn set(&mut self, key: char, path: PathBuf) {
self.marks.insert(key, path);
}
pub fn get(&self, key: char) -> Option<&PathBuf> {
self.marks.get(&key)
}
pub fn remove(&mut self, key: char) -> Option<PathBuf> {
self.marks.remove(&key)
}
pub fn sorted_entries(&self) -> Vec<(char, &PathBuf)> {
let mut entries: Vec<_> = self.marks.iter().map(|(k, v)| (*k, v)).collect();
entries.sort_by_key(|(k, _)| *k);
entries
}
pub fn is_valid_key(key: char) -> bool {
key.is_ascii_alphanumeric()
}
}
#[derive(Debug, Clone, Default)]
pub struct BookmarkListState {
pub selected: usize,
entries_count: usize,
}
impl BookmarkListState {
pub fn new(bookmarks: &Bookmarks) -> Self {
Self {
selected: 0,
entries_count: bookmarks.marks.len(),
}
}
pub fn update_count(&mut self, bookmarks: &Bookmarks) {
self.entries_count = bookmarks.marks.len();
if self.selected >= self.entries_count && self.entries_count > 0 {
self.selected = self.entries_count - 1;
}
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
pub fn move_down(&mut self) {
if self.entries_count > 0 && self.selected < self.entries_count - 1 {
self.selected += 1;
}
}
pub fn len(&self) -> usize {
self.entries_count
}
pub fn is_empty(&self) -> bool {
self.entries_count == 0
}
}
#[derive(Debug, Clone)]
pub struct RenameEntry {
pub original: PathBuf,
pub new_name: String,
}
#[derive(Debug, Clone, Default)]
pub struct BulkRenameState {
pub entries: Vec<RenameEntry>,
pub selected: usize,
pub offset: usize,
pub error: Option<String>,
}
impl BulkRenameState {
pub fn new(original_paths: Vec<PathBuf>, new_names: Vec<String>) -> Self {
let entries: Vec<RenameEntry> = original_paths
.into_iter()
.zip(new_names)
.filter_map(|(original, new_name)| {
let old_name = original.file_name()?.to_string_lossy().to_string();
if old_name != new_name && !new_name.is_empty() {
Some(RenameEntry { original, new_name })
} else {
None
}
})
.collect();
Self {
entries,
selected: 0,
offset: 0,
error: None,
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
pub fn move_down(&mut self) {
if !self.entries.is_empty() && self.selected < self.entries.len() - 1 {
self.selected += 1;
}
}
pub fn validate(&mut self) -> bool {
let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
for entry in &self.entries {
if seen_names.contains(&entry.new_name) {
self.error = Some(format!("Duplicate name: {}", entry.new_name));
return false;
}
seen_names.insert(entry.new_name.clone());
if entry.new_name.is_empty() {
self.error = Some("Empty name is not allowed".to_string());
return false;
}
if entry.new_name.contains('/') || entry.new_name.contains('\0') {
self.error = Some(format!("Invalid characters in: {}", entry.new_name));
return false;
}
if entry.new_name == "." || entry.new_name == ".." {
self.error = Some(format!("Reserved name: {}", entry.new_name));
return false;
}
if entry.new_name.len() > 255 {
self.error = Some(format!("Name too long: {}", entry.new_name));
return false;
}
}
self.error = None;
true
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct UserSettings {
pub scan_on_startup: bool,
pub show_hidden: bool,
pub default_layout: String,
pub openers: FileOpeners,
#[serde(default)]
pub editor: opensesame::EditorConfig,
#[serde(default = "Bookmarks::with_defaults")]
pub bookmarks: Bookmarks,
}
impl Default for UserSettings {
fn default() -> Self {
Self {
scan_on_startup: true, show_hidden: false,
default_layout: "tree".to_string(),
openers: FileOpeners::default(),
editor: opensesame::EditorConfig::default(),
bookmarks: Bookmarks::with_defaults(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct FileOpeners {
pub md: String,
pub text: String,
pub default: String,
}
impl Default for FileOpeners {
fn default() -> Self {
Self {
md: "treemd".to_string(),
text: "editor".to_string(),
default: "open".to_string(),
}
}
}
impl UserSettings {
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("gravityfile").join("settings.toml"))
}
pub fn load() -> Self {
Self::config_path()
.and_then(|path| std::fs::read_to_string(&path).ok())
.and_then(|content| toml::from_str(&content).ok())
.unwrap_or_default()
}
pub fn save(&self) -> std::io::Result<()> {
let path = Self::config_path().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "No config directory")
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&path, content)
}
}
#[derive(Debug, Clone, Default)]
pub struct SettingsState {
pub selected: usize,
pub settings: UserSettings,
pub dirty: bool,
}
impl SettingsState {
pub fn new(settings: UserSettings) -> Self {
Self {
selected: 0,
settings,
dirty: false,
}
}
pub fn item_count(&self) -> usize {
3 }
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
pub fn move_down(&mut self) {
if self.selected < self.item_count() - 1 {
self.selected += 1;
}
}
pub fn toggle_selected(&mut self) {
match self.selected {
0 => {
self.settings.scan_on_startup = !self.settings.scan_on_startup;
self.dirty = true;
}
1 => {
self.settings.show_hidden = !self.settings.show_hidden;
self.dirty = true;
}
2 => {
self.settings.default_layout = match self.settings.default_layout.as_str() {
"tree" => "miller".to_string(),
"miller" => "tree".to_string(),
_ => "tree".to_string(),
};
self.dirty = true;
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_visual_state_creation() {
let state = VisualState::new(5);
assert_eq!(state.anchor, 5);
assert_eq!(state.cursor, 5);
}
#[test]
fn test_visual_state_selection_range() {
let mut state = VisualState::new(5);
assert_eq!(state.selection_range(), (5, 5));
state.cursor = 8;
assert_eq!(state.selection_range(), (5, 8));
state.cursor = 2;
assert_eq!(state.selection_range(), (2, 5));
}
#[test]
fn test_visual_state_is_selected() {
let mut state = VisualState::new(3);
state.cursor = 7;
assert!(!state.is_selected(0));
assert!(!state.is_selected(2));
assert!(state.is_selected(3));
assert!(state.is_selected(5));
assert!(state.is_selected(7));
assert!(!state.is_selected(8));
}
#[test]
fn test_visual_state_selection_count() {
let mut state = VisualState::new(3);
state.cursor = 7;
assert_eq!(state.selection_count(), 5);
state.cursor = 3;
assert_eq!(state.selection_count(), 1); }
#[test]
fn test_visual_state_move_down_bounds() {
let mut state = VisualState::new(0);
state.move_down(0);
assert_eq!(state.cursor, 0);
state.move_down(1);
assert_eq!(state.cursor, 0);
state.move_down(5);
assert_eq!(state.cursor, 1);
state.cursor = 4;
state.move_down(5);
assert_eq!(state.cursor, 4); }
#[test]
fn test_visual_state_move_up() {
let mut state = VisualState::new(5);
state.move_up();
assert_eq!(state.cursor, 4);
state.cursor = 0;
state.move_up();
assert_eq!(state.cursor, 0); }
#[test]
fn test_visual_state_page_up() {
let mut state = VisualState::new(10);
state.page_up(5);
assert_eq!(state.cursor, 5);
state.page_up(10);
assert_eq!(state.cursor, 0); }
#[test]
fn test_visual_state_page_down_bounds() {
let mut state = VisualState::new(0);
state.page_down(5, 0);
assert_eq!(state.cursor, 0);
state.page_down(5, 10);
assert_eq!(state.cursor, 5);
state.page_down(10, 10);
assert_eq!(state.cursor, 9); }
#[test]
fn test_visual_state_jump_to_top() {
let mut state = VisualState::new(50);
state.jump_to_top();
assert_eq!(state.cursor, 0);
}
#[test]
fn test_visual_state_jump_to_bottom_bounds() {
let mut state = VisualState::new(0);
state.jump_to_bottom(0);
assert_eq!(state.cursor, 0);
state.jump_to_bottom(10);
assert_eq!(state.cursor, 9);
}
}