molecule-core/canvas/src/components/tabs/FilesTab/useFilesApi.ts
Hongming Wang ab1acff2d2 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)
2026-05-05 20:47:47 -07:00

356 lines
12 KiB
TypeScript

"use client";
import { useCallback, useRef, useState, useEffect } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { showToast } from "../../Toaster";
import type { FileEntry } from "./tree";
export function useFilesApi(workspaceId: string, root: string) {
const [files, setFiles] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [loadingDir, setLoadingDir] = useState<string | null>(null);
const expandedDirsRef = useRef(expandedDirs);
expandedDirsRef.current = expandedDirs;
const loadFiles = useCallback(async (subPath = "", depth = 1) => {
if (!subPath) setLoading(true);
else setLoadingDir(subPath);
try {
const params = new URLSearchParams({ root, depth: String(depth) });
if (subPath) params.set("path", subPath);
const data = await api.get<FileEntry[]>(`/workspaces/${workspaceId}/files?${params}`);
if (!subPath) {
// Root load — replace all
setFiles(data);
} else {
// Subfolder load — merge direct children only (preserve expanded grandchildren)
setFiles((prev) => {
const prefix = subPath + "/";
// Remove only direct children of this subPath (not deeper descendants)
const filtered = prev.filter((f) => {
if (!f.path.startsWith(prefix)) return true;
const remainder = f.path.slice(prefix.length);
// Keep entries that are nested deeper (grandchildren of other expanded dirs)
return remainder.includes("/");
});
const newFiles = data.map((f) => ({ ...f, path: subPath + "/" + f.path }));
return [...filtered, ...newFiles];
});
}
} catch {
if (!subPath) setFiles([]);
} finally {
setLoading(false);
setLoadingDir(null);
}
}, [workspaceId, root]);
const toggleDir = useCallback((dirPath: string) => {
const wasExpanded = expandedDirsRef.current.has(dirPath);
setExpandedDirs((prev) => {
const next = new Set(prev);
if (next.has(dirPath)) {
next.delete(dirPath);
} else {
next.add(dirPath);
}
return next;
});
if (!wasExpanded) {
loadFiles(dirPath, 1);
}
}, [loadFiles]);
useEffect(() => {
setExpandedDirs(new Set());
loadFiles();
}, [loadFiles]);
const readFile = useCallback(
(path: string) =>
api.get<{ content: string }>(`/workspaces/${workspaceId}/files/${path}?root=${encodeURIComponent(root)}`),
[workspaceId, root]
);
const writeFile = useCallback(
async (path: string, content: string) => {
await api.put(`/workspaces/${workspaceId}/files/${path}`, { content });
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
},
[workspaceId]
);
const deleteFile = useCallback(
async (path: string) => {
await api.del(`/workspaces/${workspaceId}/files/${path}`);
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
},
[workspaceId]
);
/**
* Fetch a file's content from the server and trigger a browser
* download. Used by the right-click "Download" context-menu item
* (PR-C of issue #2999) — distinct from `handleDownloadFile` in
* FilesTab which downloads the CURRENTLY-OPEN-IN-EDITOR file from
* the in-memory `editContent` buffer (so unsaved edits round-trip
* to disk). This helper downloads the on-server content, suitable
* for arbitrary tree rows the user hasn't opened.
*/
const downloadFileByPath = useCallback(
async (path: string) => {
try {
const res = await api.get<{ content: string }>(
`/workspaces/${workspaceId}/files/${path}?root=${encodeURIComponent(root)}`,
);
// text/plain is correct for the canvas's text-only file
// surface (config.yaml, prompts, skill markdown). Binary
// files would need an Accept-arraybuffer path; the API
// returns string today so this matches the wire shape.
const blob = new Blob([res.content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = path.split("/").pop() || "file";
a.click();
URL.revokeObjectURL(url);
showToast(`Downloaded ${a.download}`, "success");
} catch (e) {
showToast(
`Download failed: ${e instanceof Error ? e.message : "unknown error"}`,
"error",
);
}
},
[workspaceId, root],
);
const downloadAllFiles = useCallback(async () => {
const fileEntries = files.filter((f) => !f.dir);
const results = await Promise.allSettled(
fileEntries.map((f) =>
api
.get<{ content: string }>(`/workspaces/${workspaceId}/files/${f.path}`)
.then((res) => ({ path: f.path, content: res.content }))
)
);
const allFiles: Record<string, string> = {};
for (const r of results) {
if (r.status === "fulfilled") allFiles[r.value.path] = r.value.content;
}
const blob = new Blob([JSON.stringify(allFiles, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "workspace-files.json";
a.click();
URL.revokeObjectURL(url);
showToast(`Downloaded ${Object.keys(allFiles).length} files`, "success");
}, [files, workspaceId]);
const uploadFiles = useCallback(
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/${finalPath}`, { content });
uploaded++;
} catch {
/* skip binary */
}
}
if (uploaded > 0) {
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: true });
showToast(`Uploaded ${uploaded} files${targetDir ? ` to ${targetDir}` : ""}`, "success");
loadFiles();
}
return uploaded;
},
[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) {
if (f.dir) continue;
try {
await api.del(`/workspaces/${workspaceId}/files/${f.path}`);
deleted++;
} catch {
/* skip */
}
}
showToast(`Deleted ${deleted} files`, "info");
loadFiles();
return deleted;
}, [files, workspaceId, loadFiles]);
return {
files,
loading,
loadFiles,
expandedDirs,
loadingDir,
toggleDir,
readFile,
writeFile,
deleteFile,
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 };