File browser: folder upload + copy/move (0.13.0)

Folder upload: the Files page gained an "Upload folder" picker
(webkitdirectory); each file is sent with its webkitRelativePath and the
backend recreates the directory tree. upload_target now accepts an optional
rel_path, creating intermediate dirs (mkdir -p) inside the sandbox with each
component validated against traversal.

Copy/move: new file_service.copy/move + POST /api/files/{copy,move}
(admin, audit-logged). The UI adds per-row copy/cut actions, a clipboard bar
to paste into the current directory, and an overwrite prompt on conflict.
Both refuse to move/copy a folder into itself or its own subtree and are
sandbox-checked on source and destination.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-08 10:53:57 +00:00
co-authored by Claude Opus 4.8
parent e3313fb4ac
commit 25bba1cf2c
8 changed files with 290 additions and 16 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.12.0",
"version": "0.13.0",
"type": "module",
"scripts": {
"dev": "vite",
+12 -1
View File
@@ -44,15 +44,26 @@ export const filesApi = {
remove: (path: string, recursive = false) =>
api.delete("/api/files", { params: { path, recursive } }).then((r) => r.data),
copy: (src: string, destDir: string, overwrite = false) =>
api
.post<{ path: string }>("/api/files/copy", { src, dest_dir: destDir, overwrite })
.then((r) => r.data),
move: (src: string, destDir: string, overwrite = false) =>
api
.post<{ path: string }>("/api/files/move", { src, dest_dir: destDir, overwrite })
.then((r) => r.data),
download: async (path: string, filename: string) => {
const res = await api.get("/api/files/download", { params: { path }, responseType: "blob" });
triggerDownload(res.data as Blob, filename);
},
upload: async (path: string, file: File, overwrite = false) => {
upload: async (path: string, file: File, overwrite = false, relPath = "") => {
const form = new FormData();
form.append("path", path);
form.append("overwrite", String(overwrite));
if (relPath) form.append("rel_path", relPath);
form.append("file", file);
const res = await api.post<{ ok: boolean; name: string }>("/api/files/upload", form);
return res.data;
+132
View File
@@ -8,6 +8,7 @@ import {
FolderPlus,
FilePlus,
Upload,
FolderUp,
Download,
Pencil,
Trash2,
@@ -17,6 +18,9 @@ import {
X,
HardDrive,
Link2,
Copy,
Scissors,
ClipboardPaste,
} from "lucide-react";
import { toast } from "sonner";
import Editor from "@monaco-editor/react";
@@ -43,6 +47,13 @@ function langForName(name: string): string {
return LANG_BY_EXT[ext] ?? "plaintext";
}
interface Clipboard {
src: string;
name: string;
type: "dir" | "file";
mode: "copy" | "cut";
}
function join(path: string, name: string) {
return `${path === "/" ? "" : path}/${name}`;
}
@@ -67,7 +78,10 @@ export function Files() {
const [renaming, setRenaming] = useState<HostPathEntry | null>(null);
const [deleting, setDeleting] = useState<HostPathEntry | null>(null);
const [newKind, setNewKind] = useState<"dir" | "file" | null>(null);
const [clip, setClip] = useState<Clipboard | null>(null);
const [pasteConflict, setPasteConflict] = useState(false);
const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
const { data, isLoading, isFetching, error } = useQuery({
queryKey: ["files", path, showHidden],
@@ -95,6 +109,54 @@ export function Files() {
},
});
// Folder upload: send each file with its relative path so the backend
// recreates the directory structure. Continues past individual failures.
const uploadFolder = useMutation({
mutationFn: async (files: File[]) => {
let ok = 0;
let failed = 0;
for (const f of files) {
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name;
try {
await filesApi.upload(path, f, true, rel);
ok += 1;
} catch {
failed += 1;
}
}
return { ok, failed };
},
onSuccess: ({ ok, failed }) => {
if (failed) toast.warning(`Uploaded ${ok} file(s), ${failed} failed`);
else toast.success(`Uploaded ${ok} file(s)`);
refresh();
},
onError: (e) => toast.error(apiErrorMessage(e)),
onSettled: () => {
if (folderInput.current) folderInput.current.value = "";
},
});
const paste = useMutation({
mutationFn: (overwrite: boolean) => {
const op = clip!.mode === "copy" ? filesApi.copy : filesApi.move;
return op(clip!.src, path, overwrite);
},
onSuccess: () => {
toast.success(clip!.mode === "copy" ? "Copied" : "Moved");
setClip(null);
setPasteConflict(false);
refresh();
},
onError: (e) => {
if (apiErrorMessage(e).startsWith("Already exists")) {
setPasteConflict(true);
} else {
toast.error(apiErrorMessage(e));
}
},
});
const remove = useMutation({
mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir"),
onSuccess: () => {
@@ -141,6 +203,13 @@ export function Files() {
<Button onClick={() => fileInput.current?.click()} loading={upload.isPending}>
<Upload className="h-4 w-4" /> Upload
</Button>
<Button
variant="outline"
onClick={() => folderInput.current?.click()}
loading={uploadFolder.isPending}
>
<FolderUp className="h-4 w-4" /> Upload folder
</Button>
<input
ref={fileInput}
type="file"
@@ -150,6 +219,18 @@ export function Files() {
if (f) upload.mutate(f);
}}
/>
<input
ref={folderInput}
type="file"
className="hidden"
multiple
// webkitdirectory/directory are non-standard but widely supported.
{...({ webkitdirectory: "", directory: "" } as Record<string, string>)}
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
if (files.length) uploadFolder.mutate(files);
}}
/>
</>
)}
</div>
@@ -183,6 +264,28 @@ export function Files() {
))}
</div>
{clip && (
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-sky-50 px-3 py-2 text-sm dark:border-slate-700 dark:bg-sky-950/30">
{clip.mode === "copy" ? (
<Copy className="h-4 w-4 text-sky-500" />
) : (
<Scissors className="h-4 w-4 text-sky-500" />
)}
<span className="text-slate-600 dark:text-slate-300">
{clip.mode === "copy" ? "Copy" : "Move"} <span className="font-medium">{clip.name}</span> to{" "}
<span className="font-mono text-xs">{path}</span>
</span>
<div className="ml-auto flex gap-2">
<Button onClick={() => paste.mutate(false)} loading={paste.isPending}>
<ClipboardPaste className="h-4 w-4" /> Paste here
</Button>
<Button variant="outline" onClick={() => setClip(null)} disabled={paste.isPending}>
Cancel
</Button>
</div>
</div>
)}
{error ? (
<p className="p-4 text-sm text-red-500">{apiErrorMessage(error)}</p>
) : isLoading ? (
@@ -238,6 +341,24 @@ export function Files() {
)}
{isAdmin && (
<>
<button
title="Copy"
onClick={() =>
setClip({ src: join(path, e.name), name: e.name, type: e.type, mode: "copy" })
}
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Copy className="h-4 w-4 text-slate-500" />
</button>
<button
title="Cut (move)"
onClick={() =>
setClip({ src: join(path, e.name), name: e.name, type: e.type, mode: "cut" })
}
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Scissors className="h-4 w-4 text-slate-500" />
</button>
<button
title="Rename"
onClick={() => setRenaming(e)}
@@ -317,6 +438,17 @@ export function Files() {
onCancel={() => setDeleting(null)}
/>
)}
{pasteConflict && clip && (
<ConfirmDialog
title={`${clip.name}” already exists here`}
message={`Overwrite the existing ${clip.type === "dir" ? "folder" : "file"} at ${path}?`}
confirmLabel="Overwrite"
danger
busy={paste.isPending}
onConfirm={() => paste.mutate(true)}
onCancel={() => setPasteConflict(false)}
/>
)}
</div>
);
}