import React, { useEffect, useMemo, useState } from "react"; /** * Duplicate Review UI (simple, boring, effective) * * Assumes backend endpoints like: * GET /api/dup-groups * -> [{ groupId, hash, sizeBytes, fileCount, wastedBytes }] * * GET /api/dup-groups/:groupId * -> { groupId, hash, sizeBytes, files: [{ id, path, sizeBytes, mtime, ctime }] } * * POST /api/dup-groups/:groupId/plan * body -> { keepFileId, deleteFileIds, mode: "quarantine"|"delete", dryRun: boolean } * -> { ok: true, planned: n } * * You can adapt the fetch URLs to FastAPI/Flask easily. */ function formatBytes(bytes) { if (bytes == null) return ""; const units = ["B", "KB", "MB", "GB", "TB", "PB"]; let b = bytes; let u = 0; while (b >= 1024 && u < units.length - 1) { b /= 1024; u++; } return `${b.toFixed(u === 0 ? 0 : 2)} ${units[u]}`; } function formatDate(isoOrEpoch) { if (!isoOrEpoch) return ""; const d = typeof isoOrEpoch === "number" ? new Date(isoOrEpoch) : new Date(isoOrEpoch); if (Number.isNaN(d.getTime())) return String(isoOrEpoch); return d.toLocaleString(); } function basename(p) { if (!p) return ""; const s = p.replace(/\\/g, "/"); const parts = s.split("/"); return parts[parts.length - 1] || s; } function dirOf(p) { if (!p) return ""; const s = p.replace(/\\/g, "/"); const i = s.lastIndexOf("/"); return i >= 0 ? s.slice(0, i) : ""; } async function apiGet(url) { const r = await fetch(url, { headers: { Accept: "application/json" } }); if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); return r.json(); } async function apiPost(url, body) { const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify(body), }); if (!r.ok) { let msg = `${r.status} ${r.statusText}`; try { const j = await r.json(); msg = j?.detail || j?.error || msg; } catch {} throw new Error(msg); } return r.json(); } export default function DuplicateReviewPage() { const [groups, setGroups] = useState([]); const [groupsLoading, setGroupsLoading] = useState(false); const [groupsErr, setGroupsErr] = useState(""); const [selectedGroupId, setSelectedGroupId] = useState(null); const [groupDetail, setGroupDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [detailErr, setDetailErr] = useState(""); const [search, setSearch] = useState(""); const [sortGroups, setSortGroups] = useState("wastedDesc"); // wastedDesc|countDesc|sizeDesc const [sortFiles, setSortFiles] = useState("pathAsc"); // pathAsc|newest|oldest|dirAsc|nameAsc const [keepFileId, setKeepFileId] = useState(null); const [deleteIds, setDeleteIds] = useState(new Set()); const [mode, setMode] = useState("quarantine"); // quarantine|delete const [dryRun, setDryRun] = useState(true); const [actionMsg, setActionMsg] = useState(""); const [actionErr, setActionErr] = useState(""); const [actionLoading, setActionLoading] = useState(false); // Load groups useEffect(() => { let alive = true; (async () => { setGroupsLoading(true); setGroupsErr(""); try { const data = await apiGet("/api/dup-groups"); if (!alive) return; setGroups(Array.isArray(data) ? data : []); // auto-select first group if none selected if (!selectedGroupId && data?.length) setSelectedGroupId(data[0].groupId); } catch (e) { if (!alive) return; setGroupsErr(e?.message || String(e)); } finally { if (alive) setGroupsLoading(false); } })(); return () => { alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Load selected group detail useEffect(() => { if (!selectedGroupId) return; let alive = true; (async () => { setDetailLoading(true); setDetailErr(""); setGroupDetail(null); setKeepFileId(null); setDeleteIds(new Set()); setActionMsg(""); setActionErr(""); try { const data = await apiGet(`/api/dup-groups/${encodeURIComponent(selectedGroupId)}`); if (!alive) return; setGroupDetail(data); // default keep = "best guess": shortest path, else newest const files = Array.isArray(data?.files) ? data.files : []; if (files.length) { const best = [...files].sort((a, b) => { const ap = (a.path || "").length; const bp = (b.path || "").length; if (ap !== bp) return ap - bp; const am = new Date(a.mtime || 0).getTime(); const bm = new Date(b.mtime || 0).getTime(); return bm - am; })[0]; setKeepFileId(best?.id ?? null); } } catch (e) { if (!alive) return; setDetailErr(e?.message || String(e)); } finally { if (alive) setDetailLoading(false); } })(); return () => { alive = false; }; }, [selectedGroupId]); const filteredGroups = useMemo(() => { const q = search.trim().toLowerCase(); let g = groups || []; if (q) { g = g.filter((x) => { const hay = `${x.groupId ?? ""} ${x.hash ?? ""} ${x.fileCount ?? ""} ${x.sizeBytes ?? ""}`.toLowerCase(); return hay.includes(q); }); } const sorted = [...g]; sorted.sort((a, b) => { if (sortGroups === "countDesc") return (b.fileCount || 0) - (a.fileCount || 0); if (sortGroups === "sizeDesc") return (b.sizeBytes || 0) - (a.sizeBytes || 0); // wastedDesc default return (b.wastedBytes || 0) - (a.wastedBytes || 0); }); return sorted; }, [groups, search, sortGroups]); const sortedFiles = useMemo(() => { const files = Array.isArray(groupDetail?.files) ? groupDetail.files : []; const s = [...files]; s.sort((a, b) => { const ap = a.path || ""; const bp = b.path || ""; if (sortFiles === "pathAsc") return ap.localeCompare(bp); if (sortFiles === "dirAsc") return dirOf(ap).localeCompare(dirOf(bp)) || basename(ap).localeCompare(basename(bp)); if (sortFiles === "nameAsc") return basename(ap).localeCompare(basename(bp)) || dirOf(ap).localeCompare(dirOf(bp)); const am = new Date(a.mtime || 0).getTime(); const bm = new Date(b.mtime || 0).getTime(); if (sortFiles === "newest") return bm - am; if (sortFiles === "oldest") return am - bm; return ap.localeCompare(bp); }); return s; }, [groupDetail, sortFiles]); function toggleDelete(id) { setDeleteIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function applyKeep(id) { setKeepFileId(id); // Ensure keep isn't marked for delete setDeleteIds((prev) => { const next = new Set(prev); next.delete(id); return next; }); } function smartSelectDeleteAllButKeep() { const files = Array.isArray(groupDetail?.files) ? groupDetail.files : []; const next = new Set(); for (const f of files) { if (f.id !== keepFileId) next.add(f.id); } setDeleteIds(next); } function smartKeepNewest() { const files = Array.isArray(groupDetail?.files) ? groupDetail.files : []; if (!files.length) return; const newest = [...files].sort((a, b) => new Date(b.mtime || 0) - new Date(a.mtime || 0))[0]; if (newest?.id != null) applyKeep(newest.id); } function smartKeepShortestPath() { const files = Array.isArray(groupDetail?.files) ? groupDetail.files : []; if (!files.length) return; const best = [...files].sort((a, b) => (a.path || "").length - (b.path || "").length)[0]; if (best?.id != null) applyKeep(best.id); } async function submitPlan() { setActionMsg(""); setActionErr(""); if (!selectedGroupId) { setActionErr("No group selected."); return; } if (!keepFileId) { setActionErr("Pick a file to keep."); return; } if (deleteIds.size === 0) { setActionErr("Select at least one file to delete/quarantine."); return; } setActionLoading(true); try { const body = { keepFileId, deleteFileIds: Array.from(deleteIds), mode, dryRun, }; const res = await apiPost(`/api/dup-groups/${encodeURIComponent(selectedGroupId)}/plan`, body); setActionMsg( dryRun ? `Dry run OK: planned ${res?.planned ?? deleteIds.size} actions.` : `Done: processed ${res?.planned ?? deleteIds.size} files (${mode}).` ); // If not dry run, you might want to refresh group list/detail. if (!dryRun) { // Quick refresh detail const data = await apiGet(`/api/dup-groups/${encodeURIComponent(selectedGroupId)}`); setGroupDetail(data); // Reset selections setDeleteIds(new Set()); } } catch (e) { setActionErr(e?.message || String(e)); } finally { setActionLoading(false); } } const groupMeta = useMemo(() => { const g = (groups || []).find((x) => x.groupId === selectedGroupId); return g || null; }, [groups, selectedGroupId]); return (