mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-03 14:37:56 +00:00
fix(downloads): restore queue reorder interactions
This commit is contained in:
@@ -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)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user