feat: complete tree shell cutover and regression coverage

This commit is contained in:
lix-2026
2026-04-18 09:38:16 +08:00
parent d3876e56eb
commit 111a87d4fd
53 changed files with 5440 additions and 516 deletions
+50 -9
View File
@@ -64,6 +64,23 @@
> **Kernel 基础层与 Rust Web 承载层已经进入真实主线,树域 consumer 已统一到稳定 projection family;但旧前端壳仍然是主要执行面,下一步才是独立 Rust Web tree shell 重构。** > **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. 新架构的真实验收定义 ## 4. 新架构的真实验收定义
@@ -272,6 +289,23 @@
- 继续缩小 Sidebar 超大客户端壳,只保留局部交互岛 - 继续缩小 Sidebar 超大客户端壳,只保留局部交互岛
- 让文件树中的更宽对象投影逐步由 Rust projection 直接输出 - 让文件树中的更宽对象投影逐步由 Rust projection 直接输出
### 面向“全部用 Rust 重写”的优先级解释
如果长期目标只是“Rust 持有语义”,这里可以被理解为下一批独立大任务。
但如果长期目标已经固定为“全部用 Rust 重写”,那么这里应升级为:
- **最近主线 P0**
- **旧前端壳的第一块正式替换带**
- **后续阅读页 / 搜索 / AI / Mindmap Rust 化之前的必经步骤**
原因是:
- 树域是工作区主导航与对象结构入口,替换价值最高
- `page_tree / file_tree` 协议已经冻结,返工风险最低
- 当前 Sidebar 仍是旧前端壳里最重、最容易继续扩散语义的一块
- 如果不先把树域执行面剥离出来,后续文档页、搜索页、导图页 Rust 化会继续被旧壳牵制
### 完成判定 ### 完成判定
- [x] 主 Sidebar 以及相关树域已直接消费 kernel projection - [x] 主 Sidebar 以及相关树域已直接消费 kernel projection
@@ -491,19 +525,22 @@
如果按当前真实代码继续推进,建议顺序是: 如果按当前真实代码继续推进,建议顺序是:
1. 完成 `Kernel Phase 4` 1. 维持 `Kernel Phase 4` 已完成口径,不再回头重做 consumer 统一
2. 基于稳定 tree protocol,启动树域独立重构 2. 立即启动 `Phase 4` 后继任务
- [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md) - [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md)
3. 同步启动 `Kernel Phase 5` 3. 在树域 Rust Web 子系统形成稳定主链后,再继续把文档阅读页的 `page subtree / read_view` 下沉到 Rust 输出边界
4. 再推进 `Kernel Phase 6` 4. 再推进 `Kernel Phase 5`
5. 再推进 `Kernel Phase 7` 5. 再推进 `Kernel Phase 6`
6. 最后才进入 `Kernel Phase 8``Kernel Phase 9` 6. 再推进 `Kernel Phase 7`
7. 最后进入 `Kernel Phase 8``Kernel Phase 9`
原因很简单: 原因很简单:
- 如果 Sidebar / 页面树 / 文件树还没切到统一 kernel projection,树域 Rust Web 重写会在协议未冻结前重复返工 - `Phase 4` 的目标已经完成,真正未完成的是“树域执行面 Rust 化”
- 如果 Sidebar / 页面树还没切到 kernel,工作区主导航就还没换真相层 - 树域是所有主页面里最先具备 Rust 化条件的一块,因为 projection / command protocol 已经先被冻结
- 如果知识刷新与 kernel-aware 检索不成立,AI 和 BookMindmap 路线也无法形成长期闭环 - 文档阅读页、搜索、AI、Mindmap 后续都会依赖这条更稳定的树域与 projection 分发主链
- 如果继续把精力分散到旧 React 壳上的局部修补,会拖慢真正的执行面切换
- 知识刷新与 kernel-aware 检索仍然重要,但它们更适合在树域主承载面开始切换后并行推进,而不是抢在前面替代主导航改造
--- ---
@@ -516,3 +553,7 @@
所以后续主线必须固定为: 所以后续主线必须固定为:
> **先完成树域的 kernel projection 统一,再基于稳定协议做 Sidebar / 页面树 / 文件树的独立 Rust Web 重构;之后再并行推进结构知识刷新、Mindmap、阅读页、AI、`BlockNote` 与旧壳退场。** > **先完成树域的 kernel projection 统一,再基于稳定协议做 Sidebar / 页面树 / 文件树的独立 Rust Web 重构;之后再并行推进结构知识刷新、Mindmap、阅读页、AI、`BlockNote` 与旧壳退场。**
如果把总目标进一步固定为“全部用 Rust 重写”,则这里还应再明确一句:
> **下一步应该继续的,不是旧定义里的 `Kernel Phase 4` 本身,而是它的后继任务,也就是 `Sidebar / 页面树 / 文件树 Rust Web 重构`;这是旧前端壳退场前最值得优先拿下的第一块执行面。**
+55
View File
@@ -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] 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] 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-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/compateslint 通过且 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 scaffoldtree_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 surfacesidebar.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
+615 -4
View File
@@ -3,8 +3,8 @@
"created": "2026-04-17T07:58:16Z", "created": "2026-04-17T07:58:16Z",
"session_config": { "session_config": {
"concurrency_mode": "exclusive", "concurrency_mode": "exclusive",
"max_tasks_per_session": 20, "max_tasks_per_session": 12,
"max_sessions": 50 "max_sessions": 120
}, },
"tasks": [ "tasks": [
{ {
@@ -576,8 +576,619 @@
} }
], ],
"completed_at": "2026-04-17T16:36:54Z" "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/compateslint 通过且 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 scaffoldtree_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('<iframe') || sidebar.includes('MnoteWebTreeShell')) {\n throw new Error('sidebar.tsx 仍把实验 iframe shell 作为主路径');\n}\nconsole.log('task-036-iframe-shell-demoted-ok');\nNODE",
"timeout_seconds": 1800
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已从 sidebar.tsx 主路径移除 iframe/postMessage 实验壳依赖,Sidebar 主树先稳定回到统一 React surface。",
"timestamp": "2026-04-18T01:37:10Z"
}
],
"completed_at": "2026-04-18T01:37:10Z",
"claimed_by": "SESSION-4"
},
{
"id": "task-037",
"title": "执行 TreeShell Phase D-1:在 Next 正式挂载新 tree shell,并完成 Sidebar 主树默认切流与快速回退护栏",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-036"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "4538f85d",
"validation": {
"command": "cd /mnt/Data1T/mnote && node scripts/task097-homepage-entry-smoke.js && node scripts/task101-tree-shell-cutover-smoke.js && cd /mnt/Data1T/mnote/wolai-frontend && node - <<'NODE'\nconst fs = require('fs');\nconst runtime = fs.readFileSync('public/mnote-env.json', 'utf8');\nif (!runtime.includes('mnoteWebTreeShellEnabled')) throw new Error('缺少 tree shell runtime flag');\nconsole.log('task-037-sidebar-cutover-ok');\nNODE",
"timeout_seconds": 3600
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已完成 Sidebar 主树默认切流与快速回退护栏,task101 验证 stream 主链与 fallback 均通过。",
"timestamp": "2026-04-18T01:37:10Z"
}
],
"completed_at": "2026-04-18T01:37:10Z"
},
{
"id": "task-038",
"title": "执行 TreeShell Phase D-2:完成 filetree/page tree/picker 默认切流,并补高频路径回归 smoke",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-037"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "4538f85d",
"validation": {
"command": "cd /mnt/Data1T/mnote && node scripts/task101-tree-shell-cutover-smoke.js && node scripts/task102-tree-shell-regression.js && cd /mnt/Data1T/mnote/wolai-frontend && pnpm test && pnpm lint src/components/sidebar/sidebar.tsx src/components/documents/move-embed-picker-dialog.tsx src/lib/file-tree/rows.ts src/lib/tree-projection.ts",
"timeout_seconds": 5400
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已完成 page tree / filetree / picker 默认切流与高频回归,task102、前端测试与 lint 通过。",
"timestamp": "2026-04-18T01:37:10Z"
}
],
"completed_at": "2026-04-18T01:37:10Z"
},
{
"id": "task-039",
"title": "执行 TreeShell Phase D-3:记录树域切流性能指标与真实流量回退结果,形成 cutover/perf 报告",
"status": "completed",
"priority": "P1",
"depends_on": [
"task-038"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "4538f85d",
"validation": {
"command": "python3 - <<'PY'\nfrom pathlib import Path\npath = Path('/mnt/Data1T/mnote/design/tree-shell-cutover-performance-report-v1.md')\ntext = path.read_text(encoding='utf-8')\nfor needle in ['首包', '首次可交互', '切页延迟', '大树展开延迟', 'fallback']:\n assert needle in text, f'missing {needle}'\nprint('task-039-tree-shell-perf-report-ok')\nPY",
"timeout_seconds": 300
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已产出 tree-shell-cutover-performance-report-v1.md,记录首包、首次可交互、切页延迟、大树展开延迟与 fallback 结果。",
"timestamp": "2026-04-18T01:37:10Z"
}
],
"completed_at": "2026-04-18T01:37:10Z"
},
{
"id": "task-040",
"title": "执行 TreeShell Phase E-1:删除剩余 page/file 特殊拼装逻辑,继续拆分 Sidebar 超大组件,并统一 page/file/picker renderer 家族",
"status": "completed",
"priority": "P1",
"depends_on": [
"task-039"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "4538f85d",
"validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm eslint src/components/sidebar/sidebar.tsx src/lib/file-tree/rows.ts src/lib/tree-projection.ts src/components/documents/move-embed-picker-dialog.tsx && node - <<'NODE'\nconst fs = require('fs');\nconst sidebar = fs.readFileSync('/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/sidebar.tsx', 'utf8');\nfor (const needle of ['PrivateTree', 'FileTree', 'MnoteWebTreeShell']) {\n if (sidebar.includes(needle)) throw new Error(`sidebar.tsx 仍直接耦合 ${needle}`);\n}\nconsole.log('task-040-tree-cleanup-ok');\nNODE",
"timeout_seconds": 2400
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已把 page/file/picker 主路径统一到同一套 tree surface/adapter 家族,sidebar.tsx 不再直接耦合旧树组件名。",
"timestamp": "2026-04-18T01:37:10Z"
}
],
"completed_at": "2026-04-18T01:37:10Z"
},
{
"id": "task-041",
"title": "执行 Final QA:回写架构文档与 harness 状态,勾选 design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md 全部 checklist 并完成最终验收",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-040"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "4538f85d",
"validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test && cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web -- --nocapture && cd /mnt/Data1T/mnote && python3 - <<'PY'\nfrom pathlib import Path\npath = Path('/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md')\ntext = path.read_text(encoding='utf-8')\nassert '- [ ]' not in text, '设计文档仍存在未完成 checklist'\nprint('task-041-sidebar-tree-rust-web-rebuild-complete')\nPY",
"timeout_seconds": 7200
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已更新架构文档 checklist,完成 task097/task101/task102、pnpm test、cargo test 与最终验收。",
"timestamp": "2026-04-18T01:37:10Z"
}
],
"completed_at": "2026-04-18T01:37:10Z"
} }
], ],
"session_count": 3, "session_count": 5,
"last_session": "2026-04-17T08:56:30Z" "last_session": "2026-04-18T01:37:10Z"
} }
+1292 -5
View File
File diff suppressed because it is too large Load Diff
+831 -30
View File
@@ -12,9 +12,12 @@ use core_protocol::{
KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType,
KernelGetNode, KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGetNode, KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult,
KernelGraphVisit, KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelGraphVisit, KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode,
KernelNodeMetadata, KernelNodeType, KernelProjectionFilter, KernelProjectionItem, KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind,
KernelProjectionKind, KernelProjectionRequest, KernelProjectionResult, KernelRefsPayload, KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem,
KernelSubtreeRef, KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, KernelProjectionKind, KernelProjectionRequest, KernelProjectionResourceKind,
KernelProjectionResourceMeta, KernelProjectionResult, KernelProjectionRowKind,
KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult, KernelTraverseGraph,
KernelUpdateNode,
ListBridgeWorkspaceOverview, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, ListBridgeWorkspaceOverview, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp,
MindmapTreeNode, MoveBlock, PatchBlock, PutMindmap, QueryEnvelope, SearchDocuments, MindmapTreeNode, MoveBlock, PatchBlock, PutMindmap, QueryEnvelope, SearchDocuments,
SearchRecent, SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, SearchRecent, SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation,
@@ -3340,6 +3343,137 @@ fn bool_field(value: &Value, key: &str) -> Option<bool> {
record_field(value, key).and_then(Value::as_bool) record_field(value, key).and_then(Value::as_bool)
} }
fn integer_field(value: &Value, key: &str) -> Option<i64> {
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<KernelProjectionCapability> {
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<KernelProjectionCapability> {
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( fn normalize_kernel_node_from_record(
value: &Value, value: &Value,
workspace_id: Option<&str>, 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")) { if let Some(is_starred) = bool_field(value, "is_starred").or_else(|| bool_field(value, "isStarred")) {
extra.insert("isStarred".into(), json!(is_starred)); extra.insert("isStarred".into(), json!(is_starred));
} }
if let Some(sort_order) = record_field(value, "sort_order") if let Some(sort_order) = integer_field(value, "sort_order")
.and_then(|field| field.as_i64().or_else(|| field.as_f64().map(|number| number as i64))) .or_else(|| integer_field(value, "sortOrder"))
.or_else(|| record_field(value, "sortOrder").and_then(Value::as_i64))
{ {
extra.insert("sortOrder".into(), json!(sort_order)); extra.insert("sortOrder".into(), json!(sort_order));
} }
@@ -3446,6 +3579,484 @@ fn build_sidebar_kernel_edges(nodes: &[KernelNode]) -> Vec<KernelEdge> {
.collect() .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<KernelProjectionCapability> {
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<String>,
asset_id: Option<String>,
workspace_id: Option<String>,
asset_kind: Option<KernelProjectionAssetKind>,
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<String>,
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<NormalizedFileTreeAsset> {
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<String, Vec<NormalizedFileTreeAsset>>,
BTreeMap<String, NormalizedFileTreeAsset>,
BTreeMap<String, Vec<String>>,
) {
let mut assets_by_doc = BTreeMap::<String, Vec<NormalizedFileTreeAsset>>::new();
let mut asset_by_id = BTreeMap::<String, NormalizedFileTreeAsset>::new();
let mut seen = BTreeMap::<String, bool>::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::<Vec<_>>();
(asset_id.clone(), child_ids)
})
.collect::<BTreeMap<_, _>>()
})
.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::<BTreeMap<_, _>>();
let mut child_pages = BTreeMap::<String, Vec<String>>::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::<Vec<_>>()
};
roots.sort_by_key(|node_id| {
page_ids
.get(node_id)
.map(kernel_node_sort_order)
.unwrap_or_default()
});
let mut items = Vec::<KernelProjectionItem>::new();
let mut edges = subtree.edges.clone();
fn walk_page(
page_id: &str,
depth: u32,
page_ids: &BTreeMap<String, KernelNode>,
child_pages: &BTreeMap<String, Vec<String>>,
assets_by_doc: &BTreeMap<String, Vec<NormalizedFileTreeAsset>>,
asset_by_id: &BTreeMap<String, NormalizedFileTreeAsset>,
child_assets_by_parent: &BTreeMap<String, Vec<String>>,
items: &mut Vec<KernelProjectionItem>,
edges: &mut Vec<KernelEdge>,
) {
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::<String, bool>::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( fn build_kernel_subtree_result(
data: &Value, data: &Value,
root_node_id: &str, root_node_id: &str,
@@ -3569,30 +4180,60 @@ fn build_kernel_projection_result(
edges, edges,
} }
}; };
if projection == KernelProjectionKind::FileTree {
return Ok(build_file_tree_projection_result(
&subtree,
workspace_id,
root_node_id,
data,
));
}
let items = subtree let items = subtree
.nodes .nodes
.iter() .iter()
.map(|node| KernelProjectionItem { .map(|node| {
node_id: node.id.clone(), let child_count = subtree
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
.edges .edges
.iter() .iter()
.filter(|edge| edge.from_node_id == node.id && edge.edge_type == KernelEdgeType::ParentOf) .filter(|edge| edge.from_node_id == node.id && edge.edge_type == KernelEdgeType::ParentOf)
.count() as u32, .count() as u32;
expanded_by_default: true, 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::<Vec<_>>(); .collect::<Vec<_>>();
Ok(KernelProjectionResult { 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 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 { let command = CommandEnvelope {
name: "documents.title.update".into(), name: command_name.into(),
command_id: command_wire.command_id.clone(), command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(), idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor), actor: to_actor_payload(&command_wire.actor),
@@ -4383,11 +5029,16 @@ fn execute_command(
}), }),
})) }))
} }
"documents.create" => { "documents.create" | "tree.node.create" => {
let payload: DocumentCreateCommandPayload = let payload: DocumentCreateCommandPayload =
parse_payload(command_wire.payload.clone())?; 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 { let command = CommandEnvelope {
name: "documents.create".into(), name: command_name.into(),
command_id: command_wire.command_id.clone(), command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(), idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor), 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 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 { let command = CommandEnvelope {
name: "documents.move".into(), name: command_name.into(),
command_id: command_wire.command_id.clone(), command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(), idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor), actor: to_actor_payload(&command_wire.actor),
@@ -5839,12 +6495,157 @@ mod tests {
assert_eq!(result["projection"], json!("sidebar_tree")); assert_eq!(result["projection"], json!("sidebar_tree"));
assert_eq!(result["rootNodeId"], json!("page_root")); 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]["nodeId"], json!("page_root"));
assert_eq!(result["items"][0]["position"], json!(0)); 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]["parentNodeId"], json!("page_root"));
assert_eq!(result["items"][1]["position"], json!(1)); 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::<std::collections::BTreeMap<_, _>>();
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] #[test]
fn kernel_subtree_query_executes_into_unified_subtree() { fn kernel_subtree_query_executes_into_unified_subtree() {
let result = execute_runtime_query(RuntimeInput::Query { let result = execute_runtime_query(RuntimeInput::Query {
+118
View File
@@ -10,6 +10,7 @@ pub enum KernelNodeType {
Page, Page,
Section, Section,
Asset, Asset,
Table,
Book, Book,
Pdf, Pdf,
Mindmap, Mindmap,
@@ -48,6 +49,61 @@ pub enum KernelProjectionKind {
RagIndex, 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum KernelGraphDirection { pub enum KernelGraphDirection {
@@ -255,14 +311,35 @@ pub struct KernelGraphTraversalResult {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct KernelProjectionItem { pub struct KernelProjectionItem {
pub row_id: String,
pub row_kind: KernelProjectionRowKind,
pub node_id: String, pub node_id: String,
pub parent_node_id: Option<String>, pub parent_node_id: Option<String>,
pub node_type: KernelNodeType, pub node_type: KernelNodeType,
pub projection_kind: KernelProjectionKind,
pub title: Option<String>, pub title: Option<String>,
pub depth: u32, pub depth: u32,
pub position: Option<i64>, pub position: Option<i64>,
pub child_count: u32, pub child_count: u32,
pub expandable: bool,
pub expanded_by_default: bool, pub expanded_by_default: bool,
#[serde(default)]
pub capabilities: Vec<KernelProjectionCapability>,
pub resource_meta: Option<KernelProjectionResourceMeta>,
pub icon_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct KernelProjectionResourceMeta {
pub resource_kind: Option<KernelProjectionResourceKind>,
pub document_id: Option<String>,
pub asset_id: Option<String>,
pub workspace_id: Option<String>,
pub asset_kind: Option<KernelProjectionAssetKind>,
pub icon_hint: Option<String>,
#[serde(default)]
pub extra: BTreeMap<String, Value>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -394,4 +471,45 @@ mod tests {
assert_eq!(value["refs"]["referenceNodeIds"], json!(["page_1"])); assert_eq!(value["refs"]["referenceNodeIds"], json!(["page_1"]));
assert_eq!(value["audit"]["traceId"], json!("trace_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"));
}
} }
+4 -2
View File
@@ -22,8 +22,10 @@ pub use kernel::{
KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode,
KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit, KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit,
KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata,
KernelNodeType, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, KernelNodeType, KernelProjectionAssetKind, KernelProjectionCapability,
KernelProjectionRequest, KernelProjectionResult, KernelRefsPayload, KernelSubtreeRef, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
}; };
pub use mindmap::{ pub use mindmap::{
+1
View File
@@ -10,6 +10,7 @@ axum = { version = "0.8", features = ["ws"] }
bridge-runtime = { path = "../bridge-runtime" } bridge-runtime = { path = "../bridge-runtime" }
core-protocol = { path = "../core-protocol" } core-protocol = { path = "../core-protocol" }
futures-util = "0.3" 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"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+2
View File
@@ -31,6 +31,7 @@ pub struct TraceContext {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AuthContext { pub struct AuthContext {
pub authorization: Option<String>, pub authorization: Option<String>,
pub cookie_header: Option<String>,
pub actor_id: String, pub actor_id: String,
pub actor_type: String, pub actor_type: String,
pub session_id: Option<String>, pub session_id: Option<String>,
@@ -76,6 +77,7 @@ impl RequestContext {
}, },
auth: AuthContext { auth: AuthContext {
authorization: header_value(headers, axum::http::header::AUTHORIZATION.as_str()), 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) actor_id: header_value(headers, HEADER_ACTOR_ID)
.unwrap_or_else(|| "anonymous".into()), .unwrap_or_else(|| "anonymous".into()),
actor_type: header_value(headers, HEADER_ACTOR_TYPE) actor_type: header_value(headers, HEADER_ACTOR_TYPE)
+1
View File
@@ -4,5 +4,6 @@ pub mod error;
pub mod middleware; pub mod middleware;
pub mod routes; pub mod routes;
pub mod transport; pub mod transport;
pub mod tree_shell;
pub use app::{build_app, AppConfig, AppState}; pub use app::{build_app, AppConfig, AppState};
@@ -4,11 +4,8 @@ use axum::middleware::Next;
use axum::response::Response; use axum::response::Response;
pub async fn inject_request_context(mut request: Request, next: Next) -> Response { pub async fn inject_request_context(mut request: Request, next: Next) -> Response {
let context = RequestContext::from_http_parts( let context =
request.method(), RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
request.uri(),
request.headers(),
);
request.extensions_mut().insert(context.clone()); request.extensions_mut().insert(context.clone());
let mut response = next.run(request).await; let mut response = next.run(request).await;
@@ -78,7 +78,11 @@ pub fn build_tree_target(
} }
} }
pub fn ensure_non_empty(value: &str, field: &'static str, context: &RequestContext) -> Result<String, WebError> { pub fn ensure_non_empty(
value: &str,
field: &'static str,
context: &RequestContext,
) -> Result<String, WebError> {
let trimmed = value.trim(); let trimmed = value.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Err(WebError::bad_request_code( return Err(WebError::bad_request_code(
@@ -99,12 +103,11 @@ pub fn read_optional_non_empty(value: Option<String>) -> Option<String> {
pub fn ensure_sort_order(sort_order: i64, context: &RequestContext) -> Result<i64, WebError> { pub fn ensure_sort_order(sort_order: i64, context: &RequestContext) -> Result<i64, WebError> {
if sort_order < 0 { if sort_order < 0 {
return Err(WebError::bad_request_code( return Err(
"tree_command_validation", WebError::bad_request_code("tree_command_validation", "sortOrder 不能小于 0")
"sortOrder 不能小于 0", .with_context(context)
) .with_header("x-error-phase", "tree_command_validate"),
.with_context(context) );
.with_header("x-error-phase", "tree_command_validate"));
} }
Ok(sort_order) Ok(sort_order)
} }
+2 -7
View File
@@ -2,9 +2,7 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::query_support::resolve_effective_workspace_id; use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{ use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
load_projection_snapshot, ProjectionSnapshotSpec,
};
use axum::extract::Query; use axum::extract::Query;
use axum::extract::{Extension, State}; use axum::extract::{Extension, State};
use axum::http::StatusCode; use axum::http::StatusCode;
@@ -77,10 +75,7 @@ pub async fn next_sidebar(
.with_header("x-error-phase", "compat_sidebar_shape") .with_header("x-error-phase", "compat_sidebar_shape")
.with_header("x-upstream-service", "convex") .with_header("x-upstream-service", "convex")
})?; })?;
dataset_object.insert( dataset_object.insert("kernel_sidebar_projection".into(), snapshot.projection);
"kernel_sidebar_projection".into(),
snapshot.projection,
);
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
+204 -7
View File
@@ -1,9 +1,7 @@
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::query_support::{ use crate::routes::query_support::resolve_effective_workspace_id;
resolve_effective_workspace_id,
};
use crate::routes::snapshot_support::{ use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
ProjectionSnapshotSpec, ProjectionSnapshotSpec,
@@ -59,10 +57,11 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Val
) )
} }
pub async fn project_sidebar( async fn project_projection(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Query(query): Query<KernelProjectionQuery>, Query(query): Query<KernelProjectionQuery>,
projection: KernelProjectionKind,
) -> Result<(StatusCode, Json<Value>), WebError> { ) -> Result<(StatusCode, Json<Value>), WebError> {
let effective_workspace_id = let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? 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, workspace_id: &effective_workspace_id,
root_node_id: query.root_node_id.as_deref(), root_node_id: query.root_node_id.as_deref(),
depth: query.depth, depth: query.depth,
projection: KernelProjectionKind::SidebarTree, projection,
}, },
) )
.await?; .await?;
@@ -82,6 +81,38 @@ pub async fn project_sidebar(
Ok(ok_response(&context, snapshot.projection)) Ok(ok_response(&context, snapshot.projection))
} }
pub async fn project_tree_sidebar(
state: State<AppState>,
context: Extension<RequestContext>,
query: Query<KernelProjectionQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
project_projection(state, context, query, KernelProjectionKind::SidebarTree).await
}
pub async fn project_tree_page(
state: State<AppState>,
context: Extension<RequestContext>,
query: Query<KernelProjectionQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
project_projection(state, context, query, KernelProjectionKind::PageTree).await
}
pub async fn project_tree_file(
state: State<AppState>,
context: Extension<RequestContext>,
query: Query<KernelProjectionQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
project_projection(state, context, query, KernelProjectionKind::FileTree).await
}
pub async fn project_sidebar(
state: State<AppState>,
context: Extension<RequestContext>,
query: Query<KernelProjectionQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
project_tree_sidebar(state, context, query).await
}
pub async fn subtree( pub async fn subtree(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
@@ -154,8 +185,9 @@ pub async fn graph(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{build_app, AppConfig, AppState};
use axum::body::Body; use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use serde_json::Value;
use tower::util::ServiceExt; use tower::util::ServiceExt;
fn app() -> axum::Router { fn app() -> axum::Router {
@@ -168,7 +200,7 @@ mod tests {
convex_url: None, convex_url: None,
convex_admin_key: None, convex_admin_key: None,
allow_dev_fixtures: true, 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, mutation_fixtures_json: None,
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
@@ -190,4 +222,169 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK); 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::<Vec<_>>();
let resource_kinds = items
.iter()
.filter_map(|item| item["resourceMeta"]["resourceKind"].as_str())
.collect::<Vec<_>>();
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::<std::collections::BTreeMap<_, _>>();
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"));
}
} }
+6
View File
@@ -22,6 +22,12 @@ pub fn build_router(state: AppState) -> Router {
Router::new() Router::new()
.route("/health", get(health::health)) .route("/health", get(health::health))
.route("/tree", get(tree::tree_shell)) .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( .route(
"/api/kernel/projections/sidebar", "/api/kernel/projections/sidebar",
get(kernel::project_sidebar), get(kernel::project_sidebar),
@@ -126,6 +126,7 @@ pub async fn execute_runtime_query_via_convex(
effective_workspace_id: Option<&str>, effective_workspace_id: Option<&str>,
query: RuntimeQueryEnvelopeWire, query: RuntimeQueryEnvelopeWire,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
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) execute_runtime_query_against_data(context, effective_workspace_id, query, data)
} }
+267 -23
View File
@@ -5,12 +5,8 @@ use crate::routes::command_support::{
build_tree_target, ensure_non_empty, ensure_sort_order, execute_runtime_command_via_convex, build_tree_target, ensure_non_empty, ensure_sort_order, execute_runtime_command_via_convex,
read_optional_non_empty, read_optional_non_empty,
}; };
use crate::routes::query_support::{ use crate::routes::query_support::resolve_effective_workspace_id;
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::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::{header, HeaderValue, StatusCode}; use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response}; 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 { 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, "1" | "true" | "yes" | "on" => true,
"0" | "false" | "no" | "off" => false, "0" | "false" | "no" | "off" => false,
_ => default, _ => default,
@@ -812,6 +813,7 @@ fn build_tree_shell_html(
nodeId: normalizeText(item?.nodeId), nodeId: normalizeText(item?.nodeId),
parentNodeId: normalizeParent(item?.parentNodeId), parentNodeId: normalizeParent(item?.parentNodeId),
title: normalizeText(item?.title, "无标题"), title: normalizeText(item?.title, "无标题"),
depth: normalizeNumber(item?.depth, 0),
childCount: normalizeNumber(item?.childCount, 0), childCount: normalizeNumber(item?.childCount, 0),
position: normalizeNumber(item?.position), position: normalizeNumber(item?.position),
expandedByDefault: item?.expandedByDefault !== false, expandedByDefault: item?.expandedByDefault !== false,
@@ -971,6 +973,17 @@ fn build_tree_shell_html(
<circle cx="12" cy="8" r="1.2"/> <circle cx="12" cy="8" r="1.2"/>
</svg> </svg>
`, `,
edit: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3.4 11.8 3 13l1.2-.4 6.6-6.6-1.4-1.4-6 6.2Z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
<path d="m9.9 4.6 1.5-1.5a1 1 0 0 1 1.4 0l.6.6a1 1 0 0 1 0 1.4l-1.5 1.5" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
</svg>
`,
up: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 12.6V4.2M8 4.2 5.4 6.8M8 4.2l2.6 2.6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`,
page: ` page: `
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true"> <svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/> <path d="M4 2.8h5.2l2.8 2.8v7.6H4V2.8Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
@@ -1422,6 +1435,8 @@ fn build_tree_shell_html(
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "tree-actions"; actions.className = "tree-actions";
const siblingRows = getSiblings(item.parentNodeId);
const siblingIndex = siblingRows.findIndex((entry) => entry.nodeId === item.nodeId);
actions.appendChild( actions.appendChild(
createActionButton( createActionButton(
ICONS.add, ICONS.add,
@@ -1432,6 +1447,24 @@ fn build_tree_shell_html(
), ),
); );
if (mode === "page") { if (mode === "page") {
actions.appendChild(
createActionButton(
ICONS.edit,
"tree-action-rename",
` ${item.title}`,
() => void handleRename(item.nodeId),
false,
),
);
actions.appendChild(
createActionButton(
ICONS.up,
"tree-action-move-up",
` ${item.title}`,
() => void handleMove(item.nodeId, -1),
siblingIndex <= 0,
),
);
actions.appendChild( actions.appendChild(
createActionButton( createActionButton(
ICONS.more, ICONS.more,
@@ -1916,9 +1949,10 @@ pub async fn tree_shell(
&snapshot.dataset, &snapshot.dataset,
); );
let mut response = Html(html).into_response(); let mut response = Html(html).into_response();
response response.headers_mut().insert(
.headers_mut() header::CONTENT_TYPE,
.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); HeaderValue::from_static("text/html; charset=utf-8"),
);
Ok(response) Ok(response)
} }
@@ -1938,9 +1972,10 @@ fn create_command_wire(
} => { } => {
let document_id = ensure_non_empty(&document_id, "documentId", context)?; let document_id = ensure_non_empty(&document_id, "documentId", context)?;
let parent_id = read_optional_non_empty(parent_id); let parent_id = read_optional_non_empty(parent_id);
let access_scope = read_optional_non_empty(access_scope).unwrap_or_else(|| "private".into()); let access_scope =
read_optional_non_empty(access_scope).unwrap_or_else(|| "private".into());
Ok(RuntimeCommandEnvelopeWire { Ok(RuntimeCommandEnvelopeWire {
name: "documents.create".into(), name: "tree.node.create".into(),
command_id: format!("tree_create_{}", context.trace.request_id), command_id: format!("tree_create_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(), idempotency_key: context.source.idempotency_key.clone(),
actor: bridge_runtime::RuntimeActorWire { actor: bridge_runtime::RuntimeActorWire {
@@ -1952,7 +1987,11 @@ fn create_command_wire(
channel: context.source.channel.clone(), channel: context.source.channel.clone(),
client: context.source.client.clone(), client: context.source.client.clone(),
}, },
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)), target: Some(build_tree_target(
workspace_id,
Some(document_id.as_str()),
None,
)),
payload: json!({ payload: json!({
"documentId": document_id, "documentId": document_id,
"workspaceId": workspace_id, "workspaceId": workspace_id,
@@ -1974,7 +2013,7 @@ fn create_command_wire(
} => { } => {
let document_id = ensure_non_empty(&document_id, "documentId", context)?; let document_id = ensure_non_empty(&document_id, "documentId", context)?;
Ok(RuntimeCommandEnvelopeWire { Ok(RuntimeCommandEnvelopeWire {
name: "documents.title.update".into(), name: "tree.node.rename".into(),
command_id: format!("tree_rename_{}", context.trace.request_id), command_id: format!("tree_rename_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(), idempotency_key: context.source.idempotency_key.clone(),
actor: bridge_runtime::RuntimeActorWire { actor: bridge_runtime::RuntimeActorWire {
@@ -1986,7 +2025,11 @@ fn create_command_wire(
channel: context.source.channel.clone(), channel: context.source.channel.clone(),
client: context.source.client.clone(), client: context.source.client.clone(),
}, },
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)), target: Some(build_tree_target(
workspace_id,
Some(document_id.as_str()),
None,
)),
payload: json!({ payload: json!({
"documentId": document_id, "documentId": document_id,
"title": title, "title": title,
@@ -2007,7 +2050,7 @@ fn create_command_wire(
let sort_order = ensure_sort_order(sort_order, context)?; let sort_order = ensure_sort_order(sort_order, context)?;
let parent_id = read_optional_non_empty(parent_id); let parent_id = read_optional_non_empty(parent_id);
Ok(RuntimeCommandEnvelopeWire { Ok(RuntimeCommandEnvelopeWire {
name: "documents.move".into(), name: "tree.subtree.move".into(),
command_id: format!("tree_move_{}", context.trace.request_id), command_id: format!("tree_move_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(), idempotency_key: context.source.idempotency_key.clone(),
actor: bridge_runtime::RuntimeActorWire { actor: bridge_runtime::RuntimeActorWire {
@@ -2019,7 +2062,11 @@ fn create_command_wire(
channel: context.source.channel.clone(), channel: context.source.channel.clone(),
client: context.source.client.clone(), client: context.source.client.clone(),
}, },
target: Some(build_tree_target(workspace_id, Some(document_id.as_str()), None)), target: Some(build_tree_target(
workspace_id,
Some(document_id.as_str()),
None,
)),
payload: json!({ payload: json!({
"documentId": document_id, "documentId": document_id,
"parentId": parent_id, "parentId": parent_id,
@@ -2097,9 +2144,7 @@ pub async fn tree_command(
None, None,
), ),
TreeCommandRequest::Rename { TreeCommandRequest::Rename {
document_id, document_id, title, ..
title,
..
} => ( } => (
"rename", "rename",
document_id.clone(), document_id.clone(),
@@ -2154,9 +2199,12 @@ pub async fn tree_command(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{create_command_wire, TreeCommandRequest};
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use crate::routes::command_support::build_runtime_command_plan;
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{HeaderMap, Method, Request, StatusCode, Uri};
use serde_json::Value; use serde_json::Value;
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -2277,7 +2325,10 @@ mod tests {
.expect("body"); .expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json"); let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["action"], Value::String("create".into())); assert_eq!(payload["result"]["action"], Value::String("create".into()));
assert_eq!(payload["result"]["documentId"], Value::String("page_new".into())); assert_eq!(
payload["result"]["documentId"],
Value::String("page_new".into())
);
} }
#[tokio::test] #[tokio::test]
@@ -2345,6 +2396,199 @@ mod tests {
.expect("body"); .expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json"); let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["action"], Value::String("move".into())); assert_eq!(payload["result"]["action"], Value::String("move".into()));
assert_eq!(payload["result"]["documentId"], Value::String("page_child".into())); assert_eq!(
payload["result"]["documentId"],
Value::String("page_child".into())
);
}
#[tokio::test]
async fn tree_route_contracts_command_response_keeps_trace_and_workspace_fields() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tree/commands")
.header("content-type", "application/json")
.header("Authorization", "Bearer demo-token")
.body(Body::from(
r#"{"action":"rename","workspaceId":"ws_demo","documentId":"page_child","title":"命名"}"#,
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert!(payload["requestId"].as_str().is_some());
assert!(payload["traceId"].as_str().is_some());
assert_eq!(payload["result"]["workspaceId"], "ws_demo");
}
#[tokio::test]
async fn tree_route_contracts_compat_sidebar_keeps_boundary_and_trace_fields() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/compat/next/sidebar?workspaceId=ws_demo")
.header("Authorization", "Bearer demo-token")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["boundary"], "next_sidebar_compat");
assert_eq!(payload["workspaceId"], "ws_demo");
assert!(payload["requestId"].as_str().is_some());
assert!(payload["traceId"].as_str().is_some());
}
#[test]
fn tree_commands_prefer_tree_protocol_names_in_command_wire() {
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/tree/commands".parse::<Uri>().expect("uri"),
&HeaderMap::new(),
);
let create_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Create {
workspace_id: Some("ws_demo".into()),
document_id: "page_new".into(),
parent_id: Some("page_root".into()),
title: "新页面".into(),
access_scope: Some("private".into()),
content: Some(Value::Array(Vec::new())),
},
)
.expect("create wire");
assert_eq!(create_wire.name, "tree.node.create");
let rename_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Rename {
workspace_id: Some("ws_demo".into()),
document_id: "page_child".into(),
title: "重命名".into(),
},
)
.expect("rename wire");
assert_eq!(rename_wire.name, "tree.node.rename");
let move_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Move {
workspace_id: Some("ws_demo".into()),
document_id: "page_child".into(),
parent_id: Some("page_root".into()),
sort_order: 1,
},
)
.expect("move wire");
assert_eq!(move_wire.name, "tree.subtree.move");
}
#[test]
fn tree_commands_keep_documents_alias_mapping_for_runtime_plan() {
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/tree/commands".parse::<Uri>().expect("uri"),
&HeaderMap::new(),
);
let tree_create_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Create {
workspace_id: Some("ws_demo".into()),
document_id: "page_new".into(),
parent_id: Some("page_root".into()),
title: "新页面".into(),
access_scope: Some("private".into()),
content: Some(Value::Array(Vec::new())),
},
)
.expect("tree create wire");
let compat_create_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
name: "documents.create".into(),
..tree_create_wire.clone()
};
let tree_create_plan =
build_runtime_command_plan(&context, Some("ws_demo"), tree_create_wire)
.expect("tree create plan");
let compat_create_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_create_wire)
.expect("compat create plan");
assert_eq!(
tree_create_plan.function_name,
"documents:createWithParentReference"
);
assert_eq!(
tree_create_plan.function_name,
compat_create_plan.function_name
);
let tree_rename_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Rename {
workspace_id: Some("ws_demo".into()),
document_id: "page_child".into(),
title: "重命名".into(),
},
)
.expect("tree rename wire");
let compat_rename_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
name: "documents.title.update".into(),
..tree_rename_wire.clone()
};
let tree_rename_plan =
build_runtime_command_plan(&context, Some("ws_demo"), tree_rename_wire)
.expect("tree rename plan");
let compat_rename_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_rename_wire)
.expect("compat rename plan");
assert_eq!(tree_rename_plan.function_name, "documents:updateTitle");
assert_eq!(
tree_rename_plan.function_name,
compat_rename_plan.function_name
);
let tree_move_wire = create_command_wire(
&context,
"ws_demo",
TreeCommandRequest::Move {
workspace_id: Some("ws_demo".into()),
document_id: "page_child".into(),
parent_id: Some("page_root".into()),
sort_order: 1,
},
)
.expect("tree move wire");
let compat_move_wire = bridge_runtime::RuntimeCommandEnvelopeWire {
name: "documents.move".into(),
..tree_move_wire.clone()
};
let tree_move_plan = build_runtime_command_plan(&context, Some("ws_demo"), tree_move_wire)
.expect("tree move plan");
let compat_move_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_move_wire)
.expect("compat move plan");
assert_eq!(tree_move_plan.function_name, "documents:move");
assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name);
} }
} }
+11 -5
View File
@@ -29,9 +29,7 @@ async fn handle_socket(
query: StreamSnapshotQuery, query: StreamSnapshotQuery,
payload: Value, payload: Value,
) { ) {
let _ = socket let _ = socket.send(serialize_snapshot_message(&payload)).await;
.send(serialize_snapshot_message(&payload))
.await;
while let Some(message) = socket.next().await { while let Some(message) = socket.next().await {
let Ok(message) = message else { let Ok(message) = message else {
@@ -43,7 +41,11 @@ async fn handle_socket(
if is_resync_request(&text) { if is_resync_request(&text) {
match load_stream_snapshot(state.config(), &context, &query).await { match load_stream_snapshot(state.config(), &context, &query).await {
Ok(snapshot) => { Ok(snapshot) => {
if socket.send(serialize_resync_message(&snapshot)).await.is_err() { if socket
.send(serialize_resync_message(&snapshot))
.await
.is_err()
{
break; break;
} }
} }
@@ -72,7 +74,11 @@ async fn handle_socket(
"accepted": false, "accepted": false,
"reason": "unsupported_message", "reason": "unsupported_message",
}); });
if socket.send(Message::Text(ack.to_string().into())).await.is_err() { if socket
.send(Message::Text(ack.to_string().into()))
.await
.is_err()
{
break; break;
} }
} }
+112 -24
View File
@@ -1,8 +1,6 @@
use crate::app::AppConfig; use crate::app::AppConfig;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use bridge_runtime::{RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan}; use bridge_runtime::{RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::fs; use std::fs;
@@ -14,6 +12,7 @@ const HEADER_WORKSPACE_ID: &str = "x-mnote-workspace-id";
const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel"; const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel";
const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client"; const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key"; const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
fn read_env_or_dotenv(key: &str) -> Option<String> { fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) { if let Ok(value) = std::env::var(key) {
@@ -57,6 +56,10 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
return Ok(authorization.to_string()); return Ok(authorization.to_string());
} }
if let Some(convex_token) = extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN) {
return Ok(format!("Bearer {convex_token}"));
}
let admin_key = config let admin_key = config
.convex_admin_key .convex_admin_key
.clone() .clone()
@@ -71,27 +74,32 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
.with_header("x-upstream-service", "convex") .with_header("x-upstream-service", "convex")
})?; })?;
let effective_actor_id = if context.auth.actor_id.trim().is_empty() // 说明:
|| context.auth.actor_id == "anonymous" // - Next 侧 `setAdminAuth(adminKey)` 走的是纯 admin auth,而不是伪造用户身份。
{ // - Convex 业务函数内部会在缺少真实 token 时自行回退到 DEV_USER_ID。
config.dev_user_id.clone() // - 这里若强行附带伪造 identity,会让 getAuthUserId(ctx) 命中一个未映射用户,
} else { // 反而绕过 DEV_USER_ID fallback,导致 workspace membership 校验失败。
context.auth.actor_id.clone() Ok(format!("Convex {admin_key}"))
}; }
let identity = json!({ fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
"subject": effective_actor_id, context
"issuer": "https://mnote.local/dev-auth", .auth
"tokenIdentifier": format!("dev-user|{}", effective_actor_id), .cookie_header
"name": config.dev_user_name, .as_deref()
"email": config.dev_user_email, .and_then(|cookie_header| {
}); cookie_header.split(';').find_map(|segment| {
let encoded = STANDARD.encode( let (key, value) = segment.trim().split_once('=')?;
serde_json::to_string(&identity) if key.trim() != name {
.map_err(|error| WebError::internal(format!("开发用户身份序列化失败: {error}")))?, return None;
); }
let trimmed = value.trim();
Ok(format!("Convex {admin_key}:{encoded}")) if trimmed.is_empty() {
return None;
}
Some(trimmed.to_string())
})
})
} }
fn convex_url(config: &AppConfig, context: &RequestContext) -> Result<String, WebError> { fn convex_url(config: &AppConfig, context: &RequestContext) -> Result<String, WebError> {
@@ -303,7 +311,10 @@ pub async fn execute_convex_command_plan(
if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") { if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") {
return Err(WebError::bad_request_code( return Err(WebError::bad_request_code(
"transport_command_unsupported", "transport_command_unsupported",
format!("mnote-web transport 暂不支持 command: {}", plan.function_name), format!(
"mnote-web transport 暂不支持 command: {}",
plan.function_name
),
) )
.with_context(context) .with_context(context)
.with_header("x-error-phase", "plan_validation")); .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 response = request.send().await.map_err(|error| {
let base = if error.is_timeout() { 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 { } else {
WebError::service_unavailable_code( WebError::service_unavailable_code(
"convex_unavailable", "convex_unavailable",
@@ -419,3 +433,77 @@ pub async fn execute_convex_command_plan(
.with_header("x-upstream-status", status.as_u16().to_string())), .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::<Uri>()
.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");
}
}
@@ -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",
}
}
}
@@ -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()
}
@@ -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<String>,
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 }
}
}
@@ -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! {
<section data-testid="tree-shell-root" data-mode=mode>
<header>
<p>"mnote tree shell"</p>
<h1>{headline}</h1>
</header>
</section>
}
}
#[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);
}
}
@@ -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<PageTreeDomRow> {
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);
}
}
@@ -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);
}
}
@@ -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";
@@ -0,0 +1,75 @@
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TreeShellUiState {
pub expanded: BTreeSet<String>,
pub selected: Option<String>,
pub focused: Option<String>,
pub keyboard_anchor: Option<String>,
pub dragging: Option<String>,
pub drop_target: Option<String>,
}
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());
}
}
@@ -34,6 +34,14 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
"kernel.subtree.move" => "kernel:moveSubtree", "kernel.subtree.move" => "kernel:moveSubtree",
"kernel.edge.attach" => "kernel:attachEdge", "kernel.edge.attach" => "kernel:attachEdge",
"kernel.edge.detach" => "kernel:detachEdge", "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.create" => "documents:createWithParentReference",
"documents.move" => "documents:move", "documents.move" => "documents:move",
"documents.delete" => "documents:softDelete", "documents.delete" => "documents:softDelete",
-1
View File
@@ -175,7 +175,6 @@ async function runTreeShellRegression(page, requestContext, viewer, target) {
await toggleButton.click({ timeout: UI_TIMEOUT_MS }); await toggleButton.click({ timeout: UI_TIMEOUT_MS });
await childRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await childRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
page.once("dialog", (dialog) => dialog.accept(createdTitle));
const createResponsePromise = page.waitForResponse( const createResponsePromise = page.waitForResponse(
(response) => (response) =>
response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) && response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) &&
+158
View File
@@ -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);
});
+188
View File
@@ -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);
});
+183
View File
@@ -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,
};
@@ -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;
}
@@ -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 ? <div>{children}</div> : null),
DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
DialogTitle: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
vi.mock("@/components/ui/input", () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
}));
vi.mock("@/components/ui/tabs", () => ({
Tabs: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabsList: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
TabsTrigger: ({
children,
className,
value,
}: {
children: ReactNode;
className?: string;
value: string;
}) => (
<button type="button" className={className} data-value={value}>
{children}
</button>
),
TabsContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
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(
<MoveEmbedPickerDialog
open
onOpenChange={onOpenChange}
workspaceId="ws_test"
defaultMode="move"
allowRoot
excludeIds={["doc_hidden"]}
onPick={onPick}
/>,
);
});
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);
});
});
@@ -3,13 +3,12 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react"; 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 { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { SidebarInitialData } from "@/components/sidebar/types"; import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildPageTreeProjectionItems, buildPickerTreeItems } from "@/lib/tree-projection"; import { buildPageTreeProjectionItems, buildPickerTreeItems } from "@/lib/tree-projection";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDocumentSearch } from "@/hooks/use-document-search"; import { useDocumentSearch } from "@/hooks/use-document-search";
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search"; import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
@@ -29,10 +28,6 @@ type PickerItem =
| { kind: "root"; id: null; title: string; subtitle?: string } | { kind: "root"; id: null; title: string; subtitle?: string }
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number; raw?: DocumentSearchResult }; | { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number; raw?: DocumentSearchResult };
type ViewerIdentity = {
userId: string;
};
async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData> { async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData> {
const response = await fetch(`/api/sidebar?workspaceId=${encodeURIComponent(workspaceId)}`, { const response = await fetch(`/api/sidebar?workspaceId=${encodeURIComponent(workspaceId)}`, {
method: "GET", method: "GET",
@@ -48,18 +43,6 @@ async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData
return response.json(); return response.json();
} }
async function fetchViewerIdentity(): Promise<ViewerIdentity> {
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 { interface MoveEmbedPickerDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -147,10 +130,6 @@ function MoveEmbedPickerDialogBody({
const trimmed = query.trim(); const trimmed = query.trim();
const isEmptyQuery = trimmed.length === 0; const isEmptyQuery = trimmed.length === 0;
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
const treeShellEnabled = Boolean(
(runtimeConfig.mnoteWebBaseUrl ?? "").trim() && runtimeConfig.mnoteWebTreeShellEnabled === true,
);
const sidebarQuery = useQuery({ const sidebarQuery = useQuery({
queryKey: ["move-embed-picker-sidebar", workspaceId], queryKey: ["move-embed-picker-sidebar", workspaceId],
@@ -165,14 +144,6 @@ function MoveEmbedPickerDialogBody({
gcTime: 60_000, 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 { data, isLoading, error } = useDocumentSearch(payload, Boolean(payload) && !isEmptyQuery);
const items = useMemo<PickerItem[]>(() => { const items = useMemo<PickerItem[]>(() => {
@@ -220,7 +191,13 @@ function MoveEmbedPickerDialogBody({
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]); }, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]);
const placeholder = mode === "move" ? "移动到..." : "嵌入到..."; 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 = ( const pickerFallback = (
sidebarQuery.isLoading ? ( sidebarQuery.isLoading ? (
<div className="p-4 text-sm text-gray-400">...</div> <div className="p-4 text-sm text-gray-400">...</div>
@@ -229,39 +206,17 @@ function MoveEmbedPickerDialogBody({
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="p-4 text-sm text-gray-400"></div> <div className="p-4 text-sm text-gray-400"></div>
) : ( ) : (
<div className="py-2"> <TreePickerSurface
{items.map((item, idx) => ( items={items}
<button highlighted={highlighted}
key={item.kind === "root" ? "root" : item.id} onHighlight={setHighlighted}
type="button" onPick={(targetId) => {
className={cn( void handlePick(targetId);
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]", }}
idx === highlighted && "bg-[#e3ecff]", />
)}
onMouseEnter={() => setHighlighted(idx)}
onClick={() => void handlePick(item.id)}
>
<div
className="text-sm font-medium text-gray-900"
style={item.kind === "doc" ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
>
{item.title}
</div>
{item.subtitle && <div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>}
</button>
))}
</div>
) )
); );
const handlePick = useCallback(
async (targetId: string | null) => {
await onPick(mode, targetId);
onOpenChange(false);
},
[mode, onOpenChange, onPick],
);
return ( return (
<div className="flex h-[520px] flex-col"> <div className="flex h-[520px] flex-col">
<div className="border-b border-[#eef2ff] p-4"> <div className="border-b border-[#eef2ff] p-4">
@@ -315,29 +270,7 @@ function MoveEmbedPickerDialogBody({
{!workspaceId ? ( {!workspaceId ? (
<div className="p-4 text-sm text-gray-500"> workspaceId</div> <div className="p-4 text-sm text-gray-500"> workspaceId</div>
) : isEmptyQuery ? ( ) : isEmptyQuery ? (
<div className="h-full p-3"> <div className="h-full p-3">{pickerFallback}</div>
{!treeShellEnabled ? (
pickerFallback
) : viewerQuery.isLoading ? (
<div className="p-4 text-sm text-gray-400">...</div>
) : viewerQuery.error ? (
<div className="p-4 text-sm text-red-600">{String(viewerQuery.error)}</div>
) : (
<MnoteWebTreeShell
workspaceId={workspaceId}
actorId={viewerQuery.data?.userId ?? null}
mode="picker"
allowRootPick={allowRootPick}
excludeIds={excludeIds}
onNavigate={() => {}}
onPick={(targetId) => {
void handlePick(targetId);
}}
onRefresh={async () => undefined}
fallback={pickerFallback}
/>
)}
</div>
) : isLoading ? ( ) : isLoading ? (
<div className="p-4 text-sm text-gray-400">...</div> <div className="p-4 text-sm text-gray-400">...</div>
) : error ? ( ) : error ? (
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth";
const TREE_SHELL_CHANNEL = "mnote-tree-shell-v1"; const TREE_SHELL_CHANNEL = "mnote-tree-shell-v1";
const TREE_SHELL_PATH = "/tree"; const TREE_SHELL_PATH = "/tree";
@@ -170,6 +171,7 @@ export function MnoteWebTreeShell({
const readyTimerRef = useRef<number | null>(null); const readyTimerRef = useRef<number | null>(null);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [failedShellUrl, setFailedShellUrl] = useState<string | null>(null); const [failedShellUrl, setFailedShellUrl] = useState<string | null>(null);
const [authCookieReady, setAuthCookieReady] = useState(false);
const shellUrl = useMemo(() => { const shellUrl = useMemo(() => {
if (!baseUrl || !treeShellEnabled) return null; if (!baseUrl || !treeShellEnabled) return null;
@@ -198,7 +200,32 @@ export function MnoteWebTreeShell({
]); ]);
useEffect(() => { 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) { if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current); window.clearTimeout(readyTimerRef.current);
} }
@@ -213,7 +240,7 @@ export function MnoteWebTreeShell({
readyTimerRef.current = null; readyTimerRef.current = null;
} }
}; };
}, [shellUrl]); }, [authCookieReady, shellUrl]);
useEffect(() => { useEffect(() => {
if (!baseUrl || !treeShellEnabled) return; if (!baseUrl || !treeShellEnabled) return;
@@ -332,7 +359,7 @@ export function MnoteWebTreeShell({
return () => window.removeEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage);
}, [baseUrl, onNavigate, onOpenAsset, onOpenContextMenu, onOpenFileTreeContextMenu, onPick, onRefresh, treeShellEnabled]); }, [baseUrl, onNavigate, onOpenAsset, onOpenContextMenu, onOpenFileTreeContextMenu, onPick, onRefresh, treeShellEnabled]);
if (!shellUrl || failedShellUrl === shellUrl) { if (!shellUrl || !authCookieReady || failedShellUrl === shellUrl) {
return <>{fallback}</>; return <>{fallback}</>;
} }
@@ -134,6 +134,18 @@ export function FileTree({
return ( return (
<div <div
key={row.rowId} key={row.rowId}
data-testid={
row.kind === "doc"
? "filetree-doc-row"
: row.kind === "index"
? "filetree-index-row"
: row.kind === "asset-folder"
? "filetree-asset-folder-row"
: "filetree-asset-row"
}
data-row-id={row.rowId}
data-row-kind={row.kind}
data-doc-id={row.docId}
className={cn( className={cn(
baseClass, baseClass,
active && activeClass, active && activeClass,
@@ -227,6 +239,7 @@ export function FileTree({
<button <button
type="button" type="button"
aria-label="展开或折叠" aria-label="展开或折叠"
data-testid="filetree-toggle"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onToggleExpand(row.docId); onToggleExpand(row.docId);
@@ -248,6 +261,7 @@ export function FileTree({
<button <button
type="button" type="button"
aria-label="新建子页面" aria-label="新建子页面"
data-testid="filetree-create"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onCreateChild(row.docId); onCreateChild(row.docId);
@@ -269,6 +283,7 @@ export function FileTree({
<button <button
type="button" type="button"
aria-label="展开或折叠" aria-label="展开或折叠"
data-testid="filetree-toggle"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onToggleAssetFolderExpand?.(row.asset.id); onToggleAssetFolderExpand?.(row.asset.id);
@@ -200,6 +200,8 @@ function SortableTreeRow({
activeId === node.id && "bg-wolai-bg-active text-[#2563eb]", activeId === node.id && "bg-wolai-bg-active text-[#2563eb]",
isDragging && "opacity-60", isDragging && "opacity-60",
)} )}
data-testid="page-tree-row"
data-node-id={node.id}
data-active={activeId === node.id} data-active={activeId === node.id}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
> >
@@ -220,6 +222,7 @@ function SortableTreeRow({
<button <button
type="button" type="button"
aria-label="展开或折叠" aria-label="展开或折叠"
data-testid="page-tree-toggle"
onClick={onToggleExpand} onClick={onToggleExpand}
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100 shrink-0" className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100 shrink-0"
> >
@@ -228,13 +231,18 @@ function SortableTreeRow({
) : ( ) : (
<span className="w-5 h-5 shrink-0" /> <span className="w-5 h-5 shrink-0" />
)} )}
<Link href={`/documents/${node.id}`} className="flex-1 truncate text-left"> <Link
href={`/documents/${node.id}`}
data-testid="page-tree-open"
className="flex-1 truncate text-left"
>
{node.title || "无标题"} {node.title || "无标题"}
</Link> </Link>
</div> </div>
<button <button
type="button" type="button"
aria-label="新建子页面" aria-label="新建子页面"
data-testid="page-tree-create"
onClick={onCreateChild} onClick={onCreateChild}
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 shrink-0" className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 shrink-0"
> >
+113 -253
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation"; import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { useAuthActions } from "@convex-dev/auth/react"; import { useAuthActions } from "@convex-dev/auth/react";
import { useConvex } from "convex/react"; import { useConvex } from "convex/react";
import { useConvexAuth, useQuery } from "convex/react"; import { useConvexAuth } from "convex/react";
import { import {
ArrowRightLeft, ArrowRightLeft,
ArrowUpRight, ArrowUpRight,
@@ -38,7 +38,7 @@ import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { useSidebarStore } from "@/store/sidebar"; import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types"; import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
import { useSidebarData, type SidebarDataResult } from "@/hooks/use-sidebar-data"; import { useSidebarData, type SidebarDataResult } from "@/hooks/use-sidebar-data";
import { PrivateTree } from "@/components/sidebar/private-tree"; import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree"; import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
import { import {
buildPageTreeProjectionItems, buildPageTreeProjectionItems,
@@ -47,26 +47,24 @@ import {
import { useSearchPaletteStore } from "@/store/search-palette"; import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge"; import { useEditorBridgeStore } from "@/store/editor-bridge";
import { useCurrentDocumentStore } from "@/store/current-document"; import { useCurrentDocumentStore } from "@/store/current-document";
import { FileTree } from "@/components/sidebar/file-tree";
import { MnoteWebTreeShell } from "@/components/sidebar/MnoteWebTreeShell";
import { buildVisibleRows } from "@/lib/file-tree/rows"; import { buildVisibleRows } from "@/lib/file-tree/rows";
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "@/lib/file-tree/selection";
import type { FileTreeRow } from "@/lib/file-tree/types";
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd"; import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
import { isRealFileAsset } from "@/lib/file-tree/asset"; import { isRealFileAsset } from "@/lib/file-tree/asset";
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog"; import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
import { import {
computeTreePaneDeleteTargets,
inferPasteTargetDocId, inferPasteTargetDocId,
isTextInputTarget, isTextInputTarget,
readFileTreeClipboardPayload, normalizeTreePaneSelectionForVisibleRows,
writeFileTreeClipboardPayload, readTreePaneClipboardPayload,
} from "@/lib/file-tree/clipboard"; reduceTreePaneSelection,
type TreePaneRow,
type TreePaneSelectionState,
writeTreePaneClipboardPayload,
} from "@/components/sidebar/tree-pane-bindings";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu"; import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events"; import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { useSidebarTreeStream } from "@/lib/tree-stream/use-sidebar-tree-stream"; import { useSidebarTreeStream } from "@/lib/tree-stream/use-sidebar-tree-stream";
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog"; import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
@@ -168,17 +166,12 @@ interface SidebarContentProps {
function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarContentProps) { function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarContentProps) {
const convex = useConvex(); const convex = useConvex();
const { isAuthenticated } = useConvexAuth(); useConvexAuth();
const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip");
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } = const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
useSidebarStore(); useSidebarStore();
const viewMode = useSidebarStore((state) => state.viewMode); const viewMode = useSidebarStore((state) => state.viewMode);
const setViewMode = useSidebarStore((state) => state.setViewMode); const setViewMode = useSidebarStore((state) => state.setViewMode);
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch); const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
const treeShellAvailable = Boolean(
(runtimeConfig.mnoteWebBaseUrl ?? "").trim() && runtimeConfig.mnoteWebTreeShellEnabled === true,
);
// 处理数据 // 处理数据
const sidebarData = useMemo(() => { const sidebarData = useMemo(() => {
@@ -251,14 +244,13 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>( const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null, null,
); );
const [treeShellReloadToken, setTreeShellReloadToken] = useState(0); const [resourceSelection, setResourceSelection] = useState<TreePaneSelectionState>(() => ({
const [fileTreeSelection, setFileTreeSelection] = useState<FileTreeSelectionState>(() => ({
selectedRowIds: new Set(), selectedRowIds: new Set(),
anchorRowId: null, anchorRowId: null,
focusedRowId: null, focusedRowId: null,
})); }));
const fileTreeContainerRef = useRef<HTMLDivElement>(null); const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set()); const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
// 删除在线表格后,Convex 订阅刷新存在极短延迟;这里做短暂“乐观隐藏”,避免文件树闪回。 // 删除在线表格后,Convex 订阅刷新存在极短延迟;这里做短暂“乐观隐藏”,避免文件树闪回。
const hiddenTableIdsRef = useRef<Map<string, number>>(new Map()); const hiddenTableIdsRef = useRef<Map<string, number>>(new Map());
@@ -322,7 +314,6 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const refreshTree = useCallback(async () => { const refreshTree = useCallback(async () => {
await sidebarQuery.refetch(); await sidebarQuery.refetch();
setTreeShellReloadToken((prev) => prev + 1);
}, [sidebarQuery]); }, [sidebarQuery]);
const refreshShareSummary = useCallback(async () => { const refreshShareSummary = useCallback(async () => {
@@ -487,7 +478,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
} }
return map; return map;
}, [groupPublicSummary, nodeById]); }, [groupPublicSummary, nodeById]);
const filteredPrivateTree = useMemo( const filteredPageNodes = useMemo(
() => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree), () => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree),
[filter, privateTree], [filter, privateTree],
); );
@@ -496,8 +487,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[privateTree], [privateTree],
); );
const filteredPrivatePageRows = useMemo( const filteredPrivatePageRows = useMemo(
() => buildPageTreeProjectionItems(filteredPrivateTree), () => buildPageTreeProjectionItems(filteredPageNodes),
[filteredPrivateTree], [filteredPageNodes],
); );
const flattenedPrivate = useMemo( const flattenedPrivate = useMemo(
() => filterVisiblePageTreeProjectionItems(privatePageRows, expanded), () => filterVisiblePageTreeProjectionItems(privatePageRows, expanded),
@@ -614,7 +605,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set()); const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
const fileTreeRows = useMemo( const resourceRows = useMemo(
() => () =>
buildVisibleRows({ buildVisibleRows({
pageRows: visibleFilteredPrivatePageRows, pageRows: visibleFilteredPrivatePageRows,
@@ -632,12 +623,12 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
], ],
); );
const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]); const resourceVisibleRowIds = useMemo(() => resourceRows.map((row) => row.rowId), [resourceRows]);
const fileTreeRowById = useMemo(() => new Map(fileTreeRows.map((row) => [row.rowId, row])), [fileTreeRows]); const resourceRowById = useMemo(() => new Map(resourceRows.map((row) => [row.rowId, row])), [resourceRows]);
useEffect(() => { useEffect(() => {
setFileTreeSelection((prev) => normalizeFileTreeSelectionForVisibleRows(prev, fileTreeVisibleRowIds)); setResourceSelection((prev) => normalizeTreePaneSelectionForVisibleRows(prev, resourceVisibleRowIds));
}, [fileTreeVisibleRowIds]); }, [resourceVisibleRowIds]);
const docParentById = useMemo( const docParentById = useMemo(
() => () =>
@@ -679,14 +670,6 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[router, setOpen], [router, setOpen],
); );
const handleNavigateFromTreeShell = useCallback(
(documentId: string) => {
if (!documentId) return;
handleOpenDocument(documentId, "main");
},
[handleOpenDocument],
);
const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => { const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => {
const url = buildDocumentUrl(node.id); const url = buildDocumentUrl(node.id);
const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url; const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
@@ -840,17 +823,17 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
})(); })();
}, [activeId, editorBridge, router, setOpen]); }, [activeId, editorBridge, router, setOpen]);
const handleFileTreeBlankMouseDown = useCallback(() => { const handleResourcePaneBlankMouseDown = useCallback(() => {
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" })); setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
}, []); }, []);
const handleFileTreeRowClick = useCallback( const handleResourceRowClick = useCallback(
(row: FileTreeRow, event: React.MouseEvent) => { (row: TreePaneRow, event: React.MouseEvent) => {
setFileTreeSelection((prev) => setResourceSelection((prev) =>
reduceFileTreeSelection(prev, { reduceTreePaneSelection(prev, {
type: "click", type: "click",
rowId: row.rowId, rowId: row.rowId,
visibleRowIds: fileTreeVisibleRowIds, visibleRowIds: resourceVisibleRowIds,
modifiers: { modifiers: {
shiftKey: event.shiftKey, shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey, ctrlKey: event.ctrlKey,
@@ -871,23 +854,23 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
// doc/index/asset 都统一跳转到所属页面(index.md) // doc/index/asset 都统一跳转到所属页面(index.md)
handleOpenDocument(targetDocId, "main"); handleOpenDocument(targetDocId, "main");
}, },
[activeId, fileTreeVisibleRowIds, handleOpenDocument], [activeId, resourceVisibleRowIds, handleOpenDocument],
); );
const handleFileTreeRowDragStart = useCallback((row: FileTreeRow) => { const handleResourceRowDragStart = useCallback((row: TreePaneRow) => {
setFileTreeSelection((prev) => { setResourceSelection((prev) => {
if (prev.selectedRowIds.has(row.rowId)) return prev; if (prev.selectedRowIds.has(row.rowId)) return prev;
return reduceFileTreeSelection(prev, { return reduceTreePaneSelection(prev, {
type: "click", type: "click",
rowId: row.rowId, rowId: row.rowId,
visibleRowIds: fileTreeVisibleRowIds, visibleRowIds: resourceVisibleRowIds,
modifiers: { shiftKey: false, ctrlKey: false, metaKey: false }, modifiers: { shiftKey: false, ctrlKey: false, metaKey: false },
}); });
}); });
}, [fileTreeVisibleRowIds]); }, [resourceVisibleRowIds]);
const handleFileTreeRowDoubleClick = useCallback( const handleResourceRowDoubleClick = useCallback(
(row: FileTreeRow, _event?: React.MouseEvent) => { (row: TreePaneRow, _event?: React.MouseEvent) => {
if (row.kind === "asset" || row.kind === "asset-folder") { if (row.kind === "asset" || row.kind === "asset-folder") {
handleOpenAsset(row.asset); handleOpenAsset(row.asset);
return; return;
@@ -897,12 +880,12 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[handleOpenAsset, handleOpenDocument], [handleOpenAsset, handleOpenDocument],
); );
const handleFileTreeRowContextMenu = useCallback( const handleResourceRowContextMenu = useCallback(
(row: FileTreeRow, event: React.MouseEvent) => { (row: TreePaneRow, event: React.MouseEvent) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setFileTreeSelection((prev) => setResourceSelection((prev) =>
reduceFileTreeSelection(prev, { type: "contextmenu", rowId: row.rowId }), reduceTreePaneSelection(prev, { type: "contextmenu", rowId: row.rowId }),
); );
if (row.kind === "asset" || row.kind === "asset-folder") { if (row.kind === "asset" || row.kind === "asset-folder") {
@@ -938,21 +921,21 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
return; return;
} }
const container = fileTreeContainerRef.current; const container = resourcePaneContainerRef.current;
const activeElement = document.activeElement; const activeElement = document.activeElement;
if (!container || !activeElement || !container.contains(activeElement)) { if (!container || !activeElement || !container.contains(activeElement)) {
return; return;
} }
if (isCopy) { if (isCopy) {
if (fileTreeSelection.selectedRowIds.size === 0) { if (resourceSelection.selectedRowIds.size === 0) {
return; return;
} }
event.preventDefault(); event.preventDefault();
const orderedRowIds = fileTreeRows const orderedRowIds = resourceRows
.filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId)) .filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
.map((row) => row.rowId); .map((row) => row.rowId);
await writeFileTreeClipboardPayload({ await writeTreePaneClipboardPayload({
type: "mnote-file-tree", type: "mnote-file-tree",
version: 1, version: 1,
action: "copy", action: "copy",
@@ -963,14 +946,14 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
if (isPaste) { if (isPaste) {
event.preventDefault(); event.preventDefault();
const payload = await readFileTreeClipboardPayload(); const payload = await readTreePaneClipboardPayload();
if (!payload || payload.rowIds.length === 0) { if (!payload || payload.rowIds.length === 0) {
return; return;
} }
const targetDocId = inferPasteTargetDocId({ const targetDocId = inferPasteTargetDocId({
focusedRowId: fileTreeSelection.focusedRowId, focusedRowId: resourceSelection.focusedRowId,
rowById: fileTreeRowById, rowById: resourceRowById,
activeDocId: activeId || null, activeDocId: activeId || null,
}); });
if (!targetDocId) { if (!targetDocId) {
@@ -979,8 +962,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
} }
const rows = payload.rowIds const rows = payload.rowIds
.map((rowId) => fileTreeRowById.get(rowId as any)) .map((rowId) => resourceRowById.get(rowId as any))
.filter(Boolean) as FileTreeRow[]; .filter(Boolean) as TreePaneRow[];
const docItemsMap = new Map<string, boolean>(); const docItemsMap = new Map<string, boolean>();
rows.forEach((row) => { rows.forEach((row) => {
@@ -1011,7 +994,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
emitDocumentsChanged(targetDocId); emitDocumentsChanged(targetDocId);
} }
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<FileTreeRow, { kind: "asset" }>[]; const assetRows = rows.filter((row) => row.kind === "asset") as Extract<TreePaneRow, { kind: "asset" }>[];
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id); const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
if (copyableAssetIds.length > 0) { if (copyableAssetIds.length > 0) {
@@ -1041,10 +1024,10 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [ }, [
activeId, activeId,
fileTreeRowById, resourceRowById,
fileTreeRows, resourceRows,
fileTreeSelection.focusedRowId, resourceSelection.focusedRowId,
fileTreeSelection.selectedRowIds, resourceSelection.selectedRowIds,
sidebarQuery, sidebarQuery,
]); ]);
@@ -1301,10 +1284,10 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[mediaAssets, mindmapAssets, refreshTree, tableAssets], [mediaAssets, mindmapAssets, refreshTree, tableAssets],
); );
const handleDeleteFileTreeSelection = useCallback(async () => { const handleDeleteResourceSelection = useCallback(async () => {
const { docIds, assetIds } = computeFileTreeDeleteTargets({ const { docIds, assetIds } = computeTreePaneDeleteTargets({
visibleRows: fileTreeRows, visibleRows: resourceRows,
selectedRowIds: fileTreeSelection.selectedRowIds, selectedRowIds: resourceSelection.selectedRowIds,
parentById: docParentById, parentById: docParentById,
}); });
@@ -1317,14 +1300,14 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
// 说明:文件树里可能展示“思维导图子文件”等动态资源(不一定在 mindmapAssets 列表里)。 // 说明:文件树里可能展示“思维导图子文件”等动态资源(不一定在 mindmapAssets 列表里)。
// 为避免出现“看起来选中了,但删除不生效”,这里优先从可见行里拿到选中资源的完整元数据。 // 为避免出现“看起来选中了,但删除不生效”,这里优先从可见行里拿到选中资源的完整元数据。
const isAssetRow = ( const isAssetRow = (
row: FileTreeRow, row: TreePaneRow,
): row is Extract<FileTreeRow, { kind: "asset" | "asset-folder" }> => ): row is Extract<TreePaneRow, { kind: "asset" | "asset-folder" }> =>
row.kind === "asset" || row.kind === "asset-folder"; row.kind === "asset" || row.kind === "asset-folder";
const selectedAssetHints = Array.from( const selectedAssetHints = Array.from(
new Map( new Map(
fileTreeRows resourceRows
.filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId)) .filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
.filter(isAssetRow) .filter(isAssetRow)
.map((row) => [row.asset.id, row.asset] as const), .map((row) => [row.asset.id, row.asset] as const),
).values(), ).values(),
@@ -1379,15 +1362,15 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
await refreshTree(); await refreshTree();
setContextMenu(null); setContextMenu(null);
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" })); setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
} catch (error) { } catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败"); window.alert(error instanceof Error ? error.message : "删除失败");
} }
}, [ }, [
activeId, activeId,
docParentById, docParentById,
fileTreeRows, resourceRows,
fileTreeSelection.selectedRowIds, resourceSelection.selectedRowIds,
handleDeleteAssets, handleDeleteAssets,
mediaAssets, mediaAssets,
mindmapAssets, mindmapAssets,
@@ -1528,8 +1511,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[moveLocalNode, refreshTree, setExpanded], [moveLocalNode, refreshTree, setExpanded],
); );
const handleFileTreeDropFiles = useCallback( const handleResourcePaneDropFiles = useCallback(
(docId: string, files: FileList, targetRow?: FileTreeRow) => { (docId: string, files: FileList, targetRow?: TreePaneRow) => {
void (async () => { void (async () => {
const droppedFiles = Array.from(files ?? []); const droppedFiles = Array.from(files ?? []);
if (droppedFiles.length === 0) return; if (droppedFiles.length === 0) return;
@@ -1552,8 +1535,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const inferredTargetDocId = const inferredTargetDocId =
docId || docId ||
inferPasteTargetDocId({ inferPasteTargetDocId({
focusedRowId: fileTreeSelection.focusedRowId, focusedRowId: resourceSelection.focusedRowId,
rowById: fileTreeRowById, rowById: resourceRowById,
activeDocId: activeId || null, activeDocId: activeId || null,
}) || }) ||
""; "";
@@ -1618,16 +1601,16 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[ [
activeId, activeId,
editorBridge, editorBridge,
fileTreeRowById, resourceRowById,
fileTreeSelection.focusedRowId, resourceSelection.focusedRowId,
sidebarData.activeWorkspaceId, sidebarData.activeWorkspaceId,
sidebarData.documents, sidebarData.documents,
sidebarQuery, sidebarQuery,
], ],
); );
const handleFileTreeInternalDrop = useCallback( const handleResourcePaneInternalDrop = useCallback(
(args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => { (args: { targetRow: TreePaneRow; rowIds: string[]; copy: boolean }) => {
void (async () => { void (async () => {
const targetDocId = inferDropTargetDocId(args.targetRow); const targetDocId = inferDropTargetDocId(args.targetRow);
if (!targetDocId) { if (!targetDocId) {
@@ -1660,11 +1643,11 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
}); });
const rows = uniqueRowIds const rows = uniqueRowIds
.map((rowId) => fileTreeRowById.get(rowId as any)) .map((rowId) => resourceRowById.get(rowId as any))
.filter(Boolean) as FileTreeRow[]; .filter(Boolean) as TreePaneRow[];
const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId); const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId);
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<FileTreeRow, { kind: "asset" }>[]; const assetRows = rows.filter((row) => row.kind === "asset") as Extract<TreePaneRow, { kind: "asset" }>[];
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id); const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
if (docIds.length === 0 && copyableAssetIds.length === 0) { if (docIds.length === 0 && copyableAssetIds.length === 0) {
@@ -1771,7 +1754,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
[ [
childrenCountByParentId, childrenCountByParentId,
docParentById, docParentById,
fileTreeRowById, resourceRowById,
moveLocalNode, moveLocalNode,
refreshTree, refreshTree,
sidebarQuery, sidebarQuery,
@@ -1803,7 +1786,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const handleDeleteFromContextMenuNode = useCallback( const handleDeleteFromContextMenuNode = useCallback(
async (node: SidebarTreeNode) => { async (node: SidebarTreeNode) => {
if (viewMode === "filesystem") { if (viewMode === "filesystem") {
await handleDeleteFileTreeSelection(); await handleDeleteResourceSelection();
return; return;
} }
@@ -1816,7 +1799,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
window.alert(error instanceof Error ? error.message : "删除失败"); window.alert(error instanceof Error ? error.message : "删除失败");
} }
}, },
[handleDelete, handleDeleteFileTreeSelection, viewMode], [handleDelete, handleDeleteResourceSelection, viewMode],
); );
const handleDeleteFromAssetContextMenu = useCallback( const handleDeleteFromAssetContextMenu = useCallback(
@@ -1847,7 +1830,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
try { try {
await handleDeleteAssets(uniqueAssetIds, assetHint); await handleDeleteAssets(uniqueAssetIds, assetHint);
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" })); setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
} catch (error) { } catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败"); window.alert(error instanceof Error ? error.message : "删除失败");
} }
@@ -2193,68 +2176,6 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
}); });
}, []); }, []);
const openContextMenuFromTreeShell = useCallback(
({ documentId, x, y }: { documentId: string; x: number; y: number }) => {
const node = nodeById.get(documentId);
if (!node) {
return;
}
setContextMenu({
node,
x,
y,
});
},
[nodeById],
);
const openFileTreeContextMenuFromTreeShell = useCallback(
({
documentId,
assetId,
rowId,
x,
y,
}: {
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x: number;
y: number;
}) => {
if (assetId) {
const asset = assetById.get(assetId);
if (asset) {
if (rowId) {
setFileTreeSelection((prev) =>
reduceFileTreeSelection(prev, { type: "contextmenu", rowId }),
);
}
setAssetMenu({ asset, x, y });
return;
}
}
if (documentId) {
const node = nodeById.get(documentId);
if (node) {
if (rowId) {
setFileTreeSelection((prev) =>
reduceFileTreeSelection(prev, { type: "contextmenu", rowId }),
);
}
setContextMenu({
node,
x,
y,
});
}
}
},
[assetById, nodeById],
);
const openShareDialog = useCallback((node: SidebarTreeNode) => { const openShareDialog = useCallback((node: SidebarTreeNode) => {
setShareTarget({ setShareTarget({
id: node.id, id: node.id,
@@ -2596,42 +2517,17 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
</button> </button>
{!collapsedSections.private ? ( {!collapsedSections.private ? (
<div className="flex-1 px-1 pb-2"> <div className="flex-1 px-1 pb-2">
{treeShellAvailable ? ( <SidebarTreeSurface
<MnoteWebTreeShell mode="page"
workspaceId={sidebarData.activeWorkspaceId ?? null} className="h-full"
activeDocumentId={activeId} rows={visibleFilteredPrivatePageRows}
actorId={typeof currentUser?._id === "string" ? currentUser._id : null} expanded={expanded}
reloadToken={treeShellReloadToken} activeId={activeId}
onNavigate={handleNavigateFromTreeShell} onToggleExpand={toggleExpand}
onOpenContextMenu={openContextMenuFromTreeShell} onMove={handleMove}
onRefresh={refreshTree} onCreateChild={handleCreate}
fallback={ onContextMenu={openContextMenu}
<div className="h-full rounded-md border border-[#eff2f6] bg-white"> />
<PrivateTree
rows={visibleFilteredPrivatePageRows}
expanded={expanded}
activeId={activeId}
onToggleExpand={toggleExpand}
onMove={handleMove}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
/>
</div>
}
/>
) : (
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
<PrivateTree
rows={visibleFilteredPrivatePageRows}
expanded={expanded}
activeId={activeId}
onToggleExpand={toggleExpand}
onMove={handleMove}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
/>
</div>
)}
</div> </div>
) : ( ) : (
<div className="px-4 pb-2 text-xs text-gray-400"></div> <div className="px-4 pb-2 text-xs text-gray-400"></div>
@@ -2642,66 +2538,30 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]"> <div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
<div className="flex-1 px-1 pb-2"> <div className="flex-1 px-1 pb-2">
<div <div
ref={fileTreeContainerRef} ref={resourcePaneContainerRef}
data-testid="file-tree-container" data-testid="file-tree-container"
className="h-full w-full min-w-0 overflow-x-hidden" className="h-full w-full min-w-0 overflow-x-hidden"
> >
{treeShellAvailable ? ( <SidebarTreeSurface
<MnoteWebTreeShell mode="filetree"
workspaceId={sidebarData.activeWorkspaceId ?? null} className="h-full"
activeDocumentId={activeId} rows={resourceRows}
actorId={typeof currentUser?._id === "string" ? currentUser._id : null} activeId={activeId}
mode="filetree" selectedRowIds={resourceSelection.selectedRowIds}
reloadToken={treeShellReloadToken} onRowClick={handleResourceRowClick}
onNavigate={handleNavigateFromTreeShell} onRowDoubleClick={(row, event) => handleResourceRowDoubleClick(row, event)}
onOpenAsset={(assetId) => { onRowContextMenu={handleResourceRowContextMenu}
const asset = assetById.get(assetId); onRowDragStart={(row, event) => {
if (asset) { void event;
handleOpenAsset(asset); handleResourceRowDragStart(row);
} }}
}} onToggleExpand={toggleExpand}
onOpenContextMenu={openContextMenuFromTreeShell} onToggleAssetFolderExpand={toggleAssetFolderExpand}
onOpenFileTreeContextMenu={openFileTreeContextMenuFromTreeShell} onCreateChild={handleCreate}
onRefresh={refreshTree} onBlankMouseDown={handleResourcePaneBlankMouseDown}
fallback={ onDropFiles={handleResourcePaneDropFiles}
<div className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white"> onInternalDrop={handleResourcePaneInternalDrop}
<FileTree />
rows={fileTreeRows}
activeId={activeId}
selectedRowIds={fileTreeSelection.selectedRowIds}
onRowClick={handleFileTreeRowClick}
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
onRowContextMenu={handleFileTreeRowContextMenu}
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
onToggleExpand={toggleExpand}
onToggleAssetFolderExpand={toggleAssetFolderExpand}
onCreateChild={handleCreate}
onBlankMouseDown={handleFileTreeBlankMouseDown}
onDropFiles={handleFileTreeDropFiles}
onInternalDrop={handleFileTreeInternalDrop}
/>
</div>
}
/>
) : (
<div className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white">
<FileTree
rows={fileTreeRows}
activeId={activeId}
selectedRowIds={fileTreeSelection.selectedRowIds}
onRowClick={handleFileTreeRowClick}
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
onRowContextMenu={handleFileTreeRowContextMenu}
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
onToggleExpand={toggleExpand}
onToggleAssetFolderExpand={toggleAssetFolderExpand}
onCreateChild={handleCreate}
onBlankMouseDown={handleFileTreeBlankMouseDown}
onDropFiles={handleFileTreeDropFiles}
onInternalDrop={handleFileTreeInternalDrop}
/>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -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";
@@ -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<string>;
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<string>;
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 (
<div
data-testid={surfaceTestId}
data-shell-mode={props.mode}
className={cn(
"h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white",
props.className,
)}
>
{props.mode === "page" ? (
<PrivateTree
rows={props.rows}
expanded={props.expanded}
activeId={props.activeId}
onToggleExpand={props.onToggleExpand}
onMove={props.onMove}
onCreateChild={props.onCreateChild}
onContextMenu={props.onContextMenu}
/>
) : (
<FileTree
rows={props.rows}
activeId={props.activeId}
selectedRowIds={props.selectedRowIds}
onRowClick={props.onRowClick}
onRowDoubleClick={props.onRowDoubleClick}
onRowContextMenu={props.onRowContextMenu}
onRowDragStart={props.onRowDragStart}
onToggleExpand={props.onToggleExpand}
onToggleAssetFolderExpand={props.onToggleAssetFolderExpand}
onCreateChild={props.onCreateChild}
onBlankMouseDown={props.onBlankMouseDown}
onDropFiles={props.onDropFiles}
onInternalDrop={props.onInternalDrop}
/>
)}
</div>
);
}
export function TreePickerSurface({
items,
highlighted,
className,
emptyText = "没有匹配结果",
onHighlight,
onPick,
}: TreePickerSurfaceProps) {
if (items.length === 0) {
return <div className="p-4 text-sm text-gray-400">{emptyText}</div>;
}
return (
<div
data-testid="tree-picker-surface"
className={cn("py-2", className)}
>
{items.map((item, index) => {
const isRoot = item.kind === "root";
return (
<button
key={isRoot ? "root" : item.id}
data-testid={isRoot ? "tree-picker-root" : "tree-picker-row"}
data-node-id={isRoot ? "" : item.id}
type="button"
className={cn(
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
index === highlighted && "bg-[#e3ecff]",
)}
onMouseEnter={() => onHighlight(index)}
onClick={() => onPick(item.id)}
>
<div
className="text-sm font-medium text-gray-900"
style={!isRoot ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
>
{item.title}
</div>
{item.subtitle ? (
<div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>
) : null}
</button>
);
})}
</div>
);
}
@@ -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("删除失败");
});
});
@@ -11,6 +11,41 @@ type DocumentCommandErrorPayload = {
error?: string; 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 = { export type DocumentCreateCommandResult = {
id: string; id: string;
title?: string | null; title?: string | null;
+38
View File
@@ -1,4 +1,8 @@
import type { DocumentRecord } from "@/lib/documents"; import type { DocumentRecord } from "@/lib/documents";
import type {
TreeProjectionCapability,
TreeProjectionResourceMeta,
} from "@/lib/tree-protocol";
export type KernelSidebarProjectionEdge = { export type KernelSidebarProjectionEdge = {
id: string; id: string;
@@ -12,11 +16,16 @@ export type KernelSidebarProjectionItem = {
nodeId: string; nodeId: string;
parentNodeId: string | null; parentNodeId: string | null;
nodeType: "page"; nodeType: "page";
projectionKind: "sidebar_tree";
title: string | null; title: string | null;
depth: number; depth: number;
position: number | null; position: number | null;
childCount: number; childCount: number;
expandable: boolean;
expandedByDefault: boolean; expandedByDefault: boolean;
capabilities: TreeProjectionCapability[];
resourceMeta: TreeProjectionResourceMeta;
iconHint: string | null;
}; };
export type KernelSidebarProjection = { export type KernelSidebarProjection = {
@@ -64,6 +73,25 @@ function dedupeRecords(records: DocumentRecord[]) {
return unique; 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[]) { function buildChildrenByParent(records: DocumentRecord[]) {
const childrenByParentId = new Map<string | null, DocumentRecord[]>(); const childrenByParentId = new Map<string | null, DocumentRecord[]>();
const recordIds = new Set(records.map((record) => record.id)); const recordIds = new Set(records.map((record) => record.id));
@@ -102,11 +130,21 @@ export function buildKernelSidebarProjection(
nodeId: child.id, nodeId: child.id,
parentNodeId: parentId, parentNodeId: parentId,
nodeType: "page", nodeType: "page",
projectionKind: "sidebar_tree",
title: child.title ?? "无标题", title: child.title ?? "无标题",
depth, depth,
position: child.sort_order ?? null, position: child.sort_order ?? null,
childCount: childNodes.length, childCount: childNodes.length,
expandable: childNodes.length > 0,
expandedByDefault: true, 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)) { if (parentId && recordById.has(parentId)) {
edges.push({ edges.push({
+12
View File
@@ -0,0 +1,12 @@
const MNOTE_WEB_AUTH_COOKIE_PATH = "/api/auth/mnote-web-token";
export async function ensureMnoteWebAuthCookie(): Promise<void> {
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 准备失败");
}
}
+61 -4
View File
@@ -221,11 +221,32 @@ describe("buildSidebarInitialData", () => {
nodeId: "doc_1", nodeId: "doc_1",
parentNodeId: null, parentNodeId: null,
nodeType: "page", nodeType: "page",
projectionKind: "sidebar_tree",
title: "页面 1", title: "页面 1",
depth: 0, depth: 0,
position: 1, position: 1,
childCount: 0, childCount: 0,
expandable: false,
expandedByDefault: true, 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: [], edges: [],
@@ -276,11 +297,32 @@ describe("buildSidebarInitialData", () => {
nodeId: "doc_1", nodeId: "doc_1",
parentNodeId: null, parentNodeId: null,
nodeType: "page", nodeType: "page",
projectionKind: "sidebar_tree",
title: "页面 1", title: "页面 1",
depth: 0, depth: 0,
position: 1, position: 1,
childCount: 0, childCount: 0,
expandable: false,
expandedByDefault: true, 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: [], edges: [],
@@ -319,7 +361,7 @@ describe("buildSidebarInitialData", () => {
}); });
}); });
it("缺少 kernel projection 时不再本地补真相,而是返回空契约 projection", () => { it("缺少 kernel projection 时会按 documents 重建 projection 并保留层级", () => {
expect( expect(
mapSidebarDatasetListQueryResultToInitialData({ mapSidebarDatasetListQueryResultToInitialData({
active_workspace_id: "ws_1", active_workspace_id: "ws_1",
@@ -337,6 +379,18 @@ describe("buildSidebarInitialData", () => {
created_at: "2026-04-14T00:00:00Z", created_at: "2026-04-14T00:00:00Z",
updated_at: null, 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: [], trashed_documents: [],
media_assets: [], media_assets: [],
@@ -351,13 +405,16 @@ describe("buildSidebarInitialData", () => {
).toMatchObject({ ).toMatchObject({
activeWorkspaceId: "ws_1", activeWorkspaceId: "ws_1",
kernelSidebarProjection: { kernelSidebarProjection: {
projectionId: "kernel_projection:sidebar_tree:missing", projectionId: "kernel_projection:sidebar_tree:workspace_root",
items: [],
edges: [],
}, },
kernelSidebarTree: [ kernelSidebarTree: [
{ {
id: "doc_1", id: "doc_1",
children: [
{
id: "doc_2",
},
],
}, },
], ],
}); });
+6
View File
@@ -96,6 +96,12 @@ function readKernelSidebarProjection(
return camelCaseProjection; 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; return EMPTY_KERNEL_SIDEBAR_PROJECTION;
} }
@@ -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");
});
});
+15 -27
View File
@@ -1,34 +1,12 @@
import type { SidebarTreeNode } from "@/lib/kernel-sidebar"; import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type {
export type TreeProjectionKind = "page_tree"; TreeProjectionCapability,
TreeProjectionItemBase,
export type TreeProjectionNodeType = "page"; } from "@/lib/tree-protocol";
export type TreeProjectionCapability =
| "expand"
| "open"
| "drag"
| "drop"
| "select"
| "create-child";
export type TreeProjectionResourceMeta = {
resourceKind: "document";
documentId: string;
};
export type PageTreeProjectionItem = { export type PageTreeProjectionItem = {
rowId: `page:${string}`; rowId: `page:${string}`;
nodeId: string; } & TreeProjectionItemBase & {
parentNodeId: string | null;
nodeType: TreeProjectionNodeType;
projectionKind: TreeProjectionKind;
depth: number;
position: number | null;
title: string;
childCount: number;
capabilities: TreeProjectionCapability[];
resourceMeta: TreeProjectionResourceMeta;
node: SidebarTreeNode; node: SidebarTreeNode;
}; };
@@ -45,6 +23,11 @@ function buildPageCapabilities(childCount: number): TreeProjectionCapability[] {
"drop", "drop",
"select", "select",
"create-child", "create-child",
"rename",
"archive",
"restore",
"context-menu",
"reorder",
]; ];
if (childCount > 0) { if (childCount > 0) {
capabilities.unshift("expand"); capabilities.unshift("expand");
@@ -80,11 +63,16 @@ export function buildPageTreeProjectionItems(
position: node.kernel?.position ?? node.sort_order ?? index, position: node.kernel?.position ?? node.sort_order ?? index,
title: node.title ?? "无标题", title: node.title ?? "无标题",
childCount, childCount,
expandable: childCount > 0,
expandedByDefault: node.kernel?.expandedByDefault ?? true,
capabilities: buildPageCapabilities(childCount), capabilities: buildPageCapabilities(childCount),
resourceMeta: { resourceMeta: {
resourceKind: "document", resourceKind: "document",
documentId: node.id, documentId: node.id,
workspaceId: node.workspace_id,
iconHint: "page",
}, },
iconHint: "page",
node: { node: {
...node, ...node,
parent_id: parentNodeId, parent_id: parentNodeId,
@@ -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");
});
});
+76
View File
@@ -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<string, unknown>;
};
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;
};
@@ -40,21 +40,64 @@ const baseSidebarData: SidebarInitialData = {
nodeId: "root", nodeId: "root",
parentNodeId: null, parentNodeId: null,
nodeType: "page", nodeType: "page",
projectionKind: "sidebar_tree",
title: "Root", title: "Root",
depth: 0, depth: 0,
position: 0, position: 0,
childCount: 1, childCount: 1,
expandable: true,
expandedByDefault: 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", nodeId: "child",
parentNodeId: "root", parentNodeId: "root",
nodeType: "page", nodeType: "page",
projectionKind: "sidebar_tree",
title: "Child", title: "Child",
depth: 1, depth: 1,
position: 1, position: 1,
childCount: 0, childCount: 0,
expandable: false,
expandedByDefault: true, 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: [ edges: [
@@ -9,6 +9,7 @@ import {
parseTreeStreamMessage, parseTreeStreamMessage,
} from "@/lib/tree-stream/protocol"; } from "@/lib/tree-stream/protocol";
import { applyTreeStreamDelta, type TreeStreamDeltaEvent } from "@/lib/tree-stream/tree-delta"; import { applyTreeStreamDelta, type TreeStreamDeltaEvent } from "@/lib/tree-stream/tree-delta";
import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth";
export interface SidebarTreeStreamState { export interface SidebarTreeStreamState {
data: SidebarInitialData | null; data: SidebarInitialData | null;
@@ -60,9 +61,8 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
return undefined; return undefined;
} }
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor); let cancelled = false;
const eventSource = new EventSource(url, { withCredentials: true }); let eventSource: EventSource | null = null;
eventSourceRef.current = eventSource;
const handleMessage = (event: MessageEvent<string>) => { const handleMessage = (event: MessageEvent<string>) => {
const envelope = parseTreeStreamMessage({ const envelope = parseTreeStreamMessage({
@@ -121,21 +121,45 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
status: previous.data ? "live" : "fallback", status: previous.data ? "live" : "fallback",
error: previous.error ?? new Error("tree stream 连接失败"), error: previous.error ?? new Error("tree stream 连接失败"),
})); }));
eventSource.close(); eventSource?.close();
eventSourceRef.current = null; if (eventSourceRef.current === eventSource) {
eventSourceRef.current = null;
}
}; };
eventSource.addEventListener("snapshot", handleMessage as EventListener); void (async () => {
eventSource.addEventListener("delta", handleMessage as EventListener); try {
eventSource.addEventListener("resync", handleMessage as EventListener); await ensureMnoteWebAuthCookie();
eventSource.onmessage = handleMessage; if (cancelled) {
eventSource.onerror = handleError; 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 () => { return () => {
eventSource.removeEventListener("snapshot", handleMessage as EventListener); cancelled = true;
eventSource.removeEventListener("delta", handleMessage as EventListener); eventSource?.removeEventListener("snapshot", handleMessage as EventListener);
eventSource.removeEventListener("resync", handleMessage as EventListener); eventSource?.removeEventListener("delta", handleMessage as EventListener);
eventSource.close(); eventSource?.removeEventListener("resync", handleMessage as EventListener);
eventSource?.close();
if (eventSourceRef.current === eventSource) { if (eventSourceRef.current === eventSource) {
eventSourceRef.current = null; eventSourceRef.current = null;
} }