From ca0622ef01165c2a05c21f74598ff5fac600787d Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Thu, 18 Jun 2026 05:42:12 +0300 Subject: [PATCH] feat(client): persist page-tree open/closed state per workspace+user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar page tree previously kept its expanded/collapsed state in an in-memory jotai atom, so it reset on every reload. Persist it in localStorage instead, scoped by `${workspaceId}:${userId}` so multiple accounts sharing a browser origin don't leak tree state into each other. - open-tree-nodes-atom: replace the in-memory atom with a facade over an atomFamily of atomWithStorage, scoped via currentUserAtom; keep the same [OpenMap, functional-updater setter] public API so space-tree needs no change. - Use an explicit synchronous createJSONStorage + getOnInit: true so the saved state is read at atom init — no collapse-then-expand flicker on reload and no write against an un-hydrated empty map. - README / README.ru: document persistent page-tree state in the "What's different from Docmost" table. --- README.md | 1 + README.ru.md | 1 + .../page/tree/atoms/open-tree-nodes-atom.ts | 46 ++++++++++++++++++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f79b1647..2208222a 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ The goal of the fork is a **100% open, AGPL-only build with no Enterprise-Editio | **AI agent chat** | Built-in AI agent chat over your wiki, written from scratch as a community feature — no enterprise license. The agent reads and edits pages on your behalf (scoped to your permissions), with full-text + vector (RAG) search and optional web access via external MCP servers. | | **Rebranding** | App logo / name changed from *Docmost* to *Gitmost*. | | **Compact page tree** | Default page-tree indentation reduced from 16px to 8px per nesting level. | +| **Persistent page-tree state** | The sidebar page tree remembers which nodes you expanded/collapsed across reloads — saved in the browser (localStorage), scoped per workspace + user so accounts sharing a browser don't clash. Upstream Docmost forgets the tree on every reload. | | **CI / images** | Release CI publishes container images to GHCR (`ghcr.io/vvzvlad/gitmost`) using the built-in `GITHUB_TOKEN` instead of Docker Hub. | ### Embedded MCP server diff --git a/README.ru.md b/README.ru.md index 42957cd3..ccd2a18e 100644 --- a/README.ru.md +++ b/README.ru.md @@ -37,6 +37,7 @@ | **Чат с AI-агентом** | Встроенный чат с AI-агентом по содержимому вики, написанный с нуля как community-функция — без enterprise-лицензии. Агент читает и редактирует страницы от вашего имени (в рамках ваших прав), с полнотекстовым + векторным (RAG) поиском и опциональным доступом в интернет через внешние MCP-серверы. | | **Ребрендинг** | Логотип / название приложения изменены с *Docmost* на *Gitmost*. | | **Компактное дерево страниц** | Отступ дерева страниц по умолчанию уменьшен с 16px до 8px на уровень вложенности. | +| **Сохранение состояния дерева страниц** | Дерево страниц в сайдбаре запоминает, какие узлы вы раскрыли/свернули, между перезагрузками — состояние хранится в браузере (localStorage) отдельно для каждой пары воркспейс + пользователь, чтобы аккаунты в одном браузере не пересекались. В оригинальном Docmost дерево сбрасывается при каждой перезагрузке. | | **CI / образы** | Release-CI публикует контейнерные образы в GHCR (`ghcr.io/vvzvlad/gitmost`) через встроенный `GITHUB_TOKEN` вместо Docker Hub. | ### Встроенный MCP-сервер diff --git a/apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts b/apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts index 3dd2d98b..9bbb7ff0 100644 --- a/apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts +++ b/apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts @@ -1,5 +1,49 @@ import { atom } from "jotai"; +import { + atomFamily, + atomWithStorage, + createJSONStorage, +} from "jotai/utils"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; export type OpenMap = Record; -export const openTreeNodesAtom = atom({}); +// Explicit synchronous localStorage so `getOnInit` resolves to the sync overload +// (the default storage is typed sync+async, which would widen the value type to +// `OpenMap | Promise` and break the functional-updater setter below). +const openTreeNodesStorage = createJSONStorage(() => localStorage); + +// One persisted open/closed map per (workspace, user). Scoping the localStorage +// key prevents accounts that share a browser origin from leaking tree state. +// `getOnInit: true` reads localStorage synchronously at atom init (not on mount), +// so the first render already has the saved state — no collapse-then-expand +// flicker on reload, and writes never run against an un-hydrated empty map. +const openTreeNodesFamily = atomFamily((scopeKey: string) => + atomWithStorage(`openTreeNodes:${scopeKey}`, {}, openTreeNodesStorage, { + getOnInit: true, + }), +); + +// Resolve the storage scope from the current user. Fall back to "anon" for the +// workspace/user parts when nothing is loaded yet (logged out / first paint). +const scopeKeyAtom = atom((get) => { + const currentUser = get(currentUserAtom); + const workspaceId = currentUser?.workspace?.id ?? "anon"; + const userId = currentUser?.user?.id ?? "anon"; + return `${workspaceId}:${userId}`; +}); + +// Public facade — same read value (OpenMap) and same setter shape (value OR +// functional updater) as the previous in-memory atom, but transparently routed +// to the localStorage-backed map for the current workspace/user. +export const openTreeNodesAtom = atom( + (get) => get(openTreeNodesFamily(get(scopeKeyAtom))), + (get, set, update: OpenMap | ((prev: OpenMap) => OpenMap)) => { + const target = openTreeNodesFamily(get(scopeKeyAtom)); + const next = + typeof update === "function" + ? (update as (prev: OpenMap) => OpenMap)(get(target)) + : update; + set(target, next); + }, +);