docs: organize active mnote execution checklists

This commit is contained in:
lix-2026
2026-05-28 22:01:12 +08:00
parent 80d54c4c2b
commit 7354807ee9
7 changed files with 1484 additions and 67 deletions
@@ -0,0 +1,210 @@
# 5-27 本地 Markdown Working Copy 冲突合同 v1
## 背景
`bugs/0524.md` 第 2 条暴露的问题不是单个上传入口错误,而是本地文件夹 watcher、正文 session、文件树资源事件和冲突 UI 之间缺少清晰边界:
- 上传附件或向文件树拖入文件后,非 Markdown 文件变化会进入正文外部变更链路。
- 新建页面后,旧的冲突提示可能被同 root 的事件流带到新页面,刷新后消失。
- 真实冲突仍然需要保留:当前 Markdown 有未保存编辑,同时磁盘上的同一个 Markdown 文件被外部修改时,必须进入冲突处理。
## Sidex 对照
Sidex/VSCode 的核心模型是 `StoredFileWorkingCopy`
- 每个 working copy 绑定一个具体 `resource`
- 保存时以 `lastResolvedFileStat.etag/mtime` 做 dirty write prevention。
- 只有同一个 resource 的写入出现 `FILE_MODIFIED_SINCE` 时,才进入 `inConflictMode`
- 文件系统 watcher 的目录级事件不会直接把同目录其它文件变化升级成当前 working copy 冲突。
- 自身文件操作走 `onDidRunOperation` 一类的写入通道,外部变化走 `onDidFilesChange` watcher 通道。
- dirty working copy 收到 watcher update 时不自动 reload;真正冲突主要在保存时通过 mtime/etag 前置条件产生。
- 保存成功只在保存期间 working copy 版本没有再次变化时清 dirty,否则保留 dirty 并继续下一轮保存。
MNote 不需要照搬 VSCode 的实现,但应采用同一条合同:正文冲突只属于当前 Markdown 文件,不属于同 root 下任意附件、文件树资源或元数据文件。
## 当前根因
当前 `mnote-web` 的 document event channel 以 `rootUri` 共享:
- 前端订阅 `/api/local-folder/events?rootUri=...`,没有传 `documentId`
- 后端 `build_document_events_stream` 在没有 `documentId` 时不会按 Markdown 相对路径过滤。
- watcher 对非 Markdown 资源发出的 payload 中 `documentId` 为空。
- 前端收到空 `documentId` 后,仍会对同 root 下所有 document session 设置 `externalChangePending` 并调度正文刷新。
这会把附件上传、文件树拖入、资源创建等 root 级变化误投递到正文 session,形成文件冲突误报和跨页面状态污染。
另一个独立根因是冲突 UI 的 DOM 生命周期:
- `renderSessionConflictSurface` 会把冲突面板直接插入 `.document-pane`
- 页面切换时 `unmountEditorViewBinding` 只卸载 editor runtime 和事件监听,没有清理旧 session 的冲突面板。
- 因此旧页面已出现冲突时,新建/切换到新页面可能短暂看到旧冲突面板;刷新后整页 DOM 重建,问题消失。
2026-05-28 复发根因:
- 上传附件后,`local-upload-runtime` 会先写附件文件,再把附件链接插入编辑器并保存 Markdown。
- 保存 Markdown 成功后,后端返回新的 `fileVersion/conflictDetectionKey`,但后续用户输入的 autosave 仍可能使用旧 `expectedFileVersion`
- Local watcher 会看到 MNote 自己 `fs::write()` 产生的 Markdown 文件事件,并把它标成 `external-editor`
- 如果用户在 watcher 回声到达前后继续输入,`Dirty/saveTimer/recent input` 与 watcher 事件相遇,被误判成外部冲突。
- 进入冲突态后,第二个附件上传仍可能写入附件文件和插入链接,但正文保存链停止可靠提交,导致两个附件的编辑器链接、Markdown 正文和文件树资源状态分裂。
## 合同
1. Markdown 正文 session 只订阅自己的 Markdown 文件事件。
2. 非 Markdown 资源事件只用于文件树/资源投影刷新,不触发正文 `externalChangePending`
3. 真实正文冲突只在“同一个 Markdown documentId + 当前 session dirty/saving/saveTimer/recent input”时出现。
4. 同 root 下不同 Markdown 页面必须有独立 document event channel。
5. 文件树 live stream 继续承担 root 级变化刷新,不依赖正文冲突链路。
6. 保存冲突以 CAS 为准:`expectedFileVersion` 与当前磁盘 `fileVersion` 不一致时返回 409。
7. watcher 事件不能单独制造正文冲突;它只能触发 clean reload、标记 buffer 外部变化,或在不同版本且 dirty 时把 buffer 推到 Stale。
8. MNote 自身保存成功后的同版本 watcher 回声必须被识别为 self-write echo,不得把 Clean 变 ExternalModified,也不得把 Dirty 变 Stale。
9. 保存成功必须原子更新 Rust `BufferStore`、浏览器 `DocumentSession.conflictDetectionKey/fileVersion`、页面 aggregate script 中的版本字段。
10. dirty working copy 不自动 reload;如果磁盘版本未变化,当前编辑器内容与磁盘快照不同也不是冲突。
## 已执行修复切片
- 前端 document event channel key 从 `rootUri` 收窄为 `rootUri#documentId`
- 前端订阅本地文件夹 document events 时携带 `documentId`
- 前端收到无 `documentId` 的 change payload 时直接忽略,不再设置正文 `externalChangePending`
- editor view unmount 时清理旧 session 的冲突面板,避免手动插入的 DOM 跨页面残留。
- 后端补充 document event filter 测试,确保资源文件路径不会匹配 Markdown 正文路径。
## 后续系统性收口
- [x]`DocumentBufferStore` 作为正文冲突状态底座之一,前端 session 不再仅凭 watcher/dirty 差异制造冲突。
- [x] 保存成功后的同版本 watcher 回声由 Rust `BufferStore` 忽略。
- [x] 上传附件后继续输入、再上传第二个附件的 browser smoke 覆盖。
- [x] 引入显式 `writeIntentId/saveOperationId`,让 watcher 可精确关联 MNote 自身写入,而不只依赖 fileVersion。
- [x] 为资源 tab 增加独立 resource watch 合同:按 `resourcePath` 监听资源文件,而不是复用 Markdown document event。
- [x] 为新建页面增加创建者写入抑制或 bootstrap generation,避免未来新建空文件 watcher 与首次打开时序竞争。
- [x]`task451` 的真实冲突 smoke 与上传/新建页面无冲突 smoke 合并成一组 local Markdown conflict regression。
- [x] 补 AI 写入、多浏览器 tab、外部删除/移动的冲突矩阵。
## 执行 Checklist
### Batch 1 - 保存意图与自写回声审计
- [x] `PageBodyWriteRequest` / `/api/documents/save` 接受并回传 `writeIntentId``saveOperationId`
- [x] 浏览器 `DocumentSession` 普通保存与 `local-upload-runtime` 附件保存都生成并发送 `writeIntentId/saveOperationId`
- [x] `BufferStore.mark_saved` 记录最后一次 `writeIntentId/saveOperationId`
- [x] watcher 处理 Markdown 文件事件时在 payload 中带出 `observedFileVersion``bufferFileVersion``lastWriteIntentId``lastSaveOperationId``selfWriteEcho`
- [x] 同版本 watcher 回声继续保持 Clean/Dirty 不被升级为 ExternalModified/Stale。
- [x] 定点验证:core-protocol 反序列化测试、BufferStore self-write outcome 测试、`task503` 连续上传 smoke。
验证证据:
- `cargo test --manifest-path rust/Cargo.toml -p core-protocol page_body_write_request_uses_file_version_contract -- --nocapture`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_buffer_records_save_operation_and_reports_self_write_echo -- --nocapture`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_buffer_ignores_watcher_echo_for_last_saved_version -- --nocapture`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_buffer_marks_stale_when_dirty_buffer_sees_new_version -- --nocapture`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_markdown_write_contract_returns_page_body_write_command -- --nocapture`
- `cargo check --manifest-path rust/Cargo.toml -p mnote-web`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3017 node scripts/task503-local-pptx-upload-filetree-open-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3017 node scripts/task491-local-md-attachment-icon-refresh-smoke.js`
- `node --check rust/crates/mnote-web/browser/document-session-runtime.js && node --check rust/crates/mnote-web/browser/local-upload-runtime.js && node --check scripts/task503-local-pptx-upload-filetree-open-smoke.js`
### Batch 2 - 真实外部编辑冲突恢复
- [x] 写 browser smokedirty 当前 Markdown 后由外部进程修改同一 `.md`,必须显示冲突面板。
- [x] `accept_disk`:接受磁盘版本后编辑器、DocumentSession、BufferStore 与 aggregate 版本一致。
- [x] `keep_editor`:保留当前编辑器内容后下一次保存使用最新磁盘版本 CAS,成功后清冲突。
- [x] `open_diff`:至少能打开稳定 diff/对照视图,不丢当前编辑器内容。
- [x] 保存过程中继续输入:只清理已保存版本,后续输入保持 Dirty。
验证证据:
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task504-local-md-external-conflict-recovery-smoke.js`
- `node --check scripts/task504-local-md-external-conflict-recovery-smoke.js`
- `task504``save-while-typing` 步骤断言保存 pending 时继续输入会触发第二轮保存:`saveRequestCountBeforeRace=1``saveRequestCountAfterRace=3`,最终磁盘同时包含第一段和第二段且无冲突面板。
### Batch 3 - Resource Tab 独立 Watch 合同
- [x] 定义 resource watch 合同:`resourcePath/resourceId` 级别监听资源文件,不复用 Markdown document event。
- [x] PDF/Office/image/resource markdown tab 只响应自身 resource 事件。
- [x] resource Markdown 文件更新时刷新资源 tab 或提示冲突,不影响正文 session 冲突态。
- [x] resource 文件删除/移动进入 resource tab 自己的 missing/rekey 状态。
- [x] browser smoke 覆盖 Office/PDF resource tab 更新与删除。
验证证据:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web resource_event_path_filters_to_its_own_relative_path -- --nocapture`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task505-resource-tab-watch-contract-smoke.js`
- `node --check rust/crates/mnote-web/browser/document-session-runtime.js && node --check rust/crates/mnote-web/browser/document-editor-adapter-runtime.js && node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js && node --check scripts/task505-resource-tab-watch-contract-smoke.js`
- `task505` 覆盖 Markdown resource clean 更新、Markdown resource dirty 冲突、Office passive resource 更新重载与删除 missingPDF 与 Office 共用 iframe passive resource watch 路径,image 走同一 resourcePath watch 并重载 `img.src`
### Batch 4 - 创建/删除/移动 Bootstrap 与生命周期
- [x] 新建页面首写使用 bootstrap generation 或 creator write suppression,避免空文件 watcher 抢跑。
- [x] 外部删除打开 Markdown:不可 rekey 时进入 `Deleted`,保留 buffer 内容。
- [x] 外部移动/重命名打开 Markdown:可 rekey 时保留 buffer 并更新 workspace path/document id。
- [x] 文件树删除/移动与外部删除/移动走同一 resource lifecycle 合同。
验证证据:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_buffer_marks_deleted_after_local_file_operation_archive -- --nocapture`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_buffer_rekeys_after_local_file_operation_rename -- --nocapture`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task506-local-md-delete-move-lifecycle-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task458-local-create-page-no-conflict-smoke.js`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture`
- `node --check scripts/task506-local-md-delete-move-lifecycle-smoke.js`
- 说明:内部 filetree rename/move 通过 tree command execution 的 previous/next resource 调 `BufferStore.rekey_local_folder_markdown`;外部原始 `fs.rename` 在 watcher 层没有稳定 previous/next 配对,当前按冲突/删除态保留 buffer,不静默重定向到猜测路径。
### Batch 5 - AI 与多 Tab 并发矩阵
- [x] AI 写入 clean 文档:当前 tab 自动同步,BufferStore 保持 Clean。
- [x] AI 写入 dirty 文档:显示 agent 来源冲突,冲突 envelope 带 actor/source。
- [x] 两个浏览器 tab 并发编辑同一 Markdownclean tab 自动同步,dirty tab 保存 409。
- [x] 附件文件已落盘但 Markdown 引用保存 409:提示重试/保留孤儿/清理策略明确,不静默丢失。
- [x] 合并 `task451` 真实冲突 smoke、上传无冲突 smoke、新建页面无冲突 smoke 为 local Markdown conflict regression 组。
验证证据:
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task436-local-markdown-open-document-external-change-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task508-local-md-multitab-conflict-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task509-local-upload-save-409-orphan-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task510-local-markdown-conflict-regression-group.js`
- `task508` 断言 clean tab 自动同步,dirty tab 延迟保存经 A tab 写入后返回 `gateStatus=409`,B tab 保留未保存内容并显示冲突面板。
- `task509` 断言 local-upload save 409 后附件文件保留、编辑器引用保留、`data-mnote-last-upload-orphaned-policy=asset-kept-reference-unsaved-retry-required`;session 已冲突时第二次附件上传在写文件前阻断。
## 分阶段 Checklist
### Phase A - Working Copy 合同冻结
- [x] Page Aggregate body 输出 `fileVersion/conflictDetectionKey`
- [x] DocumentSession 保存时携带 `expectedFileVersion`
- [x] BufferStore 持有 `Clean/Dirty/Stale/ExternalModified/Deleted`
- [x] 文档化 `fileVersion` 生成规则和 `baseContentHash/currentContentHash` 语义。
合同说明:
- `fileVersion/conflictDetectionKey` 是本地 Markdown 的 CAS 令牌,由当前 `documentId + mtime_ms + file_size + content_hash_prefix` 生成;它只用于“这次保存基于哪个磁盘版本”比较,不作为页面 ID、附件 ID 或排序真相。
- `baseContentHash` 表示 buffer 上一次确认的磁盘/保存内容 hash;`currentContentHash` 表示当前 working copy 内容 hash。两者不同是 dirty/stale 的依据,但只有保存 CAS 失败或删除态才进入用户可见冲突。
### Phase B - Self-write 回声消除
- [x] `write_local_markdown_page_body()` 保存成功后调用 `BufferStore.mark_saved()`
- [x] watcher 看到同版本 Markdown 事件时不调用外部冲突转换。
- [x] 保存请求分配 `writeIntentId`watcher payload 回传并落审计。
- [x] `local-upload-runtime` 在 session 已冲突时阻止“看似成功”的第二次正文保存。
### Phase C - 真实冲突闭环
- [x]`expectedFileVersion` 保存返回 409 conflict envelope。
- [x] dirty 当前 Markdown 后外部编辑同一 `.md` 的 browser smoke。
- [x] 接受磁盘版本、保留当前编辑器版本、打开 diff 后的版本恢复 smoke。
- [x] 保存过程中用户继续输入时,只清理已保存版本,不丢后续 dirty。
### Phase D - 附件/文件树
- [x] 编辑器附件上传后继续输入不触发冲突。
- [x] 连续上传同名 pptx 后两个编辑器附件链接可打开。
- [x] 文件树拖拽上传后投影刷新并可打开。
- [x] 正文保存 409 时,附件文件存在但引用未落盘的 UI 提示/重试/孤儿清理策略。
### Phase E - AI / 多 Tab / 外部文件
- [x] AI 写入 clean 文档时自动同步。
- [x] AI 写入 dirty 文档时显示 agent 来源冲突。
- [x] 两个浏览器 tab 并发编辑同一 Markdownclean tab 自动同步,dirty tab 保存 409。
- [x] 外部删除/移动打开文件:可 rekey 时保留 buffer,不可 rekey 时进入 Deleted。
## 验收
- 上传第一个/第二个附件后,主编辑区不显示 `mnote-editor-conflict-panel`
- 新建页面后,旧页面冲突 UI 不带入新页面。
- 新建页面后直接向文件树拖入文件,不显示文件冲突。
- dirty 当前 Markdown 后由外部修改同一个 `.md` 文件,仍显示冲突并保留 accept disk / keep current / diff 流程。
@@ -1,67 +0,0 @@
# 5-27 本地 Markdown Working Copy 冲突合同 v1
## 背景
`bugs/0524.md` 第 2 条暴露的问题不是单个上传入口错误,而是本地文件夹 watcher、正文 session、文件树资源事件和冲突 UI 之间缺少清晰边界:
- 上传附件或向文件树拖入文件后,非 Markdown 文件变化会进入正文外部变更链路。
- 新建页面后,旧的冲突提示可能被同 root 的事件流带到新页面,刷新后消失。
- 真实冲突仍然需要保留:当前 Markdown 有未保存编辑,同时磁盘上的同一个 Markdown 文件被外部修改时,必须进入冲突处理。
## Sidex 对照
Sidex/VSCode 的核心模型是 `StoredFileWorkingCopy`
- 每个 working copy 绑定一个具体 `resource`
- 保存时以 `lastResolvedFileStat.etag/mtime` 做 dirty write prevention。
- 只有同一个 resource 的写入出现 `FILE_MODIFIED_SINCE` 时,才进入 `inConflictMode`
- 文件系统 watcher 的目录级事件不会直接把同目录其它文件变化升级成当前 working copy 冲突。
MNote 不需要照搬 VSCode 的实现,但应采用同一条合同:正文冲突只属于当前 Markdown 文件,不属于同 root 下任意附件、文件树资源或元数据文件。
## 当前根因
当前 `mnote-web` 的 document event channel 以 `rootUri` 共享:
- 前端订阅 `/api/local-folder/events?rootUri=...`,没有传 `documentId`
- 后端 `build_document_events_stream` 在没有 `documentId` 时不会按 Markdown 相对路径过滤。
- watcher 对非 Markdown 资源发出的 payload 中 `documentId` 为空。
- 前端收到空 `documentId` 后,仍会对同 root 下所有 document session 设置 `externalChangePending` 并调度正文刷新。
这会把附件上传、文件树拖入、资源创建等 root 级变化误投递到正文 session,形成文件冲突误报和跨页面状态污染。
另一个独立根因是冲突 UI 的 DOM 生命周期:
- `renderSessionConflictSurface` 会把冲突面板直接插入 `.document-pane`
- 页面切换时 `unmountEditorViewBinding` 只卸载 editor runtime 和事件监听,没有清理旧 session 的冲突面板。
- 因此旧页面已出现冲突时,新建/切换到新页面可能短暂看到旧冲突面板;刷新后整页 DOM 重建,问题消失。
## 合同
1. Markdown 正文 session 只订阅自己的 Markdown 文件事件。
2. 非 Markdown 资源事件只用于文件树/资源投影刷新,不触发正文 `externalChangePending`
3. 真实正文冲突只在“同一个 Markdown documentId + 当前 session dirty/saving/saveTimer/recent input”时出现。
4. 同 root 下不同 Markdown 页面必须有独立 document event channel。
5. 文件树 live stream 继续承担 root 级变化刷新,不依赖正文冲突链路。
## 已执行修复切片
- 前端 document event channel key 从 `rootUri` 收窄为 `rootUri#documentId`
- 前端订阅本地文件夹 document events 时携带 `documentId`
- 前端收到无 `documentId` 的 change payload 时直接忽略,不再设置正文 `externalChangePending`
- editor view unmount 时清理旧 session 的冲突面板,避免手动插入的 DOM 跨页面残留。
- 后端补充 document event filter 测试,确保资源文件路径不会匹配 Markdown 正文路径。
## 后续系统性收口
-`DocumentBufferStore` 作为正文冲突唯一状态底座,前端 session 只展示 buffer state,不自行拼第二套冲突事实。
- 为资源 tab 增加独立 resource watch 合同:按 `resourcePath` 监听资源文件,而不是复用 Markdown document event。
- 为新建页面增加创建者写入抑制或 bootstrap generation,避免未来新建空文件 watcher 与首次打开时序竞争。
-`task451` 的真实冲突 smoke 与上传/新建页面无冲突 smoke 合并成一组 local Markdown conflict regression。
## 验收
- 上传第一个/第二个附件后,主编辑区不显示 `mnote-editor-conflict-panel`
- 新建页面后,旧页面冲突 UI 不带入新页面。
- 新建页面后直接向文件树拖入文件,不显示文件冲突。
- dirty 当前 Markdown 后由外部修改同一个 `.md` 文件,仍显示冲突并保留 accept disk / keep current / diff 流程。
@@ -0,0 +1,235 @@
# 5-33 导航页与路由守卫执行清单 v1
> 状态:process
>
> Owner05-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 BRecent 闭环
- [ ] 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 / 中键打开浏览器新标签页不能破坏普通点击覆盖主页的主路径。