feat(tree): close rust family shell cutover

This commit is contained in:
lix-2026
2026-04-28 16:30:51 +08:00
parent 4ab36a9386
commit 7965c6c107
75 changed files with 9721 additions and 1174 deletions
@@ -0,0 +1,279 @@
# 4-16 [done] 树域 Rust 家族剩余 final runtime checklist v1
> 创建时间:2026-04-27
>
> 来源:`4-9 / 4-10 / 4-11` 中仍未完成的后续目标,以及 `4-12` 到 `4-15` 完成后的剩余边界。
>
> 本文只规划后续目标,不回滚已完成的 3000 legacy inline host、Rust initial DOM、rendererInput/state family 与现有 smoke 覆盖。
>
> 口径修正(2026-04-28):本文记录的 `harness task-001` 至 `task-009` 完成,只代表 Rust/WASM reducer runtime、artifact、route thinning、projection hardening 等阶段性目标完成;不代表 page tree / file tree / picker 已完成 final Rust DOM shell 切换。真正 final DOM shell 的硬门禁以 `../done/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md` 为准。
>
> 收口更新(2026-04-28):`harness task-010` 至 `task-013` 已完成 final DOM shell 默认路径切换。`rust_family` 默认 host 已改为 `rust_wasm_dom_shell_host`,默认 bridge 为 `dom_wasm``TreeShellIframeHost` 仅在显式 `NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST=1` 下作为 legacy/debug host 进入。
>
> 最终 runtime 剩余项收口(2026-04-28):`harness task-014` 至 `task-018` 已完成。`normalizedMove` fallback 已删除;`page.body.saved` 与 `document.snapshot.saved` 已拆分为双 domain event plan`tree.node.embed` 的 `pageReference` 结构与插入位置已迁入 Rust `pageAggregateEmbedPlan``file_tree` 搜索索引可见性已成为 projection result `meta.search.indexingVisibility`。
## 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;当前默认主路径已继续收口为 `rust_wasm_dom_shell_host``/api/tree/shell -> mnote-web:3104` debug 直连默认关闭。
- Phase K 历史阶段曾把 3000 host implementation 从 `rust_inline_compat_host` 收口为 `rust_runtime_artifact_host`,并在 legacy runtime artifact manifest 中显式标注 `family=rust_family / version=1 / executionStrategy=browser_bridge / browserBridge=iframe_srcdoc`;当前该 host 只代表显式 legacy/debug。
- Phase K 已新增 Rust `TreeShellRuntimeRequest / TreeShellRuntimeResult` wire-safe facadepage / filetree / picker 三类 request 可 `serde_json` roundtrip,输出统一分为 `TreeShellDomPatch``TreeShellHostEvent``TreeShellCommandEvent`,并已在 runtime artifact manifest 中暴露 request/result contract、state snapshot、patch/event kind。
- Phase K 已新增 `mnote-web POST /api/tree/runtime/reduce`,并新增 3000 同源 `POST /api/tree/runtime/reduce` thin proxy;这是当前 browser bridge 调用 Rust runtime facade 的首个正式 HTTP seam。
- Phase K 已把 3000 `TreeShellIframeHost` 的 filetree selection / normalize visible rows / drag row ids 路径接到 runtime reduce seam,保留本地 reducer fallback,避免 Rust runtime endpoint 不可用时破坏现有真实交互。
- Phase K 已把 3000 `TreeShellIframeHost` 的 page focus / keyboard / expand / collapse / toggle / open / context menu 路径接到 runtime reduce seam,保留本地即时 fallback 与 runtime 对账。
- Phase K 已把 3000 `TreeShellIframeHost` 的 picker focus / keyboard / pick 路径接到 runtime reduce seam,保留本地即时 fallback 与 runtime 对账。
- Phase K 已把 3000 `TreeShellIframeHost` 的 page / filetree open-context 与 picker pick 路径接到 runtime hostEvent 回放器:主路径优先消费 `TreeShellRuntimeResult.hostEvents`endpoint 不可用或无 hostEvent 时才回退本地宿主事件。
- Phase K 已把 3000 `TreeShellIframeHost` 的 filetree open / context menu 路径接到 runtime reduce seam,并已改为优先消费 runtime hostEvent。
- Phase K 已把 3000 `TreeShellIframeHost` 的 filetree drop-target highlight 路径接到 runtime reduce seamhydrated 与 fallback 两条 dragover/dragleave 链都会构造 `updateDropTarget` 请求,并把 runtime snapshot 回填到 `data-drop-target` DOM patch。
- Phase K 已落地正式 Rust/WASM reducer runtime artifact:新增 `rust/crates/tree-shell-runtime-wasm`,通过 wasm-bindgen 导出 `reduceTreeShellRuntime`,固定产物为 `mnote-tree-shell-runtime.js``mnote-tree-shell-runtime_bg.wasm`
- Phase K 已新增 3000 同源 `/api/tree-shell-runtime/*` 资源路由与 `build-tree-shell-runtime.js` 构建脚本,`dev-server` 启动前会生成正式 tree shell wasm artifact。
- Phase K 已将 3000 `TreeShellIframeHost` 的 page / filetree / picker reduce 执行器切为 wasm-first:默认优先调用 wasm artifactHTTP `/api/tree/runtime/reduce` 只作为 fallback/debug seam。
- Phase L 已引入 Rust `treeWriteOperation`Convex `documents.move` 只接受 Rust write operation`normalizedMove` fallback 已删除。
- Phase M 已将 `page.body.saved``document.snapshot.saved``block.patched``block.moved``block.embedded` 切到 formal `domainEventPlan/domainEventPlans` 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`、祖先补全/排序、projection result `meta.search.indexingVisibility`、空结果文案,以及 `index / asset-folder / asset / mindmap child / book / pdf` fixture 覆盖。
### 后续阶段已收口
- final DOM shell 默认路径已切到 `rust_wasm_dom_shell_host`page / file tree / picker 不再通过 `TreeShellIframeHost``srcDoc` 内联模板承载默认 DOM shell。
- 默认 DOM host 通过 `reduceTreeShellRuntime` WASM artifact 或同源 `/api/tree/runtime/reduce` Rust seam 消费 `state / hostEvents / commandEvents`;旧本地 JS fallback reducer 只保留在 legacy iframe host。
- 默认真实流量已禁止 `iframe_srcdoc` 作为 tree DOM shell 成功路径;对应负向门禁与 smoke 已覆盖。
- `normalizedMove` fallback 已删除,无 `treeWriteOperation` 的 move 不再进入主写路径。
- `document.snapshot.saved` 已作为独立 event type 固定;`page.body.saved` payload 只保留 page 与 blockssnapshot 版本/hash/updatedAt 进入独立事件。
- `tree.node.embed``pageReference` block 组装已迁入 Rust `pageAggregateEmbedPlan`3000 route 只负责读取源/目标 substrate 快照并作为 preflight 传入。
- Phase O 的索引可见性已从客户端 request meta 提升为 file_tree projection result meta;完整外部索引平台仍不是本树域 runtime 收口阶段目标。
## 1. 当前基线
- `harness` task-001 至 task-009 已完成。
- `page tree / file tree / picker` 默认主路径已使用 3000 same-origin `rust_wasm_dom_shell_host`,不再请求 `/api/tree/shell -> mnote-web:3104`,也不再依赖 `iframe_srcdoc` inline DOM shell。
- 首屏 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;默认 DOM host 已通过 Rust/WASM runtime facade 驱动 page / picker / filetree 的 focus、expand、selection、open/context、drop 与 picker pickHTTP reduce seam 保留为同源 Rust fallback/debug。
- 3000 legacy inline host 与 `mnote-web /tree` debug shell 已同步输出 `rust_tree_shell_runtime_artifact_v1` manifest;默认 DOM host 已使用同源 tree-shell-runtime WASM artifact 或 HTTP reduce seam 执行 runtime。
- 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。
- [x] 3000 主路径 host implementation 已从 compat 命名收口为 `rust_wasm_dom_shell_host`
- [x] Rust family 暴露可序列化的 runtime request/result facade,供后续 wasm/browser bridge 调用。
- [x] Rust family 已通过 wasm artifact 持有默认 reducer runtime 执行层,不再默认依赖 HTTP bridge 执行 reduce。
- [x] Rust family 默认持有 renderer DOM 执行层,不再依赖 `TreeShellIframeHost` browser bridge JS adapter 维护默认 DOM/state shell。
- [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。
- [x] snapshot 独立事件与 Page Aggregate 深层收口继续后续推进。
### 最高优先级硬门禁
- [x] 默认 page tree / file tree / picker 主路径不得再依赖 `TreeShellIframeHost``srcDoc` inline DOM shell。
- [x] 默认主路径不得再标注 `data-tree-browser-bridge="iframe_srcdoc"`
- [x] `TreeShellIframeHost` 必须降级为显式 legacy/debug host,不能作为 `rust_family` 默认执行面。
- [x] `task112/task113` 必须改为验证新 Rust/WASM DOM shell 主路径,而不是验证旧 inline iframe host 继续可用。
## 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 legacy inline host 与 `mnote-web /tree` debug shell 输出并消费正式 runtime artifact contract。
- [x] 历史 Phase K 主路径标识曾切到 `rust_runtime_artifact_host`iframe 上显式标注 `data-tree-runtime-artifact-host=rust_tree_shell_runtime_artifact_v1``data-tree-browser-bridge=iframe_srcdoc`;当前默认主路径已继续切到 `rust_wasm_dom_shell_host``data-tree-browser-bridge=dom_wasm`
- [x] 定义 wire-safe runtime facade
- [x] `TreeShellRuntimeRequest` 统一承载 page / filetree / picker 的 environment、state、action。
- [x] `TreeShellRuntimeResult` 统一输出 state snapshot、`domPatches``hostEvents``commandEvents`
- [x] `TreeShellDomPatch` 固定 `pageState / fileTreeState / pickerState`
- [x] `TreeShellHostEvent` 固定 page open/context menu、filetree open/context menu、picker pick root/document。
- [x] `TreeShellCommandEvent` 固定 `createNode / renameNode / moveSubtree / copyResource / moveResource / uploadResource`
- [x] runtime artifact manifest 暴露 `runtimeApi`,让 3000 host 可识别 request/result 与 patch/event kind。
- [x] `mnote-web` 暴露 `POST /api/tree/runtime/reduce`,让 browser bridge 可调用 Rust `reduce_tree_shell_runtime`
- [x] 3000 暴露同源 `POST /api/tree/runtime/reduce` thin proxy,保留 cookie/auth/header 转发与 `no-store` 边界。
- [x] 3000 `TreeShellIframeHost` 的 filetree selection / visible row normalize / drag row ids 先接入 runtime reduce seam,并保留本地 fallback。
- [x] 3000 `TreeShellIframeHost` 的 page focus / keyboard / expand / collapse / toggle / open / context menu 已接入 runtime reduce seam,并保留本地 fallback。
- [x] 3000 `TreeShellIframeHost` 的 picker focus / keyboard / pick 已接入 runtime reduce seam,并保留本地 fallback。
- [x] 3000 `TreeShellIframeHost` 的 page / filetree open-context 与 picker pick 已开始消费 runtime hostEvent,并保留本地 fallback。
- [x] 3000 `TreeShellIframeHost` 的 filetree open / context menu 已接入 runtime reduce seam,并优先消费 runtime hostEvent。
- [x] 3000 `TreeShellIframeHost` 的 filetree drop-target highlight 已接入 runtime reduce seam,并保留本地 DOM fallback。
- [x] 新增正式 wasm artifact
- [x] `rust/crates/tree-shell-runtime-wasm` 复用 `mnote-web/src/tree_shell/*` runtime API 与 reducer。
- [x] `reduceTreeShellRuntime(request)` wasm-bindgen API 已可在浏览器/Node 中执行 `TreeShellRuntimeRequest -> TreeShellRuntimeResult`
- [x] `build-tree-shell-runtime.js` 生成固定 `mnote-tree-shell-runtime.js``mnote-tree-shell-runtime_bg.wasm`
- [x] 3000 同源 `/api/tree-shell-runtime/manifest.json``/api/tree-shell-runtime/mnote-tree-shell-runtime.js``/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm` 已覆盖。
- [x] `TreeShellIframeHost` 已通过 `loadTreeShellWasmRuntime / reduceTreeShellRuntimeViaWasm / reduceTreeShellRuntimeWithArtifact` 默认优先使用 wasm artifact。
- [x] 3000 默认 host 不再维护主路径 JS adapter 状态机;`TreeShellIframeHost` 内联模板 browser bridge JS DOM shell 已降级为显式 legacy/debug。
- [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] `pnpm vitest run src/app/api/tree/runtime/reduce/route.test.ts src/app/api/tree/shell/route.test.ts src/components/sidebar/tree-shell-iframe-host.test.tsx src/components/sidebar/tree-shell-surface.test.tsx src/components/documents/move-embed-picker-dialog.test.tsx`
- [x] `pnpm vitest run src/components/documents/move-embed-picker-dialog.test.tsx`
- [x] `cargo test -p mnote-web page_renderer filetree_renderer picker_renderer`
- [x] `cargo test -p mnote-web tree_runtime_reduce_endpoint_returns_filetree_runtime_result`
- [x] `cargo test -p mnote-web tree_runtime_reduce_endpoint_returns_page_and_picker_runtime_results`
- [x] `cargo test -p mnote-web tree_shell_embeds_renderer_input_contract`
- [x] `cargo test -p mnote-web tree_shell_renderer_input_exposes_runtime_artifact_boundary`
- [x] `cargo test -p tree-shell-runtime-wasm -- --nocapture`
- [x] `node scripts/build-tree-shell-runtime.js`
- [x] `pnpm vitest run src/app/api/tree-shell-runtime/[...asset]/route.test.ts src/components/sidebar/tree-shell-iframe-host.test.tsx`
- [x] Node smoke:加载 `generated/mnote-tree-shell-runtime.js` + `.wasm` 后调用 `reduceTreeShellRuntime(mode=page, action=moveNext)`,返回 `focusedId=doc:child`
- [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。
### 当前保留边界
- 这里的历史完成口径包含正式 Rust/WASM reducer runtime artifact 与默认 wasm-first reduce 执行;真正 final DOM shell 完成以 `task-010``task-013``4-18` 硬门禁为准。
- 当前 DOM runtime 阶段已把 page/picker/filetree 默认 host 切到 `TreeShellRustDomShellHost`;后续已继续删除 `normalizedMove` fallback、补 `document.snapshot.saved` 独立事件,并把 `tree.node.embed` 的 Page Aggregate / Rust artifact 边界收口。
- 后续阶段不得继续把“新增 runtime reducer action”或 legacy iframe 能力增强误写成 final renderer 主任务;默认路径必须保持 `rust_wasm_dom_shell_host + dom_wasm`
## 4. Phase Ltree.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 再拼。
- [x] 删除 `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 保留;move 主写路径强制使用 Rust `treeWriteOperation`
## 5. Phase Mblock/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`
- [x] `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 作为 `document.snapshot.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 策略。
- [x] 将 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 N3000 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 等浏览器入口必须职责。
- [x]`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/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md`
### 当前保留边界
- 3000 仍是唯一浏览器公开入口;thin proxy 不等于删除浏览器能力桥。
- `tree.node.embed` 的目标读取仍是 3000 浏览器公开入口的 substrate 职责;`pageReference` block 结构与插入位置语义已由 Rust `pageAggregateEmbedPlan` 产出。
## 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 文案保持稳定。
- [x] 如后续需要完整索引平台,再补真实异步索引延迟、刷新和可见性指标,而不是只依赖 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 与 projection result 可见性指标,不是独立搜索服务或外部索引平台。
## 8. 非目标
- 不拆除 Convex substrate。
- 不把 OnlyOffice 嵌入 BlockNote 画布。
- 不恢复旧 React PageTree/FileTree 作为 rust_family 主路径 fallback。
- 不把 3104 重新暴露为默认前端入口。
- 不在前端新增第二套树结构真相或排序真相。
@@ -1,4 +1,4 @@
# 4-17 [process] tree 3000 route thin proxy boundary v1 # 4-17 [done] tree 3000 route thin proxy boundary v1
> 创建时间:2026-04-27 > 创建时间:2026-04-27
> >
@@ -37,9 +37,9 @@
- create / copy 后创建页面 scaffold。 - create / copy 后创建页面 scaffold。
- copy 后复制 mindmap 文件。 - copy 后复制 mindmap 文件。
- move 前采集 sidebar snapshot 作为 Rust preflight data。 - move 前采集 sidebar snapshot 作为 Rust preflight data。
- `tree.node.embed` 的目标内容读取与 `pageReference` block 组装 - `tree.node.embed` 的目标内容读取与 Rust preflight 数据准备
`tree.node.embed` 的 pageReference 组装仍是待迁移 compat,后续应进入 Page Aggregate / Rust artifact,而不是继续扩写在 route 中 `tree.node.embed``pageReference` block 结构与插入位置已经迁入 Rust `pageAggregateEmbedPlan`;3000 route 不再定义该语义,只负责源/目标快照读取与 preflight 桥接
### `/api/tree/stream` ### `/api/tree/stream`
@@ -0,0 +1,31 @@
# 4-19 [done] 树域 final status 与 Rust DOM shell 切换记录 v1
> 更新时间:2026-04-28
>
> 本文由 `process/1.md` 归档而来,用于记录 `design/04-tree-domain` 当前代码已完成的最终树域收口状态。
## 执行状态(2026-04-28
- [x] 口径收口:`task-001``task-009` 完成的是 Rust/WASM reducer runtime、runtime artifact、route thinning 与 projection hardening`task-010``task-013` 才是 `4-18-tree-final-dom-shell-cutover-hard-gate-v1.md` 定义的 final DOM shell 默认路径切换。
- [x] 默认 host 已从历史 `rust_runtime_artifact_host + iframe_srcdoc` 切到 `rust_wasm_dom_shell_host + dom_wasm``rust_runtime_artifact_host``data-tree-browser-bridge="iframe_srcdoc"` 只允许出现在显式 legacy/debug host。
- [x] `TreeShellHost``rust_family + workspaceId` 下默认挂载 `TreeShellRustDomShellHost`;旧 `TreeShellIframeHost` 只能通过 `NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST=1` 进入。
- [x] 默认 DOM host 不再调用 `LOCAL_TREE_SHELL_TEMPLATE``buildInlinePageTreeHtml``buildInlineFileTreeHtml``buildInlinePickerHtml`,也不再把 `srcDoc` inline DOM shell 当作成功路径。
- [x] 默认 page / filetree / picker DOM host 标注 `data-tree-runtime-artifact-host="rust_tree_shell_runtime_artifact_v1"``data-tree-browser-bridge="dom_wasm"`
- [x] 默认 DOM host 的 page focus / keyboard / expand / collapse / open / context / create / rename / dropfiletree selection / open / context / internal drop / external drop / drop target,以及 picker keyboard / focus / pick,均通过 `TreeShellRuntimeRequest/Result` 消费 WASM artifact 或同源 Rust HTTP seam 返回的 state、hostEvents、commandEvents。
- [x] 旧 JS DOM renderer、inline template、fallback reducer/state machine 已从默认真实流量隔离,只保留在显式 legacy iframe host 内作为短期排障面。
- [x] 默认主路径负向测试已覆盖:`tree-shell-host.test.tsx``tree-shell-surface.test.tsx``move-embed-picker-dialog.test.tsx` 均断言默认路径不得出现 `iframe_srcdoc` / `rust-iframe`
- [x] `task112-tree-rust-family-regression-smoke.js``task113-picker-keyboard-regression-smoke.js` 默认验证 `dom_wasm` DOM hostlegacy iframe 仅在显式 legacy flag 下兼容。
## 历史 runtime 阶段保留记录
- Rust/WASM reducer runtime artifact 已落地:`rust/crates/tree-shell-runtime-wasm` 通过 wasm-bindgen 导出 `reduceTreeShellRuntime`3000 同源 `/api/tree-shell-runtime/*` 发布 `mnote-tree-shell-runtime.js``mnote-tree-shell-runtime_bg.wasm`
- `mnote-web` 与 3000 同源均保留 `POST /api/tree/runtime/reduce` seam;默认 DOM host 优先调用 WASM artifactHTTP seam 作为同源 Rust fallback/debug。
- 历史 `TreeShellIframeHost` 已完成过 page/filetree/picker reducer result 回放、hostEvent/commandEvent 回放、page drop target runtime 收口、picker runtime-first、filetree drop hardening 等切片;这些能力现在作为 legacy/debug host 的保留行为,不再代表默认 DOM shell。
## 当前保留边界
- `normalizedMove` compat fallback 已删除;无 `treeWriteOperation` 的 move 不再进入主写路径。
- `document.snapshot.saved` 已作为独立 event type 固定;`page.body.saved` 不再携带 snapshot payload。
- `tree.node.embed``pageReference` block 结构与插入位置已迁入 Rust `pageAggregateEmbedPlan`3000 route 只负责读取源/目标 substrate 快照并作为 preflight 传入。
- `TreeShellIframeHost` 仍作为显式 legacy/debug host 暂留;默认路径稳定后可继续删除 legacy iframe host 与对应旧测试。
- 3000 仍保留挂载、认证、transport、文件字节读取、浏览器 DnD `File[]`、菜单状态、实际命令调度、乐观 UI 与副作用通知等浏览器能力边界;这是浏览器公开入口职责,不再代表树域主语义滞留在 Next route。
@@ -1,215 +0,0 @@
# 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 artifact3000 主路径继续使用 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 Ltree.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 Mblock/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 N3000 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 重新暴露为默认前端入口。
- 不在前端新增第二套树结构真相或排序真相。
@@ -8,7 +8,7 @@
> - `/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-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/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-6-tree-command-protocol-cutover-stage2-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> >
@@ -12,7 +12,7 @@
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-6-tree-command-protocol-cutover-stage2-v1.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` > - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
> >
> 历史参考: > 历史参考:
+42
View File
@@ -44,3 +44,45 @@
[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] 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] 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 [2026-04-27T00:41:58Z] [SESSION-3] LOCK released
[2026-04-28T00:00:00Z] [SESSION-4] CORRECTION final-renderer-gate="task-001..task-009 were Rust/WASM reducer runtime completion, not final DOM shell completion"
[2026-04-28T00:00:00Z] [SESSION-4] INIT Added task-010..task-013 from design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md
[2026-04-28T00:00:00Z] [SESSION-4] NEXT task-010 must add negative gates so default page/filetree/picker success path cannot remain iframe_srcdoc inline DOM shell
[2026-04-28T00:00:00Z] [SESSION-4] STATS tasks_total=13 completed=9 failed=0 pending=4 in_progress=0 blocked=0 attempts_total=9 checkpoints=9
[2026-04-28T02:44:47Z] [SESSION-5] LOCK acquired (pid=manual-codex)
[2026-04-28T02:44:47Z] [SESSION-5] INIT Harness environment check: PASS
[2026-04-28T02:44:47Z] [SESSION-5] Starting [task-010] Phase P-0:补 final DOM shell 负向门禁,阻止 iframe_srcdoc 被误判为完成 (base=81ba21c5)
[2026-04-28T02:52:03Z] [SESSION-6] LOCK acquired (pid=manual-codex-session-6)
[2026-04-28T02:52:03Z] [SESSION-6] RECOVERY [task-010] action="resume in-progress final DOM shell gate" reason="previous session lock stale; preserve existing uncommitted changes"
[2026-04-28T03:28:16.005752Z] [SESSION-6] CHECKPOINT [task-010] step=1/4 "已新增默认非 iframe rust_wasm_dom_shell_host 与共享 DOM projection modellegacy iframe 改为显式环境开关。"
[2026-04-28T03:34:10.533972Z] [SESSION-6] CHECKPOINT [task-010] step=2/4 "tree-shell-surface 与 legacy iframe host 目标 Vitest 通过;默认主路径断言已切成 rust_wasm_dom_shell_host 且负向排除 iframe_srcdoc。"
[2026-04-28T03:36:24.605315Z] [SESSION-6] CHECKPOINT [task-010] step=3/4 "move/embed picker 测试已切到 DOM shell;默认 picker 不再以 iframe postMessage 为成功条件,目标前端 Vitest 29 项通过。"
[2026-04-28T04:40:14Z] [SESSION-7] RECOVERY [task-010] action="resume stale leased final DOM shell cutover" reason="previous session left task-010 in_progress; existing changes validated without rollback"
[2026-04-28T04:40:14Z] [SESSION-7] CHECKPOINT [task-010] step=4/4 "已补默认 DOM shell 负向门禁:tree-shell-host/surface/picker 测试与 smoke 默认拒绝 iframe_srcdoc 成功路径。"
[2026-04-28T04:40:14Z] [SESSION-7] Completed [task-010] (commit skipped by repo rule; validated by Vitest 31 tests, mnote-web tree_shell 34 tests, tree-shell-runtime-wasm 18 tests, node --check task112/task113, task112/task113 browser smoke, tsc filtered check, and git diff --check)
[2026-04-28T04:40:14Z] [SESSION-7] Starting [task-011] Phase P-1:拆分默认 Rust DOM shell host 与 legacy TreeShellIframeHost (base=81ba21c5)
[2026-04-28T04:40:14Z] [SESSION-7] CHECKPOINT [task-011] step=1/1 "TreeShellHost 默认选择 rust_wasm_dom_shell_hostTreeShellIframeHost 仅由 NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST=1 进入。"
[2026-04-28T04:40:14Z] [SESSION-7] Completed [task-011] (commit skipped by repo rule; validated by Vitest 31 tests, mnote-web tree_shell 34 tests, tree-shell-runtime-wasm 18 tests, node --check task112/task113, task112/task113 browser smoke, tsc filtered check, and git diff --check)
[2026-04-28T04:40:14Z] [SESSION-7] Starting [task-012] Phase Qpage / picker / filetree 迁入 Rust/WASM DOM shell 主路径 (base=81ba21c5)
[2026-04-28T04:40:14Z] [SESSION-7] CHECKPOINT [task-012] step=1/1 "page / filetree / picker 默认 DOM host 已消费 TreeShellRuntimeRequest/Result,并由 WASM artifact 或同源 Rust seam 驱动 hostEvents/commandEvents。"
[2026-04-28T04:40:14Z] [SESSION-7] Completed [task-012] (commit skipped by repo rule; validated by Vitest 31 tests, mnote-web tree_shell 34 tests, tree-shell-runtime-wasm 18 tests, node --check task112/task113, task112/task113 browser smoke, tsc filtered check, and git diff --check)
[2026-04-28T04:40:14Z] [SESSION-7] Starting [task-013] Phase R/S:删除默认 JS DOM state machine 并完成文档收口 (base=81ba21c5)
[2026-04-28T04:40:14Z] [SESSION-7] CHECKPOINT [task-013] step=1/1 "已清理 1.md、4-11、4-16、4-18 的 final DOM shell 口径;旧 JS renderer/state machine 只作为 legacy iframe host 保留。"
[2026-04-28T04:40:14Z] [SESSION-7] Completed [task-013] (commit skipped by repo rule; validated by Vitest 31 tests, mnote-web tree_shell 34 tests, tree-shell-runtime-wasm 18 tests, node --check task112/task113, task112/task113 browser smoke, tsc filtered check, and git diff --check)
[2026-04-28T04:40:14Z] [SESSION-7] STATS tasks_total=13 completed=13 failed=0 pending=0 in_progress=0 blocked=0 attempts_total=13 checkpoints=16
[2026-04-28T06:29:15Z] [SESSION-7] INIT Added task-014..task-018 for remaining 4-16 final runtime closure: normalizedMove fallback removal, snapshot event/Page Aggregate, embed pageReference migration, and file_tree indexing visibility.
[2026-04-28T06:29:15Z] [SESSION-7] Starting [task-014] Phase L2:删除 normalizedMove fallback,强制 move 主写路径使用 treeWriteOperation (base=81ba21c5)
[2026-04-28T06:37:40Z] [SESSION-7] CHECKPOINT [task-014] step=1/1 "已删除 normalizedMove fallbackbridge-runtime/TS transport/Convex move 写路径只保留 treeWriteOperation,目标测试通过。"
[2026-04-28T06:37:40Z] [SESSION-7] Completed [task-014] (commit skipped by repo rule; validated by bridge-runtime tree_subtree_move_command and 20 frontend target tests)
[2026-04-28T06:37:40Z] [SESSION-7] Starting [task-015] Phase M2:定稿 document.snapshot.saved 独立事件并避免与 page.body.saved payload 重叠 (base=81ba21c5)
[2026-04-28T07:44:37Z] [SESSION-8] CHECKPOINT [task-015] step=1/1 "page.body.saved 与 document.snapshot.saved 已拆成 domainEventPlans 双事件;artifact writer 支持 domainEvents 批量落库,page.body.saved payload 不再夹带 snapshot。"
[2026-04-28T07:44:37Z] [SESSION-8] Completed [task-015] (commit skipped by repo rule; validated by bridge-runtime --lib and rust-runtime/bridge-log/page-write tests)
[2026-04-28T07:44:37Z] [SESSION-8] Starting [task-016] Phase M3:把 snapshot 独立事件接入 Page Aggregate 深层收口文档与客户端边界 (base=81ba21c5)
[2026-04-28T07:44:37Z] [SESSION-8] CHECKPOINT [task-016] step=1/1 "Page Aggregate contract 与 5-6 清单已固定 page.body.saved / document.snapshot.saved 职责边界;4-16 snapshot 项已勾选。"
[2026-04-28T07:44:37Z] [SESSION-8] Completed [task-016] (commit skipped by repo rule; validated by page-aggregate-client-state/page-aggregate-builder/page-write tests and git diff --check)
[2026-04-28T07:44:37Z] [SESSION-8] Starting [task-017] Phase N2:将 tree.node.embed 的 pageReference 组装迁到 Page Aggregate / Rust artifact 边界 (base=81ba21c5)
[2026-04-28T07:44:37Z] [SESSION-8] CHECKPOINT [task-017] step=1/1 "Rust pageAggregateEmbedPlan 已产出 pageReference block 与 next content3000 route/documents embed adapter 只传 substrate preflighttree-route-boundary compatPending 已清空。"
[2026-04-28T07:44:37Z] [SESSION-8] Completed [task-017] (commit skipped by repo rule; validated by bridge-runtime --lib, route tests, and tree-route-boundary tests)
[2026-04-28T07:44:37Z] [SESSION-8] Starting [task-018] Phase O2:补 file_tree 搜索索引可见性指标边界并收口 4-16 (base=81ba21c5)
[2026-04-28T07:44:37Z] [SESSION-8] CHECKPOINT [task-018] step=1/1 "file_tree 搜索 indexingVisibility 已提升为 projection result metamnote-web 与 3000 同源 projection route 均透传该指标,4-16 无未勾选项。"
[2026-04-28T07:44:37Z] [SESSION-8] Completed [task-018] (commit skipped by repo rule; validated by bridge-runtime --lib, mnote-web file_tree_projection, file projection route/client tests)
[2026-04-28T07:44:37Z] [SESSION-8] STATS tasks_total=18 completed=18 failed=0 pending=0 in_progress=0 blocked=0 attempts_total=18 checkpoints=20
+414 -2
View File
@@ -267,8 +267,420 @@
} }
], ],
"completed_at": "2026-04-27T00:41:58Z" "completed_at": "2026-04-27T00:41:58Z"
},
{
"id": "task-010",
"title": "Phase P-0:补 final DOM shell 负向门禁,阻止 iframe_srcdoc 被误判为完成",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-009"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "81ba21c5",
"source_doc": "design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md",
"scope": [
"wolai-frontend/src/components/sidebar/tree-shell-host*.test.tsx",
"wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx",
"scripts/task112-tree-rust-family-regression-smoke.js",
"scripts/task113-picker-keyboard-regression-smoke.js"
],
"acceptance": [
"默认 page/filetree/picker 主路径测试不得再把 srcDoc inline DOM shell 作为成功条件",
"默认主路径测试不得出现 data-tree-browser-bridge=\"iframe_srcdoc\" 正向断言",
"旧 TreeShellIframeHost 的 srcdoc 合同测试必须迁入 legacy/debug 分组或显式标注为 legacy",
"task112/task113 smoke 需要验证新 Rust/WASM DOM shell 主路径,而不是证明旧 inline iframe host 继续可用"
],
"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 && cd /mnt/Data1T/mnote && node scripts/task112-tree-rust-family-regression-smoke.js && node scripts/task113-picker-keyboard-regression-smoke.js",
"timeout_seconds": 2400
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 4,
"description": "已新增默认非 iframe rust_wasm_dom_shell_host 与共享 DOM projection modellegacy iframe 改为显式环境开关。",
"timestamp": "2026-04-28T03:28:16.005752Z"
},
{
"step": 2,
"total": 4,
"description": "tree-shell-surface 与 legacy iframe host 目标 Vitest 通过;默认主路径断言已切成 rust_wasm_dom_shell_host 且负向排除 iframe_srcdoc。",
"timestamp": "2026-04-28T03:34:10.533972Z"
},
{
"step": 3,
"total": 4,
"description": "move/embed picker 测试已切到 DOM shell;默认 picker 不再以 iframe postMessage 为成功条件,目标前端 Vitest 29 项通过。",
"timestamp": "2026-04-28T03:36:24.605315Z"
},
{
"step": 4,
"total": 4,
"description": "已补默认 DOM shell 负向门禁:tree-shell-host/surface/picker 测试与 smoke 默认拒绝 iframe_srcdoc 成功路径。",
"timestamp": "2026-04-28T04:40:14Z"
} }
], ],
"session_count": 3, "completed_at": "2026-04-28T04:40:14Z"
"last_session": "2026-04-27T00:41:58Z" },
{
"id": "task-011",
"title": "Phase P-1:拆分默认 Rust DOM shell host 与 legacy TreeShellIframeHost",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-010"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "81ba21c5",
"source_doc": "design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md",
"scope": [
"wolai-frontend/src/components/sidebar/tree-shell-host.tsx",
"wolai-frontend/src/components/sidebar/tree-shell-iframe-host.tsx",
"wolai-frontend/src/components/sidebar/tree-shell-*.test.tsx"
],
"acceptance": [
"TreeShellHost 默认选择 rust_wasm_dom_shell_host 或等价新 host",
"TreeShellIframeHost 改为 LegacyTreeShellIframeHost 或显式 legacy/debug host",
"旧 iframe srcdoc host 只能通过 debug/legacy flag 进入",
"默认 page tree 首屏不依赖 LOCAL_TREE_SHELL_TEMPLATE"
],
"validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/sidebar/tree-shell-host.test.tsx src/components/sidebar/tree-shell-surface.test.tsx src/components/sidebar/tree-shell-iframe-host.test.tsx",
"timeout_seconds": 2400
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "TreeShellHost 默认选择 rust_wasm_dom_shell_hostTreeShellIframeHost 仅由 NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST=1 进入。",
"timestamp": "2026-04-28T04:40:14Z"
}
],
"completed_at": "2026-04-28T04:40:14Z"
},
{
"id": "task-012",
"title": "Phase Qpage / picker / filetree 迁入 Rust/WASM DOM shell 主路径",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-011"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "81ba21c5",
"source_doc": "design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md",
"scope": [
"rust/crates/tree-shell-runtime-wasm",
"rust/crates/mnote-web/src/tree_shell",
"wolai-frontend/src/components/sidebar"
],
"acceptance": [
"page tree 默认主路径由新 Rust/WASM DOM shell 渲染与绑定事件",
"picker 默认主路径由新 Rust/WASM DOM shell 渲染与绑定事件",
"filetree 默认主路径由新 Rust/WASM DOM shell 渲染与绑定事件,并保留 doc/index/asset-folder/asset 能力",
"3000 host 只保留挂载、auth、transport、文件读取、postMessage/command bridge"
],
"validation": {
"command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_shell && cargo test -p tree-shell-runtime-wasm -- --nocapture && 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 src/components/documents/move-embed-picker-dialog.test.tsx && cd /mnt/Data1T/mnote && node scripts/task112-tree-rust-family-regression-smoke.js && node scripts/task113-picker-keyboard-regression-smoke.js",
"timeout_seconds": 3600
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "page / filetree / picker 默认 DOM host 已消费 TreeShellRuntimeRequest/Result,并由 WASM artifact 或同源 Rust seam 驱动 hostEvents/commandEvents。",
"timestamp": "2026-04-28T04:40:14Z"
}
],
"completed_at": "2026-04-28T04:40:14Z"
},
{
"id": "task-013",
"title": "Phase R/S:删除默认 JS DOM state machine 并完成文档收口",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-012"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "81ba21c5",
"source_doc": "design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md",
"scope": [
"wolai-frontend/src/components/sidebar",
"design/04-tree-domain/process",
"design/04-tree-domain/done"
],
"acceptance": [
"默认主路径不再调用 LOCAL_TREE_SHELL_TEMPLATE、buildInlinePageTreeHtml、buildInlineFileTreeHtml、buildInlinePickerHtml",
"默认主路径不再标注 data-tree-browser-bridge=\"iframe_srcdoc\"",
"旧 JS fallback reducer/state machine 只在 legacy/debug host 中存在",
"4-11、4-16、4-18 的 final DOM shell gate 按真实代码完成状态更新"
],
"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 src/components/documents/move-embed-picker-dialog.test.tsx && cd /mnt/Data1T/mnote && node scripts/task112-tree-rust-family-regression-smoke.js && node scripts/task113-picker-keyboard-regression-smoke.js && git diff --check -- wolai-frontend/src/components/sidebar design/04-tree-domain",
"timeout_seconds": 3600
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已清理 1.md、4-11、4-16、4-18 的 final DOM shell 口径;旧 JS renderer/state machine 只作为 legacy iframe host 保留。",
"timestamp": "2026-04-28T04:40:14Z"
}
],
"completed_at": "2026-04-28T04:40:14Z"
},
{
"id": "task-014",
"title": "Phase L2:删除 normalizedMove fallback,强制 move 主写路径使用 treeWriteOperation",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-013"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "81ba21c5",
"source_doc": "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md",
"scope": [
"rust/crates/bridge-runtime/src/lib.rs",
"wolai-frontend/convex/documents.ts",
"wolai-frontend/convex/_utils/documentMoveOrder.ts",
"wolai-frontend/src/lib/documents/rust-runtime.ts",
"wolai-frontend/src/lib/documents/*move*test.ts",
"wolai-frontend/src/lib/documents/rust-runtime.test.ts"
],
"acceptance": [
"bridge-runtime 不再输出 normalizedMove 作为 documents.move 参数",
"Convex documents.move 不再接受 normalizedMove fallback;缺少 treeWriteOperation 时显式拒绝",
"TS Rust mutation transport 不再透传 normalizedMove",
"相关测试改为验证 treeWriteOperation 是唯一主写入计划"
],
"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/lib/documents/document-move-order.test.ts src/lib/documents/document-move-order-convex.test.ts src/lib/documents/rust-runtime.test.ts src/app/api/tree/commands/route.test.ts",
"timeout_seconds": 3000
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已删除 normalizedMove fallbackRust plan 不再输出 normalizedMoveTS transport 强制 treeWriteOperationConvex documents.move 只接受 treeWriteOperation;目标 Rust/Vitest 通过。",
"timestamp": "2026-04-28T06:37:40Z"
}
],
"completed_at": "2026-04-28T06:37:40Z"
},
{
"id": "task-015",
"title": "Phase M2:定稿 document.snapshot.saved 独立事件并避免与 page.body.saved payload 重叠",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-014"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "81ba21c5",
"source_doc": "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md",
"scope": [
"rust/crates/bridge-runtime/src/lib.rs",
"wolai-frontend/src/lib/documents/rust-runtime.ts",
"wolai-frontend/src/lib/documents/bridge-log.ts",
"wolai-frontend/src/lib/documents/*test.ts"
],
"acceptance": [
"page.body.saved 保留正文保存事件语义",
"snapshot 版本/hash/updatedAt 拆入 document.snapshot.saved 独立 domain event plan 或 artifact plan",
"SSE 对 snapshot 独立事件有明确 resync 或 noop 策略",
"测试覆盖两个事件不再 payload 重叠"
],
"validation": {
"command": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime documents_save_command_plans_include_formal_domain_event_contract && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/documents/rust-runtime.test.ts src/lib/documents/bridge-log.test.ts src/lib/documents/page-write-command-adapter.test.ts",
"timeout_seconds": 3000
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已将 page.body.saved 与 document.snapshot.saved 拆成 domainEventPlans 双事件;artifact writer 支持 domainEvents 批量落库,page.body.saved payload 不再夹带 snapshot。",
"timestamp": "2026-04-28T07:44:37Z"
}
],
"completed_at": "2026-04-28T07:44:37Z"
},
{
"id": "task-016",
"title": "Phase M3:把 snapshot 独立事件接入 Page Aggregate 深层收口文档与客户端边界",
"status": "completed",
"priority": "P1",
"depends_on": [
"task-015"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "81ba21c5",
"source_doc": "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md",
"scope": [
"wolai-frontend/src/components/editor/page-aggregate*",
"wolai-frontend/src/lib/documents/page-aggregate*",
"design/05-editor-mainline/process",
"design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md"
],
"acceptance": [
"Page Aggregate 文档明确 page.body.saved 与 document.snapshot.saved 的职责边界",
"客户端 Page Aggregate 状态不再把 snapshot 保存当作正文保存的附带语义",
"4-16 对应 snapshot/Page Aggregate 项可勾选或明确剩余迁移边界"
],
"validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/editor/page-aggregate-client-state.test.ts src/lib/documents/page-aggregate-builder.test.ts src/lib/documents/page-write-command-adapter.test.ts && git diff --check -- design/04-tree-domain design/05-editor-mainline wolai-frontend/src/components/editor wolai-frontend/src/lib/documents",
"timeout_seconds": 2400
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已更新 Page Aggregate contract 与 5-6 清单,固定 page.body.saved 与 document.snapshot.saved 的职责边界,并同步 4-16 勾选状态。",
"timestamp": "2026-04-28T07:44:37Z"
}
],
"completed_at": "2026-04-28T07:44:37Z"
},
{
"id": "task-017",
"title": "Phase N2:将 tree.node.embed 的 pageReference 组装迁到 Page Aggregate / Rust artifact 边界",
"status": "completed",
"priority": "P1",
"depends_on": [
"task-015"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "81ba21c5",
"source_doc": "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md",
"scope": [
"rust/crates/bridge-runtime/src/lib.rs",
"wolai-frontend/src/app/api/tree/commands/route.ts",
"wolai-frontend/src/lib/documents/page-command-adapter.ts",
"wolai-frontend/src/lib/tree-route-boundary.ts",
"wolai-frontend/src/app/api/tree/commands/route.test.ts"
],
"acceptance": [
"3000 route 不再本地决定 pageReference block 的结构与插入位置语义",
"Rust plan 或 Page Aggregate artifact 接收 source/target/anchor/content snapshot 并产出 next content 或 block patch plan",
"tree-route-boundary manifest 移除该 compatPending 项",
"测试覆盖 tree.node.embed 的 pageReference 组装来源已迁移"
],
"validation": {
"command": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime documents_embed_command_plan_maps_to_documents_update_content tree_embed_and_copy_aliases_keep_tree_command_names && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/tree/commands/route.test.ts src/lib/tree-route-boundary.test.ts src/lib/documents/rust-runtime.test.ts",
"timeout_seconds": 3000
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已新增 Rust pageAggregateEmbedPlan3000 route 与 documents.embed adapter 只传 source/target/anchor/content snapshot preflight,不再本地决定 pageReference block 结构与插入位置。",
"timestamp": "2026-04-28T07:44:37Z"
}
],
"completed_at": "2026-04-28T07:44:37Z"
},
{
"id": "task-018",
"title": "Phase O2:补 file_tree 搜索索引可见性指标边界并收口 4-16",
"status": "completed",
"priority": "P2",
"depends_on": [
"task-014"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "81ba21c5",
"source_doc": "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md",
"scope": [
"rust/crates/bridge-runtime/src/lib.rs",
"rust/crates/mnote-web/src/routes/kernel.rs",
"wolai-frontend/src/lib/server/kernel-file-tree.ts",
"wolai-frontend/src/app/api/tree/projections/file/route.ts",
"design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md"
],
"acceptance": [
"file_tree 搜索 projection 输出稳定 indexing/visibility meta,不再只是临时 request meta",
"3000 同源 projection route 透传该 meta",
"move-embed picker/file tree 搜索可观察 index visibility 状态",
"4-16 中 Phase O 未完成项完成或明确转为非树域 runtime 后续"
],
"validation": {
"command": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime file_tree && cargo test -p mnote-web file_tree_projection && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/tree/projections/file/route.test.ts src/lib/tree-stream/tree-delta.test.ts",
"timeout_seconds": 3000
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已把 file_tree 搜索 indexingVisibility 提升为 projection result metamnote-web 与 3000 同源 projection route 均透传该指标,4-16 Phase O 已收口。",
"timestamp": "2026-04-28T07:44:37Z"
}
],
"completed_at": "2026-04-28T07:44:37Z"
}
],
"session_count": 8,
"last_session": "2026-04-28T07:44:37Z",
"final_renderer_gate": {
"source_doc": "design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md",
"status": "completed",
"note": "task-001 至 task-009 只代表 Rust/WASM reducer runtime 阶段完成;不得再把 inline iframe srcdoc host 视为 final Rust DOM renderer。"
},
"final_runtime_remaining": {
"source_doc": "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md",
"status": "completed",
"tasks": [
"task-014",
"task-015",
"task-016",
"task-017",
"task-018"
]
}
} }
+32
View File
@@ -377,6 +377,16 @@ dependencies = [
"winnow", "winnow",
] ]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]] [[package]]
name = "const-str" name = "const-str"
version = "1.1.0" version = "1.1.0"
@@ -1990,6 +2000,17 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-wasm-bindgen"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.228"
@@ -2604,6 +2625,17 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "tree-shell-runtime-wasm"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"serde",
"serde-wasm-bindgen",
"serde_json",
"wasm-bindgen",
]
[[package]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
+1
View File
@@ -10,6 +10,7 @@ members = [
"crates/mnote-web", "crates/mnote-web",
"crates/storage-convex-bridge", "crates/storage-convex-bridge",
"crates/index-fts", "crates/index-fts",
"crates/tree-shell-runtime-wasm",
] ]
resolver = "2" resolver = "2"
+398 -53
View File
@@ -294,6 +294,8 @@ pub struct RuntimeCommandExecutionPlan {
pub struct RuntimeCommandArtifactPlan { pub struct RuntimeCommandArtifactPlan {
pub command_log: RuntimeCommandLogArtifactPlan, pub command_log: RuntimeCommandLogArtifactPlan,
pub domain_event: Option<RuntimeDomainEventArtifactPlan>, pub domain_event: Option<RuntimeDomainEventArtifactPlan>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub domain_events: Vec<RuntimeDomainEventArtifactPlan>,
} }
#[derive(Debug, Clone, Serialize, PartialEq)] #[derive(Debug, Clone, Serialize, PartialEq)]
@@ -627,7 +629,8 @@ struct DocumentEmbedCommandPayload {
document_id: String, document_id: String,
workspace_id: Option<String>, workspace_id: Option<String>,
revision: Option<u64>, revision: Option<u64>,
content: Value, #[serde(default)]
content: Option<Value>,
conflict_detection_key: Option<String>, conflict_detection_key: Option<String>,
source_document_id: String, source_document_id: String,
target_document_id: String, target_document_id: String,
@@ -2247,6 +2250,32 @@ fn materialize_tree_domain_event_plan(
result: &Value, result: &Value,
) -> Option<(String, Value)> { ) -> Option<(String, Value)> {
let event_plan = plan.args_json.get("domainEventPlan")?; let event_plan = plan.args_json.get("domainEventPlan")?;
materialize_tree_domain_event_value(event_plan, plan, result)
}
fn materialize_tree_domain_event_plans(
plan: &RuntimeCommandExecutionPlan,
result: &Value,
) -> Vec<(String, Value)> {
plan.args_json
.get("domainEventPlans")
.and_then(Value::as_array)
.map(|event_plans| {
event_plans
.iter()
.filter_map(|event_plan| materialize_tree_domain_event_value(event_plan, plan, result))
.collect()
})
.filter(|event_plans: &Vec<(String, Value)>| !event_plans.is_empty())
.or_else(|| materialize_tree_domain_event_plan(plan, result).map(|event_plan| vec![event_plan]))
.unwrap_or_default()
}
fn materialize_tree_domain_event_value(
event_plan: &Value,
plan: &RuntimeCommandExecutionPlan,
result: &Value,
) -> Option<(String, Value)> {
if event_plan.get("family").and_then(Value::as_str) != Some("tree") if event_plan.get("family").and_then(Value::as_str) != Some("tree")
|| event_plan.get("schema").and_then(Value::as_str) != Some("mnote.tree.domain_event") || event_plan.get("schema").and_then(Value::as_str) != Some("mnote.tree.domain_event")
|| event_plan.get("schemaVersion").and_then(Value::as_i64) != Some(1) || event_plan.get("schemaVersion").and_then(Value::as_i64) != Some(1)
@@ -2354,8 +2383,8 @@ pub fn build_runtime_command_artifact_plan(
.and_then(|target| target.block_id.as_ref()) .and_then(|target| target.block_id.as_ref())
.map(|value| value.trim().to_string()) .map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()); .filter(|value| !value.is_empty());
let materialized_event_plan = materialize_tree_domain_event_plan(plan, result); let materialized_event_plans = materialize_tree_domain_event_plans(plan, result);
let command_payload = if let Some((_, domain_event_plan)) = &materialized_event_plan { let command_payload = if let Some((_, domain_event_plan)) = materialized_event_plans.first() {
if let Some(stream_delta) = domain_event_plan.get("streamDelta") { if let Some(stream_delta) = domain_event_plan.get("streamDelta") {
let mut payload = command.payload.clone(); let mut payload = command.payload.clone();
if let Some(map) = payload.as_object_mut() { if let Some(map) = payload.as_object_mut() {
@@ -2374,6 +2403,18 @@ pub fn build_runtime_command_artifact_plan(
command.payload.clone() command.payload.clone()
}; };
let aggregate_type = if target_block_id.is_some() {
"block"
} else if target_page_id.is_some() {
"page"
} else {
"workspace"
};
let aggregate_id = target_block_id
.as_deref()
.or(target_page_id.as_deref())
.unwrap_or(workspace_id);
let command_log = RuntimeCommandLogArtifactPlan { let command_log = RuntimeCommandLogArtifactPlan {
workspace_id: workspace_id.into(), workspace_id: workspace_id.into(),
id: command_log_id.clone(), id: command_log_id.clone(),
@@ -2400,25 +2441,22 @@ pub fn build_runtime_command_artifact_plan(
finished_at: Some(now.into()), finished_at: Some(now.into()),
}; };
let domain_event = materialized_event_plan.map(|(event_type, domain_event_plan)| { let domain_events: Vec<RuntimeDomainEventArtifactPlan> = materialized_event_plans
let aggregate_type = if target_block_id.is_some() { .iter()
"block" .enumerate()
} else if target_page_id.is_some() { .map(|(index, (event_type, domain_event_plan))| {
"page"
} else {
"workspace"
};
let aggregate_id = target_block_id
.as_deref()
.or(target_page_id.as_deref())
.unwrap_or(workspace_id);
RuntimeDomainEventArtifactPlan { RuntimeDomainEventArtifactPlan {
workspace_id: workspace_id.into(), workspace_id: workspace_id.into(),
id: format!("evt_{}", command.command_id), id: tree_domain_event_artifact_id(
&command.command_id,
event_type,
index,
materialized_event_plans.len(),
),
request_id: context.request_id.clone(), request_id: context.request_id.clone(),
trace_id: context.trace_id.clone(), trace_id: context.trace_id.clone(),
command_id: command.command_id.clone(), command_id: command.command_id.clone(),
command_log_id, command_log_id: command_log_id.clone(),
event_type: event_type.clone(), event_type: event_type.clone(),
aggregate_type: aggregate_type.into(), aggregate_type: aggregate_type.into(),
aggregate_id: aggregate_id.into(), aggregate_id: aggregate_id.into(),
@@ -2435,14 +2473,110 @@ pub fn build_runtime_command_artifact_plan(
), ),
created_at: now.into(), created_at: now.into(),
} }
}); })
.collect();
let domain_event = domain_events.first().cloned();
Some(RuntimeCommandArtifactPlan { Some(RuntimeCommandArtifactPlan {
command_log, command_log,
domain_event, domain_event,
domain_events,
}) })
} }
fn tree_domain_event_artifact_id(command_id: &str, event_type: &str, index: usize, total: usize) -> String {
if total <= 1 || index == 0 {
return format!("evt_{command_id}");
}
let suffix: String = event_type
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
format!("evt_{command_id}_{:02}_{suffix}", index + 1)
}
fn read_preflight_field<'a>(command: &'a RuntimeCommandEnvelopeWire, field: &str) -> Option<&'a Value> {
command
.preflight_data
.as_ref()
.and_then(|preflight| preflight.get(field))
}
fn compose_content_with_blocks(content: &Value, blocks: Vec<Value>) -> Value {
if content.is_array() {
return Value::Array(blocks);
}
if let Some(map) = content.as_object() {
let mut next = map.clone();
next.insert("blocks".into(), Value::Array(blocks));
return Value::Object(next);
}
json!({ "blocks": blocks })
}
fn build_page_aggregate_embed_plan(
command_wire: &RuntimeCommandEnvelopeWire,
payload: &DocumentEmbedCommandPayload,
) -> Result<Option<Value>, BridgeError> {
let Some(input) = read_preflight_field(command_wire, "pageAggregateEmbed") else {
return Ok(None);
};
let target_content = input
.get("targetContent")
.ok_or_else(|| BridgeError::validation("pageAggregateEmbed 缺少 targetContent"))?;
let source_title = read_trimmed_str_field(input, "sourceTitle").unwrap_or("无标题");
let anchor_block_id = payload
.anchor_block_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| read_trimmed_str_field(input, "anchorBlockId"));
let block_id = read_trimmed_str_field(input, "blockId")
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("page_ref_{}", payload.source_document_id));
let current_blocks = normalize_blocks_from_value(target_content);
let insert_index = anchor_block_id
.and_then(|anchor_id| {
current_blocks.iter().position(|block| {
read_trimmed_str_field(block, "id")
.map(|block_id| block_id == anchor_id)
.unwrap_or(false)
})
})
.map(|index| index + 1)
.unwrap_or(current_blocks.len());
let page_reference_block = json!({
"id": block_id,
"type": "pageReference",
"props": {
"pageId": payload.source_document_id,
"title": source_title,
},
});
let mut next_blocks = Vec::with_capacity(current_blocks.len() + 1);
next_blocks.extend(current_blocks.iter().take(insert_index).cloned());
next_blocks.push(page_reference_block.clone());
next_blocks.extend(current_blocks.iter().skip(insert_index).cloned());
let next_content = compose_content_with_blocks(target_content, next_blocks);
Ok(Some(json!({
"schema": "mnote.page_aggregate.embed_plan",
"schemaVersion": 1,
"sourceDocumentId": payload.source_document_id,
"targetDocumentId": payload.target_document_id,
"anchorBlockId": anchor_block_id,
"insertIndex": insert_index,
"block": page_reference_block,
"content": next_content,
"blockCount": normalize_blocks_from_value(&next_content).len(),
})))
}
#[derive(Debug, Serialize, PartialEq)] #[derive(Debug, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct RuntimeBlockSummary { struct RuntimeBlockSummary {
@@ -6288,6 +6422,8 @@ fn build_file_tree_projection_result(
filters: &KernelProjectionFilter, filters: &KernelProjectionFilter,
) -> KernelProjectionResult { ) -> KernelProjectionResult {
let (assets_by_doc, asset_by_id, child_assets_by_parent) = build_file_tree_assets(data); let (assets_by_doc, asset_by_id, child_assets_by_parent) = build_file_tree_assets(data);
let requested_query = normalize_projection_query(filters.query.as_deref());
let requested_max_results = filters.max_results;
let page_ids = subtree let page_ids = subtree
.nodes .nodes
.iter() .iter()
@@ -6595,6 +6731,8 @@ fn build_file_tree_projection_result(
} }
apply_file_tree_projection_search_filter(&mut items, &mut edges, filters); apply_file_tree_projection_search_filter(&mut items, &mut edges, filters);
let visible_rows = items.len();
let visible_edges = edges.len();
KernelProjectionResult { KernelProjectionResult {
projection_id: format!( projection_id: format!(
@@ -6605,6 +6743,31 @@ fn build_file_tree_projection_result(
root_node_id: root_node_id.map(ToOwned::to_owned), root_node_id: root_node_id.map(ToOwned::to_owned),
items, items,
edges, edges,
meta: BTreeMap::from([(
"search".into(),
json!({
"query": requested_query.clone(),
"maxResults": requested_max_results,
"maxResultsRule": "matches_only_before_ancestor_completion",
"ancestorCompletion": "include_all_ancestors_after_match_truncation",
"ordering": "kernel_file_tree_preorder",
"indexingVisibility": {
"schema": "mnote.file_tree.indexing_visibility",
"schemaVersion": 1,
"source": "kernel.project_view",
"status": "visible",
"requestKey": requested_query
.as_ref()
.map(|query| format!("{}:{query}", root_node_id.unwrap_or("root"))),
"indexedResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
"visibleResourceKinds": ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
"metrics": {
"visibleRows": visible_rows,
"visibleEdges": visible_edges,
},
},
}),
)]),
} }
} }
@@ -6883,6 +7046,7 @@ fn build_kernel_projection_result(
root_node_id: root_node_id.map(ToOwned::to_owned), root_node_id: root_node_id.map(ToOwned::to_owned),
items, items,
edges: subtree.edges, edges: subtree.edges,
meta: BTreeMap::new(),
}) })
} }
@@ -7677,11 +7841,24 @@ fn execute_command(
"documentId": payload.document_id, "documentId": payload.document_id,
}), }),
); );
let domain_event_payload = document_save_domain_event_payload( let page_body_event_payload = document_save_page_body_domain_event_payload(
&payload, &payload,
&editor_document, &editor_document,
);
let snapshot_event_payload = document_save_snapshot_domain_event_payload(
&payload,
&canonical_content, &canonical_content,
)?; )?;
let page_body_event_plan = tree_domain_event_plan_with_payload(
"page.body.saved",
page_body_event_payload,
stream_delta_hint.clone(),
);
let snapshot_event_plan = tree_domain_event_plan_with_payload(
"document.snapshot.saved",
snapshot_event_payload,
stream_delta_hint.clone(),
);
json!({ json!({
"id": payload.document_id, "id": payload.document_id,
"content": canonical_content, "content": canonical_content,
@@ -7691,11 +7868,8 @@ fn execute_command(
"conflictDetectionKey": payload.conflict_detection_key, "conflictDetectionKey": payload.conflict_detection_key,
"streamDeltaHint": stream_delta_hint, "streamDeltaHint": stream_delta_hint,
"domainEventHint": tree_domain_event_hint("page.body.saved"), "domainEventHint": tree_domain_event_hint("page.body.saved"),
"domainEventPlan": tree_domain_event_plan_with_payload( "domainEventPlan": page_body_event_plan.clone(),
"page.body.saved", "domainEventPlans": [page_body_event_plan, snapshot_event_plan],
domain_event_payload,
stream_delta_hint,
),
}) })
}, },
})) }))
@@ -7707,6 +7881,17 @@ fn execute_command(
} else { } else {
"documents.embed" "documents.embed"
}; };
let page_aggregate_embed_plan = build_page_aggregate_embed_plan(&command_wire, &payload)?;
let embed_content = page_aggregate_embed_plan
.as_ref()
.and_then(|plan| plan.get("content"))
.cloned()
.or_else(|| payload.content.clone())
.ok_or_else(|| {
BridgeError::validation(format!(
"{command_name} 缺少 content 或 pageAggregateEmbed preflight"
))
})?;
let command = CommandEnvelope { let command = CommandEnvelope {
name: command_name.into(), name: command_name.into(),
command_id: command_wire.command_id.clone(), command_id: command_wire.command_id.clone(),
@@ -7718,7 +7903,7 @@ fn execute_command(
page_id: payload.document_id.clone(), page_id: payload.document_id.clone(),
workspace_id: payload.workspace_id.clone(), workspace_id: payload.workspace_id.clone(),
revision: payload.revision, revision: payload.revision,
content_json: serde_json::to_string(&payload.content).map_err(|error| { content_json: serde_json::to_string(&embed_content).map_err(|error| {
BridgeError::validation(format!( BridgeError::validation(format!(
"{command_name} content 序列化失败: {error}" "{command_name} content 序列化失败: {error}"
)) ))
@@ -7743,12 +7928,13 @@ fn execute_command(
payload_json: request.payload_json, payload_json: request.payload_json,
args_json: json!({ args_json: json!({
"id": payload.document_id, "id": payload.document_id,
"content": payload.content, "content": embed_content,
"expectedRevision": payload.revision, "expectedRevision": payload.revision,
"conflictDetectionKey": payload.conflict_detection_key, "conflictDetectionKey": payload.conflict_detection_key,
"sourceDocumentId": payload.source_document_id, "sourceDocumentId": payload.source_document_id,
"targetDocumentId": payload.target_document_id, "targetDocumentId": payload.target_document_id,
"anchorBlockId": payload.anchor_block_id, "anchorBlockId": payload.anchor_block_id,
"pageAggregateEmbedPlan": page_aggregate_embed_plan,
"streamDeltaHint": tree_stream_delta_hint("noop", json!({})), "streamDeltaHint": tree_stream_delta_hint("noop", json!({})),
"domainEventHint": tree_domain_event_hint("tree.node.embedded"), "domainEventHint": tree_domain_event_hint("tree.node.embedded"),
"domainEventPlan": tree_domain_event_plan( "domainEventPlan": tree_domain_event_plan(
@@ -7979,8 +8165,12 @@ fn execute_command(
"documents.move" | "tree.subtree.move" => { "documents.move" | "tree.subtree.move" => {
let payload: DocumentMoveCommandPayload = parse_payload(command_wire.payload.clone())?; let payload: DocumentMoveCommandPayload = parse_payload(command_wire.payload.clone())?;
validate_document_move_legality(&payload, command_wire.preflight_data.as_ref())?; validate_document_move_legality(&payload, command_wire.preflight_data.as_ref())?;
let normalized_move = let move_order_plan =
resolve_document_move_order_plan(&payload, command_wire.preflight_data.as_ref())?; resolve_document_move_order_plan(&payload, command_wire.preflight_data.as_ref())?;
let tree_write_operation = document_move_write_operation(
context.workspace_id.as_deref(),
move_order_plan.as_ref(),
);
let command_name = if command_wire.name == "tree.subtree.move" { let command_name = if command_wire.name == "tree.subtree.move" {
"tree.subtree.move" "tree.subtree.move"
} else { } else {
@@ -8014,11 +8204,7 @@ fn execute_command(
"id": payload.document_id, "id": payload.document_id,
"parentId": payload.parent_id, "parentId": payload.parent_id,
"sortOrder": payload.sort_order, "sortOrder": payload.sort_order,
"normalizedMove": normalized_move, "treeWriteOperation": tree_write_operation,
"treeWriteOperation": document_move_write_operation(
context.workspace_id.as_deref(),
normalized_move.as_ref(),
),
"streamDeltaHint": tree_stream_delta_hint("move_document", json!({ "streamDeltaHint": tree_stream_delta_hint("move_document", json!({
"documentId": payload.document_id, "documentId": payload.document_id,
"parentId": payload.parent_id, "parentId": payload.parent_id,
@@ -8792,9 +8978,24 @@ fn editor_document_block_ids(document: &EditorBlockDocument) -> Vec<String> {
ids ids
} }
fn document_save_domain_event_payload( fn document_save_page_body_domain_event_payload(
payload: &DocumentSaveCommandPayload, payload: &DocumentSaveCommandPayload,
editor_document: &EditorBlockDocument, editor_document: &EditorBlockDocument,
) -> Value {
json!({
"page": {
"id": payload.document_id.clone(),
"workspaceId": payload.workspace_id.clone(),
},
"blocks": {
"ids": editor_document_block_ids(editor_document),
"count": editor_document.blocks.len(),
},
})
}
fn document_save_snapshot_domain_event_payload(
payload: &DocumentSaveCommandPayload,
canonical_content: &Value, canonical_content: &Value,
) -> Result<Value, BridgeError> { ) -> Result<Value, BridgeError> {
Ok(json!({ Ok(json!({
@@ -8807,10 +9008,6 @@ fn document_save_domain_event_payload(
"contentHash": stable_json_content_hash(canonical_content)?, "contentHash": stable_json_content_hash(canonical_content)?,
"updatedAt": Value::Null, "updatedAt": Value::Null,
}, },
"blocks": {
"ids": editor_document_block_ids(editor_document),
"count": editor_document.blocks.len(),
},
})) }))
} }
@@ -9025,22 +9222,43 @@ mod tests {
}, },
"streamDeltaHint": { "streamDeltaHint": {
"family": "tree", "family": "tree",
"kind": "noop", "kind": "resync_required",
"args": {} "args": {
"reason": "blocks.patch",
"documentId": "doc_1",
"blockId": "block_1"
}
}, },
"domainEventHint": { "domainEventHint": {
"family": "tree", "family": "tree",
"eventType": "tree.block.patched" "eventType": "block.patched"
}, },
"domainEventPlan": { "domainEventPlan": {
"family": "tree", "family": "tree",
"schema": "mnote.tree.domain_event", "schema": "mnote.tree.domain_event",
"schemaVersion": 1, "schemaVersion": 1,
"eventType": "tree.block.patched", "eventType": "block.patched",
"payload": {
"document": {
"id": "doc_1",
"workspaceId": "ws_1"
},
"block": {
"id": "block_1"
},
"patch": {
"summary": "replace_block",
"nextType": "paragraph"
}
},
"streamDeltaHint": { "streamDeltaHint": {
"family": "tree", "family": "tree",
"kind": "noop", "kind": "resync_required",
"args": {} "args": {
"reason": "blocks.patch",
"documentId": "doc_1",
"blockId": "block_1"
}
} }
} }
}) })
@@ -10197,11 +10415,6 @@ mod tests {
"id": "doc_1", "id": "doc_1",
"workspaceId": "ws_1" "workspaceId": "ws_1"
}, },
"snapshot": {
"version": 8,
"contentHash": "fnv1a64:d039f8f3496411e8",
"updatedAt": null
},
"blocks": { "blocks": {
"ids": ["legacy_content_1"], "ids": ["legacy_content_1"],
"count": 1 "count": 1
@@ -10218,6 +10431,62 @@ mod tests {
} }
}) })
); );
assert_eq!(
plan.args_json["domainEventPlans"],
json!([
{
"family": "tree",
"schema": "mnote.tree.domain_event",
"schemaVersion": 1,
"eventType": "page.body.saved",
"payload": {
"page": {
"id": "doc_1",
"workspaceId": "ws_1"
},
"blocks": {
"ids": ["legacy_content_1"],
"count": 1
}
},
"streamDeltaHint": {
"family": "tree",
"kind": "resync_required",
"args": {
"reason": "page_body_saved",
"pageId": "doc_1",
"documentId": "doc_1"
}
}
},
{
"family": "tree",
"schema": "mnote.tree.domain_event",
"schemaVersion": 1,
"eventType": "document.snapshot.saved",
"payload": {
"page": {
"id": "doc_1",
"workspaceId": "ws_1"
},
"snapshot": {
"version": 8,
"contentHash": "fnv1a64:d039f8f3496411e8",
"updatedAt": null
}
},
"streamDeltaHint": {
"family": "tree",
"kind": "resync_required",
"args": {
"reason": "page_body_saved",
"pageId": "doc_1",
"documentId": "doc_1"
}
}
}
])
);
} }
#[test] #[test]
@@ -10613,6 +10882,7 @@ mod tests {
"sourceDocumentId": "doc_1", "sourceDocumentId": "doc_1",
"targetDocumentId": "doc_2", "targetDocumentId": "doc_2",
"anchorBlockId": "anchor_1", "anchorBlockId": "anchor_1",
"pageAggregateEmbedPlan": null,
"streamDeltaHint": { "streamDeltaHint": {
"family": "tree", "family": "tree",
"kind": "noop", "kind": "noop",
@@ -11177,7 +11447,7 @@ mod tests {
} }
#[test] #[test]
fn tree_subtree_move_command_includes_normalized_move_plan_from_snapshot() { fn tree_subtree_move_command_does_not_emit_legacy_normalized_move_fallback() {
let plan = execute_runtime_input(RuntimeInput::Command { let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(), context: demo_context(),
command: RuntimeCommandEnvelopeWire { command: RuntimeCommandEnvelopeWire {
@@ -11226,9 +11496,15 @@ mod tests {
assert_eq!(plan.command_name, "tree.subtree.move"); assert_eq!(plan.command_name, "tree.subtree.move");
assert_eq!(plan.function_name, "documents:move"); assert_eq!(plan.function_name, "documents:move");
assert_eq!(plan.args_json["sortOrder"], json!(-2)); assert_eq!(plan.args_json["sortOrder"], json!(-2));
assert!(plan.args_json.get("normalizedMove").is_none());
assert_eq!( assert_eq!(
plan.args_json["normalizedMove"], plan.args_json["treeWriteOperation"],
json!({ json!({
"family": "tree",
"schema": "mnote.tree.write_operation",
"schemaVersion": 1,
"operation": "tree.subtree.move.write",
"workspaceId": "ws_1",
"documentId": "doc_b", "documentId": "doc_b",
"fromParentId": "source", "fromParentId": "source",
"toParentId": "target", "toParentId": "target",
@@ -11715,13 +11991,26 @@ mod tests {
"documentId": "doc_2", "documentId": "doc_2",
"workspaceId": "ws_1", "workspaceId": "ws_1",
"revision": 5, "revision": 5,
"content": [{ "id": "block_1", "type": "pageReference" }],
"conflictDetectionKey": "conflict_5", "conflictDetectionKey": "conflict_5",
"sourceDocumentId": "doc_1", "sourceDocumentId": "doc_1",
"targetDocumentId": "doc_2", "targetDocumentId": "doc_2",
"anchorBlockId": "anchor_1", "anchorBlockId": "anchor_1",
}), }),
preflight_data: None, preflight_data: Some(json!({
"pageAggregateEmbed": {
"sourceDocumentId": "doc_1",
"sourceTitle": "来源页面",
"targetDocumentId": "doc_2",
"targetContent": {
"blocks": [
{ "id": "anchor_1", "type": "paragraph" }
],
"format": "editor"
},
"anchorBlockId": "anchor_1",
"blockId": "page_ref_doc_1"
}
})),
reason: Some("树命令嵌入页面".into()), reason: Some("树命令嵌入页面".into()),
refs: vec![], refs: vec![],
dry_run: false, dry_run: false,
@@ -11738,12 +12027,56 @@ mod tests {
plan.args_json, plan.args_json,
json!({ json!({
"id": "doc_2", "id": "doc_2",
"content": [{ "id": "block_1", "type": "pageReference" }], "content": {
"blocks": [
{ "id": "anchor_1", "type": "paragraph" },
{
"id": "page_ref_doc_1",
"type": "pageReference",
"props": {
"pageId": "doc_1",
"title": "来源页面"
}
}
],
"format": "editor"
},
"expectedRevision": 5, "expectedRevision": 5,
"conflictDetectionKey": "conflict_5", "conflictDetectionKey": "conflict_5",
"sourceDocumentId": "doc_1", "sourceDocumentId": "doc_1",
"targetDocumentId": "doc_2", "targetDocumentId": "doc_2",
"anchorBlockId": "anchor_1", "anchorBlockId": "anchor_1",
"pageAggregateEmbedPlan": {
"schema": "mnote.page_aggregate.embed_plan",
"schemaVersion": 1,
"sourceDocumentId": "doc_1",
"targetDocumentId": "doc_2",
"anchorBlockId": "anchor_1",
"insertIndex": 1,
"block": {
"id": "page_ref_doc_1",
"type": "pageReference",
"props": {
"pageId": "doc_1",
"title": "来源页面"
}
},
"content": {
"blocks": [
{ "id": "anchor_1", "type": "paragraph" },
{
"id": "page_ref_doc_1",
"type": "pageReference",
"props": {
"pageId": "doc_1",
"title": "来源页面"
}
}
],
"format": "editor"
},
"blockCount": 2
},
"streamDeltaHint": { "streamDeltaHint": {
"family": "tree", "family": "tree",
"kind": "noop", "kind": "noop",
@@ -13181,6 +13514,18 @@ mod tests {
result["projectionId"], result["projectionId"],
json!("kernel_projection:file_tree:page_root") json!("kernel_projection:file_tree:page_root")
); );
assert_eq!(
result["meta"]["search"]["indexingVisibility"]["schema"],
json!("mnote.file_tree.indexing_visibility")
);
assert_eq!(
result["meta"]["search"]["indexingVisibility"]["status"],
json!("visible")
);
assert_eq!(
result["meta"]["search"]["indexingVisibility"]["metrics"]["visibleRows"],
json!(items.len())
);
assert_eq!( assert_eq!(
item_by_row_id["doc:page_root"]["rowKind"], item_by_row_id["doc:page_root"]["rowKind"],
json!("document") json!("document")
+2
View File
@@ -352,6 +352,8 @@ pub struct KernelProjectionResult {
pub root_node_id: Option<String>, pub root_node_id: Option<String>,
pub items: Vec<KernelProjectionItem>, pub items: Vec<KernelProjectionItem>,
pub edges: Vec<KernelEdge>, pub edges: Vec<KernelEdge>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub meta: BTreeMap<String, Value>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+3 -3
View File
@@ -4,12 +4,12 @@ use crate::error::WebError;
use crate::routes::query_support::{ use crate::routes::query_support::{
execute_runtime_query_via_convex, resolve_effective_workspace_id, execute_runtime_query_via_convex, resolve_effective_workspace_id,
}; };
use axum::Json;
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -152,7 +152,7 @@ pub async fn trace(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use tower::util::ServiceExt; use tower::util::ServiceExt;
+4 -4
View File
@@ -2,15 +2,15 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::query_support::resolve_effective_workspace_id; use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec}; use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
use axum::Json;
use axum::extract::Query; use axum::extract::Query;
use axum::extract::{Extension, State}; use axum::extract::{Extension, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use core_protocol::KernelProjectionKind; use core_protocol::KernelProjectionKind;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -94,7 +94,7 @@ pub async fn next_sidebar(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -6,15 +6,15 @@ use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_documents_meta_via_convex, execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
resolve_effective_workspace_id, resolve_effective_workspace_id,
}; };
use axum::Json;
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use bridge_runtime::{ use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::fs; use std::fs;
use std::time::Duration; use std::time::Duration;
@@ -500,8 +500,8 @@ pub async fn save(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use serde_json::Value; use serde_json::Value;
use tower::util::ServiceExt; use tower::util::ServiceExt;
+6 -6
View File
@@ -2,14 +2,14 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::documents::{ use crate::routes::documents::{
content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery, DocumentContentQuery, DocumentMetaQuery, content as document_content, meta as document_meta,
}; };
use axum::extract::{Extension, Json, Query, State}; use axum::extract::{Extension, Json, Query, State};
use axum::http::{header, HeaderValue, StatusCode}; use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{Html, IntoResponse, Response}; use axum::response::{Html, IntoResponse, Response};
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession}; use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{Value, json};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -1761,10 +1761,10 @@ pub async fn transform_runtime_snapshot(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use serde_json::{json, Value}; use serde_json::{Value, json};
use tower::util::ServiceExt; use tower::util::ServiceExt;
fn app() -> axum::Router { fn app() -> axum::Router {
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use axum::extract::{Extension, State};
use axum::Json; use axum::Json;
use axum::extract::{Extension, State};
use serde::Serialize; use serde::Serialize;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
+4 -4
View File
@@ -1,15 +1,15 @@
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use axum::Json;
use axum::extract::{Extension, State}; use axum::extract::{Extension, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::{ use bridge_runtime::{
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query, RuntimeInput, build_failure_response, build_success_response, execute_runtime_input,
runtime_input_requests_result, RuntimeInput, execute_runtime_query, runtime_input_requests_result,
}; };
use serde::Serialize; use serde::Serialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
+26 -8
View File
@@ -3,16 +3,16 @@ use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::query_support::resolve_effective_workspace_id; use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{ use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
ProjectionSnapshotSpec, subtree_query,
}; };
use axum::Json;
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{KernelGraphDirection, KernelProjectionKind}; use core_protocol::{KernelGraphDirection, KernelProjectionKind};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -188,8 +188,8 @@ pub async fn graph(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use serde_json::Value; use serde_json::Value;
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -386,11 +386,13 @@ mod tests {
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"], item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"],
"mindmap" "mindmap"
); );
assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"] assert!(
item_by_row_id["asset-folder:mind_1"]["capabilities"]
.as_array() .as_array()
.expect("capabilities") .expect("capabilities")
.iter() .iter()
.any(|value| value == "expand")); .any(|value| value == "expand")
);
} }
#[tokio::test] #[tokio::test]
@@ -417,6 +419,22 @@ mod tests {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(row_ids, vec!["doc:page_root", "asset:table_1"]); assert_eq!(row_ids, vec!["doc:page_root", "asset:table_1"]);
assert_eq!(
payload["result"]["meta"]["search"]["indexingVisibility"]["schema"],
"mnote.file_tree.indexing_visibility"
);
assert_eq!(
payload["result"]["meta"]["search"]["indexingVisibility"]["status"],
"visible"
);
assert_eq!(
payload["result"]["meta"]["search"]["indexingVisibility"]["visibleResourceKinds"]
.as_array()
.expect("visible resource kinds")
.iter()
.any(|value| value == "asset"),
true
);
assert_eq!(items[0]["expandedByDefault"], true); assert_eq!(items[0]["expandedByDefault"], true);
assert_eq!(items[1]["resourceMeta"]["resourceKind"], "table"); assert_eq!(items[1]["resourceMeta"]["resourceKind"], "table");
let edges = payload["result"]["edges"].as_array().expect("edges"); let edges = payload["result"]["edges"].as_array().expect("edges");
+5 -1
View File
@@ -14,8 +14,8 @@ mod tree;
mod ws; mod ws;
use crate::app::AppState; use crate::app::AppState;
use axum::routing::{get, post};
use axum::Router; use axum::Router;
use axum::routing::{get, post};
pub fn build_router(state: AppState) -> Router { pub fn build_router(state: AppState) -> Router {
let hermes_base_path = state.config().hermes_base_path.clone(); let hermes_base_path = state.config().hermes_base_path.clone();
@@ -45,6 +45,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/kernel/edges", get(kernel::edges)) .route("/api/kernel/edges", get(kernel::edges))
.route("/api/kernel/graph", get(kernel::graph)) .route("/api/kernel/graph", get(kernel::graph))
.route("/api/tree/commands", post(tree::tree_command)) .route("/api/tree/commands", post(tree::tree_command))
.route(
"/api/tree/runtime/reduce",
post(tree::reduce_tree_shell_runtime),
)
.route("/api/bridge/workspace", get(bridge::workspace)) .route("/api/bridge/workspace", get(bridge::workspace))
.route("/api/bridge/request", get(bridge::request)) .route("/api/bridge/request", get(bridge::request))
.route("/api/bridge/trace", get(bridge::trace)) .route("/api/bridge/trace", get(bridge::trace))
@@ -3,13 +3,13 @@ use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::transport::convex::execute_convex_query_plan; use crate::transport::convex::execute_convex_query_plan;
use bridge_runtime::{ use bridge_runtime::{
execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput,
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, execute_runtime_input,
RuntimeSourceWire, execute_runtime_query,
}; };
use core_protocol::{GetPageMeta, QueryEnvelope}; use core_protocol::{GetPageMeta, QueryEnvelope};
use serde_json::Value; use serde_json::Value;
use storage_convex_bridge::{build_query_request, BridgeContext}; use storage_convex_bridge::{BridgeContext, build_query_request};
pub fn resolve_effective_workspace_id( pub fn resolve_effective_workspace_id(
context: &RequestContext, context: &RequestContext,
@@ -6,7 +6,7 @@ use crate::routes::query_support::{
}; };
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{KernelNodeType, KernelProjectionKind}; use core_protocol::{KernelNodeType, KernelProjectionKind};
use serde_json::{json, Value}; use serde_json::{Value, json};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ProjectionSnapshotSpec<'a> { pub struct ProjectionSnapshotSpec<'a> {
+4 -5
View File
@@ -2,9 +2,8 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::stream_support::{ use crate::routes::stream_support::{
build_stream_delta_payload, load_stream_overview, load_stream_snapshot, StreamChangeKind, StreamSnapshotQuery, build_stream_delta_payload, load_stream_overview,
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind, load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
StreamSnapshotQuery,
}; };
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
@@ -138,8 +137,8 @@ fn stream_event(event_name: &str, payload: &Value) -> Event {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -5,13 +5,13 @@ use crate::routes::query_support::{
execute_runtime_query_via_convex, resolve_effective_workspace_id, execute_runtime_query_via_convex, resolve_effective_workspace_id,
}; };
use crate::routes::snapshot_support::{ use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
ProjectionSnapshotSpec, subtree_query,
}; };
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::KernelProjectionKind; use core_protocol::KernelProjectionKind;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [ const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [
"page.body.save", "page.body.save",
@@ -588,8 +588,8 @@ pub async fn load_stream_snapshot(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, StreamChangeKind, StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, resolve_stream_change,
StreamSnapshotQuery, StreamSnapshotScope, resolve_stream_cursor, resolve_stream_scope,
}; };
use serde_json::json; use serde_json::json;
+413 -23
View File
@@ -21,6 +21,10 @@ use crate::tree_shell::renderer_input::{
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher, FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
TreeShellRendererInput, TreeShellRendererInput,
}; };
use crate::tree_shell::runtime_api::{
TreeShellRuntimeRequest, TreeShellRuntimeResult,
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request,
};
use axum::Json; use axum::Json;
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::{HeaderValue, StatusCode, header}; use axum::http::{HeaderValue, StatusCode, header};
@@ -1200,6 +1204,14 @@ fn build_tree_shell_html(
typeof rendererInput.pageFocusKeyboardReducer === "object" typeof rendererInput.pageFocusKeyboardReducer === "object"
? rendererInput.pageFocusKeyboardReducer ? rendererInput.pageFocusKeyboardReducer
: {}; : {};
const runtimeArtifact =
rendererInput.runtimeArtifact && typeof rendererInput.runtimeArtifact === "object"
? rendererInput.runtimeArtifact
: {};
const runtimeApi =
runtimeArtifact.runtimeApi && typeof runtimeArtifact.runtimeApi === "object"
? runtimeArtifact.runtimeApi
: {};
const normalizeStringArray = (value) => const normalizeStringArray = (value) =>
Array.isArray(value) Array.isArray(value)
? value ? value
@@ -1294,6 +1306,10 @@ fn build_tree_shell_html(
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed || fallback; return trimmed || fallback;
}; };
const runtimeReduceEndpoint = normalizeText(
runtimeApi.reduceEndpoint,
"/api/tree/runtime/reduce",
);
const normalizeParent = (value) => { const normalizeParent = (value) => {
const normalized = normalizeText(value); const normalized = normalizeText(value);
@@ -2237,8 +2253,34 @@ fn build_tree_shell_html(
}); });
}; };
const toggleExpand = (nodeId) => { const commitPageExpandedIds = (expandedIds) => {
const nextExpanded = !expanded.has(nodeId); const nextExpanded = new Set(normalizeStringArray(expandedIds));
let changed = nextExpanded.size !== expanded.size;
if (!changed) {
changed = Array.from(nextExpanded).some((nodeId) => !expanded.has(nodeId));
}
if (!changed) return false;
expanded.clear();
nextExpanded.forEach((nodeId) => expanded.add(nodeId));
return true;
};
const patchPageTreeAfterRuntimeState = (changedNodeIds, focusedId) => {
if (mode !== "page") return;
if (usedRustInitialRenderer) {
const ids = normalizeStringArray(changedNodeIds);
ids.forEach((nodeId) => {
patchPageTreeExpansionDom(nodeId);
});
patchPageTreeActiveDom();
if (focusedId) focusRowElement(focusedId);
return;
}
renderTree();
if (focusedId) focusRowElement(focusedId);
};
const applyLocalPageExpansionFallback = (nodeId, nextExpanded) => {
if (nextExpanded) expanded.add(nodeId); if (nextExpanded) expanded.add(nodeId);
else expanded.delete(nodeId); else expanded.delete(nodeId);
postPageExpandChange(nodeId, nextExpanded); postPageExpandChange(nodeId, nextExpanded);
@@ -2249,6 +2291,10 @@ fn build_tree_shell_html(
renderTree(); renderTree();
}; };
const toggleExpand = (nodeId) => {
applyLocalPageExpansionFallback(nodeId, !expanded.has(nodeId));
};
const getVisiblePageItems = () => { const getVisiblePageItems = () => {
const visible = []; const visible = [];
const walk = (entries) => { const walk = (entries) => {
@@ -2423,7 +2469,167 @@ fn build_tree_shell_html(
focusRowElement(nodeId); focusRowElement(nodeId);
}; };
const applyPageKeyboardAction = (action, item, sourceElement) => { const resolvePageActionItem = (action, item) => {
const actionNodeId = normalizeText(action?.nodeId);
if (actionNodeId && itemById.has(actionNodeId)) {
return itemById.get(actionNodeId);
}
if (item?.nodeId && itemById.has(item.nodeId)) {
return item;
}
return focusedNodeId && itemById.has(focusedNodeId)
? itemById.get(focusedNodeId)
: null;
};
const buildPageRuntimeAction = (action, item) => {
const actionKind = normalizeText(action?.kind).toLowerCase();
if (actionKind === "focus") {
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
return nodeId ? { kind: "focus", nodeId } : null;
}
if (actionKind === "move_next") return { kind: "moveNext" };
if (actionKind === "move_previous") return { kind: "movePrevious" };
if (actionKind === "move_home") return { kind: "moveHome" };
if (actionKind === "move_end") return { kind: "moveEnd" };
if (actionKind === "open") return { kind: "openFocused" };
if (actionKind === "context_menu") return { kind: "contextMenuFocused" };
if (actionKind === "expand" || actionKind === "collapse" || actionKind === "toggle") {
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
return nodeId ? { kind: actionKind, nodeId } : null;
}
return null;
};
const buildPageRuntimeEnvironment = () => ({
visibleNodeIds: getVisiblePageItems().map((entry) => entry.nodeId),
expandableNodeIds: normalizedItems
.filter((entry) => entry.childCount > 0 && getSiblings(entry.nodeId).length > 0)
.map((entry) => entry.nodeId),
});
const readPageRuntimeState = (action, item) => {
const actionKind = normalizeText(action?.kind).toLowerCase();
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
const focusedId =
(actionKind === "open" || actionKind === "context_menu") && itemById.has(nodeId)
? nodeId
: focusedNodeId || null;
return {
focusedId,
expandedIds: Array.from(expanded),
dropFeedback: null,
};
};
const reducePageActionWithRuntime = async (action, item) => {
if (mode !== "page" || !runtimeReduceEndpoint) {
return null;
}
const runtimeAction = buildPageRuntimeAction(action, item);
if (!runtimeAction) return null;
const response = await fetch(runtimeReduceEndpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
mode: "page",
requestId: `page-runtime-${Date.now()}`,
environment: buildPageRuntimeEnvironment(),
state: readPageRuntimeState(action, item),
action: runtimeAction,
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
return response.json();
};
const normalizePageRuntimeResult = (runtimeResult) => {
if (!runtimeResult || runtimeResult.mode !== "page") {
return null;
}
const stateSnapshot =
runtimeResult.state && runtimeResult.state.mode === "page"
? runtimeResult.state.state
: null;
const pagePatch = Array.isArray(runtimeResult.domPatches)
? runtimeResult.domPatches.find((patch) => patch?.kind === "pageState")
: null;
const focusedId =
typeof pagePatch?.focusedId === "string"
? pagePatch.focusedId
: typeof stateSnapshot?.focusedId === "string"
? stateSnapshot.focusedId
: "";
const expandedIds = Array.isArray(pagePatch?.expandedIds)
? pagePatch.expandedIds
: Array.isArray(stateSnapshot?.expandedIds)
? stateSnapshot.expandedIds
: null;
return {
focusedId: normalizeText(focusedId),
expandedIds: expandedIds ? normalizeStringArray(expandedIds) : null,
hostEvents: Array.isArray(runtimeResult.hostEvents) ? runtimeResult.hostEvents : [],
};
};
const replayPageRuntimeHostEvents = (runtimeResult, item, sourceElement) => {
const result = normalizePageRuntimeResult(runtimeResult);
if (!result) return false;
let replayed = false;
result.hostEvents.forEach((event) => {
if (!event || typeof event !== "object") return;
if (event.kind === "pageOpen") {
const nodeId = normalizeText(event.nodeId);
if (nodeId) {
handleNavigate(nodeId);
replayed = true;
}
return;
}
if (event.kind === "pageContextMenu") {
const nodeId = normalizeText(event.nodeId || item?.nodeId);
if (!nodeId) return;
const rect = sourceElement?.getBoundingClientRect?.();
openContextMenu(
nodeId,
rect ? rect.left + Math.min(rect.width - 12, 28) : 0,
rect ? rect.top + Math.min(rect.height - 12, 18) : 0,
);
replayed = true;
}
});
return replayed;
};
const reconcilePageRuntimeResult = (runtimeResult, item) => {
const result = normalizePageRuntimeResult(runtimeResult);
if (!result) return false;
const previousExpanded = new Set(expanded);
let shouldPatchTree = false;
if (Array.isArray(result.expandedIds)) {
shouldPatchTree = commitPageExpandedIds(result.expandedIds) || shouldPatchTree;
}
if (result.focusedId && result.focusedId !== focusedNodeId) {
focusedNodeId = result.focusedId;
postPageFocusChange(result.focusedId);
shouldPatchTree = true;
}
const itemNodeId = normalizeText(item?.nodeId);
if (itemNodeId && previousExpanded.has(itemNodeId) !== expanded.has(itemNodeId)) {
postPageExpandChange(itemNodeId, expanded.has(itemNodeId));
}
if (shouldPatchTree) {
patchPageTreeAfterRuntimeState(
Array.isArray(result.expandedIds) ? [itemNodeId, ...result.expandedIds] : [itemNodeId],
result.focusedId || focusedNodeId,
);
}
return shouldPatchTree;
};
const applyLocalPageActionFallback = (action, item, sourceElement) => {
if (mode !== "page") return; if (mode !== "page") return;
const actionKind = normalizeText(action?.kind).toLowerCase(); const actionKind = normalizeText(action?.kind).toLowerCase();
if (!pageFocusKeyboardReducerActions.has(actionKind)) { if (!pageFocusKeyboardReducerActions.has(actionKind)) {
@@ -2467,13 +2673,7 @@ fn build_tree_shell_html(
} }
if (actionKind === "expand") { if (actionKind === "expand") {
if (item?.childCount > 0 && !expanded.has(item.nodeId)) { if (item?.childCount > 0 && !expanded.has(item.nodeId)) {
expanded.add(item.nodeId); applyLocalPageExpansionFallback(item.nodeId, true);
postPageExpandChange(item.nodeId, true);
if (usedRustInitialRenderer) {
patchPageTreeExpansionDom(item.nodeId);
} else {
renderTree();
}
focusRowElement(item.nodeId); focusRowElement(item.nodeId);
return; return;
} }
@@ -2485,13 +2685,7 @@ fn build_tree_shell_html(
} }
if (actionKind === "collapse") { if (actionKind === "collapse") {
if (item?.childCount > 0 && expanded.has(item.nodeId)) { if (item?.childCount > 0 && expanded.has(item.nodeId)) {
expanded.delete(item.nodeId); applyLocalPageExpansionFallback(item.nodeId, false);
postPageExpandChange(item.nodeId, false);
if (usedRustInitialRenderer) {
patchPageTreeExpansionDom(item.nodeId);
} else {
renderTree();
}
focusRowElement(item.nodeId); focusRowElement(item.nodeId);
return; return;
} }
@@ -2522,6 +2716,35 @@ fn build_tree_shell_html(
} }
}; };
const applyPageKeyboardAction = (action, item, sourceElement) => {
if (mode !== "page") return;
const actionKind = normalizeText(action?.kind).toLowerCase();
if (!pageFocusKeyboardReducerActions.has(actionKind)) {
return;
}
const runtimeItem = resolvePageActionItem(action, item);
const runtimeAction = buildPageRuntimeAction(action, runtimeItem);
if (!runtimeAction) {
applyLocalPageActionFallback(action, runtimeItem || item, sourceElement);
return;
}
void reducePageActionWithRuntime(action, runtimeItem)
.then((runtimeResult) => {
const replayedHostEvent = replayPageRuntimeHostEvents(
runtimeResult,
runtimeItem,
sourceElement,
);
const reconciledState = reconcilePageRuntimeResult(runtimeResult, runtimeItem);
if (!replayedHostEvent && !reconciledState) {
applyLocalPageActionFallback(action, runtimeItem || item, sourceElement);
}
})
.catch(() => {
applyLocalPageActionFallback(action, runtimeItem || item, sourceElement);
});
};
const postPickerFocusChange = (pickerItemKey) => { const postPickerFocusChange = (pickerItemKey) => {
if (mode !== "picker") return; if (mode !== "picker") return;
const normalizedItemKey = normalizeText(pickerItemKey); const normalizedItemKey = normalizeText(pickerItemKey);
@@ -2976,16 +3199,15 @@ fn build_tree_shell_html(
const action = normalizeText(element.dataset.rustAction); const action = normalizeText(element.dataset.rustAction);
if (action === "toggle") { if (action === "toggle") {
event.preventDefault(); event.preventDefault();
toggleExpand(item.nodeId); applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, element);
} else if (action === "open") { } else if (action === "open") {
handleNavigate(item.nodeId); applyPageKeyboardAction({ kind: "open", nodeId: item.nodeId }, item, element);
} else if (action === "create") { } else if (action === "create") {
void handleCreate(item.nodeId); void handleCreate(item.nodeId);
} else if (action === "rename") { } else if (action === "rename") {
void handleRename(item.nodeId); void handleRename(item.nodeId);
} else if (action === "menu") { } else if (action === "menu") {
const center = getElementCenter(element); applyPageKeyboardAction({ kind: "context_menu", nodeId: item.nodeId }, item, element);
openContextMenu(item.nodeId, center.x, center.y);
} }
}); });
}); });
@@ -3524,7 +3746,7 @@ fn build_tree_shell_html(
toggleButton.textContent = expanded.has(item.nodeId) ? "" : ""; toggleButton.textContent = expanded.has(item.nodeId) ? "" : "";
toggleButton.addEventListener("click", (event) => { toggleButton.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
toggleExpand(item.nodeId); applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, event.currentTarget);
}); });
row.appendChild(toggleButton); row.appendChild(toggleButton);
} else { } else {
@@ -4377,6 +4599,12 @@ pub async fn tree_command(
)) ))
} }
pub async fn reduce_tree_shell_runtime(
Json(body): Json<TreeShellRuntimeRequest>,
) -> Json<TreeShellRuntimeResult> {
Json(reduce_tree_shell_runtime_request(body))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{TreeCommandRequest, create_command_wire}; use super::{TreeCommandRequest, create_command_wire};
@@ -4441,6 +4669,19 @@ mod tests {
assert!(html.contains("tree.shell.state.patch")); assert!(html.contains("tree.shell.state.patch"));
assert!(html.contains("\"contractName\":\"rust_page_focus_keyboard_reducer_v1\"")); assert!(html.contains("\"contractName\":\"rust_page_focus_keyboard_reducer_v1\""));
assert!(html.contains("applyPageKeyboardAction")); assert!(html.contains("applyPageKeyboardAction"));
assert!(html.contains("reducePageActionWithRuntime"));
let apply_page_action_start = html
.find("const applyPageKeyboardAction = (action, item, sourceElement) => {")
.expect("applyPageKeyboardAction should be embedded");
let apply_page_action_end = html[apply_page_action_start..]
.find("\n const postPickerFocusChange")
.expect("applyPageKeyboardAction should end before picker focus handler");
let apply_page_action_body =
&html[apply_page_action_start..apply_page_action_start + apply_page_action_end];
assert!(
!apply_page_action_body.contains("toggleExpand("),
"page keyboard/expand should prefer runtime result instead of directly toggling local expansion state"
);
assert!(html.contains("patchPageTreeActiveDom")); assert!(html.contains("patchPageTreeActiveDom"));
assert!(html.contains("patchPageTreeExpansionDom")); assert!(html.contains("patchPageTreeExpansionDom"));
assert!(html.contains("data-rust-page-renderer=\"initial_v1\"")); assert!(html.contains("data-rust-page-renderer=\"initial_v1\""));
@@ -4550,7 +4791,16 @@ mod tests {
assert!(filetree_html.contains("\"commandDispatcher\"")); assert!(filetree_html.contains("\"commandDispatcher\""));
assert!(filetree_html.contains("\"runtimeArtifact\"")); assert!(filetree_html.contains("\"runtimeArtifact\""));
assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\"")); assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
assert!(filetree_html.contains("\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]")); assert!(filetree_html.contains("\"reduceEndpoint\":\"/api/tree/runtime/reduce\""));
assert!(filetree_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(filetree_html.contains(
"\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""
));
assert!(filetree_html.contains(
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
));
let picker_response = app() let picker_response = app()
.oneshot( .oneshot(
@@ -4571,6 +4821,146 @@ mod tests {
assert!(picker_html.contains("\"excludedPickerIds\":[\"page_root\"]")); assert!(picker_html.contains("\"excludedPickerIds\":[\"page_root\"]"));
assert!(picker_html.contains("\"runtimeArtifact\"")); assert!(picker_html.contains("\"runtimeArtifact\""));
assert!(picker_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\"")); assert!(picker_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
assert!(picker_html.contains("\"reduceEndpoint\":\"/api/tree/runtime/reduce\""));
assert!(picker_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
));
assert!(picker_html.contains(
"\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""
));
}
#[tokio::test]
async fn tree_runtime_reduce_endpoint_returns_filetree_runtime_result() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/runtime/reduce")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mode":"fileTree","requestId":"req-filetree-route","environment":{"visibleRowIds":["doc:root","asset:image"],"rows":[{"rowId":"doc:root","rowKind":"doc","documentId":"root","assetId":null},{"rowId":"asset:image","rowKind":"asset","documentId":"root","assetId":"image"}]},"state":{"selection":{"selectedRowIds":[],"anchorRowId":null,"focusedRowId":null},"dragRowIds":[],"dragEffect":null,"dropTargetRowId":null},"action":{"kind":"selectRow","rowId":"asset:image","modifiers":{"shiftKey":false,"ctrlKey":false,"metaKey":false}}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["mode"], Value::String("fileTree".into()));
assert_eq!(
payload["requestId"],
Value::String("req-filetree-route".into())
);
assert_eq!(
payload["domPatches"][0]["kind"],
Value::String("fileTreeState".into())
);
assert_eq!(
payload["domPatches"][0]["selectedRowIds"][0],
Value::String("asset:image".into())
);
let drop_target_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/runtime/reduce")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mode":"fileTree","requestId":"req-filetree-drop-target-route","environment":{"visibleRowIds":["doc:root","asset:image"],"rows":[{"rowId":"doc:root","rowKind":"doc","documentId":"root","assetId":null},{"rowId":"asset:image","rowKind":"asset","documentId":"root","assetId":"image"}]},"state":{"selection":{"selectedRowIds":[],"anchorRowId":null,"focusedRowId":null},"dragRowIds":[],"dragEffect":null,"dropTargetRowId":null},"action":{"kind":"updateDropTarget","rowId":"asset:image"}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(drop_target_response.status(), StatusCode::OK);
let drop_target_body = axum::body::to_bytes(drop_target_response.into_body(), usize::MAX)
.await
.expect("body");
let drop_target_payload: Value = serde_json::from_slice(&drop_target_body).expect("json");
assert_eq!(
drop_target_payload["domPatches"][0]["dropTargetRowId"],
Value::String("asset:image".into())
);
}
#[tokio::test]
async fn tree_runtime_reduce_endpoint_returns_page_and_picker_runtime_results() {
let page_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/runtime/reduce")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mode":"page","requestId":"req-page-route","environment":{"visibleNodeIds":["doc:root","doc:child"],"expandableNodeIds":["doc:root"]},"state":{"focusedId":"doc:root","expandedIds":[],"dropFeedback":null},"action":{"kind":"moveNext"}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(page_response.status(), StatusCode::OK);
let page_body = axum::body::to_bytes(page_response.into_body(), usize::MAX)
.await
.expect("body");
let page_payload: Value = serde_json::from_slice(&page_body).expect("json");
assert_eq!(page_payload["mode"], Value::String("page".into()));
assert_eq!(
page_payload["requestId"],
Value::String("req-page-route".into())
);
assert_eq!(
page_payload["domPatches"][0]["kind"],
Value::String("pageState".into())
);
assert_eq!(
page_payload["domPatches"][0]["focusedId"],
Value::String("doc:child".into())
);
let picker_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/runtime/reduce")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mode":"picker","requestId":"req-picker-route","environment":{"items":[{"itemKey":"doc:root","documentId":"doc:root","pickable":true},{"itemKey":"doc:child","documentId":"doc:child","pickable":true}],"excludedIds":[],"allowRootPick":false},"state":{"activeItemKey":"doc:child"},"action":{"kind":"pick"}}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(picker_response.status(), StatusCode::OK);
let picker_body = axum::body::to_bytes(picker_response.into_body(), usize::MAX)
.await
.expect("body");
let picker_payload: Value = serde_json::from_slice(&picker_body).expect("json");
assert_eq!(picker_payload["mode"], Value::String("picker".into()));
assert_eq!(
picker_payload["requestId"],
Value::String("req-picker-route".into())
);
assert_eq!(
picker_payload["hostEvents"][0]["kind"],
Value::String("pickerPickDocument".into())
);
assert_eq!(
picker_payload["hostEvents"][0]["documentId"],
Value::String("doc:child".into())
);
} }
#[tokio::test] #[tokio::test]
+2 -2
View File
@@ -1,12 +1,12 @@
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery}; use crate::routes::stream_support::{StreamSnapshotQuery, load_stream_snapshot};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::response::Response; use axum::response::Response;
use futures_util::StreamExt; use futures_util::StreamExt;
use serde_json::{json, Value}; use serde_json::{Value, json};
pub async fn socket( pub async fn socket(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
@@ -1,4 +1,7 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)] use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TreeShellDragEffect { pub enum TreeShellDragEffect {
Copy, Copy,
Move, Move,
@@ -0,0 +1,556 @@
use super::drag_drop_state::{resolve_drag_effect, TreeShellDragEffect};
use super::filetree_selection::{FileTreeSelectionModifiers, FileTreeSelectionState};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeEnvironment {
pub visible_row_ids: Vec<String>,
pub rows: Vec<FileTreeRuntimeRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeRow {
pub row_id: String,
pub row_kind: String,
pub document_id: Option<String>,
pub asset_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeState {
pub selection: FileTreeSelectionState,
pub drag_row_ids: Vec<String>,
pub drag_effect: Option<TreeShellDragEffect>,
pub drop_target_row_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileTreeRuntimeTransition {
pub state: FileTreeRuntimeState,
pub outputs: BTreeSet<FileTreeRuntimeOutput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum FileTreeRuntimeAction {
SelectRow {
row_id: String,
modifiers: FileTreeSelectionModifiers,
},
SelectContextRow {
row_id: String,
},
NormalizeVisibleRows,
ClearSelection,
ResolveDragRows {
row_id: String,
has_external_files: bool,
alt_key: bool,
},
UpdateDropTarget {
row_id: Option<String>,
},
DispatchInternalDrop {
target_row_id: Option<String>,
row_ids: Vec<String>,
copy: bool,
},
DispatchExternalDrop {
target_row_id: Option<String>,
file_count: u32,
},
OpenRow {
row_id: String,
},
ContextMenuRow {
row_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FileTreeRuntimeOutput {
DomPatch,
Intent(FileTreeIntentEvent),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FileTreeIntentEvent {
Open {
target: FileTreeOpenTarget,
},
ContextMenu {
row_id: String,
target: FileTreeOpenTarget,
},
InternalDrop {
target_row_id: Option<String>,
target: Option<FileTreeOpenTarget>,
row_ids: Vec<String>,
copy: bool,
},
ExternalDrop {
target_row_id: Option<String>,
target: Option<FileTreeOpenTarget>,
file_count: u32,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum FileTreeOpenTarget {
Document {
document_id: String,
},
Index {
document_id: String,
},
AssetFolder {
document_id: String,
asset_id: String,
},
Asset {
document_id: String,
asset_id: String,
},
}
impl FileTreeRuntimeState {
pub fn reduce(
&self,
env: &FileTreeRuntimeEnvironment,
action: FileTreeRuntimeAction,
) -> FileTreeRuntimeTransition {
match action {
FileTreeRuntimeAction::SelectRow { row_id, modifiers } => {
let mut state = self.clone();
state.selection =
state
.selection
.select_row(&row_id, &env.visible_row_ids, modifiers);
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::SelectContextRow { row_id } => {
let mut state = self.clone();
state.selection = state.selection.select_context_row(&row_id);
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::NormalizeVisibleRows => {
let mut state = self.clone();
state.selection = state
.selection
.normalize_for_visible_rows(&env.visible_row_ids);
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::ClearSelection => {
let mut state = self.clone();
state.selection = state.selection.clear();
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::ResolveDragRows {
row_id,
has_external_files,
alt_key,
} => {
let mut state = self.clone();
state.drag_row_ids = state
.selection
.resolve_drag_row_ids_for_visible_rows(&row_id, &env.visible_row_ids);
state.drag_effect = Some(resolve_drag_effect(has_external_files, alt_key));
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::UpdateDropTarget { row_id } => {
let mut state = self.clone();
state.drop_target_row_id = row_id;
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id,
row_ids,
copy,
} => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
let row_ids = row_ids
.into_iter()
.filter(|row_id| !row_id.is_empty())
.collect::<Vec<_>>();
if row_ids.is_empty() {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
}
let target = target_row_id
.as_deref()
.and_then(|row_id| resolve_open_target(env, row_id));
let Some(target) = target else {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
};
transition(
state,
[
FileTreeRuntimeOutput::DomPatch,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop {
target_row_id,
target: Some(target),
row_ids,
copy,
}),
],
)
}
FileTreeRuntimeAction::DispatchExternalDrop {
target_row_id,
file_count,
} => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
if file_count == 0 {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
}
let target = target_row_id
.as_deref()
.and_then(|row_id| resolve_open_target(env, row_id));
let Some(target) = target else {
return transition(state, [FileTreeRuntimeOutput::DomPatch]);
};
transition(
state,
[
FileTreeRuntimeOutput::DomPatch,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::ExternalDrop {
target_row_id,
target: Some(target),
file_count,
}),
],
)
}
FileTreeRuntimeAction::OpenRow { row_id } => {
if let Some(target) = resolve_open_target(env, &row_id) {
transition(
self.clone(),
[FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open {
target,
})],
)
} else {
transition(self.clone(), [])
}
}
FileTreeRuntimeAction::ContextMenuRow { row_id } => {
if let Some(target) = resolve_open_target(env, &row_id) {
transition(
self.clone(),
[FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::ContextMenu { row_id, target },
)],
)
} else {
transition(self.clone(), [])
}
}
}
}
}
fn resolve_open_target(
env: &FileTreeRuntimeEnvironment,
row_id: &str,
) -> Option<FileTreeOpenTarget> {
let row_by_id = env
.rows
.iter()
.map(|row| (row.row_id.as_str(), row))
.collect::<BTreeMap<_, _>>();
let row = row_by_id.get(row_id)?;
match row.row_kind.as_str() {
"doc" | "document" => Some(FileTreeOpenTarget::Document {
document_id: row.document_id.clone()?,
}),
"index" => Some(FileTreeOpenTarget::Index {
document_id: row.document_id.clone()?,
}),
"asset-folder" | "asset_folder" => Some(FileTreeOpenTarget::AssetFolder {
document_id: row.document_id.clone()?,
asset_id: row.asset_id.clone()?,
}),
"asset" => Some(FileTreeOpenTarget::Asset {
document_id: row.document_id.clone()?,
asset_id: row.asset_id.clone()?,
}),
_ => None,
}
}
fn transition<const N: usize>(
state: FileTreeRuntimeState,
outputs: [FileTreeRuntimeOutput; N],
) -> FileTreeRuntimeTransition {
FileTreeRuntimeTransition {
state,
outputs: outputs.into_iter().collect(),
}
}
#[cfg(test)]
mod tests {
use super::{
FileTreeIntentEvent, FileTreeOpenTarget, FileTreeRuntimeAction, FileTreeRuntimeEnvironment,
FileTreeRuntimeOutput, FileTreeRuntimeRow, FileTreeRuntimeState,
};
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
use crate::tree_shell::filetree_selection::FileTreeSelectionModifiers;
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn env() -> FileTreeRuntimeEnvironment {
FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "index:root", "asset-folder:mind", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "index:root".into(),
row_kind: "index".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset-folder:mind".into(),
row_kind: "asset-folder".into(),
document_id: Some("root".into()),
asset_id: Some("mind".into()),
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
}
}
#[test]
fn filetree_runtime_reducer_covers_selection_drag_drop_and_open_menu_intents() {
let transition = FileTreeRuntimeState::default().reduce(
&env(),
FileTreeRuntimeAction::SelectRow {
row_id: "doc:root".into(),
modifiers: FileTreeSelectionModifiers::default(),
},
);
assert!(transition
.state
.selection
.selected_row_ids
.contains("doc:root"));
assert!(transition
.outputs
.contains(&FileTreeRuntimeOutput::DomPatch));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::SelectRow {
row_id: "asset:image".into(),
modifiers: FileTreeSelectionModifiers {
shift_key: true,
..FileTreeSelectionModifiers::default()
},
},
);
assert!(transition
.state
.selection
.selected_row_ids
.contains("index:root"));
assert!(transition
.state
.selection
.selected_row_ids
.contains("asset-folder:mind"));
assert!(transition
.state
.selection
.selected_row_ids
.contains("asset:image"));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::ResolveDragRows {
row_id: "asset:image".into(),
has_external_files: false,
alt_key: true,
},
);
assert_eq!(
transition.state.drag_row_ids,
ids(&["doc:root", "index:root", "asset-folder:mind", "asset:image"])
);
assert_eq!(
transition.state.drag_effect,
Some(TreeShellDragEffect::Copy)
);
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::UpdateDropTarget {
row_id: Some("asset-folder:mind".into()),
},
);
assert_eq!(
transition.state.drop_target_row_id.as_deref(),
Some("asset-folder:mind")
);
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::OpenRow {
row_id: "doc:root".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::Document {
document_id: "root".into(),
},
},
)));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::OpenRow {
row_id: "index:root".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::Index {
document_id: "root".into(),
},
},
)));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::OpenRow {
row_id: "asset-folder:mind".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::AssetFolder {
document_id: "root".into(),
asset_id: "mind".into(),
},
},
)));
let transition = transition.state.reduce(
&env(),
FileTreeRuntimeAction::ContextMenuRow {
row_id: "asset:image".into(),
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::ContextMenu {
row_id: "asset:image".into(),
target: FileTreeOpenTarget::Asset {
document_id: "root".into(),
asset_id: "image".into(),
},
},
)));
}
#[test]
fn filetree_runtime_dispatches_drop_intents_and_clears_drag_state() {
let state = FileTreeRuntimeState {
drag_row_ids: ids(&["asset:image"]),
drag_effect: Some(TreeShellDragEffect::Move),
drop_target_row_id: Some("asset-folder:mind".into()),
..FileTreeRuntimeState::default()
};
let transition = state.reduce(
&env(),
FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id: Some("asset-folder:mind".into()),
row_ids: ids(&["asset:image"]),
copy: true,
},
);
assert!(transition.state.drag_row_ids.is_empty());
assert_eq!(transition.state.drag_effect, None);
assert_eq!(transition.state.drop_target_row_id, None);
assert!(transition
.outputs
.contains(&FileTreeRuntimeOutput::DomPatch));
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::InternalDrop {
target_row_id: Some("asset-folder:mind".into()),
target: Some(FileTreeOpenTarget::AssetFolder {
document_id: "root".into(),
asset_id: "mind".into(),
}),
row_ids: ids(&["asset:image"]),
copy: true,
},
)));
let transition = FileTreeRuntimeState::default().reduce(
&env(),
FileTreeRuntimeAction::DispatchExternalDrop {
target_row_id: Some("doc:root".into()),
file_count: 2,
},
);
assert!(transition.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::ExternalDrop {
target_row_id: Some("doc:root".into()),
target: Some(FileTreeOpenTarget::Document {
document_id: "root".into(),
}),
file_count: 2,
},
)));
let rejected = FileTreeRuntimeState::default().reduce(
&env(),
FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id: Some("missing:target".into()),
row_ids: ids(&["asset:image"]),
copy: false,
},
);
assert!(rejected
.outputs
.contains(&FileTreeRuntimeOutput::DomPatch));
assert!(!rejected.outputs.iter().any(|output| matches!(
output,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop { .. })
)));
}
}
@@ -1,4 +1,4 @@
use serde::Serialize; use serde::{Deserialize, Serialize};
use std::collections::BTreeSet; use std::collections::BTreeSet;
pub const FILETREE_SELECTION_REDUCER_CONTRACT_NAME: &str = "rust_filetree_selection_reducer_v1"; pub const FILETREE_SELECTION_REDUCER_CONTRACT_NAME: &str = "rust_filetree_selection_reducer_v1";
@@ -25,7 +25,7 @@ impl Default for FileTreeSelectionReducerContract {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct FileTreeSelectionModifiers { pub struct FileTreeSelectionModifiers {
pub shift_key: bool, pub shift_key: bool,
@@ -33,7 +33,7 @@ pub struct FileTreeSelectionModifiers {
pub meta_key: bool, pub meta_key: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct FileTreeSelectionState { pub struct FileTreeSelectionState {
pub selected_row_ids: BTreeSet<String>, pub selected_row_ids: BTreeSet<String>,
@@ -148,6 +148,26 @@ impl FileTreeSelectionState {
} }
vec![row_id.to_string()] vec![row_id.to_string()]
} }
pub fn resolve_drag_row_ids_for_visible_rows(
&self,
row_id: &str,
visible_row_ids: &[String],
) -> Vec<String> {
if !self.selected_row_ids.contains(row_id) {
return vec![row_id.to_string()];
}
let ordered = visible_row_ids
.iter()
.filter(|visible_row_id| self.selected_row_ids.contains(*visible_row_id))
.cloned()
.collect::<Vec<_>>();
if ordered.is_empty() {
self.selected_row_ids.iter().cloned().collect()
} else {
ordered
}
}
} }
fn range_row_ids(visible_row_ids: &[String], from_id: &str, to_id: &str) -> Vec<String> { fn range_row_ids(visible_row_ids: &[String], from_id: &str, to_id: &str) -> Vec<String> {
@@ -177,12 +197,11 @@ mod tests {
let visible = ids(&["doc:a", "doc:b", "asset:c", "asset:d"]); let visible = ids(&["doc:a", "doc:b", "asset:c", "asset:d"]);
let mut state = FileTreeSelectionState::default(); let mut state = FileTreeSelectionState::default();
state = state.select_row( state = state.select_row("doc:b", &visible, FileTreeSelectionModifiers::default());
"doc:b", assert_eq!(
&visible, state.selected_row_ids,
FileTreeSelectionModifiers::default(), ids(&["doc:b"]).into_iter().collect()
); );
assert_eq!(state.selected_row_ids, ids(&["doc:b"]).into_iter().collect());
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:b")); assert_eq!(state.anchor_row_id.as_deref(), Some("doc:b"));
assert_eq!(state.focused_row_id.as_deref(), Some("doc:b")); assert_eq!(state.focused_row_id.as_deref(), Some("doc:b"));
@@ -211,7 +230,9 @@ mod tests {
); );
assert_eq!( assert_eq!(
state.selected_row_ids, state.selected_row_ids,
ids(&["doc:a", "doc:b", "asset:c", "asset:d"]).into_iter().collect() ids(&["doc:a", "doc:b", "asset:c", "asset:d"])
.into_iter()
.collect()
); );
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a")); assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a"));
assert_eq!(state.focused_row_id.as_deref(), Some("doc:a")); assert_eq!(state.focused_row_id.as_deref(), Some("doc:a"));
@@ -224,12 +245,18 @@ mod tests {
state.focused_row_id = Some("asset:missing".into()); state.focused_row_id = Some("asset:missing".into());
state = state.normalize_for_visible_rows(&visible); state = state.normalize_for_visible_rows(&visible);
assert_eq!(state.selected_row_ids, ids(&["doc:a"]).into_iter().collect()); assert_eq!(
state.selected_row_ids,
ids(&["doc:a"]).into_iter().collect()
);
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a")); assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a"));
assert_eq!(state.focused_row_id, None); assert_eq!(state.focused_row_id, None);
state = state.select_context_row("asset:c"); state = state.select_context_row("asset:c");
assert_eq!(state.selected_row_ids, ids(&["asset:c"]).into_iter().collect()); assert_eq!(
state.selected_row_ids,
ids(&["asset:c"]).into_iter().collect()
);
assert_eq!(state.anchor_row_id.as_deref(), Some("asset:c")); assert_eq!(state.anchor_row_id.as_deref(), Some("asset:c"));
assert_eq!(state.focused_row_id.as_deref(), Some("asset:c")); assert_eq!(state.focused_row_id.as_deref(), Some("asset:c"));
@@ -1,8 +1,7 @@
use serde::Serialize; use serde::Serialize;
use std::collections::BTreeSet; use std::collections::BTreeSet;
pub const PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME: &str = pub const PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME: &str = "rust_page_focus_keyboard_reducer_v1";
"rust_page_focus_keyboard_reducer_v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -24,6 +23,7 @@ impl Default for PageFocusKeyboardReducerContract {
"move_end", "move_end",
"expand", "expand",
"collapse", "collapse",
"toggle",
"open", "open",
"context_menu", "context_menu",
]), ]),
@@ -77,8 +77,8 @@ impl TreeShellFocusState {
.as_deref() .as_deref()
.and_then(|focused_id| visible_ids.iter().position(|id| id == focused_id)) .and_then(|focused_id| visible_ids.iter().position(|id| id == focused_id))
.unwrap_or(0); .unwrap_or(0);
let next_index = (current_index as isize + offset) let next_index =
.clamp(0, (visible_ids.len() - 1) as isize) as usize; (current_index as isize + offset).clamp(0, (visible_ids.len() - 1) as isize) as usize;
Self { Self {
focused_id: Some(visible_ids[next_index].clone()), focused_id: Some(visible_ids[next_index].clone()),
} }
@@ -105,14 +105,23 @@ mod tests {
.normalize(&visible_ids); .normalize(&visible_ids);
assert_eq!(state.focused_id.as_deref(), Some("doc:a")); assert_eq!(state.focused_id.as_deref(), Some("doc:a"));
let state = state.move_next(&visible_ids).move_next(&visible_ids).move_next(&visible_ids); let state = state
.move_next(&visible_ids)
.move_next(&visible_ids)
.move_next(&visible_ids);
assert_eq!(state.focused_id.as_deref(), Some("doc:c")); assert_eq!(state.focused_id.as_deref(), Some("doc:c"));
assert_eq!( assert_eq!(
state.move_previous(&visible_ids).focused_id.as_deref(), state.move_previous(&visible_ids).focused_id.as_deref(),
Some("doc:b") Some("doc:b")
); );
assert_eq!(state.move_home(&visible_ids).focused_id.as_deref(), Some("doc:a")); assert_eq!(
assert_eq!(state.move_end(&visible_ids).focused_id.as_deref(), Some("doc:c")); state.move_home(&visible_ids).focused_id.as_deref(),
Some("doc:a")
);
assert_eq!(
state.move_end(&visible_ids).focused_id.as_deref(),
Some("doc:c")
);
} }
#[test] #[test]
@@ -139,6 +148,7 @@ mod tests {
assert!(contract.actions.contains("move_end")); assert!(contract.actions.contains("move_end"));
assert!(contract.actions.contains("expand")); assert!(contract.actions.contains("expand"));
assert!(contract.actions.contains("collapse")); assert!(contract.actions.contains("collapse"));
assert!(contract.actions.contains("toggle"));
assert!(contract.actions.contains("open")); assert!(contract.actions.contains("open"));
assert!(contract.actions.contains("context_menu")); assert!(contract.actions.contains("context_menu"));
} }
+2 -1
View File
@@ -1,6 +1,6 @@
pub mod action_registry; pub mod action_registry;
pub mod drag_drop_state;
pub mod dispatcher; pub mod dispatcher;
pub mod drag_drop_state;
pub mod expansion_state; pub mod expansion_state;
pub mod filetree_renderer; pub mod filetree_renderer;
pub mod filetree_runtime; pub mod filetree_runtime;
@@ -15,6 +15,7 @@ pub mod picker_runtime;
pub mod picker_state; pub mod picker_state;
pub mod protocol; pub mod protocol;
pub mod renderer_input; pub mod renderer_input;
pub mod runtime_api;
pub mod state; pub mod state;
use leptos::prelude::*; use leptos::prelude::*;
@@ -115,9 +115,8 @@ fn render_page_row(
} }
pub fn render_initial_page_tree_html(input: &PageTreeInitialRenderInput) -> String { pub fn render_initial_page_tree_html(input: &PageTreeInitialRenderInput) -> String {
let mut html = String::from( let mut html =
r#"<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">"#, String::from(r#"<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">"#);
);
if input.rows.is_empty() { if input.rows.is_empty() {
html.push_str( html.push_str(
r#"<li class="tree-empty" data-rust-rendered-row="page-empty">当前 projection 没有可渲染的页面。</li>"#, r#"<li class="tree-empty" data-rust-rendered-row="page-empty">当前 projection 没有可渲染的页面。</li>"#,
@@ -0,0 +1,589 @@
use super::focus_state::TreeShellFocusState;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeRuntimeEnvironment {
pub visible_node_ids: Vec<String>,
pub expandable_node_ids: BTreeSet<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rows: Vec<PageTreeRuntimeRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeRuntimeRow {
pub node_id: String,
pub parent_node_id: Option<String>,
pub position: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeRuntimeState {
pub focused_id: Option<String>,
pub expanded_ids: BTreeSet<String>,
pub drop_feedback: Option<PageTreeDropFeedback>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageTreeRuntimeTransition {
pub state: PageTreeRuntimeState,
pub outputs: BTreeSet<PageTreeRuntimeOutput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum PageTreeRuntimeAction {
Normalize,
Focus {
node_id: String,
},
MoveNext,
MovePrevious,
MoveHome,
MoveEnd,
Expand {
node_id: String,
},
Collapse {
node_id: String,
},
Toggle {
node_id: String,
},
OpenFocused,
ContextMenuFocused,
DispatchCreate {
parent_node_id: Option<String>,
},
DispatchRename {
node_id: String,
title: String,
},
UpdateDropFeedback {
feedback: Option<PageTreeDropFeedback>,
},
UpdateDropFeedbackForTarget {
source_node_id: String,
target_node_id: String,
position: PageTreeDropPosition,
},
DispatchMove {
source_node_id: String,
target_parent_id: Option<String>,
position: PageTreeDropPosition,
},
DispatchMoveToTarget {
source_node_id: String,
target_node_id: String,
position: PageTreeDropPosition,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PageTreeRuntimeOutput {
DomPatch,
Intent(PageTreeIntentEvent),
CommandDispatch(PageTreeCommandDispatchEvent),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PageTreeIntentEvent {
Open { node_id: String },
ContextMenu { node_id: String },
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PageTreeCommandDispatchEvent {
CreateNode {
command_name: &'static str,
parent_node_id: Option<String>,
},
RenameNode {
command_name: &'static str,
node_id: String,
title: String,
},
MoveSubtree {
command_name: &'static str,
source_node_id: String,
target_node_id: Option<String>,
target_parent_id: Option<String>,
position: PageTreeDropPosition,
sort_order: Option<i64>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PageTreeDropFeedback {
pub source_node_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_node_id: Option<String>,
pub target_parent_id: Option<String>,
pub position: PageTreeDropPosition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PageTreeDropPosition {
Before,
Inside,
After,
}
impl PageTreeRuntimeState {
pub fn reduce(
&self,
env: &PageTreeRuntimeEnvironment,
action: PageTreeRuntimeAction,
) -> PageTreeRuntimeTransition {
match action {
PageTreeRuntimeAction::Normalize => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.normalize(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::Focus { node_id } => {
let focused_id = env
.visible_node_ids
.iter()
.any(|visible_id| visible_id == &node_id)
.then_some(node_id);
self.with_focus(focused_id)
}
PageTreeRuntimeAction::MoveNext => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.move_next(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::MovePrevious => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.move_previous(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::MoveHome => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.move_home(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::MoveEnd => self.with_focus(
TreeShellFocusState {
focused_id: self.focused_id.clone(),
}
.move_end(&env.visible_node_ids)
.focused_id,
),
PageTreeRuntimeAction::Expand { node_id } => self.with_expansion(env, node_id, true),
PageTreeRuntimeAction::Collapse { node_id } => self.with_expansion(env, node_id, false),
PageTreeRuntimeAction::Toggle { node_id } => {
let expanded = !self.expanded_ids.contains(&node_id);
self.with_expansion(env, node_id, expanded)
}
PageTreeRuntimeAction::OpenFocused => {
self.with_focused_intent(PageTreeIntentEvent::Open {
node_id: self.focused_id.clone().unwrap_or_default(),
})
}
PageTreeRuntimeAction::ContextMenuFocused => {
self.with_focused_intent(PageTreeIntentEvent::ContextMenu {
node_id: self.focused_id.clone().unwrap_or_default(),
})
}
PageTreeRuntimeAction::DispatchCreate { parent_node_id } => transition(
self.clone(),
[PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::CreateNode {
command_name: "tree.node.create",
parent_node_id,
},
)],
),
PageTreeRuntimeAction::DispatchRename { node_id, title } => {
let title = title.trim().to_string();
if node_id.trim().is_empty() || title.is_empty() {
return transition(self.clone(), []);
}
transition(
self.clone(),
[PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::RenameNode {
command_name: "tree.node.rename",
node_id,
title,
},
)],
)
}
PageTreeRuntimeAction::UpdateDropFeedback { feedback } => {
let mut state = self.clone();
state.drop_feedback = feedback;
transition(state, [PageTreeRuntimeOutput::DomPatch])
}
PageTreeRuntimeAction::UpdateDropFeedbackForTarget {
source_node_id,
target_node_id,
position,
} => {
let mut state = self.clone();
state.drop_feedback =
resolve_drop_feedback(env, &source_node_id, &target_node_id, position);
transition(state, [PageTreeRuntimeOutput::DomPatch])
}
PageTreeRuntimeAction::DispatchMove {
source_node_id,
target_parent_id,
position,
} => transition(
self.clone(),
[PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name: "tree.subtree.move",
source_node_id,
target_node_id: None,
target_parent_id,
position,
sort_order: None,
},
)],
),
PageTreeRuntimeAction::DispatchMoveToTarget {
source_node_id,
target_node_id,
position,
} => {
let Some(resolved_drop) =
resolve_drop_target(env, &source_node_id, &target_node_id, position)
else {
return transition(self.clone(), []);
};
transition(
self.clone(),
[PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name: "tree.subtree.move",
source_node_id,
target_node_id: Some(target_node_id),
target_parent_id: resolved_drop.target_parent_id,
position,
sort_order: Some(resolved_drop.sort_order),
},
)],
)
}
}
}
fn with_focus(&self, focused_id: Option<String>) -> PageTreeRuntimeTransition {
let mut state = self.clone();
state.focused_id = focused_id;
transition(state, [PageTreeRuntimeOutput::DomPatch])
}
fn with_expansion(
&self,
env: &PageTreeRuntimeEnvironment,
node_id: String,
expanded: bool,
) -> PageTreeRuntimeTransition {
let mut state = self.clone();
if env.expandable_node_ids.contains(&node_id) {
if expanded {
state.expanded_ids.insert(node_id);
} else {
state.expanded_ids.remove(&node_id);
}
}
transition(state, [PageTreeRuntimeOutput::DomPatch])
}
fn with_focused_intent(&self, intent: PageTreeIntentEvent) -> PageTreeRuntimeTransition {
let has_focus = !match &intent {
PageTreeIntentEvent::Open { node_id } => node_id,
PageTreeIntentEvent::ContextMenu { node_id } => node_id,
}
.is_empty();
if has_focus {
transition(self.clone(), [PageTreeRuntimeOutput::Intent(intent)])
} else {
transition(self.clone(), [])
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedPageDropTarget {
target_parent_id: Option<String>,
sort_order: i64,
}
fn resolve_drop_feedback(
env: &PageTreeRuntimeEnvironment,
source_node_id: &str,
target_node_id: &str,
position: PageTreeDropPosition,
) -> Option<PageTreeDropFeedback> {
let resolved = resolve_drop_target(env, source_node_id, target_node_id, position)?;
Some(PageTreeDropFeedback {
source_node_id: source_node_id.to_string(),
target_node_id: Some(target_node_id.to_string()),
target_parent_id: resolved.target_parent_id,
position,
})
}
fn resolve_drop_target(
env: &PageTreeRuntimeEnvironment,
source_node_id: &str,
target_node_id: &str,
position: PageTreeDropPosition,
) -> Option<ResolvedPageDropTarget> {
let source_node_id = source_node_id.trim();
let target_node_id = target_node_id.trim();
if source_node_id.is_empty() || target_node_id.is_empty() || source_node_id == target_node_id {
return None;
}
let source_row = env.rows.iter().find(|row| row.node_id == source_node_id)?;
let target_row = env.rows.iter().find(|row| row.node_id == target_node_id)?;
if source_row.parent_node_id != target_row.parent_node_id {
return None;
}
let mut siblings = env
.rows
.iter()
.filter(|row| row.parent_node_id == target_row.parent_node_id)
.collect::<Vec<_>>();
siblings.sort_by(|left, right| {
left.position
.cmp(&right.position)
.then_with(|| left.node_id.cmp(&right.node_id))
});
let target_index = siblings
.iter()
.position(|row| row.node_id == target_row.node_id)? as i64;
let sort_order = match position {
PageTreeDropPosition::Before | PageTreeDropPosition::Inside => target_index,
PageTreeDropPosition::After => target_index + 1,
};
Some(ResolvedPageDropTarget {
target_parent_id: target_row.parent_node_id.clone(),
sort_order,
})
}
fn transition<const N: usize>(
state: PageTreeRuntimeState,
outputs: [PageTreeRuntimeOutput; N],
) -> PageTreeRuntimeTransition {
PageTreeRuntimeTransition {
state,
outputs: outputs.into_iter().collect(),
}
}
#[cfg(test)]
mod tests {
use super::{
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition,
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment, PageTreeRuntimeRow,
PageTreeRuntimeOutput, PageTreeRuntimeState,
};
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn page_tree_runtime_reducer_normalizes_focus_expansion_intents_and_drag_move() {
let env = PageTreeRuntimeEnvironment {
visible_node_ids: ids(&["doc:root", "doc:child", "doc:sibling"]),
expandable_node_ids: ids(&["doc:root"]).into_iter().collect(),
rows: vec![
PageTreeRuntimeRow {
node_id: "doc:root".into(),
parent_node_id: None,
position: 0,
},
PageTreeRuntimeRow {
node_id: "doc:child".into(),
parent_node_id: Some("doc:root".into()),
position: 0,
},
PageTreeRuntimeRow {
node_id: "doc:sibling".into(),
parent_node_id: Some("doc:root".into()),
position: 1,
},
],
};
let state = PageTreeRuntimeState {
focused_id: Some("doc:missing".into()),
..PageTreeRuntimeState::default()
};
let transition = state.reduce(&env, PageTreeRuntimeAction::Normalize);
assert_eq!(transition.state.focused_id.as_deref(), Some("doc:root"));
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::DomPatch));
let transition = transition
.state
.reduce(&env, PageTreeRuntimeAction::MoveNext);
assert_eq!(transition.state.focused_id.as_deref(), Some("doc:child"));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::Expand {
node_id: "doc:root".into(),
},
);
assert!(transition.state.expanded_ids.contains("doc:root"));
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::DomPatch));
let transition = transition
.state
.reduce(&env, PageTreeRuntimeAction::OpenFocused);
assert!(transition.outputs.contains(&PageTreeRuntimeOutput::Intent(
PageTreeIntentEvent::Open {
node_id: "doc:child".into(),
},
)));
let transition = transition
.state
.reduce(&env, PageTreeRuntimeAction::ContextMenuFocused);
assert!(transition.outputs.contains(&PageTreeRuntimeOutput::Intent(
PageTreeIntentEvent::ContextMenu {
node_id: "doc:child".into(),
},
)));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::UpdateDropFeedback {
feedback: Some(PageTreeDropFeedback {
source_node_id: "doc:child".into(),
target_node_id: Some("doc:sibling".into()),
target_parent_id: Some("doc:root".into()),
position: PageTreeDropPosition::Inside,
}),
},
);
assert_eq!(
transition
.state
.drop_feedback
.as_ref()
.map(|feedback| feedback.position),
Some(PageTreeDropPosition::Inside)
);
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::UpdateDropFeedbackForTarget {
source_node_id: "doc:child".into(),
target_node_id: "doc:sibling".into(),
position: PageTreeDropPosition::Before,
},
);
assert_eq!(
transition
.state
.drop_feedback
.as_ref()
.and_then(|feedback| feedback.target_node_id.as_deref()),
Some("doc:sibling")
);
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::DispatchMove {
source_node_id: "doc:child".into(),
target_parent_id: Some("doc:root".into()),
position: PageTreeDropPosition::After,
},
);
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name: "tree.subtree.move",
source_node_id: "doc:child".into(),
target_node_id: None,
target_parent_id: Some("doc:root".into()),
position: PageTreeDropPosition::After,
sort_order: None,
},
)));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::DispatchMoveToTarget {
source_node_id: "doc:child".into(),
target_node_id: "doc:sibling".into(),
position: PageTreeDropPosition::Before,
},
);
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name: "tree.subtree.move",
source_node_id: "doc:child".into(),
target_node_id: Some("doc:sibling".into()),
target_parent_id: Some("doc:root".into()),
position: PageTreeDropPosition::Before,
sort_order: Some(1),
},
)));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::DispatchCreate {
parent_node_id: Some("doc:root".into()),
},
);
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::CreateNode {
command_name: "tree.node.create",
parent_node_id: Some("doc:root".into()),
},
)));
let transition = transition.state.reduce(
&env,
PageTreeRuntimeAction::DispatchRename {
node_id: "doc:child".into(),
title: "新标题".into(),
},
);
assert!(transition
.outputs
.contains(&PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::RenameNode {
command_name: "tree.node.rename",
node_id: "doc:child".into(),
title: "新标题".into(),
},
)));
}
}
@@ -174,7 +174,8 @@ mod tests {
let html = render_initial_picker_html(&PickerInitialRenderInput { let html = render_initial_picker_html(&PickerInitialRenderInput {
allow_root_pick: true, allow_root_pick: true,
root_active: false, root_active: false,
rows: vec![PickerRenderRow { rows: vec![
PickerRenderRow {
node_id: "page_root".into(), node_id: "page_root".into(),
parent_node_id: None, parent_node_id: None,
title: "首页 <安全>".into(), title: "首页 <安全>".into(),
@@ -182,7 +183,8 @@ mod tests {
expandable: false, expandable: false,
expanded: false, expanded: false,
active: true, active: true,
}, PickerRenderRow { },
PickerRenderRow {
node_id: "page_other".into(), node_id: "page_other".into(),
parent_node_id: None, parent_node_id: None,
title: "其他页面".into(), title: "其他页面".into(),
@@ -190,7 +192,8 @@ mod tests {
expandable: false, expandable: false,
expanded: false, expanded: false,
active: false, active: false,
}], },
],
}); });
assert!(html.contains("data-rust-picker-renderer=\"initial_v1\"")); assert!(html.contains("data-rust-picker-renderer=\"initial_v1\""));
@@ -0,0 +1,277 @@
use super::picker_state::{PickerItem, PickerState};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PickerRuntimeEnvironment {
pub items: Vec<PickerRuntimeItem>,
pub excluded_ids: BTreeSet<String>,
pub allow_root_pick: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PickerRuntimeItem {
pub item_key: String,
pub document_id: Option<String>,
pub pickable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PickerRuntimeState {
pub active_item_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerRuntimeTransition {
pub state: PickerRuntimeState,
pub outputs: BTreeSet<PickerRuntimeOutput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum PickerRuntimeAction {
Normalize,
Focus { item_key: String, focus_dom: bool },
Next,
Previous,
Home,
End,
Pick,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PickerRuntimeOutput {
DomPatch { focus_dom: bool },
Pick(PickerRuntimePickTarget),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum PickerRuntimePickTarget {
Root,
Document { document_id: String },
}
impl PickerRuntimeState {
pub fn reduce(
&self,
env: &PickerRuntimeEnvironment,
action: PickerRuntimeAction,
) -> PickerRuntimeTransition {
match action {
PickerRuntimeAction::Normalize => self.with_picker_state(
env,
to_picker_state(self).normalize(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::Focus {
item_key,
focus_dom,
} => {
if is_pickable_item_key(env, &item_key) {
let mut state = self.clone();
state.active_item_key = Some(item_key);
transition(state, [PickerRuntimeOutput::DomPatch { focus_dom }])
} else {
transition(self.clone(), [])
}
}
PickerRuntimeAction::Next => self.with_picker_state(
env,
to_picker_state(self).move_next(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::Previous => self.with_picker_state(
env,
to_picker_state(self).move_previous(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::Home => self.with_picker_state(
env,
to_picker_state(self).move_home(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::End => self.with_picker_state(
env,
to_picker_state(self).move_end(&picker_items(env), &env.excluded_ids),
false,
),
PickerRuntimeAction::Pick => {
if self.active_item_key.as_deref() == Some("__root__") && env.allow_root_pick {
return transition(
self.clone(),
[PickerRuntimeOutput::Pick(PickerRuntimePickTarget::Root)],
);
}
let items = picker_items(env);
if let Some(document_id) = to_picker_state(self).pick(&items, &env.excluded_ids) {
transition(
self.clone(),
[PickerRuntimeOutput::Pick(
PickerRuntimePickTarget::Document { document_id },
)],
)
} else {
transition(self.clone(), [])
}
}
}
}
fn with_picker_state(
&self,
_env: &PickerRuntimeEnvironment,
picker_state: PickerState,
focus_dom: bool,
) -> PickerRuntimeTransition {
transition(
PickerRuntimeState {
active_item_key: picker_state.active_item_key,
},
[PickerRuntimeOutput::DomPatch { focus_dom }],
)
}
}
fn picker_items(env: &PickerRuntimeEnvironment) -> Vec<PickerItem> {
env.items
.iter()
.filter(|item| item.item_key != "__root__" || env.allow_root_pick)
.map(|item| PickerItem {
item_key: item.item_key.clone(),
document_id: item.document_id.clone(),
pickable: item.pickable,
})
.collect()
}
fn to_picker_state(state: &PickerRuntimeState) -> PickerState {
PickerState {
active_item_key: state.active_item_key.clone(),
}
}
fn is_pickable_item_key(env: &PickerRuntimeEnvironment, item_key: &str) -> bool {
picker_items(env).iter().any(|item| {
item.pickable
&& item.item_key == item_key
&& item
.document_id
.as_ref()
.is_none_or(|document_id| !env.excluded_ids.contains(document_id))
})
}
fn transition<const N: usize>(
state: PickerRuntimeState,
outputs: [PickerRuntimeOutput; N],
) -> PickerRuntimeTransition {
PickerRuntimeTransition {
state,
outputs: outputs.into_iter().collect(),
}
}
#[cfg(test)]
mod tests {
use super::{
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeItem, PickerRuntimeOutput,
PickerRuntimePickTarget, PickerRuntimeState,
};
use std::collections::BTreeSet;
fn excluded(values: &[&str]) -> BTreeSet<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn item(item_key: &str, document_id: Option<&str>, pickable: bool) -> PickerRuntimeItem {
PickerRuntimeItem {
item_key: item_key.into(),
document_id: document_id.map(ToOwned::to_owned),
pickable,
}
}
fn env() -> PickerRuntimeEnvironment {
PickerRuntimeEnvironment {
items: vec![
item("__root__", None, true),
item("doc:a", Some("doc:a"), true),
item("doc:hidden", Some("doc:hidden"), true),
item("doc:c", Some("doc:c"), true),
],
excluded_ids: excluded(&["doc:hidden"]),
allow_root_pick: true,
}
}
#[test]
fn picker_runtime_reducer_covers_navigation_pick_and_search_focus_boundary() {
let state = PickerRuntimeState {
active_item_key: Some("doc:hidden".into()),
};
let transition = state.reduce(&env(), PickerRuntimeAction::Normalize);
assert_eq!(
transition.state.active_item_key.as_deref(),
Some("__root__")
);
assert!(transition
.outputs
.contains(&PickerRuntimeOutput::DomPatch { focus_dom: false }));
let transition = transition.state.reduce(&env(), PickerRuntimeAction::Next);
assert_eq!(transition.state.active_item_key.as_deref(), Some("doc:a"));
assert!(transition
.outputs
.contains(&PickerRuntimeOutput::DomPatch { focus_dom: false }));
let transition = transition.state.reduce(&env(), PickerRuntimeAction::End);
assert_eq!(transition.state.active_item_key.as_deref(), Some("doc:c"));
let transition = transition.state.reduce(
&env(),
PickerRuntimeAction::Focus {
item_key: "__root__".into(),
focus_dom: true,
},
);
assert_eq!(
transition.state.active_item_key.as_deref(),
Some("__root__")
);
assert!(transition
.outputs
.contains(&PickerRuntimeOutput::DomPatch { focus_dom: true }));
let transition = transition.state.reduce(&env(), PickerRuntimeAction::Pick);
assert!(transition
.outputs
.contains(&PickerRuntimeOutput::Pick(PickerRuntimePickTarget::Root,)));
let transition = transition.state.reduce(
&env(),
PickerRuntimeAction::Focus {
item_key: "doc:c".into(),
focus_dom: true,
},
);
let transition = transition.state.reduce(&env(), PickerRuntimeAction::Pick);
assert!(transition.outputs.contains(&PickerRuntimeOutput::Pick(
PickerRuntimePickTarget::Document {
document_id: "doc:c".into(),
},
)));
}
}
@@ -67,7 +67,9 @@ impl PickerState {
pub fn move_end(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self { pub fn move_end(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
Self { Self {
active_item_key: pickable_items(items, excluded_ids).last().map(|item| item.item_key.clone()), active_item_key: pickable_items(items, excluded_ids)
.last()
.map(|item| item.item_key.clone()),
} }
} }
@@ -78,7 +80,12 @@ impl PickerState {
.and_then(|item| item.document_id.clone()) .and_then(|item| item.document_id.clone())
} }
fn move_by(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>, offset: isize) -> Self { fn move_by(
&self,
items: &[PickerItem],
excluded_ids: &BTreeSet<String>,
offset: isize,
) -> Self {
let pickable = pickable_items(items, excluded_ids).collect::<Vec<_>>(); let pickable = pickable_items(items, excluded_ids).collect::<Vec<_>>();
if pickable.is_empty() { if pickable.is_empty() {
return Self::default(); return Self::default();
@@ -88,8 +95,8 @@ impl PickerState {
.as_deref() .as_deref()
.and_then(|active| pickable.iter().position(|item| item.item_key == active)) .and_then(|active| pickable.iter().position(|item| item.item_key == active))
.unwrap_or(0); .unwrap_or(0);
let next_index = (current_index as isize + offset) let next_index =
.clamp(0, (pickable.len() - 1) as isize) as usize; (current_index as isize + offset).clamp(0, (pickable.len() - 1) as isize) as usize;
Self { Self {
active_item_key: Some(pickable[next_index].item_key.clone()), active_item_key: Some(pickable[next_index].item_key.clone()),
} }
@@ -111,8 +118,13 @@ fn pickable_items<'a>(
.filter(move |item| item.pickable && !is_item_excluded(item, excluded_ids)) .filter(move |item| item.pickable && !is_item_excluded(item, excluded_ids))
} }
fn first_pickable_item_key(items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Option<String> { fn first_pickable_item_key(
pickable_items(items, excluded_ids).next().map(|item| item.item_key.clone()) items: &[PickerItem],
excluded_ids: &BTreeSet<String>,
) -> Option<String> {
pickable_items(items, excluded_ids)
.next()
.map(|item| item.item_key.clone())
} }
fn is_pickable_item_key( fn is_pickable_item_key(
@@ -158,7 +170,9 @@ mod tests {
.normalize(&items, &excluded_ids); .normalize(&items, &excluded_ids);
assert_eq!(state.active_item_key.as_deref(), Some("root")); assert_eq!(state.active_item_key.as_deref(), Some("root"));
let state = state.move_next(&items, &excluded_ids).move_next(&items, &excluded_ids); let state = state
.move_next(&items, &excluded_ids)
.move_next(&items, &excluded_ids);
assert_eq!(state.active_item_key.as_deref(), Some("doc:c")); assert_eq!(state.active_item_key.as_deref(), Some("doc:c"));
assert_eq!(state.pick(&items, &excluded_ids).as_deref(), Some("doc:c")); assert_eq!(state.pick(&items, &excluded_ids).as_deref(), Some("doc:c"));
@@ -177,7 +191,10 @@ mod tests {
let state = PickerState::default().move_end(&items, &excluded_ids); let state = PickerState::default().move_end(&items, &excluded_ids);
assert_eq!(state.active_item_key.as_deref(), Some("doc:c")); assert_eq!(state.active_item_key.as_deref(), Some("doc:c"));
assert_eq!( assert_eq!(
state.move_home(&items, &excluded_ids).active_item_key.as_deref(), state
.move_home(&items, &excluded_ids)
.active_item_key
.as_deref(),
Some("doc:a") Some("doc:a")
); );
@@ -40,15 +40,40 @@ pub struct TreeShellCommandDispatcher {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TreeShellRuntimeArtifactBoundary { pub struct TreeShellRuntimeArtifactBoundary {
pub contract_name: &'static str, pub contract_name: &'static str,
pub family: &'static str,
pub version: u8,
pub execution_strategy: &'static str,
pub browser_bridge: &'static str,
pub wasm_module_url: Option<&'static str>,
pub js_glue_url: Option<&'static str>,
pub input_fields: BTreeSet<&'static str>, pub input_fields: BTreeSet<&'static str>,
pub output_channels: BTreeSet<&'static str>, pub output_channels: BTreeSet<&'static str>,
pub event_kinds: BTreeSet<&'static str>, pub event_kinds: BTreeSet<&'static str>,
pub runtime_api: TreeShellRuntimeApiBoundary,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeShellRuntimeApiBoundary {
pub request_contract: &'static str,
pub result_contract: &'static str,
pub reduce_endpoint: &'static str,
pub state_snapshots: BTreeSet<&'static str>,
pub dom_patch_kinds: BTreeSet<&'static str>,
pub host_event_kinds: BTreeSet<&'static str>,
pub command_event_kinds: BTreeSet<&'static str>,
} }
impl Default for TreeShellRuntimeArtifactBoundary { impl Default for TreeShellRuntimeArtifactBoundary {
fn default() -> Self { fn default() -> Self {
Self { Self {
contract_name: "rust_tree_shell_runtime_artifact_v1", contract_name: "rust_tree_shell_runtime_artifact_v1",
family: "rust_family",
version: 1,
execution_strategy: "browser_bridge",
browser_bridge: "iframe_srcdoc",
wasm_module_url: Some("/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm"),
js_glue_url: Some("/api/tree-shell-runtime/mnote-tree-shell-runtime.js"),
input_fields: BTreeSet::from([ input_fields: BTreeSet::from([
"rendererInput", "rendererInput",
"projectionItems", "projectionItems",
@@ -67,6 +92,31 @@ impl Default for TreeShellRuntimeArtifactBoundary {
"dragDrop", "dragDrop",
"pick", "pick",
]), ]),
runtime_api: TreeShellRuntimeApiBoundary {
request_contract: "TreeShellRuntimeRequest",
result_contract: "TreeShellRuntimeResult",
reduce_endpoint: "/api/tree/runtime/reduce",
state_snapshots: BTreeSet::from(["page", "fileTree", "picker"]),
dom_patch_kinds: BTreeSet::from(["pageState", "fileTreeState", "pickerState"]),
host_event_kinds: BTreeSet::from([
"pageOpen",
"pageContextMenu",
"fileTreeOpen",
"fileTreeContextMenu",
"fileTreeInternalDrop",
"fileTreeExternalDrop",
"pickerPickRoot",
"pickerPickDocument",
]),
command_event_kinds: BTreeSet::from([
"createNode",
"renameNode",
"moveSubtree",
"copyResource",
"moveResource",
"uploadResource",
]),
},
} }
} }
} }
@@ -197,10 +247,12 @@ mod tests {
}); });
assert_eq!(filetree.mode, TreeShellRendererMode::FileTree); assert_eq!(filetree.mode, TreeShellRendererMode::FileTree);
assert_eq!(filetree.focused_id.as_deref(), Some("asset:a")); assert_eq!(filetree.focused_id.as_deref(), Some("asset:a"));
assert!(filetree assert!(
filetree
.filetree_selection .filetree_selection
.selected_row_ids .selected_row_ids
.contains("asset:a")); .contains("asset:a")
);
assert!(filetree.page_focus_keyboard_reducer.is_none()); assert!(filetree.page_focus_keyboard_reducer.is_none());
assert_eq!( assert_eq!(
filetree filetree
@@ -245,6 +297,18 @@ mod tests {
artifact.contract_name, artifact.contract_name,
"rust_tree_shell_runtime_artifact_v1" "rust_tree_shell_runtime_artifact_v1"
); );
assert_eq!(artifact.family, "rust_family");
assert_eq!(artifact.version, 1);
assert_eq!(artifact.execution_strategy, "browser_bridge");
assert_eq!(artifact.browser_bridge, "iframe_srcdoc");
assert_eq!(
artifact.wasm_module_url,
Some("/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm")
);
assert_eq!(
artifact.js_glue_url,
Some("/api/tree-shell-runtime/mnote-tree-shell-runtime.js")
);
assert!(artifact.input_fields.contains("rendererInput")); assert!(artifact.input_fields.contains("rendererInput"));
assert!(artifact.input_fields.contains("projectionItems")); assert!(artifact.input_fields.contains("projectionItems"));
assert!(artifact.input_fields.contains("expandedIds")); assert!(artifact.input_fields.contains("expandedIds"));
@@ -254,6 +318,90 @@ mod tests {
assert!(artifact.output_channels.contains("domPatch")); assert!(artifact.output_channels.contains("domPatch"));
assert!(artifact.output_channels.contains("intentEvent")); assert!(artifact.output_channels.contains("intentEvent"));
assert!(artifact.output_channels.contains("commandDispatchEvent")); assert!(artifact.output_channels.contains("commandDispatchEvent"));
assert_eq!(
artifact.runtime_api.request_contract,
"TreeShellRuntimeRequest"
);
assert_eq!(
artifact.runtime_api.result_contract,
"TreeShellRuntimeResult"
);
assert_eq!(
artifact.runtime_api.reduce_endpoint,
"/api/tree/runtime/reduce"
);
assert!(artifact.runtime_api.state_snapshots.contains("page"));
assert!(artifact.runtime_api.state_snapshots.contains("fileTree"));
assert!(artifact.runtime_api.state_snapshots.contains("picker"));
assert!(artifact.runtime_api.dom_patch_kinds.contains("pageState"));
assert!(
artifact
.runtime_api
.dom_patch_kinds
.contains("fileTreeState")
);
assert!(artifact.runtime_api.dom_patch_kinds.contains("pickerState"));
assert!(artifact.runtime_api.host_event_kinds.contains("pageOpen"));
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("fileTreeOpen")
);
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("fileTreeInternalDrop")
);
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("fileTreeExternalDrop")
);
assert!(
artifact
.runtime_api
.host_event_kinds
.contains("pickerPickDocument")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("createNode")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("renameNode")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("moveSubtree")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("copyResource")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("moveResource")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("uploadResource")
);
assert!(artifact.event_kinds.contains("focus")); assert!(artifact.event_kinds.contains("focus"));
assert!(artifact.event_kinds.contains("keyboard")); assert!(artifact.event_kinds.contains("keyboard"));
assert!(artifact.event_kinds.contains("expandCollapse")); assert!(artifact.event_kinds.contains("expandCollapse"));
@@ -0,0 +1,880 @@
use super::drag_drop_state::TreeShellDragEffect;
use super::filetree_runtime::{
FileTreeIntentEvent, FileTreeRuntimeAction, FileTreeRuntimeEnvironment, FileTreeRuntimeOutput,
FileTreeRuntimeState,
};
use super::page_runtime::{
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition,
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment,
PageTreeRuntimeOutput, PageTreeRuntimeState,
};
use super::picker_runtime::{
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeOutput, PickerRuntimePickTarget,
PickerRuntimeState,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TreeShellRuntimeMode {
Page,
FileTree,
Picker,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "mode",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum TreeShellRuntimeRequest {
Page {
request_id: String,
environment: PageTreeRuntimeEnvironment,
state: PageTreeRuntimeState,
action: PageTreeRuntimeAction,
},
FileTree {
request_id: String,
environment: FileTreeRuntimeEnvironment,
state: FileTreeRuntimeState,
action: FileTreeRuntimeAction,
},
Picker {
request_id: String,
environment: PickerRuntimeEnvironment,
state: PickerRuntimeState,
action: PickerRuntimeAction,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeShellRuntimeResult {
pub request_id: String,
pub mode: TreeShellRuntimeMode,
pub state: TreeShellRuntimeStateSnapshot,
pub dom_patches: Vec<TreeShellDomPatch>,
pub host_events: Vec<TreeShellHostEvent>,
pub command_events: Vec<TreeShellCommandEvent>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", content = "state", rename_all = "camelCase")]
pub enum TreeShellRuntimeStateSnapshot {
Page(PageTreeRuntimeState),
FileTree(FileTreeRuntimeState),
Picker(PickerRuntimeState),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum TreeShellDomPatch {
PageState {
focused_id: Option<String>,
expanded_ids: BTreeSet<String>,
drop_feedback: Option<PageTreeDropFeedback>,
},
FileTreeState {
selected_row_ids: BTreeSet<String>,
anchor_row_id: Option<String>,
focused_row_id: Option<String>,
drag_row_ids: Vec<String>,
drag_effect: Option<TreeShellDragEffect>,
drop_target_row_id: Option<String>,
},
PickerState {
active_item_key: Option<String>,
focus_dom: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum TreeShellHostEvent {
PageOpen {
node_id: String,
},
PageContextMenu {
node_id: String,
},
FileTreeOpen {
target: super::filetree_runtime::FileTreeOpenTarget,
},
FileTreeContextMenu {
row_id: String,
target: super::filetree_runtime::FileTreeOpenTarget,
},
FileTreeInternalDrop {
target_row_id: Option<String>,
target: Option<super::filetree_runtime::FileTreeOpenTarget>,
row_ids: Vec<String>,
copy: bool,
},
FileTreeExternalDrop {
target_row_id: Option<String>,
target: Option<super::filetree_runtime::FileTreeOpenTarget>,
file_count: u32,
},
PickerPickRoot,
PickerPickDocument {
document_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum TreeShellCommandEvent {
CreateNode {
command_name: String,
parent_node_id: Option<String>,
},
RenameNode {
command_name: String,
node_id: String,
title: String,
},
MoveSubtree {
command_name: String,
source_node_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
target_node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
target_parent_id: Option<String>,
position: PageTreeDropPosition,
#[serde(default, skip_serializing_if = "Option::is_none")]
sort_order: Option<i64>,
},
CopyResource {
command_name: String,
source_asset_ids: Vec<String>,
target_document_id: String,
},
MoveResource {
command_name: String,
source_asset_ids: Vec<String>,
target_document_id: String,
},
UploadResource {
command_name: String,
target_document_id: String,
file_count: u32,
},
}
pub fn reduce_tree_shell_runtime(request: TreeShellRuntimeRequest) -> TreeShellRuntimeResult {
match request {
TreeShellRuntimeRequest::Page {
request_id,
environment,
state,
action,
} => {
let transition = state.reduce(&environment, action);
let dom_patches = transition
.outputs
.iter()
.filter_map(|output| match output {
PageTreeRuntimeOutput::DomPatch => Some(TreeShellDomPatch::PageState {
focused_id: transition.state.focused_id.clone(),
expanded_ids: transition.state.expanded_ids.clone(),
drop_feedback: transition.state.drop_feedback.clone(),
}),
_ => None,
})
.collect();
let host_events = transition
.outputs
.iter()
.filter_map(|output| match output {
PageTreeRuntimeOutput::Intent(PageTreeIntentEvent::Open { node_id }) => {
Some(TreeShellHostEvent::PageOpen {
node_id: node_id.clone(),
})
}
PageTreeRuntimeOutput::Intent(PageTreeIntentEvent::ContextMenu { node_id }) => {
Some(TreeShellHostEvent::PageContextMenu {
node_id: node_id.clone(),
})
}
_ => None,
})
.collect();
let command_events = transition
.outputs
.iter()
.filter_map(|output| match output {
PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::CreateNode {
command_name,
parent_node_id,
},
) => Some(TreeShellCommandEvent::CreateNode {
command_name: command_name.to_string(),
parent_node_id: parent_node_id.clone(),
}),
PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::RenameNode {
command_name,
node_id,
title,
},
) => Some(TreeShellCommandEvent::RenameNode {
command_name: command_name.to_string(),
node_id: node_id.clone(),
title: title.clone(),
}),
PageTreeRuntimeOutput::CommandDispatch(
PageTreeCommandDispatchEvent::MoveSubtree {
command_name,
source_node_id,
target_node_id,
target_parent_id,
position,
sort_order,
},
) => Some(TreeShellCommandEvent::MoveSubtree {
command_name: command_name.to_string(),
source_node_id: source_node_id.clone(),
target_node_id: target_node_id.clone(),
target_parent_id: target_parent_id.clone(),
position: *position,
sort_order: *sort_order,
}),
_ => None,
})
.collect();
TreeShellRuntimeResult {
request_id,
mode: TreeShellRuntimeMode::Page,
state: TreeShellRuntimeStateSnapshot::Page(transition.state),
dom_patches,
host_events,
command_events,
}
}
TreeShellRuntimeRequest::FileTree {
request_id,
environment,
state,
action,
} => {
let transition = state.reduce(&environment, action);
let dom_patches = transition
.outputs
.iter()
.filter_map(|output| match output {
FileTreeRuntimeOutput::DomPatch => Some(TreeShellDomPatch::FileTreeState {
selected_row_ids: transition.state.selection.selected_row_ids.clone(),
anchor_row_id: transition.state.selection.anchor_row_id.clone(),
focused_row_id: transition.state.selection.focused_row_id.clone(),
drag_row_ids: transition.state.drag_row_ids.clone(),
drag_effect: transition.state.drag_effect,
drop_target_row_id: transition.state.drop_target_row_id.clone(),
}),
_ => None,
})
.collect();
let host_events = transition
.outputs
.iter()
.filter_map(|output| match output {
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open { target }) => {
Some(TreeShellHostEvent::FileTreeOpen {
target: target.clone(),
})
}
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::ContextMenu {
row_id,
target,
}) => Some(TreeShellHostEvent::FileTreeContextMenu {
row_id: row_id.clone(),
target: target.clone(),
}),
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop {
target_row_id,
target,
row_ids,
copy,
}) => Some(TreeShellHostEvent::FileTreeInternalDrop {
target_row_id: target_row_id.clone(),
target: target.clone(),
row_ids: row_ids.clone(),
copy: *copy,
}),
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::ExternalDrop {
target_row_id,
target,
file_count,
}) => Some(TreeShellHostEvent::FileTreeExternalDrop {
target_row_id: target_row_id.clone(),
target: target.clone(),
file_count: *file_count,
}),
_ => None,
})
.collect();
TreeShellRuntimeResult {
request_id,
mode: TreeShellRuntimeMode::FileTree,
state: TreeShellRuntimeStateSnapshot::FileTree(transition.state),
dom_patches,
host_events,
command_events: Vec::new(),
}
}
TreeShellRuntimeRequest::Picker {
request_id,
environment,
state,
action,
} => {
let transition = state.reduce(&environment, action);
let dom_patches = transition
.outputs
.iter()
.filter_map(|output| match output {
PickerRuntimeOutput::DomPatch { focus_dom } => {
Some(TreeShellDomPatch::PickerState {
active_item_key: transition.state.active_item_key.clone(),
focus_dom: *focus_dom,
})
}
_ => None,
})
.collect();
let host_events = transition
.outputs
.iter()
.filter_map(|output| match output {
PickerRuntimeOutput::Pick(PickerRuntimePickTarget::Root) => {
Some(TreeShellHostEvent::PickerPickRoot)
}
PickerRuntimeOutput::Pick(PickerRuntimePickTarget::Document {
document_id,
}) => Some(TreeShellHostEvent::PickerPickDocument {
document_id: document_id.clone(),
}),
_ => None,
})
.collect();
TreeShellRuntimeResult {
request_id,
mode: TreeShellRuntimeMode::Picker,
state: TreeShellRuntimeStateSnapshot::Picker(transition.state),
dom_patches,
host_events,
command_events: Vec::new(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::{
reduce_tree_shell_runtime, TreeShellCommandEvent, TreeShellDomPatch, TreeShellHostEvent,
TreeShellRuntimeMode, TreeShellRuntimeRequest, TreeShellRuntimeStateSnapshot,
};
use crate::tree_shell::filetree_runtime::{
FileTreeOpenTarget, FileTreeRuntimeAction, FileTreeRuntimeEnvironment, FileTreeRuntimeRow,
FileTreeRuntimeState,
};
use crate::tree_shell::filetree_selection::{
FileTreeSelectionModifiers, FileTreeSelectionState,
};
use crate::tree_shell::page_runtime::{
PageTreeDropPosition, PageTreeRuntimeAction, PageTreeRuntimeEnvironment,
PageTreeRuntimeState,
};
use crate::tree_shell::picker_runtime::{
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeItem, PickerRuntimeState,
};
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
use serde_json::json;
use std::collections::BTreeSet;
fn ids(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn set(values: &[&str]) -> BTreeSet<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn runtime_request_result_are_wire_safe_and_camel_case() {
let request = TreeShellRuntimeRequest::Page {
request_id: "req-page".into(),
environment: PageTreeRuntimeEnvironment {
visible_node_ids: ids(&["doc:a", "doc:b"]),
expandable_node_ids: set(&["doc:a"]),
rows: Vec::new(),
},
state: PageTreeRuntimeState {
focused_id: Some("doc:a".into()),
expanded_ids: BTreeSet::new(),
drop_feedback: None,
},
action: PageTreeRuntimeAction::MoveNext,
};
let encoded = serde_json::to_value(&request).expect("runtime request should serialize");
assert_eq!(
encoded,
json!({
"mode": "page",
"requestId": "req-page",
"environment": {
"visibleNodeIds": ["doc:a", "doc:b"],
"expandableNodeIds": ["doc:a"]
},
"state": {
"focusedId": "doc:a",
"expandedIds": [],
"dropFeedback": null
},
"action": {
"kind": "moveNext"
}
})
);
let decoded: TreeShellRuntimeRequest =
serde_json::from_value(encoded).expect("runtime request should deserialize");
let result = reduce_tree_shell_runtime(decoded);
assert_eq!(result.request_id, "req-page");
assert_eq!(result.mode, TreeShellRuntimeMode::Page);
assert_eq!(
result.state,
TreeShellRuntimeStateSnapshot::Page(PageTreeRuntimeState {
focused_id: Some("doc:b".into()),
expanded_ids: BTreeSet::new(),
drop_feedback: None,
})
);
assert_eq!(
result.dom_patches,
vec![TreeShellDomPatch::PageState {
focused_id: Some("doc:b".into()),
expanded_ids: BTreeSet::new(),
drop_feedback: None,
}]
);
assert!(result.host_events.is_empty());
assert!(result.command_events.is_empty());
}
#[test]
fn runtime_api_maps_page_intents_and_commands_to_structured_events() {
let environment = PageTreeRuntimeEnvironment {
visible_node_ids: ids(&["doc:a", "doc:b"]),
expandable_node_ids: set(&["doc:a"]),
rows: Vec::new(),
};
let state = PageTreeRuntimeState {
focused_id: Some("doc:b".into()),
..PageTreeRuntimeState::default()
};
let open = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Page {
request_id: "req-open".into(),
environment: environment.clone(),
state: state.clone(),
action: PageTreeRuntimeAction::OpenFocused,
});
assert_eq!(
open.host_events,
vec![TreeShellHostEvent::PageOpen {
node_id: "doc:b".into(),
}]
);
let create_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Page {
request_id: "req-create".into(),
environment: environment.clone(),
state: state.clone(),
action: PageTreeRuntimeAction::DispatchCreate {
parent_node_id: Some("doc:a".into()),
},
});
assert_eq!(
create_result.command_events,
vec![TreeShellCommandEvent::CreateNode {
command_name: "tree.node.create".into(),
parent_node_id: Some("doc:a".into()),
}]
);
let rename_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Page {
request_id: "req-rename".into(),
environment: environment.clone(),
state: state.clone(),
action: PageTreeRuntimeAction::DispatchRename {
node_id: "doc:b".into(),
title: "新标题".into(),
},
});
assert_eq!(
rename_result.command_events,
vec![TreeShellCommandEvent::RenameNode {
command_name: "tree.node.rename".into(),
node_id: "doc:b".into(),
title: "新标题".into(),
}]
);
let move_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Page {
request_id: "req-move".into(),
environment,
state,
action: PageTreeRuntimeAction::DispatchMove {
source_node_id: "doc:b".into(),
target_parent_id: Some("doc:a".into()),
position: PageTreeDropPosition::Inside,
},
});
assert_eq!(
move_result.command_events,
vec![TreeShellCommandEvent::MoveSubtree {
command_name: "tree.subtree.move".into(),
source_node_id: "doc:b".into(),
target_node_id: None,
target_parent_id: Some("doc:a".into()),
position: PageTreeDropPosition::Inside,
sort_order: None,
}]
);
}
#[test]
fn runtime_api_maps_filetree_and_picker_results_to_common_output_channels() {
let filetree = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
},
state: FileTreeRuntimeState::default(),
action: FileTreeRuntimeAction::SelectRow {
row_id: "asset:image".into(),
modifiers: FileTreeSelectionModifiers::default(),
},
});
assert_eq!(filetree.mode, TreeShellRuntimeMode::FileTree);
assert_eq!(
filetree.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
selected_row_ids: set(&["asset:image"]),
anchor_row_id: Some("asset:image".into()),
focused_row_id: Some("asset:image".into()),
drag_row_ids: Vec::new(),
drag_effect: None,
drop_target_row_id: None,
}]
);
let drag_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree-drag".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
},
state: FileTreeRuntimeState {
selection: FileTreeSelectionState::from_selected(&["asset:image".to_string()]),
..FileTreeRuntimeState::default()
},
action: FileTreeRuntimeAction::ResolveDragRows {
row_id: "asset:image".into(),
has_external_files: false,
alt_key: false,
},
});
assert_eq!(
drag_result.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
selected_row_ids: set(&["asset:image"]),
anchor_row_id: Some("asset:image".into()),
focused_row_id: Some("asset:image".into()),
drag_row_ids: ids(&["asset:image"]),
drag_effect: Some(TreeShellDragEffect::Move),
drop_target_row_id: None,
}]
);
let drop_target_result = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree-drop-target".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
},
state: FileTreeRuntimeState::default(),
action: FileTreeRuntimeAction::UpdateDropTarget {
row_id: Some("asset:image".into()),
},
});
assert_eq!(
drop_target_result.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
selected_row_ids: BTreeSet::new(),
anchor_row_id: None,
focused_row_id: None,
drag_row_ids: Vec::new(),
drag_effect: None,
drop_target_row_id: Some("asset:image".into()),
}]
);
let internal_drop = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree-internal-drop".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root", "asset:image"]),
rows: vec![
FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
},
FileTreeRuntimeRow {
row_id: "asset:image".into(),
row_kind: "asset".into(),
document_id: Some("root".into()),
asset_id: Some("image".into()),
},
],
},
state: FileTreeRuntimeState {
drag_row_ids: ids(&["asset:image"]),
drag_effect: Some(TreeShellDragEffect::Move),
drop_target_row_id: Some("doc:root".into()),
..FileTreeRuntimeState::default()
},
action: FileTreeRuntimeAction::DispatchInternalDrop {
target_row_id: Some("doc:root".into()),
row_ids: ids(&["asset:image"]),
copy: false,
},
});
assert_eq!(
internal_drop.host_events,
vec![TreeShellHostEvent::FileTreeInternalDrop {
target_row_id: Some("doc:root".into()),
target: Some(FileTreeOpenTarget::Document {
document_id: "root".into(),
}),
row_ids: ids(&["asset:image"]),
copy: false,
}]
);
assert_eq!(
internal_drop.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
selected_row_ids: BTreeSet::new(),
anchor_row_id: None,
focused_row_id: None,
drag_row_ids: Vec::new(),
drag_effect: None,
drop_target_row_id: None,
}]
);
let external_drop = reduce_tree_shell_runtime(TreeShellRuntimeRequest::FileTree {
request_id: "req-filetree-external-drop".into(),
environment: FileTreeRuntimeEnvironment {
visible_row_ids: ids(&["doc:root"]),
rows: vec![FileTreeRuntimeRow {
row_id: "doc:root".into(),
row_kind: "doc".into(),
document_id: Some("root".into()),
asset_id: None,
}],
},
state: FileTreeRuntimeState::default(),
action: FileTreeRuntimeAction::DispatchExternalDrop {
target_row_id: Some("doc:root".into()),
file_count: 2,
},
});
assert_eq!(
external_drop.host_events,
vec![TreeShellHostEvent::FileTreeExternalDrop {
target_row_id: Some("doc:root".into()),
target: Some(FileTreeOpenTarget::Document {
document_id: "root".into(),
}),
file_count: 2,
}]
);
let picker = reduce_tree_shell_runtime(TreeShellRuntimeRequest::Picker {
request_id: "req-picker".into(),
environment: PickerRuntimeEnvironment {
items: vec![PickerRuntimeItem {
item_key: "doc:target".into(),
document_id: Some("target".into()),
pickable: true,
}],
excluded_ids: BTreeSet::new(),
allow_root_pick: false,
},
state: PickerRuntimeState {
active_item_key: Some("doc:target".into()),
},
action: PickerRuntimeAction::Pick,
});
assert_eq!(picker.mode, TreeShellRuntimeMode::Picker);
assert_eq!(
picker.host_events,
vec![TreeShellHostEvent::PickerPickDocument {
document_id: "target".into(),
}]
);
}
#[test]
fn runtime_command_event_schema_covers_tree_and_resource_command_channels() {
let events = vec![
TreeShellCommandEvent::CreateNode {
command_name: "tree.node.create".into(),
parent_node_id: Some("doc:parent".into()),
},
TreeShellCommandEvent::RenameNode {
command_name: "tree.node.rename".into(),
node_id: "doc:target".into(),
title: "新标题".into(),
},
TreeShellCommandEvent::MoveSubtree {
command_name: "tree.subtree.move".into(),
source_node_id: "doc:target".into(),
target_node_id: Some("doc:sibling".into()),
target_parent_id: Some("doc:parent".into()),
position: PageTreeDropPosition::After,
sort_order: Some(3),
},
TreeShellCommandEvent::CopyResource {
command_name: "tree.resource.copy".into(),
source_asset_ids: vec!["asset:a".into()],
target_document_id: "doc:target".into(),
},
TreeShellCommandEvent::MoveResource {
command_name: "tree.resource.move".into(),
source_asset_ids: vec!["asset:a".into()],
target_document_id: "doc:target".into(),
},
TreeShellCommandEvent::UploadResource {
command_name: "tree.resource.upload".into(),
target_document_id: "doc:target".into(),
file_count: 2,
},
];
let encoded = serde_json::to_value(&events).expect("command events should serialize");
assert_eq!(
encoded,
json!([
{
"kind": "createNode",
"commandName": "tree.node.create",
"parentNodeId": "doc:parent"
},
{
"kind": "renameNode",
"commandName": "tree.node.rename",
"nodeId": "doc:target",
"title": "新标题"
},
{
"kind": "moveSubtree",
"commandName": "tree.subtree.move",
"sourceNodeId": "doc:target",
"targetNodeId": "doc:sibling",
"targetParentId": "doc:parent",
"position": "after",
"sortOrder": 3
},
{
"kind": "copyResource",
"commandName": "tree.resource.copy",
"sourceAssetIds": ["asset:a"],
"targetDocumentId": "doc:target"
},
{
"kind": "moveResource",
"commandName": "tree.resource.move",
"sourceAssetIds": ["asset:a"],
"targetDocumentId": "doc:target"
},
{
"kind": "uploadResource",
"commandName": "tree.resource.upload",
"targetDocumentId": "doc:target",
"fileCount": 2
}
])
);
}
}
@@ -0,0 +1,18 @@
[package]
name = "tree-shell-runtime-wasm"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
console_error_panic_hook = "0.1.7"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
wasm-bindgen = "0.2"
[dev-dependencies]
serde_json = "1"
@@ -0,0 +1,2 @@
*
!.gitignore
@@ -0,0 +1,71 @@
use serde_wasm_bindgen::{from_value, to_value};
use wasm_bindgen::prelude::*;
// 说明:复用 mnote-web 当前 tree shell runtime 源码,避免复制第二份 reducer 真相。
pub mod tree_shell {
pub mod drag_drop_state {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mnote-web/src/tree_shell/drag_drop_state.rs"
));
}
pub mod filetree_runtime {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mnote-web/src/tree_shell/filetree_runtime.rs"
));
}
pub mod filetree_selection {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mnote-web/src/tree_shell/filetree_selection.rs"
));
}
pub mod focus_state {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mnote-web/src/tree_shell/focus_state.rs"
));
}
pub mod page_runtime {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mnote-web/src/tree_shell/page_runtime.rs"
));
}
pub mod picker_runtime {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mnote-web/src/tree_shell/picker_runtime.rs"
));
}
pub mod picker_state {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mnote-web/src/tree_shell/picker_state.rs"
));
}
pub mod runtime_api {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../mnote-web/src/tree_shell/runtime_api.rs"
));
}
}
fn format_js_error(stage: &str, error: impl std::fmt::Display) -> JsValue {
JsValue::from_str(&format!("[tree_shell_runtime_wasm:{stage}] {error}"))
}
#[wasm_bindgen(start)]
pub fn install_panic_hook() {
console_error_panic_hook::set_once();
}
#[wasm_bindgen(js_name = reduceTreeShellRuntime)]
pub fn reduce_tree_shell_runtime_export(request: JsValue) -> Result<JsValue, JsValue> {
let request = from_value::<tree_shell::runtime_api::TreeShellRuntimeRequest>(request)
.map_err(|error| format_js_error("decode_request", error))?;
let result = tree_shell::runtime_api::reduce_tree_shell_runtime(request);
to_value(&result).map_err(|error| format_js_error("encode_result", error))
}
+1 -1
View File
@@ -1 +1 @@
{"rustc_fingerprint":9228011546279038255,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}} {"rustc_fingerprint":9228011546279038255,"outputs":{"11652014622397750202":{"success":true,"status":"","code":0,"stdout":"___.wasm\nlib___.rlib\n___.wasm\nlib___.a\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\n___\ndebug_assertions\npanic=\"abort\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"wasm32\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"wasm\"\ntarget_feature=\"bulk-memory\"\ntarget_feature=\"multivalue\"\ntarget_feature=\"mutable-globals\"\ntarget_feature=\"nontrapping-fptoint\"\ntarget_feature=\"reference-types\"\ntarget_feature=\"sign-ext\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"unknown\"\ntarget_pointer_width=\"32\"\ntarget_vendor=\"unknown\"\n","stderr":"warning: dropping unsupported crate type `dylib` for target `wasm32-unknown-unknown`\n\nwarning: dropping unsupported crate type `proc-macro` for target `wasm32-unknown-unknown`\n\nwarning: 2 warnings emitted\n\n"},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
+52 -1
View File
@@ -196,6 +196,32 @@ function getProcessNameByPid(pid) {
} }
} }
function terminatePid(pid) {
if (process.platform === "win32") {
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
return;
}
try {
process.kill(pid, "SIGTERM");
} catch {
return;
}
}
function forceKillPid(pid) {
if (process.platform === "win32") {
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
return;
}
try {
process.kill(pid, "SIGKILL");
} catch {
// 进程可能已经退出。
}
}
async function ensurePortFree(port, nameForLog) { async function ensurePortFree(port, nameForLog) {
const free = await isPortFree("127.0.0.1", port); const free = await isPortFree("127.0.0.1", port);
if (free) return true; if (free) return true;
@@ -225,7 +251,7 @@ async function ensurePortFree(port, nameForLog) {
logPrefix(nameForLog, `检测到端口 ${port} 被占用,准备重启(结束旧进程):${killPids.join(", ")}`); logPrefix(nameForLog, `检测到端口 ${port} 被占用,准备重启(结束旧进程):${killPids.join(", ")}`);
for (const pid of killPids) { for (const pid of killPids) {
try { try {
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" }); terminatePid(pid);
} catch { } catch {
// ignore // ignore
} }
@@ -240,6 +266,20 @@ async function ensurePortFree(port, nameForLog) {
await new Promise((r) => setTimeout(r, 150)); await new Promise((r) => setTimeout(r, 150));
} }
if (process.platform !== "win32") {
for (const pid of killPids) {
forceKillPid(pid);
}
for (let i = 0; i < 10; i += 1) {
// eslint-disable-next-line no-await-in-loop
const ok = await isPortFree("127.0.0.1", port, 250);
if (ok) return true;
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 150));
}
}
logPrefix(nameForLog, `端口 ${port} 仍未释放,可能有其他程序占用。`); logPrefix(nameForLog, `端口 ${port} 仍未释放,可能有其他程序占用。`);
return false; return false;
} }
@@ -457,7 +497,18 @@ async function main() {
} }
} }
if (require.main === module) {
main().catch((error) => { main().catch((error) => {
logPrefix("system", `启动失败:${error.message}`); logPrefix("system", `启动失败:${error.message}`);
process.exit(1); process.exit(1);
}); });
}
module.exports = {
ensurePortFree,
getListeningPidsByPort,
getProcessNameByPid,
isPortFree,
resolveBackendExecutable,
terminatePid,
};
+110
View File
@@ -0,0 +1,110 @@
const assert = require("node:assert");
const { spawn } = require("node:child_process");
const net = require("node:net");
const { test } = require("node:test");
const { resolveBackendExecutable, ensurePortFree, isPortFree } = require("./desktop-hot.js");
function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("无法分配测试端口")));
return;
}
const port = address.port;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
function startPythonListener(port) {
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
const child = spawn(
pythonBin,
[
"-c",
`
import socket
import time
import threading
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", ${port}))
server.listen(16)
def accept_loop():
while True:
conn, _addr = server.accept()
conn.close()
threading.Thread(target=accept_loop, daemon=True).start()
print("ready", flush=True)
while True:
time.sleep(1)
`,
],
{
stdio: ["ignore", "pipe", "pipe"],
},
);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("测试监听进程启动超时"));
}, 3000);
child.stdout.on("data", (chunk) => {
if (chunk.toString("utf8").includes("ready")) {
clearTimeout(timeout);
resolve(child);
}
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
});
}
function waitForExit(child, timeoutMs = 3000) {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
child.off("exit", onExit);
reject(new Error(`进程 ${child.pid} 未在 ${timeoutMs}ms 内退出`));
}, timeoutMs);
const onExit = () => {
clearTimeout(timeout);
resolve();
};
child.once("exit", onExit);
});
}
test("ensurePortFree 在非 Windows 平台能释放后端监听进程", async (t) => {
if (process.platform === "win32") {
t.skip("该用例只覆盖 Linux/macOS 分支");
return;
}
const port = await findFreePort();
const child = await startPythonListener(port);
t.after(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
});
assert.equal(await isPortFree("127.0.0.1", port), false);
const ok = await ensurePortFree(port, "backend");
assert.equal(ok, true);
assert.equal(await isPortFree("127.0.0.1", port), true);
await waitForExit(child);
});
@@ -23,6 +23,8 @@ const {
const RUN_DIRECT_TREE_SHELL_CHECKS = const RUN_DIRECT_TREE_SHELL_CHECKS =
String(process.env.MNOTE_TREE_SHELL_DIRECT_SMOKE || "").trim() === "1"; String(process.env.MNOTE_TREE_SHELL_DIRECT_SMOKE || "").trim() === "1";
const ALLOW_LEGACY_TREE_SHELL_IFRAME_HOST =
String(process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST || "").trim() === "1";
function buildTreeShellUrl(workspaceId, params = {}) { function buildTreeShellUrl(workspaceId, params = {}) {
const search = new URLSearchParams({ const search = new URLSearchParams({
@@ -35,7 +37,19 @@ function buildTreeShellUrl(workspaceId, params = {}) {
} }
function treeHostImplementationUsesIframe(implementation) { function treeHostImplementationUsesIframe(implementation) {
return implementation === "mnote_web_iframe_proxy" || implementation === "rust_inline_compat_host"; return (
implementation === "mnote_web_iframe_proxy" ||
implementation === "rust_inline_compat_host" ||
implementation === "rust_runtime_artifact_host"
);
}
function assertDefaultHostIsDomWasm(implementation, surfaceTestId) {
if (treeHostImplementationUsesIframe(implementation) && !ALLOW_LEGACY_TREE_SHELL_IFRAME_HOST) {
throw new Error(
`${surfaceTestId} 默认主路径不能再使用 legacy iframe host: ${implementation || "unknown"}`,
);
}
} }
async function waitForInlineTreeShellReady(page, surfaceTestId) { async function waitForInlineTreeShellReady(page, surfaceTestId) {
@@ -46,9 +60,11 @@ async function waitForInlineTreeShellReady(page, surfaceTestId) {
return false; return false;
} }
const implementation = host.getAttribute("data-tree-host-implementation") || ""; const implementation = host.getAttribute("data-tree-host-implementation") || "";
if (implementation !== "mnote_web_iframe_proxy" && implementation !== "rust_inline_compat_host") { if (
return true; implementation === "mnote_web_iframe_proxy" ||
} implementation === "rust_inline_compat_host" ||
implementation === "rust_runtime_artifact_host"
) {
const iframe = host.querySelector(`[data-testid="${testId}-rust-iframe"]`); const iframe = host.querySelector(`[data-testid="${testId}-rust-iframe"]`);
if (!(iframe instanceof HTMLIFrameElement) || iframe.getClientRects().length === 0) { if (!(iframe instanceof HTMLIFrameElement) || iframe.getClientRects().length === 0) {
return false; return false;
@@ -56,6 +72,14 @@ async function waitForInlineTreeShellReady(page, surfaceTestId) {
const doc = iframe.contentDocument; const doc = iframe.contentDocument;
const status = doc?.querySelector("#tree-shell-status")?.textContent ?? ""; const status = doc?.querySelector("#tree-shell-status")?.textContent ?? "";
return Boolean(doc?.querySelector("#tree-shell-state")) && status.includes("就绪"); return Boolean(doc?.querySelector("#tree-shell-state")) && status.includes("就绪");
}
const domHost = host.querySelector(`[data-testid="${testId}-dom-host"]`);
return (
domHost instanceof HTMLElement &&
domHost.getClientRects().length > 0 &&
domHost.getAttribute("data-tree-browser-bridge") === "dom_wasm" &&
domHost.getAttribute("data-tree-dom-shell-ready") === "true"
);
}, },
surfaceTestId, surfaceTestId,
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
@@ -161,16 +185,27 @@ async function getPageTreeHostDriver(page) {
return false; return false;
} }
const implementation = element.getAttribute("data-tree-host-implementation") || ""; const implementation = element.getAttribute("data-tree-host-implementation") || "";
if (implementation === "mnote_web_iframe_proxy" || implementation === "rust_inline_compat_host") { if (
implementation === "mnote_web_iframe_proxy" ||
implementation === "rust_inline_compat_host" ||
implementation === "rust_runtime_artifact_host"
) {
const iframe = element.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]'); const iframe = element.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
return iframe instanceof HTMLIFrameElement && iframe.getClientRects().length > 0; return iframe instanceof HTMLIFrameElement && iframe.getClientRects().length > 0;
} }
return true; const domHost = element.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]');
return (
domHost instanceof HTMLElement &&
domHost.getClientRects().length > 0 &&
domHost.getAttribute("data-tree-browser-bridge") === "dom_wasm" &&
domHost.getAttribute("data-tree-dom-shell-ready") === "true"
);
}, },
undefined, undefined,
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
); );
const implementation = await host.getAttribute("data-tree-host-implementation"); const implementation = await host.getAttribute("data-tree-host-implementation");
assertDefaultHostIsDomWasm(implementation, "sidebar-page-tree-shell");
if (treeHostImplementationUsesIframe(implementation)) { if (treeHostImplementationUsesIframe(implementation)) {
await waitForInlineTreeShellReady(page, "sidebar-page-tree-shell"); await waitForInlineTreeShellReady(page, "sidebar-page-tree-shell");
return { return {
@@ -179,7 +214,8 @@ async function getPageTreeHostDriver(page) {
scope: page.frameLocator('[data-testid="sidebar-page-tree-shell-rust-iframe"]'), scope: page.frameLocator('[data-testid="sidebar-page-tree-shell-rust-iframe"]'),
}; };
} }
return { kind: "react", host, scope: host }; await waitForInlineTreeShellReady(page, "sidebar-page-tree-shell");
return { kind: "dom", host, scope: host };
} }
async function getFileTreeHostDriver(page) { async function getFileTreeHostDriver(page) {
@@ -192,16 +228,27 @@ async function getFileTreeHostDriver(page) {
return false; return false;
} }
const implementation = element.getAttribute("data-tree-host-implementation") || ""; const implementation = element.getAttribute("data-tree-host-implementation") || "";
if (implementation === "mnote_web_iframe_proxy" || implementation === "rust_inline_compat_host") { if (
implementation === "mnote_web_iframe_proxy" ||
implementation === "rust_inline_compat_host" ||
implementation === "rust_runtime_artifact_host"
) {
const iframe = element.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]'); const iframe = element.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]');
return iframe instanceof HTMLIFrameElement && iframe.getClientRects().length > 0; return iframe instanceof HTMLIFrameElement && iframe.getClientRects().length > 0;
} }
return true; const domHost = element.querySelector('[data-testid="sidebar-file-tree-shell-dom-host"]');
return (
domHost instanceof HTMLElement &&
domHost.getClientRects().length > 0 &&
domHost.getAttribute("data-tree-browser-bridge") === "dom_wasm" &&
domHost.getAttribute("data-tree-dom-shell-ready") === "true"
);
}, },
undefined, undefined,
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
); );
const implementation = await host.getAttribute("data-tree-host-implementation"); const implementation = await host.getAttribute("data-tree-host-implementation");
assertDefaultHostIsDomWasm(implementation, "sidebar-file-tree-shell");
if (treeHostImplementationUsesIframe(implementation)) { if (treeHostImplementationUsesIframe(implementation)) {
await waitForInlineTreeShellReady(page, "sidebar-file-tree-shell"); await waitForInlineTreeShellReady(page, "sidebar-file-tree-shell");
return { return {
@@ -210,7 +257,8 @@ async function getFileTreeHostDriver(page) {
scope: page.frameLocator('[data-testid="sidebar-file-tree-shell-rust-iframe"]'), scope: page.frameLocator('[data-testid="sidebar-file-tree-shell-rust-iframe"]'),
}; };
} }
return { kind: "react", host, scope: host }; await waitForInlineTreeShellReady(page, "sidebar-file-tree-shell");
return { kind: "dom", host, scope: host };
} }
async function openPageTreeDocumentFromHost(page, driver, documentId) { async function openPageTreeDocumentFromHost(page, driver, documentId) {
@@ -228,7 +276,7 @@ async function openPageTreeDocumentFromHost(page, driver, documentId) {
}); });
} else { } else {
await driver.scope await driver.scope
.locator(`[data-testid="page-tree-row"][data-node-id="${documentId}"] [data-testid="page-tree-open"]`) .locator(`.tree-row[data-shell-mode="page"][data-node-id="${documentId}"] [data-testid="tree-node-open"]`)
.click({ timeout: UI_TIMEOUT_MS }); .click({ timeout: UI_TIMEOUT_MS });
} }
const popupPage = await popupPromise; const popupPage = await popupPromise;
@@ -252,9 +300,9 @@ async function openPageTreeContextMenuFromHost(page, driver, documentId) {
button.click(); button.click();
}); });
} else { } else {
const row = driver.scope.locator(`[data-testid="page-tree-row"][data-node-id="${documentId}"]`); const row = driver.scope.locator(`.tree-row[data-shell-mode="page"][data-node-id="${documentId}"]`);
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await row.getByRole("button", { name: "更多操作" }).click({ timeout: UI_TIMEOUT_MS, force: true }); await row.locator('[data-testid="tree-action-menu"]').click({ timeout: UI_TIMEOUT_MS, force: true });
} }
await page.getByText("重命名", { exact: true }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.getByText("重命名", { exact: true }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
@@ -270,7 +318,7 @@ async function doubleClickFileTreeDocumentFromHost(page, driver, documentId) {
.dblclick({ timeout: UI_TIMEOUT_MS }); .dblclick({ timeout: UI_TIMEOUT_MS });
} else { } else {
await driver.scope await driver.scope
.locator(`[data-testid="filetree-doc-row"][data-doc-id="${documentId}"]`) .locator(`[data-testid="filetree-doc-row"][data-document-id="${documentId}"], [data-testid="filetree-doc-row"][data-doc-id="${documentId}"]`)
.dblclick({ timeout: UI_TIMEOUT_MS }); .dblclick({ timeout: UI_TIMEOUT_MS });
} }
const popupPage = await popupPromise; const popupPage = await popupPromise;
@@ -349,11 +397,11 @@ async function waitForPageTreeTitle(page, documentId, title) {
} }
await driver.scope await driver.scope
.locator(`[data-testid="page-tree-row"][data-node-id="${documentId}"]`) .locator(`.tree-row[data-shell-mode="page"][data-node-id="${documentId}"]`)
.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); .waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction( await page.waitForFunction(
({ nodeId, expectedTitle }) => { ({ nodeId, expectedTitle }) => {
const row = document.querySelector(`[data-testid="page-tree-row"][data-node-id="${nodeId}"]`); const row = document.querySelector(`.tree-row[data-shell-mode="page"][data-node-id="${nodeId}"]`);
return (row?.textContent ?? "").includes(expectedTitle); return (row?.textContent ?? "").includes(expectedTitle);
}, },
{ nodeId: documentId, expectedTitle: title }, { nodeId: documentId, expectedTitle: title },
@@ -387,11 +435,13 @@ async function waitForFileTreeTitle(page, documentId, title) {
} }
await driver.scope await driver.scope
.locator(`[data-testid="filetree-doc-row"][data-doc-id="${documentId}"]`) .locator(`[data-testid="filetree-doc-row"][data-document-id="${documentId}"], [data-testid="filetree-doc-row"][data-doc-id="${documentId}"]`)
.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); .waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction( await page.waitForFunction(
({ docId, expectedTitle }) => { ({ docId, expectedTitle }) => {
const row = document.querySelector(`[data-testid="filetree-doc-row"][data-doc-id="${docId}"]`); const row = document.querySelector(
`[data-testid="filetree-doc-row"][data-document-id="${docId}"], [data-testid="filetree-doc-row"][data-doc-id="${docId}"]`,
);
return (row?.textContent ?? "").includes(expectedTitle); return (row?.textContent ?? "").includes(expectedTitle);
}, },
{ docId: documentId, expectedTitle: title }, { docId: documentId, expectedTitle: title },
@@ -866,10 +916,12 @@ async function runPickerDialogChecks(context, fixture) {
const pickerSurface = dialog.getByTestId("tree-picker-surface"); const pickerSurface = dialog.getByTestId("tree-picker-surface");
await pickerSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await pickerSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const searchInput = dialog.getByPlaceholder("移动到..."); 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"); await waitForInlineTreeShellReady(page, "tree-picker-surface");
} const implementation = await pickerSurface.getAttribute("data-tree-host-implementation");
assertDefaultHostIsDomWasm(implementation, "tree-picker-surface");
const pickerUsesIframe =
treeHostImplementationUsesIframe(implementation) &&
(await pickerSurface.locator('[data-testid="tree-picker-surface-rust-iframe"]').count()) > 0;
return { dialog, pickerSurface, searchInput, pickerUsesIframe }; return { dialog, pickerSurface, searchInput, pickerUsesIframe };
}; };
@@ -987,7 +1039,7 @@ async function runPickerDialogChecks(context, fixture) {
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
); );
} else { } else {
await dialog await keyboardDialog
.locator(`[data-testid="tree-picker-row"][data-node-id="${fixture.targetId}"]`) .locator(`[data-testid="tree-picker-row"][data-node-id="${fixture.targetId}"]`)
.waitFor({ .waitFor({
state: "visible", state: "visible",
@@ -1048,7 +1100,7 @@ async function runPickerDialogChecks(context, fixture) {
pickerSearch: true, pickerSearch: true,
pickerEmpty: true, pickerEmpty: true,
pickerSelect: true, pickerSelect: true,
pickerHostKind: keyboardUsesIframe ? "iframe" : "react", pickerHostKind: keyboardUsesIframe ? "iframe" : "dom",
emptyStateText, emptyStateText,
pickerRootVisible, pickerRootVisible,
pickerExcludeCurrentDocument: true, pickerExcludeCurrentDocument: true,
@@ -15,6 +15,64 @@ const {
renameDocument, renameDocument,
} = require("./tree-shell-smoke-helpers"); } = require("./tree-shell-smoke-helpers");
const ALLOW_LEGACY_TREE_SHELL_IFRAME_HOST =
String(process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST || "").trim() === "1";
function treeHostImplementationUsesIframe(implementation) {
return (
implementation === "mnote_web_iframe_proxy" ||
implementation === "rust_inline_compat_host" ||
implementation === "rust_runtime_artifact_host"
);
}
async function waitForPickerShellReady(page) {
await page.waitForFunction(
() => {
const host = document.querySelector('[data-testid="tree-picker-surface"]');
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" ||
implementation === "rust_runtime_artifact_host"
) {
const iframe = host.querySelector('[data-testid="tree-picker-surface-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("就绪");
}
const domHost = host.querySelector('[data-testid="tree-picker-surface-dom-host"]');
return (
domHost instanceof HTMLElement &&
domHost.getClientRects().length > 0 &&
domHost.getAttribute("data-tree-browser-bridge") === "dom_wasm" &&
domHost.getAttribute("data-tree-dom-shell-ready") === "true"
);
},
undefined,
{ timeout: UI_TIMEOUT_MS },
);
}
async function pickerUsesLegacyIframe(pickerSurface) {
const implementation = await pickerSurface.getAttribute("data-tree-host-implementation");
if (treeHostImplementationUsesIframe(implementation) && !ALLOW_LEGACY_TREE_SHELL_IFRAME_HOST) {
throw new Error(
`tree-picker-surface 默认主路径不能再使用 legacy iframe host: ${implementation || "unknown"}`,
);
}
return (
treeHostImplementationUsesIframe(implementation) &&
(await pickerSurface.locator('[data-testid="tree-picker-surface-rust-iframe"]').count()) > 0
);
}
async function main() { async function main() {
const headless = const headless =
process.env.MNOTE_SMOKE_HEADLESS === "1" process.env.MNOTE_SMOKE_HEADLESS === "1"
@@ -61,6 +119,10 @@ async function main() {
const dialog = page.getByRole("dialog"); const dialog = page.getByRole("dialog");
await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const pickerSurface = dialog.getByTestId("tree-picker-surface");
await pickerSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await waitForPickerShellReady(page);
const usesLegacyIframe = await pickerUsesLegacyIframe(pickerSurface);
const searchInput = dialog.getByPlaceholder("移动到..."); const searchInput = dialog.getByPlaceholder("移动到...");
const keyboardQuery = `task113-keyboard-${Date.now()}`; const keyboardQuery = `task113-keyboard-${Date.now()}`;
@@ -92,6 +154,7 @@ async function main() {
try { try {
await searchInput.fill(keyboardQuery, { timeout: UI_TIMEOUT_MS }); await searchInput.fill(keyboardQuery, { timeout: UI_TIMEOUT_MS });
if (usesLegacyIframe) {
await page.waitForFunction( await page.waitForFunction(
(nodeId) => { (nodeId) => {
const frame = Array.from( const frame = Array.from(
@@ -107,6 +170,11 @@ async function main() {
fixture.targetId, fixture.targetId,
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
); );
} else {
await dialog
.locator(`[data-testid="tree-picker-row"][data-node-id="${fixture.targetId}"]`)
.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
await searchInput.focus(); await searchInput.focus();
await page.waitForFunction( await page.waitForFunction(
@@ -119,26 +187,36 @@ async function main() {
); );
const readFocusedPickerItemKey = async () => const readFocusedPickerItemKey = async () =>
page.evaluate(() => { usesLegacyIframe
? page.evaluate(() => {
const frame = Array.from( const frame = Array.from(
document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'), document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'),
) )
.reverse() .reverse()
.find((element) => element instanceof HTMLIFrameElement) ?? null; .find((element) => element instanceof HTMLIFrameElement) ?? null;
if (!(frame instanceof HTMLIFrameElement)) { if (!(frame instanceof HTMLIFrameElement) || !frame.contentDocument) {
return ""; return "";
} }
const rootButton = frame.contentDocument?.querySelector( const rootButton = frame.contentDocument.querySelector(
'.tree-row[data-testid="tree-picker-root"][data-focused="true"]', '[data-testid="tree-picker-root"][data-focused="true"]',
); );
if (rootButton) { if (rootButton) {
return "__root__"; return "__root__";
} }
const row = frame.contentDocument?.querySelector('.tree-row[data-focused="true"]'); const row = frame.contentDocument.querySelector('.tree-row[data-focused="true"]');
return row ? row.getAttribute("data-node-id") || "" : "";
})
: dialog.evaluate((dialogElement) => {
const rootButton = dialogElement.querySelector('[data-testid="tree-picker-root"][data-focused="true"]');
if (rootButton) {
return "__root__";
}
const row = dialogElement.querySelector('.tree-row[data-focused="true"]');
return row ? row.getAttribute("data-node-id") || "" : ""; return row ? row.getAttribute("data-node-id") || "" : "";
}); });
const moveHighlightTo = async (targetNodeId, maxSteps = 4) => { const moveHighlightTo = async (targetNodeId, maxSteps = 4) => {
if (usesLegacyIframe) {
await page.waitForFunction( await page.waitForFunction(
() => { () => {
const frame = Array.from( const frame = Array.from(
@@ -146,20 +224,24 @@ async function main() {
) )
.reverse() .reverse()
.find((element) => element instanceof HTMLIFrameElement) ?? null; .find((element) => element instanceof HTMLIFrameElement) ?? null;
if (!(frame instanceof HTMLIFrameElement)) { if (!(frame instanceof HTMLIFrameElement) || !frame.contentDocument) {
return false; return false;
} }
const rootButton = frame.contentDocument?.querySelector( const rootButton = frame.contentDocument.querySelector(
'.tree-row[data-testid="tree-picker-root"][data-focused="true"]', '[data-testid="tree-picker-root"][data-focused="true"]',
); );
if (rootButton) { const row = frame.contentDocument.querySelector('.tree-row[data-focused="true"]');
return true; return Boolean(rootButton || row);
}
return Boolean(frame.contentDocument?.querySelector('.tree-row[data-focused="true"]'));
}, },
undefined, undefined,
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
); );
} else {
await dialog
.locator('[data-testid="tree-picker-root"][data-focused="true"], .tree-row[data-focused="true"]')
.first()
.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
}
for (let index = 0; index < maxSteps; index += 1) { for (let index = 0; index < maxSteps; index += 1) {
const currentKey = await readFocusedPickerItemKey(); const currentKey = await readFocusedPickerItemKey();
@@ -168,19 +250,33 @@ async function main() {
} }
await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown");
await page.waitForFunction( await page.waitForFunction(
(previousKey) => { ({ previousKey, legacyIframe }) => {
if (legacyIframe) {
const frame = Array.from( const frame = Array.from(
document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'), document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'),
) )
.reverse() .reverse()
.find((element) => element instanceof HTMLIFrameElement) ?? null; .find((element) => element instanceof HTMLIFrameElement) ?? null;
if (!(frame instanceof HTMLIFrameElement)) { if (!(frame instanceof HTMLIFrameElement) || !frame.contentDocument) {
return false; return false;
} }
const rootButton = frame.contentDocument?.querySelector( const rootButton = frame.contentDocument.querySelector(
'.tree-row[data-testid="tree-picker-root"][data-focused="true"]', '[data-testid="tree-picker-root"][data-focused="true"]',
); );
const row = frame.contentDocument?.querySelector('.tree-row[data-focused="true"]'); const row = frame.contentDocument.querySelector('.tree-row[data-focused="true"]');
const currentKey = rootButton
? "__root__"
: row
? row.getAttribute("data-node-id") || ""
: "";
return currentKey !== previousKey;
}
const dialogElement = document.querySelector('[role="dialog"]');
if (!(dialogElement instanceof HTMLElement)) {
return false;
}
const rootButton = dialogElement.querySelector('[data-testid="tree-picker-root"][data-focused="true"]');
const row = dialogElement.querySelector('.tree-row[data-focused="true"]');
const currentKey = rootButton const currentKey = rootButton
? "__root__" ? "__root__"
: row : row
@@ -188,7 +284,7 @@ async function main() {
: ""; : "";
return currentKey !== previousKey; return currentKey !== previousKey;
}, },
currentKey, { previousKey: currentKey, legacyIframe: usesLegacyIframe },
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
); );
await page.waitForFunction( await page.waitForFunction(
@@ -189,19 +189,6 @@ export function normalizeDocumentMoveWriteOperation(value: unknown): DocumentMov
}; };
} }
export function assertDocumentMoveOrderPlanMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan {
const normalizedExpected = normalizeDocumentMoveOrderPlan(expected);
if (!normalizedExpected) {
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
}
if (JSON.stringify(normalizedExpected) !== JSON.stringify(actual)) {
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
}
return normalizedExpected;
}
export function assertDocumentMoveWriteOperationMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan { export function assertDocumentMoveWriteOperationMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan {
const normalizedExpected = normalizeDocumentMoveWriteOperation(expected); const normalizedExpected = normalizeDocumentMoveWriteOperation(expected);
if (!normalizedExpected) { if (!normalizedExpected) {
+2 -9
View File
@@ -5,7 +5,6 @@ import { requireUserId } from "./_utils/auth";
import { nowIso } from "./_utils/time"; import { nowIso } from "./_utils/time";
import { buildParentById, collectSubtree, isAncestorOf } from "./_utils/documentTree"; import { buildParentById, collectSubtree, isAncestorOf } from "./_utils/documentTree";
import { import {
assertDocumentMoveOrderPlanMatches,
assertDocumentMoveWriteOperationMatches, assertDocumentMoveWriteOperationMatches,
buildDocumentMoveOrderPlanFromDocuments, buildDocumentMoveOrderPlanFromDocuments,
} from "./_utils/documentMoveOrder"; } from "./_utils/documentMoveOrder";
@@ -1400,8 +1399,7 @@ export const move = mutation({
id: v.string(), id: v.string(),
parentId: v.union(v.string(), v.null()), parentId: v.union(v.string(), v.null()),
sortOrder: v.number(), sortOrder: v.number(),
normalizedMove: v.optional(v.any()), treeWriteOperation: v.any(),
treeWriteOperation: v.optional(v.any()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx); const userId = await requireUserId(ctx);
@@ -1444,12 +1442,7 @@ export const move = mutation({
parentId: toParentId, parentId: toParentId,
sortOrder: args.sortOrder, sortOrder: args.sortOrder,
}); });
const rustMoveOrderPlan = const rustMoveOrderPlan = assertDocumentMoveWriteOperationMatches(args.treeWriteOperation, currentMoveOrderPlan);
args.treeWriteOperation != null
? assertDocumentMoveWriteOperationMatches(args.treeWriteOperation, currentMoveOrderPlan)
: args.normalizedMove != null
? assertDocumentMoveOrderPlanMatches(args.normalizedMove, currentMoveOrderPlan)
: null;
// 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order // 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order
// 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。 // 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。
@@ -0,0 +1,122 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs/promises");
const path = require("path");
const { spawnSync } = require("child_process");
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const RUST_ROOT = path.join(REPO_ROOT, "rust");
const CRATE_ROOT = path.join(RUST_ROOT, "crates", "tree-shell-runtime-wasm");
const GENERATED_ROOT = path.join(CRATE_ROOT, "generated");
const TOOLCHAIN = "1.89.0-x86_64-unknown-linux-gnu";
const TARGET = "wasm32-unknown-unknown";
const OUT_NAME = "mnote-tree-shell-runtime";
const MANIFEST_PATH = path.join(CRATE_ROOT, "Cargo.toml");
const WASM_PATH = path.join(
RUST_ROOT,
"target",
TARGET,
"debug",
"tree_shell_runtime_wasm.wasm",
);
const ENTRY_PATH = path.join(GENERATED_ROOT, `${OUT_NAME}.js`);
const OUTPUT_WASM_PATH = path.join(GENERATED_ROOT, `${OUT_NAME}_bg.wasm`);
const GITIGNORE_PATH = path.join(GENERATED_ROOT, ".gitignore");
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? REPO_ROOT,
env: process.env,
encoding: "utf8",
stdio: "pipe",
});
if (result.status === 0) {
return result;
}
const stdout = (result.stdout || "").trim();
const stderr = (result.stderr || "").trim();
const details = [stdout, stderr].filter(Boolean).join("\n");
throw new Error(
[`命令失败:${command} ${args.join(" ")}`, details].filter(Boolean).join("\n"),
);
}
async function ensureGeneratedDir() {
await fs.rm(GENERATED_ROOT, { recursive: true, force: true });
await fs.mkdir(GENERATED_ROOT, { recursive: true });
await fs.writeFile(GITIGNORE_PATH, "*\n!.gitignore\n", "utf8");
}
async function verifyOutput() {
const entrySource = await fs.readFile(ENTRY_PATH, "utf8");
await fs.access(OUTPUT_WASM_PATH);
if (!entrySource.includes("export function reduceTreeShellRuntime")) {
throw new Error("tree shell runtime js glue 缺少 reduceTreeShellRuntime 导出");
}
}
async function buildTreeShellRuntime() {
// 说明:tree shell iframe 主链直接加载固定 wasm/js 产物,因此在 Next 启动前先生成正式 artifact。
run("rustup", ["target", "add", TARGET, "--toolchain", TOOLCHAIN], { cwd: RUST_ROOT });
run(
"cargo",
[
`+${TOOLCHAIN}`,
"build",
"--manifest-path",
MANIFEST_PATH,
"--package",
"tree-shell-runtime-wasm",
"--lib",
"--target",
TARGET,
"--locked",
],
{ cwd: RUST_ROOT },
);
await ensureGeneratedDir();
run(
"wasm-bindgen",
[
WASM_PATH,
"--target",
"web",
"--out-dir",
GENERATED_ROOT,
"--out-name",
OUT_NAME,
],
{ cwd: RUST_ROOT },
);
await verifyOutput();
return {
generatedRoot: GENERATED_ROOT,
entryPath: ENTRY_PATH,
wasmPath: OUTPUT_WASM_PATH,
};
}
module.exports = {
buildTreeShellRuntime,
GENERATED_ROOT,
OUT_NAME,
};
if (require.main === module) {
buildTreeShellRuntime()
.then(({ generatedRoot }) => {
process.stdout.write(`tree shell runtime 已生成:${generatedRoot}\n`);
})
.catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
process.exit(1);
});
}
+4
View File
@@ -17,6 +17,7 @@ const net = require("net");
const path = require("path"); const path = require("path");
const next = require("next"); const next = require("next");
const { parse: parseUrl } = require("url"); const { parse: parseUrl } = require("url");
const { buildTreeShellRuntime } = require("./build-tree-shell-runtime");
const { buildLeptosTiptapIsland } = require("./build-leptos-tiptap-island"); const { buildLeptosTiptapIsland } = require("./build-leptos-tiptap-island");
const ONLYOFFICE_PREFIX = "/onlyoffice-server"; const ONLYOFFICE_PREFIX = "/onlyoffice-server";
@@ -390,6 +391,9 @@ async function main() {
const hostname = resolveHostname(); const hostname = resolveHostname();
const dev = true; const dev = true;
// 说明:tree shell iframe 通过 3000 同源 js glue + wasm 直接调用 reducer,因此 dev 启动前先生成正式 artifact。
await buildTreeShellRuntime();
// 说明:3000 主链需要直接挂载正式 Leptos island,因此在 Next dev 启动前先生成 lib.rs 的 wasm-bindgen 产物。 // 说明:3000 主链需要直接挂载正式 Leptos island,因此在 Next dev 启动前先生成 lib.rs 的 wasm-bindgen 产物。
await buildLeptosTiptapIsland(); await buildLeptosTiptapIsland();
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
let generatedRoot = "";
const JS_ASSET_NAME = "mnote-tree-shell-runtime.js";
const WASM_ASSET_NAME = "mnote-tree-shell-runtime_bg.wasm";
async function writeFixtureFile(relativePath: string, content: string | Uint8Array) {
const absolutePath = path.join(generatedRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, content);
}
describe("/api/tree-shell-runtime/[...asset] route", () => {
beforeEach(async () => {
generatedRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mnote-tree-shell-runtime-"));
process.env.TREE_SHELL_RUNTIME_GENERATED_ROOT = generatedRoot;
});
afterEach(async () => {
delete process.env.TREE_SHELL_RUNTIME_GENERATED_ROOT;
await fs.rm(generatedRoot, { recursive: true, force: true });
generatedRoot = "";
});
it("返回固定资源名 manifest,并暴露同源 js/wasm 资源路径", async () => {
await writeFixtureFile(JS_ASSET_NAME, "export function reduceTreeShellRuntime() {}\n");
await writeFixtureFile(WASM_ASSET_NAME, new Uint8Array([0x00, 0x61, 0x73, 0x6d]));
const { GET } = await import("./route");
const response = await GET(new Request("http://127.0.0.1:3000/api/tree-shell-runtime/manifest.json"), {
params: Promise.resolve({ asset: ["manifest.json"] }),
});
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({
jsGlueAssetPath: `/api/tree-shell-runtime/${JS_ASSET_NAME}`,
wasmAssetPath: `/api/tree-shell-runtime/${WASM_ASSET_NAME}`,
assetPaths: [
`/api/tree-shell-runtime/${JS_ASSET_NAME}`,
`/api/tree-shell-runtime/${WASM_ASSET_NAME}`,
],
generatedRootPath: generatedRoot.split(path.sep).join("/"),
});
});
it("返回具体 wasm/js 资源内容,并拒绝越界路径", async () => {
await writeFixtureFile(JS_ASSET_NAME, "export const runtimeVersion = 1;\n");
await writeFixtureFile(WASM_ASSET_NAME, new Uint8Array([0x00, 0x61, 0x73, 0x6d]));
const { GET } = await import("./route");
const jsResponse = await GET(
new Request(`http://127.0.0.1:3000/api/tree-shell-runtime/${JS_ASSET_NAME}`),
{ params: Promise.resolve({ asset: [JS_ASSET_NAME] }) },
);
expect(jsResponse.status).toBe(200);
expect(jsResponse.headers.get("content-type")).toContain("application/javascript");
expect(await jsResponse.text()).toContain("runtimeVersion");
const wasmResponse = await GET(
new Request(`http://127.0.0.1:3000/api/tree-shell-runtime/${WASM_ASSET_NAME}`),
{ params: Promise.resolve({ asset: [WASM_ASSET_NAME] }) },
);
expect(wasmResponse.status).toBe(200);
expect(wasmResponse.headers.get("content-type")).toBe("application/wasm");
expect(new Uint8Array(await wasmResponse.arrayBuffer())).toEqual(
new Uint8Array([0x00, 0x61, 0x73, 0x6d]),
);
const invalidResponse = await GET(
new Request("http://127.0.0.1:3000/api/tree-shell-runtime/../../secret.txt"),
{ params: Promise.resolve({ asset: ["..", "..", "secret.txt"] }) },
);
expect(invalidResponse.status).toBe(400);
expect(await invalidResponse.json()).toEqual({
error: "非法 tree shell runtime 资源路径",
});
});
});
@@ -0,0 +1,153 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const DEFAULT_GENERATED_ROOT = path.resolve(
process.cwd(),
"..",
"rust",
"crates",
"tree-shell-runtime-wasm",
"generated",
);
const JS_ASSET_NAME = "mnote-tree-shell-runtime.js";
const WASM_ASSET_NAME = "mnote-tree-shell-runtime_bg.wasm";
type TreeShellRuntimeManifest = {
jsGlueAssetPath: string;
wasmAssetPath: string;
assetPaths: string[];
generatedRootPath: string;
};
function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
}
function resolveGeneratedRoot(): string {
return path.resolve(process.env.TREE_SHELL_RUNTIME_GENERATED_ROOT || DEFAULT_GENERATED_ROOT);
}
function toAssetRoutePath(fileName: string): string {
return `/api/tree-shell-runtime/${fileName}`;
}
function sanitizeRelativePath(asset: string[]): string | null {
if (!Array.isArray(asset) || asset.length === 0) {
return null;
}
const decoded = asset.map((segment) => decodeURIComponent(segment));
const joined = decoded.join("/");
if (!joined || joined.includes("\0")) {
return null;
}
const normalized = path.posix.normalize(joined);
if (normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) {
return null;
}
return normalized;
}
async function fileExists(absolutePath: string): Promise<boolean> {
try {
await fs.access(absolutePath);
return true;
} catch {
return false;
}
}
async function buildManifest(): Promise<TreeShellRuntimeManifest> {
const generatedRoot = resolveGeneratedRoot();
const assetCandidates = [JS_ASSET_NAME, WASM_ASSET_NAME];
const existingAssets: string[] = [];
for (const assetName of assetCandidates) {
const absolutePath = path.join(generatedRoot, assetName);
if (await fileExists(absolutePath)) {
existingAssets.push(toAssetRoutePath(assetName));
}
}
return {
jsGlueAssetPath: toAssetRoutePath(JS_ASSET_NAME),
wasmAssetPath: toAssetRoutePath(WASM_ASSET_NAME),
assetPaths: existingAssets,
generatedRootPath: toPosixPath(generatedRoot),
};
}
function guessContentType(absolutePath: string): string {
const extension = path.extname(absolutePath).toLowerCase();
if (extension === ".js" || extension === ".mjs") {
return "application/javascript; charset=utf-8";
}
if (extension === ".wasm") {
return "application/wasm";
}
if (extension === ".json") {
return "application/json; charset=utf-8";
}
return "application/octet-stream";
}
export async function GET(
_request: Request,
context: { params: Promise<{ asset?: string[] }> },
) {
const params = await context.params;
const asset = params.asset ?? [];
if (asset.length === 1 && asset[0] === "manifest.json") {
try {
const manifest = await buildManifest();
return NextResponse.json(manifest, {
headers: { "Cache-Control": "no-store" },
});
} catch (error) {
return NextResponse.json(
{
error: "无法生成 tree shell runtime 清单",
detail: error instanceof Error ? error.message : "unknown",
},
{ status: 500 },
);
}
}
const relativePath = sanitizeRelativePath(asset);
if (!relativePath) {
return NextResponse.json({ error: "非法 tree shell runtime 资源路径" }, { status: 400 });
}
const generatedRoot = resolveGeneratedRoot();
const absolutePath = path.resolve(generatedRoot, relativePath);
if (!absolutePath.startsWith(generatedRoot + path.sep)) {
return NextResponse.json({ error: "越界访问 tree shell runtime 资源被拒绝" }, { status: 403 });
}
try {
const fileContent = await fs.readFile(absolutePath);
return new NextResponse(fileContent, {
status: 200,
headers: {
"Content-Type": guessContentType(absolutePath),
"Cache-Control": "no-store",
},
});
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return NextResponse.json({ error: "tree shell runtime 资源不存在" }, { status: 404 });
}
return NextResponse.json(
{
error: "读取 tree shell runtime 资源失败",
detail: error instanceof Error ? error.message : "unknown",
},
{ status: 500 },
);
}
}
@@ -1233,7 +1233,7 @@ describe("/api/tree/commands route", () => {
); );
}); });
it("embed action 走 tree.node.embed,并生成 pageReference 保存 payload", async () => { it("embed action 走 tree.node.embed,并 pageReference 组装交给 Rust Page Aggregate preflight", async () => {
const client = { const client = {
mutation: vi.fn(), mutation: vi.fn(),
query: vi.fn(async (name: string, args: { id: string }) => { query: vi.fn(async (name: string, args: { id: string }) => {
@@ -1342,18 +1342,19 @@ describe("/api/tree/commands route", () => {
targetDocumentId: "doc_target", targetDocumentId: "doc_target",
revision: 7, revision: 7,
conflictDetectionKey: "doc_target:7", conflictDetectionKey: "doc_target:7",
content: [
{ id: "anchor_1", type: "paragraph" },
{
id: expect.any(String),
type: "pageReference",
props: {
pageId: "doc_source",
title: "来源页面",
},
},
],
}), }),
preflightData: {
pageAggregateEmbed: {
sourceDocumentId: "doc_source",
sourceTitle: "来源页面",
targetDocumentId: "doc_target",
targetContent: [
{ id: "anchor_1", type: "paragraph" },
],
anchorBlockId: "anchor_1",
blockId: expect.any(String),
},
},
}), }),
); );
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith( expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
@@ -1,10 +1,8 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { Json } from "@/types/supabase";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import { import {
assertDocumentId, assertDocumentId,
assertTitle, assertTitle,
@@ -19,7 +17,6 @@ import {
copyMindmapFilesIfExists, copyMindmapFilesIfExists,
ensureDocumentScaffold, ensureDocumentScaffold,
} from "@/lib/documents/page-lifecycle-side-effects"; } from "@/lib/documents/page-lifecycle-side-effects";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data"; import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
import { import {
executeRustBridgeMutationTransport, executeRustBridgeMutationTransport,
@@ -591,32 +588,9 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
} }
const targetMeta = await client.query(api.documents.getMeta, { id: targetId }); const targetMeta = await client.query(api.documents.getMeta, { id: targetId });
const currentBlocks = extractBlocksFromContent(targetContent.content);
const anchorId = trimOrNull( const anchorId = trimOrNull(
(targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id, (targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id,
); );
const anchorIndex = anchorId
? currentBlocks.findIndex(
(block) =>
typeof block === "object" &&
block !== null &&
String((block as { id?: string }).id ?? "") === anchorId,
)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
const nextBlocks: Json[] = [
...currentBlocks.slice(0, insertIndex),
{
id: randomUUID(),
type: "pageReference",
props: {
pageId: sourceId,
title: sourceDoc.title ?? "无标题",
},
},
...currentBlocks.slice(insertIndex),
];
const nextContent = composeContentWithBlocks(targetContent.content, nextBlocks);
const workspaceId = const workspaceId =
trimOrNull(sourceDoc.workspace_id) ?? trimOrNull(sourceDoc.workspace_id) ??
trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id); trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
@@ -629,24 +603,30 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
workspaceId, workspaceId,
commandName: "tree.node.embed", commandName: "tree.node.embed",
payload: { payload: {
...buildDocumentSavePayload({
documentId: targetId, documentId: targetId,
workspaceId, workspaceId,
revision: revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision) typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision ? targetContent.revision
: null, : null,
content: nextContent,
conflictDetectionKey: conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string" typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key ? targetContent.conflict_detection_key
: null, : null,
blockCount: nextBlocks.length,
}),
sourceDocumentId: sourceId, sourceDocumentId: sourceId,
targetDocumentId: targetId, targetDocumentId: targetId,
anchorBlockId: anchorId, anchorBlockId: anchorId,
}, },
preflightData: {
pageAggregateEmbed: {
sourceDocumentId: sourceId,
sourceTitle: sourceDoc.title ?? "无标题",
targetDocumentId: targetId,
targetContent: targetContent.content,
anchorBlockId: anchorId,
blockId: randomUUID(),
},
},
pageId: targetId, pageId: targetId,
client, client,
}); });
@@ -98,6 +98,23 @@ describe("/api/tree/projections/file route", () => {
}, },
], ],
edges: [], edges: [],
meta: {
search: {
indexingVisibility: {
schema: "mnote.file_tree.indexing_visibility",
schemaVersion: 1,
source: "kernel.project_view",
status: "visible",
requestKey: "page_root:预算",
indexedResourceKinds: ["document", "index", "asset"],
visibleResourceKinds: ["document", "index", "asset"],
metrics: {
visibleRows: 2,
visibleEdges: 0,
},
},
},
},
}); });
mockDocumentBridgeErrorResponse.mockClear(); mockDocumentBridgeErrorResponse.mockClear();
}); });
@@ -130,6 +147,15 @@ describe("/api/tree/projections/file route", () => {
result: { result: {
projection: "file_tree", projection: "file_tree",
rootNodeId: "page_root", rootNodeId: "page_root",
meta: {
search: {
indexingVisibility: {
schema: "mnote.file_tree.indexing_visibility",
status: "visible",
requestKey: "page_root:预算",
},
},
},
}, },
}); });
expect(body.result.items.map((item: { rowId: string }) => item.rowId)).toEqual([ expect(body.result.items.map((item: { rowId: string }) => item.rowId)).toEqual([
@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockResolveMnoteWebInternalUrl = vi.fn();
const mockBuildForwardHeaders = vi.fn();
const mockFetch = vi.fn();
vi.mock("@/lib/mnote-web/internal-url", () => ({
resolveMnoteWebInternalUrl: (...args: unknown[]) => mockResolveMnoteWebInternalUrl(...args),
}));
vi.mock("@/lib/server/forward-headers", () => ({
buildForwardHeaders: (...args: unknown[]) => mockBuildForwardHeaders(...args),
}));
describe("/api/tree/runtime/reduce route", () => {
beforeEach(() => {
vi.resetModules();
mockResolveMnoteWebInternalUrl.mockReset();
mockBuildForwardHeaders.mockReset();
mockFetch.mockReset();
vi.stubGlobal("fetch", mockFetch);
});
it("通过 3000 同源 POST 代理到 mnote-web runtime reduce,并原样转发 JSON body 与来源 header", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(
new Headers({
cookie: "session=abc",
authorization: "Bearer test-token",
}),
);
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ ok: true, revision: 7 }), {
status: 200,
statusText: "OK",
headers: {
"content-type": "application/json; charset=utf-8",
"set-cookie": "debug=1",
connection: "keep-alive",
"transfer-encoding": "chunked",
"x-upstream": "mnote-web-runtime",
},
}),
);
const { POST } = await import("./route");
const body = JSON.stringify({
workspaceId: "ws_body",
op: "tree.node.rename",
payload: { documentId: "doc_1", title: "新标题" },
});
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/runtime/reduce?workspaceId=ws_query", {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
cookie: "session=abc",
},
body,
}),
);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/tree/runtime/reduce?workspaceId=ws_query",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body,
cache: "no-store",
redirect: "manual",
}),
);
const fetchHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("cookie")).toBe("session=abc");
expect(fetchHeaders.get("authorization")).toBe("Bearer test-token");
expect(fetchHeaders.get("content-type")).toBe("application/json");
expect(fetchHeaders.get("accept")).toBe("application/json");
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next_tree_runtime_reduce_proxy");
expect(fetchHeaders.get("x-mnote-source-client")).toBe("wolai-frontend");
expect(fetchHeaders.get("x-mnote-workspace-id")).toBe("ws_query");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("connection")).toBeNull();
expect(response.headers.get("transfer-encoding")).toBeNull();
expect(response.headers.get("x-upstream")).toBe("mnote-web-runtime");
expect(await response.json()).toEqual({ ok: true, revision: 7 });
});
it("没有 workspaceId query 时使用 x-mnote-workspace-id header fallback", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(
new Headers({
"x-mnote-workspace-id": "ws_header",
}),
);
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 202,
headers: {
"content-type": "application/json",
},
}),
);
const { POST } = await import("./route");
await POST(
new Request("http://127.0.0.1:3000/api/tree/runtime/reduce", {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-workspace-id": "ws_header",
},
body: JSON.stringify({ op: "noop" }),
}),
);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/tree/runtime/reduce",
expect.objectContaining({
method: "POST",
}),
);
const fetchHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("x-mnote-workspace-id")).toBe("ws_header");
});
it("上游调用异常时返回 502 JSON", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(new Headers());
mockFetch.mockRejectedValue(new Error("runtime reduce unavailable"));
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/runtime/reduce", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ op: "noop" }),
}),
);
expect(response.status).toBe(502);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({
error: "runtime reduce unavailable",
});
});
});
@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
import { buildForwardHeaders } from "@/lib/server/forward-headers";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const stripHopByHopHeaders = (headers: Headers) => {
// 说明:代理响应不应继续透传 hop-by-hop headers,避免浏览器拿到无效连接语义。
const hopByHopHeaders = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"content-length",
];
hopByHopHeaders.forEach((name) => headers.delete(name));
};
export async function POST(request: Request) {
try {
const requestUrl = new URL(request.url);
const internalBaseUrl = await resolveMnoteWebInternalUrl();
const targetUrl = new URL("/api/tree/runtime/reduce", `${internalBaseUrl}/`);
targetUrl.search = requestUrl.search;
const headers = await buildForwardHeaders(request);
headers.set("content-type", "application/json");
headers.set("accept", "application/json");
headers.set("x-mnote-source-channel", "next_tree_runtime_reduce_proxy");
headers.set("x-mnote-source-client", "wolai-frontend");
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim();
if (workspaceId && !headers.has("x-mnote-workspace-id")) {
headers.set("x-mnote-workspace-id", workspaceId);
}
const upstream = await fetch(targetUrl.toString(), {
method: "POST",
headers,
body: await request.text(),
cache: "no-store",
redirect: "manual",
});
const body = await upstream.arrayBuffer();
const responseHeaders = new Headers(upstream.headers);
stripHopByHopHeaders(responseHeaders);
responseHeaders.delete("set-cookie");
responseHeaders.set("cache-control", "no-store");
return new NextResponse(body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
} catch (error) {
const message = error instanceof Error ? error.message : "tree runtime reduce 代理失败";
return NextResponse.json(
{
error: message,
},
{
status: 502,
headers: {
"cache-control": "no-store",
},
},
);
}
}
@@ -2,10 +2,14 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import React, { type ReactNode } from "react"; import React, { type ReactNode } from "react";
import type { Mock } from "vitest";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { MoveEmbedPickerDialog } from "./move-embed-picker-dialog"; import { MoveEmbedPickerDialog } from "./move-embed-picker-dialog";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type SidebarFixtureNode = SidebarTreeNode;
const sidebarData = { const sidebarData = {
activeWorkspaceId: "ws_test", activeWorkspaceId: "ws_test",
workspaces: [], workspaces: [],
@@ -17,15 +21,15 @@ const sidebarData = {
items: [], items: [],
edges: [], edges: [],
}, },
kernelSidebarTree: [], kernelSidebarTree: [] as SidebarFixtureNode[],
trashedDocuments: [], trashedDocuments: [],
}; };
function buildSidebarNode(input: { function buildSidebarNode(input: {
id: string; id: string;
title: string; title: string;
children?: Array<ReturnType<typeof buildSidebarNode>>; children?: SidebarFixtureNode[];
}) { }): SidebarFixtureNode {
return { return {
access_scope: "private", access_scope: "private",
id: input.id, id: input.id,
@@ -66,12 +70,89 @@ vi.mock("@tanstack/react-query", () => ({
}, },
})); }));
const mockUseDocumentSearch = vi.fn(() => ({ const mockUseDocumentSearch: Mock = vi.fn(() => ({
data: null, data: null,
isLoading: false, isLoading: false,
error: null, error: null,
})); }));
type RuntimeRequest = {
requestId: string;
mode: "page" | "fileTree" | "picker";
environment?: Record<string, unknown>;
state?: Record<string, unknown>;
action?: Record<string, unknown>;
};
type TreeShellRuntimeTestGlobal = typeof globalThis & {
__MNOTE_TREE_SHELL_RUNTIME__?: {
reduceTreeShellRuntime: Mock;
};
};
function reducePickerRuntimeForTest(request: RuntimeRequest) {
const action = request.action ?? {};
const env = request.environment ?? {};
const items = Array.isArray(env.items) ? env.items as Array<Record<string, unknown>> : [];
const state = request.state ?? {};
const pickable = items.filter((item) => item.pickable !== false);
const activeItemKey = typeof state.activeItemKey === "string" ? state.activeItemKey : null;
const activeIndex = Math.max(0, pickable.findIndex((item) => item.itemKey === activeItemKey));
let nextItemKey = activeItemKey;
if (action.kind === "focus") {
nextItemKey = typeof action.itemKey === "string" ? action.itemKey : null;
} else if (action.kind === "next") {
nextItemKey = String(pickable[Math.min(pickable.length - 1, activeIndex + 1)]?.itemKey ?? "");
} else if (action.kind === "previous") {
nextItemKey = String(pickable[Math.max(0, activeIndex - 1)]?.itemKey ?? "");
} else if (action.kind === "home") {
nextItemKey = String(pickable[0]?.itemKey ?? "");
} else if (action.kind === "end") {
nextItemKey = String(pickable[pickable.length - 1]?.itemKey ?? "");
}
const nextState = { activeItemKey: nextItemKey || null };
const hostEvents =
action.kind === "pick"
? activeItemKey === "__root__"
? [{ kind: "pickerPickRoot" }]
: activeItemKey
? [{ kind: "pickerPickDocument", documentId: activeItemKey }]
: []
: [];
return {
requestId: request.requestId,
mode: "picker",
state: { mode: "picker", state: nextState },
domPatches: [{ kind: "pickerState", activeItemKey: nextState.activeItemKey, focusDom: false }],
hostEvents,
commandEvents: [],
};
}
function mockTreeRuntimeReducer() {
const reduceTreeShellRuntime = vi.fn(async (request: RuntimeRequest) =>
reducePickerRuntimeForTest(request),
);
(globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__ = {
reduceTreeShellRuntime,
};
return reduceTreeShellRuntime;
}
async function waitForRuntimeReducer(reducerMock: Mock) {
for (let index = 0; index < 10; index += 1) {
if (reducerMock.mock.calls.length > 0) {
return;
}
await act(async () => {
await Promise.resolve();
});
}
}
vi.mock("@/hooks/use-document-search", () => ({ vi.mock("@/hooks/use-document-search", () => ({
useDocumentSearch: (...args: unknown[]) => mockUseDocumentSearch(...args), useDocumentSearch: (...args: unknown[]) => mockUseDocumentSearch(...args),
})); }));
@@ -142,6 +223,7 @@ describe("MoveEmbedPickerDialog", () => {
error: null, error: null,
}); });
sidebarData.kernelSidebarTree = []; sidebarData.kernelSidebarTree = [];
delete (globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__;
delete window.__MNOTE_RUNTIME_CONFIG__; delete window.__MNOTE_RUNTIME_CONFIG__;
}); });
@@ -438,6 +520,7 @@ describe("MoveEmbedPickerDialog", () => {
window.__MNOTE_RUNTIME_CONFIG__ = { window.__MNOTE_RUNTIME_CONFIG__ = {
treeRendererFamily: "rust_family", treeRendererFamily: "rust_family",
}; };
mockTreeRuntimeReducer();
const onPick = vi.fn(async () => undefined); const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn(); const onOpenChange = vi.fn();
@@ -458,13 +541,15 @@ describe("MoveEmbedPickerDialog", () => {
const emptySurface = container.querySelector('[data-testid="tree-picker-surface"]'); const emptySurface = container.querySelector('[data-testid="tree-picker-surface"]');
expect(emptySurface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); expect(emptySurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(emptySurface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); expect(emptySurface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect( expect(
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'), container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
).not.toBeNull(); ).not.toBeNull();
expect( expect(
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'), container.querySelector('[data-testid="tree-picker-surface-dom-host"]'),
).not.toBeNull(); ).not.toBeNull();
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
mockUseDocumentSearch.mockReturnValue({ mockUseDocumentSearch.mockReturnValue({
data: { data: {
@@ -494,19 +579,22 @@ describe("MoveEmbedPickerDialog", () => {
const resultSurface = container.querySelector('[data-testid="tree-picker-surface"]'); const resultSurface = container.querySelector('[data-testid="tree-picker-surface"]');
expect(resultSurface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); expect(resultSurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(resultSurface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); expect(resultSurface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect( expect(
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'), container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
).not.toBeNull(); ).not.toBeNull();
expect( expect(
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'), container.querySelector('[data-testid="tree-picker-surface-dom-host"]'),
).not.toBeNull(); ).not.toBeNull();
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_target"]')).not.toBeNull();
}); });
it("rust_family 配置下,输入框键盘命令应转发给 iframe,并用焦点回传更新 shell 状态", async () => { it("rust_family 配置下,输入框键盘命令应驱动 DOM shell 高亮并选中当前项", async () => {
window.__MNOTE_RUNTIME_CONFIG__ = { window.__MNOTE_RUNTIME_CONFIG__ = {
treeRendererFamily: "rust_family", treeRendererFamily: "rust_family",
}; };
const runtimeReducerMock = mockTreeRuntimeReducer();
mockUseDocumentSearch.mockReturnValue({ mockUseDocumentSearch.mockReturnValue({
data: { data: {
results: [ results: [
@@ -525,23 +613,19 @@ describe("MoveEmbedPickerDialog", () => {
isLoading: false, isLoading: false,
error: null, error: null,
}); });
vi.spyOn(globalThis, "fetch").mockResolvedValue( const onPick = vi.fn(async () => undefined);
new Response( const onOpenChange = vi.fn();
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
{ status: 200, headers: { "content-type": "text/html" } },
),
);
await act(async () => { await act(async () => {
root.render( root.render(
<MoveEmbedPickerDialog <MoveEmbedPickerDialog
open open
onOpenChange={vi.fn()} onOpenChange={onOpenChange}
workspaceId="ws_test" workspaceId="ws_test"
defaultMode="move" defaultMode="move"
allowRoot allowRoot
excludeIds={[]} excludeIds={[]}
onPick={vi.fn(async () => undefined)} onPick={onPick}
/>, />,
); );
}); });
@@ -558,80 +642,41 @@ describe("MoveEmbedPickerDialog", () => {
input?.dispatchEvent(new Event("change", { bubbles: true })); input?.dispatchEvent(new Event("change", { bubbles: true }));
}); });
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'); expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
expect(iframe).not.toBeNull(); expect(container.querySelector('[data-testid="tree-picker-surface-dom-host"]')).not.toBeNull();
Object.defineProperty(iframe, "contentWindow", { expect(
configurable: true, container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_first"]')?.getAttribute("data-focused"),
value: window, ).toBe("true");
});
const postMessageMock = vi.spyOn(window, "postMessage").mockImplementation(() => undefined);
postMessageMock.mockClear();
await act(async () => { await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
await Promise.resolve();
}); });
await waitForRuntimeReducer(runtimeReducerMock);
expect(postMessageMock).toHaveBeenCalledWith( expect(runtimeReducerMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
channel: "tree-picker-surface", mode: "picker",
type: "tree.picker.command", action: expect.objectContaining({ kind: "next" }),
command: "next",
}),
"*",
);
postMessageMock.mockClear();
await act(async () => {
window.dispatchEvent(
new MessageEvent("message", {
data: {
channel: "tree-picker-surface",
type: "tree.picker.focus.changed",
documentId: "doc_second",
itemKey: "doc_second",
},
source: window,
}), }),
); );
}); expect(
container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_second"]')?.getAttribute("data-focused"),
expect(postMessageMock).toHaveBeenCalledWith( ).toBe("true");
expect.objectContaining({
channel: "tree-picker-surface",
type: "tree.shell.state.patch",
activeDocumentId: "doc_second",
activePickerItemKey: "doc_second",
}),
"*",
);
postMessageMock.mockClear();
await act(async () => { await act(async () => {
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
}); });
expect(postMessageMock).toHaveBeenCalledWith( expect(onPick).toHaveBeenCalledWith("move", "doc_second");
expect.objectContaining({ expect(onOpenChange).toHaveBeenCalledWith(false);
channel: "tree-picker-surface",
type: "tree.picker.command",
command: "pick",
}),
"*",
);
}); });
it("rust_family 配置下,无根目录且无结果时也应保持 same-origin host 空态", async () => { it("rust_family 配置下,无根目录且无结果时也应保持 DOM shell 空态", async () => {
window.__MNOTE_RUNTIME_CONFIG__ = { window.__MNOTE_RUNTIME_CONFIG__ = {
treeRendererFamily: "rust_family", treeRendererFamily: "rust_family",
}; };
vi.spyOn(globalThis, "fetch").mockResolvedValue( mockTreeRuntimeReducer();
new Response(
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
{ status: 200, headers: { "content-type": "text/html" } },
),
);
const onPick = vi.fn(async () => undefined); const onPick = vi.fn(async () => undefined);
const onOpenChange = vi.fn(); const onOpenChange = vi.fn();
@@ -655,12 +700,14 @@ describe("MoveEmbedPickerDialog", () => {
}); });
const surface = container.querySelector('[data-testid="tree-picker-surface"]'); const surface = container.querySelector('[data-testid="tree-picker-surface"]');
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'); const domHost = container.querySelector('[data-testid="tree-picker-surface-dom-host"]');
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(iframe).not.toBeNull(); expect(domHost).not.toBeNull();
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1"); expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
expect(container.textContent).toContain("没有匹配结果");
}); });
}); });
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
"use client";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
export type TreeShellPickerItem =
| { kind: "root"; id: null; title: string; subtitle?: string; depth?: number }
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number };
export type TreeShellDomProjectionItem = {
rowId?: string;
nodeId: string;
parentNodeId: string | null;
title: string;
depth: number;
childCount: number;
position: number;
expandedByDefault: boolean;
rowKind?: string;
iconHint?: string;
capabilities?: string[];
resourceMeta?: {
resourceKind?: string;
documentId?: string;
assetId?: string;
workspaceId?: string;
assetKind?: string;
iconHint?: string;
};
};
const normalizeString = (value: unknown, fallback = "") => {
if (typeof value !== "string") {
return fallback;
}
const trimmed = value.trim();
return trimmed || fallback;
};
export function buildTreeShellDomPickerItems(
items: TreeShellPickerItem[],
): TreeShellDomProjectionItem[] {
return items
.filter(
(item): item is Extract<TreeShellPickerItem, { kind: "doc"; id: string }> =>
item.kind === "doc" && Boolean(normalizeString(item.id)),
)
.map((item, index) => ({
nodeId: normalizeString(item.id),
parentNodeId: null,
title: normalizeString(item.title, "无标题"),
depth: typeof item.depth === "number" && Number.isFinite(item.depth) ? Math.max(0, item.depth) : 0,
childCount: 0,
position: index,
expandedByDefault: false,
}));
}
export function buildTreeShellDomPageItems(
items: PageTreeProjectionItem[],
expanded: ReadonlySet<string> = new Set(),
): TreeShellDomProjectionItem[] {
return items
.map((item) => ({
rowId: normalizeString(item.rowId),
nodeId: normalizeString(item.nodeId),
parentNodeId: item.parentNodeId ?? null,
title: normalizeString(item.title, "无标题"),
depth: typeof item.depth === "number" && Number.isFinite(item.depth) ? Math.max(0, item.depth) : 0,
childCount:
typeof item.childCount === "number" && Number.isFinite(item.childCount)
? Math.max(0, item.childCount)
: 0,
position: typeof item.position === "number" && Number.isFinite(item.position) ? item.position : 0,
expandedByDefault: item.childCount > 0 ? expanded.has(item.nodeId) : false,
rowKind: "document",
iconHint: normalizeString(item.iconHint, "page"),
capabilities: Array.isArray(item.capabilities)
? item.capabilities.map((capability) => normalizeString(capability)).filter(Boolean)
: [],
resourceMeta: {
resourceKind: normalizeString(item.resourceMeta?.resourceKind, "document"),
documentId: normalizeString(item.resourceMeta?.documentId ?? item.nodeId),
workspaceId: normalizeString(item.resourceMeta?.workspaceId),
iconHint: normalizeString(item.resourceMeta?.iconHint, "page"),
},
}))
.filter((item) => Boolean(item.nodeId));
}
export function buildTreeShellDomKernelFileTreeItems(
items: KernelFileTreeProjectionItem[],
): TreeShellDomProjectionItem[] {
return items
.map((item) => ({
rowId: normalizeString(item.rowId),
nodeId: normalizeString(item.nodeId),
parentNodeId: item.parentNodeId ?? null,
title: normalizeString(item.title, "无标题"),
depth: typeof item.depth === "number" && Number.isFinite(item.depth) ? Math.max(0, item.depth) : 0,
childCount:
typeof item.childCount === "number" && Number.isFinite(item.childCount)
? Math.max(0, item.childCount)
: 0,
position: typeof item.position === "number" && Number.isFinite(item.position) ? item.position : 0,
expandedByDefault: item.expandedByDefault === true,
rowKind: normalizeString(item.rowKind, "document"),
iconHint: normalizeString(item.iconHint, "page"),
capabilities: Array.isArray(item.capabilities)
? item.capabilities.map((capability) => normalizeString(capability)).filter(Boolean)
: [],
resourceMeta: {
resourceKind: normalizeString(item.resourceMeta?.resourceKind),
documentId: normalizeString(item.resourceMeta?.documentId),
assetId: normalizeString(item.resourceMeta?.assetId),
workspaceId: normalizeString(item.resourceMeta?.workspaceId),
assetKind: normalizeString(item.resourceMeta?.assetKind),
iconHint: normalizeString(item.resourceMeta?.iconHint, normalizeString(item.iconHint, "page")),
},
}))
.filter((item) => Boolean(item.nodeId));
}
export function buildTreeShellDomChildrenByParent(items: TreeShellDomProjectionItem[]) {
const ids = new Set(items.map((item) => item.nodeId));
const childrenByParent = new Map<string, TreeShellDomProjectionItem[]>();
for (const item of items) {
const parentId = item.parentNodeId && ids.has(item.parentNodeId) ? item.parentNodeId : "";
const bucket = childrenByParent.get(parentId) ?? [];
bucket.push(item);
childrenByParent.set(parentId, bucket);
}
for (const bucket of childrenByParent.values()) {
bucket.sort((left, right) => {
const byPosition = left.position - right.position;
if (byPosition !== 0) return byPosition;
return left.title.localeCompare(right.title, "zh-CN");
});
}
return childrenByParent;
}
export function buildTreeShellDomFiletreeSelection(activeDocumentId?: string | null) {
const documentId = normalizeString(activeDocumentId);
if (!documentId) {
return {
selectedRowIds: [] as string[],
anchorRowId: null as string | null,
focusedRowId: null as string | null,
};
}
const documentRowId = `doc:${documentId}`;
return {
selectedRowIds: [documentRowId, `index:${documentId}`],
anchorRowId: documentRowId,
focusedRowId: documentRowId,
};
}
@@ -0,0 +1,82 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TreeShellHost } from "./tree-shell-host";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("tree-shell-host", () => {
let container: HTMLDivElement;
let root: Root;
let previousLegacyFlag: string | undefined;
beforeEach(() => {
previousLegacyFlag = process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
delete process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
if (previousLegacyFlag === undefined) {
delete process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
} else {
process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST = previousLegacyFlag;
}
});
function renderHost() {
act(() => {
root.render(
<TreeShellHost
mode="page"
surfaceTestId="sidebar-page-tree-shell"
rendererFamily="rust_family"
workspaceId="ws_1"
>
<div data-testid="legacy-react-fallback"> React fallback</div>
</TreeShellHost>,
);
});
}
it("rust_family 默认选择 Rust/WASM DOM shell host,不能进入 iframe_srcdoc", () => {
renderHost();
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
const rustHost = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-host"]');
const domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
expect(container.querySelector('[data-testid="legacy-react-fallback"]')).toBeNull();
});
it("只有显式 legacy flag 才允许进入旧 TreeShellIframeHost", async () => {
process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST = "1";
await act(async () => {
renderHost();
await Promise.resolve();
});
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_runtime_artifact_host");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]')).toBeNull();
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("data-tree-browser-bridge")).toBe("iframe_srcdoc");
expect(iframe?.getAttribute("srcdoc")).toContain('"browserBridge":"iframe_srcdoc"');
});
});
@@ -1,9 +1,10 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { TreeShellRustDomShellHost } from "@/components/sidebar/tree-shell-dom-host";
import type { TreeShellPickerItem } from "@/components/sidebar/tree-shell-dom-model";
import { import {
TreeShellIframeHost, TreeShellIframeHost,
type TreeShellPickerItem,
} from "@/components/sidebar/tree-shell-iframe-host"; } from "@/components/sidebar/tree-shell-iframe-host";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree"; import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { PageTreeProjectionItem } from "@/lib/tree-projection"; import type { PageTreeProjectionItem } from "@/lib/tree-projection";
@@ -14,6 +15,8 @@ export type TreeRendererFamily = "react" | "rust_family";
export type TreeShellHostMode = "page" | "filetree" | "picker"; export type TreeShellHostMode = "page" | "filetree" | "picker";
const RUST_RENDERER_CONTRACT = "rust_renderer_input_v1"; const RUST_RENDERER_CONTRACT = "rust_renderer_input_v1";
const RUST_WASM_DOM_SHELL_HOST = "rust_wasm_dom_shell_host";
const RUST_LEGACY_IFRAME_HOST = "rust_runtime_artifact_host";
export type TreeShellPickerCommand = { export type TreeShellPickerCommand = {
kind: "next" | "previous" | "home" | "end" | "pick"; kind: "next" | "previous" | "home" | "end" | "pick";
@@ -118,11 +121,17 @@ export function TreeShellHost({
children, children,
}: TreeShellHostProps) { }: TreeShellHostProps) {
const useRustHost = rendererFamily === "rust_family"; const useRustHost = rendererFamily === "rust_family";
const useIframeHost = useRustHost && Boolean(workspaceId?.trim()); const useDomHost = useRustHost && Boolean(workspaceId?.trim());
const useLegacyIframeHost =
useDomHost &&
typeof process !== "undefined" &&
process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST === "1";
const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react"; const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react";
const rendererContract = useRustHost ? RUST_RENDERER_CONTRACT : undefined; const rendererContract = useRustHost ? RUST_RENDERER_CONTRACT : undefined;
const implementation = useIframeHost const implementation = useLegacyIframeHost
? "rust_inline_compat_host" ? RUST_LEGACY_IFRAME_HOST
: useDomHost
? RUST_WASM_DOM_SHELL_HOST
: fallbackImplementation ?? : fallbackImplementation ??
(rendererFamily === "rust_family" ? "react_fallback" : "react_primary"); (rendererFamily === "rust_family" ? "react_fallback" : "react_primary");
@@ -146,7 +155,8 @@ export function TreeShellHost({
data-tree-renderer-contract={rendererContract} data-tree-renderer-contract={rendererContract}
className="contents" className="contents"
> >
{useIframeHost && workspaceId ? ( {useDomHost && workspaceId ? (
useLegacyIframeHost ? (
<TreeShellIframeHost <TreeShellIframeHost
mode={mode} mode={mode}
surfaceTestId={surfaceTestId} surfaceTestId={surfaceTestId}
@@ -176,6 +186,39 @@ export function TreeShellHost({
onAssetOpen={onAssetOpen} onAssetOpen={onAssetOpen}
onTreeMutation={onTreeMutation} onTreeMutation={onTreeMutation}
/> />
) : (
<TreeShellRustDomShellHost
mode={mode}
surfaceTestId={surfaceTestId}
workspaceId={workspaceId}
rootNodeId={rootNodeId}
activeDocumentId={activeDocumentId}
focusedDocumentId={focusedDocumentId}
activePickerItemKey={activePickerItemKey}
allowRootPick={allowRootPick}
excludeIds={excludeIds}
pickerCommand={pickerCommand}
pickerItems={pickerItems}
pageTreeItems={pageTreeItems}
inlineFileTreeItems={inlineFileTreeItems}
channel={channel}
host={host}
onNavigate={onNavigate}
onPick={onPick}
onPickerFocusChange={onPickerFocusChange}
onPageContextMenu={onPageContextMenu}
onPageExpandChange={onPageExpandChange}
onPageFocusChange={onPageFocusChange}
onFileTreeContextMenu={onFileTreeContextMenu}
onFileTreeSelectionChange={onFileTreeSelectionChange}
onInternalDrop={onInternalDrop}
onDropFiles={onDropFiles}
onAssetOpen={onAssetOpen}
onTreeMutation={onTreeMutation}
>
{children}
</TreeShellRustDomShellHost>
)
) : ( ) : (
children children
)} )}
@@ -286,11 +286,52 @@ describe("tree-shell-iframe-host", () => {
expect(iframe?.getAttribute("srcdoc")).toContain("patchPageTreeExpansionDom(nodeId)"); 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_page_focus_keyboard_reducer_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"'); expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"family":"rust_family"');
expect(iframe?.getAttribute("srcdoc")).toContain('"executionStrategy":"browser_bridge"');
expect(iframe?.getAttribute("srcdoc")).toContain('"browserBridge":"iframe_srcdoc"');
expect(iframe?.getAttribute("srcdoc")).toContain('"wasmModuleUrl":"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm"');
expect(iframe?.getAttribute("srcdoc")).toContain('"jsGlueUrl":"/api/tree-shell-runtime/mnote-tree-shell-runtime.js"');
expect(iframe?.getAttribute("srcdoc")).toContain('"inputFields":["rendererInput","projectionItems","expandedIds","selectedRowIds","activePickerItem","focusedId"]'); 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('"outputChannels":["domPatch","intentEvent","commandDispatchEvent"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeApi":{"requestContract":"TreeShellRuntimeRequest","resultContract":"TreeShellRuntimeResult"');
expect(iframe?.getAttribute("srcdoc")).toContain('"reduceEndpoint":"/api/tree/runtime/reduce"');
expect(iframe?.getAttribute("srcdoc")).toContain('"domPatchKinds":["pageState","fileTreeState","pickerState"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"hostEventKinds":["pageOpen","pageContextMenu","fileTreeOpen","fileTreeContextMenu","fileTreeInternalDrop","fileTreeExternalDrop","pickerPickRoot","pickerPickDocument"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"commandEventKinds":["createNode","renameNode","moveSubtree","copyResource","moveResource","uploadResource"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"eventKinds":["focus","keyboard","expandCollapse","selection","contextMenu","dragDrop","pick"]'); expect(iframe?.getAttribute("srcdoc")).toContain('"eventKinds":["focus","keyboard","expandCollapse","selection","contextMenu","dragDrop","pick"]');
expect(iframe?.getAttribute("srcdoc")).toContain("applyPageKeyboardAction"); expect(iframe?.getAttribute("srcdoc")).toContain("applyPageKeyboardAction");
expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "page" && usedRustInitialRenderer) {patchPageTreeActiveDom();if (focusedNodeId) focusRowElement(focusedNodeId);return;}'); expect(iframe?.getAttribute("srcdoc")).toContain("loadTreeShellWasmRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceTreeShellRuntimeViaWasm");
expect(iframe?.getAttribute("srcdoc")).toContain("reducePageActionWithRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceTreeShellRuntimeWithArtifact");
expect(iframe?.getAttribute("srcdoc")).toContain('mode: "page"');
expect(iframe?.getAttribute("srcdoc")).toContain("buildPageRuntimeEnvironment");
expect(iframe?.getAttribute("srcdoc")).toContain("rows: normalizedItems.map((entry) => ({nodeId: entry.nodeId,parentNodeId: normalizeText(entry.parentNodeId) || null,position: normalizeNumber(entry.position, 0),}))");
expect(iframe?.getAttribute("srcdoc")).toContain("moveNext");
expect(iframe?.getAttribute("srcdoc")).toContain("openFocused");
expect(iframe?.getAttribute("srcdoc")).toContain("contextMenuFocused");
expect(iframe?.getAttribute("srcdoc")).toContain("updateDropFeedback");
expect(iframe?.getAttribute("srcdoc")).toContain("updateDropFeedbackForTarget");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchMove");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchMoveToTarget");
expect(iframe?.getAttribute("srcdoc")).toContain("commandEvent.sortOrder");
expect(iframe?.getAttribute("srcdoc")).toContain("runtimeRequired: true");
expect(iframe?.getAttribute("srcdoc")).not.toContain("canAcceptPageDrop");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchCreate");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchRename");
expect(iframe?.getAttribute("srcdoc")).toContain("pageOpen");
expect(iframe?.getAttribute("srcdoc")).toContain("replayPageRuntimeHostEvents");
expect(iframe?.getAttribute("srcdoc")).toContain("replayPageRuntimeCommandEvents");
expect(iframe?.getAttribute("srcdoc")).toContain("commandEvents");
expect(iframe?.getAttribute("srcdoc")).toContain('event.kind === "createNode"');
expect(iframe?.getAttribute("srcdoc")).toContain('event.kind === "renameNode"');
expect(iframe?.getAttribute("srcdoc")).toContain("dropFeedback");
expect(iframe?.getAttribute("srcdoc")).toContain("pagePatch?.dropFeedback");
expect(iframe?.getAttribute("srcdoc")).toContain("stateSnapshot?.dropFeedback");
expect(iframe?.getAttribute("srcdoc")).toContain("data-drop-feedback");
expect(iframe?.getAttribute("srcdoc")).toContain('event.kind === "moveSubtree"');
expect(iframe?.getAttribute("srcdoc")).toContain('postToHost("tree.subtree.moved"');
expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "page" && usedRustInitialRenderer) {patchPageTreeActiveDom();syncPageDropFeedbackDom();if (focusedNodeId) focusRowElement(focusedNodeId);return;}');
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).items).toEqual([ expect(readTreeShellState(iframe?.getAttribute("srcdoc")).items).toEqual([
expect.objectContaining({ nodeId: "doc_parent" }), expect.objectContaining({ nodeId: "doc_parent" }),
expect.objectContaining({ nodeId: "doc_child", parentNodeId: "doc_parent" }), expect.objectContaining({ nodeId: "doc_child", parentNodeId: "doc_parent" }),
@@ -465,7 +506,7 @@ describe("tree-shell-iframe-host", () => {
expect(iframe?.getAttribute("srcdoc")).toContain("hydrateInitialFileTree"); expect(iframe?.getAttribute("srcdoc")).toContain("hydrateInitialFileTree");
expect(iframe?.getAttribute("srcdoc")).toContain("const usedRustInitialRenderer = hydrateInitialRenderer();"); expect(iframe?.getAttribute("srcdoc")).toContain("const usedRustInitialRenderer = hydrateInitialRenderer();");
expect(iframe?.getAttribute("srcdoc")).toContain("patchFileTreeActiveDom();"); expect(iframe?.getAttribute("srcdoc")).toContain("patchFileTreeActiveDom();");
expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "filetree" && usedRustInitialRenderer) {patchFileTreeActiveDom();syncFileTreeSelectionDom();return;}'); expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "filetree" && usedRustInitialRenderer) {patchFileTreeActiveDom();syncFileTreeSelectionDom();syncFileTreeDropTargetDom();return;}');
expect(iframe?.getAttribute("srcdoc")).toContain('"rendererInput"'); expect(iframe?.getAttribute("srcdoc")).toContain('"rendererInput"');
expect(iframe?.getAttribute("srcdoc")).toContain('"mode":"fileTree"'); expect(iframe?.getAttribute("srcdoc")).toContain('"mode":"fileTree"');
expect(iframe?.getAttribute("srcdoc")).toContain('"expandedIds":["doc_a"]'); expect(iframe?.getAttribute("srcdoc")).toContain('"expandedIds":["doc_a"]');
@@ -474,11 +515,45 @@ describe("tree-shell-iframe-host", () => {
expect(iframe?.getAttribute("srcdoc")).toContain('"selectedRowIds":["doc:doc_a","index:doc_a"]'); 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_filetree_selection_reducer_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"'); expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"family":"rust_family"');
expect(iframe?.getAttribute("srcdoc")).toContain('"executionStrategy":"browser_bridge"');
expect(iframe?.getAttribute("srcdoc")).toContain('"browserBridge":"iframe_srcdoc"');
expect(iframe?.getAttribute("srcdoc")).toContain('"wasmModuleUrl":"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm"');
expect(iframe?.getAttribute("srcdoc")).toContain('"jsGlueUrl":"/api/tree-shell-runtime/mnote-tree-shell-runtime.js"');
expect(iframe?.getAttribute("srcdoc")).toContain("applyFileTreeSelectionAction"); expect(iframe?.getAttribute("srcdoc")).toContain("applyFileTreeSelectionAction");
expect(iframe?.getAttribute("srcdoc")).toContain("loadTreeShellWasmRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceTreeShellRuntimeViaWasm");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceFileTreeSelectionActionWithRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceTreeShellRuntimeWithArtifact");
expect(iframe?.getAttribute("srcdoc")).toContain('mode: "fileTree"');
expect(iframe?.getAttribute("srcdoc")).toContain("runtimeArtifact.runtimeApi.reduceEndpoint");
expect(iframe?.getAttribute("srcdoc")).toContain("computeFileTreeSelectionActionResult(action)");
expect(iframe?.getAttribute("srcdoc")).toContain('kind: "update_drop_target"');
expect(iframe?.getAttribute("srcdoc")).toContain("updateDropTarget");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchInternalDrop");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchExternalDrop");
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchFileTreeDropWithRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("fileTreeInternalDrop");
expect(iframe?.getAttribute("srcdoc")).toContain("fileTreeExternalDrop");
expect(iframe?.getAttribute("srcdoc")).toContain("const fallbackRowId = firstItem?.rowId || firstDoc || \"\";");
expect(iframe?.getAttribute("srcdoc")).toContain("replayFileTreeRuntimeHostEvents");
expect(iframe?.getAttribute("srcdoc")).toContain("runtimeResult && fallbackHostEvent.runtimeRequired === true");
expect(iframe?.getAttribute("srcdoc")).toContain("syncFileTreeDropTargetDom");
expect(iframe?.getAttribute("srcdoc")).toContain("row.dataset.dropTarget");
expect(iframe?.getAttribute("srcdoc")).toContain("commitFileTreeRuntimeState");
expect(iframe?.getAttribute("srcdoc")).toContain("const rendererFiletreeSelection ="); expect(iframe?.getAttribute("srcdoc")).toContain("const rendererFiletreeSelection =");
expect(iframe?.getAttribute("srcdoc")).toContain("rendererFiletreeSelection.selectedRowIds"); expect(iframe?.getAttribute("srcdoc")).toContain("rendererFiletreeSelection.selectedRowIds");
expect(iframe?.getAttribute("srcdoc")).toContain("computeFileTreeSelectionActionResult"); expect(iframe?.getAttribute("srcdoc")).toContain("computeFileTreeSelectionActionResult");
expect(iframe?.getAttribute("srcdoc")).toContain("const selectFileTreeContextRow = (rowId) =>"); expect(iframe?.getAttribute("srcdoc")).toContain("const selectFileTreeContextRow = (rowId) =>");
const fileTreeFallbackRenderBody =
iframe?.getAttribute("srcdoc")?.match(/const renderFileTree = \(\) => \{(?<body>[\s\S]*?)const renderNode =/)?.groups
?.body ?? "";
expect(fileTreeFallbackRenderBody).toContain(
'toggleButton.addEventListener("click", (event) => {event.stopPropagation();toggleExpand(item.nodeId);});',
);
expect(fileTreeFallbackRenderBody).not.toContain(
'applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, event.currentTarget);',
);
expect(iframe?.getAttribute("srcdoc")).toContain("guide.pdf"); expect(iframe?.getAttribute("srcdoc")).toContain("guide.pdf");
const state = readTreeShellState(iframe?.getAttribute("srcdoc")); const state = readTreeShellState(iframe?.getAttribute("srcdoc"));
expect(state.items).toEqual([ expect(state.items).toEqual([
@@ -532,20 +607,45 @@ describe("tree-shell-iframe-host", () => {
expect(iframe?.getAttribute("srcdoc")).toContain('"excludedPickerIds":["doc_hidden"]'); 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_picker_state_reducer_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"'); expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"family":"rust_family"');
expect(iframe?.getAttribute("srcdoc")).toContain('"executionStrategy":"browser_bridge"');
expect(iframe?.getAttribute("srcdoc")).toContain('"browserBridge":"iframe_srcdoc"');
expect(iframe?.getAttribute("srcdoc")).toContain('"wasmModuleUrl":"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm"');
expect(iframe?.getAttribute("srcdoc")).toContain('"jsGlueUrl":"/api/tree-shell-runtime/mnote-tree-shell-runtime.js"');
expect(iframe?.getAttribute("srcdoc")).toContain("applyPickerStateAction"); expect(iframe?.getAttribute("srcdoc")).toContain("applyPickerStateAction");
expect(iframe?.getAttribute("srcdoc")).toContain("loadTreeShellWasmRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceTreeShellRuntimeViaWasm");
expect(iframe?.getAttribute("srcdoc")).toContain("reducePickerStateActionWithRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("reduceTreeShellRuntimeWithArtifact");
expect(iframe?.getAttribute("srcdoc")).toContain('mode: "picker"');
expect(iframe?.getAttribute("srcdoc")).toContain("buildPickerRuntimeEnvironment");
expect(iframe?.getAttribute("srcdoc")).toContain("runtimeArtifact.runtimeApi.reduceEndpoint");
expect(iframe?.getAttribute("srcdoc")).toContain("pickerPickDocument");
expect(iframe?.getAttribute("srcdoc")).toContain("patchPickerActiveDom"); expect(iframe?.getAttribute("srcdoc")).toContain("patchPickerActiveDom");
expect(iframe?.getAttribute("srcdoc")).toContain("getPickablePickerEntries"); expect(iframe?.getAttribute("srcdoc")).toContain("getPickablePickerEntries");
expect(iframe?.getAttribute("srcdoc")).toContain("postPickerPickResultToHost"); expect(iframe?.getAttribute("srcdoc")).toContain("postPickerPickResultToHost");
expect(iframe?.getAttribute("srcdoc")).toContain('tabindex="0"'); expect(iframe?.getAttribute("srcdoc")).toContain('tabindex="0"');
expect(iframe?.getAttribute("srcdoc")).toContain('tabindex="-1"'); expect(iframe?.getAttribute("srcdoc")).toContain('tabindex="-1"');
expect(iframe?.getAttribute("srcdoc")).toContain('applyPickerFocusByItemKey("__root__", { focusDom: true })'); expect(iframe?.getAttribute("srcdoc")).toContain("dispatchPickerPickByItemKeyWithRuntime");
expect(iframe?.getAttribute("srcdoc")).toContain("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })"); expect(iframe?.getAttribute("srcdoc")).toContain('dispatchPickerPickByItemKeyWithRuntime("__root__", { focusDom: true })');
expect(iframe?.getAttribute("srcdoc")).toContain("dispatchPickerPickByItemKeyWithRuntime(item.nodeId, { focusDom: true })");
expect(iframe?.getAttribute("srcdoc")).not.toContain('applyPickerFocusByItemKey("__root__", { focusDom: true });applyPickerStateAction({ kind: "pick" })');
expect(iframe?.getAttribute("srcdoc")).not.toContain('applyPickerFocusByItemKey(item.nodeId, { focusDom: true });applyPickerStateAction({ kind: "pick" })');
expect(iframe?.getAttribute("srcdoc")).toContain("const shouldFocusDom = options.focusDom === true"); expect(iframe?.getAttribute("srcdoc")).toContain("const shouldFocusDom = options.focusDom === true");
expect(iframe?.getAttribute("srcdoc")).toContain("if (shouldFocusDom) focusPickerRowElement"); expect(iframe?.getAttribute("srcdoc")).toContain("if (shouldFocusDom) focusPickerRowElement");
expect(iframe?.getAttribute("srcdoc")).toContain("postPickerPickResultToHost(runtimeResult);");
expect(iframe?.getAttribute("srcdoc")).toContain("if (runtimeResult) {reconcilePickerRuntimeResult(runtimeResult, fallbackResult);return;}commitPickerFocusResult(fallbackResult");
expect(iframe?.getAttribute("srcdoc")).toContain("commitPickerFocusResult(focusResult, { focusDom: focusAction.focusDom });");
const bindPickerRootEventsBody =
iframe?.getAttribute("srcdoc")?.match(/const bindPickerRootEvents = \(row\) => \{(?<body>[\s\S]*?)\n \};/)?.groups
?.body ?? "";
expect(bindPickerRootEventsBody).not.toContain("postPickerPickResultToHost(");
const bindPickerRowEventsBody = const bindPickerRowEventsBody =
iframe?.getAttribute("srcdoc")?.match(/const bindPickerRowEvents = \(row, item\) => \{(?<body>[\s\S]*?)\n \};/)?.groups iframe?.getAttribute("srcdoc")?.match(/const bindPickerRowEvents = \(row, item\) => \{(?<body>[\s\S]*?)\n \};/)?.groups
?.body ?? ""; ?.body ?? "";
expect(bindPickerRowEventsBody).not.toContain("handleNavigate(item.nodeId)"); expect(bindPickerRowEventsBody).not.toContain("handleNavigate(item.nodeId)");
expect(bindPickerRowEventsBody).not.toContain("postPickerPickResultToHost(");
expect(iframe?.getAttribute("srcdoc")).not.toContain("postPickerPickResultToHost(applyPickerStateAction");
expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.activePickerItem"); expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.activePickerItem");
expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.excludedPickerIds"); expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.excludedPickerIds");
const postMessage = vi.fn(); const postMessage = vi.fn();
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,97 @@
import { act } from "react"; import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import { SidebarTreeSurface, TreePickerSurface, type TreeRendererFamily } from "./tree-shell-surface"; import { SidebarTreeSurface, TreePickerSurface, type TreeRendererFamily } from "./tree-shell-surface";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type RuntimeRequest = {
requestId: string;
mode: "page" | "fileTree" | "picker";
environment?: Record<string, unknown>;
state?: Record<string, unknown>;
action?: Record<string, unknown>;
};
type TreeShellRuntimeTestGlobal = typeof globalThis & {
__MNOTE_TREE_SHELL_RUNTIME__?: {
reduceTreeShellRuntime: Mock;
};
};
function reduceTreeRuntimeForTest(request: RuntimeRequest) {
if (request.mode === "fileTree") {
const state = request.state ?? {};
const action = request.action ?? {};
const env = request.environment ?? {};
const rows = Array.isArray(env.rows) ? env.rows as Array<Record<string, unknown>> : [];
const rowId = typeof action.targetRowId === "string" ? action.targetRowId : null;
const targetRow = rows.find((row) => row.rowId === rowId) ?? {};
const target = {
kind: targetRow.rowKind ?? "doc",
documentId: targetRow.documentId ?? null,
assetId: targetRow.assetId ?? null,
};
const hostEvents =
action.kind === "dispatchInternalDrop"
? [{
kind: "fileTreeInternalDrop",
targetRowId: rowId,
target,
rowIds: Array.isArray(action.rowIds) ? action.rowIds : [],
copy: action.copy === true,
}]
: action.kind === "dispatchExternalDrop"
? [{
kind: "fileTreeExternalDrop",
targetRowId: rowId,
target,
fileCount: action.fileCount ?? 0,
}]
: [];
return {
requestId: request.requestId,
mode: "fileTree",
state: { mode: "fileTree", state },
domPatches: [{ kind: "fileTreeState" }],
hostEvents,
commandEvents: [],
};
}
return {
requestId: request.requestId,
mode: request.mode,
state: { mode: request.mode, state: request.state ?? {} },
domPatches: [],
hostEvents: [],
commandEvents: [],
};
}
function mockTreeRuntimeReducer() {
const reduceTreeShellRuntime = vi.fn(async (request: RuntimeRequest) =>
reduceTreeRuntimeForTest(request),
);
(globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__ = {
reduceTreeShellRuntime,
};
return reduceTreeShellRuntime;
}
async function waitForRuntimeReducer(reducerMock: Mock) {
for (let index = 0; index < 10; index += 1) {
if (reducerMock.mock.calls.length > 0) {
return;
}
await act(async () => {
await Promise.resolve();
});
}
}
describe("tree-shell-surface", () => { describe("tree-shell-surface", () => {
let container: HTMLDivElement; let container: HTMLDivElement;
let root: Root; let root: Root;
@@ -20,6 +107,7 @@ describe("tree-shell-surface", () => {
root.unmount(); root.unmount();
}); });
container.remove(); container.remove();
delete (globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__;
}); });
function renderPageSurface(rendererFamily: TreeRendererFamily, focusedDocumentId?: string) { function renderPageSurface(rendererFamily: TreeRendererFamily, focusedDocumentId?: string) {
@@ -43,41 +131,41 @@ describe("tree-shell-surface", () => {
}); });
} }
it("page tree surface 在 rust_family 下可切到同源 iframe host", () => { it("page tree surface 在 rust_family 下默认切到 Rust/WASM DOM shell host", () => {
renderPageSurface("rust_family"); renderPageSurface("rust_family");
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]'); const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
const rustHost = container.querySelector( const rustHost = container.querySelector(
'[data-testid="sidebar-page-tree-shell-rust-host"]', '[data-testid="sidebar-page-tree-shell-rust-host"]',
); );
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]'); const domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]');
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family"); expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost).not.toBeNull(); expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull(); expect(domHost).not.toBeNull();
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeArtifact"'); expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"'); expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull(); expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
}); });
it("page tree surface 在 rust_family 下应把 focusedDocumentId 作为宿主状态暴露,并使用 postMessage patch 同步 iframe", () => { it("page tree surface 在 rust_family 下应把 focusedDocumentId 暴露到宿主 DOM 状态", () => {
renderPageSurface("rust_family", "doc_focus"); renderPageSurface("rust_family", "doc_focus");
const iframe = container.querySelector(
'[data-testid="sidebar-page-tree-shell-rust-iframe"]',
) as HTMLIFrameElement | null;
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]'); const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
const domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]');
expect(surface?.getAttribute("data-page-tree-focused-id")).toBe("doc_focus"); expect(surface?.getAttribute("data-page-tree-focused-id")).toBe("doc_focus");
expect(iframe?.getAttribute("src")).toBeNull(); expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(iframe?.getAttribute("srcdoc")).toContain('"focusedDocumentId":"doc_focus"'); expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull();
}); });
it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => { it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => {
await act(async () => { await act(async () => {
root.render( root.render(
<SidebarTreeSurface <SidebarTreeSurface
@@ -97,8 +185,9 @@ describe("tree-shell-surface", () => {
}); });
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]'); const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).not.toBeNull(); expect(container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]')).not.toBeNull();
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull(); expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
}); });
@@ -126,7 +215,7 @@ describe("tree-shell-surface", () => {
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).not.toBeNull(); expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).not.toBeNull();
}); });
it("file tree surface 在 rust_family 下也应走同一 host 选择契约", () => { it("file tree surface 在 rust_family 下也应走默认 Rust/WASM DOM shell host", () => {
act(() => { act(() => {
root.render( root.render(
<SidebarTreeSurface <SidebarTreeSurface
@@ -149,21 +238,23 @@ describe("tree-shell-surface", () => {
const rustHost = container.querySelector( const rustHost = container.querySelector(
'[data-testid="sidebar-file-tree-shell-rust-host"]', '[data-testid="sidebar-file-tree-shell-rust-host"]',
); );
const iframe = container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]'); const domHost = container.querySelector('[data-testid="sidebar-file-tree-shell-dom-host"]');
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family"); expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost).not.toBeNull(); expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull(); expect(domHost).not.toBeNull();
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeArtifact"'); expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"'); expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull(); expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
}); });
it("file tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => { it("file tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => {
await act(async () => { await act(async () => {
root.render( root.render(
<SidebarTreeSurface <SidebarTreeSurface
@@ -183,8 +274,9 @@ describe("tree-shell-surface", () => {
}); });
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]'); const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).not.toBeNull(); expect(container.querySelector('[data-testid="sidebar-file-tree-shell-dom-host"]')).not.toBeNull();
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull(); expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
}); });
@@ -212,10 +304,50 @@ describe("tree-shell-surface", () => {
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).not.toBeNull(); expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).not.toBeNull();
}); });
it("file tree surface 应把 iframe 内部拖放与外部文件拖放桥接回宿主回调", async () => { it("file tree surface 应把 DOM shell 内部拖放与外部文件拖放桥接回宿主回调", async () => {
const runtimeReducerMock = mockTreeRuntimeReducer();
const onInternalDrop = vi.fn(); const onInternalDrop = vi.fn();
const onDropFiles = vi.fn(); const onDropFiles = vi.fn();
const droppedFile = new File(["bridge"], "bridge.txt", { type: "text/plain" }); const droppedFile = new File(["bridge"], "bridge.txt", { type: "text/plain" });
const dataTransfer = {
files: [] as File[],
types: ["application/x-mnote-file-tree", "text/plain"],
dropEffect: "move",
getData: (type: string) =>
type === "application/x-mnote-file-tree" || type === "text/plain"
? JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds: ["doc:doc_source"] })
: "",
setData: vi.fn(),
} as unknown as DataTransfer;
const externalDataTransfer = {
files: [droppedFile],
types: ["Files"],
dropEffect: "copy",
getData: () => "",
setData: vi.fn(),
} as unknown as DataTransfer;
const targetProjectionItem: KernelFileTreeProjectionItem = {
rowId: "doc:doc_target",
nodeId: "doc_target",
parentNodeId: null,
projectionKind: "file_tree",
title: "目标页面",
depth: 0,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
nodeType: "page",
rowKind: "document",
iconHint: "page",
capabilities: ["open", "select", "context-menu"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_target",
workspaceId: "ws_1",
iconHint: "page",
},
};
await act(async () => { await act(async () => {
root.render( root.render(
@@ -225,6 +357,7 @@ describe("tree-shell-surface", () => {
workspaceId="ws_1" workspaceId="ws_1"
treeShellEnabled treeShellEnabled
rows={[]} rows={[]}
treeShellItems={[targetProjectionItem]}
activeId="" activeId=""
onRowClick={() => undefined} onRowClick={() => undefined}
onRowDoubleClick={() => undefined} onRowDoubleClick={() => undefined}
@@ -237,45 +370,33 @@ describe("tree-shell-surface", () => {
); );
}); });
const iframe = container.querySelector( const targetRow = container.querySelector(
'[data-testid="sidebar-file-tree-shell-rust-iframe"]', '[data-testid="filetree-doc-row"][data-document-id="doc_target"]',
); );
expect(iframe).not.toBeNull(); expect(targetRow).not.toBeNull();
Object.defineProperty(iframe, "contentWindow", {
configurable: true,
value: window,
});
await act(async () => { await act(async () => {
window.dispatchEvent( const internalDropEvent = new Event("drop", { bubbles: true, cancelable: true });
new MessageEvent("message", { Object.defineProperty(internalDropEvent, "dataTransfer", {
data: { configurable: true,
channel: "sidebar-file-tree-shell", value: dataTransfer,
type: "tree.filetree.internal-drop",
rowIds: ["doc:doc_source"],
copy: false,
rowId: "doc:doc_target",
rowKind: "doc",
documentId: "doc_target",
},
source: window,
}),
);
window.dispatchEvent(
new MessageEvent("message", {
data: {
channel: "sidebar-file-tree-shell",
type: "tree.filetree.external-drop",
documentId: "doc_target",
rowId: "doc:doc_target",
rowKind: "doc",
files: [droppedFile],
},
source: window,
}),
);
}); });
targetRow?.dispatchEvent(internalDropEvent);
const externalDropEvent = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(externalDropEvent, "dataTransfer", {
configurable: true,
value: externalDataTransfer,
});
targetRow?.dispatchEvent(externalDropEvent);
});
await waitForRuntimeReducer(runtimeReducerMock);
expect(runtimeReducerMock).toHaveBeenCalledWith(
expect.objectContaining({
mode: "fileTree",
action: expect.objectContaining({ kind: "dispatchInternalDrop" }),
}),
);
expect(onInternalDrop).toHaveBeenCalledWith({ expect(onInternalDrop).toHaveBeenCalledWith({
targetDocumentId: "doc_target", targetDocumentId: "doc_target",
targetRowId: "doc:doc_target", targetRowId: "doc:doc_target",
@@ -293,7 +414,7 @@ describe("tree-shell-surface", () => {
}); });
}); });
it("picker surface 在 rust_family 下也应挂到同一 host 边界", async () => { it("picker surface 在 rust_family 下也应挂到默认 Rust/WASM DOM shell host", async () => {
const onPick = vi.fn(); const onPick = vi.fn();
await act(async () => { await act(async () => {
@@ -312,19 +433,24 @@ describe("tree-shell-surface", () => {
const surface = container.querySelector('[data-testid="tree-picker-surface"]'); const surface = container.querySelector('[data-testid="tree-picker-surface"]');
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]'); const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'); const domHost = container.querySelector('[data-testid="tree-picker-surface-dom-host"]');
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family"); expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost).not.toBeNull(); expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull(); expect(domHost).not.toBeNull();
expect(domHost?.getAttribute("data-tree-runtime-artifact-host")).toBe("rust_tree_shell_runtime_artifact_v1");
expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm");
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
expect(container.querySelector('[data-tree-browser-bridge="iframe_srcdoc"]')).toBeNull();
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_1"]')).not.toBeNull();
expect(onPick).not.toHaveBeenCalled(); expect(onPick).not.toHaveBeenCalled();
}); });
it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => { it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => {
await act(async () => { await act(async () => {
root.render( root.render(
<TreePickerSurface <TreePickerSurface
@@ -346,11 +472,12 @@ describe("tree-shell-surface", () => {
const surface = container.querySelector('[data-testid="tree-picker-surface"]'); const surface = container.querySelector('[data-testid="tree-picker-surface"]');
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]'); const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'); const domHost = container.querySelector('[data-testid="tree-picker-surface-dom-host"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family"); expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(rustHost).not.toBeNull(); expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull(); expect(domHost).not.toBeNull();
expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull();
}); });
}); });
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
assertDocumentMoveWriteOperationMatches,
buildDocumentMoveOrderPlanFromDocuments,
} from "../../../convex/_utils/documentMoveOrder";
describe("documentMoveOrder Convex helper", () => {
it("assertDocumentMoveWriteOperationMatches 应只接受 Rust tree write operation,并返回内部 move plan", () => {
const actual = buildDocumentMoveOrderPlanFromDocuments({
documents: [
{
id: "doc_a",
parent_id: "source",
sort_order: 0,
created_at: "2026-04-25T00:00:01Z",
},
{
id: "doc_b",
parent_id: "source",
sort_order: 1,
created_at: "2026-04-25T00:00:02Z",
},
],
documentId: "doc_b",
parentId: null,
sortOrder: 0,
});
const normalized = assertDocumentMoveWriteOperationMatches(
{
family: "tree",
schema: "mnote.tree.write_operation",
schemaVersion: 1,
operation: "tree.subtree.move.write",
workspaceId: "ws_1",
documentId: "doc_b",
fromParentId: "source",
toParentId: null,
requestedSortOrder: 0,
normalizedSortOrder: 0,
patches: [
{
documentId: "doc_b",
parentId: null,
sortOrder: 0,
moved: true,
},
],
},
actual,
);
expect(normalized).toEqual({
documentId: "doc_b",
fromParentId: "source",
toParentId: null,
requestedSortOrder: 0,
normalizedSortOrder: 0,
patches: [
{
documentId: "doc_b",
parentId: null,
sortOrder: 0,
moved: true,
},
],
});
expect(() => assertDocumentMoveWriteOperationMatches({ ...actual, operation: "documents.move" }, actual)).toThrow(
"Rust move write operation 与 Convex 当前排序状态不一致",
);
});
it("assertDocumentMoveWriteOperationMatches 应拒绝缺少正式 write operation 外壳的旧 move plan", () => {
const actual = buildDocumentMoveOrderPlanFromDocuments({
documents: [
{
id: "doc_a",
parent_id: "source",
sort_order: 0,
created_at: "2026-04-25T00:00:01Z",
},
{
id: "doc_b",
parent_id: "source",
sort_order: 1,
created_at: "2026-04-25T00:00:02Z",
},
],
documentId: "doc_b",
parentId: null,
sortOrder: 0,
});
expect(() => assertDocumentMoveWriteOperationMatches(actual, actual)).toThrow(
"Rust move write operation 与 Convex 当前排序状态不一致",
);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
assertDocumentMoveOrderPlanMatches, assertDocumentMoveWriteOperationMatches,
buildDocumentMoveOrderPlanFromDocuments, buildDocumentMoveOrderPlanFromDocuments,
} from "../../../convex/_utils/documentMoveOrder"; } from "../../../convex/_utils/documentMoveOrder";
@@ -61,7 +61,7 @@ describe("documentMoveOrder", () => {
}); });
}); });
it("normalizedMove 与当前排序状态不一致时拒绝执行", () => { it("treeWriteOperation 与当前排序状态不一致时拒绝执行", () => {
const actual = buildDocumentMoveOrderPlanFromDocuments({ const actual = buildDocumentMoveOrderPlanFromDocuments({
documents: [ documents: [
{ {
@@ -89,8 +89,13 @@ describe("documentMoveOrder", () => {
}); });
expect(() => expect(() =>
assertDocumentMoveOrderPlanMatches( assertDocumentMoveWriteOperationMatches(
{ {
family: "tree",
schema: "mnote.tree.write_operation",
schemaVersion: 1,
operation: "tree.subtree.move.write",
workspaceId: "ws_1",
...actual, ...actual,
patches: actual.patches.map((patch) => patches: actual.patches.map((patch) =>
patch.documentId === "doc_c" ? { ...patch, sortOrder: 9 } : patch, patch.documentId === "doc_c" ? { ...patch, sortOrder: 9 } : patch,
@@ -98,6 +103,6 @@ describe("documentMoveOrder", () => {
}, },
actual, actual,
), ),
).toThrow("Rust move plan 与 Convex 当前排序状态不一致"); ).toThrow("Rust move write operation 与 Convex 当前排序状态不一致");
}); });
}); });
@@ -15,13 +15,10 @@ import {
recordBridgeCommandFailureArtifacts, recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts, recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log"; } from "@/lib/documents/bridge-log";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import { import {
executeRustBridgeMutationTransport, executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan, resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime"; } from "@/lib/documents/rust-runtime";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import type { Json } from "@/types/supabase";
export type DocumentCreatePayload = { export type DocumentCreatePayload = {
documentId: string; documentId: string;
@@ -82,10 +79,7 @@ export type DocumentEmbedPayload = {
documentId: string; documentId: string;
workspaceId: string | null; workspaceId: string | null;
revision: number | null; revision: number | null;
content: Json;
conflictDetectionKey: string | null; conflictDetectionKey: string | null;
snapshotCapturedAt: string | null;
blockCount: number | null;
sourceDocumentId: string; sourceDocumentId: string;
targetDocumentId: string; targetDocumentId: string;
anchorBlockId: string | null; anchorBlockId: string | null;
@@ -275,47 +269,20 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
} }
const targetMeta = await client.query(api.documents.getMeta, { id: normalizedTargetId }); const targetMeta = await client.query(api.documents.getMeta, { id: normalizedTargetId });
const currentBlocks = extractBlocksFromContent(targetContent.content);
const anchorId = trimOrNull((targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id); const anchorId = trimOrNull((targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id);
const anchorIndex =
anchorId
? currentBlocks.findIndex(
(block) => typeof block === "object" && block !== null && String((block as { id?: string }).id ?? "") === anchorId,
)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
const nextBlocks: Json[] = [
...currentBlocks.slice(0, insertIndex),
{
id: safeRandomId(),
type: "pageReference",
props: {
pageId: normalizedSourceId,
title: sourceDoc.title ?? "无标题",
},
},
...currentBlocks.slice(insertIndex),
];
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id); const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
const context = await buildRuntimeContext(request, workspaceId); const context = await buildRuntimeContext(request, workspaceId);
const embedPayload: DocumentEmbedPayload = { const embedPayload: DocumentEmbedPayload = {
...buildDocumentSavePayload({
documentId: normalizedTargetId, documentId: normalizedTargetId,
workspaceId, workspaceId,
revision: revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision) typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision ? targetContent.revision
: null, : null,
content: payload,
conflictDetectionKey: conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string" typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key ? targetContent.conflict_detection_key
: null, : null,
blockCount: nextBlocks.length,
}),
sourceDocumentId: normalizedSourceId, sourceDocumentId: normalizedSourceId,
targetDocumentId: normalizedTargetId, targetDocumentId: normalizedTargetId,
anchorBlockId: anchorId, anchorBlockId: anchorId,
@@ -324,6 +291,16 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
name: "documents.embed", name: "documents.embed",
payload: embedPayload, payload: embedPayload,
context, context,
preflightData: {
pageAggregateEmbed: {
sourceDocumentId: normalizedSourceId,
sourceTitle: sourceDoc.title ?? "无标题",
targetDocumentId: normalizedTargetId,
targetContent: targetContent.content,
anchorBlockId: anchorId,
blockId: safeRandomId(),
},
},
target: { target: {
workspaceId, workspaceId,
pageId: normalizedTargetId, pageId: normalizedTargetId,
@@ -373,14 +373,14 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> { export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment(); assertServerEnvironment();
const requestClone = request.clone(); const requestClone = request.clone();
let normalizedMove: NormalizedDocumentMovePayload | null = null; let movePayload: NormalizedDocumentMovePayload | null = null;
let failureClient: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"] | null = null; let failureClient: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"] | null = null;
let failureAuthUserId: string | null = null; let failureAuthUserId: string | null = null;
let failureSourceDocument: MovePreflightDocument | null = null; let failureSourceDocument: MovePreflightDocument | null = null;
try { try {
const payload = (await request.json()) as MovePayload; const payload = (await request.json()) as MovePayload;
normalizedMove = normalizeDocumentMovePayload(payload); movePayload = normalizeDocumentMovePayload(payload);
const documentId = normalizedMove.documentId; const documentId = movePayload.documentId;
if (!documentId) { if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
} }
@@ -402,14 +402,14 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
const movePreflight = await buildMovePreflight({ const movePreflight = await buildMovePreflight({
client, client,
sourceDocument: failureSourceDocument, sourceDocument: failureSourceDocument,
targetParentId: normalizedMove.parentId, targetParentId: movePayload.parentId,
}); });
const envelope = buildDocumentCommandEnvelope({ const envelope = buildDocumentCommandEnvelope({
name: "documents.move", name: "documents.move",
payload: { payload: {
documentId, documentId,
parentId: normalizedMove.parentId, parentId: movePayload.parentId,
sortOrder: normalizedMove.sortOrder, sortOrder: movePayload.sortOrder,
movePreflight, movePreflight,
}, },
preflightData: movePreflight, preflightData: movePreflight,
@@ -438,7 +438,7 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) { } catch (error) {
try { try {
const fallbackMove = normalizedMove const fallbackMove = movePayload
?? normalizeDocumentMovePayload( ?? normalizeDocumentMovePayload(
(await requestClone.json().catch(() => ({}))) as MovePayload, (await requestClone.json().catch(() => ({}))) as MovePayload,
); );
@@ -5,6 +5,7 @@ import {
executeRustBridgeMutationTransport, executeRustBridgeMutationTransport,
materializeRustTreeStreamDelta, materializeRustTreeStreamDelta,
readRustTreeDomainEventPlan, readRustTreeDomainEventPlan,
readRustTreeDomainEventPlans,
readRustTreeDomainEventType, readRustTreeDomainEventType,
type RustBridgeCommandPlan, type RustBridgeCommandPlan,
} from "./rust-runtime"; } from "./rust-runtime";
@@ -83,7 +84,7 @@ describe("shouldUseBuiltBridgeRuntimeBinary", () => {
describe("executeRustBridgeMutationTransport", () => { describe("executeRustBridgeMutationTransport", () => {
it("documents.move 应把 Rust treeWriteOperation 透传给 Convex 写执行器", async () => { it("documents.move 应把 Rust treeWriteOperation 透传给 Convex 写执行器", async () => {
const normalizedMove = { const movePlan = {
documentId: "doc_b", documentId: "doc_b",
fromParentId: "source", fromParentId: "source",
toParentId: "target", toParentId: "target",
@@ -104,7 +105,7 @@ describe("executeRustBridgeMutationTransport", () => {
schemaVersion: 1, schemaVersion: 1,
operation: "tree.subtree.move.write", operation: "tree.subtree.move.write",
workspaceId: "ws_1", workspaceId: "ws_1",
...normalizedMove, ...movePlan,
}; };
const mutation = vi.fn().mockResolvedValue({ ok: true }); const mutation = vi.fn().mockResolvedValue({ ok: true });
const plan: RustBridgeCommandPlan = { const plan: RustBridgeCommandPlan = {
@@ -122,7 +123,6 @@ describe("executeRustBridgeMutationTransport", () => {
id: "doc_b", id: "doc_b",
parentId: "target", parentId: "target",
sortOrder: 0, sortOrder: 0,
normalizedMove,
treeWriteOperation, treeWriteOperation,
}, },
}; };
@@ -137,7 +137,6 @@ describe("executeRustBridgeMutationTransport", () => {
id: "doc_b", id: "doc_b",
parentId: "target", parentId: "target",
sortOrder: 0, sortOrder: 0,
normalizedMove,
treeWriteOperation, treeWriteOperation,
}); });
}); });
@@ -645,7 +644,7 @@ describe("readRustTreeDomainEventType", () => {
}); });
}); });
it("应保留 Rust formal domainEventPlan payload schema", () => { it("应保留 Rust formal domainEventPlan payload schema,并拆出 snapshot 独立事件", () => {
const plan: RustBridgeCommandPlan = { const plan: RustBridgeCommandPlan = {
kind: "command", kind: "command",
commandName: "page.body.save", commandName: "page.body.save",
@@ -668,10 +667,30 @@ describe("readRustTreeDomainEventType", () => {
id: "doc_1", id: "doc_1",
workspaceId: "ws_1", workspaceId: "ws_1",
}, },
snapshot: { blocks: {
version: 8, ids: ["block_1"],
contentHash: "fnv1a64:d039f8f3496411e8", count: 1,
updatedAt: null, },
},
streamDeltaHint: {
family: "tree",
kind: "resync_required",
args: {
reason: "page_body_saved",
pageId: "doc_1",
},
},
},
domainEventPlans: [
{
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "page.body.saved",
payload: {
page: {
id: "doc_1",
workspaceId: "ws_1",
}, },
blocks: { blocks: {
ids: ["block_1"], ids: ["block_1"],
@@ -687,6 +706,32 @@ describe("readRustTreeDomainEventType", () => {
}, },
}, },
}, },
{
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "document.snapshot.saved",
payload: {
page: {
id: "doc_1",
workspaceId: "ws_1",
},
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
},
streamDeltaHint: {
family: "tree",
kind: "resync_required",
args: {
reason: "page_body_saved",
pageId: "doc_1",
},
},
},
],
}, },
}; };
@@ -700,11 +745,6 @@ describe("readRustTreeDomainEventType", () => {
id: "doc_1", id: "doc_1",
workspaceId: "ws_1", workspaceId: "ws_1",
}, },
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
blocks: { blocks: {
ids: ["block_1"], ids: ["block_1"],
count: 1, count: 1,
@@ -719,6 +759,22 @@ describe("readRustTreeDomainEventType", () => {
}, },
}, },
}); });
expect(readRustTreeDomainEventPlans(plan).map((eventPlan) => eventPlan.eventType)).toEqual([
"page.body.saved",
"document.snapshot.saved",
]);
expect(readRustTreeDomainEventPlans(plan)[0]?.payload).not.toHaveProperty("snapshot");
expect(readRustTreeDomainEventPlans(plan)[1]?.payload).toMatchObject({
page: {
id: "doc_1",
workspaceId: "ws_1",
},
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
});
}); });
}); });
@@ -796,10 +852,30 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
id: "doc_1", id: "doc_1",
workspaceId: "ws_1", workspaceId: "ws_1",
}, },
snapshot: { blocks: {
version: 8, ids: ["block_1"],
contentHash: "fnv1a64:d039f8f3496411e8", count: 1,
updatedAt: null, },
},
streamDeltaHint: {
family: "tree",
kind: "resync_required",
args: {
reason: "page_body_saved",
pageId: "doc_1",
},
},
},
domainEventPlans: [
{
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "page.body.saved",
payload: {
page: {
id: "doc_1",
workspaceId: "ws_1",
}, },
blocks: { blocks: {
ids: ["block_1"], ids: ["block_1"],
@@ -815,6 +891,32 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
}, },
}, },
}, },
{
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "document.snapshot.saved",
payload: {
page: {
id: "doc_1",
workspaceId: "ws_1",
},
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
},
streamDeltaHint: {
family: "tree",
kind: "resync_required",
args: {
reason: "page_body_saved",
pageId: "doc_1",
},
},
},
],
}, },
}, },
result: { result: {
@@ -842,6 +944,10 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
}); });
expect(artifactPlan?.commandLog.commandId).toBe("cmd_artifact_1"); expect(artifactPlan?.commandLog.commandId).toBe("cmd_artifact_1");
expect(artifactPlan?.domainEvent?.commandId).toBe("cmd_artifact_1"); expect(artifactPlan?.domainEvent?.commandId).toBe("cmd_artifact_1");
expect(artifactPlan?.domainEvents?.map((event) => event.eventType)).toEqual([
"page.body.saved",
"document.snapshot.saved",
]);
expect(artifactPlan?.domainEvent).toMatchObject({ expect(artifactPlan?.domainEvent).toMatchObject({
id: "evt_cmd_artifact_1", id: "evt_cmd_artifact_1",
eventType: "page.body.saved", eventType: "page.body.saved",
@@ -854,11 +960,6 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
id: "doc_1", id: "doc_1",
workspaceId: "ws_1", workspaceId: "ws_1",
}, },
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
blocks: { blocks: {
ids: ["block_1"], ids: ["block_1"],
count: 1, count: 1,
@@ -870,5 +971,30 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
}, },
}, },
}); });
expect(artifactPlan?.domainEvent?.payload).not.toHaveProperty("snapshot");
expect(artifactPlan?.domainEvents?.[1]).toMatchObject({
id: "evt_cmd_artifact_1_02_document_snapshot_saved",
eventType: "document.snapshot.saved",
payload: {
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "document.snapshot.saved",
command_id: "cmd_artifact_1",
page: {
id: "doc_1",
workspaceId: "ws_1",
},
snapshot: {
version: 8,
contentHash: "fnv1a64:d039f8f3496411e8",
updatedAt: null,
},
streamDelta: {
op: "resync_required",
reason: "page_body_saved",
pageId: "doc_1",
},
},
});
}, 30_000); }, 30_000);
}); });
@@ -161,6 +161,7 @@ export type RustBridgeDomainEventArtifactPlan = {
export type RustBridgeCommandArtifactPlan = { export type RustBridgeCommandArtifactPlan = {
commandLog: RustBridgeCommandLogArtifactPlan; commandLog: RustBridgeCommandLogArtifactPlan;
domainEvent: RustBridgeDomainEventArtifactPlan | null; domainEvent: RustBridgeDomainEventArtifactPlan | null;
domainEvents?: RustBridgeDomainEventArtifactPlan[];
}; };
export type RustBridgeToolPlanStep = { export type RustBridgeToolPlanStep = {
@@ -555,6 +556,14 @@ function readOptionalRecordArg(argsJson: Record<string, unknown>, field: string)
return isRecord(value) ? value : null; return isRecord(value) ? value : null;
} }
function readRequiredRecordArg(argsJson: Record<string, unknown>, field: string) {
const value = argsJson[field];
if (isRecord(value)) {
return value;
}
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
}
function readOptionalBooleanField(source: Record<string, unknown>, field: string) { function readOptionalBooleanField(source: Record<string, unknown>, field: string) {
const value = source[field]; const value = source[field];
return typeof value === "boolean" ? value : undefined; return typeof value === "boolean" ? value : undefined;
@@ -670,6 +679,24 @@ export function readRustTreeDomainEventType(plan: RustBridgeCommandPlan): string
export function readRustTreeDomainEventPlan(plan: RustBridgeCommandPlan): RustTreeDomainEventPlan | null { export function readRustTreeDomainEventPlan(plan: RustBridgeCommandPlan): RustTreeDomainEventPlan | null {
const eventPlan = plan.argsJson.domainEventPlan; const eventPlan = plan.argsJson.domainEventPlan;
return normalizeRustTreeDomainEventPlan(eventPlan);
}
export function readRustTreeDomainEventPlans(plan: RustBridgeCommandPlan): RustTreeDomainEventPlan[] {
const eventPlans = plan.argsJson.domainEventPlans;
if (Array.isArray(eventPlans)) {
const normalized = eventPlans
.map((eventPlan) => normalizeRustTreeDomainEventPlan(eventPlan))
.filter((eventPlan): eventPlan is RustTreeDomainEventPlan => Boolean(eventPlan));
if (normalized.length > 0) {
return normalized;
}
}
const single = readRustTreeDomainEventPlan(plan);
return single ? [single] : [];
}
function normalizeRustTreeDomainEventPlan(eventPlan: unknown): RustTreeDomainEventPlan | null {
if (!isRecord(eventPlan) || eventPlan.family !== "tree") { if (!isRecord(eventPlan) || eventPlan.family !== "tree") {
return null; return null;
} }
@@ -715,6 +742,24 @@ export function materializeRustTreeDomainEventPlan(input: {
}; };
} }
export function materializeRustTreeDomainEventPlans(input: {
plan: RustBridgeCommandPlan;
result: unknown;
streamDelta?: RustTreeStreamDelta | null;
}): RustTreeDomainEventPlan[] {
const eventPlans = readRustTreeDomainEventPlans(input.plan);
const streamDelta =
input.streamDelta ??
materializeRustTreeStreamDelta({
plan: input.plan,
result: input.result,
});
return eventPlans.map((eventPlan) => ({
...eventPlan,
...(streamDelta ? { streamDelta } : {}),
}));
}
export function materializeRustTreeStreamDelta(input: { export function materializeRustTreeStreamDelta(input: {
plan: RustBridgeCommandPlan; plan: RustBridgeCommandPlan;
result: unknown; result: unknown;
@@ -882,8 +927,14 @@ export async function persistRustBridgeCommandArtifacts(input: {
) => Promise<unknown>; ) => Promise<unknown>;
await mutation(bridgeLogsApi.bridgeLogs.recordCommandLog, artifacts.commandLog as unknown as Record<string, unknown>); await mutation(bridgeLogsApi.bridgeLogs.recordCommandLog, artifacts.commandLog as unknown as Record<string, unknown>);
if (artifacts.domainEvent) { const domainEvents =
await mutation(bridgeLogsApi.bridgeLogs.recordDomainEvent, artifacts.domainEvent as unknown as Record<string, unknown>); artifacts.domainEvents && artifacts.domainEvents.length > 0
? artifacts.domainEvents
: artifacts.domainEvent
? [artifacts.domainEvent]
: [];
for (const domainEvent of domainEvents) {
await mutation(bridgeLogsApi.bridgeLogs.recordDomainEvent, domainEvent as unknown as Record<string, unknown>);
} }
} }
@@ -1151,12 +1202,7 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
id: assertStringArg(input.plan.argsJson, "id"), id: assertStringArg(input.plan.argsJson, "id"),
parentId: readOptionalStringArg(input.plan.argsJson, "parentId"), parentId: readOptionalStringArg(input.plan.argsJson, "parentId"),
sortOrder: readRequiredNumberArg(input.plan.argsJson, "sortOrder"), sortOrder: readRequiredNumberArg(input.plan.argsJson, "sortOrder"),
...("normalizedMove" in input.plan.argsJson treeWriteOperation: readRequiredRecordArg(input.plan.argsJson, "treeWriteOperation"),
? { normalizedMove: input.plan.argsJson.normalizedMove }
: {}),
...("treeWriteOperation" in input.plan.argsJson
? { treeWriteOperation: input.plan.argsJson.treeWriteOperation }
: {}),
}); });
case "documents:softDelete": case "documents:softDelete":
return mutation(api.documents.softDelete, { return mutation(api.documents.softDelete, {
@@ -21,6 +21,23 @@ describe("fetchKernelFileTreeProjection", () => {
rootNodeId: "page_root", rootNodeId: "page_root",
items: [{ rowId: "asset:table_1" }], items: [{ rowId: "asset:table_1" }],
edges: [], edges: [],
meta: {
search: {
indexingVisibility: {
schema: "mnote.file_tree.indexing_visibility",
schemaVersion: 1,
source: "kernel.project_view",
status: "visible",
requestKey: "page_root:预算",
indexedResourceKinds: ["document", "index", "asset"],
visibleResourceKinds: ["document", "index", "asset"],
metrics: {
visibleRows: 1,
visibleEdges: 0,
},
},
},
},
}, },
}), }),
{ status: 200 }, { status: 200 },
@@ -44,6 +61,11 @@ describe("fetchKernelFileTreeProjection", () => {
}), }),
); );
expect(result.items.map((item) => item.rowId)).toEqual(["asset:table_1"]); expect(result.items.map((item) => item.rowId)).toEqual(["asset:table_1"]);
expect(result.meta?.search?.indexingVisibility).toMatchObject({
schema: "mnote.file_tree.indexing_visibility",
status: "visible",
requestKey: "page_root:预算",
});
}); });
it("固定 file_tree 搜索语义边界:命中数先截断,祖先补全不计入 maxResults", () => { it("固定 file_tree 搜索语义边界:命中数先截断,祖先补全不计入 maxResults", () => {
@@ -29,12 +29,36 @@ export type KernelFileTreeProjectionEdge = {
toNodeId: string; toNodeId: string;
}; };
export type KernelFileTreeIndexingVisibility = {
schema: "mnote.file_tree.indexing_visibility";
schemaVersion: 1;
source: "kernel.project_view";
status: "visible" | "stale" | "refreshing" | "unknown";
requestKey: string | null;
indexedResourceKinds: string[];
visibleResourceKinds: string[];
metrics: {
visibleRows: number;
visibleEdges: number;
};
};
export type KernelFileTreeProjection = { export type KernelFileTreeProjection = {
projectionId: string; projectionId: string;
projection: "file_tree"; projection: "file_tree";
rootNodeId: string | null; rootNodeId: string | null;
items: KernelFileTreeProjectionItem[]; items: KernelFileTreeProjectionItem[];
edges: KernelFileTreeProjectionEdge[]; edges: KernelFileTreeProjectionEdge[];
meta?: {
search?: {
query?: string | null;
maxResults?: number | null;
maxResultsRule?: "matches_only_before_ancestor_completion";
ancestorCompletion?: "include_all_ancestors_after_match_truncation";
ordering?: "kernel_file_tree_preorder";
indexingVisibility?: KernelFileTreeIndexingVisibility;
};
};
}; };
type BuildKernelFileTreeProjectionInput = { type BuildKernelFileTreeProjectionInput = {
@@ -574,5 +598,27 @@ export function buildKernelFileTreeProjection(
rootNodeId, rootNodeId,
items, items,
edges, edges,
meta: {
search: {
query: null,
maxResults: null,
maxResultsRule: "matches_only_before_ancestor_completion",
ancestorCompletion: "include_all_ancestors_after_match_truncation",
ordering: "kernel_file_tree_preorder",
indexingVisibility: {
schema: "mnote.file_tree.indexing_visibility",
schemaVersion: 1,
source: "kernel.project_view",
status: "visible",
requestKey: null,
indexedResourceKinds: ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
visibleResourceKinds: ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
metrics: {
visibleRows: items.length,
visibleEdges: edges.length,
},
},
},
},
}; };
} }
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { TREE_3000_ROUTE_BOUNDARY_MANIFEST } from "./tree-route-boundary";
describe("TREE_3000_ROUTE_BOUNDARY_MANIFEST", () => {
it("固定 3000 route 的 thin proxy 与 compat pending 边界", () => {
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST).toMatchObject({
schema: "mnote.tree.3000_route_boundary",
schemaVersion: 1,
publicEntry: "3000",
});
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.routes).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "tree.commands",
role: "next-thin-proxy",
route: "/api/tree/commands",
}),
expect.objectContaining({
id: "tree.stream",
role: "next-thin-proxy",
route: "/api/tree/stream",
}),
expect.objectContaining({
id: "tree.shell.debug",
role: "compat-pending",
route: "/api/tree/shell",
}),
]),
);
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.rustOwnedSemantics).toContain("tree.subtree.move");
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.nextThinProxyDuties).toContain(
"Rust command result 回包整形",
);
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.browserSubstrateDuties).toContain(
"新页面 scaffold 文件创建",
);
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.forbiddenNextSemantics).toContain("树排序 canonical plan");
});
});
@@ -0,0 +1,77 @@
export type TreeRouteBoundaryRole =
| "rust-owned"
| "next-thin-proxy"
| "browser-substrate"
| "compat-pending";
export type TreeRouteBoundaryItem = {
id: string;
role: TreeRouteBoundaryRole;
route: string;
owner: "rust-runtime" | "next-3000" | "convex-substrate";
description: string;
};
export const TREE_3000_ROUTE_BOUNDARY_MANIFEST = {
schema: "mnote.tree.3000_route_boundary",
schemaVersion: 1,
publicEntry: "3000",
routes: [
{
id: "tree.commands",
role: "next-thin-proxy",
route: "/api/tree/commands",
owner: "next-3000",
description:
"浏览器公开树命令入口,只负责认证、payload envelope、Rust command transport、artifact writer 调用与必要副作用调度。",
},
{
id: "tree.stream",
role: "next-thin-proxy",
route: "/api/tree/stream",
owner: "next-3000",
description:
"浏览器 SSE/polling 入口,只转发 bridge log/domain event cursor 与 Rust 产出的 streamDelta,缺少稳定 delta 时保守 resync。",
},
{
id: "tree.shell.debug",
role: "compat-pending",
route: "/api/tree/shell",
owner: "next-3000",
description:
"仅服务显式 debug/internal runtime 验证;3000 主路径使用 same-origin inline host,不应默认请求 mnote-web:3104。",
},
] satisfies TreeRouteBoundaryItem[],
rustOwnedSemantics: [
"tree.node.create",
"tree.node.rename",
"tree.subtree.move",
"tree.node.archive",
"tree.node.restore",
"tree.node.purge",
"tree.subtree.copy",
"tree.node.embed",
],
nextThinProxyDuties: [
"cookie/auth 读取与 Convex client 获取",
"workspace bootstrap",
"CommandEnvelope 构造与 Rust runtime transport",
"Rust command result 回包整形",
"Rust artifact writer 调用",
"tree.subtree.move 的 sidebar snapshot preflight 数据采集",
],
browserSubstrateDuties: [
"新页面 scaffold 文件创建",
"复制页面后的 mindmap 文件复制",
"页面嵌入前读取目标内容、源页面标题与 anchor block,作为 Rust Page Aggregate embed plan 的 preflight substrate",
"文件字节读取、upload URL、cookie/auth 等浏览器入口能力",
],
compatPending: [] satisfies TreeRouteBoundaryItem[],
forbiddenNextSemantics: [
"树合法性判断",
"树排序 canonical plan",
"长期 streamDelta 主语义拼装",
"tree shell renderer runtime",
"tree.node.embed 的 pageReference block 结构与插入位置语义",
],
} as const;