diff --git a/design/03-rust-web/process/3-14-rust-web-dual-pane-editable-document-shell-v1.md b/design/03-rust-web/process/3-14-rust-web-dual-pane-editable-document-shell-v1.md new file mode 100644 index 00000000..ce807ae5 --- /dev/null +++ b/design/03-rust-web/process/3-14-rust-web-dual-pane-editable-document-shell-v1.md @@ -0,0 +1,795 @@ +# 3-14 [process] Rust Web 双窗格可编辑文档壳方案 v1 + +> 更新时间:2026-05-08 +> +> 关联文档: +> - `/mnt/Data1T/mnote/design/03-rust-web/process/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/process/5-6-page-aggregate-alignment-checklist-v1.md` + +## 1. 结论 + +当前 `3000` 已经把“在右侧边栏打开”挂到了右键菜单和搜索提示上,但实际只发出了 `tree.page.open-right` 事件,还没有真正的右侧文档容器、窗格状态和多实例同步模型。 + +这一步不应该做成“再开一个完整页面”或“在右侧塞一个 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 dirty:session 标记 `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 3:watcher / 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` 只会更新 primary,secondary 仍保持 `side.md` +> - `task165-rust-web-dual-pane-smoke.js` 还覆盖了: +> - fixture 同文档双开时,primary 输入后焦点仍留在 primary,secondary 输入后焦点仍留在 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 挂一个 binding,binding 自己持有 mountId、事件监听和 observer 清理逻辑 +> - `mountPane()` 现在只负责取 runtime / session、创建 binding、执行 mount,不再把所有逻辑堆在一个匿名挂载过程里 +> - `task165-rust-web-dual-pane-smoke.js` 已自动断言:primary 输入后焦点仍留在 primary,secondary 输入后焦点仍留在 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` contract,pane 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 的正式宿主。** diff --git a/rust/crates/mnote-web/src/app.rs b/rust/crates/mnote-web/src/app.rs index 31463dad..c3ab9dc5 100644 --- a/rust/crates/mnote-web/src/app.rs +++ b/rust/crates/mnote-web/src/app.rs @@ -1,5 +1,6 @@ use crate::middleware::request_context::inject_request_context; use crate::routes::build_router; +use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry; use axum::Router; use std::env; use std::fs; @@ -132,18 +133,24 @@ fn read_env_or_dotenv(key: &str) -> Option { #[derive(Debug, Clone)] pub struct AppState { config: Arc, + local_folder_watcher_registry: LocalFolderWatcherRegistry, } impl AppState { pub fn new(config: AppConfig) -> Self { Self { config: Arc::new(config), + local_folder_watcher_registry: LocalFolderWatcherRegistry::new(), } } pub fn config(&self) -> &AppConfig { self.config.as_ref() } + + pub fn local_folder_watcher_registry(&self) -> &LocalFolderWatcherRegistry { + &self.local_folder_watcher_registry + } } pub fn build_app(state: AppState) -> Router { diff --git a/rust/crates/mnote-web/src/lib.rs b/rust/crates/mnote-web/src/lib.rs index e53729f5..113355ac 100644 --- a/rust/crates/mnote-web/src/lib.rs +++ b/rust/crates/mnote-web/src/lib.rs @@ -1,6 +1,7 @@ pub mod app; pub mod context; pub mod error; +pub mod local_folder_watcher_registry; pub mod middleware; pub mod page_aggregate; pub mod routes; diff --git a/rust/crates/mnote-web/src/local_folder_watcher_registry.rs b/rust/crates/mnote-web/src/local_folder_watcher_registry.rs new file mode 100644 index 00000000..773bb8af --- /dev/null +++ b/rust/crates/mnote-web/src/local_folder_watcher_registry.rs @@ -0,0 +1,348 @@ +use notify::event::ModifyKind; +use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::sync::{broadcast, mpsc, oneshot}; + +#[derive(Clone)] +pub struct LocalFolderWatcherRegistry { + inner: Arc, +} + +impl std::fmt::Debug for LocalFolderWatcherRegistry { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LocalFolderWatcherRegistry") + .finish_non_exhaustive() + } +} + +impl LocalFolderWatcherRegistry { + pub fn new() -> Self { + Self { + inner: Arc::new(LocalFolderWatcherRegistryInner { + entries: Mutex::new(HashMap::new()), + }), + } + } + + pub(crate) fn subscribe( + &self, + canonical_root: &Path, + ) -> Result { + let key = canonical_root_uri(canonical_root); + let channel = self.inner.get_or_create_channel(&key, canonical_root)?; + channel.subscriber_count.fetch_add(1, Ordering::SeqCst); + Ok(LocalFolderWatcherSubscription { + receiver: channel.sender.subscribe(), + guard: LocalFolderWatcherSubscriptionGuard { + registry: Arc::downgrade(&self.inner), + key, + channel, + }, + }) + } + + #[cfg(test)] + pub fn active_watcher_count(&self) -> usize { + self.inner + .entries + .lock() + .expect("registry lock") + .len() + } +} + +struct LocalFolderWatcherRegistryInner { + entries: Mutex>>, +} + +impl LocalFolderWatcherRegistryInner { + fn get_or_create_channel( + &self, + key: &str, + canonical_root: &Path, + ) -> Result, String> { + if let Some(existing) = self.entries.lock().expect("registry lock").get(key).cloned() { + return Ok(existing); + } + + let channel = Arc::new(LocalFolderWatchChannel::new( + key.to_string(), + spawn_local_folder_watcher(key, canonical_root.to_path_buf())?, + )); + + let mut entries = self.entries.lock().expect("registry lock"); + if let Some(existing) = entries.get(key).cloned() { + return Ok(existing); + } + entries.insert(key.to_string(), channel.clone()); + Ok(channel) + } + + fn remove_if_idle(&self, key: &str, channel: &Arc) { + let mut entries = self.entries.lock().expect("registry lock"); + let should_remove = entries + .get(key) + .map(|current| { + Arc::ptr_eq(current, channel) + && channel.subscriber_count.load(Ordering::SeqCst) == 0 + }) + .unwrap_or(false); + if should_remove { + entries.remove(key); + channel.shutdown(); + } + } +} + +struct LocalFolderWatchChannel { + sender: broadcast::Sender, + subscriber_count: AtomicUsize, + shutdown_tx: Mutex>>, +} + +impl LocalFolderWatchChannel { + fn new( + _root_uri: String, + parts: (broadcast::Sender, oneshot::Sender<()>), + ) -> Self { + Self { + sender: parts.0, + subscriber_count: AtomicUsize::new(0), + shutdown_tx: Mutex::new(Some(parts.1)), + } + } + + fn shutdown(&self) { + if let Some(sender) = self.shutdown_tx.lock().expect("shutdown lock").take() { + let _ = sender.send(()); + } + } +} + +pub(crate) struct LocalFolderWatcherSubscription { + pub(crate) receiver: broadcast::Receiver, + guard: LocalFolderWatcherSubscriptionGuard, +} + +impl LocalFolderWatcherSubscription { + pub(crate) fn root_uri(&self) -> &str { + self.guard.key.as_str() + } +} + +struct LocalFolderWatcherSubscriptionGuard { + registry: Weak, + key: String, + channel: Arc, +} + +impl Drop for LocalFolderWatcherSubscriptionGuard { + fn drop(&mut self) { + let previous = self.channel.subscriber_count.fetch_sub(1, Ordering::SeqCst); + if previous != 1 { + return; + } + if let Some(registry) = self.registry.upgrade() { + registry.remove_if_idle(&self.key, &self.channel); + } + } +} + +fn spawn_local_folder_watcher( + root_uri: &str, + canonical_root: PathBuf, +) -> Result<(broadcast::Sender, oneshot::Sender<()>), String> { + let (event_sender, mut event_receiver) = mpsc::unbounded_channel::>(); + let mut watcher = RecommendedWatcher::new( + move |result| { + let _ = event_sender.send(result); + }, + Config::default(), + ) + .map_err(|error| format!("本地文件事件监听启动失败: {error}"))?; + watcher + .watch(&canonical_root, RecursiveMode::Recursive) + .map_err(|error| format!("本地文件夹监听失败: {error}"))?; + + let (sender, _) = broadcast::channel::(256); + let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); + let sender_for_task = sender.clone(); + let root_uri_for_task = root_uri.to_string(); + tokio::spawn(async move { + let _watcher = watcher; + loop { + tokio::select! { + _ = &mut shutdown_rx => { + return; + } + maybe_result = event_receiver.recv() => { + let Some(result) = maybe_result else { + return; + }; + let Ok(event) = result else { + continue; + }; + if !should_emit_event_kind(&event.kind) { + continue; + } + for path in event.paths { + if !is_markdown_path(&path) { + continue; + } + let Some(relative_path) = relative_path_string(&canonical_root, &path) else { + continue; + }; + let payload = json!({ + "sourceKind": "local_folder", + "rootUri": root_uri_for_task, + "relativePath": relative_path, + "documentId": format!("local-md:{}", encode_local_id_segment(&relative_path)), + "eventKind": format!("{:?}", event.kind), + "revision": event_revision(&path), + }); + let _ = sender_for_task.send(payload); + } + } + } + } + }); + + Ok((sender, shutdown_tx)) +} + +fn canonical_root_uri(root: &Path) -> String { + format!("file://{}", root.display()) +} + +fn relative_path_string(root: &Path, path: &Path) -> Option { + path.strip_prefix(root) + .ok() + .map(|relative| relative.to_string_lossy().replace('\\', "/")) + .filter(|relative| !relative.is_empty()) +} + +fn is_markdown_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| { + extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") + }) + .unwrap_or(false) +} + +fn should_emit_event_kind(kind: &EventKind) -> bool { + match kind { + EventKind::Create(_) | EventKind::Remove(_) => true, + EventKind::Modify(modify_kind) => matches!( + modify_kind, + ModifyKind::Any | ModifyKind::Data(_) | ModifyKind::Name(_) | ModifyKind::Metadata(_) + ), + _ => false, + } +} + +fn encode_local_id_segment(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.as_bytes() { + let character = *byte as char; + if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { + encoded.push(character); + } else { + encoded.push('~'); + encoded.push_str(&format!("{byte:02X}")); + } + } + encoded +} + +fn event_revision(path: &Path) -> u128 { + std::fs::metadata(path) + .ok() + .and_then(|metadata| metadata.modified().ok()) + .map(system_time_ms) + .unwrap_or_else(|| system_time_ms(SystemTime::now())) +} + +fn system_time_ms(time: SystemTime) -> u128 { + time.duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::{should_emit_event_kind, LocalFolderWatcherRegistry}; + use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind}; + use notify::EventKind; + + fn test_root(name: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!( + "mnote-local-folder-watcher-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&root).expect("create temp root"); + root + } + + #[tokio::test] + async fn same_root_subscribers_share_single_watcher() { + let registry = LocalFolderWatcherRegistry::new(); + let root = test_root("shared"); + + let first = registry.subscribe(&root).expect("first subscription"); + let second = registry.subscribe(&root).expect("second subscription"); + + assert_eq!(registry.active_watcher_count(), 1); + + drop(first); + tokio::task::yield_now().await; + assert_eq!(registry.active_watcher_count(), 1); + + drop(second); + tokio::task::yield_now().await; + assert_eq!(registry.active_watcher_count(), 0); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn different_roots_create_independent_watchers() { + let registry = LocalFolderWatcherRegistry::new(); + let first_root = test_root("first"); + let second_root = test_root("second"); + + let first = registry.subscribe(&first_root).expect("first root subscription"); + let second = registry + .subscribe(&second_root) + .expect("second root subscription"); + + assert_eq!(registry.active_watcher_count(), 2); + + drop(first); + drop(second); + tokio::task::yield_now().await; + assert_eq!(registry.active_watcher_count(), 0); + + let _ = std::fs::remove_dir_all(first_root); + let _ = std::fs::remove_dir_all(second_root); + } + + #[test] + fn event_kind_filter_ignores_access_events() { + assert!(should_emit_event_kind(&EventKind::Create(CreateKind::File))); + assert!(should_emit_event_kind(&EventKind::Modify(ModifyKind::Data( + DataChange::Content, + )))); + assert!(!should_emit_event_kind(&EventKind::Access(AccessKind::Read))); + } +} diff --git a/rust/crates/mnote-web/src/routes/local_folder_events.rs b/rust/crates/mnote-web/src/routes/local_folder_events.rs index 74c6c753..242e8bd0 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_events.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_events.rs @@ -1,18 +1,17 @@ +use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::local_folder_source::decode_local_id_segment; -use axum::extract::{Extension, Query}; +use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue}; use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; use futures_util::stream; -use notify::event::ModifyKind; -use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use serde::Deserialize; use serde_json::{json, Value}; use std::convert::Infallible; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tokio::sync::mpsc; +use tokio::sync::broadcast::error::RecvError; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -22,6 +21,7 @@ pub struct LocalFolderEventsQuery { } pub async fn local_folder_events( + State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result< @@ -43,78 +43,49 @@ pub async fn local_folder_events( .document_id .as_deref() .and_then(local_markdown_relative_path_from_document_id); - - let (sender, receiver) = mpsc::unbounded_channel::(); - let (watch_sender, mut watch_receiver) = - mpsc::unbounded_channel::>(); - let mut watcher = RecommendedWatcher::new( - move |result| { - let _ = watch_sender.send(result); - }, - Config::default(), - ) - .map_err(|error| { - WebError::internal(format!("本地文件事件监听启动失败: {error}")).with_context(&context) - })?; - watcher - .watch(&canonical_root, RecursiveMode::Recursive) - .map_err(|error| { - WebError::internal(format!("本地文件夹监听失败: {error}")).with_context(&context) - })?; - - let root_for_task = canonical_root.clone(); - let root_uri = query.root_uri.clone(); - tokio::spawn(async move { - let _watcher = watcher; - while let Some(result) = watch_receiver.recv().await { - let Ok(event) = result else { - continue; - }; - if !should_emit_event_kind(&event.kind) { - continue; - } - for path in event.paths { - if !is_markdown_path(&path) { - continue; - } - let Some(relative_path) = relative_path_string(&root_for_task, &path) else { - continue; - }; - if let Some(expected) = document_relative_path.as_deref() { - if expected != relative_path { - continue; - } - } - let payload = json!({ - "sourceKind": "local_folder", - "rootUri": root_uri, - "relativePath": relative_path, - "documentId": format!("local-md:{}", encode_local_id_segment(&relative_path)), - "eventKind": format!("{:?}", event.kind), - "revision": event_revision(&path), - }); - if sender.send(payload).is_err() { - return; - } - } - } - }); + let subscription = state + .local_folder_watcher_registry() + .subscribe(&canonical_root) + .map_err(|error| WebError::internal(error).with_context(&context))?; let initial = json!({ "sourceKind": "local_folder", - "rootUri": query.root_uri, + "rootUri": subscription.root_uri(), "documentId": query.document_id, "revision": system_time_ms(SystemTime::now()), }); - let stream = stream::unfold((Some(initial), receiver), |(initial, mut receiver)| async move { + let stream = stream::unfold( + (Some(initial), subscription, document_relative_path), + |(initial, mut subscription, document_relative_path)| async move { if let Some(payload) = initial { - return Some((Ok(stream_event("ready", &payload)), (None, receiver))); + return Some(( + Ok(stream_event("ready", &payload)), + (None, subscription, document_relative_path), + )); } - receiver - .recv() - .await - .map(|payload| (Ok(stream_event("change", &payload)), (None, receiver))) - }); + loop { + match subscription.receiver.recv().await { + Ok(payload) => { + if let Some(expected) = document_relative_path.as_deref() { + let relative_path = payload + .get("relativePath") + .and_then(Value::as_str) + .unwrap_or_default(); + if expected != relative_path { + continue; + } + } + return Some(( + Ok(stream_event("change", &payload)), + (None, subscription, document_relative_path), + )); + } + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => return None, + } + } + }, + ); let mut headers = HeaderMap::new(); if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") { @@ -151,55 +122,6 @@ fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option Option { - path.strip_prefix(root) - .ok() - .map(|relative| relative.to_string_lossy().replace('\\', "/")) - .filter(|relative| !relative.is_empty()) -} - -fn is_markdown_path(path: &Path) -> bool { - path.extension() - .and_then(|extension| extension.to_str()) - .map(|extension| { - extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") - }) - .unwrap_or(false) -} - -fn should_emit_event_kind(kind: &EventKind) -> bool { - match kind { - EventKind::Create(_) | EventKind::Remove(_) => true, - EventKind::Modify(modify_kind) => matches!( - modify_kind, - ModifyKind::Any | ModifyKind::Data(_) | ModifyKind::Name(_) | ModifyKind::Metadata(_) - ), - _ => false, - } -} - -fn encode_local_id_segment(value: &str) -> String { - let mut encoded = String::with_capacity(value.len()); - for byte in value.as_bytes() { - let character = *byte as char; - if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { - encoded.push(character); - } else { - encoded.push('~'); - encoded.push_str(&format!("{byte:02X}")); - } - } - encoded -} - -fn event_revision(path: &Path) -> u128 { - std::fs::metadata(path) - .ok() - .and_then(|metadata| metadata.modified().ok()) - .map(system_time_ms) - .unwrap_or_else(|| system_time_ms(SystemTime::now())) -} - fn system_time_ms(time: SystemTime) -> u128 { time.duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) @@ -237,20 +159,8 @@ mod tests { } #[test] - fn markdown_path_filter_accepts_markdown_files_only() { - assert!(is_markdown_path(Path::new("README.md"))); - assert!(is_markdown_path(Path::new("README.markdown"))); - assert!(!is_markdown_path(Path::new("image.png"))); - } - - #[test] - fn event_kind_filter_ignores_access_events() { - assert!(should_emit_event_kind(&EventKind::Create(notify::event::CreateKind::File))); - assert!(should_emit_event_kind(&EventKind::Modify(ModifyKind::Data( - notify::event::DataChange::Content, - )))); - assert!(!should_emit_event_kind(&EventKind::Access( - notify::event::AccessKind::Read, - ))); + fn parse_file_root_uri_requires_file_scheme() { + assert!(parse_file_root_uri("file:///tmp/example").is_ok()); + assert!(parse_file_root_uri("/tmp/example").is_err()); } } diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index efa51f8d..8774a159 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -2655,6 +2655,15 @@ fn local_markdown_conflict_detection_key( document_id: &str, markdown_path: &Path, ) -> Result { + let content = fs::read(markdown_path).map_err(|error| { + WebError::bad_request_code( + "local_markdown_read_failed", + format!( + "无法读取本地 Markdown 文件 {}: {error}", + markdown_path.display() + ), + ) + })?; let meta = fs::metadata(markdown_path).map_err(|error| { WebError::bad_request_code( "local_markdown_stat_failed", @@ -2665,8 +2674,11 @@ fn local_markdown_conflict_detection_key( ) })?; let modified_ms = system_time_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH)); + let mut hasher = DefaultHasher::new(); + content.hash(&mut hasher); + let content_hash = hasher.finish(); Ok(format!( - "local-md:{document_id}:{modified_ms}:{}", + "local-md:{document_id}:{modified_ms}:{}:{content_hash:016x}", meta.len() )) } @@ -2965,6 +2977,25 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn local_markdown_conflict_detection_key_changes_when_content_changes_with_same_size() { + let root = temp_root("mnote-local-conflict-key-content"); + let file = root.join("README.md"); + std::fs::write(&file, "aaaa\n").expect("write first content"); + + let first = super::local_markdown_conflict_detection_key("local-md:README.md", &file) + .expect("first conflict key"); + + std::fs::write(&file, "bbbb\n").expect("write second content"); + + let second = super::local_markdown_conflict_detection_key("local-md:README.md", &file) + .expect("second conflict key"); + + assert_ne!(first, second); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn local_markdown_parser_covers_basic_blocks_and_attachment_refs() { let blocks = crate::routes::local_markdown_parser::markdown_to_blocks( diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index e48cb724..ae64ffe8 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -45,6 +45,9 @@ pub struct DocumentShellQuery { pub workspace_id: Option, pub source_kind: Option, pub root_uri: Option, + pub secondary_document_id: Option, + pub secondary_source_kind: Option, + pub secondary_root_uri: Option, } #[derive(Debug, Deserialize)] @@ -60,17 +63,53 @@ pub async fn document_page_shell( Path(document_id): Path, Query(query): Query, ) -> Result { + let primary_source_kind = normalize_source_kind(query.source_kind.as_deref()); + let primary_root_uri = normalize_optional_query_value(query.root_uri.as_deref()); let aggregate = build_page_aggregate_snapshot( &state, &context, &document_id, query.workspace_id.as_deref(), - query.source_kind.as_deref(), - query.root_uri.as_deref(), + primary_source_kind, + primary_root_uri, ) .await?; let title = aggregate.head.title.as_str(); let workspace_id = aggregate.identity.workspace_id.clone(); + let requested_secondary_document_id = normalize_optional_owned(query.secondary_document_id.as_deref()); + let secondary_source_kind = normalize_source_kind( + query.secondary_source_kind + .as_deref() + .or(primary_source_kind), + ); + let secondary_root_uri = normalize_optional_query_value( + query.secondary_root_uri + .as_deref() + .or(primary_root_uri), + ); + let mut secondary_requested = false; + let mut secondary_invalid = false; + let secondary_aggregate = if let Some(secondary_document_id) = requested_secondary_document_id.as_deref() { + secondary_requested = true; + match build_page_aggregate_snapshot( + &state, + &context, + secondary_document_id, + query.workspace_id.as_deref(), + secondary_source_kind, + secondary_root_uri, + ) + .await + { + Ok(aggregate) => Some(aggregate), + Err(_) => { + secondary_invalid = true; + None + } + } + } else { + None + }; let default_workspace_name = format!("{} 的空间", state.config().dev_user_name); let mut workspace_projection = load_workspace_shell_projection( state.config(), @@ -117,8 +156,38 @@ pub async fn document_page_shell( let bootstrap_json = build_editor_bootstrap_json( &aggregate, &context, - query.source_kind.as_deref(), - query.root_uri.as_deref(), + primary_source_kind, + primary_root_uri, + ); + let secondary_page_subtree_json = secondary_aggregate + .as_ref() + .map(|aggregate| serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string())); + let secondary_page_options_json = secondary_aggregate + .as_ref() + .map(|aggregate| serde_json::to_string(&aggregate.layout.page_options).unwrap_or_else(|_| "null".to_string())); + let secondary_snapshot_json = secondary_aggregate + .as_ref() + .map(|aggregate| serde_json::to_string(aggregate).unwrap_or_else(|_| "null".to_string())); + let secondary_bootstrap_json = secondary_aggregate.as_ref().map(|aggregate| { + build_editor_bootstrap_json_with_ids( + aggregate, + &context, + secondary_source_kind, + secondary_root_uri, + "__MNOTE_SECONDARY_PAGE_AGGREGATE__", + "secondary", + ) + }); + let panes_bootstrap_json = build_document_panes_bootstrap_json( + &aggregate, + &context, + primary_source_kind, + primary_root_uri, + secondary_aggregate.as_ref(), + secondary_source_kind, + secondary_root_uri, + secondary_requested, + secondary_invalid, ); let body_content = crate::ssr::render_view(leptos::view! { }); let html = format!( @@ -140,10 +214,13 @@ pub async fn document_page_shell( {} - + {} + + {} + {} {} {} @@ -151,9 +228,20 @@ pub async fn document_page_shell( escape_html(title), crate::ssr::MNOTE_CSS, escape_html(&document_id), + secondary_requested, + secondary_invalid, body_content, escape_script_json(&snapshot_json), escape_script_json(&bootstrap_json), + escape_script_json(&panes_bootstrap_json), + secondary_snapshot_json + .as_ref() + .map(|value| format!(r#""#, escape_script_json(value))) + .unwrap_or_default(), + secondary_bootstrap_json + .as_ref() + .map(|value| format!(r#""#, escape_script_json(value))) + .unwrap_or_default(), render_document_title_controller_script(), render_editor_island_adapter_script(), ); @@ -168,11 +256,30 @@ pub(crate) fn build_editor_bootstrap_json( context: &RequestContext, source_kind: Option<&str>, root_uri: Option<&str>, +) -> String { + build_editor_bootstrap_json_with_ids( + aggregate, + context, + source_kind, + root_uri, + "__MNOTE_PAGE_AGGREGATE__", + "primary", + ) +} + +pub(crate) fn build_editor_bootstrap_json_with_ids( + aggregate: &PageAggregate, + context: &RequestContext, + source_kind: Option<&str>, + root_uri: Option<&str>, + page_aggregate_script_id: &str, + pane_role: &str, ) -> String { serde_json::to_string(&json!({ "schema": "mnote.editor_bootstrap.v1", "documentId": aggregate.identity.document_id, "workspaceId": aggregate.identity.workspace_id, + "paneRole": pane_role, "sourceKind": source_kind .map(str::trim) .filter(|value| !value.is_empty()) @@ -181,7 +288,7 @@ pub(crate) fn build_editor_bootstrap_json( .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(""), - "pageAggregateScriptId": "__MNOTE_PAGE_AGGREGATE__", + "pageAggregateScriptId": page_aggregate_script_id, "saveEndpoint": "/api/documents/save", "titleEndpoint": "/api/documents/title", "editorHostKind": "leptos_tiptap_island", @@ -192,34 +299,85 @@ pub(crate) fn build_editor_bootstrap_json( .unwrap_or_else(|_| "{}".to_string()) } +fn build_document_panes_bootstrap_json( + aggregate: &PageAggregate, + context: &RequestContext, + source_kind: Option<&str>, + root_uri: Option<&str>, + secondary_aggregate: Option<&PageAggregate>, + secondary_source_kind: Option<&str>, + secondary_root_uri: Option<&str>, + secondary_requested: bool, + secondary_invalid: bool, +) -> String { + let primary = json!({ + "role": "primary", + "aggregate": aggregate, + "bootstrap": serde_json::from_str::(&build_editor_bootstrap_json_with_ids( + aggregate, + context, + source_kind, + root_uri, + "__MNOTE_PAGE_AGGREGATE__", + "primary", + )).unwrap_or_else(|_| json!({})), + }); + let secondary = secondary_aggregate.map(|aggregate| { + json!({ + "role": "secondary", + "aggregate": aggregate, + "bootstrap": serde_json::from_str::(&build_editor_bootstrap_json_with_ids( + aggregate, + context, + secondary_source_kind, + secondary_root_uri, + "__MNOTE_SECONDARY_PAGE_AGGREGATE__", + "secondary", + )).unwrap_or_else(|_| json!({})), + }) + }); + serde_json::to_string(&json!({ + "schema": "mnote.document_panes_bootstrap.v1", + "secondaryRequested": secondary_requested, + "secondaryInvalid": secondary_invalid, + "panes": match secondary { + Some(secondary) => vec![primary, secondary], + None => vec![primary], + }, + })) + .unwrap_or_else(|_| "{}".to_string()) +} + +fn normalize_optional_query_value(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn normalize_source_kind(value: Option<&str>) -> Option<&str> { + normalize_optional_query_value(value) +} + +fn normalize_optional_owned(value: Option<&str>) -> Option { + normalize_optional_query_value(value).map(ToOwned::to_owned) +} + pub(crate) fn render_document_title_controller_script() -> &'static str { r#""# } @@ -317,6 +519,7 @@ pub(crate) fn render_document_title_controller_script() -> &'static str { pub(crate) fn render_editor_island_adapter_script() -> &'static str { r#""# @@ -1504,6 +2224,11 @@ mod tests { assert!(html.contains("data-mnote-action=\"open-page-settings\"")); assert!(html.contains("data-mnote-action=\"open-page-ai\"")); assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\"")); + assert!(html.contains("data-testid=\"mnote-document-workspace\"")); + assert!(html.contains("data-document-pane=\"true\"")); + assert!(html.contains("data-pane-role=\"primary\"")); + assert!(html.contains("data-document-pane-resizer=\"true\"")); + assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__")); assert!(html.contains("mnote.tree_live_bootstrap.v1")); assert!(html.contains("/api/tree/events")); @@ -1646,14 +2371,42 @@ mod tests { assert!(html.contains("asset.png")); assert!(html.contains("data-row-kind=\"markdown\"")); assert!(html.contains("data-mnote-action=\"open-local-folder\"")); - assert!(html.contains("refreshFromExternalFileChange")); + assert!(html.contains("refreshSessionFromExternalFileChange")); assert!(html.contains("/api/local-folder/events")); assert!(html.contains("new EventSource(url.toString())")); + assert!(html.contains("localFolderEventRegistry")); assert!(html.contains("command: 'replaceContent'")); assert!(html.contains("external-change-conflict")); assert!(!html.contains("setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);")); } + #[tokio::test] + async fn document_shell_renders_secondary_pane_contract_when_query_present() { + let response = app() + .oneshot( + Request::builder() + .uri("/documents/doc_1?workspaceId=ws_demo&secondaryDocumentId=doc_1") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("html"); + assert!(html.contains("data-has-secondary-pane=\"true\"")); + assert!(html.contains("data-pane-role=\"secondary\"")); + assert!(html.contains("data-mnote-pane-close=\"secondary\"")); + assert!(html.contains("__MNOTE_SECONDARY_PAGE_AGGREGATE__")); + assert!(html.contains("__MNOTE_SECONDARY_EDITOR_BOOTSTRAP__")); + assert!(html.contains("\"paneRole\":\"secondary\"")); + assert!(html.contains("\"secondaryRequested\":true")); + assert!(html.contains("\"secondaryInvalid\":false")); + } + #[tokio::test] async fn document_shell_bootstrap_preserves_inline_mark_conversion() { let response = app() diff --git a/rust/crates/mnote-web/src/ssr/pages/document.rs b/rust/crates/mnote-web/src/ssr/pages/document.rs index 7ec25561..7a73c196 100644 --- a/rust/crates/mnote-web/src/ssr/pages/document.rs +++ b/rust/crates/mnote-web/src/ssr/pages/document.rs @@ -7,6 +7,142 @@ use crate::ssr::pages::layout::PageLayout; use leptos::prelude::*; use serde_json::Value; +#[derive(Clone)] +pub struct DocumentPaneViewModel { + pub pane_role: &'static str, + pub title: String, + pub document_id: String, + pub workspace_id: String, + pub page_wide_layout: bool, + pub page_small_text: bool, + pub page_layout_density: String, + pub page_font: String, + pub page_show_heading_numbers: bool, + pub has_page_subtree: bool, + pub primary_legacy_ids: bool, + pub visible: bool, +} + +#[component] +fn DocumentPane(model: DocumentPaneViewModel) -> impl IntoView { + let pane_hidden = !model.visible; + let pane_role = model.pane_role.to_string(); + let pane_role_attr = pane_role.clone(); + let pane_role_attr_two = pane_role.clone(); + let pane_role_attr_three = pane_role.clone(); + let pane_role_attr_four = pane_role.clone(); + let pane_role_attr_five = pane_role.clone(); + let pane_label = if model.pane_role == "secondary" { + "右侧文档" + } else { + "主文档" + }; + let title_input_id = if model.primary_legacy_ids { + Some("mnote-page-title-input".to_string()) + } else { + None + }; + let editor_island_id = if model.primary_legacy_ids { + Some("mnote-editor-island".to_string()) + } else { + None + }; + let editor_root_id = if model.primary_legacy_ids { + Some("mnote-leptos-tiptap-island-editor-root".to_string()) + } else { + None + }; + + view! { + + } +} + /// MNOTE 文档页面 /// /// 渲染文档编辑器的 SSR 壳结构: @@ -37,6 +173,21 @@ pub fn DocumentPage( /// Page Aggregate 页面选项 JSON(可选) #[prop(optional)] page_options_json: Option, + /// 右侧文档标题(可选) + #[prop(optional)] + secondary_title: String, + /// 右侧文档 id(可选) + #[prop(optional)] + secondary_document_id: String, + /// 右侧 workspace id(可选) + #[prop(optional)] + secondary_workspace_id: String, + /// 右侧 Page Aggregate 子树 JSON(可选) + #[prop(optional)] + secondary_page_subtree_json: String, + /// 右侧页面选项 JSON(可选) + #[prop(optional)] + secondary_page_options_json: String, ) -> impl IntoView { let has_page_subtree = page_subtree_json .as_deref() @@ -71,62 +222,86 @@ pub fn DocumentPage( .unwrap_or("default") .to_string(); let page_show_heading_numbers = false; + let secondary_has_page_subtree = Some(secondary_page_subtree_json.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty() && *value != "null") + .is_some(); + let secondary_page_options = Some(secondary_page_options_json.as_str()) + .and_then(|value| serde_json::from_str::(value).ok()) + .unwrap_or(Value::Null); + let secondary_page_wide_layout = secondary_page_options + .get("wideLayout") + .and_then(Value::as_bool) + .unwrap_or(false); + let secondary_page_small_text = secondary_page_options + .get("smallText") + .and_then(Value::as_bool) + .unwrap_or(false); + let secondary_page_layout_density = secondary_page_options + .get("layoutDensity") + .and_then(Value::as_str) + .unwrap_or("normal") + .to_string(); + let secondary_page_font = secondary_page_options + .get("pageFont") + .and_then(Value::as_str) + .unwrap_or("default") + .to_string(); + let secondary_page_show_heading_numbers = false; + let secondary_visible = Some(secondary_document_id.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some(); + let primary_model = DocumentPaneViewModel { + pane_role: "primary", + title: title.clone(), + document_id: document_id.clone(), + workspace_id: workspace_id.clone(), + page_wide_layout, + page_small_text, + page_layout_density: page_layout_density.clone(), + page_font: page_font.clone(), + page_show_heading_numbers, + has_page_subtree, + primary_legacy_ids: true, + visible: true, + }; + let secondary_model = DocumentPaneViewModel { + pane_role: "secondary", + title: secondary_title, + document_id: secondary_document_id, + workspace_id: if secondary_workspace_id.trim().is_empty() { + workspace_id.clone() + } else { + secondary_workspace_id + }, + page_wide_layout: secondary_page_wide_layout, + page_small_text: secondary_page_small_text, + page_layout_density: secondary_page_layout_density, + page_font: secondary_page_font, + page_show_heading_numbers: secondary_page_show_heading_numbers, + has_page_subtree: secondary_has_page_subtree, + primary_legacy_ids: false, + visible: secondary_visible, + }; view! { -
-
- -

- -

-
- {workspace_label} - "已同步" -
-
-
-
-
-
-
-
-
+ + + + +
{workspace_label}
} } diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 7fc6b1c4..afaebf98 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -300,7 +300,7 @@ const SIDEBAR_TREE_JS: &str = r##" function copyWorkspaceSourceParams(targetUrl) { var params = new URLSearchParams(window.location.search); - ['sourceKind', 'rootUri'].forEach(function(name) { + ['sourceKind', 'rootUri', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) { var value = (params.get(name) || '').trim(); if (value) targetUrl.searchParams.set(name, value); }); diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index 529442f8..e598adc7 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -1828,12 +1828,68 @@ body { margin: 0 auto; } +.document-workspace { + --mnote-secondary-pane-width: minmax(320px, 42%); + --mnote-secondary-pane-resizer-width: 6px; + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: start; + width: 100%; + min-width: 0; +} + +.document-workspace[data-has-secondary-pane="true"] { + grid-template-columns: minmax(0, 1fr) var(--mnote-secondary-pane-resizer-width) var(--mnote-secondary-pane-width); +} + +.document-pane { + min-width: 0; +} + +.document-pane[hidden] { + display: none !important; +} + +.document-pane-resizer { + display: none; + width: var(--mnote-secondary-pane-resizer-width); + min-height: calc(100vh - 40px); + cursor: col-resize; + position: relative; +} + +.document-workspace[data-has-secondary-pane="true"] .document-pane-resizer { + display: block; +} + +.document-pane-resizer::after { + content: ""; + position: absolute; + left: 2px; + top: 64px; + bottom: 48px; + width: 2px; + border-radius: 999px; + background: rgba(27, 28, 28, 0.08); +} + .document-shell-header { max-width: none; padding: 0; margin: 0 0 50px; } +.document-pane-header-row { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.document-pane-header-row .document-title-heading { + flex: 1; + min-width: 0; +} + .document-page-icon { display: none; } @@ -1886,6 +1942,26 @@ body { text-underline-offset: 6px; } +.document-pane-close { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: #6D6A65; + cursor: pointer; + flex: 0 0 auto; +} + +.document-pane-close:hover { + background: #F4F3F3; + color: #1B1C1C; +} + .document-shell-meta { display: none; } @@ -2048,6 +2124,16 @@ body { width: min(100%, 980px); } +.document-pane[data-pane-role="secondary"] .document-shell { + width: min(100%, 820px); + padding-left: 28px; + padding-right: 20px; +} + +.document-pane[data-pane-role="secondary"] .document-shell-header { + margin-bottom: 36px; +} + .document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror, .document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p, .document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror li { @@ -2601,6 +2687,20 @@ body { display: none; } + .document-workspace[data-has-secondary-pane="true"] { + grid-template-columns: minmax(0, 1fr); + } + + .document-workspace[data-has-secondary-pane="true"] .document-pane-resizer { + display: none; + } + + .document-pane[data-pane-role="secondary"] .document-shell { + padding-top: 32px; + padding-left: 0; + padding-right: 0; + } + .document-shell { width: 100%; padding: 48px 28px 120px; @@ -2711,7 +2811,7 @@ mod tests { fn mnote_css_is_reasonably_sized() { // 至少 2000 字符才能包含完整样式 assert!(MNOTE_CSS.len() > 2000); - // 菜单与本地 SVG mask 图标会增加体积,仍保持在单文件可审阅范围内。 - assert!(MNOTE_CSS.len() < 46000); + // 当前整合了工作区壳、编辑器样式、树菜单和双 pane 布局,仍保持在单文件可审阅范围内。 + assert!(MNOTE_CSS.len() < 70000); } } diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.d.ts b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.d.ts index a8e0cf86..aa95fcb3 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.d.ts +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.d.ts @@ -46,29 +46,28 @@ export interface InitOutput { readonly memory: WebAssembly.Memory; readonly mount: (a: any, b: any) => [number, number, number]; readonly unmount: (a: number) => [number, number]; + readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void; + readonly intounderlyingsink_write: (a: number, b: any) => any; + readonly intounderlyingsink_close: (a: number) => any; + readonly intounderlyingsink_abort: (a: number, b: any) => any; readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void; readonly intounderlyingbytesource_type: (a: number) => number; readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number; readonly intounderlyingbytesource_start: (a: number, b: any) => void; readonly intounderlyingbytesource_pull: (a: number, b: any) => any; readonly intounderlyingbytesource_cancel: (a: number) => void; - readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void; - readonly intounderlyingsink_write: (a: number, b: any) => any; - readonly intounderlyingsink_close: (a: number) => any; - readonly intounderlyingsink_abort: (a: number, b: any) => any; readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void; readonly intounderlyingsource_pull: (a: number, b: any) => any; readonly intounderlyingsource_cancel: (a: number) => void; - readonly wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number]; - readonly wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void; - readonly wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b: (a: number, b: number, c: any) => void; - readonly wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void; - readonly wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void; - readonly wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void; - readonly wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number; - readonly wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398: (a: number, b: number) => void; - readonly wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void; - readonly wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void; + readonly wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number]; + readonly wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void; + readonly wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void; + readonly wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void; + readonly wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void; readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; readonly __externref_table_alloc: () => number; diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js index 2d62ff3c..e7422952 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js @@ -785,7 +785,7 @@ function __wbg_get_imports() { const a = state0.a; state0.a = 0; try { - return wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(a, state0.b, arg0, arg1); + return wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(a, state0.b, arg0, arg1); } finally { state0.a = a; } @@ -1014,24 +1014,6 @@ function __wbg_get_imports() { const ret = arg0.right; return ret; }, - __wbg_run_0b0a622deae25fda: function(arg0, arg1, arg2) { - try { - var state0 = {a: arg1, b: arg2}; - var cb0 = () => { - const a = state0.a; - state0.a = 0; - try { - return wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(a, state0.b, ); - } finally { - state0.a = a; - } - }; - const ret = arg0.run(cb0); - return ret; - } finally { - state0.a = 0; - } - }, __wbg_scrollHeight_5fe8cbb97ae906d8: function(arg0) { const ret = arg0.scrollHeight; return ret; @@ -1039,10 +1021,21 @@ function __wbg_get_imports() { __wbg_scrollIntoView_7725227126cff177: function(arg0, arg1) { arg0.scrollIntoView(arg1 !== 0); }, + __wbg_scrollTo_f357e55cd25f406f: function(arg0, arg1, arg2) { + arg0.scrollTo(arg1, arg2); + }, __wbg_scrollTop_f548101d48000fe9: function(arg0) { const ret = arg0.scrollTop; return ret; }, + __wbg_scrollX_c821c038bb4594f3: function() { return handleError(function (arg0) { + const ret = arg0.scrollX; + return ret; + }, arguments); }, + __wbg_scrollY_e80bdf3571bdf5f3: function() { return handleError(function (arg0) { + const ret = arg0.scrollY; + return ret; + }, arguments); }, __wbg_search_ceee70e1153af3ec: function() { return handleError(function (arg0, arg1) { const ret = arg1.search; const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); @@ -1082,6 +1075,9 @@ function __wbg_get_imports() { __wbg_set_body_be11680f34217f75: function(arg0, arg1) { arg0.body = arg1; }, + __wbg_set_bubbles_50e942fa177ba6bd: function(arg0, arg1) { + arg0.bubbles = arg1 !== 0; + }, __wbg_set_detail_68bec5c91196ba57: function(arg0, arg1) { arg0.detail = arg1; }, @@ -1115,10 +1111,6 @@ function __wbg_get_imports() { getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); }, - __wbg_static_accessor_CREATE_TASK_f3ab6a6954bda493: function() { - const ret = typeof console === 'undefined' ? null : console?.createTask; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }, __wbg_static_accessor_GLOBAL_8cfadc87a297ca02: function() { const ret = typeof global === 'undefined' ? null : global; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); @@ -1196,12 +1188,6 @@ function __wbg_get_imports() { const ret = arg0.view; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); }, - __wbg_warn_3cc416af27dbdc02: function(arg0) { - console.warn(arg0); - }, - __wbg_warn_bd0f407277b102f4: function(arg0, arg1, arg2) { - console.warn(arg0, arg1, arg2); - }, __wbg_width_9673a519d7bd5a6a: function(arg0) { const ret = arg0.width; return ret; @@ -1219,43 +1205,43 @@ function __wbg_get_imports() { } }, arguments); }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1476, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. - const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1025, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12); return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1715, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 794, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08); return ret; }, __wbindgen_cast_0000000000000003: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1806, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 973, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5); return ret; }, __wbindgen_cast_0000000000000004: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1632, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 916, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a); return ret; }, __wbindgen_cast_0000000000000005: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1717, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 973, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4); return ret; }, __wbindgen_cast_0000000000000006: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1631, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 918, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f); return ret; }, __wbindgen_cast_0000000000000007: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1653, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. - const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 940, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5); return ret; }, __wbindgen_cast_0000000000000008: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1716, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9); + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 976, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c); return ret; }, __wbindgen_cast_0000000000000009: function(arg0) { @@ -1299,48 +1285,43 @@ function __wbg_get_imports() { }; } -function wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398(arg0, arg1) { - wasm.wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398(arg0, arg1); +function wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1) { + wasm.wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1); } -function wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f(arg0, arg1) { - wasm.wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f(arg0, arg1); +function wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1) { + wasm.wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1); } -function wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9(arg0, arg1) { - wasm.wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9(arg0, arg1); +function wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1) { + wasm.wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1); } -function wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(arg0, arg1) { - const ret = wasm.wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(arg0, arg1); - return ret !== 0; +function wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08(arg0, arg1, arg2); } -function wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b(arg0, arg1, arg2) { - wasm.wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2); } -function wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2) { - wasm.wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2); } -function wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f(arg0, arg1, arg2) { - wasm.wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2); } -function wasm_bindgen__convert__closures_____invoke__h837fba73fce77300(arg0, arg1, arg2) { - wasm.wasm_bindgen__convert__closures_____invoke__h837fba73fce77300(arg0, arg1, arg2); -} - -function wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7(arg0, arg1, arg2) { - const ret = wasm.wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2); if (ret[1]) { throw takeFromExternrefTable0(ret[0]); } } -function wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(arg0, arg1, arg2, arg3) { - wasm.wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(arg0, arg1, arg2, arg3); +function wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3); } diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm index d467c033..de88be41 100644 Binary files a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm and b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm differ diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm.d.ts b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm.d.ts index 8e946c6f..472384e5 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm.d.ts +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm.d.ts @@ -3,29 +3,28 @@ export const memory: WebAssembly.Memory; export const mount: (a: any, b: any) => [number, number, number]; export const unmount: (a: number) => [number, number]; +export const __wbg_intounderlyingsink_free: (a: number, b: number) => void; +export const intounderlyingsink_write: (a: number, b: any) => any; +export const intounderlyingsink_close: (a: number) => any; +export const intounderlyingsink_abort: (a: number, b: any) => any; export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void; export const intounderlyingbytesource_type: (a: number) => number; export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number; export const intounderlyingbytesource_start: (a: number, b: any) => void; export const intounderlyingbytesource_pull: (a: number, b: any) => any; export const intounderlyingbytesource_cancel: (a: number) => void; -export const __wbg_intounderlyingsink_free: (a: number, b: number) => void; -export const intounderlyingsink_write: (a: number, b: any) => any; -export const intounderlyingsink_close: (a: number) => any; -export const intounderlyingsink_abort: (a: number, b: any) => any; export const __wbg_intounderlyingsource_free: (a: number, b: number) => void; export const intounderlyingsource_pull: (a: number, b: any) => any; export const intounderlyingsource_cancel: (a: number) => void; -export const wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number]; -export const wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void; -export const wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b: (a: number, b: number, c: any) => void; -export const wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void; -export const wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void; -export const wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void; -export const wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number; -export const wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398: (a: number, b: number) => void; -export const wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void; -export const wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void; +export const wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number]; +export const wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void; +export const wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void; +export const wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void; +export const wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void; +export const wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void; export const __wbindgen_malloc: (a: number, b: number) => number; export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; export const __externref_table_alloc: () => number; diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 236d4a37..6f4b1179 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -19,7 +19,7 @@ use web_sys::{ MouseEvent, Node, RequestInit, RequestMode, Response, Storage, WheelEvent, }; -const EDITOR_STAGE_SELECTOR: &str = "#editor-stage"; +const EDITOR_STAGE_SELECTOR: &str = "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]"; const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror"; const HANDLE_SHELL_SELECTOR: &str = ".block-handle-shell"; const SPIKE_STORAGE_KEY: &str = "mnote.leptos-tiptap-spike.document"; @@ -2497,6 +2497,22 @@ mod tests { ); } + #[test] + fn runtime_editor_instance_id_uses_mount_id_when_available() { + assert_eq!( + runtime_editor_instance_id(Some(7)), + "mnote-leptos-tiptap-spike-7" + ); + } + + #[test] + fn runtime_editor_instance_id_falls_back_for_standalone_mode() { + assert_eq!( + runtime_editor_instance_id(None), + "mnote-leptos-tiptap-spike-standalone" + ); + } + #[test] fn persisted_document_storage_key_isolated_per_document_identity() { let doc_a = @@ -2625,6 +2641,13 @@ fn runtime_mount_options() -> Option { RUNTIME_MOUNT_OPTIONS.with(|cell| cell.borrow().clone()) } +fn runtime_editor_instance_id(mount_id: Option) -> String { + mount_id + .filter(|value| *value > 0) + .map(|value| format!("mnote-leptos-tiptap-spike-{value}")) + .unwrap_or_else(|| "mnote-leptos-tiptap-spike-standalone".to_string()) +} + fn runtime_event_target() -> Option { if let Some((_, target, _)) = runtime_mount_context() { return Some(target); @@ -2762,6 +2785,40 @@ fn schedule_scroll_mnote_block_anchor_from_hash_retry(remaining: u8) { callback.forget(); } +fn current_viewport_scroll() -> Option<(f64, f64)> { + let win = window()?; + let x = win.scroll_x().ok()?; + let y = win.scroll_y().ok()?; + Some((x, y)) +} + +fn restore_viewport_scroll(x: f64, y: f64) { + if let Some(win) = window() { + win.scroll_to_with_x_and_y(x, y); + } +} + +fn schedule_restore_viewport_scroll(x: f64, y: f64, remaining: u8) { + restore_viewport_scroll(x, y); + let Some(win) = window() else { + return; + }; + if remaining == 0 { + return; + } + let callback = Closure::::new(move || { + restore_viewport_scroll(x, y); + if remaining > 1 { + schedule_restore_viewport_scroll(x, y, remaining - 1); + } + }); + let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0( + callback.as_ref().unchecked_ref(), + 80, + ); + callback.forget(); +} + fn dispatch_runtime_event(event_name: &'static str, payload: &T) where T: Serialize, @@ -2817,22 +2874,42 @@ fn dispatch_ready_event(payload: &ReadyPayload) { dispatch_runtime_event(READY_EVENT, payload); } +fn dispatch_ready_event_to_target(target: &EventTarget, payload: &ReadyPayload) { + dispatch_custom_event_to_target(target, READY_EVENT, payload); +} + fn dispatch_change_event(payload: &ChangePayload) { dispatch_runtime_event(CHANGE_EVENT, payload); } +fn dispatch_change_event_to_target(target: &EventTarget, payload: &ChangePayload) { + dispatch_custom_event_to_target(target, CHANGE_EVENT, payload); +} + fn dispatch_state_event(payload: &StatePayload) { dispatch_runtime_event(STATE_EVENT, payload); } +fn dispatch_state_event_to_target(target: &EventTarget, payload: &StatePayload) { + dispatch_custom_event_to_target(target, STATE_EVENT, payload); +} + fn dispatch_status_event(payload: &HostStatusPayload) { dispatch_runtime_event(STATUS_EVENT, payload); } +fn dispatch_status_event_to_target(target: &EventTarget, payload: &HostStatusPayload) { + dispatch_custom_event_to_target(target, STATUS_EVENT, payload); +} + fn dispatch_selection_event(payload: &SelectionPayload) { dispatch_runtime_event(SELECTION_EVENT, payload); } +fn dispatch_selection_event_to_target(target: &EventTarget, payload: &SelectionPayload) { + dispatch_custom_event_to_target(target, SELECTION_EVENT, payload); +} + fn register_unmount_handle( id: u32, target: EventTarget, @@ -4960,6 +5037,52 @@ fn dispatch_runtime_state( )); } +fn dispatch_runtime_state_to_target( + target: &EventTarget, + document_id: Option, + workspace_id: Option, + title: String, + dirty_count: u32, + hovered_block: Option, + editor_focused: bool, + slash_open: bool, + turn_into_open: bool, + color_menu_open: bool, + more_menu_open: bool, + read_only: bool, +) { + let state = state_payload( + document_id.clone(), + workspace_id.clone(), + title.clone(), + dirty_count, + hovered_block.clone(), + editor_focused, + slash_open, + turn_into_open, + color_menu_open, + more_menu_open, + read_only, + ); + dispatch_state_event_to_target(target, &state); + dispatch_status_event_to_target( + target, + &status_payload( + document_id, + workspace_id, + title, + dirty_count, + hovered_block, + editor_focused, + slash_open, + turn_into_open, + color_menu_open, + more_menu_open, + read_only, + ), + ); +} + fn send_selection_state( selection: &TiptapSelectionState, editor_focused: bool, @@ -4972,6 +5095,18 @@ fn send_selection_state( )); } +fn send_selection_state_to_target( + target: &EventTarget, + selection: &TiptapSelectionState, + editor_focused: bool, + current_block_index: Option, +) { + dispatch_selection_event_to_target( + target, + &selection_payload(selection, editor_focused, current_block_index), + ); +} + fn apply_host_document_payload( editor: TiptapEditorHandle, payload: HostDocumentPayload, @@ -5004,6 +5139,7 @@ fn apply_host_document_payload( set_conflict_detection_key.set(next_conflict_detection_key.clone()); if let Some(content) = payload.content { + let viewport_scroll = current_viewport_scroll(); let next_content = TiptapContent::json(content); match editor.set_content(next_content) { Ok(()) => { @@ -5015,6 +5151,9 @@ fn apply_host_document_payload( ); set_dirty_count.set(0); set_command_feedback.set("宿主文档已同步到编辑器".to_string()); + if let Some((x, y)) = viewport_scroll { + schedule_restore_viewport_scroll(x, y, 10); + } } Err(err) => { set_command_feedback.set(format!("宿主文档同步失败:{err}")); @@ -6303,6 +6442,16 @@ fn normalize_layout_density(value: Option) -> String { #[component] fn App(mount_options: MountOptions) -> impl IntoView { let editor = TiptapEditorHandle::new(); + let current_mount_id = runtime_mount_context().map(|(id, _, _)| id); + let editor_instance_id = runtime_editor_instance_id(current_mount_id); + let editor_stage_id = format!("{editor_instance_id}-stage"); + let runtime_event_target = runtime_mount_context() + .map(|(_, target, _)| target) + .or_else(|| { + window() + .and_then(|win| win.document()) + .map(|document| document.into()) + }); let initial_document_id = mount_options .document_id .clone() @@ -6420,6 +6569,11 @@ fn App(mount_options: MountOptions) -> impl IntoView { .as_ref() .and_then(|opts| opts.embed_default_block_id.clone()), ); + let command_event_target = runtime_event_target.clone(); + let ready_event_target = runtime_event_target.clone(); + let change_event_target = runtime_event_target.clone(); + let selection_event_target = runtime_event_target.clone(); + let slash_change_event_target = runtime_event_target.clone(); { let block_menu_open = block_menu_open; @@ -6483,6 +6637,7 @@ fn App(mount_options: MountOptions) -> impl IntoView { let set_show_heading_numbers = set_show_heading_numbers; let set_embed_default_block_id = set_embed_default_block_id; move |_| { + let command_event_target = command_event_target.clone(); let command_listener = Closure::::wrap(Box::new(move |event: Event| { let Some(custom_event) = event.dyn_ref::() else { @@ -6564,19 +6719,22 @@ fn App(mount_options: MountOptions) -> impl IntoView { let editable = payload.editable.unwrap_or(true); set_editor_editable.set(editable); set_read_only.set(!editable); - dispatch_runtime_state( - document_id.get_untracked(), - workspace_id.get_untracked(), - title.get_untracked(), - dirty_count.get_untracked(), - hovered_block.get_untracked(), - editor_focused.get_untracked(), - slash_open.get_untracked(), - turn_into_open.get_untracked(), - color_menu_open.get_untracked(), - more_menu_open.get_untracked(), - read_only.get_untracked(), - ); + if let Some(target) = command_event_target.as_ref() { + dispatch_runtime_state_to_target( + target, + document_id.get_untracked(), + workspace_id.get_untracked(), + title.get_untracked(), + dirty_count.get_untracked(), + hovered_block.get_untracked(), + editor_focused.get_untracked(), + slash_open.get_untracked(), + turn_into_open.get_untracked(), + color_menu_open.get_untracked(), + more_menu_open.get_untracked(), + read_only.get_untracked(), + ); + } } HostCommandKind::SetPageOptions => { if let Some(page_options) = payload.page_options { @@ -6602,22 +6760,27 @@ fn App(mount_options: MountOptions) -> impl IntoView { let current_block = current_block_info_from_index( hovered_block.get_untracked().map(|block| block.index), ); - dispatch_status_event(&HostStatusPayload { - document_id: document_id.get_untracked(), - workspace_id: workspace_id.get_untracked(), - title: title.get_untracked(), - dirty_count: dirty_count.get_untracked(), - selected_block_index: current_block.index, - current_block_id: current_block.block_id, - editor_focused: editor_focused.get_untracked(), - read_only: read_only.get_untracked(), - slash_open: slash_open.get_untracked(), - toolbar_open: toolbar_overlay_locked( - turn_into_open.get_untracked(), - color_menu_open.get_untracked(), - more_menu_open.get_untracked(), - ), - }); + if let Some(target) = command_event_target.as_ref() { + dispatch_status_event_to_target( + target, + &HostStatusPayload { + document_id: document_id.get_untracked(), + workspace_id: workspace_id.get_untracked(), + title: title.get_untracked(), + dirty_count: dirty_count.get_untracked(), + selected_block_index: current_block.index, + current_block_id: current_block.block_id, + editor_focused: editor_focused.get_untracked(), + read_only: read_only.get_untracked(), + slash_open: slash_open.get_untracked(), + toolbar_open: toolbar_overlay_locked( + turn_into_open.get_untracked(), + color_menu_open.get_untracked(), + more_menu_open.get_untracked(), + ), + }, + ); + } } HostCommandKind::InsertInlineReference | HostCommandKind::InsertEmbedReference => { @@ -7379,7 +7542,7 @@ fn App(mount_options: MountOptions) -> impl IntoView { }}
impl IntoView { let show_category = index == 0 || SLASH_ACTIONS[index - 1].category != category; let testid = format!("slash-item-{}", action.id); + let slash_change_event_target = slash_change_event_target.clone(); view! { <> {if show_category { @@ -9189,39 +9353,45 @@ fn App(mount_options: MountOptions) -> impl IntoView { set_html_output.set(html.clone()); set_document_json.set(snapshot.clone()); set_json_output.set(json_text); - dispatch_change_event(&ChangePayload { - document_id: document_id.get_untracked(), - workspace_id: workspace_id.get_untracked(), - title: title.get_untracked(), - content: snapshot.clone(), - meta: ChangeMetaPayload { - dirty_count: dirty_count.get_untracked(), - editor_focused: editor_focused.get_untracked(), - slash_open: slash_open.get_untracked(), - toolbar_open: toolbar_overlay_locked( - turn_into_open.get_untracked(), - color_menu_open.get_untracked(), - more_menu_open.get_untracked(), - ), - selected_block_index: hovered_block.get_untracked().map(|block| block.index), - revision: revision.get_untracked(), - conflict_detection_key: conflict_detection_key.get_untracked(), - read_only: read_only.get_untracked(), - }, - }); - dispatch_runtime_state( - document_id.get_untracked(), - workspace_id.get_untracked(), - title.get_untracked(), - dirty_count.get_untracked(), - hovered_block.get_untracked(), - editor_focused.get_untracked(), - slash_open.get_untracked(), - turn_into_open.get_untracked(), - color_menu_open.get_untracked(), - more_menu_open.get_untracked(), - read_only.get_untracked(), - ); + if let Some(target) = slash_change_event_target.as_ref() { + dispatch_change_event_to_target( + target, + &ChangePayload { + document_id: document_id.get_untracked(), + workspace_id: workspace_id.get_untracked(), + title: title.get_untracked(), + content: snapshot.clone(), + meta: ChangeMetaPayload { + dirty_count: dirty_count.get_untracked(), + editor_focused: editor_focused.get_untracked(), + slash_open: slash_open.get_untracked(), + toolbar_open: toolbar_overlay_locked( + turn_into_open.get_untracked(), + color_menu_open.get_untracked(), + more_menu_open.get_untracked(), + ), + selected_block_index: hovered_block.get_untracked().map(|block| block.index), + revision: revision.get_untracked(), + conflict_detection_key: conflict_detection_key.get_untracked(), + read_only: read_only.get_untracked(), + }, + }, + ); + dispatch_runtime_state_to_target( + target, + document_id.get_untracked(), + workspace_id.get_untracked(), + title.get_untracked(), + dirty_count.get_untracked(), + hovered_block.get_untracked(), + editor_focused.get_untracked(), + slash_open.get_untracked(), + turn_into_open.get_untracked(), + color_menu_open.get_untracked(), + more_menu_open.get_untracked(), + read_only.get_untracked(), + ); + } match persist_document_state( &runtime_persisted_identity(document_id, workspace_id), &title.get_untracked(), @@ -9261,7 +9431,7 @@ fn App(mount_options: MountOptions) -> impl IntoView { }} impl IntoView { let snapshot = sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output); schedule_scroll_mnote_block_anchor_from_hash(); - dispatch_ready_event(&ReadyPayload { - runtime_name: RUNTIME_NAME, - selectors: BridgeSelectorsPayload { - root: "[data-testid=\"mnote-leptos-tiptap-host\"]", - stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]", - editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]", - toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]", - slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]", - handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]", - }, - supported_commands: vec![ - "replaceContent", - "setEditable", - "undo", - "redo", - "focus", - "requestCurrentBlockId", - ], - supports_embedded_mode: true, - }); - dispatch_runtime_state( - document_id.get_untracked(), - workspace_id.get_untracked(), - title.get_untracked(), - dirty_count.get_untracked(), - hovered_block.get_untracked(), - editor_focused.get_untracked(), - slash_open.get_untracked(), - turn_into_open.get_untracked(), - color_menu_open.get_untracked(), - more_menu_open.get_untracked(), - read_only.get_untracked(), - ); + if let Some(target) = ready_event_target.as_ref() { + dispatch_ready_event_to_target( + target, + &ReadyPayload { + runtime_name: RUNTIME_NAME, + selectors: BridgeSelectorsPayload { + root: "[data-testid=\"mnote-leptos-tiptap-host\"]", + stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]", + editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]", + toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]", + slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]", + handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]", + }, + supported_commands: vec![ + "replaceContent", + "setEditable", + "undo", + "redo", + "focus", + "requestCurrentBlockId", + ], + supports_embedded_mode: true, + }, + ); + dispatch_runtime_state_to_target( + target, + document_id.get_untracked(), + workspace_id.get_untracked(), + title.get_untracked(), + dirty_count.get_untracked(), + hovered_block.get_untracked(), + editor_focused.get_untracked(), + slash_open.get_untracked(), + turn_into_open.get_untracked(), + color_menu_open.get_untracked(), + more_menu_open.get_untracked(), + read_only.get_untracked(), + ); + } match persist_document_state( &runtime_persisted_identity(document_id, workspace_id), &title.get_untracked(), @@ -9345,39 +9521,45 @@ fn App(mount_options: MountOptions) -> impl IntoView { set_dirty_count.update(|count| *count += 1); let snapshot = sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output); - dispatch_change_event(&ChangePayload { - document_id: document_id.get_untracked(), - workspace_id: workspace_id.get_untracked(), - title: title.get_untracked(), - content: snapshot.clone(), - meta: ChangeMetaPayload { - dirty_count: dirty_count.get_untracked(), - editor_focused: editor_focused.get_untracked(), - slash_open: slash_open.get_untracked(), - toolbar_open: toolbar_overlay_locked( - turn_into_open.get_untracked(), - color_menu_open.get_untracked(), - more_menu_open.get_untracked(), - ), - selected_block_index: hovered_block.get_untracked().map(|block| block.index), - revision: revision.get_untracked(), - conflict_detection_key: conflict_detection_key.get_untracked(), - read_only: read_only.get_untracked(), - }, - }); - dispatch_runtime_state( - document_id.get_untracked(), - workspace_id.get_untracked(), - title.get_untracked(), - dirty_count.get_untracked(), - hovered_block.get_untracked(), - editor_focused.get_untracked(), - slash_open.get_untracked(), - turn_into_open.get_untracked(), - color_menu_open.get_untracked(), - more_menu_open.get_untracked(), - read_only.get_untracked(), - ); + if let Some(target) = change_event_target.as_ref() { + dispatch_change_event_to_target( + target, + &ChangePayload { + document_id: document_id.get_untracked(), + workspace_id: workspace_id.get_untracked(), + title: title.get_untracked(), + content: snapshot.clone(), + meta: ChangeMetaPayload { + dirty_count: dirty_count.get_untracked(), + editor_focused: editor_focused.get_untracked(), + slash_open: slash_open.get_untracked(), + toolbar_open: toolbar_overlay_locked( + turn_into_open.get_untracked(), + color_menu_open.get_untracked(), + more_menu_open.get_untracked(), + ), + selected_block_index: hovered_block.get_untracked().map(|block| block.index), + revision: revision.get_untracked(), + conflict_detection_key: conflict_detection_key.get_untracked(), + read_only: read_only.get_untracked(), + }, + }, + ); + dispatch_runtime_state_to_target( + target, + document_id.get_untracked(), + workspace_id.get_untracked(), + title.get_untracked(), + dirty_count.get_untracked(), + hovered_block.get_untracked(), + editor_focused.get_untracked(), + slash_open.get_untracked(), + turn_into_open.get_untracked(), + color_menu_open.get_untracked(), + more_menu_open.get_untracked(), + read_only.get_untracked(), + ); + } match persist_document_state( &runtime_persisted_identity(document_id, workspace_id), &title.get_untracked(), @@ -9422,11 +9604,14 @@ fn App(mount_options: MountOptions) -> impl IntoView { set_block_menu_anchor.set(None); set_hovered_block.set(None); } - send_selection_state( - &selection_clone, - editor_focused.get_untracked(), - hovered_block.get_untracked().map(|block| block.index), - ); + if let Some(target) = selection_event_target.as_ref() { + send_selection_state_to_target( + target, + &selection_clone, + editor_focused.get_untracked(), + hovered_block.get_untracked().map(|block| block.index), + ); + } } attr:class="editor-surface" attr:data-testid="mnote-leptos-tiptap-editor-root" diff --git a/scripts/task165-rust-web-dual-pane-smoke.js b/scripts/task165-rust-web-dual-pane-smoke.js new file mode 100644 index 00000000..2f306212 --- /dev/null +++ b/scripts/task165-rust-web-dual-pane-smoke.js @@ -0,0 +1,731 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const { chromium } = require("playwright"); +const { findFreePort, waitForGateway } = require("./task114-rust-web-gateway-entry-smoke.js"); + +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000); +const TYPE_DELAY_MS = Number(process.env.MNOTE_SMOKE_TYPE_DELAY_MS || 20); + +function buildFixtureEnv(port) { + return { + ...process.env, + MNOTE_WEB_ALLOW_DEV_FIXTURES: "1", + MNOTE_WEB_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000", + MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100", + MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1", + MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({ + "documents:getMeta": { + id: "doc_1", + workspace_id: "ws_demo", + title: "双栏验收页面", + updated_at: "2026-05-08T10:00:00Z", + can_edit: true, + word_count: 10, + character_count: 32, + block_count: 1, + }, + "documents:getContent": { + title: "双栏验收页面", + content: [ + { + id: "block_1", + type: "paragraph", + content: [{ type: "text", text: "fixture init" }], + }, + ], + revision: 7, + conflict_detection_key: "doc_1:7", + pageSubtree: { rootNodeId: "doc_1", outline: [] }, + }, + "sidebar:datasetList": { + active_workspace_id: "ws_demo", + workspaces: [{ id: "ws_demo", name: "双栏空间" }], + documents: [ + { + id: "doc_1", + workspace_id: "ws_demo", + title: "双栏验收页面", + parent_id: null, + sort_order: 0, + is_starred: false, + is_template: false, + created_at: "2026-05-08T10:00:00Z", + updated_at: "2026-05-08T10:00:00Z", + }, + { + id: "doc_other", + workspace_id: "ws_demo", + title: "Fixture Other", + parent_id: null, + sort_order: 1, + is_starred: false, + is_template: false, + created_at: "2026-05-08T10:00:00Z", + updated_at: "2026-05-08T10:00:00Z", + }, + ], + trashed_documents: [], + media_assets: [], + trashed_media_assets: [], + mindmap_assets: [], + trashed_mindmap_assets: [], + table_assets: [], + trashed_table_assets: [], + mindmap_docs: [], + mindmap_asset_children: {}, + }, + "bridgeLogs:listWorkspaceOverview": { + workspace_id: "ws_demo", + command_logs: [], + domain_events: [], + next_cursor: null, + has_more: false, + filters: { + command_status: null, + event_status: null, + target_page_id: null, + target_block_id: null, + aggregate_type: null, + aggregate_id: null, + }, + generated_at: "2026-05-08T10:00:00Z", + }, + }), + MNOTE_WEB_MUTATION_FIXTURES_JSON: JSON.stringify({ + "documents:updateContent": { + ok: true, + updated_at: "2026-05-08T10:00:10Z", + revision: 8, + conflict_detection_key: "doc_1:8", + }, + "documents:updateTitle": { + ok: true, + title: "双栏验收页面", + }, + }), + }; +} + +function startGateway(port) { + return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], { + cwd: "/mnt/Data1T/mnote/rust", + env: buildFixtureEnv(port), + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function createLocalFolderFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-dual-pane-")); + const readmePath = path.join(root, "README.md"); + const sidePath = path.join(root, "side.md"); + const thirdPath = path.join(root, "third.md"); + const readmeParagraphs = Array.from({ length: 80 }, (_, index) => `local init line ${index + 1}`); + const seed = [ + "---", + "title: Local Dual Pane", + "---", + "", + ...readmeParagraphs, + "", + ].join("\n"); + fs.writeFileSync(readmePath, seed, "utf8"); + fs.writeFileSync( + sidePath, + [ + "---", + "title: Local Side", + "---", + "", + "side init", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + thirdPath, + [ + "---", + "title: Local Third", + "---", + "", + "third init", + "", + ].join("\n"), + "utf8", + ); + return { + root, + rootUri: `file://${root}`, + readmePath, + sidePath, + thirdPath, + documentId: "local-md:README.md", + sideDocumentId: "local-md:side.md", + thirdDocumentId: "local-md:third.md", + }; +} + +function paneEditorSelector(role) { + return `.document-pane[data-pane-role="${role}"] .editor-surface .ProseMirror[contenteditable="true"]`; +} + +function paneRootSelector(role) { + return `.document-pane[data-pane-role="${role}"] [data-testid="mnote-leptos-tiptap-island-editor-root"]`; +} + +function paneSelector(role) { + return `.document-pane[data-pane-role="${role}"]`; +} + +async function waitForDualPaneReady(page) { + await page.waitForFunction( + ({ primarySelector, secondarySelector, primaryRootSelector, secondaryRootSelector }) => { + const primaryEditor = document.querySelector(primarySelector); + const secondaryEditor = document.querySelector(secondarySelector); + const primaryRoot = document.querySelector(primaryRootSelector); + const secondaryRoot = document.querySelector(secondaryRootSelector); + const ready = (editor, root) => + editor instanceof HTMLElement && + editor.isContentEditable && + root instanceof HTMLElement && + root.getAttribute("data-runtime-editor-status") !== "error"; + return ready(primaryEditor, primaryRoot) && ready(secondaryEditor, secondaryRoot); + }, + { + primarySelector: paneEditorSelector("primary"), + secondarySelector: paneEditorSelector("secondary"), + primaryRootSelector: paneRootSelector("primary"), + secondaryRootSelector: paneRootSelector("secondary"), + }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function waitForSinglePaneReady(page) { + await page.waitForFunction( + ({ primarySelector, primaryRootSelector, secondaryPaneSelector }) => { + const primaryEditor = document.querySelector(primarySelector); + const primaryRoot = document.querySelector(primaryRootSelector); + const secondaryPane = document.querySelector(secondaryPaneSelector); + const secondaryVisible = + secondaryPane instanceof HTMLElement && + !secondaryPane.hasAttribute("hidden") && + secondaryPane.getAttribute("data-pane-visible") !== "false"; + return ( + primaryEditor instanceof HTMLElement && + primaryEditor.isContentEditable && + primaryRoot instanceof HTMLElement && + primaryRoot.getAttribute("data-runtime-editor-status") !== "error" && + !secondaryVisible + ); + }, + { + primarySelector: paneEditorSelector("primary"), + primaryRootSelector: paneRootSelector("primary"), + secondaryPaneSelector: paneSelector("secondary"), + }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function typePaneText(page, role, text) { + const editor = page.locator(paneEditorSelector(role)).first(); + await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press("Control+a"); + await page.keyboard.press("Backspace"); + await page.keyboard.type(text, { delay: TYPE_DELAY_MS }); +} + +async function appendPaneText(page, role, text) { + const editor = page.locator(paneEditorSelector(role)).first(); + await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press("Control+End"); + await page.keyboard.type(text, { delay: TYPE_DELAY_MS }); +} + +async function waitForPaneText(page, role, text) { + await page.waitForFunction( + ({ selector, expected }) => { + const editor = document.querySelector(selector); + return (editor?.textContent || "").includes(expected); + }, + { + selector: paneEditorSelector(role), + expected: text, + }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function readPrimaryTitleState(page) { + return await page.evaluate(() => { + const primaryInput = document.querySelector('.document-pane[data-pane-role="primary"] [data-page-title-input="true"]'); + const primaryMeta = document.querySelector('.document-pane[data-pane-role="primary"] [data-page-title-current="true"]'); + const breadcrumb = document.querySelector('.wolai-breadcrumb-current [data-page-title-current="true"]'); + return { + documentTitle: document.title || "", + inputValue: primaryInput instanceof HTMLTextAreaElement ? primaryInput.value || "" : "", + metaTitle: primaryMeta instanceof HTMLElement ? primaryMeta.textContent || "" : "", + breadcrumbTitle: breadcrumb instanceof HTMLElement ? breadcrumb.textContent || "" : "", + }; + }); +} + +async function waitForPaneStatus(page, role, status) { + await page.waitForFunction( + ({ selector, expected }) => { + const root = document.querySelector(selector); + return root?.getAttribute("data-runtime-editor-status") === expected; + }, + { + selector: paneRootSelector(role), + expected: status, + }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function readPaneText(page, role) { + return await page.evaluate((selector) => { + const editor = document.querySelector(selector); + return editor?.textContent || ""; + }, paneEditorSelector(role)); +} + +async function readActivePaneRole(page) { + return await page.evaluate(() => { + const active = document.activeElement; + return active?.closest?.(".document-pane")?.getAttribute?.("data-pane-role") || null; + }); +} + +async function readPaneScrollState(page, role) { + return await page.evaluate(({ paneSelector, editorSelector }) => { + const pane = document.querySelector(paneSelector); + const editor = document.querySelector(editorSelector); + const findScrollable = (start) => { + let current = start; + while (current instanceof HTMLElement) { + if (current.scrollHeight > current.clientHeight + 8) { + return current; + } + current = current.parentElement; + } + return null; + }; + const target = findScrollable(editor) || findScrollable(pane); + if (!(target instanceof HTMLElement)) { + return null; + } + return { + scrollTop: target.scrollTop, + scrollHeight: target.scrollHeight, + clientHeight: target.clientHeight, + }; + }, { + paneSelector: paneSelector(role), + editorSelector: paneEditorSelector(role), + }); +} + +async function setPaneScrollTop(page, role, top) { + return await page.evaluate(({ paneSelector, editorSelector, topValue }) => { + const pane = document.querySelector(paneSelector); + const editor = document.querySelector(editorSelector); + const findScrollable = (start) => { + let current = start; + while (current instanceof HTMLElement) { + if (current.scrollHeight > current.clientHeight + 8) { + return current; + } + current = current.parentElement; + } + return null; + }; + const target = findScrollable(editor) || findScrollable(pane); + if (!(target instanceof HTMLElement)) { + return null; + } + target.scrollTop = topValue; + return { + scrollTop: target.scrollTop, + scrollHeight: target.scrollHeight, + clientHeight: target.clientHeight, + }; + }, { + paneSelector: paneSelector(role), + editorSelector: paneEditorSelector(role), + topValue: top, + }); +} + +async function readViewportScroll(page) { + return await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY })); +} + +async function setViewportScroll(page, top) { + return await page.evaluate((topValue) => { + window.scrollTo({ top: topValue, left: 0, behavior: "auto" }); + return { x: window.scrollX, y: window.scrollY }; + }, top); +} + +async function waitForRequestCount(requests, startIndex, predicate, expected, label) { + const deadline = Date.now() + UI_TIMEOUT_MS; + while (Date.now() < deadline) { + const count = requests.slice(startIndex).filter(predicate).length; + if (count >= expected) { + return count; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`${label} 超时,期望至少 ${expected} 条`); +} + +async function readDocumentSessionSnapshot(page) { + return await page.evaluate(() => { + const debug = window.__mnoteDebugDocumentSessions; + if (!debug || typeof debug.snapshot !== "function") { + throw new Error("缺少文档 session debug 快照"); + } + return debug.snapshot(); + }); +} + +function countRequests(requests, startIndex, predicate) { + return requests.slice(startIndex).filter(predicate).length; +} + +function isSaveRequest(record) { + return record.method === "POST" && record.url.includes("/api/documents/save"); +} + +function isTreeEventRequest(record) { + return record.method === "GET" && record.url.includes("/api/tree/events"); +} + +function isLocalFolderEventRequest(record) { + return record.method === "GET" && record.url.includes("/api/local-folder/events"); +} + +async function runFixturePhase(page, baseUrl, requests) { + const url = `${baseUrl}/documents/doc_1?workspaceId=ws_demo&secondaryDocumentId=doc_1`; + const openIndex = requests.length; + await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForDualPaneReady(page); + await waitForRequestCount(requests, openIndex, isTreeEventRequest, 1, "tree EventSource 建连"); + await page.waitForTimeout(800); + assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 1, "双 pane fixture 页面不应建立第二条 tree EventSource"); + + const closeButton = page.locator('[data-mnote-pane-close="secondary"]').first(); + await closeButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + + const primaryToSecondary = `fixture-primary-${Date.now().toString().slice(-6)}`; + const primarySaveIndex = requests.length; + await typePaneText(page, "primary", primaryToSecondary); + await waitForPaneText(page, "primary", primaryToSecondary); + await waitForPaneText(page, "secondary", primaryToSecondary); + await waitForPaneStatus(page, "primary", "saved"); + await waitForPaneStatus(page, "secondary", "saved"); + await page.waitForTimeout(800); + assert.equal(countRequests(requests, primarySaveIndex, isSaveRequest), 1, "同 session 双 view 主 pane 输入后应只触发一次保存请求"); + assert.equal(await readActivePaneRole(page), "primary", "primary 输入后焦点不应被同步到 secondary"); + + const secondaryToPrimary = `fixture-secondary-${(Date.now() + 1).toString().slice(-6)}`; + const secondarySaveIndex = requests.length; + await typePaneText(page, "secondary", secondaryToPrimary); + await waitForPaneText(page, "primary", secondaryToPrimary); + await waitForPaneText(page, "secondary", secondaryToPrimary); + await waitForPaneStatus(page, "primary", "saved"); + await waitForPaneStatus(page, "secondary", "saved"); + await page.waitForTimeout(800); + assert.equal(countRequests(requests, secondarySaveIndex, isSaveRequest), 1, "同 session 双 view 次 pane 输入后应只触发一次保存请求"); + assert.equal(await readActivePaneRole(page), "secondary", "secondary 输入后焦点不应被同步到 primary"); + + const reloadIndex = requests.length; + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForDualPaneReady(page); + await waitForRequestCount(requests, reloadIndex, isTreeEventRequest, 1, "reload 后 tree EventSource 建连"); + await page.waitForTimeout(800); + assert.equal(countRequests(requests, reloadIndex, isTreeEventRequest), 1, "reload 后双 pane 仍应只建立一条 tree EventSource"); + const fixtureSnapshot = await readDocumentSessionSnapshot(page); + assert.equal(fixtureSnapshot.sessionCount, 1, "同文档双开时应只复用一个 session"); + assert.equal(fixtureSnapshot.sessions[0]?.viewCount, 2, "同文档双开时单 session 应挂两个 view"); + + const navigationIndex = requests.length; + await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Fixture Other" }).first().click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((nextUrl) => decodeURIComponent(nextUrl.pathname).endsWith("/documents/doc_other"), { + timeout: UI_TIMEOUT_MS, + waitUntil: "domcontentloaded", + }); + await waitForDualPaneReady(page); + await waitForRequestCount(requests, navigationIndex, isTreeEventRequest, 1, "sidebar 导航后的 tree EventSource 建连"); + await page.waitForTimeout(800); + assert.equal(countRequests(requests, navigationIndex, isTreeEventRequest), 1, "sidebar 导航后当前页面仍应只建立一条 tree EventSource"); + const navigatedFixtureUrl = new URL(page.url()); + assert.equal( + navigatedFixtureUrl.searchParams.get("secondaryDocumentId"), + "doc_1", + "fixture sidebar 导航后 secondaryDocumentId 应保持原值", + ); +} + +async function runLocalFolderPhase(page, baseUrl, requests, fixture) { + const differentDocUrl = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}&secondaryDocumentId=${encodeURIComponent(fixture.sideDocumentId)}&secondarySourceKind=local_folder&secondaryRootUri=${encodeURIComponent(fixture.rootUri)}`; + const differentDocIndex = requests.length; + await page.goto(differentDocUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForDualPaneReady(page); + await waitForRequestCount(requests, differentDocIndex, isLocalFolderEventRequest, 1, "不同文档 local folder EventSource 建连"); + await page.waitForTimeout(800); + assert.equal(countRequests(requests, differentDocIndex, isLocalFolderEventRequest), 1, "同 rootUri 不同文档双 pane 也应只复用一条 local-folder EventSource"); + const differentDocSnapshot = await readDocumentSessionSnapshot(page); + assert.equal(differentDocSnapshot.sessionCount, 2, "不同文档双开时应建立两个 session"); + assert.equal(differentDocSnapshot.localFolderChannelCount, 1, "同 rootUri 不同文档双开时应只复用一个 local-folder channel"); + assert.deepEqual( + differentDocSnapshot.sessions.map((item) => item.documentId).sort(), + [fixture.documentId, fixture.sideDocumentId].sort(), + "不同文档双开时 session 应分别归属到两个 documentId", + ); + await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Local Third" }).first().click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((nextUrl) => decodeURIComponent(nextUrl.pathname).endsWith(`/documents/${fixture.thirdDocumentId}`), { + timeout: UI_TIMEOUT_MS, + waitUntil: "domcontentloaded", + }); + await waitForDualPaneReady(page); + const navigatedUrl = new URL(page.url()); + assert.equal( + navigatedUrl.searchParams.get("secondaryDocumentId"), + fixture.sideDocumentId, + "左侧普通导航后 secondaryDocumentId 应保持原值", + ); + assert.equal( + navigatedUrl.searchParams.get("secondarySourceKind"), + "local_folder", + "左侧普通导航后 secondarySourceKind 应保持 local_folder", + ); + assert.equal( + navigatedUrl.searchParams.get("secondaryRootUri"), + fixture.rootUri, + "左侧普通导航后 secondaryRootUri 应保持原值", + ); + const primaryTextAfterNavigation = await readPaneText(page, "primary"); + const secondaryTextAfterNavigation = await readPaneText(page, "secondary"); + assert(primaryTextAfterNavigation.includes("third init"), "左侧普通导航后 primary pane 应切到新文档"); + assert(secondaryTextAfterNavigation.includes("side init"), "左侧普通导航后 secondary pane 应保持原文档"); + const topbarTitle = await page.locator(".wolai-topbar [data-page-title-current='true']").first().textContent(); + assert((topbarTitle || "").includes("Local Third"), "左侧普通导航后共享 topbar 标题应切到 primary 文档"); + const activeThirdRow = page.getByTestId("wolai-sidebar-row").filter({ hasText: "Local Third" }).first(); + const activeState = await activeThirdRow.getAttribute("data-active"); + const selectedState = await activeThirdRow.getAttribute("data-selected"); + assert( + activeState === "true" || selectedState === "true", + "左侧普通导航后共享侧栏 active/selected 状态应切到新的 primary 文档", + ); + + const url = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}&secondaryDocumentId=${encodeURIComponent(fixture.documentId)}&secondarySourceKind=local_folder&secondaryRootUri=${encodeURIComponent(fixture.rootUri)}`; + + const openIndex = requests.length; + await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForDualPaneReady(page); + await waitForRequestCount(requests, openIndex, isLocalFolderEventRequest, 1, "local folder EventSource 建连"); + await page.waitForTimeout(800); + assert.equal(countRequests(requests, openIndex, isLocalFolderEventRequest), 1, "同 rootUri 双 pane 不应建立第二条 local-folder EventSource"); + assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 0, "local folder 页面不应建立 tree EventSource"); + + const reloadIndex = requests.length; + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForDualPaneReady(page); + await waitForRequestCount(requests, reloadIndex, isLocalFolderEventRequest, 1, "reload 后 local folder EventSource 建连"); + await page.waitForTimeout(800); + assert.equal(countRequests(requests, reloadIndex, isLocalFolderEventRequest), 1, "reload 后同 rootUri 双 pane 仍应只建立一条 local-folder EventSource"); + const sameDocSnapshot = await readDocumentSessionSnapshot(page); + assert.equal(sameDocSnapshot.sessionCount, 1, "同文档 local folder 双开时应只复用一个 session"); + assert.equal(sameDocSnapshot.sessions[0]?.viewCount, 2, "同文档 local folder 双开时单 session 应挂两个 view"); + + const viewportBefore = await setViewportScroll(page, 260); + assert((viewportBefore?.y || 0) >= 200, "本地双栏页面应可滚动到可观察位置"); + const externalText = `external-sync-${Date.now().toString().slice(-6)}`; + fs.writeFileSync( + fixture.readmePath, + [ + "---", + "title: Local Dual Pane", + "---", + "", + ...Array.from({ length: 80 }, (_, index) => `local init line ${index + 1}`), + externalText, + "", + ].join("\n"), + "utf8", + ); + await waitForPaneText(page, "primary", externalText); + await waitForPaneText(page, "secondary", externalText); + await waitForPaneStatus(page, "primary", "synced-external-change"); + await waitForPaneStatus(page, "secondary", "synced-external-change"); + await page.waitForTimeout(800); + const viewportAfter = await readViewportScroll(page); + assert( + Math.abs((viewportAfter?.y || 0) - (viewportBefore?.y || 0)) < 40, + "远端 replaceContent 后共享页面滚动不应被重置", + ); + + const savedMarkdown = fs.readFileSync(fixture.readmePath, "utf8"); + assert(savedMarkdown.includes(externalText), "外部写盘未落到本地 Markdown 文件"); + + await page.locator('[data-mnote-pane-close="secondary"]').first().click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((nextUrl) => !nextUrl.searchParams.has("secondaryDocumentId"), { + timeout: UI_TIMEOUT_MS, + waitUntil: "commit", + }); + await waitForSinglePaneReady(page); + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForSinglePaneReady(page); + const finalUrl = new URL(page.url()); + assert(!finalUrl.searchParams.has("secondaryDocumentId"), "关闭 secondary 后 reload 不应恢复 secondaryDocumentId"); + assert(!finalUrl.searchParams.has("secondarySourceKind"), "关闭 secondary 后 reload 不应恢复 secondarySourceKind"); + assert(!finalUrl.searchParams.has("secondaryRootUri"), "关闭 secondary 后 reload 不应恢复 secondaryRootUri"); + + await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForDualPaneReady(page); + await page.evaluate(() => { + document.querySelector('.document-pane[data-pane-role="secondary"]')?.remove(); + }); + await page.waitForTimeout(300); + const oneViewSnapshot = await readDocumentSessionSnapshot(page); + assert.equal(oneViewSnapshot.sessionCount, 1, "移除一个 pane 后 session 不应提前释放"); + assert.equal(oneViewSnapshot.sessions[0]?.viewCount, 1, "移除一个 pane 后同 session 应只剩一个 view"); + await page.evaluate(() => { + document.querySelector('.document-pane[data-pane-role="primary"]')?.remove(); + }); + await page.waitForTimeout(1500); + const releasedSnapshot = await readDocumentSessionSnapshot(page); + assert.equal(releasedSnapshot.sessionCount, 0, "最后一个 view 卸载后应延迟释放 session"); + assert.equal(releasedSnapshot.localFolderChannelCount, 0, "最后一个 view 卸载后应回收 local-folder channel"); +} + +async function runSinglePaneTitlePhase(page, baseUrl, fixture) { + const url = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}`; + await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForSinglePaneReady(page); + const expectedTitle = "Local Dual Pane"; + const beforeClick = await readPrimaryTitleState(page); + assert.equal(beforeClick.documentTitle.trim(), expectedTitle, "单页本地文档首屏 document.title 不应被隐藏 secondary 覆盖成“无标题”"); + assert.equal(beforeClick.inputValue.trim(), expectedTitle, "单页本地文档首屏标题输入框不应被隐藏 secondary 覆盖成“无标题”"); + assert.equal(beforeClick.metaTitle.trim(), expectedTitle, "单页本地文档首屏页头标题不应被隐藏 secondary 覆盖成“无标题”"); + assert.equal(beforeClick.breadcrumbTitle.trim(), expectedTitle, "单页本地文档首屏 breadcrumb 标题不应被隐藏 secondary 覆盖成“无标题”"); + + const titleInput = page.locator('.document-pane[data-pane-role="primary"] [data-page-title-input="true"]').first(); + await titleInput.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForTimeout(250); + const afterClick = await readPrimaryTitleState(page); + assert.equal(afterClick.documentTitle.trim(), expectedTitle, "点击标题后 document.title 不应闪成“无标题”"); + assert.equal(afterClick.inputValue.trim(), expectedTitle, "点击标题后输入框标题不应闪成“无标题”"); + assert.equal(afterClick.metaTitle.trim(), expectedTitle, "点击标题后页头标题不应闪成“无标题”"); + assert.equal(afterClick.breadcrumbTitle.trim(), expectedTitle, "点击标题后 breadcrumb 标题不应闪成“无标题”"); +} + +async function main() { + const externalBaseUrl = String(process.env.MNOTE_UI_BASE_URL || "").trim(); + const useExistingServer = externalBaseUrl.length > 0; + const port = useExistingServer ? null : await findFreePort(); + const baseUrl = useExistingServer ? externalBaseUrl : `http://127.0.0.1:${port}`; + const gateway = useExistingServer ? null : startGateway(port); + const localFixture = createLocalFolderFixture(); + const requests = []; + let stderr = ""; + let stdout = ""; + + gateway?.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + gateway?.stdout.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + + await waitForGateway(baseUrl); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); + const page = await context.newPage(); + + page.on("request", (request) => { + let payload = null; + try { + payload = request.postDataJSON?.() ?? null; + } catch { + payload = null; + } + requests.push({ + url: request.url(), + method: request.method(), + payload, + }); + }); + + let caughtError = null; + try { + if (!useExistingServer) { + await runFixturePhase(page, baseUrl, requests); + } + await runLocalFolderPhase(page, baseUrl, requests, localFixture); + await runSinglePaneTitlePhase(page, baseUrl, localFixture); + console.log( + JSON.stringify( + { + ok: true, + task: "task165-rust-web-dual-pane-smoke", + baseUrl, + serverMode: useExistingServer ? "existing" : "spawned", + requestSummary: { + saveRequests: requests.filter(isSaveRequest).length, + treeEventRequests: requests.filter(isTreeEventRequest).length, + localFolderEventRequests: requests.filter(isLocalFolderEventRequest).length, + }, + }, + null, + 2, + ), + ); + } catch (error) { + caughtError = error; + } finally { + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + gateway?.kill("SIGTERM"); + if (gateway) { + setTimeout(() => { + if (!gateway.killed) { + gateway.kill("SIGKILL"); + } + }, 2000).unref(); + } + fs.rmSync(localFixture.root, { recursive: true, force: true }); + if (caughtError) { + const debug = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n"); + if (debug) { + process.stderr.write(`${debug}\n`); + } + } + } + + if (caughtError) { + throw caughtError; + } +} + +if (require.main === module) { + main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); + }); +}