From 0d25b8ae55e5e5e883301d1f830bf385381e55e4 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Thu, 22 Oct 2026 21:04:37 +0200 Subject: [PATCH] Since-last-scan comparison and a duplicate finder that protects programs (0.9.2) Comparisons - Right after a scan the app compares it with the newest earlier snapshot of the same folder and shows a "Since last scan" card (total change, new / gone / changed files); a click opens the comparison. A Compare button next to Rescan does the same for the folder on screen. The changes tab was easy to miss at the bottom of the dashboard. - The current state is captured once per tree under its own mutex, so the background snapshot save and the comparison no longer capture it twice. Duplicates - The groups were correct (verified independently with SHA-256: e.g. the Star Citizen and Predecessor launchers, or one Halo Infinite music bank under three names, are byte-identical), but on a games drive nearly all of them are copies inside installed programs, which ship them on purpose and break when one is deleted. - Files inside program and game folders (Steam, Epic, Battle.net, Ubisoft, EA, Program Files, WindowsApps, AppData, node_modules, ...) or of program / game data types (dll, exe, pak, pck, ...) are now marked. Groups made only of them are hidden by default (shown with a lock on request) and the KPIs split "your files" from "program & game files". While hidden they are never removed; in a mixed group the program's copy is kept by default; a reclaim touching them asks with an extra warning. - Folder pairs: which folders share identical files, with group / file counts and reclaimable bytes; a click filters the groups to that pair. - Category filter, an "identical (BLAKE3)" marker with the hash, and a note that names do not matter. UI helper tests: 22 (protected paths, folder pairs). --- Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 19 ++- gui/src-tauri/Cargo.lock | 4 +- gui/src-tauri/Cargo.toml | 2 +- gui/src-tauri/src/main.rs | 27 ++-- gui/src-tauri/tauri.conf.json | 2 +- gui/ui/app.js | 232 +++++++++++++++++++++++++++++----- gui/ui/lib.js | 39 ++++++ gui/ui/lib.test.js | 31 +++++ 10 files changed, 305 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d118e6..65d74b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,7 +329,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "diskghost-cli" -version = "0.9.1" +version = "0.9.2" dependencies = [ "clap", "diskghost-core", @@ -339,7 +339,7 @@ dependencies = [ [[package]] name = "diskghost-core" -version = "0.9.1" +version = "0.9.2" dependencies = [ "blake3", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 8d9e452..eff3a03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["gui"] resolver = "2" [workspace.package] -version = "0.9.1" +version = "0.9.2" edition = "2021" license = "MIT" authors = ["kingchenc"] diff --git a/README.md b/README.md index 539034d..aa29917 100644 --- a/README.md +++ b/README.md @@ -134,11 +134,19 @@ A modern Tauri UI lives in `gui/`: duplicate minimum size, Recycle Bin vs. permanent delete, window size at start (1440p if it fits the screen, otherwise maximized; or 1080p / 4K / maximized), language. The version is shown next to the name. -- **Find duplicates** to sort/filter groups and reclaim their space with one - click. Every complete scan is kept as a snapshot (in the background, so the - result shows at once); **Changes** lists them with their size on disk, - compares the folder on screen against any of them — folders and single files - — and tints the treemap and folder rows by what grew or shrank. +- **Find duplicates** — every group is byte-identical content (BLAKE3 over the + whole file), whatever the names. Copies inside installed programs and games + (Steam, Epic, Battle.net, Program Files, …; DLLs, game archives) are there on + purpose, so they are hidden by default, shown with a lock when asked for, and + never removed while hidden; the program's copy is the one kept. **Folder + pairs** sums up which folders share identical files; filter by category or + name, then reclaim with one click (dry-run first). +- **What changed** — every complete scan is kept as a snapshot (in the + background, so the result shows at once), and right after a scan a **Since + last scan** card compares it with the previous one. **Compare** / **Changes** + lists the snapshots with their size on disk, compares the folder on screen + against any of them — folders and single files — and tints the treemap and + folder rows by what grew or shrank. - **Never blocking** — every command runs off the UI thread and each scan has its own lock, so browsing one scan never waits for another. @@ -188,6 +196,7 @@ Diskghost/ - [x] Snapshots & "what grew since last time" (CLI `--save`/`--since`/`diff`, GUI history) - [x] GUI dashboard: parallel scans + queue, drives, nested treemap, file-type and size-range charts, exclude templates, settings - [x] Own parallel walker (no per-file open), file-level snapshots (zstd, streaming diff), non-blocking GUI +- [x] Automatic "since last scan" comparison; duplicate finder that protects program and game files, folder pairs ## Development diff --git a/gui/src-tauri/Cargo.lock b/gui/src-tauri/Cargo.lock index 0053811..c1c9ed3 100644 --- a/gui/src-tauri/Cargo.lock +++ b/gui/src-tauri/Cargo.lock @@ -644,7 +644,7 @@ dependencies = [ [[package]] name = "diskghost-core" -version = "0.9.1" +version = "0.9.2" dependencies = [ "blake3", "fs4", @@ -659,7 +659,7 @@ dependencies = [ [[package]] name = "diskghost-gui" -version = "0.9.1" +version = "0.9.2" dependencies = [ "diskghost-core", "serde", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 65f9c7c..0ed3619 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "diskghost-gui" -version = "0.9.1" +version = "0.9.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 3a22c1d..1a258ab 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -27,15 +27,19 @@ struct Cached { /// Views already computed, by folder and list length — going back to a /// folder is answered without touching the tree again. views: HashMap<(PathBuf, usize), Arc>, - /// The tree captured as a snapshot (for comparisons), once taken. - current: Option>, + /// The tree captured as a snapshot (for comparisons), once taken. A + /// mutex of its own, so it can be filled under the tree's read lock and a + /// second caller waits for the first capture instead of repeating it. + current: Mutex>>, } impl Cached { /// Forget everything derived from the tree (it was changed). fn changed(&mut self) { self.views.clear(); - self.current = None; + if let Ok(c) = self.current.get_mut() { + *c = None; + } } } @@ -238,16 +242,13 @@ fn store_snapshot(dir: &Path, snap: &Snapshot, keep: usize) -> Result<(), String /// Capture the tree as a snapshot once and keep it for comparisons. fn current_snapshot(shared: &Shared) -> Result, String> { - if let Some(s) = shared.read().map_err(|e| e.to_string())?.current.clone() { - return Ok(s); - } - let snap = { - let c = shared.read().map_err(|e| e.to_string())?; - Arc::new(Snapshot::capture(&c.tree)) - }; - if let Ok(mut c) = shared.write() { - c.current = Some(snap.clone()); + let c = shared.read().map_err(|e| e.to_string())?; + let mut current = c.current.lock().map_err(|e| e.to_string())?; + if let Some(s) = current.as_ref() { + return Ok(s.clone()); } + let snap = Arc::new(Snapshot::capture(&c.tree)); + *current = Some(snap.clone()); Ok(snap) } @@ -277,7 +278,7 @@ async fn scan_dir( let shared: Shared = Arc::new(RwLock::new(Cached { tree, views: HashMap::new(), - current: None, + current: Mutex::new(None), })); if let Ok(mut trees) = app.state::().trees.lock() { trees.insert(job, shared.clone()); diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index 39413a5..57d105e 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.9.1", + "version": "0.9.2", "identifier": "org.diskghost.app", "build": { "frontendDist": "../ui" diff --git a/gui/ui/app.js b/gui/ui/app.js index c6074cf..67c7557 100644 --- a/gui/ui/app.js +++ b/gui/ui/app.js @@ -66,11 +66,20 @@ const I18N = { changes: "Changes since", unchanged: "Nothing changed.", newBadge: "new", grew: "Grew", shrank: "Shrank", newFolders: "New folders", removedFolders: "Removed folders", newFiles: "New files", removedFiles: "Removed files", changedFiles: "Changed files", biggest: "biggest", + sinceLast: "Since last scan", firstScanShort: "first scan — compared from the next one on", clickDetails: "click for details", + newShort: "new", goneShort: "gone", changedShort: "changed", oldSnapshotNoFiles: "That snapshot comes from an older version and only knows folders — file changes show up from the next comparison on.", snapshots: "Saved snapshots", onDisk: "on disk", thisScan: "this scan", filesAndFolders: "files & folders", foldersOnly: "folders only", compare: "Compare", deleteSnapshot: "Delete this snapshot?", snapshotsHint: (n) => `Every complete scan is saved automatically (all folders and files, compressed); the newest ${n} per folder are kept — see Settings.`, - noDupes: "No duplicates found 🎉", groups: "groups", reclaimable: "reclaimable", + noDupes: "No duplicates found 🎉", noOwnDupes: "No duplicates among your own files 🎉 — program and game files are hidden.", + ownReclaim: "Your files — reclaimable", protHidden: "Program & game files (hidden)", protHiddenSub: "click to show", + dupHint: "Every group is byte-for-byte identical content (BLAKE3 over the whole file), whatever the names. Installed programs and games keep such copies on purpose — deleting one breaks them, so they are hidden and never removed while hidden.", + showProt: "Show program & game files", identical: "identical", protBadge: "program / game", + protWhy: "Part of an installed program or game (by folder or file type) — deleting it can break that program.", + protWarn: "This includes files of installed programs or games. They may stop working.", + tabGroups: "Groups", tabFolderSets: "Folder pairs", colFolders: "Folders sharing identical files", + folderFilter: "Only", sameFolder: "copies within this folder", nothingToReclaim: "Nothing to reclaim in the shown groups.", groups: "groups", reclaimable: "reclaimable", filter: "Filter", sort: "Sort", dryRun: "Dry-run", reclaimShown: "Reclaim shown", sortWasted: "wasted", sortSize: "size", sortCount: "count", actTrash: "→ Recycle Bin", actDelete: "Delete", actHardlink: "Hard-link", @@ -153,11 +162,20 @@ const I18N = { changes: "Änderungen seit", unchanged: "Nichts geändert.", newBadge: "neu", grew: "Gewachsen", shrank: "Geschrumpft", newFolders: "Neue Ordner", removedFolders: "Entfernte Ordner", newFiles: "Neue Dateien", removedFiles: "Entfernte Dateien", changedFiles: "Geänderte Dateien", biggest: "die größten", + sinceLast: "Seit letztem Scan", firstScanShort: "erster Scan — Vergleich ab dem nächsten", clickDetails: "klicken für Details", + newShort: "neu", goneShort: "weg", changedShort: "geändert", oldSnapshotNoFiles: "Dieser Snapshot stammt aus einer älteren Version und kennt nur Ordner — Dateiänderungen gibt es ab dem nächsten Vergleich.", snapshots: "Gespeicherte Snapshots", onDisk: "auf der Platte", thisScan: "dieser Scan", filesAndFolders: "Dateien & Ordner", foldersOnly: "nur Ordner", compare: "Vergleichen", deleteSnapshot: "Diesen Snapshot löschen?", snapshotsHint: (n) => `Jeder vollständige Scan wird automatisch gespeichert (alle Ordner und Dateien, komprimiert); die neuesten ${n} pro Ordner bleiben — siehe Einstellungen.`, - noDupes: "Keine Duplikate gefunden 🎉", groups: "Gruppen", reclaimable: "freigebbar", + noDupes: "Keine Duplikate gefunden 🎉", noOwnDupes: "Keine Duplikate unter deinen eigenen Dateien 🎉 — Programm- und Spieldateien sind ausgeblendet.", + ownReclaim: "Deine Dateien — freigebbar", protHidden: "Programm- & Spieldateien (ausgeblendet)", protHiddenSub: "klicken zum Anzeigen", + dupHint: "Jede Gruppe ist Byte für Byte identischer Inhalt (BLAKE3 über die ganze Datei), egal wie die Dateien heißen. Installierte Programme und Spiele haben solche Kopien absichtlich — Löschen macht sie kaputt, deshalb sind sie ausgeblendet und werden dann nie entfernt.", + showProt: "Programm- & Spieldateien zeigen", identical: "identisch", protBadge: "Programm / Spiel", + protWhy: "Gehört zu einem installierten Programm oder Spiel (nach Ordner oder Dateityp) — Löschen kann es beschädigen.", + protWarn: "Darunter sind Dateien installierter Programme oder Spiele. Sie könnten danach nicht mehr funktionieren.", + tabGroups: "Gruppen", tabFolderSets: "Ordner-Paare", colFolders: "Ordner mit identischen Dateien", + folderFilter: "Nur", sameFolder: "Kopien in diesem Ordner", nothingToReclaim: "In den angezeigten Gruppen gibt es nichts freizugeben.", groups: "Gruppen", reclaimable: "freigebbar", filter: "Filter", sort: "Sortierung", dryRun: "Testlauf", reclaimShown: "Angezeigte freigeben", sortWasted: "verschwendet", sortSize: "Größe", sortCount: "Anzahl", actTrash: "→ Papierkorb", actDelete: "Löschen", actHardlink: "Hardlink", @@ -534,10 +552,13 @@ async function runJob(j) { const back = j.returnTo; j.returnTo = null; if (back && back !== j.root) navigate(j, back, { keepTab: true, record: false }); + if (!view.cancelled) autoCompare(j); } else { j.groups = await invoke("find_dupes", { job: j.id, path: j.root, mb: j.mb, opts: j.opts }); j.elapsedMs = performance.now() - j.startedAt; j.keep = new Map(); + j.tagged = false; + j.dupSet = null; j.state = j.cancelRequested ? "cancelled" : "done"; } } catch (e) { @@ -586,6 +607,7 @@ function rescan(j) { j.top = settings.top; j.mb = settings.min_mb; j.returnTo = j.kind === "scan" ? j.path : null; + j.auto = null; j.state = "queued"; pump(); renderAll(); @@ -665,8 +687,10 @@ function jobSub(j) { } if (j.state === "error") return `${t("stError")}: ${j.error}`; if (j.kind === "dupes" && j.groups) { - const wasted = j.groups.reduce((a, g) => a + g.wasted, 0); - return `${j.groups.length} ${t("groups")} · ${human(wasted)} ${t("reclaimable")}`; + // Counted like the view: only the user's own files are reclaimable. + tagGroups(j); + const own = j.groups.filter((g) => !g.allProt); + return `${own.length} ${t("groups")} · ${human(own.reduce((a, g) => a + g.wasted, 0))} ${t("reclaimable")}`; } if (j.view) { const tag = j.state === "cancelled" ? `${t("stCancelled")} · ` : ""; @@ -860,6 +884,7 @@ function scanView(j) { el("button", { type: "button", class: "small", title: t("upHint"), disabled: !parent || !isUnder(parent, j.root), onclick: () => goUp(j) }, `↑ ${t("up")}`), crumbBar(j), el("span", { class: "grow" }), + el("button", { type: "button", class: "small", onclick: () => openCompare(j) }, `⇄ ${t("compare")}`), el("button", { type: "button", class: "small", onclick: () => enqueue("dupes", j.path) }, `👯 ${t("dupesHere")}`), el("button", { type: "button", class: "small", onclick: () => rescan(j) }, `↻ ${t("rescan")}`)); view.append(el("div", { class: "full", style: { display: "flex", flexDirection: "column", gap: "12px" } }, @@ -921,10 +946,65 @@ function kpiRow(j, v) { const tint = deltaTint(j.diff.delta, Math.abs(j.diff.delta)); row.append(kpi(`${t("kSince")} ${fmtDate(j.diff.old_taken_at)}`, humanDelta(j.diff.delta), `${human(j.diff.old_size)} → ${human(j.diff.new_size)}`, {}, tint.cls)); + } else if (j.path === j.root && j.auto) { + row.append(sinceLastCard(j)); } return row; } +// "Since last scan": filled in by autoCompare() right after a scan; a click +// opens the full comparison. +function sinceLastCard(j) { + const open = { role: "button", tabindex: "0", style: { cursor: "pointer" }, onclick: () => openCompare(j) }; + const a = j.auto; + if (a.state === "loading") return kpi(t("sinceLast"), "…", t("comparing"), open); + if (a.state === "none") return kpi(t("sinceLast"), "—", t("firstScanShort"), open); + if (a.state !== "done") return kpi(t("sinceLast"), "?", a.error || "", open); + const d = a.diff; + const files = d.files_compared + ? ` · ${num(d.new_files_count)} ${t("newShort")}, ${num(d.removed_files_count)} ${t("goneShort")}, ${num(d.changed_files_count)} ${t("changedShort")}` + : ""; + return kpi(`${t("sinceLast")} (${fmtDate(d.old_taken_at)})`, humanDelta(d.delta), `${t("clickDetails")}${files}`, open, + deltaTint(d.delta, Math.abs(d.delta)).cls); +} + +// Compare a finished scan with the newest earlier snapshot of its root. +async function autoCompare(j) { + j.auto = { state: "loading" }; + renderFor(j); + try { + j.history = (await invoke("list_snapshots", { job: j.id })).snapshots || []; + const older = j.history.filter((s) => s.taken_at < j.scanUnix); + if (!older.length) { + j.auto = { state: "none" }; + } else { + const diff = await invoke("diff_since", { job: j.id, path: j.root, takenAt: older[0].taken_at, top: j.top }); + j.auto = { state: "done", diff }; + } + } catch (e) { + j.auto = { state: "error", error: errText(e) }; + } + if (byId(j.id)) renderFor(j); +} + +// The Compare button / card: show the changes tab with a comparison of the +// folder on screen against the newest earlier snapshot. +async function openCompare(j) { + j.tab = "changes"; + if (j.path === j.root && j.auto?.state === "done") { + j.diff = j.auto.diff; + j.sinceAt = j.diff.old_taken_at; + return renderMain(); + } + renderMain(); + if (!j.history) { + try { j.history = (await invoke("list_snapshots", { job: j.id })).snapshots || []; } catch (_) { j.history = []; } + } + const older = j.history.filter((s) => s.taken_at < j.scanUnix); + if (older.length) await showChanges(j, j.sinceAt ?? older[0].taken_at); + else renderMain(); +} + function diskCard(v) { if (!(v.disk_total > 0)) return null; const used = v.disk_total - v.disk_free; @@ -1380,6 +1460,30 @@ async function deletePath(path) { } // -------------------------------- duplicates -------------------------------- +// A group is files with byte-identical content (BLAKE3 over the whole file, +// hard links collapsed) — whatever they are called. Copies inside installed +// programs and games are on purpose (every game ships its own DLLs, some ship +// sound banks twice) and deleting one breaks the program, so groups made only +// of such files are hidden unless asked for, and they are never removed while +// hidden. +function tagGroups(j) { + if (j.tagged) return; + for (const g of j.groups) { + g.prot = g.files.map(isProtectedPath); + g.allProt = g.prot.every(Boolean); + g.anyProt = g.prot.some(Boolean); + g.cat = categoryOf((String(g.files[0]).match(/\.([^.\\/]+)$/) || [])[1] || ""); + } + j.tagged = true; +} + +// Groups that pass the program/game switch, the category and the folder filter. +function baseGroups(j) { + tagGroups(j); + return j.groups.filter((g) => (j.dupShowProt || !g.allProt) && (!j.dupCat || g.cat === j.dupCat) && + (!j.dupSet || folderSets([g])[0].key === j.dupSet)); +} + function dupesView(j) { const groups = j.groups || []; const wrap = el("div", { class: "dupview" }); @@ -1392,45 +1496,91 @@ function dupesView(j) { wrap.append(el("div", { class: "card" }, el("div", { class: "ok", style: { fontSize: "18px" }, text: t("noDupes") }))); return wrap; } - const totalWasted = groups.reduce((a, g) => a + g.wasted, 0); - const totalFiles = groups.reduce((a, g) => a + g.files.length, 0); + tagGroups(j); + const own = groups.filter((g) => !g.allProt); + const prot = groups.filter((g) => g.allProt); + const sum = (list) => list.reduce((a, g) => a + g.wasted, 0); wrap.append(el("div", { class: "kpis" }, - kpi(t("reclaimable"), human(totalWasted), null), - kpi(t("groups"), num(groups.length), `${num(totalFiles)} ${t("files")}`), + kpi(t("ownReclaim"), human(sum(own)), `${num(own.length)} ${t("groups")} · ${num(own.reduce((a, g) => a + g.files.length, 0))} ${t("files")}`), + kpi(t("protHidden"), human(sum(prot)), `${num(prot.length)} ${t("groups")} — ${t("protHiddenSub")}`, + { role: "button", tabindex: "0", style: { cursor: "pointer" }, onclick: () => { j.dupShowProt = !j.dupShowProt; renderMain(); } }), kpi(t("kScan"), fmtMs(j.elapsedMs || 0), null))); + wrap.append(el("div", { class: "muted", text: t("dupHint") })); - const list = el("div", { class: "dupes" }); - const filter = el("input", { type: "text", value: j.dupFilter || "", oninput: (e) => { j.dupFilter = e.target.value; fill(); } }); + const filter = el("input", { type: "text", value: j.dupFilter || "", placeholder: t("filterFiles"), oninput: (e) => { j.dupFilter = e.target.value; fill(); } }); + const showProt = el("input", { type: "checkbox", onchange: (e) => { j.dupShowProt = e.target.checked; renderMain(); } }); + showProt.checked = Boolean(j.dupShowProt); + const cats = [...new Set(groups.map((g) => g.cat))]; const sort = selectInput(j.dupSort || "wasted", [["wasted", t("sortWasted")], ["size", t("sortSize")], ["count", t("sortCount")]], (val) => { j.dupSort = val; fill(); }); const action = selectInput(j.dupAction || "trash", [["trash", t("actTrash")], ["delete", t("actDelete")], ["hardlink", t("actHardlink")]], (val) => { j.dupAction = val; }); wrap.append(el("div", { class: "dupbar" }, - el("span", { class: "sum", text: `${groups.length} ${t("groups")} — ${human(totalWasted)} ${t("reclaimable")}` }), - el("label", { class: "inlabel" }, `${t("filter")} `, filter), + el("label", { class: "inlabel" }, showProt, ` ${t("showProt")}`), + selectInput(j.dupCat || "", [["", t("allCategories")], ...cats.map((c) => [c, `${catIcon[c]} ${t(`cat_${c}`)}`])], + (val) => { j.dupCat = val; renderMain(); }), + filter, el("label", { class: "inlabel" }, `${t("sort")} `, sort), + el("span", { class: "grow", style: { flex: "1" } }), action, el("button", { type: "button", onclick: () => reclaimShown(j, true) }, t("dryRun")), - el("button", { type: "button", class: "danger", onclick: () => reclaimShown(j, false) }, t("reclaimShown"))), list); + el("button", { type: "button", class: "danger", onclick: () => reclaimShown(j, false) }, t("reclaimShown")))); + + if (j.dupSet) { + wrap.append(el("div", { class: "chips" }, + el("span", { class: "chip" }, `📁 ${t("folderFilter")}: ${j.dupSetDirs.map((d) => baseName(d)).join(" ⇄ ")}`, + el("button", { type: "button", class: "x", title: t("close"), onclick: () => { j.dupSet = null; renderMain(); } }, "✕")))); + } + + const tab = j.dupTab || "groups"; + wrap.append(el("div", { class: "tabs" }, + [["groups", t("tabGroups")], ["sets", t("tabFolderSets")]].map(([id, label]) => + el("button", { type: "button", class: `tab${tab === id ? " on" : ""}`, onclick: () => { j.dupTab = id; renderMain(); } }, label)))); + const list = el("div", { class: "dupes" }); + wrap.append(list); function fill() { const shown = computeShown(j); const frag = document.createDocumentFragment(); - for (const g of shown.slice(0, 1000)) { - const box = el("div", { class: "dup" }); - box.append(el("div", { class: "dhead" }, - el("span", { class: "badge", text: `${g.files.length}×` }), - ` ${human(g.size)} `, - el("span", { class: "waste", text: `${human(g.wasted)} ${t("reclaimable")}` }))); - const kept = keptPath(j, g); - for (const f of g.files) { - const radio = el("input", { type: "radio", name: `keep-${g.hash}`, title: t("keep"), onchange: () => { j.keep.set(g.hash, f); fill(); } }); - if (f === kept) radio.checked = true; - box.append(el("label", { class: `dupfile${f === kept ? " kept" : ""}` }, radio, el("span", { class: "path", text: f }))); + if (tab === "sets") { + const sets = folderSets(shown); + const table = el("table", { class: "table" }, + el("thead", {}, el("tr", {}, el("th", { text: t("colFolders") }), el("th", { class: "num", text: t("groups") }), + el("th", { class: "num", text: t("colFiles") }), el("th", { class: "num", text: t("reclaimable") }))), + el("tbody", {}, sets.slice(0, 500).map((s) => el("tr", { + class: "click", + onclick: () => { j.dupSet = s.key; j.dupSetDirs = s.dirs; j.dupTab = "groups"; renderMain(); }, + }, + el("td", { class: "namecell" }, s.dirs.length === 1 + ? [el("div", { class: "nm", text: `${baseName(s.dirs[0])} — ${t("sameFolder")}` }), el("div", { class: "pth", text: s.dirs[0] })] + : s.dirs.map((d) => el("div", { class: "pth", text: `📁 ${d}` }))), + el("td", { class: "num", text: num(s.groups) }), + el("td", { class: "num", text: num(s.files) }), + el("td", { class: "num", text: human(s.wasted) }))))); + frag.append(table); + } else { + for (const g of shown.slice(0, 1000)) { + const box = el("div", { class: "dup" }); + box.append(el("div", { class: "dhead" }, + el("span", { class: "badge", text: `${g.files.length}×` }), + ` ${human(g.size)} `, + el("span", { class: "chip", title: `BLAKE3 ${g.hash}`, text: `✓ ${t("identical")}` }), + el("span", { class: "muted", text: `${catIcon[g.cat]} ${t(`cat_${g.cat}`)}` }), + g.anyProt ? el("span", { class: "chip bad", title: t("protWhy"), text: `🔒 ${t("protBadge")}` }) : null, + el("span", { class: "waste", text: `${human(g.wasted)} ${t("reclaimable")}` }))); + const kept = keptPath(j, g); + g.files.forEach((f, i) => { + const radio = el("input", { type: "radio", name: `keep-${g.hash}`, title: t("keep"), onchange: () => { j.keep.set(g.hash, f); fill(); } }); + if (f === kept) radio.checked = true; + box.append(el("label", { class: `dupfile${f === kept ? " kept" : ""}` }, radio, + g.prot[i] ? el("span", { title: t("protWhy"), text: "🔒" }) : null, + el("span", { class: "path", text: f }))); + }); + frag.append(box); } - frag.append(box); + if (shown.length > 1000) frag.append(el("div", { class: "muted", text: `… ${t("showing")} 1000 / ${num(shown.length)} ${t("groups")}` })); + if (!shown.length) frag.append(el("div", { class: "ok", text: j.dupShowProt ? t("noDupes") : t("noOwnDupes") })); } - if (shown.length > 1000) frag.append(el("div", { class: "muted", text: `… ${t("showing")} 1000 / ${num(shown.length)} ${t("groups")}` })); list.replaceChildren(frag); } fill(); @@ -1440,25 +1590,42 @@ function dupesView(j) { function computeShown(j) { const filter = (j.dupFilter || "").toLowerCase(); const sort = j.dupSort || "wasted"; - const groups = j.groups.filter((g) => !filter || g.files.some((f) => f.toLowerCase().includes(filter))); + const groups = baseGroups(j).filter((g) => !filter || g.files.some((f) => f.toLowerCase().includes(filter))); groups.sort((a, b) => (sort === "size" ? b.size - a.size : sort === "count" ? b.files.length - a.files.length : b.wasted - a.wasted)); return groups; } -const keptPath = (j, g) => j.keep.get(g.hash) ?? g.files[0]; +// The copy kept by default: a program's own file if the group has one (so +// the user's stray copy is what goes), else the first. +function keptPath(j, g) { + const chosen = j.keep.get(g.hash); + if (chosen) return chosen; + const i = g.prot ? g.prot.indexOf(true) : -1; + return g.files[i >= 0 ? i : 0]; +} async function reclaimShown(j, dryRun) { const action = j.dupAction || "trash"; + // While program and game files are hidden, none of them is ever removed. const reclaimJobs = computeShown(j) - .map((g) => { const keep = keptPath(j, g); return { keep, remove: g.files.filter((f) => f !== keep), size: g.size }; }) + .map((g) => { + const keep = keptPath(j, g); + const remove = g.files.filter((f, i) => f !== keep && (j.dupShowProt || !g.prot[i])); + return { keep, remove, size: g.size, prot: remove.some(isProtectedPath) }; + }) .filter((x) => x.remove.length > 0); - if (!reclaimJobs.length) return; + if (!reclaimJobs.length) return status(t("nothingToReclaim")); const count = reclaimJobs.reduce((a, x) => a + x.remove.length, 0); + const touchesProt = reclaimJobs.some((x) => x.prot); const label = { trash: t("actTrash"), delete: t("actDelete"), hardlink: t("actHardlink") }[action]; - if (!dryRun && !(await ask(t("reclaimConfirm")(label, count), action === "trash" ? [] : [{ text: t("irreversible"), cls: "error" }], t("ok"), action !== "trash"))) return; + const warn = [ + action === "trash" ? null : { text: t("irreversible"), cls: "error" }, + touchesProt ? { text: t("protWarn"), cls: "error" } : null, + ].filter(Boolean); + if (!dryRun && !(await ask(t("reclaimConfirm")(label, count), warn, t("ok"), action !== "trash" || touchesProt))) return; status(dryRun ? `${t("dryRun")}…` : t("deleting")); try { - const rep = await invoke("reclaim_dupes", { jobs: reclaimJobs, action, dryRun }); + 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); if (!rep.dry_run) { @@ -1467,6 +1634,7 @@ async function reclaimShown(j, dryRun) { o.cache.clear(); o.stale = true; } + j.tagged = false; rescan(j); loadDrives(); } diff --git a/gui/ui/lib.js b/gui/ui/lib.js index a41ea96..3c09bf5 100644 --- a/gui/ui/lib.js +++ b/gui/ui/lib.js @@ -273,6 +273,44 @@ return `${short(lo)} – ${short(hi)}`; } + // Folders whose files belong to installed programs and games: duplicates in + // there are on purpose (each game ships its own copy of a DLL, a game keeps + // two copies of a sound bank) and deleting one breaks the program. + const PROTECTED_DIRS = new Set([ + "steamapps", "program files", "program files (x86)", "programdata", "windows", "windowsapps", + "xboxgames", "wpsystem", "epic games", "riot games", "battle.net", "ubisoft", "ubisoft game launcher", + "ea games", "electronic arts", "origin games", "gog galaxy", "gog games", "rockstar games", + "roberts space industries", "bluestacks", "appdata", "node_modules", "site-packages", ".venv", "venv", ".git", + ]); + const PROTECTED_EXTS = new Set(["pck", "cab", "sld", "bk2", "bik", "vtf", "mdl", "pdb", "ttf", "otf"]); + + // Whether `path` is part of an installed program or game (by folder or by + // file type) rather than one of the user's own files. + function isProtectedPath(path) { + const parts = String(path).split(/[\\/]+/); + const name = parts[parts.length - 1] || ""; + const ext = (name.match(/\.([^.]+)$/) || [])[1] || ""; + const cat = categoryOf(ext); + if (cat === "program" || cat === "gamedata" || PROTECTED_EXTS.has(ext.toLowerCase())) return true; + return parts.slice(0, -1).some((p) => PROTECTED_DIRS.has(p.toLowerCase())); + } + + // Duplicate groups by the set of folders their copies sit in, so "these + // two folders share 300 identical files" is one line instead of 300. + function folderSets(groups) { + const sets = new Map(); + for (const g of groups) { + const dirs = [...new Set(g.files.map((f) => parentPath(f) || f))].sort(); + const key = dirs.join("\n"); + const s = sets.get(key) || { key, dirs, groups: 0, files: 0, wasted: 0 }; + s.groups += 1; + s.files += g.files.length; + s.wasted += g.wasted; + sets.set(key, s); + } + return [...sets.values()].sort((a, b) => b.wasted - a.wasted || a.key.localeCompare(b.key)); + } + // The drive a path lives on ("c:", "\\server\share", "/"), for scheduling. function driveKey(path) { const p = String(path); @@ -339,5 +377,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, }; }); diff --git a/gui/ui/lib.test.js b/gui/ui/lib.test.js index 715b758..640dcc7 100644 --- a/gui/ui/lib.test.js +++ b/gui/ui/lib.test.js @@ -233,3 +233,34 @@ test("isUnder respects separators and Windows case rules", () => { assert.ok(!lib.isUnder("/home/Me", "/home/me")); assert.ok(!lib.isUnder("/homer", "/home")); }); + +test("isProtectedPath tells program and game files from the user's own", () => { + // By folder (any depth, any case) ... + assert.ok(lib.isProtectedPath("G:\\Steam\\steamapps\\common\\Patron\\Data\\Maps\\Map_16\\Forest.bmp")); + assert.ok(lib.isProtectedPath("C:\\Program Files\\App\\readme.txt")); + assert.ok(lib.isProtectedPath("G:\\Epic Games\\Games\\GTAV\\x64\\metadata.dat")); + assert.ok(lib.isProtectedPath("/home/me/proj/node_modules/a/index.js")); + // ... or by file type, wherever it sits. + assert.ok(lib.isProtectedPath("D:\\Downloads\\setup\\libcef.DLL")); + assert.ok(lib.isProtectedPath("D:\\Stuff\\sound.pck")); + // The user's own files are not. + assert.ok(!lib.isProtectedPath("D:\\Photos\\2024\\IMG_0001.jpg")); + assert.ok(!lib.isProtectedPath("C:\\Users\\me\\Videos\\clip.mp4")); + assert.ok(!lib.isProtectedPath("/home/me/Documents/report.pdf")); +}); + +test("folderSets groups duplicates by the folders their copies are in", () => { + const a = "G:\\Halo\\Sound\\SFX", b = "G:\\Halo\\subgames\\CampaignS1\\Sound\\SFX"; + const sets = lib.folderSets([ + { files: [`${a}\\x.pck`, `${b}\\x.pck`], wasted: 100 }, + { files: [`${b}\\y.pck`, `${a}\\y.pck`], wasted: 50 }, + { files: ["D:\\P\\1.jpg", "D:\\P\\copy of 1.jpg"], wasted: 500 }, + ]); + assert.equal(sets.length, 2); + assert.deepEqual(sets[0].dirs, ["D:\\P"]); + assert.deepEqual(sets[1].dirs, [a, b]); + assert.equal(sets[1].groups, 2); + assert.equal(sets[1].files, 4); + assert.equal(sets[1].wasted, 150); + assert.deepEqual(lib.folderSets([]), []); +});