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 [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 */}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
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() {
|
async function handleUpload() {
|
||||||
if (!file) {
|
if (items.length === 0) {
|
||||||
toast.error("Choose a file first");
|
toast.error("Choose files first");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
// reset any previous errors
|
||||||
const fd = new FormData();
|
setItems((prev) => prev.map((x) => ({ ...x, status: "pending", progress: 0, error: undefined })));
|
||||||
fd.set("file", file);
|
// Upload in parallel but not too many at once to avoid overwhelming server
|
||||||
// Upload to the current folder; include filename in dest path
|
const concurrency = 3;
|
||||||
const dest = `${currentPath ?? ""}/${file.name}`.replace(/\/+/g, "/");
|
const queue = [...items];
|
||||||
fd.set("destPath", dest);
|
async function worker() {
|
||||||
|
while (queue.length) {
|
||||||
const res = await fetch("/api/files/upload", {
|
const next = queue.shift();
|
||||||
method: "POST",
|
if (!next) break;
|
||||||
body: fd,
|
await uploadOne(next);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
if (!res.ok) {
|
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||||
const payload = await res.json().catch(() => ({}));
|
const hadError = items.some((x) => x.status === "error");
|
||||||
throw new Error(payload?.message || `Upload failed (${res.status})`);
|
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) {
|
} 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
|
||||||
<Input
|
ref={dropRef}
|
||||||
type="file"
|
className="py-3 border rounded-md bg-muted/30 hover:bg-muted/40 transition-colors"
|
||||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
>
|
||||||
disabled={submitting}
|
<div className="px-3">
|
||||||
/>
|
<Input
|
||||||
<div className="text-xs text-muted-foreground mt-2">
|
type="file"
|
||||||
Destination: <code>{currentPath || "/"}</code>
|
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>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={submitting}>
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={submitting}>
|
||||||
Cancel
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleUpload} disabled={submitting || !file}>
|
<Button onClick={handleUpload} disabled={submitting || items.length === 0}>
|
||||||
{submitting ? "Uploading..." : "Upload"}
|
{submitting ? "Uploading…" : "Start Upload"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user