Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ exclude = ["gui"]
resolver = "2"

[workspace.package]
version = "0.10.1"
version = "0.10.2"
edition = "2021"
license = "MIT"
authors = ["kingchenc"]
Expand Down
57 changes: 57 additions & 0 deletions crates/diskghost-core/src/dupes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -298,6 +310,16 @@ pub(crate) fn find(
cache: &HashCache,
) -> Vec<DupGroup> {
// 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<Cand> = Vec::new();
let _ = walk(root, opts, progress, |b| {
for (f, modified) in b.files.into_iter().zip(b.modified) {
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion crates/diskghost-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions gui/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion gui/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
49 changes: 34 additions & 15 deletions gui/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ async fn reclaim_dupes(
jobs: Vec<ReclaimJob>,
action: String,
dry_run: bool,
) -> Result<ReclaimReport, String> {
) -> Result<ReclaimOutcome, String> {
let act = match action.as_str() {
"delete" => ReclaimAction::Delete,
"trash" => ReclaimAction::Trash,
Expand All @@ -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<PathBuf> = 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<PathBuf> = 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<String>,
}

/// 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
Expand Down
2 changes: 1 addition & 1 deletion gui/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
16 changes: 11 additions & 5 deletions gui/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion gui/ui/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
};
});
15 changes: 15 additions & 0 deletions gui/ui/lib.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading