From 8e9e2c91d423d67476491c8baadb17809e94b54e Mon Sep 17 00:00:00 2001
From: Quick <31828688+Quick104@users.noreply.github.com>
Date: Mon, 6 Jul 2026 12:56:45 -0400
Subject: [PATCH] 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
---
web/src/components/SplitItemDialog.tsx | 114 +++++++++++++------------
1 file changed, 59 insertions(+), 55 deletions(-)
diff --git a/web/src/components/SplitItemDialog.tsx b/web/src/components/SplitItemDialog.tsx
index 97c35c85..7f47adea 100644
--- a/web/src/components/SplitItemDialog.tsx
+++ b/web/src/components/SplitItemDialog.tsx
@@ -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();
@@ -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 (
+ {planValid && !preview && (
+
+ )}
{preview && (
@@ -403,10 +414,12 @@ export default function SplitItemDialog({ item, open, onOpenChange }: SplitItemD
{preview.reattribution.downloads} downloads move
{preview.episode_pairs > 0 && {preview.episode_pairs} episodes re-anchored}
- {preview.root_overrides.length + preview.file_overrides.length > 0 && (
+ {(preview.root_overrides?.length ?? 0) + (preview.file_overrides?.length ?? 0) >
+ 0 && (
- {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
)}
@@ -414,20 +427,11 @@ export default function SplitItemDialog({ item, open, onOpenChange }: SplitItemD
)}
-
-
+