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,
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, 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,
}
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, 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,
}
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(),
}
}
}
#[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;
}
_ => {}
}
}
}