diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index 56bb00c9..d48299a4 100644 --- a/app/client/components/snapshots-table.tsx +++ b/app/client/components/snapshots-table.tsx @@ -52,6 +52,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots const { formatDateTime } = useTimeFormat(); const [selectedIds, setSelectedIds] = useState>(new Set()); + const [lastSelectedId, setLastSelectedId] = useState(null); const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); const [showReTagDialog, setShowReTagDialog] = useState(false); const [targetScheduleId, setTargetScheduleId] = useState(""); @@ -70,6 +71,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots void queryClient.invalidateQueries({ queryKey }); setShowBulkDeleteConfirm(false); setSelectedIds(new Set()); + setLastSelectedId(null); }, }); @@ -81,6 +83,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots onSuccess: () => { setShowReTagDialog(false); setSelectedIds(new Set()); + setLastSelectedId(null); setTargetScheduleId(""); }, }); @@ -92,11 +95,63 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots const toggleSelectAll = () => { if (selectedIds.size === snapshots.length) { setSelectedIds(new Set()); + setLastSelectedId(null); } else { setSelectedIds(new Set(snapshots.map((s) => s.short_id))); + setLastSelectedId(snapshots.length > 0 ? snapshots[snapshots.length - 1].short_id : null); } }; + const handleSnapshotSelection = (snapshotId: string, event?: React.MouseEvent | React.KeyboardEvent) => { + const isShiftClick = event && "shiftKey" in event && event.shiftKey; + + // Attempt range selection first + if (isShiftClick && snapshots.length > 0) { + const currentIndex = snapshots.findIndex((s) => s.short_id === snapshotId); + + if (currentIndex !== -1) { + // If lastSelectedId exists, use it; otherwise start from the first item (index 0) + let startIndex: number; + if (lastSelectedId) { + startIndex = snapshots.findIndex((s) => s.short_id === lastSelectedId); + // If lastSelectedId no longer exists in snapshots (stale reference), fall back to single selection + if (startIndex === -1) { + const newSelected = new Set(selectedIds); + if (newSelected.has(snapshotId)) { + newSelected.delete(snapshotId); + } else { + newSelected.add(snapshotId); + } + setSelectedIds(newSelected); + setLastSelectedId(snapshotId); + return; + } + } else { + startIndex = 0; + } + + // Valid range selection - replace the entire selection with the new range + const start = Math.min(startIndex, currentIndex); + const end = Math.max(startIndex, currentIndex); + const rangeIds = new Set(snapshots.slice(start, end + 1).map((s) => s.short_id)); + + setSelectedIds(rangeIds); + setLastSelectedId(snapshotId); + return; + } + } + + // Single selection toggle (used as fallback or when shift-click not applicable) + const newSelected = new Set(selectedIds); + if (newSelected.has(snapshotId)) { + newSelected.delete(snapshotId); + } else { + newSelected.add(snapshotId); + } + setSelectedIds(newSelected); + setLastSelectedId(snapshotId); + }; + const handleBulkDelete = () => { toast.promise( deleteSnapshots.mutateAsync({ @@ -162,17 +217,12 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots className={cn("hover:bg-accent/50 cursor-pointer", isSelected && "bg-accent/30")} onClick={() => handleRowClick(snapshot.short_id)} > - e.stopPropagation()}> + e.stopPropagation()}> { - const newSelected = new Set(selectedIds); - if (newSelected.has(snapshot.short_id)) { - newSelected.delete(snapshot.short_id); - } else { - newSelected.add(snapshot.short_id); - } - setSelectedIds(newSelected); + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + handleSnapshotSelection(snapshot.short_id, e); }} aria-label={`Select snapshot ${snapshot.short_id}` as string} /> @@ -189,7 +239,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots hidden={!backup} to={backup ? `/backups/$backupId` : "."} params={backup ? { backupId: backup.shortId } : {}} - onClick={(e) => e.stopPropagation()} + onClick={(e: React.MouseEvent) => e.stopPropagation()} className="hover:underline" > {backup ? backup.name : "-"} @@ -216,7 +266,9 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots
- {formatDuration(snapshot.duration / 1000)} + + {formatDuration(snapshot.duration / 1000)} +
@@ -234,7 +286,10 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots variant="ghost" size="icon" className="h-8 w-8 rounded-full" - onClick={() => setSelectedIds(new Set())} + onClick={() => { + setSelectedIds(new Set()); + setLastSelectedId(null); + }} > @@ -269,8 +324,8 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots Delete {selectedIds.size} snapshots? - This action cannot be undone. This will permanently delete the selected snapshots and all their data from - the repository. + This action cannot be undone. This will permanently delete the selected snapshots and all + their data from the repository. @@ -291,8 +346,8 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups, listSnapshots Re-tag snapshots - Select a backup schedule to re-tag the {selectedIds.size} selected snapshots. All {selectedIds.size}{" "} - selected snapshots will be associated with the chosen schedule. + Select a backup schedule to re-tag the {selectedIds.size} selected snapshots. All{" "} + {selectedIds.size} selected snapshots will be associated with the chosen schedule.