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:
nicholai 2025-09-13 12:41:30 -06:00
parent 34e8b6389f
commit 3264ac9596
3 changed files with 360 additions and 57 deletions

View File

@ -77,6 +77,7 @@ export default function Home() {
const [path, setPath] = React.useState<string | undefined>(undefined); const [path, setPath] = React.useState<string | undefined>(undefined);
const [page, setPage] = React.useState(1); const [page, setPage] = React.useState(1);
const [perPage] = React.useState(50); const [perPage] = React.useState(50);
const [selectedFolders, setSelectedFolders] = React.useState<string[]>([]);
const [q, setQ] = React.useState(""); const [q, setQ] = React.useState("");
const [semantic, setSemantic] = React.useState(false); const [semantic, setSemantic] = React.useState(false);
@ -314,7 +315,12 @@ export default function Home() {
<div className="h-screen grid grid-cols-[280px_1fr]"> <div className="h-screen grid grid-cols-[280px_1fr]">
{/* Sidebar */} {/* Sidebar */}
<aside className="border-r h-full"> <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> </aside>
{/* Main content */} {/* Main content */}

View File

@ -1,11 +1,28 @@
"use client"; "use client";
import * as React from "react"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { toast } from "sonner"; import { toast } from "sonner";
type UploadItem = {
id: string;
file: File;
progress: number; // 0-100
status: "pending" | "uploading" | "done" | "error";
error?: string;
};
export function UploadDialog({ export function UploadDialog({
currentPath, currentPath,
onUploaded, onUploaded,
@ -14,36 +31,150 @@ export function UploadDialog({
onUploaded?: () => void; onUploaded?: () => void;
}) { }) {
const [open, setOpen] = React.useState(false); 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 [submitting, setSubmitting] = React.useState(false);
const dropRef = React.useRef<HTMLDivElement | null>(null);
async function handleUpload() { function addFiles(files: FileList | File[]) {
if (!file) { const arr = Array.from(files || []);
toast.error("Choose a file first"); if (arr.length === 0) return;
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,
})),
]);
} }
try {
setSubmitting(true); 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(); const fd = new FormData();
fd.set("file", file); fd.set("file", item.file);
// Upload to the current folder; include filename in dest path
const dest = `${currentPath ?? ""}/${file.name}`.replace(/\/+/g, "/");
fd.set("destPath", dest); fd.set("destPath", dest);
const res = await fetch("/api/files/upload", { xhr.upload.onprogress = (evt) => {
method: "POST", if (!evt.lengthComputable) return;
body: fd, 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)),
);
};
if (!res.ok) { xhr.onreadystatechange = () => {
const payload = await res.json().catch(() => ({})); if (xhr.readyState === XMLHttpRequest.DONE) {
throw new Error(payload?.message || `Upload failed (${res.status})`); 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);
});
},
);
} }
toast.success(`Uploaded ${file.name}`); async function handleUpload() {
if (items.length === 0) {
toast.error("Choose files first");
return;
}
setSubmitting(true);
try {
// 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); setOpen(false);
setFile(null); setItems([]);
onUploaded?.(); onUploaded?.();
} else {
toast.error("Some files failed to upload. Please review.");
}
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
toast.error(message); toast.error(message);
@ -57,26 +188,70 @@ export function UploadDialog({
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="default" size="sm">Upload</Button> <Button variant="default" size="sm">Upload</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Upload file</DialogTitle> <DialogTitle>Upload files</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="py-2"> <div
ref={dropRef}
className="py-3 border rounded-md bg-muted/30 hover:bg-muted/40 transition-colors"
>
<div className="px-3">
<Input <Input
type="file" type="file"
onChange={(e) => setFile(e.target.files?.[0] ?? null)} multiple
onChange={(e) => addFiles(e.target.files ?? [])}
disabled={submitting} disabled={submitting}
/> />
<div className="text-xs text-muted-foreground mt-2"> <div className="text-xs text-muted-foreground mt-2">
Destination: <code>{currentPath || "/"}</code> Drag & drop files here or choose files. Destination:
{" "}
<code>{currentPath || "/"}</code>
</div> </div>
</div> </div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={submitting}> <div className="mt-3 space-y-2 px-3">
Cancel {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> </Button>
<Button onClick={handleUpload} disabled={submitting || !file}> )}
{submitting ? "Uploading..." : "Upload"} </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 className="gap-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={submitting}>
Close
</Button>
<Button onClick={handleUpload} disabled={submitting || items.length === 0}>
{submitting ? "Uploading…" : "Start Upload"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -4,7 +4,8 @@ import * as React from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button"; 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 = { type FolderItem = {
id: string; id: string;
@ -25,18 +26,139 @@ async function fetchFolders(path?: string) {
return data.folders; 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({ export function SidebarTree({
path, path,
onNavigate, onNavigate,
canCheckFolders = false,
onSelectionChange,
}: { }: {
path?: string; path?: string;
onNavigate: (p?: string) => void; onNavigate: (p?: string) => void;
canCheckFolders?: boolean;
onSelectionChange?: (paths: string[]) => void;
}) { }) {
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["folders", path ?? "__root__"], queryKey: ["folders", "__root__"],
queryFn: () => fetchFolders(path), 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 ?? []; const items = data ?? [];
return ( return (
@ -45,7 +167,7 @@ export function SidebarTree({
<div className="text-sm font-semibold">Folders</div> <div className="text-sm font-semibold">Folders</div>
</div> </div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="px-2 py-1 space-y-1"> <div className="px-1 py-1 space-y-1">
<Button <Button
variant={path ? "ghost" : "secondary"} variant={path ? "ghost" : "secondary"}
size="sm" size="sm"
@ -55,6 +177,7 @@ export function SidebarTree({
<Folder className="h-4 w-4 mr-2" /> <Folder className="h-4 w-4 mr-2" />
Root Root
</Button> </Button>
{isLoading && ( {isLoading && (
<div className="text-xs text-muted-foreground px-2 py-1">Loading</div> <div className="text-xs text-muted-foreground px-2 py-1">Loading</div>
)} )}
@ -63,22 +186,21 @@ export function SidebarTree({
{(error as Error).message} {(error as Error).message}
</div> </div>
)} )}
{items.map((f) => ( {items.map((f) => (
<Button <FolderNode
key={f.id} key={f.id}
variant={path === f.path ? "secondary" : "ghost"} node={f}
size="sm" depth={0}
className="w-full justify-between" expandedSet={expanded}
onClick={() => onNavigate(f.path)} onToggle={toggleExpand}
title={f.path} onNavigate={onNavigate}
> canCheckFolders={canCheckFolders}
<span className="truncate flex items-center"> selected={selected}
<Folder className="h-4 w-4 mr-2" /> onSelect={handleSelect}
{f.name} />
</span>
<ChevronRight className="h-4 w-4 opacity-60" />
</Button>
))} ))}
{items.length === 0 && !isLoading && !error && ( {items.length === 0 && !isLoading && !error && (
<div className="text-xs text-muted-foreground px-2 py-1">No folders</div> <div className="text-xs text-muted-foreground px-2 py-1">No folders</div>
)} )}