fix(downloads): restore queue reorder interactions

This commit is contained in:
NimBold
2026-07-23 06:45:49 +03:30
parent 3559c69968
commit 18e9200b74
4 changed files with 67 additions and 43 deletions
+50 -23
View File
@@ -745,8 +745,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const capturePointerId = queueDragCapturePointerIdRef.current; const capturePointerId = queueDragCapturePointerIdRef.current;
queueDragCaptureTargetRef.current = null; queueDragCaptureTargetRef.current = null;
queueDragCapturePointerIdRef.current = null; queueDragCapturePointerIdRef.current = null;
if (captureTarget && capturePointerId !== null && captureTarget.hasPointerCapture(capturePointerId)) { if (!captureTarget || capturePointerId === null) return;
captureTarget.releasePointerCapture(capturePointerId); try {
if (captureTarget.hasPointerCapture(capturePointerId)) {
captureTarget.releasePointerCapture(capturePointerId);
}
} catch (error) {
// A row can be removed or its WebView can lose the pointer between the
// terminal event and cleanup. Listener/state cleanup must still finish.
console.warn('Failed to release queue pointer capture:', error);
} }
}; };
@@ -760,11 +767,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const finishQueueDrag = (cancelled = false) => { const finishQueueDrag = (cancelled = false) => {
const current = queueDragStateRef.current; const current = queueDragStateRef.current;
if (!current) return; if (!current) return;
queueDragCleanupRef.current?.(); const cleanup = queueDragCleanupRef.current;
queueDragCleanupRef.current = null; queueDragCleanupRef.current = null;
releaseQueuePointerCapture(); try {
queueDragStateRef.current = null; cleanup?.();
setQueueDragState(null); } catch (error) {
console.error('Failed to clean up queue drag listeners:', error);
} finally {
releaseQueuePointerCapture();
queueDragStateRef.current = null;
setQueueDragState(null);
}
if (current.active) { if (current.active) {
suppressQueueClickRef.current = true; suppressQueueClickRef.current = true;
window.setTimeout(() => { window.setTimeout(() => {
@@ -800,6 +813,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
id: string, id: string,
event: React.PointerEvent<HTMLDivElement> event: React.PointerEvent<HTMLDivElement>
) => { ) => {
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
if ( if (
!queueReorderingEnabled || !queueReorderingEnabled ||
event.button !== 0 || event.button !== 0 ||
@@ -855,17 +870,17 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}; };
queueDragStateRef.current = initialState; queueDragStateRef.current = initialState;
setQueueDragState(initialState); setQueueDragState(initialState);
queueDragCaptureTargetRef.current = event.currentTarget; queueDragCaptureTargetRef.current = captureTarget;
queueDragCapturePointerIdRef.current = event.pointerId; queueDragCapturePointerIdRef.current = pointerId;
try { try {
event.currentTarget.setPointerCapture(event.pointerId); captureTarget.setPointerCapture(pointerId);
} catch { } catch {
// The pointer may have been cancelled between pointerdown and capture. // The pointer may have been cancelled between pointerdown and capture.
// Window-level listeners remain the best-effort cleanup fallback. // Window-level listeners remain the best-effort cleanup fallback.
} }
const pointerMove = (pointerEvent: PointerEvent) => { const pointerMove = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== event.pointerId) return; if (pointerEvent.pointerId !== pointerId) return;
const drag = queueDragStateRef.current; const drag = queueDragStateRef.current;
if (!drag) return; if (!drag) return;
const distance = Math.hypot( const distance = Math.hypot(
@@ -893,10 +908,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
pointerEvent.preventDefault(); pointerEvent.preventDefault();
}; };
const pointerUp = (pointerEvent: PointerEvent) => { const pointerUp = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId === event.pointerId) finishQueueDrag(); if (pointerEvent.pointerId === pointerId) finishQueueDrag();
}; };
const pointerCancel = (pointerEvent: PointerEvent) => { const pointerCancel = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId === event.pointerId) finishQueueDrag(true); if (pointerEvent.pointerId === pointerId) finishQueueDrag(true);
}; };
const lostPointerCapture = () => finishQueueDrag(true); const lostPointerCapture = () => finishQueueDrag(true);
const cancel = () => finishQueueDrag(true); const cancel = () => finishQueueDrag(true);
@@ -905,14 +920,14 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
window.addEventListener('pointercancel', pointerCancel); window.addEventListener('pointercancel', pointerCancel);
window.addEventListener('blur', cancel); window.addEventListener('blur', cancel);
document.addEventListener('visibilitychange', cancel); document.addEventListener('visibilitychange', cancel);
event.currentTarget.addEventListener('lostpointercapture', lostPointerCapture); captureTarget.addEventListener('lostpointercapture', lostPointerCapture);
queueDragCleanupRef.current = () => { queueDragCleanupRef.current = () => {
window.removeEventListener('pointermove', pointerMove); window.removeEventListener('pointermove', pointerMove);
window.removeEventListener('pointerup', pointerUp); window.removeEventListener('pointerup', pointerUp);
window.removeEventListener('pointercancel', pointerCancel); window.removeEventListener('pointercancel', pointerCancel);
window.removeEventListener('blur', cancel); window.removeEventListener('blur', cancel);
document.removeEventListener('visibilitychange', cancel); document.removeEventListener('visibilitychange', cancel);
event.currentTarget.removeEventListener('lostpointercapture', lostPointerCapture); captureTarget.removeEventListener('lostpointercapture', lostPointerCapture);
}; };
}; };
@@ -1192,19 +1207,31 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
queueDragStateRef.current || queueDragStateRef.current ||
queueDragPreviewOrderRef.current queueDragPreviewOrderRef.current
) return; ) return;
const currentQueueItems = queueReorderableDownloadsRef.current;
const source = currentQueueItems.find(item => item.id === id);
if (!source) return;
const sourceQueueId = source.queueId || MAIN_QUEUE_ID;
const currentQueueIds = new Set(
currentQueueItems
.filter(item => (item.queueId || MAIN_QUEUE_ID) === sourceQueueId)
.map(item => item.id)
);
const ids = selectedIdsRef.current.has(id) const ids = selectedIdsRef.current.has(id)
? Array.from(selectedIdsRef.current) ? Array.from(selectedIdsRef.current).filter(selectedId => currentQueueIds.has(selectedId))
: id; : [id];
void trackQueueReorderOperation(moveInQueue(ids, direction)); if (ids.length === 0) return;
}, [moveInQueue]); void trackQueueReorderOperation(moveInQueue(ids, direction))
.catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error));
}, [moveInQueue, showInteractionError, t]);
const moveSelectedQueueItems = useCallback((ids: string[], direction: 'up' | 'down') => { const moveSelectedQueueItems = useCallback((ids: string[], direction: 'up' | 'down') => {
if ( if (
queueDragStateRef.current || queueDragStateRef.current ||
queueDragPreviewOrderRef.current queueDragPreviewOrderRef.current
) return; ) return;
void trackQueueReorderOperation(moveInQueue(ids, direction)); void trackQueueReorderOperation(moveInQueue(ids, direction))
}, [moveInQueue]); .catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error));
}, [moveInQueue, showInteractionError, t]);
const handleSort = (column: DownloadSortColumn) => { const handleSort = (column: DownloadSortColumn) => {
const update = (current: DownloadSortConfig | null): DownloadSortConfig => const update = (current: DownloadSortConfig | null): DownloadSortConfig =>
@@ -1449,13 +1476,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
) : null} ) : null}
{queueReorderingEnabled && queueReorderableDownloads.length > 0 ? ( {queueReorderingEnabled && queueReorderableDownloads.length > 0 ? (
<div <div
className="downloads-queue-priority-controls" className="main-control-group downloads-queue-priority-controls"
role="group" role="group"
aria-label={t($ => $.downloadTable.queuePriorityControls)} aria-label={t($ => $.downloadTable.queuePriorityControls)}
> >
<button <button
type="button" type="button"
className="app-icon-button h-7 w-7" className="main-control-button"
disabled={queueReorderPending || !canMoveSelectedUp} disabled={queueReorderPending || !canMoveSelectedUp}
aria-label={t($ => $.downloads.actions.moveUp)} aria-label={t($ => $.downloads.actions.moveUp)}
title={t($ => $.downloads.actions.moveUp)} title={t($ => $.downloads.actions.moveUp)}
@@ -1465,7 +1492,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
</button> </button>
<button <button
type="button" type="button"
className="app-icon-button h-7 w-7" className="main-control-button"
disabled={queueReorderPending || !canMoveSelectedDown} disabled={queueReorderPending || !canMoveSelectedDown}
aria-label={t($ => $.downloads.actions.moveDown)} aria-label={t($ => $.downloads.actions.moveDown)}
title={t($ => $.downloads.actions.moveDown)} title={t($ => $.downloads.actions.moveDown)}
-20
View File
@@ -1915,26 +1915,6 @@ html[data-list-density="relaxed"] {
white-space: nowrap; white-space: nowrap;
} }
.downloads-queue-priority-controls {
display: inline-flex;
align-items: center;
gap: 1px;
padding: 1px;
border: 1px solid hsl(var(--border-color));
border-radius: 7px;
background: hsl(var(--statusbar-bg) / 0.7);
}
.downloads-queue-priority-controls .app-icon-button {
color: hsl(var(--text-secondary));
}
.downloads-queue-priority-controls .app-icon-button:hover:not(:disabled),
.downloads-queue-priority-controls .app-icon-button:focus-visible {
color: hsl(var(--accent-color));
background: hsl(var(--accent-color) / 0.14);
}
.downloads-table { .downloads-table {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
+16
View File
@@ -1780,6 +1780,22 @@ describe('useDownloadStore', () => {
expect(new Set(positions).size).toBe(4); expect(new Set(positions).size).toBe(4);
}); });
it('rolls back and rejects a failed keyboard or header queue move', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'first', status: 'queued', queueId: 'move-failure-queue', queuePosition: 0 },
{ id: 'second', status: 'queued', queueId: 'move-failure-queue', queuePosition: 1 }
] as any[],
backendRegisteredIds: new Set(['second'])
});
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
await expect(
useDownloadStore.getState().moveInQueue('second', 'up')
).rejects.toThrow('backend unavailable');
expect(useDownloadStore.getState().downloads.map(item => item.queuePosition)).toEqual([0, 1]);
});
it('translates a drag target around staged rows before the atomic backend move', async () => { it('translates a drag target around staged rows before the atomic backend move', async () => {
useDownloadStore.setState({ useDownloadStore.setState({
downloads: [ downloads: [
+1
View File
@@ -948,6 +948,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
? { ...download, queuePosition: previousPositions.get(download.id) } ? { ...download, queuePosition: previousPositions.get(download.id) }
: download) : download)
})); }));
throw error;
} }
}); });
const trackedOperation = operation.finally(() => { const trackedOperation = operation.finally(() => {