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) => {