feat: Refactor folder tree management to use FoldersManager and remove onToggle functionality

This commit is contained in:
2025-12-07 01:12:28 +01:00
parent bd1cdc0817
commit 47599aba4a
13 changed files with 165 additions and 488 deletions
@@ -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<Identifier | null>;
documentsSortFieldRef: MutableRefObject<string>;
documentsSortDirectionRef: MutableRefObject<string>;
selectionHelpers: SelectionHelpers;
setDocuments: Dispatch<SetStateAction<Document[]>>;
setFolderContents: Dispatch<SetStateAction<Map<FolderNodeId, FolderContentsEntry>>>;
folderContentsRef: MutableRefObject<Map<FolderNodeId, FolderContentsEntry>>;
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<Map<FolderNodeId, FolderTreeNode>>(() => {
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<FolderNodeId>(initialSelectedFolder || 'root');
const [currentFolder, setCurrentFolder] = useState<Folder | null>(null);
@@ -199,72 +207,32 @@ const useFolderTree = ({
],
);
const expandFolderAncestors = useCallback((targetId: FolderNodeId | null) => {
if (!targetId || targetId === 'root') {
return;
}
setFolderNodes((prev: Map<FolderNodeId, FolderTreeNode>) => {
const next = new Map<FolderNodeId, FolderTreeNode>(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<FolderContentsEntry> => {
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<string, unknown> = {};
if (!includeDocuments) {
@@ -273,113 +241,30 @@ const useFolderTree = ({
params.sort = sortField;
params.dir = sortDirection;
}
const data = await listFolderContents<FolderContentsEntry>(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<FolderNodeId, FolderTreeNode>) => {
const next = new Map<FolderNodeId, FolderTreeNode>(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<FolderNodeId, FolderContentsEntry>) => {
if (tenantIdRef.current !== requestTenantId) {
return prev;
}
const next = new Map<FolderNodeId, FolderContentsEntry>(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<FolderNodeId, string>();
folderOptions.forEach((option) => {
@@ -514,8 +385,6 @@ const useFolderTree = ({
folderLabelMap,
applySelectedFolder,
ensureFolderData,
ensureFolderAncestorsLoaded,
expandFolderAncestors,
isInvalidFolderDrop,
resetFolderTreeState,
};
@@ -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<void>;
onSelect: (folderId: FolderKey, options?: SelectFolderOptions) => Promise<void>;
onDrop: (event: DragEvent<HTMLElement>, folderId: FolderKey) => Promise<void>;
onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => void;
@@ -59,8 +56,6 @@ interface UseFolderTreeActionsOptions {
selectedFolder: FolderKey;
setSelectedFolder: (folderId: FolderKey) => void;
ensureFolderData: (folderId: FolderKey, options?: EnsureFolderOptions) => Promise<any>;
ensureFolderAncestorsLoaded: (folderId: FolderKey) => Promise<void>;
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<HTMLElement>, 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,
],
);