From 4ab36a9386ed2f781a9390b3555ff937934525f7 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Mon, 27 Apr 2026 10:27:15 +0800 Subject: [PATCH] feat(tree): complete rust family runtime checklist - add tree shell runtime artifact contracts and page/filetree/picker runtime reducers - sink tree.subtree.move write operation through Rust and formalize command event plans - harden file tree search projection contract and route thin-proxy boundaries - record completed harness tasks and move design docs into process/done --- ...1-tree-first-graph-kernel-checklist-v2.md} | 19 +- ...-graph-kernel-phase3-task-breakdown-v1.md} | 17 +- .../3-1-rust-web-long-term-checklist-v2.md} | 13 +- ...-graph-kernel-phase4-task-breakdown-v1.md} | 12 +- ...ly-remaining-final-runtime-checklist-v1.md | 215 +++++ ...-tree-3000-route-thin-proxy-boundary-v1.md | 72 ++ harness-progress.txt | 64 +- harness-tasks.json | 482 +++++------ rust/crates/bridge-runtime/src/lib.rs | 746 ++++++++++++++++-- rust/crates/mnote-web/src/routes/tree.rs | 179 ++++- rust/crates/mnote-web/src/tree_shell/mod.rs | 3 + .../mnote-web/src/tree_shell/page_renderer.rs | 16 +- .../src/tree_shell/picker_renderer.rs | 16 +- .../src/tree_shell/renderer_input.rs | 80 +- ...sk112-tree-rust-family-regression-smoke.js | 48 +- wolai-frontend/convex/_generated/api.d.ts | 2 + .../convex/_utils/documentMoveOrder.ts | 60 +- wolai-frontend/convex/documents.ts | 53 +- .../src/app/api/tree/commands/route.test.ts | 14 + .../src/components/sidebar/sidebar.tsx | 7 +- .../sidebar/tree-shell-iframe-host.test.tsx | 78 +- .../sidebar/tree-shell-iframe-host.tsx | 299 ++++++- .../sidebar/tree-shell-surface.test.tsx | 4 + .../src/lib/documents/bridge-log.ts | 7 +- .../src/lib/documents/rust-runtime.test.ts | 220 +++++- .../src/lib/documents/rust-runtime.ts | 27 + .../lib/file-tree/projection-client.test.ts | 59 +- .../src/lib/file-tree/projection-client.ts | 56 +- .../src/lib/tree-stream/tree-delta.test.ts | 61 ++ .../src/lib/tree-stream/tree-delta.ts | 5 + 30 files changed, 2502 insertions(+), 432 deletions(-) rename design/{tree-first-graph-kernel-checklist-v2.md => 01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md} (95%) rename design/{tree-first-graph-kernel-phase3-task-breakdown-v1.md => 03-rust-web/done/3-2-tree-first-graph-kernel-phase3-task-breakdown-v1.md} (92%) rename design/{rust-web-long-term-checklist-v2.md => 03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md} (96%) rename design/{tree-first-graph-kernel-phase4-task-breakdown-v1.md => 04-tree-domain/done/4-3-tree-first-graph-kernel-phase4-task-breakdown-v1.md} (95%) create mode 100644 design/04-tree-domain/process/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md create mode 100644 design/04-tree-domain/process/4-17-tree-3000-route-thin-proxy-boundary-v1.md diff --git a/design/tree-first-graph-kernel-checklist-v2.md b/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md similarity index 95% rename from design/tree-first-graph-kernel-checklist-v2.md rename to design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md index d5bc4141..2d6a7aff 100644 --- a/design/tree-first-graph-kernel-checklist-v2.md +++ b/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md @@ -1,11 +1,11 @@ -# Tree-First Graph 内核实施清单 v2 +# 1-1 [process] Tree-First Graph 内核实施清单 v2 > 更新时间:2026-04-16 > > 基于以下实际状态重写: > - 当前未提交代码 -> - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-v1.md` -> - `/mnt/Data1T/mnote/design/rust-web-long-term-checklist-v2.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` > - `/mnt/Data1T/mnote/harness-tasks.json` ## 1. 这版为什么要重写 @@ -22,6 +22,11 @@ > **既承认已落地的 Rust kernel 代码,也不再把骨架、样板和 host/runtime 拆分误写成长期阶段已完成。** +> 说明(2026-04-22): +> 这份清单仍保留为 `Kernel Phase` 全景参考,但当前第一优先级已经转移到 +> `Page Aggregate`、`tree command cutover`、`tree realtime event stream`。 +> 当前执行优先级请先看 `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`。 + --- ## 2. 状态口径 @@ -210,7 +215,7 @@ - 已新增 Next 兼容切流入口: - `/api/compat/next/sidebar` - `MNOTE_WEB_QUERY_FIXTURES_JSON` + `allow_dev_fixtures` 已把 fixture 收紧到测试/开发边界 -- `/api/sidebar` 与服务端 Sidebar 首包在配置 `MNOTE_WEB_BASE_URL` 后,默认经 `mnote-web` 的 Sidebar compat route 取数 +- `/api/sidebar` 与服务端 Sidebar 首包当前已优先走 Rust bridge query + Convex transport;正式同源 `3000` 主链不再依赖 `MNOTE_WEB_BASE_URL` 或 `mnote-web` 的 Sidebar compat route - `mnote-web` transport 已优先转发真实 `Authorization`,否则才退回开发态 admin/dev identity - 已有 kernel / bridge 路由测试,说明 route 不只是声明 @@ -252,7 +257,7 @@ 这些属于 `Phase 4` 完成之后的独立大任务,即: -- [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md) +- [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/04-tree-domain/process/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md) ### 已落地 @@ -284,7 +289,7 @@ ### 下一阶段任务 -- 独立推进 [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md) +- 独立推进 [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/04-tree-domain/process/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md) - 把当前 `page_tree` / `file_tree` protocol 下沉成 Rust Web 独立 route / shell - 继续缩小 Sidebar 超大客户端壳,只保留局部交互岛 - 让文件树中的更宽对象投影逐步由 Rust projection 直接输出 @@ -527,7 +532,7 @@ 1. 维持 `Kernel Phase 4` 已完成口径,不再回头重做 consumer 统一 2. 立即启动 `Phase 4` 后继任务: - - [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md) + - [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/04-tree-domain/process/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md) 3. 在树域 Rust Web 子系统形成稳定主链后,再继续把文档阅读页的 `page subtree / read_view` 下沉到 Rust 输出边界 4. 再推进 `Kernel Phase 5` 5. 再推进 `Kernel Phase 6` diff --git a/design/tree-first-graph-kernel-phase3-task-breakdown-v1.md b/design/03-rust-web/done/3-2-tree-first-graph-kernel-phase3-task-breakdown-v1.md similarity index 92% rename from design/tree-first-graph-kernel-phase3-task-breakdown-v1.md rename to design/03-rust-web/done/3-2-tree-first-graph-kernel-phase3-task-breakdown-v1.md index e6660071..940de88b 100644 --- a/design/tree-first-graph-kernel-phase3-task-breakdown-v1.md +++ b/design/03-rust-web/done/3-2-tree-first-graph-kernel-phase3-task-breakdown-v1.md @@ -1,11 +1,16 @@ -# Tree-First Graph Kernel Phase 3 专项任务拆分 v1 +# 3-2 [done] Tree-First Graph Kernel Phase 3 专项任务拆分 v1 > 更新时间:2026-04-16 > +> 后续更新(2026-04-23): +> - 正式主链已移除 `MNOTE_WEB_BASE_URL` 与浏览器侧 `mnoteWebBaseUrl / mnoteWebTreeShellEnabled` +> - `mnote-web /api/compat/next/sidebar` 已降级为 legacy/debug 对照,不再是 `3000` 主链前提 +> - `3104` 已按 `/mnt/Data1T/mnote/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md` 退役为默认/公开边界 +> > 关联文档: -> - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-checklist-v2.md` -> - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-v1.md` -> - `/mnt/Data1T/mnote/design/rust-web-long-term-checklist-v2.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` ## 0. 本轮判断 @@ -22,7 +27,7 @@ - 实际采用的是 `bridge.workspace.overview` / `bridge.request.get` / `bridge.trace.get` 这组真实 workspace / bridge 查询主链 - `P3-4`:已完成 - `P3-5`:已完成 - - Sidebar 服务端首包与 `/api/sidebar` 在配置 `MNOTE_WEB_BASE_URL` 后,会默认经 `/api/compat/next/sidebar` 走 `mnote-web` + - 2026-04-16 阶段,Sidebar 服务端首包与 `/api/sidebar` 曾可通过配置 `MNOTE_WEB_BASE_URL` 切到 `/api/compat/next/sidebar` - `P3-6`:已完成 ## 1. 文档目的 @@ -132,7 +137,7 @@ 当前已经明确: - 第一条真实切流对象是 Sidebar 首包与 `/api/sidebar` -- 开关边界是 `MNOTE_WEB_BASE_URL` +- 2026-04-16 阶段的切流开关边界曾是 `MNOTE_WEB_BASE_URL` - 回退方式是移除该 base url,恢复 Next 侧原桥接逻辑 ### 3.5 验证重点已经转向“主链稳定” diff --git a/design/rust-web-long-term-checklist-v2.md b/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md similarity index 96% rename from design/rust-web-long-term-checklist-v2.md rename to design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md index 192ec729..8522938d 100644 --- a/design/rust-web-long-term-checklist-v2.md +++ b/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md @@ -1,13 +1,13 @@ -# Rust Web 长期架构实施清单 v2 +# 3-1 [process] Rust Web 长期架构实施清单 v2 > 更新时间:2026-04-16 > > 基于以下实际状态重写: > - 当前未提交代码 > - `/mnt/Data1T/mnote/harness-tasks.json` 中 `task-059` ~ `task-069` -> - `/mnt/Data1T/mnote/design/rust-web-long-term-architecture-v1.md` -> - `/mnt/Data1T/mnote/design/rust-web-long-term-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/old/03-rust-web/process/rust-web-long-term-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` ## 1. 这版为什么要重写 @@ -25,6 +25,11 @@ > **把长期路线重新落回“真实代码状态”,明确哪些已经落地、哪些只是最小接缝、哪些仍未开始。** +> 说明(2026-04-22): +> 这份清单仍保留为 Rust Web 分阶段全景参考,但当前最近一步不再是泛化推进全部 `Phase`, +> 而是优先推进 `Page Aggregate`、`tree command cutover`、`tree realtime event stream`。 +> 当前执行优先级请先看 `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`。 + --- ## 2. 当前总判断 diff --git a/design/tree-first-graph-kernel-phase4-task-breakdown-v1.md b/design/04-tree-domain/done/4-3-tree-first-graph-kernel-phase4-task-breakdown-v1.md similarity index 95% rename from design/tree-first-graph-kernel-phase4-task-breakdown-v1.md rename to design/04-tree-domain/done/4-3-tree-first-graph-kernel-phase4-task-breakdown-v1.md index 7e3593e5..5bba109d 100644 --- a/design/tree-first-graph-kernel-phase4-task-breakdown-v1.md +++ b/design/04-tree-domain/done/4-3-tree-first-graph-kernel-phase4-task-breakdown-v1.md @@ -1,11 +1,11 @@ -# Tree-First Graph Kernel Phase 4 专项任务拆分 v1 +# 4-3 [done] Tree-First Graph Kernel Phase 4 专项任务拆分 v1 > 更新时间:2026-04-16 > > 关联文档: -> - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-v1.md` -> - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-checklist-v2.md` -> - `/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md` +> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md` ## 1. 文档目的 @@ -324,7 +324,7 @@ - tree protocol 冻结 - 回写 checklist - 明确 Phase 4 完成后才能进入: - - [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md) + - [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/04-tree-domain/process/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md) **交付物** @@ -349,7 +349,7 @@ ### 下一阶段交接 - 本阶段完成后,下一步唯一合理的大任务是: - - [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md) + - [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/04-tree-domain/process/4-sidebar-pagetree-filetree-rust-web-rebuild-v1.md) - 原因不是“树域 UI 已重写完成”,而是: - 协议已经冻结 - consumer 已经统一 diff --git a/design/04-tree-domain/process/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md b/design/04-tree-domain/process/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md new file mode 100644 index 00000000..1acfbac4 --- /dev/null +++ b/design/04-tree-domain/process/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md @@ -0,0 +1,215 @@ +# 4-16 [process] 树域 Rust 家族剩余 final runtime checklist v1 + +> 创建时间:2026-04-27 +> +> 来源:`4-9 / 4-10 / 4-11` 中仍未完成的后续目标,以及 `4-12` 到 `4-15` 完成后的剩余边界。 +> +> 本文只规划后续目标,不回滚已完成的 3000 inline host、Rust initial DOM、rendererInput/state family 与现有 smoke 覆盖。 + +## 0. 当前执行状态(2026-04-27) + +`harness` 本轮从本文拆出的 `task-001` 至 `task-009` 已全部执行完成,`harness-progress.txt` 记录为 `completed=9 / pending=0 / in_progress=0 / failed=0`。 + +### 已完成 + +- Phase K 已完成 `rust_tree_shell_runtime_artifact_v1` artifact 边界,以及 page / filetree / picker 的 Rust-side runtime reducer contract。 +- Phase K 已固定 3000 inline host 与 `mnote-web /tree` debug shell 都输出同名 runtime artifact;3000 主路径继续使用 same-origin inline host,`/api/tree/shell -> mnote-web:3104` debug 直连默认关闭。 +- Phase L 已引入 Rust `treeWriteOperation`,Convex `documents.move` 优先执行 Rust write operation,并保留 `normalizedMove` 作为兼容 fallback。 +- Phase M 已将 `page.body.saved`、`block.patched`、`block.moved`、`block.embedded` 切到 formal `domainEventPlan` payload schema,复合命令 stream delta 使用 `resync_required` 保守策略。 +- Phase N 已新增 route thinning manifest 与 boundary 测试,明确 `/api/tree/commands`、`/api/tree/stream`、`/api/tree/shell` 的 thin proxy / debug / compat 边界。 +- Phase O 已完成 `file_tree` 搜索 hardening:`maxResults=80`、祖先补全/排序/异步可见性元数据、空结果文案,以及 `index / asset-folder / asset / mindmap child / book / pdf` fixture 覆盖。 + +### 仍剩余 + +- `TreeShellIframeHost` 内联模板中的运行时执行层仍是 compat JS 镜像;已完成的是 Rust artifact/reducer contract,不是完整 wasm/Rust DOM runtime 替换。 +- `normalizedMove` fallback 仍保留;后续可在确认所有调用方都稳定传入 `treeWriteOperation` 后删除兼容 fallback。 +- `document.snapshot.saved` 仍未作为独立 event type 固定;当前 snapshot 信息随 `page.body.saved` payload 进入 formal schema。 +- `tree.node.embed` 的 `pageReference` block 组装仍在 3000 route compat 边界内,后续应进入 Page Aggregate / Rust artifact。 +- Phase O 的异步索引可见性目前是 projection request meta / move-embed picker 搜索路径契约,不是完整索引指标平台。 + +## 1. 当前基线 + +- `harness` task-001 至 task-009 已完成。 +- `page tree / file tree / picker` 默认主路径已使用 3000 same-origin inline host,不再请求 `/api/tree/shell -> mnote-web:3104`。 +- 首屏 DOM 已由 Rust initial renderer 输出: + - `data-rust-page-renderer="initial_v1"` + - `data-rust-filetree-renderer="initial_v1"` + - `data-rust-picker-renderer="initial_v1"` +- 主路径已统一为 `state.items + rendererInput`,不再通过 `__MNOTE_TREE_SHELL_OVERRIDE__` 注入第二份 items。 +- page focus、filetree selection、picker state 已暴露并消费 reducer contract;运行时执行仍主要是 compat JS 镜像。 +- 3000 inline host 与 `mnote-web /tree` debug shell 已同步输出 `rust_tree_shell_runtime_artifact_v1`,供后续真正 Rust runtime / wasm 替换执行层。 +- tree command move 写路径已优先使用 Rust `treeWriteOperation`;Convex 继续作为 substrate 与一致性校验层。 +- block/save 复合命令已具备 formal `domainEventPlan` 与 `resync_required` 保守 delta 合同。 + +## 2. 后续总目标 + +把树域从“Rust 持有语义 + compat JS 持有运行时 DOM/state machine”的过渡态,推进到: + +- [x] Rust family 持有 renderer runtime / state machine contract。 +- [ ] Rust family 真正持有 renderer runtime / DOM 执行层,不再依赖 `TreeShellIframeHost` compat JS 镜像。 +- [x] 3000 host 收薄为挂载、认证、transport bridge、artifact writer 与浏览器能力边界。 +- [x] Convex compat mutation 不再优先承担 tree command 的核心 legality / normalize / write truth。 +- [x] block/save 复合命令进入正式 Rust artifact / domain-event contract。 +- [ ] snapshot 独立事件与 Page Aggregate 深层收口继续后续推进。 + +## 3. Phase K:正式 Rust runtime / wasm 承接 tree shell 运行时 + +### 目标 + +完整替换 `TreeShellIframeHost` 内联模板中的主要 compat JS runtime,至少先把 page/filetree/picker 的核心状态机从字符串模板 JS 迁到 Rust runtime/wasm 或等价正式 Rust family runtime。 + +### checklist + +- [x] 定义 runtime artifact 边界: + - [x] 输入:`rendererInput`、projection items、expanded ids、selected row ids、active picker item、focused id。 + - [x] 输出:DOM patch / intent event / command dispatch event。 + - [x] 事件:focus、keyboard、expand/collapse、selection、context menu、drag/drop、pick。 +- [x] 为 page tree runtime 建立 Rust-side 状态机: + - [x] focus normalize / next / previous / home / end。 + - [x] expand / collapse / open / context-menu intent。 + - [x] drag move intent 与 drop feedback state。 +- [x] 为 file tree runtime 建立 Rust-side 状态机: + - [x] selection / range / context selection / normalize visible rows。 + - [x] drag row ids / copy-move effect / drop target feedback。 + - [x] asset-folder / asset / index / doc 的打开与菜单 intent。 +- [x] 为 picker runtime 建立 Rust-side 状态机: + - [x] next / previous / home / end / focus / pick。 + - [x] excluded ids 与 root pick。 + - [x] 搜索态输入框焦点不被 iframe/runtime 抢走。 +- [x] 3000 inline host 与 `mnote-web /tree` debug shell 输出并消费正式 runtime artifact contract。 +- [ ] 3000 host 不再维护主路径 JS 镜像状态机;仍需把 `TreeShellIframeHost` 内联模板 compat JS 执行层迁到 Rust wasm/runtime 或等价正式 runtime。 +- [x] `mnote-web /tree` debug shell 只保留 debug/internal runtime 验证入口。 + +### 验收 + +- [x] `pnpm vitest run src/components/sidebar/tree-shell-iframe-host.test.tsx src/components/sidebar/tree-shell-surface.test.tsx` +- [x] `cargo test -p mnote-web page_renderer filetree_renderer picker_renderer` +- [x] `node scripts/task112-tree-rust-family-regression-smoke.js` +- [x] `node scripts/task113-picker-keyboard-regression-smoke.js` +- [x] smoke 中 `direct_tree_shell_debug_disabled` 仍成立,3000 主路径不请求 3104。 + +### 当前保留边界 + +- 这里的完成口径是 artifact / reducer contract / debug 边界完成,不代表 `TreeShellIframeHost` 的 DOM runtime 已完全从 JS template 替换为 Rust wasm。 + +## 4. Phase L:tree.subtree.move 最终写路径下沉 Rust kernel + +### 目标 + +把 `tree.subtree.move` 的 legality、target parent、position normalize、canonical sort plan 与最终写入从 Convex compat mutation 继续回收到 Rust kernel / Rust write path。 + +### checklist + +- [x] 梳理当前 `normalizedMove` 写入链: + - [x] `/api/tree/commands` preflightData。 + - [x] `bridge-runtime` canonical order plan。 + - [x] Convex `documents.move` optional validation。 +- [x] Rust kernel 输出最终 write operation: + - [x] parent_id。 + - [x] sort_order patch set。 + - [x] workspace_id / updated_at。 + - [x] self / descendant / missing-parent legality error。 +- [x] Convex mutation 优先执行 Rust `treeWriteOperation`,退为 substrate 写入执行器与一致性断言层。 +- [x] 前端 Rust runtime / command adapter 已透传 `treeWriteOperation`,不再以 documents move compat 语义作为主路径。 +- [x] domain event 与 streamDelta 使用 Rust move result,而不是 TS/Convex 再拼。 +- [ ] 删除 `normalizedMove` fallback,并把无 `treeWriteOperation` 的旧 compat move 降级为显式 legacy/debug 路径。 + +### 验收 + +- [x] `cd rust && cargo test -p bridge-runtime tree_subtree_move_command` +- [x] `cd wolai-frontend && pnpm vitest run src/app/api/tree/commands/route.test.ts src/lib/documents/tree-command-client.test.ts src/lib/tree-stream/tree-delta.test.ts` +- [x] 增加或复用 Convex 层测试,证明 Convex 优先接受 Rust `treeWriteOperation` 并校验当前排序状态。 + +### 当前保留边界 + +- `normalizedMove` 仍作为旧调用链 fallback 保留;这不是第二份主排序真相,但仍是后续可删除的 compat 面。 + +## 5. Phase M:block/save-snapshot 复合命令 formal artifact 与 domain-event contract + +### 目标 + +把 `page.body.save / documents.save / blocks.patch|move|embed / snapshot save` 等复合命令补齐最终 formal Rust artifact / domain-event contract,避免停留在 noop 或过渡 hint。 + +### checklist + +- [x] 固定复合命令 event type: + - [x] `page.body.saved` + - [ ] `document.snapshot.saved` + - [x] `block.patched` + - [x] `block.moved` + - [x] `block.embedded` +- [x] 固定 payload schema: + - [x] document id / page id。 + - [x] block ids / patch summary / move source-target。 + - [x] snapshot version / content hash / updated_at 作为 `page.body.saved` payload 的一部分。 +- [x] Rust command plan 输出正式 `domainEventPlan` 与 `streamDeltaHint`,不再只用 noop 占位。 +- [x] 3000 adapters 与 Rust artifact writer 按同一 schema 落 `command_logs / domain_events`。 +- [x] SSE reducer 对这些事件有明确 delta 或保守 resync 策略。 +- [ ] 将 snapshot save 是否需要独立 `document.snapshot.saved` 事件定稿,并避免与 `page.body.saved` payload 重叠。 + +### 验收 + +- [x] `cd rust && cargo test -p bridge-runtime documents_save_command_plans_include_formal_domain_event_contract block_commands_include_formal_domain_event_contract` +- [x] `cd wolai-frontend && pnpm vitest run src/lib/documents/rust-runtime.test.ts src/lib/documents/page-write-command-adapter.test.ts src/lib/blocks/block-command-adapter.test.ts` +- [x] 至少一个非 noop 复合命令事件可在 command log 与 domain event 中稳定关联同一 `command_id`。 + +### 当前保留边界 + +- `document.snapshot.saved` 未独立落地;当前完成的是 `page.body.saved` formal payload 中包含 snapshot 摘要。 + +## 6. Phase N:3000 Next route 过渡职责继续收薄 + +### 目标 + +`3000` 保持唯一浏览器公开入口,但 Next route 只做薄代理、认证/浏览器能力桥和兼容边界,不继续承担树域主语义、主 delta 或主 renderer runtime。 + +### checklist + +- [x] 盘点 3000 route 中仍在拼树域主语义的入口。 +- [x] 将 route 内 action switch 收敛到 Rust command result / artifact writer。 +- [x] 对 polling / SSE / snapshot route 标注 Rust 主体与 Next 薄代理边界。 +- [x] 隔离只服务旧 compat host 的 runtime 分支,`/api/tree/shell` 默认关闭 debug 直连。 +- [x] 保留文件字节读取、upload URL、cookie/auth 等浏览器入口必须职责。 +- [ ] 将 `tree.node.embed` 的 `pageReference` block 组装从 3000 compat 边界迁到 Page Aggregate / Rust artifact。 + +### 验收 + +- [x] `cd wolai-frontend && pnpm vitest run src/app/api/tree/commands/route.test.ts src/lib/tree-stream/tree-delta.test.ts` +- [x] `node scripts/task112-tree-rust-family-regression-smoke.js` +- [x] 新增文档说明哪些 route 是 thin proxy,哪些仍是待迁移 compat:`design/04-tree-domain/process/4-17-tree-3000-route-thin-proxy-boundary-v1.md`。 + +### 当前保留边界 + +- 3000 仍是唯一浏览器公开入口;thin proxy 不等于删除浏览器能力桥。 +- `tree.node.embed` 的目标读取与 `pageReference` block 组装仍是明确标注的 compat 待迁移项。 + +## 7. Phase O:搜索语义 hardening + +### 目标 + +`file_tree` 搜索过滤主链已在 Rust projection query;后续只补更丰富搜索语义和稳定性,不作为当前 renderer/runtime gate。 + +### checklist + +- [x] 为 `index.md / asset / asset-folder / mindmap child / book / pdf` 搜索命中补更多 fixture。 +- [x] 明确 `maxResults` 与祖先补全的排序/截断规则。 +- [x] 增加异步索引可见性元数据,特别是 move/embed picker 搜索结果路径。 +- [x] 空结果与 fallback 文案保持稳定。 +- [ ] 如后续需要完整索引平台,再补真实异步索引延迟、刷新和可见性指标,而不是只依赖 projection request meta。 + +### 验收 + +- [x] `cd wolai-frontend && pnpm vitest run src/lib/tree-stream/tree-delta.test.ts` +- [x] 搜索 route / projection fixture 覆盖新增资源类型与边界。 + +### 当前保留边界 + +- 本阶段完成的是 file tree projection 搜索契约 hardening,不是独立搜索服务或完整索引指标平台。 + +## 8. 非目标 + +- 不拆除 Convex substrate。 +- 不把 OnlyOffice 嵌入 BlockNote 画布。 +- 不恢复旧 React PageTree/FileTree 作为 rust_family 主路径 fallback。 +- 不把 3104 重新暴露为默认前端入口。 +- 不在前端新增第二套树结构真相或排序真相。 diff --git a/design/04-tree-domain/process/4-17-tree-3000-route-thin-proxy-boundary-v1.md b/design/04-tree-domain/process/4-17-tree-3000-route-thin-proxy-boundary-v1.md new file mode 100644 index 00000000..19968c55 --- /dev/null +++ b/design/04-tree-domain/process/4-17-tree-3000-route-thin-proxy-boundary-v1.md @@ -0,0 +1,72 @@ +# 4-17 [process] tree 3000 route thin proxy boundary v1 + +> 创建时间:2026-04-27 +> +> 来源:`4-16` Phase N。 + +## 1. 结论 + +`3000` 继续是唯一浏览器公开入口,但 tree route 不再承载长期树域主语义。 + +- Rust runtime 负责 tree command legality、canonical plan、write operation、domain event plan 与稳定 stream delta 语义。 +- Next route 负责 cookie/auth、Convex client、browser/substrate 能力桥、CommandEnvelope、transport、artifact writer 调用与用户可见回包。 +- Convex 继续作为 substrate 执行器,不重新生成 tree truth。 + +## 2. Thin Proxy Route + +### `/api/tree/commands` + +保留职责: + +- 读取 auth / cookie 并获取 Convex client。 +- 构造 `CommandEnvelope`。 +- 调用 `resolveRustBridgeCommandPlan`。 +- 调用 `executeRustBridgeMutationTransport`。 +- 调用 `recordRustBridgeCommandArtifacts` / failure artifact writer。 +- 对浏览器返回 `requestId / traceId / result`。 + +不得新增: + +- 树合法性判断。 +- 排序 canonical plan。 +- 第二套 tree write truth。 +- 长期 streamDelta 主语义拼装。 + +当前仍保留的 substrate / browser 边界: + +- create / copy 后创建页面 scaffold。 +- copy 后复制 mindmap 文件。 +- move 前采集 sidebar snapshot 作为 Rust preflight data。 +- `tree.node.embed` 的目标内容读取与 `pageReference` block 组装。 + +`tree.node.embed` 的 pageReference 组装仍是待迁移 compat,后续应进入 Page Aggregate / Rust artifact,而不是继续扩写在 route 中。 + +### `/api/tree/stream` + +保留职责: + +- 作为浏览器 SSE / polling 入口。 +- 读取 bridge overview。 +- 优先转发 command log / domain event 中的 Rust `streamDelta`。 +- 缺少稳定 delta 或 command/event 不一致时保守 `resync`。 + +不得新增: + +- 在 Next 层重新解释树排序或 projection truth。 +- 为复合命令伪造局部树补丁。 + +## 3. Debug / Compat Route + +### `/api/tree/shell` + +定位: + +- 仅显式 debug/internal runtime 验证入口。 +- 3000 主路径不应默认请求 `mnote-web:3104`。 +- 主路径继续使用 same-origin inline host 与 Rust runtime artifact。 + +## 4. 代码锚点 + +- `wolai-frontend/src/lib/tree-route-boundary.ts` 固定 3000 route boundary manifest。 +- `wolai-frontend/src/app/api/tree/commands/route.test.ts` 绑定 tree commands route 的 thin proxy 定位。 +- `wolai-frontend/src/lib/tree-stream/tree-delta.ts` 明确 `resync_required` 为保守等待 snapshot/resync 的 delta。 diff --git a/harness-progress.txt b/harness-progress.txt index 4fd439ce..d1f27c0e 100644 --- a/harness-progress.txt +++ b/harness-progress.txt @@ -1,18 +1,46 @@ -[2026-04-19T15:01:07Z] [SESSION-0] RESET Cleared historical harness backlog and progress; previous tree-shell, runtime-shell, and old editor migration batches no longer represent the current plan. -[2026-04-19T15:01:07Z] [SESSION-0] INIT Initialized new harness batch from /mnt/Data1T/mnote/design/05-editor-mainline/process/5-3-tiptap-leptos-rust-migration-checklist-v1.md with focus on P0.5 main-editor cutover. -[2026-04-19T15:01:07Z] [SESSION-0] INIT Current base commit a0edc9d1; harness scope is limited to main editor integration, Rust truth boundary, minimal schema/command mapping, and acceptance for the new Tiptap/Leptos editor path. -[2026-04-19T15:01:07Z] [SESSION-0] STATS tasks_total=10 completed=0 failed=0 pending=10 blocked=0 attempts_total=0 checkpoints=0 -[2026-04-19T15:25:40Z] [SESSION-1] LOCK acquired (pid=3267717) -[2026-04-19T15:25:40Z] [SESSION-1] INIT Session start: base=a0edc9d1, task-001 set to in_progress -[2026-04-19T15:29:25Z] [SESSION-1] CHECKPOINT [task-001] step=1/1 "已冻结真实文档页主链、DocumentContent 接入点、切流 flag 归属,以及 runtime shell 仅保留 debug/prototype 的边界。" -[2026-04-19T15:29:25Z] [SESSION-1] Completed [task-001] (commit skipped by repo rule; base=a0edc9d1) -[2026-04-19T15:29:25Z] [SESSION-1] Starting [task-002] 执行 P0.5-2:把 leptos-tiptap host 接入真实文档页壳与真实路由,退出 localhost:8123 作为唯一验证入口的状态 (base=a0edc9d1) -[2026-04-19T16:12:53Z] [SESSION-1] CHECKPOINT [task-002] step=1/1 "已在真实 /documents/[id] 页面引入 editor host 切换层,新增 mnote-web embedded surface 与 main editor smoke,成功产出真实页面截图。" -[2026-04-19T16:12:53Z] [SESSION-1] Completed [task-002] (commit skipped by repo rule; base=a0edc9d1) -[2026-04-19T16:12:53Z] [SESSION-1] Starting [task-003] 执行 P0.5-3:引入稳定 block_id 策略,明确 Rust EditorBlock.block_id 为真相,Tiptap UniqueID 仅作 runtime 辅助 (base=a0edc9d1) -[2026-04-19T17:27:37Z] [MANUAL] RESET 用户已在 git 中撤销上一版 task-002 结果;该结果被判定为偏向 debug/runtime shell,不符合“8123 真实体验进入主编辑器”的目标。现将 task-002 重新置为 in_progress,task-003 及后续任务回退为 pending。 -[2026-04-19T18:24:24Z] [SESSION-2] LOCK acquired (pid=1205413) -[2026-04-19T18:24:24Z] [SESSION-2] INIT Session restart: task-002 rerun after git rollback; bridge strategy switched from public dist embedding to real 8123 runtime + embed contract. -[2026-04-19T18:24:24Z] [SESSION-2] CHECKPOINT [task-002] step=1/1 "已在真实 /documents/[id] 页面接入真实 8123 runtime,去掉 bridge 假壳,改为 spike 主动上报 ready/height,并让 smoke 自动拉起 8123。" -[2026-04-19T18:24:24Z] [SESSION-2] Completed [task-002] (commit skipped by repo rule; screenshot=/mnt/Data1T/mnote/test/results/main-editor/block-editor-main-editor-smoke/2026-04-19T18-24-21-028Z/success-final.png) -[2026-04-19T18:24:24Z] [SESSION-2] STATS tasks_total=10 completed=2 failed=0 pending=8 blocked=0 attempts_total=2 checkpoints=2 +[2026-04-26T23:16:13Z] [SESSION-0] RESET Cleared previous completed tree rust family harness batch; new batch is rebuilt from /mnt/Data1T/mnote/design/04-tree-domain/process/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md. +[2026-04-26T23:16:13Z] [SESSION-0] INIT Initialized 9 tasks for Phase K runtime artifact/state machines, Phase L tree.subtree.move write-path sinking, Phase M formal block/save events, Phase N route thinning, and Phase O search hardening. +[2026-04-26T23:16:13Z] [SESSION-0] INIT Base commit 6d89f5ca; unrelated dirty files must be preserved. +[2026-04-26T23:16:13Z] [SESSION-1] LOCK acquired (pid=manual-codex) +[2026-04-26T23:16:13Z] [SESSION-1] Starting [task-001] Phase K-1:定义 tree shell Rust runtime artifact 边界 (base=6d89f5ca) +[2026-04-26T23:16:13Z] [SESSION-1] STATS tasks_total=9 completed=0 failed=0 pending=8 in_progress=1 blocked=0 attempts_total=1 checkpoints=0 +[2026-04-26T23:22:39Z] [SESSION-1] CHECKPOINT [task-001] step=1/1 "已定义 rust_tree_shell_runtime_artifact_v1,并让 mnote-web rendererInput 与 3000 inline host 同步输出输入/输出/事件边界。" +[2026-04-26T23:22:39Z] [SESSION-1] Completed [task-001] (commit skipped by repo rule; validated by mnote-web renderer_input/runtime_artifact tests and tree-shell-iframe-host vitest) +[2026-04-26T23:22:39Z] [SESSION-1] Starting [task-002] Phase K-2:建立 page tree Rust-side runtime 状态机 (base=6d89f5ca) +[2026-04-26T23:22:39Z] [SESSION-1] STATS tasks_total=9 completed=1 failed=0 pending=7 in_progress=1 blocked=0 attempts_total=2 checkpoints=1 +[2026-04-26T23:27:43Z] [SESSION-1] CHECKPOINT [task-002] step=1/1 "已新增 page_runtime reducer,覆盖 focus normalize/next/previous/home/end、expand/collapse/open/context-menu intent、drop feedback 与 tree.subtree.move dispatch。" +[2026-04-26T23:27:43Z] [SESSION-1] Completed [task-002] (commit skipped by repo rule; validated by mnote-web page_renderer/page_focus/page_runtime, tree-shell iframe/surface vitest, and task112 smoke) +[2026-04-26T23:27:43Z] [SESSION-1] Starting [task-003] Phase K-3:建立 file tree Rust-side runtime 状态机 (base=6d89f5ca) +[2026-04-26T23:27:43Z] [SESSION-1] STATS tasks_total=9 completed=2 failed=0 pending=6 in_progress=1 blocked=0 attempts_total=3 checkpoints=2 +[2026-04-26T23:32:11Z] [SESSION-1] CHECKPOINT [task-003] step=1/1 "已新增 filetree_runtime reducer,覆盖 selection/range/context/normalize、drag row ids、copy/move effect、drop target feedback 与 doc/index/asset-folder/asset intent。" +[2026-04-26T23:32:11Z] [SESSION-1] Completed [task-003] (commit skipped by repo rule; validated by mnote-web filetree_renderer/filetree_selection/filetree_runtime, iframe/file-tree/tree-delta vitest, and task112 smoke) +[2026-04-26T23:32:11Z] [SESSION-1] Starting [task-004] Phase K-4:建立 picker Rust-side runtime 状态机 (base=6d89f5ca) +[2026-04-26T23:32:11Z] [SESSION-1] STATS tasks_total=9 completed=3 failed=0 pending=5 in_progress=1 blocked=0 attempts_total=4 checkpoints=3 +[2026-04-26T23:34:59Z] [SESSION-1] CHECKPOINT [task-004] step=1/1 "已新增 picker_runtime reducer,覆盖 normalize/next/previous/home/end/focus/pick、excluded ids、root pick 与 focus_dom 搜索输入边界。" +[2026-04-26T23:34:59Z] [SESSION-1] Completed [task-004] (commit skipped by repo rule; validated by mnote-web picker_renderer/picker_state/picker_runtime, picker dialog + iframe vitest, and task113 smoke) +[2026-04-26T23:34:59Z] [SESSION-1] Starting [task-005] Phase K-5:3000 host 调用正式 runtime artifact,并将 mnote-web /tree 固定为 debug/internal (base=6d89f5ca) +[2026-04-26T23:34:59Z] [SESSION-1] STATS tasks_total=9 completed=4 failed=0 pending=4 in_progress=1 blocked=0 attempts_total=5 checkpoints=4 +[2026-04-26T23:38:55Z] [SESSION-1] CHECKPOINT [task-005] step=1/1 "已固定 3000 inline host 与 mnote-web debug shell 均输出 rust_tree_shell_runtime_artifact_v1;task112/task113 smoke 证明 3000 主路径保持 inline iframe host 且 direct tree shell debug 默认禁用。" +[2026-04-26T23:38:55Z] [SESSION-1] Completed [task-005] (commit skipped by repo rule; validated by mnote-web tree_shell_embeds_renderer_input_contract, tree-shell iframe/surface vitest, task112 and task113 smoke) +[2026-04-26T23:38:55Z] [SESSION-1] Starting [task-006] Phase L:将 tree.subtree.move 最终写路径继续下沉 Rust kernel (base=6d89f5ca) +[2026-04-26T23:38:55Z] [SESSION-1] STATS tasks_total=9 completed=5 failed=0 pending=3 in_progress=1 blocked=0 attempts_total=6 checkpoints=5 +[2026-04-26T23:52:20Z] [SESSION-2] LOCK acquired (pid=3897587) +[2026-04-26T23:52:20Z] [SESSION-2] RECOVERY [task-006] action="validate existing uncommitted changes" reason="previous session left task-006 in_progress after implementation and validation" +[2026-04-26T23:52:52Z] [SESSION-2] CHECKPOINT [task-006] step=1/1 "已引入 Rust treeWriteOperation,Convex 优先执行 Rust write operation;目标验证通过。" +[2026-04-26T23:52:52Z] [SESSION-2] Completed [task-006] (commit skipped by repo rule; validated by bridge-runtime tree_subtree_move_command and 39 frontend target tests) +[2026-04-26T23:52:52Z] [SESSION-2] Starting [task-007] Phase M:补齐 block/save-snapshot formal artifact 与 domain-event contract (base=6d89f5ca) +[2026-04-26T23:52:52Z] [SESSION-2] STATS tasks_total=9 completed=6 failed=0 pending=2 in_progress=1 blocked=0 attempts_total=7 checkpoints=6 +[2026-04-27T00:09:12Z] [SESSION-2] CHECKPOINT [task-007] step=1/1 "formal domainEventPlan payload schema 与 resync_required delta 已落地;目标验证通过。" +[2026-04-27T00:09:12Z] [SESSION-2] Completed [task-007] (commit skipped by repo rule; validated by bridge-runtime formal contract tests and frontend adapter/runtime tests) +[2026-04-27T00:09:12Z] [SESSION-2] Starting [task-008] Phase N:继续收薄 3000 Next route 过渡职责 (base=6d89f5ca) +[2026-04-27T00:09:12Z] [SESSION-2] STATS tasks_total=9 completed=7 failed=0 pending=1 in_progress=1 blocked=0 attempts_total=8 checkpoints=7 +[2026-04-27T00:18:38Z] [SESSION-3] LOCK acquired (pid=codex-session-3) +[2026-04-27T00:18:38Z] [SESSION-3] RECOVERY [task-008] action="resume diagnostics with uncommitted changes" reason="previous session implemented Phase N but task112 picker move wait timed out" +[2026-04-27T00:28:56Z] [SESSION-3] CHECKPOINT [task-008] step=1/2 "已定位 task112 超时根因:move 请求实际发出但 Convex 运行态 documents.move validator 未加载 treeWriteOperation,执行 npx convex dev --once 刷新本地 functions。" +[2026-04-27T00:34:43Z] [SESSION-3] CHECKPOINT [task-008] step=2/2 "目标 Vitest 与 task112 smoke 通过;补充 iframe ready 等待,运行态 Convex functions 已刷新。" +[2026-04-27T00:34:43Z] [SESSION-3] Completed [task-008] (commit skipped by repo rule; validated by tree route/delta/boundary vitest and task112 smoke) +[2026-04-27T00:34:43Z] [SESSION-3] Starting [task-009] Phase O:搜索语义 hardening 与资源类型 fixture 补齐 (base=6d89f5ca) +[2026-04-27T00:41:58Z] [SESSION-3] CHECKPOINT [task-009] step=1/1 "file_tree 搜索 hardening 前端契约与资源 fixture 已完成;目标验证通过。" +[2026-04-27T00:41:58Z] [SESSION-3] Completed [task-009] (commit skipped by repo rule; validated by tree-delta, projection-client, kernel-file-tree tests and task112 smoke) +[2026-04-27T00:41:58Z] [SESSION-3] STATS tasks_total=9 completed=9 failed=0 pending=0 in_progress=0 blocked=0 attempts_total=9 checkpoints=9 +[2026-04-27T00:41:58Z] [SESSION-3] LOCK released diff --git a/harness-tasks.json b/harness-tasks.json index 3ad2e91e..d24f4b7a 100644 --- a/harness-tasks.json +++ b/harness-tasks.json @@ -1,256 +1,274 @@ { "version": 2, - "created": "2026-04-19T15:01:07Z", + "created": "2026-04-26T23:16:13Z", "session_config": { "concurrency_mode": "exclusive", - "max_tasks_per_session": 8, + "max_tasks_per_session": 9, "max_sessions": 80 }, "tasks": [ { "id": "task-001", - "title": "执行 P0.5-1:冻结主编辑器接入点与 runtime shell 退场策略,明确真实文档页切流入口、flag、debug/prototype 边界", + "title": "Phase K-1:定义 tree shell Rust runtime artifact 边界", "status": "completed", "priority": "P0", "depends_on": [], "attempts": 1, "max_attempts": 3, - "started_at_commit": "a0edc9d1", + "started_at_commit": "6d89f5ca", "validation": { - "command": "python3 - <<'PY'\nfrom pathlib import Path\npath = Path('/mnt/Data1T/mnote/design/05-editor-mainline/process/5-1-main-editor-cutover-entry-v1.md')\ntext = path.read_text(encoding='utf-8')\nassert '主编辑器接入点' in text\nassert 'runtime shell' in text\nassert 'debug/prototype' in text\nprint('task-001-cutover-entry-doc-ok')\nPY", - "timeout_seconds": 120 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已冻结真实文档页主链、DocumentContent 接入点、切流 flag 归属,以及 runtime shell 仅保留 debug/prototype 的边界。", - "timestamp": "2026-04-19T15:29:25Z" - } - ], - "completed_at": "2026-04-19T15:29:25Z" - }, - { - "id": "task-002", - "title": "执行 P0.5-2:把 leptos-tiptap host 接入真实文档页壳与真实路由,退出 localhost:8123 作为唯一验证入口的状态", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-001" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "a0edc9d1", - "validation": { - "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/components/editor/main-editor-host.test.tsx && cd /mnt/Data1T/mnote && node test/scripts/block-editor-main-editor-smoke.js", - "timeout_seconds": 900 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已改为真实 /documents/[id] 承载 8123 runtime,去掉 bridge 假壳,补齐 embed/postMessage 协议,并通过自动拉起 8123 的 smoke 验收截图。", - "timestamp": "2026-04-19T18:24:24Z" - } - ], - "completed_at": "2026-04-19T18:24:24Z" - }, - { - "id": "task-003", - "title": "执行 P0.5-3:引入稳定 block_id 策略,明确 Rust EditorBlock.block_id 为真相,Tiptap UniqueID 仅作 runtime 辅助", - "status": "pending", - "priority": "P0", - "depends_on": [ - "task-001" - ], - "attempts": 0, - "max_attempts": 3, - "started_at_commit": null, - "validation": { - "command": "cargo test -p core-protocol editor_block_id && cargo test -p mnote-web editor_block_id_bridge", - "timeout_seconds": 600 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [], - "completed_at": null - }, - { - "id": "task-004", - "title": "执行 P0.5-4:打通 EditorBlockDocument -> Tiptap doc 最小 load adapter,先覆盖 Paragraph/Heading/BulletListItem/NumberedListItem/Todo/Quote/CodeBlock", - "status": "pending", - "priority": "P0", - "depends_on": [ - "task-002", - "task-003" - ], - "attempts": 0, - "max_attempts": 3, - "started_at_commit": null, - "validation": { - "command": "cargo test -p mnote-web tiptap_load_adapter && cargo test -p mnote-editor-core editor_block_roundtrip_load", - "timeout_seconds": 900 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [], - "completed_at": null - }, - { - "id": "task-005", - "title": "执行 P0.5-5:打通 Tiptap doc -> EditorBlockDocument 最小 save adapter,并把真实页面保存链接到 Rust truth", - "status": "pending", - "priority": "P0", - "depends_on": [ - "task-002", - "task-003", - "task-004" - ], - "attempts": 0, - "max_attempts": 3, - "started_at_commit": null, - "validation": { - "command": "cargo test -p mnote-web tiptap_save_adapter && cargo test -p mnote-editor-core editor_block_roundtrip_save", - "timeout_seconds": 900 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [], - "completed_at": null - }, - { - "id": "task-006", - "title": "执行 P0.5-6:对齐最小块类型映射与 reload/re-enter 稳定性,确保常用块在真实页面中可稳定往返", - "status": "pending", - "priority": "P0", - "depends_on": [ - "task-004", - "task-005" - ], - "attempts": 0, - "max_attempts": 3, - "started_at_commit": null, - "validation": { - "command": "cargo test -p core-protocol editor_block_schema && cargo test -p mnote-web editor_reload_roundtrip", - "timeout_seconds": 900 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [], - "completed_at": null - }, - { - "id": "task-007", - "title": "执行 P0.5-7:对齐最小 EditorCommand 合同,先覆盖 ReplaceBlock/InsertBlockAfter/DeleteBlock/SplitBlock/MergeWithPrevious/MoveBlock", - "status": "pending", - "priority": "P0", - "depends_on": [ - "task-005", - "task-006" - ], - "attempts": 0, - "max_attempts": 3, - "started_at_commit": null, - "validation": { - "command": "cargo test -p core-protocol editor_command_catalog && cargo test -p mnote-web editor_command_mapping", - "timeout_seconds": 900 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [], - "completed_at": null - }, - { - "id": "task-008", - "title": "执行 P0.5-8:把当前已有的 slash、floating toolbar、左侧手柄、块菜单、turn into 迁入主编辑器,并按官方行为模型微调", - "status": "pending", - "priority": "P0", - "depends_on": [ - "task-002", - "task-006" - ], - "attempts": 0, - "max_attempts": 3, - "started_at_commit": null, - "validation": { - "command": "cargo test -p mnote-web tiptap_surface_ui && cd /mnt/Data1T/mnote && node test/scripts/block-editor-interaction-smoke.js", - "timeout_seconds": 1200 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [], - "completed_at": null - }, - { - "id": "task-009", - "title": "执行 P0.5-9:为图片上传、表格、页面引用、块引用、heading collapse、普通块缩进增强建立显式降级和 feature guard,避免污染正式合同", - "status": "pending", - "priority": "P0", - "depends_on": [ - "task-004", - "task-005" - ], - "attempts": 0, - "max_attempts": 3, - "started_at_commit": null, - "validation": { - "command": "cargo test -p mnote-web editor_feature_guards && python3 - <<'PY'\nfrom pathlib import Path\npath = Path('/mnt/Data1T/mnote/design/05-editor-mainline/process/5-1-main-editor-cutover-entry-v1.md')\ntext = path.read_text(encoding='utf-8')\nassert '未支持能力降级' in text or '降级策略' in text\nprint('task-009-feature-guard-doc-ok')\nPY", - "timeout_seconds": 600 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [], - "completed_at": null - }, - { - "id": "task-010", - "title": "执行 P0.5-10:建立主编辑器验收链路,覆盖打开文档、编辑常用块、slash 插入、turn into、刷新保留、再次进入保留", - "status": "pending", - "priority": "P0", - "depends_on": [ - "task-005", - "task-006", - "task-007", - "task-008", - "task-009" - ], - "attempts": 0, - "max_attempts": 3, - "started_at_commit": null, - "validation": { - "command": "cd /mnt/Data1T/mnote && node test/scripts/block-editor-main-editor-acceptance.js && cargo test -p mnote-web main_editor_acceptance", + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web renderer_input && cargo test -p mnote-web runtime_artifact && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/sidebar/tree-shell-iframe-host.test.tsx", "timeout_seconds": 1800 }, "on_failure": { "cleanup": null }, "error_log": [], - "checkpoints": [], - "completed_at": null + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已在 mnote-web rendererInput 中定义 rust_tree_shell_runtime_artifact_v1,覆盖 rendererInput/projection/expanded/selected/active/focused 输入、DOM patch/intent/command 输出和 focus/keyboard/expand/selection/context/drag/pick 事件边界;3000 inline host 同步输出同名 runtimeArtifact。", + "timestamp": "2026-04-26T23:22:39Z" + } + ], + "completed_at": "2026-04-26T23:22:39Z" + }, + { + "id": "task-002", + "title": "Phase K-2:建立 page tree Rust-side runtime 状态机", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-001" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "6d89f5ca", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_renderer && cargo test -p mnote-web page_focus && cargo test -p mnote-web page_runtime && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/sidebar/tree-shell-iframe-host.test.tsx src/components/sidebar/tree-shell-surface.test.tsx && node /mnt/Data1T/mnote/scripts/task112-tree-rust-family-regression-smoke.js", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已新增 page_runtime reducer,组合 focus/expand/open/context-menu/drop feedback/tree.subtree.move dispatch 输出,验证 page_renderer/page_focus/page_runtime、tree-shell iframe/surface vitest 与 task112 smoke 均通过。", + "timestamp": "2026-04-26T23:27:43Z" + } + ], + "completed_at": "2026-04-26T23:27:43Z" + }, + { + "id": "task-003", + "title": "Phase K-3:建立 file tree Rust-side runtime 状态机", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-001" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "6d89f5ca", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web filetree_renderer && cargo test -p mnote-web filetree_selection && cargo test -p mnote-web filetree_runtime && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/sidebar/tree-shell-iframe-host.test.tsx src/lib/file-tree/shell.test.ts src/lib/tree-stream/tree-delta.test.ts && node /mnt/Data1T/mnote/scripts/task112-tree-rust-family-regression-smoke.js", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已新增 filetree_runtime reducer,覆盖 selection/range/context/normalize、drag row ids、copy/move effect、drop target feedback,以及 doc/index/asset-folder/asset 的 open/context-menu intent;目标测试与 task112 smoke 通过。", + "timestamp": "2026-04-26T23:32:11Z" + } + ], + "completed_at": "2026-04-26T23:32:11Z" + }, + { + "id": "task-004", + "title": "Phase K-4:建立 picker Rust-side runtime 状态机", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-001" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "6d89f5ca", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web picker_renderer && cargo test -p mnote-web picker_state && cargo test -p mnote-web picker_runtime && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/documents/move-embed-picker-dialog.test.tsx src/components/sidebar/tree-shell-iframe-host.test.tsx && node /mnt/Data1T/mnote/scripts/task113-picker-keyboard-regression-smoke.js", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已新增 picker_runtime reducer,覆盖 normalize/next/previous/home/end/focus/pick、excluded ids、root pick,以及搜索输入不被键盘运行时抢 DOM 焦点的 focus_dom 输出边界;目标测试与 task113 smoke 通过。", + "timestamp": "2026-04-26T23:34:59Z" + } + ], + "completed_at": "2026-04-26T23:34:59Z" + }, + { + "id": "task-005", + "title": "Phase K-5:3000 host 调用正式 runtime artifact,并将 mnote-web /tree 固定为 debug/internal", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-002", + "task-003", + "task-004" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "6d89f5ca", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/sidebar/tree-shell-iframe-host.test.tsx src/components/sidebar/tree-shell-surface.test.tsx && node /mnt/Data1T/mnote/scripts/task112-tree-rust-family-regression-smoke.js && node /mnt/Data1T/mnote/scripts/task113-picker-keyboard-regression-smoke.js", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已固定 3000 inline host 与 mnote-web debug shell 均输出 rust_tree_shell_runtime_artifact_v1,并通过 tree-shell 目标测试、task112 与 task113 证明 3000 主路径继续使用 inline iframe host,/api/tree/shell debug 直连默认禁用。", + "timestamp": "2026-04-26T23:38:55Z" + } + ], + "completed_at": "2026-04-26T23:38:55Z" + }, + { + "id": "task-006", + "title": "Phase L:将 tree.subtree.move 最终写路径继续下沉 Rust kernel", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-005" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "6d89f5ca", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime tree_subtree_move_command && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/tree/commands/route.test.ts src/lib/documents/tree-command-client.test.ts src/lib/tree-stream/tree-delta.test.ts", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已引入 Rust treeWriteOperation,Convex documents.move 优先执行 Rust write operation,normalizedMove 保留旧 fallback;bridge-runtime move 单测与前端 39 个目标测试通过。", + "timestamp": "2026-04-26T23:52:52Z" + } + ], + "completed_at": "2026-04-26T23:52:52Z" + }, + { + "id": "task-007", + "title": "Phase M:补齐 block/save-snapshot formal artifact 与 domain-event contract", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-006" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "6d89f5ca", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime documents_save_command_plans_include_formal_domain_event_contract && cargo test -p bridge-runtime block_commands_include_formal_domain_event_contract && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/documents/rust-runtime.test.ts src/lib/documents/page-write-command-adapter.test.ts src/lib/blocks/block-command-adapter.test.ts", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已将 page.body.saved、block.patched、block.moved、block.embedded 切到 formal domainEventPlan payload schema;复合命令 streamDelta 使用 resync_required 保守策略,command log/domain event 通过同一 command_id 关联;目标验证通过。", + "timestamp": "2026-04-27T00:09:12Z" + } + ], + "completed_at": "2026-04-27T00:09:12Z" + }, + { + "id": "task-008", + "title": "Phase N:继续收薄 3000 Next route 过渡职责", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-007" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "6d89f5ca", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/tree/commands/route.test.ts src/lib/tree-stream/tree-delta.test.ts && node /mnt/Data1T/mnote/scripts/task112-tree-rust-family-regression-smoke.js", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 2, + "total": 2, + "description": "Phase N route thinning manifest 与 task112 iframe-ready smoke 同步已完成;刷新本地 Convex functions 后 move treeWriteOperation 运行态验证通过,目标 Vitest 与 task112 smoke 均通过。", + "timestamp": "2026-04-27T00:34:43Z" + } + ], + "completed_at": "2026-04-27T00:34:43Z" + }, + { + "id": "task-009", + "title": "Phase O:搜索语义 hardening 与资源类型 fixture 补齐", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-008" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "6d89f5ca", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/tree-stream/tree-delta.test.ts", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已补 file_tree 搜索 hardening 前端契约:projection-client 固定 maxResults=80、祖先补全/排序/异步可见性元数据,tree-delta fixture 覆盖 index、asset-folder、mindmap child、book、pdf;目标验证与 task112 smoke 通过。", + "timestamp": "2026-04-27T00:41:58Z" + } + ], + "completed_at": "2026-04-27T00:41:58Z" } ], - "session_count": 2, - "last_session": "2026-04-19T18:24:24Z" + "session_count": 3, + "last_session": "2026-04-27T00:41:58Z" } diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 71e04e6e..8bf2de28 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -1,45 +1,44 @@ use adapter_onlyoffice::{ + prepare_callback, prepare_forcesave, prepare_proxy_request, resolve_session, sign_config, OnlyOfficeCallbackPreparationInput, OnlyOfficeForcesavePreparationInput, - OnlyOfficeProxyPreparationInput, OnlyOfficeSessionResolveInput, prepare_callback, - prepare_forcesave, prepare_proxy_request, resolve_session, sign_config, + OnlyOfficeProxyPreparationInput, OnlyOfficeSessionResolveInput, }; use core_domain::Timestamp; use core_protocol::editor::{EditorBlockDocumentTiptapBridge, TiptapNode}; use core_protocol::{ - ActorPayload, BlockProps, CommandEnvelope, ContentNode, ContentNodePayload, - DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode, - DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree, - DocumentReadStats, DocumentReadSubtree, EditorBlock, EditorBlockDocument, EditorBlockType, - EditorCommand, EditorInsertBlockAfter, EditorReplaceBlock, EmbedBlock, GetBlock, - GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind, - KernelAttachEdge, KernelAuditStamp, KernelContentPayload, KernelCreateNode, KernelDetachEdge, - KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, - KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, - KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, - KernelProjectionAssetKind, KernelProjectionCapability, KernelProjectionFilter, - KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest, - KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult, - KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult, - KernelTraverseGraph, KernelUpdateNode, ListBridgeWorkspaceOverview, MindmapNodeData, - MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode, MoveBlock, PatchBlock, - PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef, + default_tool_registry, invocation_kind_label, tool_effect_label, ActorPayload, BlockProps, + CommandEnvelope, ContentNode, ContentNodePayload, DocumentContentResult, + DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta, + DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree, DocumentReadStats, + DocumentReadSubtree, EditorBlock, EditorBlockDocument, EditorBlockType, EditorCommand, + EditorInsertBlockAfter, EditorReplaceBlock, EmbedBlock, GetBlock, GetBridgeCommand, + GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind, KernelAttachEdge, + KernelAuditStamp, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge, + KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection, + KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges, + KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind, + KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, + KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta, + KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, + KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, ListBridgeWorkspaceOverview, + MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode, MoveBlock, + PatchBlock, PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageOptions, UpdatePageStats, - default_tool_registry, invocation_kind_label, tool_effect_label, }; use event_log::DomainEventRecord; use index_fts::{ - IndexCursor, MinimalWorkspaceProjector, SearchDocumentsDataset, SearchDocumentsEvaluation, - SearchDocumentsRequest, can_rebuild_from_events, evaluate_search_documents, - rebuild_from_events, + can_rebuild_from_events, evaluate_search_documents, rebuild_from_events, IndexCursor, + MinimalWorkspaceProjector, SearchDocumentsDataset, SearchDocumentsEvaluation, + SearchDocumentsRequest, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::collections::{BTreeMap, HashMap, VecDeque}; use std::env; use std::sync::atomic::{AtomicU64, Ordering}; use storage_convex_bridge::{ - BridgeContext, BridgeError, BridgeErrorKind, build_query_request, build_write_request, + build_query_request, build_write_request, BridgeContext, BridgeError, BridgeErrorKind, }; static TOOL_BLOCK_COUNTER: AtomicU64 = AtomicU64::new(1); @@ -1032,6 +1031,28 @@ fn resolve_document_move_order_plan( build_document_move_order_plan_from_snapshot(payload, &snapshot).map(Some) } +fn document_move_write_operation( + workspace_id: Option<&str>, + plan: Option<&DocumentMoveOrderPlan>, +) -> Value { + let Some(plan) = plan else { + return Value::Null; + }; + json!({ + "family": "tree", + "schema": "mnote.tree.write_operation", + "schemaVersion": 1, + "operation": "tree.subtree.move.write", + "workspaceId": workspace_id, + "documentId": plan.document_id, + "fromParentId": plan.from_parent_id, + "toParentId": plan.to_parent_id, + "requestedSortOrder": plan.requested_sort_order, + "normalizedSortOrder": plan.normalized_sort_order, + "patches": plan.patches, + }) +} + fn resolve_document_move_preflight( payload: &DocumentMoveCommandPayload, preflight_data: Option<&Value>, @@ -2048,6 +2069,32 @@ fn tree_domain_event_plan(event_type: &str, stream_delta_hint: Value) -> Value { }) } +fn tree_domain_event_plan_with_payload( + event_type: &str, + payload: Value, + stream_delta_hint: Value, +) -> Value { + json!({ + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": event_type, + "payload": payload, + "streamDeltaHint": stream_delta_hint, + }) +} + +fn tree_resync_required_hint(reason: &str, args: Value) -> Value { + let mut hint_args = serde_json::Map::new(); + hint_args.insert("reason".into(), Value::String(reason.into())); + if let Some(args) = args.as_object() { + for (key, value) in args { + hint_args.insert(key.clone(), value.clone()); + } + } + tree_stream_delta_hint("resync_required", Value::Object(hint_args)) +} + fn read_trimmed_str_field<'a>(value: &'a Value, field: &str) -> Option<&'a str> { value .get(field) @@ -2077,6 +2124,23 @@ fn materialize_tree_stream_delta_from_hint(hint: &Value, result: &Value) -> Opti match kind { "noop" => Some(json!({ "op": "noop" })), + "resync_required" => { + let mut delta = serde_json::Map::new(); + delta.insert("op".into(), Value::String("resync_required".into())); + if let Some(reason) = read_trimmed_str_field(args, "reason") { + delta.insert("reason".into(), Value::String(reason.into())); + } + if let Some(page_id) = read_trimmed_str_field(args, "pageId") { + delta.insert("pageId".into(), Value::String(page_id.into())); + } + if let Some(document_id) = read_trimmed_str_field(args, "documentId") { + delta.insert("documentId".into(), Value::String(document_id.into())); + } + if let Some(block_id) = read_trimmed_str_field(args, "blockId") { + delta.insert("blockId".into(), Value::String(block_id.into())); + } + Some(Value::Object(delta)) + } "remove_document" => { let document_id = read_trimmed_str_field(args, "documentId")?; Some(json!({ @@ -2244,6 +2308,13 @@ fn tree_artifact_payload( map.insert("streamDelta".into(), stream_delta.clone()); } } + if let Some(formal_payload) = domain_event_plan.get("payload").and_then(Value::as_object) { + if let Some(map) = payload.as_object_mut() { + for (key, value) in formal_payload { + map.insert(key.clone(), value.clone()); + } + } + } payload } @@ -2439,9 +2510,9 @@ pub fn execute_runtime_query(input: RuntimeInput) -> Result tool, data, } => execute_tool_result(context, tool, data.unwrap_or(Value::Null)), - RuntimeInput::Command { .. } | RuntimeInput::CommandArtifact { .. } => Err(BridgeError::validation( - "execute_runtime_query 仅支持 query 输入", - )), + RuntimeInput::Command { .. } | RuntimeInput::CommandArtifact { .. } => Err( + BridgeError::validation("execute_runtime_query 仅支持 query 输入"), + ), } } @@ -2452,7 +2523,10 @@ pub fn build_success_response(plan: RuntimeExecutionPlan) -> RuntimeSuccess { pub fn build_artifact_success_response( artifacts: Option, ) -> RuntimeArtifactSuccess { - RuntimeArtifactSuccess { ok: true, artifacts } + RuntimeArtifactSuccess { + ok: true, + artifacts, + } } pub fn execute_runtime_command_artifact( @@ -2466,11 +2540,7 @@ pub fn execute_runtime_command_artifact( result, now, } => Ok(build_runtime_command_artifact_plan( - &context, - &command, - &plan, - &result, - &now, + &context, &command, &plan, &result, &now, )), _ => Err(BridgeError::validation( "execute_runtime_command_artifact 仅支持 command artifact 输入", @@ -6540,7 +6610,11 @@ fn build_file_tree_projection_result( fn normalize_projection_query(raw: Option<&str>) -> Option { let query = raw?.trim().to_lowercase(); - if query.is_empty() { None } else { Some(query) } + if query.is_empty() { + None + } else { + Some(query) + } } fn file_tree_item_matches_query(item: &KernelProjectionItem, query: &str) -> bool { @@ -7217,11 +7291,28 @@ fn execute_command( actor_id: request.actor_id, idempotency_key: request.idempotency_key, payload_json: request.payload_json, - args_json: json!({ + args_json: { + let stream_delta_hint = tree_resync_required_hint( + "blocks.patch", + json!({ + "documentId": payload.document_id, + "blockId": payload.block_id, + }), + ); + let domain_event_payload = block_patch_domain_event_payload(&payload); + json!({ "id": payload.document_id, "blockId": payload.block_id, "nextBlock": payload.next_block, - }), + "streamDeltaHint": stream_delta_hint, + "domainEventHint": tree_domain_event_hint("block.patched"), + "domainEventPlan": tree_domain_event_plan_with_payload( + "block.patched", + domain_event_payload, + stream_delta_hint, + ), + }) + }, })) } "blocks.move" => { @@ -7245,6 +7336,14 @@ fn execute_command( validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; + let stream_delta_hint = tree_resync_required_hint( + "blocks.move", + json!({ + "documentId": payload.target_document_id, + "blockId": payload.block_id, + }), + ); + let domain_event_payload = block_move_domain_event_payload(&payload); Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, @@ -7259,6 +7358,13 @@ fn execute_command( "id": payload.block_id, "sourceDocumentId": payload.source_document_id, "targetDocumentId": payload.target_document_id, + "streamDeltaHint": stream_delta_hint, + "domainEventHint": tree_domain_event_hint("block.moved"), + "domainEventPlan": tree_domain_event_plan_with_payload( + "block.moved", + domain_event_payload, + stream_delta_hint, + ), }), })) } @@ -7283,6 +7389,14 @@ fn execute_command( validate_only: command_wire.validate_only, }; let request = build_write_request(&context, &command)?; + let stream_delta_hint = tree_resync_required_hint( + "blocks.embed", + json!({ + "documentId": payload.target_document_id, + "blockId": payload.block_id, + }), + ); + let domain_event_payload = block_embed_domain_event_payload(&payload); Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { command_name: command.name, command_id: command.command_id, @@ -7298,6 +7412,13 @@ fn execute_command( "blockId": payload.block_id, "targetDocumentId": payload.target_document_id, "targetBlockId": payload.target_block_id, + "streamDeltaHint": stream_delta_hint, + "domainEventHint": tree_domain_event_hint("block.embedded"), + "domainEventPlan": tree_domain_event_plan_with_payload( + "block.embedded", + domain_event_payload, + stream_delta_hint, + ), }), })) } @@ -7426,8 +7547,7 @@ fn execute_command( })) } "documents.stats.update" => { - let payload: DocumentStatsCommandPayload = - parse_payload(command_wire.payload.clone())?; + let payload: DocumentStatsCommandPayload = parse_payload(command_wire.payload.clone())?; let stats = payload.stats; let command = CommandEnvelope { name: "documents.stats.update".into(), @@ -7549,14 +7669,35 @@ fn execute_command( actor_id: request.actor_id, idempotency_key: request.idempotency_key, payload_json: request.payload_json, - args_json: json!({ + args_json: { + let stream_delta_hint = tree_resync_required_hint( + "page_body_saved", + json!({ + "pageId": payload.document_id, + "documentId": payload.document_id, + }), + ); + let domain_event_payload = document_save_domain_event_payload( + &payload, + &editor_document, + &canonical_content, + )?; + json!({ "id": payload.document_id, "content": canonical_content, "editorDocument": editor_document, "tiptapDocument": payload.tiptap_document, "expectedRevision": payload.revision, "conflictDetectionKey": payload.conflict_detection_key, - }), + "streamDeltaHint": stream_delta_hint, + "domainEventHint": tree_domain_event_hint("page.body.saved"), + "domainEventPlan": tree_domain_event_plan_with_payload( + "page.body.saved", + domain_event_payload, + stream_delta_hint, + ), + }) + }, })) } "documents.embed" | "tree.node.embed" => { @@ -7874,6 +8015,10 @@ fn execute_command( "parentId": payload.parent_id, "sortOrder": payload.sort_order, "normalizedMove": normalized_move, + "treeWriteOperation": document_move_write_operation( + context.workspace_id.as_deref(), + normalized_move.as_ref(), + ), "streamDeltaHint": tree_stream_delta_hint("move_document", json!({ "documentId": payload.document_id, "parentId": payload.parent_id, @@ -8625,6 +8770,93 @@ fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value ) } +fn stable_json_content_hash(value: &Value) -> Result { + let serialized = serde_json::to_string(value).map_err(|error| { + BridgeError::validation(format!("复合命令 payload content hash 序列化失败: {error}")) + })?; + let mut hash = 0xcbf29ce484222325u64; + for byte in serialized.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + Ok(format!("fnv1a64:{hash:016x}")) +} + +fn editor_document_block_ids(document: &EditorBlockDocument) -> Vec { + let mut ids = document.root_block_ids.clone(); + for block in &document.blocks { + if !ids.iter().any(|id| id == &block.block_id) { + ids.push(block.block_id.clone()); + } + } + ids +} + +fn document_save_domain_event_payload( + payload: &DocumentSaveCommandPayload, + editor_document: &EditorBlockDocument, + canonical_content: &Value, +) -> Result { + Ok(json!({ + "page": { + "id": payload.document_id.clone(), + "workspaceId": payload.workspace_id.clone(), + }, + "snapshot": { + "version": payload.revision, + "contentHash": stable_json_content_hash(canonical_content)?, + "updatedAt": Value::Null, + }, + "blocks": { + "ids": editor_document_block_ids(editor_document), + "count": editor_document.blocks.len(), + }, + })) +} + +fn block_patch_domain_event_payload(payload: &BlockPatchCommandPayload) -> Value { + let next_type = read_trimmed_str_field(&payload.next_block, "type") + .or_else(|| read_trimmed_str_field(&payload.next_block, "blockType")); + json!({ + "document": { + "id": payload.document_id.clone(), + "workspaceId": payload.workspace_id.clone(), + }, + "block": { + "id": payload.block_id.clone(), + }, + "patch": { + "summary": "replace_block", + "nextType": next_type, + }, + }) +} + +fn block_move_domain_event_payload(payload: &BlockMoveCommandPayload) -> Value { + json!({ + "block": { + "id": payload.block_id.clone(), + }, + "move": { + "sourceDocumentId": payload.source_document_id.clone(), + "targetDocumentId": payload.target_document_id.clone(), + }, + }) +} + +fn block_embed_domain_event_payload(payload: &BlockEmbedCommandPayload) -> Value { + json!({ + "block": { + "id": payload.block_id.clone(), + }, + "embed": { + "sourceDocumentId": payload.source_document_id.clone(), + "targetDocumentId": payload.target_document_id.clone(), + "targetBlockId": payload.target_block_id.clone(), + }, + }) +} + fn to_bridge_context(context: RuntimeBridgeContextWire) -> BridgeContext { BridgeContext { deployment_id: context.deployment_id, @@ -8791,6 +9023,26 @@ mod tests { "id": "block_1", "type": "paragraph", }, + "streamDeltaHint": { + "family": "tree", + "kind": "noop", + "args": {} + }, + "domainEventHint": { + "family": "tree", + "eventType": "tree.block.patched" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.block.patched", + "streamDeltaHint": { + "family": "tree", + "kind": "noop", + "args": {} + } + } }) ); } @@ -9866,6 +10118,108 @@ mod tests { ); } + #[test] + fn documents_save_command_plans_include_formal_domain_event_contract() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "page.body.save".into(), + command_id: "cmd_save_contract_1".into(), + idempotency_key: Some("idem_save_contract_1".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + "revision": 8, + "content": [ + { + "id": "legacy_content_1", + "type": "paragraph", + "content": "来自旧式 content-only 保存" + } + ], + "conflictDetectionKey": "doc_1:8" + }), + preflight_data: None, + reason: Some("保存正文".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("page.body.save plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!( + plan.args_json["streamDeltaHint"], + json!({ + "family": "tree", + "kind": "resync_required", + "args": { + "reason": "page_body_saved", + "pageId": "doc_1", + "documentId": "doc_1" + } + }) + ); + assert_eq!( + plan.args_json["domainEventHint"], + json!({ + "family": "tree", + "eventType": "page.body.saved" + }) + ); + assert_eq!( + plan.args_json["domainEventPlan"], + json!({ + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "page.body.saved", + "payload": { + "page": { + "id": "doc_1", + "workspaceId": "ws_1" + }, + "snapshot": { + "version": 8, + "contentHash": "fnv1a64:d039f8f3496411e8", + "updatedAt": null + }, + "blocks": { + "ids": ["legacy_content_1"], + "count": 1 + } + }, + "streamDeltaHint": { + "family": "tree", + "kind": "resync_required", + "args": { + "reason": "page_body_saved", + "pageId": "doc_1", + "documentId": "doc_1" + } + } + }) + ); + } + #[test] fn blocks_move_command_plan_maps_to_blocks_move() { let plan = execute_runtime_input(RuntimeInput::Command { @@ -9912,6 +10266,43 @@ mod tests { "id": "block_1", "sourceDocumentId": "doc_1", "targetDocumentId": "doc_2", + "streamDeltaHint": { + "family": "tree", + "kind": "resync_required", + "args": { + "reason": "blocks.move", + "documentId": "doc_2", + "blockId": "block_1" + } + }, + "domainEventHint": { + "family": "tree", + "eventType": "block.moved" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "block.moved", + "payload": { + "block": { + "id": "block_1" + }, + "move": { + "sourceDocumentId": "doc_1", + "targetDocumentId": "doc_2" + } + }, + "streamDeltaHint": { + "family": "tree", + "kind": "resync_required", + "args": { + "reason": "blocks.move", + "documentId": "doc_2", + "blockId": "block_1" + } + } + } }) ); } @@ -9968,6 +10359,44 @@ mod tests { "blockId": "block_1", "targetDocumentId": "doc_2", "targetBlockId": "anchor_1", + "streamDeltaHint": { + "family": "tree", + "kind": "resync_required", + "args": { + "reason": "blocks.embed", + "documentId": "doc_2", + "blockId": "block_1" + } + }, + "domainEventHint": { + "family": "tree", + "eventType": "block.embedded" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "block.embedded", + "payload": { + "block": { + "id": "block_1" + }, + "embed": { + "sourceDocumentId": "doc_1", + "targetDocumentId": "doc_2", + "targetBlockId": "anchor_1" + } + }, + "streamDeltaHint": { + "family": "tree", + "kind": "resync_required", + "args": { + "reason": "blocks.embed", + "documentId": "doc_2", + "blockId": "block_1" + } + } + } }) ); } @@ -9976,6 +10405,159 @@ mod tests { } } + #[test] + fn block_commands_include_formal_domain_event_contract() { + let cases = [ + ( + "blocks.patch", + json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + "blockId": "block_1", + "nextBlock": { + "id": "block_1", + "type": "paragraph" + } + }), + "block.patched", + json!({ + "document": { + "id": "doc_1", + "workspaceId": "ws_1" + }, + "block": { + "id": "block_1" + }, + "patch": { + "summary": "replace_block", + "nextType": "paragraph" + } + }), + ), + ( + "blocks.move", + json!({ + "sourceDocumentId": "doc_1", + "blockId": "block_1", + "targetDocumentId": "doc_2" + }), + "block.moved", + json!({ + "block": { + "id": "block_1" + }, + "move": { + "sourceDocumentId": "doc_1", + "targetDocumentId": "doc_2" + } + }), + ), + ( + "blocks.embed", + json!({ + "sourceDocumentId": "doc_1", + "blockId": "block_1", + "targetDocumentId": "doc_2", + "targetBlockId": "anchor_1" + }), + "block.embedded", + json!({ + "block": { + "id": "block_1" + }, + "embed": { + "sourceDocumentId": "doc_1", + "targetDocumentId": "doc_2", + "targetBlockId": "anchor_1" + } + }), + ), + ]; + + for (command_name, payload, event_type, expected_payload) in cases { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: command_name.into(), + command_id: format!("cmd_{command_name}_contract"), + idempotency_key: Some(format!("idem_{command_name}_contract")), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_2".into()), + block_id: Some("block_1".into()), + }), + payload, + preflight_data: None, + reason: Some("块命令 formal contract".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("block command plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!( + plan.args_json["streamDeltaHint"], + json!({ + "family": "tree", + "kind": "resync_required", + "args": { + "reason": command_name, + "documentId": if command_name == "blocks.patch" { + "doc_1" + } else { + "doc_2" + }, + "blockId": "block_1" + } + }) + ); + assert_eq!( + plan.args_json["domainEventHint"], + json!({ + "family": "tree", + "eventType": event_type + }) + ); + assert_eq!( + plan.args_json["domainEventPlan"], + json!({ + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": event_type, + "payload": expected_payload, + "streamDeltaHint": { + "family": "tree", + "kind": "resync_required", + "args": { + "reason": command_name, + "documentId": if command_name == "blocks.patch" { + "doc_1" + } else { + "doc_2" + }, + "blockId": "block_1" + } + } + }) + ); + } + } + #[test] fn documents_embed_command_plan_maps_to_documents_update_content() { let plan = execute_runtime_input(RuntimeInput::Command { @@ -10695,6 +11277,78 @@ mod tests { ); } + #[test] + fn tree_subtree_move_command_includes_tree_write_operation_from_snapshot() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.subtree.move".into(), + command_id: "cmd_tree_move_write".into(), + idempotency_key: Some("idem_tree_move_write".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_b".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_b", + "parentId": "target", + "sortOrder": 99 + }), + preflight_data: Some(json!({ + "documents": [ + { "id": "target", "workspace_id": "ws_1", "parent_id": null, "sort_order": 0, "created_at": "2026-04-25T00:00:00Z" }, + { "id": "doc_a", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 0, "created_at": "2026-04-25T00:00:01Z" }, + { "id": "doc_b", "workspace_id": "ws_1", "parent_id": "source", "sort_order": 1, "created_at": "2026-04-25T00:00:02Z" }, + { "id": "doc_c", "workspace_id": "ws_1", "parent_id": "target", "sort_order": 0, "created_at": "2026-04-25T00:00:03Z" } + ] + })), + reason: Some("树命令切流".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("command plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!( + plan.args_json["treeWriteOperation"], + json!({ + "family": "tree", + "schema": "mnote.tree.write_operation", + "schemaVersion": 1, + "operation": "tree.subtree.move.write", + "workspaceId": "ws_1", + "documentId": "doc_b", + "fromParentId": "source", + "toParentId": "target", + "requestedSortOrder": 99, + "normalizedSortOrder": 1, + "patches": [ + { + "documentId": "doc_b", + "parentId": "target", + "sortOrder": 1, + "moved": true + } + ] + }) + ); + } + #[test] fn tree_command_artifact_plan_materializes_domain_event_payload_from_rust_plan() { let context = demo_context(); @@ -12397,12 +13051,10 @@ mod tests { result["items"][0]["resourceMeta"]["resourceKind"], json!("document") ); - assert!( - result["items"][0]["capabilities"] - .as_array() - .map(|caps| caps.contains(&json!("create-child"))) - .unwrap_or(false) - ); + assert!(result["items"][0]["capabilities"] + .as_array() + .map(|caps| caps.contains(&json!("create-child"))) + .unwrap_or(false)); assert_eq!(result["items"][1]["parentNodeId"], json!("page_root")); assert_eq!(result["items"][1]["position"], json!(1)); } diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index f99131b8..938ec5c8 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -2242,6 +2242,10 @@ fn build_tree_shell_html( if (nextExpanded) expanded.add(nodeId); else expanded.delete(nodeId); postPageExpandChange(nodeId, nextExpanded); + if (mode === "page" && usedRustInitialRenderer && patchPageTreeExpansionDom(nodeId)) { + focusRowElement(nodeId); + return; + } renderTree(); }; @@ -2410,6 +2414,11 @@ fn build_tree_shell_html( } focusedNodeId = nodeId; postPageFocusChange(nodeId); + if (usedRustInitialRenderer) { + patchPageTreeActiveDom(); + focusRowElement(nodeId); + return; + } renderTree(); focusRowElement(nodeId); }; @@ -2460,7 +2469,11 @@ fn build_tree_shell_html( if (item?.childCount > 0 && !expanded.has(item.nodeId)) { expanded.add(item.nodeId); postPageExpandChange(item.nodeId, true); - renderTree(); + if (usedRustInitialRenderer) { + patchPageTreeExpansionDom(item.nodeId); + } else { + renderTree(); + } focusRowElement(item.nodeId); return; } @@ -2474,7 +2487,11 @@ fn build_tree_shell_html( if (item?.childCount > 0 && expanded.has(item.nodeId)) { expanded.delete(item.nodeId); postPageExpandChange(item.nodeId, false); - renderTree(); + if (usedRustInitialRenderer) { + patchPageTreeExpansionDom(item.nodeId); + } else { + renderTree(); + } focusRowElement(item.nodeId); return; } @@ -2524,12 +2541,13 @@ fn build_tree_shell_html( }); }; - const applyPickerFocusByItemKey = (pickerItemKey) => { + const applyPickerFocusByItemKey = (pickerItemKey, options = {}) => { if (mode !== "picker") return; const result = computePickerStateActionResult({ kind: "focus", itemKey: pickerItemKey, }); + const shouldFocusDom = options.focusDom === true; const nextPickerItemKey = result.nextItemKey || ""; const nextDocumentId = nextPickerItemKey && nextPickerItemKey !== "__root__" @@ -2541,11 +2559,11 @@ fn build_tree_shell_html( focusedNodeId = nextDocumentId || ""; if (usedRustInitialRenderer) { patchPickerActiveDom(); - focusPickerRowElement(nextPickerItemKey); + if (shouldFocusDom) focusPickerRowElement(nextPickerItemKey); } else { renderTree(); } - if (nextDocumentId) { + if (shouldFocusDom && nextDocumentId) { focusRowElement(nextDocumentId); } postPickerFocusChange(nextPickerItemKey || null); @@ -2567,6 +2585,27 @@ fn build_tree_shell_html( return result; }; + const postPickerPickResultToHost = (result) => { + if (mode !== "picker" || !result) return; + if (result.pickedRoot) { + setLastAction("已选择根目录"); + postToHost("tree.pick.root", { + documentId: null, + target: { documentId: null }, + payload: { documentId: null }, + }); + return; + } + if (result.pickedDocumentId) { + postToHost("tree.pick", { + documentId: result.pickedDocumentId, + itemKey: result.nextItemKey || result.pickedDocumentId, + target: { documentId: result.pickedDocumentId }, + payload: { documentId: result.pickedDocumentId }, + }); + } + }; + const handlePickerCommand = (command) => { if (mode !== "picker") return; @@ -2576,19 +2615,7 @@ fn build_tree_shell_html( } if (normalizedCommand === "pick") { - const result = applyPickerStateAction({ kind: "pick" }); - if (result.pickedRoot) { - setLastAction("已选择根目录"); - postToHost("tree.pick.root", { - documentId: null, - target: { documentId: null }, - payload: { documentId: null }, - }); - return; - } - if (result.pickedDocumentId) { - handleNavigate(result.pickedDocumentId); - } + postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" })); return; } @@ -2947,7 +2974,10 @@ fn build_tree_shell_html( element.addEventListener("click", (event) => { event.stopPropagation(); const action = normalizeText(element.dataset.rustAction); - if (action === "open") { + if (action === "toggle") { + event.preventDefault(); + toggleExpand(item.nodeId); + } else if (action === "open") { handleNavigate(item.nodeId); } else if (action === "create") { void handleCreate(item.nodeId); @@ -2961,6 +2991,72 @@ fn build_tree_shell_html( }); }; + const patchPageTreeActiveDom = () => { + if (mode !== "page") return; + appElement.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => { + if (!(row instanceof HTMLElement)) return; + const nodeId = normalizeText(row.dataset.nodeId); + const isFocused = nodeId === focusedNodeId; + row.dataset.active = String(nodeId === currentActiveDocumentId); + row.dataset.focused = String(isFocused); + row.tabIndex = isFocused ? 0 : -1; + row.dataset.dropFeedback = String(activePageDropNodeId === nodeId); + }); + }; + + const patchPageTreeExpansionDom = (nodeId) => { + if (mode !== "page") return false; + const normalizedNodeId = normalizeText(nodeId); + if (!normalizedNodeId) return false; + const item = itemById.get(normalizedNodeId); + if (!item) return false; + const nodeElement = appElement.querySelector( + `.tree-node[data-node-id="${CSS.escape(normalizedNodeId)}"]`, + ); + if (!(nodeElement instanceof HTMLElement)) return false; + const row = nodeElement.querySelector( + `:scope > .tree-row[data-node-id="${CSS.escape(normalizedNodeId)}"]`, + ); + const children = getSiblings(normalizedNodeId); + const hasChildren = item.childCount > 0 && children.length > 0; + const isExpanded = hasChildren && expanded.has(normalizedNodeId); + if (row instanceof HTMLElement) { + row.setAttribute("aria-expanded", hasChildren ? String(isExpanded) : "false"); + const toggleButton = row.querySelector('[data-testid="tree-node-toggle"]'); + if (toggleButton instanceof HTMLButtonElement) { + toggleButton.textContent = isExpanded ? "▾" : "▸"; + toggleButton.setAttribute( + "aria-label", + `${isExpanded ? "折叠" : "展开"} ${item.title}`, + ); + } + } + if (!hasChildren) { + patchPageTreeActiveDom(); + return true; + } + let childrenList = Array.from(nodeElement.children).find( + (child) => child instanceof HTMLElement && child.classList.contains("tree-children"), + ); + if (isExpanded) { + if (!(childrenList instanceof HTMLElement)) { + childrenList = document.createElement("ul"); + childrenList.className = "tree-children"; + children.forEach((child) => { + childrenList.appendChild(renderNode(child)); + }); + nodeElement.appendChild(childrenList); + } + childrenList.hidden = false; + childrenList.style.display = ""; + } else if (childrenList instanceof HTMLElement) { + childrenList.hidden = true; + childrenList.style.display = "none"; + } + patchPageTreeActiveDom(); + return true; + }; + const hydrateInitialPageTree = () => { if (mode !== "page") return false; const root = appElement.querySelector('[data-rust-page-renderer="initial_v1"]'); @@ -3210,12 +3306,8 @@ fn build_tree_shell_html( if (!(row instanceof HTMLElement)) return; row.dataset.focused = String(resolvePickerRootFocused()); row.addEventListener("click", () => { - setLastAction("已选择根目录"); - postToHost("tree.pick.root", { - documentId: null, - target: { documentId: null }, - payload: { documentId: null }, - }); + applyPickerFocusByItemKey("__root__", { focusDom: true }); + postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" })); }); }; @@ -3228,7 +3320,8 @@ fn build_tree_shell_html( (!currentActivePickerItemKey && currentActiveDocumentId === item.nodeId), ); row.addEventListener("click", () => { - handleNavigate(item.nodeId); + applyPickerFocusByItemKey(item.nodeId, { focusDom: true }); + postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" })); }); }; @@ -3447,7 +3540,14 @@ fn build_tree_shell_html( linkButton.className = "tree-link"; linkButton.setAttribute("data-testid", "tree-node-open"); linkButton.setAttribute("aria-label", `打开 ${item.title}`); - linkButton.addEventListener("click", () => handleNavigate(item.nodeId)); + linkButton.addEventListener("click", () => { + if (mode === "picker") { + applyPickerFocusByItemKey(item.nodeId, { focusDom: true }); + postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" })); + return; + } + handleNavigate(item.nodeId); + }); const titleElement = document.createElement("span"); titleElement.className = "tree-link-title"; @@ -3812,13 +3912,10 @@ fn build_tree_shell_html( rootButton.className = "tree-row"; rootButton.setAttribute("data-testid", "tree-picker-root"); rootButton.dataset.focused = String(resolvePickerRootFocused()); + rootButton.tabIndex = resolvePickerRootFocused() ? 0 : -1; rootButton.addEventListener("click", () => { - setLastAction("已选择根目录"); - postToHost("tree.pick.root", { - documentId: null, - target: { documentId: null }, - payload: { documentId: null }, - }); + applyPickerFocusByItemKey("__root__", { focusDom: true }); + postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" })); }); const spacer = document.createElement("div"); @@ -3903,7 +4000,6 @@ fn build_tree_shell_html( focusedNodeId = resolveFocusedNodeIdFromHostState(); if (mode === "picker" && usedRustInitialRenderer) { patchPickerActiveDom(); - focusPickerRowElement(currentActivePickerItemKey); return; } renderTree(); @@ -4345,8 +4441,12 @@ mod tests { assert!(html.contains("tree.shell.state.patch")); assert!(html.contains("\"contractName\":\"rust_page_focus_keyboard_reducer_v1\"")); assert!(html.contains("applyPageKeyboardAction")); + assert!(html.contains("patchPageTreeActiveDom")); + assert!(html.contains("patchPageTreeExpansionDom")); assert!(html.contains("data-rust-page-renderer=\"initial_v1\"")); assert!(html.contains("data-rust-rendered-row=\"page\"")); + assert!(html.contains("data-rust-action=\"toggle\"")); + assert!(html.contains("data-testid=\"tree-node-toggle\"")); assert!(html.contains("hydrateInitialPageTree")); assert!(html.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")); assert!(html.contains("application/x-mnote-page-tree-node")); @@ -4382,8 +4482,14 @@ mod tests { assert!(html.contains("tree.picker.focus.changed")); assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\"")); assert!(html.contains("applyPickerStateAction")); + assert!(html.contains("postPickerPickResultToHost")); + assert!(html.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")); + assert!(html.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })")); + assert!(html.contains("const shouldFocusDom = options.focusDom === true")); + assert!(html.contains("if (shouldFocusDom) focusPickerRowElement")); assert!(html.contains("patchPickerActiveDom")); assert!(html.contains("hydrateInitialPickerTree")); + assert!(html.contains("tabindex=\"")); assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); assert!(html.contains("__MNOTE_TREE_SHELL_OVERRIDE__")); } @@ -4442,6 +4548,9 @@ mod tests { assert!(filetree_html.contains("\"filetreeSelection\"")); assert!(filetree_html.contains("\"selectedRowIds\"")); assert!(filetree_html.contains("\"commandDispatcher\"")); + assert!(filetree_html.contains("\"runtimeArtifact\"")); + assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\"")); + assert!(filetree_html.contains("\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]")); let picker_response = app() .oneshot( @@ -4460,6 +4569,8 @@ mod tests { assert!(picker_html.contains("\"mode\":\"picker\"")); assert!(picker_html.contains("\"activePickerItem\":\"page_child\"")); assert!(picker_html.contains("\"excludedPickerIds\":[\"page_root\"]")); + assert!(picker_html.contains("\"runtimeArtifact\"")); + assert!(picker_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\"")); } #[tokio::test] diff --git a/rust/crates/mnote-web/src/tree_shell/mod.rs b/rust/crates/mnote-web/src/tree_shell/mod.rs index 6039deb1..85a8e457 100644 --- a/rust/crates/mnote-web/src/tree_shell/mod.rs +++ b/rust/crates/mnote-web/src/tree_shell/mod.rs @@ -3,12 +3,15 @@ pub mod drag_drop_state; pub mod dispatcher; pub mod expansion_state; pub mod filetree_renderer; +pub mod filetree_runtime; pub mod filetree_selection; pub mod focus_state; pub mod keyboard_state; pub mod loader; pub mod page_renderer; +pub mod page_runtime; pub mod picker_renderer; +pub mod picker_runtime; pub mod picker_state; pub mod protocol; pub mod renderer_input; diff --git a/rust/crates/mnote-web/src/tree_shell/page_renderer.rs b/rust/crates/mnote-web/src/tree_shell/page_renderer.rs index 665c8f08..753f45f5 100644 --- a/rust/crates/mnote-web/src/tree_shell/page_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/page_renderer.rs @@ -78,8 +78,19 @@ fn render_page_row( .find(|source| source.node_id == row.node_id) .map(|source| source.expanded) .unwrap_or(false); + let toggle_html = if row.expandable { + format!( + r#""#, + node_id = escape_html(&row.node_id), + label = if expanded { "折叠" } else { "展开" }, + title = escape_html(&row.title), + marker = if expanded { "▾" } else { "▸" }, + ) + } else { + r#""#.to_string() + }; html.push_str(&format!( - r#"
  • "#, + r#"
  • {toggle_html}
    "#, node_id = escape_html(&row.node_id), aria_level = row.depth + 1, expanded_attr = if row.expandable && expanded { "true" } else { "false" }, @@ -87,6 +98,7 @@ fn render_page_row( active = active, focused = focused, tab_index = if focused { "0" } else { "-1" }, + toggle_html = toggle_html, title = escape_html(&row.title), )); if row.expandable && expanded { @@ -206,6 +218,8 @@ mod tests { assert!(html.contains("data-rust-rendered-row=\"page\"")); assert!(html.contains("data-shell-mode=\"page\"")); assert!(html.contains("data-rust-action=\"open\"")); + assert!(html.contains("data-rust-action=\"toggle\"")); + assert!(html.contains("data-testid=\"tree-node-toggle\"")); assert!(html.contains("data-rust-action=\"create\"")); assert!(html.contains("draggable=\"true\"")); assert!(html.contains("tree-children")); diff --git a/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs b/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs index 4c2c4a45..9d9f35b1 100644 --- a/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs @@ -52,11 +52,12 @@ fn render_picker_row( children_by_parent: &BTreeMap, Vec>, ) { html.push_str(&format!( - r#"
  • "#, + r#"
  • "#, node_id = escape_html(&row.node_id), aria_level = row.depth + 1, expanded_attr = if row.expandable && row.expanded { "true" } else { "false" }, active = row.active, + tabindex = if row.active { "0" } else { "-1" }, title = escape_html(&row.title), )); if row.expandable && row.expanded { @@ -77,8 +78,9 @@ pub fn render_initial_picker_html(input: &PickerInitialRenderInput) -> String { ); if input.allow_root_pick { html.push_str(&format!( - r#"
  • "#, + r#"
  • "#, focused = input.root_active, + tabindex = if input.root_active { "0" } else { "-1" }, )); } @@ -180,6 +182,14 @@ mod tests { expandable: false, expanded: false, active: true, + }, PickerRenderRow { + node_id: "page_other".into(), + parent_node_id: None, + title: "其他页面".into(), + depth: 0, + expandable: false, + expanded: false, + active: false, }], }); @@ -190,5 +200,7 @@ mod tests { assert!(html.contains("data-testid=\"tree-picker-row\"")); assert!(html.contains("首页 <安全>")); assert!(html.contains("data-focused=\"true\"")); + assert!(html.contains("tabindex=\"0\"")); + assert!(html.contains("tabindex=\"-1\"")); } } diff --git a/rust/crates/mnote-web/src/tree_shell/renderer_input.rs b/rust/crates/mnote-web/src/tree_shell/renderer_input.rs index 93ce1307..57634f1f 100644 --- a/rust/crates/mnote-web/src/tree_shell/renderer_input.rs +++ b/rust/crates/mnote-web/src/tree_shell/renderer_input.rs @@ -26,6 +26,7 @@ pub struct TreeShellRendererInput { pub excluded_picker_ids: BTreeSet, pub picker_state_reducer: Option, pub command_dispatcher: TreeShellCommandDispatcher, + pub runtime_artifact: TreeShellRuntimeArtifactBoundary, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -35,6 +36,41 @@ pub struct TreeShellCommandDispatcher { pub command_names: BTreeSet, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TreeShellRuntimeArtifactBoundary { + pub contract_name: &'static str, + pub input_fields: BTreeSet<&'static str>, + pub output_channels: BTreeSet<&'static str>, + pub event_kinds: BTreeSet<&'static str>, +} + +impl Default for TreeShellRuntimeArtifactBoundary { + fn default() -> Self { + Self { + contract_name: "rust_tree_shell_runtime_artifact_v1", + input_fields: BTreeSet::from([ + "rendererInput", + "projectionItems", + "expandedIds", + "selectedRowIds", + "activePickerItem", + "focusedId", + ]), + output_channels: BTreeSet::from(["domPatch", "intentEvent", "commandDispatchEvent"]), + event_kinds: BTreeSet::from([ + "focus", + "keyboard", + "expandCollapse", + "selection", + "contextMenu", + "dragDrop", + "pick", + ]), + } + } +} + impl TreeShellRendererInput { pub fn page(input: PageTreeRendererInput) -> Self { Self { @@ -49,6 +85,7 @@ impl TreeShellRendererInput { excluded_picker_ids: BTreeSet::new(), picker_state_reducer: None, command_dispatcher: input.command_dispatcher, + runtime_artifact: TreeShellRuntimeArtifactBoundary::default(), } } @@ -65,6 +102,7 @@ impl TreeShellRendererInput { excluded_picker_ids: BTreeSet::new(), picker_state_reducer: None, command_dispatcher: input.command_dispatcher, + runtime_artifact: TreeShellRuntimeArtifactBoundary::default(), } } @@ -81,6 +119,7 @@ impl TreeShellRendererInput { excluded_picker_ids: input.excluded_picker_ids, picker_state_reducer: Some(PickerStateReducerContract::default()), command_dispatcher: input.command_dispatcher, + runtime_artifact: TreeShellRuntimeArtifactBoundary::default(), } } } @@ -113,8 +152,8 @@ pub struct PickerRendererInput { #[cfg(test)] mod tests { use super::{ - FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher, - TreeShellRendererInput, TreeShellRendererMode, + FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, + TreeShellCommandDispatcher, TreeShellRendererInput, TreeShellRendererMode, }; use crate::tree_shell::filetree_selection::FileTreeSelectionState; use std::collections::BTreeSet; @@ -158,7 +197,10 @@ mod tests { }); assert_eq!(filetree.mode, TreeShellRendererMode::FileTree); assert_eq!(filetree.focused_id.as_deref(), Some("asset:a")); - assert!(filetree.filetree_selection.selected_row_ids.contains("asset:a")); + assert!(filetree + .filetree_selection + .selected_row_ids + .contains("asset:a")); assert!(filetree.page_focus_keyboard_reducer.is_none()); assert_eq!( filetree @@ -188,4 +230,36 @@ mod tests { Some("rust_picker_state_reducer_v1") ); } + + #[test] + fn tree_shell_renderer_input_exposes_runtime_artifact_boundary() { + let page = TreeShellRendererInput::page(PageTreeRendererInput { + projection_item_ids: vec!["doc:root".into()], + expanded_ids: set(&["doc:root"]), + focused_id: Some("doc:root".into()), + command_dispatcher: dispatcher(), + }); + + let artifact = page.runtime_artifact; + assert_eq!( + artifact.contract_name, + "rust_tree_shell_runtime_artifact_v1" + ); + assert!(artifact.input_fields.contains("rendererInput")); + assert!(artifact.input_fields.contains("projectionItems")); + assert!(artifact.input_fields.contains("expandedIds")); + assert!(artifact.input_fields.contains("selectedRowIds")); + assert!(artifact.input_fields.contains("activePickerItem")); + assert!(artifact.input_fields.contains("focusedId")); + assert!(artifact.output_channels.contains("domPatch")); + assert!(artifact.output_channels.contains("intentEvent")); + assert!(artifact.output_channels.contains("commandDispatchEvent")); + assert!(artifact.event_kinds.contains("focus")); + assert!(artifact.event_kinds.contains("keyboard")); + assert!(artifact.event_kinds.contains("expandCollapse")); + assert!(artifact.event_kinds.contains("selection")); + assert!(artifact.event_kinds.contains("contextMenu")); + assert!(artifact.event_kinds.contains("dragDrop")); + assert!(artifact.event_kinds.contains("pick")); + } } diff --git a/scripts/task112-tree-rust-family-regression-smoke.js b/scripts/task112-tree-rust-family-regression-smoke.js index c9e05ab2..e90d49f6 100644 --- a/scripts/task112-tree-rust-family-regression-smoke.js +++ b/scripts/task112-tree-rust-family-regression-smoke.js @@ -34,6 +34,34 @@ function buildTreeShellUrl(workspaceId, params = {}) { return `${BASE_URL}/api/tree/shell?${search.toString()}`; } +function treeHostImplementationUsesIframe(implementation) { + return implementation === "mnote_web_iframe_proxy" || implementation === "rust_inline_compat_host"; +} + +async function waitForInlineTreeShellReady(page, surfaceTestId) { + await page.waitForFunction( + (testId) => { + const host = document.querySelector(`[data-testid="${testId}"]`); + if (!(host instanceof HTMLElement) || host.getClientRects().length === 0) { + return false; + } + const implementation = host.getAttribute("data-tree-host-implementation") || ""; + if (implementation !== "mnote_web_iframe_proxy" && implementation !== "rust_inline_compat_host") { + return true; + } + const iframe = host.querySelector(`[data-testid="${testId}-rust-iframe"]`); + if (!(iframe instanceof HTMLIFrameElement) || iframe.getClientRects().length === 0) { + return false; + } + const doc = iframe.contentDocument; + const status = doc?.querySelector("#tree-shell-status")?.textContent ?? ""; + return Boolean(doc?.querySelector("#tree-shell-state")) && status.includes("就绪"); + }, + surfaceTestId, + { timeout: UI_TIMEOUT_MS }, + ); +} + async function renameFixtureDocuments(requestContext, fixture) { await renameDocument(requestContext, fixture.workspaceId, fixture.parentId, fixture.parentTitle); await renameDocument(requestContext, fixture.workspaceId, fixture.childAId, fixture.childATitle); @@ -133,7 +161,7 @@ async function getPageTreeHostDriver(page) { return false; } const implementation = element.getAttribute("data-tree-host-implementation") || ""; - if (implementation === "mnote_web_iframe_proxy") { + if (implementation === "mnote_web_iframe_proxy" || implementation === "rust_inline_compat_host") { const iframe = element.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]'); return iframe instanceof HTMLIFrameElement && iframe.getClientRects().length > 0; } @@ -143,7 +171,8 @@ async function getPageTreeHostDriver(page) { { timeout: UI_TIMEOUT_MS }, ); const implementation = await host.getAttribute("data-tree-host-implementation"); - if (implementation === "mnote_web_iframe_proxy") { + if (treeHostImplementationUsesIframe(implementation)) { + await waitForInlineTreeShellReady(page, "sidebar-page-tree-shell"); return { kind: "iframe", host, @@ -163,7 +192,7 @@ async function getFileTreeHostDriver(page) { return false; } const implementation = element.getAttribute("data-tree-host-implementation") || ""; - if (implementation === "mnote_web_iframe_proxy") { + if (implementation === "mnote_web_iframe_proxy" || implementation === "rust_inline_compat_host") { const iframe = element.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]'); return iframe instanceof HTMLIFrameElement && iframe.getClientRects().length > 0; } @@ -173,7 +202,8 @@ async function getFileTreeHostDriver(page) { { timeout: UI_TIMEOUT_MS }, ); const implementation = await host.getAttribute("data-tree-host-implementation"); - if (implementation === "mnote_web_iframe_proxy") { + if (treeHostImplementationUsesIframe(implementation)) { + await waitForInlineTreeShellReady(page, "sidebar-file-tree-shell"); return { kind: "iframe", host, @@ -837,6 +867,9 @@ async function runPickerDialogChecks(context, fixture) { await pickerSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const searchInput = dialog.getByPlaceholder("移动到..."); const pickerUsesIframe = (await pickerSurface.locator('[data-testid="tree-picker-surface-rust-iframe"]').count()) > 0; + if (pickerUsesIframe) { + await waitForInlineTreeShellReady(page, "tree-picker-surface"); + } return { dialog, pickerSurface, searchInput, pickerUsesIframe }; }; @@ -986,8 +1019,13 @@ async function runPickerDialogChecks(context, fixture) { if (!(frame instanceof HTMLIFrameElement)) { throw new Error("picker iframe 不存在"); } + const escapedNodeId = CSS.escape(nodeId); const button = frame.contentDocument?.querySelector( - `.tree-row[data-node-id="${nodeId}"] [data-testid="tree-node-open"]`, + [ + `.tree-row[data-node-id="${escapedNodeId}"][data-testid="tree-picker-row"]`, + `.tree-row[data-node-id="${escapedNodeId}"] [data-testid="tree-picker-row"]`, + `.tree-row[data-node-id="${escapedNodeId}"] [data-testid="tree-node-open"]`, + ].join(", "), ); if (!button || typeof button.click !== "function") { throw new Error("picker 目标页面按钮不存在"); diff --git a/wolai-frontend/convex/_generated/api.d.ts b/wolai-frontend/convex/_generated/api.d.ts index 97710836..5c5fcf37 100644 --- a/wolai-frontend/convex/_generated/api.d.ts +++ b/wolai-frontend/convex/_generated/api.d.ts @@ -10,6 +10,7 @@ import type * as _utils_attachmentExtract from "../_utils/attachmentExtract.js"; import type * as _utils_auth from "../_utils/auth.js"; +import type * as _utils_documentMoveOrder from "../_utils/documentMoveOrder.js"; import type * as _utils_documentRecord from "../_utils/documentRecord.js"; import type * as _utils_documentTree from "../_utils/documentTree.js"; import type * as _utils_id from "../_utils/id.js"; @@ -55,6 +56,7 @@ import type { declare const fullApi: ApiFromModules<{ "_utils/attachmentExtract": typeof _utils_attachmentExtract; "_utils/auth": typeof _utils_auth; + "_utils/documentMoveOrder": typeof _utils_documentMoveOrder; "_utils/documentRecord": typeof _utils_documentRecord; "_utils/documentTree": typeof _utils_documentTree; "_utils/id": typeof _utils_id; diff --git a/wolai-frontend/convex/_utils/documentMoveOrder.ts b/wolai-frontend/convex/_utils/documentMoveOrder.ts index be09c6e7..e54c6082 100644 --- a/wolai-frontend/convex/_utils/documentMoveOrder.ts +++ b/wolai-frontend/convex/_utils/documentMoveOrder.ts @@ -21,6 +21,14 @@ export type DocumentMoveOrderPlan = { patches: DocumentMoveOrderPatch[]; }; +export type DocumentMoveWriteOperation = DocumentMoveOrderPlan & { + family: "tree"; + schema: "mnote.tree.write_operation"; + schemaVersion: 1; + operation: "tree.subtree.move.write"; + workspaceId: string; +}; + function normalizeParentId(value: string | null | undefined): string | null { const trimmed = typeof value === "string" ? value.trim() : ""; return trimmed.length > 0 ? trimmed : null; @@ -121,7 +129,7 @@ export function buildDocumentMoveOrderPlanFromDocuments(input: { }; } -function normalizePlan(value: unknown): DocumentMoveOrderPlan | null { +export function normalizeDocumentMoveOrderPlan(value: unknown): DocumentMoveOrderPlan | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } @@ -152,8 +160,37 @@ function normalizePlan(value: unknown): DocumentMoveOrderPlan | null { }; } -export function assertDocumentMoveOrderPlanMatches(expected: unknown, actual: DocumentMoveOrderPlan) { - const normalizedExpected = normalizePlan(expected); +export function normalizeDocumentMoveWriteOperation(value: unknown): DocumentMoveWriteOperation | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Partial; + if ( + record.family !== "tree" || + record.schema !== "mnote.tree.write_operation" || + record.schemaVersion !== 1 || + record.operation !== "tree.subtree.move.write" || + typeof record.workspaceId !== "string" || + !record.workspaceId.trim() + ) { + return null; + } + const normalizedPlan = normalizeDocumentMoveOrderPlan(value); + if (!normalizedPlan) { + return null; + } + return { + family: "tree", + schema: "mnote.tree.write_operation", + schemaVersion: 1, + operation: "tree.subtree.move.write", + workspaceId: record.workspaceId.trim(), + ...normalizedPlan, + }; +} + +export function assertDocumentMoveOrderPlanMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan { + const normalizedExpected = normalizeDocumentMoveOrderPlan(expected); if (!normalizedExpected) { throw new Error("Rust move plan 与 Convex 当前排序状态不一致"); } @@ -161,4 +198,21 @@ export function assertDocumentMoveOrderPlanMatches(expected: unknown, actual: Do if (JSON.stringify(normalizedExpected) !== JSON.stringify(actual)) { throw new Error("Rust move plan 与 Convex 当前排序状态不一致"); } + + return normalizedExpected; +} + +export function assertDocumentMoveWriteOperationMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan { + const normalizedExpected = normalizeDocumentMoveWriteOperation(expected); + if (!normalizedExpected) { + throw new Error("Rust move write operation 与 Convex 当前排序状态不一致"); + } + + const { family: _family, schema: _schema, schemaVersion: _schemaVersion, operation: _operation, workspaceId: _workspaceId, ...expectedPlan } = + normalizedExpected; + if (JSON.stringify(expectedPlan) !== JSON.stringify(actual)) { + throw new Error("Rust move write operation 与 Convex 当前排序状态不一致"); + } + + return expectedPlan; } diff --git a/wolai-frontend/convex/documents.ts b/wolai-frontend/convex/documents.ts index 2c172a9a..4f8c9311 100644 --- a/wolai-frontend/convex/documents.ts +++ b/wolai-frontend/convex/documents.ts @@ -6,6 +6,7 @@ import { nowIso } from "./_utils/time"; import { buildParentById, collectSubtree, isAncestorOf } from "./_utils/documentTree"; import { assertDocumentMoveOrderPlanMatches, + assertDocumentMoveWriteOperationMatches, buildDocumentMoveOrderPlanFromDocuments, } from "./_utils/documentMoveOrder"; import { enqueueIngestDocumentJob } from "./_utils/ingestJobs"; @@ -1400,6 +1401,7 @@ export const move = mutation({ parentId: v.union(v.string(), v.null()), sortOrder: v.number(), normalizedMove: v.optional(v.any()), + treeWriteOperation: v.optional(v.any()), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); @@ -1436,23 +1438,52 @@ export const move = mutation({ } } - if (args.normalizedMove != null) { - assertDocumentMoveOrderPlanMatches( - args.normalizedMove, - buildDocumentMoveOrderPlanFromDocuments({ - documents: canonicalWorkspaceDocs, - documentId: doc.id, - parentId: toParentId, - sortOrder: args.sortOrder, - }), - ); - } + const currentMoveOrderPlan = buildDocumentMoveOrderPlanFromDocuments({ + documents: canonicalWorkspaceDocs, + documentId: doc.id, + parentId: toParentId, + sortOrder: args.sortOrder, + }); + const rustMoveOrderPlan = + args.treeWriteOperation != null + ? assertDocumentMoveWriteOperationMatches(args.treeWriteOperation, currentMoveOrderPlan) + : args.normalizedMove != null + ? assertDocumentMoveOrderPlanMatches(args.normalizedMove, currentMoveOrderPlan) + : null; // 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order, // 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。 // 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。 const ts = nowIso(); + if (rustMoveOrderPlan != null) { + const documentsById = new Map(canonicalWorkspaceDocs.map((item) => [item.id, item])); + for (const item of rustMoveOrderPlan.patches) { + const target = documentsById.get(item.documentId); + if (!target) { + throw new Error("Rust move plan 与 Convex 当前排序状态不一致"); + } + + const patch: Record = {}; + if ((target.parent_id ?? null) !== item.parentId) patch.parent_id = item.parentId; + if ((target.sort_order ?? null) !== item.sortOrder) patch.sort_order = item.sortOrder; + // Rust plan 决定被移动节点;Convex 只负责把同一结果持久化。 + if (item.moved) patch.updated_at = ts; + + if (Object.keys(patch).length > 0) { + await ctx.db.patch(target._id, patch); + } + } + + return { + ok: true, + parent_id: rustMoveOrderPlan.toParentId, + sort_order: rustMoveOrderPlan.normalizedSortOrder, + workspace_id: doc.workspace_id, + updated_at: ts, + }; + } + const compareDocOrder = (a: any, b: any) => { const orderA = typeof a.sort_order === "number" ? a.sort_order : Number.MAX_SAFE_INTEGER; const orderB = typeof b.sort_order === "number" ? b.sort_order : Number.MAX_SAFE_INTEGER; diff --git a/wolai-frontend/src/app/api/tree/commands/route.test.ts b/wolai-frontend/src/app/api/tree/commands/route.test.ts index ebead474..15916d80 100644 --- a/wolai-frontend/src/app/api/tree/commands/route.test.ts +++ b/wolai-frontend/src/app/api/tree/commands/route.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TREE_3000_ROUTE_BOUNDARY_MANIFEST } from "@/lib/tree-route-boundary"; const mockIsConvexEnabled = vi.fn(() => true); const mockGetAuthedConvexClient = vi.fn(); @@ -203,6 +204,19 @@ describe("/api/tree/commands route", () => { }); it("create action 走 tree.node.create,并保留本地 scaffold 副作用", async () => { + expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.routes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "tree.commands", + role: "next-thin-proxy", + }), + ]), + ); + expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.rustOwnedSemantics).toContain("tree.node.create"); + expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.browserSubstrateDuties).toContain( + "新页面 scaffold 文件创建", + ); + const client = { mutation: vi.fn(async () => ({ activeWorkspaceId: "ws_root" })), query: vi.fn(), diff --git a/wolai-frontend/src/components/sidebar/sidebar.tsx b/wolai-frontend/src/components/sidebar/sidebar.tsx index 116178b5..d2e7ef6e 100644 --- a/wolai-frontend/src/components/sidebar/sidebar.tsx +++ b/wolai-frontend/src/components/sidebar/sidebar.tsx @@ -56,7 +56,10 @@ import { useEditorBridgeStore } from "@/store/editor-bridge"; import { useCurrentDocumentStore } from "@/store/current-document"; import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree"; import { buildVisibleRows } from "@/lib/file-tree/rows"; -import { fetchKernelFileTreeProjection } from "@/lib/file-tree/projection-client"; +import { + FILE_TREE_SEARCH_MAX_RESULTS, + fetchKernelFileTreeProjection, +} from "@/lib/file-tree/projection-client"; import { copyFileTreeResourceAssets, deleteFileTreeResourceAssets, @@ -337,7 +340,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar void fetchKernelFileTreeProjection({ workspaceId, query, - maxResults: 80, + maxResults: FILE_TREE_SEARCH_MAX_RESULTS, }) .then((projection) => { if (cancelled) { diff --git a/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx b/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx index 674f64a5..1270cddc 100644 --- a/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx +++ b/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx @@ -193,6 +193,49 @@ describe("tree-shell-iframe-host", () => { }, } as unknown as SidebarTreeNode, }, + { + rowId: "page:doc_child", + nodeId: "doc_child", + parentNodeId: "doc_parent", + nodeType: "page", + projectionKind: "page_tree", + depth: 1, + position: 0, + title: "子页面", + childCount: 0, + expandable: false, + expandedByDefault: false, + capabilities: ["open"], + resourceMeta: { + resourceKind: "document", + documentId: "doc_child", + workspaceId: "ws_1", + iconHint: "page", + }, + iconHint: "page", + node: { + id: "doc_child", + title: "子页面", + workspace_id: "ws_1", + parent_id: "doc_parent", + sort_order: 0, + is_archived: false, + is_deleted: false, + is_published: false, + is_starred: false, + access_scope: "private", + created_at: "2026-04-24T00:00:00.000Z", + updated_at: "2026-04-24T00:00:00.000Z", + children: [], + kernel: { + nodeType: "page", + depth: 1, + position: 0, + childCount: 0, + expandedByDefault: false, + }, + } as unknown as SidebarTreeNode, + }, ]; expect(buildTreeShellInlinePageItems(pageItems, new Set(["doc_parent"]))).toEqual([ @@ -203,6 +246,13 @@ describe("tree-shell-iframe-host", () => { childCount: 1, expandedByDefault: true, }), + expect.objectContaining({ + nodeId: "doc_child", + parentNodeId: "doc_parent", + title: "子页面", + childCount: 0, + expandedByDefault: false, + }), ]); await act(async () => { @@ -227,15 +277,23 @@ describe("tree-shell-iframe-host", () => { expect(iframe?.getAttribute("srcdoc")).not.toContain("