diff --git a/apps/ui/app/pages/downloads.vue b/apps/ui/app/pages/downloads.vue index 43321e1..4257261 100644 --- a/apps/ui/app/pages/downloads.vue +++ b/apps/ui/app/pages/downloads.vue @@ -25,6 +25,13 @@ function confirmationFor(download: DownloadItem, action: DownloadAction): string return null } +async function sendAction(download: DownloadItem, action: DownloadAction) { + if (action === 'delete') + await request<{ ok: true }>(`downloads/${download.id}`, { method: 'DELETE' }) + else + await request<{ download: DownloadItem }>(`downloads/${download.id}/${action}`, { method: 'POST' }) +} + async function runAction(download: DownloadItem, action: DownloadAction) { if (pendingActions.value[download.id]) return @@ -35,10 +42,7 @@ async function runAction(download: DownloadItem, action: DownloadAction) { pendingActions.value[download.id] = action try { - if (action === 'delete') - await request<{ ok: true }>(`downloads/${download.id}`, { method: 'DELETE' }) - else - await request<{ download: DownloadItem }>(`downloads/${download.id}/${action}`, { method: 'POST' }) + await sendAction(download, action) toast.add({ title: action === 'cancel' ? 'Download cancelled' @@ -61,6 +65,90 @@ async function runAction(download: DownloadItem, action: DownloadAction) { } } +// Row selection for bulk actions, keyed by download id (see get-row-id) so it +// survives live updates and paging. +const rowSelection = ref>({}) +const selected = computed(() => (data.value?.downloads ?? []).filter(d => rowSelection.value[String(d.id)])) +const bulkPending = ref() + +const BULK_ACTIONS: { action: DownloadAction, label: string, done: string, icon: string, color: 'neutral' | 'error' | 'primary' }[] = [ + { + action: 'retry', + label: 'Retry', + done: 'Retry started for', + icon: 'i-ph-arrow-clockwise', + color: 'primary', + }, + { + action: 'cancel', + label: 'Cancel', + done: 'Cancelled', + icon: 'i-ph-x-circle', + color: 'neutral', + }, + { + action: 'delete', + label: 'Delete', + done: 'Deleted', + icon: 'i-ph-trash', + color: 'error', + }, +] + +function pluralDownloads(count: number): string { + return count === 1 + ? '1 download' + : `${count} downloads` +} + +// Each action only applies to the selected rows that support it. +function eligibleFor(action: DownloadAction): DownloadItem[] { + return selected.value.filter(d => downloadActionsFor(d).includes(action)) +} + +function toggleSelected(download: DownloadItem, value: boolean | 'indeterminate') { + rowSelection.value = { ...rowSelection.value, [String(download.id)]: value === true } +} + +async function runBulkAction(action: DownloadAction) { + const targets = eligibleFor(action) + if (bulkPending.value || targets.length === 0) + return + const confirmation = action === 'cancel' + ? `Cancel ${pluralDownloads(targets.length)}? Partial files will be kept so the transfers can be retried.` + : action === 'delete' + ? `Delete ${pluralDownloads(targets.length)}? This removes their history and any partial or completed download artifacts.` + : null + // eslint-disable-next-line no-alert -- destructive actions require confirmation + if (confirmation && !window.confirm(confirmation)) + return + + bulkPending.value = action + const results = await Promise.allSettled(targets.map(d => sendAction(d, action))) + bulkPending.value = undefined + + const failures = results.filter(r => r.status === 'rejected') + const done = targets.length - failures.length + // Keep the failed rows selected so they can be retried or inspected. + const next = { ...rowSelection.value } + for (const [index, result] of results.entries()) { + if (result.status === 'fulfilled') + delete next[String(targets[index]!.id)] + } + rowSelection.value = next + + if (done > 0) + toast.add({ title: `${BULK_ACTIONS.find(b => b.action === action)!.done} ${pluralDownloads(done)}`, color: 'success' }) + if (failures.length > 0) { + toast.add({ + title: `Failed to ${action} ${pluralDownloads(failures.length)}`, + description: extractError(failures[0]!.reason), + color: 'error', + }) + } + await refresh() +} + const statusBadge: Record = { downloading: { color: 'primary', label: 'Downloading' }, import_queued: { color: 'warning', label: 'Import queued' }, @@ -167,8 +255,10 @@ const showingTo = computed(() => Math.min(page.value * PAGE_SIZE, total.value)) // Switching filters starts over at page one; a refresh that shrinks the list // clamps the current page so we never strand the user on an empty page. +// It also drops the selection, so a bulk action never hits rows the filters hide. watch([filter, search, peerFilter, codeFilter], () => { page.value = 1 + rowSelection.value = {} }) watch(pageCount, (count) => { if (page.value > count) @@ -190,6 +280,7 @@ function onRowSelect(event: Event, row: { toggleExpanded: () => void }) { } const columns: TableColumn[] = [ + { id: 'select', header: '' }, { id: 'expand', header: '' }, { accessorKey: 'filename', header: 'File' }, { accessorKey: 'peerName', header: 'Peer' }, @@ -322,10 +413,37 @@ const columns: TableColumn[] = [
+ +
+ {{ selected.length }} selected + + +
+