diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index 40f5310c6e..6f6c2ba659 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -26,6 +26,34 @@ interface TableConfig { export default function TableView({ note, noteIds, notePath, viewConfig, saveConfig }: ViewModeProps) { const tabulatorRef = useRef(null); const parentComponent = useContext(ParentComponent); + const expandedRowsRef = useRef>(new Set()); + const isDataRefreshingRef = useRef(false); + + // Load persisted expansion state on mount + useEffect(() => { + const storageKey = `trilium-tree-expanded-${note.noteId}`; + try { + const stored = localStorage.getItem(storageKey); + if (stored) { + const expandedIds = JSON.parse(stored); + expandedRowsRef.current = new Set(expandedIds); + console.log('Loaded expansion state from storage:', expandedIds); + } + } catch (e) { + console.warn('Failed to load tree expansion state:', e); + } + }, [note.noteId]); + + // Save expansion state changes to localStorage + const persistExpandedState = useCallback(() => { + const storageKey = `trilium-tree-expanded-${note.noteId}`; + try { + const expandedIds = Array.from(expandedRowsRef.current); + localStorage.setItem(storageKey, JSON.stringify(expandedIds)); + } catch (e) { + console.warn('Failed to save tree expansion state:', e); + } + }, [note.noteId]); const [ attributeDetailWidgetEl, attributeDetailWidget ] = useLegacyWidget(() => new AttributeDetailWidget().contentSized()); const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef); @@ -39,10 +67,14 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon dataTree: true, dataTreeStartExpanded: true, dataTreeBranchElement: false, - dataTreeElementColumn: "title", + dataTreeElementColumn: "title", dataTreeChildIndent: 20, dataTreeExpandElement: ``, - dataTreeCollapseElement: `` + dataTreeCollapseElement: ``, + persistenceMode: "local", + persistence: { + tree: true + } } }, [ hasChildren ]); @@ -64,7 +96,74 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon footerElement={} events={{ ...contextMenuEvents, - ...rowEditingEvents + ...rowEditingEvents, + tableBuilt: () => { + console.log('Table built - setting up tree event tracking'); + }, + // Try all possible expand event names + rowTreeExpanded: (row) => { + const data = row.getData() as TableData; + console.log('Row expanded (rowTreeExpanded):', data.branchId, data.title, 'refreshing:', isDataRefreshingRef.current); + if (data.branchId && !isDataRefreshingRef.current) { + expandedRowsRef.current.add(data.branchId); + console.log('Updated expanded set:', Array.from(expandedRowsRef.current)); + persistExpandedState(); + } + }, + dataTreeRowExpanded: (row) => { + const data = row.getData() as TableData; + console.log('Row expanded (dataTreeRowExpanded):', data.branchId, data.title, 'refreshing:', isDataRefreshingRef.current); + if (data.branchId && !isDataRefreshingRef.current) { + expandedRowsRef.current.add(data.branchId); + console.log('Updated expanded set:', Array.from(expandedRowsRef.current)); + persistExpandedState(); + } + // Call the original context menu handler if it exists + if (contextMenuEvents.dataTreeRowExpanded) { + contextMenuEvents.dataTreeRowExpanded(row); + } + }, + treeExpanded: (row) => { + const data = row.getData() as TableData; + console.log('Row expanded (treeExpanded):', data.branchId, data.title, 'refreshing:', isDataRefreshingRef.current); + if (data.branchId && !isDataRefreshingRef.current) { + expandedRowsRef.current.add(data.branchId); + console.log('Updated expanded set:', Array.from(expandedRowsRef.current)); + persistExpandedState(); + } + }, + // Try all possible collapse event names + rowTreeCollapsed: (row) => { + const data = row.getData() as TableData; + console.log('Row collapsed (rowTreeCollapsed):', data.branchId, data.title, 'refreshing:', isDataRefreshingRef.current); + if (data.branchId && !isDataRefreshingRef.current) { + expandedRowsRef.current.delete(data.branchId); + console.log('Updated expanded set:', Array.from(expandedRowsRef.current)); + persistExpandedState(); + } + }, + dataTreeRowCollapsed: (row) => { + const data = row.getData() as TableData; + console.log('Row collapsed (dataTreeRowCollapsed):', data.branchId, data.title, 'refreshing:', isDataRefreshingRef.current); + if (data.branchId && !isDataRefreshingRef.current) { + expandedRowsRef.current.delete(data.branchId); + console.log('Updated expanded set:', Array.from(expandedRowsRef.current)); + persistExpandedState(); + } + // Call the original context menu handler if it exists + if (contextMenuEvents.dataTreeRowCollapsed) { + contextMenuEvents.dataTreeRowCollapsed(row); + } + }, + treeCollapsed: (row) => { + const data = row.getData() as TableData; + console.log('Row collapsed (treeCollapsed):', data.branchId, data.title, 'refreshing:', isDataRefreshingRef.current); + if (data.branchId && !isDataRefreshingRef.current) { + expandedRowsRef.current.delete(data.branchId); + console.log('Updated expanded set:', Array.from(expandedRowsRef.current)); + persistExpandedState(); + } + } }} persistence {...persistenceProps} layout="fitDataFill" @@ -72,7 +171,19 @@ export default function TableView({ note, noteIds, notePath, viewConfig, saveCon movableColumns movableRows={movableRows} rowFormatter={rowFormatter} + preserveTreeState={hasChildren} + expandedRowsRef={expandedRowsRef} + isDataRefreshingRef={isDataRefreshingRef} {...dataTreeProps} + dataTreeStartExpanded={(row, level) => { + if (expandedRowsRef.current && expandedRowsRef.current.size > 0) { + const rowData = row.getData() as TableData; + const isExpanded = expandedRowsRef.current.has(rowData.branchId); + console.log(`dataTreeStartExpanded called for ${rowData.branchId}: ${isExpanded}`); + return isExpanded; + } + return false; // Default collapsed state + }} /> @@ -124,6 +235,9 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef const [ movableRows, setMovableRows ] = useState(false); function refresh() { + console.log('🔄 TABLE REFRESH TRIGGERED'); + console.trace('Refresh call stack'); // This will show us what triggered it + const info = getAttributeDefinitionInformation(note); buildRowDefinitions(note, info, includeArchived, maxDepth).then(({ definitions: rowData, hasSubtree: hasChildren, rowNumber }) => { const columnDefs = buildColumnDefinitions({ @@ -140,15 +254,43 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef }); } - useEffect(refresh, [ note, noteIds, maxDepth, movableRows ]); + useEffect(() => { + console.log('⚡ useEffect refresh triggered by:', { note: note.noteId, noteIds: noteIds.length, maxDepth, movableRows }); + // Debounce rapid changes to movableRows + const timeoutId = setTimeout(() => { + refresh(); + }, 50); + return () => clearTimeout(timeoutId); + }, [ note, noteIds.length, maxDepth ]); // Remove movableRows from dependencies + const refreshTimeoutRef = useRef(); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + } + }; + }, []); + useTriliumEvent("entitiesReloaded", ({ loadResults}) => { + console.log('🔄 entitiesReloaded event triggered'); + console.log('Attributes changed:', loadResults.getAttributeRows().length); + console.log('Branches changed:', loadResults.getBranchRows().length); + console.log('Notes changed:', loadResults.getNoteIds().length); + // React to column changes. if (loadResults.getAttributeRows().find(attr => attr.type === "label" && (attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) && attributes.isAffecting(attr, note))) { - refresh(); + console.log('✅ Refreshing due to column changes'); + // Clear any pending refresh + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + } + refreshTimeoutRef.current = setTimeout(() => refresh(), 100); return; } @@ -157,9 +299,16 @@ function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undef || loadResults.getNoteIds().some(noteId => noteIds.includes(noteId)) || loadResults.getAttributeRows().some(attr => noteIds.includes(attr.noteId!)) || loadResults.getAttributeRows().some(attr => attr.name === "archived" && attr.noteId && noteIds.includes(attr.noteId))) { - refresh(); + console.log('✅ Refreshing due to row updates'); + // Clear any pending refresh and debounce + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + } + refreshTimeoutRef.current = setTimeout(() => refresh(), 100); return; } + + console.log('❌ No refresh needed for this entitiesReloaded event'); }); // Identify if movable rows. diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index 57c90b59ad..bb8671108c 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -14,9 +14,12 @@ interface TableProps extends Omit; index: keyof T; footerElement?: string | HTMLElement | JSX.Element; + preserveTreeState?: boolean; + expandedRowsRef?: RefObject>; + isDataRefreshingRef?: RefObject; } -export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, events, index, ...restProps }: TableProps) { +export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, events, index, preserveTreeState, expandedRowsRef, isDataRefreshingRef, ...restProps }: TableProps) { const parentComponent = useContext(ParentComponent); const containerRef = useRef(null); const tabulatorRef = useRef(null); @@ -62,8 +65,39 @@ export default function Tabulator({ className, columns, data, modules, tabula } }, Object.values(events ?? {})); - // Change in data. - useEffect(() => { tabulatorRef.current?.setData(data) }, [ data ]); + const treeStateTimeoutRef = useRef(); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (treeStateTimeoutRef.current) { + clearTimeout(treeStateTimeoutRef.current); + } + }; + }, []); + + // Change in data - with tree state preservation + useEffect(() => { + const tabulator = tabulatorRef.current; + if (!tabulator || !data) return; + + console.log('Data update triggered, preserveTreeState:', preserveTreeState, 'dataTree option:', tabulator.options?.dataTree); + + if (preserveTreeState && tabulator.options && "dataTree" in tabulator.options && tabulator.options.dataTree && expandedRowsRef) { + console.log('Tree state preservation using dataTreeStartExpanded approach'); + + // Clear any existing timeout to prevent overlapping updates + if (treeStateTimeoutRef.current) { + clearTimeout(treeStateTimeoutRef.current); + } + + // Simply update data - expansion state will be handled by dataTreeStartExpanded function + tabulator.setData(data); + } else { + tabulator.setData(data); + } + }, [ data, preserveTreeState, index ]); + useEffect(() => { columns && tabulatorRef.current?.setColumns(columns)}, [ data]); return (