checkpoint before gfm ast parser design
This commit is contained in:
+84
-7
@@ -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 root,root 下再进入页面/资源树。
|
||||
- 多工作区:允许多个 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 不覆盖 selection,selection 背景优先。
|
||||
|
||||
## 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。
|
||||
- 本地全文索引。
|
||||
|
||||
### P3:Local 与 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 或制造第二套树真相。
|
||||
+590
@@ -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 和 UI;Local 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] 补 fixture:local 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 B:Local 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 default:folder first,name 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 F:Local 写操作 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 G:Watcher / 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 H:Markdown 编辑与写回 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 I:Local / 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 map;execute 失败必须返回 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 保存均不依赖 Convex;Convex 能力只通过显式 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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user