import { useAtom } from "jotai"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react"; import { useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Text } from "@mantine/core"; import { notifications } from "@mantine/notifications"; import { fetchAllAncestorChildren, useGetRootSidebarPagesQuery, usePageMetaQuery, } from "@/features/page/queries/page-query.ts"; import classes from "@/features/page/tree/styles/tree.module.css"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { buildTree, buildTreeWithChildren, mergeRootTrees, collectAllIds, collectBranchIds, openBranches, closeIds, loadedOpenBranchIds, pruneCollapsedChildren, } from "@/features/page/tree/utils/utils.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { treeModel } from "@/features/page/tree/model/tree-model"; import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts"; import { getPageBreadcrumbs, getSpaceTree, } from "@/features/page/services/page-service.ts"; import { IPage } from "@/features/page/types/page.types.ts"; import { extractPageSlugId } from "@/lib"; import { isCompactPageTreeEnabled } from "@/lib/config.ts"; import { DocTree, ROW_HEIGHT_COMPACT, ROW_HEIGHT_STANDARD } from "./doc-tree"; import { SpaceTreeRow } from "./space-tree-row"; interface SpaceTreeProps { spaceId: string; readOnly: boolean; } export type SpaceTreeApi = { expandAll: () => Promise; collapseAll: () => void; isExpanding: boolean; }; const SpaceTree = forwardRef(function SpaceTree( { spaceId, readOnly }, ref, ) { const { t } = useTranslation(); const { pageSlug } = useParams(); const compactTree = isCompactPageTreeEnabled(); const [data, setData] = useAtom(treeDataAtom); const [isExpanding, setIsExpanding] = useState(false); const { handleMove } = useTreeMutation(spaceId); const { data: pagesData, hasNextPage, fetchNextPage, isFetching, } = useGetRootSidebarPagesQuery({ spaceId }); const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom); const [isDataLoaded, setIsDataLoaded] = useState(false); const spaceIdRef = useRef(spaceId); spaceIdRef.current = spaceId; const { data: currentPage } = usePageMetaQuery({ pageId: extractPageSlugId(pageSlug), }); useEffect(() => { setIsDataLoaded(false); }, [spaceId]); useEffect(() => { if (hasNextPage && !isFetching) { fetchNextPage(); } }, [hasNextPage, fetchNextPage, isFetching, spaceId]); useEffect(() => { if (!pagesData?.pages || hasNextPage) return; const allItems = pagesData.pages.flatMap((page) => page.items); const treeData = buildTree(allItems); setData((prev) => { // Keep nodes belonging to other spaces — filteredData filters by spaceId // for rendering, so accumulating is safe. Preserves lazy-loaded children // and open-state when the user returns to a previously-visited space. const otherSpaces = prev.filter((n) => n?.spaceId !== spaceId); const currentSpace = prev.filter((n) => n?.spaceId === spaceId); const refreshed = currentSpace.length > 0 ? mergeRootTrees(currentSpace, treeData) : treeData; return [...otherSpaces, ...refreshed]; }); setIsDataLoaded(true); }, [pagesData, hasNextPage, spaceId]); useEffect(() => { const effectSpaceId = spaceId; const fetchData = async () => { if (isDataLoaded && currentPage) { // check if pageId node is present in the tree const node = treeModel.find(data, currentPage.id); if (node) { // if node is found, no need to traverse its ancestors return; } // if not found, fetch and build its ancestors and their children if (!currentPage.id) return; const ancestors = await getPageBreadcrumbs(currentPage.id); if (spaceIdRef.current !== effectSpaceId) return; if (ancestors && ancestors.length > 1) { let flatTreeItems = [...buildTree(ancestors)]; const fetchAndUpdateChildren = async (ancestor: IPage) => { // we don't want to fetch the children of the opened page if (ancestor.id === currentPage.id) return; const children = await fetchAllAncestorChildren({ pageId: ancestor.id, spaceId: ancestor.spaceId, }); flatTreeItems = [ ...flatTreeItems, ...children.filter( (child) => !flatTreeItems.some((item) => item.id === child.id), ), ]; }; const fetchPromises = ancestors.map((ancestor) => fetchAndUpdateChildren(ancestor), ); Promise.all(fetchPromises).then(() => { if (spaceIdRef.current !== effectSpaceId) return; // build tree with children const ancestorsTree = buildTreeWithChildren(flatTreeItems); // child of root page we're attaching the built ancestors to const rootChild = ancestorsTree[0]; // attach built ancestors to tree using functional updater setData((currentData) => treeModel.appendChildren( currentData, rootChild.id, rootChild.children ?? [], ), ); // open all ancestors of the current page. DocTree picks up the // selectedId change and scrolls the row into view on its own once // flat contains it. setOpenTreeNodes((prev) => { const next = { ...prev }; for (const a of ancestors) { if (a.id !== currentPage.id) next[a.id] = true; } return next; }); }); } } }; fetchData(); }, [isDataLoaded, currentPage?.id]); const openIds = useMemo( () => new Set(Object.keys(openTreeNodes).filter((k) => openTreeNodes[k])), [openTreeNodes], ); // Latest tree + open-state for the reconnect handler (its closure would // otherwise read stale snapshots). const [socket] = useAtom(socketAtom); const dataRef = useRef(data); dataRef.current = data; const openIdsRef = useRef(openIds); openIdsRef.current = openIds; // Boot-cache hygiene (#159 #8): the localStorage-hydrated tree carries the // children of every branch ever expanded, including ones now COLLAPSED. Their // first expand would skip the lazy-load and render stale children (a // rename/move/delete missed while offline). Drop the cached children of every // COLLAPSED branch ONCE at mount so its first expand fetches fresh via // handleToggle — exactly as it did before the tree was cached. OPEN branches // keep their children and are refreshed by refreshOpenBranches instead, so // this runs before any expand and never double-fetches an open branch. const prunedBootCacheRef = useRef(false); useEffect(() => { if (prunedBootCacheRef.current) return; prunedBootCacheRef.current = true; setData((prev) => pruneCollapsedChildren(prev, openIdsRef.current)); }, [setData]); // Re-fetch and reconcile the children of every currently-open, already-loaded // branch of THIS space. Shared by the socket reconnect handler and the // post-load cache refresh below. The ROOT level is reconciled separately by // the root-query refetch + mergeRootTrees; an UNLOADED branch is skipped // (lazy-load fetches it fresh on expand). Reads refs so it always sees the // latest tree/open-state/space without re-creating the callback. const refreshOpenBranches = useCallback(async () => { const effectSpaceId = spaceIdRef.current; const branchIds = loadedOpenBranchIds( dataRef.current.filter((n) => n?.spaceId === effectSpaceId), openIdsRef.current, ); if (branchIds.length === 0) return; for (const id of branchIds) { try { // `fresh: true` bypasses the 30-min sidebar-pages cache so the // reconcile sees the server's CURRENT children (handler-order // independent — no reliance on the global reconnect invalidation). const fresh = await fetchAllAncestorChildren( { pageId: id, spaceId: effectSpaceId }, { fresh: true }, ); if (spaceIdRef.current !== effectSpaceId) return; // space switched setData((prev) => treeModel.reconcileChildren(prev, id, fresh)); } catch (err) { console.error("[tree] open branch refresh failed", err); } } }, [setData]); // Reconnect refresh (#159 #8): on a socket reconnect, refresh open branches // so a move/rename/delete that happened INSIDE a loaded branch while events // were missed (laptop sleep / wifi gap) is reflected instead of left stale. // No first-connect guard is needed: space-tree usually mounts AFTER the // initial connect, so every `connect` it sees is a reconnect; the rare // initial-connect case has an empty tree, so the refresh is a harmless no-op. useEffect(() => { if (!socket) return; const onConnect = () => { refreshOpenBranches(); }; socket.on("connect", onConnect); return () => { socket.off("connect", onConnect); }; }, [socket, refreshOpenBranches]); // Post-load cache refresh: the sidebar paints instantly from the // localStorage-cached tree, so children of open branches may be stale. Once // the server root set has been merged for this space (isDataLoaded flips // true), refresh every open, already-loaded branch ONCE per space per mount. // dataRef.current is already up to date here: refs are assigned during // render, and this effect runs after the merge-triggered re-render commit. const refreshedSpacesRef = useRef>(new Set()); useEffect(() => { if (!isDataLoaded) return; if (refreshedSpacesRef.current.has(spaceId)) return; refreshedSpacesRef.current.add(spaceId); refreshOpenBranches(); }, [isDataLoaded, spaceId, refreshOpenBranches]); const handleToggle = useCallback( async (id: string, isOpen: boolean) => { setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen })); if (isOpen) { const node = treeModel.find(data, id) as SpaceTreeNode | null; if ( node?.hasChildren && (!node.children || node.children.length === 0) ) { const fetched = await fetchAllAncestorChildren({ pageId: id, spaceId: node.spaceId, }); setData((prev) => treeModel.appendChildren(prev, id, fetched)); } } }, [data, setOpenTreeNodes, setData], ); const filteredData = useMemo( () => data.filter((node) => node?.spaceId === spaceId), [data, spaceId], ); const expandAll = useCallback(async () => { const startSpaceId = spaceIdRef.current; setIsExpanding(true); try { // One request: the entire space tree, permission-filtered server-side. const items = await getSpaceTree({ spaceId: startSpaceId }); // Space switched mid-flight — abort merge/expand. if (spaceIdRef.current !== startSpaceId) return; const fullTree = buildTreeWithChildren(buildTree(items)); setData((prev) => { // Replace current-space nodes with the full tree; keep other spaces intact. const others = prev.filter((n) => n?.spaceId !== startSpaceId); return [...others, ...fullTree]; }); // Open every branch node (node with children) of the current space only. const branchIds = collectBranchIds(fullTree); setOpenTreeNodes((prev) => openBranches(prev, branchIds)); } catch (err: any) { // Never swallow: log full error + surface the real reason. console.error("[tree] expandAll failed", err); notifications.show({ color: "red", message: t("Couldn't expand the tree: {{reason}}", { reason: err?.response?.data?.message ?? err?.message ?? String(err), }), }); } finally { setIsExpanding(false); } }, [setData, setOpenTreeNodes, t]); const collapseAll = useCallback(() => { // The open-map is shared across spaces; collapse only current-space ids so // other spaces' expanded state is left intact. const ids = collectAllIds(filteredData); setOpenTreeNodes((prev) => closeIds(prev, ids)); }, [filteredData, setOpenTreeNodes]); useImperativeHandle(ref, () => ({ expandAll, collapseAll, isExpanding }), [ expandAll, collapseAll, isExpanding, ]); // Stable callbacks for DocTree. Without these, every parent render recreates // the props and tears down every row's draggable/dropTarget subscription, // defeating memo(DocTreeRow). const renderRow = useCallback( (rowProps: Parameters[0]) => ( ), [readOnly], ); const disableDragDrop = useCallback( (n: SpaceTreeNode) => n.canEdit === false, [], ); const getDragLabel = useCallback( (n: SpaceTreeNode) => n.name || t("Untitled"), [t], ); return (
{/* "No pages yet" only after the SERVER confirmed the space is empty — never while just the localStorage cache is empty. */} {isDataLoaded && filteredData.length === 0 && ( {t("No pages yet")} )} {/* Cache-first paint: render as soon as ANY data exists (synchronous localStorage hydration) instead of waiting for the server round-trip; the background merge/refresh reconciles it afterwards. */} {filteredData.length > 0 && ( data={filteredData} openIds={openIds} selectedId={currentPage?.id} renderRow={renderRow} onMove={handleMove} onToggle={handleToggle} rowHeight={compactTree ? ROW_HEIGHT_COMPACT : ROW_HEIGHT_STANDARD} readOnly={readOnly} disableDrag={disableDragDrop} disableDrop={disableDragDrop} getDragLabel={getDragLabel} aria-label={t("Pages")} /> )}
); }); export default SpaceTree;