diff --git a/frontend/src/components/editor/file-tree/upload.tsx b/frontend/src/components/editor/file-tree/upload.tsx index 6049cf0ef1e..f1b10271afa 100644 --- a/frontend/src/components/editor/file-tree/upload.tsx +++ b/frontend/src/components/editor/file-tree/upload.tsx @@ -4,6 +4,7 @@ import { type DropzoneOptions, useDropzone } from "react-dropzone"; import { toast } from "@/components/ui/use-toast"; import { useRequestClient } from "@/core/network/requests"; import { serializeBlob } from "@/utils/blob"; +import { withLoadingToast } from "@/utils/download"; import { Logger } from "@/utils/Logger"; import { type FilePath, PathBuilder } from "@/utils/paths"; import { refreshRoot } from "./state"; @@ -40,30 +41,52 @@ export function useFileExplorerUpload(options: DropzoneOptions = {}) { }); }, onDrop: async (acceptedFiles) => { - for (const file of acceptedFiles) { - // We strip the leading slash since File.path can return - // `/path/to/file`. - const filePath = stripLeadingSlash(getPath(file)); - let directoryPath = "" as FilePath; - if (filePath) { - directoryPath = - PathBuilder.guessDeliminator(filePath).dirname(filePath); - } - - // File contents are sent base64-encoded to support arbitrary - // bytes data - // - // get the raw base64-encoded data from a string starting with - // data:*/*;base64, - const base64 = (await serializeBlob(file)).split(",")[1]; - await sendCreateFileOrFolder({ - path: directoryPath, - type: "file", - name: file.name, - contents: base64, - }); + if (acceptedFiles.length === 0) { + return; } - await refreshRoot(); + const isSingle = acceptedFiles.length === 1; + + const loadingTitle = isSingle + ? "Uploading file..." + : "Uploading files..."; + const onFinish = { + title: isSingle + ? "File uploaded" + : `${acceptedFiles.length} files uploaded`, + }; + + await withLoadingToast( + loadingTitle, + async (progress) => { + progress.addTotal(acceptedFiles.length); + for (const file of acceptedFiles) { + // We strip the leading slash since File.path can return + // `/path/to/file`. + const filePath = stripLeadingSlash(getPath(file)); + let directoryPath = "" as FilePath; + if (filePath) { + directoryPath = + PathBuilder.guessDeliminator(filePath).dirname(filePath); + } + + // File contents are sent base64-encoded to support arbitrary + // bytes data + // + // get the raw base64-encoded data from a string starting with + // data:*/*;base64, + const base64 = (await serializeBlob(file)).split(",")[1]; + await sendCreateFileOrFolder({ + path: directoryPath, + type: "file", + name: file.name, + contents: base64, + }); + progress.increment(1); + } + await refreshRoot(); + }, + onFinish, + ); }, ...options, }); diff --git a/frontend/src/utils/__tests__/download.test.tsx b/frontend/src/utils/__tests__/download.test.tsx index 4ab19ec0d4a..791e2c168c0 100644 --- a/frontend/src/utils/__tests__/download.test.tsx +++ b/frontend/src/utils/__tests__/download.test.tsx @@ -17,9 +17,11 @@ vi.mock("html-to-image", () => ({ // Mock the toast module const mockDismiss = vi.fn(); +const mockUpdate = vi.fn(); vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn(() => ({ dismiss: mockDismiss, + update: mockUpdate, })), })); @@ -113,6 +115,51 @@ describe("withLoadingToast", () => { ); }); + it("should update toast on finish when onFinish is provided", async () => { + await withLoadingToast("Uploading files...", async () => "done", { + title: "Upload complete", + }); + + expect(toast).toHaveBeenCalledTimes(1); + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Upload complete", + description: undefined, + duration: 1200, + }), + ); + expect(mockDismiss).not.toHaveBeenCalled(); + }); + + it("should allow onFinish to override duration", async () => { + await withLoadingToast("Uploading files...", async () => "done", { + title: "Upload complete", + duration: 2000, + }); + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + duration: 2000, + }), + ); + }); + + it("should not update toast when the operation fails", async () => { + await expect( + withLoadingToast( + "Uploading files...", + async () => { + throw new Error("Upload failed"); + }, + { title: "Upload complete" }, + ), + ).rejects.toThrow("Upload failed"); + + expect(toast).toHaveBeenCalledTimes(1); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + it("should wait for the async function to complete", async () => { const events: string[] = []; diff --git a/frontend/src/utils/download.ts b/frontend/src/utils/download.ts index 9586007eb8d..827b1cc96a1 100644 --- a/frontend/src/utils/download.ts +++ b/frontend/src/utils/download.ts @@ -13,6 +13,9 @@ import { Logger } from "./Logger"; import { ProgressState } from "./progress"; import { ToastProgress } from "./toast-progress"; +const FINISH_TOAST_DURATION_MS = 1200; +type ToastConfig = Omit[0], "id">; + /** * Show a loading toast while an async operation is in progress. * Automatically dismisses the toast when the operation completes or fails. @@ -20,6 +23,7 @@ import { ToastProgress } from "./toast-progress"; export async function withLoadingToast( title: string, fn: (progress: ProgressState) => Promise, + onFinish?: ToastConfig, ): Promise { const progress = ProgressState.indeterminate(); const loadingToast = toast({ @@ -29,7 +33,15 @@ export async function withLoadingToast( }); try { const result = await fn(progress); - loadingToast.dismiss(); + if (onFinish) { + loadingToast.update({ + duration: FINISH_TOAST_DURATION_MS, + description: undefined, + ...onFinish, + }); + } else { + loadingToast.dismiss(); + } return result; } catch (error) { loadingToast.dismiss();