feat: refine frontend folder management and display by merging folder tree data.

This commit is contained in:
2025-12-09 12:37:27 +01:00
parent 463046c970
commit 8a16293f0e
7 changed files with 155 additions and 190 deletions
+6
View File
@@ -227,6 +227,12 @@ class FoldersManager<T extends ManagedFolder = ManagedFolder> {
return this.treePromise; return this.treePromise;
} }
invalidateTree() {
this.treeSnapshot = [];
this.treePromise = null;
this.emit();
}
} }
export default FoldersManager; export default FoldersManager;
@@ -57,7 +57,6 @@ import { listFolderContents } from '../../lib/api/apiClient';
import { useApi } from '../../lib/context/ApiContext'; import { useApi } from '../../lib/context/ApiContext';
import { useWorkspaceSelection } from '../../app/useWorkspaceSelection'; import { useWorkspaceSelection } from '../../app/useWorkspaceSelection';
import useDocumentPreview from '../../app/useDocumentPreview'; import useDocumentPreview from '../../app/useDocumentPreview';
import { createRootNode } from '../../app/workspaceUtils';
import type { DocumentId, FolderNodeId, Identifier } from '../../types/identifiers'; import type { DocumentId, FolderNodeId, Identifier } from '../../types/identifiers';
const EntryType = Object.freeze({ const EntryType = Object.freeze({
@@ -250,7 +249,6 @@ const useDocumentsWorkspace = ({
}); });
const { const {
folderNodes, folderNodes,
setFolderNodes,
selectedFolder, selectedFolder,
setSelectedFolder, setSelectedFolder,
currentFolderName, currentFolderName,
@@ -261,6 +259,7 @@ const useDocumentsWorkspace = ({
const folderState = { const folderState = {
...folderStateRaw, ...folderStateRaw,
setCreatingFolder, setCreatingFolder,
foldersManager,
}; };
const [currentSubfolders, setCurrentSubfolders] = useState<Array<{ id?: FolderNodeId; name?: string | null;[key: string]: unknown }>>([]); const [currentSubfolders, setCurrentSubfolders] = useState<Array<{ id?: FolderNodeId; name?: string | null;[key: string]: unknown }>>([]);
@@ -299,7 +298,10 @@ const useDocumentsWorkspace = ({
if (includeDocuments) { if (includeDocuments) {
setDocuments((data.documents || []) as Document[]); setDocuments((data.documents || []) as Document[]);
} }
setCurrentSubfolders((data.subfolders || []) as any[]);
const subfolders = (data.subfolders || []) as any[];
setCurrentSubfolders(subfolders);
foldersManager.ingest(subfolders);
if (includeDocuments) { if (includeDocuments) {
setSelectedEntries((prev) => reconcileSelectionWithFolderData( setSelectedEntries((prev) => reconcileSelectionWithFolderData(
@@ -316,6 +318,7 @@ const useDocumentsWorkspace = ({
setCurrentSubfolders, setCurrentSubfolders,
reconcileSelectionWithFolderData, reconcileSelectionWithFolderData,
selectedFolderRef, selectedFolderRef,
foldersManager,
] ]
); );
@@ -580,8 +583,6 @@ const useDocumentsWorkspace = ({
}); });
const resetWorkspaceState = useCallback(() => { const resetWorkspaceState = useCallback(() => {
const rootNode = createRootNode();
setFolderNodes(new Map([[rootNode.id, rootNode]]));
setSelectedFolder('root'); setSelectedFolder('root');
setCurrentSubfolders([]); setCurrentSubfolders([]);
setDocuments([]); setDocuments([]);
@@ -617,7 +618,6 @@ const useDocumentsWorkspace = ({
setFocusedDocumentId, setFocusedDocumentId,
setSelectedEntries, setSelectedEntries,
setSelectionOrder, setSelectionOrder,
setFolderNodes,
setSelectedFolder, setSelectedFolder,
setCurrentSubfolders, setCurrentSubfolders,
setDocuments, setDocuments,
@@ -637,9 +637,10 @@ const useDocumentsWorkspace = ({
useEffect(() => { useEffect(() => {
if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') { if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') {
foldersManager.invalidateTree();
resetWorkspaceState(); resetWorkspaceState();
} }
}, [appStatus, resetWorkspaceState]); }, [appStatus, resetWorkspaceState, foldersManager]);
const removeDocumentsFromCaches = useCallback( const removeDocumentsFromCaches = useCallback(
@@ -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 { createRootNode, DEFAULT_FOLDER_NAME, flattenFolderTree } from '../../../app/workspaceUtils';
import type { FolderNodeId, FolderId } from '../../../types/identifiers'; import type { FolderNodeId, FolderId } from '../../../types/identifiers';
import type { FolderNode } from '../../../types/documents'; import type { FolderNode } from '../../../types/documents';
@@ -18,67 +18,81 @@ const useFolderTree = ({
initialSelectedFolder = 'root', initialSelectedFolder = 'root',
foldersManager, foldersManager,
}: UseFolderTreeOptions) => { }: UseFolderTreeOptions) => {
const [folderNodes, setFolderNodes] = useState<Map<FolderNodeId, FolderNode>>(() => { // Subscribe to manager updates
const rootNode = createRootNode() as FolderNode; const managerSnapshot = useSyncExternalStore(
return new Map([[rootNode.id, rootNode]]); 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<Set<FolderNodeId>>(new Set(['root']));
// Fetch tree on mount
useEffect(() => { useEffect(() => {
const fetchTree = async () => { if (foldersManager) {
try { foldersManager.ensureTree().catch(err => console.error(err));
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();
}, [foldersManager]); }, [foldersManager]);
const folderNodes = useMemo(() => {
if (!foldersManager || !managerSnapshot) {
const rootNode = createRootNode() as FolderNode;
return new Map([[rootNode.id, rootNode]]);
}
const map = new Map<FolderNodeId, FolderNode>();
// 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<FolderNodeId>(initialSelectedFolder || 'root'); const [selectedFolder, setSelectedFolder] = useState<FolderNodeId>(initialSelectedFolder || 'root');
const isInvalidFolderDrop = useCallback( const isInvalidFolderDrop = useCallback(
@@ -108,8 +122,7 @@ const useFolderTree = ({
); );
const resetFolderTreeState = useCallback(() => { const resetFolderTreeState = useCallback(() => {
const rootNode = createRootNode() as FolderNode; setExpandedIds(new Set(['root']));
setFolderNodes(new Map<FolderNodeId, FolderNode>([[rootNode.id, rootNode]]));
setSelectedFolder('root'); setSelectedFolder('root');
}, []); }, []);
@@ -157,12 +170,6 @@ const useFolderTree = ({
}, [folderNodes]); }, [folderNodes]);
useEffect(() => {
if (foldersManager && folderNodes.size > 0) {
foldersManager.ingest(Array.from(folderNodes.values()));
}
}, [foldersManager, folderNodes]);
const folderLabelMap = useMemo(() => { const folderLabelMap = useMemo(() => {
const map = new Map<FolderNodeId, string>(); const map = new Map<FolderNodeId, string>();
folderOptions.forEach((option) => { folderOptions.forEach((option) => {
@@ -171,9 +178,18 @@ const useFolderTree = ({
return map; return map;
}, [folderOptions]); }, [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 { return {
folderNodes, folderNodes,
setFolderNodes,
selectedFolder, selectedFolder,
setSelectedFolder, setSelectedFolder,
currentFolderName, currentFolderName,
@@ -181,6 +197,8 @@ const useFolderTree = ({
folderLabelMap, folderLabelMap,
isInvalidFolderDrop, isInvalidFolderDrop,
resetFolderTreeState, resetFolderTreeState,
toggleFolder,
setExpandedIds
}; };
}; };
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { DragEvent } from 'react'; import type { DragEvent } from 'react';
import { useStatusToast } from '../../../lib/context/StatusToastContext'; import { useStatusToast } from '../../../lib/context/StatusToastContext';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../../app/workspaceUtils'; import { hasFiles } from '../../../app/workspaceUtils';
import { import {
createFolder, createFolder,
deleteFolder, deleteFolder,
@@ -35,11 +35,12 @@ import useNotifyApiError from '../../../hooks/useNotifyApiError';
import type { import type {
FolderState, FolderState,
DragState, DragState,
FolderNode,
} from '../../types/workspaceTypes'; } from '../../types/workspaceTypes';
import type FoldersManager from '../../FoldersManager';
interface UseFolderTreeActionsOptions { interface UseFolderTreeActionsOptions {
folderState: Pick<FolderState, 'folderNodes' | 'setFolderNodes' | 'selectedFolder' | 'setSelectedFolder' | 'setCreatingFolder'>; folderState: Pick<FolderState, 'folderNodes' | 'selectedFolder' | 'setSelectedFolder' | 'setCreatingFolder'> & { foldersManager?: FoldersManager };
dragState: DragState; dragState: DragState;
actions: { actions: {
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise<void> | void; handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise<void> | void;
@@ -58,10 +59,10 @@ const useFolderTreeActions = ({
}: UseFolderTreeActionsOptions) => { }: UseFolderTreeActionsOptions) => {
const { const {
folderNodes, folderNodes,
setFolderNodes,
selectedFolder, selectedFolder,
setSelectedFolder, setSelectedFolder,
setCreatingFolder, setCreatingFolder,
foldersManager,
} = folderState; } = folderState;
const { const {
@@ -101,50 +102,9 @@ const useFolderTreeActions = ({
try { try {
await moveFolderRequest(folderId, parent_id); await moveFolderRequest(folderId, parent_id);
setFolderNodes((prev: Map<FolderKey, FolderNode>) => { if (foldersManager) {
const next = new Map(prev); foldersManager.invalidateTree();
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 (selectedFolder === folderId) { if (selectedFolder === folderId) {
setSelectedFolder(folderId); setSelectedFolder(folderId);
@@ -160,7 +120,7 @@ const useFolderTreeActions = ({
folderNodes, folderNodes,
notifyApiError, notifyApiError,
selectedFolder, selectedFolder,
setFolderNodes, foldersManager,
setSelectedFolder, setSelectedFolder,
showToast, showToast,
], ],
@@ -202,14 +162,9 @@ const useFolderTreeActions = ({
try { try {
await renameFolderRequest(folderId, trimmed); await renameFolderRequest(folderId, trimmed);
setFolderNodes((prev: Map<FolderKey, FolderNode>) => { if (foldersManager) {
const next = new Map(prev); foldersManager.ingest([{ id: folderId, name: trimmed }]);
const node = next.get(folderId); }
if (node) {
next.set(folderId, { ...node, name: trimmed });
}
return next;
});
showToast('Folder renamed.', 'success'); showToast('Folder renamed.', 'success');
return true; return true;
@@ -221,7 +176,7 @@ const useFolderTreeActions = ({
}, },
[ [
notifyApiError, notifyApiError,
setFolderNodes, foldersManager,
showToast, showToast,
], ],
); );
@@ -250,29 +205,13 @@ const useFolderTreeActions = ({
throw new Error('Folder creation failed.'); throw new Error('Folder creation failed.');
} }
showToast('Folder created.', 'success'); showToast('Folder created.', 'success');
setFolderNodes((prev: Map<FolderKey, FolderNode>) => {
const next = new Map(prev); if (foldersManager) {
const parentId = folderData.parent_id ?? payload.parent_id ?? 'root'; // Ingest the new folder data immediately so it's available
const parentNode = next.get(parentId); foldersManager.ingest([folderData]);
if (parentNode) { // Force tree refresh to update structure
next.set(parentId, { foldersManager.invalidateTree();
...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;
});
await selectFolder(folderData.id, { immediate: true }); await selectFolder(folderData.id, { immediate: true });
succeeded = true; succeeded = true;
@@ -293,7 +232,7 @@ const useFolderTreeActions = ({
selectFolder, selectFolder,
selectedFolder, selectedFolder,
setCreatingFolder, setCreatingFolder,
setFolderNodes, foldersManager,
showToast, showToast,
], ],
); );
@@ -310,26 +249,13 @@ const useFolderTreeActions = ({
try { try {
await deleteFolder(folderId); await deleteFolder(folderId);
setFolderNodes((prev: Map<FolderKey, FolderNode>) => { if (foldersManager) {
const next = new Map(prev); foldersManager.remove([folderId]);
const node = next.get(folderId); foldersManager.invalidateTree();
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 (selectedFolder === folderId) { if (selectedFolder === folderId) {
// Fallback selection logic
const node = folderNodes.get(folderId); const node = folderNodes.get(folderId);
const parentId = node?.parentId || 'root'; const parentId = node?.parentId || 'root';
setSelectedFolder(parentId); setSelectedFolder(parentId);
@@ -352,7 +278,7 @@ const useFolderTreeActions = ({
folderNodes, folderNodes,
notifyApiError, notifyApiError,
selectedFolder, selectedFolder,
setFolderNodes, foldersManager,
setSelectedFolder, setSelectedFolder,
showToast, showToast,
], ],
@@ -238,9 +238,30 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
() => foldersManager.getTreeSnapshot(), () => foldersManager.getTreeSnapshot(),
); );
const dataSnapshot = useSyncExternalStore(
useCallback(cb => foldersManager.subscribe(cb), [foldersManager]),
() => foldersManager.getSnapshot(),
() => foldersManager.getSnapshot(),
);
useEffect(() => { useEffect(() => {
setRemoteFolderTree(treeSnapshot); if (!treeSnapshot || treeSnapshot.length === 0) {
}, [treeSnapshot]); 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(() => { const requestFolderTree = useCallback(() => {
foldersManager.ensureTree(); foldersManager.ensureTree();
+1 -12
View File
@@ -1,19 +1,9 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers'; 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'; type FolderId = FolderIdentifier | 'root';
export interface FolderNode {
id: FolderId;
parentId?: FolderId;
children: FolderId[];
hasChildren?: boolean;
name?: string;
expanded?: boolean;
loaded?: boolean;
}
export interface Tag { export interface Tag {
id: DocumentId; id: DocumentId;
label: string; label: string;
@@ -55,7 +45,6 @@ export interface DocumentsState {
export interface FolderState { export interface FolderState {
folderNodes: Map<FolderId, FolderNode>; folderNodes: Map<FolderId, FolderNode>;
setFolderNodes: Dispatch<SetStateAction<Map<FolderId, FolderNode>>>;
selectedFolder: FolderId; selectedFolder: FolderId;
setSelectedFolder: Dispatch<SetStateAction<FolderId>>; setSelectedFolder: Dispatch<SetStateAction<FolderId>>;
folderLabelMap: Map<FolderId, string>; folderLabelMap: Map<FolderId, string>;
@@ -105,10 +105,13 @@ const SidebarFolderList: React.FC<SidebarFolderListProps> = ({
(nodes: FolderTreeNode[], depth: number): ReactNode => (nodes: FolderTreeNode[], depth: number): ReactNode =>
nodes.map((node) => { nodes.map((node) => {
const isExpanded = expandedIds.has(String(node.id)); 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 ( return (
<FolderNode <FolderNode
key={node.id} key={node.id}
node={node} node={displayNode}
depth={depth} depth={depth}
isSelected={selectedFolder === node.id} isSelected={selectedFolder === node.id}
onToggle={handleToggle} onToggle={handleToggle}
@@ -129,6 +132,7 @@ const SidebarFolderList: React.FC<SidebarFolderListProps> = ({
}), }),
[ [
expandedIds, expandedIds,
folderMap,
selectedFolder, selectedFolder,
handleToggle, handleToggle,
onSelect, onSelect,