From 0cda683142a51fdd3b9bfe4eb1cf6e5197203dcf Mon Sep 17 00:00:00 2001 From: Yash Sangwan Date: Sat, 29 Aug 2026 20:24:28 +0530 Subject: [PATCH 1/4] fix: let a failed download be dismissed instead of needing a page reload An errored download row had no way out: the store gave completed and cancelled rows a linger but had no case for error, the top right card rendered its button only while a transfer was running, and the bottom right panel never mapped download to a remove action in either its rows or its close button. --- .changeset/stuck-failed-download-rows.md | 31 ++++++++++++ .../ui/download-progress-manager.tsx | 46 ++++++++++++------ .../features/dashboard/hooks/use-download.ts | 9 +++- .../stores/use-download-store.test.ts | 21 +++++++++ .../dashboard/stores/use-download-store.ts | 11 +++++ .../upload/components/operations-modal.tsx | 47 ++++++++++++------- 6 files changed, 133 insertions(+), 32 deletions(-) create mode 100644 .changeset/stuck-failed-download-rows.md diff --git a/.changeset/stuck-failed-download-rows.md b/.changeset/stuck-failed-download-rows.md new file mode 100644 index 00000000..906185fe --- /dev/null +++ b/.changeset/stuck-failed-download-rows.md @@ -0,0 +1,31 @@ +--- +'frontend': patch +--- + +Let a failed download be dismissed instead of needing a page reload + +A download that hit a network error wrote a row into the store that nothing +could ever remove, so both panels showing it were stuck until the page was +reloaded. + +Three things were missing. The store gave `completed` and `cancelled` rows a +delay after which they clear themselves, but had no case for `error`. The +download card in the top right rendered its button only while a transfer was +running, so a failed row had no control on it at all. And the operations panel +in the bottom right mapped `upload` and `delete` to their remove actions and +never handled `download`, in both the row's own remove and the panel's close +button, while the panel only hides once its list is empty. So the close button +did nothing, however many times it was pressed. + +An error now clears itself after 8 seconds, longer than a cancel or a completion +because it carries a reason worth reading, and the button on the card dismisses +the row once there is nothing left to cancel. The panel's close button goes +through the same remove action as the rows, so a kind of operation cannot be +handled in one place and forgotten in the other. + +The three copies of "which statuses count as still running" are now one, and it +knows about downloads. They each listed upload and delete statuses only, so a +running download read as settled. That did not matter while the close button +ignored downloads, but it would have the moment it stopped: the button would +have dropped the row of a transfer still in flight rather than offering to +cancel it. diff --git a/frontend/src/features/dashboard/components/ui/download-progress-manager.tsx b/frontend/src/features/dashboard/components/ui/download-progress-manager.tsx index 2e47a47d..b7d319fb 100644 --- a/frontend/src/features/dashboard/components/ui/download-progress-manager.tsx +++ b/frontend/src/features/dashboard/components/ui/download-progress-manager.tsx @@ -7,7 +7,7 @@ import { cn } from '@/shared/utils/utils'; import { AriaLabel } from '@/shared/components/custom-aria-label'; export const DownloadProgressManager: React.FC = () => { - const { getAllDownloads, cancelDownload } = useDownloadList(); + const { getAllDownloads, cancelDownload, removeDownload } = useDownloadList(); const downloads = getAllDownloads(); if (downloads.length === 0) return null; @@ -19,6 +19,7 @@ export const DownloadProgressManager: React.FC = () => { key={download.fileId} download={download} onCancel={() => cancelDownload(download.fileId)} + onDismiss={() => removeDownload(download.fileId)} /> ))} @@ -35,9 +36,14 @@ interface DownloadProgressItemProps { queuePosition?: number; }; onCancel: () => void; + onDismiss: () => void; } -const DownloadProgressItem: React.FC = ({ download, onCancel }) => { +const DownloadProgressItem: React.FC = ({ + download, + onCancel, + onDismiss, +}) => { const getStatusIcon = () => { switch (download.status) { case 'completed': @@ -70,11 +76,17 @@ const DownloadProgressItem: React.FC = ({ download, o } }; - const shouldShowProgress = + /** + * Still moving, so the button aborts it. Otherwise the transfer is over one + * way or another and the button just clears the row away. + */ + const isRunning = download.status === 'downloading' || download.status === 'pending' || download.status === 'queued'; + const shouldShowProgress = isRunning; + return (
= ({ download, o
)} - {(download.status === 'downloading' || - download.status === 'pending' || - download.status === 'queued') && ( - - - - )} + {/* Always offered. The button used to render only while a download was + running, so a row that failed had no control on it at all and the + only way to be rid of it was to reload the page. */} + + + ); diff --git a/frontend/src/features/dashboard/hooks/use-download.ts b/frontend/src/features/dashboard/hooks/use-download.ts index 7e81a5f9..765429cd 100644 --- a/frontend/src/features/dashboard/hooks/use-download.ts +++ b/frontend/src/features/dashboard/hooks/use-download.ts @@ -109,6 +109,13 @@ export const useIsFileDownloading = (fileId: string): boolean => export const useDownloadList = () => { const downloads = useDownloadStore((state) => state.downloads); const { cancelDownload } = useDownloadActions(); + /** + * Drops a settled row without touching the transfer, which is what a failed + * download needs: cancelling one is a no-op because there is nothing left in + * flight to abort, so the panels had no way to clear it. Selecting the action + * on its own is free - zustand action identities are stable. + */ + const removeDownload = useDownloadStore((state) => state.removeDownload); const downloadProgress = useMemo(() => Array.from(downloads.values()), [downloads]); const getAllDownloads = useCallback( @@ -116,5 +123,5 @@ export const useDownloadList = () => { [downloadProgress] ); - return { downloadProgress, getAllDownloads, cancelDownload }; + return { downloadProgress, getAllDownloads, cancelDownload, removeDownload }; }; diff --git a/frontend/src/features/dashboard/stores/use-download-store.test.ts b/frontend/src/features/dashboard/stores/use-download-store.test.ts index 8c08d0fc..e6a36316 100644 --- a/frontend/src/features/dashboard/stores/use-download-store.test.ts +++ b/frontend/src/features/dashboard/stores/use-download-store.test.ts @@ -204,6 +204,27 @@ describe('startDownload', () => { expect(store().downloads.has('file-1')).toBe(false); }); + it('clears a failed download, and lets it linger longest', async () => { + vi.useFakeTimers(); + const service = fakeService(); + service.downloadFile.mockImplementation(async (_f, opts) => { + opts.onProgress(progress({ status: 'error', error: 'Network error' })); + }); + createDownloadServiceMock.mockReturnValue(service as never); + + await store().startDownload(apiA, file); + + // A failure has a reason worth reading, so it outstays a cancel and a + // completion. It used to outstay the page: there was no linger for 'error' + // at all, and no button on either panel would remove the row by hand. + expect(store().downloads.has('file-1')).toBe(true); + vi.advanceTimersByTime(3000); + expect(store().downloads.has('file-1')).toBe(true); + + vi.advanceTimersByTime(5000); + expect(store().downloads.has('file-1')).toBe(false); + }); + it('does not schedule removal for ordinary progress updates', async () => { vi.useFakeTimers(); const service = fakeService(); diff --git a/frontend/src/features/dashboard/stores/use-download-store.ts b/frontend/src/features/dashboard/stores/use-download-store.ts index f8263b51..7e58c44a 100644 --- a/frontend/src/features/dashboard/stores/use-download-store.ts +++ b/frontend/src/features/dashboard/stores/use-download-store.ts @@ -22,6 +22,14 @@ import type { FileItem } from '../types/file'; /** How long a finished row lingers before it clears itself from the list. */ const COMPLETED_LINGER_MS = 3000; const CANCELLED_LINGER_MS = 2000; +/** + * Longer than the other two, because a failure carries a reason worth reading. + * + * It does still leave. Errors had no linger at all, so a download that hit a + * network problem stayed in the list until the page was reloaded, and neither + * panel showing it offered a way to dismiss it by hand either. + */ +const ERROR_LINGER_MS = 8000; interface DownloadState { downloads: Map; @@ -86,6 +94,9 @@ export const useDownloadStore = create((set, get) => ({ if (progress.status === 'cancelled') { setTimeout(() => removeDownload(file.id), CANCELLED_LINGER_MS); } + if (progress.status === 'error') { + setTimeout(() => removeDownload(file.id), ERROR_LINGER_MS); + } }, onComplete: (fileId) => { setTimeout(() => removeDownload(fileId), COMPLETED_LINGER_MS); diff --git a/frontend/src/features/upload/components/operations-modal.tsx b/frontend/src/features/upload/components/operations-modal.tsx index 05921b8d..842bb23e 100644 --- a/frontend/src/features/upload/components/operations-modal.tsx +++ b/frontend/src/features/upload/components/operations-modal.tsx @@ -32,6 +32,20 @@ interface OperationItem { queuePosition?: number; } +/** + * The statuses that mean an operation is still going, for every kind of it. + * + * Uploads report `uploading`, deletes `deleting`, downloads `downloading` or + * `pending`, and any of the three can sit in `queued`. The three places that + * asked this question each carried their own list of upload and delete statuses + * only, so a running download read as settled - which mattered once the close + * button below learned to remove downloads, because it would otherwise have + * dropped the row of a transfer that was still in flight. + */ +const ACTIVE_STATUSES = ['uploading', 'deleting', 'downloading', 'pending', 'queued']; + +const isActiveOperation = (op: { status: string }) => ACTIVE_STATUSES.includes(op.status); + // Upload speed tracking for better time estimation interface SpeedTracker { speeds: number[]; // bytes per second samples @@ -155,7 +169,7 @@ export const OperationsModal: React.FC = () => { // Only the oldest pending duplicate is shown; answering it reveals the next. const currentDuplicate = duplicateQueue[0] ?? null; - const { getAllDownloads, cancelDownload } = useDownloadList(); + const { getAllDownloads, cancelDownload, removeDownload } = useDownloadList(); const downloads = getAllDownloads(); const [isExpanded, setIsExpanded] = useState(true); const [hoveredItem, setHoveredItem] = useState(null); @@ -244,9 +258,15 @@ export const OperationsModal: React.FC = () => { removeUpload(itemId); } else if (operationType === 'delete') { removeDeleteOperation(itemId); + } else if (operationType === 'download') { + // The branch this was missing. A download that failed could not be + // removed by the row's own button or by the panel's close button, and + // the panel only hides once the list is empty - so it stayed up, and + // pressing the X did nothing at all, until the page was reloaded. + removeDownload(itemId); } }, - [removeUpload, removeDeleteOperation] + [removeUpload, removeDeleteOperation, removeDownload] ); const pauseOperation = useCallback( @@ -646,20 +666,15 @@ export const OperationsModal: React.FC = () => { - - - - ); -}; diff --git a/frontend/src/features/dashboard/components/ui/index.ts b/frontend/src/features/dashboard/components/ui/index.ts index 094c7052..f9c19182 100644 --- a/frontend/src/features/dashboard/components/ui/index.ts +++ b/frontend/src/features/dashboard/components/ui/index.ts @@ -14,8 +14,6 @@ export { CreateMenu } from './menus/create-menu'; export { FileOverflowMenu } from './menus/file-overflow-menu'; export { FolderOverflowMenu } from './menus/folder-overflow-menu'; -export { DownloadProgressManager } from './download-progress-manager'; - export { SuggestedSectionSkeleton, DashboardLoading } from './skeletons/dashboard-skeleton'; export { FileSkeletonGrid, diff --git a/frontend/src/features/dashboard/hooks/use-download.ts b/frontend/src/features/dashboard/hooks/use-download.ts index 765429cd..f8940177 100644 --- a/frontend/src/features/dashboard/hooks/use-download.ts +++ b/frontend/src/features/dashboard/hooks/use-download.ts @@ -40,18 +40,11 @@ async function withConcurrency( * row in the listing on every chunk of every download. */ export const useDownloadActions = () => { - const { error: showError, info } = useNotification(); + const { error: showError } = useNotification(); const { apiS3 } = useAuthGuard(); const startDownload = useDownloadStore((state) => state.startDownload); - const cancelInStore = useDownloadStore((state) => state.cancelDownload); - - const handleError = useCallback( - (_fileId: string, error: string) => { - showError(error); - }, - [showError] - ); + const cancelDownload = useDownloadStore((state) => state.cancelDownload); const downloadFile = useCallback( async (file: FileItem) => { @@ -61,30 +54,31 @@ export const useDownloadActions = () => { if (!apiS3) return; try { - await startDownload(apiS3, file, { onError: handleError }); + await startDownload(apiS3, file); } catch (error) { + /** + * The last download toast, and the only one worth keeping. + * + * Starting, cancelling and failing are all written to the operations + * card as they happen, so a toast saying the same thing was the same + * news twice, in two corners of the screen at once. This branch is + * different: it runs only if the download threw before the service + * could record it, which means there is no row on the card to read and + * staying quiet here would lose the failure altogether. + */ showError(`Failed to download ${file.name}, ${error}`); } }, - [apiS3, startDownload, handleError, showError] + [apiS3, startDownload, showError] ); const downloadMultipleFiles = useCallback( async (files: FileItem[]) => { if (!apiS3 || files.length === 0) return; - info(`Downloading ${files.length} file${files.length > 1 ? 's' : ''}...`); await withConcurrency(files, MULTI_DOWNLOAD_CONCURRENCY, downloadFile); }, - [apiS3, downloadFile, info] - ); - - const cancelDownload = useCallback( - (fileId: string) => { - cancelInStore(fileId); - info('Download cancelled'); - }, - [cancelInStore, info] + [apiS3, downloadFile] ); return { downloadFile, downloadMultipleFiles, cancelDownload }; diff --git a/frontend/src/features/upload/components/operation-row.tsx b/frontend/src/features/upload/components/operation-row.tsx index 5c80efb7..99f1ef47 100644 --- a/frontend/src/features/upload/components/operation-row.tsx +++ b/frontend/src/features/upload/components/operation-row.tsx @@ -167,8 +167,11 @@ const OperationRowInner: React.FC = ({

)} {status === 'downloading' && ( + // With the percentage, which is the one thing the separate download + // card said that this row did not. The ring beside it has always + // shown the same figure as a shape; this puts a number on it.

- Downloading... + Downloading... {Math.round(progress)}%

)} {status === 'pending' && ( From 6bc44638e91da2d464455ee9228dd038e702fc7d Mon Sep 17 00:00:00 2001 From: Yash Sangwan Date: Sat, 29 Aug 2026 21:01:44 +0530 Subject: [PATCH 3/4] fix: give a failed row a dismiss button, and stop a linger clearing a retry Review of the branch turned up two real gaps. The row drew its trailing control for active, completed and cancelled operations only, so anything that had failed fell through to nothing and had no button at all. And the linger timers removed by file id unconditionally, so retrying inside the window deleted the row of the transfer now running. --- .changeset/download-progress-in-one-place.md | 41 +++++++++++++++++++ .changeset/one-place-for-download-progress.md | 23 ----------- .changeset/stuck-failed-download-rows.md | 31 -------------- .../features/dashboard/hooks/use-download.ts | 11 +++-- .../stores/use-download-store.test.ts | 22 ++++++++++ .../dashboard/stores/use-download-store.ts | 27 ++++++++++-- .../upload/components/operation-row.tsx | 8 +++- 7 files changed, 101 insertions(+), 62 deletions(-) create mode 100644 .changeset/download-progress-in-one-place.md delete mode 100644 .changeset/one-place-for-download-progress.md delete mode 100644 .changeset/stuck-failed-download-rows.md diff --git a/.changeset/download-progress-in-one-place.md b/.changeset/download-progress-in-one-place.md new file mode 100644 index 00000000..1b31c361 --- /dev/null +++ b/.changeset/download-progress-in-one-place.md @@ -0,0 +1,41 @@ +--- +'frontend': patch +--- + +Report a download in one place, and let a failed one be dismissed + +A download that hit a network error left a row nothing could remove, so the +panel showing it stayed up until the page was reloaded. The store gave +`completed` and `cancelled` rows a delay after which they clear themselves but +had no case for `error`; the operations panel mapped `upload` and `delete` to +their remove actions and never handled `download`, in both the row's remove and +the panel's close button; and the panel only hides once its list is empty. So +the close button did nothing, however many times it was pressed. + +A failed row also had no button on it at all. The row drew its trailing control +for active, then completed, then cancelled operations, and anything that had +gone wrong fell past all three to nothing - so the one row a reader most wants +rid of was the only one with nothing to press. That was true of failed uploads +and deletes too, and is fixed for all three. + +An error now clears itself after 8 seconds, longer than a cancel or a completion +because it carries a reason worth reading. Those timers check that the row is +still the one they were set for before removing it: a retry reuses the file id, +so a leftover timer would otherwise delete the row of the transfer now running, +and the file would read as not downloading while it was being fetched. + +The same download was also announcing itself three times over: a toast, a card +of its own in the top right, and a row on the operations card in the bottom +right. The operations card is the one that stays, since it already carried every +status the separate card did, down to the queue position and the reason for a +failure. The separate card is gone, along with the toasts for starting, +cancelling and failing. One toast is left for a download that throws before the +service can record it, which leaves no row to read. The row now shows the +percentage while downloading, the only thing the removed card said that it did +not. + +The three copies of "which statuses count as still running" are now one, and it +knows about downloads. Each listed upload and delete statuses only, so a running +download read as settled - which would have mattered the moment the close button +learned to remove downloads, since it would have dropped the row of a transfer +still in flight rather than offering to cancel it. diff --git a/.changeset/one-place-for-download-progress.md b/.changeset/one-place-for-download-progress.md deleted file mode 100644 index 1b0e3957..00000000 --- a/.changeset/one-place-for-download-progress.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'frontend': patch ---- - -Report a download in one place instead of three - -A single download announced itself three times over: a toast, a card of its own -in the top right corner, and a row on the operations card in the bottom right. -All three said the same thing, and the two cards sat in opposite corners showing -the same transfer at the same time. - -The operations card is the one that stays. It already listed downloads beside -uploads and deletes, with the same file icon, the same progress ring drawn in -its own blue, the same cancel on hover, and it already carried every status the -separate card did, down to the queue position and the reason a download failed. - -So the separate download card is gone, and with it the toasts for starting, -cancelling and failing. One toast is left, for a download that throws before the -service can record it: there is no row on the card in that case, and staying -quiet would lose the failure entirely. - -The row now shows the percentage while downloading, which is the only thing the -removed card said that the row did not. Uploads keep the display they had. diff --git a/.changeset/stuck-failed-download-rows.md b/.changeset/stuck-failed-download-rows.md deleted file mode 100644 index 906185fe..00000000 --- a/.changeset/stuck-failed-download-rows.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'frontend': patch ---- - -Let a failed download be dismissed instead of needing a page reload - -A download that hit a network error wrote a row into the store that nothing -could ever remove, so both panels showing it were stuck until the page was -reloaded. - -Three things were missing. The store gave `completed` and `cancelled` rows a -delay after which they clear themselves, but had no case for `error`. The -download card in the top right rendered its button only while a transfer was -running, so a failed row had no control on it at all. And the operations panel -in the bottom right mapped `upload` and `delete` to their remove actions and -never handled `download`, in both the row's own remove and the panel's close -button, while the panel only hides once its list is empty. So the close button -did nothing, however many times it was pressed. - -An error now clears itself after 8 seconds, longer than a cancel or a completion -because it carries a reason worth reading, and the button on the card dismisses -the row once there is nothing left to cancel. The panel's close button goes -through the same remove action as the rows, so a kind of operation cannot be -handled in one place and forgotten in the other. - -The three copies of "which statuses count as still running" are now one, and it -knows about downloads. They each listed upload and delete statuses only, so a -running download read as settled. That did not matter while the close button -ignored downloads, but it would have the moment it stopped: the button would -have dropped the row of a transfer still in flight rather than offering to -cancel it. diff --git a/frontend/src/features/dashboard/hooks/use-download.ts b/frontend/src/features/dashboard/hooks/use-download.ts index f8940177..db9acdef 100644 --- a/frontend/src/features/dashboard/hooks/use-download.ts +++ b/frontend/src/features/dashboard/hooks/use-download.ts @@ -61,10 +61,13 @@ export const useDownloadActions = () => { * * Starting, cancelling and failing are all written to the operations * card as they happen, so a toast saying the same thing was the same - * news twice, in two corners of the screen at once. This branch is - * different: it runs only if the download threw before the service - * could record it, which means there is no row on the card to read and - * staying quiet here would lose the failure altogether. + * news twice, in two corners of the screen at once. + * + * This branch is different, and rare: the service reports its own + * failures through `onProgress` and never rejects, so the only way here + * is something throwing before it takes over - building the service for + * a new provider, say. That leaves no row on the card to read, and it is + * the one failure that would otherwise pass in silence. */ showError(`Failed to download ${file.name}, ${error}`); } diff --git a/frontend/src/features/dashboard/stores/use-download-store.test.ts b/frontend/src/features/dashboard/stores/use-download-store.test.ts index e6a36316..cc32c566 100644 --- a/frontend/src/features/dashboard/stores/use-download-store.test.ts +++ b/frontend/src/features/dashboard/stores/use-download-store.test.ts @@ -225,6 +225,28 @@ describe('startDownload', () => { expect(store().downloads.has('file-1')).toBe(false); }); + it('does not let a settled row clear the retry that reuses its id', async () => { + vi.useFakeTimers(); + const service = fakeService(); + service.downloadFile.mockImplementation(async (_f, opts) => { + opts.onProgress(progress({ status: 'error', error: 'Network error' })); + }); + createDownloadServiceMock.mockReturnValue(service as never); + + await store().startDownload(apiA, file); + + // Retried while the failed row is still on screen, which is exactly what + // eight seconds of linger invites the reader to do. + vi.advanceTimersByTime(2000); + store().setProgress(progress({ status: 'downloading', progress: 10 })); + + // The timer left over from the failure must not take the live row with it: + // the panel would drop a transfer still in flight, and the file would read + // as not downloading while it was. + vi.advanceTimersByTime(10_000); + expect(store().downloads.get('file-1')?.status).toBe('downloading'); + }); + it('does not schedule removal for ordinary progress updates', async () => { vi.useFakeTimers(); const service = fakeService(); diff --git a/frontend/src/features/dashboard/stores/use-download-store.ts b/frontend/src/features/dashboard/stores/use-download-store.ts index 7e58c44a..b0d41413 100644 --- a/frontend/src/features/dashboard/stores/use-download-store.ts +++ b/frontend/src/features/dashboard/stores/use-download-store.ts @@ -88,18 +88,39 @@ export const useDownloadStore = create((set, get) => ({ startDownload: async (api, file, handlers) => { const { getService, setProgress, removeDownload } = get(); + /** + * Clears the row when its linger is up, unless it is no longer the row this + * timer was set for. + * + * A retry reuses the file id, so an unguarded timer left over from an + * attempt that already settled would delete the row of the one now running: + * the panel would drop a live transfer, `isDownloading` would go false for + * a file still being fetched, and starting it a third time would overwrite + * the abort controller of the second, leaving that one uncancellable. Easy + * to hit now that a failure lingers eight seconds and can be retried at + * once, but the shorter delays could always race the same way. + */ + const clearWhenSettled = ( + fileId: string, + settledAs: DownloadProgress['status'], + delay: number + ) => + setTimeout(() => { + if (get().downloads.get(fileId)?.status === settledAs) removeDownload(fileId); + }, delay); + await getService(api).downloadFile(file, { onProgress: (progress) => { setProgress(progress); if (progress.status === 'cancelled') { - setTimeout(() => removeDownload(file.id), CANCELLED_LINGER_MS); + clearWhenSettled(file.id, 'cancelled', CANCELLED_LINGER_MS); } if (progress.status === 'error') { - setTimeout(() => removeDownload(file.id), ERROR_LINGER_MS); + clearWhenSettled(file.id, 'error', ERROR_LINGER_MS); } }, onComplete: (fileId) => { - setTimeout(() => removeDownload(fileId), COMPLETED_LINGER_MS); + clearWhenSettled(fileId, 'completed', COMPLETED_LINGER_MS); }, onError: handlers?.onError, }); diff --git a/frontend/src/features/upload/components/operation-row.tsx b/frontend/src/features/upload/components/operation-row.tsx index 99f1ef47..45a6269e 100644 --- a/frontend/src/features/upload/components/operation-row.tsx +++ b/frontend/src/features/upload/components/operation-row.tsx @@ -378,7 +378,13 @@ const OperationRowInner: React.FC = ({ - ) : status === 'cancelled' ? ( + ) : status === 'cancelled' || status === 'error' || status === 'failed' ? ( + // A settled row that did not succeed, which until now meant no + // control at all: the chain ran active, then completed, then + // cancelled, and anything that had gone wrong fell past all three + // to null. So the one row a reader most wants rid of was the only + // one with nothing to press. ('failed' is what a delete reports; + // uploads and downloads say 'error'.)