@marimo-team/frontend 0.24.1-dev44 → 0.24.1-dev46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,7 +47,7 @@ import { MarimoIcon, MarimoPlusIcon } from "@/components/icons/marimo-icons";
47
47
  import { Spinner } from "@/components/icons/spinner";
48
48
  import { useImperativeModal } from "@/components/modal/ImperativeModal";
49
49
  import { AlertDialogDestructiveAction } from "@/components/ui/alert-dialog";
50
- import { Button, buttonVariants } from "@/components/ui/button";
50
+ import { Button } from "@/components/ui/button";
51
51
  import {
52
52
  DropdownMenuItem,
53
53
  DropdownMenuSeparator,
@@ -74,7 +74,10 @@ import { FileViewer } from "./file-viewer";
74
74
  import type { RequestingTree } from "./requesting-tree";
75
75
  import { openStateAtom, treeAtom } from "./state";
76
76
  import { PYTHON_CODE_FOR_FILE_TYPE } from "./types";
77
- import { useFileExplorerUpload } from "./upload";
77
+ import {
78
+ FILE_EXPLORER_DIRECTORY_PATH_ATTRIBUTE,
79
+ useFileExplorerUpload,
80
+ } from "./upload";
78
81
 
79
82
  const hiddenFilesState = atomWithStorage(
80
83
  "marimo:showHiddenFiles",
@@ -85,23 +88,53 @@ const hiddenFilesState = atomWithStorage(
85
88
  },
86
89
  );
87
90
 
88
- const RequestingTreeContext = React.createContext<RequestingTree | null>(null);
91
+ interface FileExplorerContextValue {
92
+ tree: RequestingTree;
93
+ uploadFiles: (destinationPath: FilePath) => void;
94
+ externalDropDestinationPath: FilePath | null;
95
+ }
96
+
97
+ const RequestingTreeContext =
98
+ React.createContext<FileExplorerContextValue | null>(null);
89
99
 
90
100
  export const FileExplorer: React.FC<{
91
101
  height: number;
92
- }> = ({ height }) => {
102
+ externalDropDestinationPath?: FilePath | null;
103
+ }> = ({ height, externalDropDestinationPath = null }) => {
93
104
  const treeRef = useRef<TreeApi<FileInfo>>(null);
94
105
  const dndManager = useTreeDndManager();
95
106
  const [tree] = useAtom(treeAtom);
96
107
  const [data, setData] = useState<FileInfo[]>([]);
97
108
  const [openFile, setOpenFile] = useState<FileInfo | null>(null);
109
+ const [selectedFolderPath, setSelectedFolderPath] = useState<FilePath | null>(
110
+ null,
111
+ );
98
112
  const [showHiddenFiles, setShowHiddenFiles] =
99
113
  useAtom<boolean>(hiddenFilesState);
114
+ // Keep external state to remember which folders are open when this
115
+ // component is unmounted.
116
+ const [openState, setOpenState] = useAtom(openStateAtom);
117
+
118
+ const refreshUploadDestination = useEvent((destinationPath: FilePath) => {
119
+ return tree.refreshPath(destinationPath);
120
+ });
121
+
122
+ const uploadDestinationRef = useRef<FilePath>("" as FilePath);
123
+ const { getInputProps: getUploadInputProps, open: openUploadPicker } =
124
+ useFileExplorerUpload({
125
+ noClick: true,
126
+ noDrag: true,
127
+ noKeyboard: true,
128
+ destinationPath: () => uploadDestinationRef.current,
129
+ getDestinationLabel: (path) => getUploadDestinationLabel(tree, path),
130
+ refreshDestination: refreshUploadDestination,
131
+ });
132
+ const handleUploadFiles = useEvent((destinationPath: FilePath) => {
133
+ uploadDestinationRef.current = destinationPath;
134
+ openUploadPicker();
135
+ });
100
136
 
101
137
  const { openPrompt } = useImperativeModal();
102
- // Keep external state to remember which folders are open
103
- // when this component is unmounted
104
- const [openState, setOpenState] = useAtom(openStateAtom);
105
138
  const { isPending, error } = useAsyncData(() => tree.initialize(setData), []);
106
139
 
107
140
  const handleRefresh = useEvent(() => {
@@ -152,6 +185,22 @@ export const FileExplorer: React.FC<{
152
185
  () => filterHiddenTree(data, showHiddenFiles),
153
186
  [data, showHiddenFiles],
154
187
  );
188
+ React.useEffect(() => {
189
+ if (
190
+ selectedFolderPath &&
191
+ !treeContainsPath(visibleData, selectedFolderPath)
192
+ ) {
193
+ setSelectedFolderPath(null);
194
+ }
195
+ }, [selectedFolderPath, visibleData]);
196
+ const contextValue = React.useMemo<FileExplorerContextValue>(
197
+ () => ({
198
+ tree,
199
+ uploadFiles: handleUploadFiles,
200
+ externalDropDestinationPath,
201
+ }),
202
+ [tree, handleUploadFiles, externalDropDestinationPath],
203
+ );
155
204
 
156
205
  if (isPending) {
157
206
  return <Spinner size="medium" centered={true} />;
@@ -193,6 +242,10 @@ export const FileExplorer: React.FC<{
193
242
 
194
243
  return (
195
244
  <>
245
+ <input
246
+ data-testid="file-explorer-upload-input"
247
+ {...getUploadInputProps()}
248
+ />
196
249
  <Toolbar
197
250
  onRefresh={handleRefresh}
198
251
  onHidden={handleHiddenFilesToggle}
@@ -201,9 +254,14 @@ export const FileExplorer: React.FC<{
201
254
  onCreateNotebook={handleCreateNotebook}
202
255
  onCreateFolder={handleCreateFolder}
203
256
  onCollapseAll={handleCollapseAll}
204
- tree={tree}
257
+ uploadDestinationPath={selectedFolderPath ?? tree.getRootPath()}
258
+ uploadDestinationLabel={getUploadDestinationLabel(
259
+ tree,
260
+ selectedFolderPath ?? tree.getRootPath(),
261
+ )}
262
+ onUpload={handleUploadFiles}
205
263
  />
206
- <RequestingTreeContext value={tree}>
264
+ <RequestingTreeContext value={contextValue}>
207
265
  <Tree<FileInfo>
208
266
  width="100%"
209
267
  ref={treeRef}
@@ -232,11 +290,15 @@ export const FileExplorer: React.FC<{
232
290
  onSelect={(nodes) => {
233
291
  const first = nodes[0];
234
292
  if (!first) {
293
+ setSelectedFolderPath(null);
235
294
  return;
236
295
  }
237
- if (!first.data.isDirectory) {
238
- setOpenFile(first.data);
296
+ if (first.data.isDirectory) {
297
+ setSelectedFolderPath(first.data.path as FilePath);
298
+ return;
239
299
  }
300
+ setSelectedFolderPath(null);
301
+ setOpenFile(first.data);
240
302
  }}
241
303
  onToggle={async (id) => {
242
304
  const result = await tree.expand(id);
@@ -269,7 +331,9 @@ interface ToolbarProps {
269
331
  onCreateNotebook: () => void;
270
332
  onCreateFolder: () => void;
271
333
  onCollapseAll: () => void;
272
- tree: RequestingTree;
334
+ uploadDestinationPath: FilePath;
335
+ uploadDestinationLabel: string;
336
+ onUpload: (destinationPath: FilePath) => void;
273
337
  }
274
338
 
275
339
  const Toolbar = ({
@@ -280,11 +344,11 @@ const Toolbar = ({
280
344
  onCreateNotebook,
281
345
  onCreateFolder,
282
346
  onCollapseAll,
347
+ uploadDestinationPath,
348
+ uploadDestinationLabel,
349
+ onUpload,
283
350
  }: ToolbarProps) => {
284
- const { getRootProps, getInputProps } = useFileExplorerUpload({
285
- noDrag: true,
286
- noDragEventsBubbling: true,
287
- });
351
+ const uploadLabel = `Upload files to ${uploadDestinationLabel}`;
288
352
 
289
353
  return (
290
354
  <div className="flex items-center justify-end px-2 shrink-0 border-b">
@@ -318,19 +382,17 @@ const Toolbar = ({
318
382
  <FolderPlusIcon size={16} />
319
383
  </Button>
320
384
  </Tooltip>
321
- <Tooltip content="Upload file">
322
- <button
385
+ <Tooltip content={uploadLabel}>
386
+ <Button
323
387
  data-testid="file-explorer-upload-button"
324
- {...getRootProps({})}
325
- className={buttonVariants({
326
- variant: "text",
327
- size: "xs",
328
- })}
388
+ aria-label={uploadLabel}
389
+ onClick={() => onUpload(uploadDestinationPath)}
390
+ variant="text"
391
+ size="xs"
329
392
  >
330
393
  <UploadIcon size={16} />
331
- </button>
394
+ </Button>
332
395
  </Tooltip>
333
- <input {...getInputProps({})} type="file" />
334
396
  <RefreshIconButton
335
397
  data-testid="file-explorer-refresh-button"
336
398
  onClick={onRefresh}
@@ -411,7 +473,11 @@ const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
411
473
  });
412
474
  };
413
475
 
414
- const tree = use(RequestingTreeContext);
476
+ const fileExplorer = use(RequestingTreeContext);
477
+ const tree = fileExplorer?.tree;
478
+ const isExternalDropTarget =
479
+ node.data.isDirectory &&
480
+ fileExplorer?.externalDropDestinationPath === node.data.path;
415
481
 
416
482
  const handleOpenMarimoFile = async (
417
483
  evt: Pick<Event, "stopPropagation" | "preventDefault">,
@@ -483,6 +549,9 @@ const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
483
549
  <div
484
550
  style={style}
485
551
  ref={dragHandle}
552
+ {...(node.data.isDirectory
553
+ ? { [FILE_EXPLORER_DIRECTORY_PATH_ATTRIBUTE]: node.data.path }
554
+ : {})}
486
555
  className={cn(
487
556
  "flex items-center cursor-pointer ml-1 text-muted-foreground whitespace-nowrap group",
488
557
  )}
@@ -490,6 +559,7 @@ const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
490
559
  onClick={(evt) => {
491
560
  evt.stopPropagation();
492
561
  if (node.data.isDirectory) {
562
+ node.select();
493
563
  node.toggle();
494
564
  }
495
565
  }}
@@ -501,6 +571,10 @@ const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
501
571
  node.willReceiveDrop &&
502
572
  node.data.isDirectory &&
503
573
  "bg-accent/80 hover:bg-accent/80 text-accent-foreground",
574
+ node.isSelected &&
575
+ "bg-accent/60 hover:bg-accent/60 text-accent-foreground",
576
+ isExternalDropTarget &&
577
+ "bg-primary/15 hover:bg-primary/15 text-accent-foreground ring-1 ring-inset ring-primary",
504
578
  )}
505
579
  >
506
580
  {node.data.isMarimoFile ? (
@@ -563,6 +637,15 @@ const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
563
637
  <FolderPlusIcon className={MENU_ITEM_ICON_CLASS} />
564
638
  Create folder
565
639
  </DropdownMenuItem>
640
+ <DropdownMenuItem
641
+ onSelect={() =>
642
+ fileExplorer?.uploadFiles(node.data.path as FilePath)
643
+ }
644
+ data-testid="file-explorer-upload-files-menu-item"
645
+ >
646
+ <UploadIcon className={MENU_ITEM_ICON_CLASS} />
647
+ Upload files here
648
+ </DropdownMenuItem>
566
649
  <DropdownMenuSeparator />
567
650
  </>
568
651
  )}
@@ -680,6 +763,16 @@ function openMarimoNotebook(
680
763
  openNotebook(path);
681
764
  }
682
765
 
766
+ export function getUploadDestinationLabel(
767
+ tree: RequestingTree,
768
+ destinationPath: FilePath,
769
+ ): string {
770
+ if (destinationPath === tree.getRootPath()) {
771
+ return "workspace root";
772
+ }
773
+ return tree.relativeFromRoot(destinationPath);
774
+ }
775
+
683
776
  export function filterHiddenTree(
684
777
  list: FileInfo[],
685
778
  showHidden: boolean,
@@ -711,3 +804,11 @@ export function isDirectoryOrFileHidden(filename: string): boolean {
711
804
  }
712
805
  return false;
713
806
  }
807
+
808
+ function treeContainsPath(list: FileInfo[], path: FilePath): boolean {
809
+ return list.some(
810
+ (item) =>
811
+ item.path === path ||
812
+ (item.children ? treeContainsPath(item.children, path) : false),
813
+ );
814
+ }
@@ -286,6 +286,24 @@ export class RequestingTree {
286
286
  this.onChange(this.delegate.data);
287
287
  };
288
288
 
289
+ refreshPath = async (path: FilePath): Promise<void> => {
290
+ const data = await this.callbacks.listFiles({ path }).catch(() => null);
291
+ if (!data) {
292
+ return;
293
+ }
294
+
295
+ if (path === this.rootPath) {
296
+ this.delegate = new SimpleTree(data.files);
297
+ } else {
298
+ const item = findFileByPath(this.delegate.data, path);
299
+ if (!item?.isDirectory) {
300
+ return;
301
+ }
302
+ this.delegate.update({ id: item.id, changes: { children: data.files } });
303
+ }
304
+ this.onChange(this.delegate.data);
305
+ };
306
+
289
307
  public relativeFromRoot = (path: FilePath): FilePath => {
290
308
  // Add a trailing delimiter to the root path if it doesn't have one
291
309
  const root = this.rootPath.endsWith(this.path.deliminator)
@@ -297,4 +315,23 @@ export class RequestingTree {
297
315
  }
298
316
  return path;
299
317
  };
318
+
319
+ public getRootPath = (): FilePath => {
320
+ return this.rootPath;
321
+ };
322
+ }
323
+
324
+ function findFileByPath(list: FileInfo[], path: string): FileInfo | undefined {
325
+ for (const item of list) {
326
+ if (item.path === path) {
327
+ return item;
328
+ }
329
+ const child = item.children
330
+ ? findFileByPath(item.children, path)
331
+ : undefined;
332
+ if (child) {
333
+ return child;
334
+ }
335
+ }
336
+ return undefined;
300
337
  }
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { atom } from "jotai";
4
4
  import { requestClientAtom } from "@/core/network/requests";
5
- import { store } from "@/core/state/jotai";
6
5
  import { invariant } from "@/utils/invariant";
7
6
  import { RequestingTree } from "./requesting-tree";
8
7
 
@@ -21,7 +20,3 @@ export const treeAtom = atom<RequestingTree>((get) => {
21
20
  });
22
21
 
23
22
  export const openStateAtom = atom<Record<string, boolean>>({});
24
-
25
- export async function refreshRoot() {
26
- await store.get(treeAtom).refreshAll([]);
27
- }
@@ -1,22 +1,61 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { type DropzoneOptions, useDropzone } from "react-dropzone";
3
+ import {
4
+ type DropEvent,
5
+ type DropzoneOptions,
6
+ useDropzone,
7
+ } from "react-dropzone";
4
8
  import { toast } from "@/components/ui/use-toast";
5
9
  import { useRequestClient } from "@/core/network/requests";
10
+ import type { FileCreateInput, FileCreateResponse } from "@/core/network/types";
6
11
  import { withLoadingToast } from "@/utils/download";
12
+ import { prettyError } from "@/utils/errors";
7
13
  import { Logger } from "@/utils/Logger";
8
- import { type FilePath, PathBuilder } from "@/utils/paths";
14
+ import { type FilePath, PathBuilder, Paths } from "@/utils/paths";
9
15
  import { mapWithConcurrency } from "@/utils/semaphore";
10
- import { refreshRoot } from "./state";
11
16
 
12
17
  const MAX_SIZE = 1024 * 1024 * 100; // 100MB
13
18
  const UPLOAD_CONCURRENCY = 5;
14
19
 
15
- export function useFileExplorerUpload(options: DropzoneOptions = {}) {
20
+ export const FILE_EXPLORER_DIRECTORY_PATH_ATTRIBUTE =
21
+ "data-file-explorer-directory-path";
22
+
23
+ type DestinationPath = FilePath | ((event: DropEvent) => FilePath);
24
+
25
+ interface FileExplorerUploadOptions extends Omit<
26
+ DropzoneOptions,
27
+ "onDrop" | "onDropRejected" | "onError"
28
+ > {
29
+ destinationPath: DestinationPath;
30
+ getDestinationLabel?: (path: FilePath) => string;
31
+ onUploadStart?: (destinationPath: FilePath, files: File[]) => void;
32
+ refreshDestination: (destinationPath: FilePath) => Promise<void>;
33
+ }
34
+
35
+ interface UploadFileResult {
36
+ file: File;
37
+ response: FileCreateResponse;
38
+ }
39
+
40
+ export interface UploadFilesResult {
41
+ successful: UploadFileResult[];
42
+ failed: UploadFileResult[];
43
+ }
44
+
45
+ export function useFileExplorerUpload(options: FileExplorerUploadOptions) {
46
+ const {
47
+ destinationPath,
48
+ getDestinationLabel = (path) => path,
49
+ onUploadStart,
50
+ refreshDestination,
51
+ ...dropzoneOptions
52
+ } = options;
16
53
  const { sendCreateFileOrFolder } = useRequestClient();
54
+
17
55
  return useDropzone({
18
56
  multiple: true,
19
57
  maxSize: MAX_SIZE,
58
+ ...dropzoneOptions,
20
59
  onError: (error) => {
21
60
  Logger.error(error);
22
61
  toast({
@@ -41,53 +80,189 @@ export function useFileExplorerUpload(options: DropzoneOptions = {}) {
41
80
  variant: "danger",
42
81
  });
43
82
  },
44
- onDrop: async (acceptedFiles) => {
83
+ onDrop: async (acceptedFiles, _rejectedFiles, event) => {
45
84
  if (acceptedFiles.length === 0) {
46
85
  return;
47
86
  }
48
- const isSingle = acceptedFiles.length === 1;
49
87
 
88
+ const resolvedDestinationPath =
89
+ typeof destinationPath === "function"
90
+ ? destinationPath(event)
91
+ : destinationPath;
92
+ const destinationLabel = getDestinationLabel(resolvedDestinationPath);
93
+ const isSingle = acceptedFiles.length === 1;
50
94
  const loadingTitle = isSingle
51
- ? "Uploading file..."
52
- : "Uploading files...";
53
- const onFinish = {
54
- title: isSingle
55
- ? "File uploaded"
56
- : `${acceptedFiles.length} files uploaded`,
57
- };
58
-
59
- await withLoadingToast(
60
- loadingTitle,
61
- async (progress) => {
95
+ ? `Uploading file to ${destinationLabel}...`
96
+ : `Uploading files to ${destinationLabel}...`;
97
+
98
+ onUploadStart?.(resolvedDestinationPath, acceptedFiles);
99
+
100
+ let result: UploadFilesResult;
101
+ try {
102
+ result = await withLoadingToast(loadingTitle, async (progress) => {
62
103
  progress.addTotal(acceptedFiles.length);
63
- await mapWithConcurrency(
64
- acceptedFiles,
65
- UPLOAD_CONCURRENCY,
66
- async (file) => {
67
- // We strip the leading slash since File.path can return
68
- // `/path/to/file`.
69
- const filePath = stripLeadingSlash(getPath(file));
70
- let directoryPath = "" as FilePath;
71
- if (filePath) {
72
- directoryPath =
73
- PathBuilder.guessDeliminator(filePath).dirname(filePath);
74
- }
75
-
76
- await sendCreateFileOrFolder({
77
- path: directoryPath,
78
- type: "file",
79
- name: file.name,
80
- file,
81
- });
82
- progress.increment(1);
83
- },
84
- );
85
- await refreshRoot();
86
- },
87
- onFinish,
88
- );
104
+ return uploadFilesToDestination({
105
+ files: acceptedFiles,
106
+ destinationPath: resolvedDestinationPath,
107
+ createFile: sendCreateFileOrFolder,
108
+ onFileProcessed: () => progress.increment(1),
109
+ });
110
+ });
111
+ } catch {
112
+ await refreshDestination(resolvedDestinationPath);
113
+ return;
114
+ }
115
+
116
+ await refreshDestination(resolvedDestinationPath);
117
+ showUploadResultToast(result, destinationLabel);
118
+ },
119
+ });
120
+ }
121
+
122
+ export async function uploadFilesToDestination({
123
+ files,
124
+ destinationPath,
125
+ createFile,
126
+ onFileProcessed,
127
+ }: {
128
+ files: File[];
129
+ destinationPath: FilePath;
130
+ createFile: (request: FileCreateInput) => Promise<FileCreateResponse>;
131
+ onFileProcessed?: () => void;
132
+ }): Promise<UploadFilesResult> {
133
+ const results = await mapWithConcurrency(
134
+ files,
135
+ UPLOAD_CONCURRENCY,
136
+ async (file): Promise<UploadFileResult> => {
137
+ try {
138
+ const filePath = stripLeadingSlash(getPath(file));
139
+ const directoryPath = resolveUploadDirectoryPath({
140
+ destinationPath,
141
+ filePath,
142
+ });
143
+ const response = await createFile({
144
+ path: directoryPath,
145
+ type: "file",
146
+ name: file.name,
147
+ file,
148
+ });
149
+ return { file, response };
150
+ } catch (error) {
151
+ return {
152
+ file,
153
+ response: { success: false, message: prettyError(error) },
154
+ };
155
+ } finally {
156
+ onFileProcessed?.();
157
+ }
89
158
  },
90
- ...options,
159
+ );
160
+
161
+ return {
162
+ successful: results.filter(({ response }) => response.success),
163
+ failed: results.filter(({ response }) => !response.success),
164
+ };
165
+ }
166
+
167
+ export function resolveUploadDirectoryPath({
168
+ destinationPath,
169
+ filePath,
170
+ }: {
171
+ destinationPath: FilePath;
172
+ filePath: FilePath | undefined;
173
+ }): FilePath {
174
+ if (!filePath) {
175
+ return destinationPath;
176
+ }
177
+
178
+ if (Paths.isAbsolute(filePath)) {
179
+ throw new Error(`Upload path must be relative: ${filePath}`);
180
+ }
181
+
182
+ const pathParts = filePath.split(/[\\/]+/);
183
+ const relativeDirectoryParts = pathParts.slice(0, -1).filter(Boolean);
184
+ if (relativeDirectoryParts.includes("..")) {
185
+ throw new Error(`Upload path cannot contain parent traversal: ${filePath}`);
186
+ }
187
+
188
+ const normalizedDirectoryParts = relativeDirectoryParts.filter(
189
+ (part) => part !== ".",
190
+ );
191
+ if (normalizedDirectoryParts.length === 0) {
192
+ return destinationPath;
193
+ }
194
+
195
+ const destinationPathBuilder = PathBuilder.guessDeliminator(destinationPath);
196
+ const normalizedRelativePath = normalizedDirectoryParts.join(
197
+ destinationPathBuilder.deliminator,
198
+ );
199
+ return destinationPathBuilder.join(destinationPath, normalizedRelativePath);
200
+ }
201
+
202
+ export function getUploadDestinationFromTarget(
203
+ target: EventTarget | null,
204
+ rootPath: FilePath,
205
+ ): FilePath {
206
+ if (!(target instanceof Element)) {
207
+ return rootPath;
208
+ }
209
+
210
+ const directory = target.closest(
211
+ `[${FILE_EXPLORER_DIRECTORY_PATH_ATTRIBUTE}]`,
212
+ );
213
+ const path = directory?.getAttribute(FILE_EXPLORER_DIRECTORY_PATH_ATTRIBUTE);
214
+ return path ? (path as FilePath) : rootPath;
215
+ }
216
+
217
+ function showUploadResultToast(
218
+ result: UploadFilesResult,
219
+ destinationLabel: string,
220
+ ) {
221
+ const total = result.successful.length + result.failed.length;
222
+ if (result.failed.length > 0) {
223
+ let title: string;
224
+ if (result.successful.length === 0) {
225
+ title = total === 1 ? "File upload failed" : "Files failed to upload";
226
+ } else {
227
+ title = `${result.successful.length} of ${total} files uploaded`;
228
+ }
229
+ toast({
230
+ title,
231
+ description: (
232
+ <div className="flex flex-col gap-1">
233
+ <div>Destination: {destinationLabel}.</div>
234
+ {result.failed.map(({ file, response }, index) => (
235
+ <div key={`${file.name}-${index}`}>
236
+ {file.name}:{" "}
237
+ {response.message || "The server rejected the upload."}
238
+ </div>
239
+ ))}
240
+ </div>
241
+ ),
242
+ variant: "danger",
243
+ });
244
+ return;
245
+ }
246
+
247
+ const renamedFiles = result.successful.filter(
248
+ ({ file, response }) =>
249
+ response.info?.name && response.info.name !== file.name,
250
+ );
251
+ toast({
252
+ title: total === 1 ? "File uploaded" : `${total} files uploaded`,
253
+ description:
254
+ renamedFiles.length === 0 ? (
255
+ `Uploaded to ${destinationLabel}.`
256
+ ) : (
257
+ <div className="flex flex-col gap-1">
258
+ <div>Uploaded to {destinationLabel}.</div>
259
+ {renamedFiles.map(({ file, response }, index) => (
260
+ <div key={`${file.name}-${index}`}>
261
+ {file.name} was saved as {response.info?.name}.
262
+ </div>
263
+ ))}
264
+ </div>
265
+ ),
91
266
  });
92
267
  }
93
268