diff --git a/frontend/src/app/DocumentsRoute.tsx b/frontend/src/app/DocumentsRoute.tsx index 26cd8b7..be64550 100644 --- a/frontend/src/app/DocumentsRoute.tsx +++ b/frontend/src/app/DocumentsRoute.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect } from 'react'; import type { ReactNode, ComponentProps } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { DocumentsFilterProvider, } from '../documents/context/DocumentsFilterContext'; @@ -11,14 +11,12 @@ 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<{ surfaceConfig: any; - folderData: any; onNavigate: (documentId: string) => void; sidebarProps: ComponentProps | null; -}> = ({ surfaceConfig, folderData, onNavigate, sidebarProps }) => { +}> = ({ surfaceConfig, onNavigate, sidebarProps }) => { const { openPreview } = usePreviewContext(); const { collapsed: sidebarCollapsed } = useSidebarContext(); const { @@ -39,10 +37,6 @@ const DocumentsInner: React.FC<{ ? { ...surfaceConfig.documentsTableProps, onBreadcrumbNavigate: surfaceConfig.handleBreadcrumbNavigate, - documents: folderData.documents, - subfolders: folderData.subfolders, - currentFolderName: folderData.folder?.name, - isSearchLoading: folderData.loading, } : null; @@ -94,13 +88,8 @@ 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,7 +102,6 @@ const DocumentsRouteContent: React.FC = () => { > diff --git a/frontend/src/documents/FoldersManager.ts b/frontend/src/documents/FoldersManager.ts index f8215f4..820cceb 100644 --- a/frontend/src/documents/FoldersManager.ts +++ b/frontend/src/documents/FoldersManager.ts @@ -1,6 +1,7 @@ import { shallowEqual } from 'react-redux'; import type { FolderNodeId } from '../types/identifiers'; import type { Folder } from '../types/documents'; +import { createRootNode } from '../app/workspaceUtils'; import type { FolderTreeNode } from '../lib/api/apiTypes'; import { getFolderTree } from '../lib/api/apiClient'; @@ -52,6 +53,14 @@ class FoldersManager { } ingest(rawFolders: unknown[] = []): { canonical: T[]; changed: boolean } { + const result = this.ingestInternal(rawFolders); + if (result.changed) { + this.emit(); + } + return result; + } + + private ingestInternal(rawFolders: unknown[] = []): { canonical: T[]; changed: boolean } { const folders = rawFolders.map((f) => f as T).filter(Boolean); let changed = false; let nextById = this.byId; @@ -81,7 +90,6 @@ class FoldersManager { if (changed) { this.byId = nextById; - this.emit(); } return { canonical, changed }; @@ -199,10 +207,16 @@ class FoldersManager { const raw = await getFolderTree(); const flattened = flattenFolderTree(raw); this.ingest(flattened); - const roots = raw as FolderTreeNode[]; - this.treeSnapshot = roots; + const rootsPromises = raw as FolderTreeNode[]; + const rootNode = createRootNode() as FolderTreeNode; + + rootNode.children = rootsPromises; + rootNode.hasChildren = rootsPromises.length > 0; + rootNode.loaded = true; + + this.treeSnapshot = [rootNode]; this.emit(); - return roots; + return [rootNode]; } catch (error) { console.warn('Failed to fetch folder tree', error); return []; @@ -215,5 +229,4 @@ class FoldersManager { } } - export default FoldersManager; diff --git a/frontend/src/documents/data/useDocumentMutations.ts b/frontend/src/documents/data/useDocumentMutations.ts index 6c72473..c56628b 100644 --- a/frontend/src/documents/data/useDocumentMutations.ts +++ b/frontend/src/documents/data/useDocumentMutations.ts @@ -34,7 +34,7 @@ type UpdateDocumentCaches = ( type EnsureFolderData = ( folderId: FolderId, - options?: { force?: boolean; includeDocuments?: boolean; prefetchDepth?: number }, + options?: { includeDocuments?: boolean }, ) => Promise; type ApplySelectedFolder = (folderId: FolderId, contents?: FolderContents | null) => void; @@ -336,7 +336,7 @@ const useDocumentMutations = ({ } if (targetFolderId && targetFolderId !== selectedFolder) { - await ensureFolderData(targetFolderId as FolderId, { force: true, prefetchDepth: 1 }); + await ensureFolderData(targetFolderId as FolderId); } } catch (error) { const message = (error as Record)?.response?.data?.error || 'Failed to move documents.'; @@ -668,10 +668,7 @@ const useDocumentMutations = ({ } try { - const contents = await ensureFolderData(folderId, { - force: true, - prefetchDepth: 1, - }); + const contents = await ensureFolderData(folderId); const hasChildren = (contents.subfolders || []).length > 0; const hasDocs = (contents.documents || []).length > 0; if (hasChildren || hasDocs) { @@ -712,13 +709,10 @@ const useDocumentMutations = ({ const node = folderNodes.get(folderId); const parentId = node?.parentId || 'root'; setSelectedFolder(parentId); - const parentContents = await ensureFolderData(parentId, { - force: true, - prefetchDepth: 1, - }); + const parentContents = await ensureFolderData(parentId); applySelectedFolder(parentId, parentContents); } else if (selectedFolder !== 'root') { - await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); + await ensureFolderData(selectedFolder); } if (showMessage) { diff --git a/frontend/src/documents/data/useDocumentsWorkspace.ts b/frontend/src/documents/data/useDocumentsWorkspace.ts index f5ddb4f..ca2864e 100644 --- a/frontend/src/documents/data/useDocumentsWorkspace.ts +++ b/frontend/src/documents/data/useDocumentsWorkspace.ts @@ -348,18 +348,16 @@ const useDocumentsWorkspace = ({ folderLabelMap, applySelectedFolder, ensureFolderData, - ensureFolderAncestorsLoaded, - expandFolderAncestors, isInvalidFolderDrop, } = useFolderTree({ initialSelectedFolder: routeFolderId || 'root', - tenantIdRef, documentsSortFieldRef: activeSortFieldRef, documentsSortDirectionRef: activeSortDirectionRef, selectionHelpers, setDocuments, setFolderContents, folderContentsRef, + foldersManager, }); const { @@ -579,10 +577,7 @@ const useDocumentsWorkspace = ({ ); const refreshCurrentFolder = useCallback(async () => { - const contents = await ensureFolderData(selectedFolder, { - force: true, - prefetchDepth: 1, - }); + const contents = await ensureFolderData(selectedFolder); applySelectedFolder(selectedFolder, contents); }, [selectedFolder, ensureFolderData, applySelectedFolder]); @@ -828,8 +823,6 @@ const useDocumentsWorkspace = ({ selectedFolder, setSelectedFolder, ensureFolderData, - ensureFolderAncestorsLoaded, - expandFolderAncestors, applySelectedFolder, notifyApiError, setStatusMessage, @@ -867,16 +860,20 @@ const useDocumentsWorkspace = ({ focusedEntryKey, }); const initializeAfterLogin = useCallback(async () => { - await Promise.all([refreshTags(), refreshCorrespondents()]); + await Promise.all([ + refreshTags(), + refreshCorrespondents(), + foldersManager.ensureTree(), + ]); const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root'; await loadFolder(initialFolder, {}); - }, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder]); + }, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, foldersManager]); useEffect(() => { if (!token) { return; } - if (appStatus !== 'ready' && appStatus !== 'bootstrapping') { + if (appStatus !== 'ready') { return; } @@ -901,7 +898,12 @@ const useDocumentsWorkspace = ({ selectFolder, ]); - + const mountedRef = useRef(true); + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); useEffect(() => { if (appStatus !== 'authenticated') { @@ -911,17 +913,16 @@ const useDocumentsWorkspace = ({ return; } - let cancelled = false; const bootstrap = async () => { bootstrapInitializedRef.current = true; appDispatch({ type: 'BOOTSTRAP_START' }); try { await initializeAfterLogin(); - if (!cancelled) { + if (mountedRef.current) { appDispatch({ type: 'BOOTSTRAP_SUCCESS' }); } } catch (error) { - if (!cancelled) { + if (mountedRef.current) { appDispatch({ type: 'BOOTSTRAP_FAILURE', error: error?.message || 'Failed to initialize data.', @@ -932,10 +933,6 @@ const useDocumentsWorkspace = ({ }; bootstrap(); - - return () => { - cancelled = true; - }; }, [appStatus, appDispatch, initializeAfterLogin]); const { diff --git a/frontend/src/documents/data/useFolderData.ts b/frontend/src/documents/data/useFolderData.ts deleted file mode 100644 index 4dafc3e..0000000 --- a/frontend/src/documents/data/useFolderData.ts +++ /dev/null @@ -1,127 +0,0 @@ -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; -} - -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 637da86..4cdc4e6 100644 --- a/frontend/src/documents/features/folders/useFolderTree.ts +++ b/frontend/src/documents/features/folders/useFolderTree.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getFolderTree, listFolderContents } from '../../../lib/api/apiClient'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { createRootNode, DEFAULT_FOLDER_NAME, flattenFolderTree } from '../../../app/workspaceUtils'; +import { listFolderContents } from '../../../lib/api/apiClient'; import { getEntryId, isDocumentEntry, @@ -11,6 +11,7 @@ import { } from '../../../app/entryKey'; import type { FolderNodeId, Identifier, FolderId } from '../../../types/identifiers'; import type { Document, Folder, FolderContents } from '../../../types/documents'; +import type FoldersManager from '../../FoldersManager'; interface FolderContentsEntry extends FolderContents { __includesDocuments?: boolean; @@ -39,13 +40,13 @@ interface SelectionHelpers { interface UseFolderTreeOptions { initialSelectedFolder?: FolderNodeId; - tenantIdRef: MutableRefObject; documentsSortFieldRef: MutableRefObject; documentsSortDirectionRef: MutableRefObject; selectionHelpers: SelectionHelpers; setDocuments: Dispatch>; setFolderContents: Dispatch>>; folderContentsRef: MutableRefObject>; + foldersManager?: FoldersManager; } interface FolderOption { @@ -55,13 +56,13 @@ interface FolderOption { const useFolderTree = ({ initialSelectedFolder = 'root', - tenantIdRef, documentsSortFieldRef, documentsSortDirectionRef, selectionHelpers, setDocuments, setFolderContents, folderContentsRef, + foldersManager, }: UseFolderTreeOptions) => { const [folderNodes, setFolderNodes] = useState>(() => { const rootNode = createRootNode() as FolderTreeNode; @@ -71,12 +72,16 @@ const useFolderTree = ({ useEffect(() => { const fetchTree = async () => { try { - const data = await getFolderTree(); + if (!foldersManager) return; + + // Use FoldersManager as the single source of truth for fetching the tree + const roots = await foldersManager.ensureTree(); + setFolderNodes((prev) => { const next = new Map(prev); const rootChildren: FolderNodeId[] = []; - const flatData = flattenFolderTree(data); + const flatData = flattenFolderTree(roots); flatData.forEach((item) => { const id = item.id as FolderNodeId; @@ -84,12 +89,15 @@ const useFolderTree = ({ // item.children is now FolderTreeNode[], so map to IDs const children = (item.children || []).map((c) => c.id as FolderNodeId); + // Preserve existing UI state (expanded, loaded) + const existing = prev.get(id); + next.set(id, { id, name: item.name, parentId, children, - expanded: false, + expanded: existing?.expanded ?? false, loaded: true, hasChildren: children.length > 0, }); @@ -115,7 +123,7 @@ const useFolderTree = ({ } }; fetchTree(); - }, []); + }, [foldersManager]); const [selectedFolder, setSelectedFolder] = useState(initialSelectedFolder || 'root'); const [currentFolder, setCurrentFolder] = useState(null); @@ -199,72 +207,32 @@ const useFolderTree = ({ ], ); - const expandFolderAncestors = useCallback((targetId: FolderNodeId | null) => { - if (!targetId || targetId === 'root') { - return; - } - - setFolderNodes((prev: Map) => { - const next = new Map(prev); - const node = next.get(targetId); - let currentId = node?.parentId ?? 'root'; - let guard = 0; - - while (currentId && guard < 32) { - guard += 1; - const currentNode = next.get(currentId); - if (!currentNode) break; - if (!currentNode.expanded) { - next.set(currentId, { ...currentNode, expanded: true }); - } - currentId = currentNode.parentId ?? 'root'; - } - return next; - }); - }, []); - const ensureFolderData = useCallback( async ( folderId: FolderNodeId, { includeDocuments = true, - prefetchDepth = 0, - force = false, sortField = documentsSortFieldRef.current, sortDirection = documentsSortDirectionRef.current, }: { includeDocuments?: boolean; - prefetchDepth?: number; - force?: boolean; sortField?: string; sortDirection?: string; } = {}, ): Promise => { - const requestTenantId = tenantIdRef.current; const cached = folderContentsRef.current.get(folderId); const cachedSortField = cached?.__sortField || documentsSortFieldRef.current; const cachedSortDirection = cached?.__sortDirection || documentsSortDirectionRef.current; const cachedSortMatches = cachedSortField === sortField && cachedSortDirection === sortDirection; - if (!force && cached) { + if (cached) { const includesDocuments = Boolean(cached.__includesDocuments); if (!includeDocuments || (includesDocuments && cachedSortMatches)) { - if (prefetchDepth > 0) { - const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : []; - await Promise.allSettled( - subfolders.map((entry) => - ensureFolderData(entry.id, { - includeDocuments: false, - prefetchDepth: prefetchDepth - 1, - force: false, - }), - ), - ); - } return cached; } } + // We use listFolderContents purely for Documents now, assuming Tree is authoritative for subfolders. const path = folderId === 'root' ? 'root' : folderId; const params: Record = {}; if (!includeDocuments) { @@ -273,113 +241,30 @@ const useFolderTree = ({ params.sort = sortField; params.dir = sortDirection; } - const data = await listFolderContents(path, params); - const childFolders = Array.isArray(data.subfolders) ? data.subfolders : []; - const childIds = childFolders - .map((child) => (child?.id ?? null) as FolderNodeId | null) - .filter((id): id is FolderNodeId => Boolean(id)); + + const data = await listFolderContents(path, params); const enriched = { ...data, + documents: (data.documents || []) as Document[], __includesDocuments: includeDocuments, __sortField: includeDocuments ? sortField : cachedSortField, __sortDirection: includeDocuments ? sortDirection : cachedSortDirection, }; - if (tenantIdRef.current !== requestTenantId) { - return enriched; - } - - setFolderNodes((prev: Map) => { - const next = new Map(prev); - const existingNode = next.get(folderId) || { - id: folderId, - name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder', - parentId: data.folder?.parent_id || 'root', - children: [], - expanded: folderId === 'root', - loaded: false, - hasChildren: false, - }; - - next.set(folderId, { - ...existingNode, - name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name, - parentId: data.folder?.parent_id ?? existingNode.parentId ?? 'root', - children: childIds, - expanded: folderId === 'root' ? true : existingNode.expanded, - loaded: true, - hasChildren: childIds.length > 0, - }); - - childFolders.forEach((child) => { - const childId = (child?.id ?? null) as FolderNodeId | null; - if (!childId) { - return; - } - const childNode = next.get(childId); - const previousChildren = Array.isArray(childNode?.children) ? childNode.children : []; - const childHasChildren = (() => { - if (childNode?.loaded) { - return previousChildren.length > 0; - } - if (Array.isArray(child?.subfolders)) { - return child.subfolders.length > 0; - } - const flag = [child?.has_children, child?.hasChildren, childNode?.hasChildren] - .find((value) => value != null); - return Boolean(flag); - })(); - next.set(childId, { - id: childId, - name: child.name, - parentId: (child.parent_id ?? 'root') as FolderNodeId, - children: previousChildren, - expanded: childNode?.expanded ?? false, - loaded: childNode?.loaded ?? false, - hasChildren: childHasChildren, - }); - }); - - return next; - }); - - if (prefetchDepth > 0 && childIds.length > 0 && tenantIdRef.current === requestTenantId) { - await Promise.allSettled( - childIds.map((childId) => - ensureFolderData(childId, { - includeDocuments: false, - force: false, - prefetchDepth: prefetchDepth - 1, - }), - ), - ); - } - setFolderContents((prev: Map) => { - if (tenantIdRef.current !== requestTenantId) { - return prev; - } const next = new Map(prev); if (includeDocuments) { next.set(folderId, enriched); } else { - const existingEntry = next.get(folderId); - if (existingEntry) { - next.set(folderId, { - ...existingEntry, - ...data, - documents: existingEntry.__includesDocuments - ? existingEntry.documents - : data.documents, - __includesDocuments: existingEntry.__includesDocuments || false, - __sortField: existingEntry.__sortField ?? enriched.__sortField, - __sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection, - }); - } else { - next.set(folderId, enriched); - } + const existing = next.get(folderId); + next.set(folderId, { + ...enriched, + documents: existing?.__includesDocuments ? existing.documents : (enriched.documents as Document[]), + __includesDocuments: existing?.__includesDocuments || false, + }); } + folderContentsRef.current = next; return next; }); @@ -388,33 +273,12 @@ const useFolderTree = ({ [ documentsSortDirectionRef, documentsSortFieldRef, - tenantIdRef, setFolderContents, folderContentsRef, + // Removed foldersManager dependency as we don't call it here anymore ], ); - const ensureFolderAncestorsLoaded = useCallback( - async (targetId: FolderNodeId | null) => { - if (!targetId || targetId === 'root') { - return; - } - let current = targetId; - let guard = 0; - while (current && current !== 'root' && guard < 32) { - guard += 1; - const node = folderNodes.get(current); - if (node?.loaded) { - current = node.parentId ?? 'root'; - continue; - } - await ensureFolderData(current, { includeDocuments: false, prefetchDepth: 0 }); - current = folderNodes.get(current)?.parentId ?? 'root'; - } - }, - [folderNodes, ensureFolderData], - ); - const isInvalidFolderDrop = useCallback( (sourceId: FolderNodeId | null, targetId: FolderNodeId | null) => { if (!sourceId) return false; @@ -492,6 +356,13 @@ const useFolderTree = ({ return entries; }, [folderNodes]); + + useEffect(() => { + if (foldersManager && folderNodes.size > 0) { + foldersManager.ingest(Array.from(folderNodes.values())); + } + }, [foldersManager, folderNodes]); + const folderLabelMap = useMemo(() => { const map = new Map(); folderOptions.forEach((option) => { @@ -514,8 +385,6 @@ const useFolderTree = ({ folderLabelMap, applySelectedFolder, ensureFolderData, - ensureFolderAncestorsLoaded, - expandFolderAncestors, isInvalidFolderDrop, resetFolderTreeState, }; diff --git a/frontend/src/documents/features/folders/useFolderTreeActions.ts b/frontend/src/documents/features/folders/useFolderTreeActions.ts index 46e9a2a..de82706 100644 --- a/frontend/src/documents/features/folders/useFolderTreeActions.ts +++ b/frontend/src/documents/features/folders/useFolderTreeActions.ts @@ -30,9 +30,7 @@ interface FolderContentsState { } interface EnsureFolderOptions { - force?: boolean; includeDocuments?: boolean; - prefetchDepth?: number; } interface LoadFolderOptions { @@ -45,7 +43,6 @@ interface SelectFolderOptions { } interface FolderClickHandlers { - onToggle: (folderId: FolderKey) => Promise; onSelect: (folderId: FolderKey, options?: SelectFolderOptions) => Promise; onDrop: (event: DragEvent, folderId: FolderKey) => Promise; onDragOver: (event: DragEvent, folderId: FolderKey) => void; @@ -59,8 +56,6 @@ interface UseFolderTreeActionsOptions { selectedFolder: FolderKey; setSelectedFolder: (folderId: FolderKey) => void; ensureFolderData: (folderId: FolderKey, options?: EnsureFolderOptions) => Promise; - ensureFolderAncestorsLoaded: (folderId: FolderKey) => Promise; - expandFolderAncestors: (folderId: FolderKey, options?: { includeSelf?: boolean }) => void; applySelectedFolder: (folderId: FolderKey, contents: any) => void; notifyApiError: (error: unknown, message?: string) => void; setStatusMessage: (message: string, level?: string) => void; @@ -88,8 +83,6 @@ const useFolderTreeActions = ({ selectedFolder, setSelectedFolder, ensureFolderData, - ensureFolderAncestorsLoaded, - expandFolderAncestors, applySelectedFolder, notifyApiError, setStatusMessage, @@ -175,12 +168,12 @@ const useFolderTreeActions = ({ const refreshTargets = new Set([previousParentKey, targetKey]); await Promise.all( Array.from(refreshTargets).map((key) => - ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }), + ensureFolderData(key), ), ); if (selectedFolder === folderId) { - await ensureFolderData(folderId, { force: true, prefetchDepth: 1 }); + await ensureFolderData(folderId); setSelectedFolder(folderId); } @@ -192,7 +185,7 @@ const useFolderTreeActions = ({ const refreshTargets = new Set([previousParentKey, targetKey]); await Promise.all( Array.from(refreshTargets).map((key) => - ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }), + ensureFolderData(key), ), ); } @@ -212,16 +205,12 @@ const useFolderTreeActions = ({ async (folderId: FolderKey | null, { preserveSearch = false }: LoadFolderOptions = {}) => { const targetId = folderId || 'root'; setSelectedFolder(targetId); - await ensureFolderAncestorsLoaded(targetId); - expandFolderAncestors(targetId); try { - const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 }); + const contents = await ensureFolderData(targetId); if (targetId !== 'root') { try { await ensureFolderData('root', { - force: false, includeDocuments: false, - prefetchDepth: 1, }); } catch (error) { console.warn('Failed to refresh root folder tree', error); @@ -237,9 +226,7 @@ const useFolderTreeActions = ({ }, [ applySelectedFolder, - ensureFolderAncestorsLoaded, ensureFolderData, - expandFolderAncestors, notifyApiError, setSearchResultIds, setSelectedFolder, @@ -250,9 +237,6 @@ const useFolderTreeActions = ({ async (folderId: FolderKey | null, { replace = false, immediate = false }: SelectFolderOptions = {}) => { const targetId = folderId && folderId !== 'root' ? folderId : 'root'; - await ensureFolderAncestorsLoaded(targetId); - expandFolderAncestors(targetId); - if (!navigate || immediate) { await loadFolder(targetId, { preserveSearch: isFilterActive }); setSelectedFolder(targetId); @@ -263,8 +247,6 @@ const useFolderTreeActions = ({ navigate(path, { replace }); }, [ - ensureFolderAncestorsLoaded, - expandFolderAncestors, isFilterActive, loadFolder, navigate, @@ -381,7 +363,7 @@ const useFolderTreeActions = ({ // Refresh the parent folder to ensure consistency const refreshTarget = targetParentId || 'root'; - await ensureFolderData(refreshTarget, { force: true, prefetchDepth: 1 }); + await ensureFolderData(refreshTarget); await selectFolder(folderData.id, { immediate: true }); succeeded = true; @@ -425,10 +407,7 @@ const useFolderTreeActions = ({ } try { - const contents = await ensureFolderData(folderId, { - force: true, - prefetchDepth: 1, - }); + const contents = await ensureFolderData(folderId); const hasChildren = (contents.subfolders || []).length > 0; const hasDocs = (contents.documents || []).length > 0; if (hasChildren || hasDocs) { @@ -469,13 +448,10 @@ const useFolderTreeActions = ({ const node = folderNodes.get(folderId); const parentId = node?.parentId || 'root'; setSelectedFolder(parentId); - const parentContents = await ensureFolderData(parentId, { - force: true, - prefetchDepth: 1, - }); + const parentContents = await ensureFolderData(parentId); applySelectedFolder(parentId, parentContents); } else if (selectedFolder !== 'root') { - await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); + await ensureFolderData(selectedFolder); } if (showMessage) { @@ -507,36 +483,6 @@ const useFolderTreeActions = ({ const folderClickHandlers: FolderClickHandlers = useMemo( () => ({ - onToggle: async (folderId: FolderKey) => { - const node = folderNodes.get(folderId); - const nextExpanded = !(node?.expanded ?? false); - if (nextExpanded) { - try { - await ensureFolderData(folderId, { - includeDocuments: false, - prefetchDepth: 1, - }); - } catch (error) { - notifyApiError(error, 'Failed to load folder.'); - } - } else if (node && !node.loaded) { - try { - await ensureFolderData(folderId, { - includeDocuments: false, - prefetchDepth: 1, - }); - } catch (error) { - notifyApiError(error, 'Failed to load folder.'); - } - } - setFolderNodes((prev) => { - const next = new Map(prev); - const current = next.get(folderId); - if (!current) return prev; - next.set(folderId, { ...current, expanded: nextExpanded }); - return next; - }); - }, onSelect: selectFolder, onDrop: async (event: DragEvent, folderId: FolderKey) => { event.preventDefault(); @@ -659,18 +605,14 @@ const useFolderTreeActions = ({ [ draggedDocumentIds, draggedFolderId, - ensureFolderData, - folderNodes, handleFileDrop, isInvalidFolderDrop, moveDocumentsToFolder, moveFolder, - notifyApiError, selectFolder, selectedFolder, setDraggedDocumentIds, setDraggedFolderId, - setFolderNodes, setStatusMessage, ], ); diff --git a/frontend/src/documents/features/upload/useDocumentUploads.ts b/frontend/src/documents/features/upload/useDocumentUploads.ts index 8036249..ddaa59b 100644 --- a/frontend/src/documents/features/upload/useDocumentUploads.ts +++ b/frontend/src/documents/features/upload/useDocumentUploads.ts @@ -95,7 +95,7 @@ interface UseDocumentUploadsArgs { token?: string | null; selectedFolder?: FolderId; currentFolderName?: string | null; - ensureFolderData: (folderId: FolderId, options?: { force?: boolean; prefetchDepth?: number }) => Promise; + ensureFolderData: (folderId: FolderId, options?: { [key: string]: unknown }) => Promise; refreshCurrentFolder: () => Promise; shellRef: MutableRefObject; notifyApiError?: NotifyApiError; @@ -441,7 +441,7 @@ const useDocumentUploads = ({ targetFolderId !== 'root' && targetFolderId !== selectedFolder ) { - await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); + await ensureFolderData(targetFolderId); } } catch (error: any) { const message = error.message || 'Failed to upload files.'; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 31e2b4c..7115a82 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -14,7 +14,7 @@ import DocumentsRoute from './app/DocumentsRoute'; import DropOverlay from './app/DropOverlay'; import LoginRoute from './app/LoginRoute'; import SettingsRoute from './app/SettingsRoute'; -import { AppStateProvider } from './lib/store/appState'; +import { AppStateProvider, useAppState } from './lib/store/appState'; import { useDocumentsPreferences } from './app/useDocumentsPreferences'; import { AppShellContext } from './lib/context/AppShellContext'; import useDocumentsWorkspace from './documents/data/useDocumentsWorkspace'; @@ -80,10 +80,18 @@ const AppLayout: React.FC = () => { ); }; +const TenantAwareLayout: React.FC = () => { + const { tenant } = useAppState(); + // Force remount when tenant changes to ensure clean state (folders, selection, etc.) + const key = tenant?.id ? String(tenant.id) : undefined; + + return ; +}; + const AppRouter: React.FC = () => ( } /> - }> + }> } /> } /> } /> diff --git a/frontend/src/lib/api/apiTypes.ts b/frontend/src/lib/api/apiTypes.ts index b333726..bc2c6ae 100644 --- a/frontend/src/lib/api/apiTypes.ts +++ b/frontend/src/lib/api/apiTypes.ts @@ -67,6 +67,8 @@ interface FolderInfo { export interface FolderTreeNode extends FolderInfo { children?: FolderTreeNode[]; + hasChildren?: boolean; + loaded?: boolean; } export interface CapabilitySetResponse { diff --git a/frontend/src/sidebar/Sidebar.tsx b/frontend/src/sidebar/Sidebar.tsx index 3165ede..338dcd1 100644 --- a/frontend/src/sidebar/Sidebar.tsx +++ b/frontend/src/sidebar/Sidebar.tsx @@ -1,6 +1,5 @@ -import React, { useRef, useSyncExternalStore, useCallback } from 'react'; +import React, { useRef } from 'react'; import { useAppShell } from '../lib/context/AppShellContext'; -import FoldersManager from '../documents/FoldersManager'; import { usePanelResizeBindings } from '../app/PanelManagerContext'; import type { Identifier } from '../types/identifiers'; import SidebarFolderList from './components/SidebarFolderList'; @@ -11,10 +10,7 @@ import { TenantOption } from './components/SidebarMenu'; import SidebarHeader from './components/SidebarHeader'; import SidebarSearch from './components/SidebarSearch'; - - interface SidebarProps { - onToggle: (folderId: FolderIdentifier) => void; onSelect: (folderId: FolderIdentifier) => void; onDrop: (event: React.DragEvent, folderId: FolderIdentifier) => void; onDragOver: (event: React.DragEvent, folderId: FolderIdentifier) => void; @@ -30,7 +26,6 @@ interface SidebarProps { } const Sidebar: React.FC = ({ - onToggle, onSelect, onDrop, onDragOver, @@ -51,7 +46,6 @@ const Sidebar: React.FC = ({ handleLogout, tags = [], correspondents = [], - foldersManager, tenant, tenants, tenantOptions, @@ -60,19 +54,6 @@ const Sidebar: React.FC = ({ handleFileSelection, // Used for upload } = useAppShell(); - // Subscribe to folder tree updates - const subscribeToTree = useCallback((callback: () => void) => { - if (!foldersManager) return () => { }; - return (foldersManager as FoldersManager).subscribe(callback); - }, [foldersManager]); - - const getTreeSnapshot = useCallback(() => { - if (!foldersManager) return []; - return (foldersManager as FoldersManager).getTreeSnapshot(); - }, [foldersManager]); - - const roots = useSyncExternalStore(subscribeToTree, getTreeSnapshot); - const onManageTags = openTagsModal; const onManageCorrespondents = openCorrespondentsModal; @@ -81,7 +62,6 @@ const Sidebar: React.FC = ({ const effectiveTenants = (tenants || tenantOptions || []); const activeTenantId = (tenant as TenantOption)?.id; - // Upload handler wrapper (if needed, or pass handleFileSelection directly if signature matches) const onUploadFiles = handleFileSelection; const sidebarRef = useRef(null); @@ -125,9 +105,7 @@ const Sidebar: React.FC = ({ void; onSelect: (folderId: FolderIdentifier) => void; onDrop: (event: React.DragEvent, folderId: FolderIdentifier) => void; onDragOver: (event: React.DragEvent, folderId: FolderIdentifier) => void; @@ -23,9 +24,7 @@ interface SidebarFolderListProps { } const SidebarFolderList: React.FC = ({ - roots, selectedFolder, - onToggle, onSelect, onDrop, onDragOver, @@ -38,17 +37,56 @@ const SidebarFolderList: React.FC = ({ onCreateFolder, creatingFolder, }) => { - // Local expansion state for the UI + const { foldersManager } = useAppShell(); + + const getTreeSnapshot = useCallback(() => { + if (!foldersManager) return []; + return (foldersManager as FoldersManager).getTreeSnapshot(); + }, [foldersManager]); + + const getFolderMap = useCallback(() => { + if (!foldersManager) return new Map(); + return (foldersManager as FoldersManager).getSnapshot(); + }, [foldersManager]); + + const subscribeToTree = useCallback((callback: () => void) => { + if (!foldersManager) return () => { }; + return (foldersManager as FoldersManager).subscribe(callback); + }, [foldersManager]); + + const roots = useSyncExternalStore(subscribeToTree, getTreeSnapshot); + const folderMap = useSyncExternalStore(subscribeToTree, getFolderMap); const [expandedIds, setExpandedIds] = React.useState>(new Set(['root'])); - // Override validation or external toggle if needed + // Auto-expand ancestors when selected folder changes React.useEffect(() => { - if (selectedFolder) { - // ensure parent paths are expanded? - // Without a parent map, we can't easily auto-expand up. - // Rely on user or initial state for now, or add parent pointers. + if (!selectedFolder || !folderMap) return; + + const ancestors = new Set(); + let current = folderMap.get(String(selectedFolder)); + + while (current) { + const parentId = current.parentId || current.parent_id; + if (!parentId || parentId === 'root') break; + + ancestors.add(String(parentId)); + current = folderMap.get(String(parentId)); } - }, [selectedFolder]); + + if (ancestors.size > 0) { + setExpandedIds((prev) => { + const next = new Set(prev); + let changed = false; + ancestors.forEach(id => { + if (!next.has(id)) { + next.add(id); + changed = true; + } + }); + return changed ? next : prev; + }); + } + }, [selectedFolder, folderMap]); const handleToggle = useCallback((folderId: FolderIdentifier) => { setExpandedIds((prev) => { @@ -61,8 +99,7 @@ const SidebarFolderList: React.FC = ({ } return next; }); - onToggle(folderId); // propagate event just in case parent needs it - }, [onToggle]); + }, []); const renderNodes = useCallback( (nodes: FolderTreeNode[], depth: number): ReactNode => @@ -126,7 +163,7 @@ const SidebarFolderList: React.FC = ({ ) : null}
    - {renderNodes(roots, 0)} + {renderNodes(roots as FolderTreeNode[], 0)}
); diff --git a/frontend/src/sidebar/useSidebarProps.ts b/frontend/src/sidebar/useSidebarProps.ts index 512b9aa..cec0a35 100644 --- a/frontend/src/sidebar/useSidebarProps.ts +++ b/frontend/src/sidebar/useSidebarProps.ts @@ -2,14 +2,12 @@ import { useMemo } from 'react'; import type { DragEvent } from 'react'; import type { Identifier } from '../types/identifiers'; -type FolderToggleHandler = (folderId: Identifier) => void | Promise; type FolderDropHandler = ( event: DragEvent, folderId: Identifier, ) => void | Promise; interface FolderClickHandlers { - onToggle: FolderToggleHandler; onSelect: (folderId: Identifier | null) => void; onDrop: FolderDropHandler; onDragOver: (event: DragEvent, folderId: Identifier) => void; @@ -30,7 +28,6 @@ interface UseSidebarPropsArgs { } interface SidebarHookResult { - onToggle: FolderClickHandlers['onToggle']; onSelect: FolderClickHandlers['onSelect']; onDrop: FolderClickHandlers['onDrop']; onDragOver: FolderClickHandlers['onDragOver']; @@ -45,44 +42,23 @@ interface SidebarHookResult { creatingFolder: boolean; } -const useSidebarProps = ({ - folderClickHandlers, - handleFolderDelete, - handleFolderRename, - selectedFolder, - handleFolderDragStart, - handleFolderDragEnd, - draggedFolderId, - handlePromptCreateFolder, - creatingFolder, -}: UseSidebarPropsArgs): SidebarHookResult => +const useSidebarProps = (args: UseSidebarPropsArgs): SidebarHookResult => useMemo( () => ({ - onToggle: folderClickHandlers.onToggle, - onSelect: folderClickHandlers.onSelect, - onDrop: folderClickHandlers.onDrop, - onDragOver: folderClickHandlers.onDragOver, - onDragLeave: folderClickHandlers.onDragLeave, - onDeleteFolder: handleFolderDelete, - onRenameFolder: handleFolderRename, - selectedFolder, - onFolderDragStart: handleFolderDragStart, - onFolderDragEnd: handleFolderDragEnd, - draggedFolderId, - onCreateFolder: (parentId) => handlePromptCreateFolder?.(parentId), - creatingFolder, + onSelect: args.folderClickHandlers.onSelect, + onDrop: args.folderClickHandlers.onDrop, + onDragOver: args.folderClickHandlers.onDragOver, + onDragLeave: args.folderClickHandlers.onDragLeave, + onDeleteFolder: args.handleFolderDelete, + onRenameFolder: args.handleFolderRename, + selectedFolder: args.selectedFolder, + onFolderDragStart: args.handleFolderDragStart, + onFolderDragEnd: args.handleFolderDragEnd, + draggedFolderId: args.draggedFolderId, + onCreateFolder: (parentId) => args.handlePromptCreateFolder?.(parentId), + creatingFolder: args.creatingFolder, }), - [ - creatingFolder, - folderClickHandlers, - handleFolderDragEnd, - handleFolderDragStart, - handleFolderDelete, - handleFolderRename, - handlePromptCreateFolder, - draggedFolderId, - selectedFolder, - ], + [args], ); export default useSidebarProps;