diff --git a/frontend/src/app/DocumentsRoute.tsx b/frontend/src/app/DocumentsRoute.tsx index da77b65..ff777f8 100644 --- a/frontend/src/app/DocumentsRoute.tsx +++ b/frontend/src/app/DocumentsRoute.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo } from 'react'; import type { ReactNode, ComponentProps } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { DocumentsFilterProvider, } from '../documents/context/DocumentsFilterContext'; @@ -12,12 +12,14 @@ import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext'; import { PanelManagerProvider, usePanelManager } from './PanelManagerContext'; import Sidebar from '../sidebar/Sidebar'; import useDocumentsShell from './useDocumentsShell'; +import useFolderData from '../documents/data/useFolderData'; const DocumentsInner: React.FC<{ sidebarProps: ComponentProps | null; surfaceConfig: any; + folderData: any; onNavigate: (documentId: string) => void; -}> = ({ sidebarProps, surfaceConfig, onNavigate }) => { +}> = ({ sidebarProps, surfaceConfig, folderData, onNavigate }) => { const { openPreview } = usePreviewContext(); const { collapsed: sidebarCollapsed } = useSidebarContext(); const { @@ -46,9 +48,16 @@ const DocumentsInner: React.FC<{ const documentsTablePropsWithNav = useMemo(() => ( surfaceConfig.documentsTableProps - ? { ...surfaceConfig.documentsTableProps, onBreadcrumbNavigate: handleBreadcrumbNavigate } + ? { + ...surfaceConfig.documentsTableProps, + onBreadcrumbNavigate: handleBreadcrumbNavigate, + documents: folderData.documents, + subfolders: folderData.subfolders, + currentFolderName: folderData.folder?.name, + isSearchLoading: folderData.loading, + } : null - ), [surfaceConfig.documentsTableProps, handleBreadcrumbNavigate]); + ), [surfaceConfig.documentsTableProps, handleBreadcrumbNavigate, folderData]); const { surface } = useWorkspaceSurface({ sidebarHidden, @@ -98,8 +107,13 @@ const DocumentsRouteContent: React.FC = () => { sidebarProps, surfaceConfig, documentsFilter, + documentsManager, + foldersManager, } = useDocumentsShell(); const navigate = useNavigate(); + const params = useParams(); + const folderId = params.folderId || 'root'; + const folderData = useFolderData(folderId, documentsManager, foldersManager); const handleDocumentNavigate = useCallback((documentId: string) => { navigate(`/documents/${documentId}`); @@ -113,6 +127,7 @@ const DocumentsRouteContent: React.FC = () => { diff --git a/frontend/src/app/useDocumentsShell.ts b/frontend/src/app/useDocumentsShell.ts index ee0577b..7760cd2 100644 --- a/frontend/src/app/useDocumentsShell.ts +++ b/frontend/src/app/useDocumentsShell.ts @@ -15,6 +15,8 @@ interface DocumentsShellView { sidebarProps: ComponentProps | null; surfaceConfig: WorkspaceSurfaceConfig; documentsFilter: DocumentsFilterValue; + documentsManager: any; + foldersManager: any; // Using any to avoid circular dependency } const useDocumentsShell = (): DocumentsShellView => { @@ -49,6 +51,8 @@ const useDocumentsShell = (): DocumentsShellView => { sidebarProps: sidebarWithActions, surfaceConfig, documentsFilter: shell.documentsFilter as DocumentsFilterValue, + documentsManager: shell.documentsManager, + foldersManager: shell.foldersManager, }; }, [shell]); }; diff --git a/frontend/src/documents/FoldersManager.ts b/frontend/src/documents/FoldersManager.ts new file mode 100644 index 0000000..fdfc1c2 --- /dev/null +++ b/frontend/src/documents/FoldersManager.ts @@ -0,0 +1,177 @@ +import { shallowEqual } from 'react-redux'; +import type { FolderNodeId } from '../types/identifiers'; +import type { Folder } from '../types/documents'; + +type ManagedFolder = Folder; + +type FetchFolder = (id: FolderNodeId) => Promise; + +class FoldersManager { + private byId: Map; + private fetcher?: FetchFolder; + private inflight: Map>; + private listeners: Set<() => void>; + private emitScheduled: boolean; + + constructor( + fetchFolder?: FetchFolder, + ) { + this.byId = new Map(); + this.fetcher = fetchFolder; + this.inflight = new Map(); + this.listeners = new Set(); + this.emitScheduled = false; + } + + private emit() { + if (this.emitScheduled) { + return; + } + this.emitScheduled = true; + setTimeout(() => { + this.emitScheduled = false; + this.listeners.forEach((fn) => fn()); + }, 0); + } + + subscribe(listener: () => void) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + setFetcher(fetchFolder?: FetchFolder) { + this.fetcher = fetchFolder; + } + + ingest(rawFolders: unknown[] = []): { canonical: T[]; changed: boolean } { + const folders = rawFolders.map((f) => f as T).filter(Boolean); + let changed = false; + let nextById = this.byId; + const canonical: T[] = []; + + folders.forEach((folder) => { + const id = folder?.id; + if (id == null) { + canonical.push(folder); + return; + } + + const existing = nextById.get(id as FolderNodeId); + const merged = existing ? ({ ...existing, ...folder } as T) : ({ ...(folder as T) } as T); + const useExisting = existing && shallowEqual(existing, merged); + const nextFolder = useExisting ? (existing as T) : merged; + + if (!useExisting) { + if (!changed) { + nextById = new Map(this.byId); + } + nextById.set(id as FolderNodeId, nextFolder); + changed = true; + } + canonical.push(nextFolder); + }); + + if (changed) { + this.byId = nextById; + this.emit(); + } + + return { canonical, changed }; + } + + async ensure(id: FolderNodeId, fetcherOverride?: FetchFolder): Promise { + if (id == null) { + return null; + } + + const cached = this.byId.get(id); + if (cached) { + return cached; + } + + const fetcher = fetcherOverride || this.fetcher; + if (!fetcher) { + return null; + } + + const inflight = this.inflight.get(id); + if (inflight) { + return inflight; + } + + const request = (async () => { + try { + const fetched = await fetcher(id); + const { canonical } = this.ingest([fetched as unknown]); + return canonical[0] ?? null; + } finally { + this.inflight.delete(id); + } + })(); + + this.inflight.set(id, request); + return request; + } + + map(mapper: (folder: T) => T | undefined): boolean { + if (!this.byId.size) { + return false; + } + + let changed = false; + const next = new Map(); + this.byId.forEach((folder, key) => { + const updated = mapper(folder); + const nextFolder = updated === undefined ? folder : updated; + if (nextFolder !== folder) { + changed = true; + } + next.set(key, nextFolder ?? folder); + }); + + if (changed) { + this.byId = next; + this.emit(); + } + + return changed; + } + + remove(ids: Array): boolean { + if (!Array.isArray(ids) || ids.length === 0) { + return false; + } + let changed = false; + let next = this.byId; + ids.forEach((id) => { + if (next.has(id)) { + if (!changed) { + next = new Map(this.byId); + } + next.delete(id); + changed = true; + } + }); + if (changed) { + this.byId = next; + this.emit(); + } + return changed; + } + + getById(id: FolderNodeId): T | null { + return this.byId.get(id) ?? null; + } + + getMany(ids: Array = []): T[] { + return ids + .map((id) => this.byId.get(id) || null) + .filter((folder): folder is T => Boolean(folder)); + } + + getSnapshot(): Map { + return this.byId; + } +} + +export default FoldersManager; diff --git a/frontend/src/documents/data/useDocumentsWorkspace.ts b/frontend/src/documents/data/useDocumentsWorkspace.ts index f219f42..1414572 100644 --- a/frontend/src/documents/data/useDocumentsWorkspace.ts +++ b/frontend/src/documents/data/useDocumentsWorkspace.ts @@ -40,6 +40,7 @@ import { useStatusToast, type ToastVariant } from '../../lib/context/StatusToast import useAuthManager from './useAuthManager'; import useTenantManager from './useTenantManager'; import useDocuments from './useDocuments'; +import FoldersManager from '../FoldersManager'; import { fetchDocument } from '../../lib/api/apiClient'; import useFolderTree from '../features/folders/useFolderTree'; import useFolderTreeActions from '../features/folders/useFolderTreeActions'; @@ -315,6 +316,12 @@ const useDocumentsWorkspace = ({ fetchDocumentById, }); + const foldersManagerRef = useRef(null); + if (!foldersManagerRef.current) { + foldersManagerRef.current = new FoldersManager(); + } + const foldersManager = foldersManagerRef.current; + const documentLookup = useSyncExternalStore( (onStoreChange) => documentsManager.subscribe(onStoreChange), () => documentsManager.getSnapshot(), @@ -1301,6 +1308,8 @@ const useDocumentsWorkspace = ({ uploadQueue, clearUploadQueue, documentsFilter, + documentsManager, + foldersManager, }; // hook callers handle rendering / routing diff --git a/frontend/src/documents/data/useFolderData.ts b/frontend/src/documents/data/useFolderData.ts new file mode 100644 index 0000000..13b6646 --- /dev/null +++ b/frontend/src/documents/data/useFolderData.ts @@ -0,0 +1,127 @@ +import { useState, useEffect, useCallback, useMemo, useSyncExternalStore } from 'react'; +import { listFolderContents } from '../../lib/api/apiClient'; +import type { Document, Folder, FolderContents } from '../../types/documents'; +import type { Identifier, DocumentId, FolderNodeId } from '../../types/identifiers'; +import type DocumentsManager from '../DocumentsManager'; +import type FoldersManager from '../FoldersManager'; + +interface UseFolderDataResult { + folder: Folder | null; + documents: Document[]; + subfolders: Folder[]; + loading: boolean; + error: Error | null; + refresh: () => Promise; +} + +export const useFolderData = ( + folderId: Identifier | 'root' = 'root', + documentsManager?: DocumentsManager, + foldersManager?: FoldersManager +): UseFolderDataResult => { + const [data, setData] = useState({ + folder: null, + documents: [], + subfolders: [], + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const path = folderId === 'root' ? 'root' : folderId; + const result = await listFolderContents(path, { + include_documents: true, + }); + + if (documentsManager && result.documents) { + documentsManager.ingest(result.documents); + } + + if (foldersManager) { + const foldersToIngest: Folder[] = []; + if (result.folder) foldersToIngest.push(result.folder); + if (result.subfolders) foldersToIngest.push(...result.subfolders); + if (foldersToIngest.length > 0) { + foldersManager.ingest(foldersToIngest); + } + } + + setData({ + folder: result.folder || null, + documents: result.documents || [], + subfolders: result.subfolders || [], + }); + } catch (err) { + console.error('Failed to fetch folder data:', err); + setError(err instanceof Error ? err : new Error('Unknown error')); + } finally { + setLoading(false); + } + }, [folderId, documentsManager, foldersManager]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const subscribeToDocs = useCallback((onStoreChange: () => void) => { + if (!documentsManager) return () => { }; + return documentsManager.subscribe(onStoreChange); + }, [documentsManager]); + + const getDocSnapshot = useCallback(() => { + if (!documentsManager) return null; + return documentsManager.getSnapshot(); + }, [documentsManager]); + + const docSnapshot = useSyncExternalStore(subscribeToDocs, getDocSnapshot, getDocSnapshot); + + const reactiveDocuments = useMemo(() => { + if (!documentsManager || !docSnapshot) { + return data.documents; + } + const ids = data.documents.map(d => d.id as DocumentId).filter(Boolean); + return documentsManager.getMany(ids); + }, [data.documents, documentsManager, docSnapshot]); + + const subscribeToFolders = useCallback((onStoreChange: () => void) => { + if (!foldersManager) return () => { }; + return foldersManager.subscribe(onStoreChange); + }, [foldersManager]); + + const getFolderSnapshot = useCallback(() => { + if (!foldersManager) return null; + return foldersManager.getSnapshot(); + }, [foldersManager]); + + const folderSnapshot = useSyncExternalStore(subscribeToFolders, getFolderSnapshot, getFolderSnapshot); + + const reactiveFolder = useMemo(() => { + if (!foldersManager || !folderSnapshot || !data.folder) { + return data.folder; + } + return foldersManager.getById(data.folder.id as FolderNodeId) || data.folder; + }, [data.folder, foldersManager, folderSnapshot]); + + const reactiveSubfolders = useMemo(() => { + if (!foldersManager || !folderSnapshot) { + return data.subfolders; + } + const ids = data.subfolders.map(f => f.id as FolderNodeId).filter(Boolean); + return foldersManager.getMany(ids); + }, [data.subfolders, foldersManager, folderSnapshot]); + + return { + ...data, + folder: reactiveFolder, + documents: reactiveDocuments, + subfolders: reactiveSubfolders, + loading, + error, + refresh: fetchData, + }; +}; + +export default useFolderData; diff --git a/frontend/src/documents/features/folders/useFolderTree.ts b/frontend/src/documents/features/folders/useFolderTree.ts index 6ee38c5..37eaffb 100644 --- a/frontend/src/documents/features/folders/useFolderTree.ts +++ b/frontend/src/documents/features/folders/useFolderTree.ts @@ -9,37 +9,23 @@ import { createDocumentEntryKey, createFolderEntryKey, } from '../../../app/entryKey'; -import type { FolderId, FolderNodeId, Identifier } from '../../../types/identifiers'; -import type { Document } from '../../../types/documents'; +import type { FolderNodeId, Identifier, FolderId } from '../../../types/identifiers'; +import type { Document, Folder, FolderContents } from '../../../types/documents'; -interface FolderSummary { - id?: FolderNodeId; - name?: string; - parent_id?: FolderNodeId | null; - parentId?: FolderNodeId | null; - children?: FolderNodeId[]; - subfolders?: FolderSummary[]; - has_children?: boolean; - hasChildren?: boolean; - [key: string]: unknown; -} - -interface FolderContentsEntry { - folder?: FolderSummary | null; - documents?: Document[]; - subfolders?: FolderSummary[]; +interface FolderContentsEntry extends FolderContents { __includesDocuments?: boolean; __sortField?: string | null; __sortDirection?: string | null; [key: string]: unknown; } -interface FolderTreeNode extends FolderSummary { +interface FolderTreeNode extends Folder { id: FolderNodeId; children: FolderNodeId[]; expanded?: boolean; loaded?: boolean; hasChildren?: boolean; + parentId?: FolderNodeId; } interface SelectionHelpers { @@ -129,8 +115,8 @@ const useFolderTree = ({ }, []); const [selectedFolder, setSelectedFolder] = useState(initialSelectedFolder || 'root'); - const [currentFolder, setCurrentFolder] = useState(null); - const [currentSubfolders, setCurrentSubfolders] = useState([]); + const [currentFolder, setCurrentFolder] = useState(null); + const [currentSubfolders, setCurrentSubfolders] = useState([]); const { focusedDocumentId, diff --git a/frontend/src/documents/features/selection/SelectionFloatingActions.tsx b/frontend/src/documents/features/selection/SelectionFloatingActions.tsx index 9521865..acf0c08 100644 --- a/frontend/src/documents/features/selection/SelectionFloatingActions.tsx +++ b/frontend/src/documents/features/selection/SelectionFloatingActions.tsx @@ -15,7 +15,7 @@ import SelectionSummary from './SelectionSummary'; import { useAppState } from '../../../lib/store/appState'; import { useWorkspaceSelectionContext } from '../../../app/WorkspaceSelectionContext'; import type { DocumentId } from '../../../types/identifiers'; -import type { FolderTreeNode } from '../../../lib/api/apiTypes'; +import type { FolderTreeNode, FolderTreeResponseItem } from '../../../lib/api/apiTypes'; type NullableDocumentId = DocumentId | null; @@ -251,31 +251,14 @@ const SelectionFloatingActions: React.FC = ({ try { const data = await getFolderTree(); - // Convert flat list to tree - const nodeMap = new Map(); - data.forEach((item) => { - nodeMap.set(item.id, { + const traverse = (items: FolderTreeResponseItem[]): FolderTreeNode[] => { + return items.map(item => ({ ...item, - children: [], - } as FolderTreeNode); - }); - - const roots: FolderTreeNode[] = []; - data.forEach((item) => { - const node = nodeMap.get(item.id); - if (!node) return; - - if (item.children && item.children.length > 0) { - node.children = item.children - .map((id) => nodeMap.get(id)) - .filter((n): n is FolderTreeNode => Boolean(n)); - } - - if (!item.parent_id) { - roots.push(node); - } - }); + children: item.children ? traverse(item.children) : [], + })); + }; + const roots = traverse(data); setRemoteFolderTree(roots); return roots; } catch (error) { diff --git a/frontend/src/types/documents.ts b/frontend/src/types/documents.ts index 5094997..e4623bc 100644 --- a/frontend/src/types/documents.ts +++ b/frontend/src/types/documents.ts @@ -14,6 +14,13 @@ export interface DocumentCorrespondent { count?: number; } +export interface FolderContents { + folder: Folder | null; + documents: Document[]; + subfolders: Folder[]; + [key: string]: unknown; +} + /** * A tag entity as returned by the API (includes usage_count). * Use DocumentTag for the embedded version on documents. @@ -76,8 +83,12 @@ export interface Document { } export interface Folder { - id?: Identifier | 'root'; - name?: string; + id: Identifier | 'root'; + name: string; + parent_id?: Identifier | 'root' | null; + created_at?: string; + updated_at?: string; + [key: string]: unknown; } type FolderEntry = {