Skip to content

Commit 1784871

Browse files
authored
feat: upload files directly into folders (#10637)
## 📝 Summary File uploads in the files sidebar always targeted an inferred location, which made it difficult to place files directly into an existing folder and gave little feedback about the chosen destination. This change makes the workspace root or a selected folder an explicit upload destination. Drag-and-drop now highlights the target folder, toolbar and context-menu uploads respect folder selection, and only the affected destination is refreshed. Upload batches wait for every request and report partial failures accurately, while preserving safe relative directory structure for folder drops. ### Visual evidence https://github.com/user-attachments/assets/c787f823-9a40-490c-997c-21626f8ca74c ## 📋 Pre-Review Checklist - [ ] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). - [ ] Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it. - [x] Video or media evidence is provided for any visual changes (optional). ## ✅ Merge Checklist - [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [x] Documentation has been updated where applicable, including docstrings for API changes. - [x] Tests have been added for the changes made. > Written by GPT-5 on Codex
1 parent 075ebda commit 1784871

8 files changed

Lines changed: 860 additions & 81 deletions

File tree

frontend/src/components/editor/chrome/panels/file-explorer-panel.tsx

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,25 @@
22

33
import { useAtom, useAtomValue } from "jotai";
44
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";
67
import useResizeObserver from "use-resize-observer";
78
import { StorageInspector } from "@/components/storage/storage-inspector";
89
import { Accordion } from "@/components/ui/accordion";
910
import { storageNamespacesAtom } from "@/core/storage/state";
1011
import { useDetectedDataSources } from "@/hooks/useDataSourceDiscovery";
1112
import { cn } from "@/utils/cn";
13+
import type { FilePath } from "@/utils/paths";
1214
import { TreeDndProvider } from "../../file-tree/dnd-wrapper";
13-
import { FileExplorer } from "../../file-tree/file-explorer";
14-
import { useFileExplorerUpload } from "../../file-tree/upload";
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";
1524
import {
1625
DiscoveredSourcesBadge,
1726
PanelAccordionContent,
@@ -25,10 +34,37 @@ import {
2534
} from "./panel-accordion-state";
2635

2736
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+
);
2850
const { getRootProps, getInputProps, isDragActive } = useFileExplorerUpload({
2951
noClick: true,
3052
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),
3162
});
63+
const displayedDestinationPath = dropDestinationPath ?? tree.getRootPath();
64+
const displayedDestinationLabel = getUploadDestinationLabel(
65+
tree,
66+
displayedDestinationPath,
67+
);
3268

3369
return (
3470
<TreeDndProvider>
@@ -39,17 +75,34 @@ const FileExplorerComponent: React.FC<{ height: number }> = ({ height }) => {
3975
>
4076
<input {...getInputProps()} />
4177
{isDragActive && (
42-
<div className="absolute inset-0 flex items-center uppercase justify-center text-xl font-bold text-primary/90 bg-accent/85 z-10 border-2 border-dashed border-primary/90 rounded-lg pointer-events-none">
43-
Drop files here
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>
4482
</div>
4583
)}
4684

47-
<FileExplorer height={height} />
85+
<FileExplorer
86+
height={height}
87+
externalDropDestinationPath={
88+
isDragActive ? displayedDestinationPath : null
89+
}
90+
/>
4891
</div>
4992
</TreeDndProvider>
5093
);
5194
};
5295

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+
53106
// Height of each accordion trigger (px-3 py-2 text-xs = ~33px)
54107
const TRIGGER_HEIGHT = 33;
55108

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

frontend/src/components/editor/file-tree/__tests__/requesting-tree.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe("RequestingTree", () => {
5252

5353
test("initialize should load files and set rootPath", async () => {
5454
expect(sendListFiles).toHaveBeenCalledWith({ path: "" });
55+
expect(requestingTree.getRootPath()).toBe("/root");
5556
expect(mockOnChange).toHaveBeenCalledWith([
5657
{ id: "1.1", name: "file1", path: "/root/file1" },
5758
{ id: "1.2", name: "folder1", isDirectory: true, path: "/root/folder1" },
@@ -261,6 +262,65 @@ describe("RequestingTree", () => {
261262
`);
262263
});
263264

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+
264324
describe("when API fails", () => {
265325
test("initialize should handle errors gracefully", async () => {
266326
requestingTree = new RequestingTree({

0 commit comments

Comments
 (0)