diff --git a/frontend/src/app/DocumentsRoute.tsx b/frontend/src/app/DocumentsRoute.tsx index ff777f8..3acdb42 100644 --- a/frontend/src/app/DocumentsRoute.tsx +++ b/frontend/src/app/DocumentsRoute.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect } from 'react'; import type { ReactNode, ComponentProps } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { @@ -7,7 +7,6 @@ import { import { PreviewProvider, usePreviewContext } from '../viewer/PreviewContext'; import { DocumentOpenProvider } from '../lib/context/DocumentOpenContext'; import { useWorkspaceSurface } from './useWorkspaceSurface'; -import { DocumentsHeaderBreadcrumb } from '../documents/panel/DocumentsPanelHeader'; import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext'; import { PanelManagerProvider, usePanelManager } from './PanelManagerContext'; import Sidebar from '../sidebar/Sidebar'; @@ -30,34 +29,22 @@ const DocumentsInner: React.FC<{ const { openDetailPanel } = surfaceConfig; const sidebarHidden = sidebarCollapsed || sidebarSuppressed; - const navigate = useNavigate(); - - const handleBreadcrumbNavigate = useCallback((crumb: DocumentsHeaderBreadcrumb) => { - if (!crumb || !crumb.id) { - return; - } - const target = crumb.id === 'root' ? '/documents' : `/documents/folder/${crumb.id}`; - navigate(target); - }, [navigate]); - const handleOpenSidepanel = useCallback((docId: string) => { if (openDetailPanel) { openDetailPanel(docId); } }, [openDetailPanel]); - const documentsTablePropsWithNav = useMemo(() => ( - surfaceConfig.documentsTableProps - ? { - ...surfaceConfig.documentsTableProps, - onBreadcrumbNavigate: handleBreadcrumbNavigate, - documents: folderData.documents, - subfolders: folderData.subfolders, - currentFolderName: folderData.folder?.name, - isSearchLoading: folderData.loading, - } - : null - ), [surfaceConfig.documentsTableProps, handleBreadcrumbNavigate, folderData]); + const documentsTablePropsWithNav = surfaceConfig.documentsTableProps + ? { + ...surfaceConfig.documentsTableProps, + onBreadcrumbNavigate: surfaceConfig.handleBreadcrumbNavigate, + documents: folderData.documents, + subfolders: folderData.subfolders, + currentFolderName: folderData.folder?.name, + isSearchLoading: folderData.loading, + } + : null; const { surface } = useWorkspaceSurface({ sidebarHidden, diff --git a/frontend/src/app/useDocumentsShell.ts b/frontend/src/app/useDocumentsShell.ts index 7760cd2..1ffc9f3 100644 --- a/frontend/src/app/useDocumentsShell.ts +++ b/frontend/src/app/useDocumentsShell.ts @@ -9,6 +9,7 @@ import type { Identifier } from '../types/identifiers'; type WorkspaceSurfaceConfig = Omit & { openDetailPanel?: (documentId: Identifier) => void; closeDetailPanel?: () => void; + handleBreadcrumbNavigate?: (crumb: any) => void; }; interface DocumentsShellView { @@ -37,6 +38,7 @@ const useDocumentsShell = (): DocumentsShellView => { ensureAssetUrl: shell.ensureAssetUrl as WorkspaceSurfaceConfig['ensureAssetUrl'], getDocumentAsset: shell.getDocumentAsset as WorkspaceSurfaceConfig['getDocumentAsset'], notifyApiError: shell.notifyApiError as WorkspaceSurfaceConfig['notifyApiError'], + handleBreadcrumbNavigate: shell.handleBreadcrumbNavigate as WorkspaceSurfaceConfig['handleBreadcrumbNavigate'], }; const sidebarWithActions = sidebar diff --git a/frontend/src/app/workspaceUtils.ts b/frontend/src/app/workspaceUtils.ts index 55b1adb..f13494e 100644 --- a/frontend/src/app/workspaceUtils.ts +++ b/frontend/src/app/workspaceUtils.ts @@ -1,3 +1,4 @@ +import type { FolderTreeNode } from '../lib/api/apiTypes'; import { DEFAULT_FOLDER_NAME, DEFAULT_SORT_DIRECTION, @@ -50,3 +51,14 @@ export const createRootNode = () => ({ loaded: false, hasChildren: false, }); + +export const flattenFolderTree = (data: FolderTreeNode[]): FolderTreeNode[] => { + const result: FolderTreeNode[] = []; + data.forEach((item) => { + result.push(item); + if (item.children) { + result.push(...flattenFolderTree(item.children)); + } + }); + return result; +}; diff --git a/frontend/src/documents/FoldersManager.ts b/frontend/src/documents/FoldersManager.ts index fdfc1c2..f8215f4 100644 --- a/frontend/src/documents/FoldersManager.ts +++ b/frontend/src/documents/FoldersManager.ts @@ -2,17 +2,25 @@ import { shallowEqual } from 'react-redux'; import type { FolderNodeId } from '../types/identifiers'; import type { Folder } from '../types/documents'; +import type { FolderTreeNode } from '../lib/api/apiTypes'; +import { getFolderTree } from '../lib/api/apiClient'; +import { flattenFolderTree } from '../app/workspaceUtils'; + type ManagedFolder = Folder; type FetchFolder = (id: FolderNodeId) => Promise; + class FoldersManager { private byId: Map; private fetcher?: FetchFolder; private inflight: Map>; + private treePromise: Promise | null = null; + private treeSnapshot: FolderTreeNode[] = []; private listeners: Set<() => void>; private emitScheduled: boolean; + constructor( fetchFolder?: FetchFolder, ) { @@ -172,6 +180,40 @@ class FoldersManager { getSnapshot(): Map { return this.byId; } + + getTreeSnapshot(): FolderTreeNode[] { + return this.treeSnapshot; + } + + async ensureTree(): Promise { + if (this.treeSnapshot.length > 0) { + return this.treeSnapshot; + } + + if (this.treePromise) { + return this.treePromise; + } + + this.treePromise = (async () => { + try { + const raw = await getFolderTree(); + const flattened = flattenFolderTree(raw); + this.ingest(flattened); + const roots = raw as FolderTreeNode[]; + this.treeSnapshot = roots; + this.emit(); + return roots; + } catch (error) { + console.warn('Failed to fetch folder tree', error); + return []; + } finally { + this.treePromise = null; + } + })(); + + return this.treePromise; + } } + export default FoldersManager; diff --git a/frontend/src/documents/data/useDocumentsWorkspace.ts b/frontend/src/documents/data/useDocumentsWorkspace.ts index 1414572..22a6898 100644 --- a/frontend/src/documents/data/useDocumentsWorkspace.ts +++ b/frontend/src/documents/data/useDocumentsWorkspace.ts @@ -139,6 +139,15 @@ const useDocumentsWorkspace = ({ const routeFolderId = folderMatch?.params?.folderId || null; const routeDocumentId = docMatch?.params?.documentId || null; const previewDocumentId = routeDocumentId; + + const handleBreadcrumbNavigate = useCallback((crumb: { id?: Identifier | string } | null) => { + if (!crumb || !crumb.id) { + return; + } + const target = crumb.id === 'root' ? '/documents' : `/documents/folder/${crumb.id}`; + navigate(target); + }, [navigate]); + const { status: appStatus, token, @@ -1310,6 +1319,7 @@ const useDocumentsWorkspace = ({ documentsFilter, documentsManager, foldersManager, + handleBreadcrumbNavigate, }; // hook callers handle rendering / routing diff --git a/frontend/src/documents/data/useFolderData.ts b/frontend/src/documents/data/useFolderData.ts index 13b6646..4dafc3e 100644 --- a/frontend/src/documents/data/useFolderData.ts +++ b/frontend/src/documents/data/useFolderData.ts @@ -14,7 +14,7 @@ interface UseFolderDataResult { refresh: () => Promise; } -export const useFolderData = ( +const useFolderData = ( folderId: Identifier | 'root' = 'root', documentsManager?: DocumentsManager, foldersManager?: FoldersManager diff --git a/frontend/src/documents/features/folders/useFolderTree.ts b/frontend/src/documents/features/folders/useFolderTree.ts index 37eaffb..637da86 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 } from '../../../app/workspaceUtils'; +import { createRootNode, DEFAULT_FOLDER_NAME, flattenFolderTree } from '../../../app/workspaceUtils'; import { getEntryId, isDocumentEntry, @@ -76,10 +76,13 @@ const useFolderTree = ({ const next = new Map(prev); const rootChildren: FolderNodeId[] = []; - data.forEach((item) => { + const flatData = flattenFolderTree(data); + + flatData.forEach((item) => { const id = item.id as FolderNodeId; const parentId = (item.parent_id || 'root') as FolderNodeId; - const children = (item.children || []).map((c) => c as FolderNodeId); + // item.children is now FolderTreeNode[], so map to IDs + const children = (item.children || []).map((c) => c.id as FolderNodeId); next.set(id, { id, diff --git a/frontend/src/documents/features/selection/SelectionFloatingActions.tsx b/frontend/src/documents/features/selection/SelectionFloatingActions.tsx index acf0c08..423848e 100644 --- a/frontend/src/documents/features/selection/SelectionFloatingActions.tsx +++ b/frontend/src/documents/features/selection/SelectionFloatingActions.tsx @@ -1,6 +1,8 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { DEFAULT_FOLDER_NAME } from '../../../constants/workspace'; -import { getFolderTree } from '../../../lib/api/apiClient'; +import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; +import { DEFAULT_FOLDER_NAME } from '../../../app/workspaceUtils'; +import { useAppShell } from '../../../lib/context/AppShellContext'; +import FoldersManager from '../../FoldersManager'; + import { TrashIcon, AnalyzeIcon, @@ -12,10 +14,10 @@ import { import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu'; import SelectionFolderMenu from './SelectionFolderMenu'; import SelectionSummary from './SelectionSummary'; -import { useAppState } from '../../../lib/store/appState'; + import { useWorkspaceSelectionContext } from '../../../app/WorkspaceSelectionContext'; import type { DocumentId } from '../../../types/identifiers'; -import type { FolderTreeNode, FolderTreeResponseItem } from '../../../lib/api/apiTypes'; +import type { FolderTreeNode } from '../../../lib/api/apiTypes'; type NullableDocumentId = DocumentId | null; @@ -217,62 +219,33 @@ const SelectionFloatingActions: React.FC = ({ onClearSelection = null, onMoveDocumentsToFolder, }) => { - const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId; name?: string } | null }; - const tenantId = tenant?.id ?? null; + const documentLookupMap = useMemo(() => ( documentLookup instanceof Map ? documentLookup : new Map() ), [documentLookup]); const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null; - const [remoteFolderTree, setRemoteFolderTree] = useState(null); - const folderTreeFetchRef = useRef | null>(null); + const shell = useAppShell(); + const foldersManager = shell.foldersManager as FoldersManager; + + const [remoteFolderTree, setRemoteFolderTree] = useState([]); + + // Sync with manager + const treeSnapshot = useSyncExternalStore( + useCallback(cb => foldersManager.subscribe(cb), [foldersManager]), + () => foldersManager.getTreeSnapshot(), + () => foldersManager.getTreeSnapshot(), + ); useEffect(() => { - setRemoteFolderTree(null); - folderTreeFetchRef.current = null; - }, [tenantId, token]); + setRemoteFolderTree(treeSnapshot); + }, [treeSnapshot]); - const requestFolderTree = useCallback(async (): Promise => { - if (!token) { - setRemoteFolderTree([]); - return []; - } + const requestFolderTree = useCallback(() => { + foldersManager.ensureTree(); + }, [foldersManager]); - if (Array.isArray(remoteFolderTree)) { - return remoteFolderTree; - } - - if (folderTreeFetchRef.current) { - return folderTreeFetchRef.current; - } - - const fetchPromise = (async () => { - try { - const data = await getFolderTree(); - - const traverse = (items: FolderTreeResponseItem[]): FolderTreeNode[] => { - return items.map(item => ({ - ...item, - children: item.children ? traverse(item.children) : [], - })); - }; - - const roots = traverse(data); - setRemoteFolderTree(roots); - return roots; - } catch (error) { - console.warn('[selection] Failed to load folder tree', error); - setRemoteFolderTree([]); - return []; - } finally { - folderTreeFetchRef.current = null; - } - })(); - - folderTreeFetchRef.current = fetchPromise; - return fetchPromise; - }, [remoteFolderTree, token]); const handleMoveMenuOpen = useCallback(() => { requestFolderTree(); diff --git a/frontend/src/lib/api/apiClient.ts b/frontend/src/lib/api/apiClient.ts index 9cc76d4..7e922c0 100644 --- a/frontend/src/lib/api/apiClient.ts +++ b/frontend/src/lib/api/apiClient.ts @@ -12,7 +12,7 @@ import type { TenantSnippet, TagResponse, CorrespondentResponse, - FolderTreeResponseItem, + FolderTreeNode, } from './apiTypes'; import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosRequestConfig } from 'axios'; @@ -79,8 +79,8 @@ export const listDocuments = async (params: Record = {}): Promi return Array.isArray(data) ? data : []; }; -export const getFolderTree = async (): Promise => { - const { data } = await api.get('/folders/tree'); +export const getFolderTree = async (): Promise => { + const { data } = await api.get('/folders/tree'); return Array.isArray(data) ? data : []; }; diff --git a/frontend/src/lib/api/apiTypes.ts b/frontend/src/lib/api/apiTypes.ts index c021103..b333726 100644 --- a/frontend/src/lib/api/apiTypes.ts +++ b/frontend/src/lib/api/apiTypes.ts @@ -69,10 +69,6 @@ export interface FolderTreeNode extends FolderInfo { children?: FolderTreeNode[]; } -export interface FolderTreeResponseItem extends FolderInfo { - children?: string[]; -} - export interface CapabilitySetResponse { id: string; slug: string;