收口 MNote P0 P1 P2 审查尾项
- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目 - 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线 - 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径 验证: - cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1 - cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1 - git diff --check - git diff --cached --check - codegraph index . --force && codegraph status . - codegraph sync . && codegraph status .
This commit is contained in:
-242
@@ -1,242 +0,0 @@
|
||||
# 5-30 Sidebar 星标置顶快捷入口与 Scoped Explorer 设计 v1
|
||||
|
||||
> 状态:process
|
||||
> Owner:05-editor-mainline / 03-rust-web / control-plane
|
||||
> 背景:星标置顶需要对齐 Wolai 的 Sidebar 顶部快速访问体验,但 MNote 还需要支持把高频本地文件夹固定成 Explorer scoped root,避免每次进入大 workspace 时加载完整根目录。
|
||||
|
||||
## 目标
|
||||
|
||||
星标置顶不是文件或文件夹自身的属性,而是用户在 Sidebar 固定的一组快捷入口。
|
||||
|
||||
- 当前页面 `.md` 可以加入星标置顶,点击后直接打开该页面。
|
||||
- FileTree 文件夹右键可以加入星标置顶,点击后切到 Explorer,并把该文件夹作为临时 scoped root 显示。
|
||||
- 星标置顶按登录用户持久化到 SQLite control-plane;同一用户在不同设备登录时应看到同一组快捷入口。
|
||||
- 顶栏“全网公开”不能默认误导显示;没有真实公开分享状态时隐藏。
|
||||
- scoped Explorer 必须避免扫描完整大 workspace,例如 `/mnt/Data1T/mnote` 下只打开 `design/` 时只加载 `design/` 子树。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不把星标写入 Markdown frontmatter。
|
||||
- 不写入 `.mnote/starred-pins.json` 作为长期真相。
|
||||
- 不把文件夹变成 Resource Tree 的业务属性。
|
||||
- 不在前端 `localStorage` 中持久化最终星标真相;前端最多做加载态/乐观态缓存。
|
||||
- 不在本阶段实现跨设备文件内容同步;本设计只定义用户快捷入口在 control-plane 的一致性。
|
||||
|
||||
## 用户模型
|
||||
|
||||
星标置顶等价于“我的 Sidebar 快捷入口”:
|
||||
|
||||
- 页面快捷入口:`kind=page`,目标是某个 workspace 下的页面 identity。
|
||||
- 文件夹快捷入口:`kind=folder`,目标是某个 workspace 下的相对路径 scope。
|
||||
- 快捷入口属于用户,不属于文件夹,不影响其他用户。
|
||||
- 如果某台设备没有对应 workspace 或没有该 folder 相对路径,星标项保留但显示失效态,允许移除或重新绑定。
|
||||
|
||||
## SQLite 数据模型
|
||||
|
||||
新增 control-plane 表:`sidebar_shortcuts`。
|
||||
|
||||
建议字段:
|
||||
|
||||
```sql
|
||||
CREATE TABLE sidebar_shortcuts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
root_uri TEXT,
|
||||
kind TEXT NOT NULL, -- page | folder
|
||||
source_kind TEXT NOT NULL DEFAULT 'local_folder',
|
||||
target_id TEXT NOT NULL,
|
||||
relative_path TEXT,
|
||||
document_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(user_id, workspace_id, kind, target_id)
|
||||
);
|
||||
```
|
||||
|
||||
`target_id` 规范:
|
||||
|
||||
- 页面:优先 `document_id`,例如 `local-md:design%2FREADME.md`。
|
||||
- 文件夹:`local-dir:<encoded relative path>` 或稳定的 `local:folder:<relative path>` 归一化值。
|
||||
|
||||
`relative_path` 用于 scoped Explorer 与跨设备路径映射,必须保存 workspace 内相对路径,不保存绝对本机路径作为主键。
|
||||
|
||||
`root_uri` 必须作为显式字段保存和返回,而不是只塞在 `metadata_json` 中。原因:
|
||||
|
||||
- 星标文件夹点击必须直接消费保存时的 workspace root 上下文,不能从 `workspace_id` 字符串反推本机路径。
|
||||
- local workspace id 是派生标识,不是路径编码协议;用它反推 `file:///...` 会把 UI fallback 变成事实源。
|
||||
- `metadata_json.rootUri` 只作为兼容冗余字段,不能作为唯一来源。
|
||||
|
||||
长期跨设备映射仍以 `workspace_id + relative_path` 为主;当前设备打开本地 workspace 时必须把实际 `root_uri` 一起写入 shortcut,缺失时应显示失效/需重绑定状态,而不是自动猜测路径。
|
||||
|
||||
## API / 命令面
|
||||
|
||||
新增最小 API:
|
||||
|
||||
- `GET /api/sidebar/shortcuts?workspaceId=...`
|
||||
- 返回当前用户当前 workspace 的星标快捷入口。
|
||||
- `POST /api/sidebar/shortcuts`
|
||||
- upsert 快捷入口。
|
||||
- body 包含 `workspaceId/rootUri/kind/sourceKind/targetId/relativePath/documentId/title/icon`。
|
||||
- `DELETE /api/sidebar/shortcuts/:id`
|
||||
- 移除当前用户自己的快捷入口。
|
||||
|
||||
实现边界:
|
||||
|
||||
- API 只操作 control-plane 用户快捷入口表。
|
||||
- API 不写本地文件,不改 Markdown,不改 `.mnote` 元数据。
|
||||
- 写操作要求真实登录用户;dev fallback 不应静默创建跨用户快捷入口。
|
||||
- 每次写入追加 audit:`sidebar.shortcut.created` / `sidebar.shortcut.removed`。
|
||||
- 后续需要实时同步时,通过 `outbox_events` 或现有 WS delta 广播 `sidebar.shortcut.*`。
|
||||
|
||||
## Projection 与 Sidebar 渲染
|
||||
|
||||
`WorkspaceShellProjection.starred_items` 需要从 `documents[].is_starred` 迁移为 control-plane `sidebar_shortcuts` 投影。
|
||||
|
||||
投影项扩展:
|
||||
|
||||
- `id`
|
||||
- `title`
|
||||
- `kind: page | folder`
|
||||
- `href`
|
||||
- `workspaceId`
|
||||
- `sourceKind`
|
||||
- `targetId`
|
||||
- `relativePath`
|
||||
- `rootUri`
|
||||
- `documentId`
|
||||
- `active`
|
||||
- `unavailable`
|
||||
|
||||
页面快捷入口:
|
||||
|
||||
- `href` 指向当前页面打开 URL。
|
||||
- 点击后走现有 document open 主链。
|
||||
|
||||
文件夹快捷入口:
|
||||
|
||||
- `href` 可为当前页面 URL 加 query,例如 `?treeView=filetree&filetreeScope=design`。
|
||||
- 更推荐 runtime 拦截点击,调用 `openScopedFileTree(relativePath)`,避免不必要的页面跳转。
|
||||
- 点击后切换到 Explorer tab,并显示 scoped Explorer。
|
||||
|
||||
## Scoped Explorer 行为
|
||||
|
||||
文件夹星标点击后:
|
||||
|
||||
1. Sidebar 切到 Explorer tab。
|
||||
2. Explorer 标题显示 scoped 状态,例如 `Explorer / design`。
|
||||
3. FileTree root 只渲染该文件夹下的 children。
|
||||
4. 子目录继续按需加载。
|
||||
5. 提供“返回工作区根”入口;点击后恢复 root Explorer。
|
||||
|
||||
数据读取:
|
||||
|
||||
- scoped root 为 `relativePath=design` 时,调用现有 `load_local_folder_file_tree_children_snapshot(rootUri, "design")` 或对应 API。
|
||||
- 不调用完整 root snapshot。
|
||||
- 不预加载 scoped root 之外的兄弟目录。
|
||||
|
||||
URL / 状态:
|
||||
|
||||
- scoped 状态可以写入 URL query:`treeView=filetree&filetreeScope=design`。
|
||||
- 刷新页面后恢复 scoped Explorer。
|
||||
- 切回“我的页面”不清除 scope;点击“返回工作区根”才清除 scope。
|
||||
|
||||
## 顶栏星标与公开状态
|
||||
|
||||
顶栏星标按钮:
|
||||
|
||||
- 未星标:空心星;tooltip 对齐 Wolai:“点击 加入星标置顶,可在左侧边栏顶部快速访问”。
|
||||
- 已星标:高亮星;tooltip “取消星标置顶”。
|
||||
- 点击当前页面星标只操作 `sidebar_shortcuts`。
|
||||
|
||||
顶栏公开状态:
|
||||
|
||||
- `wolai-public-pill` 只有当当前页面存在 active share link / public permission 时显示。
|
||||
- 没有公开状态时隐藏,不显示“全网公开”。
|
||||
- 公开分享弹窗仍属于 share link / permission 领域,不与星标快捷入口混用。
|
||||
|
||||
## FileTree 右键菜单
|
||||
|
||||
文件夹行增加:
|
||||
|
||||
- 未星标:`加入星标置顶`
|
||||
- 已星标:`取消星标置顶`
|
||||
|
||||
启用条件:
|
||||
|
||||
- `sourceKind == local_folder`
|
||||
- `rowKind == folder | directory | index`
|
||||
- 当前用户对 workspace 有至少 read access;写入快捷入口只需要用户偏好写权限,不要求文件系统写权限。
|
||||
|
||||
不支持项:
|
||||
|
||||
- 普通附件文件暂不加入星标置顶。
|
||||
- 多选批量星标暂不做。
|
||||
|
||||
## 失效与重命名处理
|
||||
|
||||
MVP 处理:
|
||||
|
||||
- 文件夹被删除:快捷入口保留,显示失效态;点击提示“文件夹不存在”,允许移除。
|
||||
- 文件夹重命名:如果重命名由 MNote 发起,可同步更新对应 `relative_path/title/target_id`;如果外部重命名,先显示失效态。
|
||||
- 页面被删除:页面快捷入口显示失效态,允许移除。
|
||||
|
||||
长期可选:
|
||||
|
||||
- 引入 local object stable id 后,星标目标可由 stable object id 跟随重命名。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 无公开分享状态时,顶栏不显示“全网公开”。
|
||||
- 当前页面点击星标后,Sidebar 星标置顶出现该页面;再次点击移除。
|
||||
- 右键 `design/` 文件夹可加入星标置顶。
|
||||
- 点击 `design/` 星标后,切到 Explorer scoped root,只加载并显示 `design/` 下内容,不显示 workspace root 的其他大目录。
|
||||
- 刷新页面后,`filetreeScope=design` 能恢复 scoped Explorer。
|
||||
- 同一用户重新登录后,星标快捷入口仍存在。
|
||||
- 不同用户登录同一 workspace 时,星标列表互不污染。
|
||||
- 文件夹不存在时,星标项显示失效态并可移除。
|
||||
|
||||
## 测试计划
|
||||
|
||||
Rust/control-plane:
|
||||
|
||||
- migration 创建 `sidebar_shortcuts`。
|
||||
- store 可 upsert/list/delete 当前用户快捷入口。
|
||||
- unique 约束防止同一用户同一 workspace 重复固定同一目标。
|
||||
- 不同用户同一目标互不影响。
|
||||
|
||||
mnote-web:
|
||||
|
||||
- API 鉴权:只能读写当前用户自己的快捷入口。
|
||||
- `WorkspaceShellProjection` 从 shortcuts 投影 starred rows。
|
||||
- local folder scoped snapshot 不扫描完整 root。
|
||||
|
||||
浏览器 smoke:
|
||||
|
||||
- 顶栏公开 pill 隐藏断言。
|
||||
- 顶栏星标页面 add/remove。
|
||||
- FileTree 文件夹右键 add/remove。
|
||||
- 点击星标文件夹进入 scoped Explorer,并断言 root siblings 不渲染。
|
||||
- 刷新恢复 scoped Explorer。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. control-plane migration / model / store:新增 `sidebar_shortcuts`。
|
||||
2. mnote-web API:list/upsert/delete shortcuts。
|
||||
3. workspace shell projection:星标区改读 shortcuts,并保留历史 `is_starred` fixture 兼容测试。
|
||||
4. 顶栏:星标按钮接 API;公开 pill 改为条件显示。
|
||||
5. FileTree 右键菜单:文件夹加入/取消星标置顶。
|
||||
6. Scoped Explorer runtime:支持 `filetreeScope`,只加载 scoped children,并提供返回 root。
|
||||
7. smoke 与 Rust 测试补齐。
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 2026-05-26:星标置顶确定为用户级 Sidebar 快捷入口,不是文件夹/页面自身属性。
|
||||
- 2026-05-26:星标快捷入口必须写入 SQLite control-plane,满足同一用户跨设备一致显示。
|
||||
- 2026-05-26:文件夹星标点击进入 scoped Explorer,避免大 workspace root 扫描。
|
||||
-380
@@ -1,380 +0,0 @@
|
||||
# 5-31 页面设置 SQLite 状态收口设计 v1
|
||||
|
||||
> 状态:process
|
||||
>
|
||||
> Owner:05-editor-mainline / control-plane / mnote-web
|
||||
>
|
||||
> 更新时间:2026-05-26
|
||||
>
|
||||
> 背景:`隐藏本地 Markdown 文件标题` 在普通 local folder 中暴露出多状态覆盖问题。进一步复核后,问题不只属于该字段:当前页面设置同时散落在 `.mnote/page-options.json`、Page Aggregate runtime cache、浏览器 `localStorage` 和前端临时状态里,导致同一用户不同浏览器之间不能稳定同步,也让 local folder watcher / stale aggregate 更容易把 UI 状态恢复成旧值。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把页面设置与阅读偏好统一收口到 Rust SQLite control-plane,形成跨浏览器一致的状态来源。
|
||||
|
||||
- 用户在一个浏览器中修改页面显示设置后,另一个浏览器刷新或重新打开同一用户空间时能读到同一状态。
|
||||
- 普通 local folder 不再把页面设置长期写入项目目录下的 `.mnote/page-options.json`。
|
||||
- `hideTitleHeader` 不再按单个 Markdown 文件保存,改为按用户和 source family 保存:
|
||||
- `my_space`:我的空间 / 托管 local workspace
|
||||
- `external_local_folder`:其它本地文件夹
|
||||
- Page Aggregate 继续作为前端读取页面运行态的统一入口,但其 `layout.pageOptions` 应是 SQLite 偏好合并后的有效值,而不是前端本地缓存或 sidecar 文件的直接回声。
|
||||
- 前端页面设置 runtime 不再直接管理多个长期状态来源,只负责乐观更新、失败回滚和重新拉取。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 本阶段不改变 Markdown 正文真相;本地 `.md` 仍是正文事实源。
|
||||
- 不把 UI 偏好写入 Markdown frontmatter。
|
||||
- 不用 `.mnote/page-options.json` 作为长期写入目标;它只保留 legacy read fallback。
|
||||
- 不把所有字段无脑变成全局开关。SQLite 是存储层,字段仍需按语义选择作用域。
|
||||
- 不在本阶段实现多人协作共享页面设置。当前目标是“同一用户跨浏览器同步”。
|
||||
|
||||
## 3. 当前状态盘点
|
||||
|
||||
### 3.1 前端页面设置字段
|
||||
|
||||
`sidebar-page-settings-runtime.js` 当前默认字段:
|
||||
|
||||
| 字段 | 当前用途 | 当前长期状态来源 | 问题 |
|
||||
| --- | --- | --- | --- |
|
||||
| `wideLayout` | 主内容列宽 | Page Aggregate / `.mnote/page-options.json` / compat command | local folder 写入项目目录;跨浏览器不稳定 |
|
||||
| `smallText` | 正文小字体 | Page Aggregate / `.mnote/page-options.json` | 同上 |
|
||||
| `layoutDensity` | 段落密度 | Page Aggregate / `.mnote/page-options.json` | 同上 |
|
||||
| `pageFont` | 页面字体 | Page Aggregate runtime 字段 | 当前更像用户阅读偏好,不应是文件属性 |
|
||||
| `showHeadingNumbers` | 标题编号 | `localStorage` 强制覆盖 | 只能单浏览器生效 |
|
||||
| `hideTitleHeader` | 本地 Markdown 页头标题显隐 | Page Aggregate / `.mnote/page-options.json` / 前端竞态保护 | 不应按文件保存,容易被 stale aggregate 覆盖 |
|
||||
| `showToc` | 目录面板 | PageOptions 字段,当前待接线 | 语义更像用户视图偏好 |
|
||||
| `showStructure` | 结构显示 | PageOptions 字段,当前未完整接线 | 语义更像用户视图偏好 |
|
||||
| `showWordCount` | 字数统计 | PageOptions 字段 | 语义更像用户视图偏好 |
|
||||
| `collapseBacklinks` | 折叠反链 | PageOptions 字段,当前待接线 | 语义更像用户视图偏好 |
|
||||
| `hideChildPages` | 隐藏子页面 | PageOptions 字段,当前待接线 | 语义可能是用户视图偏好 |
|
||||
| `showBlockRefCount` | 块引用数字 | PageOptions 字段,当前待接线 | 语义更像用户视图偏好 |
|
||||
| `protectEditing` | 编辑保护 | PageOptions 字段,当前待接线 | 语义可能是页面策略,不应直接归入用户偏好 |
|
||||
| `embedDefaultBlockId` | 嵌入默认块 | PageOptions 字段 | 语义更像页面/嵌入属性,不是纯 UI 偏好 |
|
||||
|
||||
### 3.2 后端与协议现状
|
||||
|
||||
- `core-protocol::PageOptions` 当前把页面布局、阅读偏好、编辑策略和嵌入属性都放在一个结构里。
|
||||
- `mnote-web` 的 local folder 写链是 `POST /api/documents/options -> update_local_page_options -> .mnote/page-options.json`。
|
||||
- local folder aggregate 构建会读取 `.mnote/page-options.json`,没有值时用“托管工作区显示标题、普通本地文件夹隐藏标题”的默认规则。
|
||||
- 非 local folder 仍走 `page.layout.updateOptions` compat command。
|
||||
- `showHeadingNumbers` 已经被前端单独放进 `localStorage`,说明当前系统事实上承认存在“用户级 UI 偏好”,但没有 SQLite-backed 实现。
|
||||
|
||||
## 4. 核心判断
|
||||
|
||||
页面设置需要拆成两类概念:
|
||||
|
||||
1. **用户视图偏好**:同一用户在不同浏览器应该一致,但不应改变文件、页面正文或其他用户视图。
|
||||
2. **页面共享属性 / 策略**:属于页面或工作区事实,可能影响所有用户,必须与权限、分享和协作边界一起设计。
|
||||
|
||||
当前暴露问题的字段基本属于第 1 类。它们应进入 SQLite control-plane 的用户偏好层,而不是继续写 local folder sidecar。
|
||||
|
||||
## 5. 推荐方案
|
||||
|
||||
### 5.1 新增 SQLite 用户偏好表
|
||||
|
||||
新增 control-plane migration,例如 `004-user-ui-preferences.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_ui_preferences (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
source_kind TEXT,
|
||||
scope_kind TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(user_id, workspace_id, source_kind, scope_kind, scope_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_ui_preferences_lookup
|
||||
ON user_ui_preferences(user_id, workspace_id, source_kind, scope_kind, scope_id);
|
||||
```
|
||||
|
||||
`scope_kind` 建议取值:
|
||||
|
||||
| scope_kind | scope_id 示例 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `global` | `default` | 同用户全局偏好 |
|
||||
| `source_family` | `my_space` / `external_local_folder` | 同一类数据源的默认显示策略 |
|
||||
| `workspace` | `ws_xxx` 或 `local:_mnt_Data1T_mnote` | 单工作区偏好 |
|
||||
| `document` | `local-md:README.md` | 保留能力,MVP 不用于 `hideTitleHeader` |
|
||||
|
||||
### 5.2 字段归属
|
||||
|
||||
MVP 先迁移当前已接通且影响 UI 的字段:
|
||||
|
||||
| 字段 | SQLite scope | 理由 |
|
||||
| --- | --- | --- |
|
||||
| `hideTitleHeader` | `source_family` | 用户明确希望只区分“我的空间”和“其它本地文件夹”,不按文件保存 |
|
||||
| `showHeadingNumbers` | `global` 或 `workspace` | 当前 localStorage 字段,迁 SQLite 后才能跨浏览器同步 |
|
||||
| `wideLayout` | `workspace`,可 fallback 到 `global` | 阅读布局偏好,通常用户对某类工作区有稳定习惯 |
|
||||
| `smallText` | `workspace`,可 fallback 到 `global` | 同上 |
|
||||
| `layoutDensity` | `workspace`,可 fallback 到 `global` | 同上 |
|
||||
| `pageFont` | `workspace`,可 fallback 到 `global` | 字体是用户阅读偏好,不是 Markdown 文件属性 |
|
||||
| `showToc` | `workspace`,可 fallback 到 `global` | 目录面板属于用户视图 |
|
||||
| `collapseBacklinks` | `workspace`,可 fallback 到 `global` | 反链面板展示偏好 |
|
||||
| `showWordCount` | `global` | 统计展示偏好 |
|
||||
| `hideChildPages` | `workspace` | 页面树/子页面展示偏好 |
|
||||
| `showBlockRefCount` | `workspace` | 块引用计数展示偏好 |
|
||||
|
||||
暂不迁入用户偏好的字段:
|
||||
|
||||
| 字段 | 处理 |
|
||||
| --- | --- |
|
||||
| `protectEditing` | 暂停作为可写 UI 偏好;后续进入页面策略 / 权限设计 |
|
||||
| `embedDefaultBlockId` | 保留在 Page Aggregate / 页面属性合同中;不作为用户偏好 |
|
||||
|
||||
### 5.3 默认值与合并顺序
|
||||
|
||||
页面打开时,后端合并出 effective options:
|
||||
|
||||
```text
|
||||
协议默认值
|
||||
-> 用户 global 偏好
|
||||
-> 用户 source_family 偏好
|
||||
-> 用户 workspace 偏好
|
||||
-> 用户 document 偏好(MVP 只保留能力,不主动使用)
|
||||
-> legacy .mnote/page-options.json fallback(只在 SQLite 完全没有对应值时生效)
|
||||
```
|
||||
|
||||
关键规则:
|
||||
|
||||
- SQLite 值永远优先于 `.mnote/page-options.json`。
|
||||
- `.mnote/page-options.json` 只读,不再由页面设置写链更新。
|
||||
- `hideTitleHeader` 的默认值仍保持当前体验:
|
||||
- `my_space` 默认 `false`
|
||||
- `external_local_folder` 默认 `true`
|
||||
- 一旦用户修改 `hideTitleHeader`,写入对应 `source_family` scope,之后所有同类页面使用该值。
|
||||
|
||||
## 6. API 设计
|
||||
|
||||
新增最小 API:
|
||||
|
||||
### 6.1 读取有效页面偏好
|
||||
|
||||
`GET /api/ui/preferences/effective`
|
||||
|
||||
Query:
|
||||
|
||||
- `workspaceId`
|
||||
- `sourceKind`
|
||||
- `rootUri`
|
||||
- `documentId`
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"result": {
|
||||
"scope": {
|
||||
"sourceFamily": "external_local_folder",
|
||||
"workspaceId": "local:_mnt_Data1T_mnote",
|
||||
"documentId": "local-md:README.md"
|
||||
},
|
||||
"pageOptions": {
|
||||
"wideLayout": false,
|
||||
"smallText": false,
|
||||
"layoutDensity": "normal",
|
||||
"pageFont": "default",
|
||||
"showHeadingNumbers": false,
|
||||
"hideTitleHeader": true
|
||||
},
|
||||
"sources": {
|
||||
"hideTitleHeader": "source_family",
|
||||
"showHeadingNumbers": "global"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 写入偏好
|
||||
|
||||
`PUT /api/ui/preferences`
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"workspaceId": "local:_mnt_Data1T_mnote",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": "file:///mnt/Data1T/mnote",
|
||||
"documentId": "local-md:README.md",
|
||||
"updates": {
|
||||
"hideTitleHeader": false,
|
||||
"layoutDensity": "compact"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
后端根据字段决定 scope,不信任前端直接指定 scope:
|
||||
|
||||
- `hideTitleHeader` 写入 `source_family`
|
||||
- `showHeadingNumbers` 写入 `global`
|
||||
- `wideLayout/smallText/layoutDensity/pageFont/showToc/...` 写入 `workspace`
|
||||
|
||||
返回同一份 effective preferences,前端用返回值刷新当前 shell。
|
||||
|
||||
## 7. Page Aggregate 合同调整
|
||||
|
||||
Page Aggregate 继续输出:
|
||||
|
||||
- `layout.pageOptions`
|
||||
- `layout_options`
|
||||
|
||||
但含义调整为:
|
||||
|
||||
> `layout.pageOptions` 是后端合并后的 effective UI options。
|
||||
|
||||
为避免后续再混淆,建议追加调试字段:
|
||||
|
||||
```json
|
||||
"layout": {
|
||||
"pageOptions": {},
|
||||
"pageOptionsSource": {
|
||||
"wideLayout": "workspace",
|
||||
"hideTitleHeader": "source_family",
|
||||
"showHeadingNumbers": "global"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
协议层可分两步:
|
||||
|
||||
1. MVP:保持 `PageOptions` 结构不变,只改变读取与写入来源。
|
||||
2. 后续:把 `PageOptions` 拆为 `effectiveUiOptions` 与 `pagePolicyOptions`,再处理 `protectEditing/embedDefaultBlockId`。
|
||||
|
||||
## 8. 前端 runtime 调整
|
||||
|
||||
`sidebar-page-settings-runtime.js` 的长期职责应简化为:
|
||||
|
||||
- 从 `__MNOTE_PAGE_AGGREGATE__` 读取 effective options 初始化 UI。
|
||||
- 用户修改设置时调用 `PUT /api/ui/preferences`。
|
||||
- 用返回的 effective options 更新当前 shell 和 aggregate script。
|
||||
- 不再写 `localStorage` 保存 `showHeadingNumbers`。
|
||||
- 不再把 local folder options 写入 `/api/documents/options`。
|
||||
- 不再需要 `data-mnote-page-options-local-write-at` 这类 stale aggregate 竞态补丁作为长期机制。
|
||||
|
||||
`/api/documents/options` 的定位调整:
|
||||
|
||||
- 保留为 cloud/compat 或未来页面策略命令入口。
|
||||
- local folder 的 UI 偏好写入不再走这里。
|
||||
- 如果 body 中包含 `protectEditing/embedDefaultBlockId` 这类非用户偏好字段,再进入页面策略链路;MVP 可以保持 disabled。
|
||||
|
||||
## 9. local folder sidecar 兼容策略
|
||||
|
||||
`.mnote/page-options.json` 保留读兼容:
|
||||
|
||||
- 读取时只作为 fallback。
|
||||
- 写入时不再更新。
|
||||
- 如果同一字段在 SQLite 已存在,以 SQLite 为准。
|
||||
- 不自动删除用户已有 sidecar 文件,避免破坏历史证据。
|
||||
|
||||
建议后续提供显式迁移工具:
|
||||
|
||||
```bash
|
||||
node scripts/migrate-local-page-options-to-sqlite.js --root /path/to/root --actor mnote-e2e
|
||||
```
|
||||
|
||||
迁移工具只在用户明确执行时运行。
|
||||
|
||||
## 10. 实施切片
|
||||
|
||||
### Phase A:SQLite 偏好底座
|
||||
|
||||
- 新增 `user_ui_preferences` migration。
|
||||
- control-plane model/store/sqlite 增加 upsert/list/effective merge 方法。
|
||||
- 增加单测覆盖 scope 优先级与 user 隔离。
|
||||
|
||||
验收:
|
||||
|
||||
- 同一用户同一 key 在不同 scope 下可合并。
|
||||
- 不同用户互不影响。
|
||||
- migration idempotent。
|
||||
|
||||
### Phase B:mnote-web API 与 Page Aggregate 合并
|
||||
|
||||
- 新增 `/api/ui/preferences/effective` 与 `PUT /api/ui/preferences`。
|
||||
- local folder aggregate 构建时合并 SQLite preferences。
|
||||
- `hideTitleHeader` 默认策略改为 source_family 默认值。
|
||||
- `.mnote/page-options.json` 只作为 fallback。
|
||||
|
||||
验收:
|
||||
|
||||
- 普通 local folder 默认隐藏标题。
|
||||
- 我的空间默认显示标题。
|
||||
- SQLite 写入后优先于 sidecar。
|
||||
|
||||
### Phase C:前端页面设置写链迁移
|
||||
|
||||
- `sidebar-page-settings-runtime.js` 改写设置保存 API。
|
||||
- 删除 `showHeadingNumbers` 的 localStorage 长期真相。
|
||||
- `persistPageOptionsPatch` 用后端返回 effective options 更新 UI。
|
||||
- 保留失败回滚。
|
||||
|
||||
验收:
|
||||
|
||||
- 一个浏览器修改设置,另一个浏览器刷新后同步。
|
||||
- 切换文档、local folder watcher refresh 后不恢复旧状态。
|
||||
- `hideTitleHeader` 对同类 source 生效,不按单个文件漂移。
|
||||
|
||||
### Phase D:兼容与清理
|
||||
|
||||
- local folder `/api/documents/options` 不再写 `.mnote/page-options.json`,或只保留 legacy endpoint 并标记 deprecated。
|
||||
- 移除上一轮针对 per-file sidecar 竞态的长期补丁,只保留必要的 aggregate script 同步事件。
|
||||
- 更新 `hermes_tools::page` 中页面 options 写入语义,避免 AI tool 写 UI 偏好到 sidecar。
|
||||
|
||||
验收:
|
||||
|
||||
- 不再产生新的 `.mnote/page-options.json` 写入。
|
||||
- 旧 sidecar 存在时不破坏读取。
|
||||
- CodeGraph 可清楚定位 UI preference API、control-plane store、Page Aggregate merge 三个边界。
|
||||
|
||||
## 11. Smoke / 测试计划
|
||||
|
||||
新增或更新 smoke:
|
||||
|
||||
| Smoke | 断言 |
|
||||
| --- | --- |
|
||||
| `task493-page-settings-sqlite-preferences-smoke.js` | 页面设置写入 SQLite,刷新后保持 |
|
||||
| `task494-page-settings-cross-browser-sync-smoke.js` | 同一用户两个 browser context 同步页面设置 |
|
||||
| `task495-local-title-source-family-smoke.js` | `my_space` 与 `external_local_folder` 的 `hideTitleHeader` 独立 |
|
||||
| `task496-page-options-sidecar-readonly-compat-smoke.js` | 旧 `.mnote/page-options.json` 只读 fallback,不再被写入 |
|
||||
|
||||
Rust 单测:
|
||||
|
||||
- control-plane migration / store merge
|
||||
- mnote-web preference API route
|
||||
- local folder aggregate merge preference
|
||||
- Page Aggregate effective options source 标记
|
||||
|
||||
当前实施记录(2026-05-26):
|
||||
|
||||
- 已完成 Phase A/B/C 最小闭环:`user_ui_preferences` migration/store、`/api/ui/preferences`、Page Aggregate SQLite 合并、前端页面设置写链迁移。
|
||||
- 已完成 Phase D 的旧写链收口:local folder `/api/documents/options` 与 `mnote.page.update_options` 均不再写 `.mnote/page-options.json`。
|
||||
- 已新增 `task493-page-settings-sqlite-preferences-smoke.js`,覆盖两个 browser context 的同用户同步、不同用户隔离和不生成 sidecar。
|
||||
- 已更新旧 smoke 的页面设置写链断言,从 `/api/documents/options` UI 主链改为 `/api/ui/preferences`;`/api/documents/options` 仅保留兼容入口。
|
||||
|
||||
## 12. 风险与边界
|
||||
|
||||
- 如果把所有字段都做成 user preference,未来协作页面的共享设置会缺位。因此必须保留“用户偏好”和“页面策略”两类边界。
|
||||
- 如果继续让 Page Aggregate 暴露字段但不标注来源,后续仍可能误以为它们是页面事实源。建议尽快追加 `pageOptionsSource`。
|
||||
- 对 local folder 的旧 sidecar 只能读兼容,不能自动删除。删除或批量迁移需要用户显式确认。
|
||||
- 当前工作区已有 sidebar shortcuts / control-plane 未提交改动;实施时需要先确认是否基于那些改动继续,避免 migration 编号冲突。
|
||||
|
||||
## 13. 推荐下一步
|
||||
|
||||
先做 Phase A + B 的最小闭环:
|
||||
|
||||
1. 在当前 control-plane migration 链上新增 `user_ui_preferences`。
|
||||
2. 增加 effective preference merge API。
|
||||
3. 只迁移 `hideTitleHeader` 与 `showHeadingNumbers` 两个问题最明确的字段。
|
||||
4. 通过跨浏览器 smoke 验证后,再迁移 `wideLayout/smallText/layoutDensity/pageFont`。
|
||||
|
||||
这样可以先解决“状态恢复原样”和“不同浏览器不同步”两个根问题,同时控制改动范围。
|
||||
@@ -1,550 +0,0 @@
|
||||
# 5-32 FileTree 大目录懒加载与展开状态设计 v1
|
||||
|
||||
> 状态:process
|
||||
> Owner:05-editor-mainline / 03-rust-web
|
||||
> 背景:用户在 localhost 打开 `mnote/design` 及其子目录时仍感到加载慢,并观察到“全部加载完又折叠,再点很快”。上一版仅靠恢复展开状态不够,因为它没有消除慢请求、重复请求和整树替换。
|
||||
|
||||
## 结论
|
||||
|
||||
当前问题不能只用“加载后恢复展开”解决。正确模型应对齐 VS Code/Sidex Explorer 与 MUI X lazy tree 的三个原则:
|
||||
|
||||
1. 同一个 `rootUri + parentRelativePath` 的 children 请求必须有 in-flight 去重和结果缓存。
|
||||
2. refresh/watch/local command 只刷新受影响 parent,不整棵替换已展开子树。
|
||||
3. 后端 projection 应一次扫描生成 children、`childrenCount/expandable` 和 revision,避免为了 child_count/watch revision 重复读目录。
|
||||
|
||||
## 2026-05-27 页面树 / 文件树打开性能纠偏方案
|
||||
|
||||
### 根因判断
|
||||
|
||||
本轮纠偏不再把“隐藏页面树”作为性能策略。页面树必须继续可见,且星标 scoped folder 打开后必须用本地 Markdown 页面树投影替换旧“我的空间”页面树。
|
||||
|
||||
当前卡顿的核心是:`页面树可见` 被实现成 `打开文件树 / 星标文件夹 / 文档前同步完成全量页面树 projection`。即使在 localhost,这也会把全盘 Markdown 扫描、页面树 DOM 替换和 editor mount 串到同一条打开链路上。
|
||||
|
||||
已确认的高风险点:
|
||||
|
||||
- `gateway.rs` 本地 root 入口已经读取一次 `load_local_folder_page_tree_snapshot(root_uri)`,后续 `render_local_sidebar_tree_html(...)` 仍可能再次触发同一 root 的页面树扫描。
|
||||
- `sidebar-tree-runtime.js` 星标文件夹打开必须并行请求 `/api/tree/projections/sidebar` 与 scoped `/api/tree/projections/file`;scoped FileTree 应先可交互,慢页面树只负责替换旧页面树。
|
||||
- `sidebar-tree-live-apply-runtime.js` 不应把 `renderSidebarSnapshot(...)` 写成 page tree 与 file tree 同步整块应用;二者需要独立 generation / stale guard。
|
||||
- Page Aggregate / editor runtime 已经与 runtime asset 并行,但如果 workspace shell 先同步重建页面树,editor mount 仍会被前置 shell 构建拖慢。
|
||||
|
||||
### Sidex / Context7 对齐原则
|
||||
|
||||
- Sidex / VSCode Explorer 使用 `AsyncDataTree` 按需 resolve children,慢 children 进入 slow/loading state,不把整个树同步塞进 DOM。
|
||||
- Explorer refresh 按受影响 parent、view state 和 request promise 管理,而不是 root projection 返回后整棵替换。
|
||||
- 浏览器侧参考 MDN:用 `PerformanceObserver` 记录 long task / long animation frame;用 `AbortController` 或 request generation 丢弃过期 fetch;避免大段同步 DOM insertion 阻塞主线程。
|
||||
- Tokio 侧 `spawn_blocking` 只能避免阻塞 async worker,不能消除全盘扫描成本;本地 projection 仍需要减少触发次数、限并发、缓存和局部化。
|
||||
|
||||
### 新策略
|
||||
|
||||
1. 页面树保留,但从打开关键路径降级为“可见的异步 projection”:
|
||||
- SSR 可以输出已有/轻量页面树壳。
|
||||
- scoped filetree 先应用并可点击。
|
||||
- page tree projection 完成后只替换 `#sidebar-tree-root`。
|
||||
- 旧“我的空间”页面树必须在本地 rootUri 确认后标记 stale/loading,不能继续被当成当前页面树。
|
||||
|
||||
2. FileTree / PageTree projection 拆开应用:
|
||||
- `applyFileProjection(...)` 和 `applyPageProjection(...)` 独立 generation。
|
||||
- scope/rootUri 切换时旧请求自然失效。
|
||||
- 页面树慢请求返回后,不得覆盖新的 scoped filetree state。
|
||||
|
||||
3. gateway SSR 去重:
|
||||
- 同一请求内复用已加载的 `page_tree_snapshot`。
|
||||
- 禁止 `load_local_folder_page_tree_snapshot` 和 `render_local_sidebar_tree_html` 对同一 root 重复扫描。
|
||||
- Page Aggregate/editor mount 不等待 sidebar root 重建。
|
||||
|
||||
4. 本地 PageTree 进入 lazy / cached projection:
|
||||
- 短期:按 `rootUri + watchRevision` 做 page tree snapshot cache / stale-while-revalidate。
|
||||
- 中期:PageTree 改为顶层 + active/reveal path projection,不再每次全量递归 Markdown。
|
||||
|
||||
5. 观测与验收必须落到真实浏览器:
|
||||
- 记录 projection request start/end、filetree interactive time、Page Aggregate TTFB、editor ready、longtask count。
|
||||
- localhost 打开目标 Markdown 时,不能因页面树 projection 重建而阻塞 editor surface。
|
||||
- 必须包含一条模拟用户手动操作的 Playwright/Chromium 测试:从首页进入本地 workspace,点击星标文件夹或文件树节点,逐级导航到目标 Markdown,而不是只用 `context.request` 或直接访问最终 URL。
|
||||
|
||||
### 待执行 Checklist
|
||||
|
||||
- [x] 扩展 `task492-sidebar-starred-shortcuts-smoke.js`:人为延迟 `/api/tree/projections/sidebar`,断言 scoped `/api/tree/projections/file` 先完成并可见;旧“我的空间”行消失,本地 Markdown 页面树稍后出现。
|
||||
- [x] 新增 `task497-local-page-tree-filetree-open-performance-smoke.js`:用真实 Chromium 从首页进入本地 workspace,点击星标 `design` 文件夹,再通过 Explorer 逐级打开 `design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md`;记录 sidebar/filetree/page aggregate/editor ready 时序、network/resource timing、long task,并保存打开前后截图。
|
||||
- [x] `task497` 不允许只用 `context.request.fetch` 或直接 `page.goto(最终文档 URL)` 作为主验收;可以用 direct request 作为对照指标,但真实验收必须来自 UI 点击路径。
|
||||
- [x] `task497` 断言打开过程中旧“我的空间”页面树不会长期残留、scoped FileTree 先可交互、editor surface 可见,且目标 md 打开不会因为 page tree projection 全量重建而明显延迟。
|
||||
- [x] 新增 `task498-starred-page-tree-scope-and-local-edit-smoke.js`:覆盖星标 scoped folder 打开后 PageTree 使用同一 scope,且本地 Markdown 输入 `111` 后经 `/api/page-body/write` 保存,切页再回不丢内容。
|
||||
- [x] `gateway.rs` 本地 root 分支复用已加载的 page tree snapshot,避免同一请求重复调用 `load_local_folder_page_tree_snapshot(root_uri)`。
|
||||
- [x] `sidebar-tree-runtime.js` 星标文件夹打开改为并行请求 sidebar projection 与 scoped file projection;file projection 可以先应用,sidebar projection 后补页面树。
|
||||
- [x] `sidebar-tree-live-apply-runtime.js` 拆出 page/file projection 独立 apply,并为二者加入 generation / stale guard。
|
||||
- [x] 为 scoped filetree 打开链路加入过期请求丢弃:旧 scope/rootUri 的 projection 结果不得 patch 当前 DOM。
|
||||
- [x] 对 page tree 大 DOM 替换加分片或 idle/yield,避免同步 `innerHTML` / `replaceChildren` 产生长任务。
|
||||
- [x] 后端补本地 page tree snapshot cache 或 watchRevision stale-while-revalidate,降低同一 root 高频重复扫描。
|
||||
- [x] 验证:`node scripts/task492-sidebar-starred-shortcuts-smoke.js`、`node scripts/task494-filetree-lazy-loading-dedup-smoke.js`、新增 task497、相关 Rust filters、`git diff --check`、`codegraph sync/status`。
|
||||
|
||||
## Context7 参考结论
|
||||
|
||||
Context7 查询结果:
|
||||
|
||||
- VS Code `DataTree` / `AsyncDataTree` 面向“数据懒发现”的树,例如文件浏览器。数据源用 `hasChildren(element)` 和 `getChildren(element)` 异步解析 children,tree 组件负责并发 refresh 管理和 slow loading 状态。
|
||||
- MUI X `RichTreeViewPro` lazy loading 要求 `dataSource.getChildrenCount()` 与 `dataSource.getTreeItems()`,并建议通过 `dataSourceCache` 缓存 children 数据;大数据场景还需要 request throttle,避免用户快速操作时打爆服务端。
|
||||
|
||||
映射到 MNote:
|
||||
|
||||
- `parentRelativePath` 就是 tree item identity。
|
||||
- `child_count/expandable` 就是 `getChildrenCount` 的最小版本。
|
||||
- `fileTreeLazyChildrenCache` 不能只是渲染后的临时兜底,应该成为 FileTreeDataSource cache。
|
||||
- `refreshLocalFolderSidebarSnapshot()` 不能绕过 cache 直接 `tree.innerHTML = ...`。
|
||||
|
||||
## CodeGraph / Sidex 对照
|
||||
|
||||
已用 CodeGraph 索引确认:
|
||||
|
||||
- Sidex 项目索引存在:`reference-code/sidex-main`,约 2904 files / 124438 nodes。
|
||||
- MNote 项目索引存在:`/mnt/Data1T/mnote`,约 382 files / 9520 nodes。
|
||||
|
||||
Sidex 关键链路:
|
||||
|
||||
- `reference-code/sidex-main/src/vs/base/browser/ui/tree/asyncDataTree.ts`
|
||||
- `refreshNode` 会检查 `subTreeRefreshPromises`,相交 refresh 会复用已有 promise。
|
||||
- collapsed node refresh 时只标记 `stale` 并清 children,不立即解析子树。
|
||||
- `doGetChildren` 用 `refreshPromises` 对同一 node 的 `getChildren` 去重。
|
||||
- 异步 children 超过 800ms 会进入 slow state,由 UI 显示 loading。
|
||||
- `reference-code/sidex-main/src/vs/workbench/contrib/files/browser/views/explorerView.ts`
|
||||
- `refresh(recursive, item)` 刷新指定 item,默认不是整棵 DOM 替换。
|
||||
- `setTreeInput` 读取/保存 view state,不把展开状态绑在一次 DOM 结果上。
|
||||
- `selectResource` 显式展开 parent chain,用于 reveal,而不是预加载整棵树。
|
||||
- `reference-code/sidex-main/src/vs/workbench/contrib/files/browser/explorerService.ts`
|
||||
- 文件 create/copy/move 事件刷新 parent item。
|
||||
- parent 未 resolved 时先 resolve parent,再 refresh parent。
|
||||
|
||||
MNote 当前链路:
|
||||
|
||||
- `rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js`
|
||||
- `renderFileProjection()` 会直接替换 `#sidebar-file-tree-root.innerHTML`,已展开子树 DOM 会丢失。
|
||||
- `refreshLocalFolderSidebarSnapshot()` 同时拉 sidebar projection 和 file projection,随后整树渲染。
|
||||
- `loadFileTreeChildren()` 只用 row 上的 `data-filetree-children-loading` 做加载中状态;如果 refresh 替换 DOM,loading 标记会丢。
|
||||
- `fileTreeLazyChildrenCache` 只在请求成功后写入,缺少 in-flight promise 去重。
|
||||
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
|
||||
- `load_local_folder_file_tree_scope_snapshot()` 调 `scan_directory()`。
|
||||
- `scan_directory()` 读取当前目录后,对每个子目录调用 `has_visible_child()` 再读一次该子目录。
|
||||
- 之后又调用 `local_folder_watch_revision_for_directory()` 再读一次当前目录生成 revision。
|
||||
- `rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs`
|
||||
- SSR filetree row 目前缺少 `data-local-relative-path`,JS renderer 有该属性;这导致首屏 SSR 与后续 JS patch 数据属性不一致。
|
||||
|
||||
## 实测证据
|
||||
|
||||
在当前 3000 登录态下,直接访问:
|
||||
|
||||
```text
|
||||
/?workspaceId=local:_mnt_Data1T_mnote&sourceKind=local_folder&rootUri=file:///mnt/Data1T/mnote&treeView=filetree&fileTreeScope=design
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- SSR 首屏无 `/api/tree/projections/file` 网络请求。
|
||||
- `#sidebar-file-tree-root` 显示 `design/` 子项 17 行。
|
||||
- `documentElement` 未设置 `data-mnote-filetree-scope`,但 URL 有 `fileTreeScope=design`。
|
||||
- 首屏 row 的 `data-local-relative-path` 为空,rowId 为 `local:folder:design/03-rust-web`。
|
||||
|
||||
展开 `design/03-rust-web`:
|
||||
|
||||
```text
|
||||
GET /api/tree/projections/file/children?...&parentRelativePath=design/03-rust-web
|
||||
status=200
|
||||
time=14ms
|
||||
size=3119 bytes
|
||||
children=done/process/reference
|
||||
```
|
||||
|
||||
这说明本机此子目录单次后端请求并不慢。用户感知慢更可能来自:
|
||||
|
||||
- 某些更大的目录触发多次 children 请求或 watch refresh。
|
||||
- refresh 整树替换导致已加载 children 丢失,看起来像“加载完又折叠”。
|
||||
- 首屏 SSR / JS renderer 属性不一致,导致 lazy loader 依赖 rowId fallback。
|
||||
- watcher/local command refresh 与用户展开请求并发,造成同一路径重复请求或请求完成后 DOM 被替换。
|
||||
|
||||
## 目标
|
||||
|
||||
- 打开 scoped folder 后只显示该 scope 的直接 children,不加载 workspace root siblings。
|
||||
- 展开某个目录时,同一 `rootUri + parentRelativePath` 在 in-flight 期间最多一个网络请求。
|
||||
- 请求完成后不因 watch refresh / command refresh 折叠已展开子树。
|
||||
- 第一次展开慢目录时显示 loading 状态;第二次展开命中 cache,不发网络请求。
|
||||
- 创建/重命名/删除只刷新受影响 parent,不重刷整棵 filetree。
|
||||
- SSR 与 JS 渲染输出同一套 row data attributes。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不在本阶段实现虚拟滚动。
|
||||
- 不引入 React/MUI TreeView;只吸收 lazy tree 的数据源/cache模型。
|
||||
- 不重写 Rust kernel 树协议。
|
||||
- 不改变 local-first 文件系统事实源。
|
||||
|
||||
## 新 FileTreeDataSource 模型
|
||||
|
||||
在 `sidebar-tree-live-apply-runtime.js` 中收口为浏览器端 FileTreeDataSource:
|
||||
|
||||
```js
|
||||
const fileTreeState = {
|
||||
rootUri: '',
|
||||
scope: '',
|
||||
rowsByParent: new Map(), // key: parentRelativePath -> rows[]
|
||||
loadedParents: new Set(), // loaded parentRelativePath
|
||||
loadingParents: new Map(), // parentRelativePath -> Promise
|
||||
expandedParents: new Set(), // parentRelativePath
|
||||
dirtyParents: new Set(), // parentRelativePath
|
||||
revisionByParent: new Map(), // parentRelativePath -> watchRevision
|
||||
};
|
||||
```
|
||||
|
||||
Key 规则:
|
||||
|
||||
```js
|
||||
function fileTreeParentKey(rootUri, parentRelativePath) {
|
||||
return String(rootUri || '').trim() + '\n' + String(parentRelativePath || '').trim();
|
||||
}
|
||||
```
|
||||
|
||||
请求规则:
|
||||
|
||||
```js
|
||||
async function getFileTreeChildren(parentRelativePath) {
|
||||
const key = fileTreeParentKey(currentRootUri(), parentRelativePath);
|
||||
if (fileTreeState.loadedParents.has(key) && !fileTreeState.dirtyParents.has(key)) {
|
||||
return fileTreeState.rowsByParent.get(key) || [];
|
||||
}
|
||||
if (fileTreeState.loadingParents.has(key)) {
|
||||
return fileTreeState.loadingParents.get(key);
|
||||
}
|
||||
const promise = fetchFileTreeChildren(parentRelativePath)
|
||||
.then(rows => {
|
||||
fileTreeState.rowsByParent.set(key, rows);
|
||||
fileTreeState.loadedParents.add(key);
|
||||
fileTreeState.dirtyParents.delete(key);
|
||||
return rows;
|
||||
})
|
||||
.finally(() => fileTreeState.loadingParents.delete(key));
|
||||
fileTreeState.loadingParents.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
```
|
||||
|
||||
渲染规则:
|
||||
|
||||
- `renderFileProjection()` 不再无条件替换整棵 `innerHTML`。
|
||||
- 新 projection 只更新当前 scope/root parent 的 `rowsByParent`。
|
||||
- 对每个 expanded parent,渲染时优先使用 `rowsByParent` 已缓存 children。
|
||||
- 如果 parent 被标记 dirty,保留展开 UI 和旧 children,后台刷新该 parent;刷新完成后 patch 该 parent children。
|
||||
- collapsed parent 不加载 children,只保留 `expandable`。
|
||||
|
||||
## 后端 projection 优化
|
||||
|
||||
当前后端一次 children projection 至少可能读:
|
||||
|
||||
1. 当前目录 `read_sorted_entries()`。
|
||||
2. 每个子目录一次 `has_visible_child()`。
|
||||
3. 当前目录一次 `local_folder_watch_revision_for_directory()`。
|
||||
|
||||
改为单 pass helper:
|
||||
|
||||
```rust
|
||||
struct LocalFolderScanResult {
|
||||
rows: Vec<LocalFolderRow>,
|
||||
watch_revision: LocalFolderWatchRevision,
|
||||
}
|
||||
|
||||
fn scan_directory_shallow_with_revision(
|
||||
root: &Path,
|
||||
directory: &Path,
|
||||
parent_node_id: Option<String>,
|
||||
depth: u32,
|
||||
root_source_uri: &str,
|
||||
workspace_id: &str,
|
||||
metadata: &LocalFolderMetadata,
|
||||
) -> Result<LocalFolderScanResult, WebError>
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 只读取当前目录一次。
|
||||
- 对子目录只做 `has_visible_child_fast()`,且最多读到第一个可见 child 后停止。
|
||||
- 生成 `watchRevision` 时复用当前目录 entries,不再二次 `read_sorted_entries()`。
|
||||
- 返回 projection 时明确 `parentRelativePath`、`watchRevision.entryCount`、`items.length`。
|
||||
|
||||
## Watch / refresh 策略
|
||||
|
||||
替代当前“watch root 变更后刷新 sidebar + filetree root/scope”的粗刷新:
|
||||
|
||||
- `local-folder-watch` 仍可作为 fallback,但 filetree 侧只标记 dirty,不立即整树替换。
|
||||
- 如果当前 scope root 变更,刷新 scope parent。
|
||||
- 如果已展开 parent 变更,刷新对应 parent。
|
||||
- 如果无法定位 parent,刷新当前 scope parent,但保留 expanded parent cache。
|
||||
- local command 成功后用 command result 的 `parentRelativePath` 定位刷新 parent;没有 parent 信息时才降级刷新 scope parent。
|
||||
|
||||
## 测试与验收
|
||||
|
||||
### Rust 单测
|
||||
|
||||
- `local_folder_file_tree_children_snapshot_scans_parent_once`
|
||||
- 构造含多个目录的 temp root。
|
||||
- 调用 `load_local_folder_file_tree_children_snapshot(rootUri, "design")`。
|
||||
- 断言 `projection.parentRelativePath == "design"`。
|
||||
- 断言 `items` 只包含直接 children。
|
||||
- 断言 `watchRevision.entryCount == items.len()`。
|
||||
- `filetree_ssr_rows_include_local_relative_path`
|
||||
- 构造 `FileTreeRenderRow`。
|
||||
- 断言 HTML 包含 `data-local-relative-path="design/03-rust-web"`。
|
||||
|
||||
### JS runtime 字符串/契约测试
|
||||
|
||||
在 `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中增加断言:
|
||||
|
||||
- 包含 `loadingParents` / `loadedParents` / `rowsByParent`。
|
||||
- `loadFileTreeChildren` 在 fetch 前检查 `loadingParents.has(key)`。
|
||||
- `renderFileProjection` 不包含无条件 `tree.innerHTML =`。
|
||||
- `refreshLocalFolderSidebarSnapshot` 不直接绕过 FileTreeDataSource。
|
||||
|
||||
### 浏览器 smoke
|
||||
|
||||
新增脚本:
|
||||
|
||||
```text
|
||||
scripts/task494-filetree-lazy-loading-dedup-smoke.js
|
||||
```
|
||||
|
||||
断言:
|
||||
|
||||
- 登录测试账号。
|
||||
- 访问 `fileTreeScope=design`。
|
||||
- 展开 `design/03-rust-web`。
|
||||
- 记录 `/api/tree/projections/file/children?...parentRelativePath=design/03-rust-web` 请求数为 1。
|
||||
- 收起再展开同一目录,children 请求数仍为 1。
|
||||
- 触发一次 `tree:resync` 或等待 watcher tick 后,该 row 仍 `aria-expanded=true`,children 仍存在。
|
||||
- 第二次展开耗时低于 50ms 或无网络请求。
|
||||
- 从星标 scoped 文件夹打开已可见 Markdown 时,必须走 pane 内导航;页面 JS marker 不丢失,且不重新请求 scope 根 projection。
|
||||
- scoped 模式收到 root snapshot 或 coarse local-folder-watch revision 时,不得兜底重拉 scope 根 projection。
|
||||
- 普通 Markdown mount 时,不得为了 legacy office 附件兼容重拉 workspace root projection。
|
||||
|
||||
### 2026-05-27 scoped 文件打开补充验收
|
||||
|
||||
- RED:`task494-filetree-lazy-loading-dedup-smoke.js` 在 scoped `design` 文件树点击 `design/Overview.md` 后失败于 JS marker 被清空,确认当前实现发生整页 reload。
|
||||
- RED:同一 smoke 继续失败于 root live snapshot / coarse local-folder-watch revision 触发 scope 根 projection。
|
||||
- GREEN:`navigateToDocument(...)` 允许 `fileTreeScope` 场景继续使用 `openPrimaryDocument(...)`;scoped root snapshot 改为忽略非 scope projection;fallback polling 只标记 scoped stale;legacy office 兼容先检查候选段落再取索引。
|
||||
- 真实路径验证:进入 `fileTreeScope=design`,展开 `design/05-editor-mainline` 与 `design/05-editor-mainline/process`,打开 `5-32-filetree-lazy-loading-sidex-alignment-v1.md` 后页面 marker 保留,`scopeRootRequests=0`、`workspaceRootRequests=0`、`childrenRequests=2`。
|
||||
|
||||
### 性能目标
|
||||
|
||||
- localhost 下 `design/03-rust-web` children 请求保持单次 `<100ms`。
|
||||
- 1000 个 direct children 的目录 projection 目标 `<300ms`,不得因每个子目录重复深扫导致线性倍增到秒级。
|
||||
- 同一路径 5 次快速点击最多 1 个 in-flight 请求。
|
||||
|
||||
## 实施 Checklist
|
||||
|
||||
- [x] 修改 `FileTreeRenderRow` 增加 `relative_path` 字段,并在 SSR renderer 输出 `data-local-relative-path`。
|
||||
- [x] `collect_filetree_render_rows` 从 projection item 读取 `relativePath`。
|
||||
- [x] 把 `fileTreeLazyChildrenCache` 升级为 `fileTreeState.rowsByParent/loadingParents/loadedParents/dirtyParents`。
|
||||
- [x] `loadFileTreeChildren` 改为 `getFileTreeChildren(parentRelativePath)`,复用 in-flight promise。
|
||||
- [x] `renderFileProjection` 改为更新 datasource state,再按 visible state patch DOM,不整树替换 expanded subtree。
|
||||
- [x] `refreshLocalFolderSidebarSnapshot` 只刷新当前 scope parent,且不清除 expanded children。
|
||||
- [x] local command 成功后按 parent 刷新;无 parent 才刷新 scope。
|
||||
- [x] 后端新增 `scan_directory_shallow_with_revision`,减少 children projection 重复读目录。
|
||||
- [x] 新增 Rust 单测和 browser smoke。
|
||||
- [x] 重跑 `cargo test -p mnote-web --manifest-path rust/Cargo.toml`、`cargo test -p control-plane --manifest-path rust/Cargo.toml`、`cargo fmt --check --all --manifest-path rust/Cargo.toml`。
|
||||
- [x] 跑 `codegraph sync .` 并确认 status;已有无关 pending 只记录不清理。
|
||||
|
||||
## 验证记录
|
||||
|
||||
- 2026-05-26:`node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过,`targetChildrenRequests=1`,收起后再次展开无 children 重复请求,cache 展开耗时 `33ms`。
|
||||
- 2026-05-26:`cargo test -p mnote-web --manifest-path rust/Cargo.toml` 通过,`567 passed`。
|
||||
- 2026-05-26:`cargo test -p control-plane --manifest-path rust/Cargo.toml` 通过,`23 passed`。
|
||||
- 2026-05-26:`cargo fmt --check --all --manifest-path rust/Cargo.toml` 通过。
|
||||
- 2026-05-26:`codegraph sync .` 已执行;`codegraph status .` 仍报告 `Pending Changes: Added: 5 files`,属于当前工作树既有未提交/新增文件状态,本轮不清理。
|
||||
- 2026-05-27:`task492` RED 先失败于星标文件夹等待 sidebar projection,GREEN 后 `node scripts/task492-sidebar-starred-shortcuts-smoke.js` 通过,确认 scoped FileTree 先于延迟页面树可交互。
|
||||
- 2026-05-27:`task497` RED 先失败于真实点击星标 `design` 后 scoped FileTree 未先完成;GREEN 后 `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js` 通过。最终样本:`filetreeInteractiveAt=1779857492435`,`targetClickAt=1779857492621`,`pageAggregateResponseAt=1779857492667`,`editorVisibleAt=1779857492823`,`longTasks=[]`,`directDocumentMs=8`;截图保存在 `tmp/task497-local-page-tree-filetree-open-performance-smoke/`。
|
||||
- 2026-05-27:`task498` RED 先失败于星标 `design` 后 PageTree 为空 / 未按 scope 替换;补 scoped `/api/tree/projections/sidebar?parentRelativePath=design` 后进入保存段,再 RED 失败于 `hydrateMindmapAttrsFromDom is not defined`,确认 dirty session 保存计时器抛错导致 `/api/page-body/write` 未发出。GREEN 后 `node scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js` 通过,覆盖 PageTree scope、保存到磁盘、切换 `Other.md` 再回 `Home.md` 仍保留 `111`。
|
||||
- 2026-05-27:复跑 `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js` 通过。最终样本:`filetreeInteractiveAt=1779860840801`,`targetClickAt=1779860840985`,`pageAggregateResponseAt=1779860841032`,`editorVisibleAt=1779860841196`,`longTasks=[]`,`directDocumentMs=7`;scoped PageTree 断言改为检查 design scope 内本地页面行,而不是要求父目录名 `design` 必须作为可见行出现。
|
||||
- 2026-05-27:Rust filters 通过:`root_entry_local_folder_reuses_page_tree_snapshot_for_sidebar_html`、`sidebar_runtime_applies_page_projection_with_generation_and_yield`、`sidebar_filetree_runtime`、`local_folder_file_projection`、`tree_projection_routes`、`local_folder_file_tree_children_snapshot_scans_parent_once`、`local_folder_page_tree_snapshot_reuses_cache_until_watch_revision_changes`。
|
||||
- 2026-05-27:`node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过,`targetChildrenRequests=1`,`staleScopeChildrenRequests=1`,`cachedExpandMs=33`。
|
||||
|
||||
## 2026-05-27 SidebarTreeViewState 用户保存态跟进
|
||||
|
||||
### 再次 Sidex 对照结论
|
||||
|
||||
Sidex / VSCode Explorer 的展开程度不是由每次 projection 的 `expandedByDefault` 推断出来,而是由 TreeViewState 驱动:
|
||||
|
||||
- `explorerView.ts` 定义 `TREE_VIEW_STATE_STORAGE_KEY = 'workbench.explorer.treeViewState'`。
|
||||
- `onWillSaveState` 与 `updateAnyCollapsedContext()` 都会调用 `storeTreeViewState()`,写入 `JSON.stringify(this.tree.getViewState())`。
|
||||
- `setTreeInput()` 首次输入时从 workspace storage 读取 view state;已有 input 时直接使用当前 `tree.getViewState()`。
|
||||
- 无 view state 时才使用保守默认展开:多 root 最多展开 5 个 root;单 folder workspace 受 `explorer.expandSingleFolderWorkspaces` 控制。
|
||||
- 默认 `collapseByDefault` 返回 `true`,children 默认折叠;打开 / reveal 文件时只展开目标 parent chain。
|
||||
- filter session 会临时保存 `tree.getViewState()`,说明 Sidex 把展开、选择、焦点等视为 tree widget state,而不是文件系统 projection state。
|
||||
|
||||
MNote 当前状态与 Sidex 的差距:
|
||||
|
||||
- FileTree 只有 `mnote.localFileTree.expandedRelativePaths.v1`,使用 `sessionStorage` 按 `rootUri` 保存 expanded relative paths;它不是用户级持久态,也没有完整区分 `workspaceId/sourceKind/treeKind/scope`。
|
||||
- PageTree 没有用户展开保存态,`renderPageRows()` 直接用 `item.expandedByDefault !== false`;本地 PageTree 后端还有 `depth < 2` 默认展开,所以用户看到的“有开有关”主要来自后端 projection 默认值。
|
||||
- `fileTreeViewState` 里已有 `selectedRowIds/focusedRowId/activeRowId/scrollTop` 字段,但当前没有作为完整 view state 写入 control-plane,也没有统一应用到 PageTree。
|
||||
- debug/internal `tree-shell-runtime.js` 的内存 `expandedIds` 不是主 sidebar 产品路径保存态,不能作为本问题的已完成实现。
|
||||
|
||||
### 存储决策
|
||||
|
||||
`SidebarTreeViewState` 是用户 UI 偏好态,应以 SQLite control-plane 为主存储,浏览器 storage 只能作为首屏镜像 / 离线加速缓存。
|
||||
|
||||
优先复用现有 `user_ui_preferences` 表,不新增专表:
|
||||
|
||||
- 现有表已按 `user_id/workspace_id/source_kind/scope_kind/scope_id/key` 做唯一约束。
|
||||
- `control-plane` 已有 `upsert_user_ui_preference()` 与 `list_user_ui_preferences()`,并已有用户隔离测试。
|
||||
- 新状态可作为 `key = 'sidebarTreeViewState.v1'` 的 JSON value 存入该表。
|
||||
- `scope_kind = 'sidebar_tree'`,`scope_id` 使用稳定 scope key:`{treeKind}:{rootUriHash}:{scopeHash}`。
|
||||
- `workspace_id` 使用当前 workspace;local folder workspace 继续使用 `local_workspace_id_from_root_uri(rootUri)`。
|
||||
- `source_kind` 使用当前 sourceKind,例如 `local_folder`。
|
||||
|
||||
浏览器镜像只允许作为非权威缓存:
|
||||
|
||||
- key 形如 `mnote.sidebarTreeViewState.v1:{userId}:{workspaceId}:{sourceKind}:{treeKind}:{rootUriHash}:{scopeHash}`。
|
||||
- 页面启动可先读镜像减少闪烁,但必须用 `/api/tree/view-state` 或等价 control-plane API 的 SQLite 返回值校正。
|
||||
- 写入时可先更新内存 / 镜像,再 debounce PUT 到 SQLite;服务端必须从 session 推导 `user_id`,不能信任客户端提交的 `userId`。
|
||||
|
||||
状态 JSON 最小结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"treeKind": "filetree",
|
||||
"rootUri": "file:///mnt/Data1T/mnote",
|
||||
"scope": "design",
|
||||
"expandedIds": [],
|
||||
"expandedRelativePaths": ["design/05-editor-mainline", "design/05-editor-mainline/process"],
|
||||
"selectedId": "local:markdown:design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md",
|
||||
"focusedId": "",
|
||||
"activeId": "",
|
||||
"scrollTop": 0,
|
||||
"updatedAtMs": 0
|
||||
}
|
||||
```
|
||||
|
||||
不持久化以下派生运行态:
|
||||
|
||||
- `rowsByParent`
|
||||
- `loadedParents`
|
||||
- `loadingParents`
|
||||
- `dirtyParents`
|
||||
- `staleParents`
|
||||
- `revisionByParent`
|
||||
- `latestGenerationByParent`
|
||||
|
||||
这些仍然属于 FileTreeDataSource / PageTree render cache,不能写成用户偏好。
|
||||
|
||||
### 行为规则
|
||||
|
||||
- projection apply 优先级:`SQLite 用户保存态` > `当前 active/reveal parent chain` > `后端 expandedByDefault`。
|
||||
- 已存在用户保存态时,后端 `expandedByDefault` 不得覆盖用户折叠选择。
|
||||
- 打开 Markdown / 星标文件夹只 reveal 目标 parent chain,不展开整棵 PageTree 或 FileTree。
|
||||
- PageTree 与 FileTree 使用同一套保存机制,但 `treeKind` 必须分开,防止页面树展开状态污染文件树。
|
||||
- starred scoped folder 必须使用 scope key 隔离,不能复用 workspace root 的展开状态。
|
||||
- collapse all / expand all 属于明确用户操作,必须写入保存态;普通 projection refresh 不得写入“自动折叠”覆盖用户态。
|
||||
- 新建页面 / 新建文件夹后,只允许 reveal 新节点 parent chain;不得折叠其它已展开节点。
|
||||
- 当保存态里的节点已不存在时,应用时静默跳过,并在下次写入时自然清理。
|
||||
|
||||
### 待执行 Checklist:SidebarTreeViewState
|
||||
|
||||
- [x] RED Rust 测试:在 `control-plane` 层新增或扩展 `user_ui_preferences_are_scoped_upserted_and_user_isolated`,写入 `key = sidebarTreeViewState.v1`,断言 alice/bob 隔离、`workspace_id/source_kind/scope_kind/scope_id/key` upsert 后 revision 增加。
|
||||
- [x] RED route 测试:在 `mnote-web` 增加 `/api/tree/view-state` 的 GET/PUT 测试,使用真实 session actor;断言服务端从 session 取 user,不接受客户端伪造 userId。
|
||||
- [x] RED route 测试:同一用户同一 workspace 下,`treeKind=filetree&scope=design` 与 `treeKind=pagetree&scope=design` 返回不同 state;`scope=root` 与 `scope=design` 也互不污染。
|
||||
- [x] RED JS runtime 契约测试:断言 `sidebar-tree-live-apply-runtime.js` 中存在 `SIDEBAR_TREE_VIEW_STATE_KEY`、`loadSidebarTreeViewState`、`persistSidebarTreeViewState`、`applySidebarTreeViewState`,并且 PageTree 不再只用 `item.expandedByDefault !== false` 决定展开。
|
||||
- [x] 新增 `rust/crates/mnote-web/src/routes/tree_view_state.rs`,只负责 Sidebar tree view state API;不要把这部分逻辑塞进 projection route 或 local folder source。
|
||||
- [x] 在 `rust/crates/mnote-web/src/routes/mod.rs` 注册:
|
||||
- `GET /api/tree/view-state`
|
||||
- `PUT /api/tree/view-state`
|
||||
- [x] API 入参规范化:
|
||||
- `workspaceId` 为空且 `sourceKind=local_folder` 时,用 `local_workspace_id_from_root_uri(rootUri)`。
|
||||
- `treeKind` 只允许 `filetree` / `pagetree`。
|
||||
- `rootUri` 使用 canonical root uri。
|
||||
- `scope` 为空时统一为 `root`。
|
||||
- `scope_id = "{treeKind}:{rootUriHash}:{scopeHash}"`。
|
||||
- [x] API value 校验:
|
||||
- `schemaVersion == 1`。
|
||||
- `expandedIds` / `expandedRelativePaths` 最多保留 512 条。
|
||||
- `selectedId/focusedId/activeId` 限制最大长度。
|
||||
- `scrollTop` 必须是非负数字。
|
||||
- 不接受 `rowsByParent/loadedParents/loadingParents/revisionByParent` 等派生字段。
|
||||
- [x] 服务端写入 `user_ui_preferences`:
|
||||
- `scope_kind = "sidebar_tree"`。
|
||||
- `key = "sidebarTreeViewState.v1"`。
|
||||
- `workspace_id/source_kind` 按当前请求写入,保证用户、workspace、source 隔离。
|
||||
- [x] 服务端读取时只返回当前 actor 的 active preference;无记录时返回 `{ schemaVersion: 1, treeKind, rootUri, scope, expandedIds: [], expandedRelativePaths: [] }`,并标记 `source = "default"`。
|
||||
- [x] 前端新增统一内存 state:
|
||||
- `sidebarTreeViewStates = new Map()`。
|
||||
- key 包含 `userId/workspaceId/sourceKind/treeKind/rootUri/scope`。
|
||||
- FileTree 现有 `fileTreeExpandedRelativePaths` 改为该 state 的 `expandedRelativePaths` 引用。
|
||||
- PageTree 新增 `expandedIds`。
|
||||
- [x] 前端启动流程:
|
||||
- 先从带用户维度的 `localStorage` 镜像读首屏 state。
|
||||
- 异步 GET SQLite state。
|
||||
- SQLite 返回后只在 generation/rootUri/scope 仍匹配时应用,避免旧请求覆盖当前 DOM。
|
||||
- [x] 前端保存流程:
|
||||
- toggle 更新内存态;`selectedId/focusedId/activeId/scrollTop` 已纳入 API 与序列化边界。当前主 sidebar 未提供 collapse all / expand all 入口,本轮不新增入口;后续入口必须复用同一保存态。
|
||||
- 写 localStorage 镜像。
|
||||
- debounce 300-800ms PUT 到 SQLite。
|
||||
- unload / visibility hidden 时 flush pending save。
|
||||
- [x] FileTree 迁移:
|
||||
- 读取旧 `mnote.localFileTree.expandedRelativePaths.v1` 作为 fallback。
|
||||
- 只在新 SQLite state 缺失时导入旧 expanded paths。
|
||||
- 导入后写入新 state;旧 key 不主动删除。
|
||||
- [x] PageTree 应用:
|
||||
- `renderPageRows()` 改为优先查 `SidebarTreeViewState.expandedIds`。
|
||||
- 没有用户态时,只展开 active/reveal parent chain;最后才使用 `expandedByDefault`。
|
||||
- 本地 PageTree 的 `depth < 2` 默认展开只能作为“无用户态 + 无 active reveal”的首次兜底。
|
||||
- [x] FileTree 应用:
|
||||
- `renderFileRows()` 优先查 `SidebarTreeViewState.expandedRelativePaths`。
|
||||
- `loadFileTreeChildren()` 展开 parent 时写用户态,但不得因为自动恢复 children 加载失败而删除 expanded path。
|
||||
- scoped root projection 替换时保留同 scope 的 expanded state,不回退到 root scope。
|
||||
- [x] reveal 规则:
|
||||
- 打开星标 scoped folder 时,只 reveal scoped root 和当前 active document parent chain。
|
||||
- 打开 `design/05-editor-mainline/process/5-32...md` 时,只展开 `design`、`design/05-editor-mainline`、`design/05-editor-mainline/process`。
|
||||
- reveal 产生的自动展开要标记为 `reason = "reveal"`,可写入保存态;projection refresh 产生的默认展开不得覆盖保存态。
|
||||
- [x] 新建页面回归:
|
||||
- 新建页面后,新节点 parent 保持展开。
|
||||
- 上一个文件夹不被自动折叠。
|
||||
- 当前新页面不会出现“闪一下 md 再折叠”的状态反转。
|
||||
- [x] 新增浏览器 smoke `scripts/task499-sidebar-tree-view-state-smoke.js`:
|
||||
- 登录测试账号。
|
||||
- 进入本地 workspace。
|
||||
- 打开星标 `design`。
|
||||
- 在 FileTree 展开 `design/05-editor-mainline/process`。
|
||||
- 在 PageTree 折叠一个默认展开节点并展开另一个节点。
|
||||
- 刷新页面,断言 FileTree/PageTree 状态分别恢复。
|
||||
- 切到其它页面再回来,断言状态仍恢复。
|
||||
- 记录 `/api/tree/view-state` GET/PUT 请求、response payload 和截图。
|
||||
- [x] 扩展 `task497`:
|
||||
- 打开目标 Markdown 后,断言没有 PageTree `depth < 2` 导致的大面积默认展开覆盖用户折叠态。
|
||||
- 记录 view-state GET/PUT timing,确保 editor ready 不等待 PUT 完成。
|
||||
- [x] 扩展 `task498`:
|
||||
- 星标 `design` 的 PageTree scope 必须读取 `pagetree/design` state。
|
||||
- root PageTree state 不得覆盖 starred scoped PageTree。
|
||||
- [x] 增加多用户隔离 smoke 或 route test:
|
||||
- user A 展开 `design/process`。
|
||||
- user B 登录同 workspace,不应继承 user A 的展开态。
|
||||
- [x] 验证命令:
|
||||
- `cargo test -p control-plane --manifest-path rust/Cargo.toml user_ui_preferences`
|
||||
- `cargo test -p mnote-web --manifest-path rust/Cargo.toml tree_view_state`
|
||||
- `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js`
|
||||
- `node scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js`
|
||||
- `node scripts/task499-sidebar-tree-view-state-smoke.js`
|
||||
- `git diff --check`
|
||||
- `codegraph sync .`
|
||||
- `codegraph status .`
|
||||
|
||||
### 2026-05-27 SidebarTreeViewState 验证证据
|
||||
|
||||
- `cargo test -p control-plane --manifest-path rust/Cargo.toml user_ui_preferences`:通过,覆盖 `sidebarTreeViewState.v1` 在 `user_ui_preferences` 中的 upsert revision 与 alice/bob 用户隔离。
|
||||
- `cargo test -p mnote-web --manifest-path rust/Cargo.toml tree_view_state`:通过,覆盖 `/api/tree/view-state` GET/PUT、session actor 优先、`filetree/pagetree` 与 `root/design` scope 隔离、派生 cache 字段拒绝。
|
||||
- `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js`:通过,真实 Chromium 点击星标 `design` 后逐级打开目标 Markdown;最终样本 `pageTreeUserStateRestoredAt=1779870589458`,`filetreeInteractiveAt=1779870589562`,`targetClickAt=1779870589750`,`editorVisibleAt=1779870589815`,`longTasks=[]`,`directDocumentMs=8`。
|
||||
- `node scripts/task498-starred-page-tree-scope-and-local-edit-smoke.js`:通过,覆盖星标 `design` 使用 scoped PageTree state、root PageTree state 不覆盖 scoped PageTree、本地 Markdown 输入 `111` 后切页再回不丢。
|
||||
- `node scripts/task499-sidebar-tree-view-state-smoke.js`:通过,记录 `/api/tree/view-state` GET/PUT、刷新与切页后 FileTree/PageTree 状态恢复;最终样本 `fileStateSavedAt=1779870615533`,`pageStateSavedAt=1779870616150`,`reloadedStateRestoredAt=1779870616332`,`afterNavigationRestoredAt=1779870616436`。
|
||||
|
||||
## 与 5-30 的关系
|
||||
|
||||
`5-30` 解决“星标 shortcut 是什么、保存在哪里、点击进入哪个 scope”。本设计解决“进入 scope 后 FileTree 如何懒加载、缓存、刷新、避免重复请求”。
|
||||
|
||||
`5-30` 中的 `rootUri` 必须成为 shortcut 显式字段;缺失时星标文件夹应失效提示,不允许从 `workspaceId` 反推路径。
|
||||
|
||||
## 决策记录
|
||||
|
||||
- 2026-05-26:`展开恢复`只能作为兼容保护,不是性能修复。
|
||||
- 2026-05-26:FileTree 懒加载主模型按 DataSource/cache/in-flight 去重设计,不按 DOM 替换后再恢复。
|
||||
- 2026-05-26:后端 children projection 需要单 pass shallow scan,并把 watch revision 与 rows 同时产出。
|
||||
@@ -1,235 +0,0 @@
|
||||
# 5-33 导航页与路由守卫执行清单 v1
|
||||
|
||||
> 状态:process
|
||||
>
|
||||
> Owner:05-editor-mainline / 03-rust-web / control-plane
|
||||
>
|
||||
> 创建时间:2026-05-27
|
||||
>
|
||||
> 背景:当前本地文件夹入口在没有明确页面时容易回退打开默认页面;未登录、删除页、失效页等路径也缺少统一页面级恢复策略。需要把“导航页作为主页”与“路由守卫”合并设计,避免用户停留在错误页或误打开无关页面。
|
||||
|
||||
## 0. 当前落地状态
|
||||
|
||||
- [x] Phase A 核心语义已落地:`/` 与 local folder / folder scope 无明确页面时渲染导航页,不再自动打开 recent / first page。
|
||||
- [x] Phase B recent 闭环已落地:control-plane SQLite recent 表、读写 API、SSR recent 展示、打开 folder/page 写入 recent。
|
||||
- [x] Phase C 主路径已落地:导航页/星标文件夹进入 scoped 导航页;FileTree 普通文件夹点击只展开;Markdown 普通点击记录 recent 并打开文档页。
|
||||
- [x] Phase D 验证已落地:补 Rust route/API 测试与 `task500-navigation-page-route-guard-smoke.js` browser smoke,覆盖未登录、删除页回退、recent 分组和导航页首屏无 editor bootstrap。
|
||||
- [ ] 后续增强:文档页 breadcrumb 文件夹段逐级回到导航页、所有 fetch 404/410 的全局浏览器兜底可继续拆独立 follow-up;当前服务端 HTML shell 已覆盖 canonical route guard。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 本地文件夹和文件夹 scope 没有明确页面时显示导航页,不再自动打开 recent / first page 作为默认正文。
|
||||
- 导航页是主编辑区的主页状态;点击 Markdown 页面后,文档页覆盖该主页。
|
||||
- 点击文件夹后,行为对齐星标文件夹:切到 scoped Explorer,并以该文件夹为导航页上下文。
|
||||
- 导航页展示最近访问的文件夹和最近访问的页面,两组分开显示,并可直接访问。
|
||||
- 页面级路由守卫统一处理未登录、无权限、页面不存在、页面已删除等场景。
|
||||
- 首屏性能不因导航页退化:不构建 Page Aggregate,不启动 tiptap island,不额外扫描完整大目录。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不新增第二套树真相;导航页只消费 Rust projection / control-plane recent。
|
||||
- 不把 recent 写入 Markdown frontmatter 或本地文件夹 `.mnote` 作为长期事实。
|
||||
- 不把文件夹导航页做成真实 Markdown 页面。
|
||||
- 不改变 local-first Markdown 正文事实源。
|
||||
- 不在本阶段实现复杂最近访问排序模型,例如全文索引热度、跨设备文件内容同步或多设备路径重绑定。
|
||||
|
||||
## 3. 产品语义
|
||||
|
||||
### 3.1 导航页状态
|
||||
|
||||
- 工作区导航页:`/` 或默认 local workspace 入口,展示工作区级最近访问、打开本地文件夹入口和当前工作区概览。
|
||||
- 文件夹导航页:`/?sourceKind=local_folder&rootUri=...&fileTreeScope=docs&treeView=filetree`,展示 `docs/` scope 的子文件夹、Markdown 页面和资源入口。
|
||||
- 导航页不是错误页;它是没有明确文档目标时的正常主页。
|
||||
|
||||
### 3.2 点击行为
|
||||
|
||||
- 点击最近文件夹:进入该文件夹 scope,主区域显示文件夹导航页。
|
||||
- 点击最近页面:打开文档页并覆盖导航页。
|
||||
- 在导航页或 Explorer 中点击 Markdown:打开文档页并覆盖导航页。
|
||||
- 在文档页点击 breadcrumb 的文件夹段:回到对应文件夹导航页。
|
||||
- Ctrl / Meta / 中键点击页面:允许浏览器新标签页打开;普通点击优先走 MNote 当前主编辑区。
|
||||
|
||||
### 3.3 失效恢复
|
||||
|
||||
- 未登录访问受保护页面:跳转 `/auth?next=<encoded-current-url>`。
|
||||
- 登录后有 `next`:返回原始目标;如果目标已失效,则进入对应导航页。
|
||||
- Markdown 文件不存在、已删除或 document id 无法解析:回到当前 `rootUri + fileTreeScope` 的导航页,并显示轻量提示。
|
||||
- 文件夹 scope 不存在或无权限:回到 root 导航页或授权页,不停留在空白错误态。
|
||||
|
||||
## 4. 架构判断
|
||||
|
||||
- 导航页属于 `05-editor-mainline` 的工作区/文档壳体验,数据入口由 `03-rust-web` 的 route 和 projection 提供。
|
||||
- Recent 持久化属于 control-plane 用户状态,不属于文件系统事实。
|
||||
- FileTree / PageTree 内容仍由 Rust projection 生成;前端只负责导航、局部渲染和交互。
|
||||
- 路由守卫 canonical 逻辑放在 Rust HTML shell route;前端 fetch guard 只做浏览器交互恢复。
|
||||
|
||||
## 5. 数据与协议
|
||||
|
||||
### 5.1 Recent 记录
|
||||
|
||||
- [ ] 在 control-plane 增加用户 recent 记录表或复用已有偏好表能力,字段至少包含:
|
||||
- `id`
|
||||
- `user_id`
|
||||
- `kind`: `folder | page`
|
||||
- `source_kind`: MVP 先支持 `local_folder`
|
||||
- `root_uri`
|
||||
- `relative_path`
|
||||
- `document_id`
|
||||
- `title`
|
||||
- `visited_at`
|
||||
- `status`
|
||||
- [ ] Recent folder 使用 `root_uri + relative_path` 定位;root 文件夹 `relative_path=""`。
|
||||
- [ ] Recent page 使用 `root_uri + document_id` 定位,并冗余保存 `relative_path/title` 方便展示。
|
||||
- [ ] 每个用户每类 recent 保留有限条数,MVP 建议 folder 10 条、page 20 条。
|
||||
- [ ] list recent 时做授权过滤;无权限、root 不可用、路径不存在的记录不作为可点击主项。
|
||||
- [ ] localStorage 里已有 recent local roots 只作为迁移/兜底读取,不作为长期 canonical。
|
||||
|
||||
### 5.2 API
|
||||
|
||||
- [ ] 新增或扩展读取 API:返回当前用户 recent folders / recent pages。
|
||||
- [ ] 新增写入 API:打开 folder scope 或 Markdown page 后记录 recent。
|
||||
- [ ] API 写入只记录用户行为,不触碰 Markdown 或 `.mnote` 文件。
|
||||
- [ ] API 返回项包含可直接用于导航的 URL 参数:`sourceKind/rootUri/fileTreeScope/documentId/workspaceId`。
|
||||
|
||||
## 6. Rust HTML Shell
|
||||
|
||||
### 6.1 导航页渲染
|
||||
|
||||
- [ ] 新增导航页 view model,区分 `workspace` 与 `folder` 两种 scope。
|
||||
- [ ] 导航页首屏只加载 shallow file projection 和 recent 列表。
|
||||
- [ ] 导航页不调用 `build_page_aggregate_snapshot`。
|
||||
- [ ] 导航页不注入 `__MNOTE_PAGE_AGGREGATE__` / `__MNOTE_EDITOR_BOOTSTRAP__`,除非后续明确需要编辑器。
|
||||
- [ ] 导航页继续使用 `PageLayout` 和现有 workspace sidebar。
|
||||
- [ ] 文件夹导航页主区域展示:
|
||||
- 当前文件夹标题和路径面包屑
|
||||
- 子文件夹列表
|
||||
- 当前层 Markdown 页面列表
|
||||
- 当前层资源列表
|
||||
- 最近访问页面和最近访问文件夹
|
||||
|
||||
### 6.2 `/` 入口选择规则
|
||||
|
||||
- [ ] `/` 未登录继续跳 `/auth?next=/`。
|
||||
- [ ] `/` 无 `pageId` 且无 `fileTreeScope`:显示工作区导航页。
|
||||
- [ ] `/` 有 `sourceKind=local_folder&rootUri=...` 但无 `pageId`:显示对应 root 导航页。
|
||||
- [ ] `/` 有 `fileTreeScope`:显示该 scope 文件夹导航页。
|
||||
- [ ] `/` 有 `pageId`:尝试打开文档页;失败时回退导航页。
|
||||
- [ ] 移除或限制 `recent_page_id -> first_page` 的自动正文打开行为,避免文件夹入口误开默认页面。
|
||||
|
||||
### 6.3 `/documents/{document_id}` 守卫
|
||||
|
||||
- [ ] 未登录访问 `/documents/{document_id}`:`303 /auth?next=<current-url>`。
|
||||
- [ ] local_folder 缺少 `rootUri`:跳 root/workspace 导航页,并保留错误提示参数。
|
||||
- [ ] `local_markdown_not_found`:跳 `/?sourceKind=local_folder&rootUri=...&fileTreeScope=<parent>&missingPage=<document_id>`。
|
||||
- [ ] `local_workspace_access_denied`:跳授权说明页或 root 导航页提示无权限。
|
||||
- [ ] 其它不可恢复错误保留错误页,但必须带 trace id。
|
||||
|
||||
## 7. Browser Runtime
|
||||
|
||||
- [ ] 抽出 `openNavigationPageForFolder(rootUri, relativePath, workspaceId)` helper,供最近文件夹、星标文件夹、breadcrumb 文件夹段复用。
|
||||
- [ ] 点击文件夹时不只展开侧栏;如果是导航动作,应更新 URL `fileTreeScope` 并渲染文件夹导航页。
|
||||
- [ ] 点击 Markdown 时记录 recent page,然后打开文档页覆盖导航页。
|
||||
- [ ] 点击导航页或星标 folder scope 时记录 recent folder,然后显示导航页;FileTree 普通文件夹点击只展开子项。
|
||||
- [ ] fetch 返回 `401` 且当前是浏览器交互:跳 `/auth?next=<current-url>`。
|
||||
- [ ] fetch 返回 `404/410` 且目标是当前页面:跳当前文件夹导航页。
|
||||
- [ ] 保留普通业务错误 toast,不把所有 API 错误都变成导航跳转。
|
||||
|
||||
## 8. 性能约束
|
||||
|
||||
- [ ] 导航页首屏不得扫描完整 workspace root 下所有 Markdown。
|
||||
- [ ] 文件夹导航页使用 scoped shallow FileTree projection。
|
||||
- [ ] PageTree scope 可继续给 sidebar 使用,但主导航页首屏不依赖完整 PageTree。
|
||||
- [ ] Recent list 查询必须限制条数。
|
||||
- [ ] 大目录 smoke 要验证打开 `design/` scope 不显示 root 兄弟目录,不触发完整 root 渲染。
|
||||
- [ ] 导航页首屏不启动 tiptap island,避免无文档目标时加载编辑器 bundle。
|
||||
- [ ] 若需要最近页面标题刷新,只在列表展示时对有限条记录做存在性校验,不批量读取正文。
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- [ ] 登录后访问 `/` 显示导航页,而不是自动打开某个默认页面。
|
||||
- [ ] 打开本地文件夹 root 后显示该文件夹导航页。
|
||||
- [ ] 点击文件夹进入 scoped 导航页,Sidebar Explorer 与主区域 scope 一致。
|
||||
- [ ] 点击 Markdown 后打开文档页并覆盖导航页。
|
||||
- [ ] 最近文件夹和最近页面分组展示;点击最近文件夹进入导航页,点击最近页面打开文档。
|
||||
- [ ] 删除当前 Markdown 后刷新旧 `/documents/...`,进入父文件夹导航页并显示提示。
|
||||
- [ ] 未登录访问 `/documents/...` 跳 `/auth?next=...`,登录后能回到目标或导航页兜底。
|
||||
- [ ] 无权限访问本地 folder 不显示空白文档页。
|
||||
- [ ] 导航页没有注入页面 aggregate 和 editor bootstrap。
|
||||
- [ ] 大目录 scope 打开不退化为完整 root 扫描。
|
||||
|
||||
## 10. 测试计划
|
||||
|
||||
### 10.1 Rust 单元 / route 测试
|
||||
|
||||
- [ ] `root_entry_without_page_renders_navigation_home`
|
||||
- [ ] `root_entry_local_folder_scope_renders_folder_navigation`
|
||||
- [ ] `root_entry_does_not_auto_open_recent_page_for_folder_scope`
|
||||
- [ ] `document_shell_unauthenticated_redirects_to_auth_with_next`
|
||||
- [ ] `document_shell_missing_local_markdown_redirects_to_folder_navigation`
|
||||
- [ ] `recent_navigation_records_are_user_scoped`
|
||||
- [ ] `recent_navigation_list_filters_inaccessible_local_roots`
|
||||
|
||||
### 10.2 Browser smoke
|
||||
|
||||
- [ ] 新增 `task5xx-navigation-home-local-folder-smoke.js`:
|
||||
- 登录测试账号
|
||||
- 打开本地 folder root
|
||||
- 断言主区域是导航页
|
||||
- 断言未打开默认 Markdown
|
||||
- [ ] 新增 `task5xx-navigation-folder-scope-smoke.js`:
|
||||
- 点击文件夹
|
||||
- 断言 URL 含 `fileTreeScope`
|
||||
- 断言主区域标题和 Explorer scope 一致
|
||||
- [ ] 新增 `task5xx-navigation-recent-smoke.js`:
|
||||
- 打开两个文件夹和一个 Markdown
|
||||
- 回到导航页
|
||||
- 断言 recent folders / recent pages 分组可见并可点击
|
||||
- [ ] 新增 `task5xx-route-guard-auth-and-deleted-page-smoke.js`:
|
||||
- 未登录访问文档 URL 跳 auth
|
||||
- 登录后打开文档
|
||||
- 删除对应 `.md`
|
||||
- 刷新旧文档 URL 后回到文件夹导航页
|
||||
|
||||
### 10.3 回归测试
|
||||
|
||||
- [ ] `cargo test -p mnote-web root_entry -- --nocapture`
|
||||
- [ ] `cargo test -p mnote-web local_folder -- --nocapture`
|
||||
- [ ] `node scripts/task492-sidebar-starred-shortcuts-smoke.js`
|
||||
- [ ] `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js`
|
||||
- [ ] `git diff --check`
|
||||
- [ ] 修改代码后运行 `codegraph sync .`,提交前确认 CodeGraph 无 pending。
|
||||
|
||||
## 11. 分阶段执行建议
|
||||
|
||||
### Phase A:守住语义
|
||||
|
||||
- [ ] 新增导航页 SSR 壳和 route 测试。
|
||||
- [ ] 改 `/` 入口选择规则,不再在 folder scope 下自动打开默认页面。
|
||||
- [ ] `/documents/{document_id}` 增加未登录和 missing local markdown 路由恢复。
|
||||
|
||||
### Phase B:Recent 闭环
|
||||
|
||||
- [ ] control-plane 增加 recent 记录。
|
||||
- [ ] 导航页 SSR 展示 recent folders/pages。
|
||||
- [ ] 打开 folder/page 时写入 recent。
|
||||
|
||||
### Phase C:浏览器交互对齐
|
||||
|
||||
- [ ] 文件夹点击进入导航页 scope。
|
||||
- [ ] Markdown 点击覆盖导航页。
|
||||
- [ ] breadcrumb 文件夹段返回导航页。
|
||||
- [ ] fetch guard 处理 `401/404/410`。
|
||||
|
||||
### Phase D:性能与 smoke
|
||||
|
||||
- [ ] 确认导航页首屏不构建 editor / aggregate。
|
||||
- [ ] 大目录 scoped smoke 验证不加载完整 root。
|
||||
- [ ] 删除页、未登录、recent 点击 smoke 通过。
|
||||
|
||||
## 12. 风险与边界
|
||||
|
||||
- Recent 写入如果只做前端 localStorage,会和登录用户、授权过滤、SSR 首屏冲突;MVP 应优先 SQLite。
|
||||
- 文件夹导航页如果复用完整 PageTree 递归扫描,可能在大目录退化;首屏应以 shallow FileTree projection 为主。
|
||||
- `mnote_recent_page_id` 旧 cookie 不能继续作为文件夹入口自动打开正文的依据;最多用于工作区级“继续编辑”入口展示。
|
||||
- 路由守卫不能把所有错误都吞成导航页;不可恢复错误仍要保留 trace,方便排障。
|
||||
- Ctrl / Meta / 中键打开浏览器新标签页不能破坏普通点击覆盖主页的主路径。
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# 5-35 导航页 breadcrumb 与 fetch guard follow-up v1
|
||||
|
||||
> 状态:process
|
||||
>
|
||||
> Owner:05-editor-mainline / 03-rust-web
|
||||
>
|
||||
> 创建时间:2026-06-01
|
||||
>
|
||||
> 来源:`design/05-editor-mainline/done/5-33-navigation-page-route-guard-checklist-v1.md` 归档后拆出的增强尾项。
|
||||
|
||||
## 背景
|
||||
|
||||
`5-33` 已完成导航页主路径:`/` 与 local-folder / folder scope 无明确页面时显示导航页,不再自动打开默认 Markdown;recent folders/pages、删除页 fallback、未登录 route guard、导航页首屏不注入 editor / aggregate 均已有测试和 browser smoke。
|
||||
|
||||
剩余问题不应继续阻塞主清单,但仍属于工作区体验尾项:
|
||||
|
||||
- 文档页 breadcrumb 的文件夹段应逐级回到对应 folder navigation page。
|
||||
- 浏览器交互 fetch 返回 `401/404/410` 时,当前页面应有统一恢复策略,而不是只依赖服务端 HTML route guard。
|
||||
|
||||
## 目标
|
||||
|
||||
- 点击文档页 breadcrumb 文件夹段时,进入对应 `fileTreeScope` 的导航页,并保持 Sidebar Explorer scope 一致。
|
||||
- 当前页面相关 fetch 返回 `404/410` 时,回到当前资源所在父 folder navigation page,并显示轻量提示。
|
||||
- 当前交互 fetch 返回 `401` 时,跳转 `/auth?next=<current-url>`。
|
||||
- 保留普通业务错误 toast,不把所有 API 错误都吞成导航跳转。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不重新打开 `5-33` 的导航页主清单。
|
||||
- 不新增第二套 route truth;canonical route guard 仍在 Rust HTML shell。
|
||||
- 不改变 FileTree 普通文件夹点击只展开的行为。
|
||||
- 不为所有后台 API 加全局 silent redirect。
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] breadcrumb 文件夹段点击后 URL 包含对应 `fileTreeScope`,主区域是 folder navigation page。
|
||||
- [ ] Sidebar Explorer 与主区域 scope 一致。
|
||||
- [ ] 当前文档相关 fetch 返回 `404/410` 时回到父 folder navigation page。
|
||||
- [ ] 当前交互 fetch 返回 `401` 时跳转 auth 并携带 `next`。
|
||||
- [ ] `task500-navigation-page-route-guard-smoke.js` 继续通过,或新增 `task5xx-navigation-breadcrumb-fetch-guard-smoke.js` 覆盖本文尾项。
|
||||
|
||||
## 建议验证
|
||||
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web root_entry -- --test-threads=1`
|
||||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder -- --test-threads=1`
|
||||
- `node scripts/task500-navigation-page-route-guard-smoke.js`
|
||||
Reference in New Issue
Block a user