diff --git a/client/src/components/BackupWidget.jsx b/client/src/components/BackupWidget.jsx
index 095cea943c..f7e6f2288c 100644
--- a/client/src/components/BackupWidget.jsx
+++ b/client/src/components/BackupWidget.jsx
@@ -1,4 +1,4 @@
-import { useState, memo, useCallback, useId } from 'react';
+import { useState, memo, useCallback, useId, useRef } from 'react';
import { Link } from 'react-router';
import {HardDrive,
ChevronDown,
@@ -59,44 +59,73 @@ const HEALTH_STYLES = {
// RestorePanel
// ---------------------------------------------------------------------------
-function RestorePanel({ snapshot, onClose }) {
+function RestorePanel({ snapshot, onClose, restoring, onRestoreStateChange }) {
const filterId = useId();
const [filter, setFilter] = useState('');
- const [preview, setPreview] = useState(null);
+ const [acceptedPreview, setAcceptedPreview] = useState(null);
const [previewing, setPreviewing] = useState(false);
- const [restoring, setRestoring] = useState(false);
+ const previewGenerationRef = useRef(0);
+
+ const currentRequest = {
+ snapshotId: snapshot.id,
+ subdirFilter: filter.trim() || null,
+ };
+ const previewMatchesCurrentRequest = acceptedPreview
+ && acceptedPreview.request.snapshotId === currentRequest.snapshotId
+ && acceptedPreview.request.subdirFilter === currentRequest.subdirFilter;
+
+ const handleFilterChange = useCallback((event) => {
+ previewGenerationRef.current += 1;
+ setFilter(event.target.value);
+ setAcceptedPreview(null);
+ setPreviewing(false);
+ }, []);
const handlePreview = useCallback(async () => {
- setPreviewing(true);
- setPreview(null);
- const result = await api.restoreBackup({
+ const generation = previewGenerationRef.current + 1;
+ previewGenerationRef.current = generation;
+ const request = {
snapshotId: snapshot.id,
+ subdirFilter: filter.trim() || null,
+ };
+ setPreviewing(true);
+ setAcceptedPreview(null);
+ const outcome = await api.restoreBackup({
+ ...request,
dryRun: true,
- subdirFilter: filter.trim() || null
- }, { silent: true }).catch(err => {
- toast.error(`Preview failed: ${err.message}`);
- return null;
- });
+ }, { silent: true }).then(
+ previewResult => ({ previewResult }),
+ previewError => ({ previewError }),
+ );
+
+ if (previewGenerationRef.current !== generation) return;
setPreviewing(false);
- if (result) setPreview(result);
+ if (outcome.previewError) {
+ toast.error(`Preview failed: ${outcome.previewError.message}`);
+ return;
+ }
+ if (outcome.previewResult) setAcceptedPreview({ request, result: outcome.previewResult });
}, [snapshot.id, filter]);
const handleRestore = useCallback(async () => {
- setRestoring(true);
+ if (!previewMatchesCurrentRequest || restoring) return;
+
+ onRestoreStateChange(snapshot.id);
const result = await api.restoreBackup({
- snapshotId: snapshot.id,
+ ...acceptedPreview.request,
dryRun: false,
- subdirFilter: filter.trim() || null
}, { silent: true }).catch(err => {
toast.error(`Restore failed: ${err.message}`);
return null;
});
- setRestoring(false);
+ onRestoreStateChange(null);
if (result) {
toast.success(`Restore complete — ${result.changedFiles?.length ?? 0} file(s) restored`);
onClose();
}
- }, [snapshot.id, filter, onClose]);
+ }, [acceptedPreview, onClose, onRestoreStateChange, previewMatchesCurrentRequest, restoring, snapshot.id]);
+
+ const preview = previewMatchesCurrentRequest ? acceptedPreview.result : null;
return (
@@ -106,7 +135,8 @@ function RestorePanel({ snapshot, onClose }) {
@@ -121,7 +151,8 @@ function RestorePanel({ snapshot, onClose }) {
id={filterId}
type="text"
value={filter}
- onChange={e => setFilter(e.target.value)}
+ onChange={handleFilterChange}
+ disabled={restoring}
placeholder="e.g., brain"
className="w-full bg-port-card border border-port-border rounded px-3 py-1.5 text-sm text-white placeholder-gray-600 focus:outline-hidden focus:border-port-accent"
/>
@@ -133,7 +164,7 @@ function RestorePanel({ snapshot, onClose }) {
{/* Preview button */}
@@ -296,6 +329,7 @@ const BackupWidget = memo(function BackupWidget() {
);
const [handleBackupNow, triggering] = useBackupRun();
const [snapshotsOpen, setSnapshotsOpen] = useState(false);
+ const [restoringSnapshotId, setRestoringSnapshotId] = useState(null);
// Tick every minute so the dedup-skipped widget still recomputes
// `relativeTime(lastRun/nextRun)` labels and the `computeHealth` 25h/49h
// thresholds when wall-clock time crosses a boundary even though the poll
@@ -407,7 +441,8 @@ const BackupWidget = memo(function BackupWidget() {
{/* Toggle snapshots */}