mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-11 03:59:09 +00:00
feat(downloads): add media quality and queue reordering
This commit is contained in:
@@ -1575,6 +1575,52 @@ describe('useDownloadStore', () => {
|
||||
expect(new Set(positions).size).toBe(4);
|
||||
});
|
||||
|
||||
it('translates a drag target around staged rows before the atomic backend move', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'a', status: 'queued', queueId: 'drag-queue', queuePosition: 0 },
|
||||
{ id: 'staged', status: 'staged', queueId: 'drag-queue', queuePosition: 1 },
|
||||
{ id: 'b', status: 'queued', queueId: 'drag-queue', queuePosition: 2 },
|
||||
{ id: 'c', status: 'queued', queueId: 'drag-queue', queuePosition: 3 }
|
||||
] as any[],
|
||||
backendRegisteredIds: new Set(['a', 'b', 'c']),
|
||||
pendingOrder: ['a', 'b', 'c']
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'move_many_in_queue') return ['c', 'a', 'b'];
|
||||
if (command === 'get_pending_order') return ['c', 'a', 'b'];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().moveManyInQueueToPosition('c', 'drag-queue', 0);
|
||||
|
||||
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith('move_many_in_queue', {
|
||||
ids: ['c'],
|
||||
queueId: 'drag-queue',
|
||||
direction: 'up',
|
||||
targetIndex: 0
|
||||
});
|
||||
expect(useDownloadStore.getState().downloads.map(item => item.id).sort()).toEqual(['a', 'b', 'c', 'staged']);
|
||||
expect(useDownloadStore.getState().downloads.find(item => item.id === 'c')?.queuePosition).toBe(0);
|
||||
expect(useDownloadStore.getState().downloads.find(item => item.id === 'staged')?.queuePosition).toBe(2);
|
||||
});
|
||||
|
||||
it('rolls back a failed drag move and rejects so the UI can report the failure', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'first', status: 'queued', queueId: 'rollback-queue', queuePosition: 0 },
|
||||
{ id: 'second', status: 'queued', queueId: 'rollback-queue', queuePosition: 1 }
|
||||
] as any[],
|
||||
backendRegisteredIds: new Set(['first', 'second'])
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
|
||||
|
||||
await expect(
|
||||
useDownloadStore.getState().moveManyInQueueToPosition('second', 'rollback-queue', 0)
|
||||
).rejects.toThrow('backend unavailable');
|
||||
expect(useDownloadStore.getState().downloads.map(item => item.queuePosition)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('detaches a registered queued item through the backend before reassigning it', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
} from '../utils/downloadLocations';
|
||||
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
|
||||
import { updateDockBadge } from '../utils/dockBadge';
|
||||
import {
|
||||
moveSelectedBlockToIndex,
|
||||
targetIndexForDesiredOrder
|
||||
} from '../utils/queueOrdering';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export type { DownloadCategory } from '../utils/downloads';
|
||||
@@ -659,6 +663,7 @@ interface DownloadState {
|
||||
unregisterBackendIds: (ids: string[]) => void;
|
||||
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
|
||||
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>;
|
||||
moveManyInQueueToPosition: (ids: string | string[], queueId: string, targetIndex: number) => Promise<void>;
|
||||
removeFromQueue: (id: string) => Promise<void>;
|
||||
isAddModalOpen: boolean;
|
||||
pendingAddUrls: string;
|
||||
@@ -904,6 +909,84 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
queueReorderPromises.set(queueId, trackedOperation);
|
||||
return trackedOperation;
|
||||
},
|
||||
moveManyInQueueToPosition: (idOrIds, queueId, targetIndex) => {
|
||||
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
|
||||
if (ids.length === 0) return Promise.resolve();
|
||||
|
||||
// Dragging must be one serialized, atomic queue operation. This prevents
|
||||
// a second drag or a keyboard move from calculating against stale order
|
||||
// while the backend is still applying the first drop.
|
||||
const previousOperation = queueReorderPromises.get(queueId) ?? Promise.resolve();
|
||||
const operation = previousOperation.catch(() => undefined).then(async () => {
|
||||
const allDownloads = get().downloads;
|
||||
const queueItems = queueItemsForReordering(allDownloads, queueId);
|
||||
const selectedItems = queueItems.filter(item => ids.includes(item.id));
|
||||
if (selectedItems.length === 0) return;
|
||||
|
||||
const selectedIds = new Set(selectedItems.map(item => item.id));
|
||||
const previousPositions = new Map([
|
||||
...activeQueueItems(allDownloads, queueId),
|
||||
...queueItems
|
||||
].map(item => [item.id, item.queuePosition]));
|
||||
const reordered = moveSelectedBlockToIndex(queueItems, selectedIds, targetIndex);
|
||||
set(state => ({ downloads: applyQueueOrder(state.downloads, queueId, reordered) }));
|
||||
|
||||
const registeredIdsToMove = selectedItems
|
||||
.filter(item => get().backendRegisteredIds.has(item.id))
|
||||
.map(item => item.id);
|
||||
if (registeredIdsToMove.length === 0) return;
|
||||
|
||||
// Staged rows are deliberately not registered with the backend. Convert
|
||||
// the desired local order to a registered-only target before IPC so a
|
||||
// staged row never shifts the backend insertion index.
|
||||
const registeredItems = queueItems.filter(item => get().backendRegisteredIds.has(item.id));
|
||||
const registeredSelectedIds = new Set(registeredIdsToMove);
|
||||
const registeredDesiredOrder = reordered.filter(item => get().backendRegisteredIds.has(item.id));
|
||||
const backendTargetIndex = targetIndexForDesiredOrder(
|
||||
registeredItems,
|
||||
registeredSelectedIds,
|
||||
registeredDesiredOrder
|
||||
);
|
||||
|
||||
try {
|
||||
const order = await invoke('move_many_in_queue', {
|
||||
ids: registeredIdsToMove,
|
||||
queueId,
|
||||
direction: 'up',
|
||||
targetIndex: backendTargetIndex
|
||||
}) as string[];
|
||||
if (Array.isArray(order)) {
|
||||
const globalOrder = await invoke('get_pending_order', { queueId: null })
|
||||
.catch(() => null) as string[] | null;
|
||||
set(state => ({
|
||||
pendingOrder: Array.isArray(globalOrder)
|
||||
? globalOrder
|
||||
: [
|
||||
...state.pendingOrder.filter(id => !order.includes(id)),
|
||||
...order
|
||||
]
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to move queue block to position:", error);
|
||||
// The backend operation is atomic. Restore only queue positions so a
|
||||
// progress/state event received while the RPC was in flight survives.
|
||||
set(state => ({
|
||||
downloads: state.downloads.map(download => previousPositions.has(download.id)
|
||||
? { ...download, queuePosition: previousPositions.get(download.id) }
|
||||
: download)
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const trackedOperation = operation.finally(() => {
|
||||
if (queueReorderPromises.get(queueId) === trackedOperation) {
|
||||
queueReorderPromises.delete(queueId);
|
||||
}
|
||||
});
|
||||
queueReorderPromises.set(queueId, trackedOperation);
|
||||
return trackedOperation;
|
||||
},
|
||||
removeFromQueue: async (id) => {
|
||||
try {
|
||||
await invoke('remove_from_queue', { id });
|
||||
|
||||
Reference in New Issue
Block a user