From 8a16293f0ee94c4722a57b19f452f154fa83b3c3 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 9 Dec 2025 12:37:27 +0100 Subject: [PATCH] feat: refine frontend folder management and display by merging folder tree data. --- frontend/src/documents/FoldersManager.ts | 6 + .../documents/data/useDocumentsWorkspace.ts | 15 +- .../features/folders/useFolderTree.ts | 152 ++++++++++-------- .../features/folders/useFolderTreeActions.ts | 128 ++++----------- .../selection/SelectionFloatingActions.tsx | 25 ++- .../src/documents/types/workspaceTypes.ts | 13 +- .../sidebar/components/SidebarFolderList.tsx | 6 +- 7 files changed, 155 insertions(+), 190 deletions(-) diff --git a/frontend/src/documents/FoldersManager.ts b/frontend/src/documents/FoldersManager.ts index 820cceb..dd7a076 100644 --- a/frontend/src/documents/FoldersManager.ts +++ b/frontend/src/documents/FoldersManager.ts @@ -227,6 +227,12 @@ class FoldersManager { return this.treePromise; } + + invalidateTree() { + this.treeSnapshot = []; + this.treePromise = null; + this.emit(); + } } export default FoldersManager; diff --git a/frontend/src/documents/data/useDocumentsWorkspace.ts b/frontend/src/documents/data/useDocumentsWorkspace.ts index a5156d7..ce2253f 100644 --- a/frontend/src/documents/data/useDocumentsWorkspace.ts +++ b/frontend/src/documents/data/useDocumentsWorkspace.ts @@ -57,7 +57,6 @@ import { listFolderContents } from '../../lib/api/apiClient'; import { useApi } from '../../lib/context/ApiContext'; import { useWorkspaceSelection } from '../../app/useWorkspaceSelection'; import useDocumentPreview from '../../app/useDocumentPreview'; -import { createRootNode } from '../../app/workspaceUtils'; import type { DocumentId, FolderNodeId, Identifier } from '../../types/identifiers'; const EntryType = Object.freeze({ @@ -250,7 +249,6 @@ const useDocumentsWorkspace = ({ }); const { folderNodes, - setFolderNodes, selectedFolder, setSelectedFolder, currentFolderName, @@ -261,6 +259,7 @@ const useDocumentsWorkspace = ({ const folderState = { ...folderStateRaw, setCreatingFolder, + foldersManager, }; const [currentSubfolders, setCurrentSubfolders] = useState>([]); @@ -299,7 +298,10 @@ const useDocumentsWorkspace = ({ if (includeDocuments) { setDocuments((data.documents || []) as Document[]); } - setCurrentSubfolders((data.subfolders || []) as any[]); + + const subfolders = (data.subfolders || []) as any[]; + setCurrentSubfolders(subfolders); + foldersManager.ingest(subfolders); if (includeDocuments) { setSelectedEntries((prev) => reconcileSelectionWithFolderData( @@ -316,6 +318,7 @@ const useDocumentsWorkspace = ({ setCurrentSubfolders, reconcileSelectionWithFolderData, selectedFolderRef, + foldersManager, ] ); @@ -580,8 +583,6 @@ const useDocumentsWorkspace = ({ }); const resetWorkspaceState = useCallback(() => { - const rootNode = createRootNode(); - setFolderNodes(new Map([[rootNode.id, rootNode]])); setSelectedFolder('root'); setCurrentSubfolders([]); setDocuments([]); @@ -617,7 +618,6 @@ const useDocumentsWorkspace = ({ setFocusedDocumentId, setSelectedEntries, setSelectionOrder, - setFolderNodes, setSelectedFolder, setCurrentSubfolders, setDocuments, @@ -637,9 +637,10 @@ const useDocumentsWorkspace = ({ useEffect(() => { if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') { + foldersManager.invalidateTree(); resetWorkspaceState(); } - }, [appStatus, resetWorkspaceState]); + }, [appStatus, resetWorkspaceState, foldersManager]); const removeDocumentsFromCaches = useCallback( diff --git a/frontend/src/documents/features/folders/useFolderTree.ts b/frontend/src/documents/features/folders/useFolderTree.ts index 77d953a..ec85939 100644 --- a/frontend/src/documents/features/folders/useFolderTree.ts +++ b/frontend/src/documents/features/folders/useFolderTree.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; import { createRootNode, DEFAULT_FOLDER_NAME, flattenFolderTree } from '../../../app/workspaceUtils'; import type { FolderNodeId, FolderId } from '../../../types/identifiers'; import type { FolderNode } from '../../../types/documents'; @@ -18,67 +18,81 @@ const useFolderTree = ({ initialSelectedFolder = 'root', foldersManager, }: UseFolderTreeOptions) => { - const [folderNodes, setFolderNodes] = useState>(() => { - const rootNode = createRootNode() as FolderNode; - return new Map([[rootNode.id, rootNode]]); - }); + // Subscribe to manager updates + const managerSnapshot = useSyncExternalStore( + useCallback(cb => foldersManager ? foldersManager.subscribe(cb) : () => { }, [foldersManager]), + () => foldersManager ? foldersManager.getSnapshot() : null, + () => foldersManager ? foldersManager.getSnapshot() : null, + ); + const treeSnapshot = useSyncExternalStore( + useCallback(cb => foldersManager ? foldersManager.subscribe(cb) : () => { }, [foldersManager]), + () => foldersManager ? foldersManager.getTreeSnapshot() : [], + () => foldersManager ? foldersManager.getTreeSnapshot() : [], + ); + + // Track expanded state locally + const [expandedIds, setExpandedIds] = useState>(new Set(['root'])); + + // Fetch tree on mount useEffect(() => { - const fetchTree = async () => { - try { - 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(roots); - - flatData.forEach((item) => { - const id = item.id as FolderNodeId; - const parentId = (item.parent_id || 'root') as FolderNodeId; - // 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: existing?.expanded ?? false, - loaded: true, - hasChildren: children.length > 0, - }); - - if (parentId === 'root') { - rootChildren.push(id); - } - }); - - const root = next.get('root'); - if (root) { - next.set('root', { - ...(root as FolderNode), - children: rootChildren, - hasChildren: rootChildren.length > 0, - loaded: true, - }); - } - return next; - }); - } catch (error) { - console.error('Failed to fetch folder tree', error); - } - }; - fetchTree(); + if (foldersManager) { + foldersManager.ensureTree().catch(err => console.error(err)); + } }, [foldersManager]); + const folderNodes = useMemo(() => { + if (!foldersManager || !managerSnapshot) { + const rootNode = createRootNode() as FolderNode; + return new Map([[rootNode.id, rootNode]]); + } + + const map = new Map(); + // Use the synced tree snapshot + const roots = treeSnapshot; + + if (roots.length === 0) { + // Return placeholder or empty + const rootNode = createRootNode() as FolderNode; + return new Map([[rootNode.id, rootNode]]); + } + + const flatStructure = flattenFolderTree(roots); + // Use the synced data snapshot + const dataSnapshot = managerSnapshot; + + // Reconstruct the nodes integrating data from byId and structure from tree + // plus local UI state (expanded) + const rootChildren: FolderNodeId[] = []; + + flatStructure.forEach((item) => { + const id = item.id as FolderNodeId; + const data = dataSnapshot.get(id); + + // Merge: structure (children, parent) comes from flatStructure (which comes from treeSnapshot) + // Data (name) comes from dataSnapshot (byId) to ensure renames propagate instantly + const name = data?.name ?? item.name; + const parentId = (item.parent_id || 'root') as FolderNodeId; + const children = (item.children || []).map(c => c.id as FolderNodeId); + + map.set(id, { + id, + name, + parentId, + children, + expanded: expandedIds.has(id), + loaded: true, + hasChildren: children.length > 0 + }); + + if (parentId === 'root') { + rootChildren.push(id); + } + }); + + return map; + }, [foldersManager, managerSnapshot, treeSnapshot, expandedIds]); + const [selectedFolder, setSelectedFolder] = useState(initialSelectedFolder || 'root'); const isInvalidFolderDrop = useCallback( @@ -108,8 +122,7 @@ const useFolderTree = ({ ); const resetFolderTreeState = useCallback(() => { - const rootNode = createRootNode() as FolderNode; - setFolderNodes(new Map([[rootNode.id, rootNode]])); + setExpandedIds(new Set(['root'])); setSelectedFolder('root'); }, []); @@ -157,12 +170,6 @@ const useFolderTree = ({ }, [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) => { @@ -171,9 +178,18 @@ const useFolderTree = ({ return map; }, [folderOptions]); + // Exposed helper to toggle expansion (if needed by consumers who can reach here) + const toggleFolder = useCallback((folderId: FolderNodeId) => { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(folderId)) next.delete(folderId); + else next.add(folderId); + return next; + }); + }, []); + return { folderNodes, - setFolderNodes, selectedFolder, setSelectedFolder, currentFolderName, @@ -181,6 +197,8 @@ const useFolderTree = ({ folderLabelMap, isInvalidFolderDrop, resetFolderTreeState, + toggleFolder, + setExpandedIds }; }; diff --git a/frontend/src/documents/features/folders/useFolderTreeActions.ts b/frontend/src/documents/features/folders/useFolderTreeActions.ts index 4eaf1e2..dd51891 100644 --- a/frontend/src/documents/features/folders/useFolderTreeActions.ts +++ b/frontend/src/documents/features/folders/useFolderTreeActions.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import type { DragEvent } from 'react'; import { useStatusToast } from '../../../lib/context/StatusToastContext'; -import { DEFAULT_FOLDER_NAME, hasFiles } from '../../../app/workspaceUtils'; +import { hasFiles } from '../../../app/workspaceUtils'; import { createFolder, deleteFolder, @@ -35,11 +35,12 @@ import useNotifyApiError from '../../../hooks/useNotifyApiError'; import type { FolderState, DragState, - FolderNode, } from '../../types/workspaceTypes'; +import type FoldersManager from '../../FoldersManager'; + interface UseFolderTreeActionsOptions { - folderState: Pick; + folderState: Pick & { foldersManager?: FoldersManager }; dragState: DragState; actions: { handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise | void; @@ -58,10 +59,10 @@ const useFolderTreeActions = ({ }: UseFolderTreeActionsOptions) => { const { folderNodes, - setFolderNodes, selectedFolder, setSelectedFolder, setCreatingFolder, + foldersManager, } = folderState; const { @@ -101,50 +102,9 @@ const useFolderTreeActions = ({ try { await moveFolderRequest(folderId, parent_id); - setFolderNodes((prev: Map) => { - const next = new Map(prev); - const currentNode = next.get(folderId); - if (!currentNode) { - return prev; - } - - next.set(folderId, { ...currentNode, parentId: parent_id ?? null }); - - const previousParent = next.get(previousParentKey); - if (previousParent) { - const remainingChildren = (previousParent.children || []).filter( - (childId) => childId !== folderId, - ); - next.set(previousParentKey, { - ...previousParent, - children: remainingChildren, - hasChildren: remainingChildren.length > 0, - }); - } - - if (!next.has(targetKey)) { - next.set(targetKey, { - id: targetKey, - name: targetKey === 'root' ? DEFAULT_FOLDER_NAME : 'Folder', - parentId: targetKey === 'root' ? null : null, - children: [], - expanded: targetKey === 'root', - loaded: false, - hasChildren: false, - }); - } - - const targetNode = next.get(targetKey); - if (targetNode && !targetNode.children.includes(folderId)) { - next.set(targetKey, { - ...targetNode, - children: [...targetNode.children, folderId], - hasChildren: true, - }); - } - - return next; - }); + if (foldersManager) { + foldersManager.invalidateTree(); + } if (selectedFolder === folderId) { setSelectedFolder(folderId); @@ -160,7 +120,7 @@ const useFolderTreeActions = ({ folderNodes, notifyApiError, selectedFolder, - setFolderNodes, + foldersManager, setSelectedFolder, showToast, ], @@ -202,14 +162,9 @@ const useFolderTreeActions = ({ try { await renameFolderRequest(folderId, trimmed); - setFolderNodes((prev: Map) => { - const next = new Map(prev); - const node = next.get(folderId); - if (node) { - next.set(folderId, { ...node, name: trimmed }); - } - return next; - }); + if (foldersManager) { + foldersManager.ingest([{ id: folderId, name: trimmed }]); + } showToast('Folder renamed.', 'success'); return true; @@ -221,7 +176,7 @@ const useFolderTreeActions = ({ }, [ notifyApiError, - setFolderNodes, + foldersManager, showToast, ], ); @@ -250,29 +205,13 @@ const useFolderTreeActions = ({ throw new Error('Folder creation failed.'); } showToast('Folder created.', 'success'); - setFolderNodes((prev: Map) => { - const next = new Map(prev); - const parentId = folderData.parent_id ?? payload.parent_id ?? 'root'; - const parentNode = next.get(parentId); - if (parentNode) { - next.set(parentId, { - ...parentNode, - children: parentNode.children.concat([folderData.id]), - loaded: true, - hasChildren: true, - }); - } - next.set(folderData.id, { - id: folderData.id, - name: folderData.name ?? payload.name, - parentId: parentId, - children: folderData.children || [], - expanded: false, - loaded: false, - hasChildren: Array.isArray(folderData.children) ? folderData.children.length > 0 : false, - }); - return next; - }); + + if (foldersManager) { + // Ingest the new folder data immediately so it's available + foldersManager.ingest([folderData]); + // Force tree refresh to update structure + foldersManager.invalidateTree(); + } await selectFolder(folderData.id, { immediate: true }); succeeded = true; @@ -293,7 +232,7 @@ const useFolderTreeActions = ({ selectFolder, selectedFolder, setCreatingFolder, - setFolderNodes, + foldersManager, showToast, ], ); @@ -310,26 +249,13 @@ const useFolderTreeActions = ({ try { await deleteFolder(folderId); - setFolderNodes((prev: Map) => { - const next = new Map(prev); - const node = next.get(folderId); - next.delete(folderId); - if (node) { - const parentId = node.parentId || 'root'; - const parentNode = next.get(parentId); - if (parentNode) { - const remaining = parentNode.children.filter((id) => id !== folderId); - next.set(parentId, { - ...parentNode, - children: remaining, - hasChildren: remaining.length > 0, - }); - } - } - return next; - }); + if (foldersManager) { + foldersManager.remove([folderId]); + foldersManager.invalidateTree(); + } if (selectedFolder === folderId) { + // Fallback selection logic const node = folderNodes.get(folderId); const parentId = node?.parentId || 'root'; setSelectedFolder(parentId); @@ -352,7 +278,7 @@ const useFolderTreeActions = ({ folderNodes, notifyApiError, selectedFolder, - setFolderNodes, + foldersManager, setSelectedFolder, showToast, ], diff --git a/frontend/src/documents/features/selection/SelectionFloatingActions.tsx b/frontend/src/documents/features/selection/SelectionFloatingActions.tsx index 423848e..f1771cf 100644 --- a/frontend/src/documents/features/selection/SelectionFloatingActions.tsx +++ b/frontend/src/documents/features/selection/SelectionFloatingActions.tsx @@ -238,9 +238,30 @@ const SelectionFloatingActions: React.FC = ({ () => foldersManager.getTreeSnapshot(), ); + const dataSnapshot = useSyncExternalStore( + useCallback(cb => foldersManager.subscribe(cb), [foldersManager]), + () => foldersManager.getSnapshot(), + () => foldersManager.getSnapshot(), + ); + useEffect(() => { - setRemoteFolderTree(treeSnapshot); - }, [treeSnapshot]); + if (!treeSnapshot || treeSnapshot.length === 0) { + setRemoteFolderTree([]); + return; + } + + const map = dataSnapshot; + + const mergeNode = (node: FolderTreeNode): FolderTreeNode => { + const liveData = map.get(node.id); + const name = liveData?.name ?? node.name; + const children = node.children ? node.children.map(mergeNode) : []; + return { ...node, name, children }; + }; + + const mergedTree = treeSnapshot.map(mergeNode); + setRemoteFolderTree(mergedTree); + }, [treeSnapshot, dataSnapshot]); const requestFolderTree = useCallback(() => { foldersManager.ensureTree(); diff --git a/frontend/src/documents/types/workspaceTypes.ts b/frontend/src/documents/types/workspaceTypes.ts index 6966c15..666e196 100644 --- a/frontend/src/documents/types/workspaceTypes.ts +++ b/frontend/src/documents/types/workspaceTypes.ts @@ -1,19 +1,9 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers'; -import type { Document } from '../../types/documents'; +import type { Document, FolderNode } from '../../types/documents'; type FolderId = FolderIdentifier | 'root'; -export interface FolderNode { - id: FolderId; - parentId?: FolderId; - children: FolderId[]; - hasChildren?: boolean; - name?: string; - expanded?: boolean; - loaded?: boolean; -} - export interface Tag { id: DocumentId; label: string; @@ -55,7 +45,6 @@ export interface DocumentsState { export interface FolderState { folderNodes: Map; - setFolderNodes: Dispatch>>; selectedFolder: FolderId; setSelectedFolder: Dispatch>; folderLabelMap: Map; diff --git a/frontend/src/sidebar/components/SidebarFolderList.tsx b/frontend/src/sidebar/components/SidebarFolderList.tsx index bef1c14..0c97a93 100644 --- a/frontend/src/sidebar/components/SidebarFolderList.tsx +++ b/frontend/src/sidebar/components/SidebarFolderList.tsx @@ -105,10 +105,13 @@ const SidebarFolderList: React.FC = ({ (nodes: FolderTreeNode[], depth: number): ReactNode => nodes.map((node) => { const isExpanded = expandedIds.has(String(node.id)); + const liveNode = folderMap.get(String(node.id)); + const displayNode = liveNode ? { ...node, name: liveNode.name ?? node.name } : node; + return ( = ({ }), [ expandedIds, + folderMap, selectedFolder, handleToggle, onSelect,