feat(editor): recursive tree mode for the subpages node (#150) #155

Merged
Ghost merged 2 commits from feat/subpages-recursive-tree into develop 2026-06-24 14:35:18 +03:00

Закрывает #150.

Что

Нода редактора subpages показывала только один уровень прямых детей. Добавлен атрибут recursive: при включении рендерится полное дерево потомков текущей страницы — раскрытое целиком, без ограничения глубины. По умолчанию false → все ранее вставленные ноды остаются плоскими (обратная совместимость).

Бэкенд без изменений

POST /pages/tree с pageId (через обёртку getSpaceTree) уже отдаёт всё поддерево плоским IPage[] (рекурсивный CTE, с учётом прав). Вложенное дерево собираем на клиенте по parentPageId — один запрос, без N+1.

Изменения (7 файлов, только клиент + общий пакет)

  • editor-ext/subpages.ts — атрибут recursive (parse/render data-recursive); нода общая клиент+сервер, схема коллаборации не ломается.
  • page-service.tsgetSpaceTree принимает { spaceId?; pageId? }; page-query.ts — хук useGetPageTreeQuery.
  • subpages-view.tsxFlatSubpages (без изменений) / RecursiveSubpages; buildSubtree (защита от циклов/сирот, sortPositionKeys на каждом уровне, корень не рендерится) + рекурсивный TreeNode (отступ 16px/уровень, мягкая пометка «showing N» при >300, данные не режутся). В shared-контексте — из уже загруженного вложенного дерева, без запроса.
  • Переключатели: тоггл в bubble-меню (плоский⇄дерево) + второй пункт слэш-меню «Page tree».

По ревью (мой review-агент, APPROVE WITH SUGGESTIONS — всё закрыто)

  • Инвалидация кэша ["page-tree"] из четырёх cache-хелперов (create/update/move/delete) — рекурсивное дерево свежее после изменений детей (это был единственный реальный пункт, отмеченный и в issue).
  • Иконка режима сделана реактивной (подписка на transaction), без ререндера на каждый ввод.
  • t прокинут пропом в TreeNode (нет useTranslation на каждый узел).
  • shared-хук дедуплицирован в тонкий алиас.

Проверка

pnpm --filter @docmost/editor-ext build и client tsc --noEmit — оба чисто.

🤖 Generated with Claude Code

Закрывает #150. ## Что Нода редактора `subpages` показывала только один уровень прямых детей. Добавлен атрибут `recursive`: при включении рендерится **полное дерево потомков** текущей страницы — раскрытое целиком, без ограничения глубины. По умолчанию `false` → все ранее вставленные ноды остаются плоскими (обратная совместимость). ## Бэкенд без изменений `POST /pages/tree` с `pageId` (через обёртку `getSpaceTree`) уже отдаёт всё поддерево плоским `IPage[]` (рекурсивный CTE, с учётом прав). Вложенное дерево собираем на клиенте по `parentPageId` — один запрос, без N+1. ## Изменения (7 файлов, только клиент + общий пакет) - `editor-ext/subpages.ts` — атрибут `recursive` (parse/render `data-recursive`); нода общая клиент+сервер, схема коллаборации не ломается. - `page-service.ts` — `getSpaceTree` принимает `{ spaceId?; pageId? }`; `page-query.ts` — хук `useGetPageTreeQuery`. - `subpages-view.tsx` — `FlatSubpages` (без изменений) / `RecursiveSubpages`; `buildSubtree` (защита от циклов/сирот, `sortPositionKeys` на каждом уровне, корень не рендерится) + рекурсивный `TreeNode` (отступ 16px/уровень, мягкая пометка «showing N» при >300, данные не режутся). В shared-контексте — из уже загруженного вложенного дерева, без запроса. - Переключатели: тоггл в bubble-меню (плоский⇄дерево) + второй пункт слэш-меню «Page tree». ## По ревью (мой review-агент, APPROVE WITH SUGGESTIONS — всё закрыто) - **Инвалидация кэша** `["page-tree"]` из четырёх cache-хелперов (create/update/move/delete) — рекурсивное дерево свежее после изменений детей (это был единственный реальный пункт, отмеченный и в issue). - Иконка режима сделана реактивной (подписка на transaction), без ререндера на каждый ввод. - `t` прокинут пропом в `TreeNode` (нет useTranslation на каждый узел). - shared-хук дедуплицирован в тонкий алиас. ## Проверка `pnpm --filter @docmost/editor-ext build` и client `tsc --noEmit` — оба чисто. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 1 commit 2026-06-24 06:13:46 +03:00
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>
Owner

Code review — recursive tree mode for the subpages node

Вердикт: Request changes (мягкий). Изменение аккуратное, безопасное и обратно совместимое: новых дыр в доступе нет, регрессий нет, схема ноды расширяется без поломки уже сохранённых документов и совместной правки. Блокеров нет. Просьба об изменениях держится на двух дешёвых пунктах: (1) реальный пробел в i18n — 6 новых строк не заведены в каталоги, в русском UI они покажутся по-английски; (2) новая чистая логика (buildSubtree и спутники) уезжает без юнит-тестов, хотя в репозитории есть прямой прецедент тестирования ровно таких функций. Ревью — против merge-base acf6d85.

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") повторяет штатный хук useEditorStatesubpages-menu.tsx:67-87.
    Отслеживание isRecursive через ручную подписку на транзакции с bail-on-equal — это в точности то, что делает useEditorState({ editor, selector }) из @tiptap/react, и это устоявшийся идиом во всех остальных bubble-меню этой папки (video-, pdf-, excalidraw-, image-, table-, callout-, columns-menu …). Хук сам мемоизирует и не ре-рендерит на каждое нажатие, так что длинный комментарий-обоснование не нужен.
    Fix:

    const isRecursive = useEditorState({
      editor,
      selector: (ctx) => ctx.editor?.getAttributes("subpages")?.recursive ?? false,
    });
    

    Убирает импорты useEffect/useState, ручную подписку и комментарий.

  • [stability] Комментарий про «cycle guard» переоценивает защитуsubpages-view.tsx:208-226.
    Комментарий заявляет защиту от циклов, но условие p.id !== rootId защищает только от само-родительства/привязки к корню. Многоузловой цикл parentPageId (A↔B) теоретически дал бы бесконечную рекурсию. На практике это недостижимо: на сервере есть guard от циклов при move, рекурсивный CTE getPageAndDescendants сам бы зациклился до клиента, а shared-путь строит дерево из плоского списка (циклы невозможны по конструкции). То есть это неточность комментария, а не риск краша.
    Fix: поправить комментарий (защита от self/root, не от циклов) либо добавить visited-Set в рекурсию.

Architecture & design (forward-looking, non-blocking)

  • Третья параллельная реализация «плоский список → вложенное дерево». PR добавляет buildSubtree рядом с buildTreeWithChildren (page/tree/utils/utils.ts) и buildSharedPageTree (share/utils.ts). Все три группируют плоский IPage[] по parentPageId и сортируют уровни по position, но с разными типами узлов и разным поведением на краях (новый якорится на rootId и молча выбрасывает недостижимое; buildTreeWithChildren поднимает сирот и глушит дубли id). Дублирование реальное: правку сортировки/обработки сирот теперь надо помнить в трёх местах.
    • A — оставить три билдера как есть (effort: s). + нулевой риск; − цементирует три расходящиеся копии.
    • B — вынести один генерик-билдер в page/tree/utils и свести все три к нему (effort: m). + единый источник истины; − затрагивает sidebar и share, регрессионная поверхность, нужны тесты.
    • C — узкий рефактор: строить SubpageNode через buildTreeWithChildren + якорь на rootId, share не трогать (effort: s–m). + убирает именно новую копию; − остаётся две реализации.
    • Рекомендация: для этого PR — A (оставить), B завести как follow-up; если действовать сейчас — C наименее рисковый. Не блокер.
  • Переиспользовать sidebar-рендерер DocTree для TreeNode и заменить recursive: boolean на mode/depth — рассмотрено и признано нецелесообразным: DocTree это виртуализированный drag-and-drop ARIA-treeitem интерактив, для статичного read-only TOC избыточен; булева recursive — естественная двух-состоянийная точка расширения.

Проверено и чисто (без находок)

  • Security: новый фетч useGetPageTreeQuery → getSpaceTree({pageId}) → POST /pages/tree закрыт на сервере (page.controller.ts:582: резолвит spaceId из pageId, требует CASL Read на спейс) — та же модель, что у существующего sidebar-запроса, новых страниц не раскрывает. node.icon/node.title рендерятся как React-текст (экранируются), data-recursive пишется только литералом "true" — инъекций в совместный документ нет. Секретов нет.
  • Regressions: addAttributes({recursive, default:false}) обратно совместима — старые ноды грузятся как flat, renderHTML для flat отдаёт {} (байт-в-байт прежняя сериализация), Yjs не ломается. FlatSubpages поведенчески идентичен прежнему компоненту. Опционализация spaceId в getSpaceTree неблокирующая. Широкая инвалидация ["page-tree"] не пересекается с другими ключами.
  • Documentation: заявления в комментариях верны — alias useSharedPageSubtree (shared-дерево действительно уже вложенное), JSDoc invalidatePageTree (все 4 call-site на месте, проводка и в локальные мутации, и в websocket-эхо), контракт getSpaceTree({pageId}), комментарий про мемоизацию меню.

🤖 Сгенерировано оркестратором code-review (8 специализированных ревьюеров: security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture).

## Code review — recursive tree mode for the subpages node **Вердикт: Request changes (мягкий).** Изменение аккуратное, безопасное и обратно совместимое: новых дыр в доступе нет, регрессий нет, схема ноды расширяется без поломки уже сохранённых документов и совместной правки. Блокеров нет. Просьба об изменениях держится на двух дешёвых пунктах: (1) реальный пробел в i18n — 6 новых строк не заведены в каталоги, в русском UI они покажутся по-английски; (2) новая чистая логика (`buildSubtree` и спутники) уезжает без юнит-тестов, хотя в репозитории есть прямой прецедент тестирования ровно таких функций. Ревью — против merge-base `acf6d85`. ### 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:_ ```ts const isRecursive = useEditorState({ editor, selector: (ctx) => ctx.editor?.getAttributes("subpages")?.recursive ?? false, }); ``` Убирает импорты `useEffect`/`useState`, ручную подписку и комментарий. - **[stability] Комментарий про «cycle guard» переоценивает защиту** — `subpages-view.tsx:208-226`. Комментарий заявляет защиту от циклов, но условие `p.id !== rootId` защищает только от само-родительства/привязки к корню. Многоузловой цикл `parentPageId` (A↔B) теоретически дал бы бесконечную рекурсию. На практике это **недостижимо**: на сервере есть guard от циклов при move, рекурсивный CTE `getPageAndDescendants` сам бы зациклился до клиента, а shared-путь строит дерево из плоского списка (циклы невозможны по конструкции). То есть это неточность комментария, а не риск краша. _Fix:_ поправить комментарий (защита от self/root, не от циклов) либо добавить `visited`-Set в рекурсию. ### Architecture & design (forward-looking, non-blocking) - **Третья параллельная реализация «плоский список → вложенное дерево».** PR добавляет `buildSubtree` рядом с `buildTreeWithChildren` (`page/tree/utils/utils.ts`) и `buildSharedPageTree` (`share/utils.ts`). Все три группируют плоский `IPage[]` по `parentPageId` и сортируют уровни по `position`, но с разными типами узлов и разным поведением на краях (новый якорится на `rootId` и молча выбрасывает недостижимое; `buildTreeWithChildren` поднимает сирот и глушит дубли id). Дублирование реальное: правку сортировки/обработки сирот теперь надо помнить в трёх местах. - *A — оставить три билдера как есть* (effort: s). + нулевой риск; − цементирует три расходящиеся копии. - *B — вынести один генерик-билдер в `page/tree/utils` и свести все три к нему* (effort: m). + единый источник истины; − затрагивает sidebar и share, регрессионная поверхность, нужны тесты. - *C — узкий рефактор: строить `SubpageNode` через `buildTreeWithChildren` + якорь на rootId, share не трогать* (effort: s–m). + убирает именно новую копию; − остаётся две реализации. - **Рекомендация:** для этого PR — A (оставить), B завести как follow-up; если действовать сейчас — C наименее рисковый. Не блокер. - Переиспользовать sidebar-рендерер `DocTree` для `TreeNode` и заменить `recursive: boolean` на `mode`/`depth` — рассмотрено и признано **нецелесообразным**: `DocTree` это виртуализированный drag-and-drop ARIA-`treeitem` интерактив, для статичного read-only TOC избыточен; булева `recursive` — естественная двух-состоянийная точка расширения. --- ### Проверено и чисто (без находок) - **Security:** новый фетч `useGetPageTreeQuery → getSpaceTree({pageId}) → POST /pages/tree` закрыт на сервере (`page.controller.ts:582`: резолвит `spaceId` из `pageId`, требует CASL Read на спейс) — та же модель, что у существующего sidebar-запроса, новых страниц не раскрывает. `node.icon`/`node.title` рендерятся как React-текст (экранируются), `data-recursive` пишется только литералом `"true"` — инъекций в совместный документ нет. Секретов нет. - **Regressions:** `addAttributes({recursive, default:false})` обратно совместима — старые ноды грузятся как flat, `renderHTML` для flat отдаёт `{}` (байт-в-байт прежняя сериализация), Yjs не ломается. `FlatSubpages` поведенчески идентичен прежнему компоненту. Опционализация `spaceId` в `getSpaceTree` неблокирующая. Широкая инвалидация `["page-tree"]` не пересекается с другими ключами. - **Documentation:** заявления в комментариях верны — alias `useSharedPageSubtree` (shared-дерево действительно уже вложенное), JSDoc `invalidatePageTree` (все 4 call-site на месте, проводка и в локальные мутации, и в websocket-эхо), контракт `getSpaceTree({pageId})`, комментарий про мемоизацию меню. <sub>🤖 Сгенерировано оркестратором code-review (8 специализированных ревьюеров: security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture).</sub>
Owner

после исправления можно мержить

после исправления можно мержить
Ghost added 1 commit 2026-06-24 14:32:35 +03:00
- 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>
Ghost force-pushed feat/subpages-recursive-tree from 96a1bda8d0 to 623c89554a 2026-06-24 14:35:08 +03:00 Compare
Ghost merged commit c9b012894b into develop 2026-06-24 14:35:18 +03:00
Ghost deleted branch feat/subpages-recursive-tree 2026-06-24 14:35:18 +03:00
Sign in to join this conversation.