feat(browse): lazy folder tree with expand/collapse + selection; optimistic file ops (delete/rename/copy) and confirmations; upload dialog with drag-and-drop, per-file progress, parallel uploads; wire selection to page state
This commit is contained in:
parent
34e8b6389f
commit
3264ac9596
@ -77,6 +77,7 @@ export default function Home() {
|
||||
const [path, setPath] = React.useState<string | undefined>(undefined);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [perPage] = React.useState(50);
|
||||
const [selectedFolders, setSelectedFolders] = React.useState<string[]>([]);
|
||||
|
||||
const [q, setQ] = React.useState("");
|
||||
const [semantic, setSemantic] = React.useState(false);
|
||||
@ -314,7 +315,12 @@ export default function Home() {
|
||||
<div className="h-screen grid grid-cols-[280px_1fr]">
|
||||
{/* Sidebar */}
|
||||
<aside className="border-r h-full">
|
||||
<SidebarTree path={path} onNavigate={(p) => { setPage(1); setPath(p); }} />
|
||||
<SidebarTree
|
||||
path={path}
|
||||
onNavigate={(p) => { setPage(1); setPath(p); }}
|
||||
canCheckFolders={true}
|
||||
onSelectionChange={(paths) => setSelectedFolders(paths)}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
|
||||
@ -1,11 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type UploadItem = {
|
||||
id: string;
|
||||
file: File;
|
||||
progress: number; // 0-100
|
||||
status: "pending" | "uploading" | "done" | "error";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function UploadDialog({
|
||||
currentPath,
|
||||
onUploaded,
|
||||
@ -14,36 +31,150 @@ export function UploadDialog({
|
||||
onUploaded?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [items, setItems] = React.useState<UploadItem[]>([]);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const dropRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
function addFiles(files: FileList | File[]) {
|
||||
const arr = Array.from(files || []);
|
||||
if (arr.length === 0) return;
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
...arr.map((f) => ({
|
||||
id: `${f.name}-${f.size}-${f.lastModified}-${crypto.randomUUID?.() ?? Math.random()}`,
|
||||
file: f,
|
||||
progress: 0,
|
||||
status: "pending" as const,
|
||||
})),
|
||||
]);
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
setItems((prev) => prev.filter((x) => x.id !== id));
|
||||
}
|
||||
|
||||
// Drag-and-drop within the dialog
|
||||
React.useEffect(() => {
|
||||
const el = dropRef.current;
|
||||
if (!el) return;
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
el.classList.add("ring-2", "ring-primary/60");
|
||||
}
|
||||
|
||||
function onDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
el.classList.remove("ring-2", "ring-primary/60");
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
el.classList.remove("ring-2", "ring-primary/60");
|
||||
if (e.dataTransfer?.files) {
|
||||
addFiles(e.dataTransfer.files);
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener("dragover", onDragOver);
|
||||
el.addEventListener("dragleave", onDragLeave);
|
||||
el.addEventListener("drop", onDrop);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("dragover", onDragOver);
|
||||
el.removeEventListener("dragleave", onDragLeave);
|
||||
el.removeEventListener("drop", onDrop);
|
||||
};
|
||||
}, [dropRef.current]);
|
||||
|
||||
async function uploadOne(item: UploadItem) {
|
||||
return Sentry.startSpan(
|
||||
{ op: "http.client", name: "upload.file" },
|
||||
async (span) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const dest = `${currentPath ?? ""}/${item.file.name}`.replace(/\/+/g, "/");
|
||||
span.setAttribute("path", dest);
|
||||
span.setAttribute("size", item.file.size);
|
||||
if (item.file.type) span.setAttribute("contentType", item.file.type);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set("file", item.file);
|
||||
fd.set("destPath", dest);
|
||||
|
||||
xhr.upload.onprogress = (evt) => {
|
||||
if (!evt.lengthComputable) return;
|
||||
const pct = Math.min(100, Math.round((evt.loaded / evt.total) * 100));
|
||||
setItems((prev) =>
|
||||
prev.map((x) => (x.id === item.id ? { ...x, progress: pct, status: "uploading" } : x)),
|
||||
);
|
||||
};
|
||||
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
setItems((prev) =>
|
||||
prev.map((x) => (x.id === item.id ? { ...x, progress: 100, status: "done" } : x)),
|
||||
);
|
||||
} else {
|
||||
const message =
|
||||
(() => {
|
||||
try {
|
||||
return (JSON.parse(xhr.responseText)?.message as string) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})() || `Upload failed (${xhr.status})`;
|
||||
setItems((prev) =>
|
||||
prev.map((x) =>
|
||||
x.id === item.id ? { ...x, status: "error", error: message } : x,
|
||||
),
|
||||
);
|
||||
Sentry.captureException(new Error(message));
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.open("POST", "/api/files/upload", true);
|
||||
xhr.send(fd);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!file) {
|
||||
toast.error("Choose a file first");
|
||||
if (items.length === 0) {
|
||||
toast.error("Choose files first");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const fd = new FormData();
|
||||
fd.set("file", file);
|
||||
// Upload to the current folder; include filename in dest path
|
||||
const dest = `${currentPath ?? ""}/${file.name}`.replace(/\/+/g, "/");
|
||||
fd.set("destPath", dest);
|
||||
|
||||
const res = await fetch("/api/files/upload", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
throw new Error(payload?.message || `Upload failed (${res.status})`);
|
||||
// reset any previous errors
|
||||
setItems((prev) => prev.map((x) => ({ ...x, status: "pending", progress: 0, error: undefined })));
|
||||
// Upload in parallel but not too many at once to avoid overwhelming server
|
||||
const concurrency = 3;
|
||||
const queue = [...items];
|
||||
async function worker() {
|
||||
while (queue.length) {
|
||||
const next = queue.shift();
|
||||
if (!next) break;
|
||||
await uploadOne(next);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
const hadError = items.some((x) => x.status === "error");
|
||||
if (!hadError) {
|
||||
toast.success(`Uploaded ${items.length} file(s)`);
|
||||
setOpen(false);
|
||||
setItems([]);
|
||||
onUploaded?.();
|
||||
} else {
|
||||
toast.error("Some files failed to upload. Please review.");
|
||||
}
|
||||
|
||||
toast.success(`Uploaded ${file.name}`);
|
||||
setOpen(false);
|
||||
setFile(null);
|
||||
onUploaded?.();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message);
|
||||
@ -57,26 +188,70 @@ export function UploadDialog({
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="default" size="sm">Upload</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload file</DialogTitle>
|
||||
<DialogTitle>Upload files</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Input
|
||||
type="file"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
Destination: <code>{currentPath || "/"}</code>
|
||||
<div
|
||||
ref={dropRef}
|
||||
className="py-3 border rounded-md bg-muted/30 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<div className="px-3">
|
||||
<Input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => addFiles(e.target.files ?? [])}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
Drag & drop files here or choose files. Destination:
|
||||
{" "}
|
||||
<code>{currentPath || "/"}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2 px-3">
|
||||
{items.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">No files selected</div>
|
||||
) : null}
|
||||
{items.map((it) => (
|
||||
<div key={it.id} className="border rounded-md p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm truncate" title={it.file.name}>
|
||||
{it.file.name}{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({Math.round(it.file.size / 1024)} KB)
|
||||
</span>
|
||||
</div>
|
||||
{it.status === "pending" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeItem(it.id)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Progress value={it.progress} className="h-2 w-full" />
|
||||
<div className="text-xs w-14 text-right">{it.progress}%</div>
|
||||
</div>
|
||||
{it.status === "error" && (
|
||||
<div className="text-xs text-destructive mt-1">{it.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleUpload} disabled={submitting || !file}>
|
||||
{submitting ? "Uploading..." : "Upload"}
|
||||
<Button onClick={handleUpload} disabled={submitting || items.length === 0}>
|
||||
{submitting ? "Uploading…" : "Start Upload"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@ -4,7 +4,8 @@ import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronRight, Folder } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ChevronDown, ChevronRight, Folder } from "lucide-react";
|
||||
|
||||
type FolderItem = {
|
||||
id: string;
|
||||
@ -25,18 +26,139 @@ async function fetchFolders(path?: string) {
|
||||
return data.folders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single folder node with lazy children.
|
||||
* Receives the full expanded set so child nodes can consult it too.
|
||||
*/
|
||||
function FolderNode({
|
||||
node,
|
||||
depth,
|
||||
expandedSet,
|
||||
onToggle,
|
||||
onNavigate,
|
||||
canCheckFolders,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
node: FolderItem;
|
||||
depth: number;
|
||||
expandedSet: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onNavigate: (p?: string) => void;
|
||||
canCheckFolders?: boolean;
|
||||
selected: Set<string>;
|
||||
onSelect: (path: string, checked: boolean) => void;
|
||||
}) {
|
||||
const isExpanded = expandedSet.has(node.path);
|
||||
|
||||
const { data: children, isLoading, error } = useQuery({
|
||||
queryKey: ["folders", node.path ?? "__root__", "children"],
|
||||
queryFn: () => fetchFolders(node.path),
|
||||
enabled: isExpanded,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full flex items-center gap-1">
|
||||
<button
|
||||
className="h-6 w-6 flex items-center justify-center text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
onClick={() => onToggle(node.path)}
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
{canCheckFolders ? (
|
||||
<Checkbox
|
||||
className="mr-1"
|
||||
checked={selected.has(node.path)}
|
||||
onCheckedChange={(v: boolean) => onSelect(node.path, Boolean(v))}
|
||||
aria-label={`Select ${node.name}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 justify-start"
|
||||
onClick={() => onNavigate(node.path)}
|
||||
title={node.path}
|
||||
>
|
||||
<Folder className="h-4 w-4 mr-2" />
|
||||
<span className="truncate">{node.name}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded ? (
|
||||
<div className="ml-6">
|
||||
{isLoading ? (
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">Loading…</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-destructive px-2 py-1">Failed to load</div>
|
||||
) : (children?.length ?? 0) > 0 ? (
|
||||
children!.map((child) => (
|
||||
<FolderNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
expandedSet={expandedSet}
|
||||
onToggle={onToggle}
|
||||
onNavigate={onNavigate}
|
||||
canCheckFolders={canCheckFolders}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">No subfolders</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarTree({
|
||||
path,
|
||||
onNavigate,
|
||||
canCheckFolders = false,
|
||||
onSelectionChange,
|
||||
}: {
|
||||
path?: string;
|
||||
onNavigate: (p?: string) => void;
|
||||
canCheckFolders?: boolean;
|
||||
onSelectionChange?: (paths: string[]) => void;
|
||||
}) {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["folders", path ?? "__root__"],
|
||||
queryFn: () => fetchFolders(path),
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["folders", "__root__"],
|
||||
queryFn: () => fetchFolders(undefined),
|
||||
});
|
||||
|
||||
const [expanded, setExpanded] = React.useState<Set<string>>(new Set());
|
||||
const [selected, setSelected] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = React.useCallback((p: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(p)) next.delete(p);
|
||||
else next.add(p);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(p: string, checked: boolean) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(p);
|
||||
else next.delete(p);
|
||||
onSelectionChange?.(Array.from(next));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[onSelectionChange],
|
||||
);
|
||||
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
@ -45,7 +167,7 @@ export function SidebarTree({
|
||||
<div className="text-sm font-semibold">Folders</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-2 py-1 space-y-1">
|
||||
<div className="px-1 py-1 space-y-1">
|
||||
<Button
|
||||
variant={path ? "ghost" : "secondary"}
|
||||
size="sm"
|
||||
@ -55,6 +177,7 @@ export function SidebarTree({
|
||||
<Folder className="h-4 w-4 mr-2" />
|
||||
Root
|
||||
</Button>
|
||||
|
||||
{isLoading && (
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">Loading…</div>
|
||||
)}
|
||||
@ -63,22 +186,21 @@ export function SidebarTree({
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.map((f) => (
|
||||
<Button
|
||||
<FolderNode
|
||||
key={f.id}
|
||||
variant={path === f.path ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-between"
|
||||
onClick={() => onNavigate(f.path)}
|
||||
title={f.path}
|
||||
>
|
||||
<span className="truncate flex items-center">
|
||||
<Folder className="h-4 w-4 mr-2" />
|
||||
{f.name}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 opacity-60" />
|
||||
</Button>
|
||||
node={f}
|
||||
depth={0}
|
||||
expandedSet={expanded}
|
||||
onToggle={toggleExpand}
|
||||
onNavigate={onNavigate}
|
||||
canCheckFolders={canCheckFolders}
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
|
||||
{items.length === 0 && !isLoading && !error && (
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">No folders</div>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user