feat: Refactor folder tree management to use FoldersManager and remove onToggle functionality
This commit is contained in:
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user