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
This commit is contained in:
+12
-7
@@ -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`
|
||||
+11
-6
@@ -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 验证重点已经转向“主链稳定”
|
||||
+9
-4
@@ -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. 当前总判断
|
||||
+6
-6
@@ -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 已经统一
|
||||
+215
@@ -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 重新暴露为默认前端入口。
|
||||
- 不在前端新增第二套树结构真相或排序真相。
|
||||
@@ -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。
|
||||
+46
-18
@@ -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
|
||||
|
||||
+250
-232
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<Value, BridgeError>
|
||||
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<RuntimeCommandArtifactPlan>,
|
||||
) -> 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<String> {
|
||||
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<String, BridgeError> {
|
||||
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<String> {
|
||||
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<Value, BridgeError> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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#"<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="{node_id}" aria-label="{label} {title}">{marker}</button>"#,
|
||||
node_id = escape_html(&row.node_id),
|
||||
label = if expanded { "折叠" } else { "展开" },
|
||||
title = escape_html(&row.title),
|
||||
marker = if expanded { "▾" } else { "▸" },
|
||||
)
|
||||
} else {
|
||||
r#"<span class="tree-spacer" aria-hidden="true"></span>"#.to_string()
|
||||
};
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="{test_id}" data-node-id="{node_id}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="{test_id}" data-node-id="{node_id}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||
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"));
|
||||
|
||||
@@ -52,11 +52,12 @@ fn render_picker_row(
|
||||
children_by_parent: &BTreeMap<Option<String>, Vec<PickerRenderRow>>,
|
||||
) {
|
||||
html.push_str(&format!(
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
|
||||
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick" tabindex="{tabindex}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
|
||||
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#"<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="{focused}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>"#,
|
||||
r#"<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="{focused}" tabindex="{tabindex}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>"#,
|
||||
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\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct TreeShellRendererInput {
|
||||
pub excluded_picker_ids: BTreeSet<String>,
|
||||
pub picker_state_reducer: Option<PickerStateReducerContract>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 目标页面按钮不存在");
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
@@ -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<DocumentMoveWriteOperation>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("<script>window.__MNOTE_TREE_SHELL_OVERRIDE__ =");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("父页面");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-menu");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("tree-node-toggle");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('data-rust-action="toggle"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-create");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-rename");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("/api/tree/commands");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("patchPageTreeActiveDom();");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("patchPageTreeExpansionDom(nodeId)");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_page_focus_keyboard_reducer_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"inputFields":["rendererInput","projectionItems","expandedIds","selectedRowIds","activePickerItem","focusedId"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"outputChannels":["domPatch","intentEvent","commandDispatchEvent"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"eventKinds":["focus","keyboard","expandCollapse","selection","contextMenu","dragDrop","pick"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("applyPageKeyboardAction");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "page" && usedRustInitialRenderer) {patchPageTreeActiveDom();if (focusedNodeId) focusRowElement(focusedNodeId);return;}');
|
||||
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).items).toEqual([
|
||||
expect.objectContaining({ nodeId: "doc_parent" }),
|
||||
expect.objectContaining({ nodeId: "doc_child", parentNodeId: "doc_parent" }),
|
||||
]);
|
||||
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
|
||||
});
|
||||
@@ -415,6 +473,7 @@ describe("tree-shell-iframe-host", () => {
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"filetreeSelection"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"selectedRowIds":["doc:doc_a","index:doc_a"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_filetree_selection_reducer_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("applyFileTreeSelectionAction");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("const rendererFiletreeSelection =");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("rendererFiletreeSelection.selectedRowIds");
|
||||
@@ -437,7 +496,10 @@ describe("tree-shell-iframe-host", () => {
|
||||
{ status: 200, headers: { "content-type": "text/html" } },
|
||||
),
|
||||
);
|
||||
const pickerItems: TreeShellPickerItem[] = [{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 }];
|
||||
const pickerItems: TreeShellPickerItem[] = [
|
||||
{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 },
|
||||
{ kind: "doc", id: "doc_2", title: "页面 2", depth: 0 },
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -463,13 +525,27 @@ describe("tree-shell-iframe-host", () => {
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("hydrateInitialPickerTree");
|
||||
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).items).toEqual([
|
||||
expect.objectContaining({ nodeId: "doc_1" }),
|
||||
expect.objectContaining({ nodeId: "doc_2" }),
|
||||
]);
|
||||
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).rendererInput?.activePickerItem).toBe("doc_1");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"activePickerItem":"doc_1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"excludedPickerIds":["doc_hidden"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_picker_state_reducer_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("applyPickerStateAction");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("patchPickerActiveDom");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("getPickablePickerEntries");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("postPickerPickResultToHost");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('tabindex="0"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('tabindex="-1"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('applyPickerFocusByItemKey("__root__", { focusDom: true })');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("const shouldFocusDom = options.focusDom === true");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("if (shouldFocusDom) focusPickerRowElement");
|
||||
const bindPickerRowEventsBody =
|
||||
iframe?.getAttribute("srcdoc")?.match(/const bindPickerRowEvents = \(row, item\) => \{(?<body>[\s\S]*?)\n \};/)?.groups
|
||||
?.body ?? "";
|
||||
expect(bindPickerRowEventsBody).not.toContain("handleNavigate(item.nodeId)");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.activePickerItem");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.excludedPickerIds");
|
||||
const postMessage = vi.fn();
|
||||
|
||||
@@ -103,6 +103,12 @@ type TreeShellInlineRendererInput = {
|
||||
channel: string;
|
||||
commandNames: string[];
|
||||
};
|
||||
runtimeArtifact: {
|
||||
contractName: "rust_tree_shell_runtime_artifact_v1";
|
||||
inputFields: string[];
|
||||
outputChannels: string[];
|
||||
eventKinds: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type TreeShellIframeHostProps = {
|
||||
@@ -212,6 +218,19 @@ const PICKER_STATE_REDUCER_ACTIONS = [
|
||||
"end",
|
||||
"pick",
|
||||
] as const;
|
||||
const TREE_SHELL_RUNTIME_ARTIFACT = {
|
||||
contractName: "rust_tree_shell_runtime_artifact_v1",
|
||||
inputFields: [
|
||||
"rendererInput",
|
||||
"projectionItems",
|
||||
"expandedIds",
|
||||
"selectedRowIds",
|
||||
"activePickerItem",
|
||||
"focusedId",
|
||||
],
|
||||
outputChannels: ["domPatch", "intentEvent", "commandDispatchEvent"],
|
||||
eventKinds: ["focus", "keyboard", "expandCollapse", "selection", "contextMenu", "dragDrop", "pick"],
|
||||
} as const satisfies TreeShellInlineRendererInput["runtimeArtifact"];
|
||||
|
||||
const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
@@ -706,6 +725,10 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
target: { documentId: nodeId },
|
||||
});
|
||||
}
|
||||
if (mode === "page" && usedRustInitialRenderer && patchPageTreeExpansionDom(nodeId)) {
|
||||
focusRowElement(nodeId);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
focusRowElement(nodeId);
|
||||
};
|
||||
@@ -735,6 +758,11 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
}
|
||||
focusedNodeId = nextFocusId;
|
||||
postPageFocusChange(nextFocusId);
|
||||
if (usedRustInitialRenderer) {
|
||||
patchPageTreeActiveDom();
|
||||
focusRowElement(nextFocusId);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
focusRowElement(nextFocusId);
|
||||
};
|
||||
@@ -773,11 +801,22 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
return;
|
||||
}
|
||||
if (actionKind === "expand") {
|
||||
if (item?.nodeId) toggleExpand(item.nodeId);
|
||||
if (item?.childCount > 0 && !expanded.has(item.nodeId)) {
|
||||
toggleExpand(item.nodeId);
|
||||
return;
|
||||
}
|
||||
const firstChild = item ? getSiblings(item.nodeId)[0] : null;
|
||||
if (firstChild) focusNode(firstChild.nodeId);
|
||||
return;
|
||||
}
|
||||
if (actionKind === "collapse") {
|
||||
if (item?.nodeId) toggleExpand(item.nodeId);
|
||||
if (item?.childCount > 0 && expanded.has(item.nodeId)) {
|
||||
toggleExpand(item.nodeId);
|
||||
return;
|
||||
}
|
||||
if (item?.parentNodeId && itemById.has(item.parentNodeId)) {
|
||||
focusNode(item.parentNodeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (actionKind === "open") {
|
||||
@@ -1245,7 +1284,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
syncFileTreeSelectionDom();
|
||||
return true;
|
||||
};
|
||||
const getVisiblePickerEntries = () => {
|
||||
const getPickablePickerEntries = () => {
|
||||
const visible = [];
|
||||
if (allowRootPick) {
|
||||
visible.push({ pickerItemKey: "__root__", documentId: null });
|
||||
@@ -1277,7 +1316,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
if (fromActive) return fromActive;
|
||||
const fromDocument = normalizePickerItemKey(currentActiveDocumentId);
|
||||
if (fromDocument) return fromDocument;
|
||||
return getVisiblePickerEntries()[0]?.pickerItemKey || "";
|
||||
return getPickablePickerEntries()[0]?.pickerItemKey || "";
|
||||
};
|
||||
const computePickerStateActionResult = (action) => {
|
||||
const currentPickerItemKey = resolveCurrentPickerItemKey();
|
||||
@@ -1295,21 +1334,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
pickedRoot: action?.kind === "pick" && currentPickerItemKey === "__root__",
|
||||
};
|
||||
}
|
||||
const visible = getVisiblePickerEntries();
|
||||
if (visible.length === 0) {
|
||||
const pickable = getPickablePickerEntries();
|
||||
if (pickable.length === 0) {
|
||||
return {
|
||||
nextItemKey: "",
|
||||
pickedDocumentId: null,
|
||||
pickedRoot: false,
|
||||
};
|
||||
}
|
||||
const currentIndex = visible.findIndex(
|
||||
const currentIndex = pickable.findIndex(
|
||||
(entry) => entry.pickerItemKey === currentPickerItemKey,
|
||||
);
|
||||
const baseIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||
if (action.kind === "normalize") {
|
||||
return {
|
||||
nextItemKey: visible[baseIndex]?.pickerItemKey || "",
|
||||
nextItemKey: pickable[baseIndex]?.pickerItemKey || "",
|
||||
pickedDocumentId: null,
|
||||
pickedRoot: false,
|
||||
};
|
||||
@@ -1317,13 +1356,13 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
if (action.kind === "focus") {
|
||||
const nextItemKey = normalizePickerItemKey(action.itemKey);
|
||||
return {
|
||||
nextItemKey: nextItemKey || visible[0]?.pickerItemKey || "",
|
||||
nextItemKey: nextItemKey || pickable[0]?.pickerItemKey || "",
|
||||
pickedDocumentId: null,
|
||||
pickedRoot: false,
|
||||
};
|
||||
}
|
||||
if (action.kind === "pick") {
|
||||
const target = visible[baseIndex];
|
||||
const target = pickable[baseIndex];
|
||||
const targetKey = target?.pickerItemKey || "";
|
||||
return {
|
||||
nextItemKey: targetKey,
|
||||
@@ -1338,20 +1377,35 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
: action.kind === "home"
|
||||
? 0
|
||||
: action.kind === "end"
|
||||
? visible.length - 1
|
||||
: Math.min(visible.length - 1, baseIndex + 1);
|
||||
const target = visible[nextIndex];
|
||||
? pickable.length - 1
|
||||
: Math.min(pickable.length - 1, baseIndex + 1);
|
||||
const target = pickable[nextIndex];
|
||||
return {
|
||||
nextItemKey: target?.pickerItemKey || "",
|
||||
pickedDocumentId: null,
|
||||
pickedRoot: false,
|
||||
};
|
||||
};
|
||||
const applyPickerFocusByItemKey = (pickerItemKey) => {
|
||||
const postPickerPickResultToHost = (result) => {
|
||||
if (mode !== "picker" || !result) return;
|
||||
if (result.pickedRoot) {
|
||||
postToHost("tree.pick.root", { documentId: null, target: { documentId: null } });
|
||||
return;
|
||||
}
|
||||
if (result.pickedDocumentId) {
|
||||
postToHost("tree.pick", {
|
||||
documentId: result.pickedDocumentId,
|
||||
itemKey: result.nextItemKey || result.pickedDocumentId,
|
||||
target: { documentId: result.pickedDocumentId },
|
||||
});
|
||||
}
|
||||
};
|
||||
const applyPickerFocusByItemKey = (pickerItemKey, options = {}) => {
|
||||
const result = computePickerStateActionResult({
|
||||
kind: "focus",
|
||||
itemKey: pickerItemKey,
|
||||
});
|
||||
const shouldFocusDom = options.focusDom === true;
|
||||
currentActivePickerItemKey = result.nextItemKey || "";
|
||||
currentActiveDocumentId =
|
||||
result.nextItemKey && result.nextItemKey !== "__root__"
|
||||
@@ -1364,10 +1418,11 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
});
|
||||
if (usedRustInitialRenderer) {
|
||||
patchPickerActiveDom();
|
||||
focusPickerRowElement(currentActivePickerItemKey);
|
||||
if (shouldFocusDom) focusPickerRowElement(currentActivePickerItemKey);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
if (shouldFocusDom) focusPickerRowElement(currentActivePickerItemKey);
|
||||
};
|
||||
const applyPickerStateAction = (action) => {
|
||||
const result = computePickerStateActionResult(action);
|
||||
@@ -1380,7 +1435,8 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
if (!(row instanceof HTMLElement)) return;
|
||||
row.dataset.focused = String(currentActivePickerItemKey === "__root__");
|
||||
row.addEventListener("click", () => {
|
||||
postToHost("tree.pick.root", { documentId: null, target: { documentId: null } });
|
||||
applyPickerFocusByItemKey("__root__", { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
});
|
||||
};
|
||||
const bindPickerRowEvents = (row, item) => {
|
||||
@@ -1391,7 +1447,10 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
currentActivePickerItemKey === item.nodeId ||
|
||||
(!currentActivePickerItemKey && currentActiveDocumentId === item.nodeId),
|
||||
);
|
||||
row.addEventListener("click", () => handleNavigate(item.nodeId));
|
||||
row.addEventListener("click", () => {
|
||||
applyPickerFocusByItemKey(item.nodeId, { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
});
|
||||
};
|
||||
const focusPickerRowElement = (pickerItemKey) => {
|
||||
const normalizedItemKey = normalizeText(pickerItemKey);
|
||||
@@ -1481,6 +1540,12 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
if (!(element instanceof HTMLElement)) return;
|
||||
element.addEventListener("click", (event) => {
|
||||
const action = normalizeText(element.dataset.rustAction);
|
||||
if (action === "toggle") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleExpand(item.nodeId);
|
||||
return;
|
||||
}
|
||||
if (action === "open") {
|
||||
handleNavigate(item.nodeId);
|
||||
return;
|
||||
@@ -1491,6 +1556,32 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
void runTreeCommand({ action: "create", parentId: item.nodeId });
|
||||
return;
|
||||
}
|
||||
if (action === "rename") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextTitle = window.prompt("重命名页面", item.title);
|
||||
const title = normalizeText(nextTitle);
|
||||
if (!title || title === item.title) return;
|
||||
void runTreeCommand({
|
||||
action: "rename",
|
||||
documentId: item.nodeId,
|
||||
title,
|
||||
})
|
||||
.then(() => {
|
||||
item.title = title;
|
||||
const titleElement = row.querySelector(".tree-link-title");
|
||||
if (titleElement) titleElement.textContent = title;
|
||||
setLastAction(\`已重命名为 \${title}\`);
|
||||
postToHost("tree.node.renamed", {
|
||||
documentId: item.nodeId,
|
||||
target: { documentId: item.nodeId },
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setLastAction(error instanceof Error ? error.message : "重命名失败", "error");
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === "menu") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -1504,6 +1595,141 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
});
|
||||
});
|
||||
};
|
||||
const renderHydratedPageNode = (item) => {
|
||||
if (!item) return null;
|
||||
const children = getSiblings(item.nodeId);
|
||||
const hasChildren = item.childCount > 0 && children.length > 0;
|
||||
const nodeElement = document.createElement("li");
|
||||
nodeElement.className = "tree-node";
|
||||
nodeElement.dataset.nodeId = item.nodeId;
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "tree-row";
|
||||
row.setAttribute("role", "treeitem");
|
||||
row.setAttribute("aria-level", String(item.depth + 1));
|
||||
row.setAttribute("aria-expanded", hasChildren ? String(expanded.has(item.nodeId)) : "false");
|
||||
row.setAttribute("data-testid", "tree-node-open");
|
||||
row.dataset.rustRenderedRow = "page";
|
||||
row.dataset.nodeId = item.nodeId;
|
||||
row.dataset.shellMode = "page";
|
||||
row.dataset.active = String(item.nodeId === currentActiveDocumentId);
|
||||
row.dataset.focused = String(item.nodeId === focusedNodeId);
|
||||
row.dataset.draggable = "true";
|
||||
row.draggable = true;
|
||||
|
||||
if (hasChildren) {
|
||||
const toggleButton = document.createElement("button");
|
||||
toggleButton.type = "button";
|
||||
toggleButton.className = "tree-toggle";
|
||||
toggleButton.setAttribute("data-testid", "tree-node-toggle");
|
||||
toggleButton.dataset.rustAction = "toggle";
|
||||
toggleButton.dataset.nodeId = item.nodeId;
|
||||
toggleButton.setAttribute("aria-label", \`\${expanded.has(item.nodeId) ? "折叠" : "展开"} \${item.title}\`);
|
||||
toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸";
|
||||
row.appendChild(toggleButton);
|
||||
} else {
|
||||
const spacer = document.createElement("span");
|
||||
spacer.className = "tree-spacer";
|
||||
spacer.setAttribute("aria-hidden", "true");
|
||||
row.appendChild(spacer);
|
||||
}
|
||||
|
||||
row.appendChild(createKindBadge("page"));
|
||||
|
||||
const linkButton = document.createElement("button");
|
||||
linkButton.type = "button";
|
||||
linkButton.className = "tree-link";
|
||||
linkButton.setAttribute("data-testid", "tree-node-open");
|
||||
linkButton.dataset.rustAction = "open";
|
||||
linkButton.dataset.nodeId = item.nodeId;
|
||||
const titleElement = document.createElement("span");
|
||||
titleElement.className = "tree-link-title";
|
||||
titleElement.textContent = item.title;
|
||||
linkButton.appendChild(titleElement);
|
||||
row.appendChild(linkButton);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tree-actions";
|
||||
[
|
||||
["create", "tree-action-create", "+", "新建子页面"],
|
||||
["rename", "tree-action-rename", "✎", "重命名"],
|
||||
["menu", "tree-action-menu", "…", "更多操作"],
|
||||
].forEach(([action, testId, label, ariaLabel]) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "tree-action";
|
||||
button.setAttribute("data-testid", testId);
|
||||
button.dataset.rustAction = action;
|
||||
button.dataset.nodeId = item.nodeId;
|
||||
button.setAttribute("aria-label", ariaLabel);
|
||||
button.textContent = label;
|
||||
actions.appendChild(button);
|
||||
});
|
||||
row.appendChild(actions);
|
||||
nodeElement.appendChild(row);
|
||||
bindPageRowEvents(row, item);
|
||||
|
||||
if (hasChildren && expanded.has(item.nodeId)) {
|
||||
const childrenList = document.createElement("ul");
|
||||
childrenList.className = "tree-children";
|
||||
children.forEach((child) => {
|
||||
const childNode = renderHydratedPageNode(child);
|
||||
if (childNode) childrenList.appendChild(childNode);
|
||||
});
|
||||
nodeElement.appendChild(childrenList);
|
||||
}
|
||||
return nodeElement;
|
||||
};
|
||||
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) => {
|
||||
const childNode = renderHydratedPageNode(child);
|
||||
if (childNode) childrenList.appendChild(childNode);
|
||||
});
|
||||
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 patchPageTreeActiveDom = () => {
|
||||
if (mode !== "page") return;
|
||||
appElement.querySelectorAll('[data-rust-rendered-row="page"]').forEach((row) => {
|
||||
@@ -1744,7 +1970,14 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
linkButton.className = "tree-link";
|
||||
linkButton.setAttribute("data-testid", mode === "picker" ? "tree-picker-row" : "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";
|
||||
titleElement.textContent = item.title;
|
||||
@@ -1857,9 +2090,11 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
rootButton.className = "tree-row";
|
||||
rootButton.setAttribute("data-testid", "tree-picker-root");
|
||||
rootButton.dataset.focused = String(currentActivePickerItemKey === "__root__");
|
||||
rootButton.tabIndex = currentActivePickerItemKey === "__root__" ? 0 : -1;
|
||||
rootButton.textContent = "根目录";
|
||||
rootButton.addEventListener("click", () => {
|
||||
postToHost("tree.pick.root", { documentId: null, target: { documentId: null } });
|
||||
applyPickerFocusByItemKey("__root__", { focusDom: true });
|
||||
postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" }));
|
||||
});
|
||||
root.appendChild(rootButton);
|
||||
}
|
||||
@@ -1881,11 +2116,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
}
|
||||
if (normalizedCommand === "pick") {
|
||||
const result = applyPickerStateAction({ kind: "pick" });
|
||||
if (result.pickedRoot) {
|
||||
postToHost("tree.pick.root", { documentId: null });
|
||||
} else if (result.pickedDocumentId) {
|
||||
handleNavigate(result.pickedDocumentId);
|
||||
}
|
||||
postPickerPickResultToHost(result);
|
||||
return;
|
||||
}
|
||||
applyPickerStateAction({ kind: normalizedCommand });
|
||||
@@ -1916,7 +2147,6 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
}
|
||||
if (mode === "picker" && usedRustInitialRenderer) {
|
||||
patchPickerActiveDom();
|
||||
focusPickerRowElement(currentActivePickerItemKey);
|
||||
return;
|
||||
}
|
||||
renderTree();
|
||||
@@ -2188,6 +2418,12 @@ function buildInlineRendererInput(input: {
|
||||
channel: normalizeString(input.channel),
|
||||
commandNames: [...TREE_SHELL_COMMAND_NAMES],
|
||||
};
|
||||
const runtimeArtifact = {
|
||||
contractName: TREE_SHELL_RUNTIME_ARTIFACT.contractName,
|
||||
inputFields: [...TREE_SHELL_RUNTIME_ARTIFACT.inputFields],
|
||||
outputChannels: [...TREE_SHELL_RUNTIME_ARTIFACT.outputChannels],
|
||||
eventKinds: [...TREE_SHELL_RUNTIME_ARTIFACT.eventKinds],
|
||||
};
|
||||
if (input.mode === "filetree") {
|
||||
const filetreeSelection = buildInlineFiletreeSelection(input.activeDocumentId);
|
||||
return {
|
||||
@@ -2203,6 +2439,7 @@ function buildInlineRendererInput(input: {
|
||||
activePickerItem: null,
|
||||
excludedPickerIds: [],
|
||||
commandDispatcher,
|
||||
runtimeArtifact,
|
||||
};
|
||||
}
|
||||
if (input.mode === "picker") {
|
||||
@@ -2220,6 +2457,7 @@ function buildInlineRendererInput(input: {
|
||||
actions: [...PICKER_STATE_REDUCER_ACTIONS],
|
||||
},
|
||||
commandDispatcher,
|
||||
runtimeArtifact,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -2235,6 +2473,7 @@ function buildInlineRendererInput(input: {
|
||||
activePickerItem: null,
|
||||
excludedPickerIds: [],
|
||||
commandDispatcher,
|
||||
runtimeArtifact,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2250,11 +2489,14 @@ function buildInlinePageTreeHtml(input: {
|
||||
const children = childrenByParent.get(item.nodeId) ?? [];
|
||||
const expandable = item.childCount > 0 && children.length > 0;
|
||||
const expanded = expandable && item.expandedByDefault;
|
||||
const toggleHtml = expandable
|
||||
? `<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="${expanded ? "折叠" : "展开"} ${escapeInlineHtml(item.title)}">${expanded ? "▾" : "▸"}</button>`
|
||||
: '<span class="tree-spacer" aria-hidden="true"></span>';
|
||||
const childHtml =
|
||||
expanded && children.length > 0
|
||||
? `<ul class="tree-children">${children.map(renderRow).join("")}</ul>`
|
||||
: "";
|
||||
return `<li class="tree-node" data-node-id="${escapeInlineHtml(item.nodeId)}"><div class="tree-row" role="treeitem" aria-level="${item.depth + 1}" aria-expanded="${expanded}" data-rust-rendered-row="page" data-testid="tree-node-open" data-node-id="${escapeInlineHtml(item.nodeId)}" data-shell-mode="page" data-active="${item.nodeId === activeDocumentId}" data-focused="${item.nodeId === focusedDocumentId}" data-draggable="true" draggable="true" tabindex="${item.nodeId === focusedDocumentId ? "0" : "-1"}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="${escapeInlineHtml(item.nodeId)}"><span class="tree-link-title">${escapeInlineHtml(item.title)}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="更多操作">…</button></div></div>${childHtml}</li>`;
|
||||
return `<li class="tree-node" data-node-id="${escapeInlineHtml(item.nodeId)}"><div class="tree-row" role="treeitem" aria-level="${item.depth + 1}" aria-expanded="${expanded}" data-rust-rendered-row="page" data-testid="tree-node-open" data-node-id="${escapeInlineHtml(item.nodeId)}" data-shell-mode="page" data-active="${item.nodeId === activeDocumentId}" data-focused="${item.nodeId === focusedDocumentId}" data-draggable="true" draggable="true" tabindex="${item.nodeId === focusedDocumentId ? "0" : "-1"}">${toggleHtml}<span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="${escapeInlineHtml(item.nodeId)}"><span class="tree-link-title">${escapeInlineHtml(item.title)}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="${escapeInlineHtml(item.nodeId)}" aria-label="更多操作">…</button></div></div>${childHtml}</li>`;
|
||||
};
|
||||
const roots = childrenByParent.get("") ?? [];
|
||||
if (roots.length === 0) {
|
||||
@@ -2304,8 +2546,9 @@ function buildInlinePickerHtml(input: {
|
||||
const childrenByParent = buildInlineChildrenByParent(input.items);
|
||||
const activeDocumentId = normalizeString(input.activeDocumentId);
|
||||
const activePickerItemKey = normalizeString(input.activePickerItemKey);
|
||||
const rootActive = activePickerItemKey === "__root__";
|
||||
const rootHtml = input.allowRootPick
|
||||
? `<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="${activePickerItemKey === "__root__"}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>`
|
||||
? `<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="${rootActive}" tabindex="${rootActive ? "0" : "-1"}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>`
|
||||
: "";
|
||||
const renderRow = (item: TreeShellInlineProjectionItem): string => {
|
||||
const children = childrenByParent.get(item.nodeId) ?? [];
|
||||
@@ -2318,7 +2561,7 @@ function buildInlinePickerHtml(input: {
|
||||
expanded && children.length > 0
|
||||
? `<ul class="tree-children">${children.map(renderRow).join("")}</ul>`
|
||||
: "";
|
||||
return `<li class="tree-node" data-node-id="${escapeInlineHtml(item.nodeId)}"><button type="button" class="tree-row" role="treeitem" aria-level="${item.depth + 1}" aria-expanded="${expanded}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="${escapeInlineHtml(item.nodeId)}" data-shell-mode="picker" data-focused="${active}" data-rust-action="pick"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">${escapeInlineHtml(item.title)}</span></span></button>${childHtml}</li>`;
|
||||
return `<li class="tree-node" data-node-id="${escapeInlineHtml(item.nodeId)}"><button type="button" class="tree-row" role="treeitem" aria-level="${item.depth + 1}" aria-expanded="${expanded}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="${escapeInlineHtml(item.nodeId)}" data-shell-mode="picker" data-focused="${active}" data-rust-action="pick" tabindex="${active ? "0" : "-1"}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">${escapeInlineHtml(item.title)}</span></span></button>${childHtml}</li>`;
|
||||
};
|
||||
const roots = childrenByParent.get("") ?? [];
|
||||
return `<ul class="tree-root" role="tree" data-rust-picker-renderer="initial_v1">${rootHtml}${roots.map(renderRow).join("")}</ul>`;
|
||||
|
||||
@@ -59,6 +59,8 @@ describe("tree-shell-surface", () => {
|
||||
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
|
||||
expect(rustHost).not.toBeNull();
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeArtifact"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
|
||||
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -156,6 +158,8 @@ describe("tree-shell-surface", () => {
|
||||
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
|
||||
expect(rustHost).not.toBeNull();
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeArtifact"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
|
||||
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,9 +6,14 @@ import {
|
||||
type CommandEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import type { RustTreeDomainEventPlan } from "@/lib/documents/rust-runtime";
|
||||
import {
|
||||
recordRustBridgeCommandArtifacts as recordRustBridgeCommandArtifactsFromRuntime,
|
||||
type RustTreeDomainEventPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
|
||||
export const recordRustBridgeCommandArtifacts = recordRustBridgeCommandArtifactsFromRuntime;
|
||||
|
||||
export type BridgeCommandLogStatus = "pending" | "succeeded" | "failed" | "rolled_back";
|
||||
export type BridgeDomainEventStatus = "pending" | "committed" | "rejected" | "failed";
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("shouldUseBuiltBridgeRuntimeBinary", () => {
|
||||
});
|
||||
|
||||
describe("executeRustBridgeMutationTransport", () => {
|
||||
it("documents.move 应把 Rust normalizedMove 透传给 Convex 可选校验", async () => {
|
||||
it("documents.move 应把 Rust treeWriteOperation 透传给 Convex 写执行器", async () => {
|
||||
const normalizedMove = {
|
||||
documentId: "doc_b",
|
||||
fromParentId: "source",
|
||||
@@ -98,6 +98,14 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
const treeWriteOperation = {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.write_operation",
|
||||
schemaVersion: 1,
|
||||
operation: "tree.subtree.move.write",
|
||||
workspaceId: "ws_1",
|
||||
...normalizedMove,
|
||||
};
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
@@ -115,6 +123,7 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
parentId: "target",
|
||||
sortOrder: 0,
|
||||
normalizedMove,
|
||||
treeWriteOperation,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -129,6 +138,7 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
parentId: "target",
|
||||
sortOrder: 0,
|
||||
normalizedMove,
|
||||
treeWriteOperation,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,6 +301,79 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
});
|
||||
|
||||
describe("materializeRustTreeStreamDelta", () => {
|
||||
it("应把 Rust noop hint 物化为 noop delta", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "page.body.save",
|
||||
commandId: "cmd_save",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "noop",
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
materializeRustTreeStreamDelta({
|
||||
plan,
|
||||
result: {
|
||||
revision: 8,
|
||||
conflict_detection_key: "doc_1:8",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
op: "noop",
|
||||
});
|
||||
});
|
||||
|
||||
it("应把 Rust resync_required hint 物化为保守 resync delta", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "page.body.save",
|
||||
commandId: "cmd_save",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
materializeRustTreeStreamDelta({
|
||||
plan,
|
||||
result: {
|
||||
revision: 8,
|
||||
conflict_detection_key: "doc_1:8",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
op: "resync_required",
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("应按 Rust move_document hint 与 mutation canonical 结果生成细粒度 delta", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
@@ -561,6 +644,82 @@ describe("readRustTreeDomainEventType", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("应保留 Rust formal domainEventPlan payload schema", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "page.body.save",
|
||||
commandId: "cmd_save",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
domainEventPlan: {
|
||||
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: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(readRustTreeDomainEventPlan(plan)).toEqual({
|
||||
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: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
@@ -631,14 +790,28 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "tree.subtree.moved",
|
||||
eventType: "page.body.saved",
|
||||
payload: {
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "move_document",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -661,26 +834,39 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
streamDelta: {
|
||||
op: "move_document",
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
updatedAt: "2026-04-26T10:00:00Z",
|
||||
op: "resync_required",
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(artifactPlan?.commandLog.commandId).toBe("cmd_artifact_1");
|
||||
expect(artifactPlan?.domainEvent?.commandId).toBe("cmd_artifact_1");
|
||||
expect(artifactPlan?.domainEvent).toMatchObject({
|
||||
id: "evt_cmd_artifact_1",
|
||||
eventType: "tree.subtree.moved",
|
||||
eventType: "page.body.saved",
|
||||
payload: {
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "page.body.saved",
|
||||
command_id: "cmd_artifact_1",
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
streamDelta: {
|
||||
op: "move_document",
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
updatedAt: "2026-04-26T10:00:00Z",
|
||||
op: "resync_required",
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,6 +78,7 @@ export type RustTreeDomainEventPlan = {
|
||||
schema: "mnote.tree.domain_event";
|
||||
schemaVersion: 1;
|
||||
eventType: string;
|
||||
payload?: Record<string, unknown>;
|
||||
streamDeltaHint?: Record<string, unknown>;
|
||||
streamDelta?: RustTreeStreamDelta;
|
||||
};
|
||||
@@ -86,6 +87,13 @@ export type RustTreeStreamDelta =
|
||||
| {
|
||||
op: "noop";
|
||||
}
|
||||
| {
|
||||
op: "resync_required";
|
||||
reason?: string;
|
||||
pageId?: string;
|
||||
documentId?: string;
|
||||
blockId?: string;
|
||||
}
|
||||
| {
|
||||
op: "upsert_document";
|
||||
document: Record<string, unknown>;
|
||||
@@ -674,11 +682,13 @@ export function readRustTreeDomainEventPlan(plan: RustBridgeCommandPlan): RustTr
|
||||
}
|
||||
const streamDeltaHint = readRecordField(eventPlan, "streamDeltaHint") ?? undefined;
|
||||
const streamDelta = readRecordField(eventPlan, "streamDelta") as RustTreeStreamDelta | null;
|
||||
const payload = readRecordField(eventPlan, "payload") ?? undefined;
|
||||
return {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType,
|
||||
...(payload ? { payload } : {}),
|
||||
...(streamDeltaHint ? { streamDeltaHint } : {}),
|
||||
...(streamDelta ? { streamDelta } : {}),
|
||||
};
|
||||
@@ -718,6 +728,20 @@ export function materializeRustTreeStreamDelta(input: {
|
||||
return { op: "noop" };
|
||||
}
|
||||
|
||||
if (hint.kind === "resync_required") {
|
||||
const reason = readTrimmedStringField(hint.args, "reason");
|
||||
const pageId = readTrimmedStringField(hint.args, "pageId");
|
||||
const documentId = readTrimmedStringField(hint.args, "documentId");
|
||||
const blockId = readTrimmedStringField(hint.args, "blockId");
|
||||
return {
|
||||
op: "resync_required",
|
||||
...(reason ? { reason } : {}),
|
||||
...(pageId ? { pageId } : {}),
|
||||
...(documentId ? { documentId } : {}),
|
||||
...(blockId ? { blockId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (hint.kind === "remove_document") {
|
||||
const documentId = readTrimmedStringField(hint.args, "documentId");
|
||||
return documentId ? { op: "remove_document", documentId } : null;
|
||||
@@ -1130,6 +1154,9 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
||||
...("normalizedMove" in input.plan.argsJson
|
||||
? { normalizedMove: input.plan.argsJson.normalizedMove }
|
||||
: {}),
|
||||
...("treeWriteOperation" in input.plan.argsJson
|
||||
? { treeWriteOperation: input.plan.argsJson.treeWriteOperation }
|
||||
: {}),
|
||||
});
|
||||
case "documents:softDelete":
|
||||
return mutation(api.documents.softDelete, {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchKernelFileTreeProjection } from "./projection-client";
|
||||
import {
|
||||
FILE_TREE_SEARCH_MAX_RESULTS,
|
||||
buildFileTreeProjectionSearchRequestMeta,
|
||||
fetchKernelFileTreeProjection,
|
||||
} from "./projection-client";
|
||||
|
||||
describe("fetchKernelFileTreeProjection", () => {
|
||||
beforeEach(() => {
|
||||
@@ -42,6 +46,59 @@ describe("fetchKernelFileTreeProjection", () => {
|
||||
expect(result.items.map((item) => item.rowId)).toEqual(["asset:table_1"]);
|
||||
});
|
||||
|
||||
it("固定 file_tree 搜索语义边界:命中数先截断,祖先补全不计入 maxResults", () => {
|
||||
expect(FILE_TREE_SEARCH_MAX_RESULTS).toBe(80);
|
||||
expect(
|
||||
buildFileTreeProjectionSearchRequestMeta({
|
||||
workspaceId: "ws_1",
|
||||
query: " rust ",
|
||||
maxResults: 500,
|
||||
}),
|
||||
).toEqual({
|
||||
query: "rust",
|
||||
maxResults: 80,
|
||||
maxResultsRule: "matches_only_before_ancestor_completion",
|
||||
ancestorCompletion: "include_all_ancestors_after_match_truncation",
|
||||
ordering: "kernel_file_tree_preorder",
|
||||
emptyStateText: "没有匹配结果",
|
||||
asyncVisibility: {
|
||||
source: "kernel.project_view",
|
||||
requestKey: "ws_1:rust",
|
||||
},
|
||||
coveredResourceKinds: ["index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
});
|
||||
});
|
||||
|
||||
it("搜索请求会把 maxResults 限制在 sidebar 使用的稳定上限内", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
result: {
|
||||
projectionId: "kernel_projection:file_tree:root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await fetchKernelFileTreeProjection({
|
||||
workspaceId: "ws_1",
|
||||
query: "rust",
|
||||
maxResults: 500,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/tree/projections/file?workspaceId=ws_1&query=rust&maxResults=80",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("失败时透出服务端错误消息", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||
|
||||
export const FILE_TREE_SEARCH_MAX_RESULTS = 80;
|
||||
export const FILE_TREE_SEARCH_EMPTY_STATE_TEXT = "没有匹配结果";
|
||||
|
||||
export type FileTreeProjectionSearchRequestMeta = {
|
||||
query: string | null;
|
||||
maxResults: number | null;
|
||||
maxResultsRule: "matches_only_before_ancestor_completion";
|
||||
ancestorCompletion: "include_all_ancestors_after_match_truncation";
|
||||
ordering: "kernel_file_tree_preorder";
|
||||
emptyStateText: typeof FILE_TREE_SEARCH_EMPTY_STATE_TEXT;
|
||||
asyncVisibility: {
|
||||
source: "kernel.project_view";
|
||||
requestKey: string | null;
|
||||
};
|
||||
coveredResourceKinds: ["index", "asset", "asset_folder", "mindmap", "book", "pdf"];
|
||||
};
|
||||
|
||||
export type FetchKernelFileTreeProjectionInput = {
|
||||
workspaceId: string;
|
||||
rootNodeId?: string | null;
|
||||
@@ -8,6 +25,38 @@ export type FetchKernelFileTreeProjectionInput = {
|
||||
maxResults?: number | null;
|
||||
};
|
||||
|
||||
function normalizeQuery(value: string | null | undefined): string | null {
|
||||
const query = value?.trim() ?? "";
|
||||
return query.length > 0 ? query : null;
|
||||
}
|
||||
|
||||
function normalizeMaxResults(value: number | null | undefined): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(FILE_TREE_SEARCH_MAX_RESULTS, Math.max(1, Math.floor(value)));
|
||||
}
|
||||
|
||||
export function buildFileTreeProjectionSearchRequestMeta(
|
||||
input: Pick<FetchKernelFileTreeProjectionInput, "workspaceId" | "query" | "maxResults">,
|
||||
): FileTreeProjectionSearchRequestMeta {
|
||||
const query = normalizeQuery(input.query);
|
||||
const maxResults = normalizeMaxResults(input.maxResults);
|
||||
return {
|
||||
query,
|
||||
maxResults,
|
||||
maxResultsRule: "matches_only_before_ancestor_completion",
|
||||
ancestorCompletion: "include_all_ancestors_after_match_truncation",
|
||||
ordering: "kernel_file_tree_preorder",
|
||||
emptyStateText: FILE_TREE_SEARCH_EMPTY_STATE_TEXT,
|
||||
asyncVisibility: {
|
||||
source: "kernel.project_view",
|
||||
requestKey: query ? `${input.workspaceId}:${query}` : null,
|
||||
},
|
||||
coveredResourceKinds: ["index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchKernelFileTreeProjection(
|
||||
input: FetchKernelFileTreeProjectionInput,
|
||||
): Promise<KernelFileTreeProjection> {
|
||||
@@ -20,12 +69,13 @@ export async function fetchKernelFileTreeProjection(
|
||||
if (typeof input.depth === "number" && Number.isFinite(input.depth)) {
|
||||
params.set("depth", String(input.depth));
|
||||
}
|
||||
const query = input.query?.trim();
|
||||
const query = normalizeQuery(input.query);
|
||||
if (query) {
|
||||
params.set("query", query);
|
||||
}
|
||||
if (typeof input.maxResults === "number" && Number.isFinite(input.maxResults)) {
|
||||
params.set("maxResults", String(Math.max(1, Math.floor(input.maxResults))));
|
||||
const maxResults = normalizeMaxResults(input.maxResults);
|
||||
if (maxResults != null) {
|
||||
params.set("maxResults", String(maxResults));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/tree/projections/file?${params.toString()}`, {
|
||||
|
||||
@@ -470,6 +470,14 @@ describe("tree-stream/tree-delta", () => {
|
||||
expect(next).toEqual(baseSidebarData);
|
||||
});
|
||||
|
||||
it("支持 resync_required delta 仅推进 cursor,等待后续 snapshot/resync", () => {
|
||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||
op: "resync_required",
|
||||
});
|
||||
|
||||
expect(next).toEqual(baseSidebarData);
|
||||
});
|
||||
|
||||
it("为 page_tree 定义统一 delta 应用边界,并可稳定派生页面行", () => {
|
||||
const next = applyTreeStreamDeltaToProjectionState({
|
||||
projection: "page_tree",
|
||||
@@ -557,6 +565,59 @@ describe("tree-stream/tree-delta", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("file_tree fixture 覆盖搜索 hardening 需要的 index、asset-folder、mindmap child、book 与 pdf 资源类型", () => {
|
||||
const next = applyTreeStreamDeltaToProjectionState({
|
||||
projection: "file_tree",
|
||||
base: fileTreeProjectionBase,
|
||||
event: {
|
||||
op: "replace_documents",
|
||||
documents: [...fileTreeProjectionBase.documents],
|
||||
},
|
||||
});
|
||||
const rowById = new Map(next.fileTreeItems.map((item) => [item.rowId, item]));
|
||||
|
||||
expect(rowById.get("index:root")).toMatchObject({
|
||||
rowKind: "index",
|
||||
title: "index.md",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "index",
|
||||
documentId: "root",
|
||||
}),
|
||||
});
|
||||
expect(rowById.get("asset-folder:asset_mindmap")).toMatchObject({
|
||||
rowKind: "asset_folder",
|
||||
title: "mindmap",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "mindmap",
|
||||
assetKind: "mindmap",
|
||||
}),
|
||||
});
|
||||
expect(rowById.get("asset:asset_mindmap_child")).toMatchObject({
|
||||
rowKind: "asset",
|
||||
parentNodeId: "asset-folder:asset_mindmap",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "asset",
|
||||
assetKind: "image",
|
||||
}),
|
||||
});
|
||||
expect(rowById.get("asset:asset_book")).toMatchObject({
|
||||
rowKind: "asset",
|
||||
iconHint: "book",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "book",
|
||||
assetKind: "book",
|
||||
}),
|
||||
});
|
||||
expect(rowById.get("asset:asset_pdf")).toMatchObject({
|
||||
rowKind: "asset",
|
||||
iconHint: "pdf",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "pdf",
|
||||
assetKind: "pdf",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("支持 upsert_assets 更新资源归属并重建 file_tree projection", () => {
|
||||
const next = applyTreeStreamDeltaToProjectionState({
|
||||
projection: "file_tree",
|
||||
|
||||
@@ -23,6 +23,7 @@ export type TreeStreamDocumentPatch =
|
||||
|
||||
export type TreeStreamDeltaOp =
|
||||
| "noop"
|
||||
| "resync_required"
|
||||
| "upsert_document"
|
||||
| "upsert_documents"
|
||||
| "upsert_assets"
|
||||
@@ -236,6 +237,10 @@ export function applyTreeStreamDelta(
|
||||
return base;
|
||||
}
|
||||
|
||||
if (event.op === "resync_required") {
|
||||
return base;
|
||||
}
|
||||
|
||||
if (event.op === "replace_sidebar" && event.sidebar) {
|
||||
if ("activeWorkspaceId" in event.sidebar) {
|
||||
return cloneSidebarData(event.sidebar as SidebarInitialData);
|
||||
|
||||
Reference in New Issue
Block a user