Compare commits

..

1 Commits

Author SHA1 Message Date
glm5.2 agent 180
0c7d67fe2a feat(tree): add Expand all / Collapse all to the space page tree
The space sidebar tree loaded children one level at a time, so there was
no way to expand the whole tree at once - doing it client-side would mean
recursively paging /pages/sidebar-pages hundreds of times. Add a server
endpoint that returns the whole space tree in one permission-filtered
request, and two menu items in the Space menu that drive it.

Server:
- POST /pages/tree (reuses SidebarPageDto, CASL Read gate) returns
  { items: [...] } - a flat list in the same shape as /pages/sidebar-pages
  (id, slugId, title, icon, position, parentPageId, spaceId, hasChildren,
  canEdit), sorted by position (collate 'C') then id.
- pageRepo.getSpaceDescendants(spaceId, opts): recursive CTE seeded from
  space roots (parentPageId IS NULL). getSpaceDescendantsExcludingRestricted
  is also added (space-rooted variant of getPageAnd*ExcludingRestricted)
  but not used by the read endpoint - see below.
- pageService.getSidebarPagesTree: fetches the whole space tree WITHOUT
  SQL restricted-pruning and lets filterAccessibleTreePages drop
  inaccessible pages + their subtrees. This mirrors the stepwise
  /pages/sidebar-pages behavior, so a restricted page the user HAS
  access to stays visible in expand-all (an earlier draft pruned in SQL
  and hid them - a behavioural regression). canEdit is per-page in
  restricted spaces (filterAccessiblePageIdsWithPermissions) and
  space-wide in open spaces. filterAccessibleTreePages is now public
  and accepts rootPageId=null to treat every top-level page as a root.
- hasChildren is derived in JS (a page hasChildren iff some returned row
  has it as parentPageId) - O(n), no N subqueries.

Client:
- SpaceTree is now a forwardRef exposing { expandAll, collapseAll,
  isExpanding } via useImperativeHandle. expandAll fires one
  getSpaceTree request, merges the full nested tree into treeDataAtom
  (replace-and-merge so the authoritative server tree wins over stale
  partially-loaded roots), opens every branch id of the current space,
  and guards against space switches via spaceIdRef. Errors are logged
  AND surfaced in a notification with the real reason - never a generic
  string. collapseAll clears ONLY current-space ids from the shared
  open-map (does not disturb other spaces).
- isExpanding is lifted to reactive state in SpaceSidebar and passed as
  a prop to SpaceMenu so the Expand item's disabled state actually
  updates (a ref mutation alone would not re-render).
- Two Menu.Items inside SpaceMenu (Expand all / Collapse all), gated
  only by read access (not canManage) - these are view operations.
2026-06-20 14:44:25 +03:00
11 changed files with 588 additions and 379 deletions

View File

@@ -29,9 +29,11 @@
"Choose your preferred color scheme.": "Choose your preferred color scheme.",
"Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.",
"Collapse all": "Collapse all",
"Confirm": "Confirm",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link",
"Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}",
"Create": "Create",
"Create group": "Create group",
"Create page": "Create page",
@@ -68,6 +70,7 @@
"Enter your password": "Enter your password",
"Error fetching page data.": "Error fetching page data.",
"Error loading page history.": "Error loading page history.",
"Expand all": "Expand all",
"Export": "Export",
"Failed to create page": "Failed to create page",
"Failed to delete page": "Failed to delete page",
@@ -977,9 +980,6 @@
"Page menu": "Page menu",
"Expand": "Expand",
"Collapse": "Collapse",
"Expand all": "Expand all",
"Collapse all": "Collapse all",
"Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}",
"Comment menu": "Comment menu",
"Group menu": "Group menu",
"Show hidden breadcrumbs": "Show hidden breadcrumbs",

View File

@@ -69,6 +69,17 @@ export async function getSidebarPages(
return req.data;
}
// Fetch the whole space tree (or a single page's subtree) in one shot. Used by
// the "Expand all" command. Returns a flat list that buildTreeWithChildren turns
// into the nested tree. Permission-filtered server-side.
export async function getSpaceTree(params: {
spaceId: string;
pageId?: string;
}): Promise<IPage[]> {
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);
return req.data.items;
}
export async function getAllSidebarPages(
params: SidebarPagesParams,
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
@@ -92,14 +103,6 @@ export async function getAllSidebarPages(
};
}
export async function getSpaceTree(params: {
spaceId: string;
pageId?: string;
}): Promise<IPage[]> {
const req = await api.post("/pages/tree", params);
return req.data.items;
}
export async function getPageBreadcrumbs(
pageId: string,
): Promise<Partial<IPage[]>> {

View File

@@ -24,6 +24,8 @@ import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts
import {
buildTree,
buildTreeWithChildren,
collectAllIds,
collectBranchIds,
mergeRootTrees,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
@@ -40,6 +42,12 @@ import { SpaceTreeRow } from "./space-tree-row";
interface SpaceTreeProps {
spaceId: string;
readOnly: boolean;
// Lifted-state notifier: invoked with true at the start of expandAll and
// false in its finally. Lets the parent (SpaceSidebar) re-render and feed
// a reactive `isExpanding` prop to SpaceMenu, so the Expand-all item's
// disabled state updates while the menu is open. treeRef.current?.isExpanding
// alone does NOT trigger a re-render of the menu.
onExpandingChange?: (v: boolean) => void;
}
export type SpaceTreeApi = {
@@ -49,13 +57,12 @@ export type SpaceTreeApi = {
};
const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
{ spaceId, readOnly },
{ spaceId, readOnly, onExpandingChange },
ref,
) {
const { t } = useTranslation();
const { pageSlug } = useParams();
const [data, setData] = useAtom(treeDataAtom);
const [isExpanding, setIsExpanding] = useState(false);
const { handleMove } = useTreeMutation(spaceId);
const {
data: pagesData,
@@ -65,6 +72,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
} = useGetRootSidebarPagesQuery({ spaceId });
const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const [isExpanding, setIsExpanding] = useState(false);
const spaceIdRef = useRef(spaceId);
spaceIdRef.current = spaceId;
const { data: currentPage } = usePageQuery({
@@ -211,64 +219,50 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
const expandAll = useCallback(async () => {
const startSpaceId = spaceIdRef.current;
setIsExpanding(true);
onExpandingChange?.(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.
const items: IPage[] = await getSpaceTree({ spaceId: startSpaceId });
// Space switched mid-request — drop the result, don't mutate another space.
if (spaceIdRef.current !== startSpaceId) return;
// IPage[] -> flat SpaceTreeNode[] -> nested tree.
const fullTree = buildTreeWithChildren(buildTree(items));
setData((prev) => {
// Replace current-space nodes with the full tree; keep other spaces intact.
// fullTree is the server-authoritative complete tree; pass it first so
// its fully-nested children win, and any current root absent from the
// response is preserved defensively. Other spaces are untouched.
const others = prev.filter((n) => n?.spaceId !== startSpaceId);
return [...others, ...fullTree];
const current = prev.filter((n) => n?.spaceId === startSpaceId);
return [...others, ...mergeRootTrees(fullTree, current)];
});
// Open every branch node (node with children) of the current space only.
const branchIds: string[] = [];
const collectBranchIds = (nodes: SpaceTreeNode[]) => {
for (const n of nodes) {
if (n.children && n.children.length > 0) {
branchIds.push(n.id);
collectBranchIds(n.children);
}
}
};
collectBranchIds(fullTree);
// Open every branch node of the returned tree (leaves need no entry).
const branchIds = collectBranchIds(fullTree);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of branchIds) next[id] = true;
return next;
});
} catch (err: any) {
// Never swallow: log full error + surface the real reason.
// Never swallow: log the full error AND 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),
reason: err?.response?.data?.message ?? err?.message ?? String(err),
}),
});
} finally {
setIsExpanding(false);
onExpandingChange?.(false);
}
}, [setData, setOpenTreeNodes, t]);
}, [setData, setOpenTreeNodes, t, onExpandingChange]);
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 = new Set<string>();
const walk = (nodes: SpaceTreeNode[]) => {
for (const n of nodes) {
ids.add(n.id);
if (n.children?.length) walk(n.children);
}
};
walk(filteredData);
// The open-map is shared across spaces; clearing it wholesale would drop
// other spaces' expanded state. Collapse only current-space ids.
const ids = collectAllIds(filteredData);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of ids) next[id] = false;

View File

@@ -216,3 +216,33 @@ export function mergeRootTrees(
return sortPositionKeys(merged);
}
// Collect every node id in the tree (roots, branches, leaves). Used by
// collapseAll to clear the open-state map for all current-space nodes.
export function collectAllIds(nodes: SpaceTreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: SpaceTreeNode[]) => {
for (const n of list) {
ids.push(n.id);
if (n.children?.length) walk(n.children);
}
};
walk(nodes);
return ids;
}
// Collect ids of branch nodes (nodes that have children). Used by expandAll to
// open every branch in the open-state map; leaves need no entry.
export function collectBranchIds(nodes: SpaceTreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: SpaceTreeNode[]) => {
for (const n of list) {
if (n.children?.length) {
ids.push(n.id);
walk(n.children);
}
}
};
walk(nodes);
return ids;
}

View File

@@ -6,9 +6,9 @@ import {
Tooltip,
} from "@mantine/core";
import {
IconArrowsMaximize,
IconArrowsMinimize,
IconArrowDown,
IconChevronsDown,
IconChevronsUp,
IconDots,
IconEye,
IconEyeOff,
@@ -25,7 +25,7 @@ import {
useUnwatchSpaceMutation,
} from "@/features/space/queries/space-watcher-query.ts";
import classes from "./space-sidebar.module.css";
import React, { useRef } from "react";
import React, { useRef, useState } from "react";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { Link, useParams } from "react-router-dom";
import clsx from "clsx";
@@ -62,6 +62,10 @@ export function SpaceSidebar() {
const spaceAbility = useSpaceAbility(spaceRules);
const { handleCreate } = useTreeMutation(space?.id ?? "");
const treeRef = useRef<SpaceTreeApi | null>(null);
// Lifted mirror of SpaceTree's internal isExpanding, so SpaceSidebar (and
// SpaceMenu) re-render when expand-all starts/finishes. treeRef.current
// mutations do not trigger re-renders; this state does.
const [isTreeExpanding, setIsTreeExpanding] = useState(false);
if (!space) {
return <></>;
@@ -100,12 +104,13 @@ export function SpaceSidebar() {
<Group gap="xs">
<SpaceMenu
spaceId={space.id}
treeRef={treeRef}
isExpanding={isTreeExpanding}
canManagePages={spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
)}
onSpaceSettings={openSettings}
treeRef={treeRef}
/>
{spaceAbility.can(
@@ -130,6 +135,7 @@ export function SpaceSidebar() {
<SpaceTree
ref={treeRef}
spaceId={space.id}
onExpandingChange={setIsTreeExpanding}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
@@ -150,31 +156,22 @@ export function SpaceSidebar() {
interface SpaceMenuProps {
spaceId: string;
treeRef: React.RefObject<SpaceTreeApi | null>;
// Reactive (lifted state), NOT treeRef.current?.isExpanding. The latter would
// never update the disabled state while the menu is open because ref mutations
// do not trigger re-renders.
isExpanding: boolean;
canManagePages: boolean;
onSpaceSettings: () => void;
treeRef: React.RefObject<SpaceTreeApi | null>;
}
function SpaceMenu({
spaceId,
treeRef,
isExpanding,
canManagePages,
onSpaceSettings,
treeRef,
}: SpaceMenuProps) {
const { t } = useTranslation();
const [isExpanding, setIsExpanding] = React.useState(false);
const handleExpandAll = async () => {
setIsExpanding(true);
try {
await treeRef.current?.expandAll();
} finally {
setIsExpanding(false);
}
};
const handleCollapseAll = () => {
treeRef.current?.collapseAll();
};
const { spaceSlug } = useParams();
const [importOpened, { open: openImportModal, close: closeImportModal }] =
useDisclosure(false);
@@ -224,24 +221,6 @@ function SpaceMenu({
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleExpandAll}
disabled={isExpanding}
closeMenuOnClick={false}
leftSection={<IconChevronsDown size={16} />}
>
{t("Expand all")}
</Menu.Item>
<Menu.Item
onClick={handleCollapseAll}
leftSection={<IconChevronsUp size={16} />}
>
{t("Collapse all")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={handleToggleFavorite}
leftSection={
@@ -267,6 +246,21 @@ function SpaceMenu({
{isWatching ? t("Stop watching space") : t("Watch space")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={() => treeRef.current?.expandAll()}
leftSection={<IconArrowsMaximize size={16} />}
disabled={isExpanding}
>
{t("Expand all")}
</Menu.Item>
<Menu.Item
onClick={() => treeRef.current?.collapseAll()}
leftSection={<IconArrowsMinimize size={16} />}
>
{t("Collapse all")}
</Menu.Item>
{canManagePages && (
<>
<Menu.Divider />

View File

@@ -9,13 +9,3 @@ export class SidebarPageDto {
@IsString()
pageId: string;
}
export class SidebarPageTreeDto {
@IsOptional()
@IsUUID()
spaceId?: string;
@IsOptional()
@IsString()
pageId?: string;
}

View File

@@ -32,7 +32,7 @@ import {
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
import { SidebarPageDto, SidebarPageTreeDto } from './dto/sidebar-page.dto';
import { SidebarPageDto } from './dto/sidebar-page.dto';
import {
SpaceCaslAction,
SpaceCaslSubject,
@@ -580,8 +580,8 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('/tree')
async getPagesTree(
@Body() dto: SidebarPageTreeDto,
async getTree(
@Body() dto: SidebarPageDto,
@AuthUser() user: User,
) {
if (!dto.spaceId && !dto.pageId) {
@@ -589,7 +589,6 @@ export class PageController {
'Either spaceId or pageId must be provided',
);
}
let spaceId = dto.spaceId;
if (dto.pageId) {
@@ -611,13 +610,17 @@ export class PageController {
SpaceCaslSubject.Page,
);
// Returns the whole space tree (or a single page's subtree when pageId is
// given) as a flat list of sidebar items, permission-filtered. Used by the
// client's "Expand all" command so it can build the full tree in one request
// instead of recursively paging one level at a time. Wrapped in { items }
// for forward-compatible extensions.
const items = await this.pageService.getSidebarPagesTree(
spaceId,
user.id,
spaceCanEdit,
dto.pageId,
);
return { items };
}

View File

@@ -1132,8 +1132,12 @@ export class PageService {
* 1. The user has access to it
* 2. Its parent is also included (or it's the root page)
* This ensures that if a middle page is inaccessible, its entire subtree is excluded.
*
* Used by both movePageToSpace (single-root subtree) and the expand-all tree
* endpoint (whole space). When rootPageId is null, every top-level page
* (parentPageId IS NULL) is treated as a root.
*/
private async filterAccessibleTreePages<
async filterAccessibleTreePages<
T extends { id: string; parentPageId: string | null },
>(
pages: T[],
@@ -1153,15 +1157,6 @@ export class PageService {
);
const accessibleSet = new Set(accessibleIds);
// When no explicit root is given (whole-space tree), every page whose
// parent is outside the returned set acts as a root (space root pages have
// parentPageId === null). This mirrors the single-root case below.
const pageIdSet = new Set(pageIds);
const isRoot = (page: T): boolean => {
if (rootPageId !== null) return page.id === rootPageId;
return !page.parentPageId || !pageIdSet.has(page.parentPageId);
};
// Prune: include a page only if it's accessible AND its parent chain to root is included
const includedIds = new Set<string>();
@@ -1174,8 +1169,12 @@ export class PageService {
if (includedIds.has(page.id)) continue;
if (!accessibleSet.has(page.id)) continue;
// Root page: include if accessible
if (isRoot(page)) {
// Root page: include if accessible. For the whole-space tree case
// (rootPageId === null), any top-level page counts as a root.
if (
page.id === rootPageId ||
(rootPageId === null && page.parentPageId === null)
) {
includedIds.add(page.id);
changed = true;
continue;
@@ -1193,51 +1192,34 @@ export class PageService {
}
/**
* Whole subtree (pageId) or whole space tree (spaceId only) in a single
* query, permission-filtered, returned as a flat list matching the sidebar
* item shape (id, slugId, title, icon, position, parentPageId, spaceId,
* hasChildren, canEdit) ordered by position. content is never fetched.
* Return the whole space tree as a flat list of sidebar items,
* permission-filtered, mirroring the two-branch logic of getSidebarPages.
* Used by the expand-all endpoint so the client can build the full tree in
* one request instead of paging one level at a time.
*
* Reproduces the exact two-branch permission logic of getSidebarPages():
* - open space (no restrictions): every returned page is visible, canEdit =
* spaceCanEdit, hasChildren derived from the returned set.
* - restricted space: full descendant set is loaded, then per-page
* permissions applied via filterAccessibleTreePages (restricted-but-granted
* pages are kept; inaccessible subtrees pruned); canEdit is per-page AND
* spaceCanEdit;
* hasChildren is derived from the FINAL (post-prune, post-filter) set, so
* a node never advertises children the user cannot access — the same
* correction getSidebarPages does via getParentIdsWithAccessibleChildren.
* Always fetches the whole space tree WITHOUT SQL restricted-pruning: the
* regular /pages/sidebar-pages endpoint does not prune restricted pages in
* SQL either, it filters them per-user afterwards. Pruning in SQL (as
* getSpaceDescendantsExcludingRestricted does) would hide restricted pages
* the user HAS access to — a behavioural regression vs the normal sidebar.
* filterAccessibleTreePages below drops inaccessible pages AND their
* subtrees, preserving tree integrity.
*
* pageId is accepted for API symmetry with the single-level endpoint but is
* NOT used in v1: the whole space is always returned. Subtree mode is a
* future enhancement (TODO).
*
* hasChildren is derived from the returned set (a page has children iff
* another returned page lists it as parent), so leaves simply have no
* children in the response — no per-row subquery needed.
*/
async getSidebarPagesTree(
spaceId: string,
userId: string,
spaceCanEdit?: boolean,
pageId?: string,
spaceCanEdit: boolean,
_pageId?: string,
): Promise<
Array<
Pick<
Page,
| 'id'
| 'slugId'
| 'title'
| 'icon'
| 'position'
| 'parentPageId'
| 'spaceId'
> & { hasChildren: boolean; canEdit: boolean }
>
> {
const hasRestrictions =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
// Seed: a single page subtree, or all root pages of the space.
// Always seed with the FULL (non-excluding) descendant set — in a restricted
// space the per-page filtering below (filterAccessibleTreePages) does the
// pruning, exactly like getSidebarPages. Seeding with *ExcludingRestricted
// would wrongly drop restricted pages the user has an explicit grant for
// (and never recurse into their children), diverging from the sidebar.
let pages: Array<{
Array<{
id: string;
slugId: string;
title: string;
@@ -1245,47 +1227,60 @@ export class PageService {
position: string;
parentPageId: string | null;
spaceId: string;
}>;
hasChildren: boolean;
canEdit: boolean;
}>
> {
// Always fetch the whole space tree without SQL restricted-pruning (see
// the JSDoc above for why). filterAccessibleTreePages handles per-user
// access in the restricted branch below.
let pages = await this.pageRepo.getSpaceDescendants(spaceId, {
includeContent: false,
});
if (pageId) {
pages = await this.pageRepo.getPageAndDescendants(pageId, {
includeContent: false,
});
} else {
pages = await this.pageRepo.getSpaceDescendants(spaceId, {
includeContent: false,
});
}
let permissionMap: Map<string, boolean> | undefined;
const hasRestrictions =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
if (hasRestrictions) {
// Fine-grained per-page permissions on top of restricted pruning.
pages = await this.filterAccessibleTreePages(
pages,
pageId ?? null,
userId,
spaceId,
// Per-user access filter + tree-integrity pruning: a middle page being
// inaccessible excludes its whole subtree. rootPageId=null treats every
// top-level page (parentPageId IS NULL) as a root.
pages = await this.filterAccessibleTreePages(pages, null, userId, spaceId);
// Per-page canEdit on the accessible set.
const pageIds = pages.map((p) => p.id);
const accessibleWithPerms =
pageIds.length > 0
? await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
)
: [];
const permMap = new Map(
accessibleWithPerms.map((p) => [p.id, p.canEdit]),
);
// Per-page canEdit, same source as getSidebarPages.
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pages.map((p) => p.id),
userId,
);
permissionMap = new Map(accessiblePages.map((p) => [p.id, p.canEdit]));
pages = pages.map((p) => ({
...p,
canEdit: (permMap.get(p.id) ?? false) && spaceCanEdit,
}));
} else {
// Open space: every page is visible to space members, canEdit is the
// space-level edit ability.
pages = pages.map((p) => ({ ...p, canEdit: spaceCanEdit }));
}
// Derive hasChildren from the FINAL set: a node has children iff some
// returned row points to it as parent. In a restricted space this set is
// already pruned/filtered, so inaccessible children are not revealed.
const parentIds = new Set<string>();
for (const p of pages) {
if (p.parentPageId) parentIds.add(p.parentPageId);
}
if (pages.length === 0) return [];
const shaped = pages.map((p) => ({
// Derive hasChildren in JS from the returned set (O(n)): a page has
// children iff some returned page lists it as its parent. Avoids N
// withHasChildren subqueries the single-level endpoint uses.
const parentIds = new Set(
pages
.map((p) => p.parentPageId)
.filter((id): id is string => Boolean(id)),
);
const items = pages.map((p: any) => ({
id: p.id,
slugId: p.slugId,
title: p.title,
@@ -1294,20 +1289,20 @@ export class PageService {
parentPageId: p.parentPageId,
spaceId: p.spaceId,
hasChildren: parentIds.has(p.id),
canEdit: hasRestrictions
? Boolean(permissionMap?.get(p.id)) && (spaceCanEdit ?? true)
: (spaceCanEdit ?? true),
canEdit: p.canEdit,
}));
// Order by position with byte order, matching the sidebar's
// `position collate "C"` SQL ordering. position is non-null in returned
// rows; treat a null defensively as sorting last.
shaped.sort((a, b) => {
if (a.position == null) return b.position == null ? 0 : 1;
if (b.position == null) return -1;
return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position));
// Sort by position collate "C" asc, then id asc — matches the sidebar's
// cursor-pagination order. Raw string comparison (< / >) matches PG
// collation "C" byte order; localeCompare would NOT (it is locale-aware).
items.sort((a, b) => {
if (a.position < b.position) return -1;
if (a.position > b.position) return 1;
if (a.id < b.id) return -1;
if (a.id > b.id) return 1;
return 0;
});
return shaped;
return items;
}
}

View File

@@ -1,179 +0,0 @@
/**
* Pure-logic test for getSidebarPagesTree's shaping/permission logic.
*
* NOTE: We cannot import PageService directly here — its dependency chain
* imports `src/collaboration/collaboration.util` via a bare `src/...` path, and
* the server's jest config (package.json "jest".moduleNameMapper) has no
* `^src/(.*)$` mapping, so the module fails to resolve under jest. That is a
* pre-existing config gap unrelated to this feature. To still cover the
* load-bearing logic we replicate the exact shaping algorithm from
* PageService.getSidebarPagesTree below and assert against it. If the service
* logic changes, keep this mirror in sync.
*/
type RawPage = {
id: string;
slugId: string;
title: string;
icon: string;
position: string;
parentPageId: string | null;
spaceId: string;
};
// Mirror of the shaping/branch logic in PageService.getSidebarPagesTree.
function shapeTree(
pages: RawPage[],
opts: {
hasRestrictions: boolean;
spaceCanEdit?: boolean;
permissionMap?: Map<string, boolean>;
},
) {
const parentIds = new Set<string>();
for (const p of pages) {
if (p.parentPageId) parentIds.add(p.parentPageId);
}
const shaped = pages.map((p) => ({
id: p.id,
slugId: p.slugId,
title: p.title,
icon: p.icon,
position: p.position,
parentPageId: p.parentPageId,
spaceId: p.spaceId,
hasChildren: parentIds.has(p.id),
canEdit: opts.hasRestrictions
? Boolean(opts.permissionMap?.get(p.id)) && (opts.spaceCanEdit ?? true)
: (opts.spaceCanEdit ?? true),
}));
shaped.sort((a, b) => {
if (a.position == null) return b.position == null ? 0 : 1;
if (b.position == null) return -1;
return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position));
});
return shaped;
}
const page = (
id: string,
parentPageId: string | null,
position: string,
): RawPage => ({
id,
slugId: `slug-${id}`,
title: `Page ${id}`,
icon: '',
position,
parentPageId,
spaceId: 'space-1',
});
describe('getSidebarPagesTree shaping logic', () => {
it('open space: canEdit = spaceCanEdit, hasChildren derived from set', () => {
const pages = [
page('root', null, 'a0'),
page('child', 'root', 'a0'),
page('leaf', 'child', 'a0'),
];
const result = shapeTree(pages, {
hasRestrictions: false,
spaceCanEdit: true,
});
const byId = new Map(result.map((p) => [p.id, p]));
expect(byId.get('root')!.hasChildren).toBe(true);
expect(byId.get('child')!.hasChildren).toBe(true);
expect(byId.get('leaf')!.hasChildren).toBe(false);
expect(result.every((p) => p.canEdit === true)).toBe(true);
});
it('open space: spaceCanEdit=false makes every node read-only', () => {
const pages = [page('root', null, 'a0'), page('child', 'root', 'a0')];
const result = shapeTree(pages, {
hasRestrictions: false,
spaceCanEdit: false,
});
expect(result.every((p) => p.canEdit === false)).toBe(true);
});
it('restricted space: hasChildren does not reveal pruned children', () => {
// Simulates the filterAccessibleTreePages result: "child" was pruned, so
// the returned set has no row with parent === root.
const prunedPages = [page('root', null, 'a0')];
const result = shapeTree(prunedPages, {
hasRestrictions: true,
spaceCanEdit: true,
permissionMap: new Map([['root', true]]),
});
expect(result).toHaveLength(1);
// root no longer advertises children the user cannot access.
expect(result[0].hasChildren).toBe(false);
});
it('restricted space: canEdit is per-page AND spaceCanEdit', () => {
const pages = [
page('root', null, 'a0'),
page('child', 'root', 'a0'),
];
const result = shapeTree(pages, {
hasRestrictions: true,
spaceCanEdit: true,
permissionMap: new Map([
['root', true],
['child', false],
]),
});
const byId = new Map(result.map((p) => [p.id, p]));
expect(byId.get('root')!.canEdit).toBe(true);
expect(byId.get('child')!.canEdit).toBe(false);
expect(byId.get('root')!.hasChildren).toBe(true);
});
it('restricted space: spaceCanEdit=false overrides per-page canEdit', () => {
const pages = [page('root', null, 'a0')];
const result = shapeTree(pages, {
hasRestrictions: true,
spaceCanEdit: false,
permissionMap: new Map([['root', true]]),
});
expect(result[0].canEdit).toBe(false);
});
it('orders by position (collate-C style ascending)', () => {
const pages = [
page('b', null, 'a1'),
page('c', null, 'a2'),
page('a', null, 'a0'),
];
const result = shapeTree(pages, {
hasRestrictions: false,
spaceCanEdit: true,
});
expect(result.map((p) => p.id)).toEqual(['a', 'b', 'c']);
});
it('shape contains exactly the sidebar item fields', () => {
const result = shapeTree([page('root', null, 'a0')], {
hasRestrictions: false,
spaceCanEdit: true,
});
expect(Object.keys(result[0]).sort()).toEqual(
[
'canEdit',
'hasChildren',
'icon',
'id',
'parentPageId',
'position',
'slugId',
'spaceId',
'title',
].sort(),
);
});
});

View File

@@ -673,11 +673,9 @@ export class PageRepo {
);
}
/**
* Whole space tree (all root pages and their descendants) in a single
* recursive query. Mirrors getPageAndDescendants but seeded by every root
* page of the space (parentPageId IS NULL) instead of a single parent.
*/
// Space-rooted variant of getPageAndDescendants: seeds the recursive CTE from
// the space's root pages (parentPageId IS NULL) instead of a single page, so a
// single request returns the whole space tree. Used by the expand-all endpoint.
async getSpaceDescendants(
spaceId: string,
opts: { includeContent: boolean },
@@ -699,8 +697,8 @@ export class PageRepo {
'updatedAt',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('spaceId', '=', spaceId)
.where('parentPageId', 'is', null)
.where('spaceId', '=', spaceId)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
exp
@@ -726,4 +724,84 @@ export class PageRepo {
.selectAll()
.execute();
}
// Space-rooted variant of getPageAndDescendantsExcludingRestricted: prunes
// restricted subtrees in SQL (stops traversing at restricted pages). Seeds from
// the space's root pages by default, or from a single page when pageId is given
// (single-subtree mode). Used by the expand-all endpoint for spaces that have
// page-level restrictions.
async getSpaceDescendantsExcludingRestricted(
spaceId: string,
pageId: string | null,
opts: { includeContent: boolean },
) {
return (
this.db
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
.select([
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.position',
'pages.parentPageId',
'pages.spaceId',
'pages.workspaceId',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
])
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
.where((eb) =>
pageId
? eb.and([
eb('pages.id', '=', pageId),
eb('pages.deletedAt', 'is', null),
])
: eb.and([
eb('pages.parentPageId', 'is', null),
eb('pages.spaceId', '=', spaceId),
eb('pages.deletedAt', 'is', null),
]),
)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.position',
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
])
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
.where('p.deletedAt', 'is', null)
// Only recurse into children of non-restricted pages
.where('ph.isRestricted', '=', false),
),
)
.selectFrom('page_hierarchy')
.select([
'id',
'slugId',
'title',
'icon',
'position',
'parentPageId',
'spaceId',
'workspaceId',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
// Filter out restricted pages from the result
.where('isRestricted', '=', false)
.execute()
);
}
}

View File

@@ -0,0 +1,301 @@
# Дерево страниц: кнопки «Развернуть всё» / «Свернуть всё»
Статус: **план, код не менялся.** Фича клиент+сервер. По решению владельца выбран
**серверный путь**: эндпоинт отдаёт **всё поддерево/всё дерево спейса разом**
(«отдать всё»), а клиент за один-два запроса разворачивает дерево целиком. От
клиентского рекурсивного обхода по одному уровню — отказались (см. «Почему так»).
## Суть
В сайдбаре спейса (дерево «Pages») сейчас узлы разворачиваются/сворачиваются
только поодиночке кликом по шеврону. Есть шорткат `*` (разворачивает **сиблингов**
сфокусированного узла, паттерн WAI-ARIA tree), но глобального «развернуть/свернуть
всё дерево» нет.
Хотим: две команды в шапке дерева — **«Развернуть всё»** (раскрыть все ветки
текущего спейса) и **«Свернуть всё»** (схлопнуть до корней). Это навигационная
операция над видом — прав на запись не требует, доступна любому, кто видит спейс.
## Почему так (выбор архитектуры)
Дети узлов **загружаются лениво, по одному уровню**: у свёрнутой ветки
`hasChildren === true`, но `children === []`, а эндпоинт `/pages/sidebar-pages`
отдаёт **только прямых детей** одного `pageId`. «Развернуть всё» поверх такого
API = рекурсивный BFS на десятки-сотни HTTP-запросов (шторм запросов, лимиты,
долгий индикатор, защитный потолок). Это и был отвергнутый вариант.
**Решение — отдать всё одним запросом на сервере.** У бэкенда уже есть готовые
кирпичи для рекурсивной выборки поддерева с учётом прав (используются в
`movePageToSpace`):
- `pageRepo.getPageAndDescendants(parentPageId, { includeContent: false })`
([page.repo.ts:557](apps/server/src/database/repos/page/page.repo.ts#L557)) —
рекурсивный CTE: страница + все потомки одним запросом.
- `pageRepo.getPageAndDescendantsExcludingRestricted(parentPageId, opts)`
([page.repo.ts:612](apps/server/src/database/repos/page/page.repo.ts#L612)) —
то же, но **обрезает закрытые (restricted) поддеревья прямо в SQL** (один
запрос, не тянет лишнее).
- `pageService.filterAccessibleTreePages(allPages, rootId, userId, spaceId)`
([page.service.ts:1136](apps/server/src/core/page/services/page.service.ts#L1136))
— точечная фильтрация дерева по правам с сохранением целостности (для
per-page permissions сверх restricted-спейсов).
- `pageRepo.withHasChildren(eb)`
([page.repo.ts:539](apps/server/src/database/repos/page/page.repo.ts#L539)) —
вычисление `hasChildren` в SQL (при отдаче всего дерева `hasChildren` можно и
вывести на клиенте — у узла есть дети, если в ответе есть страница с
`parentPageId === id`).
Плюсы серверного пути: один-два запроса вместо сотен; предсказуемо даже на
тысячах страниц; права считаются на сервере (единый источник правды); на клиенте
нет BFS/ограничителя параллелизма/защитного потолка. Минус — нужна работа на
бэкенде (новый рекурсивный режим эндпоинта) и контроль размера ответа.
## Где сейчас живёт код (точные места)
### Клиент — фича `apps/client/src/features/page/tree/`
- **Состояние раскрытия** —
[open-tree-nodes-atom.ts](apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts):
`openTreeNodesAtom`, тип `OpenMap = Record<string, boolean>` (id → раскрыт ли),
**персист в localStorage**, ключ `openTreeNodes:{workspaceId}:{userId}`.
**Карта общая для всех спейсов воркспейса.**
- **Данные дерева** —
[tree-data-atom.ts](apps/client/src/features/page/tree/atoms/tree-data-atom.ts):
`treeDataAtom: SpaceTreeNode[]`, накопительно по спейсам; на рендере
фильтруется по `spaceId`.
- **Модель узла** —
[types.ts](apps/client/src/features/page/tree/types.ts): `SpaceTreeNode`
(`id`, `spaceId`, `hasChildren`, `children`, `name`, `icon`, `position`,
`parentPageId`, `canEdit`, `slugId`).
- **Обёртка/тоггл/загрузка** —
[space-tree.tsx](apps/client/src/features/page/tree/components/space-tree.tsx):
`filteredData` (стр. 184-187, узлы текущего спейса), `handleToggle` (стр.
164-182, ленивая загрузка уровня), `spaceIdRef` (стр. 46-47, защита от гонок).
- **Модель-операции** —
[tree-model.ts](apps/client/src/features/page/tree/model/tree-model.ts):
`find`, `appendChildren`, `visible`, `siblingsOf`.
- **HTTP-загрузка** —
[page-query.ts](apps/client/src/features/page/queries/page-query.ts) +
[page-service.ts](apps/client/src/features/page/services/page-service.ts):
`getSidebarPages` / `getAllSidebarPages` (паджинируют **один уровень**),
`fetchAllAncestorChildren`, утилиты `buildTree` / `buildTreeWithChildren` /
`mergeRootTrees` ([utils.ts](apps/client/src/features/page/tree/utils/utils.ts)).
- **Шапка дерева (куда вешать команды)** —
[space-sidebar.tsx:117-149](apps/client/src/features/space/components/sidebar/space-sidebar.tsx#L117):
`SpaceMenu` (дропдаун на `IconDots`, стр. 172-281, уже с `Menu.Item`/
`Menu.Divider`) + кнопка «+» (Create page).
### Сервер — фича `apps/server/src/core/page/`
- **Эндпоинт сайдбара** —
[page.controller.ts:540](apps/server/src/core/page/page.controller.ts#L540)
`POST /pages/sidebar-pages` (`SidebarPageDto`: `spaceId | pageId`),
CASL-скоуп на спейс, отдаёт **один уровень**.
- **Сервис** —
[page.service.ts:304](apps/server/src/core/page/services/page.service.ts#L304)
`getSidebarPages(spaceId, pagination, pageId?, userId?, spaceCanEdit?)`:
выборка одного уровня + `withHasChildren` + **двухветочная фильтрация прав**
если в спейсе нет ограничений (`pagePermissionRepo.hasRestrictedPagesInSpace`)
`canEdit = spaceCanEdit`; иначе per-page фильтр через
`filterAccessiblePageIdsWithPermissions` + корректировка `hasChildren` по
`getParentIdsWithAccessibleChildren`. **Эту же логику прав надо повторить в
рекурсивном режиме.**
## Решение
### Серверная часть — «отдать всё поддерево» одним запросом
Добавить рекурсивный режим выдачи дерева. Варианты оформления (выбрать на ревью):
- флаг `recursive: true` (и опц. `depth`) к существующему `POST /pages/sidebar-pages`, **или**
- отдельный эндпоинт `POST /pages/tree` (`{ spaceId }` → всё дерево спейса;
`{ pageId }` → всё поддерево страницы).
Контракт ответа: **плоский список элементов в точно том же shape, что и текущий
`/pages/sidebar-pages`** (`id`, `slugId`, `title`, `icon`, `position`,
`parentPageId`, `spaceId`, `hasChildren`, `canEdit`), чтобы клиентские
`buildTree`/`buildTreeWithChildren` собрали дерево без изменений. Порядок — по
`position` (collate "C"), как сейчас.
Сервисный метод (эскиз), переиспользует существующие кирпичи:
```ts
// Whole subtree (pageId) or whole space tree (spaceId only) in a single query,
// permission-filtered, returned as a flat list matching the sidebar item shape.
async getSidebarPagesTree(spaceId, userId, spaceCanEdit, pageId?) {
const hasRestrictions = await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
// Seed: a single page subtree, or all root pages of the space.
// - restricted space -> *ExcludingRestricted (prunes closed subtrees in SQL)
// - open space -> plain recursive descendants
// For the whole-space case add a space-rooted recursive CTE (seed:
// parentPageId is null AND spaceId = ? AND deletedAt is null), mirroring
// getPageAndDescendants/...ExcludingRestricted.
let pages = hasRestrictions
? await this.pageRepo.getSpaceDescendantsExcludingRestricted(spaceId, pageId, { includeContent: false })
: await this.pageRepo.getSpaceDescendants(spaceId, pageId, { includeContent: false });
// Fine-grained per-page permissions on top of restricted pruning.
if (hasRestrictions) {
pages = await this.filterAccessibleTreePages(pages, pageId ?? null, userId, spaceId);
}
// Derive hasChildren from the returned set; stamp canEdit (per-page when
// restricted, else spaceCanEdit). Same two-branch logic as getSidebarPages().
return shapeAsSidebarItems(pages, { hasRestrictions, spaceCanEdit /*, permissionMap */ });
}
```
Где `getSpaceDescendants` / `getSpaceDescendantsExcludingRestricted` — новые
тонкие обёртки над существующими рекурсивными CTE (для случая «всё дерево спейса»
— CTE, засеянный корнями спейса вместо одного `parentPageId`).
**Важно про права:** обязательно сохранить **обе ветки** фильтрации из
`getSidebarPages` (restricted / не-restricted) и корректировку `hasChildren`,
иначе рекурсивный эндпоинт начнёт отдавать страницы, к которым у пользователя нет
доступа. Это критичная грань — на ревью проверить отдельно.
### Клиентская часть — упрощённый `expandAll`
Поскольку дерево приходит целиком, BFS/параллелизм/потолок не нужны.
`page-service.ts` — новый вызов:
```ts
// Fetch the whole space tree (all roots + descendants) in one shot.
export async function getSpaceTree(params: { spaceId: string; pageId?: string }): Promise<IPage[]> {
const req = await api.post("/pages/tree", params); // or /sidebar-pages { recursive: true }
return req.data.items;
}
```
`space-tree.tsx` — превратить `SpaceTree` в `forwardRef` и выставить
`useImperativeHandle`:
```ts
export type SpaceTreeApi = {
expandAll: () => Promise<void>;
collapseAll: () => void;
isExpanding: boolean;
};
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 });
if (spaceIdRef.current !== startSpaceId) return; // space switched — abort
const fullTree = buildTreeWithChildren(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, ...mergeRootTrees(prev.filter((n) => n?.spaceId === startSpaceId), fullTree)];
});
// Open every branch node of the current space.
const branchIds = collectBranchIds(fullTree); // nodes with children
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of branchIds) next[id] = true;
return next;
});
} catch (err) {
// Never swallow: log full error + show the real reason (project convention).
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 */]);
```
`collapseAll` — снимать раскрытие **только у узлов текущего спейса** (карта общая):
```ts
const collapseAll = useCallback(() => {
// The open-map is shared across spaces; clearing it wholesale would drop
// other spaces' expanded state. Collapse only current-space ids.
const ids = new Set<string>();
const walk = (nodes: SpaceTreeNode[]) => {
for (const n of nodes) { ids.add(n.id); if (n.children?.length) walk(n.children); }
};
walk(filteredData);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of ids) next[id] = false;
return next;
});
}, [filteredData, setOpenTreeNodes]);
```
`space-sidebar.tsx``const treeRef = useRef<SpaceTreeApi | null>(null)`, передать
в `<SpaceTree ref={treeRef} ... />`, и подвесить команды в шапке. **Без
`canManage`-гейта** — это операция над видом, не над данными.
## UX-развилка по размещению
В шапке уже два значка (`IconDots` меню + `IconPlus` создать). Варианты:
- **(1) Две `ActionIcon`** «развернуть»/«свернуть» (`IconChevronsDown` /
`IconChevronsUp`) → 4 значка в узкой шапке, явно и в один клик.
- **(2) Одна `ActionIcon`-тоггл** развернуть↔свернуть → 3 значка, компактнее, но
состояние менее очевидно.
- **(3) Два `Menu.Item`** в `SpaceMenu` (`Развернуть всё` / `Свернуть всё` +
`Menu.Divider`) → шапка не растёт, но в два клика и менее заметно.
> **Рекомендация:** **(3)** как самый чистый по вёрстке (узкая колонка) либо
> **(1)**, если важна доступность в один клик. Тултипы/`aria-label`:
> `t("Expand all")` / `t("Collapse all")`; во время загрузки — `loading`/
> `disabled` (`isExpanding`).
## Тонкие моменты / edge cases
- **Права в рекурсивном эндпоинте.** Самый важный пункт: повторить **обе** ветки
фильтрации (restricted / открытый спейс) и корректировку `hasChildren` из
`getSidebarPages`. Предпочесть `*ExcludingRestricted` (обрезает закрытые
поддеревья в SQL) + `filterAccessibleTreePages` для per-page прав. На ревью —
тест: пользователь без доступа к ветке не должен видеть её через «развернуть
всё».
- **Размер ответа.** Всё дерево спейса может быть большим. `content` **не**
тянуть (`includeContent: false`). Прикинуть потолок (число узлов) и поведение
при очень больших спейсах — отдавать всё или ограничить + честно сообщить
(конвенция: не молчать про усечение).
- **Скоуп карты раскрытия.** `openTreeNodesAtom` общая для спейсов — и
`expandAll`, и `collapseAll` работают **только по узлам текущего спейса**.
- **Гонки при смене спейса.** Запрос асинхронный; сверяться с
`spaceIdRef.current` и прерывать мёрдж/раскрытие, если спейс сменился (паттерн
уже есть в эффектах `space-tree.tsx`).
- **Мёрдж с уже загруженным.** Полное дерево вмёрджить в `treeDataAtom`, заместив
узлы текущего спейса (`mergeRootTrees`/замена ветки), **не трогая** узлы
других спейсов.
- **Ошибки не глотать.** Любой сбой — `console.error` с полным объектом **и**
уведомление с реальной причиной (`err.response?.data?.message`/`err.message`),
не «что-то пошло не так» (CLAUDE.md «Errors must never be swallowed»).
- **Индикатор.** На крупном спейсе запрос заметный — кнопку в `loading`, чтобы не
было повторных кликов/ощущения зависания.
- **Рост localStorage-карты.** `expandAll` пишет много ключей; для удалённых
страниц ключи «висят». Не критично; уборка карты — отдельная задача.
- **Пустой спейс / одни листья.** Кнопки — no-op; «развернуть» можно `disabled`.
- **Шорткат `*`** (развернуть сиблингов,
[doc-tree.tsx](apps/client/src/features/page/tree/components/doc-tree.tsx)) не
трогаем — дополняем его.
- **Виртуализация.** Дерево на `@tanstack/react-virtual` — раскрытие тысяч строк
рендер не убьёт (рисуются видимые), но резко меняет высоту скролла; проверить,
что позиция/скролл не прыгают.
## Тесты / проверка
- **Сервер:** `pnpm --filter server test` (unit на новый сервисный метод).
Кейсы: открытый спейс (видно всё), restricted-спейс (закрытые ветки и их
поддеревья **не** попадают в ответ), per-page права (`canEdit`), корректный
`hasChildren`, порядок по `position`, `content` не тянется.
- **Клиент:** `pnpm --filter client lint`, `pnpm --filter client test`.
- **Ручная:** глубокий спейс → «развернуть всё» раскрывает все уровни одним
запросом, индикатор работает; «свернуть всё» схлопывает до корней и **не**
теряет состояние другого спейса (переключиться туда-обратно); перезагрузка —
состояние сохраняется (localStorage); смена спейса в середине загрузки —
корректно прерывается; пустой спейс — без поломок; имитация ошибки сети — видно
конкретное уведомление, ошибка залогирована.
## Открытые вопросы
1. **Оформление эндпоинта:** флаг `recursive` к `/pages/sidebar-pages` против
отдельного `/pages/tree`. (Контракт ответа в обоих — плоский список в shape
текущего сайдбара.)
2. **Размещение команд:** две иконки (1) / одна-тоггл (2) / пункты меню (3).
Рекомендация — (3) или (1).
3. **Потолок размера ответа:** отдавать дерево любого размера или ограничить
(число узлов) и как сообщать про усечение.