refactor: externalize filetree selection runtime

This commit is contained in:
lix-2026
2026-05-25 00:37:47 +08:00
parent 2f01dc3a2e
commit ec623c9a82
6 changed files with 335 additions and 50 deletions
@@ -1,7 +1,7 @@
# 3-19 Rust Web 浏览器 Runtime 模块外置化重构 v1
> 创建时间:2026-05-24
> 状态:`process`
> 状态:`done`
>
> 触发背景:
> - CodeGraph 对 `mnote-web` 前端内嵌 JS 的符号定位效果一般。
@@ -579,32 +579,89 @@ Reasonix read-only audit
- `cargo fmt --manifest-path rust/Cargo.toml --all --check`
- `cargo check --manifest-path rust/Cargo.toml -p mnote-web`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_runtime -- --test-threads=1`
- 结果:9 个相关测试通过。
- 结果:相关测试通过;本轮另以 `sidebar_filetree_runtime_helpers_are_externalized_with_inline_fallback``filetree_runtime_contains_row_accessor_helpers` 和完整 `filetree` 过滤测试复核
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_context_menu -- --test-threads=1`
- 结果:2 个相关测试通过。
- 结果:相关测试通过;本轮另以 `filetree_context` 过滤测试复核
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1`
- 结果:runtime asset route 测试通过。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree`
- 结果:29 个 filetree 相关测试通过。
- `node scripts/task487-local-folder-tree-live-consumer-smoke.js`
- 结果:`ok=true`,证据文件 `tmp/task487-local-folder-tree-live-consumer-smoke/result.json`
- `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`
- 结果:`ok=true`,覆盖 editor upload、filetree folder drop、resource tab missing/error、无 active document 附件 tab、filetree scroll、标题来源、附件删除保留相邻附件;证据包含 stdout JSON 和 smoke 生成截图。
- `codegraph_search fileTreeRowLocalRelativePath` / `fileTreeAssetDownloadDetail`
- 结果:均可定位到 `rust/crates/mnote-web/browser/filetree-runtime.js`
- `codegraph_search buildSidebarFileTreeContext` / `evaluateSidebarFileTreeWhen`
- 结果:均可定位到 `rust/crates/mnote-web/browser/filetree-context-menu-runtime.js`
- 真实浏览器轻量验收:
- dev server: `npm run dev:hot`
- dev server: 复用 / 启动 `MNOTE_WEB_BIND=0.0.0.0:3000 MNOTE_WEB_PUBLIC_BIND=127.0.0.1:3000 cargo run --manifest-path rust/Cargo.toml -p mnote-web --bin mnote-web`
- 登录后访问 `http://127.0.0.1:3000/`
- `document.scripts` 包含 `/api/mnote-browser-runtime/filetree-runtime.js``/api/mnote-browser-runtime/filetree-context-menu-runtime.js`
- `window.__mnoteFileTreeRuntime.fileTreeRowLocalRelativePath``window.__mnoteFileTreeContextMenuRuntime.evaluateSidebarFileTreeWhen` 均为函数。
- 两个 runtime asset 均返回 `200``content-type=application/javascript; charset=utf-8`
- 截图证据:`p4-filetree-runtime-mounted.png`
### 9.8 Batch 7 执行记录(2026-05-25
Reasonix read-only audit
- run id: `reasonix-2026-05-24T16-25-22-379Z-3e3df753`
- worktree: `/mnt/Data1T/mnote-worktrees/p4-filetree-selection-audit`
- handoff: `/home/lix/.codex/runtime/reasonix-coding-worker/reasonix-2026-05-24T16-25-22-379Z-3e3df753/process-handoff.md`
- 类型:只读审计,主控已读取 Hindsight recall、`process-handoff.md`、工作区 diff 与验证结果。
- 采纳结论:主壳 filetree selection 应独立为 `filetree-selection-runtime.js`,不并入纯 accessor 的 `filetree-runtime.js`DND、clipboard、keydown listener 和菜单 DOM 仍留后续批次。
采纳内容:
- 新增 `rust/crates/mnote-web/browser/filetree-selection-runtime.js`
- 新增固定 asset route`GET /api/mnote-browser-runtime/filetree-selection-runtime.js`
- `PageLayout``SIDEBAR_TREE_JS` 前注入 `type="module"` 外置脚本。
- `SIDEBAR_TREE_JS` 中以下 helper 先做 runtime 代理,保留原实现 fallback
- `visibleFileTreeRows`
- `syncSidebarFileTreeSelection`
- `selectSidebarFileTreeRow`
- `selectedSidebarFileTreeRowIdsForDrag`
- `selectedSidebarFileTreeRows`
采纳边界:
- 本批次不迁移 click / contextmenu / keydown / dragstart / dragover / drop / dragend 事件监听器。
- 本批次不迁移 `pasteSidebarFileTreeClipboard``downloadSelectedFileTreeAssetRows``ensureFileTreeWritableTarget``deleteSelectedSidebarFileTreeRows`
- selection runtime 只承接选择状态同步、shift range、ctrl/meta toggle 和拖拽行 id 读取;文件树命令语义仍由主壳和 Rust API 合同决定。
已通过验证:
- `node --check rust/crates/mnote-web/browser/filetree-selection-runtime.js`
- `cargo fmt --manifest-path rust/Cargo.toml --all --check`
- `cargo check --manifest-path rust/Cargo.toml -p mnote-web`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_selection_runtime -- --test-threads=1`
- 结果:2 个 selection runtime 相关测试通过。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1`
- 结果:runtime asset route 测试通过。
- 真实浏览器轻量验收:
- dev server: `MNOTE_WEB_BIND=0.0.0.0:3000 MNOTE_WEB_PUBLIC_BIND=127.0.0.1:3000 cargo run --manifest-path rust/Cargo.toml -p mnote-web --bin mnote-web`
- 临时 local folder 入口 `sourceKind=local_folder&treeView=filetree`
- `/api/mnote-browser-runtime/filetree-selection-runtime.js` 返回 `200``content-type=application/javascript; charset=utf-8`
- `window.__mnoteFileTreeSelectionRuntime.selectSidebarFileTreeRow` 为函数。
- 对两个可见 filetree row 依次触发 click / ctrl-click 后,`data-selected="true"` 保留两行,`tree.filetree.selection.changed` 派发 2 次,最后事件包含两个 `selectedRowIds`
## 10. 分阶段执行清单
归档说明(2026-05-25):本节保留原始分阶段路线,但不再作为当前 `done/` 状态的未完成 checklist。已经满足归档条件的范围以第 13 节 Done Gate 为准;仍未实施或未专项验证的条目改为普通状态项,后续需要另起设计或 bug/checklist 承接,不能在本 done 文档中继续以未勾选 checklist 表达。
### P0:定位辅助与基线保护
- [ ] 记录当前 CodeGraph 对内嵌 JS 的限制,确认不更换 graph 项目。
- [ ] 可选新增 `scripts/extract-embedded-js-for-index.js`,生成临时索引文件,不进入 runtime。
- [ ] 先生成一份“热点函数归属表,把最近 bug 中高频搜索的函数归到待拆模块:
- 记录当前 CodeGraph 对内嵌 JS 的限制,确认不更换 graph 项目。
- 未采用:可选新增 `scripts/extract-embedded-js-for-index.js`,生成临时索引文件,不进入 runtime;本批通过真实模块外置优先解决定位问题
- 已记录:热点函数归属表,把最近 bug 中高频搜索的函数归到待拆模块:
- `openResource` / `openLocalOfficeFileInActiveTab` / `openEditorAttachmentEditTab` -> `resource-open-runtime.js`
- `executeLocalFileTreeExternalDrop` / `uploadFileToMediaAsset` / `insertUploadedAssetIntoEditor` -> `local-upload-runtime.js`
- `startWithWebSocket` / `startWithSseFallback` -> `tree-live-controller.js`
- `selectFileTreeRow` / `openFileTreeContextMenu` -> `filetree-selection-runtime.js` / `filetree-context-menu-runtime.js`
- `createEditorViewBinding` / `replacePaneDocument` -> `document-pane-host-runtime.js`,归 `03-rust-web``05-editor-mainline` 交界,需单独切片。
- [ ] `layout.rs` / `tree.rs` 中计划迁出的 script 增加最小字符串快照测试,防止迁移时漏挂载。
- [ ] 建立 smoke 基线:
- 后续:`layout.rs` / `tree.rs` 中计划迁出的 script 增加最小字符串快照测试,防止迁移时漏挂载。
- 基线候选:建立 smoke 基线:
- `node scripts/task487-local-folder-tree-live-consumer-smoke.js`
- `node scripts/task446-tree-rename-dual-browser-live-smoke.js`
- `node scripts/task472-side-target-secondary-pane-smoke.js`
@@ -615,20 +672,20 @@ Reasonix read-only audit
- [x] 新增 `rust/crates/mnote-web/browser/resource-open-runtime.js`
- [x] 新增 `rust/crates/mnote-web/browser/local-upload-runtime.js`,或先与 resource open 合并为单个不超过 800 行的 `resource-open-runtime.js`
- [x] 第一刀只迁 `layout.rs` 中 resource open 的纯函数和 bridge`web_shell.rs` 的 resource tab DOM、session、pane host 生命周期保持在后续 `document-pane-host-runtime.js` 批次。
- [ ] `SIDEBAR_TREE_JS` / `web_shell.rs` 中迁出资源打开路径适配,不改变现有 open intent
- 后续:`SIDEBAR_TREE_JS` / `web_shell.rs` 中迁出资源打开路径适配,不改变现有 open intent
- page row -> 当前 pane page tab
- filetree doc row -> Markdown page tab
- local Markdown asset -> active resource tab / secondary pane
- pdf/code -> 对应 lightweight viewer
- office -> OnlyOffice path builder
- [ ] `SIDEBAR_TREE_JS` 中迁出本地文件夹上传目标计算和 adapter 调用;资源归属语义仍由 Rust API / kernel 合同决定。
- 后续:`SIDEBAR_TREE_JS` 中迁出本地文件夹上传目标计算和 adapter 调用;资源归属语义仍由 Rust API / kernel 合同决定。
- 2026-05-24 / `reasonix-2026-05-24T15-17-16-430Z-18054487` 只读审计:`leptos-tiptap` 只应发出上传 intent;本地 Markdown 路径、`rootUri``/api/local-folder/files/open` URL、`markdownRelativePath` 与真实上传落盘都归本设计的 shell host runtime。
- 2026-05-24 / Batch 4 已迁:`resolveEditorUploadContext``editorRootFromUploadOptions``openEditorUploadFilePicker`,并保持调用 `uploadFilesWithResolvedTarget`,未同时迁上传主流程。
- 暂不迁:`uploadFileToMediaAsset``uploadFilesWithResolvedTarget``insertUploadedAssetIntoEditor`。这些函数同时涉及 API POST、file tree refresh、Tiptap chain 插入、OnlyOffice/resource tab,需在后续批次继续拆边界。
- [ ] 保持现有 smoke
- 已保留/需按后续切片复跑:现有 smoke
- `node scripts/task472-side-target-secondary-pane-smoke.js`
- `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`
- [ ] 验证 CodeGraph 或等价结构搜索能定位 `openResource``buildOnlyOfficeOpenPath``executeLocalFileTreeExternalDrop`
- 后续验证CodeGraph 或等价结构搜索能定位 `openResource``buildOnlyOfficeOpenPath``executeLocalFileTreeExternalDrop`
### P2:外置 `TREE_LIVE_CONTROLLER_JS`
@@ -639,8 +696,8 @@ Reasonix read-only audit
- [x] 浏览器断言:
- `document.documentElement.dataset.mnoteTreeLiveTransport` 正确。
- 外置 module 实际执行并设置 `window.__mnoteTreeLiveControllerStarted`
- [ ] WS 推送仍更新 page/filetree。
- [ ] WS 不可用时仍 fallback 到 SSE。
- 后续专项验证:WS 推送仍更新 page/filetree。
- 后续专项验证:WS 不可用时仍 fallback 到 SSE。
### P3:外置 debug `/tree` shell runtime bridge
@@ -653,13 +710,17 @@ Reasonix read-only audit
### P4:抽出主壳 filetree runtime
- [ ] `SIDEBAR_TREE_JS` 中拆出文件树选择、keyboard、clipboard、DND、context menu。
- 部分完成/后续:`SIDEBAR_TREE_JS` 中拆出文件树选择、keyboard、clipboard、DND、context menu。
- 2026-05-25 / Batch 6 已完成 context menu / filetree row helper 的首个外置切片。
- 2026-05-25 / Batch 7 已完成 filetree selection helper 的首个外置切片。
- 暂不迁:keyboard、clipboard、DND DOM 绑定、菜单 DOM、上传执行链。
- [x] 先拆纯函数和 reducer bridge,再拆 DOM 绑定。
- 2026-05-25 / Batch 6 已迁:filetree 行 accessor、local relative path、download detail、row lookup、child count wrapper,以及 context menu CommandContext / when evaluator。
- 暂不迁:selection、keyboard、clipboard、DND、菜单 DOM、上传执行链
- [ ] 主壳保留 `window.__mnoteSidebarTreeRuntimeStarted` 一类启动防重保护,但启动函数迁到外置模块
- [ ] 与 Rust `FileTreeSelectionState` 合同对齐,不在前端新增第二套选择真相
- [ ] 覆盖:
- 2026-05-25 / Batch 7 已迁:visible rows、selection sync、shift range、ctrl/meta toggle、drag row id helper、selected rows helper
- 暂不迁:keyboard listener、clipboard action、DND listener、菜单 DOM、上传执行链
- 后续:主壳保留 `window.__mnoteSidebarTreeRuntimeStarted` 一类启动防重保护,但启动函数迁到外置模块
- 后续审计:与 Rust `FileTreeSelectionState` 合同对齐,不在前端新增第二套选择真相。
- 后续覆盖:
- 多选、shift range、ctrl/meta toggle。
- cut/copy/paste。
- 内部拖拽 move/copy。
@@ -668,13 +729,13 @@ Reasonix read-only audit
### P5:抽出 document pane host adapter
- [ ] `web_shell.rs` 的 inline module 中拆出文档 pane host adapter,例如:
- 后续:`web_shell.rs` 的 inline module 中拆出文档 pane host adapter,例如:
- `document-pane-host-runtime.js`
- `document-session-runtime.js`
- `document-conflict-panel-runtime.js`
- [ ] 只迁 host/session/pane 生命周期,不迁 Tiptap 编辑器内部命令。
- [ ] `05-editor-mainline` 的编辑器 runtime 拆分保持边界:host 负责挂载、pane、session、资源 tabeditor 负责命令、history、附件链接、DOM overlay。
- [ ] 覆盖:
- 后续:只迁 host/session/pane 生命周期,不迁 Tiptap 编辑器内部命令。
- 后续:`05-editor-mainline` 的编辑器 runtime 拆分保持边界:host 负责挂载、pane、session、资源 tabeditor 负责命令、history、附件链接、DOM overlay。
- 后续覆盖:
- primary / secondary pane 切换
- conflict panel unmount 清理
- attachment tab 打开
@@ -682,11 +743,11 @@ Reasonix read-only audit
### P6:收尾与旧 inline script 瘦身
- [ ] `layout.rs` 中只保留小型 bootstrap 和脚本引用。
- [ ] `tree.rs` 中不再内嵌大型 runtime JS。
- [ ] `web_shell.rs` 中只保留 page aggregate/bootstrap JSON 和 module script 引用。
- [ ] 更新 CodeGraph 索引,确认新增 JS 模块被索引。
- [ ] 删除或降级 P0 提取脚本:如果真实模块已覆盖主要 runtime,提取脚本只保留为历史/临时工具
- 后续:`layout.rs` 中只保留小型 bootstrap 和脚本引用。
- 后续:`tree.rs` 中不再内嵌大型 runtime JS。
- 后续:`web_shell.rs` 中只保留 page aggregate/bootstrap JSON 和 module script 引用。
- 已完成:更新 CodeGraph 索引,确认新增 JS 模块被索引。
- 不适用:删除或降级 P0 提取脚本;本批未新增提取脚本
## 11. 测试与验收
@@ -756,11 +817,11 @@ node scripts/task487-local-folder-tree-live-consumer-smoke.js
- [x] `TREE_LIVE_CONTROLLER_JS` 已从 `layout.rs` 迁出。
- [x] debug `/tree` shell runtime 大块 JS 已从 `tree.rs` 迁出,或已有明确豁免说明。
- [ ] 主壳 filetree runtime 的核心 selection / DND / context menu 至少完成一个模块外置切片。
- [x] 主壳 filetree runtime 的核心 selection / DND / context menu 至少完成一个模块外置切片。
- [x] resource open / local upload adapter 至少完成一个高频 bug 模块外置切片。
- [x] 新增 JS runtime 文件能被 CodeGraph 索引。
- [ ] 至少一组 tree realtime smoke 和一组 local folder filetree/resource smoke 通过。
- [ ] `layout.rs` / `web_shell.rs` 不再继续新增大型 inline browser runtime。
- [x] 至少一组 tree realtime smoke 和一组 local folder filetree/resource smoke 通过。
- [x] `layout.rs` / `web_shell.rs` 不再继续新增大型 inline browser runtime。
## 14. 后续问题
@@ -94,9 +94,11 @@ rust/spikes/leptos-tiptap-spike/src/
## 7. 分阶段执行清单
归档说明(2026-05-25):本节保留原始分阶段路线,但不再作为当前 `done/` 状态的未完成 checklist。已经满足归档条件的范围以第 13 节 Done Gate 为准;仍未实施或被明确划到 `03-rust-web` / 后续编辑器切片的条目改为普通状态项,后续需要另起设计或 bug/checklist 承接,不能在本 done 文档中继续以未勾选 checklist 表达。
### P0:热点函数归属表和基线保护
- [ ] 列出当前 `src/lib.rs` 中最近 bug 高频函数的归属模块:
- 已记录:当前 `src/lib.rs` 中最近 bug 高频函数的归属模块:
- `selected_local_attachment_link_in_editor_state` -> `attachment_links.rs`
- `selected_local_attachment_link_text` -> `attachment_links.rs` / `dom_selection.rs`
- `sync_persisted_editor_command` -> `command_sync.rs`
@@ -105,8 +107,8 @@ rust/spikes/leptos-tiptap-spike/src/
- 2026-05-24`delete_top_level_block_html` 已删除,块菜单删除改走 `delete_top_level_block_with_history(...)` -> Tiptap `delete_range` / `insert_content_at`
- upload slash action / local asset path -> `attachment_upload.rs` / `local_markdown_paths.rs`
- toolbar/block menu DOM anchor -> `overlays.rs` / `block_menu.rs`
- [ ] 建立 history 风险清单所有用户编辑动作中仍调用 `set_content` 或 HTML 整文替换的路径必须登记。
- [ ] 保留当前已通过的 `scripts/task488-local-attachment-link-delete-undo-smoke.js` 作为 RED/GREEN 基线。
- 后续:建立 history 风险清单所有用户编辑动作中仍调用 `set_content` 或 HTML 整文替换的路径必须登记。
-保留当前已通过的 `scripts/task488-local-attachment-link-delete-undo-smoke.js` 作为 RED/GREEN 基线。
### P1:抽出附件链接和 history-safe delete
@@ -128,7 +130,7 @@ rust/spikes/leptos-tiptap-spike/src/
- `sync_editor_outputs`
- `sync_persisted_editor_command`
- `apply_document_update`(当前保持零调用者现状)
- [ ] 迁出 change/status event dispatch 的编辑器侧包装。
- 后续:迁出 change/status event dispatch 的编辑器侧包装。
- 2026-05-24 复核:`apply_html_update` 仍依赖 `ChangePayload` / `ChangeMetaPayload``dispatch_change_event``dispatch_runtime_state``HoveredBlockState` 和 toolbar overlay state,暂留 `lib.rs`,等待 P4 `bridge_events.rs` / `overlays.rs` 后再迁。
- [x] 明确 `set_content` 只允许用于:
- 初始加载
@@ -141,25 +143,25 @@ rust/spikes/leptos-tiptap-spike/src/
### P3:抽出附件上传与本地 Markdown 路径
- [x] 新增 `editor_runtime/attachment_upload.rs`
- [ ] 新增 `editor_runtime/local_markdown_paths.rs`
- 不纳入本设计当前归档范围:新增 `editor_runtime/local_markdown_paths.rs`
- 2026-05-24 复核:`local_markdown_paths.rs` 不应落在 `leptos-tiptap` 内部;本地 Markdown 路径、`rootUri``/api/local-folder/files/open` URL 和上传落盘归 `03-rust-web` 的 shell host runtime / `local-upload-runtime.js`
- [x] 迁出 slash 上传附件、图片的编辑器 intent。
- `dispatch_editor_upload_request(kind, accept)` 已迁入 `attachment_upload.rs`
- `SlashActionKind::Image` / `UploadAttachment` 仍留在 slash menu 基础结构中,只调用 editor intent。
- [ ] 迁出 Office/PDF/code 文件的编辑器内插入逻辑。
- 不纳入本设计当前归档范围:迁出 Office/PDF/code 文件的编辑器内插入逻辑。
- 当前实际插入链路在 `layout.rs` / `local-upload-runtime.js`,不是 editor runtime;后续由 `03-rust-web` 继续收口。
- [x] 明确 editor 只负责发起上传 intent;资源保存位置和归属仍由 shell/Rust API 合同决定。
- [ ] 覆盖:
- 后续覆盖:
- 上传第一个/第二个附件后链接仍可点击。
- 刷新后链接不退化为普通外链。
- secondary pane 上传与 primary pane 隔离。
### P4:抽出 DOM bridge / overlay / block menu
- [ ] 新增 `editor_runtime/dom_selection.rs``overlays.rs``block_menu.rs`
- [ ] 迁出 selection、toolbar anchor、slash menu anchor、block menu anchor、image/table toolbar anchor。
- [ ] 对所有 keydown/click 捕获建立“谁先处理、谁 stop propagation”的规则,避免 Tiptap 原生快捷键与外层 window listener 互相吞事件。
- [ ] 覆盖:
- 后续:新增 `editor_runtime/dom_selection.rs``overlays.rs``block_menu.rs`
- 后续:迁出 selection、toolbar anchor、slash menu anchor、block menu anchor、image/table toolbar anchor。
- 后续:对所有 keydown/click 捕获建立“谁先处理、谁 stop propagation”的规则,避免 Tiptap 原生快捷键与外层 window listener 互相吞事件。
- 后续覆盖:
- `Ctrl+Z/Ctrl+Y`
- slash menu
- block menu
@@ -168,19 +170,19 @@ rust/spikes/leptos-tiptap-spike/src/
### P5:抽出 mindmap node view
- [ ] 新增 `editor_runtime/mindmap_node_view.rs`
- [ ] 迁出 mindmap block 的 node view、runtime mount、projection refresh、simplemindmap event bridge。
- [ ] 保持 mindmap 只是编辑器块视图,不成为正文事实源。
- 后续:新增 `editor_runtime/mindmap_node_view.rs`
- 后续:迁出 mindmap block 的 node view、runtime mount、projection refresh、simplemindmap event bridge。
- 后续:保持 mindmap 只是编辑器块视图,不成为正文事实源。
### P6:收尾
- [ ] `src/lib.rs` 只保留:
- 后续:`src/lib.rs` 只保留:
- module imports
- top-level component装配
- 少量常量和 view glue
- [ ] 单个模块超过约 800 行时继续拆分。
- [ ] 运行 CodeGraph sync/index,确认新增 Rust 模块被索引。
- [ ] 更新 worker skill/task template编辑器 bug 默认先查 `editor_runtime/*`,再回到 `lib.rs`
- 后续:单个模块超过约 800 行时继续拆分。
- 已完成:运行 CodeGraph sync/index,确认新增 Rust 模块被索引。
- 后续:更新 worker skill/task template编辑器 bug 默认先查 `editor_runtime/*`,再回到 `lib.rs`
## 8. History 安全规则
@@ -437,6 +439,37 @@ Reasonix read-only audit
- 块手柄拖拽重排和复制副本仍使用 HTML 整文替换,继续登记为后续 history-safe command 切片;本设计 done gate 只要求至少一个已登记用户编辑路径完成迁移。
### 10.6 后续 P4 只读审计补充(2026-05-25
Reasonix read-only audit
- run id: `reasonix-2026-05-24T16-25-22-430Z-3a9a1a82`
- worktree: `/mnt/Data1T/mnote-worktrees/5-28-dom-bridge-audit`
- handoff: `/home/lix/.codex/runtime/reasonix-coding-worker/reasonix-2026-05-24T16-25-22-430Z-3a9a1a82/process-handoff.md`
- 类型:只读审计,主控已读取 Hindsight recall 与 `process-handoff.md`
可采纳结论:
- 后续 P4 第一刀应优先新增 `editor_runtime/dom_selection.rs`,而不是直接抽 `block_menu.rs`
- 第一刀候选函数:
- `active_editor_text_selection`
- `selection_summary`
- `has_active_text_selection`
- `selection_has_rich_marks`
- `selection_payload` / selection event payload 构造
- `send_selection_state` / `send_selection_state_to_target`
- `block_menu.rs` 暂不先做:当前 block menu / overlay 相关函数仍强依赖大量 `ReadSignal` / `WriteSignal`,在合并 overlay state 或先迁出 `HoveredBlockState` 前,直接抽模块只会把长参数耦合搬到新文件。
- `overlays.rs` 可作为 `dom_selection.rs` 之后的第二刀;`apply_html_update` 仍等 `bridge_events.rs` / overlay state 收口后再迁。
建议验证:
- `cargo fmt --manifest-path rust/Cargo.toml --all --check`
- `cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml`
- `cargo build --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml --target wasm32-unknown-unknown --release`
- `wasm-bindgen rust/spikes/leptos-tiptap-spike/target/wasm32-unknown-unknown/release/mnote_leptos_tiptap_spike.wasm --target web --out-dir rust/spikes/leptos-tiptap-spike/generated/island --out-name mnote-leptos-tiptap-spike-island`
- `node scripts/task488-local-attachment-link-delete-undo-smoke.js`
- `node scripts/task489-block-menu-delete-undo-smoke.js`
## 11. 测试与验收
### Rust 检查
@@ -0,0 +1,110 @@
// MNote 主壳文件树选择运行时外置模块。
// 当前只承接 selection 状态同步和选择算法;事件监听、DND 执行和命令分发仍留在主壳。
function cssEscapeValue(value) {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
return String(value).replace(/["\\]/g, '\\$&');
}
function visibleFileTreeRows(deps) {
if (deps && typeof deps.visibleFileTreeRows === 'function') {
return deps.visibleFileTreeRows();
}
return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
.filter(function(row) {
if (!(row instanceof HTMLElement)) return false;
if (row.closest('.tree-children--collapsed')) return false;
return row.offsetParent !== null || row.getClientRects().length > 0;
});
}
function selectedRowIdSet(selection) {
var selected = selection && selection.selectedRowIds;
if (selected instanceof Set) return new Set(selected);
if (Array.isArray(selected)) return new Set(selected);
return new Set();
}
function syncSidebarFileTreeSelection(selection, deps) {
var state = selection || {};
var selected = selectedRowIdSet(state);
var focusedRowId = String(state.focusedRowId || '');
visibleFileTreeRows(deps).forEach(function(row) {
var rowId = row.getAttribute('data-row-id') || '';
row.setAttribute('data-selected', String(Boolean(rowId && selected.has(rowId))));
row.setAttribute('data-focused', String(rowId === focusedRowId));
});
var detail = {
selectedRowIds: Array.from(selected),
anchorRowId: state.anchorRowId || null,
focusedRowId: state.focusedRowId || null
};
window.dispatchEvent(new CustomEvent('tree.filetree.selection.changed', { detail: detail }));
return detail;
}
function selectSidebarFileTreeRow(row, selection, modifiers, deps) {
if (!(row instanceof HTMLElement)) return [];
var rowId = row.getAttribute('data-row-id') || '';
if (!rowId) return [];
var state = selection || {};
var rows = visibleFileTreeRows(deps);
var visibleRowIds = rows.map(function(item) { return item.getAttribute('data-row-id') || ''; }).filter(Boolean);
var selected = selectedRowIdSet(state);
var shiftKey = Boolean(modifiers && modifiers.shiftKey);
var ctrlKey = Boolean(modifiers && (modifiers.ctrlKey || modifiers.metaKey));
if (shiftKey && state.anchorRowId) {
var anchorIndex = visibleRowIds.indexOf(state.anchorRowId);
var targetIndex = visibleRowIds.indexOf(rowId);
if (anchorIndex >= 0 && targetIndex >= 0) {
selected = new Set(visibleRowIds.slice(Math.min(anchorIndex, targetIndex), Math.max(anchorIndex, targetIndex) + 1));
} else {
selected = new Set([rowId]);
state.anchorRowId = rowId;
}
} else if (ctrlKey) {
if (selected.has(rowId) && selected.size > 1) selected.delete(rowId);
else selected.add(rowId);
state.anchorRowId = rowId;
} else {
selected = new Set([rowId]);
state.anchorRowId = rowId;
}
state.selectedRowIds = selected;
state.focusedRowId = rowId;
syncSidebarFileTreeSelection(state, deps);
return Array.from(selected);
}
function selectedSidebarFileTreeRowIdsForDrag(row, selection) {
var rowId = row instanceof HTMLElement ? row.getAttribute('data-row-id') || '' : '';
var selected = selectedRowIdSet(selection || {});
if (rowId && selected.has(rowId)) return Array.from(selected);
return rowId ? [rowId] : [];
}
function selectedSidebarFileTreeRows(selection, deps) {
var state = selection || {};
var selectedIds = selectedRowIdSet(state);
var rows = visibleFileTreeRows(deps).filter(function(row) {
var rowId = row.getAttribute('data-row-id') || '';
return rowId && selectedIds.has(rowId);
});
if (rows.length > 0) return rows;
if (state.focusedRowId) {
var escape = deps && typeof deps.cssEscape === 'function' ? deps.cssEscape : cssEscapeValue;
var focused = document.querySelector(
'#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + escape(state.focusedRowId) + '"]'
);
if (focused instanceof HTMLElement) return [focused];
}
return [];
}
window.__mnoteFileTreeSelectionRuntime = {
visibleFileTreeRows: visibleFileTreeRows,
syncSidebarFileTreeSelection: syncSidebarFileTreeSelection,
selectSidebarFileTreeRow: selectSidebarFileTreeRow,
selectedSidebarFileTreeRowIdsForDrag: selectedSidebarFileTreeRowIdsForDrag,
selectedSidebarFileTreeRows: selectedSidebarFileTreeRows
};
+5
View File
@@ -106,6 +106,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/filetree-runtime.js",
get(web_shell::filetree_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/filetree-selection-runtime.js",
get(web_shell::filetree_selection_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/filetree-context-menu-runtime.js",
get(web_shell::filetree_context_menu_runtime_asset),
@@ -522,6 +526,7 @@ mod tests {
"/api/mnote-browser-runtime/resource-open-runtime.js",
"/api/mnote-browser-runtime/local-upload-runtime.js",
"/api/mnote-browser-runtime/filetree-runtime.js",
"/api/mnote-browser-runtime/filetree-selection-runtime.js",
"/api/mnote-browser-runtime/filetree-context-menu-runtime.js",
"/api/mnote-browser-runtime/tree-live-controller.js",
"/api/mnote-browser-runtime/tree-shell-runtime.js",
@@ -4423,6 +4423,20 @@ pub async fn filetree_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn filetree_selection_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/filetree-selection-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn tree_live_controller_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-live-controller.js");
Response::builder()
@@ -3162,6 +3162,18 @@ const SIDEBAR_TREE_JS: &str = r##"
};
}
function fileTreeSelectionRuntimeFunction(name) {
var runtime = window.__mnoteFileTreeSelectionRuntime;
var fn = runtime && runtime[name];
return typeof fn === 'function' ? fn : null;
}
function fileTreeSelectionRuntimeDeps() {
return {
cssEscape: cssEscape
};
}
function uploadedAssetTitle(asset) {
var runtimeFn = localUploadRuntimeFunction('uploadedAssetTitle');
if (runtimeFn) return runtimeFn(asset);
@@ -4946,6 +4958,8 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function visibleFileTreeRows() {
var runtimeFn = fileTreeSelectionRuntimeFunction('visibleFileTreeRows');
if (runtimeFn) return runtimeFn(fileTreeSelectionRuntimeDeps());
return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
.filter(function(row) {
if (!(row instanceof HTMLElement)) return false;
@@ -4955,6 +4969,11 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function syncSidebarFileTreeSelection() {
var runtimeFn = fileTreeSelectionRuntimeFunction('syncSidebarFileTreeSelection');
if (runtimeFn) {
runtimeFn(sidebarFileTreeSelection, fileTreeSelectionRuntimeDeps());
return;
}
var rows = visibleFileTreeRows();
rows.forEach(function(row) {
var rowId = row.getAttribute('data-row-id') || '';
@@ -4971,6 +4990,8 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function selectSidebarFileTreeRow(row, modifiers) {
var runtimeFn = fileTreeSelectionRuntimeFunction('selectSidebarFileTreeRow');
if (runtimeFn) return runtimeFn(row, sidebarFileTreeSelection, modifiers, fileTreeSelectionRuntimeDeps());
if (!(row instanceof HTMLElement)) return [];
var rowId = row.getAttribute('data-row-id') || '';
if (!rowId) return [];
@@ -5110,6 +5131,8 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function selectedSidebarFileTreeRowIdsForDrag(row) {
var runtimeFn = fileTreeSelectionRuntimeFunction('selectedSidebarFileTreeRowIdsForDrag');
if (runtimeFn) return runtimeFn(row, sidebarFileTreeSelection);
var rowId = row instanceof HTMLElement ? row.getAttribute('data-row-id') || '' : '';
if (rowId && sidebarFileTreeSelection.selectedRowIds.has(rowId)) {
return Array.from(sidebarFileTreeSelection.selectedRowIds);
@@ -5118,6 +5141,8 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function selectedSidebarFileTreeRows() {
var runtimeFn = fileTreeSelectionRuntimeFunction('selectedSidebarFileTreeRows');
if (runtimeFn) return runtimeFn(sidebarFileTreeSelection, fileTreeSelectionRuntimeDeps());
var selectedIds = sidebarFileTreeSelection.selectedRowIds;
var rows = visibleFileTreeRows().filter(function(row) {
var rowId = row.getAttribute('data-row-id') || '';
@@ -10538,6 +10563,7 @@ pub fn PageLayout(
<script type="module" src="/api/mnote-browser-runtime/resource-open-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/local-upload-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/filetree-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/filetree-selection-runtime.js"></script>
<script type="module" src="/api/mnote-browser-runtime/filetree-context-menu-runtime.js"></script>
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
<script type="module" src="/api/mnote-browser-runtime/tree-live-controller.js"></script>
@@ -10582,6 +10608,8 @@ mod tests {
const FILETREE_CONTEXT_MENU_RUNTIME_JS: &str =
include_str!("../../../browser/filetree-context-menu-runtime.js");
const FILETREE_RUNTIME_JS: &str = include_str!("../../../browser/filetree-runtime.js");
const FILETREE_SELECTION_RUNTIME_JS: &str =
include_str!("../../../browser/filetree-selection-runtime.js");
const TREE_LIVE_CONTROLLER_JS: &str = include_str!("../../../browser/tree-live-controller.js");
fn js_function_body(source: &str, name: &str) -> String {
@@ -10937,6 +10965,40 @@ mod tests {
assert!(FILETREE_RUNTIME_JS.contains("function fileTreeChildCount"));
}
#[test]
fn sidebar_filetree_selection_runtime_helpers_are_externalized_with_inline_fallback() {
assert!(SIDEBAR_TREE_JS.contains("function fileTreeSelectionRuntimeFunction"));
assert!(SIDEBAR_TREE_JS.contains("window.__mnoteFileTreeSelectionRuntime"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeSelectionRuntimeFunction('visibleFileTreeRows')"));
assert!(SIDEBAR_TREE_JS
.contains("fileTreeSelectionRuntimeFunction('syncSidebarFileTreeSelection')"));
assert!(SIDEBAR_TREE_JS
.contains("fileTreeSelectionRuntimeFunction('selectSidebarFileTreeRow')"));
assert!(SIDEBAR_TREE_JS
.contains("fileTreeSelectionRuntimeFunction('selectedSidebarFileTreeRowIdsForDrag')"));
assert!(SIDEBAR_TREE_JS
.contains("fileTreeSelectionRuntimeFunction('selectedSidebarFileTreeRows')"));
let select_body = js_function_body(SIDEBAR_TREE_JS, "selectSidebarFileTreeRow");
assert!(
select_body.contains("shiftKey && sidebarFileTreeSelection.anchorRowId")
&& select_body.contains("ctrlKey"),
"inline fallback 必须保留 shift range 与 ctrl/meta toggle"
);
}
#[test]
fn filetree_selection_runtime_contains_selection_helpers() {
assert!(FILETREE_SELECTION_RUNTIME_JS.contains("window.__mnoteFileTreeSelectionRuntime"));
assert!(FILETREE_SELECTION_RUNTIME_JS.contains("function visibleFileTreeRows"));
assert!(FILETREE_SELECTION_RUNTIME_JS.contains("function syncSidebarFileTreeSelection"));
assert!(FILETREE_SELECTION_RUNTIME_JS.contains("function selectSidebarFileTreeRow"));
assert!(
FILETREE_SELECTION_RUNTIME_JS.contains("function selectedSidebarFileTreeRowIdsForDrag")
);
assert!(FILETREE_SELECTION_RUNTIME_JS.contains("function selectedSidebarFileTreeRows"));
assert!(FILETREE_SELECTION_RUNTIME_JS.contains("tree.filetree.selection.changed"));
}
#[test]
fn sidebar_tree_runtime_focuses_restored_local_folder_row() {
assert!(SIDEBAR_TREE_JS.contains("applyPendingLocalFolderRestoreFocusOnce"));