diff --git a/Cargo.lock b/Cargo.lock index 43f0b86..4adf63a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -99,7 +108,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bigfiles" -version = "1.3.1" +version = "1.4.0" dependencies = [ "blake3", "clap", @@ -120,6 +129,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "trash", ] [[package]] @@ -210,6 +220,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -327,6 +348,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.3.0" @@ -718,6 +745,30 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1001,6 +1052,16 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1074,6 +1135,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1508,6 +1575,24 @@ dependencies = [ "serde_json", ] +[[package]] +name = "trash" +version = "5.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7602e0c7d66ec2d92a8c917219fbc7894039efa2063b9064260110828a356f46" +dependencies = [ + "chrono", + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1549,6 +1634,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1719,12 +1810,118 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 996b725..361b41d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bigfiles" -version = "1.3.1" +version = "1.4.0" edition = "2021" license = "AGPL-3.0-or-later" description = "Find what's eating your disk — a parallel directory scanner with category breakdown, stale-file flagging, and hardlink-aware duplicate detection." @@ -27,6 +27,7 @@ ratatui = "0.29" crossterm = "0.28" num-format = "0.4" dirs = "5" +trash = "5" [target.'cfg(unix)'.dependencies] pager = "0.16" diff --git a/README.md b/README.md index 6e1dcd8..f558350 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,10 @@ A small Rust CLI that walks a directory in parallel, groups files by type, flags ## What it does -- **Interactive TUI** (`bigfiles tui`) — ncdu-style directory browser with arrow-key navigation, `/` filter, and `o` to reveal in OS file manager +- **Interactive TUI** (`bigfiles tui`) — ncdu-style directory browser with arrow-key navigation, `/` filter, `o` to reveal in OS file manager, `d` to send to Trash, `D` for dupes in the current subtree, `r` to re-scan - **Quick audit** (`bigfiles audit`) — severity-coded "what's eating your disk" insights in one screen +- **`bigfiles top`** — flat list of the N largest files, no category grouping +- **Safe by default**: `delete` and `dupes --delete` send to Trash by default; `--force` opts into permanent deletion - Walks a directory tree **in parallel** and collects file sizes, extensions, and modified timestamps - Respects `.gitignore` and `.ignore` files by default (use `--no-ignore` to disable) - Skips symlinks (no double-counting, no follow-link footguns) @@ -83,6 +85,13 @@ bigfiles ~ --sort size --reverse # Quick "what's eating my disk" insights view bigfiles audit ~ + +# Show just the 10 largest files anywhere under the path +bigfiles top ~/Downloads -n 10 + +# Send stale files to Trash (default), or permanently delete with --force +bigfiles delete ~/Downloads +bigfiles delete ~/Downloads --force ``` ### .gitignore awareness @@ -99,7 +108,18 @@ Use `--no-ignore` to walk everything regardless. bigfiles tui ~ ``` -Keys: `↑/↓` (or `j/k`) move • `Enter`/`→` descend into directory • `←`/`Backspace` go up • `/` filter children by substring (`Esc` cancels, `Enter` keeps) • `o` reveal selected entry in your OS file manager (`open -R` on macOS, `explorer /select,` on Windows, `xdg-open` on Linux) • `q`/`Esc` quit • `?` toggle help. +Keys: + +- `↑/↓` or `j/k` — move +- `Enter` or `→` — descend into directory +- `←` or `Backspace` — go up +- `/` — filter children by substring (`Esc` cancels, `Enter` keeps) +- `o` — reveal selected entry in your OS file manager (`open -R` on macOS, `explorer /select,` on Windows, `xdg-open` on Linux) +- `d` — send the selected file or directory to Trash (yellow confirm bar appears; `y`/Enter confirms, any other key cancels). Trash-only in the TUI for safety. For permanent delete, use `bigfiles delete --force` or `bigfiles dupes --delete --force` from the CLI. +- `D` — open a duplicate-detection popup scoped to the currently-highlighted directory. Uses the persistent hash cache, so repeat runs over the same subtree are near-instant. `j/k` or PgUp/PgDn to scroll, `Esc`/`q` to close. +- `r` — re-run the scan from disk (the TUI exits briefly, shows the spinner, then re-enters with fresh data) +- `q`/`Esc` — quit +- `?` — toggle help ### Quick audit @@ -111,6 +131,23 @@ bigfiles audit ~ Insights are bulleted by severity: red `!` for heavy (≥40% of total), yellow `•` for notable (≥20%), dimmed `·` for informational. Respects `--stale-years` and all global filters (`--skip-hidden`, `--exclude`, `--depth`, etc.). +### Top N largest files + +`bigfiles top ` prints a flat list of the N largest files under the given path, sorted by size descending. No category grouping, no bars, no stale flags — just the biggest files. Pairs well with `| head`, `| grep`, or pipelines. + +```bash +# Default: top 20 +bigfiles top ~/Downloads + +# Top 5 +bigfiles top ~/Movies -n 5 + +# Pipe into other tools +bigfiles top ~ -n 100 | grep '\.mp4' +``` + +Respects all global filters (`--skip-hidden`, `--exclude`, `--depth`, `--no-ignore`). + ### Find duplicate files `bigfiles dupes` finds files with identical content. It uses a fast three-stage check, parallelized with `rayon`: @@ -129,22 +166,29 @@ bigfiles dupes ~/Downloads --min-size 1048576 bigfiles dupes ~/Documents --min-size 1 ``` -#### Delete duplicate copies (interactive) +#### Remove duplicate copies (interactive) + +`bigfiles dupes --delete` walks each duplicate group and lets you pick which copy to **keep**; the rest are queued for removal. After all groups are processed, you get a summary and a `y/N` confirm before any file is touched. -`bigfiles dupes --delete` walks each duplicate group and lets you pick which copy to **keep**; the rest are queued for deletion. After all groups are processed, you get a red summary and a `y/N` confirm before any file is touched. +By default, removed copies are **moved to your OS Trash** (recoverable). Pair with `--force` to delete permanently. ```bash +# Default: moves duplicate copies to Trash bigfiles dupes ~/Downloads --delete + +# Permanent removal (not recoverable) +bigfiles dupes ~/Downloads --delete --force ``` Safety guarantees: -- Per-group single-choice picker — you can only delete by *not picking* one to keep +- Per-group single-choice picker — you can only remove by *not picking* one to keep - Every group offers a "skip — keep all" option; `Esc` also skips - Always keeps ≥1 copy per group (it's structurally impossible to empty a group) -- No deletion happens until the final `y/N` confirm; default is **No** +- No removal happens until the final `y/N` confirm; default is **No** - Files are re-stat'd immediately before removal; non-regular files (symlinks, sockets, devices) are refused -- Files are removed permanently — they do **not** go to Trash +- **Trash by default**: moved copies can be restored from your OS Trash unless you pass `--force` +- If Trash is unavailable (e.g. on some network mounts), the operation refuses and points you at `--force` Note that dupes are only ever paired *within* the scan root. If two copies live in separate trees, scan a common parent. @@ -160,15 +204,21 @@ Note that dupes are only ever paired *within* the scan root. If two copies live In local testing, the warm cache is roughly 40× faster than a cold first run on the same tree. -### Delete stale files (interactive) +### Remove stale files (interactive) + +`bigfiles delete` shows you every file older than `--stale-years` (default 2) in an interactive checklist. You tick which ones to remove, see a confirmation summary, and only then are files touched. -`bigfiles delete` shows you every file older than `--stale-years` (default 2) in an interactive checklist. You tick which ones to remove, see a confirmation summary, and only then are files deleted. **Files are removed permanently — they do not go to Trash.** +By default, selected files are **moved to your OS Trash** (recoverable). Pair with `--force` to delete permanently. ```bash +# Default: moves stale files to Trash bigfiles delete ~/Downloads --stale-years 3 + +# Permanent removal +bigfiles delete ~/Downloads --stale-years 3 --force ``` -The flow: list → tick boxes (Space) → Enter → review summary → type `y` to confirm. Hit Ctrl-C any time to bail. +The flow: list → tick boxes (Space) → Enter → review summary → type `y` to confirm. Hit Ctrl-C any time to bail. If Trash is unavailable, the operation refuses and points you at `--force`. ### Flags (global) @@ -193,10 +243,23 @@ The flow: list → tick boxes (Space) → Enter → review summary → type `y` | Flag | Default | Description | |---|---|---| | `--min-size ` | `1024` | Minimum file size to consider | -| `--delete` | off | Interactively delete duplicate copies (keep one per group) | +| `--delete` | off | Interactively remove duplicate copies (keep one per group). Moves to Trash by default. | +| `--force` | off | When paired with `--delete`, permanently deletes instead of moving to Trash. Cannot be undone. | | `--no-cache` | off | Skip the persistent hash cache for this run | | `--clear-cache` | off | Delete the persistent hash cache before running | +### Flags (delete subcommand) + +| Flag | Default | Description | +|---|---|---| +| `--force` | off | Permanently delete selected files instead of moving them to Trash. Cannot be undone. | + +### Flags (top subcommand) + +| Flag | Default | Description | +|---|---|---| +| `-n, --n ` | `20` | Number of largest files to show | + ### Pager When stdout is a real terminal, bigfiles auto-pages output through `$PAGER` (default `less -FRX`) — same UX as `git log`. Short output passes through instantly thanks to `-F`; long output (e.g. `bigfiles ~ --top 20`) opens scrollable. Use arrow keys / `/` to search / `q` to quit. @@ -278,9 +341,10 @@ Measured on macOS (Apple Silicon) with Criterion, 100 samples each. Numbers are ## Caveats -- **Deletion is permanent** for both `delete` and `dupes --delete` — nothing goes to Trash. The interactive flow exists precisely to keep that decision explicit; there is no `--force` or non-interactive delete mode by design. +- **Removal is to Trash by default** for both `delete` and `dupes --delete`. Pair with `--force` for permanent deletion (cannot be undone). The TUI is Trash-only — use the CLI with `--force` if you need permanent removal. +- **If Trash is unavailable** (some network mounts, certain restricted environments), the operation refuses cleanly and asks you to re-run with `--force`. bigfiles will not silently fall back to permanent deletion. - **Dupe pairing is relative to the scan root.** If two copies live in separate trees (e.g. `~/A/file` and `~/B/file`), running `bigfiles ~/A dupes` won't find them. Scan a common parent. -- **`--top` and `--json` only apply to the default scan.** They're accepted but ignored under `dupes`/`delete` (a stderr note is printed). +- **`--top`, `--json`, `--sort`, `--reverse` only apply to the default scan.** They're accepted but ignored under `dupes`/`delete`/`audit`/`top`/`tui` (a stderr note is printed). - **Symlinks are skipped entirely.** If you rely on symlink farms for organization, walking through them isn't supported — point bigfiles at the real paths. ## Future ideas diff --git a/src/delete.rs b/src/delete.rs index 28a8fef..13b5695 100644 --- a/src/delete.rs +++ b/src/delete.rs @@ -1,12 +1,12 @@ +use bigfiles::destructive::{self, RemovalError, RemovalMode}; use bigfiles::format::bytes as format_bytes; use bigfiles::walker::FileEntry; use dialoguer::{theme::ColorfulTheme, Confirm, MultiSelect}; use owo_colors::OwoColorize; -use std::fs; use std::io; use std::time::{Duration, SystemTime}; -pub fn run(files: &[FileEntry], stale_years: u64) -> io::Result<()> { +pub fn run(files: &[FileEntry], stale_years: u64, mode: RemovalMode) -> io::Result<()> { let now = SystemTime::now(); let threshold = Duration::from_secs(stale_years * 365 * 24 * 60 * 60); @@ -42,11 +42,13 @@ pub fn run(files: &[FileEntry], stale_years: u64) -> io::Result<()> { stale_years, format_bytes(total_size).bold().yellow() ); - println!( - " {}", - "Tick the files you want to permanently delete. Space to toggle, Enter to confirm." - .dimmed() - ); + let hint = match mode { + RemovalMode::Trash => "Tick the files to move to Trash. Space to toggle, Enter to confirm.", + RemovalMode::Force => { + "Tick the files you want to permanently delete. Space to toggle, Enter to confirm." + } + }; + println!(" {}", hint.dimmed()); println!(); let labels: Vec = stale @@ -55,63 +57,92 @@ pub fn run(files: &[FileEntry], stale_years: u64) -> io::Result<()> { .collect(); let selected = MultiSelect::with_theme(&ColorfulTheme::default()) - .with_prompt("Select files to delete") + .with_prompt("Select files") .items(&labels) .interact() .map_err(io::Error::other)?; if selected.is_empty() { - println!(" {}", "Nothing selected. No files deleted.".dimmed()); + println!(" {}", "Nothing selected. No files removed.".dimmed()); return Ok(()); } - let to_delete: Vec<&&FileEntry> = selected.iter().map(|i| &stale[*i]).collect(); - let delete_size: u64 = to_delete.iter().map(|f| f.size).sum(); + let to_remove: Vec<&&FileEntry> = selected.iter().map(|i| &stale[*i]).collect(); + let total_remove: u64 = to_remove.iter().map(|f| f.size).sum(); println!(); + let action_label = match mode { + RemovalMode::Trash => "move to Trash".yellow().bold().to_string(), + RemovalMode::Force => "PERMANENTLY DELETE".red().bold().to_string(), + }; println!( " About to {} {} files ({}):", - "PERMANENTLY DELETE".red().bold(), - to_delete.len().to_string().red().bold(), - format_bytes(delete_size).red().bold(), + action_label, + to_remove.len().to_string().bold(), + format_bytes(total_remove).bold(), ); - for f in &to_delete { + for f in &to_remove { println!(" {}", f.path.display()); } println!(); - println!( - " {} {}", - "Files will not go to Trash.".red(), - "This cannot be undone.".red().bold() - ); + match mode { + RemovalMode::Trash => { + println!( + " {}", + "Files will be moved to Trash. You can restore them from your OS Trash.".dimmed() + ); + } + RemovalMode::Force => { + println!( + " {} {}", + "Files will not go to Trash.".red(), + "This cannot be undone.".red().bold() + ); + } + } println!(); let confirmed = Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt("Proceed with deletion?") + .with_prompt("Proceed?") .default(false) .interact() .map_err(io::Error::other)?; if !confirmed { - println!(" {}", "Aborted. No files deleted.".dimmed()); + println!(" {}", "Aborted. No files removed.".dimmed()); return Ok(()); } - let mut deleted = 0usize; + let mut removed = 0usize; let mut failed = 0usize; let mut freed = 0u64; let mut interrupted = false; - for f in to_delete { + let mut trash_unavailable = false; + for f in to_remove { if bigfiles::interrupted() { interrupted = true; break; } - match fs::remove_file(&f.path) { + match destructive::remove_file(&f.path, mode) { Ok(()) => { - deleted += 1; + removed += 1; freed += f.size; } - Err(e) => { + Err(RemovalError::TrashUnavailable(msg)) => { + trash_unavailable = true; + eprintln!( + " {} Trash is not available on this system ({}).", + "✗".red(), + msg + ); + eprintln!( + " {} Re-run with {} to permanently delete instead.", + "→".yellow(), + "--force".bold() + ); + break; + } + Err(RemovalError::Io(e)) => { failed += 1; eprintln!(" failed: {} ({})", f.path.display(), e); } @@ -121,19 +152,29 @@ pub fn run(files: &[FileEntry], stale_years: u64) -> io::Result<()> { println!(); if interrupted { println!( - " {} interrupted; partial deletion only.", + " {} interrupted; partial removal only.", "⚠".yellow().bold() ); } + if trash_unavailable && removed == 0 { + println!(" {}", "Aborted. No files removed.".dimmed()); + println!(); + return Ok(()); + } + let verb = match mode { + RemovalMode::Trash => "moved", + RemovalMode::Force => "deleted", + }; println!( - " {} deleted {} files, freed {}", + " {} {} {} files, freed {}", "✓".green(), - deleted.to_string().cyan(), + verb, + removed.to_string().cyan(), format_bytes(freed).cyan() ); if failed > 0 { println!( - " {} {} files could not be deleted", + " {} {} files could not be removed", "✗".red(), failed.to_string().red() ); diff --git a/src/destructive.rs b/src/destructive.rs new file mode 100644 index 0000000..dd2ce0c --- /dev/null +++ b/src/destructive.rs @@ -0,0 +1,86 @@ +use std::path::Path; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum RemovalMode { + Trash, + Force, +} + +#[derive(Debug)] +pub enum RemovalError { + TrashUnavailable(String), + Io(std::io::Error), +} + +impl std::fmt::Display for RemovalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RemovalError::TrashUnavailable(msg) => { + write!(f, "trash unavailable: {}", msg) + } + RemovalError::Io(e) => write!(f, "{}", e), + } + } +} + +impl std::error::Error for RemovalError {} + +pub fn remove_file(path: &Path, mode: RemovalMode) -> Result<(), RemovalError> { + match mode { + RemovalMode::Force => std::fs::remove_file(path).map_err(RemovalError::Io), + RemovalMode::Trash => { + trash::delete(path).map_err(|e| RemovalError::TrashUnavailable(format!("{}", e))) + } + } +} + +pub fn mode_verb(mode: RemovalMode) -> &'static str { + match mode { + RemovalMode::Trash => "moved to Trash", + RemovalMode::Force => "PERMANENTLY DELETED", + } +} + +pub fn mode_short(mode: RemovalMode) -> &'static str { + match mode { + RemovalMode::Trash => "trash", + RemovalMode::Force => "delete", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + use std::io::Write; + use tempfile::tempdir; + + #[test] + fn force_mode_removes_permanently() { + let dir = tempdir().unwrap(); + let p = dir.path().join("a.txt"); + File::create(&p).unwrap().write_all(b"hi").unwrap(); + assert!(p.exists()); + remove_file(&p, RemovalMode::Force).unwrap(); + assert!(!p.exists()); + } + + #[test] + fn trash_mode_removes_path() { + let dir = tempdir().unwrap(); + let p = dir.path().join("b.txt"); + File::create(&p).unwrap().write_all(b"hi").unwrap(); + assert!(p.exists()); + match remove_file(&p, RemovalMode::Trash) { + Ok(()) => assert!(!p.exists()), + Err(RemovalError::TrashUnavailable(_)) => {} + Err(e) => panic!("unexpected error: {}", e), + } + } + + #[test] + fn mode_verb_strings() { + assert_eq!(mode_verb(RemovalMode::Trash), "moved to Trash"); + assert_eq!(mode_verb(RemovalMode::Force), "PERMANENTLY DELETED"); + } +} diff --git a/src/dupes.rs b/src/dupes.rs index e0dce14..f99bf34 100644 --- a/src/dupes.rs +++ b/src/dupes.rs @@ -237,7 +237,11 @@ pub fn render_to_string(groups: &[DupeGroup], root: &Path) -> String { out } -pub fn delete_interactive(groups: &[DupeGroup], root: &Path) -> io::Result<()> { +pub fn delete_interactive( + groups: &[DupeGroup], + root: &Path, + mode: crate::destructive::RemovalMode, +) -> io::Result<()> { println!(); if groups.is_empty() { println!( @@ -332,40 +336,55 @@ pub fn delete_interactive(groups: &[DupeGroup], root: &Path) -> io::Result<()> { } println!(); + let action_label = match mode { + crate::destructive::RemovalMode::Trash => "move to Trash".yellow().bold().to_string(), + crate::destructive::RemovalMode::Force => "PERMANENTLY DELETE".red().bold().to_string(), + }; println!( " About to {} {} duplicate cop{} ({}):", - "PERMANENTLY DELETE".red().bold(), - to_delete.len().to_string().red().bold(), + action_label, + to_delete.len().to_string().bold(), if to_delete.len() == 1 { "y" } else { "ies" }, - format_bytes(delete_size).red().bold(), + format_bytes(delete_size).bold(), ); for p in &to_delete { println!(" {}", p.display()); } println!(); - println!( - " {} {}", - "Files will not go to Trash.".red(), - "This cannot be undone.".red().bold() - ); + match mode { + crate::destructive::RemovalMode::Trash => { + println!( + " {}", + "Files will be moved to Trash. You can restore them from your OS Trash.".dimmed() + ); + } + crate::destructive::RemovalMode::Force => { + println!( + " {} {}", + "Files will not go to Trash.".red(), + "This cannot be undone.".red().bold() + ); + } + } println!(); let confirmed = Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt("Proceed with deletion?") + .with_prompt("Proceed?") .default(false) .interact() .map_err(io::Error::other)?; if !confirmed { - println!(" {}", "Aborted. No files deleted.".dimmed()); + println!(" {}", "Aborted. No files removed.".dimmed()); println!(); return Ok(()); } - let mut deleted = 0usize; + let mut removed = 0usize; let mut failed = 0usize; let mut freed = 0u64; let mut interrupted = false; + let mut trash_unavailable = false; for p in &to_delete { if crate::interrupted() { interrupted = true; @@ -388,12 +407,26 @@ pub fn delete_interactive(groups: &[DupeGroup], root: &Path) -> io::Result<()> { continue; } let size = meta.len(); - match fs::remove_file(p) { + match crate::destructive::remove_file(p, mode) { Ok(()) => { - deleted += 1; + removed += 1; freed += size; } - Err(e) => { + Err(crate::destructive::RemovalError::TrashUnavailable(msg)) => { + trash_unavailable = true; + eprintln!( + " {} Trash is not available on this system ({}).", + "✗".red(), + msg + ); + eprintln!( + " {} Re-run with {} to permanently delete instead.", + "→".yellow(), + "--force".bold() + ); + break; + } + Err(crate::destructive::RemovalError::Io(e)) => { failed += 1; eprintln!(" failed: {} ({})", p.display(), e); } @@ -403,19 +436,29 @@ pub fn delete_interactive(groups: &[DupeGroup], root: &Path) -> io::Result<()> { println!(); if interrupted { println!( - " {} interrupted; partial deletion only.", + " {} interrupted; partial removal only.", "⚠".yellow().bold() ); } + if trash_unavailable && removed == 0 { + println!(" {}", "Aborted. No files removed.".dimmed()); + println!(); + return Ok(()); + } + let verb = match mode { + crate::destructive::RemovalMode::Trash => "moved", + crate::destructive::RemovalMode::Force => "deleted", + }; println!( - " {} deleted {} files, freed {}", + " {} {} {} files, freed {}", "✓".green(), - deleted.to_string().cyan(), + verb, + removed.to_string().cyan(), format_bytes(freed).cyan() ); if failed > 0 { println!( - " {} {} files could not be deleted", + " {} {} files could not be removed", "✗".red(), failed.to_string().red() ); diff --git a/src/lib.rs b/src/lib.rs index 640f4a9..a5cecc7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod analyzer; pub mod audit; pub mod cache; pub mod classifier; +pub mod destructive; pub mod dupes; pub mod format; pub mod renderer; diff --git a/src/main.rs b/src/main.rs index 01f7b78..51db8c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -123,7 +123,7 @@ enum Command { #[arg(long, default_value_t = 1024)] min_size: u64, - /// Interactively delete duplicate copies (keep one per group) + /// Interactively delete duplicate copies (keep one per group). By default files are moved to Trash; pair with --force to delete permanently. #[arg(long)] delete: bool, @@ -134,13 +134,27 @@ enum Command { /// Delete the persistent hash cache before running #[arg(long)] clear_cache: bool, + + /// Permanently delete files instead of moving them to Trash (cannot be undone) + #[arg(long)] + force: bool, + }, + /// Interactively delete stale files (moved to Trash by default; --force for permanent) + Delete { + /// Permanently delete files instead of moving them to Trash (cannot be undone) + #[arg(long)] + force: bool, }, - /// Interactively delete stale files - Delete, /// Interactive directory browser (ncdu-style) Tui, /// Quick insights about what's eating your disk Audit, + /// Show the N largest files (flat list, no category grouping) + Top { + /// Number of files to show + #[arg(short = 'n', long, default_value_t = 20)] + n: usize, + }, } fn main() -> ExitCode { @@ -190,10 +204,12 @@ fn main() -> ExitCode { delete, no_cache, clear_cache, - }) => run_dupes(&cli, *min_size, *delete, *no_cache, *clear_cache), - Some(Command::Delete) => run_delete(&cli), + force, + }) => run_dupes(&cli, *min_size, *delete, *no_cache, *clear_cache, *force), + Some(Command::Delete { force }) => run_delete(&cli, *force), Some(Command::Tui) => run_tui(&cli), Some(Command::Audit) => run_audit(&cli), + Some(Command::Top { n }) => run_top(&cli, *n), } } @@ -300,6 +316,7 @@ fn run_dupes( delete: bool, no_cache: bool, clear_cache: bool, + force: bool, ) -> ExitCode { if clear_cache { if let Err(e) = bigfiles::cache::clear() { @@ -319,7 +336,12 @@ fn run_dupes( } } if delete { - match dupes::delete_interactive(&groups, &cli.path) { + let mode = if force { + bigfiles::destructive::RemovalMode::Force + } else { + bigfiles::destructive::RemovalMode::Trash + }; + match dupes::delete_interactive(&groups, &cli.path, mode) { Ok(()) => ExitCode::from(EXIT_SUCCESS), Err(e) => { eprintln!("bigfiles: dupes delete failed: {}", e); @@ -334,12 +356,15 @@ fn run_dupes( } fn run_tui(cli: &Cli) -> ExitCode { - let scan = scan_with_progress(cli, true); - match bigfiles::tui::run(&cli.path, &scan.files) { - Ok(()) => ExitCode::from(EXIT_SUCCESS), - Err(e) => { - eprintln!("bigfiles: tui failed: {}", e); - ExitCode::from(EXIT_RUNTIME_ERROR) + loop { + let scan = scan_with_progress(cli, true); + match bigfiles::tui::run(&cli.path, &scan.files) { + Ok(bigfiles::tui::ExitReason::Quit) => return ExitCode::from(EXIT_SUCCESS), + Ok(bigfiles::tui::ExitReason::Refresh) => continue, + Err(e) => { + eprintln!("bigfiles: tui failed: {}", e); + return ExitCode::from(EXIT_RUNTIME_ERROR); + } } } } @@ -354,9 +379,21 @@ fn run_audit(cli: &Cli) -> ExitCode { ExitCode::from(EXIT_SUCCESS) } -fn run_delete(cli: &Cli) -> ExitCode { +fn run_top(cli: &Cli, n: usize) -> ExitCode { let scan = scan_with_progress(cli, true); - match delete::run(&scan.files, cli.stale_years) { + setup_pager(cli); + renderer::render_top_flat(&scan.files, n, &cli.path); + ExitCode::from(EXIT_SUCCESS) +} + +fn run_delete(cli: &Cli, force: bool) -> ExitCode { + let scan = scan_with_progress(cli, true); + let mode = if force { + bigfiles::destructive::RemovalMode::Force + } else { + bigfiles::destructive::RemovalMode::Trash + }; + match delete::run(&scan.files, cli.stale_years, mode) { Ok(()) => ExitCode::from(EXIT_SUCCESS), Err(e) => { eprintln!("bigfiles: delete failed: {}", e); diff --git a/src/renderer.rs b/src/renderer.rs index d694ffe..fdc0343 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -98,6 +98,55 @@ pub fn render_top(files: &[FileEntry], n: usize) { print!("{}", render_top_to_string(files, n)); } +pub fn render_top_flat(files: &[FileEntry], n: usize, root: &Path) { + print!("{}", render_top_flat_to_string(files, n, root)); +} + +pub fn render_top_flat_to_string(files: &[FileEntry], n: usize, root: &Path) -> String { + let mut out = String::new(); + if n == 0 { + return out; + } + let _ = writeln!(out); + if files.is_empty() { + let _ = writeln!( + out, + " {} no files found in {}", + "✓".green(), + root.display().dimmed() + ); + let _ = writeln!(out); + return out; + } + + let mut sorted: Vec<&FileEntry> = files.iter().collect(); + sorted.sort_by_key(|f| std::cmp::Reverse(f.size)); + let take = sorted.len().min(n); + + let _ = writeln!( + out, + " {} {} largest files in {}", + "bigfiles top".bold(), + take.to_string().cyan(), + root.display().dimmed() + ); + let _ = writeln!(out); + + let idx_width = take.to_string().len(); + for (i, f) in sorted.iter().take(take).enumerate() { + let _ = writeln!( + out, + " {:>width$}. {:>10} {}", + (i + 1).to_string().dimmed(), + format_bytes(f.size).yellow(), + f.path.display(), + width = idx_width, + ); + } + let _ = writeln!(out); + out +} + pub fn render_top_to_string(files: &[FileEntry], n: usize) -> String { let mut out = String::new(); if n == 0 || files.is_empty() { @@ -233,4 +282,48 @@ mod tests { assert_eq!(render_top_to_string(&[], 5), ""); assert_eq!(render_top_to_string(&[entry("/a", 1, "txt")], 0), ""); } + + #[test] + fn top_flat_sorts_by_size_desc() { + let files = vec![ + entry("/r/small.txt", 100, "txt"), + entry("/r/big.mp4", 10_000, "mp4"), + entry("/r/mid.jpg", 1_000, "jpg"), + ]; + let out = strip_ansi(&render_top_flat_to_string(&files, 10, Path::new("/r"))); + let big_pos = out.find("big.mp4").unwrap(); + let mid_pos = out.find("mid.jpg").unwrap(); + let small_pos = out.find("small.txt").unwrap(); + assert!(big_pos < mid_pos); + assert!(mid_pos < small_pos); + } + + #[test] + fn top_flat_caps_at_n() { + let files: Vec = (0..50) + .map(|i| entry(&format!("/r/{}", i), i as u64, "bin")) + .collect(); + let out = strip_ansi(&render_top_flat_to_string(&files, 5, Path::new("/r"))); + assert!(out.contains("5 largest")); + assert!(!out.contains("/r/44")); + } + + #[test] + fn top_flat_handles_fewer_files_than_n() { + let files = vec![entry("/r/a", 10, "txt"), entry("/r/b", 5, "txt")]; + let out = strip_ansi(&render_top_flat_to_string(&files, 20, Path::new("/r"))); + assert!(out.contains("2 largest")); + } + + #[test] + fn top_flat_n_zero_returns_empty() { + let files = vec![entry("/r/a", 10, "txt")]; + assert_eq!(render_top_flat_to_string(&files, 0, Path::new("/r")), ""); + } + + #[test] + fn top_flat_empty_files_shows_friendly_message() { + let out = strip_ansi(&render_top_flat_to_string(&[], 10, Path::new("/r"))); + assert!(out.contains("no files found")); + } } diff --git a/src/tui.rs b/src/tui.rs index 626c591..dfba715 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -3,10 +3,11 @@ use crate::walker::FileEntry; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::{execute, terminal}; use ratatui::backend::CrosstermBackend; +use ratatui::layout::Rect; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use ratatui::Terminal; use std::collections::HashMap; use std::io; @@ -144,10 +145,25 @@ enum InputMode { #[default] Normal, Filter, + ConfirmDelete, + Dupes, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ExitReason { + Quit, + Refresh, +} + +struct DupesView { + title: String, + lines: Vec, + scroll: u16, } struct AppState { tree: Tree, + files: Vec, current: usize, list_state: ListState, show_help: bool, @@ -155,15 +171,19 @@ struct AppState { filter: String, input_mode: InputMode, status: Option, + refresh_requested: bool, + pending_delete: Option, + dupes_popup: Option, } impl AppState { - fn new(tree: Tree) -> Self { + fn new(tree: Tree, files: Vec) -> Self { let mut list_state = ListState::default(); list_state.select(Some(0)); let current = tree.root; Self { tree, + files, current, list_state, show_help: false, @@ -171,6 +191,9 @@ impl AppState { filter: String::new(), input_mode: InputMode::Normal, status: None, + refresh_requested: false, + pending_delete: None, + dupes_popup: None, } } @@ -263,6 +286,122 @@ impl AppState { Err(e) => self.status = Some(format!("reveal failed: {}", e)), } } + + fn request_delete_selected(&mut self) { + let Some(idx) = self.selected_node_idx() else { + self.status = Some("nothing selected".to_string()); + return; + }; + if idx == self.tree.root { + self.status = Some("can't delete the scan root from here".to_string()); + return; + } + self.pending_delete = Some(idx); + self.input_mode = InputMode::ConfirmDelete; + } + + fn cancel_pending_delete(&mut self) { + self.pending_delete = None; + self.input_mode = InputMode::Normal; + } + + fn confirm_pending_delete(&mut self) { + let Some(idx) = self.pending_delete.take() else { + self.input_mode = InputMode::Normal; + return; + }; + self.input_mode = InputMode::Normal; + let path = self.tree.nodes[idx].path.clone(); + let size = self.tree.nodes[idx].size; + match crate::destructive::remove_file(&path, crate::destructive::RemovalMode::Trash) { + Ok(()) => { + self.remove_node_from_tree(idx, size); + self.status = Some(format!("moved to Trash: {}", path.display())); + } + Err(crate::destructive::RemovalError::TrashUnavailable(msg)) => { + self.status = Some(format!( + "Trash unavailable ({msg}). Use bigfiles delete --force from the CLI for permanent removal." + )); + } + Err(crate::destructive::RemovalError::Io(e)) => { + self.status = Some(format!("delete failed: {}", e)); + } + } + } + + fn open_dupes_for_subtree(&mut self) { + let Some(idx) = self.selected_node_idx() else { + self.status = Some("nothing selected".to_string()); + return; + }; + let node = &self.tree.nodes[idx]; + if !node.is_dir { + self.status = Some("press D on a directory".to_string()); + return; + } + let subtree_root = node.path.clone(); + let title = format!("dupes in {}", subtree_root.display()); + let candidates: Vec = self + .files + .iter() + .filter(|f| f.path.starts_with(&subtree_root)) + .cloned() + .collect(); + if candidates.is_empty() { + self.status = Some(format!("no files under {}", subtree_root.display())); + return; + } + let cache = crate::cache::HashCache::load(); + let groups = crate::dupes::find_with_cache(&candidates, 1024, &cache); + let _ = cache.save(); + let rendered = crate::dupes::render_to_string(&groups, &subtree_root); + let lines: Vec = rendered.lines().map(strip_ansi).collect(); + self.dupes_popup = Some(DupesView { + title, + lines, + scroll: 0, + }); + self.input_mode = InputMode::Dupes; + } + + fn close_dupes(&mut self) { + self.dupes_popup = None; + self.input_mode = InputMode::Normal; + } + + fn scroll_dupes(&mut self, delta: i32) { + if let Some(view) = &mut self.dupes_popup { + let new = (view.scroll as i32 + delta).max(0) as u16; + view.scroll = new; + } + } + + fn remove_node_from_tree(&mut self, idx: usize, size: u64) { + let parent_path = self.tree.nodes[idx].path.parent().map(|p| p.to_path_buf()); + if let Some(parent_path) = parent_path { + if let Some(&parent_idx) = self.tree.by_path.get(&parent_path) { + self.tree.nodes[parent_idx].children.retain(|&c| c != idx); + let mut cursor = Some(parent_idx); + while let Some(c) = cursor { + self.tree.nodes[c].size = self.tree.nodes[c].size.saturating_sub(size); + if c == self.tree.root { + break; + } + let p_path = self.tree.nodes[c].path.parent().map(|p| p.to_path_buf()); + cursor = p_path.and_then(|p| self.tree.by_path.get(&p).copied()); + } + } + } + self.tree.by_path.remove(&self.tree.nodes[idx].path); + + let visible = self.visible_children(); + if visible.is_empty() { + self.list_state.select(Some(0)); + } else { + let cur = self.list_state.selected().unwrap_or(0); + self.list_state.select(Some(cur.min(visible.len() - 1))); + } + } } #[cfg(target_os = "macos")] @@ -304,9 +443,9 @@ fn open_in_file_manager(_path: &Path) -> io::Result<()> { )) } -pub fn run(root: &Path, files: &[FileEntry]) -> io::Result<()> { +pub fn run(root: &Path, files: &[FileEntry]) -> io::Result { let tree = Tree::build(root, files); - let mut state = AppState::new(tree); + let mut state = AppState::new(tree, files.to_vec()); terminal::enable_raw_mode()?; let mut stdout = io::stdout(); @@ -334,7 +473,12 @@ pub fn run(root: &Path, files: &[FileEntry]) -> io::Result<()> { terminal::disable_raw_mode()?; execute!(term.backend_mut(), terminal::LeaveAlternateScreen)?; - result + result?; + Ok(if state.refresh_requested { + ExitReason::Refresh + } else { + ExitReason::Quit + }) } fn handle_key(state: &mut AppState, code: KeyCode, mods: KeyModifiers) { @@ -365,6 +509,29 @@ fn handle_key(state: &mut AppState, code: KeyCode, mods: KeyModifiers) { } return; } + if matches!(state.input_mode, InputMode::ConfirmDelete) { + match code { + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + state.confirm_pending_delete(); + } + KeyCode::Char('c') if mods.contains(KeyModifiers::CONTROL) => state.quit = true, + _ => state.cancel_pending_delete(), + } + return; + } + if matches!(state.input_mode, InputMode::Dupes) { + match code { + KeyCode::Esc | KeyCode::Char('q') => state.close_dupes(), + KeyCode::Char('c') if mods.contains(KeyModifiers::CONTROL) => state.quit = true, + KeyCode::Down | KeyCode::Char('j') => state.scroll_dupes(1), + KeyCode::Up | KeyCode::Char('k') => state.scroll_dupes(-1), + KeyCode::PageDown => state.scroll_dupes(10), + KeyCode::PageUp => state.scroll_dupes(-10), + KeyCode::Home => state.scroll_dupes(i32::MIN / 2), + _ => {} + } + return; + } state.status = None; match code { KeyCode::Char('q') | KeyCode::Esc => state.quit = true, @@ -380,6 +547,12 @@ fn handle_key(state: &mut AppState, code: KeyCode, mods: KeyModifiers) { state.list_state.select(Some(0)); } KeyCode::Char('o') => state.reveal_selected(), + KeyCode::Char('d') => state.request_delete_selected(), + KeyCode::Char('D') => state.open_dupes_for_subtree(), + KeyCode::Char('r') => { + state.refresh_requested = true; + state.quit = true; + } _ => {} } } @@ -468,7 +641,32 @@ fn draw(f: &mut ratatui::Frame<'_>, state: &mut AppState) { .highlight_symbol(" ▶ "); f.render_stateful_widget(list, layout[1], &mut state.list_state); - let footer = if matches!(state.input_mode, InputMode::Filter) { + let footer = if matches!(state.input_mode, InputMode::ConfirmDelete) { + let (label, size) = state + .pending_delete + .map(|i| { + let n = &state.tree.nodes[i]; + (n.path.display().to_string(), format_bytes(n.size)) + }) + .unwrap_or_else(|| ("(none)".to_string(), String::new())); + Paragraph::new(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + "Move to Trash:", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(label, Style::default().fg(Color::White)), + Span::raw(" "), + Span::styled(size, Style::default().fg(Color::Yellow)), + Span::styled( + " [y/↵ confirm · any other key cancel]", + Style::default().fg(Color::DarkGray), + ), + ])) + } else if matches!(state.input_mode, InputMode::Filter) { Paragraph::new(Line::from(vec![ Span::styled(" filter: ", Style::default().fg(Color::Yellow)), Span::styled(state.filter.clone(), Style::default().fg(Color::White)), @@ -485,7 +683,7 @@ fn draw(f: &mut ratatui::Frame<'_>, state: &mut AppState) { )) } else if state.show_help { Paragraph::new(Span::styled( - " ↑/↓ or j/k: move ↵/→: open ←/⌫: up /: filter o: reveal in file manager q: quit ?: toggle help".to_string(), + " ↑/↓ or j/k: move ↵/→: open ←/⌫: up /: filter o: reveal in file manager d: trash D: dupes in subtree r: refresh scan q: quit ?: toggle help".to_string(), Style::default().fg(Color::DarkGray), )) } else { @@ -496,11 +694,50 @@ fn draw(f: &mut ratatui::Frame<'_>, state: &mut AppState) { }; Paragraph::new(Span::styled( format!( - " ↑/↓ move ↵ open ← up / filter o reveal q quit ? help{}", + " ↑/↓ move ↵ open ← up / filter o reveal d trash D dupes r refresh q quit ? help{}", suffix ), Style::default().fg(Color::DarkGray), )) }; f.render_widget(footer, layout[2]); + + if let Some(view) = &state.dupes_popup { + let area = popup_rect(80, 75, f.area()); + f.render_widget(Clear, area); + let body = view.lines.join("\n"); + let popup = Paragraph::new(body) + .block(Block::default().borders(Borders::ALL).title(format!( + " {} [Esc/q close · j/k scroll · PgUp/PgDn page] ", + view.title + ))) + .wrap(Wrap { trim: false }) + .scroll((view.scroll, 0)); + f.render_widget(popup, area); + } +} + +fn popup_rect(percent_x: u16, percent_y: u16, parent: Rect) -> Rect { + let h = parent.height * percent_y / 100; + let w = parent.width * percent_x / 100; + let x = parent.x + (parent.width - w) / 2; + let y = parent.y + (parent.height - h) / 2; + Rect::new(x, y, w, h) +} + +fn strip_ansi(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut chars = line.chars(); + while let Some(c) = chars.next() { + if c == '\x1b' { + for nc in chars.by_ref() { + if nc.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out } diff --git a/src/walker.rs b/src/walker.rs index ec36b96..570ce98 100644 --- a/src/walker.rs +++ b/src/walker.rs @@ -8,6 +8,7 @@ use std::time::SystemTime; pub type InodeKey = (u64, u64); +#[derive(Clone)] pub struct FileEntry { pub path: PathBuf, pub size: u64,