Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 46 additions & 23 deletions frontend/src/components/editor/file-tree/upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -40,30 +41,52 @@ export function useFileExplorerUpload(options: DropzoneOptions = {}) {
});
},
onDrop: async (acceptedFiles) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just to check, this covers clicking on the upload button on the panel?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it covers both click to upload and drag-drop.

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,
});
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/utils/__tests__/download.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
}));

Expand Down Expand Up @@ -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[] = [];

Expand Down
14 changes: 13 additions & 1 deletion frontend/src/utils/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ import { Logger } from "./Logger";
import { ProgressState } from "./progress";
import { ToastProgress } from "./toast-progress";

const FINISH_TOAST_DURATION_MS = 1200;
type ToastConfig = Omit<Parameters<typeof toast>[0], "id">;

/**
* Show a loading toast while an async operation is in progress.
* Automatically dismisses the toast when the operation completes or fails.
*/
export async function withLoadingToast<T>(
title: string,
fn: (progress: ProgressState) => Promise<T>,
onFinish?: ToastConfig,
): Promise<T> {
const progress = ProgressState.indeterminate();
const loadingToast = toast({
Expand All @@ -29,7 +33,15 @@ export async function withLoadingToast<T>(
});
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();
Expand Down