중복 탐지 진행을 상태바에 표시 — 패널을 닫아도 진행/취소 가능

기존엔 중복 탐지 진행이 전체화면 패널 안에서만 보였고, 패널을 닫으면
진행 상황을 알 수 없었다.

- 중복 탐지 실행/진행 상태를 store로 이관(runDedupe/dedupeRunning/dedupeProgress)
  → 패널 열림 여부와 무관하게 유지.
- DedupePanel은 store 상태를 사용(로컬 running/progress 제거), 완료 시 결과 갱신.
- StatusBar에 진행 표시: "완전 동일 검사 N/M" + 스피너 + 취소(✕), 텍스트 클릭 시
  패널 다시 열기.

검증(대용량 3000장): 패널을 닫은 상태에서 상태바가 완전 동일 검사 517→3,000
진행을 실시간 표시, 취소 버튼 노출 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-19 23:35:13 +09:00
co-authored by Claude Fable 5
parent 2072f9f27e
commit ac3b527e8e
3 changed files with 90 additions and 27 deletions
+23 -26
View File
@@ -5,28 +5,30 @@ import {
createResource, createResource,
createSignal, createSignal,
For, For,
on,
Show, Show,
type Component, type Component,
} from "solid-js"; } from "solid-js";
import { ask } from "@tauri-apps/plugin-dialog"; import { ask } from "@tauri-apps/plugin-dialog";
import { ko } from "../../i18n/ko"; import { ko } from "../../i18n/ko";
import { import {
cancelDedupe,
dismissDupePair, dismissDupePair,
listDupeGroups, listDupeGroups,
resolveDupeGroup, resolveDupeGroup,
startDedupe,
thumbUrl, thumbUrl,
trashFiles, trashFiles,
type DedupeProgress,
type DupeGroup, type DupeGroup,
} from "../../ipc/commands"; } from "../../ipc/commands";
import { import {
closeDedupe, closeDedupe,
dedupeOpen, dedupeOpen,
dedupeProgress,
dedupeRunning,
info, info,
refreshSidebar, refreshSidebar,
refreshSnapshot, refreshSnapshot,
requestCancelDedupe,
runDedupe,
toast, toast,
view, view,
} from "../../state/store"; } from "../../state/store";
@@ -40,8 +42,6 @@ function fmtSize(bytes: number): string {
const KIND_LABEL: Record<number, string> = { 0: "완전 동일", 1: "유사 이미지", 2: "유사 영상" }; const KIND_LABEL: Record<number, string> = { 0: "완전 동일", 1: "유사 이미지", 2: "유사 영상" };
export const DedupePanel: Component = () => { export const DedupePanel: Component = () => {
const [running, setRunning] = createSignal(false);
const [progress, setProgress] = createSignal<DedupeProgress | undefined>();
const [includeVideo, setIncludeVideo] = createSignal(false); const [includeVideo, setIncludeVideo] = createSignal(false);
const [threshold, setThreshold] = createSignal(5); const [threshold, setThreshold] = createSignal(5);
const [groupsVersion, setGroupsVersion] = createSignal(0); const [groupsVersion, setGroupsVersion] = createSignal(0);
@@ -67,28 +67,25 @@ export const DedupePanel: Component = () => {
}); });
const runScan = async () => { const runScan = async () => {
setRunning(true);
setProgress(undefined);
const scoped = useCurrentScope() ? currentScope() : null; const scoped = useCurrentScope() ? currentScope() : null;
try { try {
await new Promise<void>((resolve, reject) => { await runDedupe({ ...(scoped?.args ?? {}), imageThreshold: threshold(), includeVideo: includeVideo() });
startDedupe(
{ ...(scoped?.args ?? {}), imageThreshold: threshold(), includeVideo: includeVideo() },
(p) => {
setProgress(p);
if (p.phase === "done") resolve();
},
).catch(reject);
});
} catch (e) { } catch (e) {
toast(String(e)); toast(String(e));
} finally {
setRunning(false);
setProgress(undefined);
setGroupsVersion((v) => v + 1);
} }
}; };
// 탐지 완료(실행중 true→false) 시 결과 목록 갱신 — 패널이 닫혀 있다 열려도 재조회됨.
createEffect(
on(
dedupeRunning,
(running, prev) => {
if (prev && !running) setGroupsVersion((v) => v + 1);
},
{ defer: true },
),
);
/** keeper 외 전부 휴지통 */ /** keeper 외 전부 휴지통 */
const resolveGroup = async (g: DupeGroup) => { const resolveGroup = async (g: DupeGroup) => {
const losers = g.members.filter((m) => !m.isKeeper).map((m) => m.fileId); const losers = g.members.filter((m) => !m.isKeeper).map((m) => m.fileId);
@@ -181,20 +178,20 @@ export const DedupePanel: Component = () => {
</Show> </Show>
</div> </div>
<div class="ml-auto flex items-center gap-2"> <div class="ml-auto flex items-center gap-2">
<Show when={running()}> <Show when={dedupeRunning()}>
<span class="text-[11px] text-[var(--accent)]"> <span class="text-[11px] text-[var(--accent)]">
{progress() {dedupeProgress()
? ko.dedupe.scanning(progress()!.phase, progress()!.current, progress()!.total) ? ko.dedupe.scanning(dedupeProgress()!.phase, dedupeProgress()!.current, dedupeProgress()!.total)
: ko.dedupe.starting} : ko.dedupe.starting}
</span> </span>
<button <button
class="rounded border border-[var(--border)] px-2 py-1 text-xs hover:bg-[var(--bg-hover)]" class="rounded border border-[var(--border)] px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
onClick={() => void cancelDedupe()} onClick={requestCancelDedupe}
> >
{ko.common.cancel} {ko.common.cancel}
</button> </button>
</Show> </Show>
<Show when={!running()}> <Show when={!dedupeRunning()}>
<button <button
class="rounded bg-[var(--accent)] px-3 py-1 text-xs text-white" class="rounded bg-[var(--accent)] px-3 py-1 text-xs text-white"
onClick={() => void runScan()} onClick={() => void runScan()}
@@ -218,7 +215,7 @@ export const DedupePanel: Component = () => {
when={groups() && groups()!.length > 0} when={groups() && groups()!.length > 0}
fallback={ fallback={
<div class="grid h-full place-items-center text-sm text-[var(--text-muted)]"> <div class="grid h-full place-items-center text-sm text-[var(--text-muted)]">
{running() ? ko.dedupe.starting : ko.dedupe.empty} {dedupeRunning() ? ko.dedupe.starting : ko.dedupe.empty}
</div> </div>
} }
> >
+31 -1
View File
@@ -1,6 +1,17 @@
import { Show, type Component } from "solid-js"; import { Show, type Component } from "solid-js";
import { ko } from "../i18n/ko"; import { ko } from "../i18n/ko";
import { info, pending, scanProgress, selected, snapshot, statusMessage } from "../state/store"; import {
dedupeProgress,
dedupeRunning,
info,
openDedupe,
pending,
requestCancelDedupe,
scanProgress,
selected,
snapshot,
statusMessage,
} from "../state/store";
export const StatusBar: Component = () => { export const StatusBar: Component = () => {
return ( return (
@@ -25,6 +36,25 @@ export const StatusBar: Component = () => {
</span> </span>
)} )}
</Show> </Show>
{/* 중복 탐지 진행 — 패널을 닫아도 상태바에 표시 (클릭 시 패널 열기 / 취소) */}
<Show when={dedupeRunning()}>
<span class="flex items-center gap-1.5 text-[var(--accent)]">
<span class="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-[var(--accent)] border-t-transparent" />
<button class="hover:underline" onClick={openDedupe} title={ko.dedupe.open}>
{dedupeProgress()
? ko.dedupe.scanning(dedupeProgress()!.phase, dedupeProgress()!.current, dedupeProgress()!.total)
: ko.dedupe.starting}
</button>
<button
class="rounded px-1 text-[var(--text-muted)] hover:text-[var(--danger)]"
onClick={requestCancelDedupe}
title={ko.common.cancel}
>
</button>
</span>
</Show>
{/* 백그라운드 처리 진행 — 썸네일/메타 대기 수 (있을 때만) */} {/* 백그라운드 처리 진행 — 썸네일/메타 대기 수 (있을 때만) */}
<Show when={pending().thumbs > 0 || pending().meta > 0}> <Show when={pending().thumbs > 0 || pending().meta > 0}>
<span class="flex items-center gap-1.5 text-[var(--text-muted)]"> <span class="flex items-center gap-1.5 text-[var(--text-muted)]">
+36
View File
@@ -3,13 +3,16 @@ import { createSignal } from "solid-js";
import { createStore, produce } from "solid-js/store"; import { createStore, produce } from "solid-js/store";
import { import {
appInfo, appInfo,
cancelDedupe,
folderSnapshot, folderSnapshot,
pendingCounts as fetchPendingCounts, pendingCounts as fetchPendingCounts,
searchSnapshot, searchSnapshot,
smartSnapshot, smartSnapshot,
startDedupe,
tagSnapshot, tagSnapshot,
Snapshot, Snapshot,
type AppInfo, type AppInfo,
type DedupeProgress,
type PendingCounts, type PendingCounts,
type ScanProgress, type ScanProgress,
} from "../ipc/commands"; } from "../ipc/commands";
@@ -196,6 +199,39 @@ export { dedupeOpen };
export const openDedupe = () => setDedupeOpen(true); export const openDedupe = () => setDedupeOpen(true);
export const closeDedupe = () => setDedupeOpen(false); export const closeDedupe = () => setDedupeOpen(false);
// ── 중복 탐지 실행(전역) — 패널을 닫아도 상태바에서 진행이 보이도록 store가 소유 ──
const [dedupeProgress, setDedupeProgress] = createSignal<DedupeProgress | undefined>();
const [dedupeRunning, setDedupeRunning] = createSignal(false);
export { dedupeProgress, dedupeRunning };
export interface DedupeRunArgs {
sourceId?: number;
folderId?: number;
recursive?: boolean;
imageThreshold?: number;
includeVideo?: boolean;
}
/** 중복 탐지 실행. 진행 상태는 store에 유지되어 패널을 닫아도 상태바에서 보인다. */
export async function runDedupe(args: DedupeRunArgs): Promise<void> {
if (dedupeRunning()) return;
setDedupeRunning(true);
setDedupeProgress(undefined);
try {
await new Promise<void>((resolve, reject) => {
startDedupe(args, (p) => {
setDedupeProgress(p);
if (p.phase === "done") resolve();
}).catch(reject);
});
} finally {
setDedupeRunning(false);
setDedupeProgress(undefined);
}
}
export const requestCancelDedupe = () => void cancelDedupe();
// ── 상태 메시지 (토스트) ───────────────────────────────── // ── 상태 메시지 (토스트) ─────────────────────────────────
const [statusMessage, setStatusMessage] = createSignal<string | undefined>(); const [statusMessage, setStatusMessage] = createSignal<string | undefined>();
export { statusMessage }; export { statusMessage };