From b1e19b6cb437ed6ed7b31cd65b80eaa6b625b78c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:37:17 +0900 Subject: [PATCH 1/9] test: require bounded duplicate failure feedback --- .../duplicatesErrorPrivacyContract.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/lib/duplicatesErrorPrivacyContract.test.ts diff --git a/src/lib/duplicatesErrorPrivacyContract.test.ts b/src/lib/duplicatesErrorPrivacyContract.test.ts new file mode 100644 index 000000000..2e6899de2 --- /dev/null +++ b/src/lib/duplicatesErrorPrivacyContract.test.ts @@ -0,0 +1,26 @@ +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).toContain("중복 파일 검색에 실패했습니다."); + expect(source).toContain("선택한 중복 파일을 휴지통으로 보내지 못했습니다."); + }); + + it("announces operation failures without changing focus", () => { + const source = readSource("src/lib/Duplicates.svelte"); + + expect(source).toContain('role="alert"'); + }); +}); From 3414ffb0264e5f562fbd9e64a4682fdbc6559a29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 03:01:29 +0900 Subject: [PATCH 2/9] fix: bound duplicate workflow failure feedback --- src/lib/Duplicates.svelte | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/Duplicates.svelte b/src/lib/Duplicates.svelte index 29d0c27a3..c4f1d246a 100644 --- a/src/lib/Duplicates.svelte +++ b/src/lib/Duplicates.svelte @@ -38,8 +38,8 @@ } toDelete = next; loadVerdicts(groups.flatMap((g) => g.paths)); - } catch (e) { - loadError = String(e); + } catch { + loadError = "중복 파일 검색에 실패했습니다."; } finally { busy = false; } @@ -77,8 +77,8 @@ const r = await api.cleanPaths(paths); await scan(); results = r; - } catch (e) { - loadError = String(e); + } catch { + loadError = "선택한 중복 파일을 휴지통으로 보내지 못했습니다."; } finally { busy = false; } @@ -90,7 +90,7 @@ 중복 파일 {scannedRoot ? "" : "(먼저 스캔하세요)"} - {#if loadError}

{loadError}

{/if} + {#if loadError}{/if} {#if groups.length === 0 && !busy}

중복을 찾으려면 스캔 후 "중복 찾기"를 누르세요.

From 67031499bf665fbded9586ae867aa6c5e473d3d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:18:06 +0900 Subject: [PATCH 3/9] test: bound duplicate per-result failures --- src/lib/duplicatesErrorPrivacyContract.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/duplicatesErrorPrivacyContract.test.ts b/src/lib/duplicatesErrorPrivacyContract.test.ts index 2e6899de2..78943e679 100644 --- a/src/lib/duplicatesErrorPrivacyContract.test.ts +++ b/src/lib/duplicatesErrorPrivacyContract.test.ts @@ -14,8 +14,10 @@ describe("Duplicates privacy-safe failure feedback", () => { const source = readSource("src/lib/Duplicates.svelte"); expect(source).not.toContain("String(e)"); + expect(source).not.toContain("{r.error}"); expect(source).toContain("중복 파일 검색에 실패했습니다."); expect(source).toContain("선택한 중복 파일을 휴지통으로 보내지 못했습니다."); + expect(source).toContain("일부 파일을 휴지통으로 보내지 못했습니다."); }); it("announces operation failures without changing focus", () => { From d1fa8dbdd990556f23c48d7cd8d428b82edecdc1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:04:51 +0900 Subject: [PATCH 4/9] fix: bound partial duplicate cleanup errors --- src/lib/Duplicates.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/Duplicates.svelte b/src/lib/Duplicates.svelte index c4f1d246a..54aad55f7 100644 --- a/src/lib/Duplicates.svelte +++ b/src/lib/Duplicates.svelte @@ -137,7 +137,7 @@ {#if results.some((r) => !r.ok)}
    {#each results.filter((r) => !r.ok) as r (r.path)} -
  • ⚠ {r.path} — {r.error}
  • +
  • ⚠ {r.path} — 일부 파일을 휴지통으로 보내지 못했습니다.
  • {/each}
{/if} From c686f0825c7471fa11603a0d3b7ac72d03e41428 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:41:49 +0900 Subject: [PATCH 5/9] test: require fresh actionable duplicate-file evidence --- .../duplicatesErrorPrivacyContract.test.ts | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/lib/duplicatesErrorPrivacyContract.test.ts b/src/lib/duplicatesErrorPrivacyContract.test.ts index 78943e679..afcc94ab5 100644 --- a/src/lib/duplicatesErrorPrivacyContract.test.ts +++ b/src/lib/duplicatesErrorPrivacyContract.test.ts @@ -14,15 +14,49 @@ describe("Duplicates privacy-safe failure feedback", () => { 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("일부 파일을 휴지통으로 보내지 못했습니다."); + expect(source).toContain( + "중복 파일 검색에 실패했습니다. 스캔 대상 폴더의 접근 권한을 확인하고 스캔을 다시 실행한 뒤 중복 찾기를 다시 누르세요.", + ); + expect(source).toContain( + "선택한 중복 파일을 휴지통으로 보내지 못했습니다. 파일이 열려 있는지와 휴지통 접근 권한을 확인한 뒤 중복 찾기부터 다시 실행하세요.", + ); + expect(source).toContain( + "파일이 사용 중인지와 접근 권한을 확인한 뒤 중복 찾기부터 다시 실행하세요.", + ); }); - it("announces operation failures without changing focus", () => { + 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("groups = await api.findDuplicateFiles(scannedRoot)", 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("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("
  • ⚠ {r.path} —"); + expect(source).toContain("복원이 필요하면 휴지통에서 되돌리세요."); + expect(source).toContain("api.findDuplicateFiles(scannedRoot)"); + expect(source).toContain("api.cleanPaths(paths)"); + }); }); From d16610533b850bf95b8860d4e2b27aeefbd1c94a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:42:33 +0900 Subject: [PATCH 6/9] fix: clear stale duplicate evidence and guide recovery --- src/lib/Duplicates.svelte | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/lib/Duplicates.svelte b/src/lib/Duplicates.svelte index 54aad55f7..48f78dd35 100644 --- a/src/lib/Duplicates.svelte +++ b/src/lib/Duplicates.svelte @@ -28,6 +28,9 @@ if (!scannedRoot) return; busy = true; loadError = ""; + groups = []; + toDelete = new Set(); + verdicts = {}; results = []; try { groups = await api.findDuplicateFiles(scannedRoot); @@ -39,7 +42,7 @@ toDelete = next; loadVerdicts(groups.flatMap((g) => g.paths)); } catch { - loadError = "중복 파일 검색에 실패했습니다."; + loadError = "중복 파일 검색에 실패했습니다. 스캔 대상 폴더의 접근 권한을 확인하고 스캔을 다시 실행한 뒤 중복 찾기를 다시 누르세요."; } finally { busy = false; } @@ -49,6 +52,7 @@ const next = new Set(toDelete); next.has(path) ? next.delete(path) : next.add(path); toDelete = next; + loadError = ""; } let reclaimable = $derived( @@ -61,9 +65,10 @@ async function deleteSelected() { const paths = [...toDelete]; if (paths.length === 0) return; + loadError = ""; // 안전: 그룹 전체가 삭제 선택되면 최소 1개는 보존하도록 막는다 if (blocksDeletion(groups, toDelete)) { - alert("중복 그룹 하나가 통째로 삭제 선택됐습니다. 각 그룹에서 최소 1개는 보존해야 합니다."); + loadError = "중복 그룹 전체가 삭제 대상으로 선택됐습니다. 각 그룹에서 최소 1개는 보존하도록 선택을 해제한 뒤 다시 시도하세요."; return; } const okay = await confirm( @@ -78,7 +83,7 @@ await scan(); results = r; } catch { - loadError = "선택한 중복 파일을 휴지통으로 보내지 못했습니다."; + loadError = "선택한 중복 파일을 휴지통으로 보내지 못했습니다. 파일이 열려 있는지와 휴지통 접근 권한을 확인한 뒤 중복 찾기부터 다시 실행하세요."; } finally { busy = false; } @@ -133,11 +138,11 @@ {/if} {#if results.length > 0} -

    {results.filter((r) => r.ok).length}/{results.length}개 휴지통으로 이동 — 복원 가능합니다.

    +

    {results.filter((r) => r.ok).length}/{results.length}개 휴지통으로 이동했습니다. 복원이 필요하면 휴지통에서 되돌리세요.

    {#if results.some((r) => !r.ok)}
      {#each results.filter((r) => !r.ok) as r (r.path)} -
    • ⚠ {r.path} — 일부 파일을 휴지통으로 보내지 못했습니다.
    • +
    • ⚠ {r.path} — 파일이 사용 중인지와 접근 권한을 확인한 뒤 중복 찾기부터 다시 실행하세요.
    • {/each}
    {/if} From 45bc3cbfcfaa879352a3a26290760441e20164b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 13:19:49 -0700 Subject: [PATCH 7/9] test: reject stale duplicate verdict writes --- src/lib/duplicatesErrorPrivacyContract.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/lib/duplicatesErrorPrivacyContract.test.ts b/src/lib/duplicatesErrorPrivacyContract.test.ts index afcc94ab5..cbea367a4 100644 --- a/src/lib/duplicatesErrorPrivacyContract.test.ts +++ b/src/lib/duplicatesErrorPrivacyContract.test.ts @@ -41,6 +41,19 @@ describe("Duplicates privacy-safe failure feedback", () => { 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)"); + }); + it("uses the accessible failure region for an invalid all-selected group", () => { const source = readSource("src/lib/Duplicates.svelte"); From 73e92d205619798b048b2be90fa144f83ec7a3fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 13:21:50 -0700 Subject: [PATCH 8/9] fix: reject stale duplicate verdict writes --- src/lib/Duplicates.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/Duplicates.svelte b/src/lib/Duplicates.svelte index 48f78dd35..d36e7c298 100644 --- a/src/lib/Duplicates.svelte +++ b/src/lib/Duplicates.svelte @@ -14,10 +14,12 @@ let toDelete: Set = $state(new Set()); let results: api.CleanResult[] = $state([]); let verdicts: Record = $state({}); + let scanGeneration = $state(0); - async function loadVerdicts(paths: string[]) { + 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 */ @@ -26,6 +28,7 @@ async function scan() { if (!scannedRoot) return; + const generation = ++scanGeneration; busy = true; loadError = ""; groups = []; @@ -40,7 +43,7 @@ for (const p of g.paths.slice(1)) next.add(p); } toDelete = next; - loadVerdicts(groups.flatMap((g) => g.paths)); + loadVerdicts(groups.flatMap((g) => g.paths), generation); } catch { loadError = "중복 파일 검색에 실패했습니다. 스캔 대상 폴더의 접근 권한을 확인하고 스캔을 다시 실행한 뒤 중복 찾기를 다시 누르세요."; } finally { From fd8d37e0465f4d828b4cea58f353a30dac6e4f9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 00:53:10 +0900 Subject: [PATCH 9/9] fix(ui): bind duplicate actions to current scan --- src/lib/Duplicates.svelte | 73 ++++++++++++++----- .../duplicatesErrorPrivacyContract.test.ts | 17 ++++- 2 files changed, 68 insertions(+), 22 deletions(-) diff --git a/src/lib/Duplicates.svelte b/src/lib/Duplicates.svelte index d36e7c298..0ab493807 100644 --- a/src/lib/Duplicates.svelte +++ b/src/lib/Duplicates.svelte @@ -9,12 +9,28 @@ let groups: api.DupeGroup[] = $state([]); let busy = $state(false); + let confirming = $state(false); let loadError = $state(""); // 각 그룹에서 삭제 대상으로 선택된 경로 (보존할 하나를 제외한 나머지) let toDelete: Set = $state(new Set()); let results: api.CleanResult[] = $state([]); let verdicts: Record = $state({}); let scanGeneration = $state(0); + let observedRoot: string | null = null; + + $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 { @@ -27,7 +43,8 @@ } async function scan() { - if (!scannedRoot) return; + const root = scannedRoot; + if (!root) return; const generation = ++scanGeneration; busy = true; loadError = ""; @@ -36,7 +53,9 @@ 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(); for (const g of groups) { @@ -45,9 +64,11 @@ toDelete = next; loadVerdicts(groups.flatMap((g) => g.paths), generation); } catch { - loadError = "중복 파일 검색에 실패했습니다. 스캔 대상 폴더의 접근 권한을 확인하고 스캔을 다시 실행한 뒤 중복 찾기를 다시 누르세요."; + if (generation === scanGeneration && root === scannedRoot) { + loadError = "중복 파일 검색에 실패했습니다. 스캔 대상 폴더의 접근 권한을 확인하고 스캔을 다시 실행한 뒤 중복 찾기를 다시 누르세요."; + } } finally { - busy = false; + if (generation === scanGeneration && root === scannedRoot) busy = false; } } @@ -66,6 +87,7 @@ ); async function deleteSelected() { + if (busy || confirming) return; const paths = [...toDelete]; if (paths.length === 0) return; loadError = ""; @@ -74,21 +96,32 @@ 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; + 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; + } } catch { - loadError = "선택한 중복 파일을 휴지통으로 보내지 못했습니다. 파일이 열려 있는지와 휴지통 접근 권한을 확인한 뒤 중복 찾기부터 다시 실행하세요."; + loadError = "휴지통 이동 확인 창을 열지 못했습니다. 다른 확인 창을 닫은 뒤 다시 시도하세요."; } finally { - busy = false; + confirming = false; } } @@ -96,7 +129,7 @@

    중복 파일 {scannedRoot ? "" : "(먼저 스캔하세요)"} - +

    {#if loadError}{/if} @@ -115,7 +148,7 @@