diff --git a/surfsense_web/components/documents/DocumentNode.tsx b/surfsense_web/components/documents/DocumentNode.tsx index 57a12ab3a..a295696d5 100644 --- a/surfsense_web/components/documents/DocumentNode.tsx +++ b/surfsense_web/components/documents/DocumentNode.tsx @@ -89,7 +89,7 @@ export const DocumentNode = React.memo(function DocumentNode({ const isProcessing = statusState === "pending" || statusState === "processing"; const [dropdownOpen, setDropdownOpen] = useState(false); const [exporting, setExporting] = useState(null); - const rowRef = useRef(null); + const rowRef = useRef(null); const handleExport = useCallback( (format: string) => { @@ -102,8 +102,8 @@ export const DocumentNode = React.memo(function DocumentNode({ ); const attachRef = useCallback( - (node: HTMLButtonElement | null) => { - (rowRef as React.MutableRefObject).current = node; + (node: HTMLDivElement | null) => { + (rowRef as React.MutableRefObject).current = node; drag(node); }, [drag] @@ -112,8 +112,9 @@ export const DocumentNode = React.memo(function DocumentNode({ return ( - + {contextMenuOpen && ( diff --git a/surfsense_web/components/documents/FolderNode.tsx b/surfsense_web/components/documents/FolderNode.tsx index cb314effb..5a76e882d 100644 --- a/surfsense_web/components/documents/FolderNode.tsx +++ b/surfsense_web/components/documents/FolderNode.tsx @@ -14,6 +14,8 @@ import { import React, { useCallback, useEffect, useRef, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import type { FolderSelectionState } from "./FolderTreeView"; import { ContextMenu, ContextMenuContent, @@ -49,6 +51,8 @@ interface FolderNodeProps { isExpanded: boolean; isRenaming: boolean; childCount: number; + selectionState: FolderSelectionState; + onToggleSelect: (folderId: number, selectAll: boolean) => void; onToggleExpand: (folderId: number) => void; onRename: (folder: FolderDisplay, newName: string) => void; onStartRename: (folderId: number) => void; @@ -88,6 +92,8 @@ export const FolderNode = React.memo(function FolderNode({ isExpanded, isRenaming, childCount, + selectionState, + onToggleSelect, onToggleExpand, onRename, onStartRename, @@ -212,6 +218,10 @@ export const FolderNode = React.memo(function FolderNode({ onStartRename(folder.id); }, [folder, onStartRename]); + const handleCheckChange = useCallback(() => { + onToggleSelect(folder.id, selectionState !== "all"); + }, [folder.id, selectionState, onToggleSelect]); + const FolderIcon = isExpanded ? FolderOpen : Folder; return ( @@ -252,6 +262,13 @@ export const FolderNode = React.memo(function FolderNode({ )} + e.stopPropagation()} + className="h-3.5 w-3.5 shrink-0" + /> + {isRenaming ? ( diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index d7a82fb0b..eec0aa7cc 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -10,6 +10,8 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { DocumentNode, type DocumentNodeDoc } from "./DocumentNode"; import { type FolderDisplay, FolderNode } from "./FolderNode"; +export type FolderSelectionState = "all" | "some" | "none"; + interface FolderTreeViewProps { folders: FolderDisplay[]; documents: DocumentNodeDoc[]; @@ -20,6 +22,7 @@ interface FolderTreeViewProps { doc: { id: number; title: string; document_type: string }, isMentioned: boolean ) => void; + onToggleFolderSelect: (folderId: number, selectAll: boolean) => void; onRenameFolder: (folder: FolderDisplay, newName: string) => void; onDeleteFolder: (folder: FolderDisplay) => void; onMoveFolder: (folder: FolderDisplay) => void; @@ -55,6 +58,7 @@ export function FolderTreeView({ onToggleExpand, mentionedDocIds, onToggleChatMention, + onToggleFolderSelect, onRenameFolder, onDeleteFolder, onMoveFolder, @@ -122,6 +126,36 @@ export function FolderTreeView({ return match; }, [folders, docsByFolder, foldersByParent, activeTypes]); + const folderSelectionStates = useMemo(() => { + const states: Record = {}; + 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]); + function renderLevel(parentId: number | null, depth: number): React.ReactNode[] { const key = parentId ?? "root"; const childFolders = (foldersByParent[key] ?? []) @@ -151,6 +185,8 @@ export function FolderTreeView({ isExpanded={expandedIds.has(f.id)} isRenaming={renamingFolderId === f.id} childCount={folderChildCounts[f.id] ?? 0} + selectionState={folderSelectionStates[f.id] ?? "none"} + onToggleSelect={onToggleFolderSelect} onToggleExpand={onToggleExpand} onRename={onRenameFolder} onStartRename={handleStartRename} diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 1e1e8f982..f4ddf5ce5 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -348,6 +348,43 @@ export function DocumentsSidebar({ [setSidebarDocs] ); + const handleToggleFolderSelect = useCallback( + (folderId: number, selectAll: boolean) => { + function collectSubtreeDocs(parentId: number): DocumentNodeDoc[] { + const directDocs = (treeDocuments ?? []).filter( + (d) => + d.folderId === parentId && + d.status?.state !== "pending" && + d.status?.state !== "processing", + ); + const childFolders = foldersByParent[String(parentId)] ?? []; + const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id)); + return [...directDocs, ...descendantDocs]; + } + + const subtreeDocs = collectSubtreeDocs(folderId); + if (subtreeDocs.length === 0) return; + + if (selectAll) { + setSidebarDocs((prev) => { + const existingIds = new Set(prev.map((d) => d.id)); + const newDocs = subtreeDocs + .filter((d) => !existingIds.has(d.id)) + .map((d) => ({ + id: d.id, + title: d.title, + document_type: d.document_type as DocumentTypeEnum, + })); + return newDocs.length > 0 ? [...prev, ...newDocs] : prev; + }); + } else { + const idsToRemove = new Set(subtreeDocs.map((d) => d.id)); + setSidebarDocs((prev) => prev.filter((d) => !idsToRemove.has(d.id))); + } + }, + [treeDocuments, foldersByParent, setSidebarDocs], + ); + const isSearchMode = !!debouncedSearch.trim(); const { @@ -625,6 +662,7 @@ export function DocumentsSidebar({ onToggleExpand={toggleFolderExpand} mentionedDocIds={mentionedDocIds} onToggleChatMention={handleToggleChatMention} + onToggleFolderSelect={handleToggleFolderSelect} onRenameFolder={handleRenameFolder} onDeleteFolder={handleDeleteFolder} onMoveFolder={handleMoveFolder}