@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.
- 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
package/dist/index.html
CHANGED
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
<marimo-server-token data-token="{{ server_token }}" hidden></marimo-server-token>
|
|
67
67
|
<!-- /TODO -->
|
|
68
68
|
<title>{{ title }}</title>
|
|
69
|
-
<script type="module" crossorigin src="./assets/index-
|
|
69
|
+
<script type="module" crossorigin src="./assets/index-DUJeB0WY.js"></script>
|
|
70
70
|
<link rel="modulepreload" crossorigin href="./assets/rolldown-runtime-B0Z9INg1.js">
|
|
71
71
|
<link rel="modulepreload" crossorigin href="./assets/react-CBDbhulC.js">
|
|
72
72
|
<link rel="modulepreload" crossorigin href="./assets/compiler-runtime-CYIjoINj.js">
|
|
@@ -234,7 +234,7 @@
|
|
|
234
234
|
<link rel="stylesheet" crossorigin href="./assets/cells-D-c15Ks-.css">
|
|
235
235
|
<link rel="stylesheet" crossorigin href="./assets/markdown-renderer-CU2Wa_Bt.css">
|
|
236
236
|
<link rel="stylesheet" crossorigin href="./assets/JsonOutput-BQtz7-np.css">
|
|
237
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
237
|
+
<link rel="stylesheet" crossorigin href="./assets/index-Bn9RxQAN.css">
|
|
238
238
|
</head>
|
|
239
239
|
<body>
|
|
240
240
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -2,16 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
import { useAtom, useAtomValue } from "jotai";
|
|
4
4
|
import { FileIcon, HardDrive } from "lucide-react";
|
|
5
|
-
import React, { useCallback, useMemo } from "react";
|
|
5
|
+
import React, { useCallback, useMemo, useState } from "react";
|
|
6
|
+
import type { DropEvent } from "react-dropzone";
|
|
6
7
|
import useResizeObserver from "use-resize-observer";
|
|
7
8
|
import { StorageInspector } from "@/components/storage/storage-inspector";
|
|
8
9
|
import { Accordion } from "@/components/ui/accordion";
|
|
9
10
|
import { storageNamespacesAtom } from "@/core/storage/state";
|
|
10
11
|
import { useDetectedDataSources } from "@/hooks/useDataSourceDiscovery";
|
|
11
12
|
import { cn } from "@/utils/cn";
|
|
13
|
+
import type { FilePath } from "@/utils/paths";
|
|
12
14
|
import { TreeDndProvider } from "../../file-tree/dnd-wrapper";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
+
import {
|
|
16
|
+
FileExplorer,
|
|
17
|
+
getUploadDestinationLabel,
|
|
18
|
+
} from "../../file-tree/file-explorer";
|
|
19
|
+
import { treeAtom } from "../../file-tree/state";
|
|
20
|
+
import {
|
|
21
|
+
getUploadDestinationFromTarget,
|
|
22
|
+
useFileExplorerUpload,
|
|
23
|
+
} from "../../file-tree/upload";
|
|
15
24
|
import {
|
|
16
25
|
DiscoveredSourcesBadge,
|
|
17
26
|
PanelAccordionContent,
|
|
@@ -25,10 +34,37 @@ import {
|
|
|
25
34
|
} from "./panel-accordion-state";
|
|
26
35
|
|
|
27
36
|
const FileExplorerComponent: React.FC<{ height: number }> = ({ height }) => {
|
|
37
|
+
const tree = useAtomValue(treeAtom);
|
|
38
|
+
const [dropDestinationPath, setDropDestinationPath] =
|
|
39
|
+
useState<FilePath | null>(null);
|
|
40
|
+
|
|
41
|
+
const getDropDestinationPath = useCallback(
|
|
42
|
+
(event: DropEvent) =>
|
|
43
|
+
getUploadDestinationForEvent(event, tree.getRootPath()),
|
|
44
|
+
[tree],
|
|
45
|
+
);
|
|
46
|
+
const refreshUploadDestination = useCallback(
|
|
47
|
+
(destinationPath: FilePath) => tree.refreshPath(destinationPath),
|
|
48
|
+
[tree],
|
|
49
|
+
);
|
|
28
50
|
const { getRootProps, getInputProps, isDragActive } = useFileExplorerUpload({
|
|
29
51
|
noClick: true,
|
|
30
52
|
noKeyboard: true,
|
|
53
|
+
destinationPath: getDropDestinationPath,
|
|
54
|
+
getDestinationLabel: (path) => getUploadDestinationLabel(tree, path),
|
|
55
|
+
refreshDestination: refreshUploadDestination,
|
|
56
|
+
onDragEnter: (event) =>
|
|
57
|
+
setDropDestinationPath(getDropDestinationPath(event)),
|
|
58
|
+
onDragOver: (event) =>
|
|
59
|
+
setDropDestinationPath(getDropDestinationPath(event)),
|
|
60
|
+
onDragLeave: () => setDropDestinationPath(null),
|
|
61
|
+
onUploadStart: () => setDropDestinationPath(null),
|
|
31
62
|
});
|
|
63
|
+
const displayedDestinationPath = dropDestinationPath ?? tree.getRootPath();
|
|
64
|
+
const displayedDestinationLabel = getUploadDestinationLabel(
|
|
65
|
+
tree,
|
|
66
|
+
displayedDestinationPath,
|
|
67
|
+
);
|
|
32
68
|
|
|
33
69
|
return (
|
|
34
70
|
<TreeDndProvider>
|
|
@@ -39,17 +75,34 @@ const FileExplorerComponent: React.FC<{ height: number }> = ({ height }) => {
|
|
|
39
75
|
>
|
|
40
76
|
<input {...getInputProps()} />
|
|
41
77
|
{isDragActive && (
|
|
42
|
-
<div className="absolute inset-0 flex items-
|
|
43
|
-
|
|
78
|
+
<div className="absolute inset-0 flex items-start justify-center pt-3 bg-accent/20 z-10 border-2 border-dashed border-primary/90 rounded-lg pointer-events-none">
|
|
79
|
+
<span className="px-3 py-1.5 rounded-md bg-background/95 border shadow-sm text-sm font-semibold text-primary">
|
|
80
|
+
Drop files into {displayedDestinationLabel}
|
|
81
|
+
</span>
|
|
44
82
|
</div>
|
|
45
83
|
)}
|
|
46
84
|
|
|
47
|
-
<FileExplorer
|
|
85
|
+
<FileExplorer
|
|
86
|
+
height={height}
|
|
87
|
+
externalDropDestinationPath={
|
|
88
|
+
isDragActive ? displayedDestinationPath : null
|
|
89
|
+
}
|
|
90
|
+
/>
|
|
48
91
|
</div>
|
|
49
92
|
</TreeDndProvider>
|
|
50
93
|
);
|
|
51
94
|
};
|
|
52
95
|
|
|
96
|
+
export function getUploadDestinationForEvent(
|
|
97
|
+
event: DropEvent,
|
|
98
|
+
rootPath: FilePath,
|
|
99
|
+
): FilePath {
|
|
100
|
+
if (Array.isArray(event)) {
|
|
101
|
+
return rootPath;
|
|
102
|
+
}
|
|
103
|
+
return getUploadDestinationFromTarget(event.target, rootPath);
|
|
104
|
+
}
|
|
105
|
+
|
|
53
106
|
// Height of each accordion trigger (px-3 py-2 text-xs = ~33px)
|
|
54
107
|
const TRIGGER_HEIGHT = 33;
|
|
55
108
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import { Provider, createStore } from "jotai";
|
|
5
|
+
import type React from "react";
|
|
6
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { MockRequestClient } from "@/__mocks__/requests";
|
|
8
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
9
|
+
import { requestClientAtom } from "@/core/network/requests";
|
|
10
|
+
import type { FileInfo } from "@/core/network/types";
|
|
11
|
+
import { FileExplorer } from "../file-explorer";
|
|
12
|
+
|
|
13
|
+
vi.mock("react-arborist", async (importOriginal) => {
|
|
14
|
+
const actual = await importOriginal<typeof import("react-arborist")>();
|
|
15
|
+
return {
|
|
16
|
+
...actual,
|
|
17
|
+
Tree: ({
|
|
18
|
+
data,
|
|
19
|
+
onSelect,
|
|
20
|
+
}: {
|
|
21
|
+
data: FileInfo[];
|
|
22
|
+
onSelect: (nodes: Array<{ data: FileInfo }>) => void;
|
|
23
|
+
}) => (
|
|
24
|
+
<div>
|
|
25
|
+
{data.map((item) => (
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
key={item.id}
|
|
29
|
+
onClick={() => onSelect([{ data: item }])}
|
|
30
|
+
>
|
|
31
|
+
{item.name}
|
|
32
|
+
</button>
|
|
33
|
+
))}
|
|
34
|
+
</div>
|
|
35
|
+
),
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
let testStore = createStore();
|
|
40
|
+
|
|
41
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
42
|
+
<Provider store={testStore}>
|
|
43
|
+
<TooltipProvider>{children}</TooltipProvider>
|
|
44
|
+
</Provider>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
describe("FileExplorer upload destination", () => {
|
|
48
|
+
let client: ReturnType<typeof MockRequestClient.create>;
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
localStorage.removeItem("marimo:showHiddenFiles");
|
|
52
|
+
testStore = createStore();
|
|
53
|
+
client = MockRequestClient.create({
|
|
54
|
+
sendListFiles: vi.fn().mockImplementation(async ({ path }) => {
|
|
55
|
+
if (path) {
|
|
56
|
+
return { files: [] };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
root: "/workspace",
|
|
60
|
+
files: [
|
|
61
|
+
{
|
|
62
|
+
id: "data-directory",
|
|
63
|
+
name: "data",
|
|
64
|
+
path: "/workspace/data",
|
|
65
|
+
isDirectory: true,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "hidden-directory",
|
|
69
|
+
name: ".hidden",
|
|
70
|
+
path: "/workspace/.hidden",
|
|
71
|
+
isDirectory: true,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}),
|
|
76
|
+
sendCreateFileOrFolder: vi.fn().mockResolvedValue({ success: true }),
|
|
77
|
+
});
|
|
78
|
+
testStore.set(requestClientAtom, client);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("uses the selected folder for toolbar uploads", async () => {
|
|
82
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
83
|
+
|
|
84
|
+
const rootUpload = await screen.findByRole("button", {
|
|
85
|
+
name: "Upload files to workspace root",
|
|
86
|
+
});
|
|
87
|
+
expect(rootUpload).toBeVisible();
|
|
88
|
+
|
|
89
|
+
fireEvent.click(await screen.findByText("data"));
|
|
90
|
+
|
|
91
|
+
const folderUpload = await screen.findByRole("button", {
|
|
92
|
+
name: "Upload files to data",
|
|
93
|
+
});
|
|
94
|
+
fireEvent.click(folderUpload);
|
|
95
|
+
|
|
96
|
+
const file = new File(["contents"], "report.csv");
|
|
97
|
+
fireEvent.change(screen.getByTestId("file-explorer-upload-input"), {
|
|
98
|
+
target: { files: [file] },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await waitFor(() => {
|
|
102
|
+
expect(client.sendCreateFileOrFolder).toHaveBeenCalledWith({
|
|
103
|
+
path: "/workspace/data",
|
|
104
|
+
type: "file",
|
|
105
|
+
name: "report.csv",
|
|
106
|
+
file,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("clears a selected hidden folder when hidden files are hidden", async () => {
|
|
112
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
113
|
+
|
|
114
|
+
fireEvent.click(await screen.findByText(".hidden"));
|
|
115
|
+
expect(
|
|
116
|
+
await screen.findByRole("button", { name: "Upload files to .hidden" }),
|
|
117
|
+
).toBeVisible();
|
|
118
|
+
|
|
119
|
+
fireEvent.click(screen.getByTestId("file-explorer-hidden-files-button"));
|
|
120
|
+
|
|
121
|
+
expect(
|
|
122
|
+
await screen.findByRole("button", {
|
|
123
|
+
name: "Upload files to workspace root",
|
|
124
|
+
}),
|
|
125
|
+
).toBeVisible();
|
|
126
|
+
expect(screen.queryByText(".hidden")).not.toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -52,6 +52,7 @@ describe("RequestingTree", () => {
|
|
|
52
52
|
|
|
53
53
|
test("initialize should load files and set rootPath", async () => {
|
|
54
54
|
expect(sendListFiles).toHaveBeenCalledWith({ path: "" });
|
|
55
|
+
expect(requestingTree.getRootPath()).toBe("/root");
|
|
55
56
|
expect(mockOnChange).toHaveBeenCalledWith([
|
|
56
57
|
{ id: "1.1", name: "file1", path: "/root/file1" },
|
|
57
58
|
{ id: "1.2", name: "folder1", isDirectory: true, path: "/root/folder1" },
|
|
@@ -261,6 +262,65 @@ describe("RequestingTree", () => {
|
|
|
261
262
|
`);
|
|
262
263
|
});
|
|
263
264
|
|
|
265
|
+
test("refreshPath should refresh an uploaded file's destination", async () => {
|
|
266
|
+
sendListFiles.mockImplementation(async ({ path }: { path: string }) => {
|
|
267
|
+
if (path === "/root/folder1") {
|
|
268
|
+
return {
|
|
269
|
+
files: [
|
|
270
|
+
{
|
|
271
|
+
id: "uploaded",
|
|
272
|
+
name: "uploaded.csv",
|
|
273
|
+
path: "/root/folder1/uploaded.csv",
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
files: [
|
|
280
|
+
{ id: "1.1", name: "file1", path: "/root/file1" },
|
|
281
|
+
{
|
|
282
|
+
id: "1.2",
|
|
283
|
+
name: "folder1",
|
|
284
|
+
isDirectory: true,
|
|
285
|
+
path: "/root/folder1",
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
id: "1.3",
|
|
289
|
+
name: "folder2",
|
|
290
|
+
isDirectory: true,
|
|
291
|
+
path: "/root/folder2",
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await requestingTree.refreshPath("/root/folder1" as FilePath);
|
|
298
|
+
|
|
299
|
+
expect(sendListFiles).toHaveBeenCalledWith({ path: "/root/folder1" });
|
|
300
|
+
expect(mockOnChange.mock.calls.at(-1)?.[0]).toEqual([
|
|
301
|
+
{ id: "1.1", name: "file1", path: "/root/file1" },
|
|
302
|
+
{
|
|
303
|
+
id: "1.2",
|
|
304
|
+
name: "folder1",
|
|
305
|
+
isDirectory: true,
|
|
306
|
+
path: "/root/folder1",
|
|
307
|
+
children: [
|
|
308
|
+
{
|
|
309
|
+
id: "uploaded",
|
|
310
|
+
name: "uploaded.csv",
|
|
311
|
+
path: "/root/folder1/uploaded.csv",
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
id: "1.3",
|
|
317
|
+
name: "folder2",
|
|
318
|
+
isDirectory: true,
|
|
319
|
+
path: "/root/folder2",
|
|
320
|
+
},
|
|
321
|
+
]);
|
|
322
|
+
});
|
|
323
|
+
|
|
264
324
|
describe("when API fails", () => {
|
|
265
325
|
test("initialize should handle errors gracefully", async () => {
|
|
266
326
|
requestingTree = new RequestingTree({
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { FileCreateInput, FileCreateResponse } from "@/core/network/types";
|
|
5
|
+
import type { FilePath } from "@/utils/paths";
|
|
6
|
+
import {
|
|
7
|
+
FILE_EXPLORER_DIRECTORY_PATH_ATTRIBUTE,
|
|
8
|
+
getUploadDestinationFromTarget,
|
|
9
|
+
resolveUploadDirectoryPath,
|
|
10
|
+
uploadFilesToDestination,
|
|
11
|
+
} from "../upload";
|
|
12
|
+
|
|
13
|
+
const filePath = (path: string) => path as FilePath;
|
|
14
|
+
|
|
15
|
+
describe("resolveUploadDirectoryPath", () => {
|
|
16
|
+
it("uploads an individual file to the explicit destination", () => {
|
|
17
|
+
expect(
|
|
18
|
+
resolveUploadDirectoryPath({
|
|
19
|
+
destinationPath: filePath("/workspace/data"),
|
|
20
|
+
filePath: filePath("./sales.csv"),
|
|
21
|
+
}),
|
|
22
|
+
).toBe("/workspace/data");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("preserves a dropped folder's relative structure", () => {
|
|
26
|
+
expect(
|
|
27
|
+
resolveUploadDirectoryPath({
|
|
28
|
+
destinationPath: filePath("/workspace/data"),
|
|
29
|
+
filePath: filePath("raw/2026/sales.csv"),
|
|
30
|
+
}),
|
|
31
|
+
).toBe("/workspace/data/raw/2026");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("normalizes relative paths for a Windows destination", () => {
|
|
35
|
+
expect(
|
|
36
|
+
resolveUploadDirectoryPath({
|
|
37
|
+
destinationPath: filePath("C:\\workspace\\data"),
|
|
38
|
+
filePath: filePath("raw/2026/sales.csv"),
|
|
39
|
+
}),
|
|
40
|
+
).toBe("C:\\workspace\\data\\raw\\2026");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("normalizes mixed separators in relative paths", () => {
|
|
44
|
+
expect(
|
|
45
|
+
resolveUploadDirectoryPath({
|
|
46
|
+
destinationPath: filePath("/workspace/data"),
|
|
47
|
+
filePath: filePath("raw\\2026/sales.csv"),
|
|
48
|
+
}),
|
|
49
|
+
).toBe("/workspace/data/raw/2026");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("removes current-directory components", () => {
|
|
53
|
+
expect(
|
|
54
|
+
resolveUploadDirectoryPath({
|
|
55
|
+
destinationPath: filePath("/workspace/data"),
|
|
56
|
+
filePath: filePath("raw/./2026/sales.csv"),
|
|
57
|
+
}),
|
|
58
|
+
).toBe("/workspace/data/raw/2026");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it.each(["../sales.csv", "raw/../../sales.csv", "raw\\..\\sales.csv"])(
|
|
62
|
+
"rejects parent traversal in %s",
|
|
63
|
+
(unsafePath) => {
|
|
64
|
+
expect(() =>
|
|
65
|
+
resolveUploadDirectoryPath({
|
|
66
|
+
destinationPath: filePath("/workspace/data"),
|
|
67
|
+
filePath: filePath(unsafePath),
|
|
68
|
+
}),
|
|
69
|
+
).toThrow(/parent traversal/);
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
it.each([
|
|
74
|
+
"C:\\Users\\sales.csv",
|
|
75
|
+
"\\\\server\\share\\sales.csv",
|
|
76
|
+
"file://workspace/sales.csv",
|
|
77
|
+
"/Users/example/sales.csv",
|
|
78
|
+
])("rejects absolute path %s", (absolutePath) => {
|
|
79
|
+
expect(() =>
|
|
80
|
+
resolveUploadDirectoryPath({
|
|
81
|
+
destinationPath: filePath("/workspace/data"),
|
|
82
|
+
filePath: filePath(absolutePath),
|
|
83
|
+
}),
|
|
84
|
+
).toThrow(/must be relative/);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("getUploadDestinationFromTarget", () => {
|
|
89
|
+
it("uses the closest folder as the drop destination", () => {
|
|
90
|
+
const folder = document.createElement("div");
|
|
91
|
+
folder.setAttribute(
|
|
92
|
+
FILE_EXPLORER_DIRECTORY_PATH_ATTRIBUTE,
|
|
93
|
+
"/workspace/data",
|
|
94
|
+
);
|
|
95
|
+
const child = document.createElement("span");
|
|
96
|
+
folder.append(child);
|
|
97
|
+
|
|
98
|
+
expect(getUploadDestinationFromTarget(child, filePath("/workspace"))).toBe(
|
|
99
|
+
"/workspace/data",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("uses the workspace root outside a folder", () => {
|
|
104
|
+
expect(
|
|
105
|
+
getUploadDestinationFromTarget(
|
|
106
|
+
document.createElement("div"),
|
|
107
|
+
filePath("/workspace"),
|
|
108
|
+
),
|
|
109
|
+
).toBe("/workspace");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("uploadFilesToDestination", () => {
|
|
114
|
+
it("sends files to the destination and separates server failures", async () => {
|
|
115
|
+
const successfulFile = new File(["success"], "success.txt");
|
|
116
|
+
const failedFile = new File(["failure"], "failure.txt");
|
|
117
|
+
Object.defineProperty(successfulFile, "path", {
|
|
118
|
+
value: "nested/success.txt",
|
|
119
|
+
});
|
|
120
|
+
const createFile = vi.fn(
|
|
121
|
+
async (request: FileCreateInput): Promise<FileCreateResponse> =>
|
|
122
|
+
request.name === "success.txt"
|
|
123
|
+
? { success: true }
|
|
124
|
+
: { success: false, message: "Permission denied" },
|
|
125
|
+
);
|
|
126
|
+
const onFileProcessed = vi.fn();
|
|
127
|
+
|
|
128
|
+
const result = await uploadFilesToDestination({
|
|
129
|
+
files: [successfulFile, failedFile],
|
|
130
|
+
destinationPath: filePath("/workspace/data"),
|
|
131
|
+
createFile,
|
|
132
|
+
onFileProcessed,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(createFile.mock.calls.map(([request]) => request)).toEqual([
|
|
136
|
+
{
|
|
137
|
+
path: "/workspace/data/nested",
|
|
138
|
+
type: "file",
|
|
139
|
+
name: "success.txt",
|
|
140
|
+
file: successfulFile,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
path: "/workspace/data",
|
|
144
|
+
type: "file",
|
|
145
|
+
name: "failure.txt",
|
|
146
|
+
file: failedFile,
|
|
147
|
+
},
|
|
148
|
+
]);
|
|
149
|
+
expect(result).toEqual({
|
|
150
|
+
successful: [{ file: successfulFile, response: { success: true } }],
|
|
151
|
+
failed: [
|
|
152
|
+
{
|
|
153
|
+
file: failedFile,
|
|
154
|
+
response: { success: false, message: "Permission denied" },
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
expect(onFileProcessed).toHaveBeenCalledTimes(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("treats a browser-style leading slash as a relative drop path", async () => {
|
|
162
|
+
const browserFile = new File(["data"], "sales.csv");
|
|
163
|
+
Object.defineProperty(browserFile, "path", {
|
|
164
|
+
value: "/raw/2026/sales.csv",
|
|
165
|
+
});
|
|
166
|
+
const createFile = vi
|
|
167
|
+
.fn<(request: FileCreateInput) => Promise<FileCreateResponse>>()
|
|
168
|
+
.mockResolvedValue({ success: true });
|
|
169
|
+
|
|
170
|
+
await uploadFilesToDestination({
|
|
171
|
+
files: [browserFile],
|
|
172
|
+
destinationPath: filePath("/workspace/data"),
|
|
173
|
+
createFile,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(createFile).toHaveBeenCalledWith({
|
|
177
|
+
path: "/workspace/data/raw/2026",
|
|
178
|
+
type: "file",
|
|
179
|
+
name: "sales.csv",
|
|
180
|
+
file: browserFile,
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("waits for every file and normalizes thrown request errors", async () => {
|
|
185
|
+
const failedFile = new File(["failure"], "failure.txt");
|
|
186
|
+
const slowFile = new File(["success"], "slow.txt");
|
|
187
|
+
let finishSlowUpload: ((response: FileCreateResponse) => void) | undefined;
|
|
188
|
+
const createFile = vi.fn(
|
|
189
|
+
(request: FileCreateInput): Promise<FileCreateResponse> => {
|
|
190
|
+
if (request.name === "failure.txt") {
|
|
191
|
+
return Promise.reject(new Error("Connection lost"));
|
|
192
|
+
}
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
finishSlowUpload = resolve;
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
const onFileProcessed = vi.fn();
|
|
199
|
+
|
|
200
|
+
const upload = uploadFilesToDestination({
|
|
201
|
+
files: [failedFile, slowFile],
|
|
202
|
+
destinationPath: filePath("/workspace/data"),
|
|
203
|
+
createFile,
|
|
204
|
+
onFileProcessed,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await vi.waitFor(() => {
|
|
208
|
+
expect(createFile).toHaveBeenCalledTimes(2);
|
|
209
|
+
expect(onFileProcessed).toHaveBeenCalledTimes(1);
|
|
210
|
+
});
|
|
211
|
+
let hasSettled = false;
|
|
212
|
+
void upload.finally(() => {
|
|
213
|
+
hasSettled = true;
|
|
214
|
+
});
|
|
215
|
+
await Promise.resolve();
|
|
216
|
+
expect(hasSettled).toBe(false);
|
|
217
|
+
|
|
218
|
+
finishSlowUpload?.({ success: true });
|
|
219
|
+
await expect(upload).resolves.toEqual({
|
|
220
|
+
successful: [{ file: slowFile, response: { success: true } }],
|
|
221
|
+
failed: [
|
|
222
|
+
{
|
|
223
|
+
file: failedFile,
|
|
224
|
+
response: { success: false, message: "Connection lost" },
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
});
|
|
228
|
+
expect(onFileProcessed).toHaveBeenCalledTimes(2);
|
|
229
|
+
});
|
|
230
|
+
});
|