From e60840c0e619c6ad45dcbbbc71720a7bc6797ca3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:40:32 +0200 Subject: [PATCH] fix(files): say the upload was canceled instead of counting it as done Cancelling an upload ended the loop like a normal finish, and the Files app then reported every picked file as uploaded. Uploads now return how many files went up and whether they were cancelled, so a cancel shows "Upload canceled" (with the count of files that made it, if any). Folder uploads also passed no cancel signal or progress callback to the blob upload, so Cancel did not stop the file in flight and the progress line stood still; they now behave like file uploads. --- components/files/files-app.tsx | 20 +++++++++----- locales/en/common.json | 2 ++ stores/__tests__/file-store.test.ts | 42 +++++++++++++++++++++++++++++ stores/file-store.ts | 28 +++++++++++++++---- 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index f7ac1e5f4..9e07ecb22 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -7,7 +7,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; import { useAccountStore } from "@/stores/account-store"; import { useEmailStore } from "@/stores/email-store"; -import { useFileStore, resourceServerRef } from "@/stores/file-store"; +import { useFileStore, resourceServerRef, type UploadResult } from "@/stores/file-store"; import { toast } from "@/stores/toast-store"; import { cn, formatFileSize } from "@/lib/utils"; import { NavigationRail } from "@/components/layout/navigation-rail"; @@ -301,6 +301,14 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const maxSizeUpload = client?.getMaxSizeUpload() || 0; + const showUploadResult = useCallback(({ uploaded, canceled }: UploadResult) => { + if (canceled) { + toast.info(t("upload_canceled"), uploaded > 0 ? t("upload_canceled_partial", { count: uploaded }) : undefined); + } else if (uploaded > 0) { + toast.success(t("upload_success", { count: uploaded })); + } + }, [t]); + const handleUploadFiles = useCallback(async (files: File[]) => { if (maxSizeUpload > 0) { const oversized = files.filter(f => f.size > maxSizeUpload); @@ -311,13 +319,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } if (files.length === 0) return; try { - await uploadFiles(files); - toast.success(t("upload_success", { count: files.length })); + showUploadResult(await uploadFiles(files)); } catch (err) { console.error("Failed to upload files:", err); toast.error(t("upload_error")); } - }, [uploadFiles, t, maxSizeUpload]); + }, [uploadFiles, t, maxSizeUpload, showUploadResult]); const handleUploadFolder = useCallback(async (files: File[]) => { if (maxSizeUpload > 0) { @@ -329,13 +336,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } if (files.length === 0) return; try { - await uploadFolder(files); - toast.success(t("upload_success", { count: files.length })); + showUploadResult(await uploadFolder(files)); } catch (err) { console.error("Failed to upload folder:", err); toast.error(t("upload_error")); } - }, [uploadFolder, t, maxSizeUpload]); + }, [uploadFolder, t, maxSizeUpload, showUploadResult]); const handleDelete = useCallback(async (name: string) => { const confirmed = await confirmDialog({ diff --git a/locales/en/common.json b/locales/en/common.json index 120b2dc2d..c1c8fffbd 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3623,6 +3623,8 @@ "no_accounts": "No connected accounts.", "uploading": "Uploading...", "upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}", + "upload_canceled": "Upload canceled", + "upload_canceled_partial": "{count, plural, one {1 file was} other {# files were}} uploaded before you canceled", "upload_error": "Failed to upload file", "create_folder_success": "Folder created", "create_folder_error": "Failed to create folder", diff --git a/stores/__tests__/file-store.test.ts b/stores/__tests__/file-store.test.ts index b4bef8362..b43228857 100644 --- a/stores/__tests__/file-store.test.ts +++ b/stores/__tests__/file-store.test.ts @@ -504,3 +504,45 @@ describe('file-store folder tree and moves', () => { expect(await useFileStore.getState().revealNode('gone')).toBeNull(); }); }); + +describe('file-store upload result', () => { + beforeEach(() => { + useFileStore.setState({ + client: null, + currentParentId: null, + currentPath: '/', + pathStack: [{ id: null, name: '' }], + resources: [], + uploadProgress: null, + uploadAbortController: null, + }); + }); + + it('reports every file when the upload runs to the end', async () => { + const client = makeMockClient(); + useFileStore.getState().initClient(client); + const result = await useFileStore.getState().uploadFiles([ + new File(['a'], 'a.txt'), new File(['b'], 'b.txt'), + ]); + expect(result).toEqual({ uploaded: 2, canceled: false }); + }); + + it('reports a cancel and only the files that made it before it', async () => { + const client = makeMockClient(); + const upload = client.uploadBlob.bind(client); + let calls = 0; + client.uploadBlob = (async (file: File) => { + calls++; + // Cancel while the second file is on its way up. + if (calls === 2) useFileStore.getState().cancelUpload(); + return upload(file); + }) as typeof client.uploadBlob; + useFileStore.getState().initClient(client); + + const result = await useFileStore.getState().uploadFiles([ + new File(['a'], 'a.txt'), new File(['b'], 'b.txt'), new File(['c'], 'c.txt'), + ]); + expect(result).toEqual({ uploaded: 1, canceled: true }); + expect(client._nodes().map(n => n.name)).toEqual(['a.txt']); + }); +}); diff --git a/stores/file-store.ts b/stores/file-store.ts index 14b9403dc..99e3cb227 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -40,6 +40,12 @@ interface ClipboardState { sourcePath: string; } +/** How a batch upload ended: files that made it, and whether the user cancelled. */ +export interface UploadResult { + uploaded: number; + canceled: boolean; +} + interface UndoAction { type: 'rename' | 'move'; entries: { id: string; from: Partial>; to: Partial> }[]; @@ -93,8 +99,8 @@ interface FileState { refresh: () => Promise; createDirectory: (name: string) => Promise; uploadFile: (file: File) => Promise; - uploadFiles: (files: File[]) => Promise; - uploadFolder: (files: File[]) => Promise; + uploadFiles: (files: File[]) => Promise; + uploadFolder: (files: File[]) => Promise; cancelUpload: () => void; deleteResource: (name: string) => Promise; deleteResources: (names: string[]) => Promise; @@ -644,7 +650,8 @@ export const useFileStore = create((set, get) => ({ uploadFiles: async (files: File[]) => { const { client, currentParentId, resources } = get(); - if (!client) return; + if (!client) return { uploaded: 0, canceled: false }; + let uploaded = 0; const abortController = new AbortController(); set({ uploadAbortController: abortController }); @@ -669,6 +676,7 @@ export const useFileStore = create((set, get) => ({ if (abortController.signal.aborted) break; set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); await client.createFileNode(uniqueName, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId); + uploaded++; } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') break; set({ uploadProgress: null, uploadAbortController: null }); @@ -677,6 +685,7 @@ export const useFileStore = create((set, get) => ({ } set({ uploadProgress: null, uploadAbortController: null }); await get().refresh(); + return { uploaded, canceled: abortController.signal.aborted }; }, cancelUpload: () => { @@ -689,7 +698,8 @@ export const useFileStore = create((set, get) => ({ uploadFolder: async (files: File[]) => { const { client, currentParentId } = get(); - if (!client || files.length === 0) return; + if (!client || files.length === 0) return { uploaded: 0, canceled: false }; + let uploaded = 0; const abortController = new AbortController(); set({ uploadAbortController: abortController }); @@ -739,10 +749,17 @@ export const useFileStore = create((set, get) => ({ set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } }); try { - const { blobId, type } = await client.uploadBlob(file); + const idx = i; + const { blobId, type } = await client.uploadBlob(file, { + signal: abortController.signal, + onProgress: (loaded, total) => { + set({ uploadProgress: { name: relativePath, loaded, total, current: idx + 1, totalFiles } }); + }, + }); if (abortController.signal.aborted) break; set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId); + uploaded++; } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') break; set({ uploadProgress: null, uploadAbortController: null }); @@ -751,6 +768,7 @@ export const useFileStore = create((set, get) => ({ } set({ uploadProgress: null, uploadAbortController: null }); await get().refresh(); + return { uploaded, canceled: abortController.signal.aborted }; }, deleteResource: async (name: string) => {