Remove the organizer's Preview and apply section

The preview/apply flow on the Organize tab did not work as expected, so
it is removed along with its backend: the stored per-pair preview, the
/organize/preview, /organize/plan and /organize/apply endpoints and
their UI and types.

The File naming card stays, and renaming now happens only through
"Organize automatically" during scans, which keeps the underlying
planning/moving code (now internal), undo, crash recovery and dry run.
The rename-folders warning is unchanged. Tests now drive the kept
planning and moving code directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-29 13:18:40 -07:00
1 parent dbcf562f7e
commit 96ea856dfd
8 files changed
+76 -462

No files matched your search

-13
View File
@@ -240,16 +240,3 @@ export type TagFile = {
links: number;
size: number;
};
export type OrganizeItem = { id: number; side: Side; from: string; to?: string; move: boolean; reason?: string; sidecars?: string[] };
export type OrganizePlan = {
pair_id: number;
sides: Side[];
template: string;
created_at: number;
scanned: number;
conforming: number;
moves: number;
skips: Record<string, number>;
};
+11 -223
View File
@@ -1,11 +1,10 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, FolderTree, Tags as TagsIcon } from "lucide-react";
import { api, type OrganizeItem, type OrganizePlan, type Pair, type Side } from "../api";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, FolderTree } from "lucide-react";
import { api, type Pair } from "../api";
import { useInfo } from "../App";
import { Alert, Badge, Button, Card, Empty, Field, Modal, Spinner, cx, formatAgo, inputCls } from "../ui";
import { Alert, Button, Card, Field, inputCls } from "../ui";
import { useToast } from "../toast";
import { TagEditor } from "./Tags";
const TOKENS: [string, string][] = [
["artist", "album artist, or artist"],
@@ -39,8 +38,6 @@ function example(tpl: string, multi?: Pair["organize_multidisc"]) {
return parts.join("/") + ".flac";
}
type Scope = Side | "both";
export function OrganizeTab({ pair }: { pair: Pair }) {
const info = useInfo();
if (info.data && !info.data.tag_editing)
@@ -48,7 +45,6 @@ export function OrganizeTab({ pair }: { pair: Pair }) {
return (
<div className="space-y-5">
<NamingSettings pair={pair} />
<Preview pair={pair} />
</div>
);
}
@@ -99,7 +95,10 @@ function NamingSettings({ pair }: { pair: Pair }) {
<h2 className="flex items-center gap-2 font-semibold">
<FolderTree className="size-4 text-accent" /> File naming
</h2>
<p className="text-sm text-muted">Files are renamed from their tags into this layout. Nothing else about a file changes.</p>
<p className="text-sm text-muted">
Files that don't follow this layout are renamed from their tags during scans, when <b>Organize automatically</b> is on. Nothing else about
a file changes.
</p>
</div>
<Button variant="primary" size="sm" disabled={!dirty} loading={save.isPending} onClick={() => save.mutate()}>
Save
@@ -138,9 +137,9 @@ function NamingSettings({ pair }: { pair: Pair }) {
<option value="none">Nothing (clashing names are skipped)</option>
</select>
</Field>
<Field label="Organize automatically" hint="During every scan, clean renames are applied without a preview.">
<Field label="Organize automatically" hint="Renames run at the start of every scan.">
<select className={inputCls} value={auto} onChange={(e) => setAuto(e.target.value as Pair["auto_organize"])}>
<option value="off">Off: preview and apply by hand</option>
<option value="off">Off: nothing is renamed</option>
<option value="slave">Slave library</option>
<option value="master">Master library</option>
<option value="both">Both libraries</option>
@@ -150,221 +149,10 @@ function NamingSettings({ pair }: { pair: Pair }) {
{auto !== "off" && (
<Alert tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
Files are renamed during each scan without asking. Every rename is listed in History and can be undone, and files with missing tags or
clashing names are left alone. Dry run still applies.
clashing names are left alone (fix their tags in the Tags tab). Lyrics files and cover art move along. Dry run still applies.
{auto !== "both" && " Organizing only one library can make paths differ from the other; hardlinked songs stay paired either way."}
</Alert>
)}
</Card>
);
}
function Preview({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
const dry = useInfo().data?.dry_run ?? true;
const [scope, setScope] = useState<Scope>("both");
const [view, setView] = useState<"move" | "skip">("move");
const [sel, setSel] = useState<Set<number> | null>(null); // null = all
const [confirm, setConfirm] = useState(false);
const [editTags, setEditTags] = useState<OrganizeItem | null>(null);
const running = pair.job?.state === "running";
const plan = useQuery({
queryKey: ["organize", pair.id, view],
queryFn: () =>
api.get<{ plan: OrganizePlan | null; items?: OrganizeItem[]; total?: number }>(`/pairs/${pair.id}/organize/plan?filter=${view}&limit=500`),
});
useEffect(() => setSel(null), [plan.data?.plan?.created_at]);
const preview = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/organize/preview`, { side: scope }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["pair", pair.id] }),
onError: (e) => toast("err", e.message),
});
const apply = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/organize/apply`, { ids: sel ? [...sel] : [] }),
onSuccess: () => {
setConfirm(false);
toast("ok", dry ? "Started (dry run): renames are recorded as planned only" : "Renaming files. The library is rescanned afterwards.");
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
},
onError: (e) => toast("err", e.message),
});
const p = plan.data?.plan;
const items = plan.data?.items ?? [];
const skipped = p ? Object.values(p.skips).reduce((a, b) => a + b, 0) : 0;
const chosen = sel ? sel.size : (p?.moves ?? 0);
const both = (p?.sides.length ?? 0) > 1;
return (
<Card className="space-y-4 p-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="font-semibold">Preview and apply</h2>
<p className="text-sm text-muted">See what would be renamed before anything changes.</p>
</div>
<div className="flex items-center gap-2">
<select className={inputCls + " h-8 w-auto text-xs"} value={scope} onChange={(e) => setScope(e.target.value as Scope)}>
<option value="both">Both libraries (recommended)</option>
<option value="slave">Slave library</option>
<option value="master">Master library</option>
</select>
<Button size="sm" onClick={() => preview.mutate()} loading={preview.isPending} disabled={running}>
Preview renames
</Button>
</div>
</div>
{running && pair.job?.kind.startsWith("organize") && (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner className="size-4" /> {pair.job.phase || "Working"}… {pair.job.total > 0 && `${pair.job.done} / ${pair.job.total}`}
</div>
)}
{!p && !running && (
<Empty icon={<FolderTree className="size-8" />} title="No preview yet">
Choose which library to organize and click <b>Preview renames</b>. Organizing both keeps their paths in step, so songs keep matching.
</Empty>
)}
{p && (
<>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<Stat label="checked" value={p.scanned} />
<Stat label="already organized" value={p.conforming} tone="text-emerald-300" />
<Stat label="to rename" value={p.moves} tone="text-accent" />
<Stat label="skipped" value={skipped} tone={skipped ? "text-amber-300" : undefined} />
</div>
<div className="text-xs text-muted">
Preview of the {p.sides.join(" and ")} {both ? "libraries" : "library"} · {formatAgo(p.created_at)} · pattern{" "}
<code className="mono">{p.template}</code>
</div>
<div className="flex gap-1 border-b border-line">
{(["move", "skip"] as const).map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={cx("-mb-px border-b-2 px-3 py-1.5 text-sm cursor-pointer", view === v ? "border-accent text-white" : "border-transparent text-muted hover:text-white")}
>
{v === "move" ? `To rename (${p.moves})` : `Skipped (${skipped})`}
</button>
))}
</div>
{view === "skip" && skipped > 0 && (
<ul className="space-y-0.5 text-xs text-muted">
{Object.entries(p.skips).map(([why, n]) => (
<li key={why}>
<b className="tabular-nums text-gray-300">{n}</b> {why}
</li>
))}
</ul>
)}
<div className="overflow-hidden rounded-lg border border-line">
{view === "move" && items.length > 0 && (
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-3 py-2 text-xs text-muted cursor-pointer">
<input
type="checkbox"
className="accent-violet-500"
checked={sel === null}
onChange={(e) => setSel(e.target.checked ? null : new Set())}
/>
{sel === null ? `All ${p.moves} selected` : `${sel.size} selected`}
</label>
)}
<div className="max-h-[55vh] overflow-y-auto">
{items.length === 0 && <div className="p-6 text-center text-sm text-muted">Nothing here.</div>}
{items.map((it) => (
<div key={it.id} className="flex items-start gap-3 border-b border-line px-3 py-2 last:border-0">
{it.move && (
<input
type="checkbox"
className="mt-0.5 accent-violet-500"
checked={sel === null || sel.has(it.id)}
onChange={(e) =>
setSel((s) => {
const n = new Set(s ?? items.filter((x) => x.move).map((x) => x.id));
if (e.target.checked) n.add(it.id);
else n.delete(it.id);
return n;
})
}
/>
)}
{both && <Badge tone={it.side === "master" ? "violet" : "gray"}>{it.side}</Badge>}
<div className="min-w-0 flex-1 text-xs mono">
{it.move ? (
<div className="grid grid-cols-[1fr_auto_1fr] items-start gap-2">
<span className="break-all text-gray-400">{it.from}</span>
<ArrowRight className="mt-0.5 size-3 text-muted" />
<span className="break-all text-gray-100">{it.to}</span>
</div>
) : (
<div className="break-all text-gray-300">{it.from}</div>
)}
{it.sidecars?.length ? <div className="mt-0.5 font-sans text-[11px] text-muted">+ lyrics file moves along</div> : null}
{!it.move && <div className="font-sans text-[11px] text-amber-300">{it.reason}</div>}
</div>
{!it.move && it.reason?.startsWith("missing") && (
<Button size="sm" variant="ghost" onClick={() => setEditTags(it)}>
<TagsIcon className="size-3.5" /> Fix tags
</Button>
)}
</div>
))}
</div>
</div>
{(plan.data?.total ?? 0) > items.length && (
<p className="text-xs text-muted">Showing the first {items.length} of {plan.data?.total}. Applying all includes every file.</p>
)}
{p.moves > 0 && (
<div className="flex justify-end">
<Button variant="primary" disabled={chosen === 0 || running} onClick={() => setConfirm(true)}>
<FolderTree className="size-4" /> Rename {chosen} file{chosen !== 1 && "s"}…
</Button>
</div>
)}
</>
)}
<Modal open={confirm} onClose={() => setConfirm(false)} title={`Rename ${chosen} file${chosen !== 1 ? "s" : ""}?`}>
<ul className="list-disc space-y-1 pl-5 text-sm text-gray-300">
<li>Files are moved to their new names inside the same library. Their contents and hardlinks don't change.</li>
<li>Lyrics files with the same name move along; cover art follows when a whole folder moves. Emptied folders are removed.</li>
<li>
<b>Playlists or apps that point at the old paths will need updating.</b>
</li>
{p && !both && <li>Only the {p.sides[0]} library is renamed. Hardlinked songs stay paired, but paths may differ from the other library.</li>}
<li>Each rename is listed in History and can be undone. The library is rescanned when it finishes.</li>
</ul>
{dry && (
<div className="mt-3">
<Alert tone="blue">Dry run is on, so the renames are recorded as planned in Activity. No files move.</Alert>
</div>
)}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirm(false)}>
Cancel
</Button>
<Button variant="primary" loading={apply.isPending} onClick={() => apply.mutate()}>
Rename {chosen} file{chosen !== 1 && "s"}
</Button>
</div>
</Modal>
{editTags && <TagEditor pairId={pair.id} side={editTags.side} paths={[editTags.from]} onClose={() => setEditTags(null)} />}
</Card>
);
}
function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
return (
<div className="rounded-lg border border-line bg-panel-2 px-3 py-2">
<div className={cx("text-xl font-semibold tabular-nums", tone)}>{value}</div>
<div className="text-xs text-muted">{label}</div>
</div>
);
}
-1
View File
@@ -40,7 +40,6 @@ function useJobFinished(pair: Pair | undefined) {
qc.invalidateQueries({ queryKey: ["tree"] });
qc.invalidateQueries({ queryKey: ["ops"] });
qc.invalidateQueries({ queryKey: ["pairs"] });
qc.invalidateQueries({ queryKey: ["organize"] });
qc.invalidateQueries({ queryKey: ["files"] });
const j = pair.job!;
if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`);