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
+2 -14
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import type { ReactNode, ComponentProps } from 'react'; import type { ReactNode, ComponentProps } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
DocumentsFilterProvider, DocumentsFilterProvider,
} from '../documents/context/DocumentsFilterContext'; } from '../documents/context/DocumentsFilterContext';
@@ -11,14 +11,12 @@ import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext'; import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
import Sidebar from '../sidebar/Sidebar'; import Sidebar from '../sidebar/Sidebar';
import useDocumentsShell from './useDocumentsShell'; import useDocumentsShell from './useDocumentsShell';
import useFolderData from '../documents/data/useFolderData';
const DocumentsInner: React.FC<{ const DocumentsInner: React.FC<{
surfaceConfig: any; surfaceConfig: any;
folderData: any;
onNavigate: (documentId: string) => void; onNavigate: (documentId: string) => void;
sidebarProps: ComponentProps<typeof Sidebar> | null; sidebarProps: ComponentProps<typeof Sidebar> | null;
}> = ({ surfaceConfig, folderData, onNavigate, sidebarProps }) => { }> = ({ surfaceConfig, onNavigate, sidebarProps }) => {
const { openPreview } = usePreviewContext(); const { openPreview } = usePreviewContext();
const { collapsed: sidebarCollapsed } = useSidebarContext(); const { collapsed: sidebarCollapsed } = useSidebarContext();
const { const {
@@ -39,10 +37,6 @@ const DocumentsInner: React.FC<{
? { ? {
...surfaceConfig.documentsTableProps, ...surfaceConfig.documentsTableProps,
onBreadcrumbNavigate: surfaceConfig.handleBreadcrumbNavigate, onBreadcrumbNavigate: surfaceConfig.handleBreadcrumbNavigate,
documents: folderData.documents,
subfolders: folderData.subfolders,
currentFolderName: folderData.folder?.name,
isSearchLoading: folderData.loading,
} }
: null; : null;
@@ -94,13 +88,8 @@ const DocumentsRouteContent: React.FC = () => {
sidebarProps, sidebarProps,
surfaceConfig, surfaceConfig,
documentsFilter, documentsFilter,
documentsManager,
foldersManager,
} = useDocumentsShell(); } = useDocumentsShell();
const navigate = useNavigate(); const navigate = useNavigate();
const params = useParams();
const folderId = params.folderId || 'root';
const folderData = useFolderData(folderId, documentsManager, foldersManager);
const handleDocumentNavigate = useCallback((documentId: string) => { const handleDocumentNavigate = useCallback((documentId: string) => {
navigate(`/documents/${documentId}`); navigate(`/documents/${documentId}`);
@@ -113,7 +102,6 @@ const DocumentsRouteContent: React.FC = () => {
> >
<DocumentsInner <DocumentsInner
surfaceConfig={surfaceConfig} surfaceConfig={surfaceConfig}
folderData={folderData}
onNavigate={handleDocumentNavigate} onNavigate={handleDocumentNavigate}
sidebarProps={sidebarProps} sidebarProps={sidebarProps}
/> />
+18 -5
View File
@@ -1,6 +1,7 @@
import { shallowEqual } from 'react-redux'; import { shallowEqual } from 'react-redux';
import type { FolderNodeId } from '../types/identifiers'; import type { FolderNodeId } from '../types/identifiers';
import type { Folder } from '../types/documents'; import type { Folder } from '../types/documents';
import { createRootNode } from '../app/workspaceUtils';
import type { FolderTreeNode } from '../lib/api/apiTypes'; import type { FolderTreeNode } from '../lib/api/apiTypes';
import { getFolderTree } from '../lib/api/apiClient'; import { getFolderTree } from '../lib/api/apiClient';
@@ -52,6 +53,14 @@ class FoldersManager<T extends ManagedFolder = ManagedFolder> {
} }
ingest(rawFolders: unknown[] = []): { canonical: T[]; changed: boolean } { ingest(rawFolders: unknown[] = []): { canonical: T[]; changed: boolean } {
const result = this.ingestInternal(rawFolders);
if (result.changed) {
this.emit();
}
return result;
}
private ingestInternal(rawFolders: unknown[] = []): { canonical: T[]; changed: boolean } {
const folders = rawFolders.map((f) => f as T).filter(Boolean); const folders = rawFolders.map((f) => f as T).filter(Boolean);
let changed = false; let changed = false;
let nextById = this.byId; let nextById = this.byId;
@@ -81,7 +90,6 @@ class FoldersManager<T extends ManagedFolder = ManagedFolder> {
if (changed) { if (changed) {
this.byId = nextById; this.byId = nextById;
this.emit();
} }
return { canonical, changed }; return { canonical, changed };
@@ -199,10 +207,16 @@ class FoldersManager<T extends ManagedFolder = ManagedFolder> {
const raw = await getFolderTree(); const raw = await getFolderTree();
const flattened = flattenFolderTree(raw); const flattened = flattenFolderTree(raw);
this.ingest(flattened); this.ingest(flattened);
const roots = raw as FolderTreeNode[]; const rootsPromises = raw as FolderTreeNode[];
this.treeSnapshot = roots; const rootNode = createRootNode() as FolderTreeNode;
rootNode.children = rootsPromises;
rootNode.hasChildren = rootsPromises.length > 0;
rootNode.loaded = true;
this.treeSnapshot = [rootNode];
this.emit(); this.emit();
return roots; return [rootNode];
} catch (error) { } catch (error) {
console.warn('Failed to fetch folder tree', error); console.warn('Failed to fetch folder tree', error);
return []; return [];
@@ -215,5 +229,4 @@ class FoldersManager<T extends ManagedFolder = ManagedFolder> {
} }
} }
export default FoldersManager; export default FoldersManager;
@@ -34,7 +34,7 @@ type UpdateDocumentCaches = (
type EnsureFolderData = ( type EnsureFolderData = (
folderId: FolderId, folderId: FolderId,
options?: { force?: boolean; includeDocuments?: boolean; prefetchDepth?: number }, options?: { includeDocuments?: boolean },
) => Promise<FolderContents>; ) => Promise<FolderContents>;
type ApplySelectedFolder = (folderId: FolderId, contents?: FolderContents | null) => void; type ApplySelectedFolder = (folderId: FolderId, contents?: FolderContents | null) => void;
@@ -336,7 +336,7 @@ const useDocumentMutations = ({
} }
if (targetFolderId && targetFolderId !== selectedFolder) { if (targetFolderId && targetFolderId !== selectedFolder) {
await ensureFolderData(targetFolderId as FolderId, { force: true, prefetchDepth: 1 }); await ensureFolderData(targetFolderId as FolderId);
} }
} catch (error) { } catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.'; const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.';
@@ -668,10 +668,7 @@ const useDocumentMutations = ({
} }
try { try {
const contents = await ensureFolderData(folderId, { const contents = await ensureFolderData(folderId);
force: true,
prefetchDepth: 1,
});
const hasChildren = (contents.subfolders || []).length > 0; const hasChildren = (contents.subfolders || []).length > 0;
const hasDocs = (contents.documents || []).length > 0; const hasDocs = (contents.documents || []).length > 0;
if (hasChildren || hasDocs) { if (hasChildren || hasDocs) {
@@ -712,13 +709,10 @@ const useDocumentMutations = ({
const node = folderNodes.get(folderId); const node = folderNodes.get(folderId);
const parentId = node?.parentId || 'root'; const parentId = node?.parentId || 'root';
setSelectedFolder(parentId); setSelectedFolder(parentId);
const parentContents = await ensureFolderData(parentId, { const parentContents = await ensureFolderData(parentId);
force: true,
prefetchDepth: 1,
});
applySelectedFolder(parentId, parentContents); applySelectedFolder(parentId, parentContents);
} else if (selectedFolder !== 'root') { } else if (selectedFolder !== 'root') {
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); await ensureFolderData(selectedFolder);
} }
if (showMessage) { if (showMessage) {
@@ -348,18 +348,16 @@ const useDocumentsWorkspace = ({
folderLabelMap, folderLabelMap,
applySelectedFolder, applySelectedFolder,
ensureFolderData, ensureFolderData,
ensureFolderAncestorsLoaded,
expandFolderAncestors,
isInvalidFolderDrop, isInvalidFolderDrop,
} = useFolderTree({ } = useFolderTree({
initialSelectedFolder: routeFolderId || 'root', initialSelectedFolder: routeFolderId || 'root',
tenantIdRef,
documentsSortFieldRef: activeSortFieldRef, documentsSortFieldRef: activeSortFieldRef,
documentsSortDirectionRef: activeSortDirectionRef, documentsSortDirectionRef: activeSortDirectionRef,
selectionHelpers, selectionHelpers,
setDocuments, setDocuments,
setFolderContents, setFolderContents,
folderContentsRef, folderContentsRef,
foldersManager,
}); });
const { const {
@@ -579,10 +577,7 @@ const useDocumentsWorkspace = ({
); );
const refreshCurrentFolder = useCallback(async () => { const refreshCurrentFolder = useCallback(async () => {
const contents = await ensureFolderData(selectedFolder, { const contents = await ensureFolderData(selectedFolder);
force: true,
prefetchDepth: 1,
});
applySelectedFolder(selectedFolder, contents); applySelectedFolder(selectedFolder, contents);
}, [selectedFolder, ensureFolderData, applySelectedFolder]); }, [selectedFolder, ensureFolderData, applySelectedFolder]);
@@ -828,8 +823,6 @@ const useDocumentsWorkspace = ({
selectedFolder, selectedFolder,
setSelectedFolder, setSelectedFolder,
ensureFolderData, ensureFolderData,
ensureFolderAncestorsLoaded,
expandFolderAncestors,
applySelectedFolder, applySelectedFolder,
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
@@ -867,16 +860,20 @@ const useDocumentsWorkspace = ({
focusedEntryKey, focusedEntryKey,
}); });
const initializeAfterLogin = useCallback(async () => { const initializeAfterLogin = useCallback(async () => {
await Promise.all([refreshTags(), refreshCorrespondents()]); await Promise.all([
refreshTags(),
refreshCorrespondents(),
foldersManager.ensureTree(),
]);
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root'; const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
await loadFolder(initialFolder, {}); await loadFolder(initialFolder, {});
}, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder]); }, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, foldersManager]);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
return; return;
} }
if (appStatus !== 'ready' && appStatus !== 'bootstrapping') { if (appStatus !== 'ready') {
return; return;
} }
@@ -901,7 +898,12 @@ const useDocumentsWorkspace = ({
selectFolder, selectFolder,
]); ]);
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => { useEffect(() => {
if (appStatus !== 'authenticated') { if (appStatus !== 'authenticated') {
@@ -911,17 +913,16 @@ const useDocumentsWorkspace = ({
return; return;
} }
let cancelled = false;
const bootstrap = async () => { const bootstrap = async () => {
bootstrapInitializedRef.current = true; bootstrapInitializedRef.current = true;
appDispatch({ type: 'BOOTSTRAP_START' }); appDispatch({ type: 'BOOTSTRAP_START' });
try { try {
await initializeAfterLogin(); await initializeAfterLogin();
if (!cancelled) { if (mountedRef.current) {
appDispatch({ type: 'BOOTSTRAP_SUCCESS' }); appDispatch({ type: 'BOOTSTRAP_SUCCESS' });
} }
} catch (error) { } catch (error) {
if (!cancelled) { if (mountedRef.current) {
appDispatch({ appDispatch({
type: 'BOOTSTRAP_FAILURE', type: 'BOOTSTRAP_FAILURE',
error: error?.message || 'Failed to initialize data.', error: error?.message || 'Failed to initialize data.',
@@ -932,10 +933,6 @@ const useDocumentsWorkspace = ({
}; };
bootstrap(); bootstrap();
return () => {
cancelled = true;
};
}, [appStatus, appDispatch, initializeAfterLogin]); }, [appStatus, appDispatch, initializeAfterLogin]);
const { const {
@@ -1,127 +0,0 @@
import { useState, useEffect, useCallback, useMemo, useSyncExternalStore } from 'react';
import { listFolderContents } from '../../lib/api/apiClient';
import type { Document, Folder, FolderContents } from '../../types/documents';
import type { Identifier, DocumentId, FolderNodeId } from '../../types/identifiers';
import type DocumentsManager from '../DocumentsManager';
import type FoldersManager from '../FoldersManager';
interface UseFolderDataResult {
folder: Folder | null;
documents: Document[];
subfolders: Folder[];
loading: boolean;
error: Error | null;
refresh: () => Promise<void>;
}
const useFolderData = (
folderId: Identifier | 'root' = 'root',
documentsManager?: DocumentsManager<Document>,
foldersManager?: FoldersManager<Folder>
): UseFolderDataResult => {
const [data, setData] = useState<FolderContents>({
folder: null,
documents: [],
subfolders: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const path = folderId === 'root' ? 'root' : folderId;
const result = await listFolderContents<FolderContents>(path, {
include_documents: true,
});
if (documentsManager && result.documents) {
documentsManager.ingest(result.documents);
}
if (foldersManager) {
const foldersToIngest: Folder[] = [];
if (result.folder) foldersToIngest.push(result.folder);
if (result.subfolders) foldersToIngest.push(...result.subfolders);
if (foldersToIngest.length > 0) {
foldersManager.ingest(foldersToIngest);
}
}
setData({
folder: result.folder || null,
documents: result.documents || [],
subfolders: result.subfolders || [],
});
} catch (err) {
console.error('Failed to fetch folder data:', err);
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
}, [folderId, documentsManager, foldersManager]);
useEffect(() => {
fetchData();
}, [fetchData]);
const subscribeToDocs = useCallback((onStoreChange: () => void) => {
if (!documentsManager) return () => { };
return documentsManager.subscribe(onStoreChange);
}, [documentsManager]);
const getDocSnapshot = useCallback(() => {
if (!documentsManager) return null;
return documentsManager.getSnapshot();
}, [documentsManager]);
const docSnapshot = useSyncExternalStore(subscribeToDocs, getDocSnapshot, getDocSnapshot);
const reactiveDocuments = useMemo(() => {
if (!documentsManager || !docSnapshot) {
return data.documents;
}
const ids = data.documents.map(d => d.id as DocumentId).filter(Boolean);
return documentsManager.getMany(ids);
}, [data.documents, documentsManager, docSnapshot]);
const subscribeToFolders = useCallback((onStoreChange: () => void) => {
if (!foldersManager) return () => { };
return foldersManager.subscribe(onStoreChange);
}, [foldersManager]);
const getFolderSnapshot = useCallback(() => {
if (!foldersManager) return null;
return foldersManager.getSnapshot();
}, [foldersManager]);
const folderSnapshot = useSyncExternalStore(subscribeToFolders, getFolderSnapshot, getFolderSnapshot);
const reactiveFolder = useMemo(() => {
if (!foldersManager || !folderSnapshot || !data.folder) {
return data.folder;
}
return foldersManager.getById(data.folder.id as FolderNodeId) || data.folder;
}, [data.folder, foldersManager, folderSnapshot]);
const reactiveSubfolders = useMemo(() => {
if (!foldersManager || !folderSnapshot) {
return data.subfolders;
}
const ids = data.subfolders.map(f => f.id as FolderNodeId).filter(Boolean);
return foldersManager.getMany(ids);
}, [data.subfolders, foldersManager, folderSnapshot]);
return {
...data,
folder: reactiveFolder,
documents: reactiveDocuments,
subfolders: reactiveSubfolders,
loading,
error,
refresh: fetchData,
};
};
export default useFolderData;
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getFolderTree, listFolderContents } from '../../../lib/api/apiClient';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { createRootNode, DEFAULT_FOLDER_NAME, flattenFolderTree } from '../../../app/workspaceUtils'; import { createRootNode, DEFAULT_FOLDER_NAME, flattenFolderTree } from '../../../app/workspaceUtils';
import { listFolderContents } from '../../../lib/api/apiClient';
import { import {
getEntryId, getEntryId,
isDocumentEntry, isDocumentEntry,
@@ -11,6 +11,7 @@ import {
} from '../../../app/entryKey'; } from '../../../app/entryKey';
import type { FolderNodeId, Identifier, FolderId } from '../../../types/identifiers'; import type { FolderNodeId, Identifier, FolderId } from '../../../types/identifiers';
import type { Document, Folder, FolderContents } from '../../../types/documents'; import type { Document, Folder, FolderContents } from '../../../types/documents';
import type FoldersManager from '../../FoldersManager';
interface FolderContentsEntry extends FolderContents { interface FolderContentsEntry extends FolderContents {
__includesDocuments?: boolean; __includesDocuments?: boolean;
@@ -39,13 +40,13 @@ interface SelectionHelpers {
interface UseFolderTreeOptions { interface UseFolderTreeOptions {
initialSelectedFolder?: FolderNodeId; initialSelectedFolder?: FolderNodeId;
tenantIdRef: MutableRefObject<Identifier | null>;
documentsSortFieldRef: MutableRefObject<string>; documentsSortFieldRef: MutableRefObject<string>;
documentsSortDirectionRef: MutableRefObject<string>; documentsSortDirectionRef: MutableRefObject<string>;
selectionHelpers: SelectionHelpers; selectionHelpers: SelectionHelpers;
setDocuments: Dispatch<SetStateAction<Document[]>>; setDocuments: Dispatch<SetStateAction<Document[]>>;
setFolderContents: Dispatch<SetStateAction<Map<FolderNodeId, FolderContentsEntry>>>; setFolderContents: Dispatch<SetStateAction<Map<FolderNodeId, FolderContentsEntry>>>;
folderContentsRef: MutableRefObject<Map<FolderNodeId, FolderContentsEntry>>; folderContentsRef: MutableRefObject<Map<FolderNodeId, FolderContentsEntry>>;
foldersManager?: FoldersManager;
} }
interface FolderOption { interface FolderOption {
@@ -55,13 +56,13 @@ interface FolderOption {
const useFolderTree = ({ const useFolderTree = ({
initialSelectedFolder = 'root', initialSelectedFolder = 'root',
tenantIdRef,
documentsSortFieldRef, documentsSortFieldRef,
documentsSortDirectionRef, documentsSortDirectionRef,
selectionHelpers, selectionHelpers,
setDocuments, setDocuments,
setFolderContents, setFolderContents,
folderContentsRef, folderContentsRef,
foldersManager,
}: UseFolderTreeOptions) => { }: UseFolderTreeOptions) => {
const [folderNodes, setFolderNodes] = useState<Map<FolderNodeId, FolderTreeNode>>(() => { const [folderNodes, setFolderNodes] = useState<Map<FolderNodeId, FolderTreeNode>>(() => {
const rootNode = createRootNode() as FolderTreeNode; const rootNode = createRootNode() as FolderTreeNode;
@@ -71,12 +72,16 @@ const useFolderTree = ({
useEffect(() => { useEffect(() => {
const fetchTree = async () => { const fetchTree = async () => {
try { 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) => { setFolderNodes((prev) => {
const next = new Map(prev); const next = new Map(prev);
const rootChildren: FolderNodeId[] = []; const rootChildren: FolderNodeId[] = [];
const flatData = flattenFolderTree(data); const flatData = flattenFolderTree(roots);
flatData.forEach((item) => { flatData.forEach((item) => {
const id = item.id as FolderNodeId; const id = item.id as FolderNodeId;
@@ -84,12 +89,15 @@ const useFolderTree = ({
// item.children is now FolderTreeNode[], so map to IDs // item.children is now FolderTreeNode[], so map to IDs
const children = (item.children || []).map((c) => c.id as FolderNodeId); const children = (item.children || []).map((c) => c.id as FolderNodeId);
// Preserve existing UI state (expanded, loaded)
const existing = prev.get(id);
next.set(id, { next.set(id, {
id, id,
name: item.name, name: item.name,
parentId, parentId,
children, children,
expanded: false, expanded: existing?.expanded ?? false,
loaded: true, loaded: true,
hasChildren: children.length > 0, hasChildren: children.length > 0,
}); });
@@ -115,7 +123,7 @@ const useFolderTree = ({
} }
}; };
fetchTree(); fetchTree();
}, []); }, [foldersManager]);
const [selectedFolder, setSelectedFolder] = useState<FolderNodeId>(initialSelectedFolder || 'root'); const [selectedFolder, setSelectedFolder] = useState<FolderNodeId>(initialSelectedFolder || 'root');
const [currentFolder, setCurrentFolder] = useState<Folder | null>(null); 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( const ensureFolderData = useCallback(
async ( async (
folderId: FolderNodeId, folderId: FolderNodeId,
{ {
includeDocuments = true, includeDocuments = true,
prefetchDepth = 0,
force = false,
sortField = documentsSortFieldRef.current, sortField = documentsSortFieldRef.current,
sortDirection = documentsSortDirectionRef.current, sortDirection = documentsSortDirectionRef.current,
}: { }: {
includeDocuments?: boolean; includeDocuments?: boolean;
prefetchDepth?: number;
force?: boolean;
sortField?: string; sortField?: string;
sortDirection?: string; sortDirection?: string;
} = {}, } = {},
): Promise<FolderContentsEntry> => { ): Promise<FolderContentsEntry> => {
const requestTenantId = tenantIdRef.current;
const cached = folderContentsRef.current.get(folderId); const cached = folderContentsRef.current.get(folderId);
const cachedSortField = cached?.__sortField || documentsSortFieldRef.current; const cachedSortField = cached?.__sortField || documentsSortFieldRef.current;
const cachedSortDirection = cached?.__sortDirection || documentsSortDirectionRef.current; const cachedSortDirection = cached?.__sortDirection || documentsSortDirectionRef.current;
const cachedSortMatches = cachedSortField === sortField && cachedSortDirection === sortDirection; const cachedSortMatches = cachedSortField === sortField && cachedSortDirection === sortDirection;
if (!force && cached) { if (cached) {
const includesDocuments = Boolean(cached.__includesDocuments); const includesDocuments = Boolean(cached.__includesDocuments);
if (!includeDocuments || (includesDocuments && cachedSortMatches)) { 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; return cached;
} }
} }
// We use listFolderContents purely for Documents now, assuming Tree is authoritative for subfolders.
const path = folderId === 'root' ? 'root' : folderId; const path = folderId === 'root' ? 'root' : folderId;
const params: Record<string, unknown> = {}; const params: Record<string, unknown> = {};
if (!includeDocuments) { if (!includeDocuments) {
@@ -273,113 +241,30 @@ const useFolderTree = ({
params.sort = sortField; params.sort = sortField;
params.dir = sortDirection; params.dir = sortDirection;
} }
const data = await listFolderContents<FolderContentsEntry>(path, params);
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : []; const data = await listFolderContents(path, params);
const childIds = childFolders
.map((child) => (child?.id ?? null) as FolderNodeId | null)
.filter((id): id is FolderNodeId => Boolean(id));
const enriched = { const enriched = {
...data, ...data,
documents: (data.documents || []) as Document[],
__includesDocuments: includeDocuments, __includesDocuments: includeDocuments,
__sortField: includeDocuments ? sortField : cachedSortField, __sortField: includeDocuments ? sortField : cachedSortField,
__sortDirection: includeDocuments ? sortDirection : cachedSortDirection, __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>) => { setFolderContents((prev: Map<FolderNodeId, FolderContentsEntry>) => {
if (tenantIdRef.current !== requestTenantId) {
return prev;
}
const next = new Map<FolderNodeId, FolderContentsEntry>(prev); const next = new Map<FolderNodeId, FolderContentsEntry>(prev);
if (includeDocuments) { if (includeDocuments) {
next.set(folderId, enriched); next.set(folderId, enriched);
} else { } else {
const existingEntry = next.get(folderId); const existing = next.get(folderId);
if (existingEntry) { next.set(folderId, {
next.set(folderId, { ...enriched,
...existingEntry, documents: existing?.__includesDocuments ? existing.documents : (enriched.documents as Document[]),
...data, __includesDocuments: existing?.__includesDocuments || false,
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);
}
} }
folderContentsRef.current = next;
return next; return next;
}); });
@@ -388,33 +273,12 @@ const useFolderTree = ({
[ [
documentsSortDirectionRef, documentsSortDirectionRef,
documentsSortFieldRef, documentsSortFieldRef,
tenantIdRef,
setFolderContents, setFolderContents,
folderContentsRef, 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( const isInvalidFolderDrop = useCallback(
(sourceId: FolderNodeId | null, targetId: FolderNodeId | null) => { (sourceId: FolderNodeId | null, targetId: FolderNodeId | null) => {
if (!sourceId) return false; if (!sourceId) return false;
@@ -492,6 +356,13 @@ const useFolderTree = ({
return entries; return entries;
}, [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) => {
@@ -514,8 +385,6 @@ const useFolderTree = ({
folderLabelMap, folderLabelMap,
applySelectedFolder, applySelectedFolder,
ensureFolderData, ensureFolderData,
ensureFolderAncestorsLoaded,
expandFolderAncestors,
isInvalidFolderDrop, isInvalidFolderDrop,
resetFolderTreeState, resetFolderTreeState,
}; };
@@ -30,9 +30,7 @@ interface FolderContentsState {
} }
interface EnsureFolderOptions { interface EnsureFolderOptions {
force?: boolean;
includeDocuments?: boolean; includeDocuments?: boolean;
prefetchDepth?: number;
} }
interface LoadFolderOptions { interface LoadFolderOptions {
@@ -45,7 +43,6 @@ interface SelectFolderOptions {
} }
interface FolderClickHandlers { interface FolderClickHandlers {
onToggle: (folderId: FolderKey) => Promise<void>;
onSelect: (folderId: FolderKey, options?: SelectFolderOptions) => Promise<void>; onSelect: (folderId: FolderKey, options?: SelectFolderOptions) => Promise<void>;
onDrop: (event: DragEvent<HTMLElement>, folderId: FolderKey) => Promise<void>; onDrop: (event: DragEvent<HTMLElement>, folderId: FolderKey) => Promise<void>;
onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => void; onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => void;
@@ -59,8 +56,6 @@ interface UseFolderTreeActionsOptions {
selectedFolder: FolderKey; selectedFolder: FolderKey;
setSelectedFolder: (folderId: FolderKey) => void; setSelectedFolder: (folderId: FolderKey) => void;
ensureFolderData: (folderId: FolderKey, options?: EnsureFolderOptions) => Promise<any>; 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; applySelectedFolder: (folderId: FolderKey, contents: any) => void;
notifyApiError: (error: unknown, message?: string) => void; notifyApiError: (error: unknown, message?: string) => void;
setStatusMessage: (message: string, level?: string) => void; setStatusMessage: (message: string, level?: string) => void;
@@ -88,8 +83,6 @@ const useFolderTreeActions = ({
selectedFolder, selectedFolder,
setSelectedFolder, setSelectedFolder,
ensureFolderData, ensureFolderData,
ensureFolderAncestorsLoaded,
expandFolderAncestors,
applySelectedFolder, applySelectedFolder,
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
@@ -175,12 +168,12 @@ const useFolderTreeActions = ({
const refreshTargets = new Set([previousParentKey, targetKey]); const refreshTargets = new Set([previousParentKey, targetKey]);
await Promise.all( await Promise.all(
Array.from(refreshTargets).map((key) => Array.from(refreshTargets).map((key) =>
ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }), ensureFolderData(key),
), ),
); );
if (selectedFolder === folderId) { if (selectedFolder === folderId) {
await ensureFolderData(folderId, { force: true, prefetchDepth: 1 }); await ensureFolderData(folderId);
setSelectedFolder(folderId); setSelectedFolder(folderId);
} }
@@ -192,7 +185,7 @@ const useFolderTreeActions = ({
const refreshTargets = new Set([previousParentKey, targetKey]); const refreshTargets = new Set([previousParentKey, targetKey]);
await Promise.all( await Promise.all(
Array.from(refreshTargets).map((key) => 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 = {}) => { async (folderId: FolderKey | null, { preserveSearch = false }: LoadFolderOptions = {}) => {
const targetId = folderId || 'root'; const targetId = folderId || 'root';
setSelectedFolder(targetId); setSelectedFolder(targetId);
await ensureFolderAncestorsLoaded(targetId);
expandFolderAncestors(targetId);
try { try {
const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 }); const contents = await ensureFolderData(targetId);
if (targetId !== 'root') { if (targetId !== 'root') {
try { try {
await ensureFolderData('root', { await ensureFolderData('root', {
force: false,
includeDocuments: false, includeDocuments: false,
prefetchDepth: 1,
}); });
} catch (error) { } catch (error) {
console.warn('Failed to refresh root folder tree', error); console.warn('Failed to refresh root folder tree', error);
@@ -237,9 +226,7 @@ const useFolderTreeActions = ({
}, },
[ [
applySelectedFolder, applySelectedFolder,
ensureFolderAncestorsLoaded,
ensureFolderData, ensureFolderData,
expandFolderAncestors,
notifyApiError, notifyApiError,
setSearchResultIds, setSearchResultIds,
setSelectedFolder, setSelectedFolder,
@@ -250,9 +237,6 @@ const useFolderTreeActions = ({
async (folderId: FolderKey | null, { replace = false, immediate = false }: SelectFolderOptions = {}) => { async (folderId: FolderKey | null, { replace = false, immediate = false }: SelectFolderOptions = {}) => {
const targetId = folderId && folderId !== 'root' ? folderId : 'root'; const targetId = folderId && folderId !== 'root' ? folderId : 'root';
await ensureFolderAncestorsLoaded(targetId);
expandFolderAncestors(targetId);
if (!navigate || immediate) { if (!navigate || immediate) {
await loadFolder(targetId, { preserveSearch: isFilterActive }); await loadFolder(targetId, { preserveSearch: isFilterActive });
setSelectedFolder(targetId); setSelectedFolder(targetId);
@@ -263,8 +247,6 @@ const useFolderTreeActions = ({
navigate(path, { replace }); navigate(path, { replace });
}, },
[ [
ensureFolderAncestorsLoaded,
expandFolderAncestors,
isFilterActive, isFilterActive,
loadFolder, loadFolder,
navigate, navigate,
@@ -381,7 +363,7 @@ const useFolderTreeActions = ({
// Refresh the parent folder to ensure consistency // Refresh the parent folder to ensure consistency
const refreshTarget = targetParentId || 'root'; const refreshTarget = targetParentId || 'root';
await ensureFolderData(refreshTarget, { force: true, prefetchDepth: 1 }); await ensureFolderData(refreshTarget);
await selectFolder(folderData.id, { immediate: true }); await selectFolder(folderData.id, { immediate: true });
succeeded = true; succeeded = true;
@@ -425,10 +407,7 @@ const useFolderTreeActions = ({
} }
try { try {
const contents = await ensureFolderData(folderId, { const contents = await ensureFolderData(folderId);
force: true,
prefetchDepth: 1,
});
const hasChildren = (contents.subfolders || []).length > 0; const hasChildren = (contents.subfolders || []).length > 0;
const hasDocs = (contents.documents || []).length > 0; const hasDocs = (contents.documents || []).length > 0;
if (hasChildren || hasDocs) { if (hasChildren || hasDocs) {
@@ -469,13 +448,10 @@ const useFolderTreeActions = ({
const node = folderNodes.get(folderId); const node = folderNodes.get(folderId);
const parentId = node?.parentId || 'root'; const parentId = node?.parentId || 'root';
setSelectedFolder(parentId); setSelectedFolder(parentId);
const parentContents = await ensureFolderData(parentId, { const parentContents = await ensureFolderData(parentId);
force: true,
prefetchDepth: 1,
});
applySelectedFolder(parentId, parentContents); applySelectedFolder(parentId, parentContents);
} else if (selectedFolder !== 'root') { } else if (selectedFolder !== 'root') {
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); await ensureFolderData(selectedFolder);
} }
if (showMessage) { if (showMessage) {
@@ -507,36 +483,6 @@ const useFolderTreeActions = ({
const folderClickHandlers: FolderClickHandlers = useMemo( 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, onSelect: selectFolder,
onDrop: async (event: DragEvent<HTMLElement>, folderId: FolderKey) => { onDrop: async (event: DragEvent<HTMLElement>, folderId: FolderKey) => {
event.preventDefault(); event.preventDefault();
@@ -659,18 +605,14 @@ const useFolderTreeActions = ({
[ [
draggedDocumentIds, draggedDocumentIds,
draggedFolderId, draggedFolderId,
ensureFolderData,
folderNodes,
handleFileDrop, handleFileDrop,
isInvalidFolderDrop, isInvalidFolderDrop,
moveDocumentsToFolder, moveDocumentsToFolder,
moveFolder, moveFolder,
notifyApiError,
selectFolder, selectFolder,
selectedFolder, selectedFolder,
setDraggedDocumentIds, setDraggedDocumentIds,
setDraggedFolderId, setDraggedFolderId,
setFolderNodes,
setStatusMessage, setStatusMessage,
], ],
); );
@@ -95,7 +95,7 @@ interface UseDocumentUploadsArgs {
token?: string | null; token?: string | null;
selectedFolder?: FolderId; selectedFolder?: FolderId;
currentFolderName?: string | null; currentFolderName?: string | null;
ensureFolderData: (folderId: FolderId, options?: { force?: boolean; prefetchDepth?: number }) => Promise<void>; ensureFolderData: (folderId: FolderId, options?: { [key: string]: unknown }) => Promise<void>;
refreshCurrentFolder: () => Promise<void>; refreshCurrentFolder: () => Promise<void>;
shellRef: MutableRefObject<HTMLElement | null>; shellRef: MutableRefObject<HTMLElement | null>;
notifyApiError?: NotifyApiError; notifyApiError?: NotifyApiError;
@@ -441,7 +441,7 @@ const useDocumentUploads = ({
targetFolderId !== 'root' && targetFolderId !== 'root' &&
targetFolderId !== selectedFolder targetFolderId !== selectedFolder
) { ) {
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); await ensureFolderData(targetFolderId);
} }
} catch (error: any) { } catch (error: any) {
const message = error.message || 'Failed to upload files.'; const message = error.message || 'Failed to upload files.';
+10 -2
View File
@@ -14,7 +14,7 @@ import DocumentsRoute from './app/DocumentsRoute';
import DropOverlay from './app/DropOverlay'; import DropOverlay from './app/DropOverlay';
import LoginRoute from './app/LoginRoute'; import LoginRoute from './app/LoginRoute';
import SettingsRoute from './app/SettingsRoute'; import SettingsRoute from './app/SettingsRoute';
import { AppStateProvider } from './lib/store/appState'; import { AppStateProvider, useAppState } from './lib/store/appState';
import { useDocumentsPreferences } from './app/useDocumentsPreferences'; import { useDocumentsPreferences } from './app/useDocumentsPreferences';
import { AppShellContext } from './lib/context/AppShellContext'; import { AppShellContext } from './lib/context/AppShellContext';
import useDocumentsWorkspace from './documents/data/useDocumentsWorkspace'; import useDocumentsWorkspace from './documents/data/useDocumentsWorkspace';
@@ -80,10 +80,18 @@ const AppLayout: React.FC = () => {
); );
}; };
const TenantAwareLayout: React.FC = () => {
const { tenant } = useAppState();
// Force remount when tenant changes to ensure clean state (folders, selection, etc.)
const key = tenant?.id ? String(tenant.id) : undefined;
return <AppLayout key={key} />;
};
const AppRouter: React.FC = () => ( const AppRouter: React.FC = () => (
<Routes> <Routes>
<Route path="/account/login" element={<LoginRoute />} /> <Route path="/account/login" element={<LoginRoute />} />
<Route element={<AppLayout />}> <Route element={<TenantAwareLayout />}>
<Route path="/" element={<Navigate to="/documents" replace />} /> <Route path="/" element={<Navigate to="/documents" replace />} />
<Route path="/documents" element={<DocumentsRoute />} /> <Route path="/documents" element={<DocumentsRoute />} />
<Route path="/documents/folder/:folderId" element={<DocumentsRoute />} /> <Route path="/documents/folder/:folderId" element={<DocumentsRoute />} />
+2
View File
@@ -67,6 +67,8 @@ interface FolderInfo {
export interface FolderTreeNode extends FolderInfo { export interface FolderTreeNode extends FolderInfo {
children?: FolderTreeNode[]; children?: FolderTreeNode[];
hasChildren?: boolean;
loaded?: boolean;
} }
export interface CapabilitySetResponse { export interface CapabilitySetResponse {
+1 -23
View File
@@ -1,6 +1,5 @@
import React, { useRef, useSyncExternalStore, useCallback } from 'react'; import React, { useRef } from 'react';
import { useAppShell } from '../lib/context/AppShellContext'; import { useAppShell } from '../lib/context/AppShellContext';
import FoldersManager from '../documents/FoldersManager';
import { usePanelResizeBindings } from '../app/PanelManagerContext'; import { usePanelResizeBindings } from '../app/PanelManagerContext';
import type { Identifier } from '../types/identifiers'; import type { Identifier } from '../types/identifiers';
import SidebarFolderList from './components/SidebarFolderList'; import SidebarFolderList from './components/SidebarFolderList';
@@ -11,10 +10,7 @@ import { TenantOption } from './components/SidebarMenu';
import SidebarHeader from './components/SidebarHeader'; import SidebarHeader from './components/SidebarHeader';
import SidebarSearch from './components/SidebarSearch'; import SidebarSearch from './components/SidebarSearch';
interface SidebarProps { interface SidebarProps {
onToggle: (folderId: FolderIdentifier) => void;
onSelect: (folderId: FolderIdentifier) => void; onSelect: (folderId: FolderIdentifier) => void;
onDrop: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void; onDrop: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
onDragOver: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void; onDragOver: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
@@ -30,7 +26,6 @@ interface SidebarProps {
} }
const Sidebar: React.FC<SidebarProps> = ({ const Sidebar: React.FC<SidebarProps> = ({
onToggle,
onSelect, onSelect,
onDrop, onDrop,
onDragOver, onDragOver,
@@ -51,7 +46,6 @@ const Sidebar: React.FC<SidebarProps> = ({
handleLogout, handleLogout,
tags = [], tags = [],
correspondents = [], correspondents = [],
foldersManager,
tenant, tenant,
tenants, tenants,
tenantOptions, tenantOptions,
@@ -60,19 +54,6 @@ const Sidebar: React.FC<SidebarProps> = ({
handleFileSelection, // Used for upload handleFileSelection, // Used for upload
} = useAppShell(); } = useAppShell();
// Subscribe to folder tree updates
const subscribeToTree = useCallback((callback: () => void) => {
if (!foldersManager) return () => { };
return (foldersManager as FoldersManager).subscribe(callback);
}, [foldersManager]);
const getTreeSnapshot = useCallback(() => {
if (!foldersManager) return [];
return (foldersManager as FoldersManager).getTreeSnapshot();
}, [foldersManager]);
const roots = useSyncExternalStore(subscribeToTree, getTreeSnapshot);
const onManageTags = openTagsModal; const onManageTags = openTagsModal;
const onManageCorrespondents = openCorrespondentsModal; const onManageCorrespondents = openCorrespondentsModal;
@@ -81,7 +62,6 @@ const Sidebar: React.FC<SidebarProps> = ({
const effectiveTenants = (tenants || tenantOptions || []); const effectiveTenants = (tenants || tenantOptions || []);
const activeTenantId = (tenant as TenantOption)?.id; const activeTenantId = (tenant as TenantOption)?.id;
// Upload handler wrapper (if needed, or pass handleFileSelection directly if signature matches)
const onUploadFiles = handleFileSelection; const onUploadFiles = handleFileSelection;
const sidebarRef = useRef<HTMLDivElement | null>(null); const sidebarRef = useRef<HTMLDivElement | null>(null);
@@ -125,9 +105,7 @@ const Sidebar: React.FC<SidebarProps> = ({
<SidebarSearch /> <SidebarSearch />
<SidebarFolderList <SidebarFolderList
roots={roots}
selectedFolder={selectedFolder} selectedFolder={selectedFolder}
onToggle={onToggle}
onSelect={onSelect} onSelect={onSelect}
onDrop={onDrop} onDrop={onDrop}
onDragOver={onDragOver} onDragOver={onDragOver}
@@ -4,11 +4,12 @@ import type { Identifier } from '../../types/identifiers';
import { FolderPlusIcon } from '../../components/icons'; import { FolderPlusIcon } from '../../components/icons';
import FolderNode, { FolderIdentifier } from './SidebarFolderNode'; import FolderNode, { FolderIdentifier } from './SidebarFolderNode';
import type { FolderTreeNode } from '../../lib/api/apiTypes'; import type { FolderTreeNode } from '../../lib/api/apiTypes';
import { useAppShell } from '../../lib/context/AppShellContext';
import FoldersManager from '../../documents/FoldersManager';
import { useSyncExternalStore } from 'react';
interface SidebarFolderListProps { interface SidebarFolderListProps {
roots: FolderTreeNode[];
selectedFolder: FolderIdentifier | null; selectedFolder: FolderIdentifier | null;
onToggle: (folderId: FolderIdentifier) => void;
onSelect: (folderId: FolderIdentifier) => void; onSelect: (folderId: FolderIdentifier) => void;
onDrop: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void; onDrop: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
onDragOver: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void; onDragOver: (event: React.DragEvent<HTMLDivElement>, folderId: FolderIdentifier) => void;
@@ -23,9 +24,7 @@ interface SidebarFolderListProps {
} }
const SidebarFolderList: React.FC<SidebarFolderListProps> = ({ const SidebarFolderList: React.FC<SidebarFolderListProps> = ({
roots,
selectedFolder, selectedFolder,
onToggle,
onSelect, onSelect,
onDrop, onDrop,
onDragOver, onDragOver,
@@ -38,17 +37,56 @@ const SidebarFolderList: React.FC<SidebarFolderListProps> = ({
onCreateFolder, onCreateFolder,
creatingFolder, creatingFolder,
}) => { }) => {
// Local expansion state for the UI const { foldersManager } = useAppShell();
const getTreeSnapshot = useCallback(() => {
if (!foldersManager) return [];
return (foldersManager as FoldersManager).getTreeSnapshot();
}, [foldersManager]);
const getFolderMap = useCallback(() => {
if (!foldersManager) return new Map();
return (foldersManager as FoldersManager).getSnapshot();
}, [foldersManager]);
const subscribeToTree = useCallback((callback: () => void) => {
if (!foldersManager) return () => { };
return (foldersManager as FoldersManager).subscribe(callback);
}, [foldersManager]);
const roots = useSyncExternalStore(subscribeToTree, getTreeSnapshot);
const folderMap = useSyncExternalStore(subscribeToTree, getFolderMap);
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(new Set(['root'])); const [expandedIds, setExpandedIds] = React.useState<Set<string>>(new Set(['root']));
// Override validation or external toggle if needed // Auto-expand ancestors when selected folder changes
React.useEffect(() => { React.useEffect(() => {
if (selectedFolder) { if (!selectedFolder || !folderMap) return;
// ensure parent paths are expanded?
// Without a parent map, we can't easily auto-expand up. const ancestors = new Set<string>();
// Rely on user or initial state for now, or add parent pointers. let current = folderMap.get(String(selectedFolder));
while (current) {
const parentId = current.parentId || current.parent_id;
if (!parentId || parentId === 'root') break;
ancestors.add(String(parentId));
current = folderMap.get(String(parentId));
} }
}, [selectedFolder]);
if (ancestors.size > 0) {
setExpandedIds((prev) => {
const next = new Set(prev);
let changed = false;
ancestors.forEach(id => {
if (!next.has(id)) {
next.add(id);
changed = true;
}
});
return changed ? next : prev;
});
}
}, [selectedFolder, folderMap]);
const handleToggle = useCallback((folderId: FolderIdentifier) => { const handleToggle = useCallback((folderId: FolderIdentifier) => {
setExpandedIds((prev) => { setExpandedIds((prev) => {
@@ -61,8 +99,7 @@ const SidebarFolderList: React.FC<SidebarFolderListProps> = ({
} }
return next; return next;
}); });
onToggle(folderId); // propagate event just in case parent needs it }, []);
}, [onToggle]);
const renderNodes = useCallback( const renderNodes = useCallback(
(nodes: FolderTreeNode[], depth: number): ReactNode => (nodes: FolderTreeNode[], depth: number): ReactNode =>
@@ -126,7 +163,7 @@ const SidebarFolderList: React.FC<SidebarFolderListProps> = ({
) : null} ) : null}
</div> </div>
<ul className="folder-tree"> <ul className="folder-tree">
{renderNodes(roots, 0)} {renderNodes(roots as FolderTreeNode[], 0)}
</ul> </ul>
</div> </div>
); );
+14 -38
View File
@@ -2,14 +2,12 @@ import { useMemo } from 'react';
import type { DragEvent } from 'react'; import type { DragEvent } from 'react';
import type { Identifier } from '../types/identifiers'; import type { Identifier } from '../types/identifiers';
type FolderToggleHandler = (folderId: Identifier) => void | Promise<void>;
type FolderDropHandler = ( type FolderDropHandler = (
event: DragEvent<HTMLElement>, event: DragEvent<HTMLElement>,
folderId: Identifier, folderId: Identifier,
) => void | Promise<void>; ) => void | Promise<void>;
interface FolderClickHandlers { interface FolderClickHandlers {
onToggle: FolderToggleHandler;
onSelect: (folderId: Identifier | null) => void; onSelect: (folderId: Identifier | null) => void;
onDrop: FolderDropHandler; onDrop: FolderDropHandler;
onDragOver: (event: DragEvent<HTMLElement>, folderId: Identifier) => void; onDragOver: (event: DragEvent<HTMLElement>, folderId: Identifier) => void;
@@ -30,7 +28,6 @@ interface UseSidebarPropsArgs {
} }
interface SidebarHookResult { interface SidebarHookResult {
onToggle: FolderClickHandlers['onToggle'];
onSelect: FolderClickHandlers['onSelect']; onSelect: FolderClickHandlers['onSelect'];
onDrop: FolderClickHandlers['onDrop']; onDrop: FolderClickHandlers['onDrop'];
onDragOver: FolderClickHandlers['onDragOver']; onDragOver: FolderClickHandlers['onDragOver'];
@@ -45,44 +42,23 @@ interface SidebarHookResult {
creatingFolder: boolean; creatingFolder: boolean;
} }
const useSidebarProps = ({ const useSidebarProps = (args: UseSidebarPropsArgs): SidebarHookResult =>
folderClickHandlers,
handleFolderDelete,
handleFolderRename,
selectedFolder,
handleFolderDragStart,
handleFolderDragEnd,
draggedFolderId,
handlePromptCreateFolder,
creatingFolder,
}: UseSidebarPropsArgs): SidebarHookResult =>
useMemo( useMemo(
() => ({ () => ({
onToggle: folderClickHandlers.onToggle, onSelect: args.folderClickHandlers.onSelect,
onSelect: folderClickHandlers.onSelect, onDrop: args.folderClickHandlers.onDrop,
onDrop: folderClickHandlers.onDrop, onDragOver: args.folderClickHandlers.onDragOver,
onDragOver: folderClickHandlers.onDragOver, onDragLeave: args.folderClickHandlers.onDragLeave,
onDragLeave: folderClickHandlers.onDragLeave, onDeleteFolder: args.handleFolderDelete,
onDeleteFolder: handleFolderDelete, onRenameFolder: args.handleFolderRename,
onRenameFolder: handleFolderRename, selectedFolder: args.selectedFolder,
selectedFolder, onFolderDragStart: args.handleFolderDragStart,
onFolderDragStart: handleFolderDragStart, onFolderDragEnd: args.handleFolderDragEnd,
onFolderDragEnd: handleFolderDragEnd, draggedFolderId: args.draggedFolderId,
draggedFolderId, onCreateFolder: (parentId) => args.handlePromptCreateFolder?.(parentId),
onCreateFolder: (parentId) => handlePromptCreateFolder?.(parentId), creatingFolder: args.creatingFolder,
creatingFolder,
}), }),
[ [args],
creatingFolder,
folderClickHandlers,
handleFolderDragEnd,
handleFolderDragStart,
handleFolderDelete,
handleFolderRename,
handlePromptCreateFolder,
draggedFolderId,
selectedFolder,
],
); );
export default useSidebarProps; export default useSidebarProps;