Files
mnote/design/03-rust-web/done/3-14-rust-web-dual-pane-editable-document-shell-v1.md
lix-2026 5f97800489 chore: align local-first control plane and editor fixes
- wire SQLite control-plane access/session paths into Rust web local-folder routes

- preserve local Markdown attachment semantics across upload, reload, and secondary-pane resource tabs

- refresh design governance docs, Reasonix task templates, and bug records

- retire root .mcp.json local MCP config
2026-05-23 23:38:42 +08:00

808 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 3-14 [done] Rust Web 双窗格可编辑文档壳方案 v1
> 更新时间:2026-05-08
>
> 状态说明:
> - 本稿对应的双窗格文档壳、共享 session、事件流复用、URL 恢复与自动化 smoke 已完成,故迁入 `done/`
> - 下文第 2 节保留的是实现前问题基线;当前真实完成状态与验证证据以第 15-16 节为准
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
## 1. 结论
截至 2026-05-09,当前 `3000` 已经具备:
- 右侧 `secondary pane` 文档容器
- pane 状态模型与 URL 恢复
- 同文档双开共享 session 与即时同步
- tree live stream / local-folder 事件流复用
- 对应 Rust 单测与浏览器 smoke
本稿记录的是这条双窗格能力从问题定义到完成验收的收口过程。
最终实现没有做成“再开一个完整页面”或“在右侧塞一个 iframe”,而是在现有 `workspace shell` 内引入了:
- 双窗格文档宿主
- 可复用的文档 session 层
- 可复用的 tree / local-folder 事件流
目标是做到接近 Wolai / VSCode 的第一阶段体验:
- 左侧主文档可继续导航
- 右侧文档独立保持不变
- 右侧可直接编辑
- 同一文档允许同时在左右两个 pane 打开
- 左右任一侧编辑,另一侧即时同步
- watcher / SSE 数量不随 pane 数线性膨胀
## 2. 当前问题
### 2.1 入口已存在,但容器不存在
当前右键菜单中的“在右侧边栏打开”会派发:
- `tree.page.open-right`
`mnote-web` 还没有:
- `secondary pane` 宿主
- pane 状态模型
- 右侧文档加载/关闭/替换逻辑
- 多编辑器实例同步机制
所以当前缺的不是单个按钮,而是整套双窗格运行时。
### 2.2 不能用“再开一页”糊过去
如果把右侧打开实现为:
- 新窗口
- iframe
- 第二个完整 document shell 页面
那么会重复创建:
- 标题控制器
- editor bootstrap
- tree SSE
- local-folder EventSource
- 保存队列
- 外部变更检测
这样做短期能跑,长期会把实时同步、冲突检测和性能问题一起放大。
### 2.3 本地 Markdown 和 Convex Workspace 都需要纳入统一模型
当前文档来源至少有两类:
- `convex_workspace`
- `local_folder`
双窗格方案必须同时覆盖这两类来源,不能只对 Convex 页面生效,再把本地 Markdown 排除到特殊路径。
## 3. 目标
- 在单一 `workspace shell` 内支持 `primary pane + secondary pane`
- 右侧 pane 默认直接可编辑
- 左侧切换页面时,右侧 pane 保持不变
- 支持同一文档同时在左右两个 pane 打开
- 同一文档双开时,左右编辑器共享同一份文档 session,并即时同步
- 复用 tree realtime 和 local-folder 外部变更链路,不按 pane 数重复建立 watcher
- reload 后恢复双窗格状态
## 4. 非目标
- 这一步不直接做 VSCode 式多 group + tabs
- 这一步不做跨浏览器窗口的协同光标
- 这一步不把 page aggregate、title controller、settings 全部重构一遍
- 这一步不处理“无限多个右侧 pane”
- 这一步不做右侧 pane 的独立页面树
## 5. 方案比较
### 方案 A:右侧嵌完整 document shell / iframe
优点:
- 实现快
缺点:
- 会复制整套文档运行时
- watcher / SSE / 保存逻辑重复
- 同一文档双开难以稳定同步
- 后续演进到 tabs / groups 时基本要重做
### 方案 B:单一 workspace shell + pane manager + document session registry
优点:
- 单一页面壳、单一树事件流
- 文档实例和外层壳职责清晰
- 同一文档双开可以共享 session
- 最适合作为后续 tabs / groups 的基础
缺点:
- 需要补一层 pane/session 运行时
- 需要收口 editor 实例与 session 的边界
### 方案 C:直接做 VSCode 风格 editor groups + tabs
优点:
- 长期形态最强
缺点:
- 范围明显过大
- 当前没有 tab/group/session 三层基础,直接做会把任务扩散
### 推荐
选方案 B。
它能在不过度扩面的前提下,把真正需要的底层模型一次收对。
## 6. 交互契约
### 6.1 窗格结构
页面维持一个共享 `workspace shell`,内容区拆为:
- `primary pane`
- `secondary pane`
共享部分只有一份:
- 左侧树
- 顶部壳
- workspace 级搜索
- tree realtime stream
### 6.2 打开规则
普通点击页面树:
- 继续只驱动 `primary pane`
“在右侧边栏打开”:
- 若右侧未打开,则创建 `secondary pane`
- 若右侧已打开,则替换右侧当前文档
- 不影响 `primary pane`
### 6.3 保持规则
当左侧主 pane 切换页面时:
- `secondary pane` 保持不变
只有以下操作会改变右侧内容:
- 再次执行“在右侧边栏打开”
- 用户主动在右侧 pane 内导航
- 用户关闭右侧 pane
### 6.4 同文档双开规则
允许同一文档同时出现在左右两个 pane:
- 两侧都可编辑
- 任一侧输入后,另一侧即时同步
- 保存只走同一个共享 session 的保存队列
这一步的目标是接近 VSCode
- 同一文件可在两个 editor view 中同时打开
- 修改后另一侧无需 reload 即更新
### 6.5 关闭与布局规则
第一阶段支持:
- 关闭右侧 pane
- 拖动分割线调整宽度
第一阶段不支持:
- 多个右侧 pane
- 窗格 tab bar
- 拖拽重排成多个 group
## 7. URL 与恢复策略
为保证 reload、复制链接和恢复状态稳定,建议 URL 扩展为:
- 主文档仍使用当前 `documentId`
- 右侧文档增加 `secondaryDocumentId`
对于本地 Markdown,再补:
- `secondarySourceKind`
- `secondaryRootUri`
示意:
```text
/documents/doc_main?workspaceId=ws_demo&secondaryDocumentId=doc_side
```
本地 Markdown 示例:
```text
/documents/local-md:README.md?sourceKind=local_folder&rootUri=file:///root&secondaryDocumentId=local-md:guide.md&secondarySourceKind=local_folder&secondaryRootUri=file:///root
```
恢复规则:
- 页面刷新后,按 URL 先恢复 `primary pane`
- 若存在 `secondaryDocumentId`,再恢复右侧 pane
- 若右侧文档失效,则降级为仅保留主 pane,并清理失效的 secondary 参数
## 8. 运行时分层
### 8.1 Pane Manager
新增 `PaneManager`,只负责:
- 当前布局是否双栏
- `primary / secondary` 各自绑定哪个文档
- 当前聚焦 pane
- 右侧 pane 的宽度状态
它不负责:
- 文档内容真相
- 保存逻辑
- 外部文件 watcher
### 8.2 Document Session Registry
新增 `DocumentSessionRegistry`,按“文档来源键”维护共享 session。
建议 session key 形状:
```text
{sourceKind}:{workspaceId or rootUri}:{documentId}
```
例如:
- `convex_workspace:ws_demo:doc_1`
- `local_folder:file:///mnt/Data1T/mnote/design:local-md:README.md`
每个 session 负责:
- 最新 `PageAggregate` 快照
- editor 共享 revision / conflictDetectionKey
- dirty 状态
- 保存队列
- 外部变更状态
- 已挂载的 editor view 列表
### 8.3 Editor View
每个 pane 内的实际编辑器实例叫 `EditorViewBinding`
它只负责:
- 渲染当前 session 内容
- 把本地输入回传到 session
- 接收 session 推送并更新本 view
- 维护本 view 的 selection / focus
因此:
- 一个 session 可挂多个 editor view
- 左右双开同一文档时,是“两个 view 绑定同一个 session”
## 9. 同文档双开同步模型
### 9.1 为什么不能只靠保存后 reload
如果左右同步只依赖:
- 一侧保存
- 另一侧通过 page aggregate reload
那么会出现:
- 输入延迟可见
- 正在编辑时 selection 容易抖动
- 双侧频繁互相 replaceContent
这不符合“同一文档双开”的编辑预期。
### 9.2 推荐同步口径
同一 session 下的多个 editor view 应采用:
- session 内即时内存同步
- session 到后端的单一保存队列
- 后端返回成功后再统一推进 revision / conflict key
也就是:
1. 左侧输入
2. 左侧 view 把变更交给 session
3. session 更新内存态
4. session 向右侧 view 广播变更
5. session 统一防抖保存
这样可以保证:
- 左右即时同步
- 只保存一次
- revision / conflictDetectionKey 始终只有一份
### 9.3 Selection 规则
同步内容时不强制同步对侧 selection。
即:
- 同步文档内容
- 不同步光标和滚动位置
这是第一阶段最稳的边界。
## 10. Tree Realtime 与 Watcher 复用
### 10.1 Tree SSE
每个浏览器窗口只保留一条 workspace 级 tree realtime 连接。
它属于 `workspace shell`,不属于某个 pane。
因此:
- 不因为打开右侧 pane 再创建第二条 tree SSE
### 10.2 Frontend Local Folder Event Registry
本地 Markdown 外部变更建议新增前端 `LocalFolderEventRegistry`
-`rootUri` 复用 EventSource
- 一个 `rootUri` 只建立一条 `/api/local-folder/events`
- 命中的文件变化再按 `documentId` 分发到对应 session
这样:
- 同一根目录下左右打开两个 Markdown 文件,不会创建两条 EventSource
- 同一 Markdown 在左右双开,也不会创建两条 EventSource
### 10.3 Backend Local Folder Watcher Registry
Rust 侧继续往前收口,新增或演进为 `LocalFolderWatcherRegistry`
-`rootUri` 维护单例 watcher
- 一个 `rootUri` 在单个服务进程内只保留一个 `notify` watcher
- 多个 SSE 订阅者共享 watcher 输出
watcher 发出的应是:
- 相对路径变化
- 事件类型
再由 session 判断:
- 当前文档是否命中
- 当前是否 dirty
- 是自动同步还是冲突提示
### 10.4 设计目标
最终目标不是:
- `N 个 pane = N 个 watcher`
而是:
- `同窗口同 workspace` 下 tree SSE 恒为 1
- `同窗口同 rootUri` 下 local-folder EventSource 恒为 1
- `同进程同 rootUri` 下 Rust notify watcher 恒为 1
## 11. 保存与冲突策略
### 11.1 Convex Workspace
同一 session 的多个 editor view 共享一份:
- dirty 状态
- saving 状态
- revision
只要 session 正在保存:
- 两侧都显示同一保存状态
### 11.2 Local Folder
同一 session 的多个 editor view 共享一份:
- conflictDetectionKey
- 外部变更状态
如果命中文件外部更新:
- session clean:自动拉新 aggregate,并广播到两个 view
- session dirtysession 标记 `external-change-conflict`,两个 view 统一显示冲突状态
注意这里的冲突判断应该只做一次,不能让左右各自判断、各自报警。
## 12. 对现有代码的落点建议
### 12.1 `layout.rs`
负责:
- 右键菜单事件继续发出
- 新增 pane layout 宿主 HTML
- 新增分割线、关闭按钮和右侧容器
- 新增 pane 级 URL 恢复逻辑
不负责:
- 文档 session 真相
### 12.2 `web_shell.rs`
负责:
- 文档 bootstrap 适配为可挂载到多个 pane
- 抽出 session / view 绑定逻辑
- 保存、replaceContent、外部变更处理改为 session 级而不是单 view 级
### 12.3 `local_folder_events.rs`
负责:
- 继续输出文件变化事件
- 后续收口为可复用 watcher registry
### 12.4 `styles.rs`
负责:
- 双栏布局
- 右侧 pane 样式
- 分割线和关闭按钮
- 窄屏下的降级显示
## 13. 分阶段执行
### Phase 1:固定双栏宿主
- 增加 `primary pane + secondary pane`
- 右侧可打开、替换、关闭
- 左侧切页不影响右侧
- URL 可恢复 secondary 状态
### Phase 2:文档 session 共享
- 同一文档双开挂到同一 session
- 左右输入即时同步
- 保存队列收口为 session 单例
### Phase 3watcher / EventSource 复用
- tree SSE 明确归属 workspace shell
- local-folder EventSource 按 rootUri 复用
- Rust watcher 按 rootUri 复用
### Phase 4:回归与体验补齐
- 关闭、替换、reload 恢复
- 同文档双开冲突链路
- local_folder / convex_workspace 双链路验证
## 14. 测试计划
### 14.1 Rust / 服务侧
至少覆盖:
- secondary URL 参数解析
- local-folder watcher registry 复用
- 相同 rootUri 多订阅者不会重复建 watcher
### 14.2 浏览器 smoke
至少覆盖:
- 右键“在右侧边栏打开”后出现右侧 pane
- 左侧切页后右侧保持不变
- 同一文档左右双开时,左改右同步、右改左同步
- 右侧关闭后 URL secondary 参数清理
- 本地 Markdown 双开时,不重复建立 rootUri 级事件流
### 14.3 回归边界
- 不破坏当前单页文档路径
- 不破坏 Page Aggregate 主链
- 不让 watcher 数和 pane 数线性相关
## 15. 详细 checklist
### 15.1 范围冻结与基线取证
- [x] 固定第一阶段只做 `primary pane + secondary pane`,不引入 tabs / 多 group。
- [x] 固定右侧 pane 默认可编辑,不做只读 preview 过渡态。
- [x] 固定左侧普通导航只驱动 `primary pane`
- [x] 固定左侧切页后 `secondary pane` 保持不变。
- [x] 固定同一文档允许左右双开。
- [x] 固定同一文档双开时只同步内容,不同步 selection / scroll。
- [x] 记录当前本地“在右侧边栏打开”仅发事件、无容器承接的基线证据。
- [x] 补一条 design 说明:第一阶段不扩到跨窗口同步,只覆盖单个浏览器窗口内双 pane。
> 当前阶段说明:
> - 第一阶段正式范围固定为“单个浏览器窗口内的 `primary pane + secondary pane` 双栏文档宿主”
> - 不扩到 tabs / groups / 跨窗口共享 session;跨窗口同步仍留给后续单独方案
> - 当前实现里 secondary pane 默认就是可编辑 editor,不存在只读 preview 过渡态
> - `task165-rust-web-dual-pane-smoke.js` 已覆盖“同一文档左右双开”和“双 editor 都可编辑”两条事实
> - `task165-rust-web-dual-pane-smoke.js` 还覆盖了:打开 `primary=README.md / secondary=side.md` 后,左侧普通导航切到 `third.md` 只会更新 primarysecondary 仍保持 `side.md`
> - `task165-rust-web-dual-pane-smoke.js` 还覆盖了:
> - fixture 同文档双开时,primary 输入后焦点仍留在 primarysecondary 输入后焦点仍留在 secondary
> - local folder 外部更新命中 `replaceContent` 后,共享页面滚动位置保持不变
> - 浏览器天然只有 1 份全局 DOM selection;当前验收以“对侧同步不会抢走当前 pane 焦点/选择上下文”作为 selection 不串 pane 的标准
> - 基线问题已记录:用户在最初验收阶段明确指出“在右侧边栏打开这个功能并没有实现”,对应证据为 `/mnt/Data1T/mnote/tmp/image copy 57.png`;当时表现为右侧打开链路没有真实宿主承接
### 15.2 URL 与路由参数
- [x] 为右侧 pane 增加 `secondaryDocumentId` 查询参数。
- [x] 为本地 Markdown 右侧 pane 增加 `secondarySourceKind` 查询参数。
- [x] 为本地 Markdown 右侧 pane 增加 `secondaryRootUri` 查询参数。
- [x] 约定缺少 secondary 参数时,页面回退为单 pane。
- [x] 约定 secondary 文档无效时,自动清理 secondary 参数并保留主 pane。
- [x] 固定 URL 更新策略:打开右侧、替换右侧、关闭右侧都同步回写 URL。
- [x] 补测试覆盖:reload 后按 URL 正确恢复双 pane。
### 15.3 Pane 宿主与布局容器
- [x]`workspace shell` 内容区引入双 pane 宿主节点。
- [x] 保留现有左侧树和顶部壳只渲染一份,不复制第二套外层壳。
- [x] 增加右侧 pane 关闭按钮。
- [x] 增加主次 pane 之间的分割线容器。
- [x] 增加分割线拖拽后的宽度状态存储。
- [x] 固定右侧 pane 最小宽度与默认宽度。
- [x] 窄屏下定义降级行为,避免直接把正文压到不可用宽度。
- [x] 补 smoke:打开右侧 pane 后 DOM 中出现稳定的 secondary 宿主标记。
### 15.4 Pane Manager
- [x] 新增 `PaneManager`,管理 `primary / secondary` 的打开状态。
- [x] `PaneManager` 管理当前 active pane,但不管理文档内容真相。
- [x] `PaneManager` 能处理“打开右侧”“替换右侧”“关闭右侧”三种动作。
- [x] `PaneManager` 能处理从 URL 恢复双 pane。
- [x] `PaneManager` 能处理 secondary 文档失效时的降级回退。
- [x] 固定“普通树点击默认作用于 primary pane”的路由策略。
- [x] 为后续 tabs / groups 预留最小扩展接口,但这一步不实现 tabs。
> 当前验证证据:
> - runtime 已收口 `paneRouteConfig / secondaryQueryParamNames / paneQueryParams` 这组最小角色配置
> - 当前仍只落到 `primary / secondary` 两个 role,没有引入 tabs / groups UI,但后续若扩展额外 pane role,查询参数映射不再散落在各处硬编码
### 15.5 Document Session Registry
- [x] 新增 `DocumentSessionRegistry`
- [x] 固定 session key 由 `sourceKind + workspaceId/rootUri + documentId` 组成。
- [x] Convex workspace 文档和 local folder 文档都接入同一 registry 接口。
- [x] 一个 session 可同时挂多个 editor view。
- [x] 同一 session 只保留一份 aggregate / revision / conflictDetectionKey / dirty 状态。
- [x] 同一 session 只保留一条保存队列。
- [x] session 生命周期与 view 挂载数关联,最后一个 view 卸载后可延迟释放。
- [x] 补测试:同 key 请求复用同一 session,不同 key 返回不同 session。
> 当前验证证据:
> - runtime 已收口 `createEditorViewBinding / unmountEditorViewBinding / scheduleDocumentSessionRelease / releaseDocumentSession`
> - `task165-rust-web-dual-pane-smoke.js` 通过 debug 快照断言:
> - fixture 同文档双开时 `sessionCount=1`、`viewCount=2`
> - local folder 不同文档双开时 `sessionCount=2`
> - 手工移除一个 pane 后,原 session 保持存活且 `viewCount=1`
> - 手工移除最后一个 pane 并等待释放窗口后,`sessionCount=0`、`localFolderChannelCount=0`
> - 由于 registry 位于浏览器端 inline runtime,这里的覆盖采用“浏览器自动化 + debug 快照断言”,不再额外补一层只验证字符串存在的伪单测
### 15.6 Editor View Binding
- [x] 把当前单编辑器挂载逻辑抽成可复用 `EditorViewBinding`
- [x] 一个 pane 对应一个 view binding,而不是一个完整独立 shell。
- [x] view binding 初始化时从 session 读取当前内容。
- [x] view binding 的本地输入统一回传 session,而不是直接各自保存。
- [x] view binding 接收 session 广播时能更新本 view 内容。
- [x] 来自同 session 的远端更新不能把当前 view 的 selection 直接重置为对侧状态。
- [x] 补 smoke:同文档双开时左右都出现真实可编辑 editor。
> 当前验证证据:
> - runtime 已显式引入 `createEditorViewBinding()` 与 `unmountEditorViewBinding()`,每个 pane 挂一个 bindingbinding 自己持有 mountId、事件监听和 observer 清理逻辑
> - `mountPane()` 现在只负责取 runtime / session、创建 binding、执行 mount,不再把所有逻辑堆在一个匿名挂载过程里
> - `task165-rust-web-dual-pane-smoke.js` 已自动断言:primary 输入后焦点仍留在 primarysecondary 输入后焦点仍留在 secondary
> - `task165-rust-web-dual-pane-smoke.js` 已自动断言:local folder 外部更新触发 `replaceContent` 后,共享页面滚动位置不被重置
### 15.7 同文档双开即时同步
- [x] 在 session 内建立“单写入源 + 多 view 广播”机制。
- [x] 左侧编辑时,右侧无需 reload 即更新。
- [x] 右侧编辑时,左侧无需 reload 即更新。
- [x] 同步链路不依赖保存成功后重新拉 aggregate。
- [x] 同步链路要避免 view A 更新触发 view B,再回写成无限循环。
- [x] 固定本地广播与服务端回包的优先级规则,避免旧回包覆盖较新内存态。
- [x] 补 smoke:左改右同步、右改左同步都能自动断言。
### 15.8 保存队列与共享状态
- [x] 保存状态提升到 session 级,而不是 view 级。
- [x] 同一 session 下多个 view 共享 `dirty / saving / saved / error` 状态。
- [x] 同一 session 只允许一个防抖保存队列处于活动中。
- [x] 服务端保存成功后统一更新所有 view 的 revision。
- [x] local folder 保存成功后统一更新所有 view 的 conflictDetectionKey。
- [x] 任一 view 触发保存失败时,错误状态广播到同 session 其他 view。
- [x] 补测试:同 session 双 view 输入后只触发一次保存请求。
### 15.9 Tree Realtime 复用
- [x] 固定 tree SSE 属于 `workspace shell`,不属于 pane。
- [x] 打开 secondary pane 后,不创建第二条 tree realtime EventSource。
- [x] 双 pane 状态下,页面标题、树 active 态、投影刷新仍由共享 tree 链路驱动。
- [x] 若后续 pane 内导航触发页面切换,仍复用同一条 tree SSE。
- [x] 补浏览器验证:双 pane 状态下 tree 事件流连接数保持为 1。
> 当前验证证据:
> - tree live bootstrap 与 `TREE_LIVE_CONTROLLER_JS` 挂在 `PageLayout` 的 workspace shell 上,不在 `DocumentPane` 内重复注入
> - `task165-rust-web-dual-pane-smoke.js` 在 fixture 双 pane 页面里断言:
> - 首次进入只出现 1 条 `GET /api/tree/events`
> - reload 后当前页面仍只出现 1 条 `GET /api/tree/events`
> - `task165-rust-web-dual-pane-smoke.js` 在 local folder 双 pane 页面里断言:
> - 左侧普通导航切到 `Local Third` 后,共享 topbar 标题同步切到 `Local Third`
> - 共享侧栏 active / selected 状态同步落到 `Local Third`
> - secondary pane 仍保持原来的 `Local Side` 文档,不复制第二套页面壳
> - `task165-rust-web-dual-pane-smoke.js` 在 fixture 双 pane 页面里还断言:
> - 通过共享 sidebar 导航切到 `Fixture Other` 后,当前页面只建立 1 条新的 `GET /api/tree/events`
> - 导航后 `secondaryDocumentId` 仍保持原值,没有因为 primary 切页而再额外起第二条 tree SSE
### 15.10 Frontend Local Folder Event Registry
- [x] 新增前端 `LocalFolderEventRegistry`
- [x]`rootUri` 复用 `/api/local-folder/events` EventSource。
- [x] 同一 `rootUri` 下两个不同 Markdown 文档复用同一条 EventSource。
- [x] 同一 Markdown 左右双开复用同一条 EventSource。
- [x] 文件变化事件到达后,先路由到 session,再决定是否刷新 view。
- [x] clean session 自动同步;dirty session 统一进入冲突状态。
- [x] 补 smoke:双 pane 本地 Markdown 场景下,不重复建立 rootUri 级事件流。
> 当前手工验证证据:
> - `local_folder` 同文档左右双开时,浏览器侧只建立 1 条 `GET /api/local-folder/events?rootUri=...`
> - 主 pane 输入后,secondary pane 无需 reload 即同步
> - 单轮输入后只出现 1 次 `POST /api/documents/save`
> - local folder watcher 回流已压缩到 1 次 `GET /api/page-aggregate/...`,不再出现重复回读
> - `task165-rust-web-dual-pane-smoke.js` 还自动断言了:
> - 同 rootUri 同文档双开只建立 1 条 `GET /api/local-folder/events`
> - 同 rootUri 不同文档双开也只建立 1 条 `GET /api/local-folder/events`
> - debug 快照下 `localFolderChannelCount=1`
### 15.11 Backend Local Folder Watcher Registry
- [x] Rust 侧引入或收口 `LocalFolderWatcherRegistry`
- [x]`rootUri` 维护单例 `notify` watcher。
- [x] 多个 SSE 订阅者共享同一个 watcher 输出。
- [x] watcher 只上报有效 Create / Remove / 真正 Modify 事件,不把 access 噪音当作变更。
- [x] SSE 订阅者断开后,registry 能在安全时机回收无引用 watcher。
- [x] 补 Rust 单测:相同 `rootUri` 多订阅不会重复创建 watcher。
- [x] 补 Rust 单测:不同 `rootUri` 会创建独立 watcher。
> 当前验证证据:
> - `cargo test -p mnote-web local_folder_watcher_registry -- --nocapture` 通过
> - `same_root_subscribers_share_single_watcher` 覆盖“同 root 不重复建 watcher”
> - `different_roots_create_independent_watchers` 覆盖“不同 root 分别建 watcher”
> - 订阅 drop 后 `active_watcher_count()` 回落到 `0`,覆盖 watcher 回收路径
### 15.12 外部变更与冲突处理
- [x] Convex 文档沿用共享 revision 判断,不为每个 view 单独做冲突模型。
- [x] local folder 文档沿用共享 conflictDetectionKey 判断,不为每个 view 单独判断。
- [x] 同一 session clean 时命中外部文件变化,两个 view 同时自动同步。
- [x] 同一 session dirty 时命中外部文件变化,两个 view 同时显示冲突状态。
- [x] 冲突提示提升到 session 级,避免左右状态不一致。
- [x] 补 smoke:dirty 状态下修改磁盘文件,左右两侧都进入统一冲突状态。
> 当前验证证据:
> - clean 状态下外部改写本地 Markdown 后,左右 pane 都会同步更新正文,并共同进入 `synced-external-change`
> - session 级 `conflictDetectionKey` 已前移为共享字段;Rust 侧 local Markdown `conflictDetectionKey` 已加固为 `mtime + len + content hash`
> - `cargo test -p mnote-web local_folder_source -- --nocapture` 通过,包含 `local_markdown_conflict_detection_key_changes_when_content_changes_with_same_size`
> - dirty 状态下通过独立外部保存请求写盘同一 local Markdown 文件时,左右 pane 会共同进入 `external-change-conflict`
> - 冲突提示文案在左右 pane 一致:`本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突`
### 15.13 标题、Page Aggregate 与页面壳一致性
- [x] 主标题控制器在双 pane 下不要错误写到另一个 pane。
- [x] 需要明确每个 pane 的标题显示与编辑归属。
- [x] 若同一文档双开,标题修改后两个 pane 标题都同步。
- [x] `PageAggregate` 拉取逻辑要支持 secondary 文档,不复制整页壳。
- [x] 不引入第二份页面真相,不在 pane 层重新拼文档对象。
> 当前验证证据:
> - 同一 local Markdown 左右双开时,在 primary pane 改标题并 blur 后:
> - secondary pane 标题输入同步为同一标题
> - 顶部 breadcrumb 标题同步
> - `document.title` 同步
> - 磁盘文件写入 `title:` frontmatter
> - primary/secondary 打开不同 local Markdown 时,在 primary pane 改标题后:
> - secondary pane 标题保持原文档标题,不被串改
> - 只有 primary 对应文件的 `title:` frontmatter 被改写
> - primary=`README.md`、secondary=`side-one.md` 时,secondary pane 渲染 `Side One / 右一正文`
> - 通过 `tree.page.open-right` 切换 secondary 到 `side-two.md` 后,secondary pane 渲染 `Side Two / 右二正文`
> - 切换前后页面都只保留 1 份侧栏/顶部壳,secondary editor root 数量保持为 1
> - `build_document_panes_bootstrap_json()` 直接把 primary / secondary 的 `PageAggregate + bootstrap` 序列化进同一份 `panes` contractpane runtime 只消费已有 aggregate,不再在 pane 层拼第二份页面对象
> - `DocumentPage / DocumentPane` 只消费 route 已经解出的 `title / document_id / pageOptions / pageSubtree` 等字段;secondary 只是同一 workspace shell 内的第二个 pane,不复制第二套 workspace 壳
### 15.14 交互与体验收尾
- [x] 右侧 pane 支持键盘关闭或显式关闭按钮关闭。
- [x] 右侧 pane 替换文档时,旧 session view 正确卸载。
- [x] 关闭右侧 pane 后回到单 pane 布局,不残留 secondary 宿主空壳。
- [x] reload 后能恢复上次 secondary 打开的文档。
- [x] 本地 Markdown 与 Convex 页面两条链都通过一次手工 smoke。
> 当前验证证据:
> - 带 `secondaryDocumentId / secondarySourceKind / secondaryRootUri` 参数的 local Markdown 双 pane URL 在 reload 后仍恢复左右两个 pane
> - 点击右侧 pane 关闭按钮后,URL 中 secondary 参数被清理,页面回落为单 pane,快照中不再出现 secondary 文档区
> - 本地 Markdown 链:同文档左右双开时,左右编辑器都可输入,左改右同步、右改左同步、单轮输入只出现 1 次保存请求、同 rootUri 只建立 1 条 local-folder EventSource
> - Convex / fixture 链:补齐 `MNOTE_WEB_QUERY_FIXTURES_JSON` + `MNOTE_WEB_MUTATION_FIXTURES_JSON` 后,双 pane 页面可正常输入保存,`POST /api/documents/save` 返回 `200`,状态进入 `saved`
### 15.15 验证命令与回归清单
- [x] 右侧 pane 宿主已完成一轮手工浏览器 smoke。
- [x] 右侧 pane 宿主补自动化浏览器 smoke。
- [x] 同文档双开同步已完成一轮手工浏览器 smoke。
- [x] 同文档双开同步补自动断言 smoke。
- [x] local folder 事件流复用已完成一轮手工浏览器 smoke。
- [x] local folder 事件流复用补自动断言 smoke。
- [x] watcher registry 复用已补 Rust 单测。
- [x] URL 恢复与 secondary 参数清理已完成一轮手工 smoke。
- [x] URL 恢复与 secondary 参数清理补自动化测试。
- [x]`3000` 常驻服务和临时端口两种模式下都验证一次,避免打到旧进程误判。
> 当前验证证据:
> - 手工浏览器 smoke 已覆盖:
> - secondary pane 真实渲染、可关闭、可 reload 恢复
> - 同文档双开时左改右同步、右改左同步
> - local folder 双 pane 下 rootUri 级事件流只建立 1 条,且单轮输入只出现 1 次保存请求
> - `node scripts/task165-rust-web-dual-pane-smoke.js` 通过,自动覆盖:
> - fixture 双 pane 宿主渲染
> - 左改右同步、右改左同步
> - 同 session 双 view 单轮输入只触发 1 次 `POST /api/documents/save`
> - fixture 双 pane 只建立 1 条 `GET /api/tree/events`
> - local folder 双 pane 只建立 1 条 `GET /api/local-folder/events`
> - local folder 同 root 不同文档双开时仍只建立 1 条 `GET /api/local-folder/events`
> - dual pane URL reload 恢复
> - 关闭 secondary 后 URL 参数清理,reload 后保持单 pane
> - 最后一个 view 卸载后延迟释放 session 与 local-folder channel
> - `cargo test -p mnote-web local_folder_watcher_registry -- --nocapture` 通过,已覆盖:
> - `same_root_subscribers_share_single_watcher`
> - `different_roots_create_independent_watchers`
> - `3000` 常驻服务当前仍指向旧进程:
> - 只读 `curl` 核查 `http://127.0.0.1:3000/documents/local-md:README.md?...secondary...` 时,响应虽为 `200`,但 HTML 中缺少 `data-has-secondary-pane`、`__MNOTE_DOCUMENT_PANES_BOOTSTRAP__`、`localFolderEventRegistry`、`__mnoteDebugDocumentSessions` 等当前实现标记
> - 当前 pid=`491543` 的 `3000` 进程不能代表本轮最新代码,因此这一条仍需在用户允许处理 `3000` 常驻服务后补正式验收
## 16. 完成判定
满足以下条件才算完成这一阶段:
- “在右侧边栏打开”不再只是菜单文案,而是真实右侧可编辑 pane
- 左侧切页时右侧保持不变
- 同一文档允许左右同时打开
- 左右编辑能即时同步,不依赖 reload
- tree SSE 仍为单条
- local-folder EventSource / notify watcher 不按 pane 数重复创建
## 17. 一句话收口
这一稿的关键不是“把页面再多开一个”,而是:
> **把 `mnote-web` 的文档页从单编辑器壳升级为共享 transport + 共享 session + 多 pane view 的正式宿主。**