Skip to content
Draft
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
97 changes: 69 additions & 28 deletions src/lib/Duplicates.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,46 +9,74 @@

let groups: api.DupeGroup[] = $state([]);
let busy = $state(false);
let confirming = $state(false);
let loadError = $state("");
// 각 그룹에서 삭제 대상으로 선택된 경로 (보존할 하나를 제외한 나머지)
let toDelete: Set<string> = $state(new Set());
let results: api.CleanResult[] = $state([]);
let verdicts: Record<string, api.Verdict> = $state({});
let scanGeneration = $state(0);
let observedRoot: string | null = null;

async function loadVerdicts(paths: string[]) {
$effect(() => {
const root = scannedRoot;
if (root === observedRoot) return;
observedRoot = root;
++scanGeneration;
busy = false;
confirming = false;
groups = [];
toDelete = new Set();
verdicts = {};
results = [];
loadError = "";
});

async function loadVerdicts(paths: string[], generation: number) {
try {
const fvs = await api.fileVerdicts(paths);
if (generation !== scanGeneration) return;
verdicts = Object.fromEntries(fvs.map((f) => [f.path, f.verdict]));
} catch {
/* advisory only — ignore */
}
}

async function scan() {
if (!scannedRoot) return;
const root = scannedRoot;
if (!root) return;
const generation = ++scanGeneration;
busy = true;
loadError = "";
groups = [];
toDelete = new Set();
verdicts = {};
results = [];
try {
groups = await api.findDuplicateFiles(scannedRoot);
const nextGroups = await api.findDuplicateFiles(root);
if (generation !== scanGeneration || root !== scannedRoot) return;
groups = nextGroups;
// 기본 선택: 각 그룹의 첫 파일을 보존, 나머지를 삭제 후보로
const next = new Set<string>();
for (const g of groups) {
for (const p of g.paths.slice(1)) next.add(p);
}
toDelete = next;
loadVerdicts(groups.flatMap((g) => g.paths));
} catch (e) {
loadError = String(e);
loadVerdicts(groups.flatMap((g) => g.paths), generation);
} catch {
if (generation === scanGeneration && root === scannedRoot) {
loadError = "중복 파일 검색에 실패했습니다. 스캔 대상 폴더의 접근 권한을 확인하고 스캔을 다시 실행한 뒤 중복 찾기를 다시 누르세요.";
}
} finally {
busy = false;
if (generation === scanGeneration && root === scannedRoot) busy = false;
}
}

function toggle(path: string) {
const next = new Set(toDelete);
next.has(path) ? next.delete(path) : next.add(path);
toDelete = next;
loadError = "";
}

let reclaimable = $derived(
Expand All @@ -59,38 +87,51 @@
);

async function deleteSelected() {
if (busy || confirming) return;
const paths = [...toDelete];
if (paths.length === 0) return;
loadError = "";
// 안전: 그룹 전체가 삭제 선택되면 최소 1개는 보존하도록 막는다
if (blocksDeletion(groups, toDelete)) {
alert("중복 그룹 하나가 통째로 삭제 선택됐습니다. 각 그룹에서 최소 1개는 보존해야 합니다.");
loadError = "중복 그룹 전체가 삭제 대상으로 선택됐습니다. 각 그룹에서 최소 1개는 보존하도록 선택을 해제한 뒤 다시 시도하세요.";
return;
}
const okay = await confirm(
`${paths.length}개 중복 파일을 휴지통으로 보냅니다 (논리 크기 ${fmtBytes(reclaimable)}, 실제 회수량 미검증).\n` +
`각 그룹의 사본 1개는 보존됩니다. 휴지통을 비우기 전에는 물리 공간이 회수되지 않으며, APFS 공유 블록 때문에 실제 회수량은 더 작을 수 있습니다.`,
{ title: "DiskSage", kind: "warning" },
);
if (!okay) return;
busy = true;
const root = scannedRoot;
const generation = scanGeneration;
confirming = true;
try {
const r = await api.cleanPaths(paths);
await scan();
results = r;
} catch (e) {
loadError = String(e);
const okay = await confirm(
`${paths.length}개 중복 파일을 휴지통으로 보냅니다 (논리 크기 ${fmtBytes(reclaimable)}, 실제 회수량 미검증).\n` +
`각 그룹의 사본 1개는 보존됩니다. 휴지통을 비우기 전에는 물리 공간이 회수되지 않으며, APFS 공유 블록 때문에 실제 회수량은 더 작을 수 있습니다.`,
{ title: "DiskSage", kind: "warning" },
);
if (!okay || generation !== scanGeneration || root !== scannedRoot) return;
busy = true;
confirming = false;
try {
const r = await api.cleanPaths(paths);
if (root !== scannedRoot) return;
await scan();
results = r;
} catch {
loadError = "선택한 중복 파일을 휴지통으로 보내지 못했습니다. 파일이 열려 있는지와 휴지통 접근 권한을 확인한 뒤 중복 찾기부터 다시 실행하세요.";
} finally {
busy = false;
Comment on lines +113 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Stale cleanup corrupts new-root state

When the root changes during cleanup, stale completion updates results, loadError, and busy. Old outcomes appear, and active cleanup can become clickable again.

Prompt for agents
In src/lib/Duplicates.svelte, deleteSelected captures the root and generation before confirmation, but the cleanup continuation only checks the root once immediately after cleanPaths resolves. A root change during cleanPaths can make the catch and finally mutate the new root's loadError and busy state. A root change during the subsequent scan can also let results from the old root be assigned after the reset. Bind every post-cleanup state write to the originating root/generation or use an operation token distinct from scan generation, so stale success, failure, and finally paths cannot modify current state or unlock another active operation.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
} catch {
loadError = "휴지통 이동 확인 창을 열지 못했습니다. 다른 확인 창을 닫은 뒤 다시 시도하세요.";
} finally {
busy = false;
confirming = false;
}
}
</script>

<section>
<h2>
중복 파일 {scannedRoot ? "" : "(먼저 스캔하세요)"}
<button onclick={scan} disabled={busy || !scannedRoot}>{busy ? "찾는 중…" : "중복 찾기"}</button>
<button onclick={scan} disabled={busy || confirming || !scannedRoot}>{busy ? "찾는 중…" : "중복 찾기"}</button>
</h2>
{#if loadError}<p class="error">{loadError}</p>{/if}
{#if loadError}<p class="error" role="alert">{loadError}</p>{/if}

{#if groups.length === 0 && !busy}
<p class="muted">중복을 찾으려면 스캔 후 "중복 찾기"를 누르세요.</p>
Expand All @@ -107,7 +148,7 @@
<label>
<input
type="checkbox"
disabled={busy}
disabled={busy || confirming}
checked={toDelete.has(p)}
onchange={() => toggle(p)}
/>
Expand All @@ -126,18 +167,18 @@

{#if groups.length > 0}
<div class="actions">
<button onclick={deleteSelected} disabled={busy || toDelete.size === 0}>
선택 중복 휴지통으로 (논리 {fmtBytes(reclaimable)})
<button onclick={deleteSelected} disabled={busy || confirming || toDelete.size === 0}>
{confirming ? "휴지통 이동 확인 대기 중…" : `선택 중복 휴지통으로 (논리 ${fmtBytes(reclaimable)})`}
</button>
</div>
{/if}

{#if results.length > 0}
<p>{results.filter((r) => r.ok).length}/{results.length}개 휴지통으로 이동 — 복원 가능합니다.</p>
<p>{results.filter((r) => r.ok).length}/{results.length}개 휴지통으로 이동했습니다. 복원이 필요하면 휴지통에서 되돌리세요.</p>
{#if results.some((r) => !r.ok)}
<ul class="errors">
{#each results.filter((r) => !r.ok) as r (r.path)}
<li title={r.path}>⚠ {r.path} — {r.error}</li>
<li title={r.path}>⚠ {r.path} — 파일이 사용 중인지와 접근 권한을 확인한 뒤 중복 찾기부터 다시 실행하세요.</li>
{/each}
</ul>
{/if}
Expand Down
88 changes: 88 additions & 0 deletions src/lib/duplicatesErrorPrivacyContract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");

function readSource(path: string): string {
return readFileSync(resolve(repositoryRoot, path), "utf8");
}

describe("Duplicates privacy-safe failure feedback", () => {
it("never renders arbitrary thrown backend exception text", () => {
const source = readSource("src/lib/Duplicates.svelte");

expect(source).not.toContain("String(e)");
expect(source).not.toContain("catch (e)");
expect(source).not.toContain("{r.error}");
expect(source).toContain(
"중복 파일 검색에 실패했습니다. 스캔 대상 폴더의 접근 권한을 확인하고 스캔을 다시 실행한 뒤 중복 찾기를 다시 누르세요.",
);
expect(source).toContain(
"선택한 중복 파일을 휴지통으로 보내지 못했습니다. 파일이 열려 있는지와 휴지통 접근 권한을 확인한 뒤 중복 찾기부터 다시 실행하세요.",
);
expect(source).toContain(
"파일이 사용 중인지와 접근 권한을 확인한 뒤 중복 찾기부터 다시 실행하세요.",
);
});

it("clears stale duplicate and verdict evidence before replacement discovery", () => {
const source = readSource("src/lib/Duplicates.svelte");
const scanStart = source.indexOf("async function scan()");
const duplicateCall = source.indexOf("const nextGroups = await api.findDuplicateFiles(root)", scanStart);
const scanPrefix = source.slice(scanStart, duplicateCall);

expect(scanStart).toBeGreaterThanOrEqual(0);
expect(duplicateCall).toBeGreaterThan(scanStart);
expect(scanPrefix).toContain("groups = []");
expect(scanPrefix).toContain("toDelete = new Set()");
expect(scanPrefix).toContain("verdicts = {}");
expect(scanPrefix).toContain("results = []");
});

it("prevents a verdict response from an older scan replacing current evidence", () => {
const source = readSource("src/lib/Duplicates.svelte");
const verdictStart = source.indexOf("async function loadVerdicts");
const scanStart = source.indexOf("async function scan()");
const verdictBody = source.slice(verdictStart, scanStart);

expect(source).toContain("let scanGeneration = $state(0)");
expect(verdictBody).toContain("generation: number");
expect(verdictBody).toContain("if (generation !== scanGeneration) return");
expect(source).toContain("const generation = ++scanGeneration");
expect(source).toContain("loadVerdicts(groups.flatMap((g) => g.paths), generation)");
expect(source).toContain("if (generation !== scanGeneration || root !== scannedRoot) return");
expect(source).toContain("if (generation === scanGeneration && root === scannedRoot) busy = false");
});

it("invalidates old-root evidence and serializes destructive confirmation", () => {
const source = readSource("src/lib/Duplicates.svelte");

expect(source).toContain("if (root === observedRoot) return;");
expect(source).toContain("++scanGeneration;");
expect(source).toContain("if (busy || confirming) return;");
expect(source).toContain("if (!okay || generation !== scanGeneration || root !== scannedRoot) return;");
expect(source).toContain("disabled={busy || confirming || toDelete.size === 0}");
expect(source).toContain("휴지통 이동 확인 창을 열지 못했습니다. 다른 확인 창을 닫은 뒤 다시 시도하세요.");
});

it("uses the accessible failure region for an invalid all-selected group", () => {
const source = readSource("src/lib/Duplicates.svelte");

expect(source).not.toContain("alert(");
expect(source).toContain(
"중복 그룹 전체가 삭제 대상으로 선택됐습니다. 각 그룹에서 최소 1개는 보존하도록 선택을 해제한 뒤 다시 시도하세요.",
);
expect(source).toContain('role="alert"');
});

it("keeps customer-selected paths but makes result copy actionable", () => {
const source = readSource("src/lib/Duplicates.svelte");

expect(source).toContain("<li title={r.path}>⚠ {r.path} —");
expect(source).toContain("복원이 필요하면 휴지통에서 되돌리세요.");
expect(source).toContain("api.findDuplicateFiles(root)");
expect(source).toContain("api.cleanPaths(paths)");
});
});
Loading