feat(tree): checkpoint resource lifecycle work

提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。

不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
This commit is contained in:
lix-2026
2026-05-16 07:38:45 +08:00
parent 274779c0d8
commit 384da4e44c
85 changed files with 12570 additions and 394 deletions
@@ -0,0 +1,161 @@
# 4-27 Resource Lifecycle Command Cutover v1
> 状态:process
> 更新时间:2026-05-15
> 范围:`tree.resource.archive / restore / purge / rename` 正式命令面,以及 `/api/media/*`、mindmap、table 资源垃圾箱兼容入口的收口边界。
## 1. 本阶段目标
- 普通附件 file asset 的删除、恢复、永久删除、重命名必须具备正式 `tree.resource.*` 命令。
- `/api/media/batch``/api/media/purge` 可以继续作为历史 URL,但只能作为兼容 alias,内部必须生成正式 command plan 并记录 command artifact。
- mindmap / table 旧 URL 可以保留为兼容 route,但 delete / restore / purge 必须迁移到同一 `tree.resource.*` 命令 payload,不再扩写分散 route 语义。
## 2. 已落地范围
- `bridge-runtime` 新增 file asset 生命周期命令计划:
- `tree.resource.archive -> mediaAssets:patchById`
- `tree.resource.restore -> mediaAssets:patchById`
- `tree.resource.purge -> mediaAssets:purgeById`
- `tree.resource.rename -> mediaAssets:patchById`
- `storage-convex-bridge` 为上述命令提供默认 Convex 映射。
- `wolai-frontend``/api/media/batch`
- `delete` 改为 `tree.resource.archive`
- `restore` 改为 `tree.resource.restore`
- `rename` 改为 `tree.resource.rename`
- `wolai-frontend``/api/media/purge` 改为 `tree.resource.purge`
- `mnote-web` 的 Rust 3000 `/api/media/batch``/api/media/purge` 改为通过 runtime command 执行,旧 URL 仅作为兼容 alias。
- `mnote-web` 的 Rust 3000 `/api/mindmap/{docId}/{mindmapId}` DELETE / PATCH restore / PATCH purge 改为通过 `tree.resource.archive / restore / purge` 执行,旧 URL 仅作为兼容 alias。
- `mnote-web` 的 Rust 3000 `/api/tables/{tableId}` DELETE、`/api/tables/restore``/api/tables/purge` 改为通过 `tree.resource.archive / restore / purge` 执行,旧 URL 仅作为兼容 alias。
- tree shell command event schema 已补资源生命周期事件。
## 3. 未完成边界
- mindmap 与 table 的 DELETE / restore / purge 已进入 `tree.resource.*` runtime command;但旧 URL 仍保留为兼容入口,前端 Sidebar 仍按资源类型分流到这些旧 URL。
- `tree.resource.rename` 当前只覆盖普通附件 file assetmindmap rename 暂不支持,table rename 需要结合 `tables:update` 与 UI 标题语义另行收口。
- `empty-trash` 仍是 workspace 级批量兼容合同:`mediaAssets.emptyTrashByWorkspace``mindmaps.emptyTrashByWorkspace``tables.emptyTrashByWorkspace`,暂不展开为逐资源 `tree.resource.purge`
- 双浏览器 no-refresh、垃圾箱恢复后的 reveal/focus、跨资源 projection delta 细粒度更新仍由后续阶段覆盖。
## 4. 验收
- 普通附件通过 `/api/media/batch delete/restore/rename``/api/media/purge` 操作时,测试中应观察到正式命令名 `tree.resource.archive/restore/rename/purge`
- Mindmap 通过 `/api/mindmap/{docId}/{mindmapId}` DELETE / PATCH restore / PATCH purge 操作时,响应必须携带 `canonicalCommand=tree.resource.archive/restore/purge``resourceKind=mindmap`
- Table 通过 `/api/tables/{tableId}` DELETE、`/api/tables/restore``/api/tables/purge` 操作时,响应必须携带 `canonicalCommand=tree.resource.archive/restore/purge``resourceKind=table`
- Rust runtime plan 必须包含 `domainEventPlan``streamDeltaHint`
- Rust 3000 兼容 route 不再直接写 `mediaAssets:patchById / purgeById`,而是先进入 runtime command。
- 现有垃圾箱 route 回归保持通过。
## 5. 验证命令
```bash
cd /mnt/Data1T/mnote/rust
cargo test -p bridge-runtime tree_resource_lifecycle_plans_cover_file_asset_commands
cargo test -p bridge-runtime tree_resource_lifecycle_plans_cover_mindmap_and_table_commands
cargo test -p storage-convex-bridge tree_resource_lifecycle_commands_have_convex_mapping
cargo test -p mnote-web trash
cargo test -p mnote-web runtime_command_event_schema_covers_tree_and_resource_command_channels
```
```bash
cd /mnt/Data1T/mnote/wolai-frontend
pnpm test src/app/api/media/purge/route.test.ts src/app/api/media/batch/route.test.ts src/lib/documents/rust-runtime.test.ts
```
## 6. 执行记录
- 2026-05-15:先补 `mnote-web` 路由测试断言 mindmap / table DELETE、restore、purge 响应必须携带 `canonicalCommand=tree.resource.archive/restore/purge` 与对应 `resourceKind`,测试红灯确认旧路由仍只返回 legacy mutation 结果。
- 2026-05-15`mnote-web` mindmap / table 兼容 route 已改为构造 `tree.resource.archive/restore/purge` runtime command,并通过 `execute_runtime_command_via_convex_with_artifacts` 执行;旧 URL 保留。
- 2026-05-15:新增 `bridge-runtime` 合同测试 `tree_resource_lifecycle_plans_cover_mindmap_and_table_commands`,断言 mindmap 映射到 `mindmaps:softDelete/restore/purge`table 映射到 `tables:remove/restore/purge`,并携带 `resourceLifecyclePlan``domainEventHint` 与 stream delta hint。
- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime tree_resource_lifecycle_plans_cover_mindmap_and_table_commands -- --nocapture` 通过,1 项通过。
- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web trash_routes -- --nocapture --test-threads=1` 通过,3 项通过。
- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p storage-convex-bridge tree_resource_lifecycle_commands_have_convex_mapping -- --nocapture` 通过,1 项通过。
## 7. 剩余边界
- `tree.resource.rename` 仍只覆盖普通附件 file assetmindmap rename 继续明确不支持,table rename 待结合 `/api/tables/{tableId}` PATCH 与表格标题语义单独收口。
- `empty-trash` 仍是 workspace 级批量合同,不把它伪装成逐资源 `tree.resource.purge`
- 前端 Sidebar 仍按类型调用旧 URL;这是兼容 alias,不是新增长期业务语义。后续如要统一客户端命令面,应在 `resource-command-client` 引入 mindmap/table payload,并保留旧 URL 适配层。
## 8. 子阶段:local_folder file_path resource lifecycle
### 8.1 目标与范围
本子阶段只覆盖 `local_folder` workspace 中由本地文件系统提供的非 md 资源文件,典型对象包括图片、PDF、音视频、Office 文件、压缩包和其他附件型二进制文件。Markdown 页面本体、目录节点、远端 media asset、mindmap、table 不在本子阶段内。
最小目标是把本地非 md 资源文件的 delete / restore / purge 纳入 `tree.resource.*` 正式命令面,同时保留本地文件路径作为用户可理解的 identity 输入。实现不要求立即接入云端文件删除,也不要求新增复杂版本库;只要求命令可审计、垃圾箱可索引、冲突可判定、恢复行为可预测。
### 8.2 最小设计
- 资源归属:`resourceKind=local_file``resourceScope=local_folder`
- 资源定位:命令 payload 必须携带 `workspaceId``rootId``filePath``resourceKind``filePath` 使用相对 `local_folder` 根目录的规范化 UTF-8 路径,不允许绝对路径、空路径、`..` 逃逸或平台分隔符混用。
- md 排除:扩展名判定为 Markdown 的文件继续走页面/树节点生命周期,不进入 `local_file` 资源生命周期。
- delete`tree.resource.archive` 不直接物理删除文件,先把源文件移动到本工作区受控 trash 存储区,并写入 trash index。
- restore`tree.resource.restore` 只从 trash index 中恢复已归档资源,默认恢复到原 `filePath`;若原路径冲突,按冲突策略处理。
- purge`tree.resource.purge` 只允许删除 trash index 中已归档资源对应的 trash 文件和索引记录;不允许对当前活跃文件路径直接 purge。
- 事件:三类命令都产出 `domainEventPlan``streamDeltaHint`,至少能通知资源列表、附件引用状态、垃圾箱视图刷新。
### 8.3 命令 identity
`local_folder` 文件的稳定命令 identity 分两层:
- 用户输入 identity`workspaceId + rootId + normalizedFilePath`。这是兼容 UI、外部变更监听和命令 payload 的最小输入。
- 归档后 identity`trashEntryId`。第一次 archive 成功后生成并写入 trash indexrestore / purge 必须优先使用 `trashEntryId`,同时校验其记录的原始 `filePath``workspaceId``rootId` 与命令 payload 一致。
`resourceId` 不直接等同于裸 `filePath`。对活跃文件可派生为 `local_file:{workspaceId}:{rootId}:{pathHash}`,但该值只作为事件和 projection 的稳定 key;实际文件操作仍以规范化路径和 trash index 记录为准,避免路径重命名、大小写差异或 Unicode 归一化导致误删。
### 8.4 Trash index
每个 `local_folder` root 维护一个受控 trash index,最小字段如下:
- `trashEntryId`
- `workspaceId`
- `rootId`
- `resourceKind=local_file`
- `originalFilePath`
- `trashedFilePath`
- `fileSize`
- `contentHash`,可延后异步补齐,但 purge 前若存在则用于防误删校验
- `archivedAt`
- `archivedByCommandId`
- `restoreStatus`
- `purgedAt`
- `purgedByCommandId`
trash index 是 restore / purge 的唯一可信入口。目录扫描只能用于发现活跃文件或 orphan trash 文件,不能绕过 index 直接恢复或永久删除。
### 8.5 冲突策略
- archive 时源文件不存在:命令返回 `not_found`,不生成新的 trash entry;若 index 已存在同一路径的活跃归档记录,返回该 `trashEntryId` 并标记为幂等归档结果。
- archive 时目标 trash 路径已存在:生成新的 `trashEntryId` 与唯一 `trashedFilePath`,不覆盖旧 trash 文件。
- restore 时原路径不存在:直接恢复到 `originalFilePath`,并把 index 状态改为 restored。
- restore 时原路径已存在且 contentHash 相同:视为幂等恢复,index 状态改为 restored,不覆盖活跃文件。
- restore 时原路径已存在且 contentHash 不同或无法判定:不得覆盖活跃文件;返回 `conflict=file_path_occupied`,UI 可选择恢复为带后缀副本或让用户改名,但默认命令不自动改名。
- purge 时 trash 文件不存在但 index 仍为 archived:允许把 index 标记为 purged,并记录 `missing_trash_file`,用于容忍外部手动清理。
- purge 时 index 状态不是 archived:返回幂等结果,不删除任何活跃文件。
- 任一命令发现 `filePath` 逃逸 root、大小写归一化后指向不同文件、或 symlink 指向 root 外部:返回 `invalid_path`,不移动、不删除。
### 8.6 验收标准
- 本地非 md 资源文件 delete 只能通过 `tree.resource.archive` 进入 trash,不允许直接 unlink 活跃文件。
- restore / purge 必须优先基于 `trashEntryId` 与 trash index 执行;仅传 `filePath` 时只能定位唯一 archived entry,不能模糊匹配多条记录。
- trash index 中能追踪原路径、trash 路径、归档命令、恢复/永久删除命令和当前状态。
- restore 冲突时默认不覆盖用户已有文件,并返回可被 UI 展示的结构化冲突原因。
- purge 只能作用于 archived trash entry,不能对 active `filePath` 执行永久删除。
- Markdown 文件不进入本子阶段;测试中应验证 `.md` / `.markdown` 路径被拒绝或转交页面生命周期。
- 命令事件必须包含 `resourceKind=local_file``resourceScope=local_folder``filePath``trashEntryId`archive 成功后)和 `canonicalCommand=tree.resource.archive/restore/purge`
- 外部变更监听与本命令并发时,以文件系统当前状态和 trash index 状态共同判定;不得因 stale projection 覆盖真实文件系统变化。
### 8.7 执行记录
- 2026-05-15:先补 `tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index` 红灯,确认 `local:asset:docs/photo.png` delete 原本掉到 Markdown 删除路径。
- 2026-05-15`mnote-web` local folder executor 已把 `delete / restore / purge` 分流到 entry 级处理;`local:asset:*` / `local:node:*` raw file 通过 `tree.resource.archive/restore/purge` 进入 `.mnote/trash``trash-index.json`,Markdown 页面继续走原页面生命周期。
- 2026-05-15Rust 3000 主壳 filetree delete plan 已对 `sourceKind=local_folder && rowKind=asset` 使用 rowId 作为本地资源 identityDelete / Backspace 走 `/api/tree/commands`,不走 `/api/media/batch`
- 2026-05-15:新增 `scripts/task437-local-folder-asset-trash-lifecycle-smoke.js`,真实浏览器覆盖 local asset Delete 进入本地回收站、restore 回原路径、purge 清理 trash 文件与索引;结果文件 `tmp/task437-local-folder-asset-trash-lifecycle-smoke/result.json` 记录 `navigationEvents=[]`
### 8.8 验证命令
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_delete_keys_support_mixed_doc_and_asset_selection -- --nocapture
MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3427 node scripts/task437-local-folder-asset-trash-lifecycle-smoke.js
```
@@ -0,0 +1,82 @@
# 4-28 Trash Restore Location Reveal Focus v1
> 状态:process
> 创建时间:2026-05-15
> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环
> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 6
## 1. 目标
页面进入垃圾箱前必须记录可恢复的位置快照;恢复时优先回到删除前父节点与排序位置,并在 UI 中 reveal / focus 恢复对象。若原父节点已不存在或仍在垃圾箱,应显式 fallback 到根目录并提示用户。
## 2. 已落地范围
- `documents` schema 新增 `restore_parent_id``restore_sort_order`
- `documents.softDelete` 对页面子树写入删除前 `parent_id / sort_order` 快照。
- `documents.restore`
- 读取同 workspace 当前文档集合。
- 父节点仍存在或属于本次级联恢复子树时,恢复到原父节点。
- 父节点已不存在或仍在垃圾箱时,恢复到根目录。
- 恢复时回写 `parent_id / sort_order`,并对目标父级兄弟节点重新编号,避免恢复到原排序位时产生重复 `sort_order`
- 清空恢复位置快照。
- 返回 `restore_location.parent_id / sort_order / fallback_reason`
- `/api/tree/commands restore` 读取页面 meta 时允许包含已删除页面,避免垃圾箱页面在进入 restore mutation 前被 `getMeta` 过滤成 404。
- `restoreDocumentCommand` 透传恢复位置结果。
- Sidebar 垃圾箱恢复后刷新数据、展开恢复父链、滚动并 focus 恢复行;发生 fallback 时提示“原父页面已不存在,已恢复到根目录”。
## 3. 设计口径
- 当前记录的是稳定数据位置:`parent_id / sort_order`
`view path / expanded path` 不持久化到 Convex,恢复后由 Sidebar 根据当前父链展开得到。
- 子树恢复只恢复与父节点同一 `deleted_at` 批次的子节点,避免把更早独立删除的子页面误恢复。
- fallback 策略先固定为“恢复到根目录 + 明示提示”,不在本阶段引入位置选择弹窗。
- `sort_order` 会按当前兄弟数量 clamp;如果原位置越界,则放到可解释的最近位置。
## 4. 已验证
```bash
cd /mnt/Data1T/mnote/wolai-frontend
pnpm test src/lib/documents/tree-command-client.test.ts src/lib/documents/restore-location.test.ts src/components/sidebar/tree-shell-host.test.tsx src/components/sidebar/sidebar-sync.test.ts
```
结果:4 个测试文件通过,20 项测试通过。
补充 route 与排序回归:
```bash
cd /mnt/Data1T/mnote/wolai-frontend
pnpm test src/lib/documents/tree-command-client.test.ts src/lib/documents/restore-location.test.ts src/components/sidebar/tree-shell-host.test.tsx src/components/sidebar/sidebar-sync.test.ts src/app/api/tree/commands/route.test.ts
```
结果:5 个测试文件通过,32 项测试通过。
真实浏览器 smoke
```bash
cd /mnt/Data1T/mnote/wolai-frontend
npx convex dev --once --tail-logs disable --env-file ../.env.all --run ping:ping
cd /mnt/Data1T/mnote
node scripts/task429-trash-restore-location-reveal-smoke.js
```
结果:通过。脚本使用一次性用户 / workspace,在 3000 主入口验证:
- 删除子页面后通过 `tree.node.restore` 恢复,返回 `restore_location.parent_id` 为原父页面、`sort_order` 为原排序位。
- 原排序位已被兄弟页面占用时,兄弟页面被后移,Convex 中不产生重复 `sort_order`
- 当前浏览器页面不刷新,恢复后的页面行重新出现在侧边栏树中。
- 构造 `restore_parent_id` 指向缺失父节点的历史数据,恢复后返回 `fallback_reason: parent_missing_or_deleted`,并落到根目录。
局部 TypeScript 筛查:
```bash
cd /mnt/Data1T/mnote/wolai-frontend
pnpm exec tsc --noEmit --pretty false 2>&1 | rg "convex/documents.ts\\((160|161|162|163|164|165|166|167|168|169|170|171|172|173|174|175)"
```
结果:阶段 6 修改附近无新增 TypeScript 报错。全量 `tsc` 仍受仓库既有错误影响,未作为本阶段完成条件。
## 5. 待补
- React Sidebar Drawer 的恢复后 DOM focus 仍需在 React 入口可稳定渲染页面树时补浏览器证据;当前 3000 主壳 smoke 已覆盖“恢复后无需刷新可见 / reveal”,fallback 提示仍由 React Drawer 代码路径负责。
- 双浏览器 no-refresh:A 恢复页面后,B 不刷新即可看到位置变化;该项归入阶段 8 的实时刷新矩阵。
@@ -0,0 +1,122 @@
# 4-34 Filetree Trash Dual Browser No Refresh v1
> 状态:process
> 创建时间:2026-05-15
> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环
> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 8
## 1. 目标
补齐文件树与垃圾箱的双浏览器 no-refresh 回归:A 浏览器执行页面、附件、mindmap、table 的 create / archive / restore / purge / empty-trashB 浏览器不刷新即可在 File Tree 与 Trash 工作台看到变化。
阶段 8 不以“单浏览器本地 optimistic update 成功”作为完成标准;`refreshTree()` 只能作为 A 浏览器本地兜底,不能作为 B 浏览器同步证据。
## 2. 验收矩阵
| 对象 | create | archive | restore | purge | empty-trash | 证据要求 |
| --- | --- | --- | --- | --- | --- | --- |
| 页面 document | A 新建后 B File Tree 可见 | A 删除后 B File Tree 消失、Trash 可见 | A 恢复后 B File Tree 可见、Trash 消失 | A 彻底删除后 B Trash 消失 | A 清空页面垃圾箱后 B Trash 清空 | 记录 tree delta/resync 或 Convex live 来源 |
| 普通附件 file asset | A 创建/上传或 seed 后 B File Tree 可见 | A 删除后 B File Tree 消失、Trash 可见 | A 恢复后 B File Tree 可见、Trash 消失 | A 彻底删除后 B Trash 消失 | A 清空资源垃圾箱后 B Trash 清空 | 记录 `tree.resource.*` / compat alias 与实时来源 |
| mindmap | A 新建 mindmap 后 B File Tree 可见 | A 删除后 B File Tree 消失、Trash 可见 | A 恢复后 B File Tree 可见、Trash 消失 | A 彻底删除后 B Trash 消失 | A 清空资源垃圾箱后 B Trash 清空 | 记录 mindmap route 仍为 compat alias |
| table | A 新建/seed table 后 B File Tree 可见 | A 删除后 B File Tree 消失、Trash 可见 | A 恢复后 B File Tree 可见、Trash 消失 | A 彻底删除后 B Trash 消失 | A 清空资源垃圾箱后 B Trash 清空 | 记录 table route 仍为 compat alias |
| local folder Markdown | 外部创建 `.md` 后 page/file tree 可见 | 本地删除进入 `.mnote/trash` | restore 回原路径 | purge 后原文件与 trash 均不存在 | 可选本地 trash 清空 | 必须记录是否发生 reload,不能把 reload 型刷新标为 no-refresh |
| local folder 非 md 资源 | 外部创建 `.txt/.png/.pdf/.xlsx` 后 filetree 可见 | 删除语义待定 | restore 待定 | purge 待定 | 待定 | 当前不走 Markdown watcher SSE;需记录 polling / reload / delta 来源 |
## 3. 分批执行
### 3.1 task432:页面生命周期双浏览器
先覆盖页面 document,因为它是 `tree.node.*` 主链:
- 使用测试账号或一次性用户登录两个 browser context。
- 使用隔离 workspace / 本轮测试前缀创建父页面与子页面。
- B 打开 3000 主入口文档页与 `/trash?workspaceId=...` 两个 tab,安装 tree event recorder。
- A 执行 create / archive / restore / purge / empty-trash。
- B 每一步不刷新页面,等待 File Tree / Trash DOM 更新,并记录是 `tree:delta``tree:resync`、EventSource snapshot 还是 Convex live 更新。
通过后才勾选阶段 8 的页面子项。
执行记录:
- 2026-05-15`task432-filetree-trash-page-dual-browser-no-refresh-smoke` 已通过。A 浏览器对页面执行 create / archive / restore / purge / empty-trashB 浏览器文件树与 `/trash` 均无需刷新可见更新,且全程无 `framenavigated`。事件来源记录为:create -> `tree:resync`archive -> `tree:delta remove_document`restore -> `tree:delta upsert_document`purge -> `tree:resync`empty-trash -> `tree:resync`。结果文件:`tmp/task432-filetree-trash-page-dual-browser-no-refresh-smoke/result.json`
- 2026-05-15:清空页面垃圾箱的初次 smoke 暴露两层根因并已修正:Rust `documents.emptyTrashByWorkspace` 之前未写 `tree.trash.documents.emptied` domain eventConvex `documents.emptyTrashByWorkspace` validator 也未放行 `streamDeltaHint/domainEventHint/domainEventPlan`,导致 `/trash` 必须刷新才清空。两处已补齐并重新部署本地 Convex。
### 3.2 task433:普通附件生命周期双浏览器
覆盖普通 file asset
- 优先复用 `task428` / `task427` 的 API seed 方式,避免依赖上传文件选择器。
- archive / restore / purge 走 `/api/media/batch``/api/media/purge` 兼容 URL,但必须能在 artifact / response 中证明进入 `tree.resource.archive/restore/purge`
- B 侧分别观察 File Tree 与 Trash 工作台。
执行记录:
- 2026-05-15`task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke` 已通过。A 浏览器对普通附件执行 upload / archive / restore / archive+purge / upload+archive+empty-trashB 浏览器文件树与 `/trash` 均无需刷新可见更新,执行阶段无 `framenavigated`。事件来源记录为:upload -> `tree:delta upsert_assets`archive -> `tree:resync`restore -> `tree:resync`purge -> `tree:resync`empty-trash -> `tree:resync`。结果文件:`tmp/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke/result.json`
- 2026-05-15:初次 smoke 暴露 `tree.resource.archive/restore/purge` legacy Convex 参数转换覆盖真实 Convex userId,导致 `mediaAssets.patchById` membership 检查失败;已修正 `mnote-web` transport,使资源 lifecycle 转换优先保留 `args_json.userId`。随后暴露 `/api/media/empty-trash` 不写 tree event,导致 B `/trash` 清空必须刷新;已补 `tree.trash.media.emptied` domain event 与 `resync_required` stream delta。
- 2026-05-15:验证 `cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_resource_lifecycle_args_keep_effective_user_id -- --nocapture``cargo test --manifest-path rust/Cargo.toml -p mnote-web media_trash_routes_delete_restore_purge_and_empty -- --nocapture` 均通过。
### 3.3 task434mindmap / table 生命周期双浏览器
覆盖仍处 compat alias 的 mindmap / table
- mindmap 可复用现有 mindmap block / asset seed 方式。
- table 可复用 `task427` 的 table seed 与 purge query 方式。
- 本阶段不把 mindmap / table 宣称为正式 `tree.resource.*` cutover,只验证双浏览器可见性并保留 compat 边界。
执行记录:
- 2026-05-15`task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke` 已通过。A 浏览器对 mindmap 执行 create / archive / restore / archive+purge,对 table 执行 create / archive / restore / archive+purge,并对 mindmap + table 执行 create / archive / empty-trashB 浏览器文件树与 `/trash` 均无需刷新可见更新,执行阶段无 `framenavigated`。结果文件:`tmp/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke/result.json`
- 2026-05-15:为避免把 table 预置数据误标为 create 实时证据,Rust `mnote-web` 已新增 `/api/tables/create` 兼容入口,创建成功后记录 `tree.resource.table.created` domain event 与 `resync_required` stream delta。mindmap / table 的 delete / restore / purge / empty-trash compat route 也补齐 tree resync artifacts。
- 2026-05-15:事件来源记录为:mindmap create / archive / restore 主要触发 `tree:delta resync_required`mindmap purge 触发 `tree:resync`table create / archive / restore 主要触发 `tree:delta resync_required`table purge / empty-trash 触发 `tree:resync`
- 2026-05-15:验证 `cargo test --manifest-path rust/Cargo.toml -p mnote-web trash_routes -- --nocapture --test-threads=1``node --check scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js` 均通过。
### 3.4 task435empty-trash 双浏览器汇总
在一次性 workspace 中执行页面垃圾箱与资源垃圾箱清空:
- A 清空页面垃圾箱后,B `/trash` 页面不刷新变为空。
- A 清空资源垃圾箱后,B `/trash` 页面资源区不刷新变为空。
- Convex query 断言对应对象不可再查询。
### 3.5 task436local folder watcher 与本地资源生命周期
本阶段不混入 Convex 双浏览器矩阵,单独验证 local folder source
- 打开 local folder page tree 与 filetree,记录初始 `navigationEvents`
- 外部创建 `.md`,断言 page tree / filetree 可见,并记录是否由 `/api/local-folder/events``/api/tree/local-folder-watch`、reload 或手动刷新驱动。
- 外部重命名 `.md`,断言旧 row 消失、新 row 出现;当前打开文档若受影响,应出现刷新或冲突提示。
- 外部删除 `.md`,断言 page tree / filetree 中 row 消失;若当前打开该文档,应有明确状态提示。
- 外部创建 / 重命名 / 删除至少两类非 md 文件,例如 `.txt``.png`,断言 filetree 与真实文件系统一致,并记录是否发生 `window.location.reload()`
- 对本地非 md 资源执行删除 / restore / purge;若 UI 仍禁用,记录为未完成能力,不得用 Markdown trash 代替。
当前只读审计结论:
- `LocalFolderWatcherRegistry` 使用 `notify` 递归监听 root,但事件发送前经过 `is_markdown_path`,只允许 `.md/.markdown`
- `/api/local-folder/events` 订阅该 watcher,主要服务打开的本地 Markdown document session。
- 主 tree 的 local folder 模式不接 `/api/tree/events`,而是轮询 `/api/tree/local-folder-watch`;revision 扫描可见条目,非 md 文件变化会改变 revision,但当前变化后触发 `window.location.reload()`
- `task163-local-folder-unified-tree-browser-smoke.js` 已覆盖外部新增 Markdown、外部新增 / 删除 `watcher-asset.txt` 的可见性;尚未覆盖外部 Markdown 重命名 / 删除、非 md rename、非 md trash / restore / purge。
通过后才允许在 10-review 中把 local folder 阶段 8 子项勾选;如果仍发生 reload,只能标为“reload 型刷新可见”,不能标为 no-refresh。
## 4. 实时来源记录
每个 smoke 结果必须记录:
- B 浏览器是否接到 `/api/tree/events`
- B 浏览器是否接到 `tree:snapshot``tree:delta``tree:resync`
- B 侧 DOM 更新是否发生在无 reload / 无 framenavigated 的前提下。
- 是否依赖了 A 浏览器本地 `refreshTree()`;如果依赖,只能作为 A 本地补偿,不计入 B 实时证据。
## 5. 当前边界
- 3000 常驻进程可能是旧 `mnote-web` 二进制;修改 Rust 后做 smoke 时必须重启 3000,或用新编译的临时 3001 并在结果中明确记录。
- 资源生命周期中,普通附件 file asset 已进入正式 `tree.resource.*`mindmap / table 仍是 compat route,阶段 8 只验证 no-refresh,不改变 cutover 口径。
- local folder Markdown watcher 只有部分历史覆盖,且主 tree filetree 当前是 revision polling + reload;非 md 本地资源文件 watcher / trash / restore / purge 仍单列为待补,不应混入 Convex 双浏览器矩阵。
- `resync_required` delta 不是数据替换本身,必须伴随后续 snapshot / resync 或客户端主动拉取 snapshot;后续新增资源 route 时必须补事件合同回归。
## 6. 完成标准
- `design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 8 的 Convex 页面、普通附件、mindmap、table checklist 全部有对应 smoke 证据。
- local folder 子项必须有 task436 或等价 smoke 证据;若仍发生 reload,保持未完成 no-refresh 状态。
- 每个 smoke 结果写入 `tmp/task43*-*/result.json`,并在 10-review 中记录命令、对象、实时来源和剩余边界。
- 不再用单浏览器 no-refresh、API purge 成功或 Convex query 成功替代双浏览器 UI no-refresh。
@@ -0,0 +1,235 @@
# 4-35 [process] Convex File Tree 标题即 Markdown 文件名体系切换 v1
> 更新时间:2026-05-15
>
> 背景:
> - 用户反馈本地文件夹中新建页面会先显示 `/mnt/Data1T/mnote/tmp/image copy 98.png`,再转回 `新页面.md`。
> - 用户判断这是在线文件夹与本地文件夹的冲突,后续希望“统一而不是继续局部修改”。
> - 目标倾向:不再采用 Convex `page -> index.md` 体系,而是采用直接 `新页面.md` 体系,文件名与标题相同;在线文件夹尽量向本地文件夹靠拢。
## 1. 决策问题
当前 `convex_workspace` 的 File Tree 长期以复合资源树表达页面:
```text
page/document
index.md
mindmap asset
attachment asset
```
`local_folder` 则天然以真实文件系统表达:
```text
新页面.md
图片.png
子目录/
```
这两种形态同时存在时,UI、active row、rename、新建、外部 watcher、projection refresh 容易出现不一致。用户观察到的“先显示 tmp 图片路径,再转回新页面.md”就是这类冲突的症状之一:同一套 File Tree UI 里混入了本地路径资源、Convex 页面容器、`index.md` 伪文件和异步投影校准。
本设计稿用于冻结新的方向:后续 Convex File Tree 应逐步向本地文件夹模型靠拢,把页面在文件树中的默认表现改为直接的 Markdown 文件行,例如 `新页面.md`,而不是页面容器下的 `index.md`
## 2. 建议结论
建议新建一个设计文档,而不是只建 bug 修补。
原因:
1. 这会改变 `file_tree` projection 合同,不只是修一个显示名称。
2. 这会影响 `ObjectIdentity`、active row、rename/title sync、resource parent、trash、restore、search、open intent 和 smoke 期望。
3. 现有 `4-24` / `5-12` 已把 `index.md` 作为 Page Aggregate body 的 object identity 收口到 done;如果要废弃或弱化该 UI 体系,必须有新的迁移设计覆盖旧口径,不能在代码里零散改。
4. Convex 存储层仍然是 document record,不等于必须在 File Tree UI 中展示 `index.md` 伪文件。可以先改 projection / UI 语义,再决定是否迁移底层存储。
## 3. 范围
本设计只讨论 `convex_workspace` 在 File Tree 中的页面呈现与命令语义,不改变 `local_folder` 已有真实文件系统规则。
纳入范围:
- Convex 页面在 File Tree 中显示为 `{title}.md`
- 新建页面时直接出现 `新页面.md`,不先出现 `page/index.md` 或临时资源路径。
- 重命名页面时,标题与文件名同源:`新标题` 对应 `新标题.md`
- 点击 `{title}.md` 打开 Page Aggregate body。
- mindmap、附件、OnlyOffice、代码附件继续作为同一页面下的资源对象,但不能再依赖可见的 `index.md` 子行作为锚点。
- Active / reveal 以 `{title}.md` row 或明确 asset row 为准。
暂不纳入范围:
- 立即迁移 Convex 底层 documents 表结构。
- 自动双向同步 local folder 与 Convex。
- Markdown 文件名与标题的所有非法字符、大小写、重名冲突最终规则;本稿先冻结方向,具体规则后续 checklist 补齐。
## 4. 新旧模型对比
旧模型:
```text
新页面
index.md
mindmap-mindmap_xxx.json
image.png
```
新目标模型:
```text
新页面.md
mindmap-mindmap_xxx.json
image.png
```
解释:
- `新页面.md` 是页面正文对象的 File Tree row。
- 该 row 的 object identity 可继续内部映射到 Page Aggregate body,但 UI 不再展示 `index.md`
- 资源行仍然挂在页面正文对象下面,表达“属于该页面/正文 block 关联资源”。
- Page Tree 仍可显示 `新页面`,不带 `.md`,作为页面导航 projection。
## 5. 关键合同变更
### 5.1 File Tree row
Convex 页面正文 row
- `rowKind=markdown_page` 或沿用 `index` 但对 UI 隐藏 `index.md` 语义,需要后续冻结。
- `title="{pageTitle}.md"`
- `objectIdentity="page:markdown:{documentId}"` 或兼容映射到既有 `page:index:{documentId}`,但对外不再暴露为 `index.md`
- `documentId` 仍是 Convex document id。
- `resourceMeta.sourceKind="convex_workspace"`
本地 `.md` row
- 保持真实文件名。
- `objectIdentity` 与本地 page identity 绑定,不以裸路径作为长期唯一 id。
### 5.2 Rename / title sync
Convex workspace 中:
- 文件树重命名 `新标题.md` 应写入页面标题 `新标题`
- 页面标题编辑应能同步更新 File Tree 行显示为 `新标题.md`
- `.md` 扩展名是 projection/UI 层文件语义,不应直接写进页面标题。
- 重名冲突必须返回结构化 preflight,不允许静默覆盖或临时跳成其它资源路径。
Local folder 中:
- 继续以真实文件名为主,标题优先级仍按现有本地 Markdown 规则处理。
### 5.3 Create
Convex workspace 新建页面:
- 命令结果中应直接携带最终 File Tree row identity 与 `title="新页面.md"`
- UI 乐观插入时不得先显示本地 tmp 路径、上传文件名或 `index.md` fallback。
- 如果后端 title 去重为 `新页面 2`,前端最终只校准成 `新页面 2.md`,中间不出现无关资源名。
### 5.4 Resource parent
mindmap / attachment 等资源仍挂在页面正文对象下,但 parent row 从旧 `index:{documentId}` 或 page container 迁移到新的 markdown page row。
需要特别验证:
- 点击 mindmap asset 后 active row 不跳回 `{title}.md`,除非确实打开的是页面正文。
- 删除 `{title}.md` 进入页面垃圾箱;删除资源进入资源垃圾箱。
- purge 页面时 Convex document 与其资源关系按正式命令同步清理或标记孤儿处理。
## 6. 与既有设计的覆盖关系
本稿不否定 `4-24` / `5-12` 的单一真源目标,只调整 File Tree 的可见模型:
- 仍然保留 Page Aggregate body 是页面正文真源。
- 仍然保留 mindmap / attachment / OnlyOffice 是独立 resource object。
- 仍然要求 object identity 隔离,避免 mindmap 污染页面正文。
- 变化是:Convex File Tree 不再必须通过可见 `index.md` 行表达页面正文对象。
后续如执行本设计,必须同步更新以下旧口径:
- `design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md` 中“页面节点下固定派生 index.md row”的可见 UI 表达。
- `design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md``index.md tab` 的命名口径,可迁移为 `markdown page tab``{title}.md tab`
- `design/10-review/done/05-tree.md` 作为历史记录不直接改写,但 README 或新设计需说明其 `index.md` 口径已被本稿覆盖。
## 7. 建议实施阶段
### P0:只改 Convex File Tree projection 的页面正文显示
- 新建页面后 File Tree 直接出现 `{title}.md`
- 点击 `{title}.md` 打开 Page Aggregate body。
- Page Tree 仍显示无扩展名标题。
- 保持底层 object identity 兼容,减少一次性改动。
### P1Active / reveal / object identity 迁移
- active page body 映射到 `{title}.md` row。
- mindmap asset 打开后保持 asset active,不降级为页面正文 row。
- 长列表 reveal 稳定。
### P2Rename / create / conflict preflight
- 文件树 rename `{title}.md` 与页面标题同步。
- 新建同名页面明确生成 `新页面 2.md` 或返回冲突选择。
- 不再出现临时 tmp 路径或资源文件名作为页面 row title。
### P3:旧 index.md UI 退场
- 默认 Convex File Tree 不显示 `index.md`
- 如仍需 debug,可通过显式 debug/projection inspector 展示旧 identity。
- 更新 smoke 和设计口径,避免后续继续围绕 `index.md` 修补 UI。
## 8. 验收标准
- 在 Convex workspace 新建页面后,File Tree 首次可见 row 就是 `新页面.md` 或去重后的 `{title}.md`,不得短暂显示 `/tmp/...`、图片文件名、`index.md` 或其它 fallback。
- 点击 `{title}.md` 打开页面正文;点击 mindmap asset 打开 mindmap object editor,二者 active row 不互相覆盖。
- 页面标题编辑与 File Tree rename 最终保持一致:标题 `ABC` 对应 File Tree `ABC.md`
- 同名冲突、非法字符、扩展名输入等情况有结构化 preflight 和可预测结果。
- Local folder 与 Convex workspace 共用同一 File Tree UI 组件、selection/focus/reveal/context menu/keyboard 行为;差异只来自 source capability 与 executor。
- 真实浏览器 smoke 覆盖:新建页面、重命名、点击页面正文、点击 mindmap、滚动下半部分点击、删除进垃圾箱、restore 后 reveal。
## 9. 当前状态
当前状态:`process`
2026-05-15 已完成 P0/P1/P2 的最小切片:
- `bridge-runtime` 的 Convex `file_tree` projection 不再为页面正文派生可见 `index.md` row。
- Convex 页面正文 row 继续使用 `rowKind=document` / `rowId=doc:<documentId>`,但标题改为 `{pageTitle}.md`
- mindmap / attachment / table 等资源继续挂在 `doc:<documentId>` 下。
- 3000 主文档壳本地 create apply 同步改为只插入 `doc:<documentId>`,标题显示为 `{title}.md`,不再插入 `index:<documentId>`
- File Tree 当前页面 selected / focused 默认改为 `doc:<documentId>`
- `task169` 的“回到页面正文”验证从点击 `index:<documentId>` 改为点击 `doc:<documentId>`object identity 仍为 `page`
- File Tree inline rename 对页面正文 row 输入 `{title}.md` 时,提交给 `tree.node.rename` 前会剥离 `.md`,页面标题保持 `{title}`File Tree 显示保持 `{title}.md`
- File Tree inline rename 已加入主壳内校验:空名、非法文件名字符、同级同名页面会停留在输入框内并显示结构化提示,不提交 rename command。
已通过验证:
```bash
cargo test --manifest-path rust/Cargo.toml -p bridge-runtime file_tree_projection -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web file_tree_projection -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime_uses_markdown_page_row_without_local_index_child -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_shell_filetree_renderer_outputs_initial_nested_html_contract -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_shell -- --nocapture
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task426-mnote-web-main-no-reload-smoke.js
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task438-filetree-title-md-active-reveal-smoke.js
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task439-filetree-title-md-rename-smoke.js
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task440-page-title-filetree-md-sync-smoke.js
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task169-mindmap-realtime-smoke.js
```
真实浏览器结果文件:
- `tmp/task426-mnote-web-main-no-reload-smoke/result.json`
- `tmp/task438-filetree-title-md-active-reveal-smoke/result.json`
- `tmp/task439-filetree-title-md-rename-smoke/result.json`
- `tmp/task440-page-title-filetree-md-sync-smoke/result.json`
- `tmp/task169-mindmap-realtime-smoke/result.json`
本轮 P3 收口:
- 页面标题编辑反向同步到 File Tree `{title}.md` 已由真实浏览器 smoke `scripts/task440-page-title-filetree-md-sync-smoke.js` 覆盖:标题栏输入 `{title}` 后,File Tree 显示 `{title}.md`,Page Tree 与标题输入框保持 `{title}`,且不出现 `index:<documentId>` 行。
- `tree_shell/filetree_renderer.rs` 的默认 nested fixture 已从可见 `index.md` 子行改为页面 markdown row 下挂 mindmap asset`rowKind=index` 仍只作为历史 projection / 命令兼容解析保留,不再作为默认 Convex File Tree UI 口径。
- 旧设计中的“可见 `index.md`”表述由本稿覆盖:后续 UI / smoke / review 应以 `doc:<documentId>` + `{title}.md` 作为 Convex 页面正文 row;如果文档讨论历史 `index.md`,必须明确它是兼容身份或历史记录,不是默认可见行。
仍保留的后续增强:
- 删除进垃圾箱、restore 后 reveal、双浏览器 resync、搜索过滤下 reveal 等更宽场景继续由 `4-28``4-34` 与后续 tree domain checklist 覆盖。