From 7965c6c10728a318c4561df6fe44d6b737e01b77 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 28 Apr 2026 16:30:51 +0800 Subject: [PATCH] feat(tree): close rust family shell cutover --- ...ly-remaining-final-runtime-checklist-v1.md | 279 +++ ...-tree-3000-route-thin-proxy-boundary-v1.md | 6 +- ...al-status-and-rust-dom-shell-cutover-v1.md | 31 + ...ly-remaining-final-runtime-checklist-v1.md | 215 -- .../7-phase7-ai-kernel-projection-plan-v2.md | 2 +- .../7-phase7-ai-kernel-projection-plan-v1.md | 2 +- harness-progress.txt | 42 + harness-tasks.json | 416 +++- rust/Cargo.lock | 32 + rust/Cargo.toml | 1 + rust/crates/bridge-runtime/src/lib.rs | 527 +++- rust/crates/core-protocol/src/kernel.rs | 2 + rust/crates/mnote-web/src/routes/bridge.rs | 6 +- rust/crates/mnote-web/src/routes/compat.rs | 8 +- rust/crates/mnote-web/src/routes/documents.rs | 8 +- rust/crates/mnote-web/src/routes/editor.rs | 12 +- rust/crates/mnote-web/src/routes/health.rs | 2 +- rust/crates/mnote-web/src/routes/hermes.rs | 8 +- rust/crates/mnote-web/src/routes/kernel.rs | 40 +- rust/crates/mnote-web/src/routes/mod.rs | 6 +- .../mnote-web/src/routes/query_support.rs | 8 +- .../mnote-web/src/routes/snapshot_support.rs | 2 +- rust/crates/mnote-web/src/routes/sse.rs | 9 +- .../mnote-web/src/routes/stream_support.rs | 10 +- rust/crates/mnote-web/src/routes/tree.rs | 436 +++- rust/crates/mnote-web/src/routes/ws.rs | 4 +- .../src/tree_shell/drag_drop_state.rs | 5 +- .../src/tree_shell/filetree_runtime.rs | 556 +++++ .../src/tree_shell/filetree_selection.rs | 49 +- .../mnote-web/src/tree_shell/focus_state.rs | 24 +- rust/crates/mnote-web/src/tree_shell/mod.rs | 3 +- .../mnote-web/src/tree_shell/page_renderer.rs | 5 +- .../mnote-web/src/tree_shell/page_runtime.rs | 589 +++++ .../src/tree_shell/picker_renderer.rs | 37 +- .../src/tree_shell/picker_runtime.rs | 277 +++ .../mnote-web/src/tree_shell/picker_state.rs | 33 +- .../src/tree_shell/renderer_input.rs | 156 +- .../mnote-web/src/tree_shell/runtime_api.rs | 880 +++++++ .../crates/tree-shell-runtime-wasm/Cargo.toml | 18 + .../generated/.gitignore | 2 + .../crates/tree-shell-runtime-wasm/src/lib.rs | 71 + rust/target/.rustc_info.json | 2 +- scripts/desktop-hot.js | 61 +- scripts/desktop-hot.test.js | 110 + ...sk112-tree-rust-family-regression-smoke.js | 112 +- ...ask113-picker-keyboard-regression-smoke.js | 228 +- .../convex/_utils/documentMoveOrder.ts | 13 - wolai-frontend/convex/documents.ts | 11 +- .../scripts/build-tree-shell-runtime.js | 122 + wolai-frontend/scripts/dev-server.js | 4 + .../[...asset]/route.test.ts | 84 + .../tree-shell-runtime/[...asset]/route.ts | 153 ++ .../src/app/api/tree/commands/route.test.ts | 25 +- .../src/app/api/tree/commands/route.ts | 60 +- .../api/tree/projections/file/route.test.ts | 26 + .../app/api/tree/runtime/reduce/route.test.ts | 154 ++ .../src/app/api/tree/runtime/reduce/route.ts | 74 + .../move-embed-picker-dialog.test.tsx | 201 +- .../sidebar/tree-shell-dom-host.tsx | 1117 +++++++++ .../sidebar/tree-shell-dom-model.ts | 158 ++ .../sidebar/tree-shell-host.test.tsx | 82 + .../components/sidebar/tree-shell-host.tsx | 111 +- .../sidebar/tree-shell-iframe-host.test.tsx | 108 +- .../sidebar/tree-shell-iframe-host.tsx | 2187 ++++++++++++++--- .../sidebar/tree-shell-surface.test.tsx | 261 +- .../document-move-order-convex.test.ts | 99 + .../lib/documents/document-move-order.test.ts | 13 +- .../src/lib/documents/page-command-adapter.ts | 63 +- .../page-lifecycle-command-adapter.ts | 14 +- .../src/lib/documents/rust-runtime.test.ts | 176 +- .../src/lib/documents/rust-runtime.ts | 62 +- .../lib/file-tree/projection-client.test.ts | 22 + wolai-frontend/src/lib/kernel-file-tree.ts | 46 + .../src/lib/tree-route-boundary.test.ts | 40 + wolai-frontend/src/lib/tree-route-boundary.ts | 77 + 75 files changed, 9721 insertions(+), 1174 deletions(-) create mode 100644 design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md rename design/04-tree-domain/{process => done}/4-17-tree-3000-route-thin-proxy-boundary-v1.md (87%) create mode 100644 design/04-tree-domain/done/4-19-tree-domain-final-status-and-rust-dom-shell-cutover-v1.md delete mode 100644 design/04-tree-domain/process/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md create mode 100644 rust/crates/mnote-web/src/tree_shell/filetree_runtime.rs create mode 100644 rust/crates/mnote-web/src/tree_shell/page_runtime.rs create mode 100644 rust/crates/mnote-web/src/tree_shell/picker_runtime.rs create mode 100644 rust/crates/mnote-web/src/tree_shell/runtime_api.rs create mode 100644 rust/crates/tree-shell-runtime-wasm/Cargo.toml create mode 100644 rust/crates/tree-shell-runtime-wasm/generated/.gitignore create mode 100644 rust/crates/tree-shell-runtime-wasm/src/lib.rs create mode 100644 scripts/desktop-hot.test.js create mode 100644 wolai-frontend/scripts/build-tree-shell-runtime.js create mode 100644 wolai-frontend/src/app/api/tree-shell-runtime/[...asset]/route.test.ts create mode 100644 wolai-frontend/src/app/api/tree-shell-runtime/[...asset]/route.ts create mode 100644 wolai-frontend/src/app/api/tree/runtime/reduce/route.test.ts create mode 100644 wolai-frontend/src/app/api/tree/runtime/reduce/route.ts create mode 100644 wolai-frontend/src/components/sidebar/tree-shell-dom-host.tsx create mode 100644 wolai-frontend/src/components/sidebar/tree-shell-dom-model.ts create mode 100644 wolai-frontend/src/components/sidebar/tree-shell-host.test.tsx create mode 100644 wolai-frontend/src/lib/documents/document-move-order-convex.test.ts create mode 100644 wolai-frontend/src/lib/tree-route-boundary.test.ts create mode 100644 wolai-frontend/src/lib/tree-route-boundary.ts diff --git a/design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md b/design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md new file mode 100644 index 00000000..9832741f --- /dev/null +++ b/design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md @@ -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 facade:page / 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 seam:hydrated 与 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 artifact,HTTP `/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 与 blocks,snapshot 版本/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 pick,HTTP 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 L:tree.subtree.move 最终写路径下沉 Rust kernel + +### 目标 + +把 `tree.subtree.move` 的 legality、target parent、position normalize、canonical sort plan 与最终写入从 Convex compat mutation 继续回收到 Rust kernel / Rust write path。 + +### checklist + +- [x] 梳理当前 `normalizedMove` 写入链: + - [x] `/api/tree/commands` preflightData。 + - [x] `bridge-runtime` canonical order plan。 + - [x] Convex `documents.move` optional validation。 +- [x] Rust kernel 输出最终 write operation: + - [x] parent_id。 + - [x] sort_order patch set。 + - [x] workspace_id / updated_at。 + - [x] self / descendant / missing-parent legality error。 +- [x] Convex mutation 优先执行 Rust `treeWriteOperation`,退为 substrate 写入执行器与一致性断言层。 +- [x] 前端 Rust runtime / command adapter 已透传 `treeWriteOperation`,不再以 documents move compat 语义作为主路径。 +- [x] domain event 与 streamDelta 使用 Rust move result,而不是 TS/Convex 再拼。 +- [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 M:block/save-snapshot 复合命令 formal artifact 与 domain-event contract + +### 目标 + +把 `page.body.save / documents.save / blocks.patch|move|embed / snapshot save` 等复合命令补齐最终 formal Rust artifact / domain-event contract,避免停留在 noop 或过渡 hint。 + +### checklist + +- [x] 固定复合命令 event type: + - [x] `page.body.saved` + - [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 N:3000 Next route 过渡职责继续收薄 + +### 目标 + +`3000` 保持唯一浏览器公开入口,但 Next route 只做薄代理、认证/浏览器能力桥和兼容边界,不继续承担树域主语义、主 delta 或主 renderer runtime。 + +### checklist + +- [x] 盘点 3000 route 中仍在拼树域主语义的入口。 +- [x] 将 route 内 action switch 收敛到 Rust command result / artifact writer。 +- [x] 对 polling / SSE / snapshot route 标注 Rust 主体与 Next 薄代理边界。 +- [x] 隔离只服务旧 compat host 的 runtime 分支,`/api/tree/shell` 默认关闭 debug 直连。 +- [x] 保留文件字节读取、upload URL、cookie/auth 等浏览器入口必须职责。 +- [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 重新暴露为默认前端入口。 +- 不在前端新增第二套树结构真相或排序真相。 diff --git a/design/04-tree-domain/process/4-17-tree-3000-route-thin-proxy-boundary-v1.md b/design/04-tree-domain/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md similarity index 87% rename from design/04-tree-domain/process/4-17-tree-3000-route-thin-proxy-boundary-v1.md rename to design/04-tree-domain/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md index 19968c55..1aec0847 100644 --- a/design/04-tree-domain/process/4-17-tree-3000-route-thin-proxy-boundary-v1.md +++ b/design/04-tree-domain/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md @@ -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 > @@ -37,9 +37,9 @@ - create / copy 后创建页面 scaffold。 - copy 后复制 mindmap 文件。 - 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` diff --git a/design/04-tree-domain/done/4-19-tree-domain-final-status-and-rust-dom-shell-cutover-v1.md b/design/04-tree-domain/done/4-19-tree-domain-final-status-and-rust-dom-shell-cutover-v1.md new file mode 100644 index 00000000..8040d28b --- /dev/null +++ b/design/04-tree-domain/done/4-19-tree-domain-final-status-and-rust-dom-shell-cutover-v1.md @@ -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 / drop,filetree 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 host;legacy 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 artifact,HTTP 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。 diff --git a/design/04-tree-domain/process/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md b/design/04-tree-domain/process/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md deleted file mode 100644 index 1acfbac4..00000000 --- a/design/04-tree-domain/process/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md +++ /dev/null @@ -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 artifact;3000 主路径继续使用 same-origin inline host,`/api/tree/shell -> mnote-web:3104` debug 直连默认关闭。 -- Phase L 已引入 Rust `treeWriteOperation`,Convex `documents.move` 优先执行 Rust write operation,并保留 `normalizedMove` 作为兼容 fallback。 -- Phase M 已将 `page.body.saved`、`block.patched`、`block.moved`、`block.embedded` 切到 formal `domainEventPlan` payload schema,复合命令 stream delta 使用 `resync_required` 保守策略。 -- Phase N 已新增 route thinning manifest 与 boundary 测试,明确 `/api/tree/commands`、`/api/tree/stream`、`/api/tree/shell` 的 thin proxy / debug / compat 边界。 -- Phase O 已完成 `file_tree` 搜索 hardening:`maxResults=80`、祖先补全/排序/异步可见性元数据、空结果文案,以及 `index / asset-folder / asset / mindmap child / book / pdf` fixture 覆盖。 - -### 仍剩余 - -- `TreeShellIframeHost` 内联模板中的运行时执行层仍是 compat JS 镜像;已完成的是 Rust artifact/reducer contract,不是完整 wasm/Rust DOM runtime 替换。 -- `normalizedMove` fallback 仍保留;后续可在确认所有调用方都稳定传入 `treeWriteOperation` 后删除兼容 fallback。 -- `document.snapshot.saved` 仍未作为独立 event type 固定;当前 snapshot 信息随 `page.body.saved` payload 进入 formal schema。 -- `tree.node.embed` 的 `pageReference` block 组装仍在 3000 route compat 边界内,后续应进入 Page Aggregate / Rust artifact。 -- Phase O 的异步索引可见性目前是 projection request meta / move-embed picker 搜索路径契约,不是完整索引指标平台。 - -## 1. 当前基线 - -- `harness` task-001 至 task-009 已完成。 -- `page tree / file tree / picker` 默认主路径已使用 3000 same-origin inline host,不再请求 `/api/tree/shell -> mnote-web:3104`。 -- 首屏 DOM 已由 Rust initial renderer 输出: - - `data-rust-page-renderer="initial_v1"` - - `data-rust-filetree-renderer="initial_v1"` - - `data-rust-picker-renderer="initial_v1"` -- 主路径已统一为 `state.items + rendererInput`,不再通过 `__MNOTE_TREE_SHELL_OVERRIDE__` 注入第二份 items。 -- page focus、filetree selection、picker state 已暴露并消费 reducer contract;运行时执行仍主要是 compat JS 镜像。 -- 3000 inline host 与 `mnote-web /tree` debug shell 已同步输出 `rust_tree_shell_runtime_artifact_v1`,供后续真正 Rust runtime / wasm 替换执行层。 -- tree command move 写路径已优先使用 Rust `treeWriteOperation`;Convex 继续作为 substrate 与一致性校验层。 -- block/save 复合命令已具备 formal `domainEventPlan` 与 `resync_required` 保守 delta 合同。 - -## 2. 后续总目标 - -把树域从“Rust 持有语义 + compat JS 持有运行时 DOM/state machine”的过渡态,推进到: - -- [x] Rust family 持有 renderer runtime / state machine contract。 -- [ ] Rust family 真正持有 renderer runtime / DOM 执行层,不再依赖 `TreeShellIframeHost` compat JS 镜像。 -- [x] 3000 host 收薄为挂载、认证、transport bridge、artifact writer 与浏览器能力边界。 -- [x] Convex compat mutation 不再优先承担 tree command 的核心 legality / normalize / write truth。 -- [x] block/save 复合命令进入正式 Rust artifact / domain-event contract。 -- [ ] snapshot 独立事件与 Page Aggregate 深层收口继续后续推进。 - -## 3. Phase K:正式 Rust runtime / wasm 承接 tree shell 运行时 - -### 目标 - -完整替换 `TreeShellIframeHost` 内联模板中的主要 compat JS runtime,至少先把 page/filetree/picker 的核心状态机从字符串模板 JS 迁到 Rust runtime/wasm 或等价正式 Rust family runtime。 - -### checklist - -- [x] 定义 runtime artifact 边界: - - [x] 输入:`rendererInput`、projection items、expanded ids、selected row ids、active picker item、focused id。 - - [x] 输出:DOM patch / intent event / command dispatch event。 - - [x] 事件:focus、keyboard、expand/collapse、selection、context menu、drag/drop、pick。 -- [x] 为 page tree runtime 建立 Rust-side 状态机: - - [x] focus normalize / next / previous / home / end。 - - [x] expand / collapse / open / context-menu intent。 - - [x] drag move intent 与 drop feedback state。 -- [x] 为 file tree runtime 建立 Rust-side 状态机: - - [x] selection / range / context selection / normalize visible rows。 - - [x] drag row ids / copy-move effect / drop target feedback。 - - [x] asset-folder / asset / index / doc 的打开与菜单 intent。 -- [x] 为 picker runtime 建立 Rust-side 状态机: - - [x] next / previous / home / end / focus / pick。 - - [x] excluded ids 与 root pick。 - - [x] 搜索态输入框焦点不被 iframe/runtime 抢走。 -- [x] 3000 inline host 与 `mnote-web /tree` debug shell 输出并消费正式 runtime artifact contract。 -- [ ] 3000 host 不再维护主路径 JS 镜像状态机;仍需把 `TreeShellIframeHost` 内联模板 compat JS 执行层迁到 Rust wasm/runtime 或等价正式 runtime。 -- [x] `mnote-web /tree` debug shell 只保留 debug/internal runtime 验证入口。 - -### 验收 - -- [x] `pnpm vitest run src/components/sidebar/tree-shell-iframe-host.test.tsx src/components/sidebar/tree-shell-surface.test.tsx` -- [x] `cargo test -p mnote-web page_renderer filetree_renderer picker_renderer` -- [x] `node scripts/task112-tree-rust-family-regression-smoke.js` -- [x] `node scripts/task113-picker-keyboard-regression-smoke.js` -- [x] smoke 中 `direct_tree_shell_debug_disabled` 仍成立,3000 主路径不请求 3104。 - -### 当前保留边界 - -- 这里的完成口径是 artifact / reducer contract / debug 边界完成,不代表 `TreeShellIframeHost` 的 DOM runtime 已完全从 JS template 替换为 Rust wasm。 - -## 4. Phase L:tree.subtree.move 最终写路径下沉 Rust kernel - -### 目标 - -把 `tree.subtree.move` 的 legality、target parent、position normalize、canonical sort plan 与最终写入从 Convex compat mutation 继续回收到 Rust kernel / Rust write path。 - -### checklist - -- [x] 梳理当前 `normalizedMove` 写入链: - - [x] `/api/tree/commands` preflightData。 - - [x] `bridge-runtime` canonical order plan。 - - [x] Convex `documents.move` optional validation。 -- [x] Rust kernel 输出最终 write operation: - - [x] parent_id。 - - [x] sort_order patch set。 - - [x] workspace_id / updated_at。 - - [x] self / descendant / missing-parent legality error。 -- [x] Convex mutation 优先执行 Rust `treeWriteOperation`,退为 substrate 写入执行器与一致性断言层。 -- [x] 前端 Rust runtime / command adapter 已透传 `treeWriteOperation`,不再以 documents move compat 语义作为主路径。 -- [x] domain event 与 streamDelta 使用 Rust move result,而不是 TS/Convex 再拼。 -- [ ] 删除 `normalizedMove` fallback,并把无 `treeWriteOperation` 的旧 compat move 降级为显式 legacy/debug 路径。 - -### 验收 - -- [x] `cd rust && cargo test -p bridge-runtime tree_subtree_move_command` -- [x] `cd wolai-frontend && pnpm vitest run src/app/api/tree/commands/route.test.ts src/lib/documents/tree-command-client.test.ts src/lib/tree-stream/tree-delta.test.ts` -- [x] 增加或复用 Convex 层测试,证明 Convex 优先接受 Rust `treeWriteOperation` 并校验当前排序状态。 - -### 当前保留边界 - -- `normalizedMove` 仍作为旧调用链 fallback 保留;这不是第二份主排序真相,但仍是后续可删除的 compat 面。 - -## 5. Phase M:block/save-snapshot 复合命令 formal artifact 与 domain-event contract - -### 目标 - -把 `page.body.save / documents.save / blocks.patch|move|embed / snapshot save` 等复合命令补齐最终 formal Rust artifact / domain-event contract,避免停留在 noop 或过渡 hint。 - -### checklist - -- [x] 固定复合命令 event type: - - [x] `page.body.saved` - - [ ] `document.snapshot.saved` - - [x] `block.patched` - - [x] `block.moved` - - [x] `block.embedded` -- [x] 固定 payload schema: - - [x] document id / page id。 - - [x] block ids / patch summary / move source-target。 - - [x] snapshot version / content hash / updated_at 作为 `page.body.saved` payload 的一部分。 -- [x] Rust command plan 输出正式 `domainEventPlan` 与 `streamDeltaHint`,不再只用 noop 占位。 -- [x] 3000 adapters 与 Rust artifact writer 按同一 schema 落 `command_logs / domain_events`。 -- [x] SSE reducer 对这些事件有明确 delta 或保守 resync 策略。 -- [ ] 将 snapshot save 是否需要独立 `document.snapshot.saved` 事件定稿,并避免与 `page.body.saved` payload 重叠。 - -### 验收 - -- [x] `cd rust && cargo test -p bridge-runtime documents_save_command_plans_include_formal_domain_event_contract block_commands_include_formal_domain_event_contract` -- [x] `cd wolai-frontend && pnpm vitest run src/lib/documents/rust-runtime.test.ts src/lib/documents/page-write-command-adapter.test.ts src/lib/blocks/block-command-adapter.test.ts` -- [x] 至少一个非 noop 复合命令事件可在 command log 与 domain event 中稳定关联同一 `command_id`。 - -### 当前保留边界 - -- `document.snapshot.saved` 未独立落地;当前完成的是 `page.body.saved` formal payload 中包含 snapshot 摘要。 - -## 6. Phase N:3000 Next route 过渡职责继续收薄 - -### 目标 - -`3000` 保持唯一浏览器公开入口,但 Next route 只做薄代理、认证/浏览器能力桥和兼容边界,不继续承担树域主语义、主 delta 或主 renderer runtime。 - -### checklist - -- [x] 盘点 3000 route 中仍在拼树域主语义的入口。 -- [x] 将 route 内 action switch 收敛到 Rust command result / artifact writer。 -- [x] 对 polling / SSE / snapshot route 标注 Rust 主体与 Next 薄代理边界。 -- [x] 隔离只服务旧 compat host 的 runtime 分支,`/api/tree/shell` 默认关闭 debug 直连。 -- [x] 保留文件字节读取、upload URL、cookie/auth 等浏览器入口必须职责。 -- [ ] 将 `tree.node.embed` 的 `pageReference` block 组装从 3000 compat 边界迁到 Page Aggregate / Rust artifact。 - -### 验收 - -- [x] `cd wolai-frontend && pnpm vitest run src/app/api/tree/commands/route.test.ts src/lib/tree-stream/tree-delta.test.ts` -- [x] `node scripts/task112-tree-rust-family-regression-smoke.js` -- [x] 新增文档说明哪些 route 是 thin proxy,哪些仍是待迁移 compat:`design/04-tree-domain/process/4-17-tree-3000-route-thin-proxy-boundary-v1.md`。 - -### 当前保留边界 - -- 3000 仍是唯一浏览器公开入口;thin proxy 不等于删除浏览器能力桥。 -- `tree.node.embed` 的目标读取与 `pageReference` block 组装仍是明确标注的 compat 待迁移项。 - -## 7. Phase O:搜索语义 hardening - -### 目标 - -`file_tree` 搜索过滤主链已在 Rust projection query;后续只补更丰富搜索语义和稳定性,不作为当前 renderer/runtime gate。 - -### checklist - -- [x] 为 `index.md / asset / asset-folder / mindmap child / book / pdf` 搜索命中补更多 fixture。 -- [x] 明确 `maxResults` 与祖先补全的排序/截断规则。 -- [x] 增加异步索引可见性元数据,特别是 move/embed picker 搜索结果路径。 -- [x] 空结果与 fallback 文案保持稳定。 -- [ ] 如后续需要完整索引平台,再补真实异步索引延迟、刷新和可见性指标,而不是只依赖 projection request meta。 - -### 验收 - -- [x] `cd wolai-frontend && pnpm vitest run src/lib/tree-stream/tree-delta.test.ts` -- [x] 搜索 route / projection fixture 覆盖新增资源类型与边界。 - -### 当前保留边界 - -- 本阶段完成的是 file tree projection 搜索契约 hardening,不是独立搜索服务或完整索引指标平台。 - -## 8. 非目标 - -- 不拆除 Convex substrate。 -- 不把 OnlyOffice 嵌入 BlockNote 画布。 -- 不恢复旧 React PageTree/FileTree 作为 rust_family 主路径 fallback。 -- 不把 3104 重新暴露为默认前端入口。 -- 不在前端新增第二套树结构真相或排序真相。 diff --git a/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md b/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md index 23bc9676..ac85853a 100644 --- a/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md +++ b/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md @@ -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/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/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-6-page-aggregate-alignment-checklist-v1.md` > diff --git a/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v1.md b/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v1.md index 251c6f40..ec742ac3 100644 --- a/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v1.md +++ b/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-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/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/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` > > 历史参考: diff --git a/harness-progress.txt b/harness-progress.txt index d1f27c0e..e62cecfa 100644 --- a/harness-progress.txt +++ b/harness-progress.txt @@ -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] 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-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 model,legacy 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_host;TreeShellIframeHost 仅由 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 Q:page / 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 fallback;bridge-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 content;3000 route/documents embed adapter 只传 substrate preflight,tree-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 meta;mnote-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 diff --git a/harness-tasks.json b/harness-tasks.json index d24f4b7a..a5cecf6c 100644 --- a/harness-tasks.json +++ b/harness-tasks.json @@ -267,8 +267,420 @@ } ], "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 model,legacy 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" + } + ], + "completed_at": "2026-04-28T04:40:14Z" + }, + { + "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_host;TreeShellIframeHost 仅由 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 Q:page / 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 fallback:Rust plan 不再输出 normalizedMove,TS transport 强制 treeWriteOperation,Convex 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 pageAggregateEmbedPlan;3000 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 meta;mnote-web 与 3000 同源 projection route 均透传该指标,4-16 Phase O 已收口。", + "timestamp": "2026-04-28T07:44:37Z" + } + ], + "completed_at": "2026-04-28T07:44:37Z" } ], - "session_count": 3, - "last_session": "2026-04-27T00:41:58Z" + "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" + ] + } } diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f48475c1..69c5150e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -377,6 +377,16 @@ dependencies = [ "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]] name = "const-str" version = "1.1.0" @@ -1990,6 +2000,17 @@ dependencies = [ "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]] name = "serde_core" version = "1.0.228" @@ -2604,6 +2625,17 @@ dependencies = [ "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]] name = "try-lock" version = "0.2.5" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e6ce559e..7493131e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/mnote-web", "crates/storage-convex-bridge", "crates/index-fts", + "crates/tree-shell-runtime-wasm", ] resolver = "2" diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 8bf2de28..2c314ac7 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -294,6 +294,8 @@ pub struct RuntimeCommandExecutionPlan { pub struct RuntimeCommandArtifactPlan { pub command_log: RuntimeCommandLogArtifactPlan, pub domain_event: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub domain_events: Vec, } #[derive(Debug, Clone, Serialize, PartialEq)] @@ -627,7 +629,8 @@ struct DocumentEmbedCommandPayload { document_id: String, workspace_id: Option, revision: Option, - content: Value, + #[serde(default)] + content: Option, conflict_detection_key: Option, source_document_id: String, target_document_id: String, @@ -2247,6 +2250,32 @@ fn materialize_tree_domain_event_plan( result: &Value, ) -> Option<(String, Value)> { 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") || 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) @@ -2354,8 +2383,8 @@ pub fn build_runtime_command_artifact_plan( .and_then(|target| target.block_id.as_ref()) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); - let materialized_event_plan = materialize_tree_domain_event_plan(plan, result); - let command_payload = if let Some((_, domain_event_plan)) = &materialized_event_plan { + let materialized_event_plans = materialize_tree_domain_event_plans(plan, result); + let command_payload = if let Some((_, domain_event_plan)) = materialized_event_plans.first() { if let Some(stream_delta) = domain_event_plan.get("streamDelta") { let mut payload = command.payload.clone(); if let Some(map) = payload.as_object_mut() { @@ -2374,6 +2403,18 @@ pub fn build_runtime_command_artifact_plan( 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 { workspace_id: workspace_id.into(), id: command_log_id.clone(), @@ -2400,25 +2441,22 @@ pub fn build_runtime_command_artifact_plan( finished_at: Some(now.into()), }; - let domain_event = materialized_event_plan.map(|(event_type, domain_event_plan)| { - 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 domain_events: Vec = materialized_event_plans + .iter() + .enumerate() + .map(|(index, (event_type, domain_event_plan))| { RuntimeDomainEventArtifactPlan { 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(), trace_id: context.trace_id.clone(), command_id: command.command_id.clone(), - command_log_id, + command_log_id: command_log_id.clone(), event_type: event_type.clone(), aggregate_type: aggregate_type.into(), aggregate_id: aggregate_id.into(), @@ -2435,14 +2473,110 @@ pub fn build_runtime_command_artifact_plan( ), created_at: now.into(), } - }); + }) + .collect(); + let domain_event = domain_events.first().cloned(); Some(RuntimeCommandArtifactPlan { command_log, 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 { + 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, 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)] #[serde(rename_all = "camelCase")] struct RuntimeBlockSummary { @@ -6288,6 +6422,8 @@ fn build_file_tree_projection_result( filters: &KernelProjectionFilter, ) -> KernelProjectionResult { 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 .nodes .iter() @@ -6595,6 +6731,8 @@ fn build_file_tree_projection_result( } apply_file_tree_projection_search_filter(&mut items, &mut edges, filters); + let visible_rows = items.len(); + let visible_edges = edges.len(); KernelProjectionResult { projection_id: format!( @@ -6605,6 +6743,31 @@ fn build_file_tree_projection_result( root_node_id: root_node_id.map(ToOwned::to_owned), items, 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), items, edges: subtree.edges, + meta: BTreeMap::new(), }) } @@ -7677,11 +7841,24 @@ fn execute_command( "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, &editor_document, + ); + let snapshot_event_payload = document_save_snapshot_domain_event_payload( + &payload, &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!({ "id": payload.document_id, "content": canonical_content, @@ -7691,11 +7868,8 @@ fn execute_command( "conflictDetectionKey": payload.conflict_detection_key, "streamDeltaHint": stream_delta_hint, "domainEventHint": tree_domain_event_hint("page.body.saved"), - "domainEventPlan": tree_domain_event_plan_with_payload( - "page.body.saved", - domain_event_payload, - stream_delta_hint, - ), + "domainEventPlan": page_body_event_plan.clone(), + "domainEventPlans": [page_body_event_plan, snapshot_event_plan], }) }, })) @@ -7707,6 +7881,17 @@ fn execute_command( } else { "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 { name: command_name.into(), command_id: command_wire.command_id.clone(), @@ -7718,7 +7903,7 @@ fn execute_command( page_id: payload.document_id.clone(), workspace_id: payload.workspace_id.clone(), 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!( "{command_name} content 序列化失败: {error}" )) @@ -7743,12 +7928,13 @@ fn execute_command( payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, - "content": payload.content, + "content": embed_content, "expectedRevision": payload.revision, "conflictDetectionKey": payload.conflict_detection_key, "sourceDocumentId": payload.source_document_id, "targetDocumentId": payload.target_document_id, "anchorBlockId": payload.anchor_block_id, + "pageAggregateEmbedPlan": page_aggregate_embed_plan, "streamDeltaHint": tree_stream_delta_hint("noop", json!({})), "domainEventHint": tree_domain_event_hint("tree.node.embedded"), "domainEventPlan": tree_domain_event_plan( @@ -7979,8 +8165,12 @@ fn execute_command( "documents.move" | "tree.subtree.move" => { let payload: DocumentMoveCommandPayload = parse_payload(command_wire.payload.clone())?; 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())?; + 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" { "tree.subtree.move" } else { @@ -8014,11 +8204,7 @@ fn execute_command( "id": payload.document_id, "parentId": payload.parent_id, "sortOrder": payload.sort_order, - "normalizedMove": normalized_move, - "treeWriteOperation": document_move_write_operation( - context.workspace_id.as_deref(), - normalized_move.as_ref(), - ), + "treeWriteOperation": tree_write_operation, "streamDeltaHint": tree_stream_delta_hint("move_document", json!({ "documentId": payload.document_id, "parentId": payload.parent_id, @@ -8792,9 +8978,24 @@ fn editor_document_block_ids(document: &EditorBlockDocument) -> Vec { ids } -fn document_save_domain_event_payload( +fn document_save_page_body_domain_event_payload( payload: &DocumentSaveCommandPayload, 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, ) -> Result { Ok(json!({ @@ -8807,10 +9008,6 @@ fn document_save_domain_event_payload( "contentHash": stable_json_content_hash(canonical_content)?, "updatedAt": Value::Null, }, - "blocks": { - "ids": editor_document_block_ids(editor_document), - "count": editor_document.blocks.len(), - }, })) } @@ -9023,26 +9220,47 @@ mod tests { "id": "block_1", "type": "paragraph", }, - "streamDeltaHint": { - "family": "tree", - "kind": "noop", - "args": {} - }, - "domainEventHint": { - "family": "tree", - "eventType": "tree.block.patched" - }, - "domainEventPlan": { - "family": "tree", - "schema": "mnote.tree.domain_event", - "schemaVersion": 1, - "eventType": "tree.block.patched", - "streamDeltaHint": { - "family": "tree", - "kind": "noop", - "args": {} - } - } + "streamDeltaHint": { + "family": "tree", + "kind": "resync_required", + "args": { + "reason": "blocks.patch", + "documentId": "doc_1", + "blockId": "block_1" + } + }, + "domainEventHint": { + "family": "tree", + "eventType": "block.patched" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "block.patched", + "payload": { + "document": { + "id": "doc_1", + "workspaceId": "ws_1" + }, + "block": { + "id": "block_1" + }, + "patch": { + "summary": "replace_block", + "nextType": "paragraph" + } + }, + "streamDeltaHint": { + "family": "tree", + "kind": "resync_required", + "args": { + "reason": "blocks.patch", + "documentId": "doc_1", + "blockId": "block_1" + } + } + } }) ); } @@ -10197,11 +10415,6 @@ mod tests { "id": "doc_1", "workspaceId": "ws_1" }, - "snapshot": { - "version": 8, - "contentHash": "fnv1a64:d039f8f3496411e8", - "updatedAt": null - }, "blocks": { "ids": ["legacy_content_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] @@ -10610,10 +10879,11 @@ mod tests { "content": [{ "id": "block_1", "type": "pageReference" }], "expectedRevision": 5, "conflictDetectionKey": "conflict_5", - "sourceDocumentId": "doc_1", - "targetDocumentId": "doc_2", - "anchorBlockId": "anchor_1", - "streamDeltaHint": { + "sourceDocumentId": "doc_1", + "targetDocumentId": "doc_2", + "anchorBlockId": "anchor_1", + "pageAggregateEmbedPlan": null, + "streamDeltaHint": { "family": "tree", "kind": "noop", "args": {} @@ -11177,7 +11447,7 @@ mod tests { } #[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 { context: demo_context(), command: RuntimeCommandEnvelopeWire { @@ -11226,9 +11496,15 @@ mod tests { assert_eq!(plan.command_name, "tree.subtree.move"); assert_eq!(plan.function_name, "documents:move"); assert_eq!(plan.args_json["sortOrder"], json!(-2)); + assert!(plan.args_json.get("normalizedMove").is_none()); assert_eq!( - plan.args_json["normalizedMove"], + plan.args_json["treeWriteOperation"], json!({ + "family": "tree", + "schema": "mnote.tree.write_operation", + "schemaVersion": 1, + "operation": "tree.subtree.move.write", + "workspaceId": "ws_1", "documentId": "doc_b", "fromParentId": "source", "toParentId": "target", @@ -11711,20 +11987,33 @@ mod tests { page_id: Some("doc_2".into()), block_id: None, }), - payload: json!({ - "documentId": "doc_2", - "workspaceId": "ws_1", - "revision": 5, - "content": [{ "id": "block_1", "type": "pageReference" }], - "conflictDetectionKey": "conflict_5", - "sourceDocumentId": "doc_1", - "targetDocumentId": "doc_2", - "anchorBlockId": "anchor_1", - }), - preflight_data: None, - reason: Some("树命令嵌入页面".into()), - refs: vec![], - dry_run: false, + payload: json!({ + "documentId": "doc_2", + "workspaceId": "ws_1", + "revision": 5, + "conflictDetectionKey": "conflict_5", + "sourceDocumentId": "doc_1", + "targetDocumentId": "doc_2", + "anchorBlockId": "anchor_1", + }), + 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()), + refs: vec![], + dry_run: false, validate_only: false, }, }) @@ -11736,15 +12025,59 @@ mod tests { assert_eq!(plan.command_name, "tree.node.embed"); assert_eq!( plan.args_json, - json!({ - "id": "doc_2", - "content": [{ "id": "block_1", "type": "pageReference" }], - "expectedRevision": 5, - "conflictDetectionKey": "conflict_5", - "sourceDocumentId": "doc_1", - "targetDocumentId": "doc_2", - "anchorBlockId": "anchor_1", - "streamDeltaHint": { + json!({ + "id": "doc_2", + "content": { + "blocks": [ + { "id": "anchor_1", "type": "paragraph" }, + { + "id": "page_ref_doc_1", + "type": "pageReference", + "props": { + "pageId": "doc_1", + "title": "来源页面" + } + } + ], + "format": "editor" + }, + "expectedRevision": 5, + "conflictDetectionKey": "conflict_5", + "sourceDocumentId": "doc_1", + "targetDocumentId": "doc_2", + "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": { "family": "tree", "kind": "noop", "args": {} @@ -13181,6 +13514,18 @@ mod tests { result["projectionId"], 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!( item_by_row_id["doc:page_root"]["rowKind"], json!("document") diff --git a/rust/crates/core-protocol/src/kernel.rs b/rust/crates/core-protocol/src/kernel.rs index b8911afd..f0bda762 100644 --- a/rust/crates/core-protocol/src/kernel.rs +++ b/rust/crates/core-protocol/src/kernel.rs @@ -352,6 +352,8 @@ pub struct KernelProjectionResult { pub root_node_id: Option, pub items: Vec, pub edges: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub meta: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/rust/crates/mnote-web/src/routes/bridge.rs b/rust/crates/mnote-web/src/routes/bridge.rs index d90378a7..63674ae2 100644 --- a/rust/crates/mnote-web/src/routes/bridge.rs +++ b/rust/crates/mnote-web/src/routes/bridge.rs @@ -4,12 +4,12 @@ use crate::error::WebError; use crate::routes::query_support::{ execute_runtime_query_via_convex, resolve_effective_workspace_id, }; +use axum::Json; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; -use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::{Value, json}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -152,7 +152,7 @@ pub async fn trace( #[cfg(test)] mod tests { - use crate::app::{build_app, AppConfig, AppState}; + use crate::app::{AppConfig, AppState, build_app}; use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index 355f3880..fe0e349d 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -2,15 +2,15 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; 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::{Extension, State}; use axum::http::StatusCode; -use axum::Json; use core_protocol::KernelProjectionKind; use serde::Deserialize; use serde::Serialize; -use serde_json::{json, Value}; +use serde_json::{Value, json}; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -94,7 +94,7 @@ pub async fn next_sidebar( #[cfg(test)] mod tests { - use crate::app::{build_app, AppConfig, AppState}; + use crate::app::{AppConfig, AppState, build_app}; use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index d4fcf44c..ea444bc3 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -6,15 +6,15 @@ use crate::routes::query_support::{ execute_runtime_query_via_convex, fetch_documents_meta_via_convex, resolve_effective_workspace_id, }; +use axum::Json; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; -use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::{ RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, }; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::fs; use std::time::Duration; @@ -500,8 +500,8 @@ pub async fn save( #[cfg(test)] mod tests { - use crate::app::{build_app, AppConfig, AppState}; - use axum::body::{to_bytes, Body}; + use crate::app::{AppConfig, AppState, build_app}; + use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; use serde_json::Value; use tower::util::ServiceExt; diff --git a/rust/crates/mnote-web/src/routes/editor.rs b/rust/crates/mnote-web/src/routes/editor.rs index 8af849ec..7bbc8c4d 100644 --- a/rust/crates/mnote-web/src/routes/editor.rs +++ b/rust/crates/mnote-web/src/routes/editor.rs @@ -2,14 +2,14 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; 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::http::{header, HeaderValue, StatusCode}; +use axum::http::{HeaderValue, StatusCode, header}; use axum::response::{Html, IntoResponse, Response}; use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1761,10 +1761,10 @@ pub async fn transform_runtime_snapshot( #[cfg(test)] mod tests { - use crate::app::{build_app, AppConfig, AppState}; - use axum::body::{to_bytes, Body}; + use crate::app::{AppConfig, AppState, build_app}; + use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; - use serde_json::{json, Value}; + use serde_json::{Value, json}; use tower::util::ServiceExt; fn app() -> axum::Router { diff --git a/rust/crates/mnote-web/src/routes/health.rs b/rust/crates/mnote-web/src/routes/health.rs index b26fa25e..493e45dd 100644 --- a/rust/crates/mnote-web/src/routes/health.rs +++ b/rust/crates/mnote-web/src/routes/health.rs @@ -1,7 +1,7 @@ use crate::app::AppState; use crate::context::RequestContext; -use axum::extract::{Extension, State}; use axum::Json; +use axum::extract::{Extension, State}; use serde::Serialize; #[derive(Debug, Serialize)] diff --git a/rust/crates/mnote-web/src/routes/hermes.rs b/rust/crates/mnote-web/src/routes/hermes.rs index 2ecd24f9..f0fae4f2 100644 --- a/rust/crates/mnote-web/src/routes/hermes.rs +++ b/rust/crates/mnote-web/src/routes/hermes.rs @@ -1,15 +1,15 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; +use axum::Json; use axum::extract::{Extension, State}; use axum::http::StatusCode; -use axum::Json; use bridge_runtime::{ - build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query, - runtime_input_requests_result, RuntimeInput, + RuntimeInput, build_failure_response, build_success_response, execute_runtime_input, + execute_runtime_query, runtime_input_requests_result, }; use serde::Serialize; -use serde_json::{json, Value}; +use serde_json::{Value, json}; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index 06fc0bcc..e05697b7 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -3,16 +3,16 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::resolve_effective_workspace_id; use crate::routes::snapshot_support::{ - execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, - ProjectionSnapshotSpec, + ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, + subtree_query, }; +use axum::Json; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; -use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::{KernelGraphDirection, KernelProjectionKind}; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::{Value, json}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -188,8 +188,8 @@ pub async fn graph( #[cfg(test)] mod tests { - use crate::app::{build_app, AppConfig, AppState}; - use axum::body::{to_bytes, Body}; + use crate::app::{AppConfig, AppState, build_app}; + use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; use serde_json::Value; use tower::util::ServiceExt; @@ -386,11 +386,13 @@ mod tests { item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"], "mindmap" ); - assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"] - .as_array() - .expect("capabilities") - .iter() - .any(|value| value == "expand")); + assert!( + item_by_row_id["asset-folder:mind_1"]["capabilities"] + .as_array() + .expect("capabilities") + .iter() + .any(|value| value == "expand") + ); } #[tokio::test] @@ -417,6 +419,22 @@ mod tests { .collect::>(); 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[1]["resourceMeta"]["resourceKind"], "table"); let edges = payload["result"]["edges"].as_array().expect("edges"); diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 661cdfff..dae55ebb 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -14,8 +14,8 @@ mod tree; mod ws; use crate::app::AppState; -use axum::routing::{get, post}; use axum::Router; +use axum::routing::{get, post}; pub fn build_router(state: AppState) -> Router { 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/graph", get(kernel::graph)) .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/request", get(bridge::request)) .route("/api/bridge/trace", get(bridge::trace)) diff --git a/rust/crates/mnote-web/src/routes/query_support.rs b/rust/crates/mnote-web/src/routes/query_support.rs index 605143cf..fa62fc92 100644 --- a/rust/crates/mnote-web/src/routes/query_support.rs +++ b/rust/crates/mnote-web/src/routes/query_support.rs @@ -3,13 +3,13 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::transport::convex::execute_convex_query_plan; use bridge_runtime::{ - execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, - RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, - RuntimeSourceWire, + RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput, + RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, execute_runtime_input, + execute_runtime_query, }; use core_protocol::{GetPageMeta, QueryEnvelope}; 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( context: &RequestContext, diff --git a/rust/crates/mnote-web/src/routes/snapshot_support.rs b/rust/crates/mnote-web/src/routes/snapshot_support.rs index a8b62368..b48b65b7 100644 --- a/rust/crates/mnote-web/src/routes/snapshot_support.rs +++ b/rust/crates/mnote-web/src/routes/snapshot_support.rs @@ -6,7 +6,7 @@ use crate::routes::query_support::{ }; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::{KernelNodeType, KernelProjectionKind}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; #[derive(Debug, Clone)] pub struct ProjectionSnapshotSpec<'a> { diff --git a/rust/crates/mnote-web/src/routes/sse.rs b/rust/crates/mnote-web/src/routes/sse.rs index 3df2d519..a4d3d376 100644 --- a/rust/crates/mnote-web/src/routes/sse.rs +++ b/rust/crates/mnote-web/src/routes/sse.rs @@ -2,9 +2,8 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::stream_support::{ - build_stream_delta_payload, load_stream_overview, load_stream_snapshot, - read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind, - StreamSnapshotQuery, + StreamChangeKind, StreamSnapshotQuery, build_stream_delta_payload, load_stream_overview, + load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, }; use axum::extract::{Extension, Query, State}; use axum::response::sse::{Event, KeepAlive, Sse}; @@ -138,8 +137,8 @@ fn stream_event(event_name: &str, payload: &Value) -> Event { #[cfg(test)] mod tests { - use crate::app::{build_app, AppConfig, AppState}; - use axum::body::{to_bytes, Body}; + use crate::app::{AppConfig, AppState, build_app}; + use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; diff --git a/rust/crates/mnote-web/src/routes/stream_support.rs b/rust/crates/mnote-web/src/routes/stream_support.rs index 451bf1d8..f90afeb8 100644 --- a/rust/crates/mnote-web/src/routes/stream_support.rs +++ b/rust/crates/mnote-web/src/routes/stream_support.rs @@ -5,13 +5,13 @@ use crate::routes::query_support::{ execute_runtime_query_via_convex, resolve_effective_workspace_id, }; use crate::routes::snapshot_support::{ - execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, - ProjectionSnapshotSpec, + ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, + subtree_query, }; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::KernelProjectionKind; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::{Value, json}; const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [ "page.body.save", @@ -588,8 +588,8 @@ pub async fn load_stream_snapshot( #[cfg(test)] mod tests { use super::{ - resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, StreamChangeKind, - StreamSnapshotQuery, StreamSnapshotScope, + StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, resolve_stream_change, + resolve_stream_cursor, resolve_stream_scope, }; use serde_json::json; diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index 938ec5c8..c02db315 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -21,6 +21,10 @@ use crate::tree_shell::renderer_input::{ FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher, TreeShellRendererInput, }; +use crate::tree_shell::runtime_api::{ + TreeShellRuntimeRequest, TreeShellRuntimeResult, + reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, +}; use axum::Json; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderValue, StatusCode, header}; @@ -1200,6 +1204,14 @@ fn build_tree_shell_html( typeof rendererInput.pageFocusKeyboardReducer === "object" ? 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) => Array.isArray(value) ? value @@ -1294,6 +1306,10 @@ fn build_tree_shell_html( const trimmed = value.trim(); return trimmed || fallback; }; + const runtimeReduceEndpoint = normalizeText( + runtimeApi.reduceEndpoint, + "/api/tree/runtime/reduce", + ); const normalizeParent = (value) => { const normalized = normalizeText(value); @@ -2237,8 +2253,34 @@ fn build_tree_shell_html( }); }; - const toggleExpand = (nodeId) => { - const nextExpanded = !expanded.has(nodeId); + const commitPageExpandedIds = (expandedIds) => { + 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); else expanded.delete(nodeId); postPageExpandChange(nodeId, nextExpanded); @@ -2249,6 +2291,10 @@ fn build_tree_shell_html( renderTree(); }; + const toggleExpand = (nodeId) => { + applyLocalPageExpansionFallback(nodeId, !expanded.has(nodeId)); + }; + const getVisiblePageItems = () => { const visible = []; const walk = (entries) => { @@ -2423,7 +2469,167 @@ fn build_tree_shell_html( 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; const actionKind = normalizeText(action?.kind).toLowerCase(); if (!pageFocusKeyboardReducerActions.has(actionKind)) { @@ -2467,13 +2673,7 @@ fn build_tree_shell_html( } if (actionKind === "expand") { if (item?.childCount > 0 && !expanded.has(item.nodeId)) { - expanded.add(item.nodeId); - postPageExpandChange(item.nodeId, true); - if (usedRustInitialRenderer) { - patchPageTreeExpansionDom(item.nodeId); - } else { - renderTree(); - } + applyLocalPageExpansionFallback(item.nodeId, true); focusRowElement(item.nodeId); return; } @@ -2485,13 +2685,7 @@ fn build_tree_shell_html( } if (actionKind === "collapse") { if (item?.childCount > 0 && expanded.has(item.nodeId)) { - expanded.delete(item.nodeId); - postPageExpandChange(item.nodeId, false); - if (usedRustInitialRenderer) { - patchPageTreeExpansionDom(item.nodeId); - } else { - renderTree(); - } + applyLocalPageExpansionFallback(item.nodeId, false); focusRowElement(item.nodeId); 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) => { if (mode !== "picker") return; const normalizedItemKey = normalizeText(pickerItemKey); @@ -2976,16 +3199,15 @@ fn build_tree_shell_html( const action = normalizeText(element.dataset.rustAction); if (action === "toggle") { event.preventDefault(); - toggleExpand(item.nodeId); + applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, element); } else if (action === "open") { - handleNavigate(item.nodeId); + applyPageKeyboardAction({ kind: "open", nodeId: item.nodeId }, item, element); } else if (action === "create") { void handleCreate(item.nodeId); } else if (action === "rename") { void handleRename(item.nodeId); } else if (action === "menu") { - const center = getElementCenter(element); - openContextMenu(item.nodeId, center.x, center.y); + applyPageKeyboardAction({ kind: "context_menu", nodeId: item.nodeId }, item, element); } }); }); @@ -3524,7 +3746,7 @@ fn build_tree_shell_html( toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸"; toggleButton.addEventListener("click", (event) => { event.stopPropagation(); - toggleExpand(item.nodeId); + applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, event.currentTarget); }); row.appendChild(toggleButton); } else { @@ -4377,6 +4599,12 @@ pub async fn tree_command( )) } +pub async fn reduce_tree_shell_runtime( + Json(body): Json, +) -> Json { + Json(reduce_tree_shell_runtime_request(body)) +} + #[cfg(test)] mod tests { use super::{TreeCommandRequest, create_command_wire}; @@ -4441,6 +4669,19 @@ mod tests { assert!(html.contains("tree.shell.state.patch")); assert!(html.contains("\"contractName\":\"rust_page_focus_keyboard_reducer_v1\"")); assert!(html.contains("applyPageKeyboardAction")); + assert!(html.contains("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("patchPageTreeExpansionDom")); assert!(html.contains("data-rust-page-renderer=\"initial_v1\"")); @@ -4550,7 +4791,16 @@ mod tests { assert!(filetree_html.contains("\"commandDispatcher\"")); assert!(filetree_html.contains("\"runtimeArtifact\"")); assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\"")); - assert!(filetree_html.contains("\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]")); + 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() .oneshot( @@ -4571,6 +4821,146 @@ mod tests { assert!(picker_html.contains("\"excludedPickerIds\":[\"page_root\"]")); assert!(picker_html.contains("\"runtimeArtifact\"")); 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] diff --git a/rust/crates/mnote-web/src/routes/ws.rs b/rust/crates/mnote-web/src/routes/ws.rs index 876a9225..efbc61e1 100644 --- a/rust/crates/mnote-web/src/routes/ws.rs +++ b/rust/crates/mnote-web/src/routes/ws.rs @@ -1,12 +1,12 @@ use crate::app::AppState; use crate::context::RequestContext; 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::{Extension, Query, State}; use axum::response::Response; use futures_util::StreamExt; -use serde_json::{json, Value}; +use serde_json::{Value, json}; pub async fn socket( ws: WebSocketUpgrade, diff --git a/rust/crates/mnote-web/src/tree_shell/drag_drop_state.rs b/rust/crates/mnote-web/src/tree_shell/drag_drop_state.rs index 19db06d2..ed3677ca 100644 --- a/rust/crates/mnote-web/src/tree_shell/drag_drop_state.rs +++ b/rust/crates/mnote-web/src/tree_shell/drag_drop_state.rs @@ -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 { Copy, Move, diff --git a/rust/crates/mnote-web/src/tree_shell/filetree_runtime.rs b/rust/crates/mnote-web/src/tree_shell/filetree_runtime.rs new file mode 100644 index 00000000..2648a8ee --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/filetree_runtime.rs @@ -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, + pub rows: Vec, +} + +#[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, + pub asset_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileTreeRuntimeState { + pub selection: FileTreeSelectionState, + pub drag_row_ids: Vec, + pub drag_effect: Option, + pub drop_target_row_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileTreeRuntimeTransition { + pub state: FileTreeRuntimeState, + pub outputs: BTreeSet, +} + +#[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, + }, + DispatchInternalDrop { + target_row_id: Option, + row_ids: Vec, + copy: bool, + }, + DispatchExternalDrop { + target_row_id: Option, + 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, + target: Option, + row_ids: Vec, + copy: bool, + }, + ExternalDrop { + target_row_id: Option, + target: Option, + 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::>(); + 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 { + let row_by_id = env + .rows + .iter() + .map(|row| (row.row_id.as_str(), row)) + .collect::>(); + 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( + 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 { + 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 { .. }) + ))); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/filetree_selection.rs b/rust/crates/mnote-web/src/tree_shell/filetree_selection.rs index 9d140ae9..7e29ec61 100644 --- a/rust/crates/mnote-web/src/tree_shell/filetree_selection.rs +++ b/rust/crates/mnote-web/src/tree_shell/filetree_selection.rs @@ -1,4 +1,4 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; 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")] pub struct FileTreeSelectionModifiers { pub shift_key: bool, @@ -33,7 +33,7 @@ pub struct FileTreeSelectionModifiers { pub meta_key: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FileTreeSelectionState { pub selected_row_ids: BTreeSet, @@ -148,6 +148,26 @@ impl FileTreeSelectionState { } vec![row_id.to_string()] } + + pub fn resolve_drag_row_ids_for_visible_rows( + &self, + row_id: &str, + visible_row_ids: &[String], + ) -> Vec { + 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::>(); + 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 { @@ -177,12 +197,11 @@ mod tests { let visible = ids(&["doc:a", "doc:b", "asset:c", "asset:d"]); let mut state = FileTreeSelectionState::default(); - state = state.select_row( - "doc:b", - &visible, - FileTreeSelectionModifiers::default(), + state = state.select_row("doc:b", &visible, FileTreeSelectionModifiers::default()); + assert_eq!( + state.selected_row_ids, + 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.focused_row_id.as_deref(), Some("doc:b")); @@ -211,7 +230,9 @@ mod tests { ); assert_eq!( 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.focused_row_id.as_deref(), Some("doc:a")); @@ -224,12 +245,18 @@ mod tests { state.focused_row_id = Some("asset:missing".into()); 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.focused_row_id, None); 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.focused_row_id.as_deref(), Some("asset:c")); diff --git a/rust/crates/mnote-web/src/tree_shell/focus_state.rs b/rust/crates/mnote-web/src/tree_shell/focus_state.rs index 017f1fcb..3e94ef65 100644 --- a/rust/crates/mnote-web/src/tree_shell/focus_state.rs +++ b/rust/crates/mnote-web/src/tree_shell/focus_state.rs @@ -1,8 +1,7 @@ use serde::Serialize; use std::collections::BTreeSet; -pub const PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME: &str = - "rust_page_focus_keyboard_reducer_v1"; +pub const PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME: &str = "rust_page_focus_keyboard_reducer_v1"; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] @@ -24,6 +23,7 @@ impl Default for PageFocusKeyboardReducerContract { "move_end", "expand", "collapse", + "toggle", "open", "context_menu", ]), @@ -77,8 +77,8 @@ impl TreeShellFocusState { .as_deref() .and_then(|focused_id| visible_ids.iter().position(|id| id == focused_id)) .unwrap_or(0); - let next_index = (current_index as isize + offset) - .clamp(0, (visible_ids.len() - 1) as isize) as usize; + let next_index = + (current_index as isize + offset).clamp(0, (visible_ids.len() - 1) as isize) as usize; Self { focused_id: Some(visible_ids[next_index].clone()), } @@ -105,14 +105,23 @@ mod tests { .normalize(&visible_ids); 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.move_previous(&visible_ids).focused_id.as_deref(), Some("doc:b") ); - assert_eq!(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")); + assert_eq!( + 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] @@ -139,6 +148,7 @@ mod tests { assert!(contract.actions.contains("move_end")); assert!(contract.actions.contains("expand")); assert!(contract.actions.contains("collapse")); + assert!(contract.actions.contains("toggle")); assert!(contract.actions.contains("open")); assert!(contract.actions.contains("context_menu")); } diff --git a/rust/crates/mnote-web/src/tree_shell/mod.rs b/rust/crates/mnote-web/src/tree_shell/mod.rs index 85a8e457..af871d44 100644 --- a/rust/crates/mnote-web/src/tree_shell/mod.rs +++ b/rust/crates/mnote-web/src/tree_shell/mod.rs @@ -1,6 +1,6 @@ pub mod action_registry; -pub mod drag_drop_state; pub mod dispatcher; +pub mod drag_drop_state; pub mod expansion_state; pub mod filetree_renderer; pub mod filetree_runtime; @@ -15,6 +15,7 @@ pub mod picker_runtime; pub mod picker_state; pub mod protocol; pub mod renderer_input; +pub mod runtime_api; pub mod state; use leptos::prelude::*; diff --git a/rust/crates/mnote-web/src/tree_shell/page_renderer.rs b/rust/crates/mnote-web/src/tree_shell/page_renderer.rs index 753f45f5..47362d75 100644 --- a/rust/crates/mnote-web/src/tree_shell/page_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/page_renderer.rs @@ -115,9 +115,8 @@ fn render_page_row( } pub fn render_initial_page_tree_html(input: &PageTreeInitialRenderInput) -> String { - let mut html = String::from( - r#"
    "#, - ); + let mut html = + String::from(r#"
      "#); if input.rows.is_empty() { html.push_str( r#"
    • 当前 projection 没有可渲染的页面。
    • "#, diff --git a/rust/crates/mnote-web/src/tree_shell/page_runtime.rs b/rust/crates/mnote-web/src/tree_shell/page_runtime.rs new file mode 100644 index 00000000..1437d7fb --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/page_runtime.rs @@ -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, + pub expandable_node_ids: BTreeSet, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rows: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PageTreeRuntimeRow { + pub node_id: String, + pub parent_node_id: Option, + pub position: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PageTreeRuntimeState { + pub focused_id: Option, + pub expanded_ids: BTreeSet, + pub drop_feedback: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageTreeRuntimeTransition { + pub state: PageTreeRuntimeState, + pub outputs: BTreeSet, +} + +#[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, + }, + DispatchRename { + node_id: String, + title: String, + }, + UpdateDropFeedback { + feedback: Option, + }, + UpdateDropFeedbackForTarget { + source_node_id: String, + target_node_id: String, + position: PageTreeDropPosition, + }, + DispatchMove { + source_node_id: String, + target_parent_id: Option, + 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, + }, + RenameNode { + command_name: &'static str, + node_id: String, + title: String, + }, + MoveSubtree { + command_name: &'static str, + source_node_id: String, + target_node_id: Option, + target_parent_id: Option, + position: PageTreeDropPosition, + sort_order: Option, + }, +} + +#[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, + pub target_parent_id: Option, + 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) -> 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, + sort_order: i64, +} + +fn resolve_drop_feedback( + env: &PageTreeRuntimeEnvironment, + source_node_id: &str, + target_node_id: &str, + position: PageTreeDropPosition, +) -> Option { + 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 { + 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::>(); + 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( + 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 { + 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(), + }, + ))); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs b/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs index 9d9f35b1..03c511cb 100644 --- a/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs @@ -174,23 +174,26 @@ mod tests { let html = render_initial_picker_html(&PickerInitialRenderInput { allow_root_pick: true, root_active: false, - rows: vec![PickerRenderRow { - node_id: "page_root".into(), - parent_node_id: None, - title: "首页 <安全>".into(), - depth: 0, - expandable: false, - expanded: false, - active: true, - }, PickerRenderRow { - node_id: "page_other".into(), - parent_node_id: None, - title: "其他页面".into(), - depth: 0, - expandable: false, - expanded: false, - active: false, - }], + rows: vec![ + PickerRenderRow { + node_id: "page_root".into(), + parent_node_id: None, + title: "首页 <安全>".into(), + depth: 0, + expandable: false, + expanded: false, + active: true, + }, + PickerRenderRow { + node_id: "page_other".into(), + parent_node_id: None, + title: "其他页面".into(), + depth: 0, + expandable: false, + expanded: false, + active: false, + }, + ], }); assert!(html.contains("data-rust-picker-renderer=\"initial_v1\"")); diff --git a/rust/crates/mnote-web/src/tree_shell/picker_runtime.rs b/rust/crates/mnote-web/src/tree_shell/picker_runtime.rs new file mode 100644 index 00000000..6f6da1da --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/picker_runtime.rs @@ -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, + pub excluded_ids: BTreeSet, + 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, + pub pickable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PickerRuntimeState { + pub active_item_key: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickerRuntimeTransition { + pub state: PickerRuntimeState, + pub outputs: BTreeSet, +} + +#[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 { + 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( + 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 { + 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(), + }, + ))); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/picker_state.rs b/rust/crates/mnote-web/src/tree_shell/picker_state.rs index 058d129c..e9da9e6f 100644 --- a/rust/crates/mnote-web/src/tree_shell/picker_state.rs +++ b/rust/crates/mnote-web/src/tree_shell/picker_state.rs @@ -67,7 +67,9 @@ impl PickerState { pub fn move_end(&self, items: &[PickerItem], excluded_ids: &BTreeSet) -> 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()) } - fn move_by(&self, items: &[PickerItem], excluded_ids: &BTreeSet, offset: isize) -> Self { + fn move_by( + &self, + items: &[PickerItem], + excluded_ids: &BTreeSet, + offset: isize, + ) -> Self { let pickable = pickable_items(items, excluded_ids).collect::>(); if pickable.is_empty() { return Self::default(); @@ -88,8 +95,8 @@ impl PickerState { .as_deref() .and_then(|active| pickable.iter().position(|item| item.item_key == active)) .unwrap_or(0); - let next_index = (current_index as isize + offset) - .clamp(0, (pickable.len() - 1) as isize) as usize; + let next_index = + (current_index as isize + offset).clamp(0, (pickable.len() - 1) as isize) as usize; Self { 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)) } -fn first_pickable_item_key(items: &[PickerItem], excluded_ids: &BTreeSet) -> Option { - pickable_items(items, excluded_ids).next().map(|item| item.item_key.clone()) +fn first_pickable_item_key( + items: &[PickerItem], + excluded_ids: &BTreeSet, +) -> Option { + pickable_items(items, excluded_ids) + .next() + .map(|item| item.item_key.clone()) } fn is_pickable_item_key( @@ -158,7 +170,9 @@ mod tests { .normalize(&items, &excluded_ids); 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.pick(&items, &excluded_ids).as_deref(), Some("doc:c")); @@ -177,7 +191,10 @@ mod tests { let state = PickerState::default().move_end(&items, &excluded_ids); assert_eq!(state.active_item_key.as_deref(), Some("doc:c")); 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") ); diff --git a/rust/crates/mnote-web/src/tree_shell/renderer_input.rs b/rust/crates/mnote-web/src/tree_shell/renderer_input.rs index 57634f1f..2b9dce4a 100644 --- a/rust/crates/mnote-web/src/tree_shell/renderer_input.rs +++ b/rust/crates/mnote-web/src/tree_shell/renderer_input.rs @@ -40,15 +40,40 @@ pub struct TreeShellCommandDispatcher { #[serde(rename_all = "camelCase")] pub struct TreeShellRuntimeArtifactBoundary { 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 output_channels: 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 { fn default() -> Self { Self { 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([ "rendererInput", "projectionItems", @@ -67,6 +92,31 @@ impl Default for TreeShellRuntimeArtifactBoundary { "dragDrop", "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.focused_id.as_deref(), Some("asset:a")); - assert!(filetree - .filetree_selection - .selected_row_ids - .contains("asset:a")); + assert!( + filetree + .filetree_selection + .selected_row_ids + .contains("asset:a") + ); assert!(filetree.page_focus_keyboard_reducer.is_none()); assert_eq!( filetree @@ -245,6 +297,18 @@ mod tests { artifact.contract_name, "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("projectionItems")); assert!(artifact.input_fields.contains("expandedIds")); @@ -254,6 +318,90 @@ mod tests { assert!(artifact.output_channels.contains("domPatch")); assert!(artifact.output_channels.contains("intentEvent")); 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("keyboard")); assert!(artifact.event_kinds.contains("expandCollapse")); diff --git a/rust/crates/mnote-web/src/tree_shell/runtime_api.rs b/rust/crates/mnote-web/src/tree_shell/runtime_api.rs new file mode 100644 index 00000000..80108dbd --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/runtime_api.rs @@ -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, + pub host_events: Vec, + pub command_events: Vec, +} + +#[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, + expanded_ids: BTreeSet, + drop_feedback: Option, + }, + FileTreeState { + selected_row_ids: BTreeSet, + anchor_row_id: Option, + focused_row_id: Option, + drag_row_ids: Vec, + drag_effect: Option, + drop_target_row_id: Option, + }, + PickerState { + active_item_key: Option, + 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, + target: Option, + row_ids: Vec, + copy: bool, + }, + FileTreeExternalDrop { + target_row_id: Option, + target: Option, + 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, + }, + 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + target_parent_id: Option, + position: PageTreeDropPosition, + #[serde(default, skip_serializing_if = "Option::is_none")] + sort_order: Option, + }, + CopyResource { + command_name: String, + source_asset_ids: Vec, + target_document_id: String, + }, + MoveResource { + command_name: String, + source_asset_ids: Vec, + 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 { + values.iter().map(|value| (*value).to_string()).collect() + } + + fn set(values: &[&str]) -> BTreeSet { + 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 + } + ]) + ); + } +} diff --git a/rust/crates/tree-shell-runtime-wasm/Cargo.toml b/rust/crates/tree-shell-runtime-wasm/Cargo.toml new file mode 100644 index 00000000..a008e4bf --- /dev/null +++ b/rust/crates/tree-shell-runtime-wasm/Cargo.toml @@ -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" diff --git a/rust/crates/tree-shell-runtime-wasm/generated/.gitignore b/rust/crates/tree-shell-runtime-wasm/generated/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/rust/crates/tree-shell-runtime-wasm/generated/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/rust/crates/tree-shell-runtime-wasm/src/lib.rs b/rust/crates/tree-shell-runtime-wasm/src/lib.rs new file mode 100644 index 00000000..1b6375ec --- /dev/null +++ b/rust/crates/tree-shell-runtime-wasm/src/lib.rs @@ -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 { + let request = from_value::(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)) +} diff --git a/rust/target/.rustc_info.json b/rust/target/.rustc_info.json index b3a530bb..bf1da2c0 100644 --- a/rust/target/.rustc_info.json +++ b/rust/target/.rustc_info.json @@ -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":{}} \ No newline at end of file +{"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":{}} \ No newline at end of file diff --git a/scripts/desktop-hot.js b/scripts/desktop-hot.js index c7f8ca22..a5003782 100644 --- a/scripts/desktop-hot.js +++ b/scripts/desktop-hot.js @@ -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) { const free = await isPortFree("127.0.0.1", port); if (free) return true; @@ -225,7 +251,7 @@ async function ensurePortFree(port, nameForLog) { logPrefix(nameForLog, `检测到端口 ${port} 被占用,准备重启(结束旧进程):${killPids.join(", ")}`); for (const pid of killPids) { try { - execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" }); + terminatePid(pid); } catch { // ignore } @@ -240,6 +266,20 @@ async function ensurePortFree(port, nameForLog) { 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} 仍未释放,可能有其他程序占用。`); return false; } @@ -457,7 +497,18 @@ async function main() { } } -main().catch((error) => { - logPrefix("system", `启动失败:${error.message}`); - process.exit(1); -}); +if (require.main === module) { + main().catch((error) => { + logPrefix("system", `启动失败:${error.message}`); + process.exit(1); + }); +} + +module.exports = { + ensurePortFree, + getListeningPidsByPort, + getProcessNameByPid, + isPortFree, + resolveBackendExecutable, + terminatePid, +}; diff --git a/scripts/desktop-hot.test.js b/scripts/desktop-hot.test.js new file mode 100644 index 00000000..ab80b43f --- /dev/null +++ b/scripts/desktop-hot.test.js @@ -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); +}); diff --git a/scripts/task112-tree-rust-family-regression-smoke.js b/scripts/task112-tree-rust-family-regression-smoke.js index e90d49f6..b38f52be 100644 --- a/scripts/task112-tree-rust-family-regression-smoke.js +++ b/scripts/task112-tree-rust-family-regression-smoke.js @@ -23,6 +23,8 @@ const { const RUN_DIRECT_TREE_SHELL_CHECKS = 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 = {}) { const search = new URLSearchParams({ @@ -35,7 +37,19 @@ function buildTreeShellUrl(workspaceId, params = {}) { } 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) { @@ -46,16 +60,26 @@ async function waitForInlineTreeShellReady(page, surfaceTestId) { return false; } const implementation = host.getAttribute("data-tree-host-implementation") || ""; - if (implementation !== "mnote_web_iframe_proxy" && implementation !== "rust_inline_compat_host") { - return true; + if ( + implementation === "mnote_web_iframe_proxy" || + implementation === "rust_inline_compat_host" || + implementation === "rust_runtime_artifact_host" + ) { + const iframe = host.querySelector(`[data-testid="${testId}-rust-iframe"]`); + if (!(iframe instanceof HTMLIFrameElement) || iframe.getClientRects().length === 0) { + return false; + } + const doc = iframe.contentDocument; + const status = doc?.querySelector("#tree-shell-status")?.textContent ?? ""; + return Boolean(doc?.querySelector("#tree-shell-state")) && status.includes("就绪"); } - const iframe = host.querySelector(`[data-testid="${testId}-rust-iframe"]`); - if (!(iframe instanceof HTMLIFrameElement) || iframe.getClientRects().length === 0) { - return false; - } - const doc = iframe.contentDocument; - const status = doc?.querySelector("#tree-shell-status")?.textContent ?? ""; - return Boolean(doc?.querySelector("#tree-shell-state")) && status.includes("就绪"); + 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, { timeout: UI_TIMEOUT_MS }, @@ -161,16 +185,27 @@ async function getPageTreeHostDriver(page) { return false; } 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"]'); 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, { timeout: UI_TIMEOUT_MS }, ); const implementation = await host.getAttribute("data-tree-host-implementation"); + assertDefaultHostIsDomWasm(implementation, "sidebar-page-tree-shell"); if (treeHostImplementationUsesIframe(implementation)) { await waitForInlineTreeShellReady(page, "sidebar-page-tree-shell"); return { @@ -179,7 +214,8 @@ async function getPageTreeHostDriver(page) { 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) { @@ -192,16 +228,27 @@ async function getFileTreeHostDriver(page) { return false; } 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"]'); 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, { timeout: UI_TIMEOUT_MS }, ); const implementation = await host.getAttribute("data-tree-host-implementation"); + assertDefaultHostIsDomWasm(implementation, "sidebar-file-tree-shell"); if (treeHostImplementationUsesIframe(implementation)) { await waitForInlineTreeShellReady(page, "sidebar-file-tree-shell"); return { @@ -210,7 +257,8 @@ async function getFileTreeHostDriver(page) { 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) { @@ -228,7 +276,7 @@ async function openPageTreeDocumentFromHost(page, driver, documentId) { }); } else { 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 }); } const popupPage = await popupPromise; @@ -252,9 +300,9 @@ async function openPageTreeContextMenuFromHost(page, driver, documentId) { button.click(); }); } 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.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.keyboard.press("Escape"); @@ -270,7 +318,7 @@ async function doubleClickFileTreeDocumentFromHost(page, driver, documentId) { .dblclick({ timeout: UI_TIMEOUT_MS }); } else { 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 }); } const popupPage = await popupPromise; @@ -349,11 +397,11 @@ async function waitForPageTreeTitle(page, documentId, title) { } 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 }); await page.waitForFunction( ({ 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); }, { nodeId: documentId, expectedTitle: title }, @@ -387,11 +435,13 @@ async function waitForFileTreeTitle(page, documentId, title) { } 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 }); await page.waitForFunction( ({ 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); }, { docId: documentId, expectedTitle: title }, @@ -866,10 +916,12 @@ async function runPickerDialogChecks(context, fixture) { const pickerSurface = dialog.getByTestId("tree-picker-surface"); await pickerSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); const searchInput = dialog.getByPlaceholder("移动到..."); - const pickerUsesIframe = (await pickerSurface.locator('[data-testid="tree-picker-surface-rust-iframe"]').count()) > 0; - if (pickerUsesIframe) { - await waitForInlineTreeShellReady(page, "tree-picker-surface"); - } + 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 }; }; @@ -987,7 +1039,7 @@ async function runPickerDialogChecks(context, fixture) { { timeout: UI_TIMEOUT_MS }, ); } else { - await dialog + await keyboardDialog .locator(`[data-testid="tree-picker-row"][data-node-id="${fixture.targetId}"]`) .waitFor({ state: "visible", @@ -1048,7 +1100,7 @@ async function runPickerDialogChecks(context, fixture) { pickerSearch: true, pickerEmpty: true, pickerSelect: true, - pickerHostKind: keyboardUsesIframe ? "iframe" : "react", + pickerHostKind: keyboardUsesIframe ? "iframe" : "dom", emptyStateText, pickerRootVisible, pickerExcludeCurrentDocument: true, diff --git a/scripts/task113-picker-keyboard-regression-smoke.js b/scripts/task113-picker-keyboard-regression-smoke.js index 6beea965..b5c14409 100644 --- a/scripts/task113-picker-keyboard-regression-smoke.js +++ b/scripts/task113-picker-keyboard-regression-smoke.js @@ -15,6 +15,64 @@ const { renameDocument, } = 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() { const headless = process.env.MNOTE_SMOKE_HEADLESS === "1" @@ -61,6 +119,10 @@ async function main() { const dialog = page.getByRole("dialog"); 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 keyboardQuery = `task113-keyboard-${Date.now()}`; @@ -92,21 +154,27 @@ async function main() { try { await searchInput.fill(keyboardQuery, { timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - (nodeId) => { - const frame = Array.from( - document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'), - ) - .reverse() - .find((element) => element instanceof HTMLIFrameElement) ?? null; - if (!(frame instanceof HTMLIFrameElement)) { - return false; - } - return Boolean(frame.contentDocument?.querySelector(`.tree-row[data-node-id="${nodeId}"]`)); - }, - fixture.targetId, - { timeout: UI_TIMEOUT_MS }, - ); + if (usesLegacyIframe) { + await page.waitForFunction( + (nodeId) => { + const frame = Array.from( + document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'), + ) + .reverse() + .find((element) => element instanceof HTMLIFrameElement) ?? null; + if (!(frame instanceof HTMLIFrameElement)) { + return false; + } + return Boolean(frame.contentDocument?.querySelector(`.tree-row[data-node-id="${nodeId}"]`)); + }, + fixture.targetId, + { 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 page.waitForFunction( @@ -119,47 +187,61 @@ async function main() { ); const readFocusedPickerItemKey = async () => - page.evaluate(() => { - const frame = Array.from( - document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'), - ) - .reverse() - .find((element) => element instanceof HTMLIFrameElement) ?? null; - if (!(frame instanceof HTMLIFrameElement)) { - return ""; - } - const rootButton = frame.contentDocument?.querySelector( - '.tree-row[data-testid="tree-picker-root"][data-focused="true"]', - ); - if (rootButton) { - return "__root__"; - } - const row = frame.contentDocument?.querySelector('.tree-row[data-focused="true"]'); - return row ? row.getAttribute("data-node-id") || "" : ""; - }); + usesLegacyIframe + ? page.evaluate(() => { + const frame = Array.from( + document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'), + ) + .reverse() + .find((element) => element instanceof HTMLIFrameElement) ?? null; + if (!(frame instanceof HTMLIFrameElement) || !frame.contentDocument) { + return ""; + } + const rootButton = frame.contentDocument.querySelector( + '[data-testid="tree-picker-root"][data-focused="true"]', + ); + if (rootButton) { + return "__root__"; + } + 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") || "" : ""; + }); const moveHighlightTo = async (targetNodeId, maxSteps = 4) => { - await page.waitForFunction( - () => { - const frame = Array.from( - document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'), - ) - .reverse() - .find((element) => element instanceof HTMLIFrameElement) ?? null; - if (!(frame instanceof HTMLIFrameElement)) { - return false; - } - const rootButton = frame.contentDocument?.querySelector( - '.tree-row[data-testid="tree-picker-root"][data-focused="true"]', - ); - if (rootButton) { - return true; - } - return Boolean(frame.contentDocument?.querySelector('.tree-row[data-focused="true"]')); - }, - undefined, - { timeout: UI_TIMEOUT_MS }, - ); + if (usesLegacyIframe) { + await page.waitForFunction( + () => { + const frame = Array.from( + document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'), + ) + .reverse() + .find((element) => element instanceof HTMLIFrameElement) ?? null; + if (!(frame instanceof HTMLIFrameElement) || !frame.contentDocument) { + return false; + } + const rootButton = frame.contentDocument.querySelector( + '[data-testid="tree-picker-root"][data-focused="true"]', + ); + const row = frame.contentDocument.querySelector('.tree-row[data-focused="true"]'); + return Boolean(rootButton || row); + }, + undefined, + { 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) { const currentKey = await readFocusedPickerItemKey(); @@ -168,19 +250,33 @@ async function main() { } await page.keyboard.press("ArrowDown"); await page.waitForFunction( - (previousKey) => { - const frame = Array.from( - document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'), - ) - .reverse() - .find((element) => element instanceof HTMLIFrameElement) ?? null; - if (!(frame instanceof HTMLIFrameElement)) { + ({ previousKey, legacyIframe }) => { + if (legacyIframe) { + const frame = Array.from( + document.querySelectorAll('[data-testid="tree-picker-surface-rust-iframe"]'), + ) + .reverse() + .find((element) => element instanceof HTMLIFrameElement) ?? null; + if (!(frame instanceof HTMLIFrameElement) || !frame.contentDocument) { + return false; + } + const rootButton = frame.contentDocument.querySelector( + '[data-testid="tree-picker-root"][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 = frame.contentDocument?.querySelector( - '.tree-row[data-testid="tree-picker-root"][data-focused="true"]', - ); - const row = frame.contentDocument?.querySelector('.tree-row[data-focused="true"]'); + const rootButton = dialogElement.querySelector('[data-testid="tree-picker-root"][data-focused="true"]'); + const row = dialogElement.querySelector('.tree-row[data-focused="true"]'); const currentKey = rootButton ? "__root__" : row @@ -188,7 +284,7 @@ async function main() { : ""; return currentKey !== previousKey; }, - currentKey, + { previousKey: currentKey, legacyIframe: usesLegacyIframe }, { timeout: UI_TIMEOUT_MS }, ); await page.waitForFunction( diff --git a/wolai-frontend/convex/_utils/documentMoveOrder.ts b/wolai-frontend/convex/_utils/documentMoveOrder.ts index e54c6082..4aa36ab5 100644 --- a/wolai-frontend/convex/_utils/documentMoveOrder.ts +++ b/wolai-frontend/convex/_utils/documentMoveOrder.ts @@ -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 { const normalizedExpected = normalizeDocumentMoveWriteOperation(expected); if (!normalizedExpected) { diff --git a/wolai-frontend/convex/documents.ts b/wolai-frontend/convex/documents.ts index 4f8c9311..8c04877a 100644 --- a/wolai-frontend/convex/documents.ts +++ b/wolai-frontend/convex/documents.ts @@ -5,7 +5,6 @@ import { requireUserId } from "./_utils/auth"; import { nowIso } from "./_utils/time"; import { buildParentById, collectSubtree, isAncestorOf } from "./_utils/documentTree"; import { - assertDocumentMoveOrderPlanMatches, assertDocumentMoveWriteOperationMatches, buildDocumentMoveOrderPlanFromDocuments, } from "./_utils/documentMoveOrder"; @@ -1400,8 +1399,7 @@ export const move = mutation({ id: v.string(), parentId: v.union(v.string(), v.null()), sortOrder: v.number(), - normalizedMove: v.optional(v.any()), - treeWriteOperation: v.optional(v.any()), + treeWriteOperation: v.any(), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); @@ -1444,12 +1442,7 @@ export const move = mutation({ parentId: toParentId, sortOrder: args.sortOrder, }); - const rustMoveOrderPlan = - args.treeWriteOperation != null - ? assertDocumentMoveWriteOperationMatches(args.treeWriteOperation, currentMoveOrderPlan) - : args.normalizedMove != null - ? assertDocumentMoveOrderPlanMatches(args.normalizedMove, currentMoveOrderPlan) - : null; + const rustMoveOrderPlan = assertDocumentMoveWriteOperationMatches(args.treeWriteOperation, currentMoveOrderPlan); // 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order, // 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。 diff --git a/wolai-frontend/scripts/build-tree-shell-runtime.js b/wolai-frontend/scripts/build-tree-shell-runtime.js new file mode 100644 index 00000000..2097e62c --- /dev/null +++ b/wolai-frontend/scripts/build-tree-shell-runtime.js @@ -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); + }); +} diff --git a/wolai-frontend/scripts/dev-server.js b/wolai-frontend/scripts/dev-server.js index 7efa9019..d6267bf0 100644 --- a/wolai-frontend/scripts/dev-server.js +++ b/wolai-frontend/scripts/dev-server.js @@ -17,6 +17,7 @@ const net = require("net"); const path = require("path"); const next = require("next"); const { parse: parseUrl } = require("url"); +const { buildTreeShellRuntime } = require("./build-tree-shell-runtime"); const { buildLeptosTiptapIsland } = require("./build-leptos-tiptap-island"); const ONLYOFFICE_PREFIX = "/onlyoffice-server"; @@ -390,6 +391,9 @@ async function main() { const hostname = resolveHostname(); const dev = true; + // 说明:tree shell iframe 通过 3000 同源 js glue + wasm 直接调用 reducer,因此 dev 启动前先生成正式 artifact。 + await buildTreeShellRuntime(); + // 说明:3000 主链需要直接挂载正式 Leptos island,因此在 Next dev 启动前先生成 lib.rs 的 wasm-bindgen 产物。 await buildLeptosTiptapIsland(); diff --git a/wolai-frontend/src/app/api/tree-shell-runtime/[...asset]/route.test.ts b/wolai-frontend/src/app/api/tree-shell-runtime/[...asset]/route.test.ts new file mode 100644 index 00000000..a028aa13 --- /dev/null +++ b/wolai-frontend/src/app/api/tree-shell-runtime/[...asset]/route.test.ts @@ -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 资源路径", + }); + }); +}); diff --git a/wolai-frontend/src/app/api/tree-shell-runtime/[...asset]/route.ts b/wolai-frontend/src/app/api/tree-shell-runtime/[...asset]/route.ts new file mode 100644 index 00000000..2092a474 --- /dev/null +++ b/wolai-frontend/src/app/api/tree-shell-runtime/[...asset]/route.ts @@ -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 { + try { + await fs.access(absolutePath); + return true; + } catch { + return false; + } +} + +async function buildManifest(): Promise { + 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 }, + ); + } +} diff --git a/wolai-frontend/src/app/api/tree/commands/route.test.ts b/wolai-frontend/src/app/api/tree/commands/route.test.ts index 15916d80..96fac34b 100644 --- a/wolai-frontend/src/app/api/tree/commands/route.test.ts +++ b/wolai-frontend/src/app/api/tree/commands/route.test.ts @@ -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 = { mutation: vi.fn(), query: vi.fn(async (name: string, args: { id: string }) => { @@ -1342,18 +1342,19 @@ describe("/api/tree/commands route", () => { targetDocumentId: "doc_target", revision: 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( diff --git a/wolai-frontend/src/app/api/tree/commands/route.ts b/wolai-frontend/src/app/api/tree/commands/route.ts index 239a228b..991068e1 100644 --- a/wolai-frontend/src/app/api/tree/commands/route.ts +++ b/wolai-frontend/src/app/api/tree/commands/route.ts @@ -1,10 +1,8 @@ import { randomUUID } from "node:crypto"; import { NextResponse } from "next/server"; -import type { Json } from "@/types/supabase"; import { api } from "@/lib/convex/api"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { getAuthedConvexClient } from "@/lib/convex/route"; -import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content"; import { assertDocumentId, assertTitle, @@ -19,7 +17,6 @@ import { copyMindmapFilesIfExists, ensureDocumentScaffold, } from "@/lib/documents/page-lifecycle-side-effects"; -import { buildDocumentSavePayload } from "@/lib/documents/save-contract"; import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data"; import { executeRustBridgeMutationTransport, @@ -591,32 +588,9 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) { } const targetMeta = await client.query(api.documents.getMeta, { id: targetId }); - const currentBlocks = extractBlocksFromContent(targetContent.content); 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: randomUUID(), - type: "pageReference", - props: { - pageId: sourceId, - title: sourceDoc.title ?? "无标题", - }, - }, - ...currentBlocks.slice(insertIndex), - ]; - const nextContent = composeContentWithBlocks(targetContent.content, nextBlocks); const workspaceId = trimOrNull(sourceDoc.workspace_id) ?? trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id); @@ -629,24 +603,30 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) { workspaceId, commandName: "tree.node.embed", payload: { - ...buildDocumentSavePayload({ - documentId: targetId, - workspaceId, - revision: - typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision) - ? targetContent.revision - : null, - content: nextContent, - conflictDetectionKey: - typeof targetContent.conflict_detection_key === "string" - ? targetContent.conflict_detection_key - : null, - blockCount: nextBlocks.length, - }), + documentId: targetId, + workspaceId, + revision: + typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision) + ? targetContent.revision + : null, + conflictDetectionKey: + typeof targetContent.conflict_detection_key === "string" + ? targetContent.conflict_detection_key + : null, sourceDocumentId: sourceId, targetDocumentId: targetId, anchorBlockId: anchorId, }, + preflightData: { + pageAggregateEmbed: { + sourceDocumentId: sourceId, + sourceTitle: sourceDoc.title ?? "无标题", + targetDocumentId: targetId, + targetContent: targetContent.content, + anchorBlockId: anchorId, + blockId: randomUUID(), + }, + }, pageId: targetId, client, }); diff --git a/wolai-frontend/src/app/api/tree/projections/file/route.test.ts b/wolai-frontend/src/app/api/tree/projections/file/route.test.ts index eea49272..0748e024 100644 --- a/wolai-frontend/src/app/api/tree/projections/file/route.test.ts +++ b/wolai-frontend/src/app/api/tree/projections/file/route.test.ts @@ -98,6 +98,23 @@ describe("/api/tree/projections/file route", () => { }, ], 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(); }); @@ -130,6 +147,15 @@ describe("/api/tree/projections/file route", () => { result: { projection: "file_tree", 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([ diff --git a/wolai-frontend/src/app/api/tree/runtime/reduce/route.test.ts b/wolai-frontend/src/app/api/tree/runtime/reduce/route.test.ts new file mode 100644 index 00000000..8cf520ab --- /dev/null +++ b/wolai-frontend/src/app/api/tree/runtime/reduce/route.test.ts @@ -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", + }); + }); +}); diff --git a/wolai-frontend/src/app/api/tree/runtime/reduce/route.ts b/wolai-frontend/src/app/api/tree/runtime/reduce/route.ts new file mode 100644 index 00000000..b1a2a6cf --- /dev/null +++ b/wolai-frontend/src/app/api/tree/runtime/reduce/route.ts @@ -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", + }, + }, + ); + } +} diff --git a/wolai-frontend/src/components/documents/move-embed-picker-dialog.test.tsx b/wolai-frontend/src/components/documents/move-embed-picker-dialog.test.tsx index 8b76d7f1..968105ed 100644 --- a/wolai-frontend/src/components/documents/move-embed-picker-dialog.test.tsx +++ b/wolai-frontend/src/components/documents/move-embed-picker-dialog.test.tsx @@ -2,10 +2,14 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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"; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +type SidebarFixtureNode = SidebarTreeNode; + const sidebarData = { activeWorkspaceId: "ws_test", workspaces: [], @@ -17,15 +21,15 @@ const sidebarData = { items: [], edges: [], }, - kernelSidebarTree: [], + kernelSidebarTree: [] as SidebarFixtureNode[], trashedDocuments: [], }; function buildSidebarNode(input: { id: string; title: string; - children?: Array>; -}) { + children?: SidebarFixtureNode[]; +}): SidebarFixtureNode { return { access_scope: "private", id: input.id, @@ -66,12 +70,89 @@ vi.mock("@tanstack/react-query", () => ({ }, })); -const mockUseDocumentSearch = vi.fn(() => ({ +const mockUseDocumentSearch: Mock = vi.fn(() => ({ data: null, isLoading: false, error: null, })); +type RuntimeRequest = { + requestId: string; + mode: "page" | "fileTree" | "picker"; + environment?: Record; + state?: Record; + action?: Record; +}; + +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> : []; + 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", () => ({ useDocumentSearch: (...args: unknown[]) => mockUseDocumentSearch(...args), })); @@ -142,6 +223,7 @@ describe("MoveEmbedPickerDialog", () => { error: null, }); sidebarData.kernelSidebarTree = []; + delete (globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__; delete window.__MNOTE_RUNTIME_CONFIG__; }); @@ -438,6 +520,7 @@ describe("MoveEmbedPickerDialog", () => { window.__MNOTE_RUNTIME_CONFIG__ = { treeRendererFamily: "rust_family", }; + mockTreeRuntimeReducer(); const onPick = vi.fn(async () => undefined); const onOpenChange = vi.fn(); @@ -458,13 +541,15 @@ describe("MoveEmbedPickerDialog", () => { 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-implementation")).toBe("rust_inline_compat_host"); + expect(emptySurface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host"); expect( container.querySelector('[data-testid="tree-picker-surface-rust-host"]'), ).not.toBeNull(); expect( - container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'), + container.querySelector('[data-testid="tree-picker-surface-dom-host"]'), ).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({ data: { @@ -494,19 +579,22 @@ describe("MoveEmbedPickerDialog", () => { 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-implementation")).toBe("rust_inline_compat_host"); + expect(resultSurface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host"); expect( container.querySelector('[data-testid="tree-picker-surface-rust-host"]'), ).not.toBeNull(); expect( - container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'), + container.querySelector('[data-testid="tree-picker-surface-dom-host"]'), ).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__ = { treeRendererFamily: "rust_family", }; + const runtimeReducerMock = mockTreeRuntimeReducer(); mockUseDocumentSearch.mockReturnValue({ data: { results: [ @@ -525,23 +613,19 @@ describe("MoveEmbedPickerDialog", () => { isLoading: false, error: null, }); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - '', - { status: 200, headers: { "content-type": "text/html" } }, - ), - ); + const onPick = vi.fn(async () => undefined); + const onOpenChange = vi.fn(); await act(async () => { root.render( undefined)} + onPick={onPick} />, ); }); @@ -558,80 +642,41 @@ describe("MoveEmbedPickerDialog", () => { input?.dispatchEvent(new Event("change", { bubbles: true })); }); - const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'); - expect(iframe).not.toBeNull(); - Object.defineProperty(iframe, "contentWindow", { - configurable: true, - value: window, - }); - const postMessageMock = vi.spyOn(window, "postMessage").mockImplementation(() => undefined); - postMessageMock.mockClear(); + expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull(); + expect(container.querySelector('[data-testid="tree-picker-surface-dom-host"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_first"]')?.getAttribute("data-focused"), + ).toBe("true"); await act(async () => { input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + await Promise.resolve(); }); + await waitForRuntimeReducer(runtimeReducerMock); - expect(postMessageMock).toHaveBeenCalledWith( + expect(runtimeReducerMock).toHaveBeenCalledWith( expect.objectContaining({ - channel: "tree-picker-surface", - type: "tree.picker.command", - command: "next", + mode: "picker", + action: expect.objectContaining({ kind: "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(postMessageMock).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "tree-picker-surface", - type: "tree.shell.state.patch", - activeDocumentId: "doc_second", - activePickerItemKey: "doc_second", - }), - "*", - ); - - postMessageMock.mockClear(); + expect( + container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_second"]')?.getAttribute("data-focused"), + ).toBe("true"); await act(async () => { input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); }); - expect(postMessageMock).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "tree-picker-surface", - type: "tree.picker.command", - command: "pick", - }), - "*", - ); + expect(onPick).toHaveBeenCalledWith("move", "doc_second"); + expect(onOpenChange).toHaveBeenCalledWith(false); }); - it("rust_family 配置下,无根目录且无结果时也应保持 same-origin host 空态", async () => { + it("rust_family 配置下,无根目录且无结果时也应保持 DOM shell 空态", async () => { window.__MNOTE_RUNTIME_CONFIG__ = { treeRendererFamily: "rust_family", }; - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - '', - { status: 200, headers: { "content-type": "text/html" } }, - ), - ); + mockTreeRuntimeReducer(); const onPick = vi.fn(async () => undefined); const onOpenChange = vi.fn(); @@ -655,12 +700,14 @@ describe("MoveEmbedPickerDialog", () => { }); 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-implementation")).toBe("rust_inline_compat_host"); - expect(iframe).not.toBeNull(); - expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1"); + expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host"); + expect(domHost).not.toBeNull(); + 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("没有匹配结果"); }); }); diff --git a/wolai-frontend/src/components/sidebar/tree-shell-dom-host.tsx b/wolai-frontend/src/components/sidebar/tree-shell-dom-host.tsx new file mode 100644 index 00000000..4e45aa68 --- /dev/null +++ b/wolai-frontend/src/components/sidebar/tree-shell-dom-host.tsx @@ -0,0 +1,1117 @@ +"use client"; + +import type { DragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent, ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + buildTreeShellDomChildrenByParent, + buildTreeShellDomFiletreeSelection, + buildTreeShellDomKernelFileTreeItems, + buildTreeShellDomPageItems, + buildTreeShellDomPickerItems, + type TreeShellDomProjectionItem, + type TreeShellPickerItem, +} from "@/components/sidebar/tree-shell-dom-model"; +import type { + FileTreeShellExternalDropPayload, + FileTreeShellInternalDropPayload, + TreeShellHostMode, + TreeShellPickerCommand, +} from "@/components/sidebar/tree-shell-host"; +import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree"; +import type { PageTreeProjectionItem } from "@/lib/tree-projection"; +import { cn } from "@/lib/utils"; + +type PageRuntimeState = { + focusedId: string | null; + expandedIds: string[]; + dropFeedback?: { + sourceNodeId: string; + targetNodeId?: string | null; + targetParentId: string | null; + position: "before" | "inside" | "after"; + } | null; +}; + +type FileTreeRuntimeState = { + selection: { + selectedRowIds: string[]; + anchorRowId: string | null; + focusedRowId: string | null; + }; + dragRowIds: string[]; + dragEffect: "copy" | "move" | "none" | null; + dropTargetRowId: string | null; +}; + +type PickerRuntimeState = { + activeItemKey: string | null; +}; + +type TreeShellRuntimeResult = { + requestId: string; + mode: "page" | "fileTree" | "picker"; + state?: { mode: "page"; state: PageRuntimeState } | { mode: "fileTree"; state: FileTreeRuntimeState } | { mode: "picker"; state: PickerRuntimeState }; + hostEvents?: Array>; + commandEvents?: Array>; +}; + +type TreeShellRustDomShellHostProps = { + mode: TreeShellHostMode; + surfaceTestId: string; + workspaceId: string; + rootNodeId?: string | null; + activeDocumentId?: string | null; + focusedDocumentId?: string | null; + activePickerItemKey?: string | null; + allowRootPick?: boolean; + excludeIds?: string[]; + pickerCommand?: TreeShellPickerCommand | null; + pickerItems?: TreeShellPickerItem[]; + pageTreeItems?: PageTreeProjectionItem[]; + inlineFileTreeItems?: KernelFileTreeProjectionItem[]; + channel?: string; + host?: string; + onNavigate?: (documentId: string) => void; + onPick?: (targetId: string | null) => void; + onPickerFocusChange?: (payload: { itemKey: string | null; documentId: string | null }) => void; + onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void; + onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void; + onPageFocusChange?: (payload: { documentId: string | null }) => void; + onFileTreeContextMenu?: (payload: { + documentId: string | null; + assetId: string | null; + rowId: string | null; + rowKind: string | null; + x: number; + y: number; + }) => void; + onFileTreeSelectionChange?: (payload: { + selectedRowIds: string[]; + anchorRowId: string | null; + focusedRowId: string | null; + }) => void; + onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void; + onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void; + onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void; + onTreeMutation?: (payload: { type: string; documentId: string | null }) => void; + children?: ReactNode; +}; + +const PAGE_DROP_MIME = "application/x-mnote-page-tree-node"; +const FILETREE_DROP_MIME = "application/x-mnote-file-tree"; +const LEGACY_FILETREE_DROP_MIME = "application/x-mnote-filetree-row-ids"; +const RUNTIME_REDUCE_ENDPOINT = "/api/tree/runtime/reduce"; +const RUNTIME_JS_GLUE_URL = "/api/tree-shell-runtime/mnote-tree-shell-runtime.js"; +const RUNTIME_WASM_MODULE_URL = "/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm"; + +type TreeShellRuntimeWasmModule = { + default?: (moduleOrPath?: string | URL | Request) => Promise | unknown; + init?: (moduleOrPath?: string | URL | Request) => Promise | unknown; + reduceTreeShellRuntime?: (request: unknown) => Promise | unknown; + reduce_tree_shell_runtime?: (request: unknown) => Promise | unknown; +}; + +type TreeShellRuntimeReducer = (request: unknown) => Promise | unknown; + +type TreeShellRuntimeGlobal = typeof globalThis & { + __MNOTE_TREE_SHELL_RUNTIME__?: { + reduceTreeShellRuntime?: TreeShellRuntimeReducer; + reduce_tree_shell_runtime?: TreeShellRuntimeReducer; + }; +}; + +let treeShellRuntimeWasmPromise: + | Promise<{ reduceTreeShellRuntime: (request: unknown) => Promise }> + | null = null; + +const normalizeString = (value: unknown, defaultValue = "") => { + if (typeof value !== "string") { + return defaultValue; + } + const trimmed = value.trim(); + return trimmed || defaultValue; +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const normalizeStringArray = (value: unknown) => + Array.isArray(value) ? value.map((item) => normalizeString(item)).filter(Boolean) : []; + +function toRowId(item: TreeShellDomProjectionItem) { + return item.rowId || item.nodeId; +} + +function toDocumentId(item: TreeShellDomProjectionItem) { + return normalizeString(item.resourceMeta?.documentId, item.nodeId); +} + +function toAssetId(item: TreeShellDomProjectionItem) { + return normalizeString(item.resourceMeta?.assetId) || null; +} + +function toFiletreeTestId(item: TreeShellDomProjectionItem) { + const rowKind = normalizeString(item.rowKind, "doc"); + if (rowKind === "doc" || rowKind === "document") return "filetree-doc-row"; + if (rowKind === "index") return "filetree-index-row"; + if (rowKind === "asset-folder" || rowKind === "asset_folder") return "filetree-asset-folder-row"; + return "filetree-asset-row"; +} + +function toShellRowKind(item: TreeShellDomProjectionItem) { + const rowKind = normalizeString(item.rowKind, "doc"); + if (rowKind === "document") return "doc"; + if (rowKind === "assetFolder" || rowKind === "asset_folder") return "asset-folder"; + return rowKind; +} + +function normalizeShellRowKind(value: unknown, defaultValue = "") { + const rowKind = normalizeString(value, defaultValue); + if (rowKind === "document") return "doc"; + if (rowKind === "assetFolder" || rowKind === "asset_folder") return "asset-folder"; + return rowKind; +} + +function itemHasVisibleChildren(item: TreeShellDomProjectionItem, childrenByParent: Map) { + return item.childCount > 0 && (childrenByParent.get(item.nodeId)?.length ?? 0) > 0; +} + +function readFiletreeDragRowIds(dataTransfer: DataTransfer) { + for (const type of [FILETREE_DROP_MIME, LEGACY_FILETREE_DROP_MIME, "text/plain"]) { + const raw = dataTransfer.getData(type); + if (!raw) continue; + try { + const parsed = JSON.parse(raw) as { rowIds?: unknown }; + if (Array.isArray(parsed.rowIds)) { + return parsed.rowIds.map((rowId) => normalizeString(rowId)).filter(Boolean); + } + } catch { + const normalized = normalizeString(raw); + if (normalized) return [normalized]; + } + } + return []; +} + +function normalizeRuntimeState(result: TreeShellRuntimeResult | null, mode: "page" | "fileTree" | "picker"): T | null { + if (!result?.state || result.state.mode !== mode) { + return null; + } + return result.state.state as T; +} + +async function loadTreeShellRuntimeWasm() { + if (!treeShellRuntimeWasmPromise) { + treeShellRuntimeWasmPromise = (async () => { + const preloadedRuntime = (globalThis as TreeShellRuntimeGlobal).__MNOTE_TREE_SHELL_RUNTIME__; + const preloadedReducer = + typeof preloadedRuntime?.reduceTreeShellRuntime === "function" + ? preloadedRuntime.reduceTreeShellRuntime + : typeof preloadedRuntime?.reduce_tree_shell_runtime === "function" + ? preloadedRuntime.reduce_tree_shell_runtime + : null; + if (preloadedReducer) { + return { + reduceTreeShellRuntime: async (request: unknown) => + (await preloadedReducer(request)) as TreeShellRuntimeResult, + }; + } + const runtimeModule = (await import( + /* webpackIgnore: true */ RUNTIME_JS_GLUE_URL + )) as TreeShellRuntimeWasmModule; + const initRuntime = + typeof runtimeModule.default === "function" + ? runtimeModule.default + : typeof runtimeModule.init === "function" + ? runtimeModule.init + : null; + if (initRuntime) { + await initRuntime(RUNTIME_WASM_MODULE_URL); + } + const reduceTreeShellRuntime = + typeof runtimeModule.reduceTreeShellRuntime === "function" + ? runtimeModule.reduceTreeShellRuntime + : typeof runtimeModule.reduce_tree_shell_runtime === "function" + ? runtimeModule.reduce_tree_shell_runtime + : null; + if (!reduceTreeShellRuntime) { + throw new Error("tree shell wasm reducer missing"); + } + return { + reduceTreeShellRuntime: async (request: unknown) => + (await reduceTreeShellRuntime(request)) as TreeShellRuntimeResult, + }; + })().catch((error) => { + treeShellRuntimeWasmPromise = null; + throw error; + }); + } + return treeShellRuntimeWasmPromise; +} + +export function TreeShellRustDomShellHost({ + mode, + surfaceTestId, + workspaceId, + activeDocumentId = null, + focusedDocumentId = null, + activePickerItemKey = null, + allowRootPick = false, + excludeIds = [], + pickerCommand = null, + pickerItems, + pageTreeItems, + inlineFileTreeItems, + onNavigate, + onPick, + onPickerFocusChange, + onPageContextMenu, + onPageExpandChange, + onPageFocusChange, + onFileTreeContextMenu, + onFileTreeSelectionChange, + onInternalDrop, + onDropFiles, + onAssetOpen, + onTreeMutation, +}: TreeShellRustDomShellHostProps) { + const [pageState, setPageState] = useState(() => ({ + focusedId: focusedDocumentId ?? activeDocumentId, + expandedIds: [], + dropFeedback: null, + })); + const [fileTreeState, setFileTreeState] = useState(() => { + const selection = buildTreeShellDomFiletreeSelection(activeDocumentId); + return { + selection, + dragRowIds: [], + dragEffect: null, + dropTargetRowId: null, + }; + }); + const [pickerState, setPickerState] = useState(() => ({ + activeItemKey: activePickerItemKey, + })); + const pickerCommandSeqRef = useRef(null); + + const pageItems = useMemo(() => { + if (mode !== "page") return []; + const expanded = new Set( + pageTreeItems + ?.filter((item) => item.childCount > 0 && item.expandedByDefault) + .map((item) => item.nodeId) ?? [], + ); + return buildTreeShellDomPageItems(pageTreeItems ?? [], expanded); + }, [mode, pageTreeItems]); + const fileTreeItems = useMemo( + () => (mode === "filetree" ? buildTreeShellDomKernelFileTreeItems(inlineFileTreeItems ?? []) : []), + [inlineFileTreeItems, mode], + ); + const pickerProjectionItems = useMemo( + () => (mode === "picker" ? buildTreeShellDomPickerItems(pickerItems ?? []) : []), + [mode, pickerItems], + ); + const items = mode === "page" ? pageItems : mode === "filetree" ? fileTreeItems : pickerProjectionItems; + const childrenByParent = useMemo(() => buildTreeShellDomChildrenByParent(items), [items]); + const itemByNodeId = useMemo( + () => new Map(items.map((item) => [item.nodeId, item])), + [items], + ); + const filetreeItemByRowId = useMemo( + () => new Map(fileTreeItems.map((item) => [toRowId(item), item])), + [fileTreeItems], + ); + const visiblePageItems = useMemo(() => { + const expanded = new Set(pageState.expandedIds); + const visible: TreeShellDomProjectionItem[] = []; + const visit = (parentId: string) => { + for (const item of childrenByParent.get(parentId) ?? []) { + visible.push(item); + if (expanded.has(item.nodeId)) { + visit(item.nodeId); + } + } + }; + visit(""); + return visible; + }, [childrenByParent, pageState.expandedIds]); + const pickerEntries = useMemo(() => { + const excluded = new Set(excludeIds); + const docRows = (pickerItems ?? []) + .filter((item): item is Extract => { + return item.kind === "doc" && Boolean(normalizeString(item.id)) && !excluded.has(item.id); + }) + .map((item, index) => ({ + itemKey: item.id, + documentId: item.id, + title: normalizeString(item.title, "无标题"), + subtitle: item.subtitle, + depth: typeof item.depth === "number" && Number.isFinite(item.depth) ? Math.max(0, item.depth) : 0, + position: index, + })); + return allowRootPick + ? [ + { + itemKey: "__root__", + documentId: null, + title: pickerItems?.find((item) => item.kind === "root")?.title ?? "根目录", + subtitle: pickerItems?.find((item) => item.kind === "root")?.subtitle, + depth: 0, + position: -1, + }, + ...docRows, + ] + : docRows; + }, [allowRootPick, excludeIds, pickerItems]); + + const reduceRuntime = useCallback( + async (request: Record) => { + try { + const runtime = await loadTreeShellRuntimeWasm(); + const result = await runtime.reduceTreeShellRuntime(request); + if (result?.requestId === request.requestId) { + return result; + } + } catch { + // 说明:WASM artifact 在测试或降级环境不可用时,退到同源 HTTP seam。 + } + try { + const response = await fetch(`${RUNTIME_REDUCE_ENDPOINT}?workspaceId=${encodeURIComponent(workspaceId)}`, { + method: "POST", + credentials: "include", + headers: { + accept: "application/json", + "content-type": "application/json", + }, + body: JSON.stringify(request), + }); + if (!response.ok) { + return null; + } + return (await response.json().catch(() => null)) as TreeShellRuntimeResult | null; + } catch { + return null; + } + }, + [workspaceId], + ); + + const reducePageAction = useCallback( + async (action: Record, stateSnapshot: PageRuntimeState = pageState) => { + const requestId = `page-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const result = await reduceRuntime({ + mode: "page", + requestId, + environment: { + visibleNodeIds: visiblePageItems.map((item) => item.nodeId), + expandableNodeIds: pageItems.filter((item) => itemHasVisibleChildren(item, childrenByParent)).map((item) => item.nodeId), + rows: pageItems.map((item) => ({ + nodeId: item.nodeId, + parentNodeId: item.parentNodeId, + position: item.position, + })), + }, + state: stateSnapshot, + action, + }); + const nextState = normalizeRuntimeState(result, "page"); + if (nextState) { + setPageState(nextState); + return { result, state: nextState }; + } + return { result: null, state: stateSnapshot }; + }, + [childrenByParent, pageItems, pageState, reduceRuntime, visiblePageItems], + ); + + const reduceFileTreeAction = useCallback( + async (action: Record, stateSnapshot: FileTreeRuntimeState = fileTreeState) => { + const requestId = `filetree-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const result = await reduceRuntime({ + mode: "fileTree", + requestId, + environment: { + visibleRowIds: fileTreeItems.map(toRowId), + rows: fileTreeItems.map((item) => ({ + rowId: toRowId(item), + rowKind: toShellRowKind(item), + documentId: toDocumentId(item) || null, + assetId: toAssetId(item), + })), + }, + state: stateSnapshot, + action, + }); + const nextState = normalizeRuntimeState(result, "fileTree"); + if (nextState) { + setFileTreeState(nextState); + onFileTreeSelectionChange?.(nextState.selection); + return { result, state: nextState }; + } + return { result: null, state: stateSnapshot }; + }, + [fileTreeItems, fileTreeState, onFileTreeSelectionChange, reduceRuntime], + ); + + const reducePickerAction = useCallback( + async (action: Record, stateSnapshot: PickerRuntimeState = pickerState) => { + const requestId = `picker-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const result = await reduceRuntime({ + mode: "picker", + requestId, + environment: { + items: [ + ...(allowRootPick + ? [{ itemKey: "__root__", documentId: null, pickable: true }] + : []), + ...pickerEntries + .filter((item) => item.documentId) + .map((item) => ({ + itemKey: item.itemKey, + documentId: item.documentId, + pickable: true, + })), + ], + excludedIds: excludeIds, + allowRootPick, + }, + state: stateSnapshot, + action, + }); + const nextState = normalizeRuntimeState(result, "picker"); + if (nextState) { + setPickerState(nextState); + const documentId = nextState.activeItemKey === "__root__" ? null : nextState.activeItemKey; + onPickerFocusChange?.({ itemKey: nextState.activeItemKey, documentId }); + return { result, state: nextState }; + } + return { result: null, state: stateSnapshot }; + }, + [allowRootPick, excludeIds, onPickerFocusChange, pickerEntries, pickerState, reduceRuntime], + ); + + const applyPageHostEvents = useCallback( + (hostEvents?: Array>, sourceElement?: HTMLElement | null) => { + for (const event of hostEvents ?? []) { + if (event.kind === "pageOpen") { + const nodeId = normalizeString(event.nodeId); + if (!nodeId) continue; + onPageFocusChange?.({ documentId: nodeId }); + onNavigate?.(nodeId); + continue; + } + if (event.kind === "pageContextMenu") { + const nodeId = normalizeString(event.nodeId); + if (!nodeId) continue; + const rect = sourceElement?.getBoundingClientRect(); + onPageContextMenu?.({ + documentId: nodeId, + x: rect ? rect.left + rect.width / 2 : 0, + y: rect ? rect.top + rect.height / 2 : 0, + }); + } + } + }, + [onNavigate, onPageContextMenu, onPageFocusChange], + ); + + const buildFileTreeDropPayload = useCallback( + (event: Record, context: { rowId: string | null; rowKind: string | null; files?: FileList | File[] }) => { + const target = isRecord(event.target) ? event.target : {}; + const targetRowId = normalizeString(event.targetRowId, context.rowId ?? "") || null; + const targetRowKind = normalizeShellRowKind(target.kind, context.rowKind ?? "") || null; + const documentId = normalizeString(target.documentId) || null; + const assetId = normalizeString(target.assetId) || null; + return { + targetDocumentId: documentId, + targetRowId, + targetRowKind, + targetAssetId: assetId, + files: context.files, + }; + }, + [], + ); + + const applyFileTreeHostEvents = useCallback( + ( + hostEvents: Array> | undefined, + context?: { + rowId?: string | null; + rowKind?: string | null; + x?: number; + y?: number; + rowIds?: string[]; + copy?: boolean; + files?: FileList | File[]; + }, + ) => { + for (const event of hostEvents ?? []) { + const target = isRecord(event.target) ? event.target : {}; + const documentId = normalizeString(target.documentId) || null; + const assetId = normalizeString(target.assetId) || null; + const rowId = normalizeString(event.rowId, context?.rowId ?? "") || normalizeString(event.targetRowId, context?.rowId ?? "") || null; + const rowKind = normalizeShellRowKind(target.kind, context?.rowKind ?? "") || null; + if (event.kind === "fileTreeOpen") { + if (assetId) { + onAssetOpen?.({ assetId, documentId }); + } else if (documentId) { + onNavigate?.(documentId); + } + continue; + } + if (event.kind === "fileTreeContextMenu") { + onFileTreeContextMenu?.({ + documentId, + assetId, + rowId, + rowKind, + x: context?.x ?? 0, + y: context?.y ?? 0, + }); + continue; + } + if (event.kind === "fileTreeInternalDrop") { + const payload = buildFileTreeDropPayload(event, { + rowId: context?.rowId ?? null, + rowKind: context?.rowKind ?? null, + }); + const rowIds = normalizeStringArray(event.rowIds); + onInternalDrop?.({ + targetDocumentId: payload.targetDocumentId, + targetRowId: payload.targetRowId, + targetRowKind: payload.targetRowKind, + targetAssetId: payload.targetAssetId, + rowIds: rowIds.length > 0 ? rowIds : (context?.rowIds ?? []), + copy: event.copy === true || context?.copy === true, + }); + continue; + } + if (event.kind === "fileTreeExternalDrop") { + const payload = buildFileTreeDropPayload(event, { + rowId: context?.rowId ?? null, + rowKind: context?.rowKind ?? null, + files: context?.files, + }); + if (payload.files) { + onDropFiles?.({ + targetDocumentId: payload.targetDocumentId, + targetRowId: payload.targetRowId, + targetRowKind: payload.targetRowKind, + targetAssetId: payload.targetAssetId, + files: payload.files, + }); + } + } + } + }, + [buildFileTreeDropPayload, onAssetOpen, onDropFiles, onFileTreeContextMenu, onInternalDrop, onNavigate], + ); + + const applyPickerHostEvents = useCallback( + (hostEvents?: Array>) => { + for (const event of hostEvents ?? []) { + if (event.kind === "pickerPickRoot") { + onPick?.(null); + continue; + } + if (event.kind === "pickerPickDocument") { + const documentId = normalizeString(event.documentId); + if (documentId) { + onPick?.(documentId); + } + } + } + }, + [onPick], + ); + + useEffect(() => { + if (mode !== "page") return; + const expandedIds = pageItems.filter((item) => item.expandedByDefault).map((item) => item.nodeId); + setPageState({ + focusedId: focusedDocumentId ?? activeDocumentId, + expandedIds, + dropFeedback: null, + }); + }, [activeDocumentId, focusedDocumentId, mode, pageItems]); + + useEffect(() => { + if (mode !== "filetree") return; + const selection = buildTreeShellDomFiletreeSelection(activeDocumentId); + setFileTreeState({ + selection, + dragRowIds: [], + dragEffect: null, + dropTargetRowId: null, + }); + onFileTreeSelectionChange?.(selection); + }, [activeDocumentId, mode, onFileTreeSelectionChange]); + + useEffect(() => { + if (mode !== "picker") return; + setPickerState({ activeItemKey: activePickerItemKey }); + }, [activePickerItemKey, mode, pickerEntries.length]); + + useEffect(() => { + if (mode !== "picker" || !pickerCommand || pickerCommandSeqRef.current === pickerCommand.seq) { + return; + } + pickerCommandSeqRef.current = pickerCommand.seq; + void reducePickerAction({ kind: pickerCommand.kind }).then(({ result }) => { + if (pickerCommand.kind === "pick") { + applyPickerHostEvents(result?.hostEvents); + } + }); + }, [applyPickerHostEvents, mode, pickerCommand, reducePickerAction]); + + const runTreeCommand = useCallback( + async (payload: Record) => { + const response = await fetch("/api/tree/commands", { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ workspaceId, ...payload }), + }); + const result = await response.json().catch(() => null); + if (!response.ok) { + const message = result && typeof result.error === "string" ? result.error : "tree command failed"; + throw new Error(message); + } + return result; + }, + [workspaceId], + ); + + const dispatchPageCommandEvents = useCallback( + async (commandEvents?: Array>) => { + for (const event of commandEvents ?? []) { + if (event.kind === "createNode") { + const parentId = normalizeString(event.parentNodeId) || null; + const result = await runTreeCommand({ action: "create", parentId }); + const documentId = normalizeString((result as { result?: { documentId?: string }; id?: string })?.result?.documentId, normalizeString((result as { id?: string })?.id)); + onTreeMutation?.({ type: "tree.node.created", documentId: documentId || null }); + continue; + } + if (event.kind === "renameNode") { + const documentId = normalizeString(event.nodeId); + const title = normalizeString(event.title); + if (!documentId || !title) continue; + await runTreeCommand({ action: "rename", documentId, title }); + onTreeMutation?.({ type: "tree.node.renamed", documentId }); + continue; + } + if (event.kind === "moveSubtree") { + const documentId = normalizeString(event.sourceNodeId); + if (!documentId) continue; + const parentId = normalizeString(event.targetParentId) || null; + const sortOrder = typeof event.sortOrder === "number" && Number.isFinite(event.sortOrder) ? event.sortOrder : 0; + await runTreeCommand({ action: "move", documentId, parentId, sortOrder }); + onTreeMutation?.({ type: "tree.subtree.moved", documentId }); + } + } + }, + [onTreeMutation, runTreeCommand], + ); + + const handlePageOpen = useCallback( + async (nodeId: string) => { + const { state } = await reducePageAction({ kind: "focus", nodeId }); + const { result } = await reducePageAction({ kind: "openFocused" }, state); + applyPageHostEvents(result?.hostEvents); + }, + [applyPageHostEvents, reducePageAction], + ); + + const handlePageToggle = useCallback( + async (nodeId: string) => { + const { state } = await reducePageAction({ kind: "toggle", nodeId }); + if (state !== pageState) { + onPageExpandChange?.({ documentId: nodeId, expanded: state.expandedIds.includes(nodeId) }); + } + }, + [onPageExpandChange, pageState.expandedIds, reducePageAction], + ); + + const handlePageKeyDown = useCallback( + async (event: ReactKeyboardEvent, nodeId: string) => { + const visibleIds = visiblePageItems.map((item) => item.nodeId); + const index = Math.max(0, visibleIds.indexOf(nodeId)); + const focusId = async (nextId: string | null) => { + if (!nextId) return; + const { state } = await reducePageAction({ kind: "focus", nodeId: nextId }); + if (state.focusedId === nextId) { + onPageFocusChange?.({ documentId: nextId }); + } + }; + if (event.key === "ArrowDown") { + event.preventDefault(); + await focusId(visibleIds[Math.min(visibleIds.length - 1, index + 1)] ?? null); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + await focusId(visibleIds[Math.max(0, index - 1)] ?? null); + } else if (event.key === "Home") { + event.preventDefault(); + await focusId(visibleIds[0] ?? null); + } else if (event.key === "End") { + event.preventDefault(); + await focusId(visibleIds[visibleIds.length - 1] ?? null); + } else if (event.key === "ArrowLeft") { + event.preventDefault(); + if (pageState.expandedIds.includes(nodeId)) await handlePageToggle(nodeId); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + if (!pageState.expandedIds.includes(nodeId)) await handlePageToggle(nodeId); + } else if (event.key === "Enter") { + event.preventDefault(); + const { state } = await reducePageAction({ kind: "focus", nodeId }); + const { result } = await reducePageAction({ kind: "openFocused" }, state); + applyPageHostEvents(result?.hostEvents); + } + }, + [applyPageHostEvents, handlePageToggle, onPageFocusChange, pageState.expandedIds, reducePageAction, visiblePageItems], + ); + + const handlePageDrop = useCallback( + async (sourceNodeId: string, targetNodeId: string) => { + if (!sourceNodeId || !targetNodeId || sourceNodeId === targetNodeId) return; + const { result } = await reducePageAction({ + kind: "dispatchMoveToTarget", + sourceNodeId, + targetNodeId, + position: "before", + }); + const commandEvents = result?.commandEvents; + if (commandEvents?.length) { + await dispatchPageCommandEvents(commandEvents); + } + }, + [dispatchPageCommandEvents, reducePageAction], + ); + + const handlePageAction = useCallback( + async (kind: "create" | "rename" | "menu", nodeId: string, event?: MouseEvent) => { + if (kind === "menu") { + const { state } = await reducePageAction({ kind: "focus", nodeId }); + const { result } = await reducePageAction({ kind: "contextMenuFocused" }, state); + applyPageHostEvents(result?.hostEvents, event?.currentTarget as HTMLElement | null); + return; + } + const action = + kind === "create" + ? { kind: "dispatchCreate", parentNodeId: nodeId } + : { + kind: "dispatchRename", + nodeId, + title: window.prompt("输入新的标题", itemByNodeId.get(nodeId)?.title ?? "无标题") ?? "", + }; + if (kind === "rename" && !normalizeString(action.title)) { + return; + } + const { result } = await reducePageAction(action); + await dispatchPageCommandEvents(result?.commandEvents); + }, + [applyPageHostEvents, dispatchPageCommandEvents, itemByNodeId, reducePageAction], + ); + + const handleFileTreeOpen = useCallback( + async (item: TreeShellDomProjectionItem) => { + const rowId = toRowId(item); + const { result } = await reduceFileTreeAction({ kind: "openRow", rowId }); + applyFileTreeHostEvents(result?.hostEvents, { + rowId, + rowKind: toShellRowKind(item), + }); + }, + [applyFileTreeHostEvents, reduceFileTreeAction], + ); + + const handleFileTreeContextMenu = useCallback( + async (item: TreeShellDomProjectionItem, event: MouseEvent) => { + event.preventDefault(); + const rowId = toRowId(item); + const { state } = await reduceFileTreeAction({ kind: "selectContextRow", rowId }); + const { result } = await reduceFileTreeAction({ kind: "contextMenuRow", rowId }, state); + applyFileTreeHostEvents(result?.hostEvents, { + rowId, + rowKind: toShellRowKind(item), + x: event.clientX, + y: event.clientY, + }); + }, + [applyFileTreeHostEvents, reduceFileTreeAction], + ); + + const handleFileTreeSelect = useCallback( + async (item: TreeShellDomProjectionItem, event: MouseEvent) => { + const rowId = toRowId(item); + await reduceFileTreeAction({ + kind: "selectRow", + rowId, + modifiers: { + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }, + }); + }, + [reduceFileTreeAction], + ); + + const handleFileTreeDrop = useCallback( + async (item: TreeShellDomProjectionItem, event: DragEvent) => { + event.preventDefault(); + const rowId = toRowId(item); + if (event.dataTransfer.files?.length) { + const { result } = await reduceFileTreeAction({ kind: "dispatchExternalDrop", targetRowId: rowId, fileCount: event.dataTransfer.files.length }); + applyFileTreeHostEvents(result?.hostEvents, { + rowId, + rowKind: toShellRowKind(item), + files: event.dataTransfer.files, + }); + return; + } + const rowIds = readFiletreeDragRowIds(event.dataTransfer); + if (rowIds.length === 0) return; + const copy = Boolean(event.altKey); + const { result } = await reduceFileTreeAction({ kind: "dispatchInternalDrop", targetRowId: rowId, rowIds, copy }); + applyFileTreeHostEvents(result?.hostEvents, { + rowId, + rowKind: toShellRowKind(item), + rowIds, + copy, + }); + }, + [applyFileTreeHostEvents, reduceFileTreeAction], + ); + + const handlePickerPick = useCallback( + async (itemKey: string) => { + const { state } = await reducePickerAction({ kind: "focus", itemKey, focusDom: false }); + const { result } = await reducePickerAction({ kind: "pick" }, state); + applyPickerHostEvents(result?.hostEvents); + }, + [applyPickerHostEvents, reducePickerAction], + ); + + const renderPageRows = (parentId: string): ReactNode => { + const rows = childrenByParent.get(parentId) ?? []; + if (parentId === "" && rows.length === 0) { + return
      当前 projection 没有可渲染的页面。
      ; + } + return rows.map((item) => { + const children = childrenByParent.get(item.nodeId) ?? []; + const expandable = itemHasVisibleChildren(item, childrenByParent); + const expanded = pageState.expandedIds.includes(item.nodeId); + const focused = pageState.focusedId === item.nodeId; + const active = activeDocumentId === item.nodeId; + const dropFeedback = pageState.dropFeedback?.targetNodeId === item.nodeId; + return ( +
      +
      void reducePageAction({ kind: "focus", nodeId: item.nodeId })} + onKeyDown={(event) => void handlePageKeyDown(event, item.nodeId)} + onDragStart={(event) => { + event.dataTransfer.setData(PAGE_DROP_MIME, item.nodeId); + event.dataTransfer.setData("text/plain", item.nodeId); + }} + onDragOver={(event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDrop={(event) => { + event.preventDefault(); + const sourceNodeId = normalizeString(event.dataTransfer.getData(PAGE_DROP_MIME), normalizeString(event.dataTransfer.getData("text/plain"))); + void handlePageDrop(sourceNodeId, item.nodeId); + }} + > + {expandable ? ( + + ) : ( +
      + {expanded && children.length > 0 ?
      {renderPageRows(item.nodeId)}
      : null} +
      + ); + }); + }; + + const renderFileTreeRows = () => { + if (fileTreeItems.length === 0) { + return
      当前 file tree 没有可渲染的页面。
      ; + } + return fileTreeItems.map((item) => { + const rowId = toRowId(item); + const rowKind = normalizeString(item.rowKind, "doc"); + const documentId = toDocumentId(item); + const assetId = toAssetId(item); + const selected = fileTreeState.selection.selectedRowIds.includes(rowId); + return ( +
      void handleFileTreeSelect(item, event)} + onDoubleClick={() => void handleFileTreeOpen(item)} + onContextMenu={(event) => void handleFileTreeContextMenu(item, event)} + onDragStart={(event) => { + const rowIds = fileTreeState.selection.selectedRowIds.includes(rowId) ? fileTreeState.selection.selectedRowIds : [rowId]; + const payload = JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds }); + event.dataTransfer.setData(FILETREE_DROP_MIME, payload); + event.dataTransfer.setData(LEGACY_FILETREE_DROP_MIME, payload); + event.dataTransfer.setData("text/plain", payload); + }} + onDragOver={(event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = event.altKey ? "copy" : "move"; + void reduceFileTreeAction({ kind: "updateDropTarget", rowId }); + }} + onDrop={(event) => void handleFileTreeDrop(item, event)} + > +
      + ); + }); + }; + + const renderPickerRows = () => { + if (pickerEntries.length === 0) { + return
      没有匹配结果
      ; + } + return pickerEntries.map((item) => { + const focused = pickerState.activeItemKey === item.itemKey; + const isRoot = item.itemKey === "__root__"; + return ( + + ); + }); + }; + + return ( +
      +
      + {mode === "page" ? ( +
      + {renderPageRows("")} +
      + ) : mode === "filetree" ? ( +
      + {renderFileTreeRows()} +
      + ) : ( +
      + {renderPickerRows()} +
      + )} +
      +
      + ); +} diff --git a/wolai-frontend/src/components/sidebar/tree-shell-dom-model.ts b/wolai-frontend/src/components/sidebar/tree-shell-dom-model.ts new file mode 100644 index 00000000..f92c967a --- /dev/null +++ b/wolai-frontend/src/components/sidebar/tree-shell-dom-model.ts @@ -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 => + 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 = 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(); + 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, + }; +} diff --git a/wolai-frontend/src/components/sidebar/tree-shell-host.test.tsx b/wolai-frontend/src/components/sidebar/tree-shell-host.test.tsx new file mode 100644 index 00000000..892627e6 --- /dev/null +++ b/wolai-frontend/src/components/sidebar/tree-shell-host.test.tsx @@ -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( + +
      旧 React fallback
      +
      , + ); + }); + } + + 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"'); + }); +}); diff --git a/wolai-frontend/src/components/sidebar/tree-shell-host.tsx b/wolai-frontend/src/components/sidebar/tree-shell-host.tsx index dec32ce0..43289a4a 100644 --- a/wolai-frontend/src/components/sidebar/tree-shell-host.tsx +++ b/wolai-frontend/src/components/sidebar/tree-shell-host.tsx @@ -1,9 +1,10 @@ "use client"; 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 { TreeShellIframeHost, - type TreeShellPickerItem, } from "@/components/sidebar/tree-shell-iframe-host"; import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree"; import type { PageTreeProjectionItem } from "@/lib/tree-projection"; @@ -14,6 +15,8 @@ export type TreeRendererFamily = "react" | "rust_family"; export type TreeShellHostMode = "page" | "filetree" | "picker"; 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 = { kind: "next" | "previous" | "home" | "end" | "pick"; @@ -118,11 +121,17 @@ export function TreeShellHost({ children, }: TreeShellHostProps) { 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 rendererContract = useRustHost ? RUST_RENDERER_CONTRACT : undefined; - const implementation = useIframeHost - ? "rust_inline_compat_host" + const implementation = useLegacyIframeHost + ? RUST_LEGACY_IFRAME_HOST + : useDomHost + ? RUST_WASM_DOM_SHELL_HOST : fallbackImplementation ?? (rendererFamily === "rust_family" ? "react_fallback" : "react_primary"); @@ -146,36 +155,70 @@ export function TreeShellHost({ data-tree-renderer-contract={rendererContract} className="contents" > - {useIframeHost && workspaceId ? ( - + {useDomHost && workspaceId ? ( + useLegacyIframeHost ? ( + + ) : ( + + {children} + + ) ) : ( children )} diff --git a/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx b/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx index 1270cddc..63558023 100644 --- a/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx +++ b/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx @@ -286,11 +286,52 @@ describe("tree-shell-iframe-host", () => { expect(iframe?.getAttribute("srcdoc")).toContain("patchPageTreeExpansionDom(nodeId)"); expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_page_focus_keyboard_reducer_v1"'); expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"'); + expect(iframe?.getAttribute("srcdoc")).toContain('"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('"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("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.objectContaining({ nodeId: "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("const usedRustInitialRenderer = hydrateInitialRenderer();"); 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('"mode":"fileTree"'); 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('"contractName":"rust_filetree_selection_reducer_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("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("rendererFiletreeSelection.selectedRowIds"); expect(iframe?.getAttribute("srcdoc")).toContain("computeFileTreeSelectionActionResult"); expect(iframe?.getAttribute("srcdoc")).toContain("const selectFileTreeContextRow = (rowId) =>"); + const fileTreeFallbackRenderBody = + iframe?.getAttribute("srcdoc")?.match(/const renderFileTree = \(\) => \{(?[\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"); const state = readTreeShellState(iframe?.getAttribute("srcdoc")); 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('"contractName":"rust_picker_state_reducer_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("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("getPickablePickerEntries"); expect(iframe?.getAttribute("srcdoc")).toContain("postPickerPickResultToHost"); expect(iframe?.getAttribute("srcdoc")).toContain('tabindex="0"'); expect(iframe?.getAttribute("srcdoc")).toContain('tabindex="-1"'); - expect(iframe?.getAttribute("srcdoc")).toContain('applyPickerFocusByItemKey("__root__", { focusDom: true })'); - expect(iframe?.getAttribute("srcdoc")).toContain("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })"); + expect(iframe?.getAttribute("srcdoc")).toContain("dispatchPickerPickByItemKeyWithRuntime"); + 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("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\) => \{(?[\s\S]*?)\n \};/)?.groups + ?.body ?? ""; + expect(bindPickerRootEventsBody).not.toContain("postPickerPickResultToHost("); const bindPickerRowEventsBody = iframe?.getAttribute("srcdoc")?.match(/const bindPickerRowEvents = \(row, item\) => \{(?[\s\S]*?)\n \};/)?.groups ?.body ?? ""; 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.excludedPickerIds"); const postMessage = vi.fn(); diff --git a/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.tsx b/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.tsx index ef2cbe25..28d7b1da 100644 --- a/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.tsx +++ b/wolai-frontend/src/components/sidebar/tree-shell-iframe-host.tsx @@ -7,6 +7,15 @@ import type { TreeShellHostMode, TreeShellPickerCommand, } from "@/components/sidebar/tree-shell-host"; +import { + buildTreeShellDomChildrenByParent, + buildTreeShellDomFiletreeSelection, + buildTreeShellDomKernelFileTreeItems, + buildTreeShellDomPageItems, + buildTreeShellDomPickerItems, + type TreeShellDomProjectionItem, + type TreeShellPickerItem, +} from "@/components/sidebar/tree-shell-dom-model"; import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree"; import type { PageTreeProjectionItem } from "@/lib/tree-projection"; @@ -49,31 +58,9 @@ type TreeShellPickerCommandMessage = { command: TreeShellPickerCommand["kind"]; }; -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 { TreeShellPickerItem } from "@/components/sidebar/tree-shell-dom-model"; -type TreeShellInlineProjectionItem = { - 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; - }; -}; +type TreeShellInlineProjectionItem = TreeShellDomProjectionItem; type TreeShellInlineRendererInput = { mode: "page" | "fileTree" | "picker"; @@ -105,9 +92,24 @@ type TreeShellInlineRendererInput = { }; runtimeArtifact: { contractName: "rust_tree_shell_runtime_artifact_v1"; + family: "rust_family"; + version: number; + executionStrategy: "browser_bridge"; + browserBridge: "iframe_srcdoc"; + wasmModuleUrl: string | null; + jsGlueUrl: string | null; inputFields: string[]; outputChannels: string[]; eventKinds: string[]; + runtimeApi: { + requestContract: "TreeShellRuntimeRequest"; + resultContract: "TreeShellRuntimeResult"; + reduceEndpoint: "/api/tree/runtime/reduce"; + stateSnapshots: string[]; + domPatchKinds: string[]; + hostEventKinds: string[]; + commandEventKinds: string[]; + }; }; }; @@ -199,8 +201,11 @@ const PAGE_FOCUS_KEYBOARD_REDUCER_ACTIONS = [ "move_end", "expand", "collapse", + "toggle", "open", "context_menu", + "dispatch_create", + "dispatch_rename", ] as const; const FILETREE_SELECTION_REDUCER_ACTIONS = [ "select_row", @@ -208,6 +213,9 @@ const FILETREE_SELECTION_REDUCER_ACTIONS = [ "normalize_visible_rows", "clear", "resolve_drag_rows", + "update_drop_target", + "dispatch_internal_drop", + "dispatch_external_drop", ] as const; const PICKER_STATE_REDUCER_ACTIONS = [ "normalize", @@ -220,6 +228,12 @@ const PICKER_STATE_REDUCER_ACTIONS = [ ] as const; const TREE_SHELL_RUNTIME_ARTIFACT = { contractName: "rust_tree_shell_runtime_artifact_v1", + family: "rust_family", + version: 1, + executionStrategy: "browser_bridge", + browserBridge: "iframe_srcdoc", + wasmModuleUrl: "/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm", + jsGlueUrl: "/api/tree-shell-runtime/mnote-tree-shell-runtime.js", inputFields: [ "rendererInput", "projectionItems", @@ -230,6 +244,31 @@ const TREE_SHELL_RUNTIME_ARTIFACT = { ], outputChannels: ["domPatch", "intentEvent", "commandDispatchEvent"], eventKinds: ["focus", "keyboard", "expandCollapse", "selection", "contextMenu", "dragDrop", "pick"], + runtimeApi: { + requestContract: "TreeShellRuntimeRequest", + resultContract: "TreeShellRuntimeResult", + reduceEndpoint: "/api/tree/runtime/reduce", + stateSnapshots: ["page", "fileTree", "picker"], + domPatchKinds: ["pageState", "fileTreeState", "pickerState"], + hostEventKinds: [ + "pageOpen", + "pageContextMenu", + "fileTreeOpen", + "fileTreeContextMenu", + "fileTreeInternalDrop", + "fileTreeExternalDrop", + "pickerPickRoot", + "pickerPickDocument", + ], + commandEventKinds: [ + "createNode", + "renameNode", + "moveSubtree", + "copyResource", + "moveResource", + "uploadResource", + ], + }, } as const satisfies TreeShellInlineRendererInput["runtimeArtifact"]; const LOCAL_TREE_SHELL_TEMPLATE = ` @@ -466,6 +505,14 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` state.rendererInput && typeof state.rendererInput === "object" ? state.rendererInput : {}; + const runtimeArtifact = + rendererInput.runtimeArtifact && typeof rendererInput.runtimeArtifact === "object" + ? rendererInput.runtimeArtifact + : {}; + const runtimeApi = + runtimeArtifact.runtimeApi && typeof runtimeArtifact.runtimeApi === "object" + ? runtimeArtifact.runtimeApi + : {}; const rendererFiletreeSelection = rendererInput.filetreeSelection && typeof rendererInput.filetreeSelection === "object" ? rendererInput.filetreeSelection @@ -499,6 +546,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` }; const normalizeNumber = (value, fallback = Number.MAX_SAFE_INTEGER) => Number.isFinite(value) ? Number(value) : fallback; + const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value || {}, key); const normalizeRowKind = (value) => { const normalized = normalizeText(value).toLowerCase(); if (normalized === "index") return "index"; @@ -537,6 +585,9 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` ); const filetreeSelectionReducerContractName = normalizeText(filetreeSelectionReducer.contractName); const filetreeSelectionReducerActions = new Set(normalizeStringArray(filetreeSelectionReducer.actions)); + const runtimeReduceEndpoint = normalizeText( + runtimeArtifact.runtimeApi && runtimeArtifact.runtimeApi.reduceEndpoint, + ); const pickerStateReducerContractName = normalizeText(pickerStateReducer.contractName); const pickerStateReducerActions = new Set(normalizeStringArray(pickerStateReducer.actions)); const pageFocusKeyboardReducerContractName = normalizeText(pageFocusKeyboardReducer.contractName); @@ -581,6 +632,108 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` } return result; }; + const runtimeArtifactContractName = normalizeText(runtimeArtifact.contractName); + const runtimeRequestContract = normalizeText(runtimeApi.requestContract); + const runtimeResultContract = normalizeText(runtimeApi.resultContract); + const runtimeWasmModuleUrl = normalizeText(runtimeArtifact.wasmModuleUrl); + const runtimeJsGlueUrl = normalizeText(runtimeArtifact.jsGlueUrl); + let treeShellWasmRuntimePromise = null; + const canUseTreeShellRuntimeArtifact = () => + runtimeArtifactContractName === "rust_tree_shell_runtime_artifact_v1" && + runtimeRequestContract === "TreeShellRuntimeRequest" && + runtimeResultContract === "TreeShellRuntimeResult"; + const buildTreeShellRuntimeRequestId = (prefix) => + prefix + "-" + Date.now() + "-" + Math.random().toString(36).slice(2); + const loadTreeShellWasmRuntime = async () => { + if (!runtimeJsGlueUrl || !runtimeWasmModuleUrl) return null; + if (!treeShellWasmRuntimePromise) { + treeShellWasmRuntimePromise = (async () => { + const runtimeModule = await import(runtimeJsGlueUrl); + const initRuntime = + typeof runtimeModule.default === "function" + ? runtimeModule.default + : typeof runtimeModule.init === "function" + ? runtimeModule.init + : null; + if (typeof initRuntime !== "function") { + throw new Error("tree shell wasm glue missing init"); + } + await initRuntime(runtimeWasmModuleUrl); + const reduceTreeShellRuntime = + typeof runtimeModule.reduce_tree_shell_runtime === "function" + ? runtimeModule.reduce_tree_shell_runtime + : typeof runtimeModule.reduceTreeShellRuntime === "function" + ? runtimeModule.reduceTreeShellRuntime + : null; + if (typeof reduceTreeShellRuntime !== "function") { + throw new Error("tree shell wasm glue missing reducer"); + } + return { reduceTreeShellRuntime }; + })().catch((error) => { + treeShellWasmRuntimePromise = null; + throw error; + }); + } + try { + return await treeShellWasmRuntimePromise; + } catch { + return null; + } + }; + const reduceTreeShellRuntimeViaWasm = async (request) => { + const wasmRuntime = await loadTreeShellWasmRuntime(); + if (!wasmRuntime) return null; + try { + const runtimeResult = await wasmRuntime.reduceTreeShellRuntime(request); + if (!runtimeResult || runtimeResult.requestId !== request.requestId) return null; + return runtimeResult; + } catch { + return null; + } + }; + const reduceTreeShellRuntimeViaHttp = async (request) => { + if (!runtimeReduceEndpoint) return null; + try { + const response = await fetch(runtimeReduceEndpoint, { + method: "POST", + credentials: "include", + headers: { + accept: "application/json", + "content-type": "application/json", + }, + body: JSON.stringify(request), + }); + if (!response.ok) return null; + const runtimeResult = await response.json().catch(() => null); + if (!runtimeResult || runtimeResult.requestId !== request.requestId) return null; + return runtimeResult; + } catch { + return null; + } + }; + const reduceTreeShellRuntimeWithArtifact = async ({ + mode, + requestId, + environment, + state, + action, + fallbackResult, + normalizeResult, + }) => { + if (!canUseTreeShellRuntimeArtifact()) return null; + const request = { + mode, + requestId, + environment, + state, + action, + }; + const runtimeResult = + (await reduceTreeShellRuntimeViaWasm(request)) || + (await reduceTreeShellRuntimeViaHttp(request)); + if (!runtimeResult) return null; + return normalizeResult(runtimeResult, fallbackResult); + }; const rawItems = Array.isArray(state.items) ? state.items @@ -661,6 +814,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` if (!parentId) return roots.slice(); return (childrenByParentId.get(parentId) || []).slice(); }; + const PAGE_DRAG_MIME = "application/x-mnote-page-tree-node"; const resolveFocusedNodeIdFromHostState = () => mode === "picker" ? currentActivePickerItemKey && currentActivePickerItemKey !== "__root__" && itemById.has(currentActivePickerItemKey) @@ -674,6 +828,11 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` ? currentActiveDocumentId : roots[0]?.nodeId || ""; let focusedNodeId = resolveFocusedNodeIdFromHostState(); + let draggingPageNodeId = ""; + let pageRuntimeState = { + dropFeedback: null, + dropTargetNodeId: null, + }; const rendererSelectedFileTreeRowIds = normalizeStringArray( rendererFiletreeSelection.selectedRowIds, ); @@ -732,6 +891,17 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` renderTree(); focusRowElement(nodeId); }; + const applyPageExpandedIds = (expandedIds) => { + const nextExpandedIds = new Set(normalizeStringArray(expandedIds)); + let changed = expanded.size !== nextExpandedIds.size; + if (!changed) { + changed = Array.from(expanded).some((nodeId) => !nextExpandedIds.has(nodeId)); + } + if (!changed) return false; + expanded.clear(); + nextExpandedIds.forEach((nodeId) => expanded.add(nodeId)); + return true; + }; const postPageFocusChange = (nodeId) => { if (mode !== "page" || !nodeId) return; postToHost("tree.page.focus.changed", { documentId: nodeId, target: { documentId: nodeId } }); @@ -766,6 +936,602 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` renderTree(); focusRowElement(nextFocusId); }; + const normalizePageDropPosition = (position) => { + const normalizedPosition = normalizeText(position).toLowerCase(); + return normalizedPosition === "before" || normalizedPosition === "inside" || normalizedPosition === "after" + ? normalizedPosition + : null; + }; + const normalizePageDropFeedback = (feedback) => { + if (!feedback || typeof feedback !== "object") return null; + const sourceNodeId = normalizeText(feedback.sourceNodeId); + const position = normalizePageDropPosition(feedback.position); + if (!sourceNodeId || !position) return null; + return { + sourceNodeId, + targetNodeId: normalizeText(feedback.targetNodeId) || null, + targetParentId: normalizeText(feedback.targetParentId) || null, + position, + }; + }; + const normalizePageRuntimeState = (runtimeState) => ({ + dropFeedback: normalizePageDropFeedback(runtimeState?.dropFeedback), + dropTargetNodeId: normalizeText(runtimeState?.dropTargetNodeId) || null, + }); + const arePageDropFeedbackEqual = (left, right) => { + const normalizedLeft = normalizePageDropFeedback(left); + const normalizedRight = normalizePageDropFeedback(right); + return ( + normalizedLeft?.sourceNodeId === normalizedRight?.sourceNodeId && + normalizedLeft?.targetNodeId === normalizedRight?.targetNodeId && + normalizedLeft?.targetParentId === normalizedRight?.targetParentId && + normalizedLeft?.position === normalizedRight?.position + ); + }; + const commitPageRuntimeState = (nextState) => { + const normalizedRuntimeState = normalizePageRuntimeState({ + dropFeedback: hasOwn(nextState, "dropFeedback") + ? nextState.dropFeedback + : pageRuntimeState.dropFeedback, + dropTargetNodeId: hasOwn(nextState, "dropTargetNodeId") + ? nextState.dropTargetNodeId + : pageRuntimeState.dropTargetNodeId, + }); + pageRuntimeState = normalizedRuntimeState; + return normalizedRuntimeState; + }; + const resolvePageDropTargetNodeId = (element) => { + const row = element instanceof Element + ? element.closest('.tree-row[data-shell-mode="page"]') + : null; + if (!(row instanceof HTMLElement)) return ""; + return normalizeText(row.dataset.nodeId); + }; + const readPageDragNodeId = (event) => { + const raw = + event.dataTransfer?.getData(PAGE_DRAG_MIME) || + event.dataTransfer?.getData("text/plain") || + draggingPageNodeId || + ""; + return normalizeText(raw); + }; + const canSubmitPageDropCandidate = (sourceNodeId, targetNodeId) => { + const normalizedSourceNodeId = normalizeText(sourceNodeId); + const normalizedTargetNodeId = normalizeText(targetNodeId); + return Boolean( + normalizedSourceNodeId && + normalizedTargetNodeId && + normalizedSourceNodeId !== normalizedTargetNodeId, + ); + }; + const buildPageRuntimeAction = (action) => { + const actionKind = normalizeText(action?.kind); + if (actionKind === "focus") { + const nodeId = normalizeText(action.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 === "dispatch_create") { + return { + kind: "dispatchCreate", + parentNodeId: normalizeText(action.parentNodeId) || null, + }; + } + if (actionKind === "dispatch_rename") { + const nodeId = normalizeText(action.nodeId); + const title = normalizeText(action.title); + if (!nodeId || !title) return null; + return { + kind: "dispatchRename", + nodeId, + title, + }; + } + if (actionKind === "expand" || actionKind === "collapse" || actionKind === "toggle") { + const nodeId = normalizeText(action.nodeId); + if (!nodeId) return null; + return { + kind: actionKind, + nodeId, + }; + } + if (actionKind === "update_drop_feedback") { + const feedback = normalizePageDropFeedback(action.feedback); + return { + kind: "updateDropFeedback", + feedback, + }; + } + if (actionKind === "update_drop_feedback_for_target") { + const sourceNodeId = normalizeText(action.sourceNodeId); + const targetNodeId = normalizeText(action.targetNodeId); + const position = normalizePageDropPosition(action.position); + if (!sourceNodeId || !targetNodeId || !position) return null; + return { + kind: "updateDropFeedbackForTarget", + sourceNodeId, + targetNodeId, + position, + }; + } + if (actionKind === "dispatch_move") { + const sourceNodeId = normalizeText(action.sourceNodeId); + const position = normalizePageDropPosition(action.position); + if (!sourceNodeId || !position) return null; + return { + kind: "dispatchMove", + sourceNodeId, + targetParentId: normalizeText(action.targetParentId) || null, + position, + }; + } + if (actionKind === "dispatch_move_to_target") { + const sourceNodeId = normalizeText(action.sourceNodeId); + const targetNodeId = normalizeText(action.targetNodeId); + const position = normalizePageDropPosition(action.position); + if (!sourceNodeId || !targetNodeId || !position) return null; + return { + kind: "dispatchMoveToTarget", + sourceNodeId, + targetNodeId, + position, + }; + } + 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), + rows: normalizedItems.map((entry) => ({ + nodeId: entry.nodeId, + parentNodeId: normalizeText(entry.parentNodeId) || null, + position: normalizeNumber(entry.position, 0), + })), + }); + const readPageRuntimeState = (action) => { + const actionKind = normalizeText(action?.kind); + const focusedId = + (actionKind === "open" || actionKind === "context_menu") && itemById.has(normalizeText(action?.nodeId)) + ? normalizeText(action.nodeId) + : focusedNodeId || null; + return { + focusedId, + expandedIds: Array.from(expanded), + dropFeedback: pageRuntimeState.dropFeedback, + }; + }; + const normalizePageRuntimeResult = (runtimeResult, fallbackResult) => { + 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 && patch.kind === "pageState") + : null; + const rawFocusedId = + typeof pagePatch?.focusedId === "string" + ? pagePatch.focusedId + : typeof stateSnapshot?.focusedId === "string" + ? stateSnapshot.focusedId + : ""; + const rawExpandedIds = Array.isArray(pagePatch?.expandedIds) + ? pagePatch.expandedIds + : Array.isArray(stateSnapshot?.expandedIds) + ? stateSnapshot.expandedIds + : null; + const rawDropFeedback = hasOwn(pagePatch || {}, "dropFeedback") + ? pagePatch?.dropFeedback + : hasOwn(stateSnapshot || {}, "dropFeedback") + ? stateSnapshot?.dropFeedback + : hasOwn(fallbackResult || {}, "dropFeedback") + ? fallbackResult?.dropFeedback + : undefined; + const dropFeedback = + typeof rawDropFeedback === "undefined" + ? pageRuntimeState.dropFeedback + : normalizePageDropFeedback(rawDropFeedback); + return { + focusedId: normalizeText(rawFocusedId), + expandedIds: rawExpandedIds ? normalizeStringArray(rawExpandedIds) : null, + dropFeedback, + dropTargetNodeId: dropFeedback + ? normalizeText(dropFeedback.targetNodeId) || + normalizeText(fallbackResult?.dropTargetNodeId) || + pageRuntimeState.dropTargetNodeId || + null + : null, + hostEvents: Array.isArray(runtimeResult.hostEvents) ? runtimeResult.hostEvents : [], + commandEvents: Array.isArray(runtimeResult.commandEvents) ? runtimeResult.commandEvents : [], + }; + }; + const buildPageContextMenuPayload = (nodeId, sourceElement) => { + const normalizedNodeId = normalizeText(nodeId); + if (!normalizedNodeId) return null; + const rect = sourceElement?.getBoundingClientRect?.(); + return { + documentId: normalizedNodeId, + x: rect ? rect.left + Math.min(rect.width - 12, 28) : 0, + y: rect ? rect.top + Math.min(rect.height - 12, 18) : 0, + target: { documentId: normalizedNodeId }, + }; + }; + const replayPageRuntimeHostEvents = (runtimeResult, fallbackHostEvent) => { + const runtimeHostEvents = Array.isArray(runtimeResult?.hostEvents) + ? runtimeResult.hostEvents + : []; + let replayed = false; + runtimeHostEvents.forEach((event) => { + if (!event || typeof event !== "object") return; + if (event.kind === "pageOpen") { + const nodeId = normalizeText(event.nodeId); + if (!nodeId) return; + handleNavigate(nodeId); + replayed = true; + return; + } + if (event.kind === "pageContextMenu") { + const payload = buildPageContextMenuPayload(event.nodeId, fallbackHostEvent?.sourceElement); + if (!payload) return; + postToHost("tree.page.context-menu", payload); + replayed = true; + } + }); + if (replayed || !fallbackHostEvent) return replayed; + if (fallbackHostEvent.kind === "pageOpen") { + handleNavigate(fallbackHostEvent.nodeId); + return true; + } + if (fallbackHostEvent.kind === "pageContextMenu") { + const payload = buildPageContextMenuPayload( + fallbackHostEvent.nodeId, + fallbackHostEvent.sourceElement, + ); + if (!payload) return false; + postToHost("tree.page.context-menu", payload); + return true; + } + return false; + }; + const reducePageActionWithRuntime = async ( + action, + runtimeState = readPageRuntimeState(action), + runtimeEnvironment = buildPageRuntimeEnvironment(), + fallbackResult = null, + ) => { + if (mode !== "page") { + return null; + } + const runtimeAction = buildPageRuntimeAction(action); + if (!runtimeAction) return null; + return reduceTreeShellRuntimeWithArtifact({ + mode: "page", + requestId: buildTreeShellRuntimeRequestId("page-state"), + environment: runtimeEnvironment, + state: runtimeState, + action: runtimeAction, + fallbackResult, + normalizeResult: normalizePageRuntimeResult, + }); + }; + const syncPageDropFeedbackDom = () => { + if (mode !== "page") return; + const dropTargetNodeId = pageRuntimeState.dropFeedback ? pageRuntimeState.dropTargetNodeId : null; + appElement.querySelectorAll('[data-shell-mode="page"]').forEach((row) => { + if (!(row instanceof HTMLElement)) return; + const nodeId = normalizeText(row.dataset.nodeId); + row.dataset.dropFeedback = String(Boolean(nodeId && dropTargetNodeId && nodeId === dropTargetNodeId)); + }); + }; + const dispatchPageMoveCommand = async (commandEvent, fallbackCommandEvent) => { + const sourceNodeId = normalizeText( + commandEvent?.sourceNodeId, + normalizeText(fallbackCommandEvent?.sourceNodeId), + ); + const targetNodeId = normalizeText( + commandEvent?.targetNodeId, + normalizeText(fallbackCommandEvent?.targetNodeId), + ); + const position = + normalizePageDropPosition(commandEvent?.position) || + normalizePageDropPosition(fallbackCommandEvent?.position) || + "before"; + if (!sourceNodeId) return false; + const targetItem = targetNodeId ? itemById.get(targetNodeId) : null; + let parentId = + position === "inside" + ? targetNodeId + : normalizeText(commandEvent?.targetParentId) || + normalizeText(fallbackCommandEvent?.targetParentId) || + targetItem?.parentNodeId || + null; + const runtimeSortOrder = Number.isFinite(commandEvent?.sortOrder) + ? Math.max(0, Math.trunc(commandEvent.sortOrder)) + : null; + let sortOrder = runtimeSortOrder ?? 0; + if (runtimeSortOrder !== null) { + // sortOrder 已由 Rust runtime 基于 page rows 解析,JS 只负责 transport 回放。 + } else if (position === "inside" && targetNodeId) { + sortOrder = getSiblings(targetNodeId).length; + } else { + if (!targetItem) return false; + const siblings = getSiblings(targetItem.parentNodeId); + const targetIndex = siblings.findIndex((entry) => entry.nodeId === targetNodeId); + if (targetIndex < 0) return false; + sortOrder = position === "after" ? targetIndex + 1 : targetIndex; + } + try { + await runTreeCommand({ + action: "move", + documentId: sourceNodeId, + parentId, + sortOrder, + }); + setLastAction(targetItem ? \`页面已拖放到 \${targetItem.title}\` : "页面已完成拖放移动"); + postToHost("tree.subtree.moved", { + documentId: sourceNodeId, + target: { documentId: sourceNodeId }, + }); + return true; + } catch (error) { + setLastAction(error instanceof Error ? error.message : "拖拽移动失败", "error"); + return false; + } + }; + const dispatchPageCreateCommand = async (commandEvent, fallbackCommandEvent) => { + const parentId = normalizeText( + commandEvent?.parentNodeId, + normalizeText(fallbackCommandEvent?.parentNodeId), + ) || null; + try { + const result = await runTreeCommand({ + action: "create", + parentId, + }); + const documentId = normalizeText( + result && result.result && result.result.documentId, + normalizeText(result && result.id), + ); + setLastAction(documentId ? \`已创建子页面 \${documentId}\` : "已创建子页面"); + postToHost("tree.node.created", { + documentId: documentId || null, + target: { documentId: documentId || null }, + }); + return true; + } catch (error) { + setLastAction(error instanceof Error ? error.message : "创建页面失败", "error"); + return false; + } + }; + const dispatchPageRenameCommand = async (commandEvent, fallbackCommandEvent) => { + const nodeId = normalizeText( + commandEvent?.nodeId, + normalizeText(fallbackCommandEvent?.nodeId), + ); + const title = normalizeText( + commandEvent?.title, + normalizeText(fallbackCommandEvent?.title), + ); + if (!nodeId || !title) return false; + try { + await runTreeCommand({ + action: "rename", + documentId: nodeId, + title, + }); + const item = itemById.get(nodeId); + if (item) item.title = title; + setLastAction(\`已重命名为 \${title}\`); + postToHost("tree.node.renamed", { + documentId: nodeId, + target: { documentId: nodeId }, + }); + if (fallbackCommandEvent?.titleElement) { + fallbackCommandEvent.titleElement.textContent = title; + } else if (!usedRustInitialRenderer) { + renderTree(); + } + return true; + } catch (error) { + setLastAction(error instanceof Error ? error.message : "重命名失败", "error"); + return false; + } + }; + const replayPageRuntimeCommandEvents = (runtimeResult, fallbackCommandEvent) => { + const runtimeCommandEvents = Array.isArray(runtimeResult?.commandEvents) + ? runtimeResult.commandEvents + : []; + let replayed = false; + runtimeCommandEvents.forEach((event) => { + if (!event || typeof event !== "object") return; + if (event.kind === "createNode") { + replayed = true; + void dispatchPageCreateCommand(event, fallbackCommandEvent); + return; + } + if (event.kind === "renameNode") { + replayed = true; + void dispatchPageRenameCommand(event, fallbackCommandEvent); + return; + } + if (event.kind === "moveSubtree") { + replayed = true; + void dispatchPageMoveCommand(event, fallbackCommandEvent); + } + }); + if (replayed || !fallbackCommandEvent) return replayed; + if (runtimeResult && fallbackCommandEvent.runtimeRequired === true) { + return false; + } + if (fallbackCommandEvent.kind === "createNode") { + void dispatchPageCreateCommand(null, fallbackCommandEvent); + return true; + } + if (fallbackCommandEvent.kind === "renameNode") { + void dispatchPageRenameCommand(null, fallbackCommandEvent); + return true; + } + if (fallbackCommandEvent.kind === "moveSubtree") { + void dispatchPageMoveCommand(null, fallbackCommandEvent); + return true; + } + return true; + }; + const buildPageDropFeedbackAction = (sourceNodeId, targetNodeId) => { + return { + kind: "update_drop_feedback_for_target", + sourceNodeId: normalizeText(sourceNodeId), + targetNodeId: normalizeText(targetNodeId), + position: "before", + }; + }; + const buildPageMoveAction = (sourceNodeId, targetNodeId) => { + const normalizedSourceNodeId = normalizeText(sourceNodeId); + const normalizedTargetNodeId = normalizeText(targetNodeId); + if (!normalizedSourceNodeId || !normalizedTargetNodeId || normalizedSourceNodeId === normalizedTargetNodeId) return null; + const targetItem = itemById.get(normalizedTargetNodeId); + return { + kind: "dispatch_move_to_target", + sourceNodeId: normalizedSourceNodeId, + targetNodeId: normalizedTargetNodeId, + targetParentId: targetItem?.parentNodeId || null, + position: "before", + }; + }; + const applyPageDropFeedbackAction = (action) => { + const runtimeState = readPageRuntimeState(action); + const runtimeEnvironment = buildPageRuntimeEnvironment(); + const fallbackResult = { + dropFeedback: null, + dropTargetNodeId: null, + }; + void reducePageActionWithRuntime( + action, + runtimeState, + runtimeEnvironment, + fallbackResult, + ).then((runtimeResult) => { + reconcilePageRuntimeResult(runtimeResult); + }); + }; + const setPageDropFeedback = (sourceNodeId, targetNodeId) => { + applyPageDropFeedbackAction(buildPageDropFeedbackAction(sourceNodeId, targetNodeId)); + }; + const clearPageDropFeedback = () => { + applyPageDropFeedbackAction({ kind: "update_drop_feedback", feedback: null }); + }; + const dispatchPageMoveWithRuntime = (sourceNodeId, targetNodeId) => { + const action = buildPageMoveAction(sourceNodeId, targetNodeId); + if (!action) return; + const runtimeState = readPageRuntimeState(action); + const runtimeEnvironment = buildPageRuntimeEnvironment(); + const fallbackCommandEvent = { + kind: "moveSubtree", + sourceNodeId: action.sourceNodeId, + targetNodeId: action.targetNodeId, + targetParentId: action.targetParentId, + position: action.position, + runtimeRequired: true, + }; + void reducePageActionWithRuntime(action, runtimeState, runtimeEnvironment).then( + (runtimeResult) => { + replayPageRuntimeCommandEvents(runtimeResult, fallbackCommandEvent); + reconcilePageRuntimeResult(runtimeResult); + }, + ); + }; + const dispatchPageCreateWithRuntime = (parentNodeId) => { + const action = { + kind: "dispatch_create", + parentNodeId: normalizeText(parentNodeId) || null, + }; + const runtimeState = readPageRuntimeState(action); + const runtimeEnvironment = buildPageRuntimeEnvironment(); + const fallbackCommandEvent = { + kind: "createNode", + parentNodeId: action.parentNodeId, + }; + void reducePageActionWithRuntime(action, runtimeState, runtimeEnvironment).then( + (runtimeResult) => { + replayPageRuntimeCommandEvents(runtimeResult, fallbackCommandEvent); + reconcilePageRuntimeResult(runtimeResult); + }, + ); + }; + const dispatchPageRenameWithRuntime = (nodeId, title, titleElement = null) => { + const action = { + kind: "dispatch_rename", + nodeId: normalizeText(nodeId), + title: normalizeText(title), + }; + if (!action.nodeId || !action.title) return; + const runtimeState = readPageRuntimeState(action); + const runtimeEnvironment = buildPageRuntimeEnvironment(); + const fallbackCommandEvent = { + kind: "renameNode", + nodeId: action.nodeId, + title: action.title, + titleElement, + }; + void reducePageActionWithRuntime(action, runtimeState, runtimeEnvironment).then( + (runtimeResult) => { + replayPageRuntimeCommandEvents(runtimeResult, fallbackCommandEvent); + reconcilePageRuntimeResult(runtimeResult); + }, + ); + }; + const reconcilePageRuntimeResult = (runtimeResult) => { + if (!runtimeResult) return; + let shouldPatchTree = false; + let shouldSyncDropFeedback = false; + if (Array.isArray(runtimeResult.expandedIds)) { + shouldPatchTree = applyPageExpandedIds(runtimeResult.expandedIds) || shouldPatchTree; + } + if (runtimeResult.focusedId && runtimeResult.focusedId !== focusedNodeId) { + focusedNodeId = runtimeResult.focusedId; + postPageFocusChange(runtimeResult.focusedId); + shouldPatchTree = true; + } + const nextDropTargetNodeId = runtimeResult.dropFeedback + ? runtimeResult.dropTargetNodeId || pageRuntimeState.dropTargetNodeId || null + : null; + if ( + !arePageDropFeedbackEqual(runtimeResult.dropFeedback, pageRuntimeState.dropFeedback) || + nextDropTargetNodeId !== pageRuntimeState.dropTargetNodeId + ) { + commitPageRuntimeState({ + dropFeedback: runtimeResult.dropFeedback, + dropTargetNodeId: nextDropTargetNodeId, + }); + shouldSyncDropFeedback = true; + } + if (usedRustInitialRenderer) { + if (shouldPatchTree) { + if (Array.isArray(runtimeResult.expandedIds)) { + runtimeResult.expandedIds.forEach((nodeId) => { + patchPageTreeExpansionDom(nodeId); + }); + } + patchPageTreeActiveDom(); + } + if (shouldSyncDropFeedback) { + syncPageDropFeedbackDom(); + } + if (runtimeResult.focusedId) focusRowElement(runtimeResult.focusedId); + return; + } + if (shouldPatchTree || shouldSyncDropFeedback) { + renderTree(); + if (runtimeResult.focusedId) focusRowElement(runtimeResult.focusedId); + } + }; const applyPageKeyboardAction = (action, item, sourceElement) => { if (mode !== "page") return; const actionKind = normalizeText(action?.kind).toLowerCase(); @@ -775,8 +1541,23 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` ) { return; } + const runtimeAction = + item?.nodeId && (actionKind === "open" || actionKind === "context_menu") + ? Object.assign({}, action, { nodeId: item.nodeId }) + : action; + const runtimeState = readPageRuntimeState(runtimeAction); + const runtimeEnvironment = buildPageRuntimeEnvironment(); + const dispatchRuntime = (nextAction = runtimeAction, fallbackHostEvent = null) => { + void reducePageActionWithRuntime(nextAction, runtimeState, runtimeEnvironment).then( + (runtimeResult) => { + replayPageRuntimeHostEvents(runtimeResult, fallbackHostEvent); + reconcilePageRuntimeResult(runtimeResult); + }, + ); + }; if (actionKind === "focus") { focusNode(action?.nodeId); + dispatchRuntime(); return; } const visible = getVisiblePageItems(); @@ -785,52 +1566,68 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` if (actionKind === "move_next") { const next = visible[currentIndex + 1]; if (next) focusNode(next.nodeId); + dispatchRuntime(); return; } if (actionKind === "move_previous") { const previous = visible[currentIndex - 1]; if (previous) focusNode(previous.nodeId); + dispatchRuntime(); return; } if (actionKind === "move_home") { if (visibleIds[0]) focusNode(visibleIds[0]); + dispatchRuntime(); return; } if (actionKind === "move_end") { if (visibleIds[visibleIds.length - 1]) focusNode(visibleIds[visibleIds.length - 1]); + dispatchRuntime(); return; } if (actionKind === "expand") { if (item?.childCount > 0 && !expanded.has(item.nodeId)) { toggleExpand(item.nodeId); + dispatchRuntime({ kind: "expand", nodeId: item.nodeId }); return; } const firstChild = item ? getSiblings(item.nodeId)[0] : null; if (firstChild) focusNode(firstChild.nodeId); + dispatchRuntime(); return; } if (actionKind === "collapse") { if (item?.childCount > 0 && expanded.has(item.nodeId)) { toggleExpand(item.nodeId); + dispatchRuntime({ kind: "collapse", nodeId: item.nodeId }); return; } if (item?.parentNodeId && itemById.has(item.parentNodeId)) { focusNode(item.parentNodeId); } + dispatchRuntime(); + return; + } + if (actionKind === "toggle") { + if (item?.childCount > 0) { + toggleExpand(item.nodeId); + dispatchRuntime({ kind: "toggle", nodeId: item.nodeId }); + } return; } if (actionKind === "open") { - if (item?.nodeId) handleNavigate(item.nodeId); + dispatchRuntime(runtimeAction, item?.nodeId ? { + kind: "pageOpen", + nodeId: item.nodeId, + } : null); return; } if (actionKind === "context_menu") { if (!item?.nodeId) return; - const rect = sourceElement?.getBoundingClientRect?.(); - postToHost("tree.page.context-menu", { - documentId: item.nodeId, - x: rect ? rect.left + Math.min(rect.width - 12, 28) : 0, - y: rect ? rect.top + Math.min(rect.height - 12, 18) : 0, - target: { documentId: item.nodeId }, + dispatchRuntime(runtimeAction, { + kind: "pageContextMenu", + nodeId: item.nodeId, + sourceElement, }); } }; @@ -907,16 +1704,260 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` anchorRowId: fileTreeAnchorRowId, focusedRowId: fileTreeFocusedRowId, }); + const areFileTreeSelectionsEqual = (left, right) => { + const normalizedLeft = normalizeFileTreeSelectionState(left); + const normalizedRight = normalizeFileTreeSelectionState(right); + return ( + normalizedLeft.selectedRowIds.size === normalizedRight.selectedRowIds.size && + Array.from(normalizedLeft.selectedRowIds).every((rowId) => + normalizedRight.selectedRowIds.has(rowId), + ) && + normalizedLeft.anchorRowId === normalizedRight.anchorRowId && + normalizedLeft.focusedRowId === normalizedRight.focusedRowId + ); + }; + const normalizeFileTreeDragEffect = (dragEffect) => { + const normalizedDragEffect = normalizeText(dragEffect).toLowerCase(); + return normalizedDragEffect === "copy" || normalizedDragEffect === "move" + ? normalizedDragEffect + : null; + }; + const normalizeFileTreeRuntimeState = (runtimeState) => ({ + selection: normalizeFileTreeSelectionState(runtimeState?.selection), + dragRowIds: normalizeStringArray(runtimeState?.dragRowIds), + dragEffect: normalizeFileTreeDragEffect(runtimeState?.dragEffect), + dropTargetRowId: normalizeText(runtimeState?.dropTargetRowId) || null, + }); + let fileTreeRuntimeState = normalizeFileTreeRuntimeState({ + selection: { + selectedRowIds: Array.from(selectedFileTreeRowIds), + anchorRowId: fileTreeAnchorRowId, + focusedRowId: fileTreeFocusedRowId, + }, + dragRowIds: [], + dragEffect: null, + dropTargetRowId: null, + }); + const commitFileTreeRuntimeState = (nextState) => { + const normalizedRuntimeState = normalizeFileTreeRuntimeState({ + selection: hasOwn(nextState, "selection") + ? nextState.selection + : { + selectedRowIds: Array.from(selectedFileTreeRowIds), + anchorRowId: fileTreeAnchorRowId, + focusedRowId: fileTreeFocusedRowId, + }, + dragRowIds: hasOwn(nextState, "dragRowIds") + ? nextState.dragRowIds + : fileTreeRuntimeState.dragRowIds, + dragEffect: hasOwn(nextState, "dragEffect") + ? nextState.dragEffect + : fileTreeRuntimeState.dragEffect, + dropTargetRowId: hasOwn(nextState, "dropTargetRowId") + ? nextState.dropTargetRowId + : fileTreeRuntimeState.dropTargetRowId, + }); + fileTreeRuntimeState = normalizedRuntimeState; + return normalizedRuntimeState; + }; const commitFileTreeSelection = (nextSelection) => { const normalizedSelection = normalizeFileTreeSelectionState(nextSelection); selectedFileTreeRowIds = normalizedSelection.selectedRowIds; fileTreeAnchorRowId = normalizedSelection.anchorRowId; fileTreeFocusedRowId = normalizedSelection.focusedRowId; + commitFileTreeRuntimeState({ selection: normalizedSelection }); emitFileTreeSelectionChange(); return normalizedSelection; }; + const buildFileTreeRuntimeAction = (action) => { + const actionKind = normalizeText(action?.kind); + if (actionKind === "select_row") { + const rowId = normalizeText(action.rowId); + if (!rowId) return null; + return { + kind: "selectRow", + rowId, + modifiers: { + shiftKey: action.modifiers?.shiftKey === true, + ctrlKey: action.modifiers?.ctrlKey === true, + metaKey: action.modifiers?.metaKey === true, + }, + }; + } + if (actionKind === "select_context_row") { + const rowId = normalizeText(action.rowId); + return rowId ? { kind: "selectContextRow", rowId } : null; + } + if (actionKind === "normalize_visible_rows") { + return { kind: "normalizeVisibleRows" }; + } + if (actionKind === "clear") { + return { kind: "clearSelection" }; + } + if (actionKind === "resolve_drag_rows") { + const rowId = normalizeText(action.rowId); + return rowId + ? { + kind: "resolveDragRows", + rowId, + hasExternalFiles: action.hasExternalFiles === true, + altKey: action.altKey === true, + } + : null; + } + if (actionKind === "update_drop_target") { + return { + kind: "updateDropTarget", + rowId: normalizeText(action.rowId) || null, + }; + } + if (actionKind === "dispatch_internal_drop") { + const rowIds = normalizeStringArray(action.rowIds); + if (rowIds.length === 0) return null; + return { + kind: "dispatchInternalDrop", + targetRowId: normalizeText(action.targetRowId) || null, + rowIds, + copy: action.copy === true, + }; + } + if (actionKind === "dispatch_external_drop") { + const fileCount = Number.isFinite(action.fileCount) ? Math.max(0, Math.trunc(action.fileCount)) : 0; + if (fileCount <= 0) return null; + return { + kind: "dispatchExternalDrop", + targetRowId: normalizeText(action.targetRowId) || null, + fileCount, + }; + } + if (actionKind === "open_row") { + const rowId = normalizeText(action.rowId); + return rowId ? { kind: "openRow", rowId } : null; + } + if (actionKind === "context_menu_row") { + const rowId = normalizeText(action.rowId); + return rowId ? { kind: "contextMenuRow", rowId } : null; + } + return null; + }; + const buildFileTreeRuntimeEnvironment = (action) => { + const actionVisibleRowIds = normalizeStringArray(action?.visibleRowIds); + return { + visibleRowIds: + actionVisibleRowIds.length > 0 ? actionVisibleRowIds : visibleFileTreeRowIds.slice(), + rows: normalizedItems.map((item) => ({ + rowId: item.rowId, + rowKind: item.rowKind, + documentId: getFileTreeRowDocumentId(item) || null, + assetId: getFileTreeRowAssetId(item) || null, + })), + }; + }; + const readFileTreeRuntimeState = () => ({ + selection: { + selectedRowIds: Array.from(selectedFileTreeRowIds), + anchorRowId: fileTreeAnchorRowId, + focusedRowId: fileTreeFocusedRowId, + }, + dragRowIds: fileTreeRuntimeState.dragRowIds, + dragEffect: fileTreeRuntimeState.dragEffect, + dropTargetRowId: fileTreeRuntimeState.dropTargetRowId, + }); + const normalizeFileTreeRuntimeResult = (runtimeResult, fallbackResult) => { + if (!runtimeResult || runtimeResult.mode !== "fileTree") return null; + const stateSnapshot = + runtimeResult.state && runtimeResult.state.mode === "fileTree" + ? runtimeResult.state.state + : null; + const fileTreePatch = Array.isArray(runtimeResult.domPatches) + ? runtimeResult.domPatches.find((patch) => patch && patch.kind === "fileTreeState") + : null; + const selectionSource = + fileTreePatch || + (stateSnapshot && typeof stateSnapshot === "object" ? stateSnapshot.selection : null); + const dragRows = Array.isArray(fileTreePatch?.dragRowIds) + ? normalizeStringArray(fileTreePatch.dragRowIds) + : Array.isArray(stateSnapshot?.dragRowIds) + ? normalizeStringArray(stateSnapshot.dragRowIds) + : fallbackResult.dragRowIds; + const dragEffect = + normalizeFileTreeDragEffect(fileTreePatch?.dragEffect) || + normalizeFileTreeDragEffect(stateSnapshot?.dragEffect) || + fallbackResult.dragEffect || + null; + const dropTargetRowId = hasOwn(fileTreePatch, "dropTargetRowId") + ? normalizeText(fileTreePatch?.dropTargetRowId) || null + : hasOwn(stateSnapshot, "dropTargetRowId") + ? normalizeText(stateSnapshot?.dropTargetRowId) || null + : fallbackResult.dropTargetRowId || null; + const nextSelection = normalizeFileTreeSelectionState({ + selectedRowIds: + Array.isArray(selectionSource?.selectedRowIds) + ? selectionSource.selectedRowIds + : fallbackResult.nextSelection.selectedRowIds, + anchorRowId: + typeof selectionSource?.anchorRowId === "string" + ? selectionSource.anchorRowId + : fallbackResult.nextSelection.anchorRowId, + focusedRowId: + typeof selectionSource?.focusedRowId === "string" + ? selectionSource.focusedRowId + : fallbackResult.nextSelection.focusedRowId, + }); + return { + nextSelection, + dragRowIds: dragRows, + dragEffect, + dropTargetRowId, + runtimeState: normalizeFileTreeRuntimeState({ + selection: nextSelection, + dragRowIds: dragRows, + dragEffect, + dropTargetRowId, + }), + hostEvents: Array.isArray(runtimeResult.hostEvents) ? runtimeResult.hostEvents : [], + }; + }; + const reduceFileTreeSelectionActionWithRuntime = async ( + action, + fallbackResult, + runtimeState = readFileTreeRuntimeState(), + runtimeEnvironment = buildFileTreeRuntimeEnvironment(action), + ) => { + if (mode !== "filetree") { + return null; + } + const runtimeAction = buildFileTreeRuntimeAction(action); + if (!runtimeAction) return null; + return reduceTreeShellRuntimeWithArtifact({ + mode: "fileTree", + requestId: buildTreeShellRuntimeRequestId("filetree-selection"), + environment: runtimeEnvironment, + state: runtimeState, + action: runtimeAction, + fallbackResult, + normalizeResult: normalizeFileTreeRuntimeResult, + }); + }; const computeFileTreeSelectionActionResult = (action) => { - const currentSelection = readFileTreeSelectionState(); + const currentRuntimeState = readFileTreeRuntimeState(); + const currentSelection = currentRuntimeState.selection; + if (action?.kind === "update_drop_target") { + return { + nextSelection: currentSelection, + dragRowIds: currentRuntimeState.dragRowIds, + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: normalizeText(action.rowId) || null, + }; + } + if (action?.kind === "dispatch_internal_drop" || action?.kind === "dispatch_external_drop") { + return { + nextSelection: currentSelection, + dragRowIds: [], + dragEffect: null, + dropTargetRowId: null, + }; + } if ( mode !== "filetree" || filetreeSelectionReducerContractName !== "rust_filetree_selection_reducer_v1" || @@ -927,13 +1968,20 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` dragRowIds: action?.kind === "resolve_drag_rows" ? [normalizeText(action?.rowId)].filter(Boolean) - : null, + : currentRuntimeState.dragRowIds, + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: currentRuntimeState.dropTargetRowId, }; } if (action.kind === "select_row") { const rowId = normalizeText(action.rowId); if (!rowId) { - return { nextSelection: currentSelection, dragRowIds: null }; + return { + nextSelection: currentSelection, + dragRowIds: currentRuntimeState.dragRowIds, + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: currentRuntimeState.dropTargetRowId, + }; } const shiftKey = action.modifiers?.shiftKey === true; const metaKey = action.modifiers?.metaKey === true; @@ -951,7 +1999,9 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` anchorRowId: currentSelection.anchorRowId || anchor, focusedRowId: rowId, }, - dragRowIds: null, + dragRowIds: currentRuntimeState.dragRowIds, + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: currentRuntimeState.dropTargetRowId, }; } if (toggleSelection) { @@ -964,7 +2014,9 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` anchorRowId: rowId, focusedRowId: rowId, }, - dragRowIds: null, + dragRowIds: currentRuntimeState.dragRowIds, + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: currentRuntimeState.dropTargetRowId, }; } return { @@ -973,13 +2025,20 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` anchorRowId: rowId, focusedRowId: rowId, }, - dragRowIds: null, + dragRowIds: currentRuntimeState.dragRowIds, + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: currentRuntimeState.dropTargetRowId, }; } if (action.kind === "select_context_row") { const rowId = normalizeText(action.rowId); if (!rowId) { - return { nextSelection: currentSelection, dragRowIds: null }; + return { + nextSelection: currentSelection, + dragRowIds: currentRuntimeState.dragRowIds, + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: currentRuntimeState.dropTargetRowId, + }; } if (currentSelection.selectedRowIds.has(rowId)) { return { @@ -988,7 +2047,9 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` anchorRowId: currentSelection.anchorRowId, focusedRowId: rowId, }, - dragRowIds: null, + dragRowIds: currentRuntimeState.dragRowIds, + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: currentRuntimeState.dropTargetRowId, }; } return { @@ -997,7 +2058,9 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` anchorRowId: rowId, focusedRowId: rowId, }, - dragRowIds: null, + dragRowIds: currentRuntimeState.dragRowIds, + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: currentRuntimeState.dropTargetRowId, }; } if (action.kind === "normalize_visible_rows") { @@ -1017,7 +2080,9 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` ? currentSelection.focusedRowId : null, }, - dragRowIds: null, + dragRowIds: currentRuntimeState.dragRowIds, + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: currentRuntimeState.dropTargetRowId, }; } if (action.kind === "clear") { @@ -1027,47 +2092,117 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` anchorRowId: null, focusedRowId: null, }, - dragRowIds: null, + dragRowIds: currentRuntimeState.dragRowIds, + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: currentRuntimeState.dropTargetRowId, }; } if (action.kind === "resolve_drag_rows") { const rowId = normalizeText(action.rowId); if (!rowId) { - return { nextSelection: currentSelection, dragRowIds: [] }; + return { + nextSelection: currentSelection, + dragRowIds: [], + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: currentRuntimeState.dropTargetRowId, + }; } return { nextSelection: currentSelection, dragRowIds: currentSelection.selectedRowIds.has(rowId) ? Array.from(currentSelection.selectedRowIds) : [rowId], + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: currentRuntimeState.dropTargetRowId, }; } - return { nextSelection: currentSelection, dragRowIds: null }; + return { + nextSelection: currentSelection, + dragRowIds: currentRuntimeState.dragRowIds, + dragEffect: currentRuntimeState.dragEffect, + dropTargetRowId: currentRuntimeState.dropTargetRowId, + }; }; const applyFileTreeSelectionAction = (action) => { - const result = computeFileTreeSelectionActionResult(action); + const runtimeState = readFileTreeRuntimeState(); + const runtimeEnvironment = buildFileTreeRuntimeEnvironment(action); + const fallbackResult = computeFileTreeSelectionActionResult(action); + commitFileTreeRuntimeState({ + dragRowIds: fallbackResult.dragRowIds, + dragEffect: fallbackResult.dragEffect, + dropTargetRowId: fallbackResult.dropTargetRowId, + }); if (action?.kind !== "resolve_drag_rows") { - commitFileTreeSelection(result.nextSelection); + commitFileTreeSelection(fallbackResult.nextSelection); + void reduceFileTreeSelectionActionWithRuntime( + action, + fallbackResult, + runtimeState, + runtimeEnvironment, + ).then( + (runtimeResult) => { + if (!runtimeResult) return; + const shouldSyncSelection = !areFileTreeSelectionsEqual( + runtimeResult.nextSelection, + readFileTreeSelectionState(), + ); + if (shouldSyncSelection) { + commitFileTreeSelection(runtimeResult.nextSelection); + } + commitFileTreeRuntimeState(runtimeResult.runtimeState); + if (usedRustInitialRenderer) { + if (shouldSyncSelection) syncFileTreeSelectionDom(); + syncFileTreeDropTargetDom(); + } + }, + ); + } else { + void reduceFileTreeSelectionActionWithRuntime( + action, + fallbackResult, + runtimeState, + runtimeEnvironment, + ).then((runtimeResult) => { + if (!runtimeResult) return; + commitFileTreeRuntimeState(runtimeResult.runtimeState); + if (usedRustInitialRenderer) { + syncFileTreeDropTargetDom(); + } + }); } - return result; + return fallbackResult; }; const normalizeFileTreeSelectionForVisibleRows = () => { const currentSelection = readFileTreeSelectionState(); - const nextSelection = computeFileTreeSelectionActionResult({ + const action = { kind: "normalize_visible_rows", visibleRowIds: visibleFileTreeRowIds, - }).nextSelection; - if ( - nextSelection.selectedRowIds.size === currentSelection.selectedRowIds.size && - Array.from(nextSelection.selectedRowIds).every((rowId) => - currentSelection.selectedRowIds.has(rowId), - ) && - nextSelection.anchorRowId === currentSelection.anchorRowId && - nextSelection.focusedRowId === currentSelection.focusedRowId - ) { + }; + const runtimeState = readFileTreeRuntimeState(); + const runtimeEnvironment = buildFileTreeRuntimeEnvironment(action); + const fallbackResult = computeFileTreeSelectionActionResult(action); + if (areFileTreeSelectionsEqual(fallbackResult.nextSelection, currentSelection)) { return; } - commitFileTreeSelection(nextSelection); + commitFileTreeSelection(fallbackResult.nextSelection); + void reduceFileTreeSelectionActionWithRuntime( + action, + fallbackResult, + runtimeState, + runtimeEnvironment, + ).then( + (runtimeResult) => { + if (!runtimeResult || areFileTreeSelectionsEqual(runtimeResult.nextSelection, readFileTreeSelectionState())) { + return; + } + commitFileTreeSelection(runtimeResult.nextSelection); + commitFileTreeRuntimeState(runtimeResult.runtimeState); + if (usedRustInitialRenderer) { + syncFileTreeSelectionDom(); + syncFileTreeDropTargetDom(); + } + }, + ); }; const selectFileTreeRow = (rowId, modifiers = {}) => { applyFileTreeSelectionAction({ @@ -1086,39 +2221,69 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` rowId, }); }; - const openFileTreeContextMenu = ({ documentId, assetId, rowId, rowKind, clientX, clientY }) => { - postToHost("tree.filetree.context-menu", { - documentId: documentId || null, - assetId: assetId || null, - rowId: rowId || null, - rowKind: rowKind || null, - x: clientX, - y: clientY, + const reduceFileTreeIntentActionWithRuntime = (action, fallbackHostEvent = null) => { + const runtimeState = readFileTreeRuntimeState(); + const fallbackResult = { + nextSelection: readFileTreeSelectionState(), + dragRowIds: runtimeState.dragRowIds, + dragEffect: runtimeState.dragEffect, + dropTargetRowId: runtimeState.dropTargetRowId, + }; + void reduceFileTreeSelectionActionWithRuntime( + action, + fallbackResult, + runtimeState, + buildFileTreeRuntimeEnvironment(action), + ).then((runtimeResult) => { + replayFileTreeRuntimeHostEvents(runtimeResult, fallbackHostEvent); }); }; + const openFileTreeContextMenu = ({ documentId, assetId, rowId, rowKind, clientX, clientY }) => { + if (rowId) { + reduceFileTreeIntentActionWithRuntime( + { kind: "context_menu_row", rowId }, + { + kind: "contextMenu", + documentId: documentId || null, + assetId: assetId || null, + rowId: rowId || null, + rowKind: rowKind || null, + x: clientX, + y: clientY, + }, + ); + } + }; const openFileTreeItem = (item) => { const documentId = getFileTreeRowDocumentId(item); const assetId = getFileTreeRowAssetId(item); - if (item.rowKind === "document" || item.rowKind === "index") { - handleNavigate(documentId || item.nodeId); - return; + if (item?.rowId) { + reduceFileTreeIntentActionWithRuntime( + { kind: "open_row", rowId: item.rowId }, + item.rowKind === "document" || item.rowKind === "index" + ? { + kind: "navigate", + documentId: documentId || item.nodeId, + } + : { + kind: "assetOpen", + documentId: documentId || null, + assetId: assetId || null, + }, + ); } - postToHost("tree.asset.open", { - documentId: documentId || null, - assetId: assetId || null, - target: { documentId: documentId || null }, - }); }; const getFileTreeDropTargetFromElement = (element) => { const row = element instanceof Element ? element.closest('.tree-row[data-shell-mode="filetree"]') : null; if (!(row instanceof HTMLElement)) { const firstDoc = visibleFileTreeRowIds.find((rowId) => rowId.startsWith("doc:")); const firstItem = normalizedItems.find((item) => item.rowId === firstDoc); + const fallbackRowId = firstItem?.rowId || firstDoc || ""; return { - rowId: "", - rowKind: "", + rowId: fallbackRowId, + rowKind: firstItem?.rowKind || "document", documentId: firstItem ? getFileTreeRowDocumentId(firstItem) : currentActiveDocumentId, - assetId: "", + assetId: firstItem ? getFileTreeRowAssetId(firstItem) : "", }; } return { @@ -1140,6 +2305,146 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` ...extra, }); }; + const buildFileTreeDropTargetFromRuntimeEvent = (event, fallbackTarget = {}) => { + const target = event && typeof event.target === "object" ? event.target : null; + const targetKind = normalizeText(target?.kind); + const targetRowId = normalizeText(event?.targetRowId) || normalizeText(fallbackTarget.rowId); + const documentId = normalizeText(target?.documentId) || normalizeText(fallbackTarget.documentId); + const assetId = normalizeText(target?.assetId) || normalizeText(fallbackTarget.assetId); + const rowKind = targetKind === "assetFolder" + ? "asset_folder" + : targetKind === "asset" + ? "asset" + : targetKind === "index" + ? "index" + : normalizeText(fallbackTarget.rowKind) || "document"; + return { + rowId: targetRowId, + rowKind, + documentId, + assetId, + }; + }; + const replayFileTreeRuntimeHostEvents = (runtimeResult, fallbackHostEvent) => { + const runtimeHostEvents = Array.isArray(runtimeResult?.hostEvents) + ? runtimeResult.hostEvents + : []; + let replayed = false; + runtimeHostEvents.forEach((event) => { + if (!event || typeof event !== "object") return; + if (event.kind === "fileTreeOpen") { + const target = event.target; + const targetKind = normalizeText(target?.kind); + if (targetKind === "document" || targetKind === "index") { + const documentId = normalizeText(target?.documentId); + if (!documentId) return; + handleNavigate(documentId); + replayed = true; + return; + } + if (targetKind === "assetFolder" || targetKind === "asset") { + const documentId = normalizeText(target?.documentId) || null; + const assetId = normalizeText(target?.assetId) || null; + if (!assetId) return; + postToHost("tree.asset.open", { + documentId, + assetId, + target: { documentId }, + }); + replayed = true; + } + return; + } + if (event.kind === "fileTreeContextMenu") { + const target = event.target; + const targetKind = normalizeText(target?.kind); + const documentId = normalizeText(target?.documentId) || null; + const assetId = normalizeText(target?.assetId) || null; + const rowKind = + targetKind === "assetFolder" + ? "asset_folder" + : targetKind === "asset" + ? "asset" + : targetKind === "index" + ? "index" + : "document"; + postToHost("tree.filetree.context-menu", { + documentId, + assetId, + rowId: normalizeText(event.rowId) || null, + rowKind, + x: fallbackHostEvent?.x || 0, + y: fallbackHostEvent?.y || 0, + }); + replayed = true; + } + if (event.kind === "fileTreeInternalDrop") { + postFileTreeDropToHost( + "tree.filetree.internal-drop", + buildFileTreeDropTargetFromRuntimeEvent(event, fallbackHostEvent?.target), + { + rowIds: normalizeStringArray(event.rowIds), + copy: event.copy === true, + }, + ); + replayed = true; + return; + } + if (event.kind === "fileTreeExternalDrop") { + postFileTreeDropToHost( + "tree.filetree.external-drop", + buildFileTreeDropTargetFromRuntimeEvent(event, fallbackHostEvent?.target), + { + files: Array.isArray(fallbackHostEvent?.files) ? fallbackHostEvent.files : [], + fileCount: Number.isFinite(event.fileCount) ? event.fileCount : 0, + }, + ); + replayed = true; + return; + } + }); + if (replayed || !fallbackHostEvent) return replayed; + if (runtimeResult && fallbackHostEvent.runtimeRequired === true) { + return false; + } + if (fallbackHostEvent.kind === "navigate") { + handleNavigate(fallbackHostEvent.documentId); + return true; + } + if (fallbackHostEvent.kind === "assetOpen") { + postToHost("tree.asset.open", { + documentId: fallbackHostEvent.documentId, + assetId: fallbackHostEvent.assetId, + target: { documentId: fallbackHostEvent.documentId }, + }); + return true; + } + if (fallbackHostEvent.kind === "contextMenu") { + postToHost("tree.filetree.context-menu", { + documentId: fallbackHostEvent.documentId, + assetId: fallbackHostEvent.assetId, + rowId: fallbackHostEvent.rowId, + rowKind: fallbackHostEvent.rowKind, + x: fallbackHostEvent.x, + y: fallbackHostEvent.y, + }); + return true; + } + if (fallbackHostEvent.kind === "internalDrop") { + postFileTreeDropToHost("tree.filetree.internal-drop", fallbackHostEvent.target, { + rowIds: fallbackHostEvent.rowIds, + copy: fallbackHostEvent.copy === true, + }); + return true; + } + if (fallbackHostEvent.kind === "externalDrop") { + postFileTreeDropToHost("tree.filetree.external-drop", fallbackHostEvent.target, { + files: fallbackHostEvent.files, + }); + return true; + } + return false; + }; const readFileTreeInternalDropPayload = (event) => { const raw = event.dataTransfer?.getData("application/x-mnote-filetree-row-ids") || @@ -1154,6 +2459,130 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` return null; } }; + const syncFileTreeDropTargetDom = () => { + if (mode !== "filetree") return; + const dropTargetRowId = fileTreeRuntimeState.dropTargetRowId; + appElement.querySelectorAll('.tree-row[data-shell-mode="filetree"]').forEach((row) => { + if (!(row instanceof HTMLElement)) return; + const rowId = normalizeText(row.dataset.rowId); + row.dataset.dropTarget = String(Boolean(rowId && rowId === dropTargetRowId)); + }); + if (fileTreeRuntimeState.dragEffect) { + appElement.dataset.dragEffect = fileTreeRuntimeState.dragEffect; + } else { + delete appElement.dataset.dragEffect; + } + }; + const applyFileTreeDropTargetAction = (rowId) => { + const action = { + kind: "update_drop_target", + rowId: normalizeText(rowId) || null, + }; + const runtimeState = readFileTreeRuntimeState(); + const runtimeEnvironment = buildFileTreeRuntimeEnvironment(action); + const fallbackResult = computeFileTreeSelectionActionResult(action); + const targetChanged = fallbackResult.dropTargetRowId !== fileTreeRuntimeState.dropTargetRowId; + commitFileTreeRuntimeState({ + dragRowIds: fallbackResult.dragRowIds, + dragEffect: fallbackResult.dragEffect, + dropTargetRowId: fallbackResult.dropTargetRowId, + }); + if (targetChanged) { + syncFileTreeDropTargetDom(); + } + if (!targetChanged) return fallbackResult; + void reduceFileTreeSelectionActionWithRuntime( + action, + fallbackResult, + runtimeState, + runtimeEnvironment, + ).then((runtimeResult) => { + if (!runtimeResult) return; + commitFileTreeRuntimeState(runtimeResult.runtimeState); + syncFileTreeDropTargetDom(); + }); + return fallbackResult; + }; + const clearFileTreeDropTarget = () => { + applyFileTreeDropTargetAction(null); + }; + const dispatchFileTreeDropWithRuntime = (target, dropPayload, event) => { + const files = Array.isArray(dropPayload?.files) ? dropPayload.files : []; + const internalRowIds = normalizeStringArray(dropPayload?.internalRowIds); + const hasExternalFiles = files.length > 0; + const action = hasExternalFiles + ? { + kind: "dispatch_external_drop", + targetRowId: normalizeText(target?.rowId) || null, + fileCount: files.length, + } + : { + kind: "dispatch_internal_drop", + targetRowId: normalizeText(target?.rowId) || null, + rowIds: internalRowIds, + copy: event?.altKey === true, + }; + const fallbackHostEvent = hasExternalFiles + ? { + kind: "externalDrop", + target, + files, + runtimeRequired: true, + } + : { + kind: "internalDrop", + target, + rowIds: internalRowIds, + copy: event?.altKey === true, + runtimeRequired: true, + }; + const runtimeState = readFileTreeRuntimeState(); + const runtimeEnvironment = buildFileTreeRuntimeEnvironment(action); + const fallbackResult = computeFileTreeSelectionActionResult(action); + commitFileTreeRuntimeState({ + dragRowIds: fallbackResult.dragRowIds, + dragEffect: fallbackResult.dragEffect, + dropTargetRowId: fallbackResult.dropTargetRowId, + }); + syncFileTreeDropTargetDom(); + void reduceFileTreeSelectionActionWithRuntime( + action, + fallbackResult, + runtimeState, + runtimeEnvironment, + ).then((runtimeResult) => { + if (!runtimeResult) { + replayFileTreeRuntimeHostEvents(null, fallbackHostEvent); + return; + } + commitFileTreeRuntimeState(runtimeResult.runtimeState); + syncFileTreeDropTargetDom(); + if (!replayFileTreeRuntimeHostEvents(runtimeResult, fallbackHostEvent)) { + replayFileTreeRuntimeHostEvents(null, fallbackHostEvent); + } + }); + return fallbackResult; + }; + const readFileTreeDropPayloadFromEvent = (event) => { + const internalRowIds = readFileTreeInternalDropPayload(event); + const files = Array.from(event.dataTransfer?.files || []); + const transferTypes = Array.from(event.dataTransfer?.types || []); + const hasExternalFiles = files.length > 0 || transferTypes.includes("Files"); + return { + internalRowIds, + files, + hasExternalFiles, + hasPayload: Boolean(internalRowIds || hasExternalFiles), + }; + }; + const updateFileTreeDropTargetFromEvent = (event) => { + const dropPayload = readFileTreeDropPayloadFromEvent(event); + if (!dropPayload.hasPayload) return dropPayload; + event.preventDefault(); + const target = getFileTreeDropTargetFromElement(event.target); + applyFileTreeDropTargetAction(target.rowId); + return dropPayload; + }; const syncFileTreeSelectionDom = () => { if (mode !== "filetree") return; @@ -1174,25 +2603,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` }; const bindFileTreeRootEvents = (root) => { root.addEventListener("dragover", (event) => { - const internalRowIds = readFileTreeInternalDropPayload(event); - const files = Array.from(event.dataTransfer?.files || []); - if (!internalRowIds && files.length === 0) return; - event.preventDefault(); + updateFileTreeDropTargetFromEvent(event); + }); + root.addEventListener("dragleave", (event) => { + if (event.currentTarget instanceof Node && event.relatedTarget instanceof Node) { + if (event.currentTarget.contains(event.relatedTarget)) return; + } + clearFileTreeDropTarget(); }); root.addEventListener("drop", (event) => { - const internalRowIds = readFileTreeInternalDropPayload(event); - const files = Array.from(event.dataTransfer?.files || []); - if (!internalRowIds && files.length === 0) return; + const dropPayload = readFileTreeDropPayloadFromEvent(event); + const { hasPayload } = dropPayload; + if (!hasPayload) return; event.preventDefault(); const target = getFileTreeDropTargetFromElement(event.target); - if (files.length > 0) { - postFileTreeDropToHost("tree.filetree.external-drop", target, { files }); - } else { - postFileTreeDropToHost("tree.filetree.internal-drop", target, { - rowIds: internalRowIds, - copy: event.altKey === true, - }); - } + dispatchFileTreeDropWithRuntime(target, dropPayload, event); }); }; const bindFileTreeRowEvents = (row, item) => { @@ -1207,6 +2632,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` row.dataset.assetId = assetId || ""; row.dataset.shellMode = "filetree"; row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId)); + row.dataset.dropTarget = String(item.rowId === fileTreeRuntimeState.dropTargetRowId); row.tabIndex = 0; row.setAttribute("role", "treeitem"); row.addEventListener("click", (event) => { @@ -1233,6 +2659,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` applyFileTreeSelectionAction({ kind: "resolve_drag_rows", rowId: item.rowId, + altKey: event.altKey === true, }).dragRowIds || [item.rowId]; if (event.dataTransfer) { const payload = JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds }); @@ -1242,6 +2669,9 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` event.dataTransfer.setData("text/plain", payload); } }); + row.addEventListener("dragend", () => { + clearFileTreeDropTarget(); + }); row.querySelectorAll("[data-rust-action]").forEach((element) => { if (!(element instanceof HTMLElement)) return; element.addEventListener("click", (event) => { @@ -1282,6 +2712,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` }); normalizeFileTreeSelectionForVisibleRows(); syncFileTreeSelectionDom(); + syncFileTreeDropTargetDom(); return true; }; const getPickablePickerEntries = () => { @@ -1386,6 +2817,112 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` pickedRoot: false, }; }; + const buildPickerRuntimeAction = (action) => { + const actionKind = normalizeText(action?.kind); + if (actionKind === "focus") { + const itemKey = normalizeText(action.itemKey); + return itemKey + ? { + kind: "focus", + itemKey, + focusDom: action.focusDom === true, + } + : null; + } + if ( + actionKind === "normalize" || + actionKind === "next" || + actionKind === "previous" || + actionKind === "home" || + actionKind === "end" || + actionKind === "pick" + ) { + return { kind: actionKind }; + } + return null; + }; + const buildPickerRuntimeEnvironment = () => { + const items = []; + if (allowRootPick) { + items.push({ itemKey: "__root__", documentId: null, pickable: true }); + } + normalizedItems.forEach((item) => { + items.push({ + itemKey: item.nodeId, + documentId: item.nodeId, + pickable: !excludedIds.has(item.nodeId), + }); + }); + return { + items, + excludedIds: Array.from(excludedIds), + allowRootPick, + }; + }; + const readPickerRuntimeState = () => { + const activeItemKey = resolveCurrentPickerItemKey(); + return { + activeItemKey: activeItemKey || null, + }; + }; + const normalizePickerRuntimeResult = (runtimeResult, fallbackResult) => { + if (!runtimeResult || runtimeResult.mode !== "picker") return null; + const stateSnapshot = + runtimeResult.state && runtimeResult.state.mode === "picker" + ? runtimeResult.state.state + : null; + const pickerPatch = Array.isArray(runtimeResult.domPatches) + ? runtimeResult.domPatches.find((patch) => patch && patch.kind === "pickerState") + : null; + const rawNextItemKey = + typeof pickerPatch?.activeItemKey === "string" + ? pickerPatch.activeItemKey + : typeof stateSnapshot?.activeItemKey === "string" + ? stateSnapshot.activeItemKey + : fallbackResult.nextItemKey; + const nextItemKey = normalizePickerItemKey(rawNextItemKey); + const normalized = { + nextItemKey: nextItemKey || fallbackResult.nextItemKey || "", + focusDom: pickerPatch?.focusDom === true, + pickedDocumentId: null, + pickedRoot: false, + }; + if (Array.isArray(runtimeResult.hostEvents)) { + runtimeResult.hostEvents.forEach((event) => { + if (!event || typeof event !== "object") return; + if (event.kind === "pickerPickRoot") { + normalized.pickedRoot = true; + normalized.pickedDocumentId = null; + } + if (event.kind === "pickerPickDocument") { + normalized.pickedRoot = false; + normalized.pickedDocumentId = normalizeText(event.documentId) || null; + } + }); + } + return normalized; + }; + const reducePickerStateActionWithRuntime = async ( + action, + fallbackResult, + runtimeState = readPickerRuntimeState(), + runtimeEnvironment = buildPickerRuntimeEnvironment(), + ) => { + if (mode !== "picker") { + return null; + } + const runtimeAction = buildPickerRuntimeAction(action); + if (!runtimeAction) return null; + return reduceTreeShellRuntimeWithArtifact({ + mode: "picker", + requestId: buildTreeShellRuntimeRequestId("picker-state"), + environment: runtimeEnvironment, + state: runtimeState, + action: runtimeAction, + fallbackResult, + normalizeResult: normalizePickerRuntimeResult, + }); + }; const postPickerPickResultToHost = (result) => { if (mode !== "picker" || !result) return; if (result.pickedRoot) { @@ -1400,11 +2937,22 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` }); } }; - const applyPickerFocusByItemKey = (pickerItemKey, options = {}) => { - const result = computePickerStateActionResult({ - kind: "focus", - itemKey: pickerItemKey, - }); + const replayPickerRuntimePickResult = (runtimeResult, fallbackResult) => { + if (runtimeResult) { + if (runtimeResult.pickedRoot || runtimeResult.pickedDocumentId) { + postPickerPickResultToHost(runtimeResult); + return true; + } + return false; + } + if (fallbackResult && (fallbackResult.pickedRoot || fallbackResult.pickedDocumentId)) { + postPickerPickResultToHost(fallbackResult); + return true; + } + return false; + }; + const commitPickerFocusResult = (result, options = {}) => { + if (!result) return; const shouldFocusDom = options.focusDom === true; currentActivePickerItemKey = result.nextItemKey || ""; currentActiveDocumentId = @@ -1424,19 +2972,101 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` renderTree(); if (shouldFocusDom) focusPickerRowElement(currentActivePickerItemKey); }; - const applyPickerStateAction = (action) => { - const result = computePickerStateActionResult(action); - if (action?.kind !== "pick") { - applyPickerFocusByItemKey(result.nextItemKey); + const reconcilePickerRuntimeResult = (runtimeResult, fallbackResult) => { + if (!runtimeResult) return; + if ( + runtimeResult.nextItemKey && + runtimeResult.nextItemKey !== currentActivePickerItemKey + ) { + commitPickerFocusResult(runtimeResult, { focusDom: runtimeResult.focusDom }); + return; } - return result; + if (runtimeResult.focusDom) { + focusPickerRowElement(runtimeResult.nextItemKey || currentActivePickerItemKey); + } + }; + const applyPickerFocusByItemKey = (pickerItemKey, options = {}) => { + const action = { + kind: "focus", + itemKey: pickerItemKey, + focusDom: options.focusDom === true, + }; + const runtimeState = readPickerRuntimeState(); + const runtimeEnvironment = buildPickerRuntimeEnvironment(); + const fallbackResult = computePickerStateActionResult(action); + void reducePickerStateActionWithRuntime( + action, + fallbackResult, + runtimeState, + runtimeEnvironment, + ).then((runtimeResult) => { + if (runtimeResult) { + reconcilePickerRuntimeResult(runtimeResult, fallbackResult); + return; + } + commitPickerFocusResult(fallbackResult, { focusDom: action.focusDom }); + }); + return fallbackResult; + }; + const dispatchPickerPickByItemKeyWithRuntime = (pickerItemKey, options = {}) => { + const focusAction = { + kind: "focus", + itemKey: pickerItemKey, + focusDom: options.focusDom === true, + }; + const focusResult = computePickerStateActionResult(focusAction); + const runtimeState = { + activeItemKey: focusResult.nextItemKey || null, + }; + const runtimeEnvironment = buildPickerRuntimeEnvironment(); + const fallbackResult = computePickerStateActionResult({ kind: "pick" }); + fallbackResult.nextItemKey = focusResult.nextItemKey || fallbackResult.nextItemKey; + fallbackResult.pickedDocumentId = + fallbackResult.nextItemKey && fallbackResult.nextItemKey !== "__root__" + ? fallbackResult.nextItemKey + : null; + fallbackResult.pickedRoot = fallbackResult.nextItemKey === "__root__"; + void reducePickerStateActionWithRuntime( + { kind: "pick" }, + fallbackResult, + runtimeState, + runtimeEnvironment, + ).then((runtimeResult) => { + if (runtimeResult) { + reconcilePickerRuntimeResult(runtimeResult, fallbackResult); + } else { + commitPickerFocusResult(focusResult, { focusDom: focusAction.focusDom }); + } + replayPickerRuntimePickResult(runtimeResult, fallbackResult); + }); + return fallbackResult; + }; + const applyPickerStateAction = (action) => { + const runtimeState = readPickerRuntimeState(); + const runtimeEnvironment = buildPickerRuntimeEnvironment(); + const fallbackResult = computePickerStateActionResult(action); + void reducePickerStateActionWithRuntime( + action, + fallbackResult, + runtimeState, + runtimeEnvironment, + ).then((runtimeResult) => { + if (runtimeResult) { + reconcilePickerRuntimeResult(runtimeResult, fallbackResult); + } else if (action?.kind !== "pick") { + commitPickerFocusResult(fallbackResult); + } + if (action?.kind === "pick") { + replayPickerRuntimePickResult(runtimeResult, fallbackResult); + } + }); + return fallbackResult; }; const bindPickerRootEvents = (row) => { if (!(row instanceof HTMLElement)) return; row.dataset.focused = String(currentActivePickerItemKey === "__root__"); row.addEventListener("click", () => { - applyPickerFocusByItemKey("__root__", { focusDom: true }); - postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" })); + dispatchPickerPickByItemKeyWithRuntime("__root__", { focusDom: true }); }); }; const bindPickerRowEvents = (row, item) => { @@ -1448,8 +3078,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` (!currentActivePickerItemKey && currentActiveDocumentId === item.nodeId), ); row.addEventListener("click", () => { - applyPickerFocusByItemKey(item.nodeId, { focusDom: true }); - postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" })); + dispatchPickerPickByItemKeyWithRuntime(item.nodeId, { focusDom: true }); }); }; const focusPickerRowElement = (pickerItemKey) => { @@ -1504,6 +3133,9 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` row.dataset.focused = String(item.nodeId === focusedNodeId); row.dataset.nodeId = item.nodeId; row.dataset.shellMode = "page"; + row.dataset.draggable = "true"; + row.dataset.dropFeedback = String(Boolean(pageRuntimeState.dropFeedback && pageRuntimeState.dropTargetNodeId === item.nodeId)); + row.draggable = true; row.tabIndex = item.nodeId === focusedNodeId ? 0 : -1; row.addEventListener("focus", () => { if (focusedNodeId !== item.nodeId) { @@ -1536,6 +3168,52 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` event.preventDefault(); applyPageKeyboardAction({ kind: "context_menu" }, item, event.currentTarget); }); + row.addEventListener("dragstart", (event) => { + draggingPageNodeId = item.nodeId; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData(PAGE_DRAG_MIME, item.nodeId); + event.dataTransfer.setData("text/plain", item.nodeId); + } + setLastAction(\`开始拖拽页面 \${item.title}\`); + }); + row.addEventListener("dragover", (event) => { + const sourceNodeId = readPageDragNodeId(event); + const targetNodeId = resolvePageDropTargetNodeId(event.target); + if (!canSubmitPageDropCandidate(sourceNodeId, targetNodeId)) { + clearPageDropFeedback(); + return; + } + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "move"; + } + setPageDropFeedback(sourceNodeId, targetNodeId); + }); + row.addEventListener("dragleave", (event) => { + const relatedTarget = event.relatedTarget instanceof Node ? event.relatedTarget : null; + if (relatedTarget && row.contains(relatedTarget)) { + return; + } + if (pageRuntimeState.dropTargetNodeId === item.nodeId) { + clearPageDropFeedback(); + } + }); + row.addEventListener("drop", (event) => { + const sourceNodeId = readPageDragNodeId(event); + const targetNodeId = resolvePageDropTargetNodeId(event.target); + clearPageDropFeedback(); + draggingPageNodeId = ""; + if (!canSubmitPageDropCandidate(sourceNodeId, targetNodeId)) { + return; + } + event.preventDefault(); + dispatchPageMoveWithRuntime(sourceNodeId, targetNodeId); + }); + row.addEventListener("dragend", () => { + draggingPageNodeId = ""; + clearPageDropFeedback(); + }); row.querySelectorAll("[data-rust-action]").forEach((element) => { if (!(element instanceof HTMLElement)) return; element.addEventListener("click", (event) => { @@ -1543,17 +3221,17 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` if (action === "toggle") { event.preventDefault(); event.stopPropagation(); - toggleExpand(item.nodeId); + applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, event.currentTarget); return; } if (action === "open") { - handleNavigate(item.nodeId); + applyPageKeyboardAction({ kind: "open" }, item, event.currentTarget); return; } if (action === "create") { event.preventDefault(); event.stopPropagation(); - void runTreeCommand({ action: "create", parentId: item.nodeId }); + dispatchPageCreateWithRuntime(item.nodeId); return; } if (action === "rename") { @@ -1562,35 +3240,17 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` const nextTitle = window.prompt("重命名页面", item.title); const title = normalizeText(nextTitle); if (!title || title === item.title) return; - void runTreeCommand({ - action: "rename", - documentId: item.nodeId, + dispatchPageRenameWithRuntime( + item.nodeId, title, - }) - .then(() => { - item.title = title; - const titleElement = row.querySelector(".tree-link-title"); - if (titleElement) titleElement.textContent = title; - setLastAction(\`已重命名为 \${title}\`); - postToHost("tree.node.renamed", { - documentId: item.nodeId, - target: { documentId: item.nodeId }, - }); - }) - .catch((error) => { - setLastAction(error instanceof Error ? error.message : "重命名失败", "error"); - }); + row.querySelector(".tree-link-title"), + ); return; } if (action === "menu") { event.preventDefault(); event.stopPropagation(); - postToHost("tree.page.context-menu", { - documentId: item.nodeId, - x: 0, - y: 0, - target: { documentId: item.nodeId }, - }); + applyPageKeyboardAction({ kind: "context_menu" }, item, event.currentTarget); } }); }); @@ -1615,6 +3275,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` row.dataset.active = String(item.nodeId === currentActiveDocumentId); row.dataset.focused = String(item.nodeId === focusedNodeId); row.dataset.draggable = "true"; + row.dataset.dropFeedback = String(Boolean(pageRuntimeState.dropFeedback && pageRuntimeState.dropTargetNodeId === item.nodeId)); row.draggable = true; if (hasChildren) { @@ -1752,6 +3413,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` if (!item) return; bindPageRowEvents(row, item); }); + syncPageDropFeedbackDom(); if (focusedNodeId) focusRowElement(focusedNodeId); return true; }; @@ -1784,6 +3446,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` row.dataset.assetId = assetId || ""; row.dataset.shellMode = "filetree"; row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId)); + row.dataset.dropTarget = String(item.rowId === fileTreeRuntimeState.dropTargetRowId); row.setAttribute("data-testid", item.rowKind === "document" ? "filetree-doc-row" : item.rowKind === "index" ? "filetree-index-row" : "filetree-asset-row"); row.tabIndex = 0; row.setAttribute("role", "treeitem"); @@ -1814,6 +3477,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` applyFileTreeSelectionAction({ kind: "resolve_drag_rows", rowId: item.rowId, + altKey: event.altKey === true, }).dragRowIds || [item.rowId]; if (event.dataTransfer) { const payload = JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds }); @@ -1823,6 +3487,9 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` event.dataTransfer.setData("text/plain", payload); } }); + row.addEventListener("dragend", () => { + clearFileTreeDropTarget(); + }); if (hasBranches) { const toggleButton = document.createElement("button"); toggleButton.type = "button"; @@ -1871,25 +3538,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` if (hasBranches && expanded.has(item.nodeId)) children.forEach((child) => appendFileTreeRow(container, child)); }; fileRoot.addEventListener("dragover", (event) => { - const internalRowIds = readFileTreeInternalDropPayload(event); - const files = Array.from(event.dataTransfer?.files || []); - if (!internalRowIds && files.length === 0) return; - event.preventDefault(); + updateFileTreeDropTargetFromEvent(event); + }); + fileRoot.addEventListener("dragleave", (event) => { + if (event.currentTarget instanceof Node && event.relatedTarget instanceof Node) { + if (event.currentTarget.contains(event.relatedTarget)) return; + } + clearFileTreeDropTarget(); }); fileRoot.addEventListener("drop", (event) => { - const internalRowIds = readFileTreeInternalDropPayload(event); - const files = Array.from(event.dataTransfer?.files || []); - if (!internalRowIds && files.length === 0) return; + const dropPayload = readFileTreeDropPayloadFromEvent(event); + const { hasPayload } = dropPayload; + if (!hasPayload) return; event.preventDefault(); const target = getFileTreeDropTargetFromElement(event.target); - if (files.length > 0) { - postFileTreeDropToHost("tree.filetree.external-drop", target, { files }); - } else { - postFileTreeDropToHost("tree.filetree.internal-drop", target, { - rowIds: internalRowIds, - copy: event.altKey === true, - }); - } + dispatchFileTreeDropWithRuntime(target, dropPayload, event); }); if (roots.length === 0) { const empty = document.createElement("div"); @@ -1901,6 +3564,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` } normalizeFileTreeSelectionForVisibleRows(); appElement.appendChild(fileRoot); + syncFileTreeDropTargetDom(); }; const renderNode = (item, container) => { @@ -1912,6 +3576,8 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` row.dataset.nodeId = item.nodeId; row.dataset.shellMode = mode; row.dataset.draggable = String(mode === "page"); + row.dataset.dropFeedback = String(Boolean(mode === "page" && pageRuntimeState.dropFeedback && pageRuntimeState.dropTargetNodeId === item.nodeId)); + row.draggable = mode === "page"; row.tabIndex = item.nodeId === focusedNodeId ? 0 : -1; row.setAttribute("role", "treeitem"); row.setAttribute("aria-level", String(item.depth + 1)); @@ -1948,6 +3614,57 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` event.preventDefault(); applyPageKeyboardAction({ kind: "context_menu" }, item, event.currentTarget); }); + row.addEventListener("dragstart", (event) => { + if (mode !== "page") return; + draggingPageNodeId = item.nodeId; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData(PAGE_DRAG_MIME, item.nodeId); + event.dataTransfer.setData("text/plain", item.nodeId); + } + setLastAction(\`开始拖拽页面 \${item.title}\`); + }); + row.addEventListener("dragover", (event) => { + if (mode !== "page") return; + const sourceNodeId = readPageDragNodeId(event); + const targetNodeId = resolvePageDropTargetNodeId(event.target); + if (!canSubmitPageDropCandidate(sourceNodeId, targetNodeId)) { + clearPageDropFeedback(); + return; + } + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "move"; + } + setPageDropFeedback(sourceNodeId, targetNodeId); + }); + row.addEventListener("dragleave", (event) => { + if (mode !== "page") return; + const relatedTarget = event.relatedTarget instanceof Node ? event.relatedTarget : null; + if (relatedTarget && row.contains(relatedTarget)) { + return; + } + if (pageRuntimeState.dropTargetNodeId === item.nodeId) { + clearPageDropFeedback(); + } + }); + row.addEventListener("drop", (event) => { + if (mode !== "page") return; + const sourceNodeId = readPageDragNodeId(event); + const targetNodeId = resolvePageDropTargetNodeId(event.target); + clearPageDropFeedback(); + draggingPageNodeId = ""; + if (!canSubmitPageDropCandidate(sourceNodeId, targetNodeId)) { + return; + } + event.preventDefault(); + dispatchPageMoveWithRuntime(sourceNodeId, targetNodeId); + }); + row.addEventListener("dragend", () => { + if (mode !== "page") return; + draggingPageNodeId = ""; + clearPageDropFeedback(); + }); if (hasChildren) { const toggleButton = document.createElement("button"); toggleButton.type = "button"; @@ -1956,7 +3673,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸"; toggleButton.addEventListener("click", (event) => { event.stopPropagation(); - toggleExpand(item.nodeId); + applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, event.currentTarget); }); row.appendChild(toggleButton); } else { @@ -1972,11 +3689,10 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` linkButton.setAttribute("aria-label", \`打开 \${item.title}\`); linkButton.addEventListener("click", () => { if (mode === "picker") { - applyPickerFocusByItemKey(item.nodeId, { focusDom: true }); - postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" })); + dispatchPickerPickByItemKeyWithRuntime(item.nodeId, { focusDom: true }); return; } - handleNavigate(item.nodeId); + applyPageKeyboardAction({ kind: "open" }, item, linkButton); }); const titleElement = document.createElement("span"); titleElement.className = "tree-link-title"; @@ -1995,23 +3711,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` createButton.addEventListener("click", async (event) => { event.preventDefault(); event.stopPropagation(); - try { - const result = await runTreeCommand({ - action: "create", - parentId: item.nodeId, - }); - const documentId = normalizeText( - result && result.result && result.result.documentId, - normalizeText(result && result.id), - ); - setLastAction(documentId ? \`已创建子页面 \${documentId}\` : "已创建子页面"); - postToHost("tree.node.created", { - documentId: documentId || null, - target: { documentId: documentId || null }, - }); - } catch (error) { - setLastAction(error instanceof Error ? error.message : "创建页面失败", "error"); - } + dispatchPageCreateWithRuntime(item.nodeId); }); actions.appendChild(createButton); @@ -2027,22 +3727,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` const nextTitle = window.prompt("重命名页面", item.title); const title = normalizeText(nextTitle); if (!title || title === item.title) return; - try { - await runTreeCommand({ - action: "rename", - documentId: item.nodeId, - title, - }); - item.title = title; - setLastAction(\`已重命名为 \${title}\`); - postToHost("tree.node.renamed", { - documentId: item.nodeId, - target: { documentId: item.nodeId }, - }); - renderTree(); - } catch (error) { - setLastAction(error instanceof Error ? error.message : "重命名失败", "error"); - } + dispatchPageRenameWithRuntime(item.nodeId, title); }); actions.appendChild(renameButton); @@ -2055,12 +3740,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` moreButton.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); - postToHost("tree.page.context-menu", { - documentId: item.nodeId, - x: event.clientX, - y: event.clientY, - target: { documentId: item.nodeId }, - }); + applyPageKeyboardAction({ kind: "context_menu" }, item, event.currentTarget); }); actions.appendChild(moreButton); } @@ -2093,8 +3773,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` rootButton.tabIndex = currentActivePickerItemKey === "__root__" ? 0 : -1; rootButton.textContent = "根目录"; rootButton.addEventListener("click", () => { - applyPickerFocusByItemKey("__root__", { focusDom: true }); - postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" })); + dispatchPickerPickByItemKeyWithRuntime("__root__", { focusDom: true }); }); root.appendChild(rootButton); } @@ -2115,8 +3794,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` return; } if (normalizedCommand === "pick") { - const result = applyPickerStateAction({ kind: "pick" }); - postPickerPickResultToHost(result); + applyPickerStateAction({ kind: "pick" }); return; } applyPickerStateAction({ kind: normalizedCommand }); @@ -2137,12 +3815,14 @@ const LOCAL_TREE_SHELL_TEMPLATE = ` focusedNodeId = resolveFocusedNodeIdFromHostState(); if (mode === "page" && usedRustInitialRenderer) { patchPageTreeActiveDom(); + syncPageDropFeedbackDom(); if (focusedNodeId) focusRowElement(focusedNodeId); return; } if (mode === "filetree" && usedRustInitialRenderer) { patchFileTreeActiveDom(); syncFileTreeSelectionDom(); + syncFileTreeDropTargetDom(); return; } if (mode === "picker" && usedRustInitialRenderer) { @@ -2268,82 +3948,20 @@ const escapeInlineHtml = (input: string) => { export function buildTreeShellInlinePickerItems( items: TreeShellPickerItem[], ): TreeShellInlineProjectionItem[] { - return items - .filter( - (item): item is Extract => - 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, - })); + return buildTreeShellDomPickerItems(items); } export function buildTreeShellInlinePageItems( items: PageTreeProjectionItem[], expanded: ReadonlySet = new Set(), ): TreeShellInlineProjectionItem[] { - 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)); + return buildTreeShellDomPageItems(items, expanded); } export function buildTreeShellInlineKernelFileTreeItems( items: KernelFileTreeProjectionItem[], ): TreeShellInlineProjectionItem[] { - 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)); + return buildTreeShellDomKernelFileTreeItems(items); } export function injectTreeShellInlineOverrides( @@ -2360,39 +3978,11 @@ export function injectTreeShellInlineOverrides( } function buildInlineChildrenByParent(items: TreeShellInlineProjectionItem[]) { - const ids = new Set(items.map((item) => item.nodeId)); - const childrenByParent = new Map(); - 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; + return buildTreeShellDomChildrenByParent(items); } function buildInlineFiletreeSelection(activeDocumentId?: string | null) { - const documentId = normalizeString(activeDocumentId); - if (!documentId) { - return { - selectedRowIds: [], - anchorRowId: null, - focusedRowId: null, - }; - } - const documentRowId = `doc:${documentId}`; - return { - selectedRowIds: [documentRowId, `index:${documentId}`], - anchorRowId: documentRowId, - focusedRowId: documentRowId, - }; + return buildTreeShellDomFiletreeSelection(activeDocumentId); } function buildInlineRendererInput(input: { @@ -2420,9 +4010,24 @@ function buildInlineRendererInput(input: { }; const runtimeArtifact = { contractName: TREE_SHELL_RUNTIME_ARTIFACT.contractName, + family: TREE_SHELL_RUNTIME_ARTIFACT.family, + version: TREE_SHELL_RUNTIME_ARTIFACT.version, + executionStrategy: TREE_SHELL_RUNTIME_ARTIFACT.executionStrategy, + browserBridge: TREE_SHELL_RUNTIME_ARTIFACT.browserBridge, + wasmModuleUrl: TREE_SHELL_RUNTIME_ARTIFACT.wasmModuleUrl, + jsGlueUrl: TREE_SHELL_RUNTIME_ARTIFACT.jsGlueUrl, inputFields: [...TREE_SHELL_RUNTIME_ARTIFACT.inputFields], outputChannels: [...TREE_SHELL_RUNTIME_ARTIFACT.outputChannels], eventKinds: [...TREE_SHELL_RUNTIME_ARTIFACT.eventKinds], + runtimeApi: { + requestContract: TREE_SHELL_RUNTIME_ARTIFACT.runtimeApi.requestContract, + resultContract: TREE_SHELL_RUNTIME_ARTIFACT.runtimeApi.resultContract, + reduceEndpoint: TREE_SHELL_RUNTIME_ARTIFACT.runtimeApi.reduceEndpoint, + stateSnapshots: [...TREE_SHELL_RUNTIME_ARTIFACT.runtimeApi.stateSnapshots], + domPatchKinds: [...TREE_SHELL_RUNTIME_ARTIFACT.runtimeApi.domPatchKinds], + hostEventKinds: [...TREE_SHELL_RUNTIME_ARTIFACT.runtimeApi.hostEventKinds], + commandEventKinds: [...TREE_SHELL_RUNTIME_ARTIFACT.runtimeApi.commandEventKinds], + }, }; if (input.mode === "filetree") { const filetreeSelection = buildInlineFiletreeSelection(input.activeDocumentId); @@ -2496,7 +4101,7 @@ function buildInlinePageTreeHtml(input: { expanded && children.length > 0 ? `
        ${children.map(renderRow).join("")}
      ` : ""; - return `
    • ${toggleHtml}
      ${childHtml}
    • `; + return `
    • ${toggleHtml}
      ${childHtml}
    • `; }; const roots = childrenByParent.get("") ?? []; if (roots.length === 0) { @@ -3020,6 +4625,8 @@ export function TreeShellIframeHost({ data-tree-shell-mode={mode} data-tree-shell-channel={resolvedChannel} data-tree-shell-inline="1" + data-tree-runtime-artifact-host="rust_tree_shell_runtime_artifact_v1" + data-tree-browser-bridge="iframe_srcdoc" className="h-full w-full border-0 bg-white" /> ); diff --git a/wolai-frontend/src/components/sidebar/tree-shell-surface.test.tsx b/wolai-frontend/src/components/sidebar/tree-shell-surface.test.tsx index 439338f3..daa7efab 100644 --- a/wolai-frontend/src/components/sidebar/tree-shell-surface.test.tsx +++ b/wolai-frontend/src/components/sidebar/tree-shell-surface.test.tsx @@ -1,10 +1,97 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; 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"; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +type RuntimeRequest = { + requestId: string; + mode: "page" | "fileTree" | "picker"; + environment?: Record; + state?: Record; + action?: Record; +}; + +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> : []; + 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", () => { let container: HTMLDivElement; let root: Root; @@ -20,6 +107,7 @@ describe("tree-shell-surface", () => { root.unmount(); }); container.remove(); + delete (globalThis as TreeShellRuntimeTestGlobal).__MNOTE_TREE_SHELL_RUNTIME__; }); 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"); const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]'); const rustHost = container.querySelector( '[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-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(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(rustHost).not.toBeNull(); - expect(iframe).not.toBeNull(); - expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeArtifact"'); - expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"'); + expect(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="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(); }); - it("page tree surface 在 rust_family 下应把 focusedDocumentId 作为宿主状态暴露,并使用 postMessage patch 同步 iframe", () => { + it("page tree surface 在 rust_family 下应把 focusedDocumentId 暴露到宿主 DOM 状态", () => { 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 domHost = container.querySelector('[data-testid="sidebar-page-tree-shell-dom-host"]'); expect(surface?.getAttribute("data-page-tree-focused-id")).toBe("doc_focus"); - expect(iframe?.getAttribute("src")).toBeNull(); - expect(iframe?.getAttribute("srcdoc")).toContain('"focusedDocumentId":"doc_focus"'); + expect(domHost?.getAttribute("data-tree-browser-bridge")).toBe("dom_wasm"); + 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 () => { root.render( { }); const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]'); - expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); - expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).not.toBeNull(); + expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host"); + 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(); }); @@ -126,7 +215,7 @@ describe("tree-shell-surface", () => { 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(() => { root.render( { const rustHost = container.querySelector( '[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-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(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); expect(rustHost).not.toBeNull(); - expect(iframe).not.toBeNull(); - expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeArtifact"'); - expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"'); + expect(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="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(); }); - 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 () => { root.render( { }); const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]'); - expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host"); - expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).not.toBeNull(); + expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_wasm_dom_shell_host"); + 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(); }); @@ -212,10 +304,50 @@ describe("tree-shell-surface", () => { 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 onDropFiles = vi.fn(); 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 () => { root.render( @@ -225,6 +357,7 @@ describe("tree-shell-surface", () => { workspaceId="ws_1" treeShellEnabled rows={[]} + treeShellItems={[targetProjectionItem]} activeId="" onRowClick={() => undefined} onRowDoubleClick={() => undefined} @@ -237,45 +370,33 @@ describe("tree-shell-surface", () => { ); }); - const iframe = container.querySelector( - '[data-testid="sidebar-file-tree-shell-rust-iframe"]', + const targetRow = container.querySelector( + '[data-testid="filetree-doc-row"][data-document-id="doc_target"]', ); - expect(iframe).not.toBeNull(); - Object.defineProperty(iframe, "contentWindow", { - configurable: true, - value: window, - }); + expect(targetRow).not.toBeNull(); await act(async () => { - window.dispatchEvent( - new MessageEvent("message", { - data: { - channel: "sidebar-file-tree-shell", - 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, - }), - ); + const internalDropEvent = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(internalDropEvent, "dataTransfer", { + configurable: true, + value: dataTransfer, + }); + 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({ targetDocumentId: "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(); await act(async () => { @@ -312,19 +433,24 @@ describe("tree-shell-surface", () => { const surface = container.querySelector('[data-testid="tree-picker-surface"]'); 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-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(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1"); 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(); }); - it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => { + it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用默认 DOM shell host", async () => { await act(async () => { root.render( { const surface = container.querySelector('[data-testid="tree-picker-surface"]'); 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(rustHost).not.toBeNull(); - expect(iframe).not.toBeNull(); + expect(domHost).not.toBeNull(); + expect(container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]')).toBeNull(); }); }); diff --git a/wolai-frontend/src/lib/documents/document-move-order-convex.test.ts b/wolai-frontend/src/lib/documents/document-move-order-convex.test.ts new file mode 100644 index 00000000..8fc24e56 --- /dev/null +++ b/wolai-frontend/src/lib/documents/document-move-order-convex.test.ts @@ -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 当前排序状态不一致", + ); + }); +}); diff --git a/wolai-frontend/src/lib/documents/document-move-order.test.ts b/wolai-frontend/src/lib/documents/document-move-order.test.ts index 708a3f11..fdedd938 100644 --- a/wolai-frontend/src/lib/documents/document-move-order.test.ts +++ b/wolai-frontend/src/lib/documents/document-move-order.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { - assertDocumentMoveOrderPlanMatches, + assertDocumentMoveWriteOperationMatches, buildDocumentMoveOrderPlanFromDocuments, } from "../../../convex/_utils/documentMoveOrder"; @@ -61,7 +61,7 @@ describe("documentMoveOrder", () => { }); }); - it("normalizedMove 与当前排序状态不一致时拒绝执行", () => { + it("treeWriteOperation 与当前排序状态不一致时拒绝执行", () => { const actual = buildDocumentMoveOrderPlanFromDocuments({ documents: [ { @@ -89,8 +89,13 @@ describe("documentMoveOrder", () => { }); expect(() => - assertDocumentMoveOrderPlanMatches( + assertDocumentMoveWriteOperationMatches( { + family: "tree", + schema: "mnote.tree.write_operation", + schemaVersion: 1, + operation: "tree.subtree.move.write", + workspaceId: "ws_1", ...actual, patches: actual.patches.map((patch) => patch.documentId === "doc_c" ? { ...patch, sortOrder: 9 } : patch, @@ -98,6 +103,6 @@ describe("documentMoveOrder", () => { }, actual, ), - ).toThrow("Rust move plan 与 Convex 当前排序状态不一致"); + ).toThrow("Rust move write operation 与 Convex 当前排序状态不一致"); }); }); diff --git a/wolai-frontend/src/lib/documents/page-command-adapter.ts b/wolai-frontend/src/lib/documents/page-command-adapter.ts index 426c768e..a02f7808 100644 --- a/wolai-frontend/src/lib/documents/page-command-adapter.ts +++ b/wolai-frontend/src/lib/documents/page-command-adapter.ts @@ -15,13 +15,10 @@ import { recordBridgeCommandFailureArtifacts, recordRustBridgeCommandArtifacts, } from "@/lib/documents/bridge-log"; -import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content"; import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan, } from "@/lib/documents/rust-runtime"; -import { buildDocumentSavePayload } from "@/lib/documents/save-contract"; -import type { Json } from "@/types/supabase"; export type DocumentCreatePayload = { documentId: string; @@ -82,10 +79,7 @@ export type DocumentEmbedPayload = { documentId: string; workspaceId: string | null; revision: number | null; - content: Json; conflictDetectionKey: string | null; - snapshotCapturedAt: string | null; - blockCount: number | null; sourceDocumentId: string; targetDocumentId: string; 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 currentBlocks = extractBlocksFromContent(targetContent.content); 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 context = await buildRuntimeContext(request, workspaceId); const embedPayload: DocumentEmbedPayload = { - ...buildDocumentSavePayload({ - documentId: normalizedTargetId, - workspaceId, - revision: - typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision) - ? targetContent.revision - : null, - content: payload, - conflictDetectionKey: - typeof targetContent.conflict_detection_key === "string" - ? targetContent.conflict_detection_key - : null, - blockCount: nextBlocks.length, - }), + documentId: normalizedTargetId, + workspaceId, + revision: + typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision) + ? targetContent.revision + : null, + conflictDetectionKey: + typeof targetContent.conflict_detection_key === "string" + ? targetContent.conflict_detection_key + : null, sourceDocumentId: normalizedSourceId, targetDocumentId: normalizedTargetId, anchorBlockId: anchorId, @@ -324,6 +291,16 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi name: "documents.embed", payload: embedPayload, context, + preflightData: { + pageAggregateEmbed: { + sourceDocumentId: normalizedSourceId, + sourceTitle: sourceDoc.title ?? "无标题", + targetDocumentId: normalizedTargetId, + targetContent: targetContent.content, + anchorBlockId: anchorId, + blockId: safeRandomId(), + }, + }, target: { workspaceId, pageId: normalizedTargetId, diff --git a/wolai-frontend/src/lib/documents/page-lifecycle-command-adapter.ts b/wolai-frontend/src/lib/documents/page-lifecycle-command-adapter.ts index 55ff20db..28a63bc2 100644 --- a/wolai-frontend/src/lib/documents/page-lifecycle-command-adapter.ts +++ b/wolai-frontend/src/lib/documents/page-lifecycle-command-adapter.ts @@ -373,14 +373,14 @@ export async function handleDocumentCreateRequest(request: Request): Promise { assertServerEnvironment(); const requestClone = request.clone(); - let normalizedMove: NormalizedDocumentMovePayload | null = null; + let movePayload: NormalizedDocumentMovePayload | null = null; let failureClient: Awaited>["client"] | null = null; let failureAuthUserId: string | null = null; let failureSourceDocument: MovePreflightDocument | null = null; try { const payload = (await request.json()) as MovePayload; - normalizedMove = normalizeDocumentMovePayload(payload); - const documentId = normalizedMove.documentId; + movePayload = normalizeDocumentMovePayload(payload); + const documentId = movePayload.documentId; if (!documentId) { return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); } @@ -402,14 +402,14 @@ export async function handleDocumentMoveRequest(request: Request): Promise ({}))) as MovePayload, ); diff --git a/wolai-frontend/src/lib/documents/rust-runtime.test.ts b/wolai-frontend/src/lib/documents/rust-runtime.test.ts index a734fb56..3408a6e5 100644 --- a/wolai-frontend/src/lib/documents/rust-runtime.test.ts +++ b/wolai-frontend/src/lib/documents/rust-runtime.test.ts @@ -5,6 +5,7 @@ import { executeRustBridgeMutationTransport, materializeRustTreeStreamDelta, readRustTreeDomainEventPlan, + readRustTreeDomainEventPlans, readRustTreeDomainEventType, type RustBridgeCommandPlan, } from "./rust-runtime"; @@ -83,7 +84,7 @@ describe("shouldUseBuiltBridgeRuntimeBinary", () => { describe("executeRustBridgeMutationTransport", () => { it("documents.move 应把 Rust treeWriteOperation 透传给 Convex 写执行器", async () => { - const normalizedMove = { + const movePlan = { documentId: "doc_b", fromParentId: "source", toParentId: "target", @@ -104,7 +105,7 @@ describe("executeRustBridgeMutationTransport", () => { schemaVersion: 1, operation: "tree.subtree.move.write", workspaceId: "ws_1", - ...normalizedMove, + ...movePlan, }; const mutation = vi.fn().mockResolvedValue({ ok: true }); const plan: RustBridgeCommandPlan = { @@ -122,7 +123,6 @@ describe("executeRustBridgeMutationTransport", () => { id: "doc_b", parentId: "target", sortOrder: 0, - normalizedMove, treeWriteOperation, }, }; @@ -137,7 +137,6 @@ describe("executeRustBridgeMutationTransport", () => { id: "doc_b", parentId: "target", sortOrder: 0, - normalizedMove, treeWriteOperation, }); }); @@ -645,7 +644,7 @@ describe("readRustTreeDomainEventType", () => { }); }); - it("应保留 Rust formal domainEventPlan payload schema", () => { + it("应保留 Rust formal domainEventPlan payload schema,并拆出 snapshot 独立事件", () => { const plan: RustBridgeCommandPlan = { kind: "command", commandName: "page.body.save", @@ -668,11 +667,6 @@ describe("readRustTreeDomainEventType", () => { id: "doc_1", workspaceId: "ws_1", }, - snapshot: { - version: 8, - contentHash: "fnv1a64:d039f8f3496411e8", - updatedAt: null, - }, blocks: { ids: ["block_1"], count: 1, @@ -687,6 +681,57 @@ describe("readRustTreeDomainEventType", () => { }, }, }, + domainEventPlans: [ + { + family: "tree", + schema: "mnote.tree.domain_event", + schemaVersion: 1, + eventType: "page.body.saved", + payload: { + page: { + id: "doc_1", + workspaceId: "ws_1", + }, + blocks: { + ids: ["block_1"], + count: 1, + }, + }, + streamDeltaHint: { + family: "tree", + kind: "resync_required", + args: { + reason: "page_body_saved", + pageId: "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", + }, + }, + }, + ], }, }; @@ -700,11 +745,6 @@ describe("readRustTreeDomainEventType", () => { id: "doc_1", workspaceId: "ws_1", }, - snapshot: { - version: 8, - contentHash: "fnv1a64:d039f8f3496411e8", - updatedAt: null, - }, blocks: { ids: ["block_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,11 +852,6 @@ describe("buildRustBridgeCommandArtifactPlan", () => { id: "doc_1", workspaceId: "ws_1", }, - snapshot: { - version: 8, - contentHash: "fnv1a64:d039f8f3496411e8", - updatedAt: null, - }, blocks: { ids: ["block_1"], count: 1, @@ -815,6 +866,57 @@ describe("buildRustBridgeCommandArtifactPlan", () => { }, }, }, + domainEventPlans: [ + { + family: "tree", + schema: "mnote.tree.domain_event", + schemaVersion: 1, + eventType: "page.body.saved", + payload: { + page: { + id: "doc_1", + workspaceId: "ws_1", + }, + blocks: { + ids: ["block_1"], + count: 1, + }, + }, + streamDeltaHint: { + family: "tree", + kind: "resync_required", + args: { + reason: "page_body_saved", + pageId: "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", + }, + }, + }, + ], }, }, result: { @@ -842,6 +944,10 @@ describe("buildRustBridgeCommandArtifactPlan", () => { }); expect(artifactPlan?.commandLog.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({ id: "evt_cmd_artifact_1", eventType: "page.body.saved", @@ -854,11 +960,6 @@ describe("buildRustBridgeCommandArtifactPlan", () => { id: "doc_1", workspaceId: "ws_1", }, - snapshot: { - version: 8, - contentHash: "fnv1a64:d039f8f3496411e8", - updatedAt: null, - }, blocks: { ids: ["block_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); }); diff --git a/wolai-frontend/src/lib/documents/rust-runtime.ts b/wolai-frontend/src/lib/documents/rust-runtime.ts index cae7518d..78d19abf 100644 --- a/wolai-frontend/src/lib/documents/rust-runtime.ts +++ b/wolai-frontend/src/lib/documents/rust-runtime.ts @@ -161,6 +161,7 @@ export type RustBridgeDomainEventArtifactPlan = { export type RustBridgeCommandArtifactPlan = { commandLog: RustBridgeCommandLogArtifactPlan; domainEvent: RustBridgeDomainEventArtifactPlan | null; + domainEvents?: RustBridgeDomainEventArtifactPlan[]; }; export type RustBridgeToolPlanStep = { @@ -555,6 +556,14 @@ function readOptionalRecordArg(argsJson: Record, field: string) return isRecord(value) ? value : null; } +function readRequiredRecordArg(argsJson: Record, field: string) { + const value = argsJson[field]; + if (isRecord(value)) { + return value; + } + throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR"); +} + function readOptionalBooleanField(source: Record, field: string) { const value = source[field]; return typeof value === "boolean" ? value : undefined; @@ -670,6 +679,24 @@ export function readRustTreeDomainEventType(plan: RustBridgeCommandPlan): string export function readRustTreeDomainEventPlan(plan: RustBridgeCommandPlan): RustTreeDomainEventPlan | null { 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") { 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: { plan: RustBridgeCommandPlan; result: unknown; @@ -882,8 +927,14 @@ export async function persistRustBridgeCommandArtifacts(input: { ) => Promise; await mutation(bridgeLogsApi.bridgeLogs.recordCommandLog, artifacts.commandLog as unknown as Record); - if (artifacts.domainEvent) { - await mutation(bridgeLogsApi.bridgeLogs.recordDomainEvent, artifacts.domainEvent as unknown as Record); + const domainEvents = + 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); } } @@ -1151,12 +1202,7 @@ export async function executeRustBridgeMutationTransport(input: { id: assertStringArg(input.plan.argsJson, "id"), parentId: readOptionalStringArg(input.plan.argsJson, "parentId"), sortOrder: readRequiredNumberArg(input.plan.argsJson, "sortOrder"), - ...("normalizedMove" in input.plan.argsJson - ? { normalizedMove: input.plan.argsJson.normalizedMove } - : {}), - ...("treeWriteOperation" in input.plan.argsJson - ? { treeWriteOperation: input.plan.argsJson.treeWriteOperation } - : {}), + treeWriteOperation: readRequiredRecordArg(input.plan.argsJson, "treeWriteOperation"), }); case "documents:softDelete": return mutation(api.documents.softDelete, { diff --git a/wolai-frontend/src/lib/file-tree/projection-client.test.ts b/wolai-frontend/src/lib/file-tree/projection-client.test.ts index 83ef6abf..197e83c6 100644 --- a/wolai-frontend/src/lib/file-tree/projection-client.test.ts +++ b/wolai-frontend/src/lib/file-tree/projection-client.test.ts @@ -21,6 +21,23 @@ describe("fetchKernelFileTreeProjection", () => { rootNodeId: "page_root", items: [{ rowId: "asset:table_1" }], 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 }, @@ -44,6 +61,11 @@ describe("fetchKernelFileTreeProjection", () => { }), ); 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", () => { diff --git a/wolai-frontend/src/lib/kernel-file-tree.ts b/wolai-frontend/src/lib/kernel-file-tree.ts index 26b718ac..571a8c8f 100644 --- a/wolai-frontend/src/lib/kernel-file-tree.ts +++ b/wolai-frontend/src/lib/kernel-file-tree.ts @@ -29,12 +29,36 @@ export type KernelFileTreeProjectionEdge = { 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 = { projectionId: string; projection: "file_tree"; rootNodeId: string | null; items: KernelFileTreeProjectionItem[]; 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 = { @@ -574,5 +598,27 @@ export function buildKernelFileTreeProjection( rootNodeId, items, 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, + }, + }, + }, + }, }; } diff --git a/wolai-frontend/src/lib/tree-route-boundary.test.ts b/wolai-frontend/src/lib/tree-route-boundary.test.ts new file mode 100644 index 00000000..7270bf24 --- /dev/null +++ b/wolai-frontend/src/lib/tree-route-boundary.test.ts @@ -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"); + }); +}); diff --git a/wolai-frontend/src/lib/tree-route-boundary.ts b/wolai-frontend/src/lib/tree-route-boundary.ts new file mode 100644 index 00000000..56615ae0 --- /dev/null +++ b/wolai-frontend/src/lib/tree-route-boundary.ts @@ -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;