Skip to content

Commit 8828e1e

Browse files
committed
improvement(files): surface archive download errors in place
Bulk and folder downloads navigated to the API route, so any rejection replaced the Files page with the raw JSON error body. Single-file download already fetched and saved the blob, so the two paths had diverged. That was survivable when the only failures were "too many files" and "too large"; resolving rendered documents adds a 409 for a still-compiling artifact, which is reachable in normal use. Both archive paths now fetch the zip and show the server's message as a toast — the route writes that copy for a person, so it is worth surfacing rather than discarding. Single-file download shows its error too instead of only logging it.
1 parent b67b55a commit 8828e1e

2 files changed

Lines changed: 68 additions & 15 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { usePostHog } from 'posthog-js/react'
2727
import { getDocumentIcon } from '@/components/icons/document-icons'
2828
import { useLimitUpgradeToast } from '@/lib/billing/client'
2929
import { captureEvent } from '@/lib/posthog/client'
30-
import { triggerFileDownload } from '@/lib/uploads/client/download'
30+
import { triggerArchiveDownload, triggerFileDownload } from '@/lib/uploads/client/download'
3131
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
3232
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
3333
import {
@@ -951,6 +951,7 @@ export function Files() {
951951
})
952952
} catch (err) {
953953
logger.error('Failed to download file:', err)
954+
toast.error(toError(err).message)
954955
}
955956
},
956957
[workspaceId]
@@ -1071,7 +1072,7 @@ export function Files() {
10711072
setShowDeleteConfirm(true)
10721073
}, [selectedFileIds, selectedFolderIds, files, folders])
10731074

1074-
const handleBulkDownload = useCallback(() => {
1075+
const handleBulkDownload = useCallback(async () => {
10751076
const selectedFiles = files.filter((file) => selectedFileIds.includes(file.id))
10761077
if (selectedFiles.length === 1 && selectedFolderIds.length === 0) {
10771078
handleDownload(selectedFiles[0])
@@ -1088,7 +1089,14 @@ export function Files() {
10881089
is_bulk: true,
10891090
file_count: selectedFileIds.length + selectedFolderIds.length,
10901091
})
1091-
window.location.href = `/api/workspaces/${workspaceId}/files/download?${query.toString()}`
1092+
try {
1093+
await triggerArchiveDownload(
1094+
`/api/workspaces/${workspaceId}/files/download?${query.toString()}`
1095+
)
1096+
} catch (err) {
1097+
logger.error('Failed to download selection:', err)
1098+
toast.error(toError(err).message)
1099+
}
10921100
}, [selectedFileIds, selectedFolderIds, files, handleDownload, workspaceId])
10931101

10941102
const fileDetailBreadcrumbs = useMemo(() => {
@@ -1285,8 +1293,14 @@ export function Files() {
12851293
return
12861294
}
12871295
if (item.kind === 'folder') {
1288-
window.location.href = `/api/workspaces/${workspaceId}/files/download?folderIds=${encodeURIComponent(item.folder.id)}`
1296+
const folderId = item.folder.id
12891297
closeContextMenu()
1298+
triggerArchiveDownload(
1299+
`/api/workspaces/${workspaceId}/files/download?folderIds=${encodeURIComponent(folderId)}`
1300+
).catch((err) => {
1301+
logger.error('Failed to download folder:', err)
1302+
toast.error(toError(err).message)
1303+
})
12901304
return
12911305
}
12921306
handleDownload(item.file)
Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
22

3+
/** Hand a fetched blob to the browser as a file save, then release the object URL. */
4+
function saveBlob(blob: Blob, fileName: string): void {
5+
const objectUrl = URL.createObjectURL(blob)
6+
const anchor = document.createElement('a')
7+
anchor.href = objectUrl
8+
anchor.download = fileName
9+
document.body.appendChild(anchor)
10+
anchor.click()
11+
document.body.removeChild(anchor)
12+
URL.revokeObjectURL(objectUrl)
13+
}
14+
15+
function fileNameFromDisposition(response: Response, fallback: string): string {
16+
return response.headers.get('Content-Disposition')?.match(/filename="([^"]+)"/)?.[1] ?? fallback
17+
}
18+
19+
/**
20+
* Read the server's error copy off a failed download so the caller can surface it.
21+
* These routes answer with `{ error }` and the message is written for the user —
22+
* which document is still compiling, which entry is too large.
23+
*/
24+
async function downloadErrorMessage(response: Response, fallback: string): Promise<string> {
25+
try {
26+
const body = await response.json()
27+
return typeof body?.error === 'string' && body.error ? body.error : fallback
28+
} catch {
29+
return fallback
30+
}
31+
}
32+
333
export async function triggerFileDownload(record: WorkspaceFileRecord): Promise<void> {
434
const isMarkdown =
535
record.type === 'text/markdown' ||
@@ -10,17 +40,26 @@ export async function triggerFileDownload(record: WorkspaceFileRecord): Promise<
1040
? `/api/files/export/${encodeURIComponent(record.id)}`
1141
: `/api/files/serve/${encodeURIComponent(record.key)}?context=workspace&t=${Date.now()}`
1242

43+
// boundary-raw-fetch: binary download read as a blob, not a JSON contract response
1344
const response = await fetch(url, { cache: 'no-store' })
14-
if (!response.ok) throw new Error(`Failed to download file: ${response.statusText}`)
45+
if (!response.ok) {
46+
throw new Error(await downloadErrorMessage(response, `Failed to download "${record.name}"`))
47+
}
1548

16-
const blob = await response.blob()
17-
const objectUrl = URL.createObjectURL(blob)
18-
const a = document.createElement('a')
19-
a.href = objectUrl
20-
a.download =
21-
response.headers.get('Content-Disposition')?.match(/filename="([^"]+)"/)?.[1] ?? record.name
22-
document.body.appendChild(a)
23-
a.click()
24-
document.body.removeChild(a)
25-
URL.revokeObjectURL(objectUrl)
49+
saveBlob(await response.blob(), fileNameFromDisposition(response, record.name))
50+
}
51+
52+
/**
53+
* Download a multi-file selection as a zip. Fetched rather than navigated to, so a
54+
* rejection — a document still compiling, an entry too large — surfaces as an error
55+
* the caller can show in place instead of replacing the page with raw JSON.
56+
*/
57+
export async function triggerArchiveDownload(url: string): Promise<void> {
58+
// boundary-raw-fetch: binary zip download read as a blob, not a JSON contract response
59+
const response = await fetch(url, { cache: 'no-store' })
60+
if (!response.ok) {
61+
throw new Error(await downloadErrorMessage(response, 'Failed to download the selected files'))
62+
}
63+
64+
saveBlob(await response.blob(), fileNameFromDisposition(response, 'workspace-files.zip'))
2665
}

0 commit comments

Comments
 (0)