diff --git a/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md b/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md index 07c16170..16002ebc 100644 --- a/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md +++ b/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md @@ -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 才能进入完成评估。 diff --git a/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md b/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md new file mode 100644 index 00000000..b252013d --- /dev/null +++ b/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md @@ -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 行为歧义能通过测试说明和验证。 +- 新增能力没有制造第二套树真相。 diff --git a/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md b/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md new file mode 100644 index 00000000..619066f5 --- /dev/null +++ b/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md @@ -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 或制造第二套树真相。 diff --git a/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md b/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md new file mode 100644 index 00000000..feb30ee7 --- /dev/null +++ b/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md @@ -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 模式做成必须登录才能使用。 diff --git a/design/04-tree-domain/process/4-23-local-cloud-explicit-bridge-p3-candidate-v1.md b/design/04-tree-domain/process/4-23-local-cloud-explicit-bridge-p3-candidate-v1.md new file mode 100644 index 00000000..74580c97 --- /dev/null +++ b/design/04-tree-domain/process/4-23-local-cloud-explicit-bridge-p3-candidate-v1.md @@ -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。 diff --git a/docs/superpowers/plans/2026-05-07-heading-numbering-global-option.md b/docs/superpowers/plans/2026-05-07-heading-numbering-global-option.md new file mode 100644 index 00000000..0b6711fe --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-heading-numbering-global-option.md @@ -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` + diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 1e2eb6ce..3c06a30e 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -26,7 +26,7 @@ use core_protocol::{ MindmapTreeNode, MoveBlock, PageAggregateProjection, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, PageTree, PatchBlock, PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef, - ToolExecutionMode, ToolInvocation, UpdatePageOptions, UpdatePageStats, + ToolExecutionMode, ToolInvocation, UpdatePageOptions, UpdatePageStats, WorkspaceSource, }; use event_log::DomainEventRecord; use index_fts::{ @@ -105,6 +105,14 @@ pub struct RuntimeActorWire { pub struct RuntimeSourceWire { pub channel: String, pub client: String, + #[serde(default)] + pub source_kind: Option, + #[serde(default)] + pub root_uri: Option, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] + pub capabilities: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -140,6 +148,27 @@ pub struct RuntimeCommandEnvelopeWire { pub validate_only: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSourceWatch { + pub source_kind: String, + pub active: bool, +} + +pub trait WorkspaceSourceAdapter { + fn source(&self) -> &WorkspaceSource; + fn load_snapshot( + &self, + projection: KernelProjectionKind, + ) -> Result; + fn watch(&self) -> Result; + fn preflight_command(&self, command: &RuntimeCommandEnvelopeWire) + -> Result; + fn execute_command(&self, command: &RuntimeCommandEnvelopeWire) -> Result; + fn resolve_page_aggregate(&self, page_id: &str) + -> Result; +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeToolInvocationWire { @@ -288,6 +317,7 @@ pub struct RuntimeCommandExecutionPlan { pub trace_id: String, pub actor_id: String, pub idempotency_key: Option, + pub source: Value, pub payload_json: String, pub args_json: Value, } @@ -2538,6 +2568,85 @@ fn tree_domain_event_artifact_id( format!("evt_{command_id}_{:02}_{suffix}", index + 1) } +fn workspace_source_value(source: &RuntimeSourceWire) -> Value { + let mut value = json!({ + "channel": source.channel, + "client": source.client, + }); + if let Some(map) = value.as_object_mut() { + if let Some(source_kind) = source + .source_kind + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + map.insert("sourceKind".into(), json!(source_kind)); + } + if let Some(root_uri) = source + .root_uri + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + map.insert("rootUri".into(), json!(root_uri)); + } + if let Some(workspace_id) = source + .workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + map.insert("workspaceId".into(), json!(workspace_id)); + } + if !source.capabilities.is_empty() { + map.insert("capabilities".into(), json!(source.capabilities)); + } + } + value +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorkspaceCommandExecutor { + LegacyConvex, + ConvexWorkspace, + LocalFolder, +} + +fn resolve_workspace_command_executor( + source: &RuntimeSourceWire, +) -> Result { + let Some(source_kind) = source + .source_kind + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(WorkspaceCommandExecutor::LegacyConvex); + }; + + let executor = match source_kind { + "convex_workspace" => WorkspaceCommandExecutor::ConvexWorkspace, + "local_folder" => WorkspaceCommandExecutor::LocalFolder, + other => { + return Err(BridgeError::validation(format!( + "unknown workspace source kind: {other}" + ))) + } + }; + + if !source + .capabilities + .iter() + .any(|capability| capability.trim() == "execute-command") + { + return Err(BridgeError::validation(format!( + "workspace source {source_kind} missing capability: execute-command" + ))); + } + + Ok(executor) +} + fn read_preflight_field<'a>( command: &'a RuntimeCommandEnvelopeWire, field: &str, @@ -6603,6 +6712,61 @@ fn make_projection_edge( } } +fn convex_projection_source_metadata( + document_id: Option<&str>, + asset_id: Option<&str>, + workspace_id: Option<&str>, + row_kind: &str, +) -> Option { + let workspace_id = workspace_id?.trim(); + if workspace_id.is_empty() { + return None; + } + + match row_kind { + "document" => { + let document_id = document_id?.trim(); + if document_id.is_empty() { + return None; + } + Some(json!({ + "sourceKind": "convex_workspace", + "sourceUri": format!("convex://workspace/{workspace_id}/documents/{document_id}"), + "relativePath": format!("documents/{document_id}"), + "storageIdentity": document_id, + "operationProfile": "convex_workspace", + })) + } + "index" => { + let document_id = document_id?.trim(); + if document_id.is_empty() { + return None; + } + Some(json!({ + "sourceKind": "convex_workspace", + "sourceUri": format!("convex://workspace/{workspace_id}/documents/{document_id}/index"), + "relativePath": format!("documents/{document_id}/index"), + "storageIdentity": format!("{document_id}:index"), + "operationProfile": "convex_workspace", + })) + } + "asset" | "asset_folder" => { + let asset_id = asset_id?.trim(); + if asset_id.is_empty() { + return None; + } + Some(json!({ + "sourceKind": "convex_workspace", + "sourceUri": format!("convex://workspace/{workspace_id}/assets/{asset_id}"), + "relativePath": format!("assets/{asset_id}"), + "storageIdentity": asset_id, + "operationProfile": "convex_workspace", + })) + } + _ => None, + } +} + fn make_projection_resource_meta( resource_kind: KernelProjectionResourceKind, document_id: Option, @@ -6614,6 +6778,14 @@ fn make_projection_resource_meta( ) -> KernelProjectionResourceMeta { let mut extra = BTreeMap::new(); extra.insert("rowKind".into(), json!(row_kind)); + if let Some(source) = convex_projection_source_metadata( + document_id.as_deref(), + asset_id.as_deref(), + workspace_id.as_deref(), + row_kind, + ) { + extra.insert("source".into(), source); + } KernelProjectionResourceMeta { resource_kind: Some(resource_kind), document_id, @@ -7333,15 +7505,15 @@ fn build_kernel_projection_result( expandable: child_count > 0, expanded_by_default: true, capabilities: build_document_projection_capabilities(child_count), - resource_meta: Some(KernelProjectionResourceMeta { - resource_kind: Some(KernelProjectionResourceKind::Document), - document_id: Some(node.id.clone()), - asset_id: None, - workspace_id: node.workspace_id.clone(), - asset_kind: None, - icon_hint: Some("page".into()), - extra: BTreeMap::new(), - }), + resource_meta: Some(make_projection_resource_meta( + KernelProjectionResourceKind::Document, + Some(node.id.clone()), + None, + node.workspace_id.clone(), + None, + "page", + "document", + )), icon_hint: Some("page".into()), } }) @@ -7577,6 +7749,12 @@ fn execute_command( command_wire: RuntimeCommandEnvelopeWire, ) -> Result { let context = to_bridge_context(context_wire); + let source_executor = resolve_workspace_command_executor(&command_wire.source)?; + if source_executor == WorkspaceCommandExecutor::LocalFolder { + return Err(BridgeError::validation( + "local_folder command executor 尚未接入 runtime command router", + )); + } match command_wire.name.as_str() { "kernel.node.create" => { let payload: KernelCreateNodeCommandPayload = @@ -7607,6 +7785,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: serde_json::to_value(&payload).map_err(|error| { BridgeError::transport(format!("kernel.node.create 参数序列化失败: {error}")) @@ -7645,6 +7824,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: serde_json::to_value(&payload).map_err(|error| { BridgeError::transport(format!("kernel.node.update 参数序列化失败: {error}")) @@ -7681,6 +7861,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: serde_json::to_value(&payload).map_err(|error| { BridgeError::transport(format!("kernel.subtree.move 参数序列化失败: {error}")) @@ -7715,6 +7896,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: serde_json::to_value(&payload).map_err(|error| { BridgeError::transport(format!("kernel.edge.attach 参数序列化失败: {error}")) @@ -7752,6 +7934,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: serde_json::to_value(&payload).map_err(|error| { BridgeError::transport(format!("kernel.edge.detach 参数序列化失败: {error}")) @@ -7796,6 +7979,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: { let stream_delta_hint = tree_resync_required_hint( @@ -7859,6 +8043,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.block_id, @@ -7912,6 +8097,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "sourceDocumentId": payload.source_document_id, @@ -7961,6 +8147,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, @@ -8070,6 +8257,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, @@ -8110,6 +8298,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, @@ -8151,6 +8340,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "userId": payload.user_id, @@ -8199,6 +8389,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: { let stream_delta_hint = tree_resync_required_hint( @@ -8289,6 +8480,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, @@ -8340,6 +8532,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "docId": payload.document_id, @@ -8378,6 +8571,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "documentId": payload.document_id, @@ -8413,6 +8607,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "docId": payload.document_id, @@ -8446,6 +8641,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "docId": payload.document_id, @@ -8478,6 +8674,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "docId": payload.document_id, @@ -8511,6 +8708,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "workspaceId": payload.workspace_id, @@ -8548,6 +8746,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, @@ -8602,6 +8801,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, @@ -8656,6 +8856,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, @@ -8703,6 +8904,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, @@ -8741,6 +8943,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "sourceId": payload.source_document_id, @@ -8781,6 +8984,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, @@ -8814,6 +9018,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "workspaceId": payload.workspace_id, @@ -8850,6 +9055,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, @@ -8887,6 +9093,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "items": payload.items.iter().map(|item| json!({ @@ -8936,6 +9143,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "fileTreeDropPlan": drop_plan, @@ -8969,6 +9177,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "fileTreeDeletePlan": delete_plan, @@ -9002,6 +9211,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "fileTreePastePlan": paste_plan, @@ -9035,6 +9245,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "fileTreeUploadTargetPlan": upload_target_plan, @@ -9073,6 +9284,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "assetIds": transfer_plan.asset_ids, @@ -9127,6 +9339,7 @@ fn execute_command( trace_id: request.trace_id, actor_id: request.actor_id, idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ "assetId": upload_plan.asset_id, @@ -9660,6 +9873,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, tenant_id: None, auth_token: None, @@ -9669,6 +9886,41 @@ mod tests { } } + fn demo_tree_rename_command(command_id: &str) -> RuntimeCommandEnvelopeWire { + RuntimeCommandEnvelopeWire { + name: "tree.node.rename".into(), + command_id: command_id.into(), + idempotency_key: Some(format!("idem_{command_id}")), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "mnote-tree-shell-v1".into(), + client: "mnote-web".into(), + source_kind: Some("local_folder".into()), + root_uri: Some("file:///tmp/mnote-vault".into()), + workspace_id: Some("local_ws".into()), + capabilities: vec!["execute-command".into()], + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("local_ws".into()), + page_id: Some("local_page".into()), + block_id: None, + }), + payload: json!({ + "documentId": "local_page", + "title": "本地页面", + }), + preflight_data: None, + reason: Some("mock workspace source".into()), + refs: vec!["workspace-source-test".into()], + dry_run: false, + validate_only: false, + } + } + #[test] fn blocks_get_query_plan_maps_to_blocks_get_by_id() { let plan = execute_runtime_input(RuntimeInput::Query { @@ -9716,6 +9968,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -9802,6 +10058,267 @@ mod tests { } } + #[test] + fn tree_command_plan_keeps_workspace_source_contract_in_envelope() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.node.rename".into(), + command_id: "cmd_tree_source_1".into(), + idempotency_key: Some("idem_tree_source".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "mnote-tree-shell-v1".into(), + client: "mnote-web".into(), + source_kind: Some("convex_workspace".into()), + root_uri: Some("convex://workspace/ws_1".into()), + workspace_id: Some("ws_1".into()), + capabilities: vec![ + "load-snapshot".into(), + "preflight-command".into(), + "execute-command".into(), + ], + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_1", + "title": "新标题", + }), + preflight_data: None, + reason: Some("tree-shell rename".into()), + refs: vec!["mnote-web-tree".into()], + dry_run: false, + validate_only: false, + }, + }) + .expect("command plan should build"); + + match plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!(plan.command_name, "tree.node.rename"); + assert_eq!(plan.source["sourceKind"], json!("convex_workspace")); + assert_eq!(plan.source["rootUri"], json!("convex://workspace/ws_1")); + assert_eq!(plan.source["workspaceId"], json!("ws_1")); + assert_eq!( + plan.source["capabilities"], + json!(["load-snapshot", "preflight-command", "execute-command"]) + ); + } + RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), + RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), + } + } + + #[test] + fn tree_command_rejects_unknown_workspace_source_kind() { + let error = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.node.rename".into(), + command_id: "cmd_unknown_source".into(), + idempotency_key: Some("idem_unknown_source".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "mnote-tree-shell-v1".into(), + client: "mnote-web".into(), + source_kind: Some("unknown_source".into()), + root_uri: Some("unknown://workspace/ws_1".into()), + workspace_id: Some("ws_1".into()), + capabilities: vec!["execute-command".into()], + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_1", + "title": "新标题", + }), + preflight_data: None, + reason: Some("tree-shell rename".into()), + refs: vec!["mnote-web-tree".into()], + dry_run: false, + validate_only: false, + }, + }) + .expect_err("unknown source kind should be rejected"); + + assert_eq!(error.kind, BridgeErrorKind::Validation); + assert!(error.message.contains("unknown_source")); + } + + #[test] + fn tree_command_rejects_workspace_source_without_execute_capability() { + let error = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.node.rename".into(), + command_id: "cmd_missing_execute".into(), + idempotency_key: Some("idem_missing_execute".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "mnote-tree-shell-v1".into(), + client: "mnote-web".into(), + source_kind: Some("convex_workspace".into()), + root_uri: Some("convex://workspace/ws_1".into()), + workspace_id: Some("ws_1".into()), + capabilities: vec!["load-snapshot".into(), "preflight-command".into()], + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_1", + "title": "新标题", + }), + preflight_data: None, + reason: Some("tree-shell rename".into()), + refs: vec!["mnote-web-tree".into()], + dry_run: false, + validate_only: false, + }, + }) + .expect_err("source without execute-command should be rejected"); + + assert_eq!(error.kind, BridgeErrorKind::Validation); + assert!(error.message.contains("execute-command")); + } + + struct TestWorkspaceSourceAdapter { + source: core_protocol::WorkspaceSource, + } + + impl WorkspaceSourceAdapter for TestWorkspaceSourceAdapter { + fn source(&self) -> &core_protocol::WorkspaceSource { + &self.source + } + + fn load_snapshot( + &self, + projection: KernelProjectionKind, + ) -> Result { + Ok(KernelProjectionResult { + projection_id: format!("mock:{projection:?}"), + projection, + root_node_id: Some("mock_root".into()), + items: Vec::new(), + edges: Vec::new(), + meta: BTreeMap::new(), + }) + } + + fn watch(&self) -> Result { + Ok(WorkspaceSourceWatch { + source_kind: "local_folder".into(), + active: true, + }) + } + + fn preflight_command( + &self, + _command: &RuntimeCommandEnvelopeWire, + ) -> Result { + Ok(json!({ "ok": true, "executor": "mock" })) + } + + fn execute_command( + &self, + _command: &RuntimeCommandEnvelopeWire, + ) -> Result { + Ok(json!({ "ok": true, "executor": "mock" })) + } + + fn resolve_page_aggregate( + &self, + page_id: &str, + ) -> Result { + build_page_aggregate_projection_result( + &json!({ + "id": page_id, + "workspaceId": self.source.workspace_id, + "title": "Mock Page", + "content": [], + }), + page_id, + Some(self.source.workspace_id.as_str()), + PageAggregateSource::Fixture, + ) + } + } + + #[test] + fn workspace_source_adapter_mock_outputs_file_and_page_tree_snapshots() { + let adapter = TestWorkspaceSourceAdapter { + source: core_protocol::WorkspaceSource { + source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, + root_uri: "file:///tmp/mnote-vault".into(), + workspace_id: "local_ws".into(), + capabilities: vec![ + core_protocol::WorkspaceSourceCapability::LoadSnapshot, + core_protocol::WorkspaceSourceCapability::Watch, + core_protocol::WorkspaceSourceCapability::PreflightCommand, + core_protocol::WorkspaceSourceCapability::ExecuteCommand, + core_protocol::WorkspaceSourceCapability::ResolvePageAggregate, + ], + }, + }; + + assert_eq!(adapter.source().root_uri, "file:///tmp/mnote-vault"); + assert_eq!( + adapter + .load_snapshot(KernelProjectionKind::FileTree) + .expect("file_tree snapshot") + .projection, + KernelProjectionKind::FileTree + ); + assert_eq!( + adapter + .load_snapshot(KernelProjectionKind::PageTree) + .expect("page_tree snapshot") + .projection, + KernelProjectionKind::PageTree + ); + assert!(adapter.watch().expect("watch").active); + assert_eq!( + adapter + .preflight_command(&demo_tree_rename_command("cmd_preflight")) + .expect("preflight")["executor"], + json!("mock") + ); + assert_eq!( + adapter + .execute_command(&demo_tree_rename_command("cmd_execute")) + .expect("execute")["executor"], + json!("mock") + ); + assert_eq!( + adapter + .resolve_page_aggregate("local_page") + .expect("page aggregate") + .page_id, + "local_page" + ); + } + #[test] fn sidebar_dataset_query_plan_maps_to_sidebar_dataset_list() { let plan = execute_runtime_input(RuntimeInput::Query { @@ -10006,6 +10523,10 @@ mod tests { source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: None, payload: json!({ @@ -10228,6 +10749,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -10711,6 +11236,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -10787,6 +11316,10 @@ mod tests { source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -10854,6 +11387,10 @@ mod tests { source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -10925,6 +11462,10 @@ mod tests { source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -11000,6 +11541,10 @@ mod tests { source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -11080,6 +11625,10 @@ mod tests { source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -11125,6 +11674,10 @@ mod tests { source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: None, payload: json!({ @@ -11163,6 +11716,10 @@ mod tests { source: RuntimeSourceWire { channel: "rust-web".into(), client: "mnote-web".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: None, payload: json!({ @@ -11202,6 +11759,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -11299,6 +11860,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -11401,6 +11966,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -11464,6 +12033,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -11617,6 +12190,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -11708,6 +12285,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -11870,6 +12451,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -11955,6 +12540,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -12123,6 +12712,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -12167,6 +12760,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -12232,6 +12829,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -12297,6 +12898,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -12362,6 +12967,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -12415,6 +13024,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -12575,6 +13188,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -12682,6 +13299,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -12753,6 +13374,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -12849,6 +13474,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -12931,6 +13560,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -13010,6 +13643,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -13094,6 +13731,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -13231,6 +13872,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -13319,6 +13964,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -13387,6 +14036,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -13447,6 +14100,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -13511,6 +14168,10 @@ mod tests { source: RuntimeSourceWire { channel: "onlyoffice-callback".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -13585,6 +14246,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -13668,6 +14333,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -13704,6 +14373,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -13746,6 +14419,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -13867,6 +14544,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -13926,6 +14607,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -14011,6 +14696,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -14120,6 +14809,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -14190,6 +14883,10 @@ mod tests { source: RuntimeSourceWire { channel: "next-route".into(), client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some("ws_1".into()), @@ -14635,11 +15332,31 @@ mod tests { item_by_row_id["doc:page_root"]["resourceMeta"]["resourceKind"], json!("document") ); + assert_eq!( + item_by_row_id["doc:page_root"]["resourceMeta"]["extra"]["source"], + json!({ + "sourceKind": "convex_workspace", + "sourceUri": "convex://workspace/ws_1/documents/page_root", + "relativePath": "documents/page_root", + "storageIdentity": "page_root", + "operationProfile": "convex_workspace" + }) + ); assert_eq!(item_by_row_id["index:page_root"]["rowKind"], json!("index")); assert_eq!( item_by_row_id["index:page_root"]["resourceMeta"]["resourceKind"], json!("index") ); + assert_eq!( + item_by_row_id["index:page_root"]["resourceMeta"]["extra"]["source"], + json!({ + "sourceKind": "convex_workspace", + "sourceUri": "convex://workspace/ws_1/documents/page_root/index", + "relativePath": "documents/page_root/index", + "storageIdentity": "page_root:index", + "operationProfile": "convex_workspace" + }) + ); assert_eq!( item_by_row_id["asset:asset_img_1"]["resourceMeta"]["assetKind"], json!("image") @@ -14648,6 +15365,16 @@ mod tests { item_by_row_id["asset:asset_img_1"]["resourceMeta"]["resourceKind"], json!("asset") ); + assert_eq!( + item_by_row_id["asset:asset_img_1"]["resourceMeta"]["extra"]["source"], + json!({ + "sourceKind": "convex_workspace", + "sourceUri": "convex://workspace/ws_1/assets/asset_img_1", + "relativePath": "assets/asset_img_1", + "storageIdentity": "asset_img_1", + "operationProfile": "convex_workspace" + }) + ); assert_eq!( item_by_row_id["asset-folder:mind_1"]["rowKind"], json!("asset_folder") diff --git a/rust/crates/core-protocol/src/kernel.rs b/rust/crates/core-protocol/src/kernel.rs index f0bda762..15adb7fc 100644 --- a/rust/crates/core-protocol/src/kernel.rs +++ b/rust/crates/core-protocol/src/kernel.rs @@ -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, +} + #[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); + } } diff --git a/rust/crates/core-protocol/src/lib.rs b/rust/crates/core-protocol/src/lib.rs index 74f60f2c..3902676a 100644 --- a/rust/crates/core-protocol/src/lib.rs +++ b/rust/crates/core-protocol/src/lib.rs @@ -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, diff --git a/rust/crates/core-protocol/src/page_aggregate.rs b/rust/crates/core-protocol/src/page_aggregate.rs index 9252cc7f..534790a9 100644 --- a/rust/crates/core-protocol/src/page_aggregate.rs +++ b/rust/crates/core-protocol/src/page_aggregate.rs @@ -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 { diff --git a/rust/crates/mnote-web/src/page_aggregate/builder.rs b/rust/crates/mnote-web/src/page_aggregate/builder.rs index c5f8c15b..a5ac8c49 100644 --- a/rust/crates/mnote-web/src/page_aggregate/builder.rs +++ b/rust/crates/mnote-web/src/page_aggregate/builder.rs @@ -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, diff --git a/rust/crates/mnote-web/src/routes/command_support.rs b/rust/crates/mnote-web/src/routes/command_support.rs index 7456f3ee..bedd42d8 100644 --- a/rust/crates/mnote-web/src/routes/command_support.rs +++ b/rust/crates/mnote-web/src/routes/command_support.rs @@ -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(), diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index 03de2fc2..00089bd4 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -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 { + 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 = 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 { + text.split("\n\n") + .filter_map(|frame| { + let data = frame + .lines() + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim_start) + .collect::>() + .join("\n"); + if data.trim().is_empty() { + return None; + } + serde_json::from_str::(&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, +) -> Result { + 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::().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( diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index d3b45be5..7f3a96af 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -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, + pub source_kind: Option, + pub root_uri: Option, pub revision: Option, pub conflict_detection_key: Option, pub editor_document: Option, @@ -51,6 +56,8 @@ pub struct DocumentSaveRequest { pub struct DocumentTitleRequest { pub document_id: String, pub workspace_id: Option, + pub source_kind: Option, + pub root_uri: Option, pub title: String, pub command_name: Option, } @@ -60,6 +67,8 @@ pub struct DocumentTitleRequest { pub struct DocumentOptionsRequest { pub document_id: String, pub workspace_id: Option, + pub source_kind: Option, + pub root_uri: Option, #[serde(default)] pub options: Value, pub command_name: Option, @@ -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); + } } diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index cbe939dc..d8b8fd10 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -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, workspace_id: Option, + source_kind: Option, + root_uri: Option, } pub async fn gateway_health(State(state): State) -> 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! { , + page_options: BTreeMap, + trash_entries: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalTrashEntry { + document_id: String, + original_relative_path: String, + trash_relative_path: String, + deleted_at_ms: u128, +} + +#[derive(Debug, Clone)] +struct LocalFolderRow { + node_id: String, + row_id: String, + parent_node_id: Option, + title: String, + depth: u32, + position: u32, + row_kind: String, + icon_hint: String, + relative_path: String, + source_uri: String, + child_count: u32, + expandable: bool, + expanded_by_default: bool, + document_id: Option, + capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalFolderWatchRevision { + pub root_uri: String, + pub revision: String, + pub entry_count: usize, + pub latest_modified_ms: u128, +} + +pub fn load_local_folder_file_tree_snapshot( + root_uri: &str, +) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + if !canonical_root.is_dir() { + return Err(WebError::bad_request_code( + "local_folder_not_directory", + "本地 rootUri 必须指向目录", + )); + } + + let root_source_uri = file_uri_for_path(&canonical_root); + let metadata = load_local_folder_metadata(&canonical_root)?; + let mut rows = Vec::new(); + scan_directory( + &canonical_root, + &canonical_root, + None, + 0, + &root_source_uri, + &metadata, + &mut rows, + )?; + + let items = rows + .iter() + .map(local_folder_row_to_projection_item) + .collect::>(); + let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?; + let dataset = json!({ + "workspace": { + "id": local_workspace_id(&canonical_root), + "sourceKind": "local_folder", + "rootUri": root_source_uri, + }, + "nodes": [], + "edges": [], + "documents": [], + "media_assets": [], + "mindmap_assets": [], + "table_assets": [], + "mindmap_asset_children": {}, + }); + let projection = json!({ + "projection": "file_tree", + "sourceKind": "local_folder", + "rootUri": root_source_uri, + "watchRevision": watch_revision, + "items": items, + }); + + Ok(ProjectionSnapshot { + dataset, + projection, + }) +} + +pub fn load_local_folder_page_tree_snapshot( + root_uri: &str, +) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + if !canonical_root.is_dir() { + return Err(WebError::bad_request_code( + "local_folder_not_directory", + "本地 rootUri 必须指向目录", + )); + } + + let root_source_uri = file_uri_for_path(&canonical_root); + let metadata = load_local_folder_metadata(&canonical_root)?; + let mut rows = Vec::new(); + scan_markdown_page_tree( + &canonical_root, + &canonical_root, + None, + 0, + &metadata, + &mut rows, + )?; + let child_counts = rows + .iter() + .filter_map(|row| row.parent_node_id.as_ref()) + .fold( + std::collections::BTreeMap::::new(), + |mut acc, parent| { + *acc.entry(parent.clone()).or_default() += 1; + acc + }, + ); + for row in &mut rows { + row.child_count = child_counts.get(&row.node_id).copied().unwrap_or(0); + row.expandable = row.child_count > 0; + row.expanded_by_default = row.expandable && row.depth < 2; + } + + let items = rows + .iter() + .map(local_folder_row_to_projection_item) + .collect::>(); + let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?; + Ok(ProjectionSnapshot { + dataset: json!({ + "workspace": { + "id": local_workspace_id(&canonical_root), + "sourceKind": "local_folder", + "rootUri": root_source_uri, + }, + "documents": rows + .iter() + .filter_map(|row| { + row.document_id.as_ref().map(|document_id| { + json!({ + "id": document_id, + "workspace_id": local_workspace_id(&canonical_root), + "title": row.title, + "parent_id": row.parent_node_id, + "sort_order": row.position, + }) + }) + }) + .collect::>(), + }), + projection: json!({ + "projection": "page_tree", + "sourceKind": "local_folder", + "rootUri": root_source_uri, + "watchRevision": watch_revision, + "items": items, + }), + }) +} + +pub fn local_folder_watch_revision(root_uri: &str) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + if !canonical_root.is_dir() { + return Err(WebError::bad_request_code( + "local_folder_not_directory", + "本地 rootUri 必须指向目录", + )); + } + let root_source_uri = file_uri_for_path(&canonical_root); + local_folder_watch_revision_for_root(&canonical_root, &root_source_uri) +} + +pub fn resolve_local_markdown_page_aggregate( + root_uri: &str, + document_id: &str, +) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + if !canonical_root.is_dir() { + return Err(WebError::bad_request_code( + "local_folder_not_directory", + "本地 rootUri 必须指向目录", + )); + } + let metadata = load_local_folder_metadata(&canonical_root)?; + let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)? + .ok_or_else(|| { + WebError::bad_request_code( + "local_markdown_not_found", + "找不到本地 Markdown 页面对应的文件", + ) + })?; + let markdown = fs::read_to_string(&markdown_file.path).map_err(|error| { + WebError::bad_request_code( + "local_markdown_read_failed", + format!( + "无法读取本地 Markdown 文件 {}: {error}", + markdown_file.path.display() + ), + ) + })?; + let parsed = parse_markdown_page(&markdown, &markdown_file.file_name); + let title = parsed.title; + let content = markdown_to_blocks(&parsed.body); + let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64; + let page_subtree = markdown_page_subtree(document_id, &title, &content); + let workspace_id = local_workspace_id(&canonical_root); + let page_options = metadata + .page_options + .get(document_id) + .or_else(|| metadata.page_options.get(&markdown_file.relative_path)) + .map(page_options_from_metadata) + .unwrap_or_default(); + let character_count = parsed.body.chars().count() as u64; + let word_count = parsed + .body + .split_whitespace() + .filter(|word| !word.trim().is_empty()) + .count() as u64; + let read_only = markdown_file.is_readonly; + + Ok(PageAggregate { + schema: PageAggregate::SCHEMA.into(), + projection_version: PageAggregate::VERSION, + source: PageAggregateSource::KernelProjection, + page_id: document_id.to_string(), + parent_id: None, + title: title.clone(), + path: vec![document_id.to_string()], + sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()], + body_ref: Some(format!("local-md:{document_id}")), + layout_options: serde_json::to_value(&page_options).unwrap_or_else(|_| json!({})), + updated_at: None, + identity: PageIdentity { + document_id: document_id.to_string(), + workspace_id, + }, + head: PageHead { + title, + updated_at: Value::Null, + permissions: PagePermissions { + read_only, + disable_download: false, + disable_copy: false, + }, + }, + layout: PageLayout { page_options }, + body: PageBody { + content, + revision: Value::Number(0.into()), + conflict_detection_key: Value::String(format!("local-md:{document_id}")), + }, + tree: PageTree { page_subtree }, + stats: PageStats { + word_count, + character_count, + block_count, + todo_total: 0, + todo_done: 0, + }, + }) +} + +pub fn save_local_markdown_page( + root_uri: &str, + document_id: &str, + content: &Value, +) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + let metadata = load_local_folder_metadata(&canonical_root)?; + let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)? + .ok_or_else(|| { + WebError::bad_request_code( + "local_markdown_not_found", + "找不到要保存的本地 Markdown 页面", + ) + })?; + let current = fs::read_to_string(&markdown_file.path).map_err(|error| { + WebError::bad_request_code( + "local_markdown_read_failed", + format!( + "无法读取本地 Markdown 文件 {}: {error}", + markdown_file.path.display() + ), + ) + })?; + let (frontmatter, _) = split_frontmatter(¤t); + let body = editor_blocks_to_markdown(content); + let next_markdown = if let Some(frontmatter) = frontmatter { + format!("---\n{frontmatter}\n---\n{body}") + } else { + body + }; + fs::write(&markdown_file.path, next_markdown).map_err(|error| { + WebError::bad_request_code( + "local_markdown_write_failed", + format!( + "无法保存本地 Markdown 文件 {}: {error}", + markdown_file.path.display() + ), + ) + })?; + Ok(json!({ + "ok": true, + "documentId": document_id, + "revision": now_ms(), + "conflict_detection_key": format!("local-md:{document_id}:{}", now_ms()), + "executedCommand": "page.body.save", + "canonicalCommand": "page.body.save", + "sourceKind": "local_folder", + })) +} + +pub fn update_local_markdown_title( + root_uri: &str, + document_id: &str, + title: &str, +) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + let metadata = load_local_folder_metadata(&canonical_root)?; + let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)? + .ok_or_else(|| { + WebError::bad_request_code( + "local_markdown_not_found", + "找不到要更新标题的本地 Markdown 页面", + ) + })?; + let current = fs::read_to_string(&markdown_file.path).map_err(|error| { + WebError::bad_request_code( + "local_markdown_read_failed", + format!( + "无法读取本地 Markdown 文件 {}: {error}", + markdown_file.path.display() + ), + ) + })?; + fs::write( + &markdown_file.path, + markdown_with_frontmatter_fields(¤t, &[("title", title.trim())]), + ) + .map_err(|error| { + WebError::bad_request_code( + "local_markdown_write_failed", + format!( + "无法写入本地 Markdown 标题 {}: {error}", + markdown_file.path.display() + ), + ) + })?; + Ok(json!({ + "ok": true, + "documentId": document_id, + "title": title.trim(), + "updated_at": Value::Null, + "sourceKind": "local_folder", + })) +} + +pub fn update_local_page_options( + root_uri: &str, + document_id: &str, + options: &Value, +) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + let mut metadata = load_local_folder_metadata(&canonical_root)?; + metadata + .page_options + .insert(document_id.to_string(), options.clone()); + write_page_options_metadata(&canonical_root, &metadata.page_options)?; + Ok(json!({ + "ok": true, + "documentId": document_id, + "options": options, + "updated_at": Value::Null, + "sourceKind": "local_folder", + })) +} + +pub fn execute_local_tree_command( + root_uri: &str, + action: &str, + document_id: &str, + parent_id: Option<&str>, + title: Option<&str>, +) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + match action { + "create" => { + create_local_markdown_page(&canonical_root, parent_id, title.unwrap_or("新页面")) + } + "createFolder" | "create_folder" | "folder.create" => { + create_local_folder(&canonical_root, parent_id, title.unwrap_or("新建文件夹")) + } + "rename" => rename_local_entry(&canonical_root, document_id, title.unwrap_or("无标题")), + "copy" => { + copy_local_markdown_page(&canonical_root, document_id, parent_id, title.unwrap_or("")) + } + "dropFiles" | "drop_files" => drop_external_files_into_local_folder( + &canonical_root, + parent_id, + title.unwrap_or("外部文件"), + ), + "move" => move_local_entry(&canonical_root, document_id, parent_id), + "delete" | "trash" => trash_local_markdown_page(&canonical_root, document_id), + "restore" => restore_local_markdown_page(&canonical_root, document_id), + "purge" => purge_local_markdown_page(&canonical_root, document_id), + other => Err(WebError::bad_request_code( + "local_tree_command_unsupported", + format!("local_folder 暂不支持 tree action: {other}"), + )), + } +} + +fn create_local_markdown_page( + root: &Path, + parent_id: Option<&str>, + title: &str, +) -> Result { + let metadata = load_local_folder_metadata(root)?; + let parent_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; + let safe_title = sanitize_file_stem(title, "新页面"); + let target = next_available_path(&parent_directory, &safe_title, "md"); + let relative_path = normalize_relative_path(root, &target)?; + let page_id = normalize_metadata_page_id(&format!( + "local-mdid:{}", + encode_local_id_segment(&format!("{}:{relative_path}", local_workspace_id(root))) + )); + let markdown = markdown_with_frontmatter_fields( + &format!("# {}\n", title.trim()), + &[("mnote_id", page_id.as_str()), ("title", title.trim())], + ); + fs::write(&target, markdown).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法创建本地 Markdown 文件 {}: {error}", target.display()), + ) + })?; + let mut page_ids = metadata.page_ids; + page_ids.insert(relative_path.clone(), page_id.clone()); + write_page_ids_metadata(root, &page_ids)?; + Ok(json!({ + "ok": true, + "id": page_id, + "documentId": page_id, + "relativePath": relative_path, + "action": "create", + "sourceKind": "local_folder", + })) +} + +fn create_local_folder( + root: &Path, + parent_id: Option<&str>, + title: &str, +) -> Result { + let metadata = load_local_folder_metadata(root)?; + let parent_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; + let target = + next_available_directory_path(&parent_directory, &sanitize_file_stem(title, "新建文件夹")); + fs::create_dir_all(&target).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法创建本地文件夹 {}: {error}", target.display()), + ) + })?; + Ok(json!({ + "ok": true, + "id": local_directory_group_id(&normalize_relative_path(root, &target)?), + "relativePath": normalize_relative_path(root, &target)?, + "action": "createFolder", + "sourceKind": "local_folder", + })) +} + +fn rename_local_entry(root: &Path, document_id: &str, title: &str) -> Result { + if let Some(directory) = resolve_local_directory_id(root, document_id)? { + return rename_local_directory(root, &directory, title); + } + if let Some(file) = resolve_local_raw_file_id(root, document_id)? { + return rename_local_raw_file(root, &file, title); + } + rename_local_markdown_page(root, document_id, title) +} + +fn rename_local_raw_file(root: &Path, file: &Path, title: &str) -> Result { + let parent = file.parent().ok_or_else(|| { + WebError::bad_request_code("local_tree_command_failed", "无法解析文件父目录") + })?; + let original_extension = file + .extension() + .and_then(|value| value.to_str()) + .unwrap_or(""); + let requested_name = sanitize_file_name(title, "文件"); + let requested_has_extension = Path::new(&requested_name).extension().is_some(); + let target_name = if requested_has_extension || original_extension.is_empty() { + requested_name + } else { + format!( + "{}.{}", + sanitize_file_stem(&requested_name, "文件"), + original_extension + ) + }; + let target = next_available_raw_path(parent, &target_name); + fs::rename(file, &target).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法重命名本地文件 {}: {error}", file.display()), + ) + })?; + Ok(json!({ + "ok": true, + "id": local_node_id(&normalize_relative_path(root, &target)?), + "relativePath": normalize_relative_path(root, &target)?, + "action": "rename", + "sourceKind": "local_folder", + })) +} + +fn rename_local_markdown_page( + root: &Path, + document_id: &str, + title: &str, +) -> Result { + let mut metadata = load_local_folder_metadata(root)?; + let markdown_file = + find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| { + WebError::bad_request_code( + "local_markdown_not_found", + "找不到要重命名的本地 Markdown 页面", + ) + })?; + let parent = markdown_file.path.parent().ok_or_else(|| { + WebError::bad_request_code("local_tree_command_failed", "无法解析 Markdown 父目录") + })?; + let target = next_available_path(parent, &sanitize_file_stem(title, "无标题"), "md"); + let old_relative_path = markdown_file.relative_path; + fs::rename(&markdown_file.path, &target).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!( + "无法重命名本地 Markdown 文件 {}: {error}", + markdown_file.path.display() + ), + ) + })?; + let new_relative_path = normalize_relative_path(root, &target)?; + metadata.page_ids.remove(&old_relative_path); + metadata + .page_ids + .insert(new_relative_path.clone(), document_id.to_string()); + write_page_ids_metadata(root, &metadata.page_ids)?; + Ok(json!({ + "ok": true, + "id": document_id, + "documentId": document_id, + "relativePath": new_relative_path, + "action": "rename", + "sourceKind": "local_folder", + })) +} + +fn rename_local_directory(root: &Path, directory: &Path, title: &str) -> Result { + if directory == root { + return Err(WebError::bad_request_code( + "local_tree_command_failed", + "不能重命名本地 workspace root", + )); + } + let mut metadata = load_local_folder_metadata(root)?; + let parent = directory.parent().ok_or_else(|| { + WebError::bad_request_code("local_tree_command_failed", "无法解析文件夹父目录") + })?; + let target = next_available_directory_path(parent, &sanitize_file_stem(title, "文件夹")); + let old_relative_path = normalize_relative_path(root, directory)?; + fs::rename(directory, &target).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法重命名本地文件夹 {}: {error}", directory.display()), + ) + })?; + let new_relative_path = normalize_relative_path(root, &target)?; + rewrite_page_ids_prefix( + &mut metadata.page_ids, + &old_relative_path, + &new_relative_path, + ); + write_page_ids_metadata(root, &metadata.page_ids)?; + Ok(json!({ + "ok": true, + "id": local_directory_group_id(&new_relative_path), + "relativePath": new_relative_path, + "action": "rename", + "sourceKind": "local_folder", + })) +} + +fn copy_local_markdown_page( + root: &Path, + document_id: &str, + parent_id: Option<&str>, + title: &str, +) -> Result { + let mut metadata = load_local_folder_metadata(root)?; + let markdown_file = + find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| { + WebError::bad_request_code( + "local_markdown_not_found", + "找不到要复制的本地 Markdown 页面", + ) + })?; + let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; + let source_stem = markdown_file + .path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("页面"); + let copy_stem = if title.trim().is_empty() { + source_stem + } else { + title.trim() + }; + let target = next_available_path( + &target_directory, + &sanitize_file_stem(copy_stem, "页面"), + "md", + ); + let new_relative_path = normalize_relative_path(root, &target)?; + let new_page_id = normalize_metadata_page_id(&format!( + "local-mdid:{}", + encode_local_id_segment(&format!("{}:{new_relative_path}", local_workspace_id(root))) + )); + let source_markdown = fs::read_to_string(&markdown_file.path).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!( + "无法读取待复制 Markdown 文件 {}: {error}", + markdown_file.path.display() + ), + ) + })?; + fs::write( + &target, + markdown_with_frontmatter_fields(&source_markdown, &[("mnote_id", new_page_id.as_str())]), + ) + .map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法复制本地 Markdown 文件到 {}: {error}", target.display()), + ) + })?; + metadata + .page_ids + .insert(new_relative_path.clone(), new_page_id.clone()); + write_page_ids_metadata(root, &metadata.page_ids)?; + Ok(json!({ + "ok": true, + "id": new_page_id, + "documentId": new_page_id, + "relativePath": new_relative_path, + "action": "copy", + "sourceKind": "local_folder", + })) +} + +fn move_local_entry( + root: &Path, + document_id: &str, + parent_id: Option<&str>, +) -> Result { + if let Some(directory) = resolve_local_directory_id(root, document_id)? { + return move_local_directory(root, &directory, parent_id); + } + move_local_markdown_page(root, document_id, parent_id) +} + +fn move_local_markdown_page( + root: &Path, + document_id: &str, + parent_id: Option<&str>, +) -> Result { + let mut metadata = load_local_folder_metadata(root)?; + let markdown_file = + find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| { + WebError::bad_request_code( + "local_markdown_not_found", + "找不到要移动的本地 Markdown 页面", + ) + })?; + let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; + if markdown_file.path.parent() == Some(target_directory.as_path()) { + return Ok(json!({ + "ok": true, + "id": document_id, + "documentId": document_id, + "relativePath": markdown_file.relative_path, + "action": "move", + "sourceKind": "local_folder", + })); + } + let file_name = markdown_file + .path + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| sanitize_file_stem(stem, "页面")) + .unwrap_or_else(|| "页面".to_string()); + let target = next_available_path(&target_directory, &file_name, "md"); + if target == markdown_file.path { + return Ok(json!({ + "ok": true, + "id": document_id, + "documentId": document_id, + "relativePath": markdown_file.relative_path, + "action": "move", + "sourceKind": "local_folder", + })); + } + if target.starts_with(&markdown_file.path) { + return Err(WebError::bad_request_code( + "local_tree_command_failed", + "禁止把本地页面移动到自身或后代", + )); + } + let old_relative_path = markdown_file.relative_path; + fs::rename(&markdown_file.path, &target).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!( + "无法移动本地 Markdown 文件 {}: {error}", + markdown_file.path.display() + ), + ) + })?; + let new_relative_path = normalize_relative_path(root, &target)?; + metadata.page_ids.remove(&old_relative_path); + metadata + .page_ids + .insert(new_relative_path.clone(), document_id.to_string()); + write_page_ids_metadata(root, &metadata.page_ids)?; + Ok(json!({ + "ok": true, + "id": document_id, + "documentId": document_id, + "relativePath": new_relative_path, + "action": "move", + "sourceKind": "local_folder", + })) +} + +fn move_local_directory( + root: &Path, + directory: &Path, + parent_id: Option<&str>, +) -> Result { + if directory == root { + return Err(WebError::bad_request_code( + "local_tree_command_failed", + "不能移动本地 workspace root", + )); + } + let mut metadata = load_local_folder_metadata(root)?; + let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; + if target_directory == directory || target_directory.starts_with(directory) { + return Err(WebError::bad_request_code( + "local_tree_command_failed", + "禁止把本地文件夹移动到自身或后代", + )); + } + if directory.parent() == Some(target_directory.as_path()) { + return Ok(json!({ + "ok": true, + "id": local_directory_group_id(&normalize_relative_path(root, directory)?), + "relativePath": normalize_relative_path(root, directory)?, + "action": "move", + "sourceKind": "local_folder", + })); + } + let stem = directory + .file_name() + .and_then(|name| name.to_str()) + .map(|name| sanitize_file_stem(name, "文件夹")) + .unwrap_or_else(|| "文件夹".to_string()); + let target = next_available_directory_path(&target_directory, &stem); + let old_relative_path = normalize_relative_path(root, directory)?; + fs::rename(directory, &target).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法移动本地文件夹 {}: {error}", directory.display()), + ) + })?; + let new_relative_path = normalize_relative_path(root, &target)?; + rewrite_page_ids_prefix( + &mut metadata.page_ids, + &old_relative_path, + &new_relative_path, + ); + write_page_ids_metadata(root, &metadata.page_ids)?; + Ok(json!({ + "ok": true, + "id": local_directory_group_id(&new_relative_path), + "relativePath": new_relative_path, + "action": "move", + "sourceKind": "local_folder", + })) +} + +fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result { + let mut metadata = load_local_folder_metadata(root)?; + let markdown_file = + find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| { + WebError::bad_request_code( + "local_markdown_not_found", + "找不到要删除的本地 Markdown 页面", + ) + })?; + let trash_dir = root.join(".mnote").join("trash"); + fs::create_dir_all(&trash_dir).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法创建本地回收站 {}: {error}", trash_dir.display()), + ) + })?; + let file_name = markdown_file + .path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("page.md"); + let target = next_available_path(&trash_dir, &file_stem_title(file_name), "md"); + fs::rename(&markdown_file.path, &target).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!( + "无法移动到本地回收站 {}: {error}", + markdown_file.path.display() + ), + ) + })?; + let trash_relative_path = normalize_relative_path(root, &target)?; + metadata.page_ids.remove(&markdown_file.relative_path); + metadata.trash_entries.insert( + document_id.to_string(), + LocalTrashEntry { + document_id: document_id.to_string(), + original_relative_path: markdown_file.relative_path, + trash_relative_path: trash_relative_path.clone(), + deleted_at_ms: now_ms(), + }, + ); + write_page_ids_metadata(root, &metadata.page_ids)?; + write_trash_index_metadata(root, &metadata.trash_entries)?; + Ok(json!({ + "ok": true, + "id": document_id, + "documentId": document_id, + "trashPath": trash_relative_path, + "action": "delete", + "sourceKind": "local_folder", + })) +} + +fn restore_local_markdown_page(root: &Path, document_id: &str) -> Result { + let mut metadata = load_local_folder_metadata(root)?; + let trash_entry = metadata + .trash_entries + .get(document_id) + .cloned() + .ok_or_else(|| { + WebError::bad_request_code( + "local_trash_entry_not_found", + "找不到要恢复的本地回收站记录", + ) + })?; + let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?; + if !trash_path.is_file() { + return Err(WebError::bad_request_code( + "local_trash_entry_not_found", + "本地回收站文件不存在,无法恢复", + )); + } + let original_path = resolve_metadata_relative_path(root, &trash_entry.original_relative_path)?; + let parent = original_path.parent().ok_or_else(|| { + WebError::bad_request_code("local_tree_command_failed", "无法解析恢复目标目录") + })?; + fs::create_dir_all(parent).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法创建恢复目标目录 {}: {error}", parent.display()), + ) + })?; + let stem = original_path + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| sanitize_file_stem(stem, "页面")) + .unwrap_or_else(|| "页面".to_string()); + let target = if original_path.exists() { + next_available_path(parent, &stem, "md") + } else { + original_path + }; + fs::rename(&trash_path, &target).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法从本地回收站恢复 {}: {error}", trash_path.display()), + ) + })?; + let restored_relative_path = normalize_relative_path(root, &target)?; + metadata + .page_ids + .insert(restored_relative_path.clone(), document_id.to_string()); + metadata.trash_entries.remove(document_id); + write_page_ids_metadata(root, &metadata.page_ids)?; + write_trash_index_metadata(root, &metadata.trash_entries)?; + Ok(json!({ + "ok": true, + "id": document_id, + "documentId": document_id, + "relativePath": restored_relative_path, + "action": "restore", + "sourceKind": "local_folder", + })) +} + +fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result { + let mut metadata = load_local_folder_metadata(root)?; + if let Some(markdown_file) = find_markdown_by_page_id(root, &metadata, document_id)? { + fs::remove_file(&markdown_file.path).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!( + "无法永久删除本地 Markdown 文件 {}: {error}", + markdown_file.path.display() + ), + ) + })?; + metadata.page_ids.remove(&markdown_file.relative_path); + write_page_ids_metadata(root, &metadata.page_ids)?; + return Ok(json!({ + "ok": true, + "id": document_id, + "documentId": document_id, + "action": "purge", + "sourceKind": "local_folder", + })); + } + let trash_entry = metadata.trash_entries.remove(document_id).ok_or_else(|| { + WebError::bad_request_code( + "local_markdown_not_found", + "找不到要永久删除的本地 Markdown 页面", + ) + })?; + let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?; + if trash_path.exists() { + fs::remove_file(&trash_path).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!( + "无法永久删除本地回收站文件 {}: {error}", + trash_path.display() + ), + ) + })?; + } + write_trash_index_metadata(root, &metadata.trash_entries)?; + Ok(json!({ + "ok": true, + "id": document_id, + "documentId": document_id, + "action": "purge", + "sourceKind": "local_folder", + })) +} + +fn resolve_local_parent_directory( + root: &Path, + metadata: &LocalFolderMetadata, + parent_id: Option<&str>, +) -> Result { + let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(root.to_path_buf()); + }; + if let Some(encoded) = parent_id.strip_prefix("local-dir:") { + let relative_path = decode_local_id_segment(encoded)?; + let directory = root.join(relative_path); + if directory.starts_with(root) && directory.is_dir() { + return Ok(directory); + } + } + if let Some(relative_path) = parent_id.strip_prefix("local:node:") { + let directory = resolve_metadata_relative_path(root, relative_path)?; + if directory.is_dir() { + return Ok(directory); + } + } + if let Some(relative_path) = parent_id.strip_prefix("local:folder:") { + let directory = resolve_metadata_relative_path(root, relative_path)?; + if directory.is_dir() { + return Ok(directory); + } + } + if let Some(markdown_file) = find_markdown_by_page_id(root, metadata, parent_id)? { + if let Some(parent) = markdown_file.path.parent() { + return Ok(parent.to_path_buf()); + } + } + Err(WebError::bad_request_code( + "local_tree_command_failed", + "无法解析本地新建目标目录", + )) +} + +fn drop_external_files_into_local_folder( + root: &Path, + parent_id: Option<&str>, + files_json: &str, +) -> Result { + let metadata = load_local_folder_metadata(root)?; + let target_directory = resolve_local_parent_directory(root, &metadata, parent_id)?; + let files = serde_json::from_str::>(files_json).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("外部拖入文件 payload 非法: {error}"), + ) + })?; + if files.is_empty() { + return Err(WebError::bad_request_code( + "local_tree_command_failed", + "外部拖入文件不能为空", + )); + } + let mut written = Vec::new(); + for file in files { + let name = sanitize_file_name(&file.name, "dropped-file"); + let path = next_available_raw_path(&target_directory, &name); + fs::write(&path, file.text.unwrap_or_default()).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法写入外部拖入文件 {}: {error}", path.display()), + ) + })?; + written.push(json!({ + "name": name, + "relativePath": normalize_relative_path(root, &path)?, + })); + } + Ok(json!({ + "ok": true, + "action": "dropFiles", + "files": written, + "sourceKind": "local_folder", + })) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalDropFilePayload { + name: String, + text: Option, +} + +fn resolve_local_directory_id(root: &Path, entry_id: &str) -> Result, WebError> { + let trimmed = entry_id.trim(); + if let Some(relative_path) = trimmed + .strip_prefix("local:node:") + .or_else(|| trimmed.strip_prefix("local:folder:")) + { + let directory = resolve_metadata_relative_path(root, relative_path)?; + if directory.is_dir() { + return Ok(Some(directory)); + } + if directory.is_file() { + return Ok(None); + } + return Err(WebError::bad_request_code( + "local_tree_command_failed", + "本地文件夹不存在", + )); + } + let Some(encoded) = trimmed.strip_prefix("local-dir:") else { + return Ok(None); + }; + let relative_path = decode_local_id_segment(encoded)?; + let directory = resolve_metadata_relative_path(root, &relative_path)?; + if directory.is_dir() { + Ok(Some(directory)) + } else { + Err(WebError::bad_request_code( + "local_tree_command_failed", + "本地文件夹不存在", + )) + } +} + +fn resolve_local_raw_file_id(root: &Path, entry_id: &str) -> Result, WebError> { + let trimmed = entry_id.trim(); + let Some(relative_path) = trimmed + .strip_prefix("local:node:") + .or_else(|| trimmed.strip_prefix("local:asset:")) + else { + return Ok(None); + }; + let file = resolve_metadata_relative_path(root, relative_path)?; + if file.is_file() { + Ok(Some(file)) + } else { + Err(WebError::bad_request_code( + "local_tree_command_failed", + "本地文件不存在", + )) + } +} + +pub fn local_workspace_id_from_root_uri(root_uri: &str) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + Ok(local_workspace_id(&canonical_root)) +} + +fn parse_file_root_uri(root_uri: &str) -> Result { + let trimmed = root_uri.trim(); + if trimmed.is_empty() { + return Err(WebError::bad_request_code( + "local_folder_root_required", + "缺少本地文件夹 rootUri", + )); + } + let Some(path) = trimmed.strip_prefix("file://") else { + return Err(WebError::bad_request_code( + "local_folder_root_invalid", + "本地文件夹 rootUri 必须使用 file://", + )); + }; + if path.trim().is_empty() { + return Err(WebError::bad_request_code( + "local_folder_root_invalid", + "本地文件夹 rootUri 不能为空", + )); + } + Ok(PathBuf::from(percent_decode_file_uri_path(path)?)) +} + +fn percent_decode_file_uri_path(value: &str) -> Result { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + if index + 2 >= bytes.len() { + return Err(WebError::bad_request_code( + "local_folder_root_invalid", + "file:// rootUri 包含不完整的百分号编码", + )); + } + let hex = &value[index + 1..index + 3]; + let byte = u8::from_str_radix(hex, 16).map_err(|_| { + WebError::bad_request_code( + "local_folder_root_invalid", + "file:// rootUri 包含无效的百分号编码", + ) + })?; + decoded.push(byte); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).map_err(|_| { + WebError::bad_request_code( + "local_folder_root_invalid", + "file:// rootUri 不是 UTF-8 路径", + ) + }) +} + +fn load_local_folder_metadata(root: &Path) -> Result { + Ok(LocalFolderMetadata { + page_ids: load_metadata_string_map(&root.join(".mnote").join("page-ids.json"))?, + page_options: load_metadata_value_map(&root.join(".mnote").join("page-options.json"))?, + trash_entries: load_trash_index_map(&root.join(".mnote").join("trash-index.json"))?, + }) +} + +fn load_metadata_string_map(path: &Path) -> Result, WebError> { + if !path.exists() { + return Ok(BTreeMap::new()); + } + let value = read_metadata_json(path)?; + let source = value + .get("pages") + .or_else(|| value.get("pageIds")) + .unwrap_or(&value); + let Some(map) = source.as_object() else { + return Err(metadata_invalid(path, "元数据必须是对象")); + }; + let mut result = BTreeMap::new(); + for (key, value) in map { + let Some(page_id) = value + .as_str() + .map(str::trim) + .filter(|item| !item.is_empty()) + else { + return Err(metadata_invalid(path, "page id 必须是非空字符串")); + }; + result.insert(key.clone(), page_id.to_string()); + } + Ok(result) +} + +fn load_metadata_value_map(path: &Path) -> Result, WebError> { + if !path.exists() { + return Ok(BTreeMap::new()); + } + let value = read_metadata_json(path)?; + let source = value + .get("pages") + .or_else(|| value.get("options")) + .unwrap_or(&value); + let Some(map) = source.as_object() else { + return Err(metadata_invalid(path, "页面选项元数据必须是对象")); + }; + Ok(map + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect()) +} + +fn read_metadata_json(path: &Path) -> Result { + let raw = fs::read_to_string(path).map_err(|error| { + WebError::bad_request_code( + "local_metadata_read_failed", + format!("无法读取本地元数据 {}: {error}", path.display()), + ) + })?; + serde_json::from_str(&raw) + .map_err(|error| metadata_invalid(path, format!("元数据 JSON 损坏: {error}"))) +} + +fn load_trash_index_map(path: &Path) -> Result, WebError> { + if !path.exists() { + return Ok(BTreeMap::new()); + } + let value = read_metadata_json(path)?; + let source = value.get("entries").unwrap_or(&value); + let entries: BTreeMap = serde_json::from_value(source.clone()) + .map_err(|error| metadata_invalid(path, format!("回收站索引损坏: {error}")))?; + Ok(entries) +} + +fn metadata_invalid(path: &Path, message: impl Into) -> WebError { + WebError::bad_request_code( + "local_metadata_invalid", + format!("{};请修复或移走 {} 后重试", message.into(), path.display()), + ) +} + +#[allow(dead_code)] +pub fn initialize_local_page_id(root_uri: &str, relative_path: &str) -> Result { + let root_path = parse_file_root_uri(root_uri)?; + let canonical_root = root_path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + })?; + let target = canonical_root + .join(relative_path) + .canonicalize() + .map_err(|error| { + WebError::bad_request_code( + "local_page_id_target_invalid", + format!("无法解析本地 Markdown 文件: {error}"), + ) + })?; + if !target.starts_with(&canonical_root) || !target.is_file() || !is_markdown_file(relative_path) + { + return Err(WebError::bad_request_code( + "local_page_id_target_invalid", + "只能为 root 内的 Markdown 文件初始化 page id", + )); + } + let mut metadata = load_local_folder_metadata(&canonical_root)?; + if let Some(existing) = metadata.page_ids.get(relative_path) { + return Ok(normalize_metadata_page_id(existing)); + } + let page_id = format!( + "local-mdid:{}", + encode_local_id_segment(&format!( + "{}:{relative_path}", + local_workspace_id(&canonical_root) + )) + ); + metadata + .page_ids + .insert(relative_path.to_string(), page_id.clone()); + write_page_ids_metadata(&canonical_root, &metadata.page_ids)?; + Ok(page_id) +} + +#[allow(dead_code)] +fn write_page_ids_metadata( + root: &Path, + page_ids: &BTreeMap, +) -> Result<(), WebError> { + let mnote_dir = root.join(".mnote"); + fs::create_dir_all(&mnote_dir).map_err(|error| { + WebError::bad_request_code( + "local_metadata_write_failed", + format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()), + ) + })?; + let path = mnote_dir.join("page-ids.json"); + let value = json!({ + "version": 1, + "pages": page_ids, + }); + write_json_atomic(&path, &value) +} + +fn write_trash_index_metadata( + root: &Path, + entries: &BTreeMap, +) -> Result<(), WebError> { + let mnote_dir = root.join(".mnote"); + fs::create_dir_all(&mnote_dir).map_err(|error| { + WebError::bad_request_code( + "local_metadata_write_failed", + format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()), + ) + })?; + let path = mnote_dir.join("trash-index.json"); + let value = json!({ + "version": 1, + "entries": entries, + }); + write_json_atomic(&path, &value) +} + +fn write_page_options_metadata( + root: &Path, + page_options: &BTreeMap, +) -> Result<(), WebError> { + let mnote_dir = root.join(".mnote"); + fs::create_dir_all(&mnote_dir).map_err(|error| { + WebError::bad_request_code( + "local_metadata_write_failed", + format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()), + ) + })?; + let path = mnote_dir.join("page-options.json"); + let value = json!({ + "version": 1, + "pages": page_options, + }); + write_json_atomic(&path, &value) +} + +#[allow(dead_code)] +fn write_json_atomic(path: &Path, value: &Value) -> Result<(), WebError> { + let tmp_path = path.with_extension("json.tmp"); + let bytes = serde_json::to_vec_pretty(value) + .map_err(|error| WebError::internal(format!("本地元数据序列化失败: {error}")))?; + fs::write(&tmp_path, bytes).map_err(|error| { + WebError::bad_request_code( + "local_metadata_write_failed", + format!("无法写入临时元数据 {}: {error}", tmp_path.display()), + ) + })?; + fs::rename(&tmp_path, path).map_err(|error| { + let _ = fs::remove_file(&tmp_path); + WebError::bad_request_code( + "local_metadata_write_failed", + format!("无法替换本地元数据 {}: {error}", path.display()), + ) + }) +} + +fn scan_directory( + root: &Path, + directory: &Path, + parent_node_id: Option, + depth: u32, + root_source_uri: &str, + metadata: &LocalFolderMetadata, + rows: &mut Vec, +) -> Result<(), WebError> { + let entries = read_sorted_entries(directory, root)?; + let entry_count = entries.len(); + for (position, entry) in entries.into_iter().enumerate() { + let node_id = local_node_id(if entry.relative_path.is_empty() { + "." + } else { + &entry.relative_path + }); + let child_count = if entry.is_dir && !entry.is_symlink { + count_visible_children(&entry.path, root)? + } else { + 0 + }; + let row_kind = if entry.is_dir { + "folder".to_string() + } else if is_markdown_file(&entry.file_name) { + "markdown".to_string() + } else { + "asset".to_string() + }; + let icon_hint = icon_hint_for_entry(&entry); + rows.push(LocalFolderRow { + node_id: node_id.clone(), + row_id: format!("local:{row_kind}:{}", entry.relative_path), + parent_node_id: parent_node_id.clone(), + title: entry.file_name.clone(), + depth, + position: position as u32, + row_kind, + icon_hint, + relative_path: entry.relative_path.clone(), + source_uri: file_uri_for_path(&entry.path), + child_count, + expandable: entry.is_dir && child_count > 0, + expanded_by_default: depth < 1 && entry.is_dir && entry_count <= 80, + document_id: if is_markdown_file(&entry.file_name) { + Some(local_markdown_page_id(&entry.relative_path, None, metadata)) + } else { + None + }, + capabilities: local_entry_capabilities(&entry), + }); + if entry.is_dir && !entry.is_symlink { + scan_directory( + root, + &entry.path, + Some(node_id), + depth + 1, + root_source_uri, + metadata, + rows, + )?; + } + } + + if rows.is_empty() && directory == root { + rows.push(LocalFolderRow { + node_id: local_node_id("."), + row_id: "local:folder:.".to_string(), + parent_node_id: None, + title: root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("local folder") + .to_string(), + depth: 0, + position: 0, + row_kind: "folder".to_string(), + icon_hint: "folder".to_string(), + relative_path: ".".to_string(), + source_uri: root_source_uri.to_string(), + child_count: 0, + expandable: false, + expanded_by_default: false, + document_id: None, + capabilities: local_path_capabilities(root), + }); + } + + Ok(()) +} + +fn read_sorted_entries(directory: &Path, root: &Path) -> Result, WebError> { + let read_dir = fs::read_dir(directory).map_err(|error| { + WebError::bad_request_code( + "local_folder_scan_failed", + format!("无法读取本地目录 {}: {error}", directory.display()), + ) + })?; + let mut entries = Vec::new(); + for entry in read_dir { + let entry = entry.map_err(|error| { + WebError::bad_request_code( + "local_folder_scan_failed", + format!("读取目录项失败: {error}"), + ) + })?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| { + WebError::bad_request_code( + "local_folder_scan_failed", + format!("无法读取目录项元数据 {}: {error}", path.display()), + ) + })?; + let file_name = entry.file_name().to_string_lossy().to_string(); + let relative_path = normalize_relative_path(root, &path)?; + if should_ignore_entry(&relative_path, &file_name) { + continue; + } + let is_symlink = metadata.file_type().is_symlink(); + let is_dir = if is_symlink { false } else { metadata.is_dir() }; + let is_readonly = metadata.permissions().readonly(); + entries.push(LocalFolderEntry { + path, + relative_path, + file_name, + is_dir, + is_symlink, + is_readonly, + }); + } + entries.sort_by(compare_local_entries); + Ok(entries) +} + +fn count_visible_children(directory: &Path, root: &Path) -> Result { + Ok(read_sorted_entries(directory, root)?.len() as u32) +} + +fn local_entry_capabilities(entry: &LocalFolderEntry) -> Vec { + let mut capabilities = Vec::new(); + if entry.is_readonly { + capabilities.push("readonly".to_string()); + } + if entry.is_symlink { + capabilities.push("symlink".to_string()); + } + capabilities +} + +fn local_path_capabilities(path: &Path) -> Vec { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.permissions().readonly() => vec!["readonly".to_string()], + _ => Vec::new(), + } +} + +fn normalize_relative_path(root: &Path, path: &Path) -> Result { + if !path.starts_with(root) { + return Err(WebError::bad_request_code( + "local_folder_root_escape", + "本地文件夹扫描不能越过 root", + )); + } + let relative = path.strip_prefix(root).map_err(|_| { + WebError::bad_request_code("local_folder_root_escape", "本地路径不在 root 内") + })?; + Ok(relative + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect::>() + .join("/")) +} + +fn resolve_metadata_relative_path(root: &Path, relative_path: &str) -> Result { + let relative = Path::new(relative_path); + if relative.is_absolute() + || relative + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(WebError::bad_request_code( + "local_folder_root_escape", + "本地元数据路径不能越过 root", + )); + } + let target = root.join(relative); + if !target.starts_with(root) { + return Err(WebError::bad_request_code( + "local_folder_root_escape", + "本地元数据路径不在 root 内", + )); + } + Ok(target) +} + +fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool { + if matches!(file_name, ".git" | "node_modules" | ".mnote") { + return true; + } + relative_path == ".mnote/trash" || relative_path.starts_with(".mnote/trash/") +} + +fn compare_local_entries(a: &LocalFolderEntry, b: &LocalFolderEntry) -> Ordering { + match (a.is_dir, b.is_dir) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + _ => a + .file_name + .to_ascii_lowercase() + .cmp(&b.file_name.to_ascii_lowercase()) + .then_with(|| a.file_name.cmp(&b.file_name)), + } +} + +fn scan_markdown_page_tree( + root: &Path, + directory: &Path, + parent_node_id: Option, + depth: u32, + metadata: &LocalFolderMetadata, + rows: &mut Vec, +) -> Result { + let entries = read_sorted_entries(directory, root)?; + let mut directory_rows = Vec::::new(); + let mut contains_markdown = false; + for (position, entry) in entries.into_iter().enumerate() { + if entry.is_dir && !entry.is_symlink { + let node_id = local_directory_group_id(&entry.relative_path); + let mut child_rows = Vec::new(); + let child_contains_markdown = scan_markdown_page_tree( + root, + &entry.path, + Some(node_id.clone()), + depth + 1, + metadata, + &mut child_rows, + )?; + if child_contains_markdown { + directory_rows.push(LocalFolderRow { + node_id, + row_id: format!("local:page-group:{}", entry.relative_path), + parent_node_id: parent_node_id.clone(), + title: entry.file_name.clone(), + depth, + position: position as u32, + row_kind: "document".to_string(), + icon_hint: "folder".to_string(), + relative_path: entry.relative_path.clone(), + source_uri: file_uri_for_path(&entry.path), + child_count: 0, + expandable: true, + expanded_by_default: depth < 2, + document_id: None, + capabilities: local_entry_capabilities(&entry), + }); + directory_rows.extend(child_rows); + contains_markdown = true; + } + continue; + } + if is_markdown_file(&entry.file_name) { + let markdown = fs::read_to_string(&entry.path).unwrap_or_default(); + let parsed = parse_markdown_page(&markdown, &entry.file_name); + let page_id = + local_markdown_page_id(&entry.relative_path, parsed.mnote_id.as_deref(), metadata); + directory_rows.push(LocalFolderRow { + node_id: page_id.clone(), + row_id: format!("local:page:{}", entry.relative_path), + parent_node_id: parent_node_id.clone(), + title: parsed.title, + depth, + position: position as u32, + row_kind: "document".to_string(), + icon_hint: "markdown".to_string(), + relative_path: entry.relative_path.clone(), + source_uri: file_uri_for_path(&entry.path), + child_count: 0, + expandable: false, + expanded_by_default: false, + document_id: Some(page_id), + capabilities: local_entry_capabilities(&entry), + }); + contains_markdown = true; + } + } + rows.extend(directory_rows); + Ok(contains_markdown) +} + +fn find_markdown_by_page_id( + root: &Path, + metadata: &LocalFolderMetadata, + document_id: &str, +) -> Result, WebError> { + fn walk( + root: &Path, + directory: &Path, + metadata: &LocalFolderMetadata, + document_id: &str, + ) -> Result, WebError> { + for entry in read_sorted_entries(directory, root)? { + if entry.is_dir && !entry.is_symlink { + if let Some(found) = walk(root, &entry.path, metadata, document_id)? { + return Ok(Some(found)); + } + continue; + } + if !is_markdown_file(&entry.file_name) { + continue; + } + let markdown = fs::read_to_string(&entry.path).unwrap_or_default(); + let parsed = parse_markdown_page(&markdown, &entry.file_name); + let page_id = + local_markdown_page_id(&entry.relative_path, parsed.mnote_id.as_deref(), metadata); + if page_id == document_id { + return Ok(Some(entry)); + } + } + Ok(None) + } + + walk(root, root, metadata, document_id) +} + +fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value { + let mut item = json!({ + "rowId": row.row_id, + "rowKind": row.row_kind, + "nodeId": row.node_id, + "parentNodeId": row.parent_node_id, + "title": row.title, + "depth": row.depth, + "position": row.position, + "childCount": row.child_count, + "expandable": row.expandable, + "expandedByDefault": row.expanded_by_default, + "capabilities": row.capabilities.clone(), + "iconHint": row.icon_hint, + "resourceMeta": { + "resourceKind": row.row_kind, + "iconHint": row.icon_hint, + "extra": { + "source": { + "sourceKind": "local_folder", + "sourceUri": row.source_uri, + "relativePath": row.relative_path, + "storageIdentity": { + "kind": "file_path", + "path": row.relative_path, + }, + "operationProfile": "local_readonly", + } + } + } + }); + if let Some(document_id) = row.document_id.as_ref() { + item["resourceMeta"]["documentId"] = Value::String(document_id.clone()); + } + item +} + +fn local_node_id(relative_path: &str) -> String { + format!("local:node:{relative_path}") +} + +fn local_directory_group_id(relative_path: &str) -> String { + format!("local-dir:{}", encode_local_id_segment(relative_path)) +} + +fn local_markdown_page_id( + relative_path: &str, + mnote_id: Option<&str>, + metadata: &LocalFolderMetadata, +) -> String { + if let Some(mnote_id) = mnote_id.map(str::trim).filter(|value| !value.is_empty()) { + if mnote_id.starts_with("local-mdid:") || mnote_id.starts_with("local-md:") { + return mnote_id.to_string(); + } + return format!("local-mdid:{}", encode_local_id_segment(mnote_id)); + } + if let Some(page_id) = metadata + .page_ids + .get(relative_path) + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return normalize_metadata_page_id(page_id); + } + format!("local-md:{}", encode_local_id_segment(relative_path)) +} + +fn normalize_metadata_page_id(page_id: &str) -> String { + let trimmed = page_id.trim(); + if trimmed.starts_with("local-md:") || trimmed.starts_with("local-mdid:") { + trimmed.to_string() + } else { + format!("local-mdid:{}", encode_local_id_segment(trimmed)) + } +} + +fn page_options_from_metadata(value: &Value) -> PageOptions { + let mut options = PageOptions::default(); + if let Some(wide_layout) = value + .get("wideLayout") + .or_else(|| value.get("wide_layout")) + .and_then(Value::as_bool) + { + options.wide_layout = wide_layout; + } + if let Some(small_text) = value + .get("smallText") + .or_else(|| value.get("small_text")) + .and_then(Value::as_bool) + { + options.small_text = small_text; + } + if let Some(layout_density) = value + .get("layoutDensity") + .or_else(|| value.get("layout_density")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|item| !item.is_empty()) + { + options.layout_density = layout_density.to_string(); + } + if let Some(show_toc) = value + .get("showToc") + .or_else(|| value.get("show_toc")) + .and_then(Value::as_bool) + { + options.show_toc = show_toc; + } + if let Some(show_structure) = value + .get("showStructure") + .or_else(|| value.get("show_structure")) + .and_then(Value::as_bool) + { + options.show_structure = show_structure; + } + if let Some(show_heading_numbers) = value + .get("showHeadingNumbers") + .or_else(|| value.get("show_heading_numbers")) + .and_then(Value::as_bool) + { + options.show_heading_numbers = show_heading_numbers; + } + if let Some(protect_editing) = value + .get("protectEditing") + .or_else(|| value.get("protect_editing")) + .and_then(Value::as_bool) + { + options.protect_editing = protect_editing; + } + options +} + +fn local_workspace_id(root: &Path) -> String { + format!( + "local:{}", + root.to_string_lossy() + .chars() + .map(|character| match character { + '/' | '\\' | ':' | ' ' => '_', + value if value.is_ascii_alphanumeric() || matches!(value, '_' | '-' | '.') => value, + _ => '_', + }) + .collect::() + ) +} + +fn local_folder_watch_revision_for_root( + root: &Path, + root_source_uri: &str, +) -> Result { + let mut hasher = DefaultHasher::new(); + let mut entry_count = 0usize; + let mut latest_modified_ms = 0u128; + + fn visit( + root: &Path, + directory: &Path, + hasher: &mut DefaultHasher, + entry_count: &mut usize, + latest_modified_ms: &mut u128, + ) -> Result<(), WebError> { + for entry in read_sorted_entries(directory, root)? { + entry.relative_path.hash(hasher); + entry.file_name.hash(hasher); + entry.is_dir.hash(hasher); + entry.is_symlink.hash(hasher); + entry.is_readonly.hash(hasher); + if let Ok(meta) = fs::symlink_metadata(&entry.path) { + meta.len().hash(hasher); + if let Ok(modified) = meta.modified() { + if let Ok(delta) = modified.duration_since(UNIX_EPOCH) { + let modified_ms = delta.as_millis(); + modified_ms.hash(hasher); + if modified_ms > *latest_modified_ms { + *latest_modified_ms = modified_ms; + } + } + } + } + *entry_count += 1; + if entry.is_dir && !entry.is_symlink { + visit(root, &entry.path, hasher, entry_count, latest_modified_ms)?; + } + } + Ok(()) + } + + visit( + root, + root, + &mut hasher, + &mut entry_count, + &mut latest_modified_ms, + )?; + Ok(LocalFolderWatchRevision { + root_uri: root_source_uri.to_string(), + revision: format!("{:016x}", hasher.finish()), + entry_count, + latest_modified_ms, + }) +} + +fn file_uri_for_path(path: &Path) -> String { + format!("file://{}", path.display()) +} + +fn is_markdown_file(file_name: &str) -> bool { + file_name.to_ascii_lowercase().ends_with(".md") +} + +fn icon_hint_for_entry(entry: &LocalFolderEntry) -> String { + if entry.is_dir { + return "folder".to_string(); + } + if entry.is_symlink { + return "symlink".to_string(); + } + let lower = entry.file_name.to_ascii_lowercase(); + if lower.ends_with(".md") || lower.ends_with(".markdown") { + "markdown" + } else if matches!( + extension(&lower).as_deref(), + Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp") + ) { + "image" + } else if matches!( + extension(&lower).as_deref(), + Some("mp4" | "webm" | "mov" | "mkv") + ) { + "video" + } else if matches!( + extension(&lower).as_deref(), + Some("mp3" | "wav" | "flac" | "ogg") + ) { + "audio" + } else if lower.ends_with(".pdf") { + "pdf" + } else if matches!(extension(&lower).as_deref(), Some("epub" | "mobi")) { + "book" + } else if matches!( + extension(&lower).as_deref(), + Some("csv" | "tsv" | "xlsx" | "xls") + ) { + "table" + } else { + "unknown" + } + .to_string() +} + +fn extension(file_name: &str) -> Option { + Path::new(file_name) + .extension() + .and_then(|extension| extension.to_str()) + .map(ToOwned::to_owned) +} + +fn encode_local_id_segment(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.as_bytes() { + let character = *byte as char; + if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { + encoded.push(character); + } else { + encoded.push('~'); + encoded.push_str(&format!("{byte:02X}")); + } + } + encoded +} + +fn decode_local_id_segment(value: &str) -> Result { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'~' { + if index + 2 >= bytes.len() { + return Err(WebError::bad_request_code( + "local_id_invalid", + "本地 page id 编码不完整", + )); + } + let hex = &value[index + 1..index + 3]; + let byte = u8::from_str_radix(hex, 16).map_err(|_| { + WebError::bad_request_code("local_id_invalid", "本地 page id 编码非法") + })?; + decoded.push(byte); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded) + .map_err(|_| WebError::bad_request_code("local_id_invalid", "本地 page id 不是 UTF-8")) +} + +#[derive(Debug, Clone)] +struct ParsedMarkdownPage { + title: String, + mnote_id: Option, + body: String, +} + +fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedMarkdownPage { + let (frontmatter, body) = split_frontmatter(markdown); + let frontmatter_title = frontmatter + .as_deref() + .and_then(|content| read_frontmatter_field(content, "title")); + let mnote_id = frontmatter + .as_deref() + .and_then(|content| read_frontmatter_field(content, "mnote_id")); + let h1_title = body.lines().find_map(|line| { + let trimmed = line.trim(); + trimmed + .strip_prefix("# ") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }); + let title = frontmatter_title + .or(h1_title) + .unwrap_or_else(|| file_stem_title(file_name)); + ParsedMarkdownPage { + title, + mnote_id, + body: body.to_string(), + } +} + +fn split_frontmatter(markdown: &str) -> (Option, &str) { + let normalized = markdown.strip_prefix('\u{feff}').unwrap_or(markdown); + if !normalized.starts_with("---\n") { + return (None, normalized); + } + let rest = &normalized[4..]; + if let Some(end) = rest.find("\n---\n") { + let frontmatter = rest[..end].to_string(); + let body = &rest[end + 5..]; + return (Some(frontmatter), body); + } + (None, normalized) +} + +fn read_frontmatter_field(frontmatter: &str, key: &str) -> Option { + frontmatter.lines().find_map(|line| { + let (candidate_key, value) = line.split_once(':')?; + if candidate_key.trim() != key { + return None; + } + let trimmed = value.trim().trim_matches('"').trim_matches('\'').trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn file_stem_title(file_name: &str) -> String { + Path::new(file_name) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(file_name) + .to_string() +} + +fn rewrite_page_ids_prefix( + page_ids: &mut BTreeMap, + old_relative_path: &str, + new_relative_path: &str, +) { + let old_prefix = format!("{old_relative_path}/"); + let replacements = page_ids + .iter() + .filter_map(|(relative_path, page_id)| { + if relative_path == old_relative_path { + Some(( + relative_path.clone(), + new_relative_path.to_string(), + page_id.clone(), + )) + } else if let Some(rest) = relative_path.strip_prefix(&old_prefix) { + Some(( + relative_path.clone(), + format!("{new_relative_path}/{rest}"), + page_id.clone(), + )) + } else { + None + } + }) + .collect::>(); + for (old_key, new_key, page_id) in replacements { + page_ids.remove(&old_key); + page_ids.insert(new_key, page_id); + } +} + +fn sanitize_file_stem(value: &str, fallback: &str) -> String { + let sanitized = value + .trim() + .chars() + .map(|character| { + if matches!( + character, + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' + ) { + '_' + } else { + character + } + }) + .collect::(); + let trimmed = sanitized.trim().trim_matches('.').trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.to_string() + } +} + +fn sanitize_file_name(value: &str, fallback: &str) -> String { + let sanitized = value + .trim() + .chars() + .map(|character| { + if matches!( + character, + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' + ) { + '_' + } else { + character + } + }) + .collect::(); + let trimmed = sanitized.trim().trim_matches('.').trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.to_string() + } +} + +fn next_available_path(directory: &Path, stem: &str, extension: &str) -> PathBuf { + let first = directory.join(format!("{stem}.{extension}")); + if !first.exists() { + return first; + } + for index in 2..10_000 { + let candidate = directory.join(format!("{stem} {index}.{extension}")); + if !candidate.exists() { + return candidate; + } + } + directory.join(format!("{stem}-{}.{}", std::process::id(), extension)) +} + +fn next_available_raw_path(directory: &Path, file_name: &str) -> PathBuf { + let first = directory.join(file_name); + if !first.exists() { + return first; + } + let path = Path::new(file_name); + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(file_name); + let extension = path.extension().and_then(|value| value.to_str()); + for index in 2..10_000 { + let candidate_name = if let Some(extension) = extension { + format!("{stem} {index}.{extension}") + } else { + format!("{stem} {index}") + }; + let candidate = directory.join(candidate_name); + if !candidate.exists() { + return candidate; + } + } + directory.join(format!("{stem}-{}", std::process::id())) +} + +fn next_available_directory_path(directory: &Path, stem: &str) -> PathBuf { + let first = directory.join(stem); + if !first.exists() { + return first; + } + for index in 2..10_000 { + let candidate = directory.join(format!("{stem} {index}")); + if !candidate.exists() { + return candidate; + } + } + directory.join(format!("{stem}-{}", std::process::id())) +} + +fn markdown_with_frontmatter_fields(markdown: &str, fields: &[(&str, &str)]) -> String { + let (frontmatter, body) = split_frontmatter(markdown); + let mut lines = frontmatter + .unwrap_or_default() + .lines() + .map(ToOwned::to_owned) + .collect::>(); + for (key, value) in fields { + let replacement = format!("{key}: {}", value.trim()); + if let Some(existing) = lines.iter_mut().find(|line| { + line.split_once(':') + .map(|(candidate, _)| candidate.trim() == *key) + .unwrap_or(false) + }) { + *existing = replacement; + } else { + lines.push(replacement); + } + } + format!("---\n{}\n---\n{}", lines.join("\n"), body) +} + +fn editor_blocks_to_markdown(content: &Value) -> String { + let blocks = if let Some(array) = content.as_array() { + array.clone() + } else { + content + .get("blocks") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + }; + let mut lines = Vec::new(); + for block in blocks { + let block_type = block + .get("type") + .or_else(|| block.get("blockType")) + .and_then(Value::as_str) + .unwrap_or("paragraph"); + let inline_markdown = block_content_value(&block) + .map(inline_nodes_to_markdown) + .unwrap_or_default() + .trim() + .to_string(); + let text = if inline_markdown.is_empty() { + extract_block_text(&block).trim().to_string() + } else { + inline_markdown + }; + if text.is_empty() && !matches!(block_type, "divider" | "media" | "table") { + lines.push(String::new()); + continue; + } + match block_type { + "media" => { + let props = block.get("props").and_then(Value::as_object); + let url = props + .and_then(|props| { + props + .get("sourcePath") + .or_else(|| props.get("url")) + .or_else(|| props.get("src")) + }) + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + let name = props + .and_then(|props| props.get("name").or_else(|| props.get("fileName"))) + .and_then(Value::as_str) + .unwrap_or(text.as_str()) + .trim(); + if url.is_empty() { + lines.push(text); + } else { + lines.push(format!( + "[{}]({url})", + if name.is_empty() { url } else { name } + )); + } + } + "heading" => { + let level = block + .get("props") + .and_then(|props| props.get("level")) + .and_then(Value::as_u64) + .unwrap_or(1) + .clamp(1, 6); + lines.push(format!("{} {text}", "#".repeat(level as usize))); + } + "bulletListItem" | "bullet_list_item" | "list_item" => { + lines.push(format!("- {text}")); + } + "numberedListItem" | "numbered_list_item" => { + lines.push(format!("1. {text}")); + } + "todo" | "checkListItem" | "advancedTodo" => { + let checked = block + .get("props") + .and_then(|props| props.get("checked")) + .and_then(Value::as_bool) + .unwrap_or(false); + let checkbox = if checked { "[x]" } else { "[ ]" }; + lines.push(format!("- {checkbox} {text}")); + } + "quote" | "blockquote" => { + lines.push(format!("> {text}")); + } + "codeBlock" | "code_block" | "code" => { + lines.push(format!("```\n{text}\n```")); + } + "divider" => { + lines.push("---".to_string()); + } + "table" => { + let table_markdown = editor_block_table_to_markdown(&block); + if table_markdown.is_empty() { + lines.push(text); + } else { + lines.extend(table_markdown.lines().map(ToOwned::to_owned)); + } + } + _ => lines.push(text), + } + lines.push(String::new()); + } + let markdown = lines.join("\n").trim_end().to_string(); + if markdown.is_empty() { + "\n".to_string() + } else { + format!("{markdown}\n") + } +} + +fn editor_block_table_to_markdown(block: &Value) -> String { + let table = block + .get("props") + .and_then(|props| props.get("tiptapTable")) + .or_else(|| block.get("tiptapTable")); + let rows = table + .and_then(|value| value.get("content")) + .and_then(Value::as_array); + let Some(rows) = rows else { + return String::new(); + }; + let mut parsed_rows = Vec::>::new(); + let mut max_columns = 0usize; + for row in rows { + let cells = row + .get("content") + .and_then(Value::as_array) + .map(|cells| { + cells + .iter() + .map(|cell| { + let cell_text = + extract_block_text(cell.get("content").unwrap_or(&Value::Null)) + .replace('\n', " ") + .replace('|', r"\|") + .trim() + .to_string(); + let is_header = + cell.get("type").and_then(Value::as_str) == Some("tableHeader"); + (cell_text, is_header) + }) + .collect::>() + }) + .unwrap_or_default(); + max_columns = max_columns.max(cells.len()); + parsed_rows.push(cells); + } + if parsed_rows.is_empty() || max_columns == 0 { + return String::new(); + } + let mut lines = Vec::::new(); + for (index, row) in parsed_rows.iter().enumerate() { + let mut cells = row.iter().map(|(text, _)| text.clone()).collect::>(); + while cells.len() < max_columns { + cells.push(String::new()); + } + lines.push(format!("| {} |", cells.join(" | "))); + if index == 0 { + lines.push(format!( + "| {} |", + std::iter::repeat("---") + .take(max_columns) + .collect::>() + .join(" | ") + )); + } + } + if lines.len() < 2 { + String::new() + } else { + lines.join("\n") + } +} + +fn extract_block_text(value: &Value) -> String { + if let Some(text) = value.as_str() { + return text.to_string(); + } + if let Some(array) = value.as_array() { + return array + .iter() + .map(extract_block_text) + .collect::>() + .join(""); + } + if let Some(object) = value.as_object() { + if let Some(text) = object.get("text").and_then(Value::as_str) { + return text.to_string(); + } + if let Some(content) = object.get("content") { + return extract_block_text(content); + } + if let Some(children) = object.get("children") { + return extract_block_text(children); + } + } + String::new() +} + +fn block_content_value(block: &Value) -> Option<&Value> { + block.get("content").or_else(|| block.get("contentNodes")) +} + +fn inline_nodes_to_markdown(value: &Value) -> String { + if let Some(text) = value.as_str() { + return escape_markdown_inline_text(text); + } + if let Some(array) = value.as_array() { + return array + .iter() + .map(inline_node_to_markdown) + .collect::>() + .join(""); + } + if let Some(object) = value.as_object() { + if let Some(text) = object.get("text").and_then(Value::as_str) { + return markdown_text_with_styles(text, object.get("styles").unwrap_or(&Value::Null)); + } + if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) { + return inline_nodes_to_markdown(content); + } + } + String::new() +} + +fn inline_node_to_markdown(node: &Value) -> String { + if let Some(text) = node.as_str() { + return escape_markdown_inline_text(text); + } + if let Some(object) = node.as_object() { + let text = object + .get("text") + .and_then(Value::as_str) + .unwrap_or_default(); + if !text.is_empty() { + return markdown_text_with_styles(text, object.get("styles").unwrap_or(&Value::Null)); + } + if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) { + return inline_nodes_to_markdown(content); + } + } + String::new() +} + +fn markdown_text_with_styles(text: &str, styles: &Value) -> String { + let mut value = escape_markdown_inline_text(text); + let link = styles + .get("link") + .or_else(|| styles.get("href")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|href| !href.is_empty()) + .map(ToOwned::to_owned); + if styles + .get("code") + .or_else(|| styles.get("inlineCode")) + .and_then(Value::as_bool) + .unwrap_or(false) + { + value = format!("`{}`", text.replace('`', r"\`")); + } + if styles.get("bold").and_then(Value::as_bool).unwrap_or(false) { + value = format!("**{value}**"); + } + if styles + .get("italic") + .and_then(Value::as_bool) + .unwrap_or(false) + { + value = format!("*{value}*"); + } + if styles + .get("strike") + .or_else(|| styles.get("strikethrough")) + .and_then(Value::as_bool) + .unwrap_or(false) + { + value = format!("~~{value}~~"); + } + if let Some(href) = link { + value = format!("[{}]({href})", value.replace(']', r"\]")); + } + value +} + +fn escape_markdown_inline_text(text: &str) -> String { + text.replace('\\', r"\\") +} + +fn now_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0) +} + +fn markdown_to_blocks(markdown: &str) -> Value { + let mut blocks = Vec::new(); + let mut paragraph = Vec::::new(); + let mut code_language = String::new(); + let mut code_lines = Vec::::new(); + let lines = markdown.lines().collect::>(); + let mut index = 0usize; + while index < lines.len() { + let line = lines[index]; + let trimmed = line.trim(); + if let Some(language) = trimmed.strip_prefix("```") { + if code_lines.is_empty() && code_language.is_empty() { + push_paragraph_block(&mut blocks, &mut paragraph); + code_language = language.trim().to_string(); + code_lines.push(String::new()); + index += 1; + continue; + } + let text = code_lines + .iter() + .skip(1) + .cloned() + .collect::>() + .join("\n"); + blocks.push(json!({ + "id": format!("local-block-{}", blocks.len() + 1), + "type": "codeBlock", + "props": { "language": code_language }, + "content": [{ "type": "text", "text": text, "styles": {} }], + "children": [], + })); + code_language.clear(); + code_lines.clear(); + index += 1; + continue; + } + if !code_language.is_empty() || !code_lines.is_empty() { + code_lines.push(line.to_string()); + index += 1; + continue; + } + if trimmed.is_empty() { + push_paragraph_block(&mut blocks, &mut paragraph); + index += 1; + continue; + } + if matches!(trimmed, "---" | "***" | "___") { + push_paragraph_block(&mut blocks, &mut paragraph); + blocks.push(json!({ + "id": format!("local-block-{}", blocks.len() + 1), + "type": "divider", + "content": [], + "children": [], + })); + index += 1; + continue; + } + if let Some((level, title)) = parse_markdown_heading(trimmed) { + push_paragraph_block(&mut blocks, &mut paragraph); + blocks.push(json!({ + "id": format!("local-block-{}", blocks.len() + 1), + "type": "heading", + "props": { "level": level }, + "content": parse_inline_markdown(&title), + "children": [], + })); + index += 1; + continue; + } + if let Some((checked, text)) = parse_task_list_item(trimmed) { + push_paragraph_block(&mut blocks, &mut paragraph); + blocks.push(json!({ + "id": format!("local-block-{}", blocks.len() + 1), + "type": "todo", + "props": { "checked": checked }, + "content": parse_inline_markdown(text), + "children": [], + })); + index += 1; + continue; + } + if let Some(text) = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* ")) + { + push_paragraph_block(&mut blocks, &mut paragraph); + push_text_block(&mut blocks, "bulletListItem", text.trim()); + index += 1; + continue; + } + if let Some(text) = parse_numbered_list_item(trimmed) { + push_paragraph_block(&mut blocks, &mut paragraph); + push_text_block(&mut blocks, "numberedListItem", text); + index += 1; + continue; + } + if let Some(text) = trimmed.strip_prefix("> ") { + push_paragraph_block(&mut blocks, &mut paragraph); + push_text_block(&mut blocks, "quote", text.trim()); + index += 1; + continue; + } + if let Some((name, source_path)) = parse_markdown_attachment_link(trimmed) { + push_paragraph_block(&mut blocks, &mut paragraph); + blocks.push(json!({ + "id": format!("local-block-{}", blocks.len() + 1), + "type": "media", + "props": { + "name": name, + "fileName": name, + "sourcePath": source_path, + "url": source_path, + "assetId": "", + "sourceKind": "local_folder", + }, + "content": [{ "type": "text", "text": name, "styles": {} }], + "children": [], + })); + index += 1; + continue; + } + if let Some((table_block, consumed)) = + parse_pipe_table_block(&lines, index, blocks.len() + 1) + { + push_paragraph_block(&mut blocks, &mut paragraph); + blocks.push(table_block); + index += consumed; + continue; + } + paragraph.push(trimmed.to_string()); + index += 1; + } + if !code_language.is_empty() || !code_lines.is_empty() { + let text = code_lines + .iter() + .skip(1) + .cloned() + .collect::>() + .join("\n"); + blocks.push(json!({ + "id": format!("local-block-{}", blocks.len() + 1), + "type": "codeBlock", + "props": { "language": code_language }, + "content": [{ "type": "text", "text": text, "styles": {} }], + "children": [], + })); + } + push_paragraph_block(&mut blocks, &mut paragraph); + Value::Array(blocks) +} + +fn parse_pipe_table_block( + lines: &[&str], + start_index: usize, + block_number: usize, +) -> Option<(Value, usize)> { + let header_line = lines.get(start_index)?.trim(); + if !header_line.contains('|') || header_line.starts_with("```") { + return None; + } + let separator_index = start_index + 1; + let separator_line = lines.get(separator_index)?.trim(); + if !is_pipe_table_separator(separator_line) { + return None; + } + let mut table_rows = Vec::>::new(); + let header_cells = split_pipe_table_row(header_line); + if header_cells.is_empty() { + return None; + } + table_rows.push(header_cells.into_iter().map(|cell| (cell, true)).collect()); + let mut consumed = 2usize; + let mut current_index = start_index + 2; + while let Some(raw_line) = lines.get(current_index) { + let trimmed = raw_line.trim(); + if trimmed.is_empty() || !trimmed.contains('|') { + break; + } + if trimmed.starts_with("```") { + break; + } + let row_cells = split_pipe_table_row(trimmed); + if row_cells.is_empty() { + break; + } + table_rows.push(row_cells.into_iter().map(|cell| (cell, false)).collect()); + consumed += 1; + current_index += 1; + } + if table_rows.len() < 2 { + return None; + } + let max_columns = table_rows.iter().map(|row| row.len()).max().unwrap_or(0); + if max_columns == 0 { + return None; + } + let mut content_rows = Vec::new(); + for (row_index, row) in table_rows.into_iter().enumerate() { + let mut cells = row; + while cells.len() < max_columns { + cells.push((String::new(), row_index == 0)); + } + let cell_nodes = cells + .into_iter() + .map(|(text, is_header)| { + let cell_type = if row_index == 0 || is_header { + "tableHeader" + } else { + "tableCell" + }; + json!({ + "type": cell_type, + "attrs": { "colspan": 1, "rowspan": 1, "colwidth": null }, + "content": [{ + "type": "paragraph", + "content": if text.is_empty() { + Vec::::new() + } else { + parse_inline_markdown_for_tiptap(&text) + } + }] + }) + }) + .collect::>(); + content_rows.push(json!({ + "type": "tableRow", + "content": cell_nodes, + })); + } + Some(( + json!({ + "id": format!("local-block-{block_number}"), + "type": "table", + "props": { + "tiptapTable": { + "type": "table", + "attrs": { + "blockId": format!("local-block-{block_number}"), + }, + "content": content_rows, + } + }, + "content": [], + "children": [], + }), + consumed, + )) +} + +fn split_pipe_table_row(line: &str) -> Vec { + let trimmed = line.trim().trim_matches('|'); + if trimmed.is_empty() { + return Vec::new(); + } + trimmed + .split('|') + .map(|cell| cell.trim().to_string()) + .collect::>() +} + +fn is_pipe_table_separator(line: &str) -> bool { + let trimmed = line.trim().trim_matches('|'); + if trimmed.is_empty() { + return false; + } + trimmed.split('|').all(|cell| { + let cell = cell.trim(); + !cell.is_empty() + && cell + .chars() + .all(|character| matches!(character, '-' | ':' | ' ')) + && cell.chars().any(|character| character == '-') + }) +} + +fn parse_markdown_heading(trimmed: &str) -> Option<(u64, String)> { + let hashes = trimmed + .chars() + .take_while(|character| *character == '#') + .count(); + if !(1..=6).contains(&hashes) { + return None; + } + let rest = trimmed.get(hashes..)?.strip_prefix(' ')?; + let title = rest.trim(); + if title.is_empty() { + None + } else { + Some((hashes as u64, title.to_string())) + } +} + +fn parse_numbered_list_item(trimmed: &str) -> Option<&str> { + let (prefix, rest) = trimmed.split_once(". ")?; + if prefix.chars().all(|character| character.is_ascii_digit()) && !rest.trim().is_empty() { + Some(rest.trim()) + } else { + None + } +} + +fn parse_task_list_item(trimmed: &str) -> Option<(bool, &str)> { + let text = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* "))? + .trim_start(); + let (checked, rest) = if let Some(rest) = text.strip_prefix("[x]") { + (true, rest) + } else if let Some(rest) = text.strip_prefix("[X]") { + (true, rest) + } else if let Some(rest) = text.strip_prefix("[ ]") { + (false, rest) + } else { + return None; + }; + let content = rest.trim(); + if content.is_empty() { + None + } else { + Some((checked, content)) + } +} + +fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> { + let value = trimmed + .strip_prefix("!") + .unwrap_or(trimmed) + .strip_prefix('[')?; + let (label, rest) = value.split_once("](")?; + let target = rest.strip_suffix(')')?.trim(); + if target.is_empty() + || target.starts_with("http://") + || target.starts_with("https://") + || target.starts_with('#') + || target.starts_with("mailto:") + { + return None; + } + let target_path = Path::new(target); + let extension = target_path.extension().and_then(|value| value.to_str())?; + if extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") { + return None; + } + let fallback_name = target_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(target) + .trim(); + let name = if label.trim().is_empty() { + fallback_name + } else { + label.trim() + }; + Some((name.to_string(), target.to_string())) +} + +fn parse_inline_markdown(text: &str) -> Vec { + let mut nodes = Vec::::new(); + parse_inline_markdown_into(text, &Map::new(), &mut nodes); + merge_adjacent_inline_nodes(nodes) +} + +fn parse_inline_markdown_for_tiptap(text: &str) -> Vec { + parse_inline_markdown(text) + .into_iter() + .map(legacy_inline_node_to_tiptap) + .collect() +} + +fn legacy_inline_node_to_tiptap(mut node: Value) -> Value { + let marks = node + .get("styles") + .and_then(Value::as_object) + .map(tiptap_marks_from_styles) + .unwrap_or_default(); + if let Some(object) = node.as_object_mut() { + object.remove("styles"); + if !marks.is_empty() { + object.insert("marks".to_string(), Value::Array(marks)); + } + } + node +} + +fn tiptap_marks_from_styles(styles: &Map) -> Vec { + let mut marks = Vec::::new(); + if styles.get("bold").and_then(Value::as_bool).unwrap_or(false) { + marks.push(json!({ "type": "bold" })); + } + if styles + .get("italic") + .and_then(Value::as_bool) + .unwrap_or(false) + { + marks.push(json!({ "type": "italic" })); + } + if styles + .get("underline") + .and_then(Value::as_bool) + .unwrap_or(false) + { + marks.push(json!({ "type": "underline" })); + } + if styles + .get("strike") + .or_else(|| styles.get("strikethrough")) + .and_then(Value::as_bool) + .unwrap_or(false) + { + marks.push(json!({ "type": "strike" })); + } + if styles + .get("code") + .or_else(|| styles.get("inlineCode")) + .and_then(Value::as_bool) + .unwrap_or(false) + { + marks.push(json!({ "type": "code" })); + } + let href = styles + .get("link") + .or_else(|| styles.get("href")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|href| !href.is_empty()); + if let Some(href) = href { + marks.push(json!({ "type": "link", "attrs": { "href": href } })); + } + marks +} + +fn parse_inline_markdown_into( + text: &str, + active_styles: &Map, + nodes: &mut Vec, +) { + let mut offset = 0usize; + while offset < text.len() { + let rest = &text[offset..]; + let mut matched = false; + for marker in [ + InlineMarker::new("`", "`", "code", true), + InlineMarker::new("**", "**", "bold", false), + InlineMarker::new("__", "__", "bold", false), + InlineMarker::new("~~", "~~", "strike", false), + InlineMarker::new("*", "*", "italic", false), + InlineMarker::new("_", "_", "italic", false), + ] { + if !rest.starts_with(marker.open) { + continue; + } + let inner_start = offset + marker.open.len(); + let search_area = &text[inner_start..]; + let Some(inner_len) = search_area.find(marker.close) else { + continue; + }; + let inner = &search_area[..inner_len]; + if inner.is_empty() { + continue; + } + let mut styles = active_styles.clone(); + styles.insert(marker.style.to_string(), Value::Bool(true)); + if marker.literal { + push_inline_text_node(nodes, inner, &styles); + } else { + parse_inline_markdown_into(inner, &styles, nodes); + } + offset = inner_start + inner_len + marker.close.len(); + matched = true; + break; + } + if matched { + continue; + } + if rest.starts_with('[') { + if let Some((label, href, consumed)) = parse_inline_link(rest) { + let mut styles = active_styles.clone(); + styles.insert("link".to_string(), Value::String(href)); + parse_inline_markdown_into(&label, &styles, nodes); + offset += consumed; + continue; + } + } + let next = next_inline_marker_offset(rest).unwrap_or(rest.len()); + let plain = &rest[..next.max(1)]; + push_inline_text_node(nodes, plain, active_styles); + offset += plain.len(); + } +} + +#[derive(Clone, Copy)] +struct InlineMarker { + open: &'static str, + close: &'static str, + style: &'static str, + literal: bool, +} + +impl InlineMarker { + fn new(open: &'static str, close: &'static str, style: &'static str, literal: bool) -> Self { + Self { + open, + close, + style, + literal, + } + } +} + +fn parse_inline_link(rest: &str) -> Option<(String, String, usize)> { + let close_label = rest.find("](")?; + let label = rest.get(1..close_label)?; + let target_start = close_label + 2; + let target_rest = rest.get(target_start..)?; + let close_target = target_rest.find(')')?; + let href = target_rest[..close_target].trim(); + if label.is_empty() || href.is_empty() { + return None; + } + Some(( + label.to_string(), + href.to_string(), + target_start + close_target + 1, + )) +} + +fn next_inline_marker_offset(rest: &str) -> Option { + ["`", "**", "__", "~~", "*", "_", "["] + .into_iter() + .filter_map(|marker| rest.find(marker)) + .filter(|index| *index > 0) + .min() +} + +fn push_inline_text_node(nodes: &mut Vec, text: &str, styles: &Map) { + if text.is_empty() { + return; + } + nodes.push(json!({ + "type": "text", + "text": text, + "styles": Value::Object(styles.clone()), + })); +} + +fn merge_adjacent_inline_nodes(nodes: Vec) -> Vec { + let mut merged = Vec::::new(); + for node in nodes { + let Some(last) = merged.last_mut() else { + merged.push(node); + continue; + }; + let same_styles = last.get("styles") == node.get("styles"); + if same_styles { + if let (Some(last_text), Some(next_text)) = ( + last.get("text").and_then(Value::as_str), + node.get("text").and_then(Value::as_str), + ) { + let combined = format!("{last_text}{next_text}"); + if let Some(object) = last.as_object_mut() { + object.insert("text".to_string(), Value::String(combined)); + } + continue; + } + } + merged.push(node); + } + merged +} + +fn push_text_block(blocks: &mut Vec, block_type: &str, text: &str) { + blocks.push(json!({ + "id": format!("local-block-{}", blocks.len() + 1), + "type": block_type, + "content": parse_inline_markdown(text), + "children": [], + })); +} + +fn push_paragraph_block(blocks: &mut Vec, paragraph: &mut Vec) { + if paragraph.is_empty() { + return; + } + let text = paragraph.join("\n"); + blocks.push(json!({ + "id": format!("local-block-{}", blocks.len() + 1), + "type": "paragraph", + "content": parse_inline_markdown(&text), + "children": [], + })); + paragraph.clear(); +} + +fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Value { + let outline = content + .as_array() + .unwrap_or(&Vec::new()) + .iter() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("heading")) + .enumerate() + .map(|(index, block)| { + json!({ + "id": format!("outline-{index}"), + "nodeId": document_id, + "blockId": block.get("id").and_then(Value::as_str).unwrap_or_default(), + "title": block + .get("content") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("text")) + .and_then(Value::as_str) + .unwrap_or(title), + "depth": block + .get("props") + .and_then(|props| props.get("level")) + .and_then(Value::as_u64) + .unwrap_or(1), + }) + }) + .collect::>(); + json!({ + "projectionId": format!("page-subtree:{document_id}"), + "projection": "page_tree", + "rootNodeId": document_id, + "rootNode": { + "id": document_id, + "title": title, + "nodeType": "page", + "depth": 0, + }, + "subtree": { + "rootNodeId": document_id, + "nodes": [{ + "id": document_id, + "title": title, + "nodeType": "page", + "depth": 0, + }], + }, + "outline": outline, + "evidence": [{ + "id": format!("evidence:{document_id}"), + "nodeId": document_id, + "kind": "page", + "snippet": title, + }], + "stats": { + "blockCount": content.as_array().map(|blocks| blocks.len()).unwrap_or(0), + "headingCount": outline.len(), + "evidenceCount": 1, + "maxDepth": 1, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + initialize_local_page_id, load_local_folder_page_tree_snapshot, + local_folder_watch_revision, markdown_to_blocks, resolve_local_markdown_page_aggregate, + save_local_markdown_page, + }; + use serde_json::Value; + + fn temp_root(name: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create temp root"); + root + } + + #[test] + fn local_frontmatter_mnote_id_survives_rename_and_move() { + let root = temp_root("mnote-local-frontmatter-id"); + std::fs::write( + root.join("page.md"), + "---\nmnote_id: stable-frontmatter-id\ntitle: Stable\n---\n正文\n", + ) + .expect("write md"); + let root_uri = format!("file://{}", root.display()); + + let first = load_local_folder_page_tree_snapshot(&root_uri).expect("first snapshot"); + assert!(first + .projection + .to_string() + .contains("local-mdid:stable-frontmatter-id")); + + std::fs::create_dir_all(root.join("docs")).expect("create docs"); + std::fs::rename(root.join("page.md"), root.join("docs").join("renamed.md")) + .expect("move md"); + let second = load_local_folder_page_tree_snapshot(&root_uri).expect("second snapshot"); + let second_json = second.projection.to_string(); + assert!(second_json.contains("local-mdid:stable-frontmatter-id")); + assert!(second_json.contains("renamed.md")); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_page_ids_metadata_overrides_path_derived_id_and_hides_mnote() { + let root = temp_root("mnote-local-page-ids"); + std::fs::create_dir_all(root.join(".mnote")).expect("create metadata dir"); + std::fs::create_dir_all(root.join("docs")).expect("create docs"); + std::fs::write(root.join("docs").join("page.md"), "# Metadata Page\n").expect("write md"); + std::fs::write( + root.join(".mnote").join("page-ids.json"), + r#"{"version":1,"pages":{"docs/page.md":"stable-from-page-ids"}}"#, + ) + .expect("write page ids"); + let root_uri = format!("file://{}", root.display()); + + let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("snapshot"); + let html_json = snapshot.projection.to_string(); + assert!(html_json.contains("local-mdid:stable-from-page-ids")); + assert!(!html_json.contains("page-ids.json")); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_page_id_initialization_writes_page_ids_atomically() { + let root = temp_root("mnote-local-page-id-init"); + std::fs::write(root.join("draft.md"), "# Draft\n").expect("write md"); + let root_uri = format!("file://{}", root.display()); + + let page_id = initialize_local_page_id(&root_uri, "draft.md").expect("init page id"); + assert!(page_id.starts_with("local-mdid:")); + let metadata = std::fs::read_to_string(root.join(".mnote").join("page-ids.json")) + .expect("read page ids"); + let parsed: Value = serde_json::from_str(&metadata).expect("json"); + assert_eq!(parsed["pages"]["draft.md"], page_id); + assert!(!root.join(".mnote").join("page-ids.json.tmp").exists()); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_metadata_damage_returns_explainable_error() { + let root = temp_root("mnote-local-bad-metadata"); + std::fs::create_dir_all(root.join(".mnote")).expect("create metadata"); + std::fs::write(root.join("README.md"), "# Broken Metadata\n").expect("write md"); + std::fs::write(root.join(".mnote").join("page-ids.json"), "{not-json") + .expect("write bad metadata"); + let root_uri = format!("file://{}", root.display()); + + let error = load_local_folder_page_tree_snapshot(&root_uri).expect_err("metadata error"); + assert!(error.message().contains("元数据 JSON 损坏")); + assert!(error.message().contains("请修复或移走")); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_page_options_metadata_flows_into_page_aggregate() { + let root = temp_root("mnote-local-page-options"); + std::fs::create_dir_all(root.join(".mnote")).expect("create metadata"); + std::fs::write(root.join("README.md"), "# Local Options\n").expect("write md"); + std::fs::write( + root.join(".mnote").join("page-options.json"), + r#"{"version":1,"pages":{"local-md:README.md":{"wideLayout":true,"showToc":true,"showHeadingNumbers":true}}}"#, + ) + .expect("write options"); + let root_uri = format!("file://{}", root.display()); + + let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md") + .expect("aggregate"); + assert!(aggregate.layout.page_options.wide_layout); + assert!(aggregate.layout.page_options.show_toc); + assert!(aggregate.layout.page_options.show_heading_numbers); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_markdown_parser_preserves_pipe_table_as_table_block() { + let blocks = markdown_to_blocks("| 左 | 右 |\n| --- | --- |\n| `A` | **B** |\n"); + let array = blocks.as_array().expect("blocks"); + let table = array + .iter() + .find(|block| block["type"].as_str() == Some("table")) + .expect("pipe table 应保留为 table block"); + assert_eq!(table["props"]["tiptapTable"]["type"], "table"); + assert_eq!( + table["props"]["tiptapTable"]["content"][0]["content"][0]["type"], + "tableHeader" + ); + assert_eq!( + table["props"]["tiptapTable"]["content"][1]["content"][0]["type"], + "tableCell" + ); + assert_eq!( + table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0] + ["text"], + "左" + ); + assert_eq!( + table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0] + ["text"], + "A" + ); + assert_eq!( + table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0] + ["marks"][0]["type"], + "code" + ); + assert_eq!( + table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0] + ["text"], + "B" + ); + assert_eq!( + table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0] + ["marks"][0]["type"], + "bold" + ); + } + + #[test] + fn local_markdown_parser_preserves_task_list_and_inline_marks() { + let blocks = markdown_to_blocks( + "- [x] `3000` 默认由 **mnote-web** 监听。\n- [ ] *待办* ~~删除~~ [链接](https://example.com)\n", + ); + let array = blocks.as_array().expect("blocks"); + assert_eq!(array[0]["type"], "todo"); + assert_eq!(array[0]["props"]["checked"], true); + assert_eq!(array[1]["type"], "todo"); + assert_eq!(array[1]["props"]["checked"], false); + + let first_content = array[0]["content"].as_array().expect("first content"); + assert!(first_content.iter().any(|node| { + node["text"].as_str() == Some("3000") && node["styles"]["code"].as_bool() == Some(true) + })); + assert!(first_content.iter().any(|node| { + node["text"].as_str() == Some("mnote-web") + && node["styles"]["bold"].as_bool() == Some(true) + })); + + let second_content = array[1]["content"].as_array().expect("second content"); + assert!(second_content.iter().any(|node| { + node["text"].as_str() == Some("待办") + && node["styles"]["italic"].as_bool() == Some(true) + })); + assert!(second_content.iter().any(|node| { + node["text"].as_str() == Some("删除") + && node["styles"]["strike"].as_bool() == Some(true) + })); + assert!(second_content.iter().any(|node| { + node["text"].as_str() == Some("链接") + && node["styles"]["link"].as_str() == Some("https://example.com") + })); + } + + #[test] + fn local_markdown_aggregate_follows_file_permissions_for_editability() { + let root = temp_root("mnote-local-readonly-permission"); + std::fs::write(root.join("README.md"), "# Editable?\n").expect("write md"); + let root_uri = format!("file://{}", root.display()); + + let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md") + .expect("aggregate"); + assert!(!aggregate.head.permissions.read_only); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_folder_watch_revision_changes_for_visible_files_only() { + let root = temp_root("mnote-local-watch-revision"); + std::fs::create_dir_all(root.join(".mnote")).expect("create metadata"); + std::fs::create_dir_all(root.join("docs")).expect("create docs"); + std::fs::write(root.join("docs").join("page.md"), "# Watch\n").expect("write md"); + let root_uri = format!("file://{}", root.display()); + + let initial = local_folder_watch_revision(&root_uri).expect("initial revision"); + std::fs::write(root.join(".mnote").join("ignored.txt"), "ignored").expect("write ignored"); + let ignored = local_folder_watch_revision(&root_uri).expect("ignored revision"); + assert_eq!(initial.revision, ignored.revision); + std::fs::write(root.join("docs").join("page.md"), "# Watch Again\n").expect("update md"); + let updated = local_folder_watch_revision(&root_uri).expect("updated revision"); + assert_ne!(initial.revision, updated.revision); + assert!(updated.entry_count >= 1); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_markdown_parser_covers_basic_blocks_and_attachment_refs() { + let blocks = markdown_to_blocks( + r#"# Title + +paragraph + +## Section +- bullet +1. numbered +> quote +```rust +fn main() {} +``` +--- +[Spec](attachments/spec.pdf) +| unsupported | table | +"#, + ); + let block_types = blocks + .as_array() + .expect("blocks") + .iter() + .map(|block| block["type"].as_str().unwrap_or_default().to_string()) + .collect::>(); + assert!(block_types.contains(&"heading".to_string())); + assert!(block_types.contains(&"paragraph".to_string())); + assert!(block_types.contains(&"bulletListItem".to_string())); + assert!(block_types.contains(&"numberedListItem".to_string())); + assert!(block_types.contains(&"quote".to_string())); + assert!(block_types.contains(&"codeBlock".to_string())); + assert!(block_types.contains(&"divider".to_string())); + assert!(block_types.contains(&"media".to_string())); + assert!(blocks.to_string().contains("attachments/spec.pdf")); + assert!(blocks.to_string().contains("unsupported")); + } + + #[test] + fn local_markdown_save_preserves_frontmatter_and_writes_basic_blocks() { + let root = temp_root("mnote-local-markdown-save-basic-blocks"); + std::fs::write( + root.join("README.md"), + "---\ntitle: Preserved\nmnote_id: stable\n---\n# Old\n", + ) + .expect("write md"); + let root_uri = format!("file://{}", root.display()); + save_local_markdown_page( + &root_uri, + "local-mdid:stable", + &serde_json::json!([ + {"type":"heading","props":{"level":2},"content":[{"type":"text","text":"Heading"}]}, + {"type":"bulletListItem","content":[{"type":"text","text":"Bullet"}]}, + {"type":"numberedListItem","content":[{"type":"text","text":"Numbered"}]}, + {"type":"quote","content":[{"type":"text","text":"Quoted"}]}, + {"type":"codeBlock","content":[{"type":"text","text":"let x = 1;"}]}, + {"type":"divider"}, + {"type":"media","props":{"name":"Spec","sourcePath":"attachments/spec.pdf"}}, + {"type":"table","props":{"tiptapTable":{"type":"table","content":[ + {"type":"tableRow","content":[ + {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"左","styles":{}}]}]}, + {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"右","styles":{}}]}]} + ]}, + {"type":"tableRow","content":[ + {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{}}]}]}, + {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{}}]}]} + ]} + ]}}} + ]), + ) + .expect("save"); + + let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); + assert!(saved.starts_with("---\ntitle: Preserved\nmnote_id: stable\n---\n")); + assert!(saved.contains("## Heading")); + assert!(saved.contains("- Bullet")); + assert!(saved.contains("1. Numbered")); + assert!(saved.contains("> Quoted")); + assert!(saved.contains("```\nlet x = 1;\n```")); + assert!(saved.contains("[Spec](attachments/spec.pdf)")); + assert!(saved.contains("| 左 | 右 |")); + assert!(saved.contains("| --- | --- |")); + assert!(saved.contains("| A | B |")); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_markdown_save_writes_task_list_and_inline_marks() { + let root = temp_root("mnote-local-markdown-save-inline-marks"); + std::fs::write(root.join("README.md"), "---\ntitle: Inline\n---\n# Old\n") + .expect("write md"); + let root_uri = format!("file://{}", root.display()); + save_local_markdown_page( + &root_uri, + "local-md:README.md", + &serde_json::json!([ + { + "type":"todo", + "props":{"checked":true}, + "content":[ + {"type":"text","text":"3000","styles":{"code":true}}, + {"type":"text","text":" 默认由 "}, + {"type":"text","text":"mnote-web","styles":{"bold":true}}, + {"type":"text","text":" 监听"} + ] + }, + { + "type":"todo", + "props":{"checked":false}, + "content":[ + {"type":"text","text":"待办","styles":{"italic":true}}, + {"type":"text","text":" "}, + {"type":"text","text":"删除","styles":{"strike":true}}, + {"type":"text","text":" "}, + {"type":"text","text":"链接","styles":{"link":"https://example.com"}} + ] + } + ]), + ) + .expect("save"); + + let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); + assert!(saved.contains("- [x] `3000` 默认由 **mnote-web** 监听")); + assert!(saved.contains("- [ ] *待办* ~~删除~~ [链接](https://example.com)")); + + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 45dfe784..fc239d8d 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -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), diff --git a/rust/crates/mnote-web/src/routes/query_support.rs b/rust/crates/mnote-web/src/routes/query_support.rs index 605143cf..62e92006 100644 --- a/rust/crates/mnote-web/src/routes/query_support.rs +++ b/rust/crates/mnote-web/src/routes/query_support.rs @@ -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(), diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index b15be42a..ef1e5dd5 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -5,6 +5,11 @@ use crate::routes::command_support::{ build_tree_target, ensure_non_empty, ensure_sort_order, execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty, }; +use crate::routes::local_folder_source::{ + execute_local_tree_command, load_local_folder_file_tree_snapshot, + load_local_folder_page_tree_snapshot, local_folder_watch_revision, + local_workspace_id_from_root_uri, +}; use crate::routes::query_support::{ fetch_documents_meta_via_convex, resolve_effective_workspace_id, }; @@ -55,21 +60,77 @@ pub struct TreeShellQuery { pub channel: Option, pub host: Option, pub mode: Option, + pub source_kind: Option, + pub root_uri: Option, pub allow_root_pick: Option, pub exclude_ids: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalFolderWatchQuery { + pub root_uri: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TreeCommandEnvelope { pub action: String, pub workspace_id: Option, + pub source_kind: Option, + pub root_uri: Option, + #[serde(default)] + pub source_capabilities: Vec, + pub target_node_id: Option, + pub target_resource_meta: Option, + pub selection: Option, + pub operation: Option, pub document_id: Option, pub parent_id: Option, + pub target_parent_id: Option, pub title: Option, pub access_scope: Option, pub content: Option, pub sort_order: Option, + #[serde(default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TreeCommandCopyItem { + pub document_id: String, + #[serde(default)] + pub recursive: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct TreeCommandEnvelopeContext { + pub source_kind: Option, + pub root_uri: Option, + pub source_capabilities: Vec, + pub target_node_id: Option, + pub target_resource_meta: Option, + pub selection: Option, + pub operation: Option, +} + +impl TreeCommandEnvelopeContext { + fn from_envelope(envelope: &TreeCommandEnvelope) -> Self { + Self { + source_kind: read_optional_non_empty(envelope.source_kind.clone()), + root_uri: read_optional_non_empty(envelope.root_uri.clone()), + source_capabilities: envelope + .source_capabilities + .iter() + .filter_map(|capability| read_optional_non_empty(Some(capability.clone()))) + .collect(), + target_node_id: read_optional_non_empty(envelope.target_node_id.clone()), + target_resource_meta: envelope.target_resource_meta.clone(), + selection: envelope.selection.clone(), + operation: read_optional_non_empty(envelope.operation.clone()), + } + } } #[derive(Debug)] @@ -82,6 +143,12 @@ pub enum TreeCommandRequest { access_scope: Option, content: Option, }, + CreateFolder { + workspace_id: Option, + document_id: String, + parent_id: Option, + title: String, + }, Rename { workspace_id: Option, document_id: String, @@ -93,6 +160,26 @@ pub enum TreeCommandRequest { parent_id: Option, sort_order: i64, }, + Archive { + workspace_id: Option, + document_id: String, + }, + Restore { + workspace_id: Option, + document_id: String, + }, + Copy { + workspace_id: Option, + document_id: String, + target_parent_id: Option, + items: Vec, + title: Option, + }, + DropFiles { + workspace_id: Option, + parent_id: Option, + files_json: String, + }, Purge { workspace_id: Option, document_id: String, @@ -154,6 +241,65 @@ fn normalize_bool_flag(value: Option<&str>, default: bool) -> bool { } } +fn build_workspace_source_wire( + context: &RequestContext, + workspace_id: &str, + envelope_context: &TreeCommandEnvelopeContext, +) -> bridge_runtime::RuntimeSourceWire { + let source_kind = envelope_context + .source_kind + .clone() + .unwrap_or_else(|| "convex_workspace".into()); + let root_uri = envelope_context.root_uri.clone().or_else(|| { + if source_kind == "convex_workspace" { + Some(format!("convex://workspace/{workspace_id}")) + } else { + None + } + }); + let capabilities = + if envelope_context.source_capabilities.is_empty() && source_kind == "convex_workspace" { + vec![ + "load-snapshot".into(), + "preflight-command".into(), + "execute-command".into(), + "resolve-page-aggregate".into(), + ] + } else { + envelope_context.source_capabilities.clone() + }; + + bridge_runtime::RuntimeSourceWire { + channel: context.source.channel.clone(), + client: context.source.client.clone(), + source_kind: Some(source_kind), + root_uri, + workspace_id: Some(workspace_id.to_string()), + capabilities, + } +} + +fn attach_tree_command_envelope_context( + mut payload: Value, + envelope_context: &TreeCommandEnvelopeContext, +) -> Value { + if let Some(map) = payload.as_object_mut() { + if let Some(target_node_id) = envelope_context.target_node_id.as_ref() { + map.insert("targetNodeId".into(), json!(target_node_id)); + } + if let Some(target_resource_meta) = envelope_context.target_resource_meta.as_ref() { + map.insert("targetResourceMeta".into(), target_resource_meta.clone()); + } + if let Some(selection) = envelope_context.selection.as_ref() { + map.insert("selection".into(), selection.clone()); + } + if let Some(operation) = envelope_context.operation.as_ref() { + map.insert("operation".into(), json!(operation)); + } + } + payload +} + fn parse_exclude_ids(value: Option<&str>) -> Vec { value .unwrap_or_default() @@ -292,9 +438,12 @@ pub(crate) fn collect_filetree_render_rows( projection: &Value, active_document_id: Option<&str>, ) -> Vec { - let selected_ids = active_document_id + let active_document_id = active_document_id .map(str::trim) .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let selected_ids = active_document_id + .as_deref() .map(|document_id| BTreeSet::from([format!("index:{document_id}")])) .unwrap_or_default(); @@ -316,6 +465,16 @@ pub(crate) fn collect_filetree_render_rows( .map(str::trim) .filter(|value| !value.is_empty())?; let resource_meta = item.get("resourceMeta").and_then(Value::as_object); + let document_id = resource_meta + .and_then(|meta| meta.get("documentId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let selected = selected_ids.contains(row_id) + || active_document_id + .as_deref() + .is_some_and(|active_id| document_id.as_deref() == Some(active_id)); Some(FileTreeRenderRow { row_id: row_id.to_string(), row_kind: item @@ -365,19 +524,14 @@ pub(crate) fn collect_filetree_render_rows( .filter(|value| !value.is_empty()) .unwrap_or("file") .to_string(), - document_id: resource_meta - .and_then(|meta| meta.get("documentId")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned), + document_id, asset_id: resource_meta .and_then(|meta| meta.get("assetId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), - selected: selected_ids.contains(row_id), + selected, }) }) .collect() @@ -582,6 +736,15 @@ fn build_tree_shell_html( "channel": channel, "host": host, "mode": mode, + "sourceKind": projection + .get("sourceKind") + .and_then(Value::as_str) + .unwrap_or("convex_workspace"), + "rootUri": projection + .get("rootUri") + .and_then(Value::as_str) + .unwrap_or(""), + "localWatchRevision": projection.get("watchRevision").cloned().unwrap_or(Value::Null), "allowRootPick": allow_root_pick, "excludeIds": exclude_ids, "rendererInput": renderer_input, @@ -1037,9 +1200,121 @@ fn build_tree_shell_html( background: rgba(15, 108, 132, 0.14); outline: 1px solid rgba(15, 108, 132, 0.35); } + .tree-row[data-cut="true"] { + opacity: 0.55; + } + .tree-row[data-drop-position="before"]::before, + .tree-row[data-drop-position="after"]::after { + content: ""; + position: absolute; + left: 20px; + right: 8px; + height: 2px; + border-radius: 999px; + background: rgba(37, 99, 235, 0.82); + } + .tree-row[data-drop-position="before"]::before { + top: 0; + } + .tree-row[data-drop-position="after"]::after { + bottom: 0; + } .tree-root[data-drop-target="true"] { background: rgba(15, 108, 132, 0.05); } + .tree-context-menu { + position: fixed; + z-index: 50; + min-width: 188px; + padding: 4px; + border: 1px solid rgba(31, 35, 40, 0.12); + border-radius: 6px; + background: #ffffff; + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16); + } + .tree-context-menu[hidden] { + display: none; + } + .tree-menu-item { + width: 100%; + min-height: 26px; + border: 0; + border-radius: 4px; + padding: 4px 8px; + background: transparent; + color: #1f2328; + font-size: 12px; + text-align: left; + } + .tree-menu-item:hover:not(:disabled), + .tree-menu-item:focus-visible:not(:disabled) { + background: rgba(37, 99, 235, 0.08); + outline: none; + } + .tree-menu-item:disabled { + color: #9ca3af; + cursor: default; + } + .tree-menu-separator { + height: 1px; + margin: 4px 2px; + background: rgba(31, 35, 40, 0.08); + } + .tree-preflight-backdrop { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(15, 23, 42, 0.28); + } + .tree-preflight-dialog { + width: min(420px, 100%); + border-radius: 8px; + border: 1px solid rgba(31, 35, 40, 0.12); + background: #ffffff; + box-shadow: 0 20px 48px rgba(15, 23, 42, 0.22); + } + .tree-preflight-body { + padding: 16px; + } + .tree-preflight-title { + margin: 0 0 8px; + color: #1f2328; + font-size: 14px; + font-weight: 600; + } + .tree-preflight-list { + margin: 0; + padding-left: 18px; + color: #4b5563; + font-size: 12px; + line-height: 1.5; + } + .tree-preflight-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 16px; + border-top: 1px solid rgba(31, 35, 40, 0.08); + } + .tree-preflight-actions button { + min-width: 64px; + min-height: 28px; + border: 1px solid rgba(31, 35, 40, 0.12); + border-radius: 6px; + padding: 4px 10px; + background: #ffffff; + color: #1f2328; + font-size: 12px; + } + .tree-preflight-actions [data-role="confirm"] { + border-color: #2563eb; + background: #2563eb; + color: #ffffff; + } .tree-toggle, .tree-action { border-radius: 4px; @@ -1065,6 +1340,19 @@ fn build_tree_shell_html( line-height: 1.35; color: #1f2328; } + .tree-rename-input { + width: 100%; + min-width: 80px; + height: 22px; + border: 1px solid rgba(37, 99, 235, 0.8); + border-radius: 4px; + padding: 0 4px; + background: #fff; + color: #1f2328; + font: inherit; + line-height: 20px; + outline: none; + } .tree-link-meta { display: none; } @@ -1255,6 +1543,20 @@ fn build_tree_shell_html( typeof state.workspaceId === "string" && state.workspaceId.trim() ? state.workspaceId.trim() : ""; + const sourceKind = + typeof state.sourceKind === "string" && state.sourceKind.trim() + ? state.sourceKind.trim() + : "convex_workspace"; + const rootUri = + typeof state.rootUri === "string" && state.rootUri.trim() + ? state.rootUri.trim() + : ""; + const initialLocalWatchRevision = + state.localWatchRevision && + typeof state.localWatchRevision === "object" && + typeof state.localWatchRevision.revision === "string" + ? state.localWatchRevision.revision + : ""; const actorId = typeof state.actorId === "string" && state.actorId.trim() ? state.actorId.trim() @@ -1330,6 +1632,13 @@ fn build_tree_shell_html( const trimmed = value.trim(); return trimmed || fallback; }; + const initialRenameRowId = (() => { + try { + return normalizeText(new URL(window.location.href).searchParams.get("renameRowId")); + } catch { + return ""; + } + })(); const runtimeReduceEndpoint = normalizeText( runtimeApi.reduceEndpoint, "/api/tree/runtime/reduce", @@ -1348,6 +1657,8 @@ fn build_tree_shell_html( const normalized = normalizeText(value).toLowerCase(); if (normalized === "index") return "index"; if (normalized === "asset") return "asset"; + if (normalized === "folder") return "folder"; + if (normalized === "markdown") return "markdown"; if (normalized === "asset_folder") return "asset_folder"; return "document"; }; @@ -1514,7 +1825,13 @@ fn build_tree_shell_html( let activePageDropNodeId = null; let draggingFileTreeRowIds = []; let activeFileTreeDropRowId = null; + let activeFileTreeDropPosition = null; let activeFileTreeRootDrop = false; + let fileTreeHoverExpandTimer = 0; + let fileTreeClipboard = { action: null, rowIds: [] }; + let inlineRenameState = { mode: null, id: null, committing: false }; + let activeFileTreeMenuElement = null; + let activeFileTreePreflightElement = null; let activeCursor = itemById.get(currentActiveDocumentId) || null; while (activeCursor && activeCursor.parentNodeId && itemById.has(activeCursor.parentNodeId)) { @@ -1588,7 +1905,7 @@ fn build_tree_shell_html( const getFileTreeRowDocumentId = (item) => { if (!item) return ""; if (item.resourceMeta?.documentId) return item.resourceMeta.documentId; - if (item.rowKind === "document") return item.nodeId; + if (item.rowKind === "document" || item.rowKind === "markdown") return item.nodeId; if (item.rowKind === "index") return item.nodeId.replace(/^index:/, ""); return ""; }; @@ -1609,6 +1926,8 @@ fn build_tree_shell_html( if (iconHint === "index") return "index"; if (iconHint === "page") return "page"; if (item.rowKind === "document") return "page"; + if (item.rowKind === "folder") return "folder"; + if (item.rowKind === "markdown") return "file"; if (item.rowKind === "index") return "index"; if (item.rowKind === "asset_folder") return "mindmap"; if (item.resourceMeta?.resourceKind === "table") return "table"; @@ -1621,6 +1940,12 @@ fn build_tree_shell_html( if (item.rowKind === "document") { return `${getFileTreeRowDocumentId(item)} · 页面`; } + if (item.rowKind === "folder") { + return "本地文件夹"; + } + if (item.rowKind === "markdown") { + return `${getFileTreeRowDocumentId(item)} · Markdown`; + } if (item.rowKind === "index") { return "页面正文"; } @@ -1643,6 +1968,7 @@ fn build_tree_shell_html( return { rowId: null, rowKind: "root", + nodeId: null, documentId: null, assetId: null, }; @@ -1650,21 +1976,370 @@ fn build_tree_shell_html( return { rowId: normalizeText(row.dataset.rowId) || null, rowKind: normalizeText(row.dataset.rowKind, "document"), + nodeId: normalizeText(row.dataset.nodeId) || null, documentId: normalizeText(row.dataset.documentId) || null, assetId: normalizeText(row.dataset.assetId) || null, }; }; + const getFileTreeDropTargetFromEvent = (event) => { + const target = getFileTreeDropTargetFromElement(event.target); + if (!target.rowId) return { ...target, dropPosition: "inside" }; + const row = event.target instanceof Element + ? event.target.closest('.tree-row[data-shell-mode="filetree"]') + : null; + if (!(row instanceof HTMLElement)) return { ...target, dropPosition: "inside" }; + const rect = row.getBoundingClientRect(); + const offset = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5; + const item = fileTreeRowById.get(target.rowId); + if (offset < 0.25) return { ...target, dropPosition: "before" }; + if (offset > 0.75) return { ...target, dropPosition: "after" }; + return { + ...target, + dropPosition: item && canExpandFileTreeRow(item) ? "inside" : "after", + }; + }; + + const resolveLocalFileTreeParentId = (target) => { + if (!target || target.rowKind === "root") return null; + const targetItem = target.rowId ? fileTreeRowById.get(target.rowId) : null; + if (targetItem && targetItem.rowKind === "folder") { + return targetItem.nodeId; + } + if (targetItem && targetItem.parentNodeId) { + return targetItem.parentNodeId; + } + return null; + }; + + const isFileTreeDescendantOf = (candidate, ancestor) => { + let cursor = candidate; + while (cursor && cursor.parentNodeId) { + if (cursor.parentNodeId === ancestor.nodeId) return true; + cursor = itemById.get(cursor.parentNodeId) || null; + } + return false; + }; + + const validateFileTreeInternalDrop = (target, rowIds, copy) => { + const sourceRowIds = Array.isArray(rowIds) ? rowIds.filter(Boolean) : []; + if (sourceRowIds.length === 0) return { ok: false, reason: "empty" }; + const targetItem = target?.rowId ? fileTreeRowById.get(target.rowId) : null; + if (targetItem && targetItem.capabilities.some((capability) => + capability === "readonly" || capability === "readOnly" || capability === "permissionDenied" + )) { + return { ok: false, reason: "readonly" }; + } + for (const rowId of sourceRowIds) { + const sourceItem = fileTreeRowById.get(rowId); + if (!sourceItem) return { ok: false, reason: "cross_workspace" }; + if (targetItem && targetItem.rowId === sourceItem.rowId) { + return { ok: false, reason: "self" }; + } + if (targetItem && sourceItem.rowKind === "folder" && isFileTreeDescendantOf(targetItem, sourceItem)) { + return { ok: false, reason: "descendant" }; + } + if (copy && sourceItem.rowKind === "folder") { + return { ok: false, reason: "copy_folder_unsupported" }; + } + } + return { ok: true, reason: "" }; + }; + + const filterRedundantFileTreeRowIds = (rowIds) => { + const selected = new Set(Array.isArray(rowIds) ? rowIds.filter(Boolean) : []); + return Array.from(selected).filter((rowId) => { + const item = fileTreeRowById.get(rowId); + if (!item) return false; + let cursor = item; + while (cursor && cursor.parentNodeId) { + const parent = itemById.get(cursor.parentNodeId) || null; + if (parent && selected.has(parent.rowId)) { + return false; + } + cursor = parent; + } + return true; + }); + }; + + const validateFileTreeWritableTarget = (target) => { + const targetItem = target?.rowId ? fileTreeRowById.get(target.rowId) : null; + if ( + targetItem && + targetItem.capabilities.some((capability) => + capability === "readonly" || + capability === "readOnly" || + capability === "permissionDenied" + ) + ) { + return { ok: false, reason: "readonly", targetItem }; + } + return { ok: true, reason: "", targetItem }; + }; + + const getFileTreeRowLabel = (item) => + item?.title || item?.rowId || item?.nodeId || "选中项"; + + const getFileTreeTargetLabel = (target) => { + if (!target || target.rowKind === "root" || !target.rowId) return "Explorer 根目录"; + const item = fileTreeRowById.get(target.rowId); + if (!item) return target.rowId; + if (item.rowKind === "markdown" || item.rowKind === "document" || item.rowKind === "index") { + return `${item.title} 的父目录`; + } + return item.title; + }; + + const showFileTreePreflight = (preflight) => + new Promise((resolve) => { + if (activeFileTreePreflightElement) { + activeFileTreePreflightElement.remove(); + activeFileTreePreflightElement = null; + } + + const backdrop = document.createElement("div"); + backdrop.className = "tree-preflight-backdrop"; + backdrop.dataset.testid = "tree-preflight"; + backdrop.dataset.preflightKind = preflight.kind || ""; + const dialog = document.createElement("div"); + dialog.className = "tree-preflight-dialog"; + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + const body = document.createElement("div"); + body.className = "tree-preflight-body"; + const title = document.createElement("h2"); + title.className = "tree-preflight-title"; + title.textContent = preflight.title || "操作预检"; + body.appendChild(title); + + const list = document.createElement("ul"); + list.className = "tree-preflight-list"; + (preflight.lines || []).forEach((line) => { + const item = document.createElement("li"); + item.textContent = line; + list.appendChild(item); + }); + body.appendChild(list); + dialog.appendChild(body); + + const actions = document.createElement("div"); + actions.className = "tree-preflight-actions"; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.dataset.role = "cancel"; + cancel.textContent = "取消"; + const confirm = document.createElement("button"); + confirm.type = "button"; + confirm.dataset.role = "confirm"; + confirm.textContent = preflight.confirmLabel || "继续"; + actions.appendChild(cancel); + actions.appendChild(confirm); + dialog.appendChild(actions); + backdrop.appendChild(dialog); + + const finish = (accepted) => { + backdrop.remove(); + activeFileTreePreflightElement = null; + document.removeEventListener("keydown", onKeyDown, true); + resolve(accepted); + }; + const onKeyDown = (event) => { + if (event.key === "Escape") { + event.preventDefault(); + finish(false); + } + }; + cancel.addEventListener("click", () => finish(false)); + confirm.addEventListener("click", () => finish(true)); + document.addEventListener("keydown", onKeyDown, true); + document.body.appendChild(backdrop); + activeFileTreePreflightElement = backdrop; + cancel.focus({ preventScroll: true }); + }); + + const runFileTreePreflight = async (kind, context = {}) => { + const sourceRowIds = Array.isArray(context.rowIds) ? context.rowIds.filter(Boolean) : []; + const sourceItems = sourceRowIds + .map((rowId) => fileTreeRowById.get(rowId)) + .filter(Boolean); + const targetLabel = getFileTreeTargetLabel(context.target); + const sourceLabel = + sourceItems.length === 0 + ? "外部文件" + : sourceItems.map(getFileTreeRowLabel).join("、"); + const fileNames = Array.isArray(context.files) + ? context.files.map((file) => file.name || "未命名文件").filter(Boolean) + : []; + + if (context.validation && context.validation.ok === false) { + if (context.validation.reason === "readonly") { + await showFileTreePreflight({ + kind: "readonly", + title: "操作预检失败", + confirmLabel: "知道了", + lines: [ + "目标: readonly", + "原因: 当前目标为只读或权限不足。", + "结果: 不会发送 execute。", + ], + }); + return false; + } + setLastAction(`预检拒绝: ${context.validation.reason}`, "error"); + return false; + } + + const writableTarget = validateFileTreeWritableTarget(context.target); + if (!writableTarget.ok) { + await showFileTreePreflight({ + kind: "readonly", + title: "操作预检失败", + confirmLabel: "知道了", + lines: [ + `目标: ${getFileTreeRowLabel(writableTarget.targetItem)}`, + "原因: 当前目标为只读或权限不足。", + "结果: 不会发送 execute。", + ], + }); + return false; + } + + const preflight = (() => { + if (kind === "delete") { + return { + kind, + title: "删除预检", + confirmLabel: "删除", + lines: [ + `目标: ${sourceLabel}`, + `影响: ${Math.max(1, sourceItems.length)} 个文件树节点`, + sourceKind === "local_folder" + ? "删除后进入本地 .mnote/trash。" + : "删除将进入云端回收站。", + ], + }; + } + if (kind === "paste") { + return { + kind, + title: context.copy ? "粘贴复制预检" : "粘贴移动预检", + confirmLabel: "粘贴", + lines: [ + `来源: ${sourceLabel}`, + `目标: ${targetLabel}`, + "命名冲突策略: 使用递增命名,不静默覆盖。", + ], + }; + } + if (kind === "dropFiles") { + return { + kind, + title: "外部拖入预检", + confirmLabel: sourceKind === "local_folder" ? "拖入" : "发送", + lines: [ + `文件: ${fileNames.join("、") || "外部文件"}`, + `目标: ${targetLabel}`, + sourceKind === "local_folder" + ? "目标可写时复制进本地文件夹;命名冲突使用递增命名。" + : "Convex 目标交给宿主上传到对象存储;命名冲突由上传 executor 处理。", + ], + }; + } + return { + kind: context.copy ? "copy" : "move", + title: context.copy ? "复制预检" : "移动预检", + confirmLabel: context.copy ? "复制" : "移动", + lines: [ + `来源: ${sourceLabel}`, + `目标: ${targetLabel}`, + context.copy + ? "命名冲突策略: 使用递增命名,不静默覆盖。" + : "会检查自拖自身、后代目标和跨 workspace 来源。", + ], + }; + })(); + + return await showFileTreePreflight(preflight); + }; + + const executeLocalFileTreeInternalDrop = async (target, rowIds, copy, trigger = "drop") => { + const validation = validateFileTreeInternalDrop(target, rowIds, copy); + if (!validation.ok) { + setLastAction(`已拒绝非法拖拽: ${validation.reason}`, "error"); + return false; + } + const accepted = await runFileTreePreflight(trigger === "paste" ? "paste" : "move", { + target, + rowIds, + copy, + validation, + }); + if (!accepted) return false; + const parentId = resolveLocalFileTreeParentId(target); + const sourceRowIds = filterRedundantFileTreeRowIds(rowIds); + if (sourceRowIds.length === 0) return false; + for (const rowId of sourceRowIds) { + const sourceItem = fileTreeRowById.get(rowId); + const documentId = getFileTreeRowDocumentId(sourceItem) || sourceItem?.nodeId || ""; + if (!documentId) continue; + await sendCommand({ + action: copy ? "copy" : "move", + workspaceId, + documentId, + parentId, + sortOrder: 0, + }); + } + setStatus(copy ? "复制成功" : "移动成功"); + setLastAction(copy ? "本地文件树复制完成" : "本地文件树移动完成"); + scheduleRefresh(); + return true; + }; + + const executeLocalFileTreeExternalDrop = async (target, files) => { + const droppedFiles = Array.from(files || []); + if (droppedFiles.length === 0) return false; + const accepted = await runFileTreePreflight("dropFiles", { + target, + files: droppedFiles, + }); + if (!accepted) return false; + const parentId = resolveLocalFileTreeParentId(target); + const payload = await Promise.all( + droppedFiles.map(async (file) => ({ + name: file.name || "dropped-file", + text: await file.text(), + })), + ); + await sendCommand({ + action: "dropFiles", + workspaceId, + parentId, + content: payload, + }); + setStatus("外部文件拖入成功"); + setLastAction(`已拖入 ${payload.length} 个本地文件`); + scheduleRefresh(); + return true; + }; + const clearFileTreeDropFeedback = () => { + if (fileTreeHoverExpandTimer) { + window.clearTimeout(fileTreeHoverExpandTimer); + fileTreeHoverExpandTimer = 0; + } if (activeFileTreeDropRowId) { const previousRow = appElement.querySelector( `.tree-row[data-shell-mode="filetree"][data-row-id="${activeFileTreeDropRowId}"]`, ); if (previousRow instanceof HTMLElement) { previousRow.dataset.dropTarget = "false"; + delete previousRow.dataset.dropPosition; } } activeFileTreeDropRowId = null; + activeFileTreeDropPosition = null; activeFileTreeRootDrop = false; appElement.querySelectorAll('.tree-root[data-drop-target="true"]').forEach((element) => { if (element instanceof HTMLElement) { @@ -1675,12 +2350,14 @@ fn build_tree_shell_html( const setFileTreeDropFeedback = (target) => { const nextRowId = target?.rowId || null; + const nextDropPosition = target?.dropPosition || "inside"; if (activeFileTreeDropRowId && activeFileTreeDropRowId !== nextRowId) { const previousRow = appElement.querySelector( `.tree-row[data-shell-mode="filetree"][data-row-id="${activeFileTreeDropRowId}"]`, ); if (previousRow instanceof HTMLElement) { previousRow.dataset.dropTarget = "false"; + delete previousRow.dataset.dropPosition; } } if (nextRowId) { @@ -1689,9 +2366,28 @@ fn build_tree_shell_html( ); if (nextRow instanceof HTMLElement) { nextRow.dataset.dropTarget = "true"; + nextRow.dataset.dropPosition = nextDropPosition; } activeFileTreeDropRowId = nextRowId; + activeFileTreeDropPosition = nextDropPosition; activeFileTreeRootDrop = false; + if (fileTreeHoverExpandTimer) { + window.clearTimeout(fileTreeHoverExpandTimer); + fileTreeHoverExpandTimer = 0; + } + const targetItem = fileTreeRowById.get(nextRowId); + if ( + nextDropPosition === "inside" && + targetItem && + canExpandFileTreeRow(targetItem) && + !expanded.has(targetItem.nodeId) + ) { + fileTreeHoverExpandTimer = window.setTimeout(() => { + expanded.add(targetItem.nodeId); + fileTreeHoverExpandTimer = 0; + renderTree(); + }, 650); + } appElement.querySelectorAll('.tree-root[data-drop-target="true"]').forEach((element) => { if (element instanceof HTMLElement) { element.dataset.dropTarget = "false"; @@ -1700,6 +2396,7 @@ fn build_tree_shell_html( return; } activeFileTreeDropRowId = null; + activeFileTreeDropPosition = null; const root = appElement.querySelector(".tree-root"); if (root instanceof HTMLElement) { root.dataset.dropTarget = "true"; @@ -2025,12 +2722,283 @@ fn build_tree_shell_html( rowId, }).dragRowIds || []; - const scheduleRefresh = () => { + const patchFileTreeCutDecoration = () => { + appElement.querySelectorAll('[data-rust-rendered-row="filetree"], .tree-row[data-shell-mode="filetree"]').forEach((row) => { + if (!(row instanceof HTMLElement)) return; + row.dataset.cut = String( + fileTreeClipboard.action === "cut" && + fileTreeClipboard.rowIds.includes(normalizeText(row.dataset.rowId)), + ); + }); + }; + + const focusFileTreeRowByOffset = (offset) => { + if (visibleFileTreeRowIds.length === 0) return; + const currentIndex = Math.max(0, visibleFileTreeRowIds.indexOf(fileTreeFocusedRowId)); + const nextIndex = Math.max(0, Math.min(visibleFileTreeRowIds.length - 1, currentIndex + offset)); + const rowId = visibleFileTreeRowIds[nextIndex]; + if (!rowId) return; + commitFileTreeSelection({ selectedRowIds: [rowId], anchorRowId: rowId, focusedRowId: rowId }); + const row = appElement.querySelector(`.tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(rowId)}"]`); + if (row instanceof HTMLElement) row.focus({ preventScroll: true }); + }; + + const setFileTreeClipboard = (action) => { + const rowIds = selectedFileTreeRowIds.size > 0 + ? Array.from(selectedFileTreeRowIds) + : fileTreeFocusedRowId + ? [fileTreeFocusedRowId] + : []; + fileTreeClipboard = { action, rowIds }; + patchFileTreeCutDecoration(); + setLastAction(action === "cut" ? "已剪切选中文件树节点" : "已复制选中文件树节点"); + }; + + const resolveFileTreeActionRowIds = (rowId) => { + const normalizedRowId = normalizeText(rowId); + if (normalizedRowId && selectedFileTreeRowIds.has(normalizedRowId)) { + return Array.from(selectedFileTreeRowIds); + } + if (normalizedRowId) return [normalizedRowId]; + if (selectedFileTreeRowIds.size > 0) return Array.from(selectedFileTreeRowIds); + return fileTreeFocusedRowId ? [fileTreeFocusedRowId] : []; + }; + + const runFileTreeDelete = async (rowId) => { + const rowIds = resolveFileTreeActionRowIds(rowId); + const deletableItems = rowIds + .map((id) => fileTreeRowById.get(id)) + .filter((item) => Boolean(getFileTreeRowDocumentId(item))); + if (deletableItems.length === 0) { + setLastAction("当前选择没有可删除的页面或 Markdown 文件", "error"); + return false; + } + const accepted = await runFileTreePreflight("delete", { + rowIds: deletableItems.map((item) => item.rowId), + }); + if (!accepted) return false; + if (sourceKind === "local_folder") { + for (const item of deletableItems) { + await sendCommand({ + action: "delete", + workspaceId, + documentId: getFileTreeRowDocumentId(item), + }); + } + scheduleRefresh(); + return true; + } + if (sourceKind === "convex_workspace") { + for (const item of deletableItems) { + await sendCommand({ + action: "delete", + workspaceId, + documentId: getFileTreeRowDocumentId(item), + }); + } + scheduleRefresh(); + return true; + } + postToHost("tree.filetree.delete", { + workspaceId, + rowIds: deletableItems.map((item) => item.rowId), + payload: { + workspaceId, + rowIds: deletableItems.map((item) => item.rowId), + documentIds: deletableItems.map(getFileTreeRowDocumentId), + }, + }); + return true; + }; + + const pasteFileTreeClipboardInto = async (targetRowId) => { + if (!fileTreeClipboard.action || fileTreeClipboard.rowIds.length === 0) return; + const targetItem = targetRowId ? fileTreeRowById.get(targetRowId) : null; + const target = targetItem + ? { + rowId: targetItem.rowId, + rowKind: targetItem.rowKind, + nodeId: targetItem.nodeId, + documentId: getFileTreeRowDocumentId(targetItem) || null, + assetId: getFileTreeRowAssetId(targetItem) || null, + } + : { rowId: null, rowKind: "root", nodeId: null, documentId: null, assetId: null }; + if (sourceKind === "local_folder") { + const pasted = await executeLocalFileTreeInternalDrop( + target, + fileTreeClipboard.rowIds, + fileTreeClipboard.action === "copy", + "paste", + ); + if (pasted && fileTreeClipboard.action === "cut") { + fileTreeClipboard = { action: null, rowIds: [] }; + } + patchFileTreeCutDecoration(); + } else { + const accepted = await runFileTreePreflight("paste", { + target, + rowIds: fileTreeClipboard.rowIds, + copy: fileTreeClipboard.action === "copy", + }); + if (!accepted) return; + if (sourceKind === "convex_workspace") { + const sourceRowIds = filterRedundantFileTreeRowIds(fileTreeClipboard.rowIds); + for (const rowId of sourceRowIds) { + const sourceItem = fileTreeRowById.get(rowId); + const documentId = getFileTreeRowDocumentId(sourceItem); + if (!documentId) continue; + await sendCommand({ + action: fileTreeClipboard.action === "copy" ? "copy" : "move", + workspaceId, + documentId, + parentId: target.documentId || null, + targetParentId: target.documentId || null, + sortOrder: 0, + }); + } + if (fileTreeClipboard.action === "cut") { + fileTreeClipboard = { action: null, rowIds: [] }; + } + patchFileTreeCutDecoration(); + scheduleRefresh(); + } else { + postToHost("tree.filetree.paste", { + workspaceId, + rowIds: fileTreeClipboard.rowIds, + action: fileTreeClipboard.action, + targetRowId: target.rowId, + targetRowKind: target.rowKind, + documentId: target.documentId, + payload: { + workspaceId, + rowIds: fileTreeClipboard.rowIds, + action: fileTreeClipboard.action, + targetRowId: target.rowId, + targetRowKind: target.rowKind, + documentId: target.documentId, + }, + }); + } + } + }; + + const handleFileTreeKeyDown = (event, item) => { + if (mode !== "filetree") return; + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") { + event.preventDefault(); + setFileTreeClipboard("copy"); + return; + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "x") { + event.preventDefault(); + setFileTreeClipboard("cut"); + return; + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") { + event.preventDefault(); + void pasteFileTreeClipboardInto(item?.rowId || fileTreeFocusedRowId); + return; + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") { + event.preventDefault(); + commitFileTreeSelection({ + selectedRowIds: visibleFileTreeRowIds, + anchorRowId: visibleFileTreeRowIds[0] || null, + focusedRowId: visibleFileTreeRowIds[visibleFileTreeRowIds.length - 1] || null, + }); + renderTree(); + return; + } + if (event.key === "F2") { + event.preventDefault(); + const rowId = item?.rowId || fileTreeFocusedRowId; + if (rowId) beginInlineRename("filetree", rowId); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + focusFileTreeRowByOffset(1); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + focusFileTreeRowByOffset(-1); + return; + } + if (event.key === "Home") { + event.preventDefault(); + focusFileTreeRowByOffset(-visibleFileTreeRowIds.length); + return; + } + if (event.key === "End") { + event.preventDefault(); + focusFileTreeRowByOffset(visibleFileTreeRowIds.length); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + if (item) { + const documentId = getFileTreeRowDocumentId(item); + if (documentId) handleNavigate(documentId); + } + return; + } + if (event.key === "Delete" || event.key === "Backspace") { + event.preventDefault(); + void runFileTreeDelete(item?.rowId || fileTreeFocusedRowId); + } + }; + + const scheduleRefresh = (options = {}) => { window.setTimeout(() => { + const renameRowId = normalizeText(options.renameRowId); + if (renameRowId) { + const url = new URL(window.location.href); + url.searchParams.set("renameRowId", renameRowId); + window.location.assign(url.toString()); + return; + } window.location.reload(); }, 80); }; + if (sourceKind === "local_folder" && rootUri) { + let localWatchRevision = initialLocalWatchRevision; + let localWatchRefreshTimer = 0; + const refreshFromLocalWatch = () => { + if (localWatchRefreshTimer) return; + localWatchRefreshTimer = window.setTimeout(() => { + window.location.reload(); + }, 180); + }; + const pollLocalFolderRevision = async () => { + if (busy || document.hidden) return; + const url = new URL("/api/tree/local-folder-watch", window.location.origin); + url.searchParams.set("rootUri", rootUri); + const response = await fetch(url.toString(), { headers: { "accept": "application/json" } }); + if (!response.ok) return; + const payload = await response.json(); + const nextRevision = + payload && + payload.result && + typeof payload.result.revision === "string" + ? payload.result.revision + : ""; + if (!nextRevision) return; + if (!localWatchRevision) { + localWatchRevision = nextRevision; + return; + } + if (nextRevision !== localWatchRevision) { + localWatchRevision = nextRevision; + refreshFromLocalWatch(); + } + }; + window.setInterval(() => { + void pollLocalFolderRevision(); + }, 1200); + } + const readErrorMessage = async (response) => { const text = await response.text(); try { @@ -2140,6 +3108,12 @@ fn build_tree_shell_html( setBusy(true); setStatus("正在提交树命令…"); try { + const commandPayload = Object.assign( + {}, + payload, + sourceKind ? { sourceKind } : {}, + rootUri ? { rootUri } : {}, + ); const response = await fetch(commandPath, { method: "POST", headers: { @@ -2154,7 +3128,7 @@ fn build_tree_shell_html( } : {}), }, - body: JSON.stringify(payload), + body: JSON.stringify(commandPayload), }); if (!response.ok) { throw new Error(await readErrorMessage(response)); @@ -2175,13 +3149,6 @@ fn build_tree_shell_html( } }; - const promptTitle = (message, defaultValue) => { - const value = window.prompt(message, defaultValue || "无标题"); - if (value === null) return null; - const trimmed = value.trim(); - return trimmed || "无标题"; - }; - const getSiblings = (parentId) => { if (!parentId) return roots.slice(); return (childrenByParentId.get(parentId) || []).slice(); @@ -2869,6 +3836,254 @@ fn build_tree_shell_html( applyPickerStateAction({ kind: normalizedCommand }); }; + const closeFileTreeContextMenu = () => { + if (!activeFileTreeMenuElement) return; + activeFileTreeMenuElement.remove(); + activeFileTreeMenuElement = null; + }; + + const buildFileTreeMenuTarget = (rowId, rowKind, documentId, assetId) => { + const item = rowId ? fileTreeRowById.get(rowId) : null; + return { + item, + rowId: rowId || null, + rowKind: rowKind || item?.rowKind || "root", + documentId: documentId || getFileTreeRowDocumentId(item) || null, + assetId: assetId || getFileTreeRowAssetId(item) || null, + }; + }; + + const createFileTreeMenuItem = (kind, label, options = {}) => ({ + kind, + label, + disabled: options.disabled === true, + reason: normalizeText(options.reason), + separatorBefore: options.separatorBefore === true, + }); + + const buildFileTreeContextMenuProfile = (target) => { + const selectedCount = selectedFileTreeRowIds.size; + const isMulti = selectedCount > 1 && target.rowId && selectedFileTreeRowIds.has(target.rowId); + const item = target.item; + const rowKind = target.rowKind || "root"; + const canPaste = Boolean(fileTreeClipboard.action && fileTreeClipboard.rowIds.length > 0); + const hasDocument = Boolean(getFileTreeRowDocumentId(item)); + const hasAsset = Boolean(getFileTreeRowAssetId(item)); + const localSource = sourceKind === "local_folder"; + const convexSource = sourceKind === "convex_workspace"; + const canCreateFolder = localSource; + const canCreatePage = rowKind === "root" || rowKind === "folder" || rowKind === "document"; + const canRename = + rowKind === "markdown" || + rowKind === "document" || + rowKind === "folder" || + rowKind === "index" || + (localSource && rowKind === "asset"); + const canCopyCut = + rowKind === "markdown" || + rowKind === "document" || + rowKind === "index"; + const canDelete = + rowKind === "markdown" || + rowKind === "document" || + rowKind === "index"; + + if (rowKind === "root") { + return [ + createFileTreeMenuItem("newPage", "新建页面", { + disabled: !canCreatePage, + reason: "当前 root 不支持新建页面", + }), + createFileTreeMenuItem("newFolder", "新建文件夹", { + disabled: !canCreateFolder, + reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹", + }), + createFileTreeMenuItem("upload", "上传/导入", { + disabled: true, + reason: localSource ? "外部文件请拖入 Explorer" : "Convex 上传 executor 尚未接入", + }), + createFileTreeMenuItem("refresh", "刷新", { separatorBefore: true }), + createFileTreeMenuItem("collapseAll", "全部折叠"), + ]; + } + + if (isMulti) { + return [ + createFileTreeMenuItem("copy", "复制"), + createFileTreeMenuItem("cut", "剪切"), + createFileTreeMenuItem("delete", "删除", { disabled: false }), + createFileTreeMenuItem("moveTo", "移动到", { + separatorBefore: true, + disabled: true, + reason: "目标选择器尚未接入 filetree 菜单", + }), + ]; + } + + const items = []; + if (rowKind === "folder") { + items.push(createFileTreeMenuItem("newPage", "新建页面", { + disabled: !canCreatePage, + reason: "当前文件夹不支持新建页面", + })); + items.push(createFileTreeMenuItem("newFolder", "新建文件夹", { + disabled: !canCreateFolder, + reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹", + })); + items.push(createFileTreeMenuItem("paste", "粘贴", { + disabled: !canPaste, + reason: "剪贴板为空", + })); + items.push(createFileTreeMenuItem("rename", "重命名", { + separatorBefore: true, + disabled: !canRename, + reason: "当前文件夹不支持重命名", + })); + items.push(createFileTreeMenuItem("delete", "删除", { + disabled: true, + reason: "目录删除尚未收口到统一 executor", + })); + return items; + } + + items.push(createFileTreeMenuItem("open", "打开", { + disabled: !hasDocument && !hasAsset, + reason: "当前行没有可打开资源", + })); + items.push(createFileTreeMenuItem("rename", "重命名", { + disabled: !canRename, + reason: "当前资源暂不支持重命名", + })); + items.push(createFileTreeMenuItem("copy", "复制", { + disabled: !canCopyCut, + reason: "当前资源暂不支持复制", + })); + items.push(createFileTreeMenuItem("cut", "剪切", { + disabled: !canCopyCut, + reason: "当前资源暂不支持剪切", + })); + items.push(createFileTreeMenuItem("paste", "粘贴", { + disabled: !canPaste, + reason: "剪贴板为空", + })); + items.push(createFileTreeMenuItem("moveTo", "移动到", { + disabled: true, + reason: "目标选择器尚未接入 filetree 菜单", + })); + items.push(createFileTreeMenuItem("delete", "删除", { + disabled: !canDelete, + reason: "当前资源暂不支持删除", + })); + items.push(createFileTreeMenuItem("reveal", "Reveal", { separatorBefore: true })); + if (hasAsset) { + items.push(createFileTreeMenuItem("download", "下载", { + disabled: true, + reason: "下载 executor 尚未接入", + })); + } + if (item?.capabilities?.includes("share")) { + items.push(createFileTreeMenuItem("share", "分享", { separatorBefore: true })); + } + if (item?.capabilities?.includes("publish")) { + items.push(createFileTreeMenuItem("publish", "发布")); + } + return items; + }; + + const localCreatedRowIdFromCommandResult = (result, rowKind) => { + const relativePath = + typeof result?.execution?.relativePath === "string" && result.execution.relativePath.trim() + ? result.execution.relativePath.trim() + : typeof result?.relativePath === "string" && result.relativePath.trim() + ? result.relativePath.trim() + : ""; + if (!relativePath) return ""; + return `local:${rowKind}:${relativePath}`; + }; + + const executeFileTreeContextMenuAction = async (kind, target) => { + closeFileTreeContextMenu(); + const item = target.item; + if (kind === "open") { + if (item) openHydratedFileTreeItem(item); + return; + } + if (kind === "rename") { + if (target.rowId) beginInlineRename("filetree", target.rowId); + return; + } + if (kind === "copy" || kind === "cut") { + if (target.rowId && !selectedFileTreeRowIds.has(target.rowId)) { + commitFileTreeSelection({ + selectedRowIds: [target.rowId], + anchorRowId: target.rowId, + focusedRowId: target.rowId, + }); + syncFileTreeSelectionDom(); + } + setFileTreeClipboard(kind); + return; + } + if (kind === "paste") { + await pasteFileTreeClipboardInto(target.rowId); + return; + } + if (kind === "delete") { + await runFileTreeDelete(target.rowId); + return; + } + if (kind === "newPage") { + const parentId = item && item.rowKind === "folder" + ? item.nodeId + : item && item.rowKind === "document" + ? item.nodeId + : null; + await handleCreate(parentId); + return; + } + if (kind === "newFolder") { + const parentId = item && item.rowKind === "folder" ? item.nodeId : null; + const result = await sendCommand({ + action: "createFolder", + workspaceId, + documentId: "", + parentId, + title: "新建文件夹", + }); + setStatus("创建文件夹成功"); + setLastAction("已创建新建文件夹"); + scheduleRefresh({ + renameRowId: localCreatedRowIdFromCommandResult(result, "folder"), + }); + return result; + } + if (kind === "refresh") { + window.location.reload(); + return; + } + if (kind === "collapseAll") { + expanded.clear(); + renderTree(); + return; + } + if (kind === "reveal") { + setLastAction(`Reveal ${target.documentId || target.assetId || target.rowId || "root"}`); + postToHost("tree.filetree.reveal", { + documentId: target.documentId, + assetId: target.assetId, + rowId: target.rowId, + payload: target, + }); + return; + } + postToHost(`tree.filetree.${kind}`, { + documentId: target.documentId, + assetId: target.assetId, + rowId: target.rowId, + payload: target, + }); + }; + const openFileTreeContextMenu = ({ documentId, assetId, @@ -2877,6 +4092,69 @@ fn build_tree_shell_html( clientX, clientY, }) => { + closeFileTreeContextMenu(); + const target = buildFileTreeMenuTarget(rowId, rowKind, documentId, assetId); + const profile = buildFileTreeContextMenuProfile(target); + const menu = document.createElement("div"); + menu.className = "tree-context-menu"; + menu.dataset.testid = "filetree-context-menu"; + menu.dataset.sourceKind = sourceKind; + menu.dataset.rowKind = target.rowKind; + menu.setAttribute("role", "menu"); + + profile.forEach((entry) => { + if (entry.separatorBefore) { + const separator = document.createElement("div"); + separator.className = "tree-menu-separator"; + separator.setAttribute("role", "separator"); + menu.appendChild(separator); + } + const button = document.createElement("button"); + button.type = "button"; + button.className = "tree-menu-item"; + button.dataset.menuAction = entry.kind; + button.setAttribute("role", "menuitem"); + button.textContent = entry.label; + if (entry.disabled) { + button.disabled = true; + if (entry.reason) { + button.title = entry.reason; + button.dataset.disabledReason = entry.reason; + } + } else { + button.addEventListener("click", () => { + void executeFileTreeContextMenuAction(entry.kind, target); + }); + } + menu.appendChild(button); + }); + + const closeOnOutside = (event) => { + if (activeFileTreeMenuElement && !activeFileTreeMenuElement.contains(event.target)) { + closeFileTreeContextMenu(); + document.removeEventListener("mousedown", closeOnOutside, true); + document.removeEventListener("keydown", closeOnKeyDown, true); + } + }; + const closeOnKeyDown = (event) => { + if (event.key === "Escape") { + closeFileTreeContextMenu(); + document.removeEventListener("mousedown", closeOnOutside, true); + document.removeEventListener("keydown", closeOnKeyDown, true); + } + }; + + document.body.appendChild(menu); + const x = Number.isFinite(clientX) ? clientX : 16; + const y = Number.isFinite(clientY) ? clientY : 16; + const rect = menu.getBoundingClientRect(); + menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - rect.width - 4))}px`; + menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - rect.height - 4))}px`; + activeFileTreeMenuElement = menu; + window.setTimeout(() => { + document.addEventListener("mousedown", closeOnOutside, true); + document.addEventListener("keydown", closeOnKeyDown, true); + }, 0); setLastAction( assetId ? `已打开资源 ${assetId} 的更多操作` @@ -2973,7 +4251,7 @@ fn build_tree_shell_html( } if (event.key === "F2") { event.preventDefault(); - void handleRename(item.nodeId); + beginInlineRename("page", item.nodeId); return; } if ( @@ -3033,7 +4311,9 @@ fn build_tree_shell_html( payload: { documentId }, }); } - scheduleRefresh(); + scheduleRefresh({ + renameRowId: localCreatedRowIdFromCommandResult(result, "markdown"), + }); } catch (error) { const message = error instanceof Error ? error.message : "创建页面失败"; setStatus(message, "error"); @@ -3042,38 +4322,97 @@ fn build_tree_shell_html( } }; - const handleRename = async (nodeId) => { - const item = itemById.get(nodeId); - if (!item) return; - const title = promptTitle("输入新的页面标题", item.title); - if (title === null) return; + const focusInlineRenameInput = (id) => { + window.requestAnimationFrame(() => { + const input = appElement.querySelector( + `.tree-rename-input[data-rename-id="${CSS.escape(id)}"]`, + ); + if (!(input instanceof HTMLInputElement)) return; + input.focus(); + const dotIndex = input.value.lastIndexOf("."); + const end = dotIndex > 0 ? dotIndex : input.value.length; + input.setSelectionRange(0, end); + }); + }; + + const beginInlineRename = (renameMode, id) => { + inlineRenameState = { mode: renameMode, id, committing: false }; + renderTree(); + focusInlineRenameInput(id); + }; + + const cancelInlineRename = () => { + inlineRenameState = { mode: null, id: null, committing: false }; + renderTree(); + }; + + const commitInlineRename = async (title) => { + if (!inlineRenameState.mode || !inlineRenameState.id || inlineRenameState.committing) return; + const trimmed = normalizeText(title); + if (!trimmed) { + cancelInlineRename(); + return; + } + inlineRenameState.committing = true; + const renameMode = inlineRenameState.mode; + const id = inlineRenameState.id; + const item = renameMode === "filetree" ? fileTreeRowById.get(id) : itemById.get(id); + const documentId = renameMode === "filetree" + ? getFileTreeRowDocumentId(item) || item?.nodeId || id + : id; try { const result = await sendCommand({ action: "rename", workspaceId, - documentId: nodeId, - title, + documentId, + title: trimmed, }); - const documentId = + const resultDocumentId = typeof result.documentId === "string" && result.documentId.trim() ? result.documentId.trim() - : nodeId; + : id; setStatus("重命名成功"); - setLastAction(`页面已重命名为 ${title}`); + setLastAction(`已重命名为 ${trimmed}`); postToHost("tree.node.renamed", { - documentId, - target: { documentId }, - payload: { documentId }, + documentId: resultDocumentId, + target: { documentId: resultDocumentId }, + payload: { documentId: resultDocumentId }, }); + inlineRenameState = { mode: null, id: null, committing: false }; scheduleRefresh(); } catch (error) { const message = error instanceof Error ? error.message : "重命名失败"; setStatus(message, "error"); setLastAction("重命名失败", "error"); + inlineRenameState = { mode: null, id: null, committing: false }; + renderTree(); window.alert(message); } }; + const attachInlineRenameInput = (input, originalTitle) => { + input.addEventListener("click", (event) => event.stopPropagation()); + input.addEventListener("dblclick", (event) => event.stopPropagation()); + input.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + void commitInlineRename(input.value); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelInlineRename(); + } + }); + input.addEventListener("blur", () => { + if (!inlineRenameState.mode || inlineRenameState.committing) return; + if (input.value === originalTitle) { + cancelInlineRename(); + return; + } + void commitInlineRename(input.value); + }); + }; + const handleMove = async (nodeId, delta) => { const item = itemById.get(nodeId); if (!item) return; @@ -3229,7 +4568,7 @@ fn build_tree_shell_html( } else if (action === "create") { void handleCreate(item.nodeId); } else if (action === "rename") { - void handleRename(item.nodeId); + beginInlineRename("page", item.nodeId); } else if (action === "menu") { applyPageKeyboardAction({ kind: "context_menu", nodeId: item.nodeId }, item, element); } @@ -3405,6 +4744,19 @@ fn build_tree_shell_html( if (event.target !== event.currentTarget) return; clearFileTreeSelection(); }); + root.addEventListener("contextmenu", (event) => { + if (event.target !== event.currentTarget) return; + event.preventDefault(); + clearFileTreeSelection(); + openFileTreeContextMenu({ + documentId: null, + assetId: null, + rowId: null, + rowKind: "root", + clientX: event.clientX, + clientY: event.clientY, + }); + }); root.addEventListener("dragover", (event) => { const internalRowIds = readFileTreeInternalDropPayload(event); const files = Array.from(event.dataTransfer?.files || []); @@ -3412,7 +4764,11 @@ fn build_tree_shell_html( return; } event.preventDefault(); - const target = getFileTreeDropTargetFromElement(event.target); + const target = getFileTreeDropTargetFromEvent(event); + if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) { + clearFileTreeDropFeedback(); + return; + } if (event.dataTransfer) { event.dataTransfer.dropEffect = files.length > 0 || event.altKey ? "copy" : "move"; @@ -3434,22 +4790,46 @@ fn build_tree_shell_html( return; } event.preventDefault(); - const target = getFileTreeDropTargetFromElement(event.target); + const target = getFileTreeDropTargetFromEvent(event); + if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) { + clearFileTreeDropFeedback(); + return; + } if (files.length > 0) { - setLastAction(`已发送 ${files.length} 个外部文件到宿主`); - postHydratedFileTreeDropToHost("tree.filetree.external-drop", target, { - files, - }); + if (sourceKind === "local_folder") { + void executeLocalFileTreeExternalDrop(target, files); + } else { + void (async () => { + const accepted = await runFileTreePreflight("dropFiles", { target, files }); + if (!accepted) return; + setLastAction(`已发送 ${files.length} 个外部文件到宿主`); + postHydratedFileTreeDropToHost("tree.filetree.external-drop", target, { + files, + }); + })(); + } } else { - setLastAction( - event.altKey - ? `已发送复制拖放到 ${target.rowId || "根目录"}` - : `已发送移动拖放到 ${target.rowId || "根目录"}`, - ); - postHydratedFileTreeDropToHost("tree.filetree.internal-drop", target, { - rowIds: internalRowIds, - copy: event.altKey === true, - }); + if (sourceKind === "local_folder") { + void executeLocalFileTreeInternalDrop(target, internalRowIds, event.altKey === true); + } else { + void (async () => { + const accepted = await runFileTreePreflight("move", { + target, + rowIds: internalRowIds, + copy: event.altKey === true, + }); + if (!accepted) return; + setLastAction( + event.altKey + ? `已发送复制拖放到 ${target.rowId || "根目录"}` + : `已发送移动拖放到 ${target.rowId || "根目录"}`, + ); + postHydratedFileTreeDropToHost("tree.filetree.internal-drop", target, { + rowIds: internalRowIds, + copy: event.altKey === true, + }); + })(); + } } draggingFileTreeRowIds = []; clearFileTreeDropFeedback(); @@ -3471,6 +4851,9 @@ fn build_tree_shell_html( row.dataset.shellMode = "filetree"; row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId)); row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId); + if (activeFileTreeDropRowId === item.rowId && activeFileTreeDropPosition) { + row.dataset.dropPosition = activeFileTreeDropPosition; + } row.tabIndex = 0; row.setAttribute("role", "treeitem"); row.setAttribute("aria-level", String(item.depth + 1)); @@ -3480,11 +4863,13 @@ fn build_tree_shell_html( ); row.addEventListener("click", (event) => { selectFileTreeRow(item.rowId, event); + row.focus({ preventScroll: true }); syncFileTreeSelectionDom(); }); row.addEventListener("dblclick", () => { openHydratedFileTreeItem(item); }); + row.addEventListener("keydown", (event) => handleFileTreeKeyDown(event, item)); row.addEventListener("contextmenu", (event) => { event.preventDefault(); selectFileTreeContextRow(item.rowId); @@ -3499,6 +4884,7 @@ fn build_tree_shell_html( }); }); attachHydratedFileTreeDragSource(row, item); + patchFileTreeCutDecoration(); row.querySelectorAll("[data-rust-action]").forEach((element) => { if (!(element instanceof HTMLElement)) return; element.addEventListener("click", (event) => { @@ -3795,10 +5181,21 @@ fn build_tree_shell_html( handleNavigate(item.nodeId); }); - const titleElement = document.createElement("span"); - titleElement.className = "tree-link-title"; - titleElement.textContent = item.title; - linkButton.appendChild(titleElement); + if (inlineRenameState.mode === "page" && inlineRenameState.id === item.nodeId) { + const renameInput = document.createElement("input"); + renameInput.type = "text"; + renameInput.className = "tree-rename-input"; + renameInput.value = item.title; + renameInput.setAttribute("aria-label", `重命名 ${item.title}`); + renameInput.dataset.renameId = item.nodeId; + attachInlineRenameInput(renameInput, item.title); + linkButton.appendChild(renameInput); + } else { + const titleElement = document.createElement("span"); + titleElement.className = "tree-link-title"; + titleElement.textContent = item.title; + linkButton.appendChild(titleElement); + } const metaElement = document.createElement("span"); metaElement.className = "tree-link-meta"; @@ -3825,7 +5222,7 @@ fn build_tree_shell_html( ICONS.edit, "tree-action-rename", `重命名 ${item.title}`, - () => void handleRename(item.nodeId), + () => beginInlineRename("page", item.nodeId), false, ), ); @@ -3894,7 +5291,7 @@ fn build_tree_shell_html( const openFileTreeItem = (item) => { const documentId = getFileTreeRowDocumentId(item); const assetId = getFileTreeRowAssetId(item); - if (item.rowKind === "document" || item.rowKind === "index") { + if (item.rowKind === "document" || item.rowKind === "index" || item.rowKind === "markdown") { handleNavigate(documentId || item.nodeId); return; } @@ -3979,6 +5376,9 @@ fn build_tree_shell_html( row.dataset.shellMode = "filetree"; row.dataset.selected = String(selectedFileTreeRowIds.has(item.rowId)); row.dataset.dropTarget = String(activeFileTreeDropRowId === item.rowId); + if (activeFileTreeDropRowId === item.rowId && activeFileTreeDropPosition) { + row.dataset.dropPosition = activeFileTreeDropPosition; + } row.setAttribute( "data-testid", item.rowKind === "document" @@ -3996,10 +5396,17 @@ fn build_tree_shell_html( row.addEventListener("click", (event) => { selectFileTreeRow(item.rowId, event); renderTree(); + const nextRow = appElement.querySelector( + `.tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(item.rowId)}"]`, + ); + if (nextRow instanceof HTMLElement) { + nextRow.focus({ preventScroll: true }); + } }); row.addEventListener("dblclick", () => { openFileTreeItem(item); }); + row.addEventListener("keydown", (event) => handleFileTreeKeyDown(event, item)); row.addEventListener("contextmenu", (event) => { event.preventDefault(); selectFileTreeContextRow(item.rowId); @@ -4046,18 +5453,38 @@ fn build_tree_shell_html( linkButton.setAttribute("data-testid", "filetree-asset-open"); } linkButton.addEventListener("click", () => openFileTreeItem(item)); - const title = document.createElement("span"); - title.className = "tree-link-title"; - title.textContent = item.title; + if (inlineRenameState.mode === "filetree" && inlineRenameState.id === item.rowId) { + const renameInput = document.createElement("input"); + renameInput.type = "text"; + renameInput.className = "tree-rename-input"; + renameInput.value = item.title; + renameInput.setAttribute("aria-label", `重命名 ${item.title}`); + renameInput.dataset.renameId = item.rowId; + attachInlineRenameInput(renameInput, item.title); + linkButton.appendChild(renameInput); + } else { + const title = document.createElement("span"); + title.className = "tree-link-title"; + title.textContent = item.title; + linkButton.appendChild(title); + } const meta = document.createElement("span"); meta.className = "tree-link-meta"; meta.textContent = getFileTreeRowMetaLabel(item); - linkButton.appendChild(title); linkButton.appendChild(meta); row.appendChild(linkButton); const actions = document.createElement("div"); actions.className = "tree-actions"; + actions.appendChild( + createActionButton( + ICONS.edit, + "filetree-action-rename", + `重命名 ${item.title}`, + () => beginInlineRename("filetree", item.rowId), + false, + ), + ); actions.appendChild( createActionButton( ICONS.more, @@ -4091,7 +5518,11 @@ fn build_tree_shell_html( return; } event.preventDefault(); - const target = getFileTreeDropTargetFromElement(event.target); + const target = getFileTreeDropTargetFromEvent(event); + if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) { + clearFileTreeDropFeedback(); + return; + } if (event.dataTransfer) { event.dataTransfer.dropEffect = files.length > 0 || event.altKey ? "copy" : "move"; @@ -4106,6 +5537,19 @@ fn build_tree_shell_html( } clearFileTreeDropFeedback(); }); + fileRoot.addEventListener("contextmenu", (event) => { + if (event.target !== event.currentTarget) return; + event.preventDefault(); + clearFileTreeSelection(); + openFileTreeContextMenu({ + documentId: null, + assetId: null, + rowId: null, + rowKind: "root", + clientX: event.clientX, + clientY: event.clientY, + }); + }); fileRoot.addEventListener("drop", (event) => { const internalRowIds = readFileTreeInternalDropPayload(event); const files = Array.from(event.dataTransfer?.files || []); @@ -4113,22 +5557,46 @@ fn build_tree_shell_html( return; } event.preventDefault(); - const target = getFileTreeDropTargetFromElement(event.target); + const target = getFileTreeDropTargetFromEvent(event); + if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) { + clearFileTreeDropFeedback(); + return; + } if (files.length > 0) { - setLastAction(`已发送 ${files.length} 个外部文件到宿主`); - postFileTreeDropToHost("tree.filetree.external-drop", target, { - files, - }); + if (sourceKind === "local_folder") { + void executeLocalFileTreeExternalDrop(target, files); + } else { + void (async () => { + const accepted = await runFileTreePreflight("dropFiles", { target, files }); + if (!accepted) return; + setLastAction(`已发送 ${files.length} 个外部文件到宿主`); + postFileTreeDropToHost("tree.filetree.external-drop", target, { + files, + }); + })(); + } } else { - setLastAction( - event.altKey - ? `已发送复制拖放到 ${target.rowId || "根目录"}` - : `已发送移动拖放到 ${target.rowId || "根目录"}`, - ); - postFileTreeDropToHost("tree.filetree.internal-drop", target, { - rowIds: internalRowIds, - copy: event.altKey === true, - }); + if (sourceKind === "local_folder") { + void executeLocalFileTreeInternalDrop(target, internalRowIds, event.altKey === true); + } else { + void (async () => { + const accepted = await runFileTreePreflight("move", { + target, + rowIds: internalRowIds, + copy: event.altKey === true, + }); + if (!accepted) return; + setLastAction( + event.altKey + ? `已发送复制拖放到 ${target.rowId || "根目录"}` + : `已发送移动拖放到 ${target.rowId || "根目录"}`, + ); + postFileTreeDropToHost("tree.filetree.internal-drop", target, { + rowIds: internalRowIds, + copy: event.altKey === true, + }); + })(); + } } draggingFileTreeRowIds = []; clearFileTreeDropFeedback(); @@ -4144,6 +5612,7 @@ fn build_tree_shell_html( } normalizeFileTreeSelectionForVisibleRows(); appElement.appendChild(fileRoot); + patchFileTreeCutDecoration(); }; const renderTree = () => { @@ -4276,6 +5745,16 @@ fn build_tree_shell_html( if (mode === "filetree") { emitFileTreeSelectionChange(); } + if (mode === "filetree" && initialRenameRowId) { + const url = new URL(window.location.href); + url.searchParams.delete("renameRowId"); + window.history.replaceState(null, "", url.toString()); + window.setTimeout(() => { + if (fileTreeRowById.has(initialRenameRowId)) { + beginInlineRename("filetree", initialRenameRowId); + } + }, 120); + } setStatus( mode === "picker" ? "Tree picker 已就绪,可以展开目录并选择目标页面。" @@ -4314,35 +5793,80 @@ fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json, + Query(query): Query, +) -> Result<(StatusCode, Json), WebError> { + let revision = local_folder_watch_revision(&query.root_uri)?; + Ok(json_response( + &context, + json!({ + "sourceKind": "local_folder", + "rootUri": revision.root_uri, + "revision": revision.revision, + "entryCount": revision.entry_count, + "latestModifiedMs": revision.latest_modified_ms, + }), + )) +} + pub async fn tree_shell( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result { let effective_context = override_actor_context(&context, query.actor_id.as_deref()); - let effective_workspace_id = - resolve_effective_workspace_id(&effective_context, query.workspace_id.as_deref(), true)? - .expect("workspace_required 已确保存在"); let mode = normalize_tree_mode(query.mode.as_deref()); let allow_root_pick = normalize_bool_flag(query.allow_root_pick.as_deref(), false); let exclude_ids = parse_exclude_ids(query.exclude_ids.as_deref()); - let snapshot = load_projection_snapshot( - state.config(), - &effective_context, - &ProjectionSnapshotSpec { - workspace_id: &effective_workspace_id, - root_node_id: query.root_node_id.as_deref(), - depth: query.depth, - query: None, - max_results: None, - projection: if mode == "filetree" { - KernelProjectionKind::FileTree + let source_kind = query + .source_kind + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let (effective_workspace_id, snapshot) = if source_kind == Some("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") + })?; + ( + local_workspace_id_from_root_uri(root_uri)?, + if mode == "filetree" { + load_local_folder_file_tree_snapshot(root_uri)? } else { - KernelProjectionKind::PageTree + load_local_folder_page_tree_snapshot(root_uri)? }, - }, - ) - .await?; + ) + } else { + let effective_workspace_id = resolve_effective_workspace_id( + &effective_context, + query.workspace_id.as_deref(), + true, + )? + .expect("workspace_required 已确保存在"); + let snapshot = load_projection_snapshot( + state.config(), + &effective_context, + &ProjectionSnapshotSpec { + workspace_id: &effective_workspace_id, + root_node_id: query.root_node_id.as_deref(), + depth: query.depth, + query: None, + max_results: None, + projection: if mode == "filetree" { + KernelProjectionKind::FileTree + } else { + KernelProjectionKind::PageTree + }, + }, + ) + .await?; + (effective_workspace_id, snapshot) + }; let html = build_tree_shell_html( &effective_workspace_id, query.root_node_id.as_deref(), @@ -4370,6 +5894,7 @@ fn create_command_wire( context: &RequestContext, workspace_id: &str, request: TreeCommandRequest, + envelope_context: &TreeCommandEnvelopeContext, ) -> Result { match request { TreeCommandRequest::Create { @@ -4393,23 +5918,23 @@ fn create_command_wire( actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, - source: bridge_runtime::RuntimeSourceWire { - channel: context.source.channel.clone(), - client: context.source.client.clone(), - }, + source: build_workspace_source_wire(context, workspace_id, envelope_context), target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), - payload: json!({ - "documentId": document_id, - "workspaceId": workspace_id, - "parentId": parent_id, - "title": title, - "accessScope": access_scope, - "content": content.unwrap_or_else(|| Value::Array(Vec::new())), - }), + payload: attach_tree_command_envelope_context( + json!({ + "documentId": document_id, + "workspaceId": workspace_id, + "parentId": parent_id, + "title": title, + "accessScope": access_scope, + "content": content.unwrap_or_else(|| Value::Array(Vec::new())), + }), + envelope_context, + ), preflight_data: None, reason: Some("tree-shell create".into()), refs: vec!["mnote-web-tree".into()], @@ -4417,6 +5942,12 @@ fn create_command_wire( validate_only: false, }) } + TreeCommandRequest::CreateFolder { .. } => Err(WebError::bad_request_code( + "tree_command_validation", + "convex_workspace 暂不支持 folder create capability", + ) + .with_context(context) + .with_header("x-error-phase", "tree_command_validate")), TreeCommandRequest::Rename { workspace_id: _, document_id, @@ -4432,19 +5963,19 @@ fn create_command_wire( actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, - source: bridge_runtime::RuntimeSourceWire { - channel: context.source.channel.clone(), - client: context.source.client.clone(), - }, + source: build_workspace_source_wire(context, workspace_id, envelope_context), target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), - payload: json!({ - "documentId": document_id, - "title": title, - }), + payload: attach_tree_command_envelope_context( + json!({ + "documentId": document_id, + "title": title, + }), + envelope_context, + ), preflight_data: None, reason: Some("tree-shell rename".into()), refs: vec!["mnote-web-tree".into()], @@ -4470,20 +6001,20 @@ fn create_command_wire( actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, - source: bridge_runtime::RuntimeSourceWire { - channel: context.source.channel.clone(), - client: context.source.client.clone(), - }, + source: build_workspace_source_wire(context, workspace_id, envelope_context), target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), - payload: json!({ - "documentId": document_id, - "parentId": parent_id, - "sortOrder": sort_order, - }), + payload: attach_tree_command_envelope_context( + json!({ + "documentId": document_id, + "parentId": parent_id, + "sortOrder": sort_order, + }), + envelope_context, + ), preflight_data: None, reason: Some("tree-shell move".into()), refs: vec!["mnote-web-tree".into()], @@ -4491,6 +6022,135 @@ fn create_command_wire( validate_only: false, }) } + TreeCommandRequest::Archive { + workspace_id: _, + document_id, + } => { + let document_id = ensure_non_empty(&document_id, "documentId", context)?; + Ok(RuntimeCommandEnvelopeWire { + name: "tree.node.archive".into(), + command_id: format!("tree_archive_{}", context.trace.request_id), + idempotency_key: context.source.idempotency_key.clone(), + actor: bridge_runtime::RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: context.auth.actor_id.clone(), + session_id: context.auth.session_id.clone(), + }, + source: build_workspace_source_wire(context, workspace_id, envelope_context), + target: Some(build_tree_target( + workspace_id, + Some(document_id.as_str()), + None, + )), + payload: attach_tree_command_envelope_context( + json!({ + "documentId": document_id, + "workspaceId": workspace_id, + }), + envelope_context, + ), + preflight_data: None, + reason: Some("tree-shell archive".into()), + refs: vec!["mnote-web-tree".into()], + dry_run: false, + validate_only: false, + }) + } + TreeCommandRequest::Restore { + workspace_id: _, + document_id, + } => { + let document_id = ensure_non_empty(&document_id, "documentId", context)?; + Ok(RuntimeCommandEnvelopeWire { + name: "tree.node.restore".into(), + command_id: format!("tree_restore_{}", context.trace.request_id), + idempotency_key: context.source.idempotency_key.clone(), + actor: bridge_runtime::RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: context.auth.actor_id.clone(), + session_id: context.auth.session_id.clone(), + }, + source: build_workspace_source_wire(context, workspace_id, envelope_context), + target: Some(build_tree_target( + workspace_id, + Some(document_id.as_str()), + None, + )), + payload: attach_tree_command_envelope_context( + json!({ + "documentId": document_id, + "workspaceId": workspace_id, + }), + envelope_context, + ), + preflight_data: None, + reason: Some("tree-shell restore".into()), + refs: vec!["mnote-web-tree".into()], + dry_run: false, + validate_only: false, + }) + } + TreeCommandRequest::Copy { + workspace_id: _, + document_id, + target_parent_id, + items, + title: _, + } => { + let fallback_document_id = ensure_non_empty(&document_id, "documentId", context)?; + let copy_items = if items.is_empty() { + vec![json!({ + "documentId": fallback_document_id, + "recursive": true, + })] + } else { + items + .into_iter() + .map(|item| { + Ok(json!({ + "documentId": ensure_non_empty(&item.document_id, "items.documentId", context)?, + "recursive": item.recursive, + })) + }) + .collect::, WebError>>()? + }; + let target_parent_id = read_optional_non_empty(target_parent_id); + Ok(RuntimeCommandEnvelopeWire { + name: "tree.subtree.copy".into(), + command_id: format!("tree_copy_{}", context.trace.request_id), + idempotency_key: context.source.idempotency_key.clone(), + actor: bridge_runtime::RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: context.auth.actor_id.clone(), + session_id: context.auth.session_id.clone(), + }, + source: build_workspace_source_wire(context, workspace_id, envelope_context), + target: Some(build_tree_target( + workspace_id, + target_parent_id.as_deref(), + None, + )), + payload: attach_tree_command_envelope_context( + json!({ + "workspaceId": workspace_id, + "targetParentId": target_parent_id, + "items": copy_items, + }), + envelope_context, + ), + preflight_data: None, + reason: Some("tree-shell copy".into()), + refs: vec!["mnote-web-tree".into()], + dry_run: false, + validate_only: false, + }) + } + TreeCommandRequest::DropFiles { .. } => Err(WebError::bad_request_code( + "tree_command_validation", + "convex_workspace 外部文件 drop 需要走上传 preflight / object storage executor", + ) + .with_context(context) + .with_header("x-error-phase", "tree_command_validate")), TreeCommandRequest::Purge { workspace_id: _, document_id, @@ -4505,19 +6165,19 @@ fn create_command_wire( actor_id: context.auth.actor_id.clone(), session_id: context.auth.session_id.clone(), }, - source: bridge_runtime::RuntimeSourceWire { - channel: context.source.channel.clone(), - client: context.source.client.clone(), - }, + source: build_workspace_source_wire(context, workspace_id, envelope_context), target: Some(build_tree_target( workspace_id, Some(document_id.as_str()), None, )), - payload: json!({ - "documentId": document_id, - "workspaceId": workspace_id, - }), + payload: attach_tree_command_envelope_context( + json!({ + "documentId": document_id, + "workspaceId": workspace_id, + }), + envelope_context, + ), preflight_data: None, reason: Some("tree-shell purge".into()), refs: vec!["mnote-web-tree".into()], @@ -4627,6 +6287,7 @@ pub async fn tree_command( .with_context(&context) .with_header("x-error-phase", "tree_command_decode") })?; + let envelope_context = TreeCommandEnvelopeContext::from_envelope(&raw_request); let request = match raw_request.action.trim() { "create" => TreeCommandRequest::Create { workspace_id: raw_request.workspace_id, @@ -4637,6 +6298,13 @@ pub async fn tree_command( access_scope: raw_request.access_scope, content: raw_request.content, }, + "createFolder" | "create_folder" | "folder.create" => TreeCommandRequest::CreateFolder { + workspace_id: raw_request.workspace_id, + document_id: read_optional_non_empty(raw_request.document_id) + .unwrap_or_else(generate_tree_document_id), + parent_id: raw_request.parent_id, + title: normalize_title(raw_request.title.or_else(|| Some("新建文件夹".into()))), + }, "rename" => TreeCommandRequest::Rename { workspace_id: raw_request.workspace_id, document_id: raw_request.document_id.unwrap_or_default(), @@ -4648,6 +6316,30 @@ pub async fn tree_command( parent_id: raw_request.parent_id, sort_order: raw_request.sort_order.unwrap_or(-1), }, + "archive" | "delete" | "trash" => TreeCommandRequest::Archive { + workspace_id: raw_request.workspace_id, + document_id: raw_request.document_id.unwrap_or_default(), + }, + "restore" => TreeCommandRequest::Restore { + workspace_id: raw_request.workspace_id, + document_id: raw_request.document_id.unwrap_or_default(), + }, + "copy" => TreeCommandRequest::Copy { + workspace_id: raw_request.workspace_id, + document_id: raw_request.document_id.unwrap_or_default(), + target_parent_id: raw_request.target_parent_id.or(raw_request.parent_id), + items: raw_request.items, + title: raw_request.title, + }, + "dropFiles" | "drop_files" => TreeCommandRequest::DropFiles { + workspace_id: raw_request.workspace_id, + parent_id: raw_request.parent_id, + files_json: match raw_request.content { + Some(Value::String(value)) => value, + Some(value) => value.to_string(), + None => "[]".into(), + }, + }, "purge" => TreeCommandRequest::Purge { workspace_id: raw_request.workspace_id, document_id: raw_request.document_id.unwrap_or_default(), @@ -4663,8 +6355,13 @@ pub async fn tree_command( }; let requested_workspace_id = match &request { TreeCommandRequest::Create { workspace_id, .. } => workspace_id.as_deref(), + TreeCommandRequest::CreateFolder { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Rename { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Move { workspace_id, .. } => workspace_id.as_deref(), + TreeCommandRequest::Archive { workspace_id, .. } => workspace_id.as_deref(), + TreeCommandRequest::Restore { workspace_id, .. } => workspace_id.as_deref(), + TreeCommandRequest::Copy { workspace_id, .. } => workspace_id.as_deref(), + TreeCommandRequest::DropFiles { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Purge { workspace_id, .. } => workspace_id.as_deref(), }; let (action, requested_document_id, requested_parent_id, requested_title, requested_sort_order) = @@ -4681,6 +6378,18 @@ pub async fn tree_command( Some(title.clone()), None, ), + TreeCommandRequest::CreateFolder { + document_id, + parent_id, + title, + .. + } => ( + "createFolder", + document_id.clone(), + parent_id.clone(), + Some(title.clone()), + None, + ), TreeCommandRequest::Rename { document_id, title, .. } => ( @@ -4702,10 +6411,80 @@ pub async fn tree_command( None, Some(*sort_order), ), + TreeCommandRequest::Archive { document_id, .. } => { + ("delete", document_id.clone(), None, None, None) + } + TreeCommandRequest::Restore { document_id, .. } => { + ("restore", document_id.clone(), None, None, None) + } + TreeCommandRequest::Copy { + document_id, + target_parent_id, + title, + .. + } => ( + "copy", + document_id.clone(), + target_parent_id.clone(), + title.clone(), + None, + ), + TreeCommandRequest::DropFiles { + parent_id, + files_json, + .. + } => ( + "dropFiles", + String::new(), + parent_id.clone(), + Some(files_json.clone()), + None, + ), TreeCommandRequest::Purge { document_id, .. } => { ("purge", document_id.clone(), None, None, None) } }; + if envelope_context.source_kind.as_deref() == Some("local_folder") { + let root_uri = envelope_context + .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 execution = execute_local_tree_command( + root_uri, + action, + &requested_document_id, + requested_parent_id.as_deref(), + requested_title.as_deref(), + ) + .map_err(|error| { + error + .with_context(&context) + .with_header("x-error-phase", "tree_local_executor") + })?; + return Ok(json_response( + &context, + json!({ + "workspaceId": local_workspace_id_from_root_uri(root_uri)?, + "action": action, + "documentId": execution + .get("documentId") + .and_then(Value::as_str) + .unwrap_or(&requested_document_id), + "parentId": requested_parent_id, + "title": requested_title, + "sortOrder": requested_sort_order, + "updatedAt": Value::Null, + "execution": execution, + "artifacts": Value::Null, + "artifactError": Value::Null, + }), + )); + } let effective_workspace_id = match &request { TreeCommandRequest::Create { parent_id, .. } => { resolve_tree_create_workspace_id( @@ -4716,11 +6495,24 @@ pub async fn tree_command( ) .await? } + TreeCommandRequest::CreateFolder { .. } => { + return Err(WebError::bad_request_code( + "tree_command_validation", + "convex_workspace 暂不支持 folder create capability", + ) + .with_context(&context) + .with_header("x-error-phase", "tree_command_validate")); + } _ => resolve_effective_workspace_id(&context, requested_workspace_id, true)? .expect("workspace_required 已确保存在"), }; let needs_move_preflight = matches!(&request, TreeCommandRequest::Move { .. }); - let mut command_wire = create_command_wire(&context, &effective_workspace_id, request)?; + let mut command_wire = create_command_wire( + &context, + &effective_workspace_id, + request, + &envelope_context, + )?; if needs_move_preflight { command_wire.preflight_data = Some(load_tree_move_preflight_data(&state, &context, &effective_workspace_id).await?); @@ -4774,7 +6566,7 @@ pub async fn reduce_tree_shell_runtime( #[cfg(test)] mod tests { - use super::{create_command_wire, TreeCommandRequest}; + use super::{create_command_wire, TreeCommandEnvelopeContext, TreeCommandRequest}; use crate::app::{build_app, AppConfig, AppState}; use crate::context::RequestContext; use crate::routes::command_support::build_runtime_command_plan; @@ -4938,6 +6730,329 @@ mod tests { assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); } + #[tokio::test] + async fn tree_shell_filetree_mode_can_open_local_folder_readonly_snapshot() { + let root = + std::env::temp_dir().join(format!("mnote-local-folder-source-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("docs")).expect("create local docs dir"); + std::fs::write(root.join("README.md"), "# Local Root\n").expect("write local md"); + std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md"); + std::fs::write(root.join("image.png"), b"png").expect("write asset"); + + let root_uri = format!("file://{}", root.display()); + let response = app() + .oneshot( + Request::builder() + .uri(format!( + "/tree?mode=filetree&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); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains("local_folder")); + assert!(html.contains("README.md")); + assert!(html.contains("child.md")); + assert!(html.contains("image.png")); + assert!(html.contains("data-row-kind=\"folder\"")); + assert!(html.contains("data-row-kind=\"markdown\"")); + assert!(html.contains("data-document-id=\"local-md:README.md\"")); + } + + #[tokio::test] + async fn tree_shell_page_mode_can_open_local_folder_md_only_snapshot() { + let root = std::env::temp_dir().join(format!( + "mnote-local-page-tree-source-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("docs")).expect("create local docs dir"); + std::fs::write( + root.join("README.md"), + "---\ntitle: Frontmatter Title\n---\n# Ignored H1\n", + ) + .expect("write local md"); + std::fs::write(root.join("docs").join("child.md"), "# Child H1\n").expect("write child md"); + std::fs::write(root.join("image.png"), b"png").expect("write asset"); + + let root_uri = format!("file://{}", root.display()); + let response = app() + .oneshot( + Request::builder() + .uri(format!( + "/tree?mode=page&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); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains("local_folder")); + assert!(html.contains("Frontmatter Title")); + assert!(html.contains("Child H1")); + assert!(html.contains(">docs<") || html.contains("docs")); + assert!(!html.contains("image.png")); + } + + #[tokio::test] + async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint( + ) { + let root = + std::env::temp_dir().join(format!("mnote-local-tree-command-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create local root"); + let root_uri = format!("file://{}", root.display()); + + let create_response = app() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/tree/commands") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"action":"create","sourceKind":"local_folder","rootUri":"{root_uri}","title":"新页面"}}"# + ))) + .expect("request"), + ) + .await + .expect("response"); + let create_status = create_response.status(); + let body = axum::body::to_bytes(create_response.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!( + create_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&body) + ); + let payload: Value = serde_json::from_slice(&body).expect("json"); + let document_id = payload["result"]["documentId"] + .as_str() + .expect("document id") + .to_string(); + assert!(root.join("新页面.md").exists()); + assert!(root.join(".mnote").join("page-ids.json").exists()); + + let rename_response = app() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/tree/commands") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"action":"rename","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}","title":"重命名页面"}}"# + ))) + .expect("request"), + ) + .await + .expect("response"); + let rename_status = rename_response.status(); + let rename_body = axum::body::to_bytes(rename_response.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!( + rename_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&rename_body) + ); + assert!(!root.join("新页面.md").exists()); + assert!(root.join("重命名页面.md").exists()); + + std::fs::create_dir_all(root.join("docs")).expect("create docs dir"); + let move_response = app() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/tree/commands") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"action":"move","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}","parentId":"local-dir:docs","sortOrder":0}}"# + ))) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(move_response.status(), StatusCode::OK); + assert!(!root.join("重命名页面.md").exists()); + assert!(root.join("docs").join("重命名页面.md").exists()); + + let copy_response = app() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/tree/commands") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"action":"copy","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}","parentId":"local-dir:docs"}}"# + ))) + .expect("request"), + ) + .await + .expect("response"); + let copy_status = copy_response.status(); + let copy_body = axum::body::to_bytes(copy_response.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!( + copy_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(©_body) + ); + let copy_payload: Value = serde_json::from_slice(©_body).expect("copy json"); + let copied_document_id = copy_payload["result"]["documentId"] + .as_str() + .expect("copied document id") + .to_string(); + assert!(root.join("docs").join("重命名页面 2.md").exists()); + + let folder_response = app() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/tree/commands") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"action":"createFolder","sourceKind":"local_folder","rootUri":"{root_uri}","title":"资料"}}"# + ))) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(folder_response.status(), StatusCode::OK); + assert!(root.join("资料").is_dir()); + + let delete_response = app() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/tree/commands") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}"}}"# + ))) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(delete_response.status(), StatusCode::OK); + assert!(!root.join("docs").join("重命名页面.md").exists()); + assert!(root + .join(".mnote") + .join("trash") + .join("重命名页面.md") + .exists()); + assert!(root.join(".mnote").join("trash-index.json").exists()); + + let restore_response = app() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/tree/commands") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"action":"restore","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{document_id}"}}"# + ))) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(restore_response.status(), StatusCode::OK); + assert!(root.join("docs").join("重命名页面.md").exists()); + + let purge_response = app() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/tree/commands") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"action":"purge","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{copied_document_id}"}}"# + ))) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(purge_response.status(), StatusCode::OK); + assert!(!root.join("docs").join("重命名页面 2.md").exists()); + + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn tree_command_local_folder_root_escape_returns_unified_error_envelope() { + let root = std::env::temp_dir().join(format!( + "mnote-local-tree-root-escape-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create local root"); + let root_uri = format!("file://{}", root.display()); + + let response = app() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/tree/commands") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"action":"create","sourceKind":"local_folder","rootUri":"{root_uri}","parentId":"local:folder:../outside","title":"逃逸"}}"# + ))) + .expect("request"), + ) + .await + .expect("response"); + + let status = response.status(); + let headers = response.headers().clone(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("error json"); + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(payload["ok"], false); + assert_eq!(payload["code"], "local_folder_root_escape"); + assert!(payload["message"] + .as_str() + .unwrap_or_default() + .contains("root")); + assert!(payload["requestId"].as_str().unwrap_or_default().len() > 0); + assert_eq!( + headers + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("local_folder_root_escape") + ); + assert_eq!( + headers + .get("x-error-phase") + .and_then(|value| value.to_str().ok()), + Some("tree_local_executor") + ); + } + #[tokio::test] async fn tree_shell_embeds_renderer_input_contract() { let filetree_response = app() @@ -5402,9 +7517,24 @@ mod tests { access_scope: Some("private".into()), content: Some(Value::Array(Vec::new())), }, + &TreeCommandEnvelopeContext::default(), ) .expect("create wire"); assert_eq!(create_wire.name, "tree.node.create"); + assert_eq!( + create_wire.source.source_kind.as_deref(), + Some("convex_workspace") + ); + assert_eq!( + create_wire.source.root_uri.as_deref(), + Some("convex://workspace/ws_demo") + ); + assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo")); + assert!(create_wire + .source + .capabilities + .iter() + .any(|capability| capability == "execute-command")); let rename_wire = create_command_wire( &context, @@ -5414,6 +7544,7 @@ mod tests { document_id: "page_child".into(), title: "重命名".into(), }, + &TreeCommandEnvelopeContext::default(), ) .expect("rename wire"); assert_eq!(rename_wire.name, "tree.node.rename"); @@ -5427,11 +7558,77 @@ mod tests { parent_id: Some("page_root".into()), sort_order: 1, }, + &TreeCommandEnvelopeContext::default(), ) .expect("move wire"); assert_eq!(move_wire.name, "tree.subtree.move"); } + #[test] + fn tree_command_wire_carries_source_and_target_context_from_unified_envelope() { + let context = RequestContext::from_http_parts( + &Method::POST, + &"/api/tree/commands".parse::().expect("uri"), + &HeaderMap::new(), + ); + let envelope_context = TreeCommandEnvelopeContext { + source_kind: Some("convex_workspace".into()), + root_uri: Some("convex://workspace/ws_demo".into()), + source_capabilities: vec![ + "load-snapshot".into(), + "preflight-command".into(), + "execute-command".into(), + ], + target_node_id: Some("doc:page_child".into()), + target_resource_meta: Some(serde_json::json!({ + "resourceKind": "document", + "documentId": "page_child", + "workspaceId": "ws_demo" + })), + selection: Some(serde_json::json!({ + "rowIds": ["doc:page_child"] + })), + operation: Some("tree.node.rename".into()), + }; + + let rename_wire = create_command_wire( + &context, + "ws_demo", + TreeCommandRequest::Rename { + workspace_id: Some("ws_demo".into()), + document_id: "page_child".into(), + title: "重命名".into(), + }, + &envelope_context, + ) + .expect("rename wire"); + + assert_eq!( + rename_wire.source.source_kind.as_deref(), + Some("convex_workspace") + ); + assert_eq!( + rename_wire.source.root_uri.as_deref(), + Some("convex://workspace/ws_demo") + ); + assert_eq!( + rename_wire.payload["targetNodeId"], + serde_json::json!("doc:page_child") + ); + assert_eq!( + rename_wire.payload["targetResourceMeta"]["documentId"], + serde_json::json!("page_child") + ); + assert_eq!( + rename_wire.payload["selection"]["rowIds"][0], + serde_json::json!("doc:page_child") + ); + assert_eq!( + rename_wire.payload["operation"], + serde_json::json!("tree.node.rename") + ); + } + #[test] fn tree_commands_keep_documents_alias_mapping_for_runtime_plan() { let context = RequestContext::from_http_parts( @@ -5451,6 +7648,7 @@ mod tests { access_scope: Some("private".into()), content: Some(Value::Array(Vec::new())), }, + &TreeCommandEnvelopeContext::default(), ) .expect("tree create wire"); let compat_create_wire = bridge_runtime::RuntimeCommandEnvelopeWire { @@ -5480,6 +7678,7 @@ mod tests { document_id: "page_child".into(), title: "重命名".into(), }, + &TreeCommandEnvelopeContext::default(), ) .expect("tree rename wire"); let compat_rename_wire = bridge_runtime::RuntimeCommandEnvelopeWire { @@ -5507,6 +7706,7 @@ mod tests { parent_id: Some("page_root".into()), sort_order: 1, }, + &TreeCommandEnvelopeContext::default(), ) .expect("tree move wire"); let compat_move_wire = bridge_runtime::RuntimeCommandEnvelopeWire { diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 7e7c24cb..0c8beefc 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -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, + pub source_kind: Option, + pub root_uri: Option, } #[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! { , + 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 { + 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 { + 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 { + 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('')" + )); + } } diff --git a/rust/crates/mnote-web/src/ssr/pages/document.rs b/rust/crates/mnote-web/src/ssr/pages/document.rs index bc684e65..7ec25561 100644 --- a/rust/crates/mnote-web/src/ssr/pages/document.rs +++ b/rust/crates/mnote-web/src/ssr/pages/document.rs @@ -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! {
= 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##" ''; } + function createGlobalHeadingNumbersRow() { + return '' + + ''; + } + + function renderGlobalOptions(popover) { + var globalHeadingNumbers = readGlobalShowHeadingNumbers(); + popover.querySelectorAll('[data-global-option-checkbox="showHeadingNumbers"]').forEach(function(input) { + input.checked = globalHeadingNumbers; + }); + } + function createPageFontRow() { return '' + '