From 3264ac95966e192636e09c31b63e59846e729639 Mon Sep 17 00:00:00 2001 From: nicholai Date: Sat, 13 Sep 2025 12:41:30 -0600 Subject: [PATCH] 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 --- src/app/page.tsx | 8 +- src/components/files/upload-dialog.tsx | 251 ++++++++++++++++++++---- src/components/sidebar/sidebar-tree.tsx | 158 +++++++++++++-- 3 files changed, 360 insertions(+), 57 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index b62e696..e183eb9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -77,6 +77,7 @@ export default function Home() { const [path, setPath] = React.useState(undefined); const [page, setPage] = React.useState(1); const [perPage] = React.useState(50); + const [selectedFolders, setSelectedFolders] = React.useState([]); const [q, setQ] = React.useState(""); const [semantic, setSemantic] = React.useState(false); @@ -314,7 +315,12 @@ export default function Home() {
{/* Sidebar */} {/* Main content */} diff --git a/src/components/files/upload-dialog.tsx b/src/components/files/upload-dialog.tsx index 0895fea..71edaf1 100644 --- a/src/components/files/upload-dialog.tsx +++ b/src/components/files/upload-dialog.tsx @@ -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(null); + const [items, setItems] = React.useState([]); const [submitting, setSubmitting] = React.useState(false); + const dropRef = React.useRef(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((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({ - + - Upload file + Upload files -
- setFile(e.target.files?.[0] ?? null)} - disabled={submitting} - /> -
- Destination: {currentPath || "/"} +
+
+ addFiles(e.target.files ?? [])} + disabled={submitting} + /> +
+ Drag & drop files here or choose files. Destination: + {" "} + {currentPath || "/"} +
+
+ +
+ {items.length === 0 ? ( +
No files selected
+ ) : null} + {items.map((it) => ( +
+
+
+ {it.file.name}{" "} + + ({Math.round(it.file.size / 1024)} KB) + +
+ {it.status === "pending" && ( + + )} +
+
+ +
{it.progress}%
+
+ {it.status === "error" && ( +
{it.error}
+ )} +
+ ))}
- + + - diff --git a/src/components/sidebar/sidebar-tree.tsx b/src/components/sidebar/sidebar-tree.tsx index 3113387..9cc4ef9 100644 --- a/src/components/sidebar/sidebar-tree.tsx +++ b/src/components/sidebar/sidebar-tree.tsx @@ -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; + onToggle: (path: string) => void; + onNavigate: (p?: string) => void; + canCheckFolders?: boolean; + selected: Set; + 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 ( +
+
+ + + {canCheckFolders ? ( + onSelect(node.path, Boolean(v))} + aria-label={`Select ${node.name}`} + /> + ) : null} + + +
+ + {isExpanded ? ( +
+ {isLoading ? ( +
Loading…
+ ) : error ? ( +
Failed to load
+ ) : (children?.length ?? 0) > 0 ? ( + children!.map((child) => ( + + )) + ) : ( +
No subfolders
+ )} +
+ ) : null} +
+ ); +} + 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>(new Set()); + const [selected, setSelected] = React.useState>(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({
Folders
-
+
+ {isLoading && (
Loading…
)} @@ -63,22 +186,21 @@ export function SidebarTree({ {(error as Error).message}
)} + {items.map((f) => ( - + node={f} + depth={0} + expandedSet={expanded} + onToggle={toggleExpand} + onNavigate={onNavigate} + canCheckFolders={canCheckFolders} + selected={selected} + onSelect={handleSelect} + /> ))} + {items.length === 0 && !isLoading && !error && (
No folders
)}