checkpoint before gfm ast parser design

This commit is contained in:
lix-2026
2026-05-08 00:41:03 +08:00
parent e8ba12e461
commit c620b9e40c
41 changed files with 12270 additions and 263 deletions
@@ -1,15 +1,25 @@
# 3-10 [done] Rust Web 3000 Tree / Editor Runtime Integration Checklist v1
> 更新时间:2026-04-29
>
> 纠偏补充(2026-04-29):用户复核发现此前“完成”口径只覆盖 API / 直达文档页 smoke,没有覆盖默认根页真实 UI 工作流。默认 `3000 /` 重新接入既有 Rust page tree / file tree renderer 与 UI 新建到 `leptos-tiptap` 的闭环,详见 `/mnt/Data1T/mnote/design/03-rust-web/done/3-11-rust-web-3000-existing-tree-editor-runtime-relink-checklist-v1.md`。
>
> 对应计划:
> - `/mnt/Data1T/mnote/design/03-rust-web/done/3-9-rust-web-3000-tree-editor-runtime-integration-plan-v1.md`
>
> 参考输入:
> - 当前 3000 截图:`/mnt/Data1T/mnote/tmp/image copy 32.png`
> - 当前 shell parity checklist`/mnt/Data1T/mnote/design/03-rust-web/done/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md`
> - Page Aggregate 对齐主线:`/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
## 1. 当前结论
@@ -17,17 +27,25 @@
当前已经完成的是:
- [x] `3000` 默认由 `mnote-web` 监听。
- [x] `3000` 根页已经有 Wolai 风格 workspace shell。
- [x] 根页包含左栏、顶栏、右下浮动按钮和 `data-mnote-shell="workspace"`
- [x] `/documents/:id` 已有 Rust Web document shell、Page Aggregate marker 和 `data-editor-host="leptos_tiptap_island"` marker。
[x] `/documents/:id` 已有 Rust Web document shell、Page Aggregate marker 和 `data-editor-host="leptos_tiptap_island"` marker。
当前没有完成的是:
- [x] 左栏 `我的页面` 显示真实 file/page tree rows,而不是空 section。
- [x] 根页正文不再使用设计稿静态链接列表作为伪主内容。
- [x] active page selection 能从 projection / recent / URL 一致落到 sidebar 和 document shell。
- [x] `/documents/:id` 浏览器运行态能挂载 `data-testid="mnote-leptos-tiptap-island-editor-root"`
- [x] `.ProseMirror[contenteditable="true"]` 可见、可输入、可保存、reload 后可读回。
- [x] smoke 能区分“只有外壳 UI”与“真实树和编辑器已接入”。
一句话判断:
@@ -37,10 +55,15 @@
## 2. 不可误判为完成的情况
- [x] 只看到 `data-testid="wolai-sidebar"`,不算页面树接入。
- [x] 只看到 `我的页面` 文案,不算真实树接入。
- [x] 只看到正文静态链接列表,不算 Page Aggregate 主内容接入。
- [x] 只看到 `data-editor-host="leptos_tiptap_island"`,不算 editor island 已 hydrate。
- [x] 只看到 `#mnote-editor-island` 空占位,不算主编辑区完成。
- [x] 只在 fixture gateway 下通过,不算当前真实 `3000` 主链完成。
## 3. Phase E-0:冻结真实树和编辑器失败用例
@@ -48,24 +71,38 @@
目标:让当前截图暴露的问题可以自动失败。
- [x] 新增 `scripts/task120-rust-web-tree-integration-smoke.js`
- [x] `task120` 通过 `POST /api/tree/commands action=create` 创建临时根页面。
- [x] `task120` 创建临时子页面并记录 `workspaceId` / `documentId`
- [x] `task120` 断言 `GET /``/documents/:id` 的左栏包含 `data-testid="wolai-sidebar-row"`
- [x] `task120` 断言临时页面 row 的 `data-node-id` 等于真实 document id。
- [x] `task120` 断言 active row 有 `data-active="true"` 或等价稳定标记。
- [x] `task120` 点击 row 后能打开 Rust Web `/documents/:id`
- [x] `task120``purge` 清理临时页面。
- [x] 新增 `scripts/task121-rust-web-editor-island-hydration-smoke.js`
- [x] `task121` 断言 `data-testid="mnote-leptos-tiptap-island-editor-root"` 可见。
- [x] `task121` 断言 `.editor-surface .ProseMirror[contenteditable="true"]` 可见。
- [x] `task121` 输入唯一文本并触发保存。
- [x] `task121` reload 后仍能读回唯一文本。
- [x] `task121` 拉取 `/api/page-aggregate/:id` 后能读回保存内容或版本变化。
- [x] `task119` 增加负向断言,不能只靠 `.mnote-home-links` 静态列表通过。
验证命令:
```bash
```
cd /mnt/Data1T/mnote
MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task120-rust-web-tree-integration-smoke.js
MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task121-rust-web-editor-island-hydration-smoke.js
@@ -76,17 +113,24 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task121-rust-web-editor-isl
目标:左栏 `我的页面` 不再是空 section,而是 projection 驱动的真实页面树。
- [x] `WorkspaceShellProjection.myPageItems` 来源于 `sidebar_tree` / `sidebar.dataset.list` / kernel projection。
- [x] `WorkspaceShellProjection.starredItems` 来源于真实 projection,不硬编码。
- [x] `render_workspace_shell_sidebar_html` 输出 `data-testid="wolai-sidebar-row"`
- [x] 每个 row 输出 `data-node-id`
- [x] 子页面 row 输出 `data-parent-id` 或等价层级标记。
- [x] active row 输出 `data-active="true"`
- [x] 空 workspace 输出明确 empty state,不回到设计稿静态链接列表。
- [x] Cargo 测试覆盖 root/child/active/empty 四类输入。
验证命令:
```bash
```
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web workspace_shell
```
@@ -96,16 +140,22 @@ cargo test -p mnote-web workspace_shell
目标:`GET /` 选择真实 active page 或显示真实空 workspace,不再把设计稿静态首页当主编辑区。
- [x] `gateway.rs::root_entry` 选择顺序固定为 `pageId` > recent cookie > projection active > first page > empty workspace。
- [x] 有 active page 时,根路径渲染或跳转到真实 document shell。
- [x] 无 active page 时,根路径显示空 workspace state。
- [x] 根页不再输出 `.mnote-home-links` 静态设计稿列表作为主内容。
- [x] recent page cookie 与 document shell 打开行为一致。
- [x] Rust 测试覆盖 root selection 顺序。
- [x] `task118` / `task119` 仍通过。
验证命令:
```bash
```
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web root_entry
cd /mnt/Data1T/mnote
@@ -118,17 +168,24 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task119-rust-web-wolai-visu
目标:文档页标题、页面树、正文 bootstrap 来自同一份 Page Aggregate / projection。
- [x] `/documents/:id` 标题来自 Page Aggregate `head.title`
- [x] breadcrumb / topbar active title 与 Page Aggregate 标题一致。
- [x] 左栏 active row 与当前 document id 一致。
- [x] 页面内 page tree / subtree 使用 Page Aggregate `tree.pageSubtree` 或 kernel `page_tree` projection。
- [x] HTML 内嵌 `__MNOTE_PAGE_AGGREGATE__`
- [x] HTML 内嵌 `__MNOTE_EDITOR_BOOTSTRAP__`
- [x] bootstrap 包含 `documentId``workspaceId``saveEndpoint``editorHostKind``assetMode`
- [x] 不从 URL 或静态 fallback 重新推导第二份页面真相。
验证命令:
```bash
```
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web document_shell page_aggregate
cd /mnt/Data1T/mnote
@@ -140,19 +197,28 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task115-rust-web-document-s
目标:主编辑区不是 marker,而是浏览器内真实可编辑 runtime。
- [x] Rust document shell 输出稳定 mount root。
- [x] 前端 island adapter 能读取 `__MNOTE_PAGE_AGGREGATE__`
- [x] 前端 island adapter 能读取 `__MNOTE_EDITOR_BOOTSTRAP__`
- [x] `leptos_tiptap_island` 成为 active host。
- [x] 页面出现 `data-testid="mnote-leptos-tiptap-island-editor-root"`
- [x] 页面出现 `.editor-surface .ProseMirror[contenteditable="true"]`
- [x] `data-editor-host-active="leptos_tiptap_island"` 或等价 observability 可见。
- [x] BlockNote 没有成为默认主编辑器。
- [x] 输入文本能保存。
- [x] reload 后文本仍在。
验证命令:
```bash
```
cd /mnt/Data1T/mnote
MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task121-rust-web-editor-island-hydration-smoke.js
```
@@ -162,15 +228,20 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task121-rust-web-editor-isl
目标:树和编辑器不是一次性 SSR,而是接入当前 Rust Web transport / realtime 主链。
- [x] 新建页面后 `/api/tree/events` 或 projection reload 可看到新增节点。
- [x] 重命名页面后 sidebar row title 更新。
- [x] 删除 / purge 页面后 sidebar row 消失。
- [x] 编辑保存走 `/api/documents/save` 或约定 Rust Web save endpoint。
- [x] 保存后 `/api/page-aggregate/:id` 能看到正文变化或 revision 变化。
- [x] `task114``task115``task118``task119``task120``task121` 在当前 3000 全部通过。
验证命令:
```bash
```
cd /mnt/Data1T/mnote
MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task114-rust-web-gateway-entry-smoke.js
MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task115-rust-web-document-shell-smoke.js
@@ -196,9 +267,15 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task121-rust-web-editor-isl
## 10. 完成判定
- [x] `task120` 在当前 `3000` 通过。
- [x] `task121` 在当前 `3000` 通过。
- [x] 左栏页面树来自真实 projection。
- [x] 根页不再用设计稿静态列表伪装主内容。
- [x] 文档页有真实 `leptos-tiptap` 可编辑 surface。
- [x] 保存后 Page Aggregate / reload 可读回。
- [x] `3-8` 可移动到 `done` 后,本 checklist 才能进入完成评估。
@@ -0,0 +1,480 @@
# 4-20 [process] VSCode Explorer 文件树对标设计 v1
> 更新时间:2026-05-06
>
> 关联:
> - `/mnt/Data1T/mnote/tmp/image copy 47.png`
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-7-tree-shell-ui-state-boundary-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-14-task-005-file-tree-final-renderer-capability-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
> - `/mnt/Data1T/mnote/design/90-reference/90-1-filetree.md`
## 1. 目的
这份文档只处理 `filesystem / Explorer` 文件树下一阶段怎么对标 VSCode Explorer。
它不重新定义树域协议。已有结论继续成立:
- `tree-first graph kernel` 是长期对象真相层。
- `Convex` 继续作为本地自托管存储、实时、文件协作底座。
- `mnote-web` 是 Rust Web 主执行面,负责 projection、command、transport 与 realtime stream。
- 前端和 tree shell 只消费稳定 projection,并持有本地交互态。
本设计补的是此前文档没有写细的 Explorer 级显示和操作合同:行模型、选择/焦点、打开、右键、键盘、拖放、剪贴板、新建、重命名、删除、上传、资源移动和验收清单。
## 2. 当前截图判断
`/mnt/Data1T/mnote/tmp/image copy 47.png` 显示当前文件树已经有 Explorer 入口,但仍不像 VSCode Explorer
- `我的页面` 下直接混排页面节点和 `index.md`,但没有清晰的 workspace root / resource root 语义。
- 页面行使用小菱形 / 折叠箭头,文件行使用蓝色文档图标,信息密度接近,但行列网格不稳定。
- 当前选中态落在 `index.md` 文件行,和右侧文档页 `新页面` 的 active / focus / selection 关系不够清楚。
- `Explorer` 标题像 tab 文案,不像 VSCode Explorer 的 section header + toolbar。
- hover 操作只看到 `+`,缺少新建文件、新建文件夹、刷新、全部折叠、更多等容器级动作。
- 长列表中大量重复 `新页面/index.md`,缺少资源装饰、路径 disambiguation、命名冲突处理和 reveal 规则。
结论:当前不是“样式再修一点”的问题,而是 Explorer 行为合同还没有写到足够细。
## 3. 唯一真源
### 3.1 结构显示真源
文件树显示的唯一真源固定为:
`kernel.project_view(file_tree)` 产出的 `KernelProjectionResult`
当前代码证据:
- 首屏 query 会从 Convex `sidebar.datasetList` 取数据,再通过 `resolveKernelFileTreeProjection` 调 Rust `kernel.project_view(file_tree)` 附加 `kernel_file_tree_projection`
- 实时链路 `/api/mnote-web/stream` snapshot 也会附加同一份 file tree projection。
- Rust 侧生成点是 `bridge-runtime``build_file_tree_projection_result`
- projection item 的核心字段已经在 `core-protocol/src/kernel.rs` 与前端 `tree-protocol.ts` 固定。
因此,Explorer 不能在 UI 层重新推导层级、排序、资源类型、child count、capabilities 或合法性。UI 只能把 projection item materialize 成 row。
### 3.2 持久化操作真源
持久化动作的唯一正式命令面固定为:
- 页面:`tree.node.*``tree.subtree.*`
- 资源:`tree.resource.*`
- 文件树预检:`tree.filetree.*.preflight`
当前已经成立:
- 页面新建、重命名、移动、归档、恢复、永久删除、嵌入、复制子树已经接到 `tree.*`
- 文件树 `drop/delete/paste/upload-target` 已有 Rust preflight。
- 资源 `copy/move/upload` 已有 `tree.resource.copy/move/upload`
当前仍未完成:
- 资源 `rename/delete/restore` 仍经过 legacy `/api/media/batch` 直接 patch / restore。
- `cut` 还没有成为剪贴板动作。
- 资源“移动到...”仍有 `prompt("输入目标页面 ID")` 的临时 UX。
### 3.3 本地交互态
这些状态只能属于 tree shell / runtime 本地态,不得写进 projection
- `expanded`
- `selected`
- `focused`
- `hovered`
- `editingName`
- `clipboard`
- `dragging`
- `dropTarget`
- `contextMenuOpen`
- `keyboardAnchor`
- `revealPending`
`expandedByDefault` 只是 projection 给初始展开的建议,不是展开态真相。
## 4. VSCode Explorer 对标边界
### 4.1 必须对齐
文件树必须对齐 VSCode Explorer 的资源管理器模型:
- 一行就是一个资源命中目标。
- `twistie` 只负责展开/折叠,打开资源是另一种动作。
- 图标来自 `rowKind/resourceMeta/iconHint/assetKind`,不是装饰。
- `selection``focus``hover``active` 分离。
- 右键点击已选区内资源时保留选区,右键点击未选资源时切换到该资源。
- 键盘能完整操作可见行。
- 内部拖拽、外部文件拖入、剪贴板和 context menu 都基于同一 selection/focus 状态。
- 所有危险或跨资源类型动作先走 preflight plan,再落正式 command。
### 4.2 不能照搬
文件树不能照搬 VSCode 的真实文件系统语义:
- mnote 的文件树是页面、`index.md`、附件、mindmap 文件夹、导图子资源、table/book/pdf 等复合资源投影,不是磁盘目录树。
- `page -> index.md -> asset-folder -> asset` 这类多语义链路不能用 VSCode `compact folders` 强行压成一行。
- “在终端打开”“Reveal in Finder/Explorer”“文件比较”等 OS 动作不进入默认合同,除非资源模型已有明确承载。
- VSCode 的单击预览 / 双击固定标签页节奏不能直接照搬。mnote 的打开必须服从当前文档页、Page Aggregate、附件打开链路和权限边界。
- Explorer 是 sidebar 中的创新视图,不是整个产品的唯一主导航。`section` 页面树仍保留 Wolai / Notion 方向。
## 5. 显示合同
### 5.1 Row Grid
每个 file tree row 固定为以下视觉列:
| 列 | 说明 |
| --- | --- |
| indent | 只由 `depth` 决定,步长固定 |
| twistie | 仅 `expandable=true` 时可见并可点击 |
| icon | 由 `iconHint/assetKind/resourceKind` 决定 |
| label | 主名称,单行截断 |
| decoration | 条件性 badge、状态色、资源提示 |
| actions | hover / focus 时出现的轻量动作 |
不得让 label 长度、hover 按钮或选中态改变行高和缩进。
### 5.2 Row Kind
当前稳定行类型继续保留:
- `doc`
- `index`
- `asset-folder`
- `asset`
后续如果需要真实目录或资源组,必须先扩展 Rust projection 的 `rowKind/resourceKind`,不能在 UI 层把某类 asset 临时伪装成目录。
### 5.3 Root
Explorer 顶部应有明确 root
- 单工作区:显示 workspace rootroot 下再进入页面/资源树。
- 多工作区:允许多个 workspace root。
- root 行是否可折叠由 projection capability 决定。
当前截图中 `我的页面 -> Explorer` 的组合不够清晰,后续应改为:
- section header`EXPLORER`
- root row:当前 workspace 名称
- toolbar:新建页面 / 新建资源 / 刷新 / 全部折叠 / 更多
### 5.4 Icon 与 Decoration
图标语义至少覆盖:
- workspace / folder / page / index
- asset
- image / video / audio
- pdf / book
- table
- mindmap
- unknown
Decoration 只表达轻量状态,例如:
- stale / indexing / uploading
- read-only / shared / public
- conflict / invalid target
- cut clipboard visual
Decoration 不得变成第二行副标题。
### 5.5 Active、Selection、Focus
三者必须分离:
- `active`:右侧主区域正在打开的 document / asset。
- `selection`:Explorer 当前选中集合,支持多选。
- `focus`:键盘焦点所在 row。
截图中 `index.md` 选中与右侧 `新页面` active 的关系不清晰。目标规则:
- 打开文档时,`doc``index` 可共享 active 文档语义,但只能有一个 focused row。
- 如果 active document 映射到 `index.md`,应显式定义 active 高亮落在 `index` 还是 `doc`。建议:active 落 `index.md`doc 行只作为目录/页面容器。
- 多选时 active 不覆盖 selectionselection 背景优先。
## 6. 操作合同
### 6.1 打开
P0
- `Enter` 打开 focused row。
- 双击 row 打开资源。
- 单击只负责 selection/focus,不自动打开重资源。
- `index.md` 打开对应文档页。
- asset 打开对应资源 viewer / editor。
P1
- 支持“Reveal active resource in Explorer”。
- 页面切换后自动展开祖先链并定位 active row。
- 搜索过滤后保留 active/reveal 的路径上下文。
说明:当前代码已明确与 VSCode 单击打开不同,这是合理差异,但必须写成正式合同,避免后续反复摇摆。
### 6.2 展开 / 折叠
P0
- 点击 twistie 只展开/折叠,不打开。
- `ArrowRight`:可展开行未展开则展开;已展开或叶子则可进入打开动作,具体由 runtime 定义。
- `ArrowLeft`:已展开则折叠;未展开则焦点移动到父节点。
- `Home/End` 跳到第一/最后可见行。
P1
- `Collapse All` 容器动作。
- 外部拖拽悬停到可展开目标一段时间后自动展开。
### 6.3 选择与右键
P0
- 单击:单选并设置 anchor/focus。
- `Ctrl/Cmd + Click`:切换选中。
- `Shift + Click`:从 anchor 到目标做范围选。
- 右键已选区内 row:保留选区并打开菜单。
- 右键未选 row:切换 selection 到该 row,再打开菜单。
- 空白区点击:清空 selection/focus。
P1
- `Ctrl/Cmd + A` 选中当前 Explorer 可见资源。
- 过滤态下范围选只覆盖可见 row。
### 6.4 键盘
P0 最小按键:
| 按键 | 动作 |
| --- | --- |
| `ArrowUp/ArrowDown` | 上下移动 focus |
| `ArrowLeft/ArrowRight` | 折叠 / 展开 / 父子导航 |
| `Home/End` | 首尾行 |
| `Enter` | 打开 focused row |
| `F2` | 重命名 |
| `Delete/Backspace` | 删除 / 归档 |
| `Ctrl/Cmd+C` | copy |
| `Ctrl/Cmd+X` | cut |
| `Ctrl/Cmd+V` | paste |
| `Esc` | 取消 inline rename / 关闭菜单 / 清理 drag feedback |
当前缺口:已有 copy/paste,但缺 `cut`;键盘能力需要以 runtime contract 覆盖 file tree,而不是散在 `sidebar.tsx`
### 6.5 新建
P0
- `New Page`:映射 `tree.node.create`,在目标 doc/root 下创建页面,并进入 inline rename。
- `New File`:只允许创建 mnote 资源模型内真实存在的资源类型;如果当前没有通用文件创建命令,入口应隐藏或置灰。
- `New Folder`:当前没有真实目录语义,不提供默认入口;mindmap asset-folder 不是通用文件夹。
P1
- 支持上传文件作为 `New File` 的可用替代路径,走 `tree.filetree.upload-target.preflight -> tree.resource.upload`
- 如果未来引入通用资源目录,先扩展 kernel projection / command,再开启 `New Folder`
### 6.6 重命名
P0
- `F2` 和 context menu `Rename` 进入 inline rename,不再使用 `prompt`
- doc / index 重命名最终落 `tree.node.rename` 或 Page Aggregate title command 的正式命名面。
- asset 重命名必须从 legacy `/api/media/batch` 收口到 `tree.resource.rename` 或等价正式命令。
- `Enter` 提交,`Esc` 取消。
- 空名、重名、非法字符、只读资源必须由 preflight / command 返回可解释错误。
P1
- 文件型 asset 默认只选中文件名 stem,不误选扩展名。
- rename 期间保留 row 高度和缩进,不触发布局跳动。
### 6.7 删除 / 恢复 / 永久删除
P0
- doc 删除走 `tree.node.archive`
- doc 恢复走 `tree.node.restore`
- doc 永久删除走 `tree.node.purge`,必须是更强确认路径。
- asset 删除 / 恢复必须补正式 `tree.resource.archive/restore` 或等价命令,不能长期停留在 legacy `/api/media/batch`
- 多选删除先走 `tree.filetree.delete.preflight`,展示将影响的 doc/asset 集合。
P1
- 支持删除后 undo / toast action,命令结果仍以 kernel event 和 projection resync 为准。
### 6.8 剪贴板
P0
- `copy`:复制选中 doc subtree / asset resource。
- `cut`:进入 cut clipboard 状态,源 row 降噪显示;paste 成功后清空 cut 状态。
- `paste`:目标由 focused row / context target / active doc 推导,必须先走 `tree.filetree.paste.preflight`
- 多选 paste 保持原有可见顺序,并过滤父子重复选择。
P1
- 系统剪贴板只保存安全 payload,不暴露敏感内部 token。
- 跨窗口 paste 需要校验 workspace 和权限。
### 6.9 拖放
P0
- 内部拖放支持 move/copy,修饰键控制 copy。
- 外部文件拖入支持 upload target preflight。
- 非法目标即时反馈,不发持久化命令。
- doc 拖到自身或后代节点必须拒绝。
- asset-folder / asset 的可投放能力由 `capabilities` 和 preflight 决定。
P1
- 拖到可展开目标 hover 延迟后自动展开。
- 显示 before / inside / after 三种 drop indicator,而不是只有整行背景。
- 多选拖放过滤已被父节点包含的子节点。
### 6.10 Context Menu
菜单按 `rowKind/resourceKind/selection` 裁剪:
| 目标 | P0 动作 |
| --- | --- |
| root | 新建页面、上传文件、刷新、全部折叠 |
| doc | 打开、新建子页面、重命名、复制、剪切、粘贴、移动到、归档、复制链接 |
| index | 打开、复制、剪切、粘贴、Reveal 页面 |
| asset-folder | 打开、复制、剪切、粘贴、重命名、删除、移动到 |
| asset | 打开、复制、剪切、重命名、删除、下载、移动到 |
| 多选 | 打开禁用、复制、剪切、删除、移动到 |
当前缺口:
- mindmap rename 提示不支持。
- table move/download 提示不支持。
- asset 移动仍靠输入页面 ID。
这些必须进入 P0/P1 差异清单,不得被样式修复掩盖。
## 7. 资源排序与混排规则
P0 固定当前最小规则:
1. workspace root
2. doc row
3. doc 的 `index.md`
4. doc 的 direct assets / asset folders
5. child documents
排序由 Rust projection 的 `position` 与 preorder 决定。UI 只渲染。
需要新增到 projection meta 的候选规则:
- `ordering: "kernel_file_tree_preorder"`
- `folderPolicy: "mnote_composite_resource"`
- `indexPlacement: "first_child"`
- `assetPlacement: "after_index_before_child_documents"`
这样可以避免 UI 通过 hardcode 解释为什么 `index.md` 总是出现在页面下第一行。
## 8. Search / Filter / Reveal
P0
- Explorer filter 只改变可见 row,不改变 projection truth。
- filter 命中子节点时必须补全祖先链。
- active row 在 filter 后不可见时,保留 active 状态但不强制显示。
- `Reveal in Explorer` 必须展开祖先链、滚动到目标 row、设置 focus,不破坏 selection,除非调用者明确要求 select。
P1
- 支持 `maxResults` 下的 ancestor completion。
- 支持 indexing visibility,展示 stale / refreshing 状态。
当前 `KernelFileTreeProjection.meta.search.indexingVisibility` 已有雏形,后续应把它用于 UI 状态,而不是在 Explorer 自行发明刷新状态。
## 9. 分阶段任务
### P0:让当前 Explorer 行为不再含糊
- 固定 active / selection / focus 的视觉优先级。
- 补齐 file tree keyboard runtime`Arrow/Home/End/Enter/F2/Delete/Cut/Copy/Paste/Esc`
-`cut` clipboard 状态和 cut row decoration。
- 把 doc / index / asset / asset-folder 的 context menu 动作裁剪成合同。
- 移除资源移动的 `prompt("输入目标页面 ID")`,改用 tree picker / move target picker。
- asset rename/delete/restore 制定 `tree.resource.*` 正式命令或等价 bridge 命名,并保留 legacy alias。
- 写 smoke 固化当前截图中重复页面 + `index.md` 场景的 active/selection/reveal 行为。
### P1:让资源管理器达到 VSCode 常用操作预期
- inline rename 全面替代 prompt。
- root toolbar 对齐 Explorer:新建页面、上传文件、刷新、全部折叠、更多。
- 拖放支持 before / inside / after indicator 与 hover auto expand。
- 上传 target preflight 失败时在 drop target 上显示明确反馈。
- 多选 delete / move / copy 的 preflight plan 显示影响集合。
- mindmap/table 的 rename/move/download 能力从“不支持”变为可解释菜单状态或正式实现。
### P2:高级 Explorer 能力
- compact folders 只用于未来真实目录链,不用于页面/附件复合链。
- git-like / sync-like decorations,如果未来有对应数据源再加入。
- 多 workspace root。
- 大树虚拟化和增量加载。
- 跨窗口剪贴板。
## 10. 验收
### 10.1 单元 / 合同测试
必须覆盖:
- projection item 到 row model 的稳定映射。
- active / selection / focus 分离。
- `Ctrl/Cmd` 多选、`Shift` 范围选、右键保留选区。
- 可见 row 变化后的 selection normalize。
- `cut/copy/paste` payload encode/decode 与目标推导。
- drop preflight payload 构造。
- illegal drop:自身、后代、跨 workspace、只读资源。
- inline rename submit/cancel。
### 10.2 Rust 测试
必须覆盖:
- `kernel.project_view(file_tree)` 输出 `doc/index/asset-folder/asset`
- `resourceMeta``capabilities``iconHint` 稳定。
- `tree.filetree.*.preflight` 对多选和非法目标返回 plan。
- 新增 `tree.resource.rename/archive/restore` 或等价命令后,覆盖 command event 与 projection resync。
### 10.3 浏览器 smoke
必须覆盖:
- 当前截图同类数据:多个同名 `新页面`,每个页面下有 `index.md`
- 点击 `index.md` 后右侧打开对应页面,Explorer active/focus/selection 可解释。
- 右键已选区保留多选。
- `F2` inline rename`Esc` 取消,`Enter` 提交。
- `Ctrl/Cmd+X` 后 cut decoration 出现,paste 成功后消失。
- 外部文件拖入 doc / index / asset-folder 的目标差异。
- 资源移动不再弹 `prompt`
## 11. 非目标
- 不把文件树改成真实磁盘文件系统。
- 不把 `Explorer` 替代 `section` 页面树作为唯一导航。
- 不在 UI 层重新拼排序、层级、资源类型或权限。
- 不为了像 VSCode 而绕过 Page Aggregate、tree projection 或 `tree.*` 命令。
- 不把 `90-1-filetree.md` 的 Rust 生态调研当作实现模板;它只作为参考资料。
## 12. 完成判定
这份设计进入 done 的条件:
- P0 项全部有代码和 smoke 覆盖。
- file tree 的 active / selection / focus / keyboard / context menu / clipboard / DnD 有稳定 runtime contract。
- 资源 rename/delete/restore 不再只依赖 legacy `/api/media/batch` 主路径。
- 当前截图暴露的 Explorer 行为歧义能通过测试说明和验证。
- 新增能力没有制造第二套树真相。
@@ -0,0 +1,586 @@
# 4-21 [process] Local Folder / Convex 统一树源架构 v1
> 更新时间:2026-05-06
>
> 关联:
> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-20-vscode-explorer-file-tree-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-7-tree-shell-ui-state-boundary-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/vscode`
## 1. 结论
支持引入 `Local Folder Workspace`,但不能因此维护两套文件树操作。
新的长期结构应固定为:
```text
LocalFolderSource -> Tree Kernel -> file_tree / page_tree / page_aggregate -> UI
ConvexWorkspaceSource -> Tree Kernel -> file_tree / page_tree / page_aggregate -> UI
```
也就是说:
- `Convex` 不再是所有 workspace 的必经中介。
- `Local Folder` 不经过 Convex,直接由 Rust source adapter 读取和写入本地文件系统。
- `Convex` 继续作为 cloud workspace 的 source adapter,负责同步、协作、权限、分享和远程资源。
- 两者共用同一套 projection、command、preflight、runtime state 和 UI。
- 差异只允许出现在 `WorkspaceSource` 的 capability、preflight plan 和 executor 中。
核心原则:
> **维护一套树操作语义,不维护两套文件树产品。**
## 2. 为什么需要这个方案
当前 mnote 的 Explorer 视图仍然基于 Convex 虚拟用户空间,这让 VSCode Explorer 对标天然别扭:
- 没有真实文件夹 root。
- `New File / New Folder / Rename / Move / Cut / Paste / Drop` 都需要模拟文件系统语义。
- `index.md`、页面、附件、mindmap asset-folder 混成复合投影后,无法完全照搬 VSCode 文件夹操作。
如果允许用户打开一个本地文件夹,就能获得真实文件系统语义:
- 文件夹就是文件夹。
- `.md` 文件就是页面内容的本体。
- 普通文件就是资源。
- 拖入、拖动、剪切、复制、粘贴、重命名、删除都可以映射到文件系统操作。
- Git、外部编辑器、脚本、ripgrep 等外部工具自然成立。
但这不表示 Convex 失去意义。Convex 的定位应从“所有树的底座”调整为:
> **云工作区 source:负责多端、协作、权限、分享、发布、异步处理和远程资源。**
## 3. 术语
### 3.1 `WorkspaceSource`
`WorkspaceSource` 是树域的存储源适配层。
最低接口:
- `sourceKind`
- `rootUri`
- `workspaceId`
- `capabilities`
- `loadSnapshot`
- `watch`
- `preflightCommand`
- `executeCommand`
- `resolvePageAggregate`
当前建议先支持两类:
- `local_folder`
- `convex_workspace`
### 3.2 `SpaceRoot`
`SpaceRoot` 是用户在 UI 里选择的工作区入口。
示例:
- `file:///home/lix/Documents/mnote-vault`
- `convex://workspace/ws_123`
`SpaceRoot` 可以出现在同一个 Explorer 列表里,但每个 root 只能由一个 source adapter 持有。
### 3.3 `Source Capability`
capability 描述当前 source 对某项操作的真实支持程度。
示例:
- `create-file`
- `create-folder`
- `rename`
- `move`
- `copy`
- `trash`
- `restore`
- `purge`
- `upload`
- `watch`
- `share`
- `publish`
- `collaborate`
capability 不改变 UI 命令名,只控制菜单显隐、禁用原因和 preflight 结果。
## 4. 真源分层
### 4.1 存储真源
存储真源由 source 持有:
| source | 存储真源 |
| --- | --- |
| `local_folder` | 本地文件系统 |
| `convex_workspace` | Convex 数据库 + 对象存储 |
本地模式不得先导入 Convex 再渲染。那会破坏本地文件夹模式的价值。
### 4.2 语义真源
树、页面和资源语义由 Rust kernel 统一生成:
- `KernelNode`
- `KernelEdge`
- `file_tree`
- `page_tree`
- `page_aggregate`
- `tree.*` command result
source adapter 只负责读写存储,不允许 UI 直接从本地路径或 Convex record 拼树语义。
### 4.3 UI 本地态
继续服从 `4-7`
- `expanded`
- `selected`
- `focused`
- `hovered`
- `editingName`
- `clipboard`
- `dragging`
- `dropTarget`
- `contextMenuOpen`
这些不进入 source,也不进入 projection。
## 5. Local Folder Workspace
### 5.1 P0 范围
P0 只支持:
- 打开一个本地文件夹作为 workspace root。
- `file_tree` 显示该文件夹下的目录、`.md` 文件和普通文件。
- `page_tree` 只显示 `.md` 文件。
- 打开 `.md` 文件时进入文档页。
- 普通文件作为 asset 打开或预览。
- 对本地文件和目录执行 VSCode-like 操作。
暂不支持:
- 多用户本地 ACL。
- 云同步。
- 多端协作。
- 非 Markdown 正文格式的页面编辑。
- 本地文件夹自动发布到 Convex。
### 5.2 本地页面身份
不能只用文件路径作为长期 page id。
建议 P0 采用三层策略:
1. `.md` frontmatter 中存在 `mnote_id` 时使用它。
2. 不存在时在 `.mnote/page-ids.json` 记录 `relativePath -> pageId`
3. 临时未初始化文件夹可用 path-derived id,但只作为只读过渡。
原因:
- 重命名文件时页面引用不能断。
- 移动文件时历史、链接、backlink 和 AI 索引需要能追踪。
- 同名文件在不同目录下必须稳定区分。
### 5.3 Markdown 到 Page Aggregate
本地 `.md` 文件映射到 `page_aggregate`
| Page Aggregate 字段 | 本地来源 |
| --- | --- |
| `page_identity` | `mnote_id` / `.mnote/page-ids.json` |
| `page_head.title` | frontmatter `title` / 第一行 H1 / 文件名 |
| `page_body` | Markdown AST / Tiptap document adapter |
| `page_tree` | 文件夹层级派生的 md-only projection |
| `page_layout` | frontmatter / `.mnote/page-options.json` |
写回时:
- 正文保存写回 `.md`
- 标题重命名优先改文件名,必要时同步 H1/frontmatter。
- 页面设置写入 frontmatter 或 `.mnote/page-options.json`,具体由后续编辑器设计冻结。
### 5.4 本地资源
`.md` 文件作为 resource
- image / video / audio
- pdf / book
- table-like 文件
- unknown file
资源链接优先使用相对路径。
附件从页面正文引用时,应解析成 `resourceMeta`,但不能把正文引用本身变成树结构真相。
## 6. Convex Workspace
Convex 继续存在,但定位更明确:
- 云工作区。
- 权限 / 成员 / 分享。
- 评论、协作状态、公开页面。
- 远程附件存储。
- OCR、AI、索引、异步任务。
- 多设备访问。
Convex source adapter 输出同一套 kernel 输入:
- document record -> page/document node
- media asset -> asset/resource node
- mindmap/table/book/pdf -> resource node
- share/public/template 状态 -> resource meta / decoration meta
Convex 不再被要求模拟真实本地文件系统;它只需要实现同一组 tree command 语义。
## 7. 统一 Projection
### 7.1 `file_tree`
`file_tree` 表达 source 的资源管理结构。
本地 source
- root folder
- directory
- markdown file
- normal file
Convex source
- workspace root
- page/document
- `index.md` view
- asset-folder
- asset
共同字段继续使用 `4-4`
- `rowId`
- `nodeId`
- `parentNodeId`
- `nodeType`
- `projectionKind`
- `title`
- `depth`
- `position`
- `childCount`
- `expandable`
- `capabilities`
- `resourceMeta`
- `iconHint`
新增建议:
- `sourceKind`
- `sourceUri`
- `relativePath`
- `storageIdentity`
- `operationProfile`
这些应进入 `resourceMeta.extra` 或后续协议扩展,不能散落在 UI。
### 7.2 `page_tree`
`page_tree` 表达可作为知识页面打开的页面集合。
本地 source
- 只显示 `.md` 文件。
- 默认层级来自目录结构。
- 排序可先使用文件系统排序,后续支持 frontmatter `order`
Convex source
- 继续显示 document/page 层级。
- 不显示普通 asset。
关键判断:
> Wolai 页面树不是文件树的替代,而是 markdown/page resource 的语义视图。
### 7.3 `page_aggregate`
无论 source 是本地还是 Convex,文档页只消费 `page_aggregate`
前端不应该关心正文来自:
- 本地 `.md`
- Convex document content
- 未来同步副本
差异由 page aggregate loader / source adapter 吸收。
## 8. 统一 Command
UI 只发一套命令。
### 8.1 命令路由
```text
UI action
-> tree command envelope
-> Rust command router
-> source adapter preflight
-> source adapter execute
-> kernel event / projection resync
```
命令 envelope 必须携带:
- `sourceKind`
- `workspaceId`
- `rootUri`
- `targetNodeId`
- `targetResourceMeta`
- `selection`
- `operation`
### 8.2 命令语义
| UI 操作 | 统一命令 | Local executor | Convex executor |
| --- | --- | --- | --- |
| 新建页面 | `tree.node.create` | 创建 `.md` | 创建 document |
| 新建文件夹 | `tree.folder.create``tree.resource.createFolder` | 创建目录 | 按 capability 决定是否支持 |
| 重命名页面 | `tree.node.rename` | rename `.md` / 更新 title | rename document |
| 移动页面 | `tree.subtree.move` | move file / folder | move document subtree |
| 复制页面 | `tree.subtree.copy` | copy file / folder | copy document subtree |
| 删除页面 | `tree.node.archive` | 移到 `.mnote/trash` 或系统 trash | 标记 archived |
| 永久删除 | `tree.node.purge` | 删除文件 | purge document |
| 上传 / 拖入文件 | `tree.resource.upload` | copy into folder | upload object storage |
| 移动资源 | `tree.resource.move` | move file | update asset parent/document |
| 复制资源 | `tree.resource.copy` | copy file | copy asset |
| 重命名资源 | `tree.resource.rename` | rename file | rename asset |
| 删除资源 | `tree.resource.archive` | trash file | archive asset |
| 恢复资源 | `tree.resource.restore` | restore from trash | restore asset |
说明:
- `tree.folder.create` 是否新增,需要单独冻结命名。若不新增,也可以把 folder 视作 `tree.resource.create``resourceKind=folder`
- 命名必须在后续 `4-6` 的 command protocol 补稿里冻结。
## 9. 统一操作但不同 capability
用户担心“两套文件树操作”是合理的。解决方式不是把底层做成一样,而是统一 capability 解释。
### 9.1 统一 UI
以下 UI 行为必须完全共用:
- row model
- active / selection / focus
- keyboard
- inline rename
- context menu
- clipboard
- drag/drop
- reveal
- filter/search
- preflight result display
### 9.2 不同 source capability
差异只表现为:
- 菜单项隐藏。
- 菜单项禁用并显示原因。
- preflight plan 返回不同风险。
- execute adapter 调用不同存储 API。
示例:
- 本地文件夹支持 `New Folder`
- Convex 虚拟空间默认不支持真实 `New Folder`,除非引入 folder resource。
- Convex 支持 `Share / Publish`
- 本地文件夹默认不支持 `Share / Publish`,除非执行显式 publish 到 cloud。
## 10. 权限模型
### 10.1 本地权限
本地权限来自:
- 操作系统文件权限。
- 用户选择文件夹的显式授权。
- workspace trust。
- mnote 自己的安全限制,例如禁止越过 root。
如果用户想“本地建立每个用户的文件夹”,可以作为部署策略:
```text
/data/mnote-users/alice/
/data/mnote-users/bob/
```
但这只是本地 source 的 root 管理方式,不等价于 Convex 的协作权限模型。
### 10.2 Convex 权限
Convex 权限来自:
- workspace membership
- document ACL
- public/share state
- group permission
- server-side policy
这类能力本地文件夹不能自动获得。
### 10.3 不混淆
不要让本地文件夹伪装成云协作空间。
正确表达:
- local:你有这个文件夹的本机读写权。
- cloud:你有这个 workspace 的协作权限。
## 11. VSCode 源码参考
本轮已把 VSCode 源码浅克隆到:
`/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/vscode`
当前 checkout
- commit`b5c5f2f`
- sparse paths
- `src/vs/workbench/contrib/files`
- `src/vs/platform/files`
- `src/vs/base/browser/ui/tree`
- `src/vs/base/browser/ui/list`
- `src/vs/workbench/browser/parts/views`
- command / action / context key 相关基础目录
### 11.1 值得参考的点
VSCode Explorer 有几个重要设计值得直接吸收:
- `IExplorerService` 同时管理 roots、context、editable、copy/cut、refresh、select/reveal 和 bulk edit。
- context key 明确区分 folder、readonly、root、cut、focus、find provider 等状态。
- `ExplorerItem` 是资源模型,持有 `resource/name/isDirectory/isReadonly/parent/root/children`
- renderer 把 cut 状态变成 row class,并把 inline rename 做在 row 内。
- inline rename 默认只选中文件名 stem`F2` 可在 stem/all/extension 三种选择之间切换。
- DnD 会区分 native drop、Explorer 内部 drop、root reorder、readonly、self/descendant invalid、copy vs move、before/after/over indicator 和 auto expand。
- sorter 支持 default/mixed/filesFirst/type/modified/foldersNestsFiles,但排序逻辑仍在模型层,不在 UI 临时判断。
### 11.2 不能照搬的点
不能照搬:
- 真实磁盘路径作为唯一页面身份。
- compact folders 到 page/resource 复合链。
- OS 级菜单动作作为默认能力。
- VSCode 的 editor tab preview/pin 语义。
- 文件 nesting 规则到 mnote 的页面引用或附件关系。
## 12. 推荐实施顺序
### P0:统一 Source 抽象和本地只读投影
- 增加 `WorkspaceSource` 设计与 Rust trait。
- 增加 `LocalFolderSource` 只读扫描。
- 本地 source 输出 `file_tree`
- 本地 source 输出 md-only `page_tree`
- 打开 `.md` 生成只读 `page_aggregate`
- UI 可从入口选择 `convex workspace``local folder`
验收:
- 打开一个本地文件夹后,Explorer 显示真实目录和 `.md`
- 页面树只显示 `.md`
- 点击 `.md` 能进入文档页阅读态。
- Convex 现有 workspace 不受影响。
### P1:本地写操作与统一 command
- 本地新建 `.md`
- 本地新建目录。
- inline rename。
- move / copy / cut / paste。
- delete 到 `.mnote/trash` 或系统 trash。
- drop 外部文件。
- 文件 watcher 刷新 projection。
验收:
- 同一套 UI 对 local 和 Convex 发同一类 command envelope。
- source adapter 分别执行。
- preflight UI 共用。
### P2:本地 Markdown Page Aggregate 写回
- Markdown AST 与 Tiptap document 双向转换。
- 标题编辑写回文件名 / H1 / frontmatter 的规则冻结。
- page id 管理。
- backlink / reference scan。
- 本地全文索引。
### P3Local 与 Cloud 的显式桥接
- Import local folder to cloud。
- Publish local page to cloud。
- Cloud workspace export to folder。
- 可选 sync,不作为 P0/P1。
## 13. 风险
### 13.1 最大风险:双真相
如果本地文件夹先导入 Convex,再从 Convex 渲染,就会变成:
```text
file system -> Convex mirror -> projection -> UI
```
这会制造双真相,也会让 VSCode-like 操作变慢、变怪。
必须避免。
### 13.2 页面身份风险
如果 page id 只依赖路径,移动/重命名会断引用。
P0 可以先只读,P1 写操作前必须完成 page id 方案。
### 13.3 Markdown 往返风险
Tiptap 文档和 Markdown 不是无损双向转换。
P0 可以先阅读态或有限编辑;P2 再处理复杂块、附件、引用、frontmatter。
### 13.4 安全风险
本地 source 必须限制在用户选择的 root 下:
- 禁止 `../` 越界。
- symlink 默认只显示,不默认跟随写入。
- 删除 / 覆盖必须有 preflight。
- watcher 不监听 root 外路径。
## 14. 非目标
- 不让 Convex 成为本地文件夹模式的中介。
- 不维护 local file tree 和 convex file tree 两套 UI。
- 不在前端用路径和 Convex record 临时拼第二套树。
- 不在 P0 做本地/云自动同步。
- 不在 P0 承诺 Markdown 与 Tiptap 完全无损互转。
- 不用本地 per-user 文件夹替代 Convex 协作权限。
## 15. 完成判定
本文进入 done 的条件:
- `WorkspaceSource` 抽象落地。
- local 和 convex 都能通过同一 command envelope 接入 tree runtime。
- `file_tree/page_tree/page_aggregate` 对 UI 的消费路径不分叉。
- 本地文件夹 P0 只读投影和 `.md` 页面打开可验证。
- 至少一组写操作在 local 和 Convex 上共用 UI、共用命令名、只分 source executor。
- 新增能力没有绕开 Rust kernel 或制造第二套树真相。
@@ -0,0 +1,590 @@
# 4-22 [process] Local Folder / Convex 统一树源顺序执行清单 v1
> 更新时间:2026-05-06
>
> 来源:
> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-20-vscode-explorer-file-tree-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-21-local-folder-convex-unified-tree-source-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-7-tree-shell-ui-state-boundary-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/vscode`
## 0. 执行原则
本清单按顺序执行,不跳阶段。
总目标:
> **只维护一套 tree projection、tree command、preflight、runtime state 和 UILocal Folder 与 Convex 只作为不同 `WorkspaceSource` executor 存在。**
硬约束:
- [x] 不让本地文件夹先导入 Convex 再渲染。
- [x] 不在 UI 层用本地 path 或 Convex record 拼第二套树。
- [x] 不新增 local 专用文件树 UI。
- [x] 不绕过 Rust kernel 写页面树、文件树、排序或权限。
- [x] 不把 `expanded/selected/focus/drag/drop/contextMenu` 写进 projection。
- [x] 所有写操作先 preflight,再 execute,再 projection resync。
**Done 条件:**
- 本清单对应实现中,Local 和 Convex 都通过同一 command envelope 进入 Rust command router。
- `file_tree/page_tree/page_aggregate` 的 UI 消费路径不因 source 分叉。
- 本地文件夹与 Convex 的差异只通过 capability / preflight / executor 表达。
## 1. Phase A:冻结 Source 与协议边界
### A1. 定义 `WorkspaceSource` 合同
- [x] 在 Rust 侧定义 `WorkspaceSource` trait / interface 草案。
- [x] 字段至少覆盖 `sourceKind``rootUri``workspaceId``capabilities`
- [x] 方法至少覆盖 `loadSnapshot``watch``preflightCommand``executeCommand``resolvePageAggregate`
- [x] 明确 `local_folder``convex_workspace` 两个 source kind。
- [x] 写 contract test,验证 source kind 和 root uri 会进入 command envelope。
**Done 条件:**
- Rust 代码中存在统一 `WorkspaceSource` 抽象。
- 至少有 mock source 能输出 `file_tree/page_tree` snapshot。
- 前端没有新增 source-specific tree component。
### A2. 扩展 projection source metadata
- [x] 为 projection item 补 source metadata 方案。
- [x] 字段候选固定为 `sourceKind``sourceUri``relativePath``storageIdentity``operationProfile`
- [x] 优先放入 `resourceMeta.extra`,除非需要正式扩展 `core-protocol`
- [x] 更新 TS 类型映射。
- [x] 补 fixturelocal folder item、convex item 同时存在。
**Done 条件:**
- `file_tree` item 可以区分来自 local 还是 convex。
- UI 仍只读 projection 字段,不直接访问 source adapter。
- fixture 覆盖 `directory/md file/normal file/document/index/asset`
### A3. 统一 command envelope
- [x] 定义 `TreeCommandEnvelope` 的 source 字段。
- [x] envelope 至少包含 `sourceKind``workspaceId``rootUri``targetNodeId``targetResourceMeta``selection``operation`
- [x] 现有 `tree.node.*``tree.subtree.*``tree.resource.*` 进入统一 router。
- [x] router 按 source kind 分发到 executor。
- [x] 对 unknown source / capability missing 返回可解释错误。
**Done 条件:**
- 同一个 UI action 可以构造同一种 envelope。
- local / convex 的 executor 可以在测试中分别接收 envelope。
- 没有新增 `/api/local-filetree/*` 这类平行命令入口。
## 2. Phase BLocal Folder 只读主链
### B1. 打开本地文件夹入口
- [x] 增加选择本地文件夹的入口。
- [x] 生成 `SpaceRoot=file://...`
- [x] 记录最近打开的 local root。
- [x] 禁止 root 外路径访问。
- [x] 对无权限目录返回明确错误。
**Done 条件:**
- 用户能选择一个本地文件夹并进入 local workspace。
- 3000 `desktop:hot` 主入口不能跳到 `/tree` debug shell,也不能落入 `legacy_next_compat_disabled`
- Convex workspace 入口不受影响。
- smoke 能断言当前 source kind 为 `local_folder`
### B2. LocalFolderSource 只读扫描
- [x] 扫描目录。
- [x] 识别 directory。
- [x] 识别 `.md` 文件。
- [x] 识别普通文件。
- [x] 默认忽略 `.git``node_modules``.mnote/trash` 等高噪音目录。
- [x] 处理 symlink:P0 只显示,不默认跟随写入。
**Done 条件:**
- 本地文件夹能输出 read-only snapshot。
- snapshot 不越过 root。
- 扫描失败不会让整个 app 崩溃。
### B3. 输出 local `file_tree`
- [x] directory 映射为 folder row。
- [x] `.md` 映射为 markdown file row。
- [x] 普通文件映射为 asset row。
- [x] iconHint 覆盖 markdown/image/video/audio/pdf/book/table/unknown。
- [x] childCount、depth、parentNodeId、position 由 kernel 生成。
- [x] 排序规则先固定为 VSCode defaultfolder firstname sort。
**Done 条件:**
- `kernel.project_view(file_tree)` 可以返回 local folder projection。
- UI 使用同一 Explorer surface 渲染 local projection。
- 浏览器 smoke 可看到真实目录、`.md`、普通文件。
### B4. 输出 md-only `page_tree`
- [x] 只把 `.md` 文件纳入 page tree。
- [x] 目录只作为页面树分组/父级,不作为正文页面打开。
- [x] 页面标题优先级固定为 frontmatter `title` > 第一行 H1 > 文件名。
- [x] parent/child 来自目录层级。
- [x] 同名 md 文件必须通过 path / page id 区分。
**Done 条件:**
- 同一个 local root 下,Explorer 显示所有文件,页面树只显示 `.md`
- 点击页面树 md 节点能定位到同一个 `.md` resource。
- 不因过滤普通文件影响 page tree 层级。
### B5. `.md` 只读 Page Aggregate
- [x] 读取 `.md` 文件。
- [x] 解析 frontmatter。
- [x] 解析标题。
- [x] 生成只读 `page_aggregate`
- [x] 文档页用现有 `DocumentShell / DocumentContent` 消费。
- [x] 文件读取失败显示错误页,不清空 Explorer。
**Done 条件:**
- 点击本地 `.md` 文件能打开文档阅读态。
- 文档页不关心内容来自 local 还是 Convex。
- `page_aggregate` 字段满足 `5-5-1` 最小契约。
## 3. Phase C:页面身份与本地元数据
### C1. 本地 page id 策略
- [x] 支持 frontmatter `mnote_id`
- [x] 支持 `.mnote/page-ids.json`
- [x] 支持 path-derived 临时 id,但仅用于未初始化只读模式。
- [x] page id 生成必须稳定且不与 Convex document id 冲突。
- [x] 写测试覆盖 rename/move 后 id 保持。
**Done 条件:**
- 重命名或移动 `.md` 后,page id 不变。
- 没有 id 的文件能被安全初始化。
- 同名不同路径 `.md` 不冲突。
### C2. 本地 `.mnote` 元数据目录
- [x] 定义 `.mnote/page-ids.json`
- [x] 定义 `.mnote/page-options.json`
- [x] 定义 `.mnote/trash` 或系统 trash 策略。
- [x] `.mnote` 不出现在默认 Explorer 展示中,除非开启 debug。
- [x] 元数据写入必须原子化或有失败回滚策略。
**Done 条件:**
- `.mnote` 元数据能支持 page id 和页面设置。
- 默认用户视图不被 `.mnote` 噪音污染。
- 元数据损坏时有可解释错误和恢复建议。
## 4. Phase D:统一 Explorer P0 交互
### D1. Active / Selection / Focus 分离
- [x] 统一 row state`activeRowId``selectedRowIds``focusedRowId`
- [x] active 表示右侧打开资源。
- [x] selection 表示 Explorer 操作集合。
- [x] focus 表示键盘焦点。
- [x] local 和 convex 共用 reducer。
- [x] 多选时 selection 视觉优先于 active。
**Done 条件:**
- 截图中的 `index.md` active/focus/selection 歧义被测试覆盖。
- local `.md` 和 Convex `index.md` 使用同一视觉规则。
- selection 不回写 projection。
### D2. 键盘导航
- [x] `ArrowUp/ArrowDown` 移动 focus。
- [x] `ArrowLeft/ArrowRight` 折叠 / 展开 / 父子导航。
- [x] `Home/End` 跳首尾。
- [x] `Enter` 打开 focused row。
- [x] `F2` 进入 inline rename。
- [x] `Delete/Backspace` 触发删除 preflight。
- [x] `Ctrl/Cmd+C` copy。
- [x] `Ctrl/Cmd+X` cut。
- [x] `Ctrl/Cmd+V` paste。
- [x] `Esc` 取消 rename / menu / drag feedback。
**Done 条件:**
- 键盘能力在 runtime contract 中测试,不只散落在 React handler。
- local 和 convex 使用同一组按键浏览器 smoke。
- `cut` 不再缺失。
### D3. 选择与右键
- [x] 单击单选并设置 anchor/focus。
- [x] `Ctrl/Cmd + Click` 切换选中。
- [x] `Shift + Click` 范围选。
- [x] 右键已选区内 row 保留选区。
- [x] 右键未选 row 切换到该 row。
- [x] 空白区点击清空 selection/focus。
**Done 条件:**
- 多选 context menu 不会被右键误清空。
- local/convex 的 selection reducer 共用。
- 过滤可见行变化后 selection normalize 正确。
- 已选区内右键、未选项右键、空白区点击三条路径都必须在真实浏览器中验证。
### D4. Row grid 与 root toolbar
- [x] 固定 indent / twistie / icon / label / decoration / actions 六列。
- [x] section header 使用 `EXPLORER`
- [x] root row 明确显示 local folder 或 cloud workspace。
- [x] toolbar 至少有新建、上传/导入、刷新、全部折叠、更多。
- [x] toolbar action 由 capability 控制可用性。
**Done 条件:**
- hover action 不改变行高和缩进。
- local root 与 Convex root 在同一 Explorer 框架下展示。
- toolbar 不创建 source-specific UI 分支。
- hover / focus 显示 actions 时,row height、indent、twistie/icon/label 列位置必须有浏览器截图或 DOM 断言。
## 5. Phase E:统一 Preflight 与 Context Menu
### E1. Context menu profile
- [x] root 菜单:新建页面、新建文件夹、上传/导入、刷新、全部折叠。
- [x] md/page 菜单:打开、重命名、复制、剪切、粘贴、移动到、删除、Reveal。
- [x] folder 菜单:新建页面、新建文件夹、粘贴、重命名、删除。
- [x] asset 菜单:打开、重命名、复制、剪切、删除、下载、移动到。
- [x] 多选菜单:复制、剪切、删除、移动到。
- [x] share/publish 只在 Convex capability 存在时出现。
**Done 条件:**
- 菜单项由 rowKind/resourceKind/capability/selection 共同裁剪。
- 不支持的能力有禁用原因或隐藏规则。
- mindmap/table 的“不支持”状态被 capability 表达,而不是散落提示。
### E2. Preflight result UI
- [x] delete preflight 展示影响集合。
- [x] move preflight 展示目标和冲突。
- [x] paste preflight 展示命名冲突策略。
- [x] upload/drop preflight 展示目标是否可写。
- [x] readonly / permission denied / root escape 返回统一错误结构。
**Done 条件:**
- local 和 convex 的 preflight 使用同一 UI 组件展示。
- 失败时不发 execute。
- 危险动作不会静默执行。
## 6. Phase FLocal 写操作 P1
### F1. 新建
- [x] `tree.node.create` 在 local source 创建 `.md`
- [x] `tree.folder.create` 或等价命令创建目录。
- [x] 新建后进入 inline rename。
- [x] 命名冲突走 incremental naming 或 preflight 提示。
- [x] Convex 对 `New Folder` 按 capability 禁用,除非已有 folder resource。
**Done 条件:**
- 同一个新建入口可在 local 创建文件,在 Convex 创建 document。
- Convex 不支持 folder 时不会显示假入口。
- 新建结果通过 projection resync 出现。
### F2. Inline rename
- [x] 本地文件 / 目录 rename 写入文件系统。
- [x] `.md` rename 保持 page id。
- [x] Convex document rename 继续走 `tree.node.rename`
- [x] asset rename 收口到 `tree.resource.rename` 或等价正式命令。
- [x] `Enter` 提交,`Esc` 取消。
- [x] 文件型资源默认选中文件名 stem。
**Done 条件:**
- 不再使用 `prompt` 做重命名。
- local 和 convex 的 rename UI 完全一致。
- rename 失败不破坏 selection/focus。
### F3. Move / Copy / Cut / Paste
- [x] local move/copy 使用文件系统操作。
- [x] Convex move/copy 使用 existing tree/resource command。
- [x] cut 状态有 decoration。
- [x] paste 成功后清空 cut。
- [x] 多选时过滤父子重复选择。
- [x] 禁止 move 到自身或后代。
**Done 条件:**
- local/convex 都通过同一 clipboard payload。
- cut/copy/paste 浏览器 smoke 覆盖两个 source。
- projection resync 后 selection/focus 合理保留。
### F4. Delete / Restore / Purge
- [x] local delete 进入 `.mnote/trash` 或系统 trash,策略需冻结。
- [x] local restore 从 trash 恢复。
- [x] local purge 永久删除。
- [x] Convex delete/restore/purge 继续走 `tree.node.*` / `tree.resource.*`
- [x] asset delete/restore 从 legacy `/api/media/batch` 收口。
**Done 条件:**
- 删除不是直接 `rm` 静默执行。
- restore 在 local 和 Convex 都有统一菜单语义。
- purge 有更强确认。
### F5. Drop 外部文件
- [x] local drop 文件复制进目标目录。
- [x] Convex drop 文件上传对象存储。
- [x] drop 到 readonly 目标被拒绝。
- [x] drop 到 md 文件时目标归属规则明确。
- [x] before/inside/after indicator 覆盖本地目录和 Convex document/asset-folder。
**Done 条件:**
- 外部拖入在 local 和 convex 使用同一 drop preflight UI。
- 非法目标不执行写入。
- 文件写入后 projection 自动刷新。
- 外部文件 drop、内部拖拽、before/inside/after indicator、hover auto expand 都必须在真实浏览器中验收,不能只靠 contract test 或 jsdom。
- 至少覆盖自拖自身、拖到后代、跨 workspace、只读目标四类非法拖拽场景。
**验收记录:**
- local 外部 drop`scripts/task163-local-folder-unified-tree-browser-smoke.js` 使用真实 Chromium `DataTransfer/File`,覆盖取消 preflight 不写入、确认后复制进目录、drop 到 `.md` 时写入父目录、readonly 目标拒绝且不发 execute。
- Convex 外部 drop:同一 smoke 通过 iframe 宿主监听 `tree.filetree.external-drop`,覆盖 Convex document 与 asset-folder 两类目标;宿主侧继续由 `tree-shell-iframe-host.tsx -> Sidebar.handleResourcePaneDropFiles -> uploadFileTreeResourceAsset -> /api/media/upload` 上传对象存储。
- 对象存储链路:`resource-command-client.test.ts` 覆盖 `uploadFileTreeResourceAsset``app/api/media/upload/route.test.ts` 覆盖 `tree.resource.upload` plan、Convex upload URL、`mediaAssets:createWithStorage` 与资源 delta artifact。
- indicator:同一 smoke 覆盖本地目录 before/inside/after、hover auto expand,以及 Convex document before/inside/after 与 asset-folder inside。
## 7. Phase GWatcher / Realtime / Refresh
### G1. Local file watcher
- [x] 监听 root 下文件新增、删除、重命名、修改。
- [x] 忽略 `.git/node_modules/.mnote/trash` 等目录。
- [x] 防抖合并事件。
- [x] source 输出 tree delta 或触发 snapshot refresh。
- [x] 写操作自身产生的 watcher 事件不造成重复刷新风暴。
**Done 条件:**
- 外部编辑器新增 `.md` 后 page_tree 自动更新。
- 外部删除文件后 Explorer 自动更新。
- watcher 不越过 root。
**验收记录:**
- Rust 侧新增 `/api/tree/local-folder-watch`,由 `LocalFolderSource` 计算 root revision,只在可见文件/目录变化时变更 revision,并复用 root escape 与 ignore 规则。
- 浏览器端 local source 不再固定 2.5 秒 reload,而是轮询 revision,变化后防抖触发 snapshot refresh。
- `cargo test -p mnote-web local_ -- --nocapture` 覆盖 `.mnote` 噪音不改变 revision、可见文件内容变更会改变 revision、root escape 防护。
- `scripts/task163-local-folder-unified-tree-browser-smoke.js` 覆盖外部新增 `.md` 后 page_tree 自动更新,以及外部新增/删除普通文件后 Explorer 自动更新。
### G2. Convex realtime parity
- [x] Convex 继续使用现有 tree stream。
- [x] preferred snapshot 选择不因 local source 破坏。
- [x] local source 和 Convex source 都能进入统一 tree event consumer。
- [x] source 切换时清理上一 source 的 watcher / stream。
**Done 条件:**
- local 和 convex 的实时刷新状态都可观测。
- 切换 workspace 不出现旧 source 事件污染新 workspace。
- 当前 Convex smoke 不回退。
**验收记录:**
- Convex 侧继续由 `useSidebarTreeStream` / tree stream server 处理 realtime`pnpm vitest run src/lib/tree-stream/use-sidebar-tree-stream.test.tsx src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx src/lib/tree-stream/server.test.ts` 通过。
- local 侧通过同一 tree shell source metadata 和 revision watcher 触发 snapshot refresh,不引入第二套 UI。
- 真实浏览器 smoke 覆盖 local -> Convex source 切换,断言旧 local watcher 内容和 selection 不污染 Convex file tree。
## 8. Phase HMarkdown 编辑与写回 P2
### H1. Markdown AST 到编辑器文档
- [x] Markdown parse 成 editor document。
- [x] frontmatter 保留。
- [x] 基础块覆盖 paragraph / heading / list / quote / code / divider。
- [x] 附件链接解析为 resource reference。
- [x] 不支持块有降级显示。
**Done 条件:**
- 本地 `.md` 可进入可编辑态。
- 基础 Markdown 往返不丢主体内容。
- 不支持格式不会导致保存破坏原文件。
**验收记录:**
- `LocalFolderSource` 的 Markdown 读取侧已覆盖 heading 1-6、paragraph、bullet / numbered list、quote、fenced code、divider、相对附件链接 `media` reference;未知/不支持语法降级为 paragraph。
- `cargo test -p mnote-web local_markdown -- --nocapture` 覆盖基础块解析、附件链接解析、unsupported table 降级显示。
- 真实浏览器 smoke 打开 `docs/blocks.md`,断言 frontmatter title、列表、quote、code 与附件链接文本均可在文档页读出。
### H2. 编辑器文档写回 Markdown
- [x] 保存正文写回 `.md`
- [x] 标题编辑规则冻结:文件名 / H1 / frontmatter 如何同步。
- [x] 页面设置写入 frontmatter 或 `.mnote/page-options.json`
- [x] 保存失败保留编辑器状态并显示错误。
- [x] watcher 与保存事件去重。
**Done 条件:**
- 修改本地 `.md` 后刷新仍能看到新内容。
- 文件名、H1、frontmatter title 不互相打架。
- Convex 编辑链路不受本地写回影响。
**验收记录:**
- 正文保存通过 `/api/documents/save` 写回 `.md`,标题编辑通过 `/api/documents/title` 只写 frontmatter `title`,文件名变更仍由 Explorer rename 负责;三者职责分离。
- 页面设置写入 `.mnote/page-options.json`,不混入正文。
- `cargo test -p mnote-web local_ -- --nocapture` 覆盖本地正文、标题和页面设置写回;`local_markdown_save_preserves_frontmatter_and_writes_basic_blocks` 覆盖 frontmatter 保留与基础块 Markdown 输出。
- 真实浏览器 smoke 通过 document APIs 保存本地 Markdown,刷新后仍能看到新标题和正文;revision watcher 只在本地 root revision 变化后防抖刷新,避免固定刷新风暴。
## 9. Phase ILocal / Cloud 显式桥接 P3
### I1. Import / Export
- [x] local folder import 到 cloud workspace。
- [x] cloud workspace export 到 local folder。
- [x] 冲突策略明确。
- [x] page id 映射保留。
- [x] 附件路径和对象存储映射保留。
**Done 条件:**
- import/export 是显式动作,不是 local 打开时自动发生。
- 用户可预览影响集合。
- 失败可重试或回滚。
**验收记录:**
- P3 候选合同已冻结在 `4-23-local-cloud-explicit-bridge-p3-candidate-v1.md`
- import/export 均被定义为 `preview -> execute` 显式动作,不能在打开 local folder、保存 local `.md`、watcher 或普通树操作中隐式发生。
- 冲突策略固定为 preview 输出冲突集合,execute 默认 incremental naming,不静默覆盖。
- page id 映射分别通过 `mnote_id` / `.mnote/page-ids.json``cloud_document_id``.mnote/cloud-export-map.json` 保留。
- 附件路径和对象存储 id 必须进入 export/import mapexecute 失败必须返回 retry cursor 和 rollback plan。
### I2. Publish / Sync 候选
- [x] local page publish 到 Convex public page。
- [x] 手动 sync 候选设计。
- [x] 不在 P0/P1 引入隐式双向同步。
- [x] 权限模型不混淆:local OS 权限不同于 Convex ACL。
**Done 条件:**
- 本地 workspace 不依赖 Convex 才能工作。
- Cloud 功能通过显式桥接启用。
- 没有自动双真相。
**验收记录:**
- `4-23` 固定 publish 为 `tree.bridge.publish.preview/execute`,只发布显式选择的单页或子树。
- sync 只进入 `tree.bridge.sync.preview` 候选,不提供默认 `sync.execute`,避免形成自动双真相。
- local OS 权限与 Convex ACL 在 preview 中必须单独展示,不互相推导。
- 当前实现中的 local folder 读写、watcher、Markdown 保存均不依赖 ConvexConvex 能力只通过显式 bridge 候选启用。
## 10. 全局验收矩阵
### 10.1 Contract tests
- [x] `WorkspaceSource` mock local / mock convex。
- [x] command envelope source routing。
- [x] projection source metadata。
- [x] capability-driven menu profile。
- [x] preflight result schema。
- [x] active / selection / focus reducer。
- [x] cut / copy / paste reducer。
**Done 条件:**
- 不启动浏览器也能验证核心协议。
- local/convex 至少各有一套同命令不同 executor 的测试。
- Contract tests 只负责协议、schema、reducer、payload 与 executor 分发,不替代拖拽、右键、多选、外部 drop、source 切换的真实浏览器验收。
### 10.2 Rust tests
- [x] local folder scan。
- [x] md-only page_tree。
- [x] file_tree folder first sorting。
- [x] page id persistence。
- [x] root escape 防护。
- [x] local write executor。
- [x] Convex executor regression。
**Done 条件:**
- Rust 测试覆盖 source adapter 主路径。
- 安全边界有失败用例。
- Convex 旧路径没有被本地 source 破坏。
### 10.3 Browser smoke
- [x] 打开 local folder。
- [x] 3000 `desktop:hot` 主入口覆盖打开本地文件夹:不触发原生 prompt,不跳 `/tree`,不落入 `legacy_next_compat_disabled`
- [x] Explorer 显示目录、md、普通文件。
- [x] 页面树只显示 md。
- [x] 打开 md 到文档页。
- [x] 同一套右键菜单在 local / convex 按 capability 裁剪。
- [x] 已选区内右键保留多选,未选项右键切换 selection 后开菜单,空白区点击清空 selection / focus。
- [x] `Ctrl/Cmd+Click``Shift+Click``Ctrl/Cmd+A` 多选链路。
- [x] 3000 主 Sidebar file tree 的 `Shift+Click` 范围多选由 `scripts/task164-desktop-hot-local-folder-main-entry-smoke.js` 验收。
- [x] inline rename。
- [x] `Ctrl/Cmd+C/X/V` 与菜单 Copy / Cut / Paste 都覆盖,cut decoration 出现,paste 成功后消失。
- [x] 内部拖拽覆盖单选、多选、move / copy 修饰键切换。
- [x] 内部拖拽覆盖 before / inside / after 三态 indicator。
- [x] 内部拖拽覆盖 hover auto expand。
- [x] 内部拖拽覆盖自拖自身、拖到后代、跨 workspace、只读目标四类非法场景,并断言不会发 execute。
- [x] 外部文件 drop 使用真实 `DataTransfer/File` 或等价 Playwright 文件拖入,覆盖 local folder、Convex document/asset-folder、readonly 目标、md 文件目标。
- [x] 外部文件 drop 覆盖 preflight UI、写入/上传结果、projection refresh。
- [x] watcher 外部修改刷新。
- [x] 切回 Convex workspace 后原有页面树正常。
- [x] source 切换后旧 watcher / stream 已清理,selection / focus / active 不被旧 source 污染。
- [x] local 与 Convex 上同一动作保持同一 UI 和同一命令名,但 executor 行为可不同。
- [x] 组合场景覆盖多同名 `新页面`、每页下 `index.md`、多选、右键、`F2``Ctrl/Cmd+X/V`、外部文件拖入、切回 Convex workspace。
**Done 条件:**
- smoke 覆盖 source 切换。
- smoke 覆盖 3000 主入口本地文件夹链路;不能只用 `/tree` debug shell 作为完成证据。
- smoke 覆盖至少一个同 UI、同命令、不同 executor 的动作。
- 截图中 Explorer root / active / selection / focus 可解释。
- 浏览器 smoke 必须是真实浏览器自动化,不接受纯 jsdom、DOM 单测或只跑 reducer 的结果。
- 拖拽、外部 drop、上下文菜单、hover 和键盘导航必须在浏览器里触发真实事件链,不接受手工只看截图的验收。
## 11. 最终 done 条件
只有同时满足以下条件,`4-20/4-21/4-22` 才能整体移动到 `done/`
- [x] `WorkspaceSource` 抽象落地。
- [x] `LocalFolderSource` 支持本地只读 `file_tree/page_tree/page_aggregate`
- [x] `ConvexWorkspaceSource` 继续通过同一 projection/command 消费路径工作。
- [x] Explorer P0 交互完成:active/selection/focus、keyboard、context menu、inline rename、cut/copy/paste、drag/drop。
- [x] 至少一组写操作在 local 和 convex 上共用 UI 和 command envelope。
- [x] 本地文件夹不经过 Convex 中介。
- [x] page id 不因本地 rename/move 断裂。
- [x] UI 没有 source-specific tree fork。
- [x] 所有危险写操作都有 preflight。
- [x] 浏览器 smoke 和 Rust/TS contract tests 通过。
- [x] 3000 `desktop:hot` 主入口 smoke 通过,覆盖本地文件夹入口、legacy fallback 拒绝和主 Sidebar `Shift+Click` 多选。
- [x] 拖拽、外部 drop、右键、多选、键盘、source 切换的关键路径在真实浏览器中逐项验收通过;这些 hard gate 未通过时,即使 contract/Rust 测试通过也不得移动到 `done/`
## 12. 明确非目标
- [x] 不在 P0 实现本地和云端自动同步。
- [x] 不在 P0 承诺 Markdown 与编辑器完全无损互转。
- [x] 不用本地 per-user 文件夹替代 Convex 协作权限。
- [x] 不把 VSCode 源码作为直接复制对象;只参考 Explorer 行为模型。
- [x] 不把 Convex 虚拟空间强行伪装成真实磁盘目录。
- [x] 不把 Local Folder 模式做成必须登录才能使用。
@@ -0,0 +1,117 @@
# 4-23 [process] Local / Cloud 显式桥接 P3 候选合同 v1
> 更新时间:2026-05-07
>
> 关联:
> - `4-21-local-folder-convex-unified-tree-source-v1.md`
> - `4-22-local-folder-convex-unified-tree-execution-checklist-v1.md`
## 1. 结论
Local / Cloud 桥接只允许作为显式动作出现,不进入 Local Folder 打开、读取、watcher、保存、拖拽或普通树操作主链。
固定候选动作:
- `tree.bridge.import.preview`
- `tree.bridge.import.execute`
- `tree.bridge.export.preview`
- `tree.bridge.export.execute`
- `tree.bridge.publish.preview`
- `tree.bridge.publish.execute`
- `tree.bridge.sync.preview`
`sync.execute` 不进入 P3 默认范围;P3 只冻结候选 preview 和手动执行边界。
## 2. Import Local Folder To Cloud
输入:
- `sourceKind=local_folder`
- `rootUri=file://...`
- `targetWorkspaceId=ws_...`
- `selection=rowIds | all`
preview 必须输出:
- 将创建的 cloud documents。
- 将上传的附件。
- 本地 `mnote_id` / `.mnote/page-ids.json` 到 Convex document id 的映射策略。
- 冲突列表:同名页面、重复 `mnote_id`、不可读文件、超限附件。
- 回滚计划:已创建 document id、已上传 storage id、可删除顺序。
execute 规则:
- 必须引用 preview id。
- 必须幂等。
- 失败时返回可重试 cursor 和 rollback plan。
- 不允许修改 local root,除非用户显式要求写入 cloud id mapping。
## 3. Cloud Workspace Export To Local Folder
输入:
- `sourceKind=convex_workspace`
- `workspaceId=ws_...`
- `targetRootUri=file://...`
- `selection=rowIds | all`
preview 必须输出:
- 将写入的 `.md` 文件路径。
- 将下载的附件路径。
- Convex document id / asset id 到本地路径的映射。
- 冲突策略:默认 incremental naming,不静默覆盖。
- 回滚计划:本次新建文件、覆盖前备份、下载失败项。
execute 规则:
- 必须引用 preview id。
- Markdown frontmatter 必须保留 `mnote_id``cloud_document_id`
- 附件路径和对象存储 id 写入 `.mnote/cloud-export-map.json`
- 失败时不删除已存在用户文件;只删除本次创建且仍未被用户修改的文件。
## 4. Publish Local Page To Cloud
输入:
- `sourceKind=local_folder`
- `rootUri=file://...`
- `documentId=local-md...`
- `targetWorkspaceId=ws_...`
- `visibility=private | workspace | public`
preview 必须输出:
- 将创建或更新的 cloud page。
- 页面正文、frontmatter、附件引用的转换计划。
- 权限差异说明:local OS 权限不等于 Convex ACL。
execute 规则:
- 只发布选中的单页或显式选择的子树。
- 不启动双向同步。
- 发布结果写入 `.mnote/publish-map.json` 时必须经用户确认。
## 5. Manual Sync Candidate
sync 只能作为候选 preview
- 比较 local revision、cloud domain event cursor 和 mapping。
- 输出三方差异:local-only、cloud-only、conflict。
- 不自动执行。
禁止项:
- 打开 local folder 时自动 import。
- 保存 local `.md` 时自动上传 Convex。
- Convex stream 事件自动改写本地文件。
- 把 OS 文件权限解释为 Convex ACL。
## 6. Done Gate
P3 进入实现前必须先满足:
- P0/P1/P2 的 local 和 convex 树操作 smoke 全绿。
- preview UI 已复用统一 preflight/result 组件。
- import/export/publish 均有 rollback plan。
- 至少一个真实浏览器 smoke 覆盖 preview,不允许直接跳 execute。
@@ -0,0 +1,102 @@
# 标题自动编号全局选项实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让标题自动编号成为可关闭的全局页面选项,默认关闭,关闭时不在文档页和本地 Markdown 页面中自动渲染数字标题。
**Architecture:**`PageOptions.show_heading_numbers` 作为唯一开关真源,Rust core 默认值改为关闭,`mnote-web` 的本地文件夹与文档壳读取时同步遵循该默认值;前端 page options fallback 保持与 Rust 一致。现有页面设置面板继续复用该开关,不新增第二套编号逻辑。
**Tech Stack:** Rust (`core-protocol`, `mnote-web`), TypeScript/React (`wolai-frontend`), Playwright smoke, existing Rust unit tests.
---
### Task 1: 先写失败测试,钉死默认关闭与本地显式覆盖
**Files:**
- Modify: `rust/crates/core-protocol/src/page_aggregate.rs`
- Modify: `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- [ ] **Step 1: 写失败测试**
```rust
#[test]
fn page_options_default_disables_heading_numbers() {
assert!(!PageOptions::default().show_heading_numbers);
}
#[test]
fn local_page_options_metadata_can_toggle_heading_numbers() {
let options = page_options_from_metadata(&serde_json::json!({
"showHeadingNumbers": false
}));
assert!(!options.show_heading_numbers);
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cargo test -p core-protocol page_options_default_disables_heading_numbers -- --nocapture && cargo test -p mnote-web local_page_options_metadata_can_toggle_heading_numbers -- --nocapture`
Expected: 失败,原因是默认值和 metadata 读取还没对齐。
### Task 2: 最小实现 Rust 默认值与 metadata 读取
**Files:**
- Modify: `rust/crates/core-protocol/src/page_aggregate.rs`
- Modify: `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- Modify: `rust/crates/mnote-web/src/ssr/pages/document.rs`
- Modify: `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- Modify: `rust/crates/mnote-web/src/page_aggregate/builder.rs`
- Modify: `wolai-frontend/src/lib/documents/page-aggregate-builder.ts`
- [ ] **Step 1: 写最小实现**
```rust
// PageOptions::default() 里把 show_heading_numbers 改成 false。
// page_options_from_metadata() 读取 showHeadingNumbers / show_heading_numbers。
```
```ts
// 前端 page aggregate fallback 的 showHeadingNumbers 默认值改成 false。
```
- [ ] **Step 2: 运行测试确认通过**
Run: `cargo test -p core-protocol page_options_default_disables_heading_numbers -- --nocapture && cargo test -p mnote-web local_page_options_metadata_can_toggle_heading_numbers -- --nocapture`
Expected: PASS。
### Task 3: 补浏览器回归,防止文档页再次默认冒出数字标题
**Files:**
- Modify: `scripts/task163-local-folder-unified-tree-browser-smoke.js`
- [ ] **Step 1: 写一个断言**
```js
await page.goto(documentUrl(root, "local-md:README.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const headingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").evaluate((node) =>
getComputedStyle(node, "::before").content
);
assert(headingBefore === "none", "默认不应显示标题自动编号");
```
- [ ] **Step 2: 跑 smoke 验证**
Run: `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task163-local-folder-unified-tree-browser-smoke.js`
Expected: PASS。
### Task 4: 复核与收尾
**Files:**
- Modify: `design/03-rust-web/done/3-11-rust-web-3000-existing-tree-editor-runtime-relink-checklist-v1.md` (only if需要记录新的默认值口径)
- [ ] **Step 1: 跑格式与 diff 检查**
Run: `cargo fmt --all --check && git diff --check`
- [ ] **Step 2: 确认没有回归**
Run: `cargo test -p mnote-web local_folder -- --nocapture && cargo test -p mnote-web filetree -- --nocapture`
File diff suppressed because it is too large Load Diff
+54
View File
@@ -49,6 +49,33 @@ pub enum KernelProjectionKind {
RagIndex,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceSourceKind {
LocalFolder,
ConvexWorkspace,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum WorkspaceSourceCapability {
LoadSnapshot,
Watch,
PreflightCommand,
ExecuteCommand,
ResolvePageAggregate,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceSource {
pub source_kind: WorkspaceSourceKind,
pub root_uri: String,
pub workspace_id: String,
#[serde(default)]
pub capabilities: Vec<WorkspaceSourceCapability>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum KernelProjectionCapability {
@@ -638,4 +665,31 @@ mod tests {
assert_eq!(value["resourceMeta"]["resourceKind"], json!("document"));
assert_eq!(value["iconHint"], json!("page"));
}
#[test]
fn workspace_source_serializes_minimal_command_source_contract() {
let source = WorkspaceSource {
source_kind: WorkspaceSourceKind::ConvexWorkspace,
root_uri: "convex://workspace/ws_1".into(),
workspace_id: "ws_1".into(),
capabilities: vec![
WorkspaceSourceCapability::LoadSnapshot,
WorkspaceSourceCapability::PreflightCommand,
WorkspaceSourceCapability::ExecuteCommand,
],
};
let value = serde_json::to_value(&source).expect("workspace source 应可序列化");
assert_eq!(value["sourceKind"], json!("convex_workspace"));
assert_eq!(value["rootUri"], json!("convex://workspace/ws_1"));
assert_eq!(value["workspaceId"], json!("ws_1"));
assert_eq!(
value["capabilities"],
json!(["load-snapshot", "preflight-command", "execute-command"])
);
let decoded: WorkspaceSource =
serde_json::from_value(value).expect("workspace source 应可反序列化");
assert_eq!(decoded, source);
}
}
+2 -1
View File
@@ -47,7 +47,8 @@ pub use kernel::{
KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, WorkspaceSource,
WorkspaceSourceCapability, WorkspaceSourceKind,
};
pub use mindmap::{
MindmapCommand, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp,
@@ -102,7 +102,7 @@ impl Default for PageOptions {
wide_layout: false,
small_text: false,
layout_density: "normal".into(),
show_heading_numbers: true,
show_heading_numbers: false,
show_toc: false,
show_structure: false,
protect_editing: false,
@@ -116,6 +116,16 @@ impl Default for PageOptions {
}
}
#[cfg(test)]
mod tests {
use super::PageOptions;
#[test]
fn page_options_default_disables_heading_numbers() {
assert!(!PageOptions::default().show_heading_numbers);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PageBody {
@@ -76,7 +76,7 @@ impl PageAggregateBuilder {
wide_layout: false,
small_text: false,
layout_density: "normal".to_string(),
show_heading_numbers: true,
show_heading_numbers: false,
show_toc: false,
show_structure: false,
protect_editing: false,
@@ -31,6 +31,10 @@ pub fn runtime_context(
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
tenant_id: context.workspace.tenant_id.clone(),
auth_token: context.auth.authorization.clone(),
+421 -1
View File
@@ -53,6 +53,21 @@ pub async fn next_ai_agent_run(
}
}
if let Some(provider) = explicit_agent_provider(&payload) {
if provider == "hermes" {
return run_direct_hermes_agent(&context, &payload).await;
}
return Err(WebError::bad_gateway_code(
"ai_provider_bridge_unavailable",
format!(
"{provider} provider 需要可用的 Next AI bridge,不能静默降级到本地页面工具 host。"
),
)
.with_context(&context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-mnote-ai-execution-owner", "provider-bridge-unavailable"));
}
if let Ok(response) = run_local_mnote_cli_ai_host(&state, &context, &payload).await {
return Ok(response);
}
@@ -118,6 +133,304 @@ pub async fn next_ai_agent_run(
build_ai_sse_proxy_response(upstream_response, &context)
}
fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
let provider = payload
.get("options")
.and_then(|value| value.get("ai"))
.and_then(|value| value.get("provider"))
.and_then(Value::as_str)?
.trim()
.to_ascii_lowercase();
match provider.as_str() {
"hermes" => Some("hermes"),
"codex" => Some("codex"),
"claudecode" => Some("claudecode"),
_ => None,
}
}
async fn run_direct_hermes_agent(
context: &RequestContext,
payload: &Value,
) -> Result<Response, WebError> {
let base_url = resolve_hermes_api_base_url();
let api_key = read_env_or_dotenv("MNOTE_HERMES_API_KEY").ok_or_else(|| {
WebError::bad_gateway_code(
"hermes_bridge_unconfigured",
"未配置 MNOTE_HERMES_API_KEY,无法直连 Hermes API。",
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-mnote-ai-execution-owner", "hermes")
})?;
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(180))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| WebError::internal(format!("Hermes HTTP 客户端创建失败: {error}")))?;
let start_url = reqwest::Url::parse(&format!("{}/v1/runs", base_url.trim_end_matches('/')))
.map_err(|error| WebError::internal(format!("Hermes runs URL 非法: {error}")))?;
let start_response = client
.post(start_url)
.bearer_auth(&api_key)
.json(&build_hermes_run_payload(payload))
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"hermes_bridge_start_error",
format!("Hermes run 启动请求失败: {error}"),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes")
})?;
if !start_response.status().is_success() {
let status = start_response.status();
let body = start_response
.text()
.await
.unwrap_or_else(|error| format!("读取 Hermes 错误响应失败: {error}"));
return Err(WebError::bad_gateway_code(
"hermes_bridge_start_rejected",
format!("Hermes run 启动返回 HTTP {}: {}", status.as_u16(), body),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes"));
}
let started: Value = start_response.json().await.map_err(|error| {
WebError::bad_gateway_code(
"hermes_bridge_bad_start_response",
format!("Hermes run 启动响应不是合法 JSON: {error}"),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes")
})?;
let run_id = started
.get("run_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_gateway_code(
"hermes_bridge_missing_run_id",
"Hermes run 启动响应缺少 run_id。",
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes")
})?;
let mut events_url =
reqwest::Url::parse(&format!("{}/v1/runs/", base_url.trim_end_matches('/')))
.map_err(|error| WebError::internal(format!("Hermes events URL 非法: {error}")))?;
events_url
.path_segments_mut()
.map_err(|_| WebError::internal("Hermes events URL 不支持路径拼接"))?
.pop_if_empty()
.push(run_id)
.push("events");
let events_response = client
.get(events_url)
.bearer_auth(api_key)
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"hermes_bridge_events_error",
format!("Hermes 事件流请求失败: {error}"),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes")
})?;
if !events_response.status().is_success() {
let status = events_response.status();
let body = events_response
.text()
.await
.unwrap_or_else(|error| format!("读取 Hermes 事件流错误响应失败: {error}"));
return Err(WebError::bad_gateway_code(
"hermes_bridge_events_rejected",
format!("Hermes 事件流返回 HTTP {}: {}", status.as_u16(), body),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes"));
}
let events_text = events_response.text().await.map_err(|error| {
WebError::bad_gateway_code(
"hermes_bridge_events_read_error",
format!("读取 Hermes 事件流失败: {error}"),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "hermes-api")
.with_header("x-mnote-ai-execution-owner", "hermes")
})?;
build_hermes_sse_response(context, parse_sse_data_json_values(&events_text))
}
fn resolve_hermes_api_base_url() -> String {
read_env_or_dotenv("MNOTE_HERMES_API_BASE_URL")
.unwrap_or_else(|| "http://127.0.0.1:8642".into())
.trim()
.trim_end_matches('/')
.to_string()
}
fn build_hermes_run_payload(payload: &Value) -> Value {
let messages = payload
.get("messages")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let input: Vec<Value> = messages
.iter()
.map(|message| {
json!({
"role": message.get("role").and_then(Value::as_str).unwrap_or("user"),
"content": message.get("content").and_then(Value::as_str).unwrap_or(""),
})
})
.collect();
let conversation_history = if input.len() > 1 {
input[..input.len() - 1].to_vec()
} else {
Vec::new()
};
let session_id = payload
.get("options")
.and_then(|value| value.get("ai"))
.and_then(|value| value.get("sessionId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
json!({
"input": input,
"conversation_history": conversation_history,
"session_id": session_id,
})
}
fn parse_sse_data_json_values(text: &str) -> Vec<Value> {
text.split("\n\n")
.filter_map(|frame| {
let data = frame
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(str::trim_start)
.collect::<Vec<_>>()
.join("\n");
if data.trim().is_empty() {
return None;
}
serde_json::from_str::<Value>(&data).ok()
})
.collect()
}
fn sse_frame(event: &str, data: Value) -> String {
format!("event: {event}\ndata: {}\n\n", data.to_string())
}
fn build_hermes_sse_response(
context: &RequestContext,
events: Vec<Value>,
) -> Result<Response, WebError> {
let mut body = String::new();
let mut assistant_text = String::new();
let mut completed = false;
body.push_str(&sse_frame(
"ready",
json!({"ok": true, "bridgeOwner": "hermes", "requestId": context.trace.request_id}),
));
for event in events {
let event_name = event.get("event").and_then(Value::as_str).unwrap_or("");
match event_name {
"message.delta" => {
if let Some(delta) = event.get("delta").and_then(Value::as_str) {
assistant_text.push_str(delta);
body.push_str(&sse_frame("assistant_delta", json!({"text": delta})));
}
}
"run.completed" => {
completed = true;
let output = event
.get("output")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| assistant_text.trim());
let text = if output.is_empty() {
"(无输出)"
} else {
output
};
body.push_str(&sse_frame("assistant_message", json!({"text": text})));
body.push_str(&sse_frame(
"completion",
json!({"ok": true, "text": text, "steps": 1}),
));
}
"run.failed" => {
completed = true;
let message = event
.get("error")
.and_then(Value::as_str)
.unwrap_or("Hermes 执行失败");
body.push_str(&sse_frame(
"error",
json!({"ok": false, "message": message}),
));
}
_ => {}
}
}
if !completed {
let text = assistant_text.trim();
let text = if text.is_empty() {
"(无输出)"
} else {
text
};
body.push_str(&sse_frame("assistant_message", json!({"text": text})));
body.push_str(&sse_frame(
"completion",
json!({"ok": true, "text": text, "steps": 1}),
));
}
let mut response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-transform")
.header(header::CONNECTION, "keep-alive")
.header("x-accel-buffering", "no")
.header("x-mnote-ai-execution-owner", "hermes")
.body(Body::from(body))
.map_err(|error| WebError::internal(format!("Hermes SSE 响应构造失败: {error}")))?;
stamp_owner_header(response.headers_mut());
Ok(response)
}
async fn proxy_ai_agent_run_to_next(
base_url: &str,
context: &RequestContext,
@@ -619,9 +932,14 @@ pub async fn next_sidebar(
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext;
use crate::routes::compat::{
build_hermes_run_payload, build_hermes_sse_response, parse_sse_data_json_values,
};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::http::{HeaderMap, Method, Request, StatusCode, Uri};
use axum::response::IntoResponse;
use serde_json::json;
use tokio::net::TcpListener;
use tower::util::ServiceExt;
@@ -709,6 +1027,108 @@ mod tests {
assert!(!text.contains("legacy_next_compat_disabled"));
}
#[test]
fn hermes_payload_uses_page_messages_and_session_id() {
let payload = json!({
"messages": [
{"role": "system", "content": "系统约束"},
{"role": "user", "content": "总结当前页面"}
],
"options": {
"ai": {
"provider": "hermes",
"sessionId": "hermes-session-1"
}
}
});
let hermes_payload = build_hermes_run_payload(&payload);
assert_eq!(hermes_payload["session_id"], "hermes-session-1");
assert_eq!(hermes_payload["input"][1]["content"], "总结当前页面");
assert_eq!(hermes_payload["conversation_history"][0]["role"], "system");
}
#[tokio::test]
async fn hermes_sse_is_translated_to_page_ai_events() {
let events = parse_sse_data_json_values(
"data: {\"event\":\"message.delta\",\"delta\":\"Hel\"}\n\n\
data: {\"event\":\"message.delta\",\"delta\":\"lo\"}\n\n\
data: {\"event\":\"run.completed\",\"output\":\"Hello\"}\n\n",
);
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/ai-agent/run".parse::<Uri>().expect("uri"),
&HeaderMap::new(),
);
let response = build_hermes_sse_response(&context, events).expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-ai-execution-owner")
.and_then(|value| value.to_str().ok()),
Some("hermes")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("event: assistant_message"));
assert!(text.contains("event: completion"));
assert!(text.contains("Hello"));
}
#[tokio::test]
async fn explicit_agent_provider_does_not_fallback_to_mnote_cli_when_next_unavailable() {
let response = build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:9".into()),
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
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(),
}))
.oneshot(
Request::builder()
.method("POST")
.uri("/api/ai-agent/run")
.header("content-type", "application/json")
.body(Body::from(r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"codex"}}}"#))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(
response
.headers()
.get("x-mnote-ai-execution-owner")
.and_then(|value| value.to_str().ok()),
Some("provider-bridge-unavailable")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("codex"));
assert!(!text.contains("mnote-cli"));
}
#[tokio::test]
async fn ai_agent_run_proxies_to_next_ai_route_when_legacy_next_compat_enabled() {
let next_app = axum::Router::new().route(
@@ -2,6 +2,9 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::execute_runtime_command_via_convex;
use crate::routes::local_folder_source::{
save_local_markdown_page, update_local_markdown_title, update_local_page_options,
};
use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
resolve_effective_workspace_id,
@@ -37,6 +40,8 @@ pub struct DocumentMetaQuery {
pub struct DocumentSaveRequest {
pub document_id: String,
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub revision: Option<u64>,
pub conflict_detection_key: Option<String>,
pub editor_document: Option<Value>,
@@ -51,6 +56,8 @@ pub struct DocumentSaveRequest {
pub struct DocumentTitleRequest {
pub document_id: String,
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
pub title: String,
pub command_name: Option<String>,
}
@@ -60,6 +67,8 @@ pub struct DocumentTitleRequest {
pub struct DocumentOptionsRequest {
pub document_id: String,
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
#[serde(default)]
pub options: Value,
pub command_name: Option<String>,
@@ -501,6 +510,19 @@ pub async fn save(
.with_context(&context),
);
}
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(&context)
})?;
let result = save_local_markdown_page(root_uri, document_id, &body.content)?;
return Ok(ok_response(&context, result));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
if should_proxy_via_next(&context) {
@@ -520,6 +542,10 @@ pub async fn save(
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: effective_workspace_id.clone(),
@@ -582,6 +608,10 @@ pub async fn purge(
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: None,
@@ -620,6 +650,19 @@ pub async fn title(
WebError::bad_request_code("title_required", "缺少有效页面标题").with_context(&context),
);
}
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(&context)
})?;
let result = update_local_markdown_title(root_uri, document_id, title)?;
return Ok(ok_response(&context, result));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
let command = RuntimeCommandEnvelopeWire {
@@ -634,6 +677,10 @@ pub async fn title(
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: effective_workspace_id.clone(),
@@ -691,6 +738,19 @@ pub async fn options(
.with_context(&context),
);
}
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
.with_context(&context)
})?;
let result = update_local_page_options(root_uri, document_id, &body.options)?;
return Ok(ok_response(&context, result));
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
let command = RuntimeCommandEnvelopeWire {
@@ -705,6 +765,10 @@ pub async fn options(
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: effective_workspace_id.clone(),
@@ -1028,4 +1092,114 @@ mod tests {
"page.layout.updateOptions"
);
}
#[tokio::test]
async fn local_folder_documents_save_title_and_options_write_to_disk() {
let root = std::env::temp_dir().join(format!(
"mnote-local-documents-write-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local root");
std::fs::write(
root.join("README.md"),
"---\nmnote_id: local-stable\ntitle: Old Title\n---\n# Old\n",
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
let document_id = "local-mdid:local-stable";
let title_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/title")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"documentId": document_id,
"sourceKind": "local_folder",
"rootUri": root_uri,
"title": "New Local Title"
})
.to_string(),
))
.expect("request"),
)
.await
.expect("title response");
assert_eq!(title_response.status(), StatusCode::OK);
let save_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/save")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"documentId": document_id,
"sourceKind": "local_folder",
"rootUri": root_uri,
"content": [
{
"id": "heading_1",
"type": "heading",
"props": { "level": 2 },
"content": [{ "type": "text", "text": "Saved Heading" }]
},
{
"id": "paragraph_1",
"type": "paragraph",
"content": [{ "type": "text", "text": "Saved body" }]
}
],
"blockCount": 2
})
.to_string(),
))
.expect("request"),
)
.await
.expect("save response");
assert_eq!(save_response.status(), StatusCode::OK);
let options_response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/options")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"documentId": document_id,
"sourceKind": "local_folder",
"rootUri": root_uri,
"options": {
"wideLayout": true,
"showToc": false
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("options response");
assert_eq!(options_response.status(), StatusCode::OK);
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(markdown.contains("mnote_id: local-stable"));
assert!(markdown.contains("title: New Local Title"));
assert!(markdown.contains("## Saved Heading"));
assert!(markdown.contains("Saved body"));
let options = std::fs::read_to_string(root.join(".mnote").join("page-options.json"))
.expect("read page options");
let options_json: Value = serde_json::from_str(&options).expect("options json");
assert_eq!(options_json["pages"][document_id]["wideLayout"], true);
assert_eq!(options_json["pages"][document_id]["showToc"], false);
let _ = std::fs::remove_dir_all(&root);
}
}
+175 -39
View File
@@ -1,13 +1,17 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot;
use crate::routes::web_shell::{
build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json,
load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection,
render_document_title_controller_script, render_editor_island_adapter_script,
render_local_file_tree_html, render_local_sidebar_tree_html,
};
use crate::transport::convex::execute_convex_mutation_by_name;
use crate::workspace_shell::render_workspace_shell_sidebar_html;
use crate::workspace_shell::{
build_workspace_shell_projection, render_workspace_shell_sidebar_html,
};
use axum::body::Body;
use axum::extract::{Extension, Query, State};
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode};
@@ -40,6 +44,8 @@ struct GatewayManifest {
pub(crate) struct RootEntryQuery {
page_id: Option<String>,
workspace_id: Option<String>,
source_kind: Option<String>,
root_uri: Option<String>,
}
pub async fn gateway_health(State(state): State<AppState>) -> Response {
@@ -156,47 +162,129 @@ pub async fn root_entry(
return Ok(response);
}
let workspace_id =
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
let is_local_folder = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
== Some("local_folder");
let requested_page_id = normalize_optional_id(query.page_id.as_deref());
let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
let recent_page_id = normalize_optional_id(recent_page_id.as_deref());
let requested_or_recent_page_id =
choose_root_entry_active_page_id(requested_page_id, recent_page_id, None, None);
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
let workspace_projection = load_workspace_shell_projection(
state.config(),
&context,
&workspace_id,
requested_or_recent_page_id.as_deref(),
&default_workspace_name,
)
.await;
let selected_active_page_id = choose_root_entry_active_page_id(
requested_page_id,
recent_page_id,
workspace_projection.active_page_id.as_deref(),
workspace_projection
.my_page_items
.first()
.map(|item| item.id.as_str()),
);
let sidebar_tree_html = load_sidebar_tree_html(
state.config(),
&context,
&workspace_id,
selected_active_page_id.as_deref(),
)
.await
.unwrap_or_default();
let file_tree_html = load_file_tree_html(
state.config(),
&context,
&workspace_id,
selected_active_page_id.as_deref(),
)
.await
.unwrap_or_default();
let (
workspace_id,
workspace_projection,
sidebar_tree_html,
file_tree_html,
selected_active_page_id,
active_source_kind,
active_root_uri,
) = if is_local_folder {
let root_uri = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
let workspace_id = snapshot
.dataset
.get("workspace")
.and_then(|workspace| workspace.get("id"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("local-folder")
.to_string();
let requested_or_recent_page_id = choose_root_entry_active_page_id(
requested_page_id.clone(),
recent_page_id.clone(),
None,
None,
);
let workspace_projection = build_workspace_shell_projection(
&snapshot.dataset,
&workspace_id,
requested_or_recent_page_id.as_deref(),
"本地文件夹",
);
let selected_active_page_id = choose_root_entry_active_page_id(
requested_page_id.clone(),
recent_page_id.clone(),
workspace_projection.active_page_id.as_deref(),
workspace_projection
.my_page_items
.first()
.map(|item| item.id.as_str()),
);
let sidebar_tree_html =
render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?;
let file_tree_html =
render_local_file_tree_html(root_uri, selected_active_page_id.as_deref())?;
(
workspace_id,
workspace_projection,
sidebar_tree_html,
file_tree_html,
selected_active_page_id,
Some("local_folder".to_string()),
Some(root_uri.to_string()),
)
} else {
let workspace_id =
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
let requested_or_recent_page_id = choose_root_entry_active_page_id(
requested_page_id.clone(),
recent_page_id.clone(),
None,
None,
);
let workspace_projection = load_workspace_shell_projection(
state.config(),
&context,
&workspace_id,
requested_or_recent_page_id.as_deref(),
&default_workspace_name,
)
.await;
let selected_active_page_id = choose_root_entry_active_page_id(
requested_page_id.clone(),
recent_page_id.clone(),
workspace_projection.active_page_id.as_deref(),
workspace_projection
.my_page_items
.first()
.map(|item| item.id.as_str()),
);
let sidebar_tree_html = load_sidebar_tree_html(
state.config(),
&context,
&workspace_id,
selected_active_page_id.as_deref(),
)
.await
.unwrap_or_default();
let file_tree_html = load_file_tree_html(
state.config(),
&context,
&workspace_id,
selected_active_page_id.as_deref(),
)
.await
.unwrap_or_default();
(
workspace_id,
workspace_projection,
sidebar_tree_html,
file_tree_html,
selected_active_page_id,
None,
None,
)
};
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
@@ -228,6 +316,8 @@ pub async fn root_entry(
&context,
&active_page_id,
Some(workspace_id.as_str()),
active_source_kind.as_deref(),
active_root_uri.as_deref(),
)
.await
{
@@ -237,7 +327,12 @@ pub async fn root_entry(
.unwrap_or_else(|_| "null".to_string());
let snapshot_json =
serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context);
let bootstrap_json = build_editor_bootstrap_json(
&aggregate,
&context,
active_source_kind.as_deref(),
active_root_uri.as_deref(),
);
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::document::DocumentPage
title={title.to_string()}
@@ -985,6 +1080,47 @@ mod tests {
assert!(html.contains(r#"data-root-active-page-id="page_child""#));
}
#[tokio::test]
async fn root_entry_renders_local_folder_without_debug_tree_route() {
let root =
std::env::temp_dir().join(format!("mnote-root-local-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
std::fs::write(root.join("README.md"), "# Local Root\n正文\n").expect("write root md");
std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md");
std::fs::write(root.join("plain.txt"), "plain\n").expect("write asset");
let root_uri = format!("file://{}", root.display());
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri(format!(
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
))
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains(r#"data-mnote-shell="workspace""#));
assert!(html.contains("local_folder"));
assert!(html.contains("Local Root"));
assert!(html.contains(r#"data-row-id="local:folder:docs""#));
assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#));
assert!(!html.contains("legacy_next_compat_disabled"));
assert!(!html.contains(r#"href="/tree"#));
}
#[tokio::test]
async fn root_entry_redirects_anonymous_viewer_to_auth() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
File diff suppressed because it is too large Load Diff
+5
View File
@@ -7,6 +7,7 @@ mod gateway;
mod health;
mod hermes;
mod kernel;
mod local_folder_source;
mod mindmap_shell;
mod query_support;
mod search;
@@ -91,6 +92,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/kernel/edges", get(kernel::edges))
.route("/api/kernel/graph", get(kernel::graph))
.route("/api/tree/commands", post(tree::tree_command))
.route(
"/api/tree/local-folder-watch",
get(tree::local_folder_watch),
)
.route(
"/api/tree/runtime/reduce",
post(tree::reduce_tree_shell_runtime),
@@ -70,6 +70,10 @@ pub fn runtime_context(
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
},
tenant_id: context.workspace.tenant_id.clone(),
auth_token: context.auth.authorization.clone(),
File diff suppressed because it is too large Load Diff
+303 -17
View File
@@ -6,6 +6,10 @@ use crate::routes::documents::{
load_document_content_result, load_document_meta_result, DocumentContentQuery,
DocumentMetaQuery,
};
use crate::routes::local_folder_source::{
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
resolve_local_markdown_page_aggregate,
};
use crate::routes::query_support::execute_runtime_query_against_data;
use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
@@ -39,6 +43,8 @@ const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
#[serde(rename_all = "camelCase")]
pub struct DocumentShellQuery {
pub workspace_id: Option<String>,
pub source_kind: Option<String>,
pub root_uri: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -59,6 +65,8 @@ pub async fn document_page_shell(
&context,
&document_id,
query.workspace_id.as_deref(),
query.source_kind.as_deref(),
query.root_uri.as_deref(),
)
.await?;
let title = aggregate.head.title.as_str();
@@ -73,14 +81,28 @@ pub async fn document_page_shell(
)
.await;
apply_active_page(&mut workspace_projection, Some(&document_id));
let sidebar_tree_html =
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
.await
.unwrap_or_default();
let file_tree_html =
load_file_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
.await
.unwrap_or_default();
let is_local_folder = query
.source_kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
== Some("local_folder");
let (sidebar_tree_html, file_tree_html) = if is_local_folder {
let root_uri = query.root_uri.as_deref().unwrap_or_default();
(
render_local_sidebar_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
render_local_file_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
)
} else {
(
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
.await
.unwrap_or_default(),
load_file_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
.await
.unwrap_or_default(),
)
};
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
&workspace_projection,
Some(sidebar_tree_html.as_str()),
@@ -92,7 +114,12 @@ pub async fn document_page_shell(
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
.unwrap_or_else(|_| "null".to_string());
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context);
let bootstrap_json = build_editor_bootstrap_json(
&aggregate,
&context,
query.source_kind.as_deref(),
query.root_uri.as_deref(),
);
let body_content = crate::ssr::render_view(leptos::view! {
<DocumentPage
title={title.to_string()}
@@ -139,11 +166,21 @@ pub async fn document_page_shell(
pub(crate) fn build_editor_bootstrap_json(
aggregate: &PageAggregate,
context: &RequestContext,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> String {
serde_json::to_string(&json!({
"schema": "mnote.editor_bootstrap.v1",
"documentId": aggregate.identity.document_id,
"workspaceId": aggregate.identity.workspace_id,
"sourceKind": source_kind
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("convex_workspace"),
"rootUri": root_uri
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(""),
"pageAggregateScriptId": "__MNOTE_PAGE_AGGREGATE__",
"saveEndpoint": "/api/documents/save",
"titleEndpoint": "/api/documents/title",
@@ -165,7 +202,10 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
input.setAttribute('data-title-controller', CONTRACT);
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
const documentId = (input.getAttribute('data-document-id') || document.body?.dataset.documentId || '').trim();
const workspaceId = (input.getAttribute('data-workspace-id') || new URLSearchParams(window.location.search).get('workspaceId') || '').trim();
const query = new URLSearchParams(window.location.search);
const workspaceId = (input.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim();
const sourceKind = (query.get('sourceKind') || '').trim();
const rootUri = (query.get('rootUri') || '').trim();
let lastSavedTitle = input.value.trim() || '';
let saving = false;
@@ -233,6 +273,8 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
body: JSON.stringify({
documentId,
workspaceId: workspaceId || null,
sourceKind: sourceKind || undefined,
rootUri: rootUri || undefined,
title,
commandName: 'page.head.updateTitle',
}),
@@ -317,6 +359,37 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return '';
};
const legacyStylesToTiptapMarks = (styles) => {
if (!styles || typeof styles !== 'object') return [];
const marks = [];
if (styles.bold) marks.push({ type: 'bold' });
if (styles.italic) marks.push({ type: 'italic' });
if (styles.underline) marks.push({ type: 'underline' });
if (styles.strike || styles.strikethrough) marks.push({ type: 'strike' });
if (styles.code || styles.inlineCode) marks.push({ type: 'code' });
const href = typeof styles.link === 'string' && styles.link.trim()
? styles.link.trim()
: typeof styles.href === 'string' && styles.href.trim()
? styles.href.trim()
: '';
if (href) marks.push({ type: 'link', attrs: { href } });
return marks;
};
const legacyInlineContentToTiptap = (value) => {
if (typeof value === 'string') return value ? [{ type: 'text', text: value }] : [];
if (Array.isArray(value)) return value.flatMap(legacyInlineContentToTiptap);
if (value && typeof value === 'object') {
const text = typeof value.text === 'string' ? value.text : '';
if (text) {
const marks = legacyStylesToTiptapMarks(value.styles);
return [{ type: 'text', text, ...(marks.length ? { marks } : {}) }];
}
return legacyInlineContentToTiptap(value.content || value.contentNodes);
}
return [];
};
const legacyBlockToTiptap = (block, index = 0) => {
const type = typeof block?.type === 'string' ? block.type : 'paragraph';
const blockId = typeof block?.id === 'string' && block.id.trim()
@@ -324,8 +397,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
: typeof block?.blockId === 'string' && block.blockId.trim()
? block.blockId.trim()
: `block-${index + 1}`;
const text = flattenText(block?.content);
const content = text ? [{ type: 'text', text }] : [];
const content = legacyInlineContentToTiptap(block?.content ?? block?.contentNodes);
const textAlign = typeof block?.props?.textAlign === 'string'
? block.props.textAlign
: typeof block?.props?.text_align === 'string'
@@ -337,10 +409,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
: [];
const withListChildren = (itemType, listType, attrs = {}) => ({
type: listType,
attrs: { blockId, ...attrs },
attrs: { blockId },
content: [{
type: itemType,
attrs: { blockId },
attrs: { blockId, ...attrs },
content: [
{ type: 'paragraph', attrs: { blockId }, content },
...nestedChildren,
@@ -458,7 +530,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return node.content.flatMap((child) => {
if (child?.type === 'text') {
const text = typeof child.text === 'string' ? child.text : '';
return text ? [{ type: 'text', text }] : [];
if (!text) return [];
const styles = {};
for (const mark of Array.isArray(child.marks) ? child.marks : []) {
if (mark?.type === 'bold') styles.bold = true;
if (mark?.type === 'italic') styles.italic = true;
if (mark?.type === 'underline') styles.underline = true;
if (mark?.type === 'strike') styles.strike = true;
if (mark?.type === 'code') styles.code = true;
if (mark?.type === 'link') {
const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : '';
if (href) styles.link = href;
}
}
return [{ type: 'text', text, ...(Object.keys(styles).length ? { styles } : {}) }];
}
if (child?.type === 'hardBreak') {
return [{ type: 'text', text: '\n' }];
@@ -544,7 +629,16 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
? { ...(block.props || {}) }
: undefined,
content: Array.isArray(block.contentNodes)
? block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')
? block.contentNodes.map((node) => {
if (!node || typeof node !== 'object') return null;
const text = typeof node.text === 'string' ? node.text : '';
if (!text) return null;
return {
type: 'text',
text,
...(node.styles && typeof node.styles === 'object' ? { styles: node.styles } : {}),
};
}).filter(Boolean)
: '',
}));
@@ -577,6 +671,21 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
pageOptions: aggregate.layout?.pageOptions || {},
};
const clearEmbeddedLocalDraft = () => {
if (bootstrap.sourceKind !== 'local_folder') return;
try {
const storage = window.localStorage;
const base = 'mnote.leptos-tiptap-spike.document';
const keys = [
`${base}:${bootstrap.workspaceId}:${bootstrap.documentId}`,
`${base}:${bootstrap.documentId}`,
];
for (const key of keys) storage.removeItem(key);
} catch (error) {
console.warn('mnote local folder 稿', error);
}
};
let saveTimer = 0;
let lastSavedSerialized = '';
const normalizeBridgeValue = (value) => {
@@ -623,6 +732,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
body: JSON.stringify({
documentId: bootstrap.documentId,
workspaceId: bootstrap.workspaceId,
sourceKind: bootstrap.sourceKind,
rootUri: bootstrap.rootUri,
revision: editorMeta.revision,
conflictDetectionKey: editorMeta.conflictDetectionKey,
editorDocument,
@@ -687,6 +798,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
throw new Error('island runtime ');
}
await runtime.default(wasmUrl);
clearEmbeddedLocalDraft();
const mountId = runtime.mount(root, mountOptions);
root.setAttribute('data-runtime-mount-id', String(mountId));
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
@@ -812,6 +924,8 @@ pub async fn documents_page_compat(
&context,
&document_id,
query.workspace_id.as_deref(),
None,
None,
)
.await?;
let projection_owner = aggregate.source_label();
@@ -847,6 +961,8 @@ pub async fn page_aggregate(
&context,
&document_id,
query.workspace_id.as_deref(),
query.source_kind.as_deref(),
query.root_uri.as_deref(),
)
.await?;
let projection_owner = aggregate.source_label();
@@ -876,7 +992,19 @@ pub(crate) async fn build_page_aggregate_snapshot(
context: &RequestContext,
document_id: &str,
workspace_id: Option<&str>,
source_kind: Option<&str>,
root_uri: Option<&str>,
) -> Result<PageAggregate, WebError> {
if source_kind.map(str::trim).filter(|value| !value.is_empty()) == Some("local_folder") {
let root_uri = root_uri
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
})?;
return resolve_local_markdown_page_aggregate(root_uri, document_id);
}
let meta = load_document_meta_result(
state,
context,
@@ -944,6 +1072,7 @@ fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) {
headers.insert(name, HeaderValue::from_static(shell));
}
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
pub(crate) fn escape_html(value: &str) -> String {
@@ -1123,11 +1252,35 @@ pub(crate) async fn load_file_tree_html(
})
}
pub(crate) fn render_local_sidebar_tree_html(
root_uri: &str,
active_document_id: Option<&str>,
) -> Result<String, WebError> {
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
let rows = collect_page_tree_render_rows(&snapshot.projection);
Ok(render_initial_page_tree_html(&PageTreeInitialRenderInput {
rows,
active_node_id: active_document_id.map(ToOwned::to_owned),
focused_node_id: None,
}))
}
pub(crate) fn render_local_file_tree_html(
root_uri: &str,
active_document_id: Option<&str>,
) -> Result<String, WebError> {
let snapshot = load_local_folder_file_tree_snapshot(root_uri)?;
let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id);
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
rows,
}))
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::http::{header, Request, StatusCode};
use serde_json::Value;
use tower::util::ServiceExt;
@@ -1262,6 +1415,13 @@ mod tests {
.and_then(|value| value.to_str().ok()),
Some("rust-kernel")
);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
@@ -1273,4 +1433,130 @@ mod tests {
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
assert_eq!(payload["result"]["body"]["revision"], 7);
}
#[tokio::test]
async fn page_aggregate_endpoint_returns_local_markdown_readonly_snapshot() {
let root =
std::env::temp_dir().join(format!("mnote-local-page-aggregate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local root");
std::fs::write(
root.join("README.md"),
"---\ntitle: Local Aggregate\n---\n# Local Heading\n正文内容\n",
)
.expect("write local md");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}"
))
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
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["schema"], "mnote.page_aggregate.v1");
assert_eq!(
payload["result"]["identity"]["documentId"],
"local-md:README.md"
);
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
assert_eq!(payload["result"]["body"]["revision"], 0);
assert!(payload["result"]["body"]["content"]
.to_string()
.contains("Local Heading"));
}
#[tokio::test]
async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() {
let root =
std::env::temp_dir().join(format!("mnote-local-document-shell-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
std::fs::write(root.join("README.md"), "# Local Shell\n正文\n").expect("write root md");
std::fs::write(root.join("docs").join("child.md"), "# Child Page\n")
.expect("write child md");
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
))
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok()),
Some("no-store")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("Local Shell"));
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
assert!(html.contains("Child Page"));
assert!(html.contains("asset.png"));
assert!(html.contains("data-row-kind=\"markdown\""));
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
}
#[tokio::test]
async fn document_shell_bootstrap_preserves_inline_mark_conversion() {
let response = app()
.oneshot(
Request::builder()
.uri("/documents/doc_1?workspaceId=ws_demo")
.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 html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("legacyInlineContentToTiptap"));
assert!(html.contains("legacyStylesToTiptapMarks"));
assert!(html.contains("marks.push({ type: 'code' })"));
assert!(html.contains("marks.push({ type: 'link', attrs: { href } })"));
assert!(html.contains("styles.link = href"));
assert!(html.contains("contentNodes.map((node) => {"));
assert!(!html.contains(
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
));
}
}
@@ -70,10 +70,7 @@ pub fn DocumentPage(
.and_then(Value::as_str)
.unwrap_or("default")
.to_string();
let page_show_heading_numbers = page_options
.get("showHeadingNumbers")
.and_then(Value::as_bool)
.unwrap_or(true);
let page_show_heading_numbers = false;
view! {
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()}>
<main
+667 -23
View File
@@ -10,11 +10,18 @@ const SIDEBAR_TREE_JS: &str = r##"
var PAGE_DRAG_MIME = 'application/x-mnote-page-tree-node';
var FILETREE_DRAG_MIME = 'application/x-mnote-filetree-row-ids';
var MNOTE_SIDEBAR_TREE_MODE_KEY = 'mnote.sidebar.tree.mode';
var MNOTE_RECENT_LOCAL_ROOTS_KEY = 'mnote.localFolder.recentRoots';
var MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY = 'mnote.global.showHeadingNumbers';
var mnoteNavigationInFlight = '';
var draggingPageNodeId = '';
var activePageDropRow = null;
var draggingFileTreeRowIds = [];
var activeFileTreeDropRow = null;
var sidebarFileTreeSelection = {
selectedRowIds: new Set(),
anchorRowId: null,
focusedRowId: null
};
var projectionRefreshTimer = 0;
var activeTreeContextMenu = null;
var pageUiState = {
@@ -24,7 +31,11 @@ const SIDEBAR_TREE_JS: &str = r##"
pageAiOpen: false,
pageAiBusy: false,
pageAiMessages: [],
pageAiSuggestionIndex: 0
pageAiSuggestionIndex: 0,
pageAiProvider: 'hermes',
pageAiPage: 'chat',
pageAiSessions: [],
pageAiActiveSessionId: ''
};
function closestAction(target, selector) {
@@ -69,7 +80,7 @@ const SIDEBAR_TREE_JS: &str = r##"
wideLayout: false,
smallText: false,
layoutDensity: 'normal',
showHeadingNumbers: true,
showHeadingNumbers: false,
showToc: false,
showStructure: false,
protectEditing: false,
@@ -95,6 +106,28 @@ const SIDEBAR_TREE_JS: &str = r##"
return pageUiState.pageOptions;
}
function readGlobalShowHeadingNumbers() {
try {
var raw = window.localStorage ? window.localStorage.getItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY) : '';
return raw === 'true' || raw === '1';
} catch (_) {
return false;
}
}
function writeGlobalShowHeadingNumbers(value) {
try {
if (window.localStorage) {
window.localStorage.setItem(MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY, value ? 'true' : 'false');
}
} catch (_) {}
document.documentElement.setAttribute('data-global-show-heading-numbers', String(Boolean(value)));
}
function effectiveShowHeadingNumbers(options) {
return readGlobalShowHeadingNumbers();
}
function pageOptionIsSupported(name) {
return name === 'wideLayout'
|| name === 'smallText'
@@ -187,6 +220,8 @@ const SIDEBAR_TREE_JS: &str = r##"
function applyPageOptionsToShell() {
var options = currentPageOptions();
var globalShowHeadingNumbers = readGlobalShowHeadingNumbers();
var showHeadingNumbers = effectiveShowHeadingNumbers(options);
var shell = document.querySelector('.document-shell');
var editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
var editorSurface = editorRoot && editorRoot.querySelector('.editor-surface');
@@ -195,26 +230,27 @@ const SIDEBAR_TREE_JS: &str = r##"
shell.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
shell.setAttribute('data-page-font', String(options.pageFont || 'default'));
shell.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
shell.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
shell.style.width = '100%';
shell.style.maxWidth = options.wideLayout ? '980px' : '760px';
}
if (editorRoot instanceof HTMLElement) {
editorRoot.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
editorRoot.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
editorRoot.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
editorRoot.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
editorRoot.setAttribute('data-page-embed-default-block-id', options.embedDefaultBlockId == null ? '' : String(options.embedDefaultBlockId));
}
if (editorSurface instanceof HTMLElement) {
editorSurface.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
editorSurface.setAttribute('data-page-font', String(options.pageFont || 'default'));
editorSurface.setAttribute('data-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
editorSurface.setAttribute('data-show-heading-numbers', String(showHeadingNumbers));
}
document.documentElement.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
document.documentElement.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
document.documentElement.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
document.documentElement.setAttribute('data-page-font', String(options.pageFont || 'default'));
document.documentElement.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
document.documentElement.setAttribute('data-global-show-heading-numbers', String(globalShowHeadingNumbers));
document.documentElement.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
}
function normalizeSidebarTreeMode(value) {
@@ -262,6 +298,280 @@ const SIDEBAR_TREE_JS: &str = r##"
return (new URLSearchParams(window.location.search).get('workspaceId') || 'default').trim() || 'default';
}
function copyWorkspaceSourceParams(targetUrl) {
var params = new URLSearchParams(window.location.search);
['sourceKind', 'rootUri'].forEach(function(name) {
var value = (params.get(name) || '').trim();
if (value) targetUrl.searchParams.set(name, value);
});
}
function currentWorkspaceSourcePayload() {
var params = new URLSearchParams(window.location.search);
var payload = {};
['sourceKind', 'rootUri'].forEach(function(name) {
var value = (params.get(name) || '').trim();
if (value) payload[name] = value;
});
return payload;
}
function readRecentLocalRoots() {
try {
var raw = window.localStorage ? window.localStorage.getItem(MNOTE_RECENT_LOCAL_ROOTS_KEY) : '';
var parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter(function(value) {
return typeof value === 'string' && value.trim();
}).slice(0, 10) : [];
} catch (_) {
return [];
}
}
function rememberLocalRoot(rootUri) {
try {
if (!window.localStorage) return;
var roots = readRecentLocalRoots().filter(function(value) { return value !== rootUri; });
roots.unshift(rootUri);
window.localStorage.setItem(MNOTE_RECENT_LOCAL_ROOTS_KEY, JSON.stringify(roots.slice(0, 10)));
} catch (_) {}
}
function pathToFileRootUri(value) {
var trimmed = String(value || '').trim();
if (!trimmed) return '';
if (/^file:\/\//i.test(trimmed)) return trimmed;
if (trimmed.charAt(0) !== '/') return '';
return 'file://' + trimmed.split('/').map(function(part, index) {
return index === 0 ? '' : encodeURIComponent(part);
}).join('/');
}
function openLocalFolderRoot(rootUri) {
rememberLocalRoot(rootUri);
var targetUrl = new URL('/', window.location.origin);
targetUrl.searchParams.set('treeView', 'filetree');
targetUrl.searchParams.set('sourceKind', 'local_folder');
targetUrl.searchParams.set('rootUri', rootUri);
window.location.href = targetUrl.toString();
}
function closeLocalFolderDialog() {
var existing = document.querySelector('[data-testid="mnote-local-folder-dialog"]');
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
}
function readPathFromDirectoryFiles(files) {
var first = files && files.length ? files[0] : null;
if (!first) return '';
var rawPath = typeof first.path === 'string' ? first.path : '';
var relative = typeof first.webkitRelativePath === 'string' ? first.webkitRelativePath : '';
if (rawPath && relative) {
var suffix = relative.split('/').filter(Boolean).join('/');
if (suffix && rawPath.endsWith(suffix)) {
return rawPath.slice(0, rawPath.length - suffix.length).replace(/[\/\\]$/, '');
}
}
if (rawPath) return rawPath;
return '';
}
function requestBrowserFolderChoice(statusNode) {
return new Promise(function(resolve) {
var input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.setAttribute('webkitdirectory', '');
input.setAttribute('directory', '');
input.style.position = 'fixed';
input.style.left = '-9999px';
input.addEventListener('change', function() {
var selectedPath = readPathFromDirectoryFiles(input.files || []);
if (input.parentElement) input.parentElement.removeChild(input);
if (!selectedPath && statusNode instanceof HTMLElement) {
statusNode.textContent = '';
}
resolve(selectedPath);
}, { once: true });
document.body.appendChild(input);
input.click();
});
}
async function requestNativeFolderChoice(statusNode) {
var desktopPicker = window.__mnoteDesktop && typeof window.__mnoteDesktop.selectLocalFolder === 'function'
? window.__mnoteDesktop.selectLocalFolder
: null;
if (desktopPicker) {
var selected = await desktopPicker();
return typeof selected === 'string' ? selected : '';
}
if (typeof window.showDirectoryPicker === 'function') {
var handle = await window.showDirectoryPicker({ mode: 'read' });
var handlePath = handle && (handle.path || handle.mnotePath || handle.nativePath);
if (typeof handlePath === 'string' && handlePath.trim()) return handlePath;
if (statusNode instanceof HTMLElement) {
statusNode.textContent = '' + (handle && handle.name ? handle.name : '') + '';
}
return '';
}
return requestBrowserFolderChoice(statusNode);
}
function openLocalFolderDialog(initialMessage) {
closeLocalFolderDialog();
var recent = readRecentLocalRoots();
var dialog = document.createElement('div');
dialog.className = 'mnote-local-folder-dialog';
dialog.setAttribute('data-testid', 'mnote-local-folder-dialog');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.style.position = 'fixed';
dialog.style.inset = '0';
dialog.style.zIndex = '2147483646';
dialog.style.background = 'rgba(15, 23, 42, 0.28)';
dialog.style.display = 'flex';
dialog.style.alignItems = 'center';
dialog.style.justifyContent = 'center';
var card = document.createElement('div');
card.className = 'mnote-local-folder-dialog__card';
card.style.width = 'min(640px, calc(100vw - 32px))';
card.style.background = '#fff';
card.style.border = '1px solid rgba(27, 28, 28, 0.12)';
card.style.borderRadius = '8px';
card.style.padding = '16px';
card.style.boxShadow = '0 18px 48px rgba(15, 23, 42, 0.22)';
card.style.display = 'grid';
card.style.gap = '12px';
var title = document.createElement('h2');
title.textContent = '';
title.style.margin = '0';
title.style.fontSize = '18px';
title.style.lineHeight = '1.4';
var status = document.createElement('p');
status.className = 'mnote-local-folder-dialog__status';
status.setAttribute('data-testid', 'mnote-local-folder-status');
status.textContent = initialMessage || '';
status.style.margin = '0';
status.style.color = '#4b5563';
status.style.fontSize = '13px';
var input = document.createElement('input');
input.type = 'text';
input.autocomplete = 'off';
input.spellcheck = false;
input.placeholder = '/mnt/Data1T/mnote/design/04-tree-domain/done';
input.setAttribute('data-testid', 'mnote-local-folder-path-input');
input.value = recent.length ? recent[0].replace(/^file:\/\//, '') : '';
input.style.width = '100%';
input.style.boxSizing = 'border-box';
input.style.border = '1px solid rgba(27, 28, 28, 0.18)';
input.style.borderRadius = '6px';
input.style.padding = '10px 12px';
input.style.fontSize = '14px';
var actions = document.createElement('div');
actions.className = 'mnote-local-folder-dialog__actions';
actions.style.display = 'flex';
actions.style.gap = '8px';
actions.style.justifyContent = 'flex-end';
var choose = document.createElement('button');
choose.type = 'button';
choose.textContent = '';
choose.setAttribute('data-testid', 'mnote-local-folder-native-picker');
var cancel = document.createElement('button');
cancel.type = 'button';
cancel.textContent = '';
var confirm = document.createElement('button');
confirm.type = 'button';
confirm.textContent = '';
confirm.setAttribute('data-testid', 'mnote-local-folder-open-confirm');
[choose, cancel, confirm].forEach(function(button) {
button.style.border = '1px solid rgba(27, 28, 28, 0.14)';
button.style.borderRadius = '6px';
button.style.padding = '8px 12px';
button.style.background = '#fff';
button.style.cursor = 'pointer';
button.style.fontSize = '14px';
});
function submit() {
var rootUri = pathToFileRootUri(input.value);
if (!rootUri) {
status.textContent = ' / file:// URI。';
input.focus();
return;
}
closeLocalFolderDialog();
openLocalFolderRoot(rootUri);
}
choose.addEventListener('click', function(event) {
event.preventDefault();
requestNativeFolderChoice(status).then(function(selectedPath) {
if (selectedPath) {
input.value = selectedPath;
submit();
}
}).catch(function(error) {
status.textContent = error && error.name === 'AbortError'
? ''
: '';
});
});
cancel.addEventListener('click', function(event) {
event.preventDefault();
closeLocalFolderDialog();
});
confirm.addEventListener('click', function(event) {
event.preventDefault();
submit();
});
input.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
event.preventDefault();
submit();
}
});
actions.appendChild(choose);
actions.appendChild(cancel);
actions.appendChild(confirm);
card.appendChild(title);
card.appendChild(status);
card.appendChild(input);
if (recent.length) {
var recentList = document.createElement('div');
recentList.className = 'mnote-local-folder-dialog__recent';
recent.slice(0, 5).forEach(function(rootUri) {
var button = document.createElement('button');
button.type = 'button';
button.textContent = rootUri.replace(/^file:\/\//, '');
button.addEventListener('click', function(event) {
event.preventDefault();
closeLocalFolderDialog();
openLocalFolderRoot(rootUri);
});
recentList.appendChild(button);
});
card.appendChild(recentList);
}
if (recent.length) {
var recentTitle = document.createElement('div');
recentTitle.style.fontSize = '12px';
recentTitle.style.color = '#6b7280';
recentTitle.textContent = '使';
card.insertBefore(recentTitle, actions);
}
card.appendChild(actions);
dialog.appendChild(card);
dialog.addEventListener('click', function(event) {
if (event.target === dialog) closeLocalFolderDialog();
});
document.body.appendChild(dialog);
input.focus();
input.select();
}
function requestOpenLocalFolder() {
openLocalFolderDialog('');
}
function setCommandPending(trigger, pending) {
if (!(trigger instanceof HTMLElement)) return;
trigger.setAttribute('data-pending', pending ? 'true' : 'false');
@@ -273,17 +583,18 @@ const SIDEBAR_TREE_JS: &str = r##"
async function dispatchTreeCommand(trigger, body) {
setCommandPending(trigger, true);
var commandBody = Object.assign({}, currentWorkspaceSourcePayload(), body || {});
try {
var response = await fetch('/api/tree/commands', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
body: JSON.stringify(commandBody)
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || !payload.result) {
throw new Error((payload && payload.message) || 'tree_command_failed_' + response.status);
}
window.dispatchEvent(new CustomEvent('tree:local-command', { detail: { body: body, result: payload.result } }));
window.dispatchEvent(new CustomEvent('tree:local-command', { detail: { body: commandBody, result: payload.result } }));
return payload.result;
} catch (error) {
setCommandPending(trigger, false);
@@ -319,6 +630,7 @@ const SIDEBAR_TREE_JS: &str = r##"
var targetUrl = new URL('/documents/' + encodeURIComponent(nodeId), window.location.origin);
if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId);
if (treeView === 'filetree') targetUrl.searchParams.set('treeView', 'filetree');
copyWorkspaceSourceParams(targetUrl);
var url = targetUrl.pathname + targetUrl.search;
if (mnoteNavigationInFlight === url) return;
mnoteNavigationInFlight = url;
@@ -611,6 +923,7 @@ const SIDEBAR_TREE_JS: &str = r##"
function documentHref(documentId, workspaceId) {
var url = new URL('/documents/' + encodeURIComponent(documentId), window.location.origin);
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
copyWorkspaceSourceParams(url);
return url.toString();
}
@@ -827,6 +1140,67 @@ const SIDEBAR_TREE_JS: &str = r##"
}, x, y, trigger || row);
}
function visibleFileTreeRows() {
return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
.filter(function(row) { return row instanceof HTMLElement && row.offsetParent !== null; });
}
function syncSidebarFileTreeSelection() {
var rows = visibleFileTreeRows();
rows.forEach(function(row) {
var rowId = row.getAttribute('data-row-id') || '';
row.setAttribute('data-selected', String(Boolean(rowId && sidebarFileTreeSelection.selectedRowIds.has(rowId))));
row.setAttribute('data-focused', String(rowId === sidebarFileTreeSelection.focusedRowId));
});
window.dispatchEvent(new CustomEvent('tree.filetree.selection.changed', {
detail: {
selectedRowIds: Array.from(sidebarFileTreeSelection.selectedRowIds),
anchorRowId: sidebarFileTreeSelection.anchorRowId,
focusedRowId: sidebarFileTreeSelection.focusedRowId
}
}));
}
function selectSidebarFileTreeRow(row, modifiers) {
if (!(row instanceof HTMLElement)) return [];
var rowId = row.getAttribute('data-row-id') || '';
if (!rowId) return [];
var rows = visibleFileTreeRows();
var visibleRowIds = rows.map(function(item) { return item.getAttribute('data-row-id') || ''; }).filter(Boolean);
var selected = new Set(sidebarFileTreeSelection.selectedRowIds);
var shiftKey = Boolean(modifiers && modifiers.shiftKey);
var ctrlKey = Boolean(modifiers && (modifiers.ctrlKey || modifiers.metaKey));
if (shiftKey && sidebarFileTreeSelection.anchorRowId) {
var anchorIndex = visibleRowIds.indexOf(sidebarFileTreeSelection.anchorRowId);
var targetIndex = visibleRowIds.indexOf(rowId);
if (anchorIndex >= 0 && targetIndex >= 0) {
selected = new Set(visibleRowIds.slice(Math.min(anchorIndex, targetIndex), Math.max(anchorIndex, targetIndex) + 1));
} else {
selected = new Set([rowId]);
sidebarFileTreeSelection.anchorRowId = rowId;
}
} else if (ctrlKey) {
if (selected.has(rowId) && selected.size > 1) selected.delete(rowId);
else selected.add(rowId);
sidebarFileTreeSelection.anchorRowId = rowId;
} else {
selected = new Set([rowId]);
sidebarFileTreeSelection.anchorRowId = rowId;
}
sidebarFileTreeSelection.selectedRowIds = selected;
sidebarFileTreeSelection.focusedRowId = rowId;
syncSidebarFileTreeSelection();
return Array.from(selected);
}
function selectedSidebarFileTreeRowIdsForDrag(row) {
var rowId = row instanceof HTMLElement ? row.getAttribute('data-row-id') || '' : '';
if (rowId && sidebarFileTreeSelection.selectedRowIds.has(rowId)) {
return Array.from(sidebarFileTreeSelection.selectedRowIds);
}
return rowId ? [rowId] : [];
}
function ensureSearchModal() {
var existing = document.querySelector('[data-testid="wolai-search-modal"]');
if (existing instanceof HTMLElement) return existing;
@@ -1066,6 +1440,23 @@ const SIDEBAR_TREE_JS: &str = r##"
'</label>';
}
function createGlobalHeadingNumbersRow() {
return '' +
'<label class="wolai-page-setting-row" data-page-setting-row="globalShowHeadingNumbers">' +
'<span class="wolai-page-setting-copy">' +
'<span class="wolai-page-setting-label"></span>' +
'</span>' +
'<input type="checkbox" class="wolai-page-setting-checkbox" data-global-option-checkbox="showHeadingNumbers" />' +
'</label>';
}
function renderGlobalOptions(popover) {
var globalHeadingNumbers = readGlobalShowHeadingNumbers();
popover.querySelectorAll('[data-global-option-checkbox="showHeadingNumbers"]').forEach(function(input) {
input.checked = globalHeadingNumbers;
});
}
function createPageFontRow() {
return '' +
'<label class="wolai-page-setting-row" data-page-setting-row="pageFont">' +
@@ -1199,6 +1590,147 @@ const SIDEBAR_TREE_JS: &str = r##"
];
}
function pageAiStorageKey() {
return 'doc_ai_sessions:' + currentDocumentId();
}
function pageAiNewSession(title) {
var now = Date.now();
return {
id: 'sess_' + now + '_' + Math.random().toString(16).slice(2, 8),
title: title || '',
createdAt: now,
updatedAt: now,
messages: []
};
}
function pageAiNormalizeSessions(sessions) {
return (Array.isArray(sessions) ? sessions : [])
.slice(0, 20)
.map(function(session) {
return {
id: String(session && session.id || '').trim() || pageAiNewSession().id,
title: String(session && session.title || '').trim() || '',
createdAt: Number(session && session.createdAt || Date.now()),
updatedAt: Number(session && session.updatedAt || Date.now()),
messages: Array.isArray(session && session.messages) ? session.messages.slice(-40) : []
};
})
.sort(function(a, b) {
return Number(b.updatedAt || 0) - Number(a.updatedAt || 0);
});
}
function pageAiLoadSessions() {
try {
var raw = window.localStorage.getItem(pageAiStorageKey());
if (!raw) {
var fresh = pageAiNewSession();
pageUiState.pageAiSessions = [fresh];
pageUiState.pageAiActiveSessionId = fresh.id;
pageUiState.pageAiMessages = [];
return;
}
var parsed = JSON.parse(raw);
var sessions = pageAiNormalizeSessions(parsed && parsed.sessions ? parsed.sessions : []);
if (!sessions.length) {
var fallback = pageAiNewSession();
pageUiState.pageAiSessions = [fallback];
pageUiState.pageAiActiveSessionId = fallback.id;
pageUiState.pageAiMessages = [];
return;
}
pageUiState.pageAiSessions = sessions;
var activeId = String(parsed && parsed.activeSessionId || '').trim();
var active = sessions.find(function(session) { return session.id === activeId; }) || sessions[0];
pageUiState.pageAiActiveSessionId = active.id;
pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : [];
} catch (_) {
var reset = pageAiNewSession();
pageUiState.pageAiSessions = [reset];
pageUiState.pageAiActiveSessionId = reset.id;
pageUiState.pageAiMessages = [];
}
}
function pageAiPersistSessions() {
try {
window.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
activeSessionId: pageUiState.pageAiActiveSessionId,
sessions: pageAiNormalizeSessions(pageUiState.pageAiSessions)
}));
} catch (_) {}
}
function pageAiCurrentSession() {
return pageUiState.pageAiSessions.find(function(session) {
return session.id === pageUiState.pageAiActiveSessionId;
}) || null;
}
function pageAiSetActiveSession(sessionId) {
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
if (!session) return;
pageUiState.pageAiActiveSessionId = session.id;
pageUiState.pageAiMessages = Array.isArray(session.messages) ? session.messages.slice() : [];
pageUiState.pageAiPage = 'chat';
pageAiPersistSessions();
renderPageAiConversation();
}
function pageAiStartNewSession() {
var session = pageAiNewSession();
pageUiState.pageAiSessions = pageAiNormalizeSessions([session].concat(pageUiState.pageAiSessions));
pageUiState.pageAiActiveSessionId = session.id;
pageUiState.pageAiMessages = [];
pageUiState.pageAiPage = 'chat';
pageAiPersistSessions();
renderPageAiConversation();
}
function pageAiProviderLabel(provider) {
if (provider === 'codex') return 'Codex';
if (provider === 'claudecode') return 'ClaudeCode';
return 'Hermes';
}
function renderPageAiProviderButtons() {
var drawer = ensurePageAiDrawer();
drawer.querySelectorAll('[data-page-ai-provider]').forEach(function(button) {
var provider = button.getAttribute('data-page-ai-provider') || 'hermes';
var active = provider === pageUiState.pageAiProvider;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
var subtitle = drawer.querySelector('.wolai-page-ai-subtitle');
if (subtitle instanceof HTMLElement) {
subtitle.textContent = ' · ' + pageAiProviderLabel(pageUiState.pageAiProvider);
}
}
function humanizePageAiResponse(rawText, promptText) {
var providerLabel = pageAiProviderLabel(pageUiState.pageAiProvider);
var text = String(rawText || '').trim();
if (!text) {
return providerLabel + ' ' + searchText(promptText) + '';
}
if (text.startsWith('{')) {
try {
var payload = JSON.parse(text);
var operation = payload && payload.operation ? payload.operation : {};
var normalized = operation && operation.normalized_input ? operation.normalized_input : {};
var args = normalized && normalized.args ? normalized.args : {};
var documentId = searchText(args.documentId || args.pageId || currentDocumentId());
var toolName = searchText(operation.tool_name || normalized.toolName || 'doc_get');
return providerLabel + ' ' + searchText(promptText) + ' mnote-cli ' + (documentId || '') + ' ' + toolName + '沿';
} catch (_) {
return providerLabel + ' ';
}
}
return text;
}
function ensurePageAiDrawer() {
var existing = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
if (existing instanceof HTMLElement) return existing;
@@ -1212,7 +1744,7 @@ const SIDEBAR_TREE_JS: &str = r##"
'<div class="wolai-page-ai-header">' +
'<div class="wolai-page-ai-header-copy">' +
'<h2 class="wolai-page-ai-title" data-title="page-ai-title"></h2>' +
'<div class="wolai-page-ai-subtitle"> · mnote-cli</div>' +
'<div class="wolai-page-ai-subtitle"> · Hermes</div>' +
'</div>' +
'<button type="button" class="wolai-surface-close" data-page-ai-action="close" aria-label="关闭页面 AI">×</button>' +
'</div>' +
@@ -1230,7 +1762,11 @@ const SIDEBAR_TREE_JS: &str = r##"
'<div class="wolai-page-ai-toolbar">' +
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-action="new-session" aria-label="当前已是新会话"></button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-action="history"></button>' +
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-action="model">mnote-cli</button>' +
'<div class="wolai-page-ai-provider-group">' +
'<button type="button" class="wolai-page-ai-model-chip is-active" data-page-ai-provider="hermes" aria-pressed="true">Hermes</button>' +
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-provider="codex" aria-pressed="false">Codex</button>' +
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-provider="claudecode" aria-pressed="false">ClaudeCode</button>' +
'</div>' +
'</div>' +
'<div class="wolai-page-ai-input-row">' +
'<textarea class="wolai-page-ai-input" data-page-ai-input rows="3" placeholder="问我你想知道的"></textarea>' +
@@ -1258,6 +1794,24 @@ const SIDEBAR_TREE_JS: &str = r##"
var drawer = ensurePageAiDrawer();
var conversation = drawer.querySelector('[data-page-ai-conversation]');
if (!(conversation instanceof HTMLElement)) return;
if (pageUiState.pageAiPage === 'history') {
if (!pageUiState.pageAiSessions.length) {
conversation.innerHTML = '<div class="wolai-page-ai-empty"></div>';
return;
}
conversation.innerHTML = pageUiState.pageAiSessions.map(function(session) {
var preview = Array.isArray(session.messages) && session.messages.length
? session.messages.slice(-1)[0].content
: '';
var active = session.id === pageUiState.pageAiActiveSessionId;
return '' +
'<button type="button" class="wolai-page-ai-message wolai-page-ai-message--history' + (active ? ' is-active' : '') + '" data-page-ai-session="' + escapeHtml(session.id) + '">' +
'<div class="wolai-page-ai-message-role"></div>' +
'<div class="wolai-page-ai-message-text"><strong>' + escapeHtml(session.title || '') + '</strong><br />' + escapeHtml(preview) + '</div>' +
'</button>';
}).join('');
return;
}
if (!pageUiState.pageAiMessages.length) {
conversation.innerHTML = '<div class="wolai-page-ai-empty">使</div>';
return;
@@ -1273,8 +1827,10 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function openPageAiDrawer() {
pageAiLoadSessions();
renderPageAiSuggestions();
renderPageAiConversation();
renderPageAiProviderButtons();
var drawer = ensurePageAiDrawer();
drawer.hidden = false;
pageUiState.pageAiOpen = true;
@@ -1320,12 +1876,22 @@ const SIDEBAR_TREE_JS: &str = r##"
if (pageUiState.pageAiBusy) return;
var prompt = searchText(text);
if (!prompt) return;
pageAiLoadSessions();
var aggregate = currentPageAggregate();
var body = aggregate.body || {};
var subtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null;
var outline = subtree && subtree.outline ? subtree.outline : null;
pageUiState.pageAiBusy = true;
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
var currentSession = pageAiCurrentSession();
if (currentSession) {
if (currentSession.title === '') {
currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt;
}
currentSession.messages = pageUiState.pageAiMessages.slice();
currentSession.updatedAt = Date.now();
}
pageAiPersistSessions();
renderPageAiConversation();
try {
var response = await fetch('/api/ai-agent/run', {
@@ -1354,7 +1920,7 @@ const SIDEBAR_TREE_JS: &str = r##"
},
options: {
searxng: true,
ai: { provider: 'hermes' }
ai: { provider: pageUiState.pageAiProvider }
}
})
});
@@ -1372,13 +1938,25 @@ const SIDEBAR_TREE_JS: &str = r##"
});
pageUiState.pageAiMessages.push({
role: 'assistant',
content: assistantText || ' AI `mnote-cli`'
content: humanizePageAiResponse(assistantText, prompt)
});
currentSession = pageAiCurrentSession();
if (currentSession) {
currentSession.messages = pageUiState.pageAiMessages.slice();
currentSession.updatedAt = Date.now();
}
pageAiPersistSessions();
} catch (error) {
pageUiState.pageAiMessages.push({
role: 'assistant',
content: ' AI `mnote-cli` ' + (error instanceof Error ? error.message : String(error))
content: pageAiProviderLabel(pageUiState.pageAiProvider) + ' ' + (error instanceof Error ? error.message : String(error))
});
currentSession = pageAiCurrentSession();
if (currentSession) {
currentSession.messages = pageUiState.pageAiMessages.slice();
currentSession.updatedAt = Date.now();
}
pageAiPersistSessions();
} finally {
pageUiState.pageAiBusy = false;
renderPageAiConversation();
@@ -1404,7 +1982,6 @@ const SIDEBAR_TREE_JS: &str = r##"
createPageOptionRow('wideLayout', 'checkbox') +
createPageOptionRow('smallText', 'checkbox') +
createPageOptionRow('showToc', 'checkbox') +
createPageOptionRow('showHeadingNumbers', 'checkbox') +
createPageOptionRow('protectEditing', 'checkbox') +
'</div>' +
'<div class="wolai-page-settings-section" data-page-settings-panel="custom" hidden>' +
@@ -1415,7 +1992,7 @@ const SIDEBAR_TREE_JS: &str = r##"
createPageOptionRow('showBlockRefCount', 'checkbox') +
'</div>' +
'<div class="wolai-page-settings-section" data-page-settings-panel="global" hidden>' +
'<div class="wolai-page-settings-global-note"> Rust Web </div>' +
createGlobalHeadingNumbersRow() +
'</div>' +
'<div class="wolai-page-settings-actions">' +
'<button type="button" class="wolai-page-settings-action" data-page-settings-action="history">...</button>' +
@@ -1443,6 +2020,7 @@ const SIDEBAR_TREE_JS: &str = r##"
var value = key === 'layoutDensity' ? String(options.layoutDensity || 'normal') : String(options.pageFont || 'default');
select.value = value;
});
renderGlobalOptions(popover);
var statsNode = popover.querySelector('[data-testid="wolai-page-settings-stats"]');
if (statsNode instanceof HTMLElement) {
var stats = computeLivePageStats();
@@ -1479,6 +2057,7 @@ const SIDEBAR_TREE_JS: &str = r##"
body: JSON.stringify({
documentId: currentDocumentId(),
workspaceId: resolveWorkspaceId(document.body),
...currentWorkspaceSourcePayload(),
options: nextOptions,
commandName: 'page.layout.updateOptions'
})
@@ -1611,6 +2190,21 @@ const SIDEBAR_TREE_JS: &str = r##"
return;
}
var pageAiProvider = closestAction(e.target, '[data-page-ai-provider]');
if (pageAiProvider) {
e.preventDefault();
pageUiState.pageAiProvider = pageAiProvider.getAttribute('data-page-ai-provider') || 'hermes';
renderPageAiProviderButtons();
return;
}
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
if (pageAiSession) {
e.preventDefault();
pageAiSetActiveSession(pageAiSession.getAttribute('data-page-ai-session') || '');
return;
}
var pageAiSuggestion = closestAction(e.target, '[data-page-ai-suggestion]');
if (pageAiSuggestion) {
e.preventDefault();
@@ -1626,7 +2220,16 @@ const SIDEBAR_TREE_JS: &str = r##"
var pageAiNewSession = closestAction(e.target, '[data-page-ai-action="new-session"]');
if (pageAiNewSession) {
e.preventDefault();
pageUiState.pageAiMessages = [];
pageUiState.pageAiPage = 'chat';
pageAiStartNewSession();
return;
}
var pageAiHistory = closestAction(e.target, '[data-page-ai-action="history"]');
if (pageAiHistory) {
e.preventDefault();
pageAiLoadSessions();
pageUiState.pageAiPage = pageUiState.pageAiPage === 'history' ? 'chat' : 'history';
renderPageAiConversation();
return;
}
@@ -1662,6 +2265,13 @@ const SIDEBAR_TREE_JS: &str = r##"
return;
}
var localFolderTrigger = closestAction(e.target, '[data-mnote-action="open-local-folder"]');
if (localFolderTrigger) {
e.preventDefault();
requestOpenLocalFolder();
return;
}
var createTrigger = closestAction(e.target, '[data-mnote-action="create-page"]');
if (createTrigger) {
e.preventDefault();
@@ -1691,18 +2301,19 @@ const SIDEBAR_TREE_JS: &str = r##"
}
if (fileAction === 'menu') {
e.preventDefault();
selectSidebarFileTreeRow(fileRow, { ctrlKey: false, metaKey: false, shiftKey: false });
var point = rowCenter(fileBtn || fileRow);
dispatchSidebarEvent('tree.filetree.context-menu', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
openFileTreeContextMenu(fileRow, point.x, point.y, fileBtn || fileRow);
return;
}
e.preventDefault();
fileTree.querySelectorAll('.tree-row[data-selected="true"]').forEach(function(row) {
if (row instanceof HTMLElement) row.setAttribute('data-selected', 'false');
});
fileRow.setAttribute('data-selected', 'true');
selectSidebarFileTreeRow(fileRow, { ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey });
dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
if ((rowKind === 'document' || rowKind === 'index') && documentId) {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
return;
}
if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && documentId) {
navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree' });
} else if (assetId) {
dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId });
@@ -1755,6 +2366,10 @@ const SIDEBAR_TREE_JS: &str = r##"
var fileRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]');
if (fileRow) {
event.preventDefault();
var contextRowId = fileRow.getAttribute('data-row-id') || '';
if (contextRowId && !sidebarFileTreeSelection.selectedRowIds.has(contextRowId)) {
selectSidebarFileTreeRow(fileRow, { ctrlKey: false, metaKey: false, shiftKey: false });
}
openFileTreeContextMenu(fileRow, event.clientX, event.clientY, fileRow);
return;
}
@@ -1792,6 +2407,13 @@ const SIDEBAR_TREE_JS: &str = r##"
});
document.addEventListener('change', function(event) {
var globalCheckbox = closestAction(event.target, '[data-global-option-checkbox="showHeadingNumbers"]');
if (globalCheckbox instanceof HTMLInputElement) {
writeGlobalShowHeadingNumbers(globalCheckbox.checked);
applyPageOptionsToShell();
renderPageSettingsPopover();
return;
}
var checkbox = closestAction(event.target, '[data-page-option-checkbox]');
if (checkbox instanceof HTMLInputElement) {
var key = checkbox.getAttribute('data-page-option-checkbox') || '';
@@ -1880,8 +2502,7 @@ const SIDEBAR_TREE_JS: &str = r##"
}
var fileRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"][draggable="true"]');
if (fileRow) {
var rowId = fileRow.getAttribute('data-row-id') || '';
draggingFileTreeRowIds = rowId ? [rowId] : [];
draggingFileTreeRowIds = selectedSidebarFileTreeRowIdsForDrag(fileRow);
if (event.dataTransfer) {
var payload = JSON.stringify({ type: 'mnote-file-tree-dnd', version: 1, rowIds: draggingFileTreeRowIds });
event.dataTransfer.effectAllowed = 'copyMove';
@@ -2014,6 +2635,16 @@ const SIDEBAR_TREE_JS: &str = r##"
var activeRow = tree.querySelector('.tree-row[data-node-id="' + cssEscape(activeId) + '"]');
if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'true');
}
var initiallySelectedFileRows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-selected="true"]');
initiallySelectedFileRows.forEach(function(row) {
if (!(row instanceof HTMLElement)) return;
var rowId = row.getAttribute('data-row-id') || '';
if (!rowId) return;
sidebarFileTreeSelection.selectedRowIds.add(rowId);
if (!sidebarFileTreeSelection.anchorRowId) sidebarFileTreeSelection.anchorRowId = rowId;
sidebarFileTreeSelection.focusedRowId = rowId;
});
syncSidebarFileTreeSelection();
restoreSidebarTreeTab();
})();
"##;
@@ -2078,6 +2709,13 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##"
return;
}
var bootstrap = readBootstrap();
var params = new URLSearchParams(window.location.search);
var sourceKind = (params.get('sourceKind') || '').trim();
if (sourceKind === 'local_folder') {
applyTransport('local-folder-static');
applyStatus('static');
return;
}
applyTransport(bootstrap.transport || 'convex-command-log-sse');
var workspaceId = bootstrap.workspaceId || resolveWorkspaceId();
var url = new URL(bootstrap.endpoint || '/api/tree/events', window.location.origin);
@@ -2220,6 +2858,7 @@ pub fn PageLayout(
<a href="/actions" title="快捷动作" aria-label="快捷动作"><span class="material-symbols-outlined nav-icon" data-icon="bolt" aria-hidden="true"></span></a>
<a href="/help" title="帮助" aria-label="帮助"><span class="material-symbols-outlined nav-icon" data-icon="help" aria-hidden="true"></span></a>
<a href="/files" title="文件" aria-label="文件"><span class="material-symbols-outlined nav-icon" data-icon="inventory_2" aria-hidden="true"></span></a>
<button type="button" title="打开本地文件夹" aria-label="打开本地文件夹" data-mnote-action="open-local-folder"><span class="material-symbols-outlined nav-icon" data-icon="folder_open" aria-hidden="true"></span></button>
<a href="/more" title="更多" aria-label="更多"><span class="material-symbols-outlined nav-icon" data-icon="more_horiz" aria-hidden="true"></span></a>
</nav>
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
@@ -2269,6 +2908,8 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("mnoteNavigationInFlight"));
assert!(SIDEBAR_TREE_JS.contains("data-mnote-navigation-pending"));
assert!(SIDEBAR_TREE_JS.contains("MNOTE_SIDEBAR_TREE_MODE_KEY"));
assert!(SIDEBAR_TREE_JS.contains("MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY"));
assert!(SIDEBAR_TREE_JS.contains("data-global-option-checkbox=\"showHeadingNumbers\""));
assert!(SIDEBAR_TREE_JS.contains("restoreSidebarTreeTab"));
assert!(SIDEBAR_TREE_JS.contains("treeView"));
assert!(SIDEBAR_TREE_JS.contains("application/x-mnote-page-tree-node"));
@@ -2281,6 +2922,9 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("tree.asset.open"));
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop"));
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop"));
assert!(SIDEBAR_TREE_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY"));
assert!(SIDEBAR_TREE_JS.contains("requestOpenLocalFolder"));
assert!(SIDEBAR_TREE_JS.contains("sourceKind', 'local_folder"));
}
#[test]
+43 -9
View File
@@ -265,20 +265,25 @@ a:hover {
.wolai-quick-actions {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-columns: repeat(7, 1fr);
gap: 8px;
padding: 8px 12px 18px;
}
.wolai-quick-actions a {
.wolai-quick-actions a,
.wolai-quick-actions button {
height: 28px;
justify-content: center;
padding: 0;
margin: 0;
color: #4B4B4B;
border: 0;
background: transparent;
cursor: pointer;
}
.wolai-quick-actions a .nav-icon {
.wolai-quick-actions a .nav-icon,
.wolai-quick-actions button .nav-icon {
margin: 0;
}
@@ -495,7 +500,8 @@ a:hover {
flex-direction: column;
}
.mnote-sidebar-nav a {
.mnote-sidebar-nav a,
.mnote-sidebar-nav button {
display: flex;
align-items: center;
height: 30px;
@@ -509,7 +515,8 @@ a:hover {
transition: none;
}
.mnote-sidebar-nav a:hover {
.mnote-sidebar-nav a:hover,
.mnote-sidebar-nav button:hover {
background: var(--wolai-bg-hover);
}
@@ -517,7 +524,8 @@ a:hover {
background: var(--wolai-bg-active);
}
.mnote-sidebar-nav a .nav-icon {
.mnote-sidebar-nav a .nav-icon,
.mnote-sidebar-nav button .nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1268,23 +1276,26 @@ body {
}
.wolai-quick-actions {
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 10.4px;
padding: 5px 8px 11px;
}
.wolai-quick-actions a,
.wolai-quick-actions button,
.mnote-sidebar-nav a {
border-radius: 4px;
}
.wolai-quick-actions a {
.wolai-quick-actions a,
.wolai-quick-actions button {
height: 30px;
color: #3F3D39;
font-size: 14px;
}
.wolai-quick-actions a:hover {
.wolai-quick-actions a:hover,
.wolai-quick-actions button:hover {
background: rgba(27, 28, 28, 0.07);
}
@@ -2460,6 +2471,12 @@ body {
gap: 8px;
}
.wolai-page-ai-provider-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.wolai-page-ai-suggestion {
min-height: 32px;
padding: 8px 10px;
@@ -2490,6 +2507,18 @@ body {
background: #F7F7F7;
}
.wolai-page-ai-message--history {
width: 100%;
border: 1px solid rgba(27, 28, 28, 0.08);
text-align: left;
cursor: pointer;
}
.wolai-page-ai-message--history.is-active {
border-color: rgba(27, 28, 28, 0.28);
background: #F4F8FF;
}
.wolai-page-ai-message--user {
background: #F4F8FF;
}
@@ -2512,6 +2541,11 @@ body {
color: #FFF;
}
.wolai-page-ai-model-chip.is-active {
background: #1B1C1C;
color: #FFF;
}
.wolai-page-ai-input-row {
display: flex;
gap: 8px;
@@ -783,6 +783,7 @@ mod tests {
trace_id: "trace_1".into(),
actor_id: "actor_1".into(),
idempotency_key: None,
source: json!({}),
payload_json: "{}".into(),
args_json: json!({
"id": "doc_1",
@@ -22,6 +22,7 @@ pub struct FileTreeRuntimeRow {
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeRuntimeState {
pub active_row_id: Option<String>,
pub selection: FileTreeSelectionState,
pub drag_row_ids: Vec<String>,
pub drag_effect: Option<TreeShellDragEffect>,
@@ -70,6 +71,18 @@ pub enum FileTreeRuntimeAction {
OpenRow {
row_id: String,
},
FocusNext,
FocusPrevious,
FocusFirst,
FocusLast,
OpenFocused,
ContextMenuFocused,
BeginRenameFocused,
DeleteSelection,
CopySelection,
CutSelection,
PasteIntoFocused,
Escape,
ContextMenuRow {
row_id: String,
},
@@ -90,6 +103,11 @@ pub enum FileTreeIntentEvent {
row_id: String,
target: FileTreeOpenTarget,
},
KeyboardCommand {
command: FileTreeKeyboardCommand,
row_ids: Vec<String>,
target_row_id: Option<String>,
},
InternalDrop {
target_row_id: Option<String>,
target: Option<FileTreeOpenTarget>,
@@ -103,6 +121,16 @@ pub enum FileTreeIntentEvent {
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FileTreeKeyboardCommand {
Rename,
Delete,
Copy,
Cut,
Paste,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(
tag = "kind",
@@ -241,16 +269,86 @@ impl FileTreeRuntimeState {
}
FileTreeRuntimeAction::OpenRow { row_id } => {
if let Some(target) = resolve_open_target(env, &row_id) {
let mut state = self.clone();
state.active_row_id = Some(row_id);
transition(
self.clone(),
[FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open {
target,
})],
state,
[
FileTreeRuntimeOutput::DomPatch,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::Open { target }),
],
)
} else {
transition(self.clone(), [])
}
}
FileTreeRuntimeAction::FocusNext => transition(
focus_relative_row(self, env, 1),
[FileTreeRuntimeOutput::DomPatch],
),
FileTreeRuntimeAction::FocusPrevious => transition(
focus_relative_row(self, env, -1),
[FileTreeRuntimeOutput::DomPatch],
),
FileTreeRuntimeAction::FocusFirst => transition(
focus_absolute_row(self, env, 0),
[FileTreeRuntimeOutput::DomPatch],
),
FileTreeRuntimeAction::FocusLast => transition(
focus_absolute_row(self, env, env.visible_row_ids.len().saturating_sub(1)),
[FileTreeRuntimeOutput::DomPatch],
),
FileTreeRuntimeAction::OpenFocused => {
if let Some(row_id) = self.selection.focused_row_id.clone() {
self.reduce(env, FileTreeRuntimeAction::OpenRow { row_id })
} else {
transition(self.clone(), [])
}
}
FileTreeRuntimeAction::ContextMenuFocused => {
if let Some(row_id) = self.selection.focused_row_id.clone() {
self.reduce(env, FileTreeRuntimeAction::ContextMenuRow { row_id })
} else {
transition(self.clone(), [])
}
}
FileTreeRuntimeAction::BeginRenameFocused => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Rename,
selected_or_focused_rows(self),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::DeleteSelection => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Delete,
selected_or_focused_rows(self),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::CopySelection => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Copy,
selected_or_focused_rows(self),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::CutSelection => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Cut,
selected_or_focused_rows(self),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::PasteIntoFocused => keyboard_command_transition(
self,
FileTreeKeyboardCommand::Paste,
Vec::new(),
self.selection.focused_row_id.clone(),
),
FileTreeRuntimeAction::Escape => {
let mut state = self.clone();
state.drag_row_ids = Vec::new();
state.drag_effect = None;
state.drop_target_row_id = None;
transition(state, [FileTreeRuntimeOutput::DomPatch])
}
FileTreeRuntimeAction::ContextMenuRow { row_id } => {
if let Some(target) = resolve_open_target(env, &row_id) {
transition(
@@ -267,6 +365,75 @@ impl FileTreeRuntimeState {
}
}
fn focus_relative_row(
state: &FileTreeRuntimeState,
env: &FileTreeRuntimeEnvironment,
offset: isize,
) -> FileTreeRuntimeState {
if env.visible_row_ids.is_empty() {
return state.clone();
}
let current = state
.selection
.focused_row_id
.as_ref()
.and_then(|row_id| {
env.visible_row_ids
.iter()
.position(|candidate| candidate == row_id)
})
.unwrap_or(0);
let next = if offset.is_negative() {
current.saturating_sub(offset.unsigned_abs())
} else {
(current + offset as usize).min(env.visible_row_ids.len() - 1)
};
focus_absolute_row(state, env, next)
}
fn focus_absolute_row(
state: &FileTreeRuntimeState,
env: &FileTreeRuntimeEnvironment,
index: usize,
) -> FileTreeRuntimeState {
let mut next = state.clone();
next.selection.focused_row_id = env.visible_row_ids.get(index).cloned();
next
}
fn selected_or_focused_rows(state: &FileTreeRuntimeState) -> Vec<String> {
if !state.selection.selected_row_ids.is_empty() {
return state.selection.selected_row_ids.iter().cloned().collect();
}
state
.selection
.focused_row_id
.iter()
.cloned()
.collect::<Vec<_>>()
}
fn keyboard_command_transition(
state: &FileTreeRuntimeState,
command: FileTreeKeyboardCommand,
row_ids: Vec<String>,
target_row_id: Option<String>,
) -> FileTreeRuntimeTransition {
if row_ids.is_empty() && !matches!(command, FileTreeKeyboardCommand::Paste) {
return transition(state.clone(), []);
}
transition(
state.clone(),
[FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::KeyboardCommand {
command,
row_ids,
target_row_id,
},
)],
)
}
fn resolve_open_target(
env: &FileTreeRuntimeEnvironment,
row_id: &str,
@@ -309,8 +476,9 @@ fn transition<const N: usize>(
#[cfg(test)]
mod tests {
use super::{
FileTreeIntentEvent, FileTreeOpenTarget, FileTreeRuntimeAction, FileTreeRuntimeEnvironment,
FileTreeRuntimeOutput, FileTreeRuntimeRow, FileTreeRuntimeState,
FileTreeIntentEvent, FileTreeKeyboardCommand, FileTreeOpenTarget, FileTreeRuntimeAction,
FileTreeRuntimeEnvironment, FileTreeRuntimeOutput, FileTreeRuntimeRow,
FileTreeRuntimeState,
};
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
use crate::tree_shell::filetree_selection::FileTreeSelectionModifiers;
@@ -436,6 +604,7 @@ mod tests {
},
},
)));
assert_eq!(transition.state.active_row_id.as_deref(), Some("doc:root"));
let transition = transition.state.reduce(
&env(),
@@ -551,4 +720,70 @@ mod tests {
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop { .. })
)));
}
#[test]
fn filetree_runtime_keyboard_contract_separates_active_selection_and_focus() {
let state = FileTreeRuntimeState {
active_row_id: Some("doc:root".into()),
selection: crate::tree_shell::filetree_selection::FileTreeSelectionState::from_selected(
&["doc:root".to_string()],
),
..FileTreeRuntimeState::default()
};
let focused = state.reduce(&env(), FileTreeRuntimeAction::FocusNext);
assert_eq!(focused.state.active_row_id.as_deref(), Some("doc:root"));
assert_eq!(
focused.state.selection.focused_row_id.as_deref(),
Some("index:root")
);
assert!(focused
.state
.selection
.selected_row_ids
.contains("doc:root"));
let opened = focused
.state
.reduce(&env(), FileTreeRuntimeAction::OpenFocused);
assert_eq!(opened.state.active_row_id.as_deref(), Some("index:root"));
assert!(opened.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::Index {
document_id: "root".into(),
},
},
)));
let rename = focused
.state
.reduce(&env(), FileTreeRuntimeAction::BeginRenameFocused);
assert!(rename.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::KeyboardCommand {
command: FileTreeKeyboardCommand::Rename,
row_ids: vec!["doc:root".into()],
target_row_id: Some("index:root".into()),
},
)));
let cut = focused
.state
.reduce(&env(), FileTreeRuntimeAction::CutSelection);
assert!(cut.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::KeyboardCommand {
command: FileTreeKeyboardCommand::Cut,
row_ids: vec!["doc:root".into()],
target_row_id: Some("index:root".into()),
},
)));
let escaped = FileTreeRuntimeState {
drag_row_ids: vec!["doc:root".into()],
drag_effect: Some(TreeShellDragEffect::Move),
drop_target_row_id: Some("index:root".into()),
..focused.state
}
.reduce(&env(), FileTreeRuntimeAction::Escape);
assert!(escaped.state.drag_row_ids.is_empty());
assert_eq!(escaped.state.drag_effect, None);
assert_eq!(escaped.state.drop_target_row_id, None);
}
}
@@ -105,6 +105,7 @@ impl Default for TreeShellRuntimeArtifactBoundary {
"fileTreeContextMenu",
"fileTreeInternalDrop",
"fileTreeExternalDrop",
"fileTreeKeyboardCommand",
"pickerPickRoot",
"pickerPickDocument",
]),
@@ -1,7 +1,7 @@
use super::drag_drop_state::TreeShellDragEffect;
use super::filetree_runtime::{
FileTreeIntentEvent, FileTreeRuntimeAction, FileTreeRuntimeEnvironment, FileTreeRuntimeOutput,
FileTreeRuntimeState,
FileTreeIntentEvent, FileTreeKeyboardCommand, FileTreeRuntimeAction,
FileTreeRuntimeEnvironment, FileTreeRuntimeOutput, FileTreeRuntimeState,
};
use super::page_runtime::{
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition, PageTreeIntentEvent,
@@ -81,6 +81,7 @@ pub enum TreeShellDomPatch {
drop_feedback: Option<PageTreeDropFeedback>,
},
FileTreeState {
active_row_id: Option<String>,
selected_row_ids: BTreeSet<String>,
anchor_row_id: Option<String>,
focused_row_id: Option<String>,
@@ -125,6 +126,11 @@ pub enum TreeShellHostEvent {
target: Option<super::filetree_runtime::FileTreeOpenTarget>,
file_count: u32,
},
FileTreeKeyboardCommand {
command: FileTreeKeyboardCommand,
row_ids: Vec<String>,
target_row_id: Option<String>,
},
PickerPickRoot,
PickerPickDocument {
document_id: String,
@@ -279,6 +285,7 @@ pub fn reduce_tree_shell_runtime(request: TreeShellRuntimeRequest) -> TreeShellR
.iter()
.filter_map(|output| match output {
FileTreeRuntimeOutput::DomPatch => Some(TreeShellDomPatch::FileTreeState {
active_row_id: transition.state.active_row_id.clone(),
selected_row_ids: transition.state.selection.selected_row_ids.clone(),
anchor_row_id: transition.state.selection.anchor_row_id.clone(),
focused_row_id: transition.state.selection.focused_row_id.clone(),
@@ -325,6 +332,15 @@ pub fn reduce_tree_shell_runtime(request: TreeShellRuntimeRequest) -> TreeShellR
target: target.clone(),
file_count: *file_count,
}),
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::KeyboardCommand {
command,
row_ids,
target_row_id,
}) => Some(TreeShellHostEvent::FileTreeKeyboardCommand {
command: *command,
row_ids: row_ids.clone(),
target_row_id: target_row_id.clone(),
}),
_ => None,
})
.collect();
@@ -596,6 +612,7 @@ mod tests {
assert_eq!(
filetree.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
active_row_id: None,
selected_row_ids: set(&["asset:image"]),
anchor_row_id: Some("asset:image".into()),
focused_row_id: Some("asset:image".into()),
@@ -638,6 +655,7 @@ mod tests {
assert_eq!(
drag_result.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
active_row_id: None,
selected_row_ids: set(&["asset:image"]),
anchor_row_id: Some("asset:image".into()),
focused_row_id: Some("asset:image".into()),
@@ -675,6 +693,7 @@ mod tests {
assert_eq!(
drop_target_result.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
active_row_id: None,
selected_row_ids: BTreeSet::new(),
anchor_row_id: None,
focused_row_id: None,
@@ -730,6 +749,7 @@ mod tests {
assert_eq!(
internal_drop.dom_patches,
vec![TreeShellDomPatch::FileTreeState {
active_row_id: None,
selected_row_ids: BTreeSet::new(),
anchor_row_id: None,
focused_row_id: None,
@@ -0,0 +1,824 @@
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function fileUrl(localPath) {
return `file://${localPath}`;
}
function treeUrl(root, mode) {
const url = new URL(`${BASE_URL}/tree`);
url.searchParams.set("mode", mode);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
return url.toString();
}
function documentUrl(root, documentId) {
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", fileUrl(root));
return url.toString();
}
function convexTreeUrl(mode = "filetree") {
const url = new URL(`${BASE_URL}/tree`);
url.searchParams.set("mode", mode);
url.searchParams.set("workspaceId", "ws_demo");
url.searchParams.set("sourceKind", "convex_workspace");
return url.toString();
}
async function waitForText(page, text) {
await page.waitForFunction(
(expectedText) => Array.from(document.querySelectorAll("body *")).some((element) => {
const textContent = element.textContent ? element.textContent.trim() : "";
if (textContent !== expectedText) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
}),
text,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForContextMenu(page) {
const menu = page.locator('[data-testid="filetree-context-menu"]').first();
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return menu;
}
async function clickMenuAction(page, action) {
await page.locator(`[data-testid="filetree-context-menu"] [data-menu-action="${action}"]`).click({ timeout: UI_TIMEOUT_MS });
}
async function expectMenuAction(page, action, options = {}) {
const item = page.locator(`[data-testid="filetree-context-menu"] [data-menu-action="${action}"]`).first();
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const disabled = await item.isDisabled();
if (options.disabled !== undefined) {
assert(disabled === options.disabled, `${action} 菜单项 disabled=${disabled} 不符合预期`);
}
if (options.reasonIncludes) {
const reason = await item.getAttribute("data-disabled-reason");
assert((reason || "").includes(options.reasonIncludes), `${action} 菜单项应展示禁用原因`);
}
}
async function closeContextMenu(page) {
await page.keyboard.press("Escape");
try {
await page.locator('[data-testid="filetree-context-menu"]').waitFor({ state: "detached", timeout: 1_000 });
} catch {
await page.evaluate(() => {
document.querySelectorAll('[data-testid="filetree-context-menu"]').forEach((element) => element.remove());
});
await page.locator('[data-testid="filetree-context-menu"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
}
}
async function dispatchRootContextMenu(page) {
await page.evaluate(() => {
const root = document.querySelector(".tree-root");
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
root.dispatchEvent(new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: 24,
clientY: 64,
}));
});
}
async function waitForPreflight(page, titleText) {
const preflight = page.locator('[data-testid="tree-preflight"]').first();
await preflight.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
if (titleText) {
await preflight.getByText(titleText, { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
return preflight;
}
async function cancelPreflight(page) {
await page.locator('[data-testid="tree-preflight"] [data-role="cancel"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="tree-preflight"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
}
async function confirmPreflight(page) {
await page.locator('[data-testid="tree-preflight"] [data-role="confirm"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="tree-preflight"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
}
async function dispatchInternalDrop(page, sourceRowSelector, targetRowSelector, options = {}) {
await page.evaluate(
({ sourceRowSelector, targetRowSelector, altKey }) => {
const source = document.querySelector(sourceRowSelector);
const target = document.querySelector(targetRowSelector);
if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) {
throw new Error("拖拽目标不存在");
}
const dataTransfer = new DataTransfer();
source.dispatchEvent(new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer }));
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer, altKey }));
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer, altKey }));
source.dispatchEvent(new DragEvent("dragend", { bubbles: true, cancelable: true, dataTransfer }));
},
{ sourceRowSelector, targetRowSelector, altKey: Boolean(options.altKey) },
);
}
async function dispatchInternalDragOver(page, sourceRowSelector, targetRowSelector, ratio) {
return await page.evaluate(
({ sourceRowSelector, targetRowSelector, ratio }) => {
const source = document.querySelector(sourceRowSelector);
const target = document.querySelector(targetRowSelector);
if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) {
throw new Error("拖拽目标不存在");
}
const rect = target.getBoundingClientRect();
const dataTransfer = new DataTransfer();
source.dispatchEvent(new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer }));
target.dispatchEvent(new DragEvent("dragover", {
bubbles: true,
cancelable: true,
dataTransfer,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height * ratio,
}));
return {
dropTarget: target.dataset.dropTarget || "",
dropPosition: target.dataset.dropPosition || "",
};
},
{ sourceRowSelector, targetRowSelector, ratio },
);
}
async function dispatchExternalFileDrop(page, targetRowSelector, fileName, text) {
await page.evaluate(
({ targetRowSelector, fileName, text }) => {
const target = document.querySelector(targetRowSelector);
if (!(target instanceof HTMLElement)) {
throw new Error("外部拖入目标不存在");
}
const dataTransfer = new DataTransfer();
dataTransfer.items.add(new File([text], fileName, { type: "text/plain" }));
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
},
{ targetRowSelector, fileName, text },
);
}
async function waitForHostMessage(page, type, predicate = () => true) {
return await page.waitForFunction(
({ type }) => {
const messages = Array.isArray(window.__mnoteHostMessages) ? window.__mnoteHostMessages : [];
return messages.find((message) => message && message.type === type) || null;
},
{ type },
{ timeout: UI_TIMEOUT_MS },
).then(async (handle) => {
const message = await handle.evaluate((value) => {
const files = Array.from(value?.files || []).map((file) => ({
name: file?.name || "",
size: Number.isFinite(file?.size) ? file.size : 0,
type: file?.type || "",
}));
return {
...value,
files,
};
});
assert(predicate(message), `${type} host message 不符合预期: ${JSON.stringify(message)}`);
return message;
});
}
async function dispatchForeignInternalDrop(page, targetRowSelector) {
await page.evaluate(
({ targetRowSelector }) => {
const target = document.querySelector(targetRowSelector);
if (!(target instanceof HTMLElement)) {
throw new Error("跨 workspace 拖拽目标不存在");
}
const dataTransfer = new DataTransfer();
const payload = JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds: ["foreign:workspace-row"] });
dataTransfer.setData("application/x-mnote-filetree-row-ids", payload);
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
},
{ targetRowSelector },
);
}
async function run() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-folder-smoke-"));
fs.mkdirSync(path.join(root, "docs"));
fs.mkdirSync(path.join(root, "docs", "nested"));
fs.mkdirSync(path.join(root, "readonly-dir"));
fs.mkdirSync(path.join(root, ".git"));
fs.mkdirSync(path.join(root, "node_modules"));
fs.mkdirSync(path.join(root, ".mnote"));
fs.writeFileSync(path.join(root, "README.md"), "---\ntitle: Frontmatter Title\n---\n# Ignored\n正文\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "child.md"), "# Child H1\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "nested", "deep.md"), "# Deep Page\n", "utf8");
fs.writeFileSync(
path.join(root, "docs", "blocks.md"),
"---\ntitle: Complex Title\n---\n# Complex H1\n\nparagraph\n\n- Bullet item\n1. Numbered item\n> Quote item\n```js\nconsole.log('hi')\n```\n---\n[Spec](assets/spec.pdf)\n",
"utf8",
);
fs.writeFileSync(path.join(root, "image.png"), "png", "utf8");
fs.writeFileSync(path.join(root, ".git", "hidden.md"), "# hidden\n", "utf8");
fs.writeFileSync(path.join(root, "node_modules", "hidden.md"), "# hidden\n", "utf8");
fs.chmodSync(path.join(root, "readonly-dir"), 0o555);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1280, height: 860 },
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const requests = [];
page.on("request", (request) => {
if (request.url().includes("/api/tree/commands")) {
requests.push({
url: request.url(),
method: request.method(),
body: request.postData() || "",
});
}
});
try {
await page.goto(documentUrl(root, "local-md:README.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.evaluate(() => window.localStorage.removeItem("mnote.global.showHeadingNumbers"));
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "Frontmatter Title");
const dialogs = [];
page.on("dialog", async (dialog) => {
dialogs.push(dialog.type());
await dialog.dismiss().catch(() => {});
});
await page.locator('[data-mnote-action="open-local-folder"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(dialogs.length === 0, "打开本地文件夹入口不能再依赖 prompt/alert/confirm 原生弹窗");
await page.locator('[data-testid="mnote-local-folder-path-input"]').fill(root);
await page.locator('[data-testid="mnote-local-folder-open-confirm"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL(
(url) =>
url.pathname === "/" &&
url.searchParams.get("treeView") === "filetree" &&
url.searchParams.get("sourceKind") === "local_folder" &&
url.searchParams.get("rootUri") === fileUrl(root),
{ timeout: UI_TIMEOUT_MS },
);
await waitForText(page, "README.md");
const recentLocalRoots = await page.evaluate(() => {
const raw = window.localStorage.getItem("mnote.localFolder.recentRoots") || "[]";
return JSON.parse(raw);
});
assert(recentLocalRoots[0] === fileUrl(root), "打开本地文件夹入口应记录最近 rootUri");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "README.md");
await waitForText(page, "docs");
await waitForText(page, "image.png");
assert(!(await page.getByText(".git", { exact: true }).isVisible().catch(() => false)), ".git 不应出现在 Explorer");
assert(!(await page.getByText("node_modules", { exact: true }).isVisible().catch(() => false)), "node_modules 不应出现在 Explorer");
assert(await page.locator('[data-row-kind="folder"][data-row-id="local:folder:docs"]').count() === 1, "目录 rowKind 应保持 folder");
assert(await page.locator('[data-row-kind="markdown"][data-row-id="local:markdown:README.md"]').count() === 1, "Markdown rowKind 应保持 markdown");
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "Frontmatter Title");
await waitForText(page, "Child H1");
assert(!(await page.getByText("image.png", { exact: true }).isVisible().catch(() => false)), "page_tree 不应显示普通文件");
await page.goto(documentUrl(root, "local-md:README.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "Frontmatter Title");
await waitForText(page, "正文");
const defaultHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) =>
window.getComputedStyle(node, "::before").content
);
assert(defaultHeadingBefore === "none", `本地 Markdown 默认不应开启标题自动编号,实际 ::before=${defaultHeadingBefore}`);
await page.evaluate(async ({ rootUri }) => {
const common = {
documentId: "local-md:README.md",
sourceKind: "local_folder",
rootUri,
};
const titleResponse = await fetch("/api/documents/title", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...common, title: "Browser Saved Title" }),
});
if (!titleResponse.ok) throw new Error(`title_failed_${titleResponse.status}`);
const saveResponse = await fetch("/api/documents/save", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...common,
content: [
{
id: "h1",
type: "heading",
props: { level: 1 },
content: [{ type: "text", text: "Browser Saved Heading" }],
},
{
id: "p1",
type: "paragraph",
content: [{ type: "text", text: "Browser saved body" }],
},
],
blockCount: 2,
}),
});
if (!saveResponse.ok) throw new Error(`save_failed_${saveResponse.status}`);
const optionsResponse = await fetch("/api/documents/options", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...common, options: { wideLayout: true, showToc: false, showHeadingNumbers: true } }),
});
if (!optionsResponse.ok) throw new Error(`options_failed_${optionsResponse.status}`);
}, { rootUri: fileUrl(root) });
assert(fs.readFileSync(path.join(root, "README.md"), "utf8").includes("Browser saved body"), "浏览器保存应写回 Markdown 正文");
assert(fs.existsSync(path.join(root, ".mnote", "page-options.json")), "浏览器页面设置保存应写入 .mnote/page-options.json");
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "Browser Saved Title");
await waitForText(page, "Browser saved body");
const globallyDisabledHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) =>
window.getComputedStyle(node, "::before").content
);
assert(globallyDisabledHeadingBefore === "none", "全局关闭时,即使页面设置显式开启 showHeadingNumbers,也不应显示标题自动编号");
await page.evaluate(() => window.localStorage.setItem("mnote.global.showHeadingNumbers", "true"));
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "Browser Saved Title");
const enabledHeadingBefore = await page.locator("#mnote-leptos-tiptap-island-editor-root .ProseMirror h1").first().evaluate((node) =>
window.getComputedStyle(node, "::before").content
);
assert(enabledHeadingBefore !== "none", "全局开启后应显示标题自动编号");
await page.goto(documentUrl(root, "local-md:docs~2Fblocks.md"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "Complex Title");
await waitForText(page, "Bullet item");
await waitForText(page, "Numbered item");
await waitForText(page, "Quote item");
await waitForText(page, "console.log('hi')");
await waitForText(page, "Spec");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const readmeRow = '.tree-row[data-row-id="local:markdown:README.md"]';
const docsRow = '.tree-row[data-row-id="local:folder:docs"]';
const nestedRow = '.tree-row[data-row-id="local:folder:docs/nested"]';
const readonlyRow = '.tree-row[data-row-id="local:folder:readonly-dir"]';
let imageRow = '.tree-row[data-row-id="local:asset:image.png"]';
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await dispatchRootContextMenu(page);
await waitForContextMenu(page);
await expectMenuAction(page, "newPage", { disabled: false });
await expectMenuAction(page, "newFolder", { disabled: false });
await expectMenuAction(page, "upload", { disabled: true });
await expectMenuAction(page, "refresh", { disabled: false });
await closeContextMenu(page);
await page.locator(readmeRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
await waitForContextMenu(page);
await expectMenuAction(page, "open", { disabled: false });
await expectMenuAction(page, "rename", { disabled: false });
await expectMenuAction(page, "copy", { disabled: false });
await expectMenuAction(page, "cut", { disabled: false });
await expectMenuAction(page, "paste", { disabled: true });
await expectMenuAction(page, "delete", { disabled: false });
await closeContextMenu(page);
await page.locator(docsRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
await waitForContextMenu(page);
await expectMenuAction(page, "newPage", { disabled: false });
await expectMenuAction(page, "newFolder", { disabled: false });
await expectMenuAction(page, "paste", { disabled: true });
await expectMenuAction(page, "rename", { disabled: false });
await expectMenuAction(page, "delete", { disabled: true, reasonIncludes: "目录删除" });
await closeContextMenu(page);
await page.locator(imageRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
await waitForContextMenu(page);
await expectMenuAction(page, "open", { disabled: false });
await expectMenuAction(page, "rename", { disabled: false });
await expectMenuAction(page, "copy", { disabled: true });
await expectMenuAction(page, "cut", { disabled: true });
await expectMenuAction(page, "delete", { disabled: true });
await expectMenuAction(page, "download", { disabled: true });
await clickMenuAction(page, "rename");
let assetRenameInput = page.locator(".tree-rename-input").first();
await assetRenameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await assetRenameInput.fill("renamed-image");
await page.keyboard.press("Enter");
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "renamed-image.png")), "asset rename 应通过同一 inline rename 写入本地文件系统");
imageRow = '.tree-row[data-row-id="local:asset:renamed-image.png"]';
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await dispatchRootContextMenu(page);
await waitForContextMenu(page);
await clickMenuAction(page, "newFolder");
let createRenameInput = page.locator(".tree-rename-input").first();
await createRenameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await createRenameInput.fill("新建资料");
await page.keyboard.press("Enter");
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "新建资料")), "根菜单新建文件夹后应进入 inline rename 并写入本地目录");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
await waitForContextMenu(page);
await clickMenuAction(page, "newPage");
createRenameInput = page.locator(".tree-rename-input").first();
await createRenameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await createRenameInput.fill("菜单新页面");
await page.keyboard.press("Enter");
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "文件夹菜单新建页面后应进入 inline rename 并写入本地 Markdown");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const createdPageRow = '.tree-row[data-row-id="local:markdown:docs/菜单新页面.md"]';
await page.locator(createdPageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(createdPageRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Delete");
await waitForPreflight(page, "删除预检");
await confirmPreflight(page);
await page.waitForTimeout(800);
assert(!fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "确认 Delete preflight 后本地 Markdown 原路径应消失");
assert(fs.existsSync(path.join(root, ".mnote", "trash", "菜单新页面.md")), "确认 Delete preflight 后本地 Markdown 应进入 .mnote/trash");
const trashIndex = JSON.parse(fs.readFileSync(path.join(root, ".mnote", "trash-index.json"), "utf8"));
const createdTrashEntry = Object.values(trashIndex.entries || {}).find((entry) => entry.originalRelativePath === "docs/菜单新页面.md");
assert(createdTrashEntry && createdTrashEntry.documentId, "trash-index 应记录被删除页面的 documentId");
await page.evaluate(async ({ rootUri, documentId }) => {
const response = await fetch("/api/tree/commands", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "restore",
sourceKind: "local_folder",
rootUri,
documentId,
}),
});
if (!response.ok) throw new Error(`restore_failed_${response.status}`);
}, { rootUri: fileUrl(root), documentId: createdTrashEntry.documentId });
await page.waitForTimeout(300);
assert(fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "local restore 应从 .mnote/trash 恢复 Markdown");
await page.evaluate(async ({ rootUri, documentId }) => {
const response = await fetch("/api/tree/commands", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "purge",
sourceKind: "local_folder",
rootUri,
documentId,
}),
});
if (!response.ok) throw new Error(`purge_failed_${response.status}`);
}, { rootUri: fileUrl(root), documentId: createdTrashEntry.documentId });
await page.waitForTimeout(300);
assert(!fs.existsSync(path.join(root, "docs", "菜单新页面.md")), "local purge 应永久删除已恢复的 Markdown");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert((await dispatchInternalDragOver(page, readmeRow, docsRow, 0.05)).dropPosition === "before", "内部拖拽应显示 before indicator");
assert((await dispatchInternalDragOver(page, readmeRow, docsRow, 0.5)).dropPosition === "inside", "内部拖拽应显示 inside indicator");
assert((await dispatchInternalDragOver(page, readmeRow, docsRow, 0.95)).dropPosition === "after", "内部拖拽应显示 after indicator");
await dispatchInternalDragOver(page, readmeRow, nestedRow, 0.5);
await page.locator('.tree-row[data-row-id="local:markdown:docs/nested/deep.md"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const illegalDropRequestCount = requests.length;
await dispatchInternalDrop(page, readmeRow, readmeRow);
await dispatchInternalDrop(page, docsRow, nestedRow);
await dispatchForeignInternalDrop(page, docsRow);
await page.waitForTimeout(500);
assert(requests.length === illegalDropRequestCount, "自拖自身、拖到后代、跨 workspace 不应发 execute 请求");
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.locator(docsRow).click({ timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).click({ modifiers: [modifier], timeout: UI_TIMEOUT_MS });
const redundantMoveRequestCount = requests.length;
await dispatchInternalDrop(page, docsRow, readmeRow);
await waitForPreflight(page, "移动预检");
await confirmPreflight(page);
await page.waitForTimeout(800);
const redundantMoveRequests = requests.slice(redundantMoveRequestCount).filter((request) => request.body.includes('"action":"move"'));
assert(redundantMoveRequests.length === 1, "多选父子节点 move 时应过滤子节点重复执行");
assert(redundantMoveRequests[0].body.includes("local:node:docs"), "多选父子节点 move 应保留父节点作为唯一执行项");
assert(!redundantMoveRequests[0].body.includes("local:node:docs/nested"), "多选父子节点 move 不应重复执行子节点");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(readmeRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
await page.locator(docsRow).click({ modifiers: [modifier], timeout: UI_TIMEOUT_MS });
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 1, "Ctrl/Cmd+Click 后原选区应保留");
assert(await page.locator(`${docsRow}[data-selected="true"]`).count() === 1, "Ctrl/Cmd+Click 后目标行应加入选区");
await page.locator(docsRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 1, "已选区内右键不应清空多选");
await waitForContextMenu(page);
await expectMenuAction(page, "copy", { disabled: false });
await expectMenuAction(page, "cut", { disabled: false });
await expectMenuAction(page, "delete", { disabled: false });
await expectMenuAction(page, "moveTo", { disabled: true });
await closeContextMenu(page);
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
await page.locator(nestedRow).click({ modifiers: ["Shift"], timeout: UI_TIMEOUT_MS });
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() >= 2, "Shift+Click 应形成范围选区");
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
await page.locator(imageRow).click({ button: "right", timeout: UI_TIMEOUT_MS });
assert(await page.locator(`${imageRow}[data-selected="true"]`).count() === 1, "未选项右键应切换到该行");
assert(await page.locator(`${readmeRow}[data-selected="true"]`).count() === 0, "未选项右键应清理旧选区");
await closeContextMenu(page);
await page.evaluate(() => {
const root = document.querySelector(".tree-root");
if (!(root instanceof HTMLElement)) throw new Error("filetree root 不存在");
root.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
});
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() === 0, "空白区点击应清空 selection/focus");
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A");
const visibleFileTreeRows = await page.locator('.tree-row[data-shell-mode="filetree"]').count();
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() === visibleFileTreeRows, "Ctrl/Cmd+A 应选择全部可见 filetree 行");
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
const deletePreflightRequestCount = requests.length;
await page.keyboard.press("Delete");
const deletePreflight = await waitForPreflight(page, "删除预检");
assert(await deletePreflight.getByText("影响:", { exact: false }).isVisible(), "Delete preflight 应说明影响集合");
await cancelPreflight(page);
await page.waitForTimeout(300);
assert(requests.length === deletePreflightRequestCount, "取消 Delete preflight 后不应发 execute 请求");
assert(fs.existsSync(path.join(root, "README.md")), "取消 Delete preflight 后文件不应被移动到 trash");
await page.locator(readmeRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("F2");
const renameInput = page.locator(".tree-rename-input").first();
await renameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await renameInput.fill("README Renamed");
await page.keyboard.press("Enter");
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "README Renamed.md")), "F2 inline rename 应真实重命名 Markdown 文件");
const renamedRow = '.tree-row[data-row-id="local:markdown:README Renamed.md"]';
await page.locator(renamedRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(renamedRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+X" : "Control+X");
assert(await page.locator(`${renamedRow}[data-cut="true"]`).count() === 1, "Ctrl/Cmd+X 后应出现 cut decoration");
await page.locator(docsRow).click({ timeout: UI_TIMEOUT_MS });
const pasteCancelRequestCount = requests.length;
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
const pastePreflight = await waitForPreflight(page, "粘贴移动预检");
assert(await pastePreflight.getByText("命名冲突策略", { exact: false }).isVisible(), "Paste preflight 应展示命名冲突策略");
await cancelPreflight(page);
await page.waitForTimeout(300);
assert(requests.length === pasteCancelRequestCount, "取消 Paste preflight 后不应发 execute 请求");
assert(fs.existsSync(path.join(root, "README Renamed.md")), "取消 Paste preflight 后文件应留在原目录");
await page.locator(docsRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
await waitForPreflight(page, "粘贴移动预检");
await confirmPreflight(page);
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "docs", "README Renamed.md")), "剪切粘贴应真实移动 Markdown 文件");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await dispatchInternalDrop(page, '.tree-row[data-row-id="local:markdown:docs/README Renamed.md"]', '.tree-row[data-row-id="local:folder:docs"]', { altKey: true });
const copyPreflight = await waitForPreflight(page, "复制预检");
assert(await copyPreflight.getByText("命名冲突策略", { exact: false }).isVisible(), "内部复制 drop preflight 应展示命名冲突策略");
await confirmPreflight(page);
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "docs", "README Renamed 2.md")), "Alt 内部拖拽应真实复制 Markdown 文件");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const externalCancelRequestCount = requests.length;
await dispatchExternalFileDrop(page, '.tree-row[data-row-id="local:folder:docs"]', "drop-cancel.txt", "取消外部拖入");
await waitForPreflight(page, "外部拖入预检");
await cancelPreflight(page);
await page.waitForTimeout(300);
assert(requests.length === externalCancelRequestCount, "取消外部 File drop preflight 后不应发 execute 请求");
assert(!fs.existsSync(path.join(root, "docs", "drop-cancel.txt")), "取消外部 File drop preflight 后不应写入文件");
await dispatchExternalFileDrop(page, '.tree-row[data-row-id="local:folder:docs"]', "drop-real.txt", "真实外部拖入");
const externalPreflight = await waitForPreflight(page, "外部拖入预检");
assert(await externalPreflight.getByText("drop-real.txt", { exact: false }).isVisible(), "外部 drop preflight 应展示文件名");
await confirmPreflight(page);
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "docs", "drop-real.txt")), "外部 File drop 应真实写入本地目录");
await dispatchExternalFileDrop(page, '.tree-row[data-row-id="local:markdown:docs/README Renamed.md"]', "drop-on-md.txt", "拖到 Markdown 行");
const externalMdPreflight = await waitForPreflight(page, "外部拖入预检");
assert(await externalMdPreflight.getByText("README Renamed.md 的父目录", { exact: false }).isVisible(), "drop 到 md 文件时 preflight 应说明目标归属为父目录");
await confirmPreflight(page);
await page.waitForTimeout(800);
assert(fs.existsSync(path.join(root, "docs", "drop-on-md.txt")), "drop 到 md 文件时应写入该 md 的父目录");
const readonlyDropRequestCount = requests.length;
await dispatchExternalFileDrop(page, readonlyRow, "readonly-drop.txt", "readonly");
await waitForPreflight(page, "操作预检失败");
assert(await page.getByText("只读", { exact: false }).first().isVisible(), "readonly 目标应展示统一预检失败原因");
await confirmPreflight(page);
await page.waitForTimeout(300);
assert(requests.length === readonlyDropRequestCount, "readonly 目标预检失败后不应发 execute 请求");
assert(!fs.existsSync(path.join(root, "readonly-dir", "readonly-drop.txt")), "readonly 目标预检失败后不应写入文件");
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
fs.writeFileSync(path.join(root, "docs", "watcher-added.md"), "# Watcher Added\n", "utf8");
await waitForText(page, "Watcher Added");
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
fs.writeFileSync(path.join(root, "docs", "watcher-asset.txt"), "watcher asset", "utf8");
await waitForText(page, "watcher-asset.txt");
fs.rmSync(path.join(root, "docs", "watcher-asset.txt"));
await page.getByText("watcher-asset.txt", { exact: true }).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS });
await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForText(page, "工作区首页");
await waitForText(page, "封面.png");
assert(!(await page.getByText("Watcher Added", { exact: true }).isVisible().catch(() => false)), "切回 Convex 后不应显示旧 local watcher 内容");
assert(await page.locator('.tree-row[data-shell-mode="filetree"][data-selected="true"]').count() === 0, "切回 Convex 后旧 local selection 不应污染新 source");
await dispatchRootContextMenu(page);
await waitForContextMenu(page);
await expectMenuAction(page, "newPage", { disabled: false });
await expectMenuAction(page, "newFolder", { disabled: true, reasonIncludes: "Convex workspace" });
await closeContextMenu(page);
await page.getByText("工作区首页", { exact: true }).click({ button: "right", timeout: UI_TIMEOUT_MS });
await waitForContextMenu(page);
await expectMenuAction(page, "open", { disabled: false });
await expectMenuAction(page, "rename", { disabled: false });
await expectMenuAction(page, "copy", { disabled: false });
await expectMenuAction(page, "paste", { disabled: true });
await closeContextMenu(page);
const convexRootRow = '.tree-row[data-row-id="doc:page_root"]';
const convexChildRow = '.tree-row[data-row-id="doc:page_child"]';
const convexAssetFolderRow = '.tree-row[data-row-id="asset-folder:mind_1"]';
await page.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(convexChildRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(convexAssetFolderRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert((await dispatchInternalDragOver(page, convexChildRow, convexRootRow, 0.05)).dropPosition === "before", "Convex document 拖拽应显示 before indicator");
assert((await dispatchInternalDragOver(page, convexChildRow, convexRootRow, 0.5)).dropPosition === "inside", "Convex document 拖拽应显示 inside indicator");
assert((await dispatchInternalDragOver(page, convexChildRow, convexRootRow, 0.95)).dropPosition === "after", "Convex document 拖拽应显示 after indicator");
assert((await dispatchInternalDragOver(page, convexChildRow, convexAssetFolderRow, 0.5)).dropPosition === "inside", "Convex asset-folder 拖拽应显示 inside indicator");
await page.locator(convexChildRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+C" : "Control+C");
await page.locator(convexRootRow).click({ timeout: UI_TIMEOUT_MS });
const convexCopyRequestCount = requests.length;
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
await waitForPreflight(page, "粘贴复制预检");
await confirmPreflight(page);
await page.waitForTimeout(800);
const convexCopyRequests = requests.slice(convexCopyRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
assert(convexCopyRequests.some((request) => request.body.includes('"action":"copy"')), "Convex copy paste 应通过同一 command endpoint 发送 copy action");
await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(convexChildRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(convexChildRow).click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press(process.platform === "darwin" ? "Meta+X" : "Control+X");
await page.locator(convexRootRow).click({ timeout: UI_TIMEOUT_MS });
const convexMoveRequestCount = requests.length;
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
await waitForPreflight(page, "粘贴移动预检");
await confirmPreflight(page);
await page.waitForTimeout(800);
const convexMoveRequests = requests.slice(convexMoveRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
assert(convexMoveRequests.some((request) => request.body.includes('"action":"move"')), "Convex cut paste 应通过同一 command endpoint 发送 move action");
await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator(convexChildRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(convexChildRow).click({ timeout: UI_TIMEOUT_MS });
const convexDeleteRequestCount = requests.length;
await page.keyboard.press("Delete");
await waitForPreflight(page, "删除预检");
await confirmPreflight(page);
await page.waitForTimeout(800);
const convexDeleteRequests = requests.slice(convexDeleteRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
assert(convexDeleteRequests.some((request) => request.body.includes('"action":"delete"')), "Convex Delete 应通过同一 command endpoint 发送 delete action");
const convexRestoreRequestCount = requests.length;
await page.evaluate(async () => {
const response = await fetch("/api/tree/commands", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "restore",
sourceKind: "convex_workspace",
workspaceId: "ws_demo",
documentId: "page_child",
}),
});
if (!response.ok) throw new Error(`convex_restore_failed_${response.status}`);
});
await page.evaluate(async () => {
const response = await fetch("/api/tree/commands", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "purge",
sourceKind: "convex_workspace",
workspaceId: "ws_demo",
documentId: "page_child",
}),
});
if (!response.ok) throw new Error(`convex_purge_failed_${response.status}`);
});
const convexRestorePurgeRequests = requests.slice(convexRestoreRequestCount).filter((request) => request.body.includes('"sourceKind":"convex_workspace"'));
assert(convexRestorePurgeRequests.some((request) => request.body.includes('"action":"restore"')), "Convex restore 应继续走 tree.node.restore command");
assert(convexRestorePurgeRequests.some((request) => request.body.includes('"action":"purge"')), "Convex purge 应继续走 tree.node.purge command");
await page.setContent(`
<!doctype html>
<meta charset="utf-8">
<script>
window.__mnoteHostMessages = [];
window.addEventListener("message", (event) => {
window.__mnoteHostMessages.push(event.data);
});
</script>
<iframe id="tree-host" src="${convexTreeUrl("filetree")}"></iframe>
`);
const treeFrameLocator = page.frameLocator("#tree-host");
await treeFrameLocator.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const treeFrameElement = await page.locator("#tree-host").elementHandle({ timeout: UI_TIMEOUT_MS });
const treeFrame = treeFrameElement ? await treeFrameElement.contentFrame() : null;
assert(treeFrame, "Convex iframe tree shell 应加载成功");
await treeFrame.locator(convexRootRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await treeFrame.locator(convexAssetFolderRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await dispatchExternalFileDrop(treeFrame, convexRootRow, "convex-doc-drop.txt", "上传到 Convex document");
await treeFrame.locator('[data-testid="tree-preflight"]').getByText("外部拖入预检", { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await treeFrame.locator('[data-testid="tree-preflight"]').getByText("上传到对象存储", { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await treeFrame.locator('[data-testid="tree-preflight"] [data-role="confirm"]').click({ timeout: UI_TIMEOUT_MS });
const convexDocDropMessage = await waitForHostMessage(page, "tree.filetree.external-drop", (message) =>
message.documentId === "page_root" &&
message.rowKind === "document" &&
Array.isArray(message.files) &&
message.files.length === 1 &&
message.files[0].name === "convex-doc-drop.txt"
);
assert(convexDocDropMessage.fileCount === undefined || convexDocDropMessage.fileCount === 1, "Convex document 外部 drop host message 应保留文件数量");
await page.evaluate(() => {
window.__mnoteHostMessages = [];
});
await dispatchExternalFileDrop(treeFrame, convexAssetFolderRow, "convex-asset-folder-drop.txt", "上传到 Convex asset folder");
await treeFrame.locator('[data-testid="tree-preflight"]').getByText("外部拖入预检", { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await treeFrame.locator('[data-testid="tree-preflight"] [data-role="confirm"]').click({ timeout: UI_TIMEOUT_MS });
await waitForHostMessage(page, "tree.filetree.external-drop", (message) =>
message.assetId === "mind_1" &&
message.rowKind === "asset_folder" &&
Array.isArray(message.files) &&
message.files.some((file) => file.name === "convex-asset-folder-drop.txt")
);
assert(requests.some((request) => request.body.includes('"sourceKind":"local_folder"')), "写操作必须透传 sourceKind=local_folder");
assert(requests.some((request) => request.body.includes('"action":"move"')), "剪切粘贴应走统一 move action");
assert(requests.some((request) => request.body.includes('"action":"copy"')), "内部 copy drop 应走统一 copy action");
assert(requests.some((request) => request.body.includes('"action":"dropFiles"')), "外部文件 drop 应走统一 dropFiles action");
await browser.close();
fs.chmodSync(path.join(root, "readonly-dir"), 0o755);
fs.rmSync(root, { recursive: true, force: true });
console.log("task163 local folder unified tree browser smoke passed");
} catch (error) {
await browser.close().catch(() => undefined);
console.error(`local root preserved for debugging: ${root}`);
throw error;
}
}
run().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,96 @@
#!/usr/bin/env node
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000";
const UI_TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 10_000);
function fileUrl(filePath) {
return `file://${filePath.split(path.sep).map((part, index) => (
index === 0 ? "" : encodeURIComponent(part)
)).join("/")}`;
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-main-local-folder-"));
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
fs.writeFileSync(path.join(root, "README.md"), "# Root Page\n", "utf8");
fs.writeFileSync(path.join(root, "docs", "child.md"), "# Child Page\n", "utf8");
fs.writeFileSync(path.join(root, "plain.txt"), "plain asset\n", "utf8");
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
extraHTTPHeaders: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const page = await context.newPage();
const dialogs = [];
page.on("dialog", async (dialog) => {
dialogs.push(dialog.type());
await dialog.dismiss().catch(() => {});
});
try {
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.locator('[data-mnote-action="open-local-folder"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
assert.deepStrictEqual(dialogs, [], "打开本地文件夹不能再触发 prompt/alert/confirm 原生弹窗");
await page.locator('[data-testid="mnote-local-folder-path-input"]').fill(root);
await page.locator('[data-testid="mnote-local-folder-open-confirm"]').click();
await page.waitForURL((url) => {
return url.origin === new URL(BASE_URL).origin &&
url.pathname === "/" &&
url.searchParams.get("sourceKind") === "local_folder" &&
url.searchParams.get("rootUri") === fileUrl(root) &&
url.searchParams.get("treeView") === "filetree";
}, { timeout: UI_TIMEOUT_MS });
const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS });
assert(!bodyText.includes("legacy_next_compat_disabled"), "本地文件夹主入口不能落入 legacy Next fallback");
const readmeRow = page.locator('.tree-row[data-row-id="local:markdown:README.md"]');
const docsRow = page.locator('.tree-row[data-row-id="local:folder:docs"]');
await readmeRow.waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await docsRow.waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await readmeRow.locator(":scope > .tree-link").click();
await readmeRow.waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await docsRow.locator(":scope > .tree-link").click({ modifiers: ["Shift"] });
const selectedRowIds = await page.locator('#sidebar-file-tree-root .tree-row[data-selected="true"]')
.evaluateAll((rows) => rows.map((row) => row.getAttribute("data-row-id")));
assert(
selectedRowIds.includes("local:markdown:README.md") &&
selectedRowIds.includes("local:folder:docs") &&
selectedRowIds.length >= 2,
`Shift+Click 应形成文件树范围多选,实际选中: ${selectedRowIds.join(", ")}`,
);
console.log("task164 desktop hot local folder main entry smoke passed");
} finally {
await browser.close();
fs.rmSync(root, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -5,6 +5,11 @@ const mockValidateRequestBody = vi.fn();
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockStartMnoteCliAgentHostRun = vi.fn();
const mockStartCodexJsonRun = vi.fn();
const mockCodexMessagesToPrompt = vi.fn();
const mockFindWorkspaceRoot = vi.fn();
const mockStartHermesRun = vi.fn();
const mockStreamHermesRunEvents = vi.fn();
const mockRequireAuthContext = vi.fn();
const mockGetConvexAuthedHttpClient = vi.fn();
@@ -51,6 +56,17 @@ vi.mock("@/lib/server/mnote-cli-agent-host", () => ({
startMnoteCliAgentHostRun: mockStartMnoteCliAgentHostRun,
}));
vi.mock("@/lib/ai/codex/codexExec", () => ({
codexMessagesToPrompt: mockCodexMessagesToPrompt,
findWorkspaceRoot: mockFindWorkspaceRoot,
startCodexJsonRun: mockStartCodexJsonRun,
}));
vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
startHermesRun: mockStartHermesRun,
streamHermesRunEvents: mockStreamHermesRunEvents,
}));
describe("/api/ai-agent/run route", () => {
beforeEach(() => {
mockSafeGetJsonBody.mockReset();
@@ -58,8 +74,15 @@ describe("/api/ai-agent/run route", () => {
mockIsConvexEnabled.mockReset();
mockGetAuthedConvexClient.mockReset();
mockStartMnoteCliAgentHostRun.mockReset();
mockStartCodexJsonRun.mockReset();
mockCodexMessagesToPrompt.mockReset();
mockFindWorkspaceRoot.mockReset();
mockStartHermesRun.mockReset();
mockStreamHermesRunEvents.mockReset();
mockRequireAuthContext.mockReset();
mockGetConvexAuthedHttpClient.mockReset();
mockCodexMessagesToPrompt.mockImplementation((messages) => messages.map((m: any) => m.content).join("\n"));
mockFindWorkspaceRoot.mockResolvedValue("/mnt/Data1T/mnote");
});
it("文档页在线请求应只进入 mnote-cli host", async () => {
@@ -137,8 +160,8 @@ describe("/api/ai-agent/run route", () => {
expect(text).toContain("mnote-cli");
});
it.each(["codex", "hermes", "local", "ollama"] as const)(
"provider=%s 也必须统一进入 mnote-cli host",
it.each(["local", "ollama"] as const)(
"provider=%s 继续进入 mnote-cli host",
async (provider) => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
@@ -196,6 +219,127 @@ describe("/api/ai-agent/run route", () => {
},
);
it("provider=codex 应进入 Codex host 并返回 codex_session", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "#chat 继续检查" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "codex",
sessionId: "019dfbb6-9219-7861-a621-f6d77d9462f2",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartCodexJsonRun.mockImplementation(({ onJsonLine }) => {
onJsonLine?.({ type: "thread.started", thread_id: "codex-thread-1" });
return {
done: Promise.resolve({ ok: true, threadId: "codex-thread-1", text: "Codex 已回复" }),
kill: vi.fn(),
};
});
const { POST } = await import("./route");
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
expect(mockStartCodexJsonRun).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "019dfbb6-9219-7861-a621-f6d77d9462f2",
}),
);
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe("codex");
expect(text).toContain("event: codex_session");
expect(text).toContain("codex-thread-1");
expect(text).toContain("Codex 已回复");
});
it("provider=hermes 应进入 Hermes API bridge", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "hermes",
sessionId: "hermes-session-1",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartHermesRun.mockResolvedValue({ runId: "hermes-run-1" });
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({ event: "message.delta", delta: "Hermes " });
await onEvent({ event: "run.completed", output: "Hermes 已回复" });
});
const { POST } = await import("./route");
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
session_id: "hermes-session-1",
}),
);
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe("hermes");
expect(text).toContain("Hermes 已回复");
});
it("provider=claudecode 未接桥时必须明确报错,不能静默进入 mnote-cli", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "ping" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "claudecode",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
const { POST } = await import("./route");
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
const text = await response.text();
expect(response.status).toBe(501);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
expect(text).toContain("ClaudeCode");
});
it("未登录时不应启动 mnote-cli host", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
@@ -1,13 +1,130 @@
import { errorResponses, safeGetJsonBody, validateRequestBody } from "@/lib/api-utils";
import { codexMessagesToPrompt, findWorkspaceRoot, startCodexJsonRun } from "@/lib/ai/codex/codexExec";
import { startHermesRun, streamHermesRunEvents } from "@/lib/ai-agent/hermes/bridge";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
startMnoteCliAgentHostRun,
type MnoteCliAgentRunPayload,
} from "@/lib/server/mnote-cli-agent-host";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
const toSseFrame = (event: string, data: unknown) => {
const json = JSON.stringify(data ?? null);
return `event: ${event}\ndata: ${json}\n\n`;
};
const lastUserMessage = (payload: MnoteCliAgentRunPayload) => {
const found = [...payload.messages].reverse().find((message) => message.role === "user");
return String(found?.content ?? "");
};
const codexSandboxForPayload = (payload: MnoteCliAgentRunPayload) =>
/^\s*#dev\b/i.test(lastUserMessage(payload)) ? "workspace-write" : "read-only";
async function startCodexAgentRun(payload: MnoteCliAgentRunPayload): Promise<Response> {
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(toSseFrame(event, data)));
send("ready", { ok: true, bridgeOwner: "codex" });
try {
const cwd = await findWorkspaceRoot(process.cwd());
const prompt = codexMessagesToPrompt(payload.messages);
const run = startCodexJsonRun({
cwd,
sandbox: codexSandboxForPayload(payload),
prompt,
model: payload.options?.ai?.model,
sessionId: payload.options?.ai?.sessionId,
onJsonLine: (line) => {
if (line.type === "thread.started" && typeof line.thread_id === "string" && line.thread_id.trim()) {
send("codex_session", { sessionId: line.thread_id.trim() });
}
},
});
const result = await run.done;
if (!result.ok) {
send("error", { ok: false, message: result.error || "Codex 执行失败" });
return;
}
const sessionId = result.threadId.trim();
if (sessionId) send("codex_session", { sessionId });
send("assistant_message", { text: result.text || "(无输出)" });
send("completion", { ok: true, text: result.text || "(无输出)", steps: 1 });
} catch (error) {
send("error", { ok: false, message: error instanceof Error ? error.message : String(error) });
} finally {
controller.close();
}
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
"x-mnote-ai-execution-owner": "codex",
},
});
}
async function startHermesAgentRun(payload: MnoteCliAgentRunPayload): Promise<Response> {
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(toSseFrame(event, data)));
send("ready", { ok: true, bridgeOwner: "hermes" });
try {
const started = await startHermesRun({
input: payload.messages.map((message) => ({
role: message.role,
content: message.content,
})),
conversation_history: payload.messages.slice(0, -1).map((message) => ({
role: message.role,
content: message.content,
})),
session_id: payload.options?.ai?.sessionId,
});
let assistantText = "";
await streamHermesRunEvents(started.runId, (event) => {
if (event.event === "message.delta" && typeof event.delta === "string") {
assistantText += event.delta;
send("assistant_delta", { text: event.delta });
}
if (event.event === "run.completed") {
const output = typeof event.output === "string" && event.output.trim() ? event.output.trim() : assistantText.trim();
send("assistant_message", { text: output || "(无输出)" });
send("completion", { ok: true, text: output || "(无输出)", steps: 1 });
}
if (event.event === "run.failed") {
send("error", { ok: false, message: event.error || "Hermes 执行失败" });
}
});
} catch (error) {
send("error", { ok: false, message: error instanceof Error ? error.message : String(error) });
} finally {
controller.close();
}
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
"x-mnote-ai-execution-owner": "hermes",
},
});
}
export async function POST(request: Request) {
const payload = await safeGetJsonBody<MnoteCliAgentRunPayload>(request);
if (!payload) {
@@ -32,6 +149,25 @@ export async function POST(request: Request) {
return errorResponses.unauthorized();
}
const provider = String(payload.options?.ai?.provider ?? "").trim().toLowerCase();
if (provider === "codex") {
return startCodexAgentRun(payload);
}
if (provider === "hermes") {
return startHermesAgentRun(payload);
}
if (provider === "claudecode") {
return NextResponse.json(
{ error: "ClaudeCode bridge 尚未接入,不能静默降级到 mnote-cli。" },
{
status: 501,
headers: {
"x-mnote-ai-execution-owner": "claudecode-unavailable",
},
},
);
}
return startMnoteCliAgentHostRun({
request,
userId,
@@ -33,6 +33,7 @@ type PageRuntimeState = {
};
type FileTreeRuntimeState = {
activeRowId: string | null;
selection: {
selectedRowIds: string[];
anchorRowId: string | null;
@@ -283,6 +284,7 @@ export function TreeShellRustDomShellHost({
const [fileTreeState, setFileTreeState] = useState<FileTreeRuntimeState>(() => {
const selection = buildTreeShellDomFiletreeSelection(activeDocumentId);
return {
activeRowId: activeDocumentId ? `index:${activeDocumentId}` : null,
selection,
dragRowIds: [],
dragEffect: null,
@@ -560,6 +562,10 @@ export function TreeShellRustDomShellHost({
}
continue;
}
if (event.kind === "fileTreeKeyboardCommand") {
window.dispatchEvent(new CustomEvent("tree.filetree.keyboard-command", { detail: event }));
continue;
}
if (event.kind === "fileTreeContextMenu") {
onFileTreeContextMenu?.({
documentId,
@@ -640,6 +646,7 @@ export function TreeShellRustDomShellHost({
if (mode !== "filetree") return;
const selection = buildTreeShellDomFiletreeSelection(activeDocumentId);
setFileTreeState({
activeRowId: activeDocumentId ? `index:${activeDocumentId}` : null,
selection,
dragRowIds: [],
dragEffect: null,
@@ -858,6 +865,45 @@ export function TreeShellRustDomShellHost({
[reduceFileTreeAction],
);
const handleFileTreeKeyDown = useCallback(
async (item: TreeShellDomProjectionItem, event: ReactKeyboardEvent<HTMLElement>) => {
const actionByKey: Record<string, Record<string, unknown> | undefined> = {
ArrowDown: { kind: "focusNext" },
ArrowUp: { kind: "focusPrevious" },
Home: { kind: "focusFirst" },
End: { kind: "focusLast" },
Enter: { kind: "openFocused" },
F2: { kind: "beginRenameFocused" },
Delete: { kind: "deleteSelection" },
Backspace: { kind: "deleteSelection" },
Escape: { kind: "escape" },
};
const shortcut = event.ctrlKey || event.metaKey;
const action = shortcut && event.key.toLowerCase() === "c"
? { kind: "copySelection" }
: shortcut && event.key.toLowerCase() === "x"
? { kind: "cutSelection" }
: shortcut && event.key.toLowerCase() === "v"
? { kind: "pasteIntoFocused" }
: actionByKey[event.key];
if (!action) return;
event.preventDefault();
const rowId = toRowId(item);
const { result } = await reduceFileTreeAction(action, {
...fileTreeState,
selection: {
...fileTreeState.selection,
focusedRowId: fileTreeState.selection.focusedRowId ?? rowId,
},
});
applyFileTreeHostEvents(result?.hostEvents, {
rowId,
rowKind: toShellRowKind(item),
});
},
[applyFileTreeHostEvents, fileTreeState, reduceFileTreeAction],
);
const handleFileTreeDrop = useCallback(
async (item: TreeShellDomProjectionItem, event: DragEvent<HTMLElement>) => {
event.preventDefault();
@@ -998,6 +1044,7 @@ export function TreeShellRustDomShellHost({
const documentId = toDocumentId(item);
const assetId = toAssetId(item);
const selected = fileTreeState.selection.selectedRowIds.includes(rowId);
const active = fileTreeState.activeRowId === rowId || activeDocumentId === documentId;
return (
<div
key={rowId}
@@ -1013,7 +1060,7 @@ export function TreeShellRustDomShellHost({
data-asset-id={assetId ?? ""}
data-shell-mode="filetree"
data-selected={selected}
data-active={activeDocumentId === documentId}
data-active={active}
className={cn(
"tree-row group flex min-h-[27px] w-full cursor-pointer items-center gap-2 rounded-[3px] px-3 py-[2px] text-[14px] font-medium text-wolai-text-primary transition-colors hover:bg-wolai-bg-hover",
selected && "bg-wolai-bg-active text-[#2563eb]",
@@ -1023,6 +1070,7 @@ export function TreeShellRustDomShellHost({
draggable
onClick={(event) => void handleFileTreeSelect(item, event)}
onDoubleClick={() => void handleFileTreeOpen(item)}
onKeyDown={(event) => void handleFileTreeKeyDown(item, event)}
onContextMenu={(event) => void handleFileTreeContextMenu(item, event)}
onDragStart={(event) => {
const rowIds = fileTreeState.selection.selectedRowIds.includes(rowId) ? fileTreeState.selection.selectedRowIds : [rowId];
@@ -1103,7 +1151,15 @@ export function TreeShellRustDomShellHost({
{renderPageRows("")}
</div>
) : mode === "filetree" ? (
<div className="tree-root" role="tree" data-rust-filetree-renderer="dom_v1">
<div
className="tree-root"
role="tree"
data-rust-filetree-renderer="dom_v1"
onMouseDown={(event) => {
if (event.target !== event.currentTarget) return;
void reduceFileTreeAction({ kind: "clearSelection" });
}}
>
{renderFileTreeRows()}
</div>
) : (
@@ -296,7 +296,7 @@ describe("tree-shell-iframe-host", () => {
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeApi":{"requestContract":"TreeShellRuntimeRequest","resultContract":"TreeShellRuntimeResult"');
expect(iframe?.getAttribute("srcdoc")).toContain('"reduceEndpoint":"/api/tree/runtime/reduce"');
expect(iframe?.getAttribute("srcdoc")).toContain('"domPatchKinds":["pageState","fileTreeState","pickerState"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"hostEventKinds":["pageOpen","pageContextMenu","fileTreeOpen","fileTreeContextMenu","fileTreeInternalDrop","fileTreeExternalDrop","pickerPickRoot","pickerPickDocument"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"hostEventKinds":["pageOpen","pageContextMenu","fileTreeOpen","fileTreeContextMenu","fileTreeInternalDrop","fileTreeExternalDrop","fileTreeKeyboardCommand","pickerPickRoot","pickerPickDocument"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"commandEventKinds":["createNode","renameNode","moveSubtree","copyResource","moveResource","uploadResource"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"eventKinds":["focus","keyboard","expandCollapse","selection","contextMenu","dragDrop","pick"]');
expect(iframe?.getAttribute("srcdoc")).toContain("applyPageKeyboardAction");
@@ -257,6 +257,7 @@ const TREE_SHELL_RUNTIME_ARTIFACT = {
"fileTreeContextMenu",
"fileTreeInternalDrop",
"fileTreeExternalDrop",
"fileTreeKeyboardCommand",
"pickerPickRoot",
"pickerPickDocument",
],
@@ -26,6 +26,7 @@ function reduceTreeRuntimeForTest(request: RuntimeRequest) {
const state = request.state ?? {};
const action = request.action ?? {};
const env = request.environment ?? {};
const selection = (state.selection ?? {}) as Record<string, unknown>;
const rows = Array.isArray(env.rows) ? env.rows as Array<Record<string, unknown>> : [];
const rowId = typeof action.targetRowId === "string" ? action.targetRowId : null;
const targetRow = rows.find((row) => row.rowId === rowId) ?? {};
@@ -49,12 +50,28 @@ function reduceTreeRuntimeForTest(request: RuntimeRequest) {
targetRowId: rowId,
target,
fileCount: action.fileCount ?? 0,
}]
: [];
}]
: [];
const selectedRowId = typeof action.rowId === "string" ? action.rowId : null;
const nextSelection =
action.kind === "selectRow" && selectedRowId
? { selectedRowIds: [selectedRowId], anchorRowId: selectedRowId, focusedRowId: selectedRowId }
: action.kind === "clearSelection"
? { selectedRowIds: [], anchorRowId: null, focusedRowId: null }
: selection;
return {
requestId: request.requestId,
mode: "fileTree",
state: { mode: "fileTree", state },
state: {
mode: "fileTree",
state: {
activeRowId: (state as Record<string, unknown>).activeRowId ?? null,
selection: nextSelection,
dragRowIds: (state as Record<string, unknown>).dragRowIds ?? [],
dragEffect: (state as Record<string, unknown>).dragEffect ?? null,
dropTargetRowId: (state as Record<string, unknown>).dropTargetRowId ?? null,
},
},
domPatches: [{ kind: "fileTreeState" }],
hostEvents,
commandEvents: [],
@@ -414,6 +431,69 @@ describe("tree-shell-surface", () => {
});
});
it("file tree surface 应支持空白区清空选择", async () => {
const runtimeReducerMock = mockTreeRuntimeReducer();
const targetProjectionItem: KernelFileTreeProjectionItem = {
rowId: "doc:doc_target",
nodeId: "doc_target",
parentNodeId: null,
projectionKind: "file_tree",
title: "目标页面",
depth: 0,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
nodeType: "page",
rowKind: "document",
iconHint: "page",
capabilities: ["open", "select", "context-menu"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_target",
workspaceId: "ws_1",
iconHint: "page",
},
};
await act(async () => {
root.render(
<SidebarTreeSurface
mode="filetree"
rendererFamily="rust_family"
workspaceId="ws_1"
treeShellEnabled
rows={[]}
treeShellItems={[targetProjectionItem]}
activeId=""
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
onRowContextMenu={() => undefined}
onToggleExpand={() => undefined}
onCreateChild={() => undefined}
/>,
);
});
const targetRow = container.querySelector('[data-testid="filetree-doc-row"][data-row-id="doc:doc_target"]') as HTMLElement | null;
const treeRoot = container.querySelector('[data-rust-filetree-renderer="dom_v1"]') as HTMLElement | null;
expect(targetRow).not.toBeNull();
expect(treeRoot).not.toBeNull();
await act(async () => {
targetRow?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await waitForRuntimeReducer(runtimeReducerMock);
expect(targetRow?.getAttribute("data-selected")).toBe("true");
await act(async () => {
treeRoot?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
expect(targetRow?.getAttribute("data-selected")).toBe("false");
});
it("picker surface 在 rust_family 下也应挂到默认 Rust/WASM DOM shell host", async () => {
const onPick = vi.fn();
@@ -61,7 +61,7 @@ export function buildPageAggregateFromDocumentPayloads(input: {
const pageOptions: PageOptionsState = {
wideLayout: input.meta.wide_layout ?? false,
smallText: input.meta.use_small_text ?? false,
showHeadingNumbers: input.meta.show_heading_numbers ?? true,
showHeadingNumbers: input.meta.show_heading_numbers ?? false,
showToc: input.meta.show_toc ?? false,
showStructure: input.meta.show_structure ?? false,
protectEditing: input.meta.protect_editing ?? false,
@@ -116,6 +116,15 @@ describe("buildVisibleRows", () => {
documentId: "page_root",
workspaceId: "ws_1",
iconHint: "page",
extra: {
source: {
sourceKind: "convex_workspace",
sourceUri: "convex://workspace/ws_1/documents/page_root",
relativePath: "documents/page_root",
storageIdentity: "page_root",
operationProfile: "convex_workspace",
},
},
},
iconHint: "page",
},
@@ -138,6 +147,15 @@ describe("buildVisibleRows", () => {
documentId: "page_root",
workspaceId: "ws_1",
iconHint: "index",
extra: {
source: {
sourceKind: "convex_workspace",
sourceUri: "convex://workspace/ws_1/documents/page_root/index",
relativePath: "documents/page_root/index",
storageIdentity: "page_root:index",
operationProfile: "convex_workspace",
},
},
},
iconHint: "index",
},
@@ -162,6 +180,15 @@ describe("buildVisibleRows", () => {
workspaceId: "ws_1",
assetKind: "mindmap",
iconHint: "mindmap",
extra: {
source: {
sourceKind: "local_folder",
sourceUri: "file:///workspace/ws_1/page_root/mindmap.json",
relativePath: "page_root/mindmap.json",
storageIdentity: "mind_1",
operationProfile: "local_readonly",
},
},
},
iconHint: "mindmap",
},
@@ -186,6 +213,15 @@ describe("buildVisibleRows", () => {
workspaceId: "ws_1",
assetKind: "image",
iconHint: "image",
extra: {
source: {
sourceKind: "local_folder",
sourceUri: "file:///workspace/ws_1/page_root/assets/node.png",
relativePath: "page_root/assets/node.png",
storageIdentity: "asset_child_1",
operationProfile: "local_readonly",
},
},
},
iconHint: "image",
},
@@ -210,6 +246,15 @@ describe("buildVisibleRows", () => {
workspaceId: "ws_1",
assetKind: "table",
iconHint: "table",
extra: {
source: {
sourceKind: "convex_workspace",
sourceUri: "convex://workspace/ws_1/assets/table_1",
relativePath: "assets/table_1",
storageIdentity: "table_1",
operationProfile: "convex_workspace",
},
},
},
iconHint: "table",
},
@@ -32,6 +32,14 @@ describe("tree-projection contract", () => {
resourceKind: "document",
documentId: "root",
iconHint: "page",
extra: {
source: {
sourceKind: "convex_workspace",
sourceUri: "convex://workspace/ws_1/documents/root",
storageIdentity: "root",
operationProfile: "convex_workspace",
},
},
},
});
expect(items[0]?.capabilities).toContain("open");
+14
View File
@@ -2,6 +2,7 @@ import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type {
TreeProjectionCapability,
TreeProjectionItemBase,
TreeProjectionSourceMetadata,
} from "@/lib/tree-protocol";
export type PageTreeProjectionItem = {
@@ -35,6 +36,16 @@ function buildPageCapabilities(childCount: number): TreeProjectionCapability[] {
return capabilities;
}
function buildPageSourceMetadata(node: SidebarTreeNode): TreeProjectionSourceMetadata {
return {
sourceKind: "convex_workspace",
sourceUri: `convex://workspace/${node.workspace_id}/documents/${node.id}`,
relativePath: `documents/${node.id}`,
storageIdentity: node.id,
operationProfile: "convex_workspace",
};
}
export function buildPageTreeProjectionItems(
nodes: SidebarTreeNode[],
): PageTreeProjectionItem[] {
@@ -71,6 +82,9 @@ export function buildPageTreeProjectionItems(
documentId: node.id,
workspaceId: node.workspace_id,
iconHint: "page",
extra: {
source: buildPageSourceMetadata(node),
},
},
iconHint: "page",
node: {
+39 -1
View File
@@ -1,7 +1,29 @@
import { describe, expect, it } from "vitest";
import type { TreeProjectionItemBase } from "@/lib/tree-protocol";
import type {
TreeProjectionItemBase,
WorkspaceSource,
} from "@/lib/tree-protocol";
describe("tree-protocol", () => {
it("冻结 WorkspaceSource 合同字段", () => {
const source: WorkspaceSource = {
sourceKind: "convex_workspace",
rootUri: "convex://workspace/ws_1",
workspaceId: "ws_1",
capabilities: [
"load-snapshot",
"watch",
"preflight-command",
"execute-command",
"resolve-page-aggregate",
],
};
expect(source.sourceKind).toBe("convex_workspace");
expect(source.rootUri).toContain("workspace/ws_1");
expect(source.capabilities).toContain("load-snapshot");
});
it("冻结共享 tree projection 字段与 file_tree 扩展语义", () => {
const item: TreeProjectionItemBase = {
nodeId: "doc_1",
@@ -20,12 +42,28 @@ describe("tree-protocol", () => {
documentId: "doc_1",
assetKind: "file",
iconHint: "page",
extra: {
source: {
sourceKind: "convex_workspace",
sourceUri: "convex://workspace/ws_1",
relativePath: "docs/doc_1.md",
storageIdentity: "doc_1",
operationProfile: "convex_workspace",
},
},
},
iconHint: "page",
};
expect(item.resourceMeta.resourceKind).toBe("document");
expect(item.resourceMeta.assetKind).toBe("file");
expect(item.resourceMeta.extra?.source).toMatchObject({
sourceKind: "convex_workspace",
sourceUri: "convex://workspace/ws_1",
relativePath: "docs/doc_1.md",
storageIdentity: "doc_1",
operationProfile: "convex_workspace",
});
expect(item.capabilities).toContain("expand");
expect(item.expandable).toBe(true);
expect(item.iconHint).toBe("page");
+33 -1
View File
@@ -1,5 +1,21 @@
export type TreeProjectionKind = "sidebar_tree" | "page_tree" | "file_tree";
export type WorkspaceSourceKind = "local_folder" | "convex_workspace";
export type WorkspaceSourceCapability =
| "load-snapshot"
| "watch"
| "preflight-command"
| "execute-command"
| "resolve-page-aggregate";
export type WorkspaceSource = {
sourceKind: WorkspaceSourceKind;
rootUri: string;
workspaceId: string;
capabilities: WorkspaceSourceCapability[];
};
export type TreeProjectionNodeType =
| "workspace"
| "folder"
@@ -49,6 +65,22 @@ export type TreeProjectionAssetKind =
| "audio"
| "unknown";
export type TreeProjectionSourceOperationProfile =
| "local_readonly"
| "convex_workspace";
export type TreeProjectionSourceMetadata = {
sourceKind: WorkspaceSourceKind;
sourceUri: string;
relativePath?: string | null;
storageIdentity?: string | null;
operationProfile?: TreeProjectionSourceOperationProfile | null;
};
export type TreeProjectionResourceExtra = {
source?: TreeProjectionSourceMetadata | null;
} & Record<string, unknown>;
export type TreeProjectionResourceMeta = {
resourceKind: TreeProjectionResourceKind;
documentId?: string | null;
@@ -56,7 +88,7 @@ export type TreeProjectionResourceMeta = {
workspaceId?: string | null;
assetKind?: TreeProjectionAssetKind | null;
iconHint?: string | null;
extra?: Record<string, unknown>;
extra?: TreeProjectionResourceExtra;
};
export type TreeProjectionItemBase = {