2026-03-27 01:39:15 -07:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useAtom } from "jotai";
|
2026-04-01 20:31:45 +05:30
|
|
|
import { Search } from "lucide-react";
|
2026-03-27 23:14:10 +05:30
|
|
|
import { useCallback, useMemo, useState } from "react";
|
2026-03-27 01:39:15 -07:00
|
|
|
import { DndProvider } from "react-dnd";
|
|
|
|
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
|
|
|
|
import { renamingFolderIdAtom } from "@/atoms/documents/folder.atoms";
|
|
|
|
|
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
|
|
|
|
import { DocumentNode, type DocumentNodeDoc } from "./DocumentNode";
|
2026-03-27 03:17:05 -07:00
|
|
|
import { type FolderDisplay, FolderNode } from "./FolderNode";
|
2026-03-27 01:39:15 -07:00
|
|
|
|
2026-03-27 17:58:04 -07:00
|
|
|
export type FolderSelectionState = "all" | "some" | "none";
|
|
|
|
|
|
2026-03-27 01:39:15 -07:00
|
|
|
interface FolderTreeViewProps {
|
|
|
|
|
folders: FolderDisplay[];
|
|
|
|
|
documents: DocumentNodeDoc[];
|
|
|
|
|
expandedIds: Set<number>;
|
|
|
|
|
onToggleExpand: (folderId: number) => void;
|
|
|
|
|
mentionedDocIds: Set<number>;
|
2026-03-27 03:17:05 -07:00
|
|
|
onToggleChatMention: (
|
|
|
|
|
doc: { id: number; title: string; document_type: string },
|
|
|
|
|
isMentioned: boolean
|
|
|
|
|
) => void;
|
2026-03-27 17:58:04 -07:00
|
|
|
onToggleFolderSelect: (folderId: number, selectAll: boolean) => void;
|
2026-03-27 01:39:15 -07:00
|
|
|
onRenameFolder: (folder: FolderDisplay, newName: string) => void;
|
|
|
|
|
onDeleteFolder: (folder: FolderDisplay) => void;
|
|
|
|
|
onMoveFolder: (folder: FolderDisplay) => void;
|
|
|
|
|
onCreateFolder: (parentId: number | null) => void;
|
|
|
|
|
onPreviewDocument: (doc: DocumentNodeDoc) => void;
|
|
|
|
|
onEditDocument: (doc: DocumentNodeDoc) => void;
|
|
|
|
|
onDeleteDocument: (doc: DocumentNodeDoc) => void;
|
|
|
|
|
onMoveDocument: (doc: DocumentNodeDoc) => void;
|
2026-03-28 02:58:38 +05:30
|
|
|
onExportDocument?: (doc: DocumentNodeDoc, format: string) => void;
|
2026-04-03 10:42:21 +05:30
|
|
|
onVersionHistory?: (doc: DocumentNodeDoc) => void;
|
2026-03-27 01:39:15 -07:00
|
|
|
activeTypes: DocumentTypeEnum[];
|
2026-03-28 16:39:46 -07:00
|
|
|
searchQuery?: string;
|
2026-03-27 03:17:05 -07:00
|
|
|
onDropIntoFolder?: (
|
|
|
|
|
itemType: "folder" | "document",
|
|
|
|
|
itemId: number,
|
|
|
|
|
targetFolderId: number | null
|
|
|
|
|
) => void;
|
2026-03-27 01:39:15 -07:00
|
|
|
onReorderFolder?: (folderId: number, beforePos: string | null, afterPos: string | null) => void;
|
2026-04-02 22:21:01 +05:30
|
|
|
watchedFolderIds?: Set<number>;
|
|
|
|
|
onRescanFolder?: (folder: FolderDisplay) => void;
|
|
|
|
|
onStopWatchingFolder?: (folder: FolderDisplay) => void;
|
2026-03-27 01:39:15 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function groupBy<T>(items: T[], keyFn: (item: T) => string | number): Record<string | number, T[]> {
|
|
|
|
|
const result: Record<string | number, T[]> = {};
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
const key = keyFn(item);
|
2026-03-27 03:17:05 -07:00
|
|
|
if (!result[key]) result[key] = [];
|
|
|
|
|
result[key].push(item);
|
2026-03-27 01:39:15 -07:00
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function FolderTreeView({
|
|
|
|
|
folders,
|
|
|
|
|
documents,
|
|
|
|
|
expandedIds,
|
|
|
|
|
onToggleExpand,
|
|
|
|
|
mentionedDocIds,
|
|
|
|
|
onToggleChatMention,
|
2026-03-27 17:58:04 -07:00
|
|
|
onToggleFolderSelect,
|
2026-03-27 01:39:15 -07:00
|
|
|
onRenameFolder,
|
|
|
|
|
onDeleteFolder,
|
|
|
|
|
onMoveFolder,
|
|
|
|
|
onCreateFolder,
|
|
|
|
|
onPreviewDocument,
|
|
|
|
|
onEditDocument,
|
|
|
|
|
onDeleteDocument,
|
|
|
|
|
onMoveDocument,
|
2026-03-28 02:58:38 +05:30
|
|
|
onExportDocument,
|
2026-04-03 10:42:21 +05:30
|
|
|
onVersionHistory,
|
2026-03-27 01:39:15 -07:00
|
|
|
activeTypes,
|
2026-03-28 16:39:46 -07:00
|
|
|
searchQuery,
|
2026-03-27 01:39:15 -07:00
|
|
|
onDropIntoFolder,
|
|
|
|
|
onReorderFolder,
|
2026-04-02 22:21:01 +05:30
|
|
|
watchedFolderIds,
|
|
|
|
|
onRescanFolder,
|
|
|
|
|
onStopWatchingFolder,
|
2026-03-27 01:39:15 -07:00
|
|
|
}: FolderTreeViewProps) {
|
2026-03-27 03:17:05 -07:00
|
|
|
const foldersByParent = useMemo(() => groupBy(folders, (f) => f.parentId ?? "root"), [folders]);
|
2026-03-27 01:39:15 -07:00
|
|
|
|
2026-03-27 03:17:05 -07:00
|
|
|
const docsByFolder = useMemo(() => groupBy(documents, (d) => d.folderId ?? "root"), [documents]);
|
2026-03-27 01:39:15 -07:00
|
|
|
|
2026-03-27 23:14:10 +05:30
|
|
|
const [openContextMenuId, setOpenContextMenuId] = useState<string | null>(null);
|
|
|
|
|
|
2026-03-27 01:39:15 -07:00
|
|
|
// Single subscription for rename state — derived boolean passed to each FolderNode
|
|
|
|
|
const [renamingFolderId, setRenamingFolderId] = useAtom(renamingFolderIdAtom);
|
|
|
|
|
const handleStartRename = useCallback(
|
|
|
|
|
(folderId: number) => setRenamingFolderId(folderId),
|
2026-03-27 03:17:05 -07:00
|
|
|
[setRenamingFolderId]
|
2026-03-27 01:39:15 -07:00
|
|
|
);
|
2026-03-27 03:17:05 -07:00
|
|
|
const handleCancelRename = useCallback(() => setRenamingFolderId(null), [setRenamingFolderId]);
|
2026-03-27 01:39:15 -07:00
|
|
|
|
2026-04-06 14:41:53 +05:30
|
|
|
const effectiveActiveTypes = useMemo(() => {
|
2026-04-07 05:55:39 +05:30
|
|
|
if (
|
|
|
|
|
activeTypes.includes("FILE" as DocumentTypeEnum) &&
|
|
|
|
|
!activeTypes.includes("LOCAL_FOLDER_FILE" as DocumentTypeEnum)
|
|
|
|
|
) {
|
2026-04-06 14:41:53 +05:30
|
|
|
return [...activeTypes, "LOCAL_FOLDER_FILE" as DocumentTypeEnum];
|
|
|
|
|
}
|
|
|
|
|
return activeTypes;
|
|
|
|
|
}, [activeTypes]);
|
|
|
|
|
|
2026-03-27 01:39:15 -07:00
|
|
|
const hasDescendantMatch = useMemo(() => {
|
2026-04-06 14:41:53 +05:30
|
|
|
if (effectiveActiveTypes.length === 0 && !searchQuery) return null;
|
2026-03-27 01:39:15 -07:00
|
|
|
const match: Record<number, boolean> = {};
|
|
|
|
|
|
|
|
|
|
function check(folderId: number): boolean {
|
|
|
|
|
if (match[folderId] !== undefined) return match[folderId];
|
2026-03-28 16:39:46 -07:00
|
|
|
const childDocs = (docsByFolder[folderId] ?? []).some(
|
2026-04-07 05:55:39 +05:30
|
|
|
(d) =>
|
|
|
|
|
effectiveActiveTypes.length === 0 ||
|
|
|
|
|
effectiveActiveTypes.includes(d.document_type as DocumentTypeEnum)
|
2026-03-27 01:39:15 -07:00
|
|
|
);
|
|
|
|
|
if (childDocs) {
|
|
|
|
|
match[folderId] = true;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
const childFolders = foldersByParent[folderId] ?? [];
|
|
|
|
|
for (const cf of childFolders) {
|
|
|
|
|
if (check(cf.id)) {
|
|
|
|
|
match[folderId] = true;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
match[folderId] = false;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const f of folders) {
|
|
|
|
|
check(f.id);
|
|
|
|
|
}
|
|
|
|
|
return match;
|
2026-04-06 14:41:53 +05:30
|
|
|
}, [folders, docsByFolder, foldersByParent, effectiveActiveTypes, searchQuery]);
|
2026-03-27 01:39:15 -07:00
|
|
|
|
2026-03-27 17:58:04 -07:00
|
|
|
const folderSelectionStates = useMemo(() => {
|
|
|
|
|
const states: Record<number, FolderSelectionState> = {};
|
|
|
|
|
const isSelectable = (d: DocumentNodeDoc) =>
|
|
|
|
|
d.status?.state !== "pending" && d.status?.state !== "processing";
|
|
|
|
|
|
|
|
|
|
function compute(folderId: number): { selected: number; total: number } {
|
|
|
|
|
const directDocs = (docsByFolder[folderId] ?? []).filter(isSelectable);
|
|
|
|
|
let selected = directDocs.filter((d) => mentionedDocIds.has(d.id)).length;
|
|
|
|
|
let total = directDocs.length;
|
|
|
|
|
|
|
|
|
|
for (const child of foldersByParent[folderId] ?? []) {
|
|
|
|
|
const sub = compute(child.id);
|
|
|
|
|
selected += sub.selected;
|
|
|
|
|
total += sub.total;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (total === 0) states[folderId] = "none";
|
|
|
|
|
else if (selected === total) states[folderId] = "all";
|
|
|
|
|
else if (selected > 0) states[folderId] = "some";
|
|
|
|
|
else states[folderId] = "none";
|
|
|
|
|
|
|
|
|
|
return { selected, total };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const f of folders) {
|
|
|
|
|
if (states[f.id] === undefined) compute(f.id);
|
|
|
|
|
}
|
|
|
|
|
return states;
|
|
|
|
|
}, [folders, docsByFolder, foldersByParent, mentionedDocIds]);
|
2026-03-27 01:39:15 -07:00
|
|
|
|
2026-04-08 16:48:40 +05:30
|
|
|
const folderMap = useMemo(() => {
|
|
|
|
|
const map: Record<number, FolderDisplay> = {};
|
|
|
|
|
for (const f of folders) map[f.id] = f;
|
|
|
|
|
return map;
|
|
|
|
|
}, [folders]);
|
|
|
|
|
|
2026-04-03 04:14:09 +05:30
|
|
|
const folderProcessingStates = useMemo(() => {
|
|
|
|
|
const states: Record<number, "idle" | "processing" | "failed"> = {};
|
|
|
|
|
|
|
|
|
|
function compute(folderId: number): { hasProcessing: boolean; hasFailed: boolean } {
|
|
|
|
|
const directDocs = docsByFolder[folderId] ?? [];
|
|
|
|
|
let hasProcessing = directDocs.some(
|
|
|
|
|
(d) => d.status?.state === "pending" || d.status?.state === "processing"
|
|
|
|
|
);
|
|
|
|
|
let hasFailed = directDocs.some((d) => d.status?.state === "failed");
|
|
|
|
|
|
2026-04-08 16:48:40 +05:30
|
|
|
const folder = folderMap[folderId];
|
|
|
|
|
if (folder?.metadata?.indexing_in_progress) {
|
|
|
|
|
hasProcessing = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 04:14:09 +05:30
|
|
|
for (const child of foldersByParent[folderId] ?? []) {
|
|
|
|
|
const sub = compute(child.id);
|
|
|
|
|
hasProcessing = hasProcessing || sub.hasProcessing;
|
|
|
|
|
hasFailed = hasFailed || sub.hasFailed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hasProcessing) states[folderId] = "processing";
|
|
|
|
|
else if (hasFailed) states[folderId] = "failed";
|
|
|
|
|
else states[folderId] = "idle";
|
|
|
|
|
|
|
|
|
|
return { hasProcessing, hasFailed };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const f of folders) {
|
|
|
|
|
if (states[f.id] === undefined) compute(f.id);
|
|
|
|
|
}
|
|
|
|
|
return states;
|
2026-04-08 16:48:40 +05:30
|
|
|
}, [folders, docsByFolder, foldersByParent, folderMap]);
|
2026-04-03 04:14:09 +05:30
|
|
|
|
2026-03-27 01:39:15 -07:00
|
|
|
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
|
|
|
|
|
const key = parentId ?? "root";
|
|
|
|
|
const childFolders = (foldersByParent[key] ?? [])
|
|
|
|
|
.slice()
|
|
|
|
|
.sort((a, b) => a.position.localeCompare(b.position));
|
|
|
|
|
const visibleFolders = hasDescendantMatch
|
|
|
|
|
? childFolders.filter((f) => hasDescendantMatch[f.id])
|
|
|
|
|
: childFolders;
|
2026-03-27 03:17:05 -07:00
|
|
|
const childDocs = (docsByFolder[key] ?? []).filter(
|
2026-04-07 05:55:39 +05:30
|
|
|
(d) =>
|
|
|
|
|
effectiveActiveTypes.length === 0 ||
|
|
|
|
|
effectiveActiveTypes.includes(d.document_type as DocumentTypeEnum)
|
2026-03-27 03:17:05 -07:00
|
|
|
);
|
2026-03-27 01:39:15 -07:00
|
|
|
|
|
|
|
|
const nodes: React.ReactNode[] = [];
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < visibleFolders.length; i++) {
|
|
|
|
|
const f = visibleFolders[i];
|
|
|
|
|
const siblingPositions = {
|
|
|
|
|
before: i > 0 ? visibleFolders[i - 1].position : null,
|
|
|
|
|
after: i < visibleFolders.length - 1 ? visibleFolders[i + 1].position : null,
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-28 16:39:46 -07:00
|
|
|
const isAutoExpanded = !!searchQuery && !!hasDescendantMatch?.[f.id];
|
|
|
|
|
const isExpanded = expandedIds.has(f.id) || isAutoExpanded;
|
|
|
|
|
|
2026-03-27 01:39:15 -07:00
|
|
|
nodes.push(
|
|
|
|
|
<FolderNode
|
|
|
|
|
key={`folder-${f.id}`}
|
|
|
|
|
folder={f}
|
|
|
|
|
depth={depth}
|
2026-03-28 16:39:46 -07:00
|
|
|
isExpanded={isExpanded}
|
2026-03-27 01:39:15 -07:00
|
|
|
isRenaming={renamingFolderId === f.id}
|
2026-04-07 05:55:39 +05:30
|
|
|
selectionState={folderSelectionStates[f.id] ?? "none"}
|
2026-04-03 04:14:09 +05:30
|
|
|
processingState={folderProcessingStates[f.id] ?? "idle"}
|
2026-03-27 17:58:04 -07:00
|
|
|
onToggleSelect={onToggleFolderSelect}
|
2026-03-27 01:39:15 -07:00
|
|
|
onToggleExpand={onToggleExpand}
|
|
|
|
|
onRename={onRenameFolder}
|
|
|
|
|
onStartRename={handleStartRename}
|
|
|
|
|
onCancelRename={handleCancelRename}
|
|
|
|
|
onDelete={onDeleteFolder}
|
|
|
|
|
onMove={onMoveFolder}
|
|
|
|
|
onCreateSubfolder={onCreateFolder}
|
|
|
|
|
onDropIntoFolder={onDropIntoFolder}
|
|
|
|
|
onReorderFolder={onReorderFolder}
|
|
|
|
|
siblingPositions={siblingPositions}
|
2026-03-27 23:14:10 +05:30
|
|
|
contextMenuOpen={openContextMenuId === `folder-${f.id}`}
|
|
|
|
|
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `folder-${f.id}` : null)}
|
2026-04-03 13:14:40 +05:30
|
|
|
isWatched={watchedFolderIds?.has(f.id)}
|
|
|
|
|
onRescan={onRescanFolder}
|
|
|
|
|
onStopWatching={onStopWatchingFolder}
|
|
|
|
|
/>
|
2026-03-27 01:39:15 -07:00
|
|
|
);
|
|
|
|
|
|
2026-03-28 16:39:46 -07:00
|
|
|
if (isExpanded) {
|
2026-03-27 01:39:15 -07:00
|
|
|
nodes.push(...renderLevel(f.id, depth + 1));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const d of childDocs) {
|
|
|
|
|
nodes.push(
|
|
|
|
|
<DocumentNode
|
|
|
|
|
key={`doc-${d.id}`}
|
|
|
|
|
doc={d}
|
|
|
|
|
depth={depth}
|
|
|
|
|
isMentioned={mentionedDocIds.has(d.id)}
|
|
|
|
|
onToggleChatMention={onToggleChatMention}
|
|
|
|
|
onPreview={onPreviewDocument}
|
|
|
|
|
onEdit={onEditDocument}
|
|
|
|
|
onDelete={onDeleteDocument}
|
|
|
|
|
onMove={onMoveDocument}
|
2026-03-28 02:58:38 +05:30
|
|
|
onExport={onExportDocument}
|
2026-04-03 10:42:21 +05:30
|
|
|
onVersionHistory={onVersionHistory}
|
2026-03-27 23:14:10 +05:30
|
|
|
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
|
|
|
|
|
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
|
2026-03-27 03:17:05 -07:00
|
|
|
/>
|
2026-03-27 01:39:15 -07:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nodes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const treeNodes = renderLevel(null, 0);
|
|
|
|
|
|
|
|
|
|
if (treeNodes.length === 0 && folders.length === 0 && documents.length === 0) {
|
|
|
|
|
return (
|
2026-04-08 16:20:41 +05:30
|
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-1 px-4 py-12 text-muted-foreground select-none">
|
2026-03-29 03:43:50 +05:30
|
|
|
<p className="text-sm font-medium">No documents found</p>
|
2026-03-30 01:50:41 +05:30
|
|
|
<p className="text-xs text-muted-foreground/70">
|
|
|
|
|
Use the upload button or connect a source above
|
|
|
|
|
</p>
|
2026-03-27 01:39:15 -07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 14:41:53 +05:30
|
|
|
if (treeNodes.length === 0 && (effectiveActiveTypes.length > 0 || searchQuery)) {
|
2026-03-27 01:39:15 -07:00
|
|
|
return (
|
|
|
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
|
2026-04-01 20:31:45 +05:30
|
|
|
<Search className="h-10 w-10" />
|
|
|
|
|
<p className="text-sm text-muted-foreground">No matching documents</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground/70 mt-1">Try a different search term</p>
|
2026-03-27 01:39:15 -07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DndProvider backend={HTML5Backend}>
|
2026-03-27 03:17:05 -07:00
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-1">{treeNodes}</div>
|
2026-03-27 01:39:15 -07:00
|
|
|
</DndProvider>
|
|
|
|
|
);
|
|
|
|
|
}
|