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
@@ -2,6 +2,10 @@
> 更新时间:2026-05-13
>
> 2026-05-15 口径更新:
> - 本文件完成时的 `index.md` 可见 UI 模型已被 `/mnt/Data1T/mnote/design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md` 覆盖。
> - 后续 Convex File Tree 默认页面正文行显示为 `{title}.md`rowId 为 `doc:<documentId>`;本文中的 `index.md` 仅表示历史正文 object identity / 兼容语义,不再作为默认可见子行继续扩展。
>
> 上游依据:
> - `/mnt/Data1T/mnote/design/10-review/05-tree.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
@@ -0,0 +1,64 @@
# 4-25 Trash Empty 隔离 Workspace 验收设计
> 状态:done
> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 3
> 范围:页面、附件、mindmap、table 删除进垃圾箱后,执行 workspace 级 empty trash,并确认 Convex 行与关联数据被永久清理。
## 背景
`documents.emptyTrashByWorkspace``mediaAssets.emptyTrashByWorkspace``mindmaps.emptyTrashByWorkspace``tables.emptyTrashByWorkspace` 都是 workspace 级破坏性操作。默认测试账号 `mnote.e2e@example.com` 已有长期工作区与历史垃圾箱数据,不能直接在该 workspace 上点击“清空垃圾箱”做 smoke,否则可能误删非本轮测试数据。
阶段 3 的验收必须先建立隔离边界,再执行真实清空。
## 目标
- 使用本轮独立身份或独立 workspace 创建测试页面、子页面、附件、mindmap、table。
- 对五类对象执行默认删除,确认进入对应垃圾箱。
- 执行页面垃圾箱清空与资源垃圾箱清空。
- 查询 Convex,确认本轮 document / media_asset / mindmap / document_table / document_table_rows 不再存在。
- 记录关联清理边界:Storage、OCR、LightRAG、page references、recent pages、favorites。
## 推荐方案
优先使用一次性 Convex Auth 用户:
1. 通过 Rust `/api/auth` 走 password `signUp`,邮箱使用 `mnote.trash.<timestamp>@example.com`
2. 使用同一 browser/request context 打开 `/`,触发 `workspaces.ensureDefaultWorkspace` 创建独立 personal workspace。
3. 在该 workspace 内创建:
- 根页面 `TEST-10REVIEW-07-P3-root-*`
- 子页面 `TEST-10REVIEW-07-P3-child-*`
- 附件 `TEST-10REVIEW-07-P3-file.txt`
- mindmap `TEST-10REVIEW-07-P3-mind`
- table `TEST-10REVIEW-07-P3-table`
4. 通过 Rust 3000 主壳 API 执行软删除:
- 页面:`POST /api/tree/commands``action: "archive"`
- 附件:`POST /api/media/batch``action: "delete"`
- mindmap`DELETE /api/mindmap/{docId}/{mindmapId}`
- table`DELETE /api/tables/{tableId}`
5. 打开 `/trash?workspaceId=<isolated>`,确认页面区与资源区出现本轮数据。
6. 调用:
- `POST /api/documents/empty-trash`
- `POST /api/media/empty-trash`
- `POST /api/mindmap-trash/empty`
- `POST /api/tables/empty-trash`
7. 使用 Convex query 或 Rust 只读 API 查询,确认本轮对象不存在。
## 禁止方案
- 不得在默认测试账号既有 workspace 上执行 workspace 级清空。
- 不得只用 UI 列表消失作为永久删除证据。
- 不得把 route fixture 单测等同于真实 Convex 清空验收。
## 执行结果
- 已新增 `scripts/task427-trash-empty-isolated-workspace-smoke.js`,覆盖“一次性用户注册 -> 独立 workspace -> seed 五类对象 -> 删除进垃圾箱 -> 清空 -> Convex 不存在断言”。
- 2026-05-15 执行通过:一次性 workspace `ws_req_1778793827876_33` 内,页面、子页面、附件、mindmap、table 均完成删除进垃圾箱与 empty trash 后不可查询断言。
- Rust 3000 已补资源软删除兼容入口;正式 `tree.resource.*` 命令仍由 10-review 阶段 5 收口。
- LightRAG 目前可见 ingest 任务入口,未见 purge 反向删除索引路径;阶段 3 验收只能记录该边界,不能标为已清理。
## 验收产物
- 新增或复用一个 smoke 脚本,默认只操作一次性用户 / 一次性 workspace。
- 输出本轮 `workspaceId``documentId``childDocumentId``assetId``mindmapId``tableId`
- 输出清空前后的 Convex 查询摘要。
- 将结果回填到 `design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 3。
@@ -0,0 +1,94 @@
# 4-26 FileTree 批量删除与选择矩阵 Smoke
> 状态:done
> 创建时间:2026-05-15
> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环
> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 4
## 1. 目标
补齐 Convex filetree 下的真实浏览器验收,避免只用 reducer、local-folder smoke 或 API seed 证明“文件树多选删除已完成”。
本设计只覆盖阶段 4 的最小闭环:
- 隔离用户 / 隔离 workspace seed 3 个页面、2 个普通附件、1 个 mindmap、1 个 table。
- 在 3000 主入口打开 filetree UI。
- 验证 Ctrl/Cmd 多选、Shift 范围、右键已选项保持 selection、右键未选项切换 action target。
- 验证 Delete / Backspace 触发混合 doc + asset 批量删除。
- 验证确认文案按页面、附件、mindmap、table 计数。
- 验证删除后无需浏览器刷新,filetree 中目标行立即消失,并能在 trash dataset 或 `/trash` 中看到。
## 2. 安全边界
- 不在默认测试账号既有 workspace 上执行批量删除。
- 不对默认 workspace 执行 `emptyTrashByWorkspace`
- 使用一次性 Convex Auth 用户注册后自动获得的默认 workspace。
- 清理仅针对本轮创建对象;如清理失败,只记录对象 ID,不扩大删除范围。
## 3. 建议脚本
新增:
- `scripts/task428-filetree-bulk-delete-selection-smoke.js`
脚本输入:
- `MNOTE_UI_BASE_URL`:默认 `http://127.0.0.1:3001`,便于复用 `FRONTEND_PORT=3001 npm run desktop:hot`
- `NEXT_PUBLIC_CONVEX_URL` / `CONVEX_SELF_HOSTED_URL`:默认 `http://127.0.0.1:3210`
脚本输出:
- `tmp/task428-filetree-bulk-delete-selection-smoke/result.json`
- 包含本轮 email、workspaceId、docIds、assetIds、captured confirm 文案、tree command / resource request 摘要、trash 验证结果。
## 4. 验收矩阵
### 4.1 Seed
- 创建父页面 `root`
- 创建子页面 `child`
- 创建兄弟页面 `sibling`
- 创建资源页面 `resources`,用于挂载:
- 普通附件 2 个。
- mindmap 1 个。
- table 1 个。
### 4.2 Selection
- Ctrl/Cmd 点击:选中 `root` 与一个外部普通附件。
- Shift 点击:在可见行上形成范围选区,至少包含 2 行。
- 右键已选项:selection 数量保持不变。
- 右键未选项:selection 切换为该项。
### 4.3 Delete / Backspace
第一轮 Delete
- 选择 `root``child`、外部普通附件、mindmap、table。
- 预期 preflight plan 去重:`root` 保留,`child` 被父页面覆盖,外部资源保留。
- 确认文案应包含:
- `1 个页面`
- `1 个附件`
- `1 个思维导图`
- `1 个在线表格`
- 确认后:
- `root``child`、外部普通附件、mindmap、table 对应行无需刷新消失。
- Convex trash / sidebar dataset 可查询到对应 deleted / archived 状态。
第二轮 Backspace
- 选择 `sibling` 与第二个普通附件。
- 触发 Backspace,确认后无需刷新消失。
- 作为 Delete 入口的键盘等价验证。
## 5. 执行结果
`scripts/task428-filetree-bulk-delete-selection-smoke.js` 已落地并通过真实浏览器验证:
- 创建一次性用户和隔离 workspace。
- seed 页面、附件、mindmap、table。
- 验证 Ctrl/Cmd 多选、Shift 范围、右键已选保持、右键未选切换。
- 验证 Delete 混合删除页面 + 附件 + mindmap + table。
- 验证 Backspace 混合删除页面 + 附件。
- 验证确认文案计数、filetree 行无需刷新消失、Convex 垃圾箱状态。
- 通过 route interception 强制 `/api/media/batch` 失败,验证页面成功进垃圾箱、失败附件仍保留,并出现“部分对象删除失败”摘要。
@@ -0,0 +1,68 @@
# 4-29 VSCode Explorer Cut Paste Move v1
> 状态:done
> 创建时间:2026-05-15
> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环
> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 7
## 1. 目标
先收口阶段 7 的最小可执行项:文件树 `Ctrl/Cmd+X``Ctrl/Cmd+V` 必须执行移动,而不是复制或 no-op。
## 2. 范围
- `wolai-frontend` 主 Sidebar 文件树剪贴板 action 从 `copy` 扩展为 `copy | cut`
- `Ctrl/Cmd+C` 写入 `copy` payload`Ctrl/Cmd+X` 写入 `cut` payload。
- `Ctrl/Cmd+V` 读取 `cut` payload 时:
- Rust family renderer 复用 `tree.filetree.drop.preflight` 的 move plan。
- 页面移动走 `moveDocumentCommand`
- 普通附件移动走 `moveFileTreeResourceAssets`
- 成功后清空剪贴板并刷新/广播相关树变化。
- `copy` 路径保持原行为。
## 3. 非目标
- F2 inline rename、右键菜单补齐、DnD readonly / 冲突确认不在本小阶段内实现,继续留在阶段 7 后续子项。
- 不扩展 `tree.filetree.paste.preflight` 的 Rust plancut 粘贴复用 drop preflight,是当前最小改动。
## 4. 验收标准
- `decodeFileTreeClipboardPayload` 能识别 `copy | cut`,拒绝其它 action。
- Sidebar 快捷键监听同时处理 `Ctrl/Cmd+C``Ctrl/Cmd+X``Ctrl/Cmd+V`
- cut paste 分支不调用 `copyTreeCommand` / `copyFileTreeResourceAssets`,而调用 `moveDocumentCommand` / `moveFileTreeResourceAssets`
- 成功 cut paste 后清空剪贴板,避免二次粘贴重复移动。
## 5. 已落地
- `src/lib/file-tree/clipboard.ts`
- `FileTreeClipboardAction` 扩展为 `copy | cut`
- decoder 允许 `cut`,继续拒绝其它 action。
- 新增 `clearFileTreeClipboardPayload`
- `src/components/sidebar/sidebar.tsx`
- 全局文件树快捷键支持 `Ctrl/Cmd+X`
- Rust family renderer 的 cut paste 复用 `preflightFileTreeInternalDrop(... copy: false)`
- cut paste 执行页面移动与附件移动,成功后清空剪贴板。
- `src/components/sidebar/tree-pane-bindings.ts` 透出清空剪贴板 helper。
## 6. 验证命令
```bash
cd /mnt/Data1T/mnote/wolai-frontend
pnpm test src/lib/file-tree/clipboard.test.ts src/components/sidebar/sidebar-paste-preflight-source.test.ts
```
结果:2 个测试文件通过,7 项测试通过。
局部 TypeScript 筛查:
```bash
pnpm exec tsc --noEmit --pretty false 2>&1 | rg "(src/lib/file-tree/clipboard.ts|src/components/sidebar/tree-pane-bindings.ts|src/components/sidebar/sidebar.tsx)"
```
结果:无输出,本轮修改文件无新增 TypeScript 报错。全量 `tsc` 仍受仓库既有错误影响。
## 7. 待补
- 2026-05-15 已补 3000 主入口真实浏览器 smoke:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task430-vscode-explorer-stage7-smoke.js`
- `task430` 使用一次性用户 / workspace,验证页面行 `Ctrl/Cmd+X` 后聚焦目标页面并 `Ctrl/Cmd+V`,捕获 `/api/tree/commands action=move`,并通过 Convex 查询确认子页面 `parent_id` 变为目标父页面。
- 普通附件 cut-paste move 的执行路径已有代码与 preflight 设计,真实附件 move 全矩阵仍归入后续资源树 / DnD 综合 smoke,不阻塞本小阶段“页面 cut-paste move”验收。
@@ -0,0 +1,38 @@
# 4-30 VSCode Explorer F2 Inline Rename v1
> 状态:done
> 创建时间:2026-05-15
> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环
> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 7
## 1. 目标
补齐 `wolai-frontend` 文件树 DOM host 的 F2 行内重命名入口,避免键盘路径只触发 reducer 事件但 UI 仍无 inline editor。
## 2. 已落地
- `tree-shell-dom-host.tsx` 为 filetree row 增加行内 `tree-rename-input`
- F2 不再只转发 `beginRenameFocused`,而是直接进入当前 row 的 inline rename 状态。
- Enter / blur 提交,Escape 取消。
- 页面 / index row 提交到 `/api/tree/commands``rename`,并通过 `onTreeMutation` 回写 `tree.node.renamed`
- 普通 asset row 提交到 `/api/media/batch``rename`,并广播 `wolai:assets-changed`
- F2 输入框已补行内 validation:空名、非法路径字符、同级重名会在 input 下方显示错误并阻断提交,不再依赖 alert。
- 普通附件重命名时,如果用户输入不带扩展名,会保留原附件扩展名后再提交,例如 `附件.pdf` 输入 `附件新名` 会提交 `附件新名.pdf`
- jsdom / 旧浏览器环境缺少 `CSS.escape` 时使用本地 fallback,避免 F2 聚焦逻辑抛错。
## 3. 验证命令
```bash
cd /mnt/Data1T/mnote/wolai-frontend
pnpm test src/components/sidebar/tree-shell-host.test.tsx
```
结果:1 个测试文件通过,10 项测试通过;新增测试覆盖 F2 进入 inline input、提交 `tree.node.rename`、空名 / 非法字符 / 同级重名行内阻断,以及附件扩展名保留。
## 4. 待补
- 2026-05-15 已补 3000 主入口真实浏览器 smoke:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task430-vscode-explorer-stage7-smoke.js`
- `task430` 覆盖页面 filetree row F2 inline rename:请求 `/api/tree/commands action=rename`,Convex 标题更新,无需刷新可见。
- `task430` 覆盖普通附件 row F2 inline rename:请求 `/api/media/batch action=rename`Convex `media_assets.file_name` 更新,无需刷新可见。
- 3000 Rust SSR 主壳已补 F2 inline rename;页面树 fallback 中仍保留 prompt 式重命名,按当前口径不属于 filetree F2 小阶段完成条件。
- 2026-05-15 二次补强:本次只补 `wolai-frontend` DOM host 的行内 validationRust SSR srcdoc 路径如继续作为 3000 主入口,需要在后续 smoke 中补同等级 validation parity。
@@ -0,0 +1,39 @@
# 4-31 VSCode Explorer Context Menu Minimum v1
> 状态:done
> 创建时间:2026-05-15
> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环
> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 7
## 1. 目标
先让主 Sidebar 右键菜单显式覆盖 VSCode Explorer 常见项,避免缺项被误读为“无能力”。已有 executor 的项接真实动作;暂无 executor 的项以禁用态展示原因。
## 2. 已落地
- `New File`:复用当前“新建子页面”能力。
- `New Folder`:禁用态,说明页面树暂不区分文件夹,待 Resource Tree folder 合同收口。
- `Paste Into`:主 Sidebar 节点右键已接真实 paste target,目标为当前菜单节点;`Ctrl/Cmd+V` 继续使用 focused target。
- `Refresh`:调用 `refreshTree()`,不使用 `window.location.reload()`
- `Collapse All`:清空展开集合。
- `Copy Path`:复制当前页面标题路径。
- `Copy Relative Path`:复制不带根斜杠的页面标题路径,按当前 workspace tree 计算。
- `Reveal`:展开父链并滚动到当前节点。
## 3. 验证命令
```bash
cd /mnt/Data1T/mnote/wolai-frontend
pnpm test src/components/sidebar/sidebar-context-menu-source.test.ts
pnpm test src/components/sidebar/sidebar-paste-preflight-source.test.ts -- --runInBand
```
结果:`sidebar-context-menu-source.test.ts` 1 项通过;`sidebar-paste-preflight-source.test.ts` 3 项通过。覆盖菜单项存在、`Copy Relative Path` 接入、`Paste Into` 右键 action target、仍保留 `New Folder` 禁用态、未引入 `window.location.reload`
## 4. 待补
- 2026-05-15 已补 3000 主入口真实浏览器 smoke:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task430-vscode-explorer-stage7-smoke.js`
- `task430` 验证 filetree 右键菜单可见 `New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Reveal`;当时 `New Folder``Paste Into` 为禁用态,且 title 中说明原因。
- 2026-05-15 二次补强:`Paste Into` 已从禁用态升级为真实 paste target;主 Sidebar 抽出 `executeFileTreePaste(targetDocumentIdOverride)`,右键传当前菜单节点 `node.id`,键盘 `Ctrl/Cmd+V``null` 保留 focused target。
- `New Folder` 需等待 folder/resource tree 合同,而不是在页面树里临时伪造文件夹真相。
- 2026-05-15 二次补强:已补 `Copy Relative Path`;空白区右键菜单当前仍没有独立入口,后续若新增必须复用同一 paste helper 和 target 规则。
@@ -0,0 +1,36 @@
# 4-32 VSCode Explorer DnD Modifier Guard v1
> 状态:done
> 创建时间:2026-05-15
> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环
> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 7
## 1. 目标
补齐文件树 DnD copy modifier 的 VSCode 基线:Alt、Ctrl、Meta 任一修饰键都应进入 copy drop,而不是只有 Alt 生效。
## 2. 已落地
- `tree-shell-dom-host.tsx` 的 filetree dragover 改为 `Alt / Ctrl / Meta -> copy`
- `mnote-web` SSR 主壳 `layout.rs` 的 filetree dragover / drop 也同步改为 `Alt / Ctrl / Meta -> copy`
## 3. 已有防线
- 父拖子 / 拖到自身禁止由 Rust `tree.filetree.drop.preflight` 校验,错误语义为不能把页面移动到自身或后代下面。
- 主 Sidebar 继续依赖 drop preflight,不在 UI 层复制第二套树祖先判断。
## 4. 验证命令
```bash
cd /mnt/Data1T/mnote/wolai-frontend
pnpm test src/components/sidebar/sidebar-dnd-source.test.ts
```
结果:1 个测试文件通过,1 项测试通过。
## 5. 待补
- 2026-05-15 已补 3000 主入口 `POST /api/tree/filetree/drop-preflight`,通过 Rust bridge `tree.filetree.drop.preflight` 返回真实 preflight plan。
- 2026-05-15 已补 3000 主入口真实浏览器 smoke:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task430-vscode-explorer-stage7-smoke.js`
- `task430` 验证自拖自身与父拖子均返回 400copy modifier preflight 返回 `copy: true``documentTransferPlan.action=copy`
- 后续 readonly 与命名冲突确认已由 `4-33-vscode-explorer-dnd-readonly-conflict-v1.md` 收口。
@@ -0,0 +1,152 @@
# 4-33 VSCode Explorer DnD Readonly Conflict v1
> 状态:done
> 创建时间:2026-05-15
> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环
> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 7 DnD 剩余项
## 1. 目标
补齐阶段 7 中尚未闭合的 DnD 剩余项:主 Sidebar Convex filetree 的 readonly 禁止与命名冲突确认。
当前已完成的 DnD 证据是:
- 自拖自身拒绝。
- 父拖到子节点 / 后代拒绝。
- Alt / Ctrl / Meta copy modifier 被识别为 copy。
- 3000 主入口 `/api/tree/filetree/drop-preflight` 已返回 Rust bridge `tree.filetree.drop.preflight` plan。
尚未完成的是:
- 主 Sidebar Convex filetree 下的 readonly 目标 / readonly source 禁止。
- 主 Sidebar Convex filetree 下的重名目标冲突确认,而不是直接 move/copy。
## 2. 当前代码事实
- `bridge-runtime``FileTreeDropPreflightCommandPayload` 当前只有:
- `copy`
- `targetDocumentId / targetRowId / focusedRowId / activeDocumentId`
- `rowIds`
- `rows`
- `documentParents`
- `FileTreeDropPreflightRow` 当前只有:
- `rowId / rowKind / documentId / assetId / assetDocumentId / assetType / storagePath`
- 现有 `build_filetree_drop_plan` 能判断:
- 目标页面。
- doc / asset transfer plan。
- 父拖子 / 自拖非法 move。
- copy / move action。
- 现有 payload 不能判断:
- 目标是否 readonly。
- source 是否 readonly。
- target 下是否已有同名页面 / 附件。
- 冲突应 rename、replace、skip 还是 cancel。
## 3. 设计增量
### 3.1 Drop preflight payload 增量
`tree.filetree.drop.preflight` payload 增加以下可选字段:
```json
{
"sourceCapabilities": ["read", "write", "move", "copy"],
"targetCapabilities": ["read", "write", "drop"],
"rows": [
{
"rowId": "doc:...",
"title": "页面标题",
"operationProfile": "convex_workspace"
}
],
"targetChildren": [
{
"rowKind": "doc",
"documentId": "...",
"assetId": null,
"title": "页面标题"
}
],
"conflictPolicy": "prompt"
}
```
说明:
- `sourceCapabilities` / `targetCapabilities` 用于表达当前 workspace source 与目标 row 的写权限,不在 UI 层猜测。
- `operationProfile` 允许 local readonly、remote readonly、shared readonly 等来源进入同一 preflight,而不是散落在前端。
- `title``targetChildren` 用于做同级重名检测。
- `conflictPolicy` 默认 `prompt`,后续可扩展 `rename` / `skip` / `replace`,但本阶段只要求“发现冲突并要求确认”。
### 3.2 Drop preflight plan 增量
`FileTreeDropPlan` 增加:
```json
{
"allowed": true,
"blockedReason": null,
"requiresConfirmation": false,
"conflicts": []
}
```
readonly 场景:
- `allowed=false`
- `blockedReason="readonly_target"``readonly_source"`
- 不返回可执行 transfer plan,避免 UI 误执行。
命名冲突场景:
- `allowed=true`
- `requiresConfirmation=true`
- `conflicts` 列出冲突对象、目标父级、建议策略。
- UI 必须展示确认,不允许静默执行。
### 3.3 3000 主壳行为
- 内部 drop 触发前继续调用 `/api/tree/filetree/drop-preflight`
-`allowed=false`,显示阻塞原因,不发 `/api/tree/commands``/api/media/batch`
-`requiresConfirmation=true`,显示冲突确认;用户取消时不执行。
- 用户确认后,按 plan 执行 move/copy。
## 4. 验收标准
- readonly target
- 真实或构造的 readonly target drop preflight 返回 400 或 `allowed=false`
- 浏览器不发执行请求。
- UI 有“只读”类可解释反馈。
- conflict
- 目标父页面已有同名页面或同名附件时,preflight 返回 `requiresConfirmation=true``conflicts`
- 用户取消确认后不执行 move/copy。
- 用户确认后执行 move/copy,并在结果中记录冲突处理策略。
- no false positive
- 非冲突移动不弹冲突确认。
- copy modifier 仍保留 `copy=true`
- 父拖子 / 自拖仍被拒绝。
## 5. 建议执行顺序
1. 扩展 `bridge-runtime` payload / plan 类型,补 readonly 与 conflict 单测。
2. 扩展 3000 `/api/tree/filetree/drop-preflight` route 透传新增字段。
3. 扩展 3000 Rust SSR 主壳内部 drop 逻辑,生成 row title / target children / capability payload。
4. 新增 `scripts/task431-vscode-explorer-dnd-readonly-conflict-smoke.js`
- 一次性用户 / workspace。
- 构造同名目标,验证 conflict confirmation cancel / confirm。
- 构造 readonly payload 或 readonly source fixture,验证 blocked。
5. 更新 `design/10-review/07-vscode-explorer-filetree-trash-gap-review.md`,仅在 `task431` 通过后勾选阶段 7 DnD 总项。
## 6. 当前状态
截至 2026-05-15,本文件已完成阶段 7 DnD readonly / conflict 的执行与验证:
- `bridge-runtime``tree.filetree.drop.preflight` 已支持 source / target capability、target children、conflict policy、`requiresConfirmation``conflicts`
- readonly target / source 在 preflight 阶段被拒绝;同名目标返回确认计划;copy modifier 不被 readonly move 规则误拒绝。
- 主 Sidebar 内部 drop 与 cut-paste move 路径在 `requiresConfirmation` 时确认,取消后不执行后续 move/copy。
- 验证命令:
- `cargo test -p bridge-runtime tree_filetree_drop_preflight -- --nocapture`
- `pnpm test src/lib/file-tree/shell.test.ts src/lib/file-tree/resource-command-client.test.ts src/components/sidebar/sidebar-paste-preflight-source.test.ts src/components/sidebar/sidebar-dnd-source.test.ts`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3001 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task431-vscode-explorer-dnd-readonly-conflict-smoke.js`
注意:验证时 3000 常驻进程仍是旧 `mnote-web` 二进制,因此 `task431` 对 3000 初次执行复现旧 readonly 放行行为;当前代码在 3001 新编译进程验证通过,3000 需重启后生效。
@@ -0,0 +1,91 @@
# 4-37 [done] 垃圾箱改为居中弹窗工作台 v1
> 更新时间:2026-05-15
>
> 背景:
> - 用户建议:垃圾箱应做成弹窗,而不是单一页面。
> - 当前 `/trash` 是完整页面入口;在本地文件夹 / 云空间切换异常时,用户会通过垃圾箱“找回”原空间页面,这进一步暴露垃圾箱作为导航页面会改变当前工作区上下文。
## 1. 设计目标
把垃圾箱从“离开当前工作区的独立页面”逐步改为“当前工作区内的居中弹窗工作台”。
弹窗形态以 Wolai 居中对话框为准:遮罩覆盖页面,工作台面板在视口中心显示,左右和上下都保留留白;不要做成靠右侧贴边的 drawer / 抽屉。
目标体验:
- 用户点击左下角“垃圾箱”时,不离开当前页面和当前文件树上下文。
- 垃圾箱以居中 modal 打开,不贴右侧边缘,不改变当前 URL。
- 弹窗内展示页面垃圾箱与资源垃圾箱。
- 恢复、彻底删除、清空操作完成后,当前 Sidebar / File Tree / Page Tree 实时同步。
- 关闭弹窗后,用户回到原页面、原选中 row、原滚动位置。
## 2. 非目标
- 本稿不改变删除、恢复、彻底删除的底层命令语义。
- 本稿不一次性废弃 `/trash``/trash` 可作为兼容、直接链接和测试入口保留。
- 本稿不扩大到全局搜索、模板中心等其它 footer 入口。
## 3. 最小实现切片
P0:入口形态
- 左下角“垃圾箱”从普通导航改为打开弹窗。
- 弹窗复用现有 `mnote-trash-workbench` DOM 与 API。
- 打开弹窗不改变当前 URL;如需要可用 `?trash=1` 或 history state,但默认不跳整页。
P1:上下文保持
- 弹窗打开前记录当前 `sourceKind``workspaceId``rootUri`、active row、scrollTop。
- 关闭弹窗后恢复原 File Tree / Page Tree 视图状态。
- 本地文件夹场景下,垃圾箱只展示本地 `.mnote/trash` 能力;Convex 云空间展示 Convex trash。
P2:实时同步
- 页面 restore / purge / empty 后,当前 Sidebar 通过 tree event 或本地 apply 更新。
- 资源 restore / purge / empty 后,File Tree 对应资源 row 同步恢复或消失。
## 4. 验收标准
- 点击“垃圾箱”不会离开当前文档 URL。
- 垃圾箱面板在视口中心显示,左右居中偏差不超过 8px,并保留可见的上下留白。
- 弹窗内能恢复页面、彻底删除页面、清空页面垃圾箱。
- 弹窗内能恢复/彻底删除/清空附件、mindmap、table 等资源。
- 关闭弹窗后,原页面仍打开,File Tree 滚动位置和选中 row 不被无关重置。
- `/trash` 兼容入口仍可访问并通过现有 smoke。
## 5. 当前状态
当前状态:`done`
2026-05-15 已完成最小可用实现:
- 左下角“垃圾箱”入口保留 `href="/trash"` 作为兼容 fallback,但主壳点击会拦截为 `data-mnote-action="open-trash-modal"`
- `PageLayout` 新增垃圾箱 modal:打开后 fetch `/trash?workspaceId=...`,解析并挂载现有 `mnote-trash-workbench`,不改变当前 URL。
- modal 关闭时会关闭 workbench EventSource,并恢复 File Tree `scrollTop`;不改变原页面、active row 或当前文档 URL。
- `/trash` 独立页面继续保留,作为直接链接、兼容入口和现有 smoke 的验证对象。
- 本地文件夹上下文下,modal 保持在本地 source,不跳回 Convex;当前先展示 `.mnote/trash` / `trash-index.json` 的本地垃圾箱说明,后续如需完整 local trash list 可继续扩展。
- 2026-05-15 根据用户参考图修正 modal 形态:从右侧 drawer 改为居中对话框,并在 smoke 中增加居中与上下留白断言,避免后续重复退回侧栏抽屉形态。
代码落点:
- `rust/crates/mnote-web/src/workspace_shell.rs`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- `rust/crates/mnote-web/src/ssr/styles.rs`
- `rust/crates/mnote-web/src/routes/gateway.rs`
- `scripts/task442-trash-modal-workbench-smoke.js`
已通过验证:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web workspace_shell -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web trash_entry -- --nocapture
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task442-trash-modal-workbench-smoke.js
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js
```
结果文件:
- `tmp/task442-trash-modal-workbench-smoke/result.json`
- `tmp/task432-filetree-trash-page-dual-browser-no-refresh-smoke/result.json`
@@ -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 覆盖。
@@ -2,6 +2,10 @@
> 更新时间:2026-05-13
>
> 2026-05-15 口径更新:
> - 本文件中的 `index.md` tab / row 表述是完成当时用于隔离页面正文与资源对象的历史命名。
> - Convex File Tree 默认可见页面正文行已由 `/mnt/Data1T/mnote/design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md` 覆盖为 `doc:<documentId>` + `{title}.md`;后续 UI、smoke 与 review 不应再把可见 `index.md` 子行作为目标模型。
>
> 上游依据:
> - `/mnt/Data1T/mnote/design/10-review/05-tree.md`
> - `/mnt/Data1T/mnote/bugs/05-editor-mainline/done/5-10-mindmap-filetree-index-single-truth-split-v1.md`
+11 -3
View File
@@ -1,8 +1,14 @@
# 10-review 审查总览
> 执行状态:本轮 10-review 已完成并迁入 [done/](./done/) 留档。根目录只保留索引,避免后续 worker 把历史审查快照误当成当前待办
> 执行状态:`done/01` 到 `done/07` 均已归档
最终执行入口:
当前无活跃审查。
最新归档审查:
- [VSCode Explorer 文件树与垃圾箱闭环增量审查](./done/07-vscode-explorer-filetree-trash-gap-review.md)
上一轮最终执行入口:
- [顺序执行清单与验收标准](./done/06-execution-checklist-and-acceptance.md)
@@ -18,4 +24,6 @@
- `done/01``done/04` 是历史偏差审查快照;其中旧的“未完成 / 风险”条目已由 `done/06` 承接闭环,或转入对应主线设计文档继续跟踪。
- `done/05-tree.md` 的 Resource Tree / File Tree / Page Tree、ObjectIdentity、mindmap 与 `index.md` 隔离主线已由 `design/04-tree-domain/done/4-24-*``design/05-editor-mainline/done/5-12-*` 承接完成。
- `done/06-execution-checklist-and-acceptance.md` 是本轮 10-review 的最终验收依据;后续只作为防回归和口径核验材料
- 2026-05-15 起,Convex File Tree 的默认可见页面正文行由 `design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md` 覆盖为 `doc:<documentId>` + `{title}.md``done/05-tree.md``4-24``5-12` 中的 `index.md` 表述仅作为历史 object identity / 兼容语义理解,不再作为默认 UI 可见模型继续派生新任务
- `done/06-execution-checklist-and-acceptance.md` 是上一轮 10-review 的最终验收依据;后续只作为防回归和口径核验材料。
- `done/07-vscode-explorer-filetree-trash-gap-review.md` 是 07-ai 开发前对 04-tree 文件树 / 页面树多选、默认删除进垃圾箱、垃圾箱恢复与永久删除、资源级 `tree.resource.*`、Convex purge 同步、VSCode Explorer 体验对标的增量审查归档。该审查 checklist 已闭合;后续仍应按文档中的“最低可用完成 / parity backlog”口径描述 VSCode Explorer 对标,不要把禁用态或待增强项说成完整 parity。
@@ -0,0 +1,6 @@
{
"pages": {
"新页面 2.md": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~202.md"
},
"version": 1
}
@@ -0,0 +1,57 @@
{
"entries": {
"local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2.md": {
"archivedAt": 1778837327751,
"deletedAtMs": 1778837327751,
"documentId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2.md",
"originalFilePath": "新页面.md",
"originalRelativePath": "新页面.md",
"purgedAt": null,
"resourceKind": "markdown",
"resourceScope": "local_folder",
"trashEntryId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2.md",
"trashRelativePath": ".mnote/trash/新页面.md",
"trashedFilePath": ".mnote/trash/新页面.md"
},
"local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~203.md": {
"archivedAt": 1778837335689,
"deletedAtMs": 1778837335689,
"documentId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~203.md",
"originalFilePath": "新页面 3.md",
"originalRelativePath": "新页面 3.md",
"purgedAt": null,
"resourceKind": "markdown",
"resourceScope": "local_folder",
"trashEntryId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~203.md",
"trashRelativePath": ".mnote/trash/新页面 3.md",
"trashedFilePath": ".mnote/trash/新页面 3.md"
},
"local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~204.md": {
"archivedAt": 1778837335735,
"deletedAtMs": 1778837335735,
"documentId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~204.md",
"originalFilePath": "新页面 4.md",
"originalRelativePath": "新页面 4.md",
"purgedAt": null,
"resourceKind": "markdown",
"resourceScope": "local_folder",
"trashEntryId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~204.md",
"trashRelativePath": ".mnote/trash/新页面 4.md",
"trashedFilePath": ".mnote/trash/新页面 4.md"
},
"local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~205.md": {
"archivedAt": 1778837335779,
"deletedAtMs": 1778837335779,
"documentId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~205.md",
"originalFilePath": "新页面 5.md",
"originalRelativePath": "新页面 5.md",
"purgedAt": null,
"resourceKind": "markdown",
"resourceScope": "local_folder",
"trashEntryId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~205.md",
"trashRelativePath": ".mnote/trash/新页面 5.md",
"trashedFilePath": ".mnote/trash/新页面 5.md"
}
},
"version": 1
}
@@ -0,0 +1,5 @@
---
mnote_id: local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~203.md
title: 新页面
---
# 新页面
@@ -0,0 +1,5 @@
---
mnote_id: local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~204.md
title: 新页面
---
# 新页面
@@ -0,0 +1,5 @@
---
mnote_id: local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~205.md
title: 新页面
---
# 新页面
@@ -0,0 +1,5 @@
---
mnote_id: local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2.md
title: 新页面
---
# 新页面
@@ -0,0 +1,609 @@
# 07 VSCode Explorer 文件树与垃圾箱闭环增量审查
> 更新时间:2026-05-15
> 状态:done
> 范围:`04-tree-domain` 中 File Tree / Page Tree / Resource Tree 与垃圾箱、删除、恢复、永久删除、VSCode Explorer 对标相关缺口。
> 结论:本审查 checklist 已闭合。当前可表述为文件树多选、删除进入垃圾箱、资源生命周期、恢复位置、双浏览器 no-refresh、本地 folder watcher 与本地非 md 资源 trash baseline 已完成最低可用闭环;但 VSCode Explorer 对标仍应按“最低可用完成 / parity backlog”分层,不应把禁用态或后续增强项描述为完整 parity。
## 1. 本轮判断
- `design/04-tree-domain/done/4-12-*``4-14-*` 的 done 口径是 Page Tree / File Tree initial DOM、hydration、row contract、selection state family 与能力保留完成,不等于 final DOM shell、VSCode Explorer 体验或垃圾箱产品闭环完成。
- `design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md` 已经把 VSCode Explorer 对标列为 done,但文档自身仍明确写着资源 `rename/delete/restore` 不能长期停留在 legacy `/api/media/batch`,需要 `tree.resource.rename/archive/restore` 或等价正式命令。
- 当前 `wolai-frontend/src/components/sidebar/sidebar.tsx` 已经存在文件树多选删除和垃圾桶 Drawer,但仍以页面、附件、mindmap、table 的分散 route 组合闭环;这更像“可用基础”,不是统一 tree-first command 产品面。
- 初始审查时 `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中“删除到垃圾桶”上下文菜单实际 dispatch `action: 'purge'`,这是已在阶段 1 止血的最高优先级语义风险;后续仍需防回归。
## 2. 主要缺口
### F-01 P0Rust Web 主壳“删除到垃圾桶”实际触发永久删除
初始证据:
- `rust/crates/mnote-web/src/ssr/pages/layout.rs``delete-trash` 分支显示确认文案“删除到垃圾桶”,但 dispatch `action: 'purge'`
- `wolai-frontend/src/app/api/tree/commands/route.ts``purge` 对应 `tree.node.purge`,语义是永久删除。
当前状态:
- 阶段 1 已改为 dispatch `archive`,并由 `sidebar_tree_delete_to_trash_dispatches_archive_not_purge` 与真实浏览器 smoke 防回归。
影响:
- 用户以为页面进入垃圾桶,实际可能走永久删除。
- 这会破坏“默认 Delete = archive / trash,永久删除只在 Trash 工作台强确认”的 VSCode Explorer 基线。
应执行:
- `delete-trash` 必须改为 `archive` / `tree.node.archive`
- `purge` 只能从垃圾箱中的“彻底删除 / 清空垃圾箱”入口触发,并保留更强确认。
### F-02 P0:垃圾箱产品面存在,但 `/trash` 与 3000 主壳口径未闭合
证据:
- `wolai-frontend/src/components/sidebar/sidebar.tsx` 已有 `Drawer` 垃圾桶,支持页面 / 附件 tab、搜索、恢复、彻底删除、清空。
- `rust/crates/mnote-web/src/workspace_shell.rs` 暴露 footer entry`href: "/trash"`
- 当前只检索到 `/api/*/empty-trash`,未看到完整 `wolai-frontend/src/app/trash` 或 Rust `/trash` 工作台页面。
影响:
- Sidebar Drawer 可用不等于全局 Trash 工作台可用。
- workspace shell 的“垃圾箱”链接可能成为空入口或体验断点。
应执行:
- 明确产品口径:垃圾箱是 Sidebar Drawer、独立 `/trash` 页面,还是两者都有。
- 若保留 `/trash`,必须有真实路由、真实数据、恢复、彻底删除、清空和浏览器验收。
- 若只保留 Drawer,应移除或改写 `/trash` 链接,避免入口误导。
### F-03 P0:删除默认语义必须先进入垃圾箱,永久删除必须只从垃圾箱触发
证据:
- 页面删除在 `deleteDocumentCommand` 主链中已经走 `archive`Convex `documents.softDelete` 会写 `deleted_at`
- 但 Rust Web 主壳存在 `delete-trash -> purge` 反向危险。
- 附件、mindmap、table 删除链分别走 `/api/media/batch``/api/mindmap/:doc/:id``/api/tables/:id` 等分散 route。
影响:
- 同一个“删除”动作在不同入口、不同资源类型下语义不一致。
- 用户无法建立稳定心智:删除后是否能恢复,永久删除是否真的同步清理 Convex。
应执行:
- 统一命名:默认删除 = `archive` / `trash`;彻底删除 = `purge`
- 所有入口的确认文案、commandName、route、Convex mutation 必须同义。
- 删除后必须能在垃圾箱中看到对象,恢复后回到 projection,彻底删除后 Convex 查询不到对象。
### F-04 P1:文件树多选基础已存在,但页面树与批量命令闭环不足
证据:
- `wolai-frontend/src/lib/file-tree/selection.ts``tree_shell/filetree_selection.rs` 已支持普通点击、Ctrl/Cmd 多选、Shift 范围、多选 toggle、右键保留选区。
- `wolai-frontend/src/components/sidebar/tree-shell-dom-host.tsx` 会把 Delete / Backspace 转给 `onFileTreeDeleteSelection`
- `wolai-frontend/src/components/sidebar/sidebar.tsx` 的批量页面删除是逐个 `deleteDocumentCommand`,不是一个原子批量 tree command。
- 页面树没有同等级多选模型,主要是 focus / active / 单节点 context menu。
影响:
- 用户指出的“多选删除”在文件树有基础,但没有形成 VSCode Explorer 式跨入口一致能力。
- 批量删除部分失败时会出现部分已删、部分未删,缺少统一 plan / rollback / result summary。
应执行:
- 文件树先补真实浏览器 smokeCtrl/Cmd 多选、Shift 范围、右键已选项保持 selection、Delete 批量删除。
- 批量删除应进入统一 preflight plan,至少对父子重复选择去重,并返回可解释的 partial failure。
- 页面树是否需要多选要明确产品口径;若 page tree 也承担 Explorer 行为,应补 selection state family。
### F-05 P1:资源级 `tree.resource.*` 未收口
证据:
- `4-20` 明确写到资源 `copy/move/upload` 已有 `tree.resource.copy/move/upload`,但 `rename/delete/restore` 仍经过 legacy `/api/media/batch`,需要 `tree.resource.rename/archive/restore` 或等价 bridge。
- 当前前端 `handleDeleteAssets` 对 mindmap、table、file asset 分别调用不同 route,并在删除后 `refreshTree()`
影响:
- File Tree / Resource Tree 仍有第二套资源命令真相。
- 后续 07-ai 若依赖资源树状态,容易再次绕过 tree-first command。
应执行:
- 定义并落地 `tree.resource.archive``tree.resource.restore``tree.resource.purge``tree.resource.rename`,或明确等价 bridge 命名。
- legacy `/api/media/batch` 只能作为兼容 alias,不应是主路径。
- command event 与 projection resync 必须覆盖资源恢复、永久删除、重命名。
### F-06 P1:垃圾箱清空需要跨 documents / media / mindmap / table 一致验收
证据:
- 当前存在 `/api/documents/empty-trash``/api/media/empty-trash``/api/mindmap-trash/empty``/api/tables/empty-trash`
- Sidebar 中“页面垃圾桶”和“附件垃圾桶”分别清空:页面只调 documents;附件 tab 并发清空 media、mindmap、table。
影响:
- 用户预期的“垃圾箱删除后 Convex 同步删除”必须跨对象类型验收,而不是只验收页面。
- 如果多个 API 中任一失败,可能出现计数、projection 与 Convex 数据不一致。
应执行:
- 清空垃圾箱需要明确是否是一键全量清空,还是按页面 / 附件 tab 分区清空。
- 验收必须覆盖 Convex `documents``media_assets``mindmaps``document_tables` 以及页面关联数据清理。
- 对 Storage / OCR / LightRAG / 引用 / 最近访问 / 收藏等关联清理给出确认边界。
### F-07 P1:恢复位置、排序与子树还原规则不足
证据:
- 子代理审查指出 Convex 页面 restore 会把根节点 `parent_id` 置为 `null`,子节点按同一 `deleted_at` 级联恢复;原父位置 / 排序不一定完整还原。
影响:
- “恢复”只能证明对象复活,不等于恢复到删除前位置。
- 对文件树体验来说,恢复后 reveal/select/focus 也属于闭环的一部分。
应执行:
- 删除前记录原 parent / order / source view / expanded path,恢复后尽量还原。
- 如果父节点已不存在,应有明确 fallback:恢复到根、恢复到最近有效父、或提示用户选择位置。
- 恢复后必须 reveal 并选中对象,避免用户以为恢复失败。
### F-08 P1VSCode Explorer 的 cut / inline rename / context menu 命令面未完全对齐
证据:
- 文件树键盘层会产生 `cutSelection` reducer 事件,但全局粘贴链主要读取 `action: "copy"` 的剪贴板写入,cut-move paste 未完整确认。
- F2 目前更像触发 `beginRenameFocused`,实际重命名仍大量依赖右键菜单 / prompt,不是 VSCode 式 inline rename。
- 右键菜单缺少或未统一:Cut、Paste Into、New File、New Folder、Refresh 单节点、Collapse All、Reveal、Copy Path / Relative Path 等 Explorer 常见项。
影响:
- 当前“对标 VSCode Explorer”容易被误读成完成全部 Explorer 产品能力。
- 07-ai 若把这些能力当成已有,会在自动操作、上下文菜单和 keyboard workflow 中遇到断点。
应执行:
- P1 先收口 Cut / Paste move、F2 inline rename、右键命令可用性。
- P2 再补 Reveal、Collapse All、Copy Path、Refresh 单节点等效率项。
- 不应照搬 VSCode 文件系统 provider 复杂度,但应照搬 command context 和 selection/focus 规则。
### F-09 P1:本地文件夹与 Convex 不是同一 executorwatch 范围也不一致
证据:
- Rust Web `tree.rs` 支持 `/api/tree/commands`,但 `local_folder` 分支走独立 `execute_local_tree_command`
- 本地 watcher 当前主要监听 markdown 路径事件,未覆盖非 md 资源文件的完整资源树变更。
- `4-22` 已把 Local Folder / Convex 统一 source 大范围标为完成,但 `4-23` import/export/publish/sync 候选合同仍在 `process/`
影响:
- “Local Folder 与 Convex 共用一套 projection / command / preflight / runtime / UI”仍需要拆成已完成和待核验两层。
- 普通附件、OnlyOffice、mindmap、本地资源文件的 Trash / restore / purge 不能默认等同于 markdown page trash。
应执行:
- 10-review 口径中明确:local markdown/page trash baseline 已有,完整 Resource Tree / File Tree Trash 待核验。
- 本地资源文件 watch、trash、restore、purge 需要单独验收。
### F-10 P2:实时刷新仍混合 refetch,缺少 no-refresh 级回归验收
证据:
- Sidebar 删除资产后仍显式 `refreshTree()`
- 当前同时存在 Convex live/query、tree SSE、手动 refetch、本地 watcher 多条刷新链。
影响:
- “刷新浏览器才能看到变化”类问题容易复发。
- 局部 optimistic update、server delta、manual refetch 之间没有统一验收矩阵。
应执行:
- 保留手动 refetch 作为兜底,但主验收必须是无浏览器刷新可见。
- 覆盖双浏览器:A 删除 / 恢复 / 清空,B 通过 Convex live 或 tree stream 自动更新。
### F-11 P1F2 inline rename 仍缺 VSCode 式行内校验体验
证据:
- VSCode Explorer rename 输入框会做空名、非法名、同级重名等 validation,并在输入框上下文展示错误。
- 当前 filetree 已有 F2 inline rename,但主要是 trim 后提交;失败路径仍可能落到 alert / 后端错误提示,不是行内 validation。
- 2026-05-15 二次修正后,`wolai-frontend` DOM host 已补空名、非法路径字符、同级重名行内阻断和普通附件扩展名保留;Rust SSR srcdoc 路径仍需后续 smoke 核验是否同等级。
影响:
- “能 F2 重命名”不等于 VSCode Explorer rename 体验闭环。
- 07-ai 或自动化操作若只看 input 存在,可能无法在非法名、重名、扩展名策略下获得稳定可解释反馈。
应执行:
- 页面、普通附件、本地 Markdown、本地非 md 资源统一行内校验:空名、非法字符、同级重名、扩展名保留 / 改写策略。
- Enter 不提交非法值;Escape 取消;blur 行为必须明确。
- 错误提示必须出现在 rename input 附近,不能只依赖 alert。
### F-12 P2:右键菜单仍是最低项可见,不是完整 VSCode Explorer command surface
证据:
- 当前已补 `New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Reveal` 的最低可见或禁用态。
- `New Folder` 在当前页面树下仍是禁用态;`Paste Into` 已在主 Sidebar 节点右键菜单接入 action target,但空白区右键菜单与 Rust SSR srcdoc 路径仍需后续 smoke / 实现时继续沿同一 target 规则验收。
影响:
- 当前只能说“最低菜单项有口径”,不能说“VSCode Explorer 菜单面已完整对齐”。
- 右键 action target 与键盘 paste target 若不统一,批量选择和右键未选项切换目标会再次出现歧义。
应执行:
- 明确哪些菜单项是长期不做、哪些进入 backlog。
- 至少补 `Copy Relative Path` 的 workspace-relative / local-root-relative 语义和真实复制验收。
- `Paste Into` 应使用右键 action target,并与 Ctrl/Cmd+V 的 focused target 规则写清。
### F-13 P2`resync_required` 事件合同需要防回归
证据:
- `applyTreeStreamDelta``resync_required` 本身不直接替换 tree 数据,必须依赖后续 snapshot / resync envelope。
- 阶段 8 smoke 已证明本轮页面、附件、mindmap、table 链路能触发 no-refresh 更新,但这不能单独证明所有未来 `resync_required` 都会配套 resync。
影响:
- 如果后续 route 只写 `resync_required` delta、不写 snapshot / resyncB 浏览器会停留在旧数据。
- 这类问题表面上会再次表现为“必须刷新浏览器才能看到变化”。
应执行:
- 给 tree event stream 增加合同级回归:每个 `resync_required` 必须伴随后续 snapshot / resync,或客户端必须主动拉取新 snapshot。
- 验收断言包括 cursor 顺序、断线重连、重复 resync 去重。
- smoke 结果中继续记录实际事件来源,不把本地 `refreshTree()` 误标为实时同步。
## 3. 按顺序执行的 checklist
### 阶段 1P0 删除语义止血
- [x] 修正 `rust/crates/mnote-web/src/ssr/pages/layout.rs``delete-trash` 必须 dispatch `archive`,不能 dispatch `purge`
- [x] 补单元测试或字符串级回归:菜单文案“删除到垃圾桶”不得与 `tree.node.purge` 绑定。
- [x] 真实浏览器验证:Rust 3000 主壳右键删除页面后,页面进入垃圾桶,Convex `deleted_at` 非空,未被永久删除。
验收标准:
- Network / bridge log 中默认删除命令为 `tree.node.archive`
- `tree.node.purge` 只出现在垃圾箱“彻底删除 / 清空”路径。
执行记录:
- 2026-05-15`delete-trash` 已改为 `archive`;新增 `sidebar_tree_delete_to_trash_dispatches_archive_not_purge` 回归测试。
- 2026-05-15:浏览器 smoke 使用 `TEST-10REVIEW-07-P0-*` 新建页面后右键删除,捕获请求 `action: "archive"`、bridge command `tree.node.archive`、响应 `deleted_at` 非空、同页面 `purge` 请求数为 0。
### 阶段 2:统一垃圾箱入口与产品口径
- [x] 决定 `/trash` 是独立页面还是移除入口;如果保留,补真实 Trash 工作台。
- [x] Sidebar Drawer 与 `/trash` 使用同一 dataset`trashed_documents``trashed_media_assets``trashed_mindmap_assets``trashed_table_assets`
- [x] 页面、附件、mindmap、table 均支持恢复、彻底删除、清空。
验收标准:
- 不存在点开“垃圾箱”后空路由或 404。
- 垃圾箱计数与列表一致;恢复 / 彻底删除后计数和列表实时更新。
执行记录:
- 2026-05-15Rust 3000 主壳已挂载 `GET /trash`,复用 `sidebar.datasetList``trashed_*` 数据渲染垃圾箱工作台;页面条目提供恢复与彻底删除按钮。
- 2026-05-15:补 `trash_entry_renders_real_workspace_trash_workbench` 路由级回归;浏览器 smoke 访问 `/trash?workspaceId=<active>` 返回 200,出现 `mnote-trash-workbench`、页面区、资源区、恢复按钮和彻底删除按钮。
- 2026-05-15Rust 3000 主壳已挂载 `POST /api/documents/empty-trash`,补 `documents_empty_trash_route_executes_workspace_trash_purge` 回归;`/trash` 页面已出现“清空页面垃圾箱”按钮,并只读浏览器 smoke 验证按钮存在,未点击清空以避免删除既有垃圾箱数据。
- 2026-05-15Rust 3000 主壳已新增资源垃圾箱兼容入口:`POST /api/media/batch` restore、`POST /api/media/purge``POST /api/media/empty-trash``PATCH /api/mindmap/{docId}/{mindmapId}` restore / purge、`POST /api/mindmap-trash/empty``POST /api/tables/restore``POST /api/tables/purge``POST /api/tables/empty-trash`;补 `media_trash_routes_restore_purge_and_empty``mindmap_trash_routes_restore_purge_and_empty``table_trash_routes_restore_purge_and_empty` 回归。
- 2026-05-15`/trash` 资源区已从“待迁移”改为真实恢复 / 彻底删除按钮,并新增“清空资源垃圾箱”;路由级 fixture 覆盖附件、mindmap、table 三类资源按钮。
- 2026-05-15`cargo test -p mnote-web trash` 通过 7 项;Playwright 只读 smoke 访问 `http://localhost:3001/trash?workspaceId=ws_demo` 返回 200,确认工作台、页面区、资源区、“清空页面垃圾箱”和“清空资源垃圾箱”存在。真实数据当前没有垃圾箱资源行,未执行清空或彻底删除动作。
- 注意:本阶段完成的是 `/trash` 产品入口和 Rust 兼容 route 止血;附件 / mindmap / table 尚未正式收口为 `tree.resource.*` 命令,继续由阶段 5 跟踪。页面恢复 / 彻底删除 / 清空后的跨浏览器实时计数仍需在阶段 10 的 no-refresh 验收覆盖。
### 阶段 3:删除进入垃圾箱,清空后 Convex 同步永久删除
- [x] 创建测试页面、子页面、附件、mindmap、table,删除后均进入对应垃圾箱。
- [x] 清空页面垃圾箱后,确认 `documents.emptyTrashByWorkspace` 或等价 command 删除文档子树及关联数据。
- [x] 清空附件垃圾箱后,确认 `mediaAssets.emptyTrashByWorkspace``mindmaps.emptyTrashByWorkspace``tables.emptyTrashByWorkspace` 或统一 route 均执行成功。
- [x] 记录 Storage / OCR / LightRAG / page references / recent pages / favorites 的清理边界。
验收标准:
- Convex 查询不到已 purge 的 document / asset / mindmap / table。
- 清空失败时 UI 给出明确错误,不出现“UI 消失但 Convex 仍保留”的假成功。
安全前置:
- `emptyTrashByWorkspace` 是 workspace 级破坏性动作;写入型 smoke 必须使用本轮独立 workspace 或可证明只含本轮 `TEST-10REVIEW-07-P3-*` 数据的隔离环境。
- 禁止在默认测试账号的既有 workspace 上直接点击“清空页面垃圾箱 / 清空资源垃圾箱”,否则可能误删同一用户历史垃圾箱数据。
- 若当前没有安全 workspace seed 机制,先保留代码审查与 route 级 fixture 验收,补齐隔离 workspace 后再执行真实清空。
执行记录:
- 2026-05-15 代码审查:`documents.emptyTrashByWorkspace` 会删除当前用户在 workspace 内 `deleted_at != null` 的 documents,并调用 `purgeDocumentRelatedData` 清理关联 `mindmaps``media_assets``document_tables` / `document_table_rows``comment_messages` / `comment_threads``page_references``document_stars``user_recent_pages`;附件底层 Convex Files 只在同一 `storage_id` 没有其它未清理引用时删除。
- 2026-05-15 代码审查:`mediaAssets.emptyTrashByWorkspace` 会清理已过期 `deleted_at` 或历史 `purged_at``media_assets`,并按 `storage_id` 引用计数决定是否删除 Convex Files`mindmaps.emptyTrashByWorkspace` 删除当前用户已软删 mindmap`tables.emptyTrashByWorkspace` 删除已归档且到期的 `document_tables` 及其 `document_table_rows`
- 2026-05-15 边界记录:OCR 文本随 `media_assets` 行删除;LightRAG 已有 `ingest.rag_index_*` 任务入口,但当前 purge 路径未发现反向删除 LightRAG 索引的实现,应作为后续补充边界而非已完成项。
- 2026-05-15 代码缺口止血:Rust 3000 主壳已补资源软删除兼容入口,避免阶段 3 被“资源无法进入垃圾箱”阻塞:
- `POST /api/media/batch` 支持 `action: "delete"`,写入 `media_assets.deleted_at / deleted_by`
- `DELETE /api/mindmap/{docId}/{mindmapId}` 调用 `mindmaps.softDelete`
- `DELETE /api/tables/{tableId}` 调用 `tables.remove`
- `cargo test -p mnote-web trash` 覆盖 `media_trash_routes_delete_restore_purge_and_empty``mindmap_trash_routes_delete_restore_purge_and_empty``table_trash_routes_delete_restore_purge_and_empty`
- 2026-05-15 安全验证结论:未找到现成 smoke 能安全创建独立 workspace 并完成页面 + 附件 + mindmap + table 的真实清空验收;默认测试账号已有 workspace,不能直接对其执行 workspace 级 empty trash。
- 2026-05-15:已补详细执行设计 `design/04-tree-domain/done/4-25-trash-empty-isolated-workspace-smoke-v1.md`,阶段 3 写入型 smoke 必须按一次性用户 / 一次性 workspace 隔离方案执行。
- 2026-05-15:已固化并执行 `scripts/task427-trash-empty-isolated-workspace-smoke.js`,一次性用户 `mnote.trash.1778793827654@example.com` 创建隔离 workspace `ws_req_1778793827876_33`seed 页面 `tree_1778793827972_4`、子页面 `tree_1778793827997_5`、附件 `asset_trash_1778793827654`、mindmap `mind_trash_1778793827654`、table `mp5zx1wjqwssfr4g5jj`;删除后确认进入垃圾箱,随后清空页面与资源垃圾箱,并用 Convex query 确认 document / media_asset / mindmap / document_table / document_table_rows 均不可再查询。
- 2026-05-15:真实 smoke 输出:`documents.emptyTrashByWorkspace` 删除 2 个页面;`mediaAssets.emptyTrashByWorkspace` 删除 1 个附件;`mindmaps.emptyTrashByWorkspace` 删除 1 个 mindmap`tables.emptyTrashByWorkspace` 删除 1 个 table。
### 阶段 4:文件树多选与批量删除
- [x] 文件树创建 3 个页面、2 个附件、1 个 mindmap、1 个 table 测试数据。
- [x] 验证 Ctrl/Cmd 多选、Shift 范围、右键已选项保持 selection、右键未选项切换 action target 的 reducer / DOM host / local-folder 浏览器证据。
- [x] 验证 Convex filetree 下 Ctrl/Cmd、Shift、右键已选 / 未选 action target 的真实端到端行为。
- [x] 验证 Delete / Backspace 批量删除混合 doc + asset。
- [x] 验证父子页面同时选中时 preflight 只生成一次有效删除计划,不重复删除子树或页面下资产。
- [x] 验证部分失败时保留可理解的 result summary,并刷新到真实服务端状态。
验收标准:
- 删除前确认弹窗准确列出页面、附件、mindmap、table 数量。
- 删除后无需刷新浏览器即可从文件树消失,并在垃圾箱中出现。
执行记录:
- 2026-05-15 代码审查:`wolai-frontend/src/lib/file-tree/selection.ts``reduceFileTreeSelection` 已支持 Ctrl/Cmd toggle、Shift range、右键已选项保持 selection、右键未选项切单选;`selection.test.ts` 已覆盖这些 reducer 规则。
- 2026-05-15 代码审查:`wolai-frontend/src/components/sidebar/tree-shell-dom-host.tsx` 已把真实点击、右键、Delete、Backspace 接到 filetree selection 与 `onFileTreeDeleteSelection``tree-shell-host.test.tsx` 已覆盖 Delete / Backspace 使用当前稳定多选 selection,避免旧 runtime selection 回写覆盖新 selection。
- 2026-05-15 代码审查:`handleDeleteResourceSelection` 已走 `preflightFileTreeDelete(buildFileTreeShellDeletePreflightPayload(...))`,确认文案会按页面、附件、思维导图、在线表格分别计数。
- 2026-05-15 代码审查:父子去重已有两层实现证据:TS legacy `computeFileTreeDeleteTargets` 会过滤被父页面覆盖的子页面 / 资产;Rust `build_filetree_delete_plan` 会过滤 top-level docs,并跳过被选中页面或其祖先页面覆盖的资产。`tree_filetree_delete_preflight_plans_doc_and_asset_targets` 已覆盖父页面 + 子 index + 父/子资产 + 另一页面资产同时选中时,最终只保留父页面与未被该子树覆盖的外部资产。
- 2026-05-15 验证:`cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime tree_filetree_delete_preflight_plans_doc_and_asset_targets` 通过,1 项通过、0 失败。
- 2026-05-15 浏览器证据边界:`scripts/task163-local-folder-unified-tree-browser-smoke.js` 已覆盖 local-folder 的 Ctrl/Cmd 多选、右键已选保持、Shift 范围、右键未选切换、Delete preflight 取消;`scripts/task179-tree-create-delete-no-reload-smoke.js` 只覆盖 Convex filetree 单页面 Delete 后无导航 / 无刷新;`scripts/task427-trash-empty-isolated-workspace-smoke.js` 覆盖隔离 workspace 的 API seed 与清空垃圾箱,不是 filetree UI 多选删除。
- 2026-05-15:已补阶段 4 执行设计并归档为 `design/04-tree-domain/done/4-26-filetree-bulk-delete-selection-smoke-v1.md`,明确一次性用户 / workspace seed 与 Convex filetree UI 多选删除验收矩阵。
- 2026-05-15:已新增 `scripts/task428-filetree-bulk-delete-selection-smoke.js`,真实浏览器使用一次性用户 `mnote.filetree.1778795393112@example.com` 和隔离 workspace `ws_req_1778795393339_3` 创建 3 页面、2 附件、1 mindmap、1 table;在 3001 主入口 filetree 验证 Ctrl/Cmd 多选、Shift 范围、右键已选保持、右键未选切换。
- 2026-05-15`task428` Delete 轮选择 root + child + file asset + mindmap + table,确认文案为 `1 个页面 + 1 个附件 + 1 个思维导图 + 1 个在线表格`,请求为 `tree.node.archive``/api/media/batch delete``DELETE /api/mindmap/{docId}/{mindmapId}``DELETE /api/tables/{tableId}`;父子页面去重后只发送 root archivechild 随子树删除。
- 2026-05-15`task428` Backspace 轮选择 sibling + 第二个 file asset,确认文案为 `1 个页面 + 1 个附件`,请求为 `tree.node.archive``/api/media/batch delete`;两轮均验证目标行无需刷新从 filetree 消失,并用 Convex query 确认对应 document / media / mindmap / table 进入垃圾箱。
- 2026-05-15:阶段 4 新增 Rust SSR 主壳键盘批量删除实现。原因是 `task428` 初次失败暴露 3001 主壳 filetree selection 存在,但 Delete / Backspace 未进入删除处理;已补 `deleteSelectedSidebarFileTreeRows`,支持混合页面、附件、mindmap、table 删除,并补 `sidebar_filetree_delete_keys_support_mixed_doc_and_asset_selection` 回归。
- 2026-05-15 验证:`MNOTE_UI_BASE_URL=http://127.0.0.1:3001 node scripts/task428-filetree-bulk-delete-selection-smoke.js` 通过,结果文件 `tmp/task428-filetree-bulk-delete-selection-smoke/result.json` 记录请求、确认文案与部分失败摘要。
- 2026-05-15`task428` 部分失败轮通过 route interception 强制 `/api/media/batch``asset_p4_failure_1778795530948` 返回 500;页面 `tree_1778795531341_8` 成功进入垃圾箱并从 filetree 消失,失败附件仍保留且 `deleted_at` 为空,UI 弹出 `部分对象删除失败:asset_p4_failure_1778795530948`
- 2026-05-15 阶段 4 结论:阶段 4 子项已完成;剩余更长期问题转入阶段 5-8,包括资源正式 `tree.resource.*` 命令、恢复位置、VSCode Explorer 完整体验与双浏览器 no-refresh。
### 阶段 5:资源级 tree command 收口
- [x] 新增或确认 `tree.resource.archive``tree.resource.restore``tree.resource.purge``tree.resource.rename` 正式命令。
- [x] 将普通附件 `/api/media/batch` / `/api/media/purge` 收束为正式 `tree.resource.*` 兼容 alias。
- [x] 将 mindmap / table delete / restore / purge 收束为正式 `tree.resource.archive / restore / purge` 兼容 alias。
- [x] command event、projection resync、sidebar dataset 均能表达普通附件资源恢复和永久删除。
验收标准:
- 文件树资源删除 / 恢复 / 永久删除不再只依赖 legacy `/api/media/batch``/api/mindmap/*``/api/tables/*` 主路径;旧 URL 只能作为兼容 alias。
- 资源 commandName、确认文案、UI 状态一致。
执行记录:
- 2026-05-15 只读核对:阶段 5 未完成。现有正式资源命令只覆盖 `tree.resource.copy``tree.resource.move``tree.resource.upload``tree.resource.rename``tree.resource.archive``tree.resource.restore``tree.resource.purge` 尚未在 Rust runtime / storage-convex mapping 中形成正式命令面。
- 2026-05-15 代码证据:`wolai-frontend/src/lib/file-tree/resource-command-client.ts` 的 rename / delete / restore 默认仍发送 `/api/media/batch``wolai-frontend/src/app/api/media/batch/route.ts` 中 copy / move 会构造 `tree.resource.copy/move`,但 delete / restore / rename 仍直连 `mediaAssets.patchById`
- 2026-05-15 代码证据:`rust/crates/bridge-runtime/src/lib.rs` 只处理 `tree.resource.copy | tree.resource.move``tree.resource.upload``rust/crates/storage-convex-bridge/src/mapping.rs` 也只映射这三类命令。
- 2026-05-15 代码证据:`rust/crates/mnote-web/src/routes/resource_trash.rs``wolai-frontend/src/components/sidebar/sidebar.tsx` 当前把 file asset、mindmap、table 按类型分流到 `/api/media/batch``/api/mindmap/*``/api/tables/*` 兼容入口;`rust/crates/mnote-web/src/routes/gateway.rs` 已在 `/trash` 页面明示“正式 tree.resource.* 命令仍在后续阶段收口”。
- 2026-05-15 测试边界:已有 `media_trash_routes_delete_restore_purge_and_empty``mindmap_trash_routes_delete_restore_purge_and_empty``table_trash_routes_delete_restore_purge_and_empty` 覆盖 Rust 兼容 route;这不能替代 `tree.resource.archive/restore/purge/rename` command 级回归。
- 2026-05-15:已补阶段 5 执行设计 `design/04-tree-domain/process/4-27-resource-lifecycle-command-cutover-v1.md`,明确本轮先完成普通附件 file assetmindmap / table 暂保持兼容 alias,不冒充正式 cutover 完成。
- 2026-05-15`bridge-runtime` 已新增 `tree.resource.archive/restore/purge/rename` 生命周期 command plan,普通附件分别映射到 `mediaAssets:patchById` / `mediaAssets:purgeById`,并携带 `domainEventPlan``streamDeltaHint``storage-convex-bridge` 已补默认命令映射。
- 2026-05-15`wolai-frontend``/api/media/batch` 已将 `delete/restore/rename` 从直连 `mediaAssets.patchById` 改为 `tree.resource.archive/restore/rename` 兼容 alias`/api/media/purge` 已从直连 `mediaAssets.purgeById` 改为 `tree.resource.purge` 兼容 alias,并记录 Rust bridge artifact。
- 2026-05-15Rust 3000 `/api/media/batch``/api/media/purge` 已改为通过 runtime command 执行普通附件 archive / restore / rename / purge,旧 URL 仅保留为兼容入口;`tree_shell` command event schema 已补资源生命周期事件。
- 2026-05-15 验证:`cargo test -p bridge-runtime tree_resource_lifecycle_plans_cover_file_asset_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` 均通过。
- 2026-05-15 验证:`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` 通过,26 项通过。
- 2026-05-15 剩余:mindmap / table 的 DELETE / restore / purge 仍是兼容 route 直连对应 Convex mutation;本阶段只完成普通附件 file asset 主链,不把 mindmap / table 误标为已完成正式 command cutover。
- 2026-05-15 二次审计补充:mindmap / table 双浏览器 no-refresh 已由阶段 8 验证,但它们仍不是正式 `tree.resource.*` lifecycle;后续应补 `tree.resource.mindmap.*` / `tree.resource.table.*` 或等价命名合同、command log、domain event 与 projection resync 回归。
- 2026-05-15 执行修正:先补 `mnote-web` 路由测试断言 mindmap / table DELETE、restore、purge 响应必须携带 `canonicalCommand=tree.resource.archive/restore/purge` 与对应 `resourceKind`,红灯确认旧 route 仍只返回 legacy mutation 结果。
- 2026-05-15 执行修正:Rust 3000 的 `/api/mindmap/{docId}/{mindmapId}` DELETE / PATCH restore / PATCH purge 已改为通过 `tree.resource.archive / restore / purge` runtime command 执行;`/api/tables/{tableId}` DELETE、`/api/tables/restore``/api/tables/purge` 已改为通过同一 runtime command 执行;旧 URL 保留为兼容 alias。
- 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 项通过;`cargo test --manifest-path rust/Cargo.toml -p mnote-web trash_routes -- --nocapture --test-threads=1` 通过,3 项通过;`cargo test --manifest-path rust/Cargo.toml -p storage-convex-bridge tree_resource_lifecycle_commands_have_convex_mapping -- --nocapture` 通过,1 项通过。
- 2026-05-15 阶段 5 当前边界:`tree.resource.rename` 仍只覆盖普通附件 file assetmindmap rename 继续明确不支持,table rename 待结合 `/api/tables/{tableId}` PATCH 与表格标题语义单独收口。`empty-trash` 仍是 workspace 级批量合同,不伪装成逐资源 `tree.resource.purge`
### 阶段 6:恢复位置与 reveal/focus
- [x] 删除时记录恢复所需的 parent / orderview path / expanded path 由恢复后的父链展开补齐。
- [x] 恢复页面子树后验证原父节点、排序、展开路径和 active/focus 的代码路径与单测。
- [x] 父节点已永久删除时,提供明确 fallback。
验收标准:
- 恢复后对象回到可解释位置,并自动 reveal/select。
- 恢复结果不会悄悄落到根目录而无提示。
执行记录:
- 2026-05-15 代码审查:`wolai-frontend/convex/documents.ts``documents.restore` 会把根恢复节点 `parent_id` 置为 `null`,并只按同一 `deleted_at` 级联恢复子节点;当前没有恢复删除前 parent / sort_order / expanded path 的证据。
- 2026-05-15 对照证据:local-folder Markdown trash 已在 `rust/crates/mnote-web/src/routes/local_folder_source.rs` 记录 `original_relative_path`,恢复时可按原路径回放并处理重名冲突;Convex documents restore 没有同等级“删除前位置”记录。
- 2026-05-15 命令证据:`tree.node.restore` 命令链在 Next route 与 Rust route 中存在,`scripts/task163-local-folder-unified-tree-browser-smoke.js` 也覆盖了 Convex restore / purge 请求仍走 `/api/tree/commands`;但这只是请求级证据,不覆盖恢复位置。
- 2026-05-15 风险结论:这能保证“对象复活”,但不能保证恢复到删除前位置。若父节点仍存在,当前也可能恢复到根;若父节点已永久删除,缺少显式 fallback 选择与 UI 提示。
- 2026-05-15 UI 证据边界:Sidebar / tree shell 有 focusedId、selection、active row 的基础状态;Sidebar 垃圾箱恢复后主要 `refetch/refreshTree`,未找到 `router.push`、reveal、select、focus 恢复对象的端到端 smoke。Rust shell 虽有 `tree.filetree.reveal` postToHost 事件,但未找到 Sidebar / DOM host 的完整落地处理。
- 2026-05-15:已补阶段 6 执行设计 `design/04-tree-domain/process/4-28-trash-restore-location-reveal-focus-v1.md`
- 2026-05-15`wolai-frontend/convex/schema.ts` 为 documents 新增 `restore_parent_id / restore_sort_order``documents.softDelete` 删除子树时记录删除前 `parent_id / sort_order``documents.restore` 恢复时优先回放原父节点与排序,并返回 `restore_location.parent_id / sort_order / fallback_reason`
- 2026-05-15:恢复父节点仍存在或属于本次级联恢复子树时回到原父节点;原父节点已不存在或仍在垃圾箱时 fallback 到根目录,并清空恢复位置快照。
- 2026-05-15`restoreDocumentCommand` 已透传恢复位置;Sidebar 垃圾箱恢复后刷新数据、展开父链、滚动并 focus 恢复行;fallback 时提示“原父页面已不存在,已恢复到根目录”。
- 2026-05-15 验证:`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 项测试通过。
- 2026-05-15 验证:`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` 仍受仓库既有错误影响,不作为本阶段完成条件。
- 2026-05-15 review 修正:发现 `/api/tree/commands restore` 先查 `documents.getMeta`,而 `getMeta` 默认过滤 `deleted_at != null`,会导致垃圾箱页面在进入 restore mutation 前 404;已改为 restore 入口使用 `includeDeleted: true`,并在 route 测试中断言该参数。
- 2026-05-15 review 修正:恢复到原排序位时可能与已有兄弟节点产生重复 `sort_order`;已新增 `buildDocumentRestoreOrderAssignments`,恢复时按目标父级重新编号兄弟节点,补“旧排序位被占用时 restored row 插入并后移兄弟节点”的单测。
- 2026-05-15 验证:`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 项测试通过。
- 2026-05-15 验证:`pnpm exec tsc --noEmit --pretty false 2>&1 | rg "convex/documents.ts\\((16[5-9][0-9]|17[0-9][0-9]|18[0-4][0-9])"` 无输出,restore / softDelete 修改区间无新增 TypeScript 报错;全量 `tsc` 仍受既有无关错误影响。
- 2026-05-15 运行时刷新:执行 `npx convex dev --once --tail-logs disable --env-file ../.env.all --run ping:ping`,把本轮 Convex functions 部署到本地自托管运行时;首次 3000 smoke 在部署前复现旧 runtime 行为:恢复后 `parent_id` 仍为 `null`,部署后恢复位置生效。
- 2026-05-15 浏览器验证:新增并执行 `node scripts/task429-trash-restore-location-reveal-smoke.js` 通过;脚本使用一次性用户 / workspace,在 3000 主入口验证删除子页面后 `tree.node.restore` 返回原父页面与原排序位、兄弟 `sort_order` 后移、当前页面无需刷新重新显示恢复行;同时构造缺失父节点历史数据,验证 fallback 返回 `parent_missing_or_deleted` 并恢复到根目录。结果文件:`tmp/task429-trash-restore-location-reveal-smoke/result.json`
- 2026-05-15 剩余:React Sidebar Drawer 的“恢复后 DOM focus 与 fallback alert”仍缺少 3000 主壳浏览器证据;当前 3000 主壳已切 Rust 垃圾箱入口,React Drawer focus 由代码路径和单测覆盖,后续如恢复 React 入口或补专用 harness 再补截图证据。双浏览器 no-refresh 归入阶段 8。
### 阶段 7VSCode Explorer 体验补齐
- [x] Cut / Paste moveCtrl/Cmd+X 后 Paste 应移动,不是复制或 no-op。
- [x] F2 inline rename:文件树行内编辑,不再只靠 prompt。
- [x] 右键菜单补齐 New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Reveal 的可用性或禁用态。
- [x] DnD 补齐父拖子禁止、拖到自身禁止、readonly 禁止、Alt/Ctrl copy modifier、冲突确认。
- [x] F2 inline rename validation parity:空名、非法名、同级重名、扩展名策略在行内提示并阻断提交。
- [x] 右键菜单增强项:Copy Relative Path。
- [x] 右键菜单增强项:Paste Into action target。
- [x] 右键菜单增强项:New Folder 产品口径明确为“等待 Resource Tree folder 合同,不在页面树里伪造 folder 真相”。
验收标准:
- keyboard 与 context menu 对同一 selection 得到同一 command target。
- 禁用态有可解释条件,不出现点击后无反馈。
执行记录:
- 2026-05-15 代码审查:`wolai-frontend/src/components/sidebar/tree-shell-dom-host.tsx` 已在 filetree 键盘层识别 `F2`、Delete / Backspace、Ctrl/Cmd+C、Ctrl/Cmd+X、Ctrl/Cmd+V,并向 runtime 发 `beginRenameFocused``cutSelection``pasteIntoFocused` 等动作。
- 2026-05-15 浏览器证据边界:`scripts/task163-local-folder-unified-tree-browser-smoke.js` 已在 local-folder 模式覆盖 F2 inline rename、Ctrl/Cmd+X 后 cut decoration、Paste move preflight、确认后移动 Markdown、Alt 内部拖拽复制、外部 File drop、readonly drop 预检失败。
- 2026-05-15 Convex 证据边界:`scripts/task163-local-folder-unified-tree-browser-smoke.js` 也覆盖了 Convex context menu 基础项、copy / cut paste 命令请求、Delete 请求和 DnD indicator;但未覆盖 Convex F2 inline rename、真实 internal drop 执行、父拖子 / 拖自身 / readonly / 冲突确认端到端。
- 2026-05-15 初始缺口结论:上述强证据主要来自 local-folderConvex filetree 下 Cut / Paste move、F2 inline rename、右键 New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Relative Path / Reveal、DnD 禁止父拖子 / 自拖 / readonly / copy modifier 的完整端到端矩阵当时未找到。后续已由 `task430``task431` 补齐阶段 7 必要矩阵。
- 2026-05-15 代码风险:页面树 fallback / iframe host 中仍可见 `window.prompt("重命名页面", ...)` 与 prompt 式重命名路径,不能把“F2 inline rename 已全入口完成”作为当前口径。
- 2026-05-15 代码风险:`wolai-frontend/src/lib/file-tree/clipboard.ts` 的 TS clipboard payload 仍只有 `copy` 类型,Next DOM host / TS 工具层与 Rust srcdoc 路径的 cut 能力不完全一致;Rust shell context menu 的 `Refresh` 当前仍可能走 `window.location.reload()`,不符合 no-refresh 目标。
- 2026-05-15:已补阶段 7 子设计 `design/04-tree-domain/done/4-29-vscode-explorer-cut-paste-move-v1.md`
- 2026-05-15`wolai-frontend` 主 Sidebar 剪贴板 action 已扩展为 `copy | cut``Ctrl/Cmd+X` 写入 cut payload`Ctrl/Cmd+V` 遇到 cut payload 时,Rust family renderer 复用 `tree.filetree.drop.preflight` move plan,页面走 `moveDocumentCommand`,普通附件走 `moveFileTreeResourceAssets`,成功后清空剪贴板。
- 2026-05-15 验证:`pnpm test src/lib/file-tree/clipboard.test.ts src/components/sidebar/sidebar-paste-preflight-source.test.ts` 通过,2 个测试文件、7 项测试通过。
- 2026-05-15 验证:`pnpm exec tsc --noEmit --pretty false 2>&1 | rg "(src/lib/file-tree/clipboard.ts|src/components/sidebar/tree-pane-bindings.ts|src/components/sidebar/sidebar.tsx)"` 无输出,本轮 cut/paste 修改文件无新增 TypeScript 报错;全量 `tsc` 仍受既有无关错误影响。
- 2026-05-15:已补阶段 7 子设计 `design/04-tree-domain/done/4-30-vscode-explorer-f2-inline-rename-v1.md`
- 2026-05-15`tree-shell-dom-host.tsx` filetree row 已新增行内 `tree-rename-input`F2 进入 inline renameEnter / blur 提交,Escape 取消。页面 / index row 提交 `tree.node.rename` 并通过 `onTreeMutation` 回写;asset row 走 `/api/media/batch rename` 并广播 `wolai:assets-changed`
- 2026-05-15 验证:`pnpm test src/components/sidebar/tree-shell-host.test.tsx` 通过,1 个测试文件、8 项测试通过;新增测试覆盖 F2 inline input 与 `tree.node.rename` 提交。
- 2026-05-15:已补阶段 7 子设计 `design/04-tree-domain/done/4-31-vscode-explorer-context-menu-minimum-v1.md`
- 2026-05-15:主 Sidebar 右键菜单已显式补齐 `New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Reveal`。其中 `New File` 复用新建子页面,`Refresh``refreshTree()``Collapse All` 清空展开集合,`Copy Path` 复制页面标题路径,`Reveal` 展开父链并滚动;`New Folder``Paste Into` 以禁用态展示原因,避免伪造未完成的 folder / right-click paste target 合同。
- 2026-05-15 验证:`pnpm test src/components/sidebar/sidebar-context-menu-source.test.ts` 通过,1 个测试文件、1 项测试通过;覆盖菜单项存在、禁用态存在、未引入 `window.location.reload`
- 2026-05-15:已补阶段 7 子设计 `design/04-tree-domain/done/4-32-vscode-explorer-dnd-modifier-guard-v1.md`
- 2026-05-15filetree DnD copy modifier 已从仅 Alt 扩展为 Alt / Ctrl / Meta;同时覆盖 `wolai-frontend` DOM host 与 Rust SSR 主壳 `layout.rs`。父拖子 / 自拖继续由 Rust `tree.filetree.drop.preflight` 校验,不在 UI 层新增第二套祖先判断。
- 2026-05-15 验证:`pnpm test src/components/sidebar/sidebar-dnd-source.test.ts` 通过,1 个测试文件、1 项测试通过。
- 2026-05-15 3000 主入口修正:Rust SSR 主壳补齐文件树 F2 inline rename、Ctrl/Cmd+X/V cut-paste move、右键菜单最低项 `New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Reveal``Refresh` 只发局部刷新事件,不使用 `window.location.reload()``New Folder``Paste Into` 保持禁用态并给出原因。
- 2026-05-15 3000 主入口修正:新增 `POST /api/tree/filetree/drop-preflight`,通过 Rust bridge `tree.filetree.drop.preflight` 返回真实 preflight plan;自拖自身与父拖子返回 400copy modifier plan 保留 `copy: true`
- 2026-05-15 资源 rename 修正:`tree.resource.rename` 的 bridge plan 补 `patch.file_name`Rust `/api/media/batch rename` 在保留 `tree.resource.rename` artifact command 的同时先执行兼容 `mediaAssets:patchById`,避免 3000 主入口附件 F2 后 Convex 文件名不更新。
- 2026-05-15 真实浏览器 smoke:新增并执行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task430-vscode-explorer-stage7-smoke.js` 通过。脚本使用一次性用户 / workspace,验证:
- 页面 F2 inline rename:请求 `/api/tree/commands action=rename`,Convex 标题更新,无需刷新可见。
- 附件 F2 inline rename:请求 `/api/media/batch action=rename`Convex `media_assets.file_name` 更新,无需刷新可见。
- Cut / Paste move`Ctrl/Cmd+X` 后目标页面 `Ctrl/Cmd+V` 触发 `action=move`,子页面 `parent_id` 变为目标父页面。
- 右键菜单最低项:`New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Reveal` 可见,禁用项有 title 原因。
- DnD preflight:自拖自身与父拖子均被拒绝;copy modifier preflight 返回 `copy: true`
- 结果文件:`tmp/task430-vscode-explorer-stage7-smoke/result.json`
- 2026-05-15 阶段 7 中途剩余:`task430` 之后 readonly 与命名冲突确认仍未在主 Sidebar Convex filetree 构造真实 route 矩阵;当时 DnD 总项因 readonly / conflict 尚未勾选完成。后续已由 `task431` 补齐。
- 2026-05-15`bridge-runtime``tree.filetree.drop.preflight` payload / plan 已补 `sourceCapabilities``targetCapabilities``targetChildren``conflictPolicy``requiresConfirmation``conflicts`readonly target/source 会在 preflight 阶段返回 validation error,同名目标会返回 `requiresConfirmation=true`,普通 copy modifier 不被 readonly source move 规则误拒绝。
- 2026-05-15:主 Sidebar `buildFileTreeShellInternalDropPreflightPayload` 已把 row title、target children 与 capability payload 透传到 preflight`handleResourcePaneInternalDrop` 与 cut-paste move 路径在 `requiresConfirmation` 时调用确认,用户取消时不执行后续 move/copy。
- 2026-05-15 验证:`cargo test -p bridge-runtime tree_filetree_drop_preflight -- --nocapture` 通过 4 项;`pnpm test src/lib/file-tree/shell.test.ts src/lib/file-tree/resource-command-client.test.ts src/components/sidebar/sidebar-paste-preflight-source.test.ts src/components/sidebar/sidebar-dnd-source.test.ts` 通过 4 个测试文件、21 项。
- 2026-05-15 真实 route smoke:新增并执行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3001 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task431-vscode-explorer-dnd-readonly-conflict-smoke.js` 通过。脚本使用当前代码新启动的 3001 mnote-web 与 3000 auth cookie,验证 readonly target/status 400、readonly source/status 400、同名目标返回 `requiresConfirmation=true` 与 conflict 明细、copy modifier 保持 `copy=true` 且无误报。结果文件:`tmp/task431-vscode-explorer-dnd-readonly-conflict-smoke/result.json`
- 2026-05-15 注意:本次 `task431` 未直接复用常驻 3000 进程,因为 3000 当时仍运行旧 `mnote-web` 二进制,第一次对 3000 执行脚本复现旧 preflight 放行 readonly 的行为;新代码已在 3001 当前编译进程验证通过,3000 需要重启后才具备同一行为。
- 2026-05-15 二次审计补充:阶段 7 已完成最低可用体验,不等于完整 VSCode Explorer parity。后续仍需补 F2 行内 validation、`Copy Relative Path``Paste Into` 右键 action target、`New Folder` 长期产品口径;这些列为 P1/P2,不阻塞已完成的 cut / paste move、F2 基础行内重命名、菜单最低项与 DnD guard。
- 2026-05-15 执行修正:先补 `tree-shell-host.test.tsx` 红灯测试,断言 F2 行内重命名必须在输入框内阻断空名、非法路径字符、同级重名,且普通附件输入不带扩展名时保留原扩展名。
- 2026-05-15 执行修正:`tree-shell-dom-host.tsx` 已新增 F2 rename 行内错误状态、`aria-invalid`、错误文案、`CSS.escape` fallback、普通附件扩展名保留;非法输入不再发 rename 请求。
- 2026-05-15 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/components/sidebar/tree-shell-host.test.tsx -- --runInBand` 通过,1 个测试文件、10 项通过。
- 2026-05-15 执行修正:`ContextMenu` 新增 `Copy Relative Path`,复用页面标题路径计算但复制不带根斜杠的 workspace-relative 路径;`New Folder` 继续明确禁用,原因是当前页面树没有 folder 对象合同,不能临时伪造第二套 folder 真相。
- 2026-05-15 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/components/sidebar/sidebar-context-menu-source.test.ts -- --runInBand` 通过,1 个测试文件、1 项通过。
- 2026-05-15 执行修正:先补 `sidebar-paste-preflight-source.test.ts` 红灯测试,断言 `ContextMenuProps` 暴露 `onPasteInto`、右键 `Paste Into` 调用 `onPasteInto(node)`,并要求 paste helper 使用 `targetDocumentIdOverride` 进入 Rust paste / drop preflight。
- 2026-05-15 执行修正:主 Sidebar 已抽出 `executeFileTreePaste(targetDocumentIdOverride)``Ctrl/Cmd+V``null` 保留 focused target 规则,右键 `Paste Into` 传当前菜单节点 `node.id` 作为目标页面。空剪贴板时右键入口会提示“剪贴板没有可粘贴的文件树内容”,不再是点击无反馈。
- 2026-05-15 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/components/sidebar/sidebar-paste-preflight-source.test.ts -- --runInBand` 通过,1 个测试文件、3 项通过。
- 2026-05-15 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/components/sidebar/sidebar-context-menu-source.test.ts -- --runInBand` 通过,1 个测试文件、1 项通过。
- 2026-05-15 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/components/sidebar/tree-shell-host.test.tsx -- --runInBand` 通过,1 个测试文件、10 项通过。
- 2026-05-15 验证边界:`pnpm exec eslint src/components/sidebar/sidebar.tsx --quiet``pnpm exec eslint src/components/sidebar/sidebar-paste-preflight-source.test.ts src/components/sidebar/sidebar-context-menu-source.test.ts src/components/sidebar/tree-shell-host.test.tsx --quiet` 无 error;全量 `pnpm lint` 仍被仓库既有 lint 问题阻塞,本轮输出显示 `823 problems (2 errors, 821 warnings)`
### 阶段 8:实时刷新与双浏览器回归
- [x] A 浏览器新建、删除、恢复、彻底删除页面,B 浏览器无需刷新可见更新。
- [x] A 浏览器对普通附件 file asset 执行上传、删除、恢复、彻底删除、清空资源垃圾箱,B 浏览器无需刷新可见更新。
- [x] A 浏览器对 mindmap / table 执行新建、删除、恢复、彻底删除、清空资源垃圾箱,B 浏览器无需刷新可见更新。
- [x] 本地 folder 模式验证 markdown 新建 / 重命名 / 删除 watcher 不触发浏览器 reload;非 md 资源文件 watcher create / rename / delete 已覆盖,trash / restore / purge 单列待补。
- [x] `resync_required` 事件合同防回归:`resync_required` 本身只推进 cursor,不替换数据;后续 snapshot / resync 才替换数据,已补前端 hook 与 Rust stream 合同测试。
验收标准:
- 所有主链操作不依赖浏览器刷新。
- `refreshTree()` 可以作为兜底,但不作为唯一可见性来源。
执行记录:
- 2026-05-15 代码审查:Rust Web `/api/tree/events` 已有 snapshot / delta / resync 路由与单元测试;`scripts/task120-rust-web-tree-integration-smoke.js``scripts/task123-rust-web-tree-live-stream-consumer-smoke.js``scripts/task177-tree-move-archive-live-smoke.js``scripts/task179-tree-create-delete-no-reload-smoke.js` 覆盖了 tree stream、页面 rename / move / archive、单页面 create/delete no-refresh 等部分链路。
- 2026-05-15 代码审查:`wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts` 已接入 EventSource `/api/tree/events` 并将 delta 交给 `applyTreeStreamDelta``scripts/task426-mnote-web-main-no-reload-smoke.js` 覆盖了单浏览器新建页面、新建 mindmap asset、删除页面本地 apply。
- 2026-05-15 代码审查:资源删除与部分 sidebar 操作仍显式调用 `refreshTree()` 或依赖局部 state 移除;这可以作为兜底,但不能证明双浏览器实时同步闭环。
- 2026-05-15 浏览器证据边界:`scripts/task169-mindmap-realtime-smoke.js` 已使用双浏览器上下文覆盖 mindmap realtime 相关场景,但未覆盖文件树中页面、附件、mindmap、table 的删除 / 恢复 / 彻底删除 / 清空垃圾箱全矩阵。
- 2026-05-15 未闭合结论:阶段 8 仍需新增双浏览器 smoke,验证 A 浏览器对页面和三类资源执行 create / archive / restore / purge / empty-trash 后,B 浏览器无需刷新可见更新,并记录事件来源是 tree delta / resync 还是 Convex live query。
- 2026-05-15 补充审查结论:阶段 8 需要显式拆成对象类型 × 生命周期矩阵。页面、附件、mindmap、table 都必须分别覆盖 create / archive / restore / purge / empty-trash,且验收口径是 A 浏览器执行、B 浏览器不刷新即可在 File Tree 与 Trash 工作台看到变化。现有 `task177/task179/task426/task428/task429` 主要是单浏览器 no-refresh 或局部 live probe`task427` 是 API/Convex 级 purge 验证,`task169` 是 mindmap 内容实时稳定性;这些不能替代文件树/垃圾箱双浏览器生命周期矩阵。实现边界上,`useSidebarTreeStream` 可消费 snapshot/delta/resync`useConvexSidebarData` 依赖 Convex live,但 Trash Drawer 仍大量使用 `refreshTree()/refetch()` 作为本地补偿;阶段 8 应记录每个矩阵项实际由 tree delta、tree resync snapshot、Convex live,还是手动 refreshTree 兜底驱动,避免把本地刷新补偿误标为实时闭环。
- 2026-05-15 页面双浏览器 smoke 初次失败暴露根因:`documents.emptyTrashByWorkspace` 只执行 Convex mutation,没有写入 tree command/domain event,导致 B 浏览器 `/trash` 收不到清空事件,必须刷新后才消失。已给 `documents.emptyTrashByWorkspace` command plan 补 `tree.trash.documents.emptied` domain event 与 `resync_required` stream hint,并把 `/api/documents/empty-trash` 改为 artifact-aware 执行;同时补 Convex mutation validator 的 `streamDeltaHint / domainEventHint / domainEventPlan / domainEventPlans` 参数,避免运行时 502。
- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime documents_empty_trash_plan_emits_tree_resync_event -- --nocapture` 通过,断言 `documents.emptyTrashByWorkspace` command plan 携带 `tree.trash.documents.emptied``resync_required``cargo test --manifest-path rust/Cargo.toml -p mnote-web documents_empty_trash_route_executes_workspace_trash_purge -- --nocapture` 通过,断言 empty-trash 返回 `documents.emptyTrashByWorkspace` command log、`tree.trash.documents.emptied` domain event 与 `resync_required` stream delta`pnpm test src/app/api/tree/commands/route.test.ts src/lib/documents/tree-command-client.test.ts` 通过,2 个测试文件、14 项通过。
- 2026-05-15 运行时刷新:执行 `npx convex dev --once --tail-logs disable --env-file ../.env.all --run ping:ping`,把本轮 Convex validator 修改部署到本地自托管运行时。
- 2026-05-15 真实双浏览器 smoke:新增并执行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3001 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js` 通过。脚本使用一次性用户 `mnote.stage8.page.1778804544630@example.com` 与隔离 workspace `tree_1778804545042_7`B1 打开 `/documents/tree_1778804545042_6?workspaceId=tree_1778804545042_7` 观察 File TreeB2 打开 `/trash?workspaceId=tree_1778804545042_7` 观察 Trash 工作台。A 侧依次执行页面 create / archive / restore / archive+purge / create+archive+empty-trashB 侧每一步均无需刷新可见更新,`navigationEvents` 始终为空。事件来源记录: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 初次失败暴露身份与事件两层根因:`tree.resource.archive/restore/purge` 计划中 route 已放入真实 Convex userId,但 `mnote-web` transport 在 legacy `mediaAssets:*` 参数转换时又用 HTTP context actor 覆盖,导致 `mediaAssets.patchById``assertWorkspaceMember` 处返回“无权访问该工作空间”;已修正为优先保留 `args_json.userId`。随后 `mediaAssets.emptyTrashByWorkspace` 只执行 Convex mutation、不写 tree domain event,导致 B 浏览器 `/trash` 不刷新看不到清空;已在 Rust `/api/media/empty-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` 通过,断言资源 lifecycle 参数转换保留真实 Convex userId`cargo test --manifest-path rust/Cargo.toml -p mnote-web media_trash_routes_delete_restore_purge_and_empty -- --nocapture` 通过。
- 2026-05-15 真实双浏览器 smoke:新增并执行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3001 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke.js` 通过。脚本使用一次性用户 `mnote.stage8.file.1778806752763@example.com` 与隔离 workspace `tree_1778806753148_2`B1 打开 `/documents/tree_1778806753148_1?workspaceId=tree_1778806753148_2` 观察 File TreeB2 打开 `/trash?workspaceId=tree_1778806753148_2` 观察 Trash 工作台。A 侧依次执行普通附件 upload / archive / restore / archive+purge / upload+archive+empty-trashB 侧每一步均无需刷新可见更新,执行阶段 `navigationEvents` 为空。事件来源记录:upload 使用 `tree:delta upsert_assets`archive / restore / purge / empty-trash 使用 `tree:resync`;结果文件为 `tmp/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke/result.json`
- 2026-05-15 mindmap/table 双浏览器 smoke 初次实现时发现 table create 只是测试前 seed,不能证明“新建 table 后 B 无刷新出现”;已补 Rust `/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,避免 B 浏览器只能依赖刷新。
- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web trash_routes -- --nocapture --test-threads=1` 通过,覆盖 media / mindmap / table 资源垃圾箱 route`node --check scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js` 通过。
- 2026-05-15 真实双浏览器 smoke:新增并执行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3001 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js` 通过。脚本使用一次性用户 `mnote.stage8.mindtable.1778808180081@example.com` 与隔离 workspace `tree_1778808180480_2`A 侧依次执行 mindmap create / archive / restore / archive+purge、table create / archive / restore / archive+purge、mindmap+table create / archive / empty-trashB 侧 File Tree 与 `/trash` 每一步均无需刷新可见更新,执行阶段 `navigationEvents` 为空。事件来源记录:mindmap create/delete/restore 主要为 `tree:delta resync_required`mindmap purge 为 `tree:resync`table create/delete/restore 主要为 `tree:delta resync_required`table purge / empty-trash 为 `tree:resync`。结果文件为 `tmp/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke/result.json`
- 2026-05-15 local folder 二次审计:`scripts/task163-local-folder-unified-tree-browser-smoke.js` 在 extended 模式覆盖本地 Markdown 新建、删除进入 `.mnote/trash`、restore、purge,也覆盖外部新增 `watcher-added.md` 后页面树可见;但当前本地 folder watcher 主壳在 revision 变化后调用 `window.location.reload()`,因此不能算 no-refresh watcher 闭环。
- 2026-05-15 local folder 二次审计:同一脚本也覆盖 `watcher-asset.txt` 外部新增 / 删除后 filetree 可见 / 消失;但 Rust `LocalFolderWatcherRegistry` 只广播 markdown 事件,当前 filetree 资产可见性主要依赖 `/api/tree/local-folder-watch` revision 轮询后重载,不是非 md 资源事件级 no-refresh。
- 2026-05-15 local folder 二次审计:`LocalFolderWatcherRegistry` 底层理论覆盖 Markdown create / remove / name / data / metadata 事件,但现有 smoke 只实测外部新增 Markdown;外部 Markdown 重命名、删除尚未形成浏览器验收。非 md 资源文件在 filetree 中能作为 `asset` 出现,inline rename 可写入文件系统,但 copy / cut / delete / download 当前仍是禁用态,本地 `delete / restore / purge` command 只覆盖 Markdown 页面。
- 2026-05-15 阶段 8 中途状态:页面 document、普通附件 file asset、mindmap、table 的 Convex 双浏览器生命周期已闭合;本地 folder markdown watcher 当时仍是 reload 型刷新,非 md 本地资源文件 watcher / trash / restore / purge 当时仍未闭合;`resync_required` 事件合同当时还需要防回归测试。后续已由阶段 8/9 继续执行记录闭合。
- 2026-05-15 继续执行审计:local folder no-refresh 的实际刷新来源在 Rust Web 内嵌 tree shell runtime`rust/crates/mnote-web/src/routes/tree.rs` 的 local folder revision polling 命中 `/api/tree/local-folder-watch` 后调用 `window.location.reload()`;同文件的 filetree 右键 `Refresh` 当前也直接 `window.location.reload()`。因此现有能力只能称为 revision polling + reload 型可见性,不是 no-refresh。
- 2026-05-15 继续执行审计:`/api/local-folder/events``LocalFolderWatcherRegistry` 提供 SSE,但 watcher 当前 `is_markdown_path` 过滤为 `.md/.markdown`,只对 Markdown 事件广播;非 md 资源文件变化不会走该 SSE,主要依赖 `/api/tree/local-folder-watch` 的全目录 revision hash。
- 2026-05-15 下一步最小实现口径:先不要扩散到本地非 md trash / restore / purge。优先把 local folder filetree/page tree 的 revision polling 从 `window.location.reload()` 改为“拉取最新 local folder snapshot 并替换当前内存行模型后 `renderTree()`”,并新增回归测试断言 local folder watch 分支不含 `window.location.reload()`,再补浏览器 smoke 记录 `navigationEvents.length === 0`
- 2026-05-15 执行修正:先补 `rust/crates/mnote-web/src/routes/tree.rs` debug/internal tree shell 回归,断言 local folder watch 分支包含 `/api/tree/local-folder-watch``refreshLocalFolderSnapshot`,且不含 `window.location.reload`;随后发现 3000 主入口实际走 `rust/crates/mnote-web/src/ssr/pages/layout.rs` 的主 Sidebar runtime,不能只改 debug tree shell。
- 2026-05-15 执行修正:主 Sidebar runtime 已新增 `startLocalFolderSidebarWatch()`,对 `sourceKind=local_folder` 轮询 `/api/tree/local-folder-watch`revision 变化后 fetch 当前 HTML 并用 `replaceSidebarTreeFromDocument()` 原地替换 `sidebar-tree-root``sidebar-file-tree-root`;启动后立即执行一次 baseline poll,避免外部变更发生在首轮定时轮询前被误当作初始 revision。
- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_polls_local_folder_without_browser_reload -- --nocapture` 通过,断言主 Sidebar runtime 具备 local folder polling / snapshot apply 且不使用 `window.location.reload``cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_tree_shell_does_not_reload_page_for_refresh -- --nocapture` 通过,保留 debug/internal tree shell no-reload 防回归。
- 2026-05-15 真实浏览器 smoke:新增并执行 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3427 node scripts/task435-local-folder-watch-no-reload-smoke.js` 通过。脚本使用临时本地目录,验证 Markdown 外部 create / rename / delete 在 page tree 与 filetree 原地更新;`.txt``.png` 两类非 md 资源外部 create / rename / delete 在 filetree 原地更新;所有步骤 `navigationEventsBefore=0``navigationEventsAfter=0`,结果文件 `tmp/task435-local-folder-watch-no-reload-smoke/result.json`
- 2026-05-15 `resync_required` 合同修正:只读审计确认前端 `applyTreeStreamDelta``op=resync_required` 不替换数据,只推进 cursor;已补 `wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.test.tsx`,断言 `resync_required` delta 保持旧数据、后续 `resync` 才替换数据。已补 `rust/crates/mnote-web/src/routes/stream_support.rs`,断言 matching command/domain event 中的 `streamDelta.op=resync_required` 会作为 delta 透传并推进 cursor。
- 2026-05-15 验证:`cd wolai-frontend && pnpm test src/lib/tree-stream/use-sidebar-tree-stream.test.tsx -- --runInBand` 通过,1 个测试文件、4 项通过;`cargo test --manifest-path rust/Cargo.toml -p mnote-web stream_change_preserves_resync_required_delta_contract -- --nocapture` 通过,1 项通过。
### 阶段 9Local Folder watcher 与本地资源生命周期
- [x] Markdown 外部 watcher 三件套:外部创建 `.md`、外部重命名 `.md`、外部删除 `.md`page tree 与 filetree 均无需手动刷新可见。
- [x] 打开的本地 Markdown 文档在外部修改 / 重命名 / 删除时,能收到明确刷新或冲突提示,不静默覆盖用户编辑。
- [x] 非 md 本地资源文件外部 create / rename / delete 在 filetree 可见,并记录实际机制是 `/api/tree/local-folder-watch` revision polling + HTML snapshot apply,不是 `LocalFolderWatcherRegistry` SSE delta,也不是 browser reload。
- [x] 非 md 本地资源文件 delete / restore / purge 支持本地垃圾箱或明确不支持;若支持,必须记录原路径、冲突策略与 purge 后索引清理。
- [x] 本地 folder watcher 验收脚本必须记录 `navigationEvents` / reload 次数;若发生 `window.location.reload()`,不得标为 no-refresh 完成。
验收标准:
- Markdown watcher 不依赖手动刷新;若内部实现仍需 reload,文档必须标为“reload 型刷新”,不能写成 no-refresh。
- 非 md 资源文件至少覆盖 `.txt/.png/.pdf/.xlsx` 中两类,create / rename / delete 后 filetree 状态与本地文件系统一致。
- 本地资源进入垃圾箱时必须写入 `.mnote/trash` 或等价索引;restore 回原路径,冲突时有可解释 fallback;purge 后原文件、trash 文件、索引均不可再查询。
执行记录:
- 2026-05-15:只读审计确认 `LocalFolderWatcherRegistry` 递归监听 root,但 `is_markdown_path` 只允许 `.md/.markdown`,非 md 直接过滤;`/api/local-folder/events` 主要服务打开的 Markdown session。
- 2026-05-15:只读审计确认主 tree 对 local folder 不接 `/api/tree/events`,而是用 `/api/tree/local-folder-watch` 轮询 revisionrevision 扫描可见条目并 hash path / name / dir / symlink / readonly / size / mtime,非 md 文件也会改变 revision,但当前变化后会触发 `window.location.reload()`
- 2026-05-15:只读审计确认 `task163` 覆盖外部新增 `watcher-added.md` 后 page tree 出现,以及外部新增 / 删除 `watcher-asset.txt` 后 filetree 出现 / 消失;缺少外部 Markdown rename / delete、非 md rename、非 md trash / restore / purge 的写入型浏览器验收。
- 2026-05-15 继续执行审计:`rust/crates/mnote-web/src/local_folder_watcher_registry.rs``should_emit_event_kind` 接受 create/remove/name/data/metadata,但实际发送前仍按 `is_markdown_path` 过滤;`rust/crates/mnote-web/src/routes/local_folder_events.rs` 也会按 `documentId` 限定单个 Markdown 相对路径,因此它更适合打开文档内容刷新,不足以承担完整 filetree 资源 watcher。
- 2026-05-15 继续执行审计:`rust/crates/mnote-web/src/routes/local_folder_source.rs``local_folder_watch_revision_for_root` 会遍历 visible files 并把 path/name/dir/symlink/readonly/size/mtime 纳入 hash,因此它可以发现 Markdown rename/delete 与非 md create/rename/delete;缺口在客户端收到 revision 变化后执行整页 reload,而不是应用 snapshot delta。
- 2026-05-15 执行记录:本轮已把 3000 主 Sidebar local folder watch 从“static / reload 型”改为 revision polling + HTML snapshot apply。`task435` 记录 `navigationEvents=[]`,覆盖 Markdown page tree 三件套、Markdown filetree 三件套、`.txt``.png` 非 md filetree 三件套。
- 2026-05-15 打开态本地 Markdown 修正:新增 `scripts/task436-local-markdown-open-document-external-change-smoke.js`,先红灯确认外部重命名后的打开 session 未进入 `external-change-conflict`,根因是 `/api/page-aggregate/<旧 documentId>` 404 被静默忽略,且 local folder SSE 对 rename/remove 事件按新 `documentId` 过滤后不能可靠触达旧 session。
- 2026-05-15 打开态本地 Markdown 修正:`rust/crates/mnote-web/src/routes/web_shell.rs` 已改为本地 Markdown 外部刷新遇到非 OK aggregate 响应时标记 `external-change-conflict`;对 `eventKind` 包含 `Remove` / `Name` 的 local folder 事件,不再仅按新 `documentId` 过滤,而是让同 root 打开的 session 重查当前 aggregate,由 conflict key 或 404 决定自动同步或冲突提示。
- 2026-05-15 验证:`MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3427 node scripts/task436-local-markdown-open-document-external-change-smoke.js` 通过,覆盖打开文档外部修改自动同步、dirty 文档外部修改冲突提示、打开文档外部重命名冲突提示、打开文档外部删除冲突提示;四步 `navigationEventsBefore=0``navigationEventsAfter=0`,结果文件 `tmp/task436-local-markdown-open-document-external-change-smoke/result.json`
- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture``cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_polls_local_folder_without_browser_reload -- --nocapture``cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_tree_shell_does_not_reload_page_for_refresh -- --nocapture` 均通过。
- 2026-05-15 非 md 本地资源生命周期设计:已在 `design/04-tree-domain/process/4-27-resource-lifecycle-command-cutover-v1.md` 追加 `local_folder file_path resource lifecycle` 子阶段,明确 `tree.resource.archive/restore/purge``resourceKind=local_file``trashEntryId`、trash index、restore 冲突与 purge 幂等策略。
- 2026-05-15 非 md 本地资源生命周期实现:先补 `tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index` 红灯,确认 `local:asset:docs/photo.png` delete 原本掉到 Markdown 删除路径;随后扩展 local folder executor,使 `local:asset:*` / `local:node:*` raw file 通过 `tree.resource.archive/restore/purge` 进入 `.mnote/trash``trash-index.json`restore 回原路径,purge 删除 trash 文件并清理索引。Markdown 页面原有 trash / restore / purge 回归保持不变。
- 2026-05-15 UI 接入:Rust 3000 主壳 filetree delete plan 对 `sourceKind=local_folder && rowKind=asset` 使用 rowId 作为本地资源 identityDelete / Backspace 走 `/api/tree/commands`,不再落到 `/api/media/batch`;补 `sidebar_filetree_delete_keys_support_mixed_doc_and_asset_selection` 字符串级回归。
- 2026-05-15 真实浏览器 smoke:新增并执行 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3427 node scripts/task437-local-folder-asset-trash-lifecycle-smoke.js` 通过。脚本在本地临时 root 下创建 `docs/asset.txt`,主壳 filetree 选中后按 Delete,确认源文件消失、`.mnote/trash/asset.txt` 出现、trash index 包含 `local-file:docs/asset.txt`;随后通过 tree command restore 回原路径,再次 delete 后 purge,确认源文件、trash 文件和 trash index entry 均不存在;全程 `navigationEvents=[]`
- 2026-05-15 阶段 9 结论:Markdown 外部 watcher、打开态 Markdown 外部变更提示、非 md 外部可见性、本地非 md 资源 delete / restore / purge 与 no-reload 记录均已闭合;后续更完整的 `.pdf/.xlsx` 类型矩阵、restore 冲突 UI、Trash 工作台展示 local_file entry 可作为下一轮增强,不阻塞本阶段 checklist。
### 阶段 10Explorer parity 收口项
- [x] F2 inline rename validation:空名、非法名、同级重名、扩展名策略行内提示,并阻断提交。
- [x] Copy Relative PathConvex workspace-relative 路径已接入页面右键菜单;local root-relative 仍归入 local folder 阶段 9 验收。
- [x] Paste Into action target:主 Sidebar 节点右键使用当前菜单节点作为目标;Ctrl/Cmd+V 继续使用 focused target;空白区当前没有独立右键菜单入口,后续若新增必须复用同一 helper。
- [x] New Folder:当前 Convex workspace 页面树不支持 folder 概念,禁用态保留解释;后续若支持,必须等待 Resource Tree folder 合同并走 tree command。
- [x] `resync_required` 合同:前端明确 `resync_required` 只推进 cursor、不替换数据;后续 snapshot / resync 才替换数据。Rust stream 合同测试覆盖 matching command/domain event 的 `resync_required` delta 透传。
验收标准:
- VSCode Explorer 对标只能按“最低可用完成 / parity backlog”分层汇报,不能把禁用态或待补项描述成完整完成。
- 所有右键命令与键盘命令必须共享同一 action target 规则。
- 事件合同测试能复现“只发 resync_required 不发 resync 时 B 端不会更新”的失败模式,并用实现修复后变绿。
执行记录:
- 2026-05-15`use-sidebar-tree-stream.test.tsx` 已补 `resync_required delta 只推进 cursor,后续 resync 才替换数据``stream_support.rs` 已补 `stream_change_preserves_resync_required_delta_contract`。这固定了当前合同口径:`resync_required` 是“需要 resync 的信号”,不是可直接应用的数据 delta。
## 4. 最小浏览器验收脚本建议
测试数据前缀:`TEST-10REVIEW-07-<timestamp>`
必须覆盖:
- 登录 `http://localhost:3000/auth`,使用测试账号快速登录。
- 在 3000 主入口创建父页面、子页面、附件、mindmap、table。
- 文件树多选父页面 + 子页面 + 附件,按 Delete。
- 断言:文件树立即消失,垃圾箱 Drawer 或 `/trash` 立即出现相应条目。
- 恢复父页面,断言子页面恢复规则、位置、focus/reveal。
- 对同一批对象执行彻底删除 / 清空垃圾箱。
- 查询 Convex 或通过 API 断言 purge 后不存在。
- 双浏览器验证不刷新同步。
## 5. 不应误读的口径
- 不应说“04-tree 文件树已完全对标 VSCode Explorer”。更准确的说法是:文件树 renderer、selection、preflight、部分批量删除和垃圾桶基础已经具备;VSCode Explorer 产品闭环仍在收口。
- 不应说“Trash 已完整完成”。更准确的说法是:页面软删 / 恢复 / purge 与 Sidebar Drawer 基础已存在;Rust 主壳、`/trash` 入口、资源级命令、清空后 Convex 同步删除还需要端到端验收。
- 不应说“Resource Tree runtime 已完成”。更准确的说法是:object identity、block-asset relation、file_tree projection 合同已落地;资源树产品面与资源命令闭环待核验。
- 不应说“local folder 已满足 no-refresh”。更准确的说法是:local folder Markdown lifecycle 与非 md 文件基础可见性已有 smoke 覆盖,但当前本地 watch 仍有 `window.location.reload()`,非 md 资源 trash / restore / purge 未收口。
- 不应说“mindmap / table 已正式切到 tree.resource.*”。更准确的说法是:mindmap / table 生命周期已有 compat route 与 no-refresh 证据,正式资源命令合同仍待补。
- 不应把 07-ai 开发建立在“04-tree 删除 / 垃圾箱 / Explorer 能力已经全闭环”的假设上。后续 07-ai 若需要操作页面、附件、mindmap、table,必须优先走统一 tree command 和 projection,不要继续扩散 legacy route。
+5
View File
@@ -0,0 +1,5 @@
---
mnote_id: local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~202.md
title: 新页面
---
# 新页面