# 4-34 Filetree Trash Dual Browser No Refresh v1

> 状态：done
> 创建时间：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-trash，B 浏览器不刷新即可在 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 可见 | ✅ task475 API archive（delete 后进入 `.mnote/trash`，trash-index 记录 `local-md:*`） | ✅ task475 API restore（`.md` 回原路径，trash 文件消失，index 清理） | ✅ task475 API purge（源路径与 trash 路径均不存在，index 清理） | ✅ task474 UI empty-trash（清空页面垃圾箱，confirm dialog → refresh()，无导航） | task475 覆盖 loose Markdown API trash lifecycle；task474 覆盖 empty-trash UI；bundled Markdown 由 Rust 单测覆盖 |
| local folder 非 md 资源 | ✅ task435 外部 create/rename/delete 无 reload | ✅ task437 API archive + task464 UI trash | ✅ task437 API restore + task473 UI restore no-refresh + focus/reveal | ✅ task437 API purge + task464 UI purge | ✅ task474 UI empty-trash（清空资源垃圾箱，confirm dialog → refresh()，无导航） | task435/437/464/473 覆盖；restore focus 缺口已关闭，见 task473；task474 覆盖 empty-trash UI |

## 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-trash；B 浏览器文件树与 `/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 event，Convex `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-trash；B 浏览器文件树与 `/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 task434：mindmap / 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-trash；B 浏览器文件树与 `/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 task435：empty-trash 双浏览器汇总

在一次性 workspace 中执行页面垃圾箱与资源垃圾箱清空：

- A 清空页面垃圾箱后，B `/trash` 页面不刷新变为空。
- A 清空资源垃圾箱后，B `/trash` 页面资源区不刷新变为空。
- Convex query 断言对应对象不可再查询。

### 3.5 task436：local 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`（`startLocalFolderSidebarWatch`，1200ms 间隔）。revision 变化时调用 `refreshLocalFolderSidebarSnapshot()` 重渲染 filetree，**不触发 `window.location.reload()`**。Rust 单元测试 `sidebar_tree_runtime_polls_local_folder_without_browser_reload` 验证了 SSR template 中不含 reload 语句。
- trash workbench 的 local folder restore/purge 使用 `refresh()`（gateway.rs:1040-1060）：`fetch` + `DOMParser` 替换 workbench `innerHTML`，不触发浏览器导航。但 restore/purge 完成后不调用 `refreshLocalFolderSidebarSnapshot()`，filetree 更新依赖下次轮询触发。
- `task163-local-folder-unified-tree-browser-smoke.js` 已覆盖外部新增 Markdown、外部新增 / 删除 `watcher-asset.txt` 的可见性；`task435-local-folder-watch-no-reload-smoke` 已覆盖 Markdown 与非 md 外部 create/rename/delete 无 reload；非 md trash/restore/purge 已由 task437（API）和 task464（UI）覆盖；local folder Markdown 自身 trash lifecycle（archive/restore/purge through tree commands）已由 task475 覆盖（API-only：loose Markdown 的 delete → trash 进入/restore 恢复/purge 清理）。
- `task435-local-folder-watch-no-reload-smoke` 已通过：外部非 md 文件（`.txt`、`.png`）的 create / rename / delete 在 filetree 中无刷新可见更新，全程无 `framenavigated`。（结果：`tmp/task435-local-folder-watch-no-reload-smoke/result.json`）
- `task437-local-folder-asset-trash-lifecycle-smoke` 已通过（API-only）：`tree.resource.archive` / `restore` / `purge` 的 canonicalCommand 与文件系统一致性。（结果：`tmp/task437-local-folder-asset-trash-lifecycle-smoke/result.json`）
- `task464-local-folder-resource-trash-ui-smoke` 已通过（Browser UI）：trash 页中 delete→visible / restore→file restored / purge→file gone 完整 UI 流程。（结果：`tmp/task464-local-folder-resource-trash-ui-smoke/result.json`）
- `task473-local-folder-trash-restore-no-refresh-focus-gap-smoke` 新增并已由 Codex + Reasonix Browser Worker 实跑：验证 trash workbench 的 restore 操作为 fetch 型 `refresh()`（`restoreNavFree.delta=0`，不触发浏览器导航），restore 后 filetree 通过轮询显示恢复行（`fileReappearsInFiletree=true`）。2026-05-21/22 Codex 修复并复跑后，恢复行出现 focus 证据（`data-active="true"`），focus gap 已关闭。（结果：`tmp/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke/result.json`；浏览器证据：`tmp/reasonix-batch-a-browser-task473-2026-05-21/`）
- `task475-local-folder-markdown-trash-lifecycle-smoke` 已通过（API-only）：验证 loose Markdown 页面在 `/api/tree/commands` 的 delete（archive）→ restore → 再次 delete → purge 完整生命周期，文件系统与 `trash-index.json` 一致性。当前实现中 loose Markdown 的 delete/restore/purge 响应不含 `canonicalCommand` 字段（与 `trash_local_raw_file` 不同）。bundled Markdown 未覆盖（bundle 涉及目录整体移动/恢复，由 Rust 单测 `local_tree_command_manages_loose_markdown_like_regular_file` 和 `local_tree_command_delete_page_bundle_directory_uses_page_trash` 覆盖）。（结果：`tmp/task475-local-folder-markdown-trash-lifecycle-smoke/result.json`）
- `task474-local-folder-empty-trash-ui-smoke` 新增：验证 local folder trash workbench 的清空页面垃圾箱（`local-empty-documents`）和清空资源垃圾箱（`local-empty-resources`）按钮操作。点击按钮后 accept confirm dialog，`refresh()` 以 fetch+DOMParser 替换 innerHTML，不触发浏览器导航；断言对应 trash row 消失、`trash-index.json` 对应 entry 清理、trash 文件删除。结果写入 `tmp/task474-local-folder-empty-trash-ui-smoke/result.json`。
- 2026-05-21：Codex 在最新临时 `mnote-web` `http://127.0.0.1:3014` 上复跑 `task474` / `task475` 均通过；Reasonix Browser Worker 输出 `tmp/reasonix-batch-a3-browser-task474-empty-trash-2026-05-21/`，截图显示清空后页面与资源均为 0，两个清空按钮 disabled。

通过后才允许在 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；非 md 本地资源文件 watcher / trash / restore / purge 已由 task435（外部 create/rename/delete）、task437（API trash lifecycle）、task464（trash UI）、task473（restore no-refresh + focus/reveal）分项覆盖；local folder empty-trash UI（md 页面 + 非 md 资源）已由 task474 覆盖；local folder Markdown loose page trash lifecycle 已由 task475（API）覆盖；bundled Markdown 由 Rust 单测覆盖，尚未编写独立 smoke。
- `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 子项必须有 task435/437/464/473/474/475 或等价 smoke 证据；若仍发生 reload，保持未完成 no-refresh 状态。restore focus 已由 task473 关闭，不再视为 blocker。
- 每个 smoke 结果写入 `tmp/task43*-*/result.json`，并在 10-review 中记录命令、对象、实时来源和剩余边界。
- 不再用单浏览器 no-refresh、API purge 成功或 Convex query 成功替代双浏览器 UI no-refresh。
