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:
co-authored by
Claude Opus 4.8
parent
e3313fb4ac
commit
25bba1cf2c
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user