"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronDown, ChevronRight, Folder, FolderPlus, Loader2, MoveRight, PanelLeftClose, PanelLeftOpen, RefreshCw, Trash2, X, } from "lucide-react"; import { invalidateClientCache } from "@/lib/client-cache"; import { createKbFolder, deleteKbFile, listKnowledgeBaseFiles, moveKbFile, type KnowledgeBaseFile, } from "@/lib/knowledge-api"; import { docIconFor, formatBytes } from "@/lib/doc-attachments"; interface KbDocumentListProps { kbName: string; /** Refresh trigger: bumping this prop forces a re-fetch (e.g. after upload). */ refreshKey?: number; selectedFile: string | null; onSelect: (file: KnowledgeBaseFile | null) => void; collapsed: boolean; onToggleCollapsed: () => void; } interface TreeNode { name: string; // segment label path: string; // full POSIX path relative to raw/ type: "file" | "folder"; file?: KnowledgeBaseFile; children: TreeNode[]; } function parentOf(path: string): string { const idx = path.lastIndexOf("/"); return idx === -1 ? "" : path.slice(0, idx); } function buildTree(entries: KnowledgeBaseFile[]): { root: TreeNode[]; folderPaths: string[]; } { const folders = new Map(); const root: TreeNode[] = []; const folderPaths: string[] = []; const ensureFolder = (path: string): TreeNode => { const existing = folders.get(path); if (existing) return existing; const node: TreeNode = { name: path.slice(path.lastIndexOf("/") + 1), path, type: "folder", children: [], }; folders.set(path, node); folderPaths.push(path); const parent = parentOf(path); if (parent) ensureFolder(parent).children.push(node); else root.push(node); return node; }; // Folders first so a file's parent always exists, and empty folders show. for (const entry of entries) { if (entry.type === "folder") ensureFolder(entry.name); } for (const entry of entries) { if (entry.type === "folder") continue; const node: TreeNode = { name: entry.name.slice(entry.name.lastIndexOf("/") + 1), path: entry.name, type: "file", file: entry, children: [], }; const parent = parentOf(entry.name); if (parent) ensureFolder(parent).children.push(node); else root.push(node); } const sortNodes = (nodes: TreeNode[]) => { nodes.sort((a, b) => { if (a.type !== b.type) return a.type === "folder" ? -1 : 1; return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); nodes.forEach((n) => n.children.length && sortNodes(n.children)); }; sortNodes(root); folderPaths.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); return { root, folderPaths }; } export default function KbDocumentList({ kbName, refreshKey = 0, selectedFile, onSelect, collapsed, onToggleCollapsed, }: KbDocumentListProps) { const { t } = useTranslation(); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expanded, setExpanded] = useState>(new Set()); const [newFolderOpen, setNewFolderOpen] = useState(false); const [newFolderName, setNewFolderName] = useState(""); const [moveMenuFor, setMoveMenuFor] = useState(null); const [confirmDeleteFor, setConfirmDeleteFor] = useState(null); const [dragPath, setDragPath] = useState(null); const [dropTarget, setDropTarget] = useState(null); const [busy, setBusy] = useState(false); const load = useCallback( async (force = false) => { setLoading(true); setError(null); try { if (force) invalidateClientCache(`knowledge:files:${kbName}`); const next = await listKnowledgeBaseFiles(kbName, { force }); setFiles(next); // Default-expand every folder so files are visible without digging. setExpanded( new Set(next.filter((e) => e.type === "folder").map((e) => e.name)), ); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } }, [kbName], ); useEffect(() => { void load(refreshKey > 0); }, [load, refreshKey]); const { root, folderPaths } = useMemo(() => buildTree(files), [files]); const fileEntries = useMemo( () => files.filter((e) => e.type !== "folder"), [files], ); const toggleFolder = (path: string) => setExpanded((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); const handleCreateFolder = async () => { const name = newFolderName.trim(); if (!name) return; setBusy(true); try { await createKbFolder(kbName, name); setNewFolderName(""); setNewFolderOpen(false); await load(true); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }; const handleMove = async (source: string, destFolder: string) => { setMoveMenuFor(null); setDropTarget(null); setDragPath(null); if (parentOf(source) === destFolder) return; // already there setBusy(true); try { await moveKbFile(kbName, source, destFolder); const basename = source.slice(source.lastIndexOf("/") + 1); const newPath = destFolder ? `${destFolder}/${basename}` : basename; await load(true); // Keep the preview pointed at the moved file if it was selected. if (selectedFile === source) { const moved = files.find((f) => f.name === source); onSelect({ ...(moved ?? { name: newPath }), name: newPath, type: "file", }); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }; const handleDelete = async (path: string) => { setConfirmDeleteFor(null); setBusy(true); try { await deleteKbFile(kbName, path); // Drop the preview if the deleted file was the one being shown. if (selectedFile === path) onSelect(null); await load(true); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }; if (collapsed) { return ( ); } const renderNode = (node: TreeNode, depth: number): React.ReactNode => { const indent = { paddingLeft: `${depth * 12 + 8}px` }; if (node.type === "folder") { const open = expanded.has(node.path); const isDrop = dropTarget === node.path; return (
  • toggleFolder(node.path)} onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); setDropTarget(node.path); }} onDragLeave={() => setDropTarget((cur) => (cur === node.path ? null : cur)) } onDrop={(e) => { e.preventDefault(); e.stopPropagation(); const src = e.dataTransfer.getData("text/plain"); if (src) void handleMove(src, node.path); }} style={indent} className={`flex cursor-pointer items-center gap-1 rounded-md py-1.5 pr-2 text-left transition-colors ${ isDrop ? "bg-[var(--primary)]/15 ring-1 ring-[var(--primary)]/40" : "hover:bg-[var(--muted)]/50" }`} > {open ? ( ) : ( )} {node.name}
    {open && node.children.length > 0 && (
      {node.children.map((child) => renderNode(child, depth + 1))}
    )}
  • ); } const spec = docIconFor(node.name); const Icon = spec.Icon; const active = selectedFile === node.path; const file = node.file!; return (
  • { e.dataTransfer.setData("text/plain", node.path); e.dataTransfer.effectAllowed = "move"; setDragPath(node.path); }} onDragEnd={() => setDragPath(null)} style={indent} className={`flex items-center gap-2 rounded-md py-1.5 pr-1 text-left transition-colors ${ active ? "bg-[var(--primary)]/10 text-[var(--foreground)]" : "hover:bg-[var(--muted)]/50" } ${dragPath === node.path ? "opacity-50" : ""}`} > {confirmDeleteFor === node.path ? (
    {t("Delete?")}
    ) : (
    )}
    {moveMenuFor === node.path && ( <>
    setMoveMenuFor(null)} />
    {t("Move to")}
    {parentOf(node.path) !== "" && ( )} {folderPaths .filter((p) => p !== parentOf(node.path)) .map((p) => ( ))} {folderPaths.length === 0 && parentOf(node.path) === "" && (
    {t("No folders yet")}
    )}
    )}
  • ); }; return ( ); } function formatRelative(unixSeconds: number): string { const ts = unixSeconds * 1000; const diff = Date.now() - ts; if (diff < 60_000) return "just now"; if (diff < 60 * 60_000) return `${Math.floor(diff / 60_000)}m ago`; if (diff < 24 * 60 * 60_000) return `${Math.floor(diff / (60 * 60_000))}h ago`; if (diff < 30 * 24 * 60 * 60_000) return `${Math.floor(diff / (24 * 60 * 60_000))}d ago`; return new Date(ts).toLocaleDateString(); }