mirror of
https://github.com/sreedevk/deduplicator.git
synced 2026-08-26 02:04:39 +00:00
[ADD] tui: interactive terminal UI for resolving duplicates
The tool could only act on duplicates non-interactively or through a line-oriented per-group prompt. Add a full-screen terminal UI (--tui) for browsing and resolving them visually. The UI is a two-pane master/detail view: a list of duplicate groups and, for the selected group, its files with per-file deletion marks. Files can be marked individually or in bulk by a keep-strategy (newest/oldest/first/ last/shortest/shallowest, reused from the resolver) applied to the current group or to all groups at once. A footer tracks how many files are marked and how much space would be reclaimed. Deletion is gated behind an explicit confirmation, a group always keeps at least one file, and the highlighted file can be opened in the system default application. The design keeps the interaction logic pure and independently testable: an App model translates an abstract key event into an outcome, so navigation, marking, strategy application, and post-deletion model updates are all unit tested without a terminal. Rendering (ratatui) and the destructive file I/O live in the event loop, which restores the terminal on both normal exit and panic so a crash never leaves it broken. --tui is mutually exclusive with --keep and --interactive.
This commit is contained in:
1300
Cargo.lock
generated
1300
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -26,8 +26,10 @@ globwalk = "0.9.1"
|
||||
gxhash = { version = "3.4.1", default-features = false }
|
||||
indicatif = { version = "0.18.0", features = ["rayon"] }
|
||||
memmap2 = "0.9.7"
|
||||
open = "5.4.0"
|
||||
pathdiff = "0.2.1"
|
||||
prettytable-rs = "0.10.0"
|
||||
ratatui = "0.30.2"
|
||||
rayon = "1.6.1"
|
||||
unicode-segmentation = "1.12.0"
|
||||
|
||||
|
||||
12
src/main.rs
12
src/main.rs
@@ -7,6 +7,7 @@ mod pipeline;
|
||||
mod processor;
|
||||
mod resolver;
|
||||
mod scanner;
|
||||
mod tui;
|
||||
|
||||
use self::{formatter::Formatter, interactive::Interactive};
|
||||
use anyhow::Result;
|
||||
@@ -17,12 +18,11 @@ fn main() -> Result<()> {
|
||||
let params = Params::parse();
|
||||
let report = pipeline::run(¶ms)?;
|
||||
|
||||
match params.keep {
|
||||
Some(strategy) => resolver::run(&report, strategy, params.force, ¶ms)?,
|
||||
None => match params.interactive {
|
||||
true => Interactive::init(&report.groups, ¶ms)?,
|
||||
false => Formatter::print(&report, ¶ms),
|
||||
},
|
||||
match (params.tui, params.keep, params.interactive) {
|
||||
(true, _, _) => tui::run(report, ¶ms)?,
|
||||
(false, Some(strategy), _) => resolver::run(&report, strategy, params.force, ¶ms)?,
|
||||
(false, None, true) => Interactive::init(&report.groups, ¶ms)?,
|
||||
(false, None, false) => Formatter::print(&report, ¶ms),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -48,6 +48,9 @@ pub struct Params {
|
||||
/// Use a specific cache file instead of the default location
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub cache_file: Option<PathBuf>,
|
||||
/// Browse and resolve duplicates in an interactive terminal UI
|
||||
#[arg(long, conflicts_with_all = ["keep", "interactive"])]
|
||||
pub tui: bool,
|
||||
}
|
||||
|
||||
impl Params {
|
||||
|
||||
499
src/tui/app.rs
Normal file
499
src/tui/app.rs
Normal file
@@ -0,0 +1,499 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::fileinfo::FileInfo;
|
||||
use crate::pipeline::DuplicateGroup;
|
||||
use crate::resolver::{select_keeper, KeepStrategy};
|
||||
|
||||
pub const STRATEGIES: [KeepStrategy; 6] = [
|
||||
KeepStrategy::Newest,
|
||||
KeepStrategy::Oldest,
|
||||
KeepStrategy::First,
|
||||
KeepStrategy::Last,
|
||||
KeepStrategy::Shortest,
|
||||
KeepStrategy::Shallowest,
|
||||
];
|
||||
|
||||
pub fn strategy_label(strategy: KeepStrategy) -> &'static str {
|
||||
match strategy {
|
||||
KeepStrategy::Newest => "newest",
|
||||
KeepStrategy::Oldest => "oldest",
|
||||
KeepStrategy::First => "first",
|
||||
KeepStrategy::Last => "last",
|
||||
KeepStrategy::Shortest => "shortest",
|
||||
KeepStrategy::Shallowest => "shallowest",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Focus {
|
||||
Groups,
|
||||
Files,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum StrategyScope {
|
||||
CurrentGroup,
|
||||
AllGroups,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Popup {
|
||||
None,
|
||||
Strategy { scope: StrategyScope },
|
||||
ConfirmDelete,
|
||||
}
|
||||
|
||||
pub enum Key {
|
||||
Up,
|
||||
Down,
|
||||
Tab,
|
||||
Space,
|
||||
Enter,
|
||||
Esc,
|
||||
Char(char),
|
||||
}
|
||||
|
||||
pub enum Outcome {
|
||||
Continue,
|
||||
Quit,
|
||||
Delete,
|
||||
Open(PathBuf),
|
||||
}
|
||||
|
||||
pub struct Group {
|
||||
pub files: Vec<FileInfo>,
|
||||
pub marked: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Group {
|
||||
pub fn size(&self) -> u64 {
|
||||
self.files.first().map(|f| f.size).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub groups: Vec<Group>,
|
||||
pub group_cursor: usize,
|
||||
pub file_cursor: usize,
|
||||
pub focus: Focus,
|
||||
pub popup: Popup,
|
||||
pub strategy_cursor: usize,
|
||||
pub status: Option<String>,
|
||||
pub should_quit: bool,
|
||||
}
|
||||
|
||||
fn clamp_add(cur: usize, delta: isize, max: usize) -> usize {
|
||||
(cur as isize + delta).clamp(0, max as isize) as usize
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn from_groups(groups: Vec<DuplicateGroup>) -> Self {
|
||||
let groups = groups
|
||||
.into_iter()
|
||||
.map(|g| Group {
|
||||
marked: vec![false; g.files.len()],
|
||||
files: g.files,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
groups,
|
||||
group_cursor: 0,
|
||||
file_cursor: 0,
|
||||
focus: Focus::Files,
|
||||
popup: Popup::None,
|
||||
strategy_cursor: 0,
|
||||
status: None,
|
||||
should_quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_group(&mut self, delta: isize) {
|
||||
if self.groups.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.group_cursor = clamp_add(self.group_cursor, delta, self.groups.len() - 1);
|
||||
let len = self.groups[self.group_cursor].files.len();
|
||||
self.file_cursor = self.file_cursor.min(len.saturating_sub(1));
|
||||
}
|
||||
|
||||
pub fn move_file(&mut self, delta: isize) {
|
||||
if let Some(g) = self.groups.get(self.group_cursor) {
|
||||
self.file_cursor = clamp_add(self.file_cursor, delta, g.files.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_mark(&mut self) {
|
||||
let fc = self.file_cursor;
|
||||
if let Some(g) = self.groups.get_mut(self.group_cursor) {
|
||||
if fc >= g.marked.len() {
|
||||
return;
|
||||
}
|
||||
if !g.marked[fc] && g.marked.iter().filter(|m| !**m).count() <= 1 {
|
||||
return;
|
||||
}
|
||||
g.marked[fc] = !g.marked[fc];
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_strategy(&mut self, strategy: KeepStrategy, scope: StrategyScope) {
|
||||
let indices: Vec<usize> = match scope {
|
||||
StrategyScope::CurrentGroup => vec![self.group_cursor],
|
||||
StrategyScope::AllGroups => (0..self.groups.len()).collect(),
|
||||
};
|
||||
for gi in indices {
|
||||
if let Some(g) = self.groups.get_mut(gi) {
|
||||
if g.files.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let keeper = select_keeper(&g.files, strategy);
|
||||
for i in 0..g.marked.len() {
|
||||
g.marked[i] = i != keeper;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn marked_count(&self) -> usize {
|
||||
self.groups
|
||||
.iter()
|
||||
.flat_map(|g| g.marked.iter())
|
||||
.filter(|m| **m)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn reclaimable_bytes(&self) -> u64 {
|
||||
self.groups
|
||||
.iter()
|
||||
.flat_map(|g| g.files.iter().zip(&g.marked))
|
||||
.filter(|(_, m)| **m)
|
||||
.map(|(f, _)| f.size)
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn marked_paths(&self) -> Vec<PathBuf> {
|
||||
self.groups
|
||||
.iter()
|
||||
.flat_map(|g| g.files.iter().zip(&g.marked))
|
||||
.filter(|(_, m)| **m)
|
||||
.map(|(f, _)| f.path.to_path_buf())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn apply_deletion(&mut self, deleted: &[PathBuf]) {
|
||||
use std::collections::HashSet;
|
||||
let removed: HashSet<&std::path::Path> = deleted.iter().map(|p| p.as_path()).collect();
|
||||
|
||||
for g in &mut self.groups {
|
||||
let mut files = Vec::new();
|
||||
let mut marked = Vec::new();
|
||||
for (i, f) in g.files.iter().enumerate() {
|
||||
if !removed.contains(&*f.path) {
|
||||
files.push(f.clone());
|
||||
marked.push(g.marked[i]);
|
||||
}
|
||||
}
|
||||
g.files = files;
|
||||
g.marked = marked;
|
||||
}
|
||||
|
||||
self.groups.retain(|g| g.files.len() >= 2);
|
||||
|
||||
if self.groups.is_empty() {
|
||||
self.should_quit = true;
|
||||
self.group_cursor = 0;
|
||||
self.file_cursor = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
self.group_cursor = self.group_cursor.min(self.groups.len() - 1);
|
||||
let len = self.groups[self.group_cursor].files.len();
|
||||
self.file_cursor = self.file_cursor.min(len.saturating_sub(1));
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: Key) -> Outcome {
|
||||
match self.popup {
|
||||
Popup::Strategy { scope } => self.handle_strategy_key(key, scope),
|
||||
Popup::ConfirmDelete => self.handle_confirm_key(key),
|
||||
Popup::None => self.handle_main_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_main_key(&mut self, key: Key) -> Outcome {
|
||||
self.status = None;
|
||||
match key {
|
||||
Key::Char('q') | Key::Esc => {
|
||||
self.should_quit = true;
|
||||
Outcome::Quit
|
||||
}
|
||||
Key::Tab => {
|
||||
self.focus = match self.focus {
|
||||
Focus::Groups => Focus::Files,
|
||||
Focus::Files => Focus::Groups,
|
||||
};
|
||||
Outcome::Continue
|
||||
}
|
||||
Key::Up | Key::Char('k') => {
|
||||
self.move_in_focus(-1);
|
||||
Outcome::Continue
|
||||
}
|
||||
Key::Down | Key::Char('j') => {
|
||||
self.move_in_focus(1);
|
||||
Outcome::Continue
|
||||
}
|
||||
Key::Space => {
|
||||
if self.focus == Focus::Files {
|
||||
self.toggle_mark();
|
||||
}
|
||||
Outcome::Continue
|
||||
}
|
||||
Key::Char('s') => {
|
||||
self.open_strategy(StrategyScope::CurrentGroup);
|
||||
Outcome::Continue
|
||||
}
|
||||
Key::Char('S') => {
|
||||
self.open_strategy(StrategyScope::AllGroups);
|
||||
Outcome::Continue
|
||||
}
|
||||
Key::Char('o') => self.open_current(),
|
||||
Key::Char('d') => {
|
||||
if self.marked_count() > 0 {
|
||||
self.popup = Popup::ConfirmDelete;
|
||||
} else {
|
||||
self.status = Some("nothing marked".to_string());
|
||||
}
|
||||
Outcome::Continue
|
||||
}
|
||||
_ => Outcome::Continue,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_strategy_key(&mut self, key: Key, scope: StrategyScope) -> Outcome {
|
||||
match key {
|
||||
Key::Up | Key::Char('k') => {
|
||||
self.strategy_cursor = self.strategy_cursor.saturating_sub(1);
|
||||
}
|
||||
Key::Down | Key::Char('j') => {
|
||||
self.strategy_cursor = (self.strategy_cursor + 1).min(STRATEGIES.len() - 1);
|
||||
}
|
||||
Key::Enter => {
|
||||
let strategy = STRATEGIES[self.strategy_cursor];
|
||||
self.apply_strategy(strategy, scope);
|
||||
self.popup = Popup::None;
|
||||
}
|
||||
Key::Esc => self.popup = Popup::None,
|
||||
_ => {}
|
||||
}
|
||||
Outcome::Continue
|
||||
}
|
||||
|
||||
fn handle_confirm_key(&mut self, key: Key) -> Outcome {
|
||||
match key {
|
||||
Key::Char('y') => {
|
||||
self.popup = Popup::None;
|
||||
Outcome::Delete
|
||||
}
|
||||
Key::Char('n') | Key::Esc => {
|
||||
self.popup = Popup::None;
|
||||
Outcome::Continue
|
||||
}
|
||||
_ => Outcome::Continue,
|
||||
}
|
||||
}
|
||||
|
||||
fn move_in_focus(&mut self, delta: isize) {
|
||||
match self.focus {
|
||||
Focus::Groups => self.move_group(delta),
|
||||
Focus::Files => self.move_file(delta),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_strategy(&mut self, scope: StrategyScope) {
|
||||
self.strategy_cursor = 0;
|
||||
self.popup = Popup::Strategy { scope };
|
||||
}
|
||||
|
||||
fn open_current(&self) -> Outcome {
|
||||
match self.groups.get(self.group_cursor).and_then(|g| g.files.get(self.file_cursor)) {
|
||||
Some(f) => Outcome::Open(f.path.to_path_buf()),
|
||||
None => Outcome::Continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fileinfo::FileInfo;
|
||||
use crate::pipeline::DuplicateGroup;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn file(path: &str, size: u64, mtime_secs: u64) -> FileInfo {
|
||||
FileInfo {
|
||||
path: PathBuf::from(path).into_boxed_path(),
|
||||
size,
|
||||
modified: UNIX_EPOCH + Duration::from_secs(mtime_secs),
|
||||
}
|
||||
}
|
||||
|
||||
fn group(files: Vec<FileInfo>) -> DuplicateGroup {
|
||||
DuplicateGroup { hash: 0, files }
|
||||
}
|
||||
|
||||
fn sample() -> App {
|
||||
App::from_groups(vec![
|
||||
group(vec![file("/a/x", 10, 100), file("/a/y", 10, 200)]),
|
||||
group(vec![file("/b/p", 5, 50), file("/b/q", 5, 60), file("/b/r", 5, 70)]),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_group_clamps_at_bounds() {
|
||||
let mut app = sample();
|
||||
app.move_group(-1);
|
||||
assert_eq!(app.group_cursor, 0);
|
||||
app.move_group(1);
|
||||
assert_eq!(app.group_cursor, 1);
|
||||
app.move_group(5);
|
||||
assert_eq!(app.group_cursor, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_group_reclamps_file_cursor() {
|
||||
let mut app = sample();
|
||||
app.group_cursor = 1;
|
||||
app.file_cursor = 2;
|
||||
app.move_group(-1);
|
||||
assert_eq!(app.group_cursor, 0);
|
||||
assert_eq!(app.file_cursor, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_file_clamps() {
|
||||
let mut app = sample();
|
||||
app.move_file(-1);
|
||||
assert_eq!(app.file_cursor, 0);
|
||||
app.move_file(10);
|
||||
assert_eq!(app.file_cursor, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_mark_blocks_marking_the_last_survivor() {
|
||||
let mut app = sample();
|
||||
app.group_cursor = 0;
|
||||
app.file_cursor = 0;
|
||||
app.toggle_mark();
|
||||
assert!(app.groups[0].marked[0]);
|
||||
app.file_cursor = 1;
|
||||
app.toggle_mark();
|
||||
assert!(!app.groups[0].marked[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_mark_allows_unmark() {
|
||||
let mut app = sample();
|
||||
app.file_cursor = 0;
|
||||
app.toggle_mark();
|
||||
app.toggle_mark();
|
||||
assert!(!app.groups[0].marked[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_strategy_current_group_marks_all_but_keeper() {
|
||||
let mut app = sample();
|
||||
app.group_cursor = 0;
|
||||
app.apply_strategy(KeepStrategy::Newest, StrategyScope::CurrentGroup);
|
||||
assert_eq!(app.groups[0].marked, vec![true, false]);
|
||||
assert_eq!(app.groups[1].marked, vec![false, false, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_strategy_all_groups() {
|
||||
let mut app = sample();
|
||||
app.apply_strategy(KeepStrategy::Oldest, StrategyScope::AllGroups);
|
||||
assert_eq!(app.groups[0].marked, vec![false, true]);
|
||||
assert_eq!(app.groups[1].marked, vec![false, true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_sum_marked_only() {
|
||||
let mut app = sample();
|
||||
app.apply_strategy(KeepStrategy::Newest, StrategyScope::AllGroups);
|
||||
assert_eq!(app.marked_count(), 3);
|
||||
assert_eq!(app.reclaimable_bytes(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marked_paths_lists_marked_files() {
|
||||
let mut app = sample();
|
||||
app.group_cursor = 0;
|
||||
app.file_cursor = 0;
|
||||
app.toggle_mark();
|
||||
assert_eq!(app.marked_paths(), vec![PathBuf::from("/a/x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_deletion_removes_files_and_drops_small_groups() {
|
||||
let mut app = sample();
|
||||
app.apply_deletion(&[PathBuf::from("/a/x")]);
|
||||
assert_eq!(app.groups.len(), 1);
|
||||
assert_eq!(app.groups[0].files.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_deletion_emptying_everything_sets_quit() {
|
||||
let mut app = App::from_groups(vec![group(vec![file("/a/x", 1, 1), file("/a/y", 1, 2)])]);
|
||||
app.apply_deletion(&[PathBuf::from("/a/x")]);
|
||||
assert!(app.groups.is_empty());
|
||||
assert!(app.should_quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_quits_on_q() {
|
||||
let mut app = sample();
|
||||
assert!(matches!(app.handle_key(Key::Char('q')), Outcome::Quit));
|
||||
assert!(app.should_quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_space_marks_in_files_focus() {
|
||||
let mut app = sample();
|
||||
app.focus = Focus::Files;
|
||||
app.handle_key(Key::Space);
|
||||
assert!(app.groups[0].marked[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_strategy_popup_apply_flow() {
|
||||
let mut app = sample();
|
||||
app.handle_key(Key::Char('S'));
|
||||
assert!(matches!(app.popup, Popup::Strategy { .. }));
|
||||
app.handle_key(Key::Enter);
|
||||
assert!(matches!(app.popup, Popup::None));
|
||||
assert_eq!(app.groups[0].marked, vec![true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_delete_requires_marks_then_confirms() {
|
||||
let mut app = sample();
|
||||
app.handle_key(Key::Char('d'));
|
||||
assert!(matches!(app.popup, Popup::None));
|
||||
app.focus = Focus::Files;
|
||||
app.handle_key(Key::Space);
|
||||
app.handle_key(Key::Char('d'));
|
||||
assert!(matches!(app.popup, Popup::ConfirmDelete));
|
||||
assert!(matches!(app.handle_key(Key::Char('y')), Outcome::Delete));
|
||||
assert!(matches!(app.popup, Popup::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_open_returns_path() {
|
||||
let mut app = sample();
|
||||
app.focus = Focus::Files;
|
||||
match app.handle_key(Key::Char('o')) {
|
||||
Outcome::Open(p) => assert_eq!(p, PathBuf::from("/a/x")),
|
||||
_ => panic!("expected Open"),
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/tui/mod.rs
Normal file
129
src/tui/mod.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
pub mod app;
|
||||
mod ui;
|
||||
|
||||
use std::io::{self, Stdout};
|
||||
|
||||
use anyhow::Result;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
|
||||
use ratatui::crossterm::execute;
|
||||
use ratatui::crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
use ratatui::Terminal;
|
||||
|
||||
use crate::params::Params;
|
||||
use crate::pipeline::DedupReport;
|
||||
use app::{App, Key, Outcome};
|
||||
|
||||
type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
pub fn run(report: DedupReport, params: &Params) -> Result<()> {
|
||||
if report.groups.is_empty() {
|
||||
println!("No duplicates found matching your search criteria.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut app = App::from_groups(report.groups);
|
||||
let mut terminal = setup_terminal()?;
|
||||
let result = event_loop(&mut terminal, &mut app, params);
|
||||
restore_terminal(&mut terminal)?;
|
||||
result
|
||||
}
|
||||
|
||||
fn setup_terminal() -> Result<Tui> {
|
||||
install_panic_hook();
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
Ok(Terminal::new(CrosstermBackend::new(stdout))?)
|
||||
}
|
||||
|
||||
fn restore_terminal(terminal: &mut Tui) -> Result<()> {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
|
||||
let _ = terminal.show_cursor();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_panic_hook() {
|
||||
let original = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||
original(info);
|
||||
}));
|
||||
}
|
||||
|
||||
fn event_loop(terminal: &mut Tui, app: &mut App, params: &Params) -> Result<()> {
|
||||
loop {
|
||||
terminal.draw(|frame| ui::draw(frame, app, params))?;
|
||||
if app.should_quit {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
if let Some(translated) = translate(key) {
|
||||
match app.handle_key(translated) {
|
||||
Outcome::Quit => {}
|
||||
Outcome::Continue => {}
|
||||
Outcome::Delete => perform_deletion(app),
|
||||
Outcome::Open(path) => {
|
||||
if let Err(err) = open::that(&path) {
|
||||
app.status = Some(format!("open failed: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if app.should_quit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn translate(key: KeyEvent) -> Option<Key> {
|
||||
Some(match key.code {
|
||||
KeyCode::Up => Key::Up,
|
||||
KeyCode::Down => Key::Down,
|
||||
KeyCode::Tab => Key::Tab,
|
||||
KeyCode::Enter => Key::Enter,
|
||||
KeyCode::Esc => Key::Esc,
|
||||
KeyCode::Char(' ') => Key::Space,
|
||||
KeyCode::Char(c) => Key::Char(c),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn perform_deletion(app: &mut App) {
|
||||
let paths = app.marked_paths();
|
||||
let mut deleted = Vec::new();
|
||||
let mut freed: u64 = 0;
|
||||
let mut failures: u64 = 0;
|
||||
|
||||
for path in &paths {
|
||||
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(_) => {
|
||||
deleted.push(path.clone());
|
||||
freed += size;
|
||||
}
|
||||
Err(_) => failures += 1,
|
||||
}
|
||||
}
|
||||
|
||||
app.apply_deletion(&deleted);
|
||||
app.status = Some(match failures {
|
||||
0 => format!("deleted {}, freed {}", deleted.len(), bytesize::ByteSize::b(freed)),
|
||||
_ => format!(
|
||||
"deleted {}, {failures} failed, freed {}",
|
||||
deleted.len(),
|
||||
bytesize::ByteSize::b(freed)
|
||||
),
|
||||
});
|
||||
}
|
||||
211
src/tui/ui.rs
Normal file
211
src/tui/ui.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::params::Params;
|
||||
use crate::formatter::Formatter;
|
||||
|
||||
use super::app::{strategy_label, App, Focus, Popup, StrategyScope, STRATEGIES};
|
||||
|
||||
pub fn draw(frame: &mut Frame, app: &App, params: &Params) {
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(1), Constraint::Length(1)])
|
||||
.split(frame.area());
|
||||
|
||||
let panes = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
|
||||
.split(rows[0]);
|
||||
|
||||
draw_groups(frame, app, panes[0]);
|
||||
draw_files(frame, app, params, panes[1]);
|
||||
draw_footer(frame, app, rows[1]);
|
||||
|
||||
match app.popup {
|
||||
Popup::Strategy { scope } => draw_strategy_popup(frame, app, scope),
|
||||
Popup::ConfirmDelete => draw_confirm_popup(frame, app),
|
||||
Popup::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn pane_block(title: &str, focused: bool) -> Block<'_> {
|
||||
let block = Block::default().borders(Borders::ALL).title(title.to_string());
|
||||
match focused {
|
||||
true => block.border_style(Style::new().yellow()),
|
||||
false => block,
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_groups(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let items: Vec<ListItem> = app
|
||||
.groups
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, g)| {
|
||||
ListItem::new(format!(
|
||||
"{:>3} {} files {}",
|
||||
i + 1,
|
||||
g.files.len(),
|
||||
bytesize::ByteSize::b(g.size())
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(pane_block("Groups", app.focus == Focus::Groups))
|
||||
.highlight_style(Style::new().reversed());
|
||||
|
||||
let mut state = ListState::default();
|
||||
state.select(Some(app.group_cursor));
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
fn draw_files(frame: &mut Frame, app: &App, params: &Params, area: Rect) {
|
||||
let title = format!(
|
||||
"Group {}/{}",
|
||||
(app.group_cursor + 1).min(app.groups.len().max(1)),
|
||||
app.groups.len()
|
||||
);
|
||||
|
||||
let items: Vec<ListItem> = match app.groups.get(app.group_cursor) {
|
||||
Some(g) => g
|
||||
.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let box_char = if g.marked[i] { "[x]" } else { "[ ]" };
|
||||
let path = Formatter::human_path(f, params, 0).unwrap_or_default();
|
||||
let size = Formatter::human_filesize(f).unwrap_or_default();
|
||||
let mtime = Formatter::human_mtime(f).unwrap_or_default();
|
||||
let line = format!("{box_char} {} {} {}", path.trim_end(), size.trim_start(), mtime);
|
||||
match g.marked[i] {
|
||||
true => ListItem::new(Line::from(line).style(Style::new().red())),
|
||||
false => ListItem::new(line),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
let list = List::new(items)
|
||||
.block(pane_block(&title, app.focus == Focus::Files))
|
||||
.highlight_style(Style::new().reversed());
|
||||
|
||||
let mut state = ListState::default();
|
||||
state.select(Some(app.file_cursor));
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
fn draw_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let hints = "[Tab]pane [Space]mark [s]group [S]all [o]pen [d]elete [q]uit";
|
||||
let left = match &app.status {
|
||||
Some(s) => s.clone(),
|
||||
None => format!(
|
||||
"marked {} · reclaim {}",
|
||||
app.marked_count(),
|
||||
bytesize::ByteSize::b(app.reclaimable_bytes())
|
||||
),
|
||||
};
|
||||
let text = format!("{left} {hints}");
|
||||
frame.render_widget(Paragraph::new(text), area);
|
||||
}
|
||||
|
||||
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||
let x = area.x + area.width.saturating_sub(width) / 2;
|
||||
let y = area.y + area.height.saturating_sub(height) / 2;
|
||||
Rect {
|
||||
x,
|
||||
y,
|
||||
width: width.min(area.width),
|
||||
height: height.min(area.height),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_strategy_popup(frame: &mut Frame, app: &App, scope: StrategyScope) {
|
||||
let title = match scope {
|
||||
StrategyScope::CurrentGroup => "Keep in this group",
|
||||
StrategyScope::AllGroups => "Keep in ALL groups",
|
||||
};
|
||||
let items: Vec<ListItem> = STRATEGIES
|
||||
.iter()
|
||||
.map(|s| ListItem::new(strategy_label(*s)))
|
||||
.collect();
|
||||
|
||||
let area = centered_rect(28, (STRATEGIES.len() as u16) + 2, frame.area());
|
||||
frame.render_widget(Clear, area);
|
||||
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.highlight_style(Style::new().reversed());
|
||||
|
||||
let mut state = ListState::default();
|
||||
state.select(Some(app.strategy_cursor));
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
fn draw_confirm_popup(frame: &mut Frame, app: &App) {
|
||||
let area = centered_rect(48, 3, frame.area());
|
||||
frame.render_widget(Clear, area);
|
||||
let text = format!(
|
||||
"Delete {} files, reclaim {}? [y/N]",
|
||||
app.marked_count(),
|
||||
bytesize::ByteSize::b(app.reclaimable_bytes())
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Confirm")),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fileinfo::FileInfo;
|
||||
use crate::pipeline::DuplicateGroup;
|
||||
use crate::tui::app::App;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn sample_app() -> App {
|
||||
let files = vec![
|
||||
FileInfo {
|
||||
path: PathBuf::from("/tmp/report.pdf").into_boxed_path(),
|
||||
size: 4_000_000,
|
||||
modified: UNIX_EPOCH + Duration::from_secs(1_700_000_000),
|
||||
},
|
||||
FileInfo {
|
||||
path: PathBuf::from("/tmp/report-copy.pdf").into_boxed_path(),
|
||||
size: 4_000_000,
|
||||
modified: UNIX_EPOCH + Duration::from_secs(1_700_100_000),
|
||||
},
|
||||
];
|
||||
App::from_groups(vec![DuplicateGroup { hash: 0, files }])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_renders_without_panic_and_shows_content() {
|
||||
let app = sample_app();
|
||||
let params = crate::params::Params::default();
|
||||
let backend = TestBackend::new(100, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal.draw(|f| draw(f, &app, ¶ms)).unwrap();
|
||||
|
||||
let text: String = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content()
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect();
|
||||
|
||||
assert!(text.contains("Groups"));
|
||||
assert!(text.contains("report"));
|
||||
assert!(text.contains("marked"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user