feat(web): auto-run split dry-run preview instead of a manual Preview button

The Split Versions dialog now debounces a dry run whenever the plan is
valid, shows the preview automatically (with a stale-response token
guard), and tolerates null override arrays from older servers. Split
still requires a rendered preview before enabling.

Part of #319 follow-up.

AI-use: implemented with Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-06 12:56:45 -04:00
co-authored by Claude Fable 5
parent 80aff39921
commit 8e9e2c91d4
+59 -55
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Folder, Search, Scissors } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
@@ -15,6 +15,7 @@ import {
import type {
ItemFile,
ItemMatchSearchRequest,
ItemSplitRequest,
ItemSplitResponse,
MatchCandidate,
SplitHistoryMode,
@@ -69,11 +70,9 @@ export default function SplitItemDialog({ item, open, onOpenChange }: SplitItemD
const splitMutation = useSplitItem();
const candidates = searchMutation.data?.candidates ?? [];
// Any change to the plan invalidates a previously fetched preview.
const resetPreview = useCallback(() => setPreview(null), []);
useEffect(() => {
resetPreview();
}, [selectedIds, selectedCandidate, detachUnmatched, historyMode, resetPreview]);
// Monotonic token so a slow dry-run response for an outdated plan can't
// overwrite the preview of the current one.
const previewToken = useRef(0);
const filesByRoot = useMemo(() => {
const groups = new Map<string, ItemFile[]>();
@@ -129,47 +128,54 @@ export default function SplitItemDialog({ item, open, onOpenChange }: SplitItemD
const targetChosen = detachUnmatched || selectedCandidate !== null;
const selectionValid = selectedIds.size > 0 && selectedIds.size < files.length;
const canSubmit = selectionValid && targetChosen && !splitMutation.isPending;
const planValid = selectionValid && targetChosen;
const submit = useCallback(
(dryRun: boolean) => {
splitMutation.mutate(
{
contentId: item.content_id,
request: {
file_ids: [...selectedIds],
target: detachUnmatched
? { unmatched: true }
: {
provider_ids: selectedCandidate?.provider_ids,
title: selectedCandidate?.title,
year: selectedCandidate?.year || undefined,
},
history_mode: historyMode,
dry_run: dryRun,
const buildRequest = useCallback(
(dryRun: boolean): ItemSplitRequest => ({
file_ids: [...selectedIds],
target: detachUnmatched
? { unmatched: true }
: {
provider_ids: selectedCandidate?.provider_ids,
title: selectedCandidate?.title,
year: selectedCandidate?.year || undefined,
},
},
history_mode: historyMode,
dry_run: dryRun,
}),
[selectedIds, detachUnmatched, selectedCandidate, historyMode],
);
// react-query guarantees `mutate` is referentially stable, so the effect
// below only re-runs when the plan itself changes.
const { mutate: runSplit } = splitMutation;
// Any change to the plan invalidates the previous preview; once the plan is
// valid, a debounced dry run fetches a fresh one automatically.
useEffect(() => {
previewToken.current += 1;
setPreview(null);
if (!open || !planValid) return;
const token = previewToken.current;
const timer = setTimeout(() => {
runSplit(
{ contentId: item.content_id, request: buildRequest(true) },
{
onSuccess: (result) => {
if (dryRun) {
setPreview(result);
} else {
onOpenChange(false);
}
if (token === previewToken.current) setPreview(result);
},
},
);
},
[
splitMutation,
item.content_id,
selectedIds,
detachUnmatched,
selectedCandidate,
historyMode,
onOpenChange,
],
);
}, 400);
return () => clearTimeout(timer);
}, [open, planValid, buildRequest, runSplit, item.content_id]);
const confirmSplit = useCallback(() => {
runSplit(
{ contentId: item.content_id, request: buildRequest(false) },
{ onSuccess: () => onOpenChange(false) },
);
}, [runSplit, item.content_id, buildRequest, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -384,6 +390,11 @@ export default function SplitItemDialog({ item, open, onOpenChange }: SplitItemD
</p>
</section>
{planValid && !preview && (
<section className="bg-muted/30 text-muted-foreground rounded-lg border px-3 py-2 text-sm">
Previewing…
</section>
)}
{preview && (
<section className="bg-muted/30 space-y-1 rounded-lg border px-3 py-2 text-sm">
<div className="font-medium">
@@ -403,10 +414,12 @@ export default function SplitItemDialog({ item, open, onOpenChange }: SplitItemD
</li>
<li>{preview.reattribution.downloads} downloads move</li>
{preview.episode_pairs > 0 && <li>{preview.episode_pairs} episodes re-anchored</li>}
{preview.root_overrides.length + preview.file_overrides.length > 0 && (
{(preview.root_overrides?.length ?? 0) + (preview.file_overrides?.length ?? 0) >
0 && (
<li>
{preview.root_overrides.length} folder / {preview.file_overrides.length} file
identity override{"(s)"} pinned for future scans
{preview.root_overrides?.length ?? 0} folder /{" "}
{preview.file_overrides?.length ?? 0} file identity override{"(s)"} pinned for
future scans
</li>
)}
</ul>
@@ -414,20 +427,11 @@ export default function SplitItemDialog({ item, open, onOpenChange }: SplitItemD
)}
</div>
<div className="border-border/50 flex shrink-0 gap-2 border-t pt-4">
<Button
variant="secondary"
className="flex-1"
onClick={() => submit(true)}
disabled={!canSubmit}
data-testid="preview-split"
>
{splitMutation.isPending && !preview ? "Previewing…" : "Preview"}
</Button>
<div className="border-border/50 flex shrink-0 border-t pt-4">
<Button
className="flex-1 gap-2"
onClick={() => submit(false)}
disabled={!canSubmit || !preview}
onClick={confirmSplit}
disabled={!planValid || !preview || splitMutation.isPending}
data-testid="confirm-split"
>
<Scissors className="h-4 w-4" />