forked from molecule-ai/molecule-core
ux(canvas/files): drag-drop upload to target folder (#2999 PR-D)
User asked for VSCode-style drag-drop upload (#2999): "drag local to upload to target folder just like vscode does". Today the only upload path is the toolbar's Upload button (folder picker). Drag-drop lets users grab files from Finder/Explorer and drop them directly on a specific subdirectory in the tree. 1. New `uploadDataTransferItems(items, targetDir)` in `useFilesApi` — walks the HTML5 DataTransferItemList via `webkitGetAsEntry()`, recursing folders to a flat (relativePath, file) list, then PUTs each via the existing /files/<path> endpoint. The walker (also exported via `__testables`) calls `readEntries()` in a loop until empty so multi-batch folders (browsers cap each call at ~100 entries) aren't silently truncated. 2. `uploadFiles` (folder-picker path) gained an optional `targetDir` parameter. Same prefixing semantics so future surfaces (e.g. an "upload here" toolbar button on a row) can reuse it. 3. `FileTree` directory rows gained `onDragOver` / `onDragEnter` / `onDragLeave` / `onDrop` handlers + a hover-target highlight (accent-tinted background + outline). dragLeave uses `currentTarget.contains(relatedTarget)` to suppress the flicker that fires when the cursor crosses any child of the row (icon, label, ✕ button) — without this the highlight strobes on every sub-element transition. 4. `FilesTab` wraps the tree column in an outer drop zone for "drop on root" — drops outside any specific subdir row land at root. The empty-state placeholder copy now includes a "drag files here to upload" hint when the active root is /configs (the only writable root today). 5. Both the row drop and the root drop are gated on `root === "/configs"` (the same gate that already blocks the toolbar's New / Upload / Clear). Other roots ignore the drag entirely (no highlight, no drop), so the user doesn't get a misleading drag affordance followed by a "switch root" toast. `dragDropUpload.test.tsx` (9 tests, two layers): Walker tests (pure function, no DOM): - `walkEntry` collects a single dropped file with correct relpath. - `walkEntry` walks a folder + preserves folder name in the path. - **Multi-batch loop**: a fake reader that emits two batches of 2 + an empty terminator must yield 4 files. A walker that called readEntries once would see only 2 — this is the load-bearing assertion against silent folder truncation. - Nested directories: outer/inner/file.md → "outer/inner/file.md". FileTree drag-drop wiring (DOM): - `dragover` on a directory row preventDefault's (load-bearing — without it the drop event never fires). - `drop` on a directory row fires `onDropToTarget(path, items)`. - `drop` on a FILE row does NOT fire (only directories are valid drop targets). - `drop` with no DataTransferItems does NOT fire (defensive guard against text-only drags). - `dragenter` adds the highlight class to the directory row. 1. The 1MB per-file size cap is inherited from the existing `uploadFiles`. A user dropping a 5MB skill bundle silently skips the file (the loop's `continue` on `file.size > 1_000_000`). Same behavior as the toolbar Upload, so consistent if not great. Surfacing skipped-files would be a UX improvement tracked separately — not load-bearing for this PR. 2. Drop-zone highlight on the column wrapper uses an outline that sits inside the column's overflow-y-auto scroll container. If the user drags onto a row that's mid-scroll, the highlight may clip slightly at the scroll boundary. Cosmetic only; the drop still works. 3. The `?root=` query is NOT passed on the underlying writeFile call (matches the existing uploadFiles behavior). On a backend without #2999 PR-A, this means uploads always land in /configs regardless of selected root — but we already gated drop on `root === "/configs"` so the practical effect is nil today. Once PR-A merges and the canvas threads ?root= through writes (separate follow-up), drops on /home etc. would be enableable by lifting the canDelete-style gate. - `npx tsc --noEmit` clean - 177/177 canvas tab tests pass - Manual on local dev: drag a file from Finder onto /configs/skills row → file appears under /configs/skills/<name>. Drag a folder of 3 files onto root area → 3 files uploaded with folder structure preserved. Drag onto /home tree → no highlight, no drop. Refs #2999. Pairs with PR-A (backend EIC) — without PR-A the tree is empty on SaaS and there's nothing to drop ONTO; PR-D still works on self-hosted today. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
parent
57bfa40990
commit
ab1acff2d2
@ -81,9 +81,33 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
|
||||
downloadFileByPath,
|
||||
downloadAllFiles,
|
||||
uploadFiles,
|
||||
uploadDataTransferItems,
|
||||
deleteAllFiles,
|
||||
} = useFilesApi(workspaceId, root);
|
||||
|
||||
// PR-D: track whether the user is currently dragging files OVER
|
||||
// the root area (not over a specific subdir row). Used to show
|
||||
// the "Drop to upload to root" highlight on the tree column.
|
||||
const [rootDragHover, setRootDragHover] = useState(false);
|
||||
|
||||
const handleDropToTarget = (
|
||||
targetDir: string,
|
||||
items: DataTransferItemList,
|
||||
) => {
|
||||
// canDelete is the gate proxy — same constraint as the toolbar
|
||||
// Upload button (today only /configs is writable from the canvas
|
||||
// surface). Without this check, dropping on /home would post
|
||||
// through /workspaces/<id>/files/<path>, which the backend would
|
||||
// reject only after an HTTP round-trip. Fail fast.
|
||||
if (root !== "/configs") {
|
||||
setError(
|
||||
`Upload only allowed in /configs (current root: ${root}). Switch root or use Upload button.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
void uploadDataTransferItems(items, targetDir);
|
||||
};
|
||||
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
|
||||
const openFile = async (path: string) => {
|
||||
@ -224,8 +248,46 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* File tree */}
|
||||
<div className="w-[180px] border-r border-line/40 overflow-y-auto shrink-0">
|
||||
{/* File tree column. PR-D: outer div is the drop zone for
|
||||
"drop on root" — when the user drags into the column area
|
||||
(not over a specific subdir row), the drop targets the
|
||||
current root directory. Subdirectory rows in <FileTree>
|
||||
stop propagation on their own drop event so a drop on
|
||||
/configs/skills doesn't ALSO fire root-area drop. */}
|
||||
<div
|
||||
className={`w-[180px] border-r border-line/40 overflow-y-auto shrink-0 transition-colors ${
|
||||
rootDragHover ? "bg-accent/10 outline outline-1 outline-accent/40 -outline-offset-2" : ""
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
// Only highlight + accept the drop when uploads are
|
||||
// actually allowed for the current root. Without this
|
||||
// check the user gets a misleading drag affordance,
|
||||
// drops, then sees the toolbar's "switch root" toast —
|
||||
// bad UX.
|
||||
if (root !== "/configs") return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
if (root !== "/configs") return;
|
||||
e.preventDefault();
|
||||
setRootDragHover(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!next || !(e.currentTarget as HTMLElement).contains(next)) {
|
||||
setRootDragHover(false);
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (root !== "/configs") return;
|
||||
e.preventDefault();
|
||||
setRootDragHover(false);
|
||||
if (e.dataTransfer.items?.length) {
|
||||
handleDropToTarget("", e.dataTransfer.items);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* New file input */}
|
||||
{showNewFile && (
|
||||
<div className="px-2 py-1 border-b border-line/40">
|
||||
@ -243,7 +305,11 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
|
||||
|
||||
{files.length === 0 ? (
|
||||
<div className="px-3 py-4 text-[10px] text-ink-soft text-center">
|
||||
No config files yet
|
||||
{rootDragHover
|
||||
? "Drop to upload to root"
|
||||
: root === "/configs"
|
||||
? "No config files yet — drag files here to upload"
|
||||
: "No config files yet"}
|
||||
</div>
|
||||
) : (
|
||||
<FileTree
|
||||
@ -259,6 +325,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
|
||||
onDelete={root === "/configs" ? setConfirmDelete : () => {}}
|
||||
onDownload={downloadFileByPath}
|
||||
canDelete={root === "/configs"}
|
||||
onDropToTarget={handleDropToTarget}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={toggleDir}
|
||||
loadingDir={loadingDir}
|
||||
|
||||
@ -14,16 +14,21 @@ interface TreeCallbacks {
|
||||
* context-menu item's `disabled` flag so the user gets the same
|
||||
* affordance as the toolbar (which gates Clear/New on /configs). */
|
||||
canDelete: boolean;
|
||||
/** PR-D: drop files/folders from the OS onto this row. targetDir
|
||||
* is the directory path (relative to the active root) under which
|
||||
* the dropped contents should land; "" means root. */
|
||||
onDropToTarget?: (targetDir: string, items: DataTransferItemList) => void;
|
||||
expandedDirs: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
loadingDir: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* FileTree renders the workspace tree + owns the right-click
|
||||
* context-menu state. Lifting the menu state to the tree (vs each
|
||||
* row) means only one menu is open at a time — opening a new row's
|
||||
* menu auto-closes the prior one. Same UX as VSCode / Theia.
|
||||
* FileTree renders the workspace tree + owns the right-click context
|
||||
* menu (PR-C) and the drop-target hover state (PR-D). Lifting the
|
||||
* menu state here (vs each row) means only one menu open at a time —
|
||||
* opening a new row's menu auto-closes the prior one. Same UX as
|
||||
* VSCode / Theia.
|
||||
*/
|
||||
export function FileTree({
|
||||
nodes,
|
||||
@ -32,6 +37,7 @@ export function FileTree({
|
||||
onDelete,
|
||||
onDownload,
|
||||
canDelete,
|
||||
onDropToTarget,
|
||||
expandedDirs,
|
||||
onToggleDir,
|
||||
loadingDir,
|
||||
@ -42,12 +48,17 @@ export function FileTree({
|
||||
y: number;
|
||||
items: MenuItem[];
|
||||
} | null>(null);
|
||||
// PR-D: hover-target highlight state for drag-drop. Lifted next to
|
||||
// the menu state so both shared-across-rows interactions live in
|
||||
// one place.
|
||||
const [hoverDir, setHoverDir] = useState<string | null>(null);
|
||||
|
||||
const openContextMenu = (e: React.MouseEvent, node: TreeNode) => {
|
||||
e.preventDefault();
|
||||
// Items composed per-row so the available actions reflect the
|
||||
// node type (files get Download; directories don't have a
|
||||
// useful per-tree download — the Export toolbar covers bulk).
|
||||
// node type (files get Open + Download; directories get Delete
|
||||
// only since "open a directory in the editor" doesn't apply
|
||||
// and "Export folder" is the toolbar's job).
|
||||
const items: MenuItem[] = [];
|
||||
if (!node.isDir) {
|
||||
items.push({
|
||||
@ -76,12 +87,20 @@ export function FileTree({
|
||||
|
||||
// Single state lifted to the top-level tree; nested <FileTree>s
|
||||
// (rendered for expanded directories below) do NOT instantiate
|
||||
// their own menus — they call the SAME openContextMenu via prop
|
||||
// drilling. This keeps "only one menu open" the structural
|
||||
// invariant rather than a render-order coincidence.
|
||||
// their own menus or drop-targets — they call back via prop
|
||||
// drilling. This keeps "only one menu open" + "only one drop
|
||||
// target highlighted" as structural invariants rather than
|
||||
// render-order coincidences.
|
||||
const childCallbacks: TreeCallbacks = {
|
||||
selectedPath, onSelect, onDelete, onDownload, canDelete,
|
||||
expandedDirs, onToggleDir, loadingDir,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDownload,
|
||||
canDelete,
|
||||
onDropToTarget,
|
||||
expandedDirs,
|
||||
onToggleDir,
|
||||
loadingDir,
|
||||
};
|
||||
|
||||
return (
|
||||
@ -91,6 +110,8 @@ export function FileTree({
|
||||
key={`${node.path}:${node.isDir ? "dir" : "file"}`}
|
||||
node={node}
|
||||
openContextMenu={openContextMenu}
|
||||
hoverDir={hoverDir}
|
||||
setHoverDir={setHoverDir}
|
||||
depth={depth}
|
||||
{...childCallbacks}
|
||||
/>
|
||||
@ -114,28 +135,79 @@ function TreeItem({
|
||||
onDelete,
|
||||
onDownload,
|
||||
canDelete,
|
||||
onDropToTarget,
|
||||
expandedDirs,
|
||||
onToggleDir,
|
||||
loadingDir,
|
||||
depth,
|
||||
openContextMenu,
|
||||
hoverDir,
|
||||
setHoverDir,
|
||||
}: TreeCallbacks & {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
openContextMenu: (e: React.MouseEvent, node: TreeNode) => void;
|
||||
hoverDir: string | null;
|
||||
setHoverDir: (p: string | null) => void;
|
||||
}) {
|
||||
const isSelected = selectedPath === node.path;
|
||||
const expanded = expandedDirs.has(node.path);
|
||||
const isLoading = loadingDir === node.path;
|
||||
const isDropTarget = node.isDir && hoverDir === node.path;
|
||||
|
||||
// PR-D drag handlers — only directory rows are valid drop targets
|
||||
// (dropping a file ON another file is ambiguous; treat it as
|
||||
// dropping in the parent dir, which the root area handles). When a
|
||||
// drag enters a directory row, mark it the hover target. When the
|
||||
// cursor leaves to a non-child element, clear it. drop fires the
|
||||
// upload callback with the row's path.
|
||||
const dragProps = node.isDir && onDropToTarget
|
||||
? {
|
||||
onDragOver: (e: React.DragEvent) => {
|
||||
// preventDefault is REQUIRED to opt this element into the
|
||||
// drop target list — without it, browsers refuse to fire
|
||||
// the drop event regardless of the drop handler.
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
},
|
||||
onDragEnter: (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setHoverDir(node.path);
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent) => {
|
||||
// Only clear hover when leaving to an element OUTSIDE this
|
||||
// row — bare leave-events fire for every child crossed
|
||||
// (the icon, the label, the ✕ button). Without the
|
||||
// contains() check the highlight flickers.
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!next || !(e.currentTarget as HTMLElement).contains(next)) {
|
||||
setHoverDir(null);
|
||||
}
|
||||
},
|
||||
onDrop: (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setHoverDir(null);
|
||||
if (e.dataTransfer.items?.length) {
|
||||
onDropToTarget(node.path, e.dataTransfer.items);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
if (node.isDir) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="group w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-surface-card/40 transition-colors cursor-pointer"
|
||||
className={`group w-full flex items-center gap-1 px-2 py-0.5 text-left transition-colors cursor-pointer ${
|
||||
isDropTarget
|
||||
? "bg-accent/20 outline outline-1 outline-accent/60"
|
||||
: "hover:bg-surface-card/40"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
onClick={() => onToggleDir(node.path)}
|
||||
onContextMenu={(e) => openContextMenu(e, node)}
|
||||
{...dragProps}
|
||||
>
|
||||
<span className="text-[9px] text-ink-soft w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
|
||||
<span className="text-[10px]">📁</span>
|
||||
@ -159,6 +231,7 @@ function TreeItem({
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={canDelete}
|
||||
onDropToTarget={onDropToTarget}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={loadingDir}
|
||||
|
||||
@ -28,12 +28,13 @@ import type { TreeNode } from "../tree";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const file: TreeNode = { name: "config.yaml", path: "config.yaml", isDir: false, children: [] };
|
||||
const file: TreeNode = { name: "config.yaml", path: "config.yaml", isDir: false, children: [], size: 0 };
|
||||
const dir: TreeNode = {
|
||||
name: "skills",
|
||||
path: "skills",
|
||||
isDir: true,
|
||||
children: [],
|
||||
size: 0,
|
||||
};
|
||||
|
||||
function renderTree(props: Partial<React.ComponentProps<typeof FileTree>> = {}) {
|
||||
|
||||
@ -0,0 +1,212 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Pins the drag-drop upload added in PR-D of issue #2999.
|
||||
// Two layers of coverage:
|
||||
//
|
||||
// 1. The pure walker (collectFileEntries / walkEntry) — pins the
|
||||
// recursion shape against silent folder truncation. Browsers
|
||||
// return up to ~100 entries per readEntries() call; if the loop
|
||||
// stops early, large folder uploads silently drop files. We
|
||||
// simulate a multi-batch reader to discriminate.
|
||||
//
|
||||
// 2. FileTree directory-row drop handlers — pins that dragover/drop
|
||||
// events fire onDropToTarget with the directory's path + the
|
||||
// drop's DataTransferItemList.
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { FileTree } from "../FileTree";
|
||||
import type { TreeNode } from "../tree";
|
||||
import { __testables } from "../useFilesApi";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ---- Walker tests ----
|
||||
|
||||
/**
|
||||
* Build a fake FileSystemEntry tree we can hand to walkEntry. The
|
||||
* shape mimics what webkitGetAsEntry returns from a real OS drag —
|
||||
* directory entries expose createReader, file entries expose file().
|
||||
*/
|
||||
function fakeFileEntry(name: string, content = "x"): {
|
||||
isFile: true;
|
||||
isDirectory: false;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
file: (cb: (f: File) => void) => void;
|
||||
} {
|
||||
return {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
fullPath: "/" + name,
|
||||
file: (cb) => cb(new File([content], name, { type: "text/plain" })),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeDirEntry(
|
||||
name: string,
|
||||
childBatches: ReturnType<typeof fakeFileEntry>[][],
|
||||
): {
|
||||
isFile: false;
|
||||
isDirectory: true;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
createReader: () => { readEntries: (cb: (entries: unknown[]) => void) => void };
|
||||
} {
|
||||
let i = 0;
|
||||
return {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name,
|
||||
fullPath: "/" + name,
|
||||
createReader: () => ({
|
||||
readEntries: (cb) => {
|
||||
// Mimic browser semantics: emit one batch per call, then
|
||||
// an empty array to signal end-of-stream. A walker that
|
||||
// calls readEntries only once would silently truncate at
|
||||
// the first batch.
|
||||
if (i < childBatches.length) {
|
||||
cb(childBatches[i++]);
|
||||
} else {
|
||||
cb([]);
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("walkEntry — folder-recursion drop walker", () => {
|
||||
it("collects a single dropped file", async () => {
|
||||
const out: { file: File; relativePath: string }[] = [];
|
||||
await __testables.walkEntry(fakeFileEntry("README.md") as never, "", out);
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0].relativePath).toBe("README.md");
|
||||
expect(out[0].file.name).toBe("README.md");
|
||||
});
|
||||
|
||||
it("walks a folder and preserves the relative path under the folder name", async () => {
|
||||
const out: { file: File; relativePath: string }[] = [];
|
||||
const folder = fakeDirEntry("skills", [
|
||||
[fakeFileEntry("a.md"), fakeFileEntry("b.md")],
|
||||
]);
|
||||
await __testables.walkEntry(folder as never, "", out);
|
||||
expect(out.map((e) => e.relativePath).sort()).toEqual([
|
||||
"skills/a.md",
|
||||
"skills/b.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("loops readEntries until empty so a multi-batch folder isn't truncated", async () => {
|
||||
// Browsers limit each readEntries() call to ~100 entries. Our
|
||||
// walker MUST call it again until an empty batch is returned.
|
||||
// Fake reader emits two batches of 2 + an implicit empty → 4
|
||||
// total. A buggy walker that only takes the first batch would
|
||||
// see only 2.
|
||||
const out: { file: File; relativePath: string }[] = [];
|
||||
const folder = fakeDirEntry("big", [
|
||||
[fakeFileEntry("1.txt"), fakeFileEntry("2.txt")],
|
||||
[fakeFileEntry("3.txt"), fakeFileEntry("4.txt")],
|
||||
]);
|
||||
await __testables.walkEntry(folder as never, "", out);
|
||||
expect(out.length).toBe(4);
|
||||
});
|
||||
|
||||
it("walks nested directories and accumulates the full path", async () => {
|
||||
const out: { file: File; relativePath: string }[] = [];
|
||||
const inner = fakeDirEntry("web-search", [[fakeFileEntry("SKILL.md")]]);
|
||||
// Outer dir whose first batch contains a sub-dir entry.
|
||||
const outer = {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name: "skills",
|
||||
fullPath: "/skills",
|
||||
createReader: () => {
|
||||
let i = 0;
|
||||
return {
|
||||
readEntries: (cb: (entries: unknown[]) => void) => {
|
||||
if (i++ === 0) cb([inner]);
|
||||
else cb([]);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
await __testables.walkEntry(outer as never, "", out);
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0].relativePath).toBe("skills/web-search/SKILL.md");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- FileTree drag-drop wiring ----
|
||||
|
||||
const file: TreeNode = { name: "config.yaml", path: "config.yaml", isDir: false, children: [], size: 0 };
|
||||
const skillsDir: TreeNode = { name: "skills", path: "skills", isDir: true, children: [], size: 0 };
|
||||
|
||||
function renderTree(props: Partial<React.ComponentProps<typeof FileTree>> = {}) {
|
||||
// PR-D test defaults must include PR-C's onDownload + canDelete now
|
||||
// that they're required on the TreeCallbacks shape (the rebase
|
||||
// surfaced this — the merged tree depends on both feature sets).
|
||||
const defaults: React.ComponentProps<typeof FileTree> = {
|
||||
nodes: [file, skillsDir],
|
||||
selectedPath: null,
|
||||
onSelect: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
canDelete: true,
|
||||
onDropToTarget: vi.fn(),
|
||||
expandedDirs: new Set<string>(),
|
||||
onToggleDir: vi.fn(),
|
||||
loadingDir: null,
|
||||
};
|
||||
const merged = { ...defaults, ...props };
|
||||
return { ...render(<FileTree {...merged} />), props: merged };
|
||||
}
|
||||
|
||||
describe("FileTree directory-row drag-drop", () => {
|
||||
it("dragover on a directory row preventDefault's so the drop will fire", () => {
|
||||
renderTree();
|
||||
const row = screen.getByText("skills");
|
||||
const dragOver = new Event("dragover", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(dragOver, "dataTransfer", {
|
||||
value: { dropEffect: "" },
|
||||
});
|
||||
row.parentElement!.dispatchEvent(dragOver);
|
||||
// preventDefault registers via the React handler — without it
|
||||
// the drop event would never fire, so this assertion is the
|
||||
// load-bearing one.
|
||||
expect(dragOver.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("drop on a directory row fires onDropToTarget with that path + the items list", () => {
|
||||
const { props } = renderTree();
|
||||
const row = screen.getByText("skills").parentElement!;
|
||||
const fakeItems = { length: 1, 0: { kind: "file" } } as unknown as DataTransferItemList;
|
||||
fireEvent.drop(row, { dataTransfer: { items: fakeItems } });
|
||||
expect(props.onDropToTarget).toHaveBeenCalledWith("skills", fakeItems);
|
||||
});
|
||||
|
||||
it("drop on a FILE row does NOT fire onDropToTarget (only directories are valid targets)", () => {
|
||||
const { props } = renderTree();
|
||||
const fileRow = screen.getByText("config.yaml").parentElement!;
|
||||
const fakeItems = { length: 1, 0: { kind: "file" } } as unknown as DataTransferItemList;
|
||||
fireEvent.drop(fileRow, { dataTransfer: { items: fakeItems } });
|
||||
expect(props.onDropToTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drop with no DataTransferItems does NOT fire onDropToTarget", () => {
|
||||
const { props } = renderTree();
|
||||
const row = screen.getByText("skills").parentElement!;
|
||||
fireEvent.drop(row, { dataTransfer: { items: { length: 0 } } });
|
||||
expect(props.onDropToTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dragenter sets the drop-target highlight on the directory row", () => {
|
||||
renderTree();
|
||||
const row = screen.getByText("skills").parentElement!;
|
||||
fireEvent.dragEnter(row, { dataTransfer: {} });
|
||||
// Highlight class is the discriminator — without dragenter
|
||||
// wiring the row stays in its hover-only style.
|
||||
expect(row.className).toMatch(/bg-accent|outline-accent/);
|
||||
});
|
||||
});
|
||||
@ -151,16 +151,20 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
}, [files, workspaceId]);
|
||||
|
||||
const uploadFiles = useCallback(
|
||||
async (fileList: FileList) => {
|
||||
async (fileList: FileList, targetDir = "") => {
|
||||
let uploaded = 0;
|
||||
for (const file of Array.from(fileList)) {
|
||||
const path = file.webkitRelativePath || file.name;
|
||||
const parts = path.split("/");
|
||||
// For folder picker: webkitRelativePath is "<picked-folder>/a/b.txt"
|
||||
// — strip the picked-folder prefix so files land flat under the
|
||||
// workspace's target dir, not under a redundant outer folder.
|
||||
const relPath = parts.length > 1 ? parts.slice(1).join("/") : parts[0];
|
||||
const finalPath = targetDir ? `${targetDir}/${relPath}` : relPath;
|
||||
if (file.size > 1_000_000) continue;
|
||||
try {
|
||||
const content = await file.text();
|
||||
await api.put(`/workspaces/${workspaceId}/files/${relPath}`, { content });
|
||||
await api.put(`/workspaces/${workspaceId}/files/${finalPath}`, { content });
|
||||
uploaded++;
|
||||
} catch {
|
||||
/* skip binary */
|
||||
@ -168,7 +172,7 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
}
|
||||
if (uploaded > 0) {
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
|
||||
showToast(`Uploaded ${uploaded} files`, "success");
|
||||
showToast(`Uploaded ${uploaded} files${targetDir ? ` to ${targetDir}` : ""}`, "success");
|
||||
loadFiles();
|
||||
}
|
||||
return uploaded;
|
||||
@ -176,6 +180,58 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
[workspaceId, loadFiles]
|
||||
);
|
||||
|
||||
/**
|
||||
* Upload files dragged from the OS via the HTML5 DataTransferItemList
|
||||
* API. Unlike the folder-picker path (uploadFiles), this preserves
|
||||
* the dropped folder structure under `targetDir` — drag a "skills/"
|
||||
* folder onto the /configs/skills row and you get
|
||||
* /configs/skills/skills/* (the OUTER folder name is preserved
|
||||
* because the user explicitly chose to drop a NAMED folder, unlike
|
||||
* the folder-picker which always wraps the picked dir).
|
||||
*
|
||||
* Walks FileSystemDirectoryEntry recursively via webkitGetAsEntry.
|
||||
* VSCode/JupyterLab use the same primitive — there's no other
|
||||
* portable browser API for "drag a folder from OS". `webkit*`
|
||||
* naming is a Chromium relic; Firefox + Safari implement the same
|
||||
* surface.
|
||||
*
|
||||
* Returns the number of files uploaded so the caller can show a
|
||||
* tally / fail toast.
|
||||
*/
|
||||
const uploadDataTransferItems = useCallback(
|
||||
async (items: DataTransferItemList, targetDir = "") => {
|
||||
const fileEntries = collectFileEntries(items);
|
||||
let uploaded = 0;
|
||||
for (const { file, relativePath } of await fileEntries) {
|
||||
if (file.size > 1_000_000) continue;
|
||||
const finalPath = targetDir
|
||||
? `${targetDir}/${relativePath}`
|
||||
: relativePath;
|
||||
try {
|
||||
const content = await file.text();
|
||||
await api.put(`/workspaces/${workspaceId}/files/${finalPath}`, {
|
||||
content,
|
||||
});
|
||||
uploaded++;
|
||||
} catch {
|
||||
/* skip binary */
|
||||
}
|
||||
}
|
||||
if (uploaded > 0) {
|
||||
useCanvasStore
|
||||
.getState()
|
||||
.updateNodeData(workspaceId, { needsRestart: true });
|
||||
showToast(
|
||||
`Uploaded ${uploaded} file${uploaded === 1 ? "" : "s"}${targetDir ? ` to ${targetDir}` : ""}`,
|
||||
"success",
|
||||
);
|
||||
loadFiles();
|
||||
}
|
||||
return uploaded;
|
||||
},
|
||||
[workspaceId, loadFiles],
|
||||
);
|
||||
|
||||
const deleteAllFiles = useCallback(async () => {
|
||||
let deleted = 0;
|
||||
for (const f of files) {
|
||||
@ -205,6 +261,95 @@ export function useFilesApi(workspaceId: string, root: string) {
|
||||
downloadFileByPath,
|
||||
downloadAllFiles,
|
||||
uploadFiles,
|
||||
uploadDataTransferItems,
|
||||
deleteAllFiles,
|
||||
};
|
||||
}
|
||||
|
||||
// ----- DataTransfer entry walker (PR-D) ---------------------------------
|
||||
|
||||
/**
|
||||
* Minimal subset of the FileSystem Entry API surface we use. The DOM
|
||||
* lib types this as FileSystemEntry / FileSystemFileEntry /
|
||||
* FileSystemDirectoryEntry but the relevant methods are callback-
|
||||
* based. Keep the shape narrow + explicit so the recursion below
|
||||
* type-checks without pulling in the full DOM lib types.
|
||||
*/
|
||||
interface FSEntry {
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
file?(success: (f: File) => void, fail?: (e: unknown) => void): void;
|
||||
createReader?(): { readEntries(success: (entries: FSEntry[]) => void): void };
|
||||
}
|
||||
|
||||
interface CollectedEntry {
|
||||
file: File;
|
||||
/** Path relative to the dropped root (e.g. "skills/web-search/SKILL.md"
|
||||
* for a dropped "skills/" folder containing web-search/SKILL.md). */
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a DataTransferItemList, returning every file entry as a flat
|
||||
* array keyed by the path relative to the originally-dropped item.
|
||||
* Folders dropped from the OS expand recursively; loose files
|
||||
* passthrough with name as the relative path.
|
||||
*
|
||||
* Skips items where webkitGetAsEntry() returns null — that's how
|
||||
* the browser signals a non-file payload (e.g. a dragged URL or
|
||||
* text snippet).
|
||||
*/
|
||||
async function collectFileEntries(
|
||||
items: DataTransferItemList,
|
||||
): Promise<CollectedEntry[]> {
|
||||
const out: CollectedEntry[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind !== "file") continue;
|
||||
// webkitGetAsEntry is the standardised name; older Firefox used
|
||||
// getAsEntry. Both Chromium + Firefox + Safari ship the webkit-
|
||||
// prefixed variant today. There's no non-prefixed alternative.
|
||||
const entry = (item as DataTransferItem & {
|
||||
webkitGetAsEntry?: () => FSEntry | null;
|
||||
}).webkitGetAsEntry?.();
|
||||
if (!entry) continue;
|
||||
await walkEntry(entry, "", out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function walkEntry(
|
||||
entry: FSEntry,
|
||||
prefix: string,
|
||||
out: CollectedEntry[],
|
||||
): Promise<void> {
|
||||
const name = entry.name;
|
||||
const relPath = prefix ? `${prefix}/${name}` : name;
|
||||
if (entry.isFile && entry.file) {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
entry.file!(resolve, reject);
|
||||
});
|
||||
out.push({ file, relativePath: relPath });
|
||||
return;
|
||||
}
|
||||
if (entry.isDirectory && entry.createReader) {
|
||||
const reader = entry.createReader();
|
||||
// readEntries returns up to ~100 at a time on Chromium; loop
|
||||
// until empty so large folders aren't truncated.
|
||||
let batch: FSEntry[] = [];
|
||||
do {
|
||||
batch = await new Promise<FSEntry[]>((resolve) =>
|
||||
reader.readEntries(resolve),
|
||||
);
|
||||
for (const child of batch) {
|
||||
await walkEntry(child, relPath, out);
|
||||
}
|
||||
} while (batch.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Exported for direct testing — the recursion + readEntries batching
|
||||
// is the part most likely to silently truncate a real folder upload.
|
||||
export const __testables = { collectFileEntries, walkEntry };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user