From 544d7b3e81328a294f4004c156f0b70a031288c8 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Fri, 23 Oct 2026 13:05:04 +0200 Subject: [PATCH 1/2] Reclaiming duplicates updates the view in place; trash is never searched (0.10.2) - After a real reclaim the duplicate view searched the whole drive again. It now stays where it was: reclaim_dupes handles one copy at a time and returns exactly which copies were deleted, trashed or hard-linked (`done`); the view drops those, removes groups left with a single file and recounts what each wastes (dropReclaimed, with a test). Open scans have the files taken out of their trees as before. The status says that trashed space is freed only when the Recycle Bin is emptied. - That new search also listed the copies just sent to the Recycle Bin as duplicates again: the bin is a folder on the same drive. Duplicate searches now never enter trash folders below their root ($Recycle.Bin, RECYCLER, System Volume Information, .Trash, .Trashes, .Trash-*; core TRASH_DIRS, so the CLI too). Searching inside the bin itself still works. Test included. --- .claude/settings.json | 7 ++++ Cargo.lock | 4 +-- Cargo.toml | 2 +- crates/diskghost-core/src/dupes.rs | 57 ++++++++++++++++++++++++++++++ crates/diskghost-core/src/lib.rs | 2 +- gui/src-tauri/Cargo.lock | 4 +-- gui/src-tauri/Cargo.toml | 2 +- gui/src-tauri/src/main.rs | 49 +++++++++++++++++-------- gui/src-tauri/tauri.conf.json | 2 +- gui/ui/app.js | 16 ++++++--- gui/ui/lib.js | 15 +++++++- gui/ui/lib.test.js | 15 ++++++++ 12 files changed, 146 insertions(+), 29 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..acb7afd --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "statusLine": { + "type": "command", + "command": "node \"D:\\Coding\\switchboard\\spike\\tools\\statusline.mjs\"", + "padding": 0 + } +} diff --git a/Cargo.lock b/Cargo.lock index ab4ac78..bf8ff8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,7 +330,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "diskghost-cli" -version = "0.10.1" +version = "0.10.2" dependencies = [ "clap", "diskghost-core", @@ -340,7 +340,7 @@ dependencies = [ [[package]] name = "diskghost-core" -version = "0.10.1" +version = "0.10.2" dependencies = [ "blake3", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 5422a02..80de6f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["gui"] resolver = "2" [workspace.package] -version = "0.10.1" +version = "0.10.2" edition = "2021" license = "MIT" authors = ["kingchenc"] diff --git a/crates/diskghost-core/src/dupes.rs b/crates/diskghost-core/src/dupes.rs index b19c159..75dd2cd 100644 --- a/crates/diskghost-core/src/dupes.rs +++ b/crates/diskghost-core/src/dupes.rs @@ -26,6 +26,18 @@ use crate::snapshot::{get_varint, put_varint}; use crate::walk::walk; use crate::{phase, DupGroup, Options, Progress}; +/// Folders a duplicate search never enters (below its root): the recycle bin +/// and trash of each platform, and Windows' restore-point store. Files there +/// are copies of something that was just deleted, not duplicates to clean up. +pub const TRASH_DIRS: [&str; 6] = [ + "$Recycle.Bin", + "RECYCLER", + "System Volume Information", + ".Trash", + ".Trashes", + ".Trash-*", +]; + /// Bytes read from each end of a candidate for the quick comparison. const SAMPLE: u64 = 16 * 1024; @@ -298,6 +310,16 @@ pub(crate) fn find( cache: &HashCache, ) -> Vec { // 1. Every file big enough; duplicates group by *logical* (content) size. + // Trash folders hold what was just reclaimed: never duplicates to act on. + let opts = &Options { + exclude: opts + .exclude + .iter() + .cloned() + .chain(TRASH_DIRS.iter().map(|d| d.to_string())) + .collect(), + ..opts.clone() + }; let mut cands: Vec = Vec::new(); let _ = walk(root, opts, progress, |b| { for (f, modified) in b.files.into_iter().zip(b.modified) { @@ -463,6 +485,41 @@ mod tests { std::fs::remove_dir_all(&d).ok(); } + #[test] + fn the_recycle_bin_is_not_searched() { + // A copy sent to the recycle bin must not reappear as a duplicate of + // the file that was kept. + let d = tmpdir("trash"); + write(&d.join("keep/a.bin"), &[3u8; 50_000]); + write( + &d.join("$Recycle.Bin/S-1-5-21/$RABC123.bin"), + &[3u8; 50_000], + ); + write(&d.join(".Trash-1000/files/a.bin"), &[3u8; 50_000]); + let g = find( + &d, + 1, + &Options::default(), + &Progress::default(), + &HashCache::new(), + ); + assert!(g.is_empty(), "{g:?}"); + // Searching inside the trash itself still works. + write( + &d.join("$Recycle.Bin/S-1-5-21/$RDEF456.bin"), + &[3u8; 50_000], + ); + let inside = find( + &d.join("$Recycle.Bin"), + 1, + &Options::default(), + &Progress::default(), + &HashCache::new(), + ); + assert_eq!(inside.len(), 1); + std::fs::remove_dir_all(&d).ok(); + } + #[test] fn cache_round_trip_and_reuse() { let d = tmpdir("cache"); diff --git a/crates/diskghost-core/src/lib.rs b/crates/diskghost-core/src/lib.rs index 6b7ef2b..b6864a1 100644 --- a/crates/diskghost-core/src/lib.rs +++ b/crates/diskghost-core/src/lib.rs @@ -23,7 +23,7 @@ mod snapshot; mod tree; mod walk; -pub use dupes::HashCache; +pub use dupes::{HashCache, TRASH_DIRS}; pub use snapshot::{ diff, format_timestamp, DiffReport, DirDelta, FileDelta, Snapshot, SnapshotError, diff --git a/gui/src-tauri/Cargo.lock b/gui/src-tauri/Cargo.lock index 649d1eb..c658863 100644 --- a/gui/src-tauri/Cargo.lock +++ b/gui/src-tauri/Cargo.lock @@ -645,7 +645,7 @@ dependencies = [ [[package]] name = "diskghost-core" -version = "0.10.1" +version = "0.10.2" dependencies = [ "blake3", "fs4", @@ -659,7 +659,7 @@ dependencies = [ [[package]] name = "diskghost-gui" -version = "0.10.1" +version = "0.10.2" dependencies = [ "diskghost-core", "serde", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index b47ff7b..c84eaa2 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "diskghost-gui" -version = "0.10.1" +version = "0.10.2" edition = "2021" license = "MIT" description = "Diskghost desktop GUI (Tauri) — modern disk usage & duplicate finder." diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 127f6e3..1656bd9 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -595,7 +595,7 @@ async fn reclaim_dupes( jobs: Vec, action: String, dry_run: bool, -) -> Result { +) -> Result { let act = match action.as_str() { "delete" => ReclaimAction::Delete, "trash" => ReclaimAction::Trash, @@ -606,29 +606,48 @@ async fn reclaim_dupes( let mut removed = 0usize; let mut reclaimed = 0u64; let mut errors = Vec::new(); - let mut touched = Vec::new(); + let mut done = Vec::new(); + // One path at a time, so the outcome of each is known exactly: the + // view drops just those copies instead of searching again. for j in jobs { let keep = PathBuf::from(&j.keep); - let remove: Vec = j.remove.iter().map(PathBuf::from).collect(); - let r = reclaim(&keep, &remove, j.size, act, dry_run); - removed += r.removed; - reclaimed += r.reclaimed; - errors.extend(r.errors); - touched.extend(remove); + for p in j.remove { + let path = PathBuf::from(&p); + let r = reclaim(&keep, std::slice::from_ref(&path), j.size, act, dry_run); + removed += r.removed; + reclaimed += r.reclaimed; + errors.extend(r.errors); + if r.removed > 0 { + done.push(p); + } + } } - if !dry_run && removed > 0 { - forget_paths(&app, &touched); + if !dry_run && act != ReclaimAction::Hardlink { + let gone: Vec = done.iter().map(PathBuf::from).collect(); + forget_paths(&app, &gone); } - Ok(ReclaimReport { - removed, - reclaimed, - errors, - dry_run, + Ok(ReclaimOutcome { + report: ReclaimReport { + removed, + reclaimed, + errors, + dry_run, + }, + done, }) }) .await } +/// A reclaim's report plus the copies it actually dealt with. +#[derive(Serialize)] +struct ReclaimOutcome { + #[serde(flatten)] + report: ReclaimReport, + /// Paths deleted, sent to the trash or replaced by a hard link. + done: Vec, +} + /// Remove a file or folder as job `job` — permanently or to the OS trash. /// `apply=false` is a dry run that only reports what would go. Progress is /// emitted live and the operation is cancellable. Refuses a filesystem root as diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index c4224be..dff8cbd 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Diskghost", - "version": "0.10.1", + "version": "0.10.2", "identifier": "org.diskghost.app", "build": { "frontendDist": "../ui" diff --git a/gui/ui/app.js b/gui/ui/app.js index cdc759e..d310df5 100644 --- a/gui/ui/app.js +++ b/gui/ui/app.js @@ -89,6 +89,7 @@ const I18N = { confirmTrash: "Move to the Recycle Bin?", confirmDelete: "Delete permanently?", confirmDeleteWarn: "This cannot be undone.", ok: "OK", reclaimConfirm: (a, n) => `${a}: ${n} file(s)?`, irreversible: "This cannot be undone.", + toTrashNote: "(moved to the Recycle Bin — the space is freed when it is emptied)", enterPath: "Enter a folder path first.", // categories cat_video: "Video", cat_image: "Images", cat_audio: "Audio", cat_archive: "Archives", @@ -197,6 +198,7 @@ const I18N = { confirmTrash: "In den Papierkorb verschieben?", confirmDelete: "Endgültig löschen?", confirmDeleteWarn: "Das lässt sich nicht rückgängig machen.", ok: "OK", reclaimConfirm: (a, n) => `${a}: ${n} Datei(en)?`, irreversible: "Das lässt sich nicht rückgängig machen.", + toTrashNote: "(in den Papierkorb verschoben — der Platz wird frei, sobald er geleert wird)", enterPath: "Bitte zuerst einen Ordnerpfad eingeben.", cat_video: "Video", cat_image: "Bilder", cat_audio: "Audio", cat_archive: "Archive", cat_diskimage: "Disk-Images & VMs", cat_document: "Dokumente", cat_code: "Code & Textdaten", @@ -1707,16 +1709,20 @@ async function reclaimShown(j, dryRun) { status(dryRun ? `${t("dryRun")}…` : t("deleting")); try { const rep = await invoke("reclaim_dupes", { jobs: reclaimJobs.map(({ keep, remove, size }) => ({ keep, remove, size })), action, dryRun }); - status(`${rep.dry_run ? "DRY-RUN" : "OK"}: ${num(rep.removed)} ${t("files")}, ${human(rep.reclaimed)} ${t("reclaimable")}` + - (rep.errors.length ? `, ${rep.errors.length} ${t("errors")}` : ""), rep.errors.length > 0); + const where = action === "trash" ? ` ${t("toTrashNote")}` : ""; + status(`${rep.dry_run ? "DRY-RUN" : "OK"}: ${num(rep.removed)} ${t("files")}, ${human(rep.reclaimed)} ${t("reclaimable")}${rep.dry_run ? "" : where}` + + (rep.errors.length ? `, ${rep.errors.length} ${t("errors")}: ${rep.errors[0]}` : ""), rep.errors.length > 0); if (!rep.dry_run) { + // Drop exactly the copies that were dealt with; stay in this view. + j.groups = dropReclaimed(j.groups, rep.done); + for (const [hash, kept] of j.keep) if (!j.groups.some((g) => g.hash === hash && g.files.includes(kept))) j.keep.delete(hash); + j.tagged = false; for (const o of jobs) { if (o.kind !== "scan" || !(isUnder(j.root, o.root) || isUnder(o.root, j.root))) continue; - o.cache.clear(); + o.cache.clear(); // the backend took the files out of its tree o.stale = true; } - j.tagged = false; - rescan(j); + renderFor(j); loadDrives(); } } catch (e) { diff --git a/gui/ui/lib.js b/gui/ui/lib.js index f1eddb7..ad103c3 100644 --- a/gui/ui/lib.js +++ b/gui/ui/lib.js @@ -320,6 +320,19 @@ return [...sets.values()].sort((a, b) => b.wasted - a.wasted || a.key.localeCompare(b.key)); } + // Duplicate groups after a reclaim: the copies in `done` are gone (or are + // hard links now), groups left with one file are no duplicates any more, + // and what each group wastes is counted again. + function dropReclaimed(groups, done) { + const gone = new Set(done || []); + return (groups || []) + .map((g) => { + const files = g.files.filter((f) => !gone.has(f)); + return { ...g, files, wasted: g.size * Math.max(0, files.length - 1) }; + }) + .filter((g) => g.files.length > 1); + } + // The drive a path lives on ("c:", "\\server\share", "/"), for scheduling. function driveKey(path) { const p = String(path); @@ -386,6 +399,6 @@ human, humanDelta, fmtMs, baseName, fmtDate, crumbs, squarify, deltaTint, changesByPath, pct, EXCLUDE_PRESETS, DEFAULT_SETTINGS, mergeSettings, activeExcludes, categoryOf, categorize, bucketLabel, jobsToStart, driveKey, mapItems, parentPath, isUnder, SETTINGS_VERSION, - isProtectedPath, folderSets, protectedExcludes, + isProtectedPath, folderSets, protectedExcludes, dropReclaimed, }; }); diff --git a/gui/ui/lib.test.js b/gui/ui/lib.test.js index 877d0c8..9c8b0b6 100644 --- a/gui/ui/lib.test.js +++ b/gui/ui/lib.test.js @@ -276,3 +276,18 @@ test("protectedExcludes covers the protected folders and program file types", () assert.equal(new Set(ex).size, ex.length); assert.equal(lib.mergeSettings({ engine: "mft" }).engine, "mft"); }); + +test("dropReclaimed removes handled copies and finished groups", () => { + const groups = [ + { hash: "a", size: 10, files: ["/k/a", "/x/a", "/y/a"], wasted: 20 }, + { hash: "b", size: 5, files: ["/k/b", "/x/b"], wasted: 5 }, + { hash: "c", size: 7, files: ["/k/c", "/x/c"], wasted: 7 }, + ]; + const left = lib.dropReclaimed(groups, ["/x/a", "/x/b"]); + assert.deepEqual(left.map((g) => [g.hash, g.files, g.wasted]), [ + ["a", ["/k/a", "/y/a"], 10], + ["c", ["/k/c", "/x/c"], 7], + ]); + assert.equal(groups[0].files.length, 3); // the input is not modified + assert.deepEqual(lib.dropReclaimed(groups, []).length, 3); +}); From 94bf9dca3d72713d34317a1c4781d3976e928727 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Fri, 23 Oct 2026 13:06:19 +0200 Subject: [PATCH 2/2] Keep the local Claude Code settings out of the repository --- .claude/settings.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index acb7afd..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "statusLine": { - "type": "command", - "command": "node \"D:\\Coding\\switchboard\\spike\\tools\\statusline.mjs\"", - "padding": 0 - } -}