feat(client): persist page-tree open/closed state per workspace+user
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.
This commit is contained in:
@@ -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. |
|
| **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*. |
|
| **Rebranding** | App logo / name changed from *Docmost* to *Gitmost*. |
|
||||||
| **Compact page tree** | Default page-tree indentation reduced from 16px to 8px per nesting level. |
|
| **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. |
|
| **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
|
### Embedded MCP server
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
| **Чат с AI-агентом** | Встроенный чат с AI-агентом по содержимому вики, написанный с нуля как community-функция — без enterprise-лицензии. Агент читает и редактирует страницы от вашего имени (в рамках ваших прав), с полнотекстовым + векторным (RAG) поиском и опциональным доступом в интернет через внешние MCP-серверы. |
|
| **Чат с AI-агентом** | Встроенный чат с AI-агентом по содержимому вики, написанный с нуля как community-функция — без enterprise-лицензии. Агент читает и редактирует страницы от вашего имени (в рамках ваших прав), с полнотекстовым + векторным (RAG) поиском и опциональным доступом в интернет через внешние MCP-серверы. |
|
||||||
| **Ребрендинг** | Логотип / название приложения изменены с *Docmost* на *Gitmost*. |
|
| **Ребрендинг** | Логотип / название приложения изменены с *Docmost* на *Gitmost*. |
|
||||||
| **Компактное дерево страниц** | Отступ дерева страниц по умолчанию уменьшен с 16px до 8px на уровень вложенности. |
|
| **Компактное дерево страниц** | Отступ дерева страниц по умолчанию уменьшен с 16px до 8px на уровень вложенности. |
|
||||||
|
| **Сохранение состояния дерева страниц** | Дерево страниц в сайдбаре запоминает, какие узлы вы раскрыли/свернули, между перезагрузками — состояние хранится в браузере (localStorage) отдельно для каждой пары воркспейс + пользователь, чтобы аккаунты в одном браузере не пересекались. В оригинальном Docmost дерево сбрасывается при каждой перезагрузке. |
|
||||||
| **CI / образы** | Release-CI публикует контейнерные образы в GHCR (`ghcr.io/vvzvlad/gitmost`) через встроенный `GITHUB_TOKEN` вместо Docker Hub. |
|
| **CI / образы** | Release-CI публикует контейнерные образы в GHCR (`ghcr.io/vvzvlad/gitmost`) через встроенный `GITHUB_TOKEN` вместо Docker Hub. |
|
||||||
|
|
||||||
### Встроенный MCP-сервер
|
### Встроенный MCP-сервер
|
||||||
|
|||||||
@@ -1,5 +1,49 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
|
import {
|
||||||
|
atomFamily,
|
||||||
|
atomWithStorage,
|
||||||
|
createJSONStorage,
|
||||||
|
} from "jotai/utils";
|
||||||
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
|
|
||||||
export type OpenMap = Record<string, boolean>;
|
export type OpenMap = Record<string, boolean>;
|
||||||
|
|
||||||
export const openTreeNodesAtom = atom<OpenMap>({});
|
// 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<OpenMap>` and break the functional-updater setter below).
|
||||||
|
const openTreeNodesStorage = createJSONStorage<OpenMap>(() => 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<OpenMap>(`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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user