From 111a87d4fd0cda24e42b8aa61131a343c6de3e2d Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Sat, 18 Apr 2026 09:38:16 +0800 Subject: [PATCH] feat: complete tree shell cutover and regression coverage --- .../tree-first-graph-kernel-checklist-v2.md | 59 +- harness-progress.txt | 55 + harness-tasks.json | 619 +++++++- rust/Cargo.lock | 1297 ++++++++++++++++- rust/crates/bridge-runtime/src/lib.rs | 861 ++++++++++- rust/crates/core-protocol/src/kernel.rs | 118 ++ rust/crates/core-protocol/src/lib.rs | 6 +- rust/crates/mnote-web/Cargo.toml | 1 + rust/crates/mnote-web/src/context.rs | 2 + rust/crates/mnote-web/src/lib.rs | 1 + .../src/middleware/request_context.rs | 7 +- .../mnote-web/src/routes/command_support.rs | 17 +- rust/crates/mnote-web/src/routes/compat.rs | 9 +- rust/crates/mnote-web/src/routes/kernel.rs | 211 ++- rust/crates/mnote-web/src/routes/mod.rs | 6 + .../mnote-web/src/routes/query_support.rs | 3 +- rust/crates/mnote-web/src/routes/tree.rs | 290 +++- rust/crates/mnote-web/src/routes/ws.rs | 16 +- rust/crates/mnote-web/src/transport/convex.rs | 136 +- .../mnote-web/src/tree_shell/dispatcher.rs | 39 + .../src/tree_shell/filetree_renderer.rs | 21 + .../crates/mnote-web/src/tree_shell/loader.rs | 22 + rust/crates/mnote-web/src/tree_shell/mod.rs | 54 + .../mnote-web/src/tree_shell/page_renderer.rs | 66 + .../src/tree_shell/picker_renderer.rs | 59 + .../mnote-web/src/tree_shell/protocol.rs | 14 + rust/crates/mnote-web/src/tree_shell/state.rs | 75 + .../storage-convex-bridge/src/mapping.rs | 8 + scripts/task091-tree-shell-smoke.js | 1 - scripts/task101-tree-shell-cutover-smoke.js | 158 ++ scripts/task102-tree-shell-regression.js | 188 +++ scripts/tree-shell-smoke-helpers.js | 183 +++ .../src/app/api/auth/mnote-web-token/route.ts | 35 + .../move-embed-picker-dialog.test.tsx | 134 ++ .../documents/move-embed-picker-dialog.tsx | 101 +- .../components/sidebar/MnoteWebTreeShell.tsx | 33 +- .../src/components/sidebar/file-tree.tsx | 15 + .../src/components/sidebar/private-tree.tsx | 10 +- .../src/components/sidebar/sidebar.tsx | 366 ++--- .../components/sidebar/tree-pane-bindings.ts | 15 + .../components/sidebar/tree-shell-surface.tsx | 150 ++ .../lib/documents/tree-command-client.test.ts | 54 + .../src/lib/documents/tree-command-client.ts | 35 + wolai-frontend/src/lib/kernel-sidebar.ts | 38 + wolai-frontend/src/lib/mnote-web-auth.ts | 12 + wolai-frontend/src/lib/sidebar-data.test.ts | 65 +- wolai-frontend/src/lib/sidebar-data.ts | 6 + .../src/lib/tree-projection-contract.test.ts | 39 + wolai-frontend/src/lib/tree-projection.ts | 42 +- wolai-frontend/src/lib/tree-protocol.test.ts | 33 + wolai-frontend/src/lib/tree-protocol.ts | 76 + .../src/lib/tree-stream/tree-delta.test.ts | 43 + .../tree-stream/use-sidebar-tree-stream.ts | 52 +- 53 files changed, 5440 insertions(+), 516 deletions(-) create mode 100644 rust/crates/mnote-web/src/tree_shell/dispatcher.rs create mode 100644 rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs create mode 100644 rust/crates/mnote-web/src/tree_shell/loader.rs create mode 100644 rust/crates/mnote-web/src/tree_shell/mod.rs create mode 100644 rust/crates/mnote-web/src/tree_shell/page_renderer.rs create mode 100644 rust/crates/mnote-web/src/tree_shell/picker_renderer.rs create mode 100644 rust/crates/mnote-web/src/tree_shell/protocol.rs create mode 100644 rust/crates/mnote-web/src/tree_shell/state.rs create mode 100644 scripts/task101-tree-shell-cutover-smoke.js create mode 100644 scripts/task102-tree-shell-regression.js create mode 100644 scripts/tree-shell-smoke-helpers.js create mode 100644 wolai-frontend/src/app/api/auth/mnote-web-token/route.ts create mode 100644 wolai-frontend/src/components/documents/move-embed-picker-dialog.test.tsx create mode 100644 wolai-frontend/src/components/sidebar/tree-pane-bindings.ts create mode 100644 wolai-frontend/src/components/sidebar/tree-shell-surface.tsx create mode 100644 wolai-frontend/src/lib/documents/tree-command-client.test.ts create mode 100644 wolai-frontend/src/lib/mnote-web-auth.ts create mode 100644 wolai-frontend/src/lib/tree-projection-contract.test.ts create mode 100644 wolai-frontend/src/lib/tree-protocol.test.ts create mode 100644 wolai-frontend/src/lib/tree-protocol.ts diff --git a/design/tree-first-graph-kernel-checklist-v2.md b/design/tree-first-graph-kernel-checklist-v2.md index 1bedac2a..d5bc4141 100644 --- a/design/tree-first-graph-kernel-checklist-v2.md +++ b/design/tree-first-graph-kernel-checklist-v2.md @@ -64,6 +64,23 @@ > **Kernel 基础层与 Rust Web 承载层已经进入真实主线,树域 consumer 已统一到稳定 projection family;但旧前端壳仍然是主要执行面,下一步才是独立 Rust Web tree shell 重构。** +### 面向“全部用 Rust 重写”的附加口径 + +如果把长期总目标进一步固定为: + +> **主执行面最终也迁到 Rust 家族,而不只是“Rust 拥有 kernel 语义”。** + +那么当前状态应再补一层判断: + +- `Kernel Phase 0-4` 解决的是“树域真相和协议先收口” +- 但距离“主要 UI 执行面改由 Rust 承接”还差一个关键中段: + - `Sidebar / 页面树 / 文件树` 独立 Rust Web 子系统重构 +- 这一步不是可选优化,而是旧前端壳退场前必须先完成的第一块主执行面切换 + +也就是说: + +> **如果目标是“全部用 Rust 重写”,那么当前最优先的下一步,不是继续在旧 React 壳里打补丁,而是启动树域独立 Rust Web 重构。** + --- ## 4. 新架构的真实验收定义 @@ -272,6 +289,23 @@ - 继续缩小 Sidebar 超大客户端壳,只保留局部交互岛 - 让文件树中的更宽对象投影逐步由 Rust projection 直接输出 +### 面向“全部用 Rust 重写”的优先级解释 + +如果长期目标只是“Rust 持有语义”,这里可以被理解为下一批独立大任务。 + +但如果长期目标已经固定为“全部用 Rust 重写”,那么这里应升级为: + +- **最近主线 P0** +- **旧前端壳的第一块正式替换带** +- **后续阅读页 / 搜索 / AI / Mindmap Rust 化之前的必经步骤** + +原因是: + +- 树域是工作区主导航与对象结构入口,替换价值最高 +- `page_tree / file_tree` 协议已经冻结,返工风险最低 +- 当前 Sidebar 仍是旧前端壳里最重、最容易继续扩散语义的一块 +- 如果不先把树域执行面剥离出来,后续文档页、搜索页、导图页 Rust 化会继续被旧壳牵制 + ### 完成判定 - [x] 主 Sidebar 以及相关树域已直接消费 kernel projection @@ -491,19 +525,22 @@ 如果按当前真实代码继续推进,建议顺序是: -1. 完成 `Kernel Phase 4` -2. 基于稳定 tree protocol,启动树域独立重构: +1. 维持 `Kernel Phase 4` 已完成口径,不再回头重做 consumer 统一 +2. 立即启动 `Phase 4` 后继任务: - [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md) -3. 同步启动 `Kernel Phase 5` -4. 再推进 `Kernel Phase 6` -5. 再推进 `Kernel Phase 7` -6. 最后才进入 `Kernel Phase 8` 和 `Kernel Phase 9` +3. 在树域 Rust Web 子系统形成稳定主链后,再继续把文档阅读页的 `page subtree / read_view` 下沉到 Rust 输出边界 +4. 再推进 `Kernel Phase 5` +5. 再推进 `Kernel Phase 6` +6. 再推进 `Kernel Phase 7` +7. 最后进入 `Kernel Phase 8` 和 `Kernel Phase 9` 原因很简单: -- 如果 Sidebar / 页面树 / 文件树还没切到统一 kernel projection,树域 Rust Web 重写会在协议未冻结前重复返工 -- 如果 Sidebar / 页面树还没切到 kernel,工作区主导航就还没换真相层 -- 如果知识刷新与 kernel-aware 检索不成立,AI 和 BookMindmap 路线也无法形成长期闭环 +- 旧 `Phase 4` 的目标已经完成,真正未完成的是“树域执行面 Rust 化” +- 树域是所有主页面里最先具备 Rust 化条件的一块,因为 projection / command protocol 已经先被冻结 +- 文档阅读页、搜索、AI、Mindmap 后续都会依赖这条更稳定的树域与 projection 分发主链 +- 如果继续把精力分散到旧 React 壳上的局部修补,会拖慢真正的执行面切换 +- 知识刷新与 kernel-aware 检索仍然重要,但它们更适合在树域主承载面开始切换后并行推进,而不是抢在前面替代主导航改造 --- @@ -516,3 +553,7 @@ 所以后续主线必须固定为: > **先完成树域的 kernel projection 统一,再基于稳定协议做 Sidebar / 页面树 / 文件树的独立 Rust Web 重构;之后再并行推进结构知识刷新、Mindmap、阅读页、AI、`BlockNote` 与旧壳退场。** + +如果把总目标进一步固定为“全部用 Rust 重写”,则这里还应再明确一句: + +> **下一步应该继续的,不是旧定义里的 `Kernel Phase 4` 本身,而是它的后继任务,也就是 `Sidebar / 页面树 / 文件树 Rust Web 重构`;这是旧前端壳退场前最值得优先拿下的第一块执行面。** diff --git a/harness-progress.txt b/harness-progress.txt index be457d8e..32294984 100644 --- a/harness-progress.txt +++ b/harness-progress.txt @@ -69,3 +69,58 @@ [2026-04-17T16:36:54Z] [SESSION-3] Completed [task-020] (commit skipped by repo rule; base=aa6ee384) [2026-04-17T16:36:54Z] [SESSION-3] STATS tasks_total=20 completed=20 failed=0 pending=0 blocked=0 attempts_total=11 checkpoints=17 [2026-04-17T16:36:54Z] [SESSION-3] LOCK released +[2026-04-17T23:01:36Z] [SESSION-4] LOCK acquired (pid=codex) +[2026-04-17T23:01:36Z] [SESSION-4] INIT Harness session started for sidebar/page/filetree rust web rebuild batch task-021..task-041 +[2026-04-17T23:01:36Z] [SESSION-4] Starting [task-021] 执行 TreeShell Phase A-1:产出共享 tree projection contract 文档,冻结 sidebar_tree/page_tree/file_tree 共用字段与差异字段 (base=f1c1bcf0) +[2026-04-17T23:18:26Z] [SESSION-4] CHECKPOINT [task-021] step=1/1 "已产出共享 tree projection contract 文档,并冻结 sidebar_tree/page_tree/file_tree 的共享字段、row_id 与 resource_meta 口径。" +[2026-04-17T23:18:26Z] [SESSION-4] Completed [task-021] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-17T23:18:26Z] [SESSION-4] Starting [task-022] 执行 TreeShell Phase A-2:落地前后端共享 projection 类型,并冻结 file_tree 扩展字段与 capability/icon/resource 语义 (base=f1c1bcf0) +[2026-04-17T23:18:30Z] [SESSION-4] CHECKPOINT [task-022] step=1/1 "已补齐前后端共享 projection 类型与 file_tree 扩展字段,并跑通 core-protocol 与 tree-protocol 目标测试。" +[2026-04-17T23:18:30Z] [SESSION-4] Completed [task-022] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-17T23:18:30Z] [SESSION-4] Starting [task-023] 执行 TreeShell Phase A-3:固定树域 command protocol 长期命名面,并给出 documents.* 到 tree.* 的兼容迁移文档 (base=f1c1bcf0) +[2026-04-17T23:18:34Z] [SESSION-4] CHECKPOINT [task-023] step=1/1 "已冻结 tree.* 长期命名面,并把 documents.* 到 tree.* 的兼容映射与 cutover 顺序写入设计文档。" +[2026-04-17T23:18:34Z] [SESSION-4] Completed [task-023] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-17T23:18:34Z] [SESSION-4] Starting [task-024] 执行 TreeShell Phase A-4:明确 projection 状态与本地 UI 状态边界,并形成 Rust/前端共享的状态约束文档 (base=f1c1bcf0) +[2026-04-17T23:18:38Z] [SESSION-4] CHECKPOINT [task-024] step=1/1 "已固定 projection truth 与本地 UI state 的边界,并锁定 expanded、selected、focus、dragging、drop target 不进入 projection。" +[2026-04-17T23:18:38Z] [SESSION-4] Completed [task-024] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-17T23:18:38Z] [SESSION-4] Starting [task-025] 执行 TreeShell Phase A-5:补齐 projection/command protocol fixtures 与 contract tests,锁死后续 Phase B-D 的协议基线 (base=f1c1bcf0) +[2026-04-17T23:18:42Z] [SESSION-4] CHECKPOINT [task-025] step=1/1 "已新增 projection/command protocol contract tests,并跑通前端 fixtures 与 mnote-web tree_projection_contract 验证。" +[2026-04-17T23:18:42Z] [SESSION-4] Completed [task-025] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-17T23:18:42Z] [SESSION-4] Starting [task-026] 执行 TreeShell Phase B-1:在 mnote-web 补正式 sidebar_tree/page_tree/file_tree projection route 族,退出“只靠 kernel sidebar 兼容入口”阶段 (base=f1c1bcf0) +[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-026] step=1/1 "已补齐 mnote-web 的 sidebar/page/file projection route 族,并重新验证 tree projection routes。" +[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-026] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-027] step=1/1 "Rust 已直接输出 file_tree projection 的 document/index/asset 行语义,前端 rows 测试通过。" +[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-027] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-028] step=1/1 "Rust file_tree mapper 已扩展到 book/pdf/index/iconHint/capabilities,并验证资源变体测试。" +[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-028] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-029] step=1/1 "树域 command 主协议已切到 tree.*,同时保留 documents.* alias 与共享 client 回归通过。" +[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-029] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-030] step=1/1 "树域 route 契约已补齐 trace/workspace/auth 边界,tree_route_contracts 测试通过。" +[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-030] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-031] step=1/1 "Next 树域主路径已收口为 transport/compat,eslint 通过且 buildDocumentTree 主路径检查通过。" +[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-031] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-032] step=1/1 "mnote-web 已接入 Leptos tree shell scaffold,tree_shell 模块与 loader/dispatcher/state 骨架测试通过。" +[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-032] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-033] step=1/1 "page_tree renderer 与真实网页 smoke 已跑通;task091 创建/重命名/移动/导航链路通过。" +[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-033] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-034] step=1/1 "selection/focus/keyboard/dragging 的局部 UI state 已落地并通过 Rust 测试与文档检查。" +[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-034] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-035] step=1/1 "正式 tree shell 的 filetree/picker 轻量模式已补齐,新增 move-embed-picker-dialog 前端测试并通过。" +[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-035] (commit skipped by repo rule; base=f1c1bcf0) +[2026-04-18T00:38:02Z] [SESSION-4] Starting [task-036] 执行 TreeShell Phase C-5:去掉主路径对 iframe/postMessage 实验壳的依赖,把旧 tree shell 降回 compat/debug 用途 (base=f1c1bcf0) +[2026-04-18T01:37:10Z] [SESSION-5] LOCK acquired (pid=codex) +[2026-04-18T01:37:10Z] [SESSION-5] INIT Resumed harness for task-036..task-041 final integration and verification +[2026-04-18T01:37:10Z] [SESSION-5] CHECKPOINT [task-036] step=1/1 "已从 sidebar.tsx 主路径移除 iframe/postMessage 实验壳依赖,Sidebar 主树回到统一 surface。" +[2026-04-18T01:37:10Z] [SESSION-5] Completed [task-036] (commit skipped by repo rule) +[2026-04-18T01:37:10Z] [SESSION-5] CHECKPOINT [task-037] step=1/1 "已完成 Sidebar 主树默认切流与 fallback 护栏,task101 网页 smoke 通过。" +[2026-04-18T01:37:10Z] [SESSION-5] Completed [task-037] (commit skipped by repo rule) +[2026-04-18T01:37:10Z] [SESSION-5] CHECKPOINT [task-038] step=1/1 "已完成 page/filetree/picker 默认切流与高频回归,task102 与前端测试通过。" +[2026-04-18T01:37:10Z] [SESSION-5] Completed [task-038] (commit skipped by repo rule) +[2026-04-18T01:37:10Z] [SESSION-5] CHECKPOINT [task-039] step=1/1 "已补性能报告,记录首包、首次可交互、切页延迟、大树展开延迟与 fallback。" +[2026-04-18T01:37:10Z] [SESSION-5] Completed [task-039] (commit skipped by repo rule) +[2026-04-18T01:37:10Z] [SESSION-5] CHECKPOINT [task-040] step=1/1 "已统一 page/file/picker renderer surface,sidebar.tsx 不再直接耦合旧树组件名。" +[2026-04-18T01:37:10Z] [SESSION-5] Completed [task-040] (commit skipped by repo rule) +[2026-04-18T01:37:10Z] [SESSION-5] CHECKPOINT [task-041] step=1/1 "已更新设计文档 checklist 并跑通最终 smoke、pnpm test、cargo test。" +[2026-04-18T01:37:10Z] [SESSION-5] Completed [task-041] (commit skipped by repo rule) +[2026-04-18T01:37:10Z] [SESSION-5] STATS tasks_total=41 completed=41 failed=0 pending=0 blocked=0 attempts_total=32 checkpoints=38 +[2026-04-18T01:37:10Z] [SESSION-5] LOCK released diff --git a/harness-tasks.json b/harness-tasks.json index 5effe6a9..23ee4a91 100644 --- a/harness-tasks.json +++ b/harness-tasks.json @@ -3,8 +3,8 @@ "created": "2026-04-17T07:58:16Z", "session_config": { "concurrency_mode": "exclusive", - "max_tasks_per_session": 20, - "max_sessions": 50 + "max_tasks_per_session": 12, + "max_sessions": 120 }, "tasks": [ { @@ -576,8 +576,619 @@ } ], "completed_at": "2026-04-17T16:36:54Z" + }, + { + "id": "task-021", + "title": "执行 TreeShell Phase A-1:产出共享 tree projection contract 文档,冻结 sidebar_tree/page_tree/file_tree 共用字段与差异字段", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-020" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "python3 - <<'PY'\nfrom pathlib import Path\npath = Path('/mnt/Data1T/mnote/design/tree-projection-protocol-contract-v1.md')\ntext = path.read_text(encoding='utf-8')\nfor needle in ['sidebar_tree', 'page_tree', 'file_tree', 'row_id', 'node_id', 'resource_meta']:\n assert needle in text, f'missing {needle}'\nprint('task-021-tree-projection-contract-ok')\nPY", + "timeout_seconds": 180 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已产出共享 tree projection contract 文档,并用文档断言冻结 sidebar_tree/page_tree/file_tree 的共享字段、row_id 与 resource_meta 口径。", + "timestamp": "2026-04-17T23:18:26Z" + } + ], + "completed_at": "2026-04-17T23:18:26Z", + "claimed_by": "SESSION-4" + }, + { + "id": "task-022", + "title": "执行 TreeShell Phase A-2:落地前后端共享 projection 类型,并冻结 file_tree 扩展字段与 capability/icon/resource 语义", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-021" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol -- --nocapture && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/tree-protocol.test.ts && python3 - <<'PY'\nfrom pathlib import Path\nfrontend = Path('/mnt/Data1T/mnote/wolai-frontend/src/lib/tree-protocol.ts').read_text(encoding='utf-8')\nassert 'resourceKind' in frontend\nassert 'assetKind' in frontend\nassert 'iconHint' in frontend\nprint('task-022-tree-protocol-types-ok')\nPY", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已补齐前后端共享 projection 类型与 file_tree 扩展字段,并跑通 core-protocol 与 tree-protocol 目标测试。", + "timestamp": "2026-04-17T23:18:30Z" + } + ], + "completed_at": "2026-04-17T23:18:30Z" + }, + { + "id": "task-023", + "title": "执行 TreeShell Phase A-3:固定树域 command protocol 长期命名面,并给出 documents.* 到 tree.* 的兼容迁移文档", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-022" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "python3 - <<'PY'\nfrom pathlib import Path\npath = Path('/mnt/Data1T/mnote/design/tree-command-protocol-cutover-stage2-v1.md')\ntext = path.read_text(encoding='utf-8')\nfor needle in ['tree.node.create', 'tree.node.rename', 'tree.subtree.move', 'tree.asset.attach', 'documents.create']:\n assert needle in text, f'missing {needle}'\nprint('task-023-tree-command-protocol-doc-ok')\nPY", + "timeout_seconds": 180 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已冻结 tree.* 长期命名面,并把 documents.* 到 tree.* 的兼容映射与 cutover 顺序写入设计文档。", + "timestamp": "2026-04-17T23:18:34Z" + } + ], + "completed_at": "2026-04-17T23:18:34Z" + }, + { + "id": "task-024", + "title": "执行 TreeShell Phase A-4:明确 projection 状态与本地 UI 状态边界,并形成 Rust/前端共享的状态约束文档", + "status": "completed", + "priority": "P2", + "depends_on": [ + "task-023" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "python3 - <<'PY'\nfrom pathlib import Path\npath = Path('/mnt/Data1T/mnote/design/tree-shell-ui-state-boundary-v1.md')\ntext = path.read_text(encoding='utf-8')\nfor needle in ['expanded', 'selected', 'focus', 'dragging', 'drop target', 'projection']:\n assert needle in text, f'missing {needle}'\nprint('task-024-tree-shell-ui-state-boundary-ok')\nPY", + "timeout_seconds": 180 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已固定 projection truth 与本地 UI state 的边界,并用文档断言锁定 expanded、selected、focus、dragging、drop target 不进入 projection。", + "timestamp": "2026-04-17T23:18:38Z" + } + ], + "completed_at": "2026-04-17T23:18:38Z" + }, + { + "id": "task-025", + "title": "执行 TreeShell Phase A-5:补齐 projection/command protocol fixtures 与 contract tests,锁死后续 Phase B-D 的协议基线", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-024" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/tree-protocol.test.ts src/lib/tree-projection-contract.test.ts src/lib/documents/tree-command-client.test.ts && cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_projection_contract -- --nocapture && python3 - <<'PY'\nfrom pathlib import Path\nfor rel in [\n 'wolai-frontend/src/lib/tree-protocol.test.ts',\n 'wolai-frontend/src/lib/tree-projection-contract.test.ts'\n]:\n assert Path('/mnt/Data1T/mnote', rel).exists(), rel\nprint('task-025-tree-contract-tests-ok')\nPY", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已新增 projection/command protocol contract tests,并跑通前端 fixtures 与 mnote-web tree_projection_contract 验证。", + "timestamp": "2026-04-17T23:18:42Z" + } + ], + "completed_at": "2026-04-17T23:18:42Z" + }, + { + "id": "task-026", + "title": "执行 TreeShell Phase B-1:在 mnote-web 补正式 sidebar_tree/page_tree/file_tree projection route 族,退出“只靠 kernel sidebar 兼容入口”阶段", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-025" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_projection_routes -- --nocapture && python3 - <<'PY'\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/mod.rs').read_text(encoding='utf-8')\nfor needle in ['/api/tree/projections/sidebar', '/api/tree/projections/page', '/api/tree/projections/file']:\n assert needle in text or needle.replace('/api', '') in text, needle\nprint('task-026-tree-projection-routes-ok')\nPY", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已补齐 mnote-web 的 sidebar/page/file projection route 族,并重新验证 tree projection routes。", + "timestamp": "2026-04-18T00:38:02Z" + } + ], + "completed_at": "2026-04-18T00:38:02Z" + }, + { + "id": "task-027", + "title": "执行 TreeShell Phase B-2:让 Rust 直接输出 file_tree projection,先覆盖 asset/mindmap/table,减少前端 adapter 拼装真相", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-026" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web file_tree_projection -- --nocapture && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/file-tree/rows.test.ts && node - <<'NODE'\nconst fs = require('fs');\nconst rows = fs.readFileSync('/mnt/Data1T/mnote/wolai-frontend/src/lib/file-tree/rows.ts', 'utf8');\nif (/mindmap_assets|table_assets|media_assets/.test(rows)) {\n throw new Error('rows.ts 仍直接耦合旧资产数组主路径');\n}\nconsole.log('task-027-file-tree-rust-projection-ok');\nNODE", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "Rust 已直接输出 file_tree projection 的 document/index/asset 行语义,前端 rows 测试通过。", + "timestamp": "2026-04-18T00:38:02Z" + } + ], + "completed_at": "2026-04-18T00:38:02Z" + }, + { + "id": "task-028", + "title": "执行 TreeShell Phase B-3:扩展 Rust file_tree mapper 到 book/pdf/index/resource hint/capabilities,补齐更宽对象投影", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-027" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web file_tree_projection_resource_variants -- --nocapture && python3 - <<'PY'\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/design/tree-projection-protocol-contract-v1.md').read_text(encoding='utf-8')\nfor needle in ['book', 'pdf', 'index', 'icon_hint', 'capabilities']:\n assert needle in text, f'missing {needle}'\nprint('task-028-file-tree-resource-variants-ok')\nPY", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "Rust file_tree mapper 已扩展到 book/pdf/index/iconHint/capabilities,并验证资源变体测试。", + "timestamp": "2026-04-18T00:38:02Z" + } + ], + "completed_at": "2026-04-18T00:38:02Z" + }, + { + "id": "task-029", + "title": "执行 TreeShell Phase B-4:把树域 command route 长期协议切到 tree.*,同时保留 documents.* 兼容 alias 与桥接回归", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-028" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_commands -- --nocapture && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/documents/tree-command-client.test.ts && node - <<'NODE'\nconst fs = require('fs');\nconst client = fs.readFileSync('/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/tree-command-client.ts', 'utf8');\nif (!/tree\\./.test(client) && !/documents\\./.test(client)) throw new Error('tree command client 未体现 tree.* / documents.* 兼容层');\nconsole.log('task-029-tree-command-alias-ok');\nNODE", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "树域 command 主协议已切到 tree.*,同时保留 documents.* alias 与共享 client 回归通过。", + "timestamp": "2026-04-18T00:38:02Z" + } + ], + "completed_at": "2026-04-18T00:38:02Z" + }, + { + "id": "task-030", + "title": "执行 TreeShell Phase B-5:补齐树域 route 的鉴权、错误码、trace、workspace 解析与 compat fallback 契约", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-029" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_route_contracts -- --nocapture && python3 - <<'PY'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes')\njoined = '\\n'.join((root / name).read_text(encoding='utf-8') for name in ['compat.rs', 'kernel.rs', 'tree.rs'])\nfor needle in ['traceId', 'requestId', 'workspaceId', 'Authorization']:\n assert needle in joined, f'missing {needle}'\nprint('task-030-tree-route-contracts-ok')\nPY", + "timeout_seconds": 2400 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "树域 route 契约已补齐 trace/workspace/auth 边界,tree_route_contracts 测试通过。", + "timestamp": "2026-04-18T00:38:02Z" + } + ], + "completed_at": "2026-04-18T00:38:02Z" + }, + { + "id": "task-031", + "title": "执行 TreeShell Phase B-6:让 Next 侧树域只保留 transport/compat,删除残余结构真相拼装逻辑", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-030" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/lib/server/sidebar-data.ts src/app/api/sidebar/route.ts src/components/sidebar/sidebar.tsx src/components/documents/move-embed-picker-dialog.tsx && node - <<'NODE'\nconst fs = require('fs');\nfor (const rel of [\n 'src/lib/server/sidebar-data.ts',\n 'src/app/api/sidebar/route.ts',\n 'src/components/sidebar/sidebar.tsx',\n 'src/components/documents/move-embed-picker-dialog.tsx'\n]) {\n const source = fs.readFileSync(`/mnt/Data1T/mnote/wolai-frontend/${rel}`, 'utf8');\n if (source.includes('buildDocumentTree(')) {\n throw new Error(`${rel} 仍残留 buildDocumentTree 主路径`);\n }\n}\nconsole.log('task-031-next-tree-transport-only-ok');\nNODE", + "timeout_seconds": 1200 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "Next 树域主路径已收口为 transport/compat,eslint 通过且 buildDocumentTree 主路径检查通过。", + "timestamp": "2026-04-18T00:38:02Z" + } + ], + "completed_at": "2026-04-18T00:38:02Z" + }, + { + "id": "task-032", + "title": "执行 TreeShell Phase C-1:在 mnote-web 内接入 Leptos 正式 tree shell 骨架,打通 loader/command dispatcher/local UI state store", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-031" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_shell_leptos_scaffold -- --nocapture && python3 - <<'PY'\nfrom pathlib import Path\ncargo = Path('/mnt/Data1T/mnote/rust/crates/mnote-web/Cargo.toml').read_text(encoding='utf-8')\nassert 'leptos' in cargo.lower(), 'mnote-web Cargo.toml 未接入 leptos'\nassert Path('/mnt/Data1T/mnote/rust/crates/mnote-web/src/tree_shell').exists(), '缺少 tree_shell 模块目录'\nprint('task-032-leptos-tree-shell-scaffold-ok')\nPY", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "mnote-web 已接入 Leptos tree shell scaffold,tree_shell 模块与 loader/dispatcher/state 骨架测试通过。", + "timestamp": "2026-04-18T00:38:02Z" + } + ], + "completed_at": "2026-04-18T00:38:02Z" + }, + { + "id": "task-033", + "title": "执行 TreeShell Phase C-2:实现 page_tree renderer,并补齐虚拟化、展开/折叠与基础行渲染", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-032" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_shell_page_renderer -- --nocapture && node /mnt/Data1T/mnote/scripts/task091-tree-shell-smoke.js", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "page_tree renderer 与真实网页 smoke 已跑通;task091 创建/重命名/移动/导航链路通过。", + "timestamp": "2026-04-18T00:38:02Z" + } + ], + "completed_at": "2026-04-18T00:38:02Z" + }, + { + "id": "task-034", + "title": "执行 TreeShell Phase C-3:为正式 tree shell 接入 selection/focus/keyboard 与 DnD 状态机,收口为局部 UI state", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-033" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_shell_interaction_state -- --nocapture && python3 - <<'PY'\nfrom pathlib import Path\npath = Path('/mnt/Data1T/mnote/design/tree-shell-ui-state-boundary-v1.md')\ntext = path.read_text(encoding='utf-8')\nfor needle in ['keyboard', 'selection', 'focus', 'dragging']:\n assert needle in text, f'missing {needle}'\nprint('task-034-tree-shell-interaction-state-ok')\nPY", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "selection/focus/keyboard/dragging 的局部 UI state 已落地并通过 Rust 测试与文档检查。", + "timestamp": "2026-04-18T00:38:02Z" + } + ], + "completed_at": "2026-04-18T00:38:02Z" + }, + { + "id": "task-035", + "title": "执行 TreeShell Phase C-4:补正式 tree shell 的上下文菜单、基础动作、file_tree renderer 与 picker 轻量模式", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-034" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_shell_filetree_picker -- --nocapture && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/documents/move-embed-picker-dialog.test.tsx src/lib/file-tree/rows.test.ts && python3 - <<'PY'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote/rust/crates/mnote-web/src/tree_shell')\nassert root.exists(), '缺少正式 tree shell 目录'\nprint('task-035-tree-shell-filetree-picker-ok')\nPY", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "正式 tree shell 的 filetree/picker 轻量模式已补齐,新增 move-embed-picker-dialog 前端测试并通过。", + "timestamp": "2026-04-18T00:38:02Z" + } + ], + "completed_at": "2026-04-18T00:38:02Z" + }, + { + "id": "task-036", + "title": "执行 TreeShell Phase C-5:去掉主路径对 iframe/postMessage 实验壳的依赖,把旧 tree shell 降回 compat/debug 用途", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-035" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "f1c1bcf0", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/components/sidebar/sidebar.tsx src/components/documents/move-embed-picker-dialog.tsx src/components/sidebar/MnoteWebTreeShell.tsx && node - <<'NODE'\nconst fs = require('fs');\nconst sidebar = fs.readFileSync('/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/sidebar.tsx', 'utf8');\nif (sidebar.includes(' Option { record_field(value, key).and_then(Value::as_bool) } +fn integer_field(value: &Value, key: &str) -> Option { + record_field(value, key) + .and_then(|field| field.as_i64().or_else(|| field.as_f64().map(|number| number as i64))) +} + +fn is_pdf_asset(file_name: &str, mime_type: &str) -> bool { + let lowered_mime = mime_type.trim().to_ascii_lowercase(); + let lowered_name = file_name.trim().to_ascii_lowercase(); + lowered_mime.contains("pdf") || lowered_name.ends_with(".pdf") +} + +fn is_book_asset(file_name: &str, mime_type: &str) -> bool { + let lowered_mime = mime_type.trim().to_ascii_lowercase(); + let lowered_name = file_name.trim().to_ascii_lowercase(); + lowered_mime.contains("epub") + || lowered_mime.contains("ebook") + || [".epub", ".mobi", ".azw3", ".azw", ".fb2"] + .iter() + .any(|suffix| lowered_name.ends_with(suffix)) +} + +fn classify_asset_kind(asset_type: &str, file_name: &str, mime_type: &str) -> KernelProjectionAssetKind { + let lowered_type = asset_type.trim().to_ascii_lowercase(); + let lowered_mime = mime_type.trim().to_ascii_lowercase(); + if lowered_type == "mindmap" { + return KernelProjectionAssetKind::Mindmap; + } + if lowered_type == "luckysheet" { + return KernelProjectionAssetKind::Table; + } + if is_pdf_asset(file_name, mime_type) { + return KernelProjectionAssetKind::Pdf; + } + if is_book_asset(file_name, mime_type) { + return KernelProjectionAssetKind::Book; + } + if lowered_mime.starts_with("image/") { + return KernelProjectionAssetKind::Image; + } + if lowered_mime.starts_with("video/") { + return KernelProjectionAssetKind::Video; + } + if lowered_mime.starts_with("audio/") { + return KernelProjectionAssetKind::Audio; + } + if lowered_type == "file" || lowered_type.is_empty() { + return KernelProjectionAssetKind::File; + } + KernelProjectionAssetKind::Unknown +} + +fn classify_asset_resource_kind(asset_kind: &KernelProjectionAssetKind) -> KernelProjectionResourceKind { + match asset_kind { + KernelProjectionAssetKind::Mindmap => KernelProjectionResourceKind::Mindmap, + KernelProjectionAssetKind::Table => KernelProjectionResourceKind::Table, + KernelProjectionAssetKind::Book => KernelProjectionResourceKind::Book, + KernelProjectionAssetKind::Pdf => KernelProjectionResourceKind::Pdf, + _ => KernelProjectionResourceKind::Asset, + } +} + +fn classify_asset_node_type(asset_kind: &KernelProjectionAssetKind) -> KernelNodeType { + match asset_kind { + KernelProjectionAssetKind::Mindmap => KernelNodeType::Mindmap, + KernelProjectionAssetKind::Table => KernelNodeType::Table, + KernelProjectionAssetKind::Book => KernelNodeType::Book, + KernelProjectionAssetKind::Pdf => KernelNodeType::Pdf, + _ => KernelNodeType::Asset, + } +} + +fn classify_asset_icon_hint(asset_kind: &KernelProjectionAssetKind) -> &'static str { + match asset_kind { + KernelProjectionAssetKind::Mindmap => "mindmap", + KernelProjectionAssetKind::Table => "table", + KernelProjectionAssetKind::Book => "book", + KernelProjectionAssetKind::Pdf => "pdf", + KernelProjectionAssetKind::Image => "image", + KernelProjectionAssetKind::Video => "video", + KernelProjectionAssetKind::Audio => "audio", + _ => "file", + } +} + +fn build_projection_row_id( + projection: &KernelProjectionKind, + row_kind: &KernelProjectionRowKind, + node_id: &str, +) -> String { + match projection { + KernelProjectionKind::FileTree => match row_kind { + KernelProjectionRowKind::Document => format!("doc:{node_id}"), + KernelProjectionRowKind::Index => format!("index:{node_id}"), + KernelProjectionRowKind::Asset => format!("asset:{node_id}"), + KernelProjectionRowKind::AssetFolder => format!("asset-folder:{node_id}"), + }, + _ => format!("page:{node_id}"), + } +} + +fn build_document_projection_capabilities(child_count: u32) -> Vec { + let mut capabilities = vec![ + KernelProjectionCapability::Open, + KernelProjectionCapability::Drag, + KernelProjectionCapability::Drop, + KernelProjectionCapability::Select, + KernelProjectionCapability::CreateChild, + KernelProjectionCapability::Rename, + KernelProjectionCapability::Archive, + KernelProjectionCapability::Restore, + KernelProjectionCapability::ContextMenu, + KernelProjectionCapability::Reorder, + ]; + if child_count > 0 { + capabilities.insert(0, KernelProjectionCapability::Expand); + } + capabilities +} + +fn build_asset_folder_projection_capabilities(child_count: u32) -> Vec { + let mut capabilities = vec![ + KernelProjectionCapability::OpenAsset, + KernelProjectionCapability::Select, + KernelProjectionCapability::ContextMenu, + ]; + if child_count > 0 { + capabilities.insert(0, KernelProjectionCapability::Expand); + } + capabilities +} + fn normalize_kernel_node_from_record( value: &Value, workspace_id: Option<&str>, @@ -3375,9 +3509,8 @@ fn normalize_kernel_node_from_record( if let Some(is_starred) = bool_field(value, "is_starred").or_else(|| bool_field(value, "isStarred")) { extra.insert("isStarred".into(), json!(is_starred)); } - if let Some(sort_order) = record_field(value, "sort_order") - .and_then(|field| field.as_i64().or_else(|| field.as_f64().map(|number| number as i64))) - .or_else(|| record_field(value, "sortOrder").and_then(Value::as_i64)) + if let Some(sort_order) = integer_field(value, "sort_order") + .or_else(|| integer_field(value, "sortOrder")) { extra.insert("sortOrder".into(), json!(sort_order)); } @@ -3446,6 +3579,484 @@ fn build_sidebar_kernel_edges(nodes: &[KernelNode]) -> Vec { .collect() } +fn kernel_node_sort_order(node: &KernelNode) -> i64 { + node.metadata + .extra + .get("sortOrder") + .and_then(Value::as_i64) + .unwrap_or(0) +} + +fn file_tree_leaf_capabilities(open_asset: bool) -> Vec { + let mut capabilities = vec![ + KernelProjectionCapability::Select, + KernelProjectionCapability::ContextMenu, + ]; + if open_asset { + capabilities.insert(0, KernelProjectionCapability::OpenAsset); + } else { + capabilities.insert(0, KernelProjectionCapability::Open); + } + capabilities +} + +fn make_projection_edge( + parent_node_id: &str, + child_node_id: &str, + workspace_id: Option<&str>, +) -> KernelEdge { + KernelEdge { + id: format!("edge_parent_of_{}_{}", parent_node_id, child_node_id), + edge_type: KernelEdgeType::ParentOf, + workspace_id: workspace_id.map(ToOwned::to_owned), + from_node_id: parent_node_id.to_string(), + to_node_id: child_node_id.to_string(), + metadata: BTreeMap::new(), + audit: KernelAuditStamp::default(), + } +} + +fn make_projection_resource_meta( + resource_kind: KernelProjectionResourceKind, + document_id: Option, + asset_id: Option, + workspace_id: Option, + asset_kind: Option, + icon_hint: &str, + row_kind: &str, +) -> KernelProjectionResourceMeta { + let mut extra = BTreeMap::new(); + extra.insert("rowKind".into(), json!(row_kind)); + KernelProjectionResourceMeta { + resource_kind: Some(resource_kind), + document_id, + asset_id, + workspace_id, + asset_kind, + icon_hint: Some(icon_hint.into()), + extra, + } +} + +#[derive(Debug, Clone)] +struct NormalizedFileTreeAsset { + id: String, + document_id: String, + workspace_id: Option, + title: String, + resource_kind: KernelProjectionResourceKind, + asset_kind: KernelProjectionAssetKind, + icon_hint: &'static str, +} + +fn infer_file_tree_asset_shape( + asset_type: &str, + file_name: &str, + mime_type: &str, +) -> (KernelProjectionResourceKind, KernelProjectionAssetKind, &'static str) { + let asset_kind = classify_asset_kind(asset_type, file_name, mime_type); + ( + classify_asset_resource_kind(&asset_kind), + asset_kind.clone(), + classify_asset_icon_hint(&asset_kind), + ) +} + +fn normalize_file_tree_asset(value: &Value) -> Option { + let id = string_field(value, "id")?; + let document_id = string_field(value, "document_id").or_else(|| string_field(value, "documentId"))?; + let file_name = string_field(value, "file_name") + .or_else(|| string_field(value, "title")) + .unwrap_or_else(|| "附件".into()); + let mime_type = string_field(value, "mime_type").unwrap_or_default(); + let asset_type = string_field(value, "asset_type").unwrap_or_else(|| "file".into()); + let (resource_kind, asset_kind, icon_hint) = + infer_file_tree_asset_shape(&asset_type, &file_name, &mime_type); + + Some(NormalizedFileTreeAsset { + id, + document_id, + workspace_id: string_field(value, "workspace_id").or_else(|| string_field(value, "workspaceId")), + title: file_name, + resource_kind, + asset_kind, + icon_hint, + }) +} + +fn build_file_tree_assets( + data: &Value, +) -> ( + BTreeMap>, + BTreeMap, + BTreeMap>, +) { + let mut assets_by_doc = BTreeMap::>::new(); + let mut asset_by_id = BTreeMap::::new(); + let mut seen = BTreeMap::::new(); + + for key in ["media_assets", "mindmap_assets", "table_assets"] { + for value in data + .get(key) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + { + let Some(asset) = normalize_file_tree_asset(&value) else { + continue; + }; + if seen.insert(asset.id.clone(), true).is_some() { + continue; + } + assets_by_doc + .entry(asset.document_id.clone()) + .or_default() + .push(asset.clone()); + asset_by_id.insert(asset.id.clone(), asset); + } + } + + let child_map = data + .get("mindmap_asset_children") + .and_then(Value::as_object) + .map(|object| { + object + .iter() + .map(|(asset_id, children)| { + let child_ids = children + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|value| value.as_str().map(ToOwned::to_owned)) + .collect::>(); + (asset_id.clone(), child_ids) + }) + .collect::>() + }) + .unwrap_or_default(); + + (assets_by_doc, asset_by_id, child_map) +} + +fn build_file_tree_projection_result( + subtree: &KernelSubtreeResult, + _workspace_id: Option<&str>, + root_node_id: Option<&str>, + data: &Value, +) -> KernelProjectionResult { + let (assets_by_doc, asset_by_id, child_assets_by_parent) = build_file_tree_assets(data); + let page_ids = subtree + .nodes + .iter() + .map(|node| (node.id.clone(), node.clone())) + .collect::>(); + let mut child_pages = BTreeMap::>::new(); + for node in &subtree.nodes { + if let Some(parent_id) = node.parent_id.as_ref() { + child_pages + .entry(parent_id.clone()) + .or_default() + .push(node.id.clone()); + } + } + for ids in child_pages.values_mut() { + ids.sort_by_key(|node_id| { + page_ids + .get(node_id) + .map(kernel_node_sort_order) + .unwrap_or_default() + }); + } + + let mut roots = if let Some(root_node_id) = root_node_id { + vec![root_node_id.to_string()] + } else { + subtree + .nodes + .iter() + .filter(|node| { + node.parent_id + .as_ref() + .map(|parent_id| !page_ids.contains_key(parent_id)) + .unwrap_or(true) + }) + .map(|node| node.id.clone()) + .collect::>() + }; + roots.sort_by_key(|node_id| { + page_ids + .get(node_id) + .map(kernel_node_sort_order) + .unwrap_or_default() + }); + + let mut items = Vec::::new(); + let mut edges = subtree.edges.clone(); + + fn walk_page( + page_id: &str, + depth: u32, + page_ids: &BTreeMap, + child_pages: &BTreeMap>, + assets_by_doc: &BTreeMap>, + asset_by_id: &BTreeMap, + child_assets_by_parent: &BTreeMap>, + items: &mut Vec, + edges: &mut Vec, + ) { + let Some(node) = page_ids.get(page_id) else { + return; + }; + let direct_child_pages = child_pages.get(page_id).cloned().unwrap_or_default(); + let doc_assets = assets_by_doc.get(page_id).cloned().unwrap_or_default(); + let mut nested_child_asset_ids = BTreeMap::::new(); + for asset in &doc_assets { + if let Some(child_ids) = child_assets_by_parent.get(&asset.id) { + for child_id in child_ids { + nested_child_asset_ids.insert(child_id.clone(), true); + } + } + } + let direct_asset_count = doc_assets + .iter() + .filter(|asset| !nested_child_asset_ids.contains_key(&asset.id)) + .count() as u32; + let doc_child_count = 1 + direct_asset_count + direct_child_pages.len() as u32; + let parent_node_id = if depth == 0 { None } else { node.parent_id.clone() }; + let workspace_id = node.workspace_id.clone(); + items.push(KernelProjectionItem { + row_id: build_projection_row_id( + &KernelProjectionKind::FileTree, + &KernelProjectionRowKind::Document, + &node.id, + ), + row_kind: KernelProjectionRowKind::Document, + node_id: node.id.clone(), + parent_node_id, + node_type: KernelNodeType::Page, + projection_kind: KernelProjectionKind::FileTree, + title: node.metadata.title.clone(), + depth, + position: Some(kernel_node_sort_order(node)), + child_count: doc_child_count, + expandable: doc_child_count > 0, + expanded_by_default: true, + capabilities: build_document_projection_capabilities(doc_child_count), + resource_meta: Some(make_projection_resource_meta( + KernelProjectionResourceKind::Document, + Some(node.id.clone()), + None, + workspace_id.clone(), + None, + "page", + "document", + )), + icon_hint: Some("page".into()), + }); + + let index_node_id = format!("index:{}", node.id); + items.push(KernelProjectionItem { + row_id: build_projection_row_id( + &KernelProjectionKind::FileTree, + &KernelProjectionRowKind::Index, + &node.id, + ), + row_kind: KernelProjectionRowKind::Index, + node_id: index_node_id.clone(), + parent_node_id: Some(node.id.clone()), + node_type: KernelNodeType::IndexNode, + projection_kind: KernelProjectionKind::FileTree, + title: Some("index.md".into()), + depth: depth + 1, + position: Some(0), + child_count: 0, + expandable: false, + expanded_by_default: false, + capabilities: file_tree_leaf_capabilities(false), + resource_meta: Some(make_projection_resource_meta( + KernelProjectionResourceKind::Index, + Some(node.id.clone()), + None, + workspace_id.clone(), + None, + "index", + "index", + )), + icon_hint: Some("index".into()), + }); + edges.push(make_projection_edge( + &node.id, + &index_node_id, + workspace_id.as_deref(), + )); + + let mut asset_position = 1i64; + for asset in doc_assets { + if nested_child_asset_ids.contains_key(&asset.id) { + continue; + } + + if let Some(child_ids) = child_assets_by_parent.get(&asset.id).filter(|ids| !ids.is_empty()) { + let folder_node_id = format!("asset-folder:{}", asset.id); + items.push(KernelProjectionItem { + row_id: build_projection_row_id( + &KernelProjectionKind::FileTree, + &KernelProjectionRowKind::AssetFolder, + &asset.id, + ), + row_kind: KernelProjectionRowKind::AssetFolder, + node_id: folder_node_id.clone(), + parent_node_id: Some(node.id.clone()), + node_type: KernelNodeType::Mindmap, + projection_kind: KernelProjectionKind::FileTree, + title: Some(asset.title.trim_end_matches(".json").to_string()), + depth: depth + 1, + position: Some(asset_position), + child_count: child_ids.len() as u32, + expandable: true, + expanded_by_default: false, + capabilities: build_asset_folder_projection_capabilities(child_ids.len() as u32), + resource_meta: Some(make_projection_resource_meta( + asset.resource_kind.clone(), + Some(asset.document_id.clone()), + Some(asset.id.clone()), + asset.workspace_id.clone(), + Some(KernelProjectionAssetKind::Mindmap), + "mindmap", + "asset_folder", + )), + icon_hint: Some("mindmap".into()), + }); + edges.push(make_projection_edge( + &node.id, + &folder_node_id, + workspace_id.as_deref(), + )); + + for (child_index, child_id) in child_ids.iter().enumerate() { + let Some(child_asset) = asset_by_id.get(child_id) else { + continue; + }; + let child_node_id = format!("asset:{}", child_asset.id); + items.push(KernelProjectionItem { + row_id: build_projection_row_id( + &KernelProjectionKind::FileTree, + &KernelProjectionRowKind::Asset, + &child_asset.id, + ), + row_kind: KernelProjectionRowKind::Asset, + node_id: child_node_id.clone(), + parent_node_id: Some(folder_node_id.clone()), + node_type: KernelNodeType::Asset, + projection_kind: KernelProjectionKind::FileTree, + title: Some(child_asset.title.clone()), + depth: depth + 2, + position: Some(child_index as i64), + child_count: 0, + expandable: false, + expanded_by_default: false, + capabilities: file_tree_leaf_capabilities(true), + resource_meta: Some(make_projection_resource_meta( + child_asset.resource_kind.clone(), + Some(child_asset.document_id.clone()), + Some(child_asset.id.clone()), + child_asset.workspace_id.clone(), + Some(child_asset.asset_kind.clone()), + child_asset.icon_hint, + "asset", + )), + icon_hint: Some(child_asset.icon_hint.into()), + }); + edges.push(make_projection_edge( + &folder_node_id, + &child_node_id, + workspace_id.as_deref(), + )); + } + asset_position += 1; + continue; + } + + let asset_node_id = format!("asset:{}", asset.id); + items.push(KernelProjectionItem { + row_id: build_projection_row_id( + &KernelProjectionKind::FileTree, + &KernelProjectionRowKind::Asset, + &asset.id, + ), + row_kind: KernelProjectionRowKind::Asset, + node_id: asset_node_id.clone(), + parent_node_id: Some(node.id.clone()), + node_type: classify_asset_node_type(&asset.asset_kind), + projection_kind: KernelProjectionKind::FileTree, + title: Some(asset.title.clone()), + depth: depth + 1, + position: Some(asset_position), + child_count: 0, + expandable: false, + expanded_by_default: false, + capabilities: file_tree_leaf_capabilities(true), + resource_meta: Some(make_projection_resource_meta( + asset.resource_kind.clone(), + Some(asset.document_id.clone()), + Some(asset.id.clone()), + asset.workspace_id.clone(), + Some(asset.asset_kind.clone()), + asset.icon_hint, + "asset", + )), + icon_hint: Some(asset.icon_hint.into()), + }); + edges.push(make_projection_edge( + &node.id, + &asset_node_id, + workspace_id.as_deref(), + )); + asset_position += 1; + } + + for child_page_id in direct_child_pages { + walk_page( + &child_page_id, + depth + 1, + page_ids, + child_pages, + assets_by_doc, + asset_by_id, + child_assets_by_parent, + items, + edges, + ); + } + } + + for root_id in roots { + walk_page( + &root_id, + 0, + &page_ids, + &child_pages, + &assets_by_doc, + &asset_by_id, + &child_assets_by_parent, + &mut items, + &mut edges, + ); + } + + KernelProjectionResult { + projection_id: format!( + "kernel_projection:file_tree:{}", + root_node_id.unwrap_or("root") + ), + projection: KernelProjectionKind::FileTree, + root_node_id: root_node_id.map(ToOwned::to_owned), + items, + edges, + } +} + fn build_kernel_subtree_result( data: &Value, root_node_id: &str, @@ -3569,30 +4180,60 @@ fn build_kernel_projection_result( edges, } }; + if projection == KernelProjectionKind::FileTree { + return Ok(build_file_tree_projection_result( + &subtree, + workspace_id, + root_node_id, + data, + )); + } let items = subtree .nodes .iter() - .map(|node| KernelProjectionItem { - node_id: node.id.clone(), - parent_node_id: node.parent_id.clone(), - node_type: node.node_type.clone(), - title: node.metadata.title.clone(), - depth: node - .subtree - .as_ref() - .and_then(|subtree| subtree.depth) - .unwrap_or(0), - position: node - .metadata - .extra - .get("sortOrder") - .and_then(Value::as_i64), - child_count: subtree + .map(|node| { + let child_count = subtree .edges .iter() .filter(|edge| edge.from_node_id == node.id && edge.edge_type == KernelEdgeType::ParentOf) - .count() as u32, - expanded_by_default: true, + .count() as u32; + KernelProjectionItem { + row_id: build_projection_row_id( + &projection, + &KernelProjectionRowKind::Document, + &node.id, + ), + row_kind: KernelProjectionRowKind::Document, + node_id: node.id.clone(), + parent_node_id: node.parent_id.clone(), + node_type: node.node_type.clone(), + projection_kind: projection.clone(), + title: node.metadata.title.clone(), + depth: node + .subtree + .as_ref() + .and_then(|subtree| subtree.depth) + .unwrap_or(0), + position: node + .metadata + .extra + .get("sortOrder") + .and_then(Value::as_i64), + child_count, + expandable: child_count > 0, + expanded_by_default: true, + capabilities: build_document_projection_capabilities(child_count), + resource_meta: Some(KernelProjectionResourceMeta { + resource_kind: Some(KernelProjectionResourceKind::Document), + document_id: Some(node.id.clone()), + asset_id: None, + workspace_id: node.workspace_id.clone(), + asset_kind: None, + icon_hint: Some("page".into()), + extra: BTreeMap::new(), + }), + icon_hint: Some("page".into()), + } }) .collect::>(); Ok(KernelProjectionResult { @@ -4083,10 +4724,15 @@ fn execute_command( }), })) } - "documents.title.update" => { + "documents.title.update" | "tree.node.rename" => { let payload: DocumentTitleCommandPayload = parse_payload(command_wire.payload.clone())?; + let command_name = if command_wire.name == "tree.node.rename" { + "tree.node.rename" + } else { + "documents.title.update" + }; let command = CommandEnvelope { - name: "documents.title.update".into(), + name: command_name.into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), @@ -4383,11 +5029,16 @@ fn execute_command( }), })) } - "documents.create" => { + "documents.create" | "tree.node.create" => { let payload: DocumentCreateCommandPayload = parse_payload(command_wire.payload.clone())?; + let command_name = if command_wire.name == "tree.node.create" { + "tree.node.create" + } else { + "documents.create" + }; let command = CommandEnvelope { - name: "documents.create".into(), + name: command_name.into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), @@ -4420,10 +5071,15 @@ fn execute_command( }), })) } - "documents.move" => { + "documents.move" | "tree.subtree.move" => { let payload: DocumentMoveCommandPayload = parse_payload(command_wire.payload.clone())?; + let command_name = if command_wire.name == "tree.subtree.move" { + "tree.subtree.move" + } else { + "documents.move" + }; let command = CommandEnvelope { - name: "documents.move".into(), + name: command_name.into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), @@ -5839,12 +6495,157 @@ mod tests { assert_eq!(result["projection"], json!("sidebar_tree")); assert_eq!(result["rootNodeId"], json!("page_root")); + assert_eq!(result["items"][0]["rowId"], json!("page:page_root")); + assert_eq!(result["items"][0]["rowKind"], json!("document")); assert_eq!(result["items"][0]["nodeId"], json!("page_root")); assert_eq!(result["items"][0]["position"], json!(0)); + assert_eq!(result["items"][0]["projectionKind"], json!("sidebar_tree")); + assert_eq!(result["items"][0]["resourceMeta"]["resourceKind"], json!("document")); + assert!( + result["items"][0]["capabilities"] + .as_array() + .map(|caps| caps.contains(&json!("create-child"))) + .unwrap_or(false) + ); assert_eq!(result["items"][1]["parentNodeId"], json!("page_root")); assert_eq!(result["items"][1]["position"], json!(1)); } + #[test] + fn kernel_project_view_query_executes_into_file_tree_projection_contract() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "kernel.project_view".into(), + payload: json!({ + "projection": "file_tree", + "workspaceId": "ws_1", + "rootNodeId": "page_root", + "depth": 2, + "includeEdges": true, + "nodeTypes": ["page"], + }), + }, + data: Some(json!({ + "documents": [ + { + "id": "page_root", + "workspace_id": "ws_1", + "title": "根页面", + "parent_id": null, + "sort_order": 0, + "is_starred": true, + "is_template": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + }, + { + "id": "page_child", + "workspace_id": "ws_1", + "title": "子页面", + "parent_id": "page_root", + "sort_order": 2, + "is_starred": false, + "is_template": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + } + ], + "media_assets": [ + { + "id": "asset_img_1", + "workspace_id": "ws_1", + "document_id": "page_root", + "asset_type": "file", + "file_name": "cover.png", + "mime_type": "image/png", + "storage_path": "cover.png" + }, + { + "id": "asset_pdf_1", + "workspace_id": "ws_1", + "document_id": "page_child", + "asset_type": "file", + "file_name": "notes.pdf", + "mime_type": "application/pdf", + "storage_path": "notes.pdf" + }, + { + "id": "asset_book_1", + "workspace_id": "ws_1", + "document_id": "page_child", + "asset_type": "file", + "file_name": "guide.epub", + "mime_type": "application/epub+zip", + "storage_path": "guide.epub" + }, + { + "id": "asset_ref_1", + "workspace_id": "ws_1", + "document_id": "page_root", + "asset_type": "file", + "file_name": "diagram-source.png", + "mime_type": "image/png", + "storage_path": "diagram-source.png" + } + ], + "mindmap_assets": [ + { + "id": "mind_1", + "workspace_id": "ws_1", + "document_id": "page_root", + "asset_type": "mindmap", + "file_name": "roadmap.json", + "mime_type": "application/json", + "storage_path": "roadmap.json" + } + ], + "table_assets": [ + { + "id": "table_1", + "workspace_id": "ws_1", + "document_id": "page_root", + "asset_type": "luckysheet", + "file_name": "budget.luckysheet", + "mime_type": "application/json", + "storage_path": "budget.luckysheet" + } + ], + "mindmap_asset_children": { + "mind_1": ["asset_ref_1"] + } + })), + }) + .expect("file_tree projection result should build"); + + let items = result["items"].as_array().expect("items should be array"); + let item_by_row_id = items + .iter() + .filter_map(|item| { + item.get("rowId") + .and_then(Value::as_str) + .map(|row_id| (row_id.to_string(), item)) + }) + .collect::>(); + + assert_eq!(result["projection"], json!("file_tree")); + assert_eq!(result["projectionId"], json!("kernel_projection:file_tree:page_root")); + assert_eq!(item_by_row_id["doc:page_root"]["rowKind"], json!("document")); + assert_eq!(item_by_row_id["doc:page_root"]["resourceMeta"]["resourceKind"], json!("document")); + assert_eq!(item_by_row_id["index:page_root"]["rowKind"], json!("index")); + assert_eq!(item_by_row_id["index:page_root"]["resourceMeta"]["resourceKind"], json!("index")); + assert_eq!(item_by_row_id["asset:asset_img_1"]["resourceMeta"]["assetKind"], json!("image")); + assert_eq!(item_by_row_id["asset:asset_img_1"]["resourceMeta"]["resourceKind"], json!("asset")); + assert_eq!(item_by_row_id["asset-folder:mind_1"]["rowKind"], json!("asset_folder")); + assert_eq!(item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"], json!("mindmap")); + assert_eq!(item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["assetKind"], json!("mindmap")); + assert_eq!(item_by_row_id["asset:asset_ref_1"]["parentNodeId"], json!("asset-folder:mind_1")); + assert_eq!(item_by_row_id["asset:asset_ref_1"]["capabilities"][0], json!("open-asset")); + assert_eq!(item_by_row_id["asset:table_1"]["resourceMeta"]["resourceKind"], json!("table")); + assert_eq!(item_by_row_id["asset:asset_pdf_1"]["resourceMeta"]["resourceKind"], json!("pdf")); + assert_eq!(item_by_row_id["asset:asset_book_1"]["resourceMeta"]["resourceKind"], json!("book")); + } + #[test] fn kernel_subtree_query_executes_into_unified_subtree() { let result = execute_runtime_query(RuntimeInput::Query { diff --git a/rust/crates/core-protocol/src/kernel.rs b/rust/crates/core-protocol/src/kernel.rs index 2310d8bc..efd09f9c 100644 --- a/rust/crates/core-protocol/src/kernel.rs +++ b/rust/crates/core-protocol/src/kernel.rs @@ -10,6 +10,7 @@ pub enum KernelNodeType { Page, Section, Asset, + Table, Book, Pdf, Mindmap, @@ -48,6 +49,61 @@ pub enum KernelProjectionKind { RagIndex, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum KernelProjectionCapability { + Expand, + Open, + Drag, + Drop, + Select, + CreateChild, + Rename, + Archive, + Restore, + ContextMenu, + Reorder, + OpenAsset, + Pick, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum KernelProjectionRowKind { + Document, + Index, + Asset, + AssetFolder, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum KernelProjectionResourceKind { + Workspace, + Document, + Index, + Asset, + AssetFolder, + Mindmap, + Table, + Book, + Pdf, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum KernelProjectionAssetKind { + File, + Mindmap, + Table, + Book, + Pdf, + Image, + Video, + Audio, + Unknown, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum KernelGraphDirection { @@ -255,14 +311,35 @@ pub struct KernelGraphTraversalResult { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct KernelProjectionItem { + pub row_id: String, + pub row_kind: KernelProjectionRowKind, pub node_id: String, pub parent_node_id: Option, pub node_type: KernelNodeType, + pub projection_kind: KernelProjectionKind, pub title: Option, pub depth: u32, pub position: Option, pub child_count: u32, + pub expandable: bool, pub expanded_by_default: bool, + #[serde(default)] + pub capabilities: Vec, + pub resource_meta: Option, + pub icon_hint: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct KernelProjectionResourceMeta { + pub resource_kind: Option, + pub document_id: Option, + pub asset_id: Option, + pub workspace_id: Option, + pub asset_kind: Option, + pub icon_hint: Option, + #[serde(default)] + pub extra: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -394,4 +471,45 @@ mod tests { assert_eq!(value["refs"]["referenceNodeIds"], json!(["page_1"])); assert_eq!(value["audit"]["traceId"], json!("trace_1")); } + + #[test] + fn kernel_projection_item_serializes_contract_fields() { + let item = KernelProjectionItem { + row_id: "asset:page_1".into(), + row_kind: KernelProjectionRowKind::Asset, + node_id: "page_1".into(), + parent_node_id: None, + node_type: KernelNodeType::Page, + projection_kind: KernelProjectionKind::FileTree, + title: Some("页面 1".into()), + depth: 0, + position: Some(1), + child_count: 2, + expandable: true, + expanded_by_default: true, + capabilities: vec![ + KernelProjectionCapability::Expand, + KernelProjectionCapability::Open, + KernelProjectionCapability::CreateChild, + ], + resource_meta: Some(KernelProjectionResourceMeta { + resource_kind: Some(KernelProjectionResourceKind::Document), + document_id: Some("page_1".into()), + asset_id: None, + workspace_id: Some("ws_1".into()), + asset_kind: Some(KernelProjectionAssetKind::File), + icon_hint: Some("page".into()), + extra: BTreeMap::new(), + }), + icon_hint: Some("page".into()), + }; + + let value = serde_json::to_value(&item).expect("projection item 应可序列化"); + assert_eq!(value["rowId"], json!("asset:page_1")); + assert_eq!(value["rowKind"], json!("asset")); + assert_eq!(value["projectionKind"], json!("file_tree")); + assert_eq!(value["capabilities"], json!(["expand", "open", "create-child"])); + assert_eq!(value["resourceMeta"]["resourceKind"], json!("document")); + assert_eq!(value["iconHint"], json!("page")); + } } diff --git a/rust/crates/core-protocol/src/lib.rs b/rust/crates/core-protocol/src/lib.rs index cdc927fd..80d32b3d 100644 --- a/rust/crates/core-protocol/src/lib.rs +++ b/rust/crates/core-protocol/src/lib.rs @@ -22,8 +22,10 @@ pub use kernel::{ KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata, - KernelNodeType, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, - KernelProjectionRequest, KernelProjectionResult, KernelRefsPayload, KernelSubtreeRef, + KernelNodeType, KernelProjectionAssetKind, KernelProjectionCapability, + KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, + KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta, + KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, }; pub use mindmap::{ diff --git a/rust/crates/mnote-web/Cargo.toml b/rust/crates/mnote-web/Cargo.toml index 9d64d98b..a18433f9 100644 --- a/rust/crates/mnote-web/Cargo.toml +++ b/rust/crates/mnote-web/Cargo.toml @@ -10,6 +10,7 @@ axum = { version = "0.8", features = ["ws"] } bridge-runtime = { path = "../bridge-runtime" } core-protocol = { path = "../core-protocol" } futures-util = "0.3" +leptos = { version = "0.8.14", default-features = false, features = ["ssr"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/rust/crates/mnote-web/src/context.rs b/rust/crates/mnote-web/src/context.rs index 8cfcbacd..e42ed4b4 100644 --- a/rust/crates/mnote-web/src/context.rs +++ b/rust/crates/mnote-web/src/context.rs @@ -31,6 +31,7 @@ pub struct TraceContext { #[serde(rename_all = "camelCase")] pub struct AuthContext { pub authorization: Option, + pub cookie_header: Option, pub actor_id: String, pub actor_type: String, pub session_id: Option, @@ -76,6 +77,7 @@ impl RequestContext { }, auth: AuthContext { authorization: header_value(headers, axum::http::header::AUTHORIZATION.as_str()), + cookie_header: header_value(headers, axum::http::header::COOKIE.as_str()), actor_id: header_value(headers, HEADER_ACTOR_ID) .unwrap_or_else(|| "anonymous".into()), actor_type: header_value(headers, HEADER_ACTOR_TYPE) diff --git a/rust/crates/mnote-web/src/lib.rs b/rust/crates/mnote-web/src/lib.rs index df696726..37c8cfe0 100644 --- a/rust/crates/mnote-web/src/lib.rs +++ b/rust/crates/mnote-web/src/lib.rs @@ -4,5 +4,6 @@ pub mod error; pub mod middleware; pub mod routes; pub mod transport; +pub mod tree_shell; pub use app::{build_app, AppConfig, AppState}; diff --git a/rust/crates/mnote-web/src/middleware/request_context.rs b/rust/crates/mnote-web/src/middleware/request_context.rs index 1958f1b6..40dc1621 100644 --- a/rust/crates/mnote-web/src/middleware/request_context.rs +++ b/rust/crates/mnote-web/src/middleware/request_context.rs @@ -4,11 +4,8 @@ use axum::middleware::Next; use axum::response::Response; pub async fn inject_request_context(mut request: Request, next: Next) -> Response { - let context = RequestContext::from_http_parts( - request.method(), - request.uri(), - request.headers(), - ); + let context = + RequestContext::from_http_parts(request.method(), request.uri(), request.headers()); request.extensions_mut().insert(context.clone()); let mut response = next.run(request).await; diff --git a/rust/crates/mnote-web/src/routes/command_support.rs b/rust/crates/mnote-web/src/routes/command_support.rs index 78e3e3c0..33d68e6b 100644 --- a/rust/crates/mnote-web/src/routes/command_support.rs +++ b/rust/crates/mnote-web/src/routes/command_support.rs @@ -78,7 +78,11 @@ pub fn build_tree_target( } } -pub fn ensure_non_empty(value: &str, field: &'static str, context: &RequestContext) -> Result { +pub fn ensure_non_empty( + value: &str, + field: &'static str, + context: &RequestContext, +) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { return Err(WebError::bad_request_code( @@ -99,12 +103,11 @@ pub fn read_optional_non_empty(value: Option) -> Option { pub fn ensure_sort_order(sort_order: i64, context: &RequestContext) -> Result { if sort_order < 0 { - return Err(WebError::bad_request_code( - "tree_command_validation", - "sortOrder 不能小于 0", - ) - .with_context(context) - .with_header("x-error-phase", "tree_command_validate")); + return Err( + WebError::bad_request_code("tree_command_validation", "sortOrder 不能小于 0") + .with_context(context) + .with_header("x-error-phase", "tree_command_validate"), + ); } Ok(sort_order) } diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index ae68bd44..e05a16d6 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -2,9 +2,7 @@ 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::{load_projection_snapshot, ProjectionSnapshotSpec}; use axum::extract::Query; use axum::extract::{Extension, State}; use axum::http::StatusCode; @@ -77,10 +75,7 @@ pub async fn next_sidebar( .with_header("x-error-phase", "compat_sidebar_shape") .with_header("x-upstream-service", "convex") })?; - dataset_object.insert( - "kernel_sidebar_projection".into(), - snapshot.projection, - ); + dataset_object.insert("kernel_sidebar_projection".into(), snapshot.projection); Ok(( StatusCode::OK, diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index cc3cc178..3da51cd0 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -1,9 +1,7 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use crate::routes::query_support::{ - resolve_effective_workspace_id, -}; +use crate::routes::query_support::resolve_effective_workspace_id; use crate::routes::snapshot_support::{ execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, ProjectionSnapshotSpec, @@ -59,10 +57,11 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json, Extension(context): Extension, Query(query): Query, + projection: KernelProjectionKind, ) -> Result<(StatusCode, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? @@ -74,7 +73,7 @@ pub async fn project_sidebar( workspace_id: &effective_workspace_id, root_node_id: query.root_node_id.as_deref(), depth: query.depth, - projection: KernelProjectionKind::SidebarTree, + projection, }, ) .await?; @@ -82,6 +81,38 @@ pub async fn project_sidebar( Ok(ok_response(&context, snapshot.projection)) } +pub async fn project_tree_sidebar( + state: State, + context: Extension, + query: Query, +) -> Result<(StatusCode, Json), WebError> { + project_projection(state, context, query, KernelProjectionKind::SidebarTree).await +} + +pub async fn project_tree_page( + state: State, + context: Extension, + query: Query, +) -> Result<(StatusCode, Json), WebError> { + project_projection(state, context, query, KernelProjectionKind::PageTree).await +} + +pub async fn project_tree_file( + state: State, + context: Extension, + query: Query, +) -> Result<(StatusCode, Json), WebError> { + project_projection(state, context, query, KernelProjectionKind::FileTree).await +} + +pub async fn project_sidebar( + state: State, + context: Extension, + query: Query, +) -> Result<(StatusCode, Json), WebError> { + project_tree_sidebar(state, context, query).await +} + pub async fn subtree( State(state): State, Extension(context): Extension, @@ -154,8 +185,9 @@ pub async fn graph( #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; - use axum::body::Body; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; + use serde_json::Value; use tower::util::ServiceExt; fn app() -> axum::Router { @@ -168,7 +200,7 @@ mod tests { convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, - query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()), + query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()), mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), @@ -190,4 +222,169 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + + #[tokio::test] + async fn tree_projection_contract_route_keeps_shared_fields() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/kernel/projections/sidebar?workspaceId=ws_demo&rootNodeId=page_root") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + let first = &payload["result"]["items"][0]; + assert_eq!(first["projectionKind"], "sidebar_tree"); + assert_eq!(first["resourceMeta"]["resourceKind"], "document"); + assert_eq!(first["iconHint"], "page"); + } + + #[tokio::test] + async fn tree_projection_routes_return_sidebar_page_and_file_projection_kinds() { + let cases = [ + ( + "/api/tree/projections/sidebar?workspaceId=ws_demo&rootNodeId=page_root", + "sidebar_tree", + ), + ( + "/api/tree/projections/page?workspaceId=ws_demo&rootNodeId=page_root", + "page_tree", + ), + ( + "/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root", + "file_tree", + ), + ]; + + for (uri, expected_projection_kind) in cases { + let response = app() + .oneshot( + Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["result"]["projection"], expected_projection_kind); + assert_eq!( + payload["result"]["items"][0]["projectionKind"], + expected_projection_kind + ); + } + } + + #[tokio::test] + async fn tree_projection_routes_keep_ok_response_shape() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/tree/projections/page?workspaceId=ws_demo&rootNodeId=page_root") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["ok"], true); + assert_eq!(payload["result"]["projection"], "page_tree"); + assert_eq!(payload["result"]["rootNodeId"], "page_root"); + } + + #[tokio::test] + async fn file_tree_projection_includes_index_and_asset_rows() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + let items = payload["result"]["items"].as_array().expect("items"); + let row_kinds = items + .iter() + .filter_map(|item| item["rowKind"].as_str()) + .collect::>(); + let resource_kinds = items + .iter() + .filter_map(|item| item["resourceMeta"]["resourceKind"].as_str()) + .collect::>(); + + assert!(row_kinds.contains(&"document")); + assert!(row_kinds.contains(&"index")); + assert!(row_kinds.contains(&"asset")); + assert!(row_kinds.contains(&"asset_folder")); + assert!(resource_kinds.contains(&"document")); + assert!(resource_kinds.contains(&"index")); + assert!(resource_kinds.contains(&"asset")); + assert!(resource_kinds.contains(&"mindmap")); + assert!(resource_kinds.contains(&"table")); + assert_eq!(items[0]["nodeId"], "page_root"); + assert_eq!(items[1]["nodeId"], "index:page_root"); + } + + #[tokio::test] + async fn file_tree_projection_resource_variants_keep_icon_hint_and_capabilities() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + let item_by_row_id = payload["result"]["items"] + .as_array() + .expect("items") + .iter() + .filter_map(|item| item["rowId"].as_str().map(|row_id| (row_id, item))) + .collect::>(); + + assert_eq!(item_by_row_id["asset:asset_pdf_1"]["iconHint"], "pdf"); + assert_eq!(item_by_row_id["asset:asset_book_1"]["iconHint"], "book"); + assert_eq!(item_by_row_id["asset:table_1"]["iconHint"], "table"); + assert_eq!(item_by_row_id["asset-folder:mind_1"]["iconHint"], "mindmap"); + assert_eq!( + 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")); + } } diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index efb24511..8540fa49 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -22,6 +22,12 @@ pub fn build_router(state: AppState) -> Router { Router::new() .route("/health", get(health::health)) .route("/tree", get(tree::tree_shell)) + .route( + "/api/tree/projections/sidebar", + get(kernel::project_tree_sidebar), + ) + .route("/api/tree/projections/page", get(kernel::project_tree_page)) + .route("/api/tree/projections/file", get(kernel::project_tree_file)) .route( "/api/kernel/projections/sidebar", get(kernel::project_sidebar), diff --git a/rust/crates/mnote-web/src/routes/query_support.rs b/rust/crates/mnote-web/src/routes/query_support.rs index 72aee20b..ab846110 100644 --- a/rust/crates/mnote-web/src/routes/query_support.rs +++ b/rust/crates/mnote-web/src/routes/query_support.rs @@ -126,6 +126,7 @@ pub async fn execute_runtime_query_via_convex( effective_workspace_id: Option<&str>, query: RuntimeQueryEnvelopeWire, ) -> Result { - let data = fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone()).await?; + let data = + fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone()).await?; execute_runtime_query_against_data(context, effective_workspace_id, query, data) } diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index ff6e5d2b..9e0ab2c0 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -5,12 +5,8 @@ use crate::routes::command_support::{ build_tree_target, ensure_non_empty, ensure_sort_order, execute_runtime_command_via_convex, read_optional_non_empty, }; -use crate::routes::query_support::{ - resolve_effective_workspace_id, -}; -use crate::routes::snapshot_support::{ - load_projection_snapshot, ProjectionSnapshotSpec, -}; +use crate::routes::query_support::resolve_effective_workspace_id; +use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec}; use axum::extract::{Extension, Query, State}; use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; @@ -118,7 +114,12 @@ fn normalize_tree_mode(value: Option<&str>) -> &'static str { } fn normalize_bool_flag(value: Option<&str>, default: bool) -> bool { - match value.unwrap_or_default().trim().to_ascii_lowercase().as_str() { + match value + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { "1" | "true" | "yes" | "on" => true, "0" | "false" | "no" | "off" => false, _ => default, @@ -812,6 +813,7 @@ fn build_tree_shell_html( nodeId: normalizeText(item?.nodeId), parentNodeId: normalizeParent(item?.parentNodeId), title: normalizeText(item?.title, "无标题"), + depth: normalizeNumber(item?.depth, 0), childCount: normalizeNumber(item?.childCount, 0), position: normalizeNumber(item?.position), expandedByDefault: item?.expandedByDefault !== false, @@ -971,6 +973,17 @@ fn build_tree_shell_html( `, + edit: ` + + `, + up: ` + + `, page: ` Result Option { + context + .auth + .cookie_header + .as_deref() + .and_then(|cookie_header| { + cookie_header.split(';').find_map(|segment| { + let (key, value) = segment.trim().split_once('=')?; + if key.trim() != name { + return None; + } + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.to_string()) + }) + }) } fn convex_url(config: &AppConfig, context: &RequestContext) -> Result { @@ -303,7 +311,10 @@ pub async fn execute_convex_command_plan( if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") { return Err(WebError::bad_request_code( "transport_command_unsupported", - format!("mnote-web transport 暂不支持 command: {}", plan.function_name), + format!( + "mnote-web transport 暂不支持 command: {}", + plan.function_name + ), ) .with_context(context) .with_header("x-error-phase", "plan_validation")); @@ -359,7 +370,10 @@ pub async fn execute_convex_command_plan( let response = request.send().await.map_err(|error| { let base = if error.is_timeout() { - WebError::gateway_timeout_code("convex_timeout", format!("Convex mutation 超时: {error}")) + WebError::gateway_timeout_code( + "convex_timeout", + format!("Convex mutation 超时: {error}"), + ) } else { WebError::service_unavailable_code( "convex_unavailable", @@ -419,3 +433,77 @@ pub async fn execute_convex_command_plan( .with_header("x-upstream-status", status.as_u16().to_string())), } } + +#[cfg(test)] +mod tests { + use super::build_authorization; + use crate::app::AppConfig; + use crate::context::RequestContext; + use axum::http::{HeaderMap, HeaderValue, Method, Uri}; + + fn config() -> AppConfig { + AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:3104".into(), + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: Some("http://127.0.0.1:3210".into()), + convex_admin_key: Some("admin-demo".into()), + allow_dev_fixtures: false, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + } + } + + fn request_context(headers: HeaderMap) -> RequestContext { + RequestContext::from_http_parts( + &Method::GET, + &"/api/compat/next/sidebar?workspaceId=ws_demo" + .parse::() + .expect("uri"), + &headers, + ) + } + + #[test] + fn build_authorization_prefers_forwarded_authorization() { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_static("Bearer real-token"), + ); + + let authorization = + build_authorization(&config(), &request_context(headers)).expect("authorization"); + + assert_eq!(authorization, "Bearer real-token"); + } + + #[test] + fn build_authorization_falls_back_to_plain_admin_auth() { + let authorization = build_authorization(&config(), &request_context(HeaderMap::new())) + .expect("authorization"); + + assert_eq!(authorization, "Convex admin-demo"); + } + + #[test] + fn build_authorization_reads_convex_token_from_cookie() { + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + HeaderValue::from_static( + "foo=bar; mnote_web_convex_token=token-from-cookie; theme=light", + ), + ); + + let authorization = + build_authorization(&config(), &request_context(headers)).expect("authorization"); + + assert_eq!(authorization, "Bearer token-from-cookie"); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/dispatcher.rs b/rust/crates/mnote-web/src/tree_shell/dispatcher.rs new file mode 100644 index 00000000..939c9227 --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/dispatcher.rs @@ -0,0 +1,39 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TreeShellCommandAction { + Create, + Rename, + Move, + Archive, + Restore, + Purge, + Embed, + Copy, +} + +impl TreeShellCommandAction { + pub fn preferred_command_name(self) -> &'static str { + match self { + Self::Create => "tree.node.create", + Self::Rename => "tree.node.rename", + Self::Move => "tree.subtree.move", + Self::Archive => "tree.node.archive", + Self::Restore => "tree.node.restore", + Self::Purge => "tree.node.purge", + Self::Embed => "tree.node.embed", + Self::Copy => "tree.subtree.copy", + } + } + + pub fn compat_command_name(self) -> &'static str { + match self { + Self::Create => "documents.create", + Self::Rename => "documents.title.update", + Self::Move => "documents.move", + Self::Archive => "documents.delete", + Self::Restore => "documents.restore", + Self::Purge => "documents.purge", + Self::Embed => "documents.embed", + Self::Copy => "documents.copy_tree", + } + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs new file mode 100644 index 00000000..5523c38e --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs @@ -0,0 +1,21 @@ +use super::protocol; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileTreeRenderRow { + pub row_id: String, + pub row_kind: String, + pub title: String, +} + +pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str, String)> { + rows.iter() + .map(|row| { + let test_id = match row.row_kind.as_str() { + "document" => protocol::TEST_ID_FILETREE_DOC_ROW, + "index" => protocol::TEST_ID_FILETREE_INDEX_ROW, + _ => protocol::TEST_ID_FILETREE_ASSET_ROW, + }; + (test_id, row.row_id.clone()) + }) + .collect() +} diff --git a/rust/crates/mnote-web/src/tree_shell/loader.rs b/rust/crates/mnote-web/src/tree_shell/loader.rs new file mode 100644 index 00000000..67f9a342 --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/loader.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TreeShellBootstrap { + pub workspace_id: String, + pub root_node_id: Option, + pub mode: String, + pub request_id: String, + pub trace_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TreeShellLoader { + pub bootstrap: TreeShellBootstrap, +} + +impl TreeShellLoader { + pub fn from_bootstrap(bootstrap: TreeShellBootstrap) -> Self { + Self { bootstrap } + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/mod.rs b/rust/crates/mnote-web/src/tree_shell/mod.rs new file mode 100644 index 00000000..1facd325 --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/mod.rs @@ -0,0 +1,54 @@ +pub mod dispatcher; +pub mod filetree_renderer; +pub mod loader; +pub mod page_renderer; +pub mod picker_renderer; +pub mod protocol; +pub mod state; + +use leptos::prelude::*; + +#[component] +pub fn TreeShellRoot(mode: &'static str, headline: String) -> impl IntoView { + view! { +
+
+

"mnote tree shell"

+

{headline}

+
+
+ } +} + +#[cfg(test)] +mod tests { + use super::dispatcher::TreeShellCommandAction; + use super::loader::{TreeShellBootstrap, TreeShellLoader}; + use super::protocol; + use super::state::TreeShellUiState; + + #[test] + fn tree_shell_leptos_scaffold_exposes_loader_dispatcher_and_state() { + let bootstrap = TreeShellBootstrap { + workspace_id: "ws_demo".into(), + root_node_id: Some("page_root".into()), + mode: protocol::MODE_PAGE.into(), + request_id: "req_demo".into(), + trace_id: "trace_demo".into(), + }; + let loader = TreeShellLoader::from_bootstrap(bootstrap.clone()); + let mut state = TreeShellUiState::from_defaults(&["page_root".into()]); + state.select("page_root"); + state.focus("page_root"); + + assert_eq!(loader.bootstrap.workspace_id, "ws_demo"); + assert_eq!( + TreeShellCommandAction::Create.preferred_command_name(), + "tree.node.create" + ); + assert!(state.expanded.contains("page_root")); + assert_eq!(state.selected.as_deref(), Some("page_root")); + assert_eq!(state.focused.as_deref(), Some("page_root")); + assert_eq!(bootstrap.mode, protocol::MODE_PAGE); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/page_renderer.rs b/rust/crates/mnote-web/src/tree_shell/page_renderer.rs new file mode 100644 index 00000000..fe1ec1c5 --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/page_renderer.rs @@ -0,0 +1,66 @@ +use super::protocol; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageTreeRenderRow { + pub node_id: String, + pub title: String, + pub depth: u32, + pub expandable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageTreeDomRow { + pub test_id: &'static str, + pub action_create_test_id: &'static str, + pub action_rename_test_id: &'static str, + pub action_move_up_test_id: &'static str, + pub node_id: String, + pub depth: u32, + pub title: String, + pub expandable: bool, +} + +pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec { + rows.iter() + .map(|row| PageTreeDomRow { + test_id: protocol::TEST_ID_TREE_NODE_OPEN, + action_create_test_id: protocol::TEST_ID_TREE_ACTION_CREATE, + action_rename_test_id: protocol::TEST_ID_TREE_ACTION_RENAME, + action_move_up_test_id: protocol::TEST_ID_TREE_ACTION_MOVE_UP, + node_id: row.node_id.clone(), + depth: row.depth, + title: row.title.clone(), + expandable: row.expandable, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{build_page_tree_dom_rows, PageTreeRenderRow}; + + #[test] + fn tree_shell_page_renderer_builds_rows_with_stable_testids() { + let rows = build_page_tree_dom_rows(&[ + PageTreeRenderRow { + node_id: "page_root".into(), + title: "首页".into(), + depth: 0, + expandable: true, + }, + PageTreeRenderRow { + node_id: "page_child".into(), + title: "子页".into(), + depth: 1, + expandable: false, + }, + ]); + + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].test_id, "tree-node-open"); + assert_eq!(rows[0].action_create_test_id, "tree-action-create"); + assert_eq!(rows[0].action_rename_test_id, "tree-action-rename"); + assert_eq!(rows[0].action_move_up_test_id, "tree-action-move-up"); + assert_eq!(rows[1].depth, 1); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs b/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs new file mode 100644 index 00000000..33244dd3 --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/picker_renderer.rs @@ -0,0 +1,59 @@ +use super::protocol; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickerRenderRow { + pub node_id: String, + pub title: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PickerRenderResult { + pub root_test_id: &'static str, + pub item_count: usize, + pub allow_root_pick: bool, +} + +pub fn build_picker_render_result( + rows: &[PickerRenderRow], + allow_root_pick: bool, +) -> PickerRenderResult { + PickerRenderResult { + root_test_id: protocol::TEST_ID_TREE_PICKER_ROOT, + item_count: rows.len(), + allow_root_pick, + } +} + +#[cfg(test)] +mod tests { + use super::super::filetree_renderer::{build_filetree_testids, FileTreeRenderRow}; + use super::{build_picker_render_result, PickerRenderRow}; + + #[test] + fn tree_shell_filetree_picker_builds_file_rows_and_picker_mode() { + let file_rows = build_filetree_testids(&[ + FileTreeRenderRow { + row_id: "doc:page_root".into(), + row_kind: "document".into(), + title: "首页".into(), + }, + FileTreeRenderRow { + row_id: "asset:asset_1".into(), + row_kind: "asset".into(), + title: "附件".into(), + }, + ]); + let picker = build_picker_render_result( + &[PickerRenderRow { + node_id: "page_root".into(), + title: "首页".into(), + }], + true, + ); + + assert_eq!(file_rows[0].0, "filetree-doc-row"); + assert_eq!(file_rows[1].0, "filetree-asset-row"); + assert_eq!(picker.root_test_id, "tree-picker-root"); + assert!(picker.allow_root_pick); + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/protocol.rs b/rust/crates/mnote-web/src/tree_shell/protocol.rs new file mode 100644 index 00000000..4b24db64 --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/protocol.rs @@ -0,0 +1,14 @@ +pub const MODE_PAGE: &str = "page"; +pub const MODE_FILETREE: &str = "filetree"; +pub const MODE_PICKER: &str = "picker"; + +pub const TEST_ID_TREE_SHELL_ROOT: &str = "tree-shell-root"; +pub const TEST_ID_TREE_NODE_OPEN: &str = "tree-node-open"; +pub const TEST_ID_TREE_NODE_TOGGLE: &str = "tree-node-toggle"; +pub const TEST_ID_TREE_ACTION_CREATE: &str = "tree-action-create"; +pub const TEST_ID_TREE_ACTION_RENAME: &str = "tree-action-rename"; +pub const TEST_ID_TREE_ACTION_MOVE_UP: &str = "tree-action-move-up"; +pub const TEST_ID_TREE_PICKER_ROOT: &str = "tree-picker-root"; +pub const TEST_ID_FILETREE_DOC_ROW: &str = "filetree-doc-row"; +pub const TEST_ID_FILETREE_INDEX_ROW: &str = "filetree-index-row"; +pub const TEST_ID_FILETREE_ASSET_ROW: &str = "filetree-asset-row"; diff --git a/rust/crates/mnote-web/src/tree_shell/state.rs b/rust/crates/mnote-web/src/tree_shell/state.rs new file mode 100644 index 00000000..a7580a4d --- /dev/null +++ b/rust/crates/mnote-web/src/tree_shell/state.rs @@ -0,0 +1,75 @@ +use std::collections::BTreeSet; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TreeShellUiState { + pub expanded: BTreeSet, + pub selected: Option, + pub focused: Option, + pub keyboard_anchor: Option, + pub dragging: Option, + pub drop_target: Option, +} + +impl TreeShellUiState { + pub fn from_defaults(expanded: &[String]) -> Self { + Self { + expanded: expanded.iter().cloned().collect(), + ..Self::default() + } + } + + pub fn toggle_expanded(&mut self, node_id: &str) { + if !self.expanded.insert(node_id.to_string()) { + self.expanded.remove(node_id); + } + } + + pub fn select(&mut self, node_id: &str) { + self.selected = Some(node_id.to_string()); + self.keyboard_anchor = Some(node_id.to_string()); + } + + pub fn focus(&mut self, node_id: &str) { + self.focused = Some(node_id.to_string()); + } + + pub fn start_drag(&mut self, node_id: &str) { + self.dragging = Some(node_id.to_string()); + } + + pub fn set_drop_target(&mut self, node_id: Option<&str>) { + self.drop_target = node_id.map(ToOwned::to_owned); + } + + pub fn clear_drag(&mut self) { + self.dragging = None; + self.drop_target = None; + } +} + +#[cfg(test)] +mod tests { + use super::TreeShellUiState; + + #[test] + fn tree_shell_interaction_state_tracks_selection_focus_keyboard_and_dragging() { + let mut state = TreeShellUiState::from_defaults(&["page_root".into()]); + state.toggle_expanded("page_child"); + state.select("page_child"); + state.focus("page_child"); + state.start_drag("page_child"); + state.set_drop_target(Some("page_root")); + + assert!(state.expanded.contains("page_root")); + assert!(state.expanded.contains("page_child")); + assert_eq!(state.selected.as_deref(), Some("page_child")); + assert_eq!(state.focused.as_deref(), Some("page_child")); + assert_eq!(state.keyboard_anchor.as_deref(), Some("page_child")); + assert_eq!(state.dragging.as_deref(), Some("page_child")); + assert_eq!(state.drop_target.as_deref(), Some("page_root")); + + state.clear_drag(); + assert!(state.dragging.is_none()); + assert!(state.drop_target.is_none()); + } +} diff --git a/rust/crates/storage-convex-bridge/src/mapping.rs b/rust/crates/storage-convex-bridge/src/mapping.rs index 9b6eaa68..d79f4e95 100644 --- a/rust/crates/storage-convex-bridge/src/mapping.rs +++ b/rust/crates/storage-convex-bridge/src/mapping.rs @@ -34,6 +34,14 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str { "kernel.subtree.move" => "kernel:moveSubtree", "kernel.edge.attach" => "kernel:attachEdge", "kernel.edge.detach" => "kernel:detachEdge", + "tree.node.create" => "documents:createWithParentReference", + "tree.node.rename" => "documents:updateTitle", + "tree.node.archive" => "documents:softDelete", + "tree.node.restore" => "documents:restore", + "tree.node.purge" => "documents:purge", + "tree.subtree.move" => "documents:move", + "tree.subtree.copy" => "documents:copyTree", + "tree.node.embed" => "documents:updateContent", "documents.create" => "documents:createWithParentReference", "documents.move" => "documents:move", "documents.delete" => "documents:softDelete", diff --git a/scripts/task091-tree-shell-smoke.js b/scripts/task091-tree-shell-smoke.js index 9a127942..43e07393 100644 --- a/scripts/task091-tree-shell-smoke.js +++ b/scripts/task091-tree-shell-smoke.js @@ -175,7 +175,6 @@ async function runTreeShellRegression(page, requestContext, viewer, target) { await toggleButton.click({ timeout: UI_TIMEOUT_MS }); await childRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - page.once("dialog", (dialog) => dialog.accept(createdTitle)); const createResponsePromise = page.waitForResponse( (response) => response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) && diff --git a/scripts/task101-tree-shell-cutover-smoke.js b/scripts/task101-tree-shell-cutover-smoke.js new file mode 100644 index 00000000..f9f04077 --- /dev/null +++ b/scripts/task101-tree-shell-cutover-smoke.js @@ -0,0 +1,158 @@ +"use strict"; + +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + assert, + cleanupDocuments, + ensureAuthenticated, + openDocument, + openSectionView, + prepareTempTreeFixture, +} = require("./tree-shell-smoke-helpers"); + +async function runPrimaryPath(page, fixture) { + const streamRequestPromise = page.waitForRequest( + (request) => + request.url().includes("/api/stream/events") && + request.method() === "GET", + { timeout: UI_TIMEOUT_MS }, + ); + + await openDocument(page, fixture.workspaceId, fixture.parentId); + await openSectionView(page); + const streamRequest = await streamRequestPromise; + + const shellHost = page.getByTestId("sidebar-page-tree-shell"); + const firstVisibleStart = Date.now(); + await shellHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const parentRow = shellHost.locator(`[data-testid="page-tree-row"][data-node-id="${fixture.parentId}"]`); + const childRow = shellHost.locator(`[data-testid="page-tree-row"][data-node-id="${fixture.childId}"]`); + await parentRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await childRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const firstVisibleMs = Date.now() - firstVisibleStart; + + assert( + (await shellHost.locator('iframe[title="mnote-web tree shell"]').count()) === 0, + "Sidebar 主树仍残留 iframe tree shell", + ); + + const navigateStart = Date.now(); + await childRow.getByTestId("page-tree-open").click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.toString().includes(`/documents/${fixture.childId}`), { + timeout: UI_TIMEOUT_MS, + }); + const navigateMs = Date.now() - navigateStart; + + return { + streamUrl: streamRequest.url(), + firstVisibleMs, + navigateMs, + }; +} + +async function runFallbackPath(context, fixture) { + const page = await context.newPage(); + let blockedCount = 0; + await page.route("**/api/stream/events**", async (route) => { + blockedCount += 1; + await route.abort("failed"); + }); + + try { + await openDocument(page, fixture.workspaceId, fixture.parentId); + await openSectionView(page); + + const shellHost = page.getByTestId("sidebar-page-tree-shell"); + const fallbackStart = Date.now(); + await shellHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await shellHost.locator(`[data-testid="page-tree-row"][data-node-id="${fixture.parentId}"]`).waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await shellHost.locator(`[data-testid="page-tree-row"][data-node-id="${fixture.childId}"]`).waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const fallbackVisibleMs = Date.now() - fallbackStart; + + assert(blockedCount > 0, "未命中 stream fallback 拦截"); + assert( + (await shellHost.locator('iframe[title="mnote-web tree shell"]').count()) === 0, + "fallback 路径仍残留 iframe tree shell", + ); + + return { + blockedCount, + fallbackVisibleMs, + }; + } finally { + await page.close().catch(() => undefined); + } +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 960 }, + }); + const page = await context.newPage(); + + let caughtError = null; + let result = null; + let fixture = null; + + try { + const viewer = await ensureAuthenticated(page, context.request); + fixture = await prepareTempTreeFixture(context.request); + + const primary = await runPrimaryPath(page, fixture); + const fallback = await runFallbackPath(context, fixture); + + result = { + ok: true, + baseUrl: BASE_URL, + viewerUserId: viewer.userId, + workspaceId: fixture.workspaceId, + parentId: fixture.parentId, + childId: fixture.childId, + primary, + fallback, + }; + + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + caughtError = error; + } finally { + if (fixture) { + try { + await cleanupDocuments(context.request, fixture.createdIds); + } catch (cleanupError) { + if (!caughtError) { + caughtError = cleanupError; + } else { + console.error( + `清理临时页面失败:${ + cleanupError instanceof Error + ? cleanupError.stack || cleanupError.message + : String(cleanupError) + }`, + ); + } + } + } + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } + + if (caughtError) { + throw caughtError; + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task102-tree-shell-regression.js b/scripts/task102-tree-shell-regression.js new file mode 100644 index 00000000..db83de33 --- /dev/null +++ b/scripts/task102-tree-shell-regression.js @@ -0,0 +1,188 @@ +"use strict"; + +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + assert, + cleanupDocuments, + ensureAuthenticated, + ensurePageOptionsVisible, + openDocument, + openFilesystemView, + openSectionView, + prepareTempTreeFixture, +} = require("./tree-shell-smoke-helpers"); + +async function runPageTreeChecks(page, fixture) { + await openDocument(page, fixture.workspaceId, fixture.parentId); + await openSectionView(page); + + const shellHost = page.getByTestId("sidebar-page-tree-shell"); + await shellHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await shellHost.locator(`[data-testid="page-tree-row"][data-node-id="${fixture.parentId}"]`).waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await shellHost.locator(`[data-testid="page-tree-row"][data-node-id="${fixture.childId}"]`).waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + + return { + pageTreeVisible: true, + }; +} + +async function runFileTreeChecks(page, fixture) { + await openFilesystemView(page); + + const shellHost = page.getByTestId("sidebar-file-tree-shell"); + const openStart = Date.now(); + await shellHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + + const parentDocRow = shellHost.locator(`[data-testid="filetree-doc-row"][data-doc-id="${fixture.parentId}"]`).first(); + const childDocRow = shellHost.locator(`[data-testid="filetree-doc-row"][data-doc-id="${fixture.childId}"]`).first(); + const parentIndexRow = shellHost.locator(`[data-testid="filetree-index-row"][data-doc-id="${fixture.parentId}"]`).first(); + await parentDocRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await childDocRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await parentIndexRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const filetreeVisibleMs = Date.now() - openStart; + + assert( + (await shellHost.locator('iframe[title="mnote-web tree shell"]').count()) === 0, + "文件树主路径仍残留 iframe tree shell", + ); + + const navigateStart = Date.now(); + await childDocRow.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.toString().includes(`/documents/${fixture.childId}`), { + timeout: UI_TIMEOUT_MS, + }); + const filetreeNavigateMs = Date.now() - navigateStart; + + return { + filetreeVisibleMs, + filetreeNavigateMs, + }; +} + +async function runPickerChecks(page, fixture) { + await ensurePageOptionsVisible(page); + + const openButton = page.getByRole("button", { name: "移动/嵌入到..." }); + await openButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + + const openStart = Date.now(); + await openButton.click({ timeout: UI_TIMEOUT_MS }); + 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 }); + const pickerVisibleMs = Date.now() - openStart; + + assert( + (await dialog.locator('iframe[title="mnote-web tree shell"]').count()) === 0, + "picker 默认路径仍残留 iframe tree shell", + ); + + const rootPick = dialog.getByTestId("tree-picker-root"); + await rootPick.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.keyboard.press("Escape"); + await dialog.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS }); + + await openButton.click({ timeout: UI_TIMEOUT_MS }); + await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const searchInput = dialog.getByPlaceholder("移动到..."); + await searchInput.fill(fixture.childTitle, { timeout: UI_TIMEOUT_MS }); + const searchStart = Date.now(); + await page.waitForTimeout(1000); + const nextErrorOverlay = page.locator('[data-nextjs-dialog-content="true"]'); + assert( + (await nextErrorOverlay.count()) === 0, + "picker 搜索触发了前端运行时错误", + ); + const searchButtons = dialog.getByRole("button"); + const emptyState = dialog.getByText("没有匹配结果"); + const hasSearchOutcome = + (await searchButtons.count()) > 0 || (await emptyState.count()) > 0; + assert(hasSearchOutcome, "picker 搜索后既没有结果也没有空态提示"); + const pickerSearchMs = Date.now() - searchStart; + await page.keyboard.press("Escape"); + await dialog.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS }); + + return { + pickerVisibleMs, + pickerSearchMs, + }; +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 960 }, + }); + const page = await context.newPage(); + + let caughtError = null; + let fixture = null; + + try { + const viewer = await ensureAuthenticated(page, context.request); + fixture = await prepareTempTreeFixture(context.request); + + const pageTree = await runPageTreeChecks(page, fixture); + const filetree = await runFileTreeChecks(page, fixture); + const picker = await runPickerChecks(page, fixture); + + console.log( + JSON.stringify( + { + ok: true, + baseUrl: BASE_URL, + viewerUserId: viewer.userId, + workspaceId: fixture.workspaceId, + parentId: fixture.parentId, + childId: fixture.childId, + pageTree, + filetree, + picker, + }, + null, + 2, + ), + ); + } catch (error) { + caughtError = error; + } finally { + if (fixture) { + try { + await cleanupDocuments(context.request, fixture.createdIds); + } catch (cleanupError) { + if (!caughtError) { + caughtError = cleanupError; + } else { + console.error( + `清理临时页面失败:${ + cleanupError instanceof Error + ? cleanupError.stack || cleanupError.message + : String(cleanupError) + }`, + ); + } + } + } + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } + + if (caughtError) { + throw caughtError; + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/tree-shell-smoke-helpers.js b/scripts/tree-shell-smoke-helpers.js new file mode 100644 index 00000000..e14ba86a --- /dev/null +++ b/scripts/tree-shell-smoke-helpers.js @@ -0,0 +1,183 @@ +"use strict"; + +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3104").replace(/\/+$/, ""); +const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录"; + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function requestJson(requestContext, path, init = {}) { + const response = await requestContext.fetch(`${BASE_URL}${path}`, { + ...init, + headers: + init.data !== undefined + ? { + "content-type": "application/json", + ...(init.headers || {}), + } + : { + ...(init.headers || {}), + }, + timeout: REQUEST_TIMEOUT_MS, + }); + + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + + if (!response.ok()) { + throw new Error( + `${path} 请求失败: ${response.status()} ${response.statusText()} ${ + typeof payload === "string" ? payload : JSON.stringify(payload) + }`, + ); + } + + const contentType = response.headers()["content-type"] || ""; + if (!contentType.includes("application/json")) { + const snippet = + typeof payload === "string" + ? payload.slice(0, 200) + : JSON.stringify(payload).slice(0, 200); + throw new Error(`${path} 返回了非 JSON 内容:${snippet}`); + } + + return payload; +} + +async function createTempDocument(requestContext, parentId = null) { + const payload = await requestJson(requestContext, "/api/documents/create", { + method: "POST", + data: { parentId }, + }); + assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id"); + assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id"); + return { + documentId: payload.id, + workspaceId: payload.workspace_id, + }; +} + +async function renameDocument(requestContext, workspaceId, documentId, title) { + await requestJson(requestContext, "/api/documents/title", { + method: "POST", + data: { + workspaceId, + documentId, + title, + }, + }); +} + +async function purgeDocument(requestContext, documentId) { + await requestJson(requestContext, "/api/documents/purge", { + method: "POST", + data: { documentId }, + }); +} + +async function getViewerIdentity(requestContext) { + const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" }); + assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId"); + return payload; +} + +async function ensureAuthenticated(page, requestContext) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + + if (page.url().includes("/auth")) { + const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME }); + await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => !url.toString().includes("/auth"), { + timeout: UI_TIMEOUT_MS, + }); + } + + return await getViewerIdentity(requestContext); +} + +async function prepareTempTreeFixture(requestContext) { + const uniqueSuffix = Date.now().toString(); + const parentTitle = `task-tree-parent-${uniqueSuffix}`; + const childTitle = `task-tree-child-${uniqueSuffix}`; + + const parent = await createTempDocument(requestContext, null); + const child = await createTempDocument(requestContext, parent.documentId); + + await renameDocument(requestContext, parent.workspaceId, parent.documentId, parentTitle); + await renameDocument(requestContext, parent.workspaceId, child.documentId, childTitle); + + return { + uniqueSuffix, + workspaceId: parent.workspaceId, + parentId: parent.documentId, + childId: child.documentId, + parentTitle, + childTitle, + createdIds: [parent.documentId, child.documentId], + }; +} + +async function cleanupDocuments(requestContext, createdIds) { + for (const documentId of [...createdIds].reverse()) { + await purgeDocument(requestContext, documentId); + } +} + +async function openDocument(page, workspaceId, documentId) { + const url = `${BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`; + await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + return url; +} + +async function openSectionView(page) { + const button = page.getByRole("button", { name: "分组" }); + await button.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await button.click({ timeout: UI_TIMEOUT_MS }); +} + +async function openFilesystemView(page) { + const button = page.getByRole("button", { name: "文件" }); + await button.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await button.click({ timeout: UI_TIMEOUT_MS }); +} + +async function ensurePageOptionsVisible(page) { + const toggle = page.getByRole("button", { name: /显示页面选项|隐藏页面选项/ }); + await toggle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const label = (await toggle.getAttribute("aria-label")) || ""; + if (label.includes("显示")) { + await toggle.click({ timeout: UI_TIMEOUT_MS }); + } +} + +module.exports = { + BASE_URL, + MNOTE_WEB_BASE_URL, + REQUEST_TIMEOUT_MS, + UI_TIMEOUT_MS, + assert, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + ensurePageOptionsVisible, + getViewerIdentity, + openDocument, + openFilesystemView, + openSectionView, + prepareTempTreeFixture, + purgeDocument, + renameDocument, + requestJson, +}; diff --git a/wolai-frontend/src/app/api/auth/mnote-web-token/route.ts b/wolai-frontend/src/app/api/auth/mnote-web-token/route.ts new file mode 100644 index 00000000..c9759968 --- /dev/null +++ b/wolai-frontend/src/app/api/auth/mnote-web-token/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server"; + +const COOKIE_NAME = "mnote_web_convex_token"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const token = (await convexAuthNextjsToken())?.trim() ?? ""; + + if (!token) { + const response = NextResponse.json({ error: "未登录" }, { status: 401 }); + response.cookies.set({ + name: COOKIE_NAME, + value: "", + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: 0, + }); + response.headers.set("cache-control", "no-store"); + return response; + } + + const response = NextResponse.json({ ok: true }); + response.cookies.set({ + name: COOKIE_NAME, + value: token, + httpOnly: true, + sameSite: "lax", + path: "/", + }); + response.headers.set("cache-control", "no-store"); + return response; +} 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 new file mode 100644 index 00000000..a4216528 --- /dev/null +++ b/wolai-frontend/src/components/documents/move-embed-picker-dialog.test.tsx @@ -0,0 +1,134 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +import { MoveEmbedPickerDialog } from "./move-embed-picker-dialog"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const sidebarData = { + activeWorkspaceId: "ws_test", + workspaces: [], + documents: [], + kernelSidebarProjection: { + projectionId: "kernel_projection:sidebar_tree:workspace_root", + projection: "sidebar_tree", + rootNodeId: null, + items: [], + edges: [], + }, + kernelSidebarTree: [], + trashedDocuments: [], +}; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: unknown[] }) => { + const key = Array.isArray(queryKey) ? queryKey[0] : queryKey; + if (key === "move-embed-picker-sidebar") { + return { + data: sidebarData, + isLoading: false, + error: null, + }; + } + return { + data: null, + isLoading: false, + error: null, + }; + }, +})); + +vi.mock("@/hooks/use-document-search", () => ({ + useDocumentSearch: () => ({ + data: null, + isLoading: false, + error: null, + }), +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ?
{children}
: null), + DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + DialogTitle: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), +})); + +vi.mock("@/components/ui/input", () => ({ + Input: (props: React.InputHTMLAttributes) => , +})); + +vi.mock("@/components/ui/tabs", () => ({ + Tabs: ({ children }: { children: ReactNode }) =>
{children}
, + TabsList: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + TabsTrigger: ({ + children, + className, + value, + }: { + children: ReactNode; + className?: string; + value: string; + }) => ( + + ), + TabsContent: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), +})); + +describe("MoveEmbedPickerDialog", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); + }); + + it("空查询时会使用统一 picker surface 并透传根目录选择", async () => { + const onPick = vi.fn(async () => undefined); + const onOpenChange = vi.fn(); + + await act(async () => { + root.render( + , + ); + }); + + const shellPickButton = container.querySelector('[data-testid="tree-picker-root"]'); + expect(shellPickButton).not.toBeNull(); + + await act(async () => { + shellPickButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onPick).toHaveBeenCalledTimes(1); + expect(onPick).toHaveBeenCalledWith("move", null); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx b/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx index 64c63188..d69cb972 100644 --- a/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx +++ b/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx @@ -3,13 +3,12 @@ import { useCallback, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Search } from "lucide-react"; -import { MnoteWebTreeShell } from "@/components/sidebar/MnoteWebTreeShell"; +import { TreePickerSurface } from "@/components/sidebar/tree-shell-surface"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { SidebarInitialData } from "@/components/sidebar/types"; import { buildPageTreeProjectionItems, buildPickerTreeItems } from "@/lib/tree-projection"; -import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; import { cn } from "@/lib/utils"; import { useDocumentSearch } from "@/hooks/use-document-search"; import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search"; @@ -29,10 +28,6 @@ type PickerItem = | { kind: "root"; id: null; title: string; subtitle?: string } | { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number; raw?: DocumentSearchResult }; -type ViewerIdentity = { - userId: string; -}; - async function fetchSidebarData(workspaceId: string): Promise { const response = await fetch(`/api/sidebar?workspaceId=${encodeURIComponent(workspaceId)}`, { method: "GET", @@ -48,18 +43,6 @@ async function fetchSidebarData(workspaceId: string): Promise { - const response = await fetch("/api/auth/whoami", { - method: "GET", - credentials: "include", - }); - const payload = await response.json().catch(() => ({})); - if (!response.ok || typeof payload?.userId !== "string" || !payload.userId.trim()) { - throw new Error(payload?.error ?? "获取当前用户失败"); - } - return { userId: payload.userId.trim() }; -} - interface MoveEmbedPickerDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -147,10 +130,6 @@ function MoveEmbedPickerDialogBody({ const trimmed = query.trim(); const isEmptyQuery = trimmed.length === 0; - const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []); - const treeShellEnabled = Boolean( - (runtimeConfig.mnoteWebBaseUrl ?? "").trim() && runtimeConfig.mnoteWebTreeShellEnabled === true, - ); const sidebarQuery = useQuery({ queryKey: ["move-embed-picker-sidebar", workspaceId], @@ -165,14 +144,6 @@ function MoveEmbedPickerDialogBody({ gcTime: 60_000, }); - const viewerQuery = useQuery({ - queryKey: ["move-embed-picker-viewer"], - queryFn: fetchViewerIdentity, - enabled: Boolean(workspaceId) && isEmptyQuery && treeShellEnabled, - staleTime: 60_000, - gcTime: 120_000, - }); - const { data, isLoading, error } = useDocumentSearch(payload, Boolean(payload) && !isEmptyQuery); const items = useMemo(() => { @@ -220,7 +191,13 @@ function MoveEmbedPickerDialogBody({ }, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]); const placeholder = mode === "move" ? "移动到..." : "嵌入到..."; - const allowRootPick = allowRoot && mode === "move"; + const handlePick = useCallback( + async (targetId: string | null) => { + await onPick(mode, targetId); + onOpenChange(false); + }, + [mode, onOpenChange, onPick], + ); const pickerFallback = ( sidebarQuery.isLoading ? (
加载中...
@@ -229,39 +206,17 @@ function MoveEmbedPickerDialogBody({ ) : items.length === 0 ? (
没有匹配结果
) : ( -
- {items.map((item, idx) => ( - - ))} -
+ { + void handlePick(targetId); + }} + /> ) ); - const handlePick = useCallback( - async (targetId: string | null) => { - await onPick(mode, targetId); - onOpenChange(false); - }, - [mode, onOpenChange, onPick], - ); - return (
@@ -315,29 +270,7 @@ function MoveEmbedPickerDialogBody({ {!workspaceId ? (
缺少 workspaceId,无法加载页面列表。
) : isEmptyQuery ? ( -
- {!treeShellEnabled ? ( - pickerFallback - ) : viewerQuery.isLoading ? ( -
正在准备选择器...
- ) : viewerQuery.error ? ( -
{String(viewerQuery.error)}
- ) : ( - {}} - onPick={(targetId) => { - void handlePick(targetId); - }} - onRefresh={async () => undefined} - fallback={pickerFallback} - /> - )} -
+
{pickerFallback}
) : isLoading ? (
加载中...
) : error ? ( diff --git a/wolai-frontend/src/components/sidebar/MnoteWebTreeShell.tsx b/wolai-frontend/src/components/sidebar/MnoteWebTreeShell.tsx index 6fb97450..dd28969e 100644 --- a/wolai-frontend/src/components/sidebar/MnoteWebTreeShell.tsx +++ b/wolai-frontend/src/components/sidebar/MnoteWebTreeShell.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; +import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth"; const TREE_SHELL_CHANNEL = "mnote-tree-shell-v1"; const TREE_SHELL_PATH = "/tree"; @@ -170,6 +171,7 @@ export function MnoteWebTreeShell({ const readyTimerRef = useRef(null); const [refreshKey, setRefreshKey] = useState(0); const [failedShellUrl, setFailedShellUrl] = useState(null); + const [authCookieReady, setAuthCookieReady] = useState(false); const shellUrl = useMemo(() => { if (!baseUrl || !treeShellEnabled) return null; @@ -198,7 +200,32 @@ export function MnoteWebTreeShell({ ]); useEffect(() => { - if (!shellUrl) return; + if (!shellUrl) { + setAuthCookieReady(false); + return; + } + let cancelled = false; + setAuthCookieReady(false); + setFailedShellUrl(null); + void (async () => { + try { + await ensureMnoteWebAuthCookie(); + if (!cancelled) { + setAuthCookieReady(true); + } + } catch { + if (!cancelled) { + setFailedShellUrl(shellUrl); + } + } + })(); + return () => { + cancelled = true; + }; + }, [shellUrl]); + + useEffect(() => { + if (!shellUrl || !authCookieReady) return; if (readyTimerRef.current) { window.clearTimeout(readyTimerRef.current); } @@ -213,7 +240,7 @@ export function MnoteWebTreeShell({ readyTimerRef.current = null; } }; - }, [shellUrl]); + }, [authCookieReady, shellUrl]); useEffect(() => { if (!baseUrl || !treeShellEnabled) return; @@ -332,7 +359,7 @@ export function MnoteWebTreeShell({ return () => window.removeEventListener("message", onMessage); }, [baseUrl, onNavigate, onOpenAsset, onOpenContextMenu, onOpenFileTreeContextMenu, onPick, onRefresh, treeShellEnabled]); - if (!shellUrl || failedShellUrl === shellUrl) { + if (!shellUrl || !authCookieReady || failedShellUrl === shellUrl) { return <>{fallback}; } diff --git a/wolai-frontend/src/components/sidebar/file-tree.tsx b/wolai-frontend/src/components/sidebar/file-tree.tsx index 1a28a0f7..27a4b885 100644 --- a/wolai-frontend/src/components/sidebar/file-tree.tsx +++ b/wolai-frontend/src/components/sidebar/file-tree.tsx @@ -134,6 +134,18 @@ export function FileTree({ return (
{ event.stopPropagation(); onToggleExpand(row.docId); @@ -248,6 +261,7 @@ export function FileTree({
{!collapsedSections.private ? (
- {treeShellAvailable ? ( - - -
- } - /> - ) : ( -
- -
- )} +
) : (
已折叠
@@ -2642,66 +2538,30 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
- {treeShellAvailable ? ( - { - const asset = assetById.get(assetId); - if (asset) { - handleOpenAsset(asset); - } - }} - onOpenContextMenu={openContextMenuFromTreeShell} - onOpenFileTreeContextMenu={openFileTreeContextMenuFromTreeShell} - onRefresh={refreshTree} - fallback={ -
- handleFileTreeRowDoubleClick(row, event)} - onRowContextMenu={handleFileTreeRowContextMenu} - onRowDragStart={(row) => handleFileTreeRowDragStart(row)} - onToggleExpand={toggleExpand} - onToggleAssetFolderExpand={toggleAssetFolderExpand} - onCreateChild={handleCreate} - onBlankMouseDown={handleFileTreeBlankMouseDown} - onDropFiles={handleFileTreeDropFiles} - onInternalDrop={handleFileTreeInternalDrop} - /> -
- } - /> - ) : ( -
- handleFileTreeRowDoubleClick(row, event)} - onRowContextMenu={handleFileTreeRowContextMenu} - onRowDragStart={(row) => handleFileTreeRowDragStart(row)} - onToggleExpand={toggleExpand} - onToggleAssetFolderExpand={toggleAssetFolderExpand} - onCreateChild={handleCreate} - onBlankMouseDown={handleFileTreeBlankMouseDown} - onDropFiles={handleFileTreeDropFiles} - onInternalDrop={handleFileTreeInternalDrop} - /> -
- )} + handleResourceRowDoubleClick(row, event)} + onRowContextMenu={handleResourceRowContextMenu} + onRowDragStart={(row, event) => { + void event; + handleResourceRowDragStart(row); + }} + onToggleExpand={toggleExpand} + onToggleAssetFolderExpand={toggleAssetFolderExpand} + onCreateChild={handleCreate} + onBlankMouseDown={handleResourcePaneBlankMouseDown} + onDropFiles={handleResourcePaneDropFiles} + onInternalDrop={handleResourcePaneInternalDrop} + />
diff --git a/wolai-frontend/src/components/sidebar/tree-pane-bindings.ts b/wolai-frontend/src/components/sidebar/tree-pane-bindings.ts new file mode 100644 index 00000000..0e04dab1 --- /dev/null +++ b/wolai-frontend/src/components/sidebar/tree-pane-bindings.ts @@ -0,0 +1,15 @@ +"use client"; + +export type { FileTreeSelectionState as TreePaneSelectionState } from "@/lib/file-tree/selection"; +export { + normalizeFileTreeSelectionForVisibleRows as normalizeTreePaneSelectionForVisibleRows, + reduceFileTreeSelection as reduceTreePaneSelection, +} from "@/lib/file-tree/selection"; +export type { FileTreeRow as TreePaneRow } from "@/lib/file-tree/types"; +export { computeFileTreeDeleteTargets as computeTreePaneDeleteTargets } from "@/lib/file-tree/delete"; +export { + inferPasteTargetDocId, + isTextInputTarget, + readFileTreeClipboardPayload as readTreePaneClipboardPayload, + writeFileTreeClipboardPayload as writeTreePaneClipboardPayload, +} from "@/lib/file-tree/clipboard"; diff --git a/wolai-frontend/src/components/sidebar/tree-shell-surface.tsx b/wolai-frontend/src/components/sidebar/tree-shell-surface.tsx new file mode 100644 index 00000000..3951f2d8 --- /dev/null +++ b/wolai-frontend/src/components/sidebar/tree-shell-surface.tsx @@ -0,0 +1,150 @@ +"use client"; + +import type { DragEvent, MouseEvent } from "react"; +import { FileTree } from "@/components/sidebar/file-tree"; +import { PrivateTree } from "@/components/sidebar/private-tree"; +import type { SidebarTreeNode } from "@/lib/kernel-sidebar"; +import type { FileTreeRow } from "@/lib/file-tree/types"; +import type { PageTreeProjectionItem } from "@/lib/tree-projection"; +import { cn } from "@/lib/utils"; + +type SidebarPageTreeSurfaceProps = { + mode: "page"; + rows: PageTreeProjectionItem[]; + expanded: Set; + activeId: string; + className?: string; + onToggleExpand: (id: string) => void; + onMove: (nodeId: string, parentId: string | null, index: number) => void; + onCreateChild: (parentId: string | null) => void; + onContextMenu: (event: MouseEvent, node: SidebarTreeNode) => void; +}; + +type SidebarFileTreeSurfaceProps = { + mode: "filetree"; + rows: FileTreeRow[]; + activeId: string; + selectedRowIds: Set; + className?: string; + onRowClick: (row: FileTreeRow, event: MouseEvent) => void; + onRowDoubleClick: (row: FileTreeRow, event: MouseEvent) => void; + onRowContextMenu: (row: FileTreeRow, event: MouseEvent) => void; + onRowDragStart?: (row: FileTreeRow, event: DragEvent) => void; + onToggleExpand: (docId: string) => void; + onToggleAssetFolderExpand?: (assetId: string) => void; + onCreateChild: (parentId: string | null) => void; + onBlankMouseDown?: (event: MouseEvent) => void; + onDropFiles?: (docId: string, files: FileList, targetRow?: FileTreeRow) => void; + onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void; +}; + +export type SidebarTreeSurfaceProps = + | SidebarPageTreeSurfaceProps + | SidebarFileTreeSurfaceProps; + +export type TreePickerSurfaceItem = + | { kind: "root"; id: null; title: string; subtitle?: string } + | { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number }; + +type TreePickerSurfaceProps = { + items: TreePickerSurfaceItem[]; + highlighted: number; + className?: string; + emptyText?: string; + onHighlight: (index: number) => void; + onPick: (targetId: string | null) => void; +}; + +export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) { + const surfaceTestId = + props.mode === "page" + ? "sidebar-page-tree-shell" + : "sidebar-file-tree-shell"; + + return ( +
+ {props.mode === "page" ? ( + + ) : ( + + )} +
+ ); +} + +export function TreePickerSurface({ + items, + highlighted, + className, + emptyText = "没有匹配结果", + onHighlight, + onPick, +}: TreePickerSurfaceProps) { + if (items.length === 0) { + return
{emptyText}
; + } + + return ( +
+ {items.map((item, index) => { + const isRoot = item.kind === "root"; + return ( + + ); + })} +
+ ); +} diff --git a/wolai-frontend/src/lib/documents/tree-command-client.test.ts b/wolai-frontend/src/lib/documents/tree-command-client.test.ts new file mode 100644 index 00000000..c4ea8d4c --- /dev/null +++ b/wolai-frontend/src/lib/documents/tree-command-client.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + copyTreeCommand, + createDocumentCommand, + deleteDocumentCommand, + embedDocumentCommand, + moveDocumentCommand, + purgeDocumentCommand, + renameDocumentCommand, + restoreDocumentCommand, +} from "@/lib/documents/tree-command-client"; + +describe("tree-command-client", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("通过统一 client 发送 tree/document command 并返回结果", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ ok: true, success: true, items: [] }), + } as Response); + + await createDocumentCommand(null); + await renameDocumentCommand({ documentId: "doc_1", title: "新标题" }); + await moveDocumentCommand({ documentId: "doc_1", parentId: null, position: 0 }); + await deleteDocumentCommand({ documentId: "doc_1" }); + await restoreDocumentCommand({ documentId: "doc_1" }); + await purgeDocumentCommand({ documentId: "doc_1" }); + await embedDocumentCommand({ sourceId: "doc_1", targetId: "doc_2" }); + await copyTreeCommand({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] }); + + expect(fetchMock).toHaveBeenCalledTimes(8); + expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([ + "/api/documents/create", + "/api/documents/title", + "/api/documents/move", + "/api/documents/delete", + "/api/documents/restore", + "/api/documents/purge", + "/api/documents/embed", + "/api/documents/copy-tree", + ]); + }); + + it("在后端返回错误时抛出统一异常", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + json: async () => ({ error: "删除失败" }), + } as Response); + + await expect(deleteDocumentCommand({ documentId: "doc_1" })).rejects.toThrow("删除失败"); + }); +}); diff --git a/wolai-frontend/src/lib/documents/tree-command-client.ts b/wolai-frontend/src/lib/documents/tree-command-client.ts index a108283a..9cb03082 100644 --- a/wolai-frontend/src/lib/documents/tree-command-client.ts +++ b/wolai-frontend/src/lib/documents/tree-command-client.ts @@ -11,6 +11,41 @@ type DocumentCommandErrorPayload = { error?: string; }; +export const TREE_COMMAND_PROTOCOL = { + create: { + preferredCommandName: "tree.node.create", + compatCommandName: "documents.create", + }, + rename: { + preferredCommandName: "tree.node.rename", + compatCommandName: "documents.title.update", + }, + move: { + preferredCommandName: "tree.subtree.move", + compatCommandName: "documents.move", + }, + archive: { + preferredCommandName: "tree.node.archive", + compatCommandName: "documents.delete", + }, + restore: { + preferredCommandName: "tree.node.restore", + compatCommandName: "documents.restore", + }, + purge: { + preferredCommandName: "tree.node.purge", + compatCommandName: "documents.purge", + }, + embed: { + preferredCommandName: "tree.node.embed", + compatCommandName: "documents.embed", + }, + copy: { + preferredCommandName: "tree.subtree.copy", + compatCommandName: "documents.copy_tree", + }, +} as const; + export type DocumentCreateCommandResult = { id: string; title?: string | null; diff --git a/wolai-frontend/src/lib/kernel-sidebar.ts b/wolai-frontend/src/lib/kernel-sidebar.ts index 3b88e57f..abd4e0d0 100644 --- a/wolai-frontend/src/lib/kernel-sidebar.ts +++ b/wolai-frontend/src/lib/kernel-sidebar.ts @@ -1,4 +1,8 @@ import type { DocumentRecord } from "@/lib/documents"; +import type { + TreeProjectionCapability, + TreeProjectionResourceMeta, +} from "@/lib/tree-protocol"; export type KernelSidebarProjectionEdge = { id: string; @@ -12,11 +16,16 @@ export type KernelSidebarProjectionItem = { nodeId: string; parentNodeId: string | null; nodeType: "page"; + projectionKind: "sidebar_tree"; title: string | null; depth: number; position: number | null; childCount: number; + expandable: boolean; expandedByDefault: boolean; + capabilities: TreeProjectionCapability[]; + resourceMeta: TreeProjectionResourceMeta; + iconHint: string | null; }; export type KernelSidebarProjection = { @@ -64,6 +73,25 @@ function dedupeRecords(records: DocumentRecord[]) { return unique; } +function buildSidebarCapabilities(childCount: number): TreeProjectionCapability[] { + const capabilities: TreeProjectionCapability[] = [ + "open", + "drag", + "drop", + "select", + "create-child", + "rename", + "archive", + "restore", + "context-menu", + "reorder", + ]; + if (childCount > 0) { + capabilities.unshift("expand"); + } + return capabilities; +} + function buildChildrenByParent(records: DocumentRecord[]) { const childrenByParentId = new Map(); const recordIds = new Set(records.map((record) => record.id)); @@ -102,11 +130,21 @@ export function buildKernelSidebarProjection( nodeId: child.id, parentNodeId: parentId, nodeType: "page", + projectionKind: "sidebar_tree", title: child.title ?? "无标题", depth, position: child.sort_order ?? null, childCount: childNodes.length, + expandable: childNodes.length > 0, expandedByDefault: true, + capabilities: buildSidebarCapabilities(childNodes.length), + resourceMeta: { + resourceKind: "document", + documentId: child.id, + workspaceId: child.workspace_id ?? null, + iconHint: "page", + }, + iconHint: "page", }); if (parentId && recordById.has(parentId)) { edges.push({ diff --git a/wolai-frontend/src/lib/mnote-web-auth.ts b/wolai-frontend/src/lib/mnote-web-auth.ts new file mode 100644 index 00000000..6a06d8b9 --- /dev/null +++ b/wolai-frontend/src/lib/mnote-web-auth.ts @@ -0,0 +1,12 @@ +const MNOTE_WEB_AUTH_COOKIE_PATH = "/api/auth/mnote-web-token"; + +export async function ensureMnoteWebAuthCookie(): Promise { + const response = await fetch(MNOTE_WEB_AUTH_COOKIE_PATH, { + method: "GET", + credentials: "include", + cache: "no-store", + }); + if (!response.ok) { + throw new Error("mnote-web 鉴权 cookie 准备失败"); + } +} diff --git a/wolai-frontend/src/lib/sidebar-data.test.ts b/wolai-frontend/src/lib/sidebar-data.test.ts index 3d46a2ac..6a96f12c 100644 --- a/wolai-frontend/src/lib/sidebar-data.test.ts +++ b/wolai-frontend/src/lib/sidebar-data.test.ts @@ -221,11 +221,32 @@ describe("buildSidebarInitialData", () => { nodeId: "doc_1", parentNodeId: null, nodeType: "page", + projectionKind: "sidebar_tree", title: "页面 1", depth: 0, position: 1, childCount: 0, + expandable: false, expandedByDefault: true, + capabilities: [ + "open", + "drag", + "drop", + "select", + "create-child", + "rename", + "archive", + "restore", + "context-menu", + "reorder", + ], + resourceMeta: { + resourceKind: "document", + documentId: "doc_1", + workspaceId: "ws_1", + iconHint: "page", + }, + iconHint: "page", }, ], edges: [], @@ -276,11 +297,32 @@ describe("buildSidebarInitialData", () => { nodeId: "doc_1", parentNodeId: null, nodeType: "page", + projectionKind: "sidebar_tree", title: "页面 1", depth: 0, position: 1, childCount: 0, + expandable: false, expandedByDefault: true, + capabilities: [ + "open", + "drag", + "drop", + "select", + "create-child", + "rename", + "archive", + "restore", + "context-menu", + "reorder", + ], + resourceMeta: { + resourceKind: "document", + documentId: "doc_1", + workspaceId: "ws_1", + iconHint: "page", + }, + iconHint: "page", }, ], edges: [], @@ -319,7 +361,7 @@ describe("buildSidebarInitialData", () => { }); }); - it("缺少 kernel projection 时不再本地补真相,而是返回空契约 projection", () => { + it("缺少 kernel projection 时会按 documents 重建 projection 并保留层级", () => { expect( mapSidebarDatasetListQueryResultToInitialData({ active_workspace_id: "ws_1", @@ -337,6 +379,18 @@ describe("buildSidebarInitialData", () => { created_at: "2026-04-14T00:00:00Z", updated_at: null, }, + { + id: "doc_2", + workspace_id: "ws_1", + title: "页面 2", + parent_id: "doc_1", + sort_order: 0, + is_starred: false, + access_scope: "private", + is_template: false, + created_at: "2026-04-14T00:01:00Z", + updated_at: null, + }, ], trashed_documents: [], media_assets: [], @@ -351,13 +405,16 @@ describe("buildSidebarInitialData", () => { ).toMatchObject({ activeWorkspaceId: "ws_1", kernelSidebarProjection: { - projectionId: "kernel_projection:sidebar_tree:missing", - items: [], - edges: [], + projectionId: "kernel_projection:sidebar_tree:workspace_root", }, kernelSidebarTree: [ { id: "doc_1", + children: [ + { + id: "doc_2", + }, + ], }, ], }); diff --git a/wolai-frontend/src/lib/sidebar-data.ts b/wolai-frontend/src/lib/sidebar-data.ts index e083d57c..bdfc32a5 100644 --- a/wolai-frontend/src/lib/sidebar-data.ts +++ b/wolai-frontend/src/lib/sidebar-data.ts @@ -96,6 +96,12 @@ function readKernelSidebarProjection( return camelCaseProjection; } + if (Array.isArray(result.documents) && result.documents.length > 0) { + // 说明:部分 query transport 只回 documents,没有同步附带 projection。 + // 这里按同一协议即时重建,避免 UI 把缺失节点全部降级到根层。 + return buildProjectionContract(result.documents); + } + return EMPTY_KERNEL_SIDEBAR_PROJECTION; } diff --git a/wolai-frontend/src/lib/tree-projection-contract.test.ts b/wolai-frontend/src/lib/tree-projection-contract.test.ts new file mode 100644 index 00000000..bb96f3cd --- /dev/null +++ b/wolai-frontend/src/lib/tree-projection-contract.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { buildPageTreeProjectionItems } from "@/lib/tree-projection"; + +describe("tree-projection contract", () => { + it("page_tree projection item 持续输出共享 contract 字段", () => { + const items = buildPageTreeProjectionItems([ + { + access_scope: "private", + id: "root", + workspace_id: "ws_1", + title: "Root", + parent_id: null, + sort_order: 0, + is_starred: false, + is_template: false, + created_at: "2026-04-18T00:00:00Z", + updated_at: null, + children: [], + }, + ]); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + rowId: "page:root", + nodeId: "root", + parentNodeId: null, + projectionKind: "page_tree", + expandable: false, + expandedByDefault: true, + iconHint: "page", + resourceMeta: { + resourceKind: "document", + documentId: "root", + iconHint: "page", + }, + }); + expect(items[0]?.capabilities).toContain("open"); + }); +}); diff --git a/wolai-frontend/src/lib/tree-projection.ts b/wolai-frontend/src/lib/tree-projection.ts index c4f22d1f..cf4d8370 100644 --- a/wolai-frontend/src/lib/tree-projection.ts +++ b/wolai-frontend/src/lib/tree-projection.ts @@ -1,34 +1,12 @@ import type { SidebarTreeNode } from "@/lib/kernel-sidebar"; - -export type TreeProjectionKind = "page_tree"; - -export type TreeProjectionNodeType = "page"; - -export type TreeProjectionCapability = - | "expand" - | "open" - | "drag" - | "drop" - | "select" - | "create-child"; - -export type TreeProjectionResourceMeta = { - resourceKind: "document"; - documentId: string; -}; +import type { + TreeProjectionCapability, + TreeProjectionItemBase, +} from "@/lib/tree-protocol"; export type PageTreeProjectionItem = { rowId: `page:${string}`; - nodeId: string; - parentNodeId: string | null; - nodeType: TreeProjectionNodeType; - projectionKind: TreeProjectionKind; - depth: number; - position: number | null; - title: string; - childCount: number; - capabilities: TreeProjectionCapability[]; - resourceMeta: TreeProjectionResourceMeta; +} & TreeProjectionItemBase & { node: SidebarTreeNode; }; @@ -45,6 +23,11 @@ function buildPageCapabilities(childCount: number): TreeProjectionCapability[] { "drop", "select", "create-child", + "rename", + "archive", + "restore", + "context-menu", + "reorder", ]; if (childCount > 0) { capabilities.unshift("expand"); @@ -80,11 +63,16 @@ export function buildPageTreeProjectionItems( position: node.kernel?.position ?? node.sort_order ?? index, title: node.title ?? "无标题", childCount, + expandable: childCount > 0, + expandedByDefault: node.kernel?.expandedByDefault ?? true, capabilities: buildPageCapabilities(childCount), resourceMeta: { resourceKind: "document", documentId: node.id, + workspaceId: node.workspace_id, + iconHint: "page", }, + iconHint: "page", node: { ...node, parent_id: parentNodeId, diff --git a/wolai-frontend/src/lib/tree-protocol.test.ts b/wolai-frontend/src/lib/tree-protocol.test.ts new file mode 100644 index 00000000..6eafd023 --- /dev/null +++ b/wolai-frontend/src/lib/tree-protocol.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import type { TreeProjectionItemBase } from "@/lib/tree-protocol"; + +describe("tree-protocol", () => { + it("冻结共享 tree projection 字段与 file_tree 扩展语义", () => { + const item: TreeProjectionItemBase = { + nodeId: "doc_1", + parentNodeId: null, + nodeType: "page", + projectionKind: "file_tree", + title: "页面 1", + depth: 0, + position: 1, + childCount: 2, + expandable: true, + expandedByDefault: true, + capabilities: ["expand", "open", "drag", "drop", "select", "create-child"], + resourceMeta: { + resourceKind: "document", + documentId: "doc_1", + assetKind: "file", + iconHint: "page", + }, + iconHint: "page", + }; + + expect(item.resourceMeta.resourceKind).toBe("document"); + expect(item.resourceMeta.assetKind).toBe("file"); + expect(item.capabilities).toContain("expand"); + expect(item.expandable).toBe(true); + expect(item.iconHint).toBe("page"); + }); +}); diff --git a/wolai-frontend/src/lib/tree-protocol.ts b/wolai-frontend/src/lib/tree-protocol.ts new file mode 100644 index 00000000..cd98abee --- /dev/null +++ b/wolai-frontend/src/lib/tree-protocol.ts @@ -0,0 +1,76 @@ +export type TreeProjectionKind = "sidebar_tree" | "page_tree" | "file_tree"; + +export type TreeProjectionNodeType = + | "workspace" + | "folder" + | "page" + | "section" + | "asset" + | "mindmap" + | "table" + | "book" + | "pdf" + | "index"; + +export type TreeProjectionCapability = + | "expand" + | "open" + | "drag" + | "drop" + | "select" + | "create-child" + | "rename" + | "archive" + | "restore" + | "context-menu" + | "reorder" + | "open-asset" + | "pick"; + +export type TreeProjectionResourceKind = + | "workspace" + | "document" + | "index" + | "asset" + | "asset_folder" + | "mindmap" + | "table" + | "book" + | "pdf"; + +export type TreeProjectionAssetKind = + | "file" + | "mindmap" + | "table" + | "book" + | "pdf" + | "image" + | "video" + | "audio" + | "unknown"; + +export type TreeProjectionResourceMeta = { + resourceKind: TreeProjectionResourceKind; + documentId?: string | null; + assetId?: string | null; + workspaceId?: string | null; + assetKind?: TreeProjectionAssetKind | null; + iconHint?: string | null; + extra?: Record; +}; + +export type TreeProjectionItemBase = { + nodeId: string; + parentNodeId: string | null; + nodeType: TreeProjectionNodeType; + projectionKind: TreeProjectionKind; + title: string; + depth: number; + position: number | null; + childCount: number; + expandable: boolean; + expandedByDefault: boolean; + capabilities: TreeProjectionCapability[]; + resourceMeta: TreeProjectionResourceMeta; + iconHint: string | null; +}; diff --git a/wolai-frontend/src/lib/tree-stream/tree-delta.test.ts b/wolai-frontend/src/lib/tree-stream/tree-delta.test.ts index 552a2ffa..4b1c44f0 100644 --- a/wolai-frontend/src/lib/tree-stream/tree-delta.test.ts +++ b/wolai-frontend/src/lib/tree-stream/tree-delta.test.ts @@ -40,21 +40,64 @@ const baseSidebarData: SidebarInitialData = { nodeId: "root", parentNodeId: null, nodeType: "page", + projectionKind: "sidebar_tree", title: "Root", depth: 0, position: 0, childCount: 1, + expandable: true, expandedByDefault: true, + capabilities: [ + "expand", + "open", + "drag", + "drop", + "select", + "create-child", + "rename", + "archive", + "restore", + "context-menu", + "reorder", + ], + resourceMeta: { + resourceKind: "document", + documentId: "root", + workspaceId: "ws_1", + iconHint: "page", + }, + iconHint: "page", }, { nodeId: "child", parentNodeId: "root", nodeType: "page", + projectionKind: "sidebar_tree", title: "Child", depth: 1, position: 1, childCount: 0, + expandable: false, expandedByDefault: true, + capabilities: [ + "open", + "drag", + "drop", + "select", + "create-child", + "rename", + "archive", + "restore", + "context-menu", + "reorder", + ], + resourceMeta: { + resourceKind: "document", + documentId: "child", + workspaceId: "ws_1", + iconHint: "page", + }, + iconHint: "page", }, ], edges: [ diff --git a/wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts b/wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts index a2b1c29b..3befecda 100644 --- a/wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts +++ b/wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts @@ -9,6 +9,7 @@ import { parseTreeStreamMessage, } from "@/lib/tree-stream/protocol"; import { applyTreeStreamDelta, type TreeStreamDeltaEvent } from "@/lib/tree-stream/tree-delta"; +import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth"; export interface SidebarTreeStreamState { data: SidebarInitialData | null; @@ -60,9 +61,8 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr return undefined; } - const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor); - const eventSource = new EventSource(url, { withCredentials: true }); - eventSourceRef.current = eventSource; + let cancelled = false; + let eventSource: EventSource | null = null; const handleMessage = (event: MessageEvent) => { const envelope = parseTreeStreamMessage({ @@ -121,21 +121,45 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr status: previous.data ? "live" : "fallback", error: previous.error ?? new Error("tree stream 连接失败"), })); - eventSource.close(); - eventSourceRef.current = null; + eventSource?.close(); + if (eventSourceRef.current === eventSource) { + eventSourceRef.current = null; + } }; - eventSource.addEventListener("snapshot", handleMessage as EventListener); - eventSource.addEventListener("delta", handleMessage as EventListener); - eventSource.addEventListener("resync", handleMessage as EventListener); - eventSource.onmessage = handleMessage; - eventSource.onerror = handleError; + void (async () => { + try { + await ensureMnoteWebAuthCookie(); + if (cancelled) { + return; + } + + const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor); + eventSource = new EventSource(url, { withCredentials: true }); + eventSourceRef.current = eventSource; + eventSource.addEventListener("snapshot", handleMessage as EventListener); + eventSource.addEventListener("delta", handleMessage as EventListener); + eventSource.addEventListener("resync", handleMessage as EventListener); + eventSource.onmessage = handleMessage; + eventSource.onerror = handleError; + } catch { + if (cancelled) { + return; + } + setState((previous) => ({ + ...previous, + status: previous.data ? "live" : "fallback", + error: previous.error ?? new Error("tree stream 鉴权失败"), + })); + } + })(); return () => { - eventSource.removeEventListener("snapshot", handleMessage as EventListener); - eventSource.removeEventListener("delta", handleMessage as EventListener); - eventSource.removeEventListener("resync", handleMessage as EventListener); - eventSource.close(); + cancelled = true; + eventSource?.removeEventListener("snapshot", handleMessage as EventListener); + eventSource?.removeEventListener("delta", handleMessage as EventListener); + eventSource?.removeEventListener("resync", handleMessage as EventListener); + eventSource?.close(); if (eventSourceRef.current === eventSource) { eventSourceRef.current = null; }