feat(editor): recursive tree mode for the subpages node (#150) #155
Reference in New Issue
Block a user
Delete Branch "feat/subpages-recursive-tree"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Закрывает #150.
Что
Нода редактора
subpagesпоказывала только один уровень прямых детей. Добавлен атрибутrecursive: при включении рендерится полное дерево потомков текущей страницы — раскрытое целиком, без ограничения глубины. По умолчаниюfalse→ все ранее вставленные ноды остаются плоскими (обратная совместимость).Бэкенд без изменений
POST /pages/treeсpageId(через обёрткуgetSpaceTree) уже отдаёт всё поддерево плоскимIPage[](рекурсивный CTE, с учётом прав). Вложенное дерево собираем на клиенте поparentPageId— один запрос, без N+1.Изменения (7 файлов, только клиент + общий пакет)
editor-ext/subpages.ts— атрибутrecursive(parse/renderdata-recursive); нода общая клиент+сервер, схема коллаборации не ломается.page-service.ts—getSpaceTreeпринимает{ spaceId?; pageId? };page-query.ts— хукuseGetPageTreeQuery.subpages-view.tsx—FlatSubpages(без изменений) /RecursiveSubpages;buildSubtree(защита от циклов/сирот,sortPositionKeysна каждом уровне, корень не рендерится) + рекурсивныйTreeNode(отступ 16px/уровень, мягкая пометка «showing N» при >300, данные не режутся). В shared-контексте — из уже загруженного вложенного дерева, без запроса.По ревью (мой review-агент, APPROVE WITH SUGGESTIONS — всё закрыто)
["page-tree"]из четырёх cache-хелперов (create/update/move/delete) — рекурсивное дерево свежее после изменений детей (это был единственный реальный пункт, отмеченный и в issue).tпрокинут пропом вTreeNode(нет useTranslation на каждый узел).Проверка
pnpm --filter @docmost/editor-ext buildи clienttsc --noEmit— оба чисто.🤖 Generated with Claude Code
The `subpages` node showed only one level of direct children. Add a `recursive` attribute that renders the FULL descendant tree of the current page — fully expanded, unlimited depth. Default `false`, so every previously-inserted node stays flat (backward compatible). No backend changes: `POST /pages/tree` (via the `getSpaceTree` wrapper) already returns the whole subtree as a flat `IPage[]` (recursive CTE, permission-filtered); the nested tree is built on the client by `parentPageId`. - editor-ext `subpages.ts`: `recursive` attribute (parse/render `data-recursive`), shared by client + server so the collab ProseMirror schema keeps the attribute. - `getSpaceTree`: arg loosened to `{ spaceId?; pageId? }` (the endpoint accepts either); new `useGetPageTreeQuery(pageId)` react-query hook. - `subpages-view.tsx`: split into `FlatSubpages` (unchanged) and `RecursiveSubpages`; `buildSubtree` assembles the nested tree (cycle/self-parent guard, `sortPositionKeys` per level, root excluded) and a recursive `TreeNode` renders it (16px indent per depth, soft "showing N" note past 300 — data never capped). Shared/public context reads the already-nested shared tree, no `/pages/tree` request. - toggles: bubble-menu flat⇄tree button + a second slash-menu item "Page tree". Review follow-ups folded in: invalidate `["page-tree"]` from the create / update / move / delete cache helpers so an open recursive tree refreshes (no stale data); mode icon made reactive on editor transactions; `t` threaded into `TreeNode` (no per-node useTranslation); shared-subtree hook deduped to a thin alias. editor-ext build + client `tsc --noEmit` both clean. Backend untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Code review — recursive tree mode for the subpages node
Вердикт: Request changes (мягкий). Изменение аккуратное, безопасное и обратно совместимое: новых дыр в доступе нет, регрессий нет, схема ноды расширяется без поломки уже сохранённых документов и совместной правки. Блокеров нет. Просьба об изменениях держится на двух дешёвых пунктах: (1) реальный пробел в i18n — 6 новых строк не заведены в каталоги, в русском UI они покажутся по-английски; (2) новая чистая логика (
buildSubtreeи спутники) уезжает без юнит-тестов, хотя в репозитории есть прямой прецедент тестирования ровно таких функций. Ревью — против merge-baseacf6d85.Critical
Нет.
Warnings
[i18n] 6 новых пользовательских строк не заведены в каталоги локалей —
subpages-menu.tsx:104-119,menu-items.ts:11-12,subpages-view.tsx:428.Проверено:
"Switch to tree","Switch to flat list","Toggle subpages display mode","Page tree (child pages, recursive)","Render the full nested tree of all descendant pages","Showing"отсутствуют и вen-US/translation.json, и вru-RU/translation.json. При этом все сопоставимые существующие строки ("Subpages (Child pages)","No subpages","Failed to load subpages","Synced block") заведены в обоих каталогах. Ключ = английский текст +fallbackLng: "en-US", поэтому английский UI отрисуется, но русский UI покажет эти контролы по-английски — нарушение задокументированной вi18n.ts:15-24политики «en-US — источник истины, en-US и ru-RU поддерживаются полностью».Fix: добавить 6 ключей в
en-US/translation.json(значение = английский текст) и перевести вru-RU/translation.json.[i18n] Конкатенация строк в счётчике ломает порядок слов и плюрализацию —
subpages-view.tsx:426-430.{t("Showing")} {total} {t("subpages")}собирается из трёх фрагментов. В проекте принят i18next с интерполяцией и плюральными ключами ("{{count}} command available_one"/_other,"Pages in trash will be permanently deleted after {{count}} days."). Текущая форма не склоняется по-русски и регистрирует отдельный ключ"subpages", дублирующий уже существующий"Subpages".Fix: один ключ с плюрализацией —
t("Showing {{count}} subpages", { count: total })+ варианты_one/_otherв обоих каталогах.[test coverage] Чистые функции
buildSubtree/countNodes/mapSharedNodesуходят без юнит-тестов —subpages-view.tsx:193-242.buildSubtree— нетривиальная ветвящаяся логика: построение Map по id, привязка к родителю только приnode && parent && p.id !== rootId, отбрасывание корня, рекурсивная сортировка каждого уровня черезsortPositionKeys. В диффе нет ни одного тест-файла. Это не выше планки проекта, а ровно по ней: репозиторий уже юнит-тестирует почти идентичныйbuildSharedPageTreeвshare/utils.test.tsиbuildTree/buildTreeWithChildrenвpage/tree/utils/utils.test.ts, там же есть фабрикаpage({...})для копирования. Непокрытые кейсы: базовое вложение + порядок поposition; отсутствующий корень →[]; сирота сparentPageIdна отсутствующий узел (здесь молча отбрасывается, в отличие отbuildSharedPageTree, который сироту поднимает — расхождение стоит зафиксировать); guard self-parent; пустой ввод; суммыcountNodes; ремап полей вmapSharedNodes.Fix: вынести три функции в
subpages-view.utils.ts(импорт без React/Tiptap) и добавитьsubpages-view.utils.test.tsпо образцуshare/utils.test.ts.Suggestions
[simplification] Ручной
useState+editor.on("transaction")повторяет штатный хукuseEditorState—subpages-menu.tsx:67-87.Отслеживание
isRecursiveчерез ручную подписку на транзакции с bail-on-equal — это в точности то, что делаетuseEditorState({ editor, selector })из@tiptap/react, и это устоявшийся идиом во всех остальных bubble-меню этой папки (video-, pdf-, excalidraw-, image-, table-, callout-, columns-menu …). Хук сам мемоизирует и не ре-рендерит на каждое нажатие, так что длинный комментарий-обоснование не нужен.Fix:
Убирает импорты
useEffect/useState, ручную подписку и комментарий.[stability] Комментарий про «cycle guard» переоценивает защиту —
subpages-view.tsx:208-226.Комментарий заявляет защиту от циклов, но условие
p.id !== rootIdзащищает только от само-родительства/привязки к корню. Многоузловой циклparentPageId(A↔B) теоретически дал бы бесконечную рекурсию. На практике это недостижимо: на сервере есть guard от циклов при move, рекурсивный CTEgetPageAndDescendantsсам бы зациклился до клиента, а shared-путь строит дерево из плоского списка (циклы невозможны по конструкции). То есть это неточность комментария, а не риск краша.Fix: поправить комментарий (защита от self/root, не от циклов) либо добавить
visited-Set в рекурсию.Architecture & design (forward-looking, non-blocking)
buildSubtreeрядом сbuildTreeWithChildren(page/tree/utils/utils.ts) иbuildSharedPageTree(share/utils.ts). Все три группируют плоскийIPage[]поparentPageIdи сортируют уровни поposition, но с разными типами узлов и разным поведением на краях (новый якорится наrootIdи молча выбрасывает недостижимое;buildTreeWithChildrenподнимает сирот и глушит дубли id). Дублирование реальное: правку сортировки/обработки сирот теперь надо помнить в трёх местах.page/tree/utilsи свести все три к нему (effort: m). + единый источник истины; − затрагивает sidebar и share, регрессионная поверхность, нужны тесты.SubpageNodeчерезbuildTreeWithChildren+ якорь на rootId, share не трогать (effort: s–m). + убирает именно новую копию; − остаётся две реализации.DocTreeдляTreeNodeи заменитьrecursive: booleanнаmode/depth— рассмотрено и признано нецелесообразным:DocTreeэто виртуализированный drag-and-drop ARIA-treeitemинтерактив, для статичного read-only TOC избыточен; булеваrecursive— естественная двух-состоянийная точка расширения.Проверено и чисто (без находок)
useGetPageTreeQuery → getSpaceTree({pageId}) → POST /pages/treeзакрыт на сервере (page.controller.ts:582: резолвитspaceIdизpageId, требует CASL Read на спейс) — та же модель, что у существующего sidebar-запроса, новых страниц не раскрывает.node.icon/node.titleрендерятся как React-текст (экранируются),data-recursiveпишется только литералом"true"— инъекций в совместный документ нет. Секретов нет.addAttributes({recursive, default:false})обратно совместима — старые ноды грузятся как flat,renderHTMLдля flat отдаёт{}(байт-в-байт прежняя сериализация), Yjs не ломается.FlatSubpagesповеденчески идентичен прежнему компоненту. ОпционализацияspaceIdвgetSpaceTreeнеблокирующая. Широкая инвалидация["page-tree"]не пересекается с другими ключами.useSharedPageSubtree(shared-дерево действительно уже вложенное), JSDocinvalidatePageTree(все 4 call-site на месте, проводка и в локальные мутации, и в websocket-эхо), контрактgetSpaceTree({pageId}), комментарий про мемоизацию меню.🤖 Сгенерировано оркестратором code-review (8 специализированных ревьюеров: security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture).
после исправления можно мержить
- Extract buildSubtree/mapSharedNodes/countNodes/SubpageNode into subpages-view.utils.ts with a unit test (subpages-view.utils.test.ts) covering nesting, position order, missing/unreachable parent, self-parent guard, empty input, countNodes and mapSharedNodes remap. - Replace the manual useState + editor.on("transaction") subscription in subpages-menu.tsx with useEditorState (the idiom the sibling bubble menus use), so the mode icon/tooltip track the live recursive attribute without re-rendering on every keystroke. - i18n: add the 6 menu/tree strings and a pluralized "Showing {{count}} subpages" key to en-US and ru-RU. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>96a1bda8d0to623c89554a