@marimo-team/frontend 0.24.1-dev45 → 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.
- package/dist/assets/{edit-page-jAyDC21z.js → edit-page-DxMQN491.js} +2 -2
- package/dist/assets/file-explorer-panel-DaDDgJV2.js +1 -0
- package/dist/assets/file-name-input-BeYyD2dj.js +3 -0
- package/dist/assets/{home-page-CFAldshJ.js → home-page-Chy6SA41.js} +1 -1
- package/dist/assets/{index-0VrRpta0.css → index-Bn9RxQAN.css} +1 -1
- package/dist/assets/{index-BBPHmHdW.js → index-DUJeB0WY.js} +2 -2
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/components/editor/chrome/panels/file-explorer-panel.tsx +59 -6
- package/src/components/editor/file-tree/__tests__/file-explorer-interactions.test.tsx +128 -0
- package/src/components/editor/file-tree/__tests__/requesting-tree.test.ts +60 -0
- package/src/components/editor/file-tree/__tests__/upload.test.tsx +230 -0
- package/src/components/editor/file-tree/file-explorer.tsx +127 -26
- package/src/components/editor/file-tree/requesting-tree.tsx +37 -0
- package/src/components/editor/file-tree/state.tsx +0 -5
- package/src/components/editor/file-tree/upload.tsx +219 -44
- package/dist/assets/file-explorer-panel-DYujxxK-.js +0 -1
- package/dist/assets/file-name-input-BDGq0-ac.js +0 -3
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
+
uploadDestinationPath={selectedFolderPath ?? tree.getRootPath()}
|
|
258
|
+
uploadDestinationLabel={getUploadDestinationLabel(
|
|
259
|
+
tree,
|
|
260
|
+
selectedFolderPath ?? tree.getRootPath(),
|
|
261
|
+
)}
|
|
262
|
+
onUpload={handleUploadFiles}
|
|
205
263
|
/>
|
|
206
|
-
<RequestingTreeContext value={
|
|
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 (
|
|
238
|
-
|
|
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
|
-
|
|
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
|
|
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=
|
|
322
|
-
<
|
|
385
|
+
<Tooltip content={uploadLabel}>
|
|
386
|
+
<Button
|
|
323
387
|
data-testid="file-explorer-upload-button"
|
|
324
|
-
{
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
})}
|
|
388
|
+
aria-label={uploadLabel}
|
|
389
|
+
onClick={() => onUpload(uploadDestinationPath)}
|
|
390
|
+
variant="text"
|
|
391
|
+
size="xs"
|
|
329
392
|
>
|
|
330
393
|
<UploadIcon size={16} />
|
|
331
|
-
</
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
?
|
|
52
|
-
:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
64
|
-
acceptedFiles,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|