From ab1acff2d294f2c4526bcdab52f0e6bc7501bc78 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 5 May 2026 20:30:25 -0700 Subject: [PATCH] ux(canvas/files): drag-drop upload to target folder (#2999 PR-D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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/. 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) --- canvas/src/components/tabs/FilesTab.tsx | 73 +++++- .../src/components/tabs/FilesTab/FileTree.tsx | 97 +++++++- .../__tests__/FileTreeContextMenu.test.tsx | 3 +- .../__tests__/dragDropUpload.test.tsx | 212 ++++++++++++++++++ .../components/tabs/FilesTab/useFilesApi.ts | 151 ++++++++++++- 5 files changed, 517 insertions(+), 19 deletions(-) create mode 100644 canvas/src/components/tabs/FilesTab/__tests__/dragDropUpload.test.tsx diff --git a/canvas/src/components/tabs/FilesTab.tsx b/canvas/src/components/tabs/FilesTab.tsx index 79059be5..e1ee3bfd 100644 --- a/canvas/src/components/tabs/FilesTab.tsx +++ b/canvas/src/components/tabs/FilesTab.tsx @@ -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//files/, 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 }) { )}
- {/* File tree */} -
+ {/* 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 + stop propagation on their own drop event so a drop on + /configs/skills doesn't ALSO fire root-area drop. */} +
{ + // 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 && (
@@ -243,7 +305,11 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) { {files.length === 0 ? (
- 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"}
) : ( {}} onDownload={downloadFileByPath} canDelete={root === "/configs"} + onDropToTarget={handleDropToTarget} expandedDirs={expandedDirs} onToggleDir={toggleDir} loadingDir={loadingDir} diff --git a/canvas/src/components/tabs/FilesTab/FileTree.tsx b/canvas/src/components/tabs/FilesTab/FileTree.tsx index 32d56ebe..50bc0760 100644 --- a/canvas/src/components/tabs/FilesTab/FileTree.tsx +++ b/canvas/src/components/tabs/FilesTab/FileTree.tsx @@ -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; 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(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 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 (
onToggleDir(node.path)} onContextMenu={(e) => openContextMenu(e, node)} + {...dragProps} > {isLoading ? "…" : expanded ? "▼" : "▶"} 📁 @@ -159,6 +231,7 @@ function TreeItem({ onDelete={onDelete} onDownload={onDownload} canDelete={canDelete} + onDropToTarget={onDropToTarget} expandedDirs={expandedDirs} onToggleDir={onToggleDir} loadingDir={loadingDir} diff --git a/canvas/src/components/tabs/FilesTab/__tests__/FileTreeContextMenu.test.tsx b/canvas/src/components/tabs/FilesTab/__tests__/FileTreeContextMenu.test.tsx index 73a4c4b1..059c49f7 100644 --- a/canvas/src/components/tabs/FilesTab/__tests__/FileTreeContextMenu.test.tsx +++ b/canvas/src/components/tabs/FilesTab/__tests__/FileTreeContextMenu.test.tsx @@ -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> = {}) { diff --git a/canvas/src/components/tabs/FilesTab/__tests__/dragDropUpload.test.tsx b/canvas/src/components/tabs/FilesTab/__tests__/dragDropUpload.test.tsx new file mode 100644 index 00000000..ee3cbd38 --- /dev/null +++ b/canvas/src/components/tabs/FilesTab/__tests__/dragDropUpload.test.tsx @@ -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[][], +): { + 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> = {}) { + // 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 = { + nodes: [file, skillsDir], + selectedPath: null, + onSelect: vi.fn(), + onDelete: vi.fn(), + onDownload: vi.fn(), + canDelete: true, + onDropToTarget: vi.fn(), + expandedDirs: new Set(), + onToggleDir: vi.fn(), + loadingDir: null, + }; + const merged = { ...defaults, ...props }; + return { ...render(), 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/); + }); +}); diff --git a/canvas/src/components/tabs/FilesTab/useFilesApi.ts b/canvas/src/components/tabs/FilesTab/useFilesApi.ts index b1aabbf6..83713540 100644 --- a/canvas/src/components/tabs/FilesTab/useFilesApi.ts +++ b/canvas/src/components/tabs/FilesTab/useFilesApi.ts @@ -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 "/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 { + 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 { + const name = entry.name; + const relPath = prefix ? `${prefix}/${name}` : name; + if (entry.isFile && entry.file) { + const file = await new Promise((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((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 };