fix: 修复本地文件夹垃圾箱恢复聚焦

This commit is contained in:
lix-2026
2026-05-21 14:39:38 +08:00
parent b0fa1e53d8
commit 83ad32dd67
17 changed files with 680 additions and 39 deletions
@@ -0,0 +1,48 @@
# Reasonix Batch A2 Worker A04-tree 已完成文档归档审查
Project root: `/mnt/Data1T/mnote`
## 目标
审查 Batch A 当前 04-tree 文档,判断哪些已经有真实 smoke / 测试证据,可以从 `process/` 移到 `done/`。你只做文档状态与归档,不改 Rust / JS 业务代码。
## Ownership
允许修改:
- `design/04-tree-domain/process/4-44-filetree-action-layer-paste-drop-delete-v1.md`
- `design/04-tree-domain/process/4-45-filetree-open-editors-lifecycle-link-checklist-v1.md`
- 如确认完成,可移动到 `design/04-tree-domain/done/`
- `.codex/reasonix-tasks/results/batch-a2-worker-a-doc-archive.md`
只读参考:
- `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
- `design/04-tree-domain/process/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md`
- `tmp/task430-vscode-explorer-stage7-smoke/result.json`
- `tmp/task471-local-folder-bulk-resource-trash-smoke/result.json`
- `tmp/reasonix-batch-a-browser-task430-2026-05-21/result.json`
- `tmp/reasonix-batch-a-browser-task471-2026-05-21/result.json`
## 要求
1. 核对 `4-44` / `4-45` 的 checklist 是否都已有可复核证据。
2. 对仍是已知缺口但不属于该文档完成条件的项,写入“后续边界”,不要把它们当成未完成 blocker。
3. 如果确认完成,移动到 `done/`,并把顶部状态改为 `done`
4. 不移动 `4-28``4-34``4-46`
5. 不提交 git,不回滚任何文件。
## 验收命令
```bash
git diff --name-status
```
## 最终输出
写入 `.codex/reasonix-tasks/results/batch-a2-worker-a-doc-archive.md`,包含:
- 修改/移动的文件
- 归档依据
- 未归档文档及原因
- 风险或需要 Codex 复核的点
@@ -0,0 +1,58 @@
# Reasonix Batch A2 Worker Blocal folder restore 后 FileTree focus/reveal
Project root: `/mnt/Data1T/mnote`
## 目标
修复 `task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js` 记录的缺口:local folder 非 md 资源从 trash workbench restore 后,FileTree 恢复行应有 `data-selected="true"``data-active="true"``data-focused="true"`,且不触发页面 reload。
## Ownership
允许修改:
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- `rust/crates/mnote-web/src/routes/gateway.rs`
- `scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js`
- `design/04-tree-domain/process/4-28-trash-restore-location-reveal-focus-v1.md`
- `design/04-tree-domain/process/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md`
- `.codex/reasonix-tasks/results/batch-a2-worker-b-restore-focus.md`
禁止:
- 不改 delete 默认进入 trash 的语义。
- 不把 browser reload 当成 no-refresh。
- 不引入跨窗口 postMessage 或复杂新服务。
- 不提交 git。
## 推荐实现方向
短路径即可:
1. trash workbench local restore 成功后,把恢复对象的 filetree row-id 写入 `sessionStorage`
- 对非 md local resourcerow-id 是 `local:asset:${original_relative_path}`
- 当前 row HTML 有 `data-trash-entry-id`,通常是 `local-file:${relative_path}`;可在 HTML 中新增 `data-filetree-row-id`,避免前端猜。
2. `refreshLocalFolderSidebarSnapshot()` 渲染后消费这个 pending row-id,选择/聚焦对应 filetree row。
3. 复用现有 `selectSidebarFileTreeRow(row, {})` 或新增小 helper,不要只改 DOM 属性导致 selection store 不一致。
## 验收命令
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web layout -- --test-threads=1
cargo test --manifest-path rust/Cargo.toml -p mnote-web gateway -- --test-threads=1
node --check scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js
```
如本机 3000 可用,运行:
```bash
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js
```
## 最终输出
写入 `.codex/reasonix-tasks/results/batch-a2-worker-b-restore-focus.md`,包含:
- 修改文件
- 实现策略
- 验证命令和结果
- 未完成项/风险
@@ -0,0 +1,48 @@
# Reasonix Batch A2 Worker Ctask473 语义与 4-34/4-46 文档收口
Project root: `/mnt/Data1T/mnote`
## 目标
配合 Batch A2 收口,审查 `task473` 当前 smoke 语义是否能在 restore focus 修复后作为正向回归测试,并更新 `4-34` / `4-46` 中 local folder no-refresh 与 restore focus 的执行记录。
## Ownership
允许修改:
- `scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js`
- `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md`
- `design/04-tree-domain/process/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md`
- `.codex/reasonix-tasks/results/batch-a2-worker-c-smoke-docs.md`
只读参考:
- `design/04-tree-domain/process/4-28-trash-restore-location-reveal-focus-v1.md`
- `tmp/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke/result.json`
- `tmp/task435-local-folder-watch-no-reload-smoke/result.json`
- `tmp/task437-local-folder-asset-trash-lifecycle-smoke/result.json`
- `tmp/task464-local-folder-resource-trash-ui-smoke/result.json`
## 要求
1. 不改 Rust。
2. 如果 `task473` 仍把“无 focus”当作 `ok:true` 的已知缺口,请改为:脚本整体仍允许记录诊断,但完成条件必须要求 `hasFocusEvidence === true`
3. `4-34` 应写实:Convex 双浏览器矩阵已经有 `task432/433/434`local folder 非 md create/rename/delete、resource trash lifecycle、trash UI、restore no-refresh/focus 分别由 task435/437/464/473 覆盖。仍未覆盖的项要明确保留。
4. `4-46` 应追加 Batch A2 执行记录占位,说明本轮目标是关闭 `task473` 的 focus gap。
5. 不提交 git,不回滚任何文件。
## 验收命令
```bash
node --check scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js
git diff --name-status
```
## 最终输出
写入 `.codex/reasonix-tasks/results/batch-a2-worker-c-smoke-docs.md`,包含:
- 修改文件
- smoke 语义变化
- 文档状态变化
- 仍未覆盖项
@@ -0,0 +1,62 @@
# Reasonix Browser Test: Batch A2 task473 Local Folder Restore Focus Closed
项目根目录:`/mnt/Data1T/mnote`
请先阅读并遵守:`/home/lix/.codex/skills/reasonix-browser-tester/SKILL.md`
## 角色
你是浏览器测试员,不是代码修改员。
禁止修改源码、禁止 git commit、禁止回滚文件。只允许创建测试输出目录、临时测试数据、截图和测试结果文件。
## 测试目标
验证 `task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js` 在当前最新 `mnote-web` 上已经关闭 local folder trash restore focus gap
- restore 不触发浏览器 reload / navigation。
- restore 后文件恢复到原路径。
- 回到 filetree 后恢复行重新出现。
- 恢复行最终出现 focus 证据:`data-active="true"``data-selected="true"``data-focused="true"`
## 前置
- 被测入口:`http://127.0.0.1:3012`
- 3012 是 Codex 为本轮验证启动的最新 mnote-web 临时进程。
- 使用真实浏览器或 Playwright。
- 使用全新的 isolated browser context / userDataDir;测试开始前清空 cookies、localStorage、sessionStorage、IndexedDB、Cache Storage。
## 操作
1. 运行:
```bash
cd /mnt/Data1T/mnote
node --check scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js
MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3012 PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js
```
2. 读取 `tmp/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke/result.json`
3. 截图恢复后 filetree 状态,截图中应能看到恢复行或至少 filetree 区域。
4. 记录:
- `ok`
- `restoreNavFree.delta`
- `fileReappearsInFiletree`
- `focusAfterRestore`
- `restoreFocusDebug.focusStatusAfterFiletreeLoad`
## 输出目录
`/mnt/Data1T/mnote/tmp/reasonix-batch-a2-browser-task473-focus-closed-2026-05-21/`
必须输出:
- `result.json`
- `final.md`
- 至少 1 张截图;失败时必须截图。
## 判定
- `PASS`:语法检查和 smoke 实跑 exit 0`result.json.ok=true``restoreNavFree.delta=0`,文件行恢复,且 `focusAfterRestore` 显示至少一个 active/selected/focused 证据为 true。
- `FAIL`:脚本退出非 0,或 no-refresh / 文件恢复 / focus 断言失败。
- `BLOCKED`3012 不可达或浏览器无法启动。
@@ -0,0 +1,112 @@
# Batch A2 Worker A04-tree 文档归档审查报告
> 执行日期:2026-05-22
>
> WorkerReasonix Batch A2 Worker A
>
> 任务:审查 4-44 / 4-45 checklist 完成状态,确认归档到 `done/`
---
## 修改/移动的文件
| 文件 | 操作 | 旧路径 → 新路径 |
|------|------|----------------|
| `4-44-filetree-action-layer-paste-drop-delete-v1.md` | 编辑状态 + 移动 | `design/04-tree-domain/process/``design/04-tree-domain/done/` |
| `4-45-filetree-open-editors-lifecycle-link-checklist-v1.md` | 编辑状态 + 移动 | `design/04-tree-domain/process/``design/04-tree-domain/done/` |
**状态变更**:两文件 `> 状态:process``> 状态:done`
---
## 归档依据
### 4-44 — FileTree Action Layer Paste / Drop / Delete Checklist v1
**Checklist 6 项全部完成(有可复核证据):**
| # | 项 | 证据 |
|---|-----|------|
| 1 | 提炼前端 File Tree action helper | 文中 5.1 节:`recordFileTreeAction` / `recordFileTreeActionStatus` 已合入 |
| 2 | delete 对 local folder 资源统一走 archive tree command,暴露 undo/fallback 占位 | task471 result.json: `data-mnote-filetree-last-action-status: "archived"`trash-index 含两条 |
| 3 | right-click paste 使用菜单目标而非仅 focused row | 文中 5.1 节:`paste-into` 使用菜单触发 row |
| 4 | paste/drop 对 readonly 目标一致阻断提示和 DOM 标记 | 文中 5.1 节:`blockReadonlyFileTreeAction`/`data-mnote-filetree-readonly-blocked` / `data-mnote-filetree-readonly-message` + `alert` 已合入 |
| 5 | external drop 二进制继续上传链路,失败不清空 selection | 文中确认 dnd preflight 复用 `/api/tree/filetree/drop-preflight`task430 result 中 `dndPreflight` 含完整 copyDropPlan |
| 6 | smoke 增加语义断言 | task471 result.json 确认 action 名 `bulk-delete`、状态 `archived`、触发 row 均写入 DOM |
**验收条件 4/5 完成(第 5 项为可选):**
| # | 验收 | 状态 | 证据 |
|---|------|------|------|
| 1 | `cargo test -p mnote-web layout` | ✅ 已通过 | 文中记录 |
| 2 | `cargo test -p mnote-web tree_command_local_folder` | ✅ 已通过 | 文中记录 |
| 3 | `node scripts/task430-vscode-explorer-stage7-smoke.js` | ✅ PASS_WITH_KNOWN_GAPS | `tmp/task430-vscode-explorer-stage7-smoke/result.json` + `tmp/reasonix-batch-a-browser-task430-2026-05-21/` |
| 4 | `node scripts/task471-local-folder-bulk-resource-trash-smoke.js` | ✅ PASS | `tmp/reasonix-batch-a-browser-task471-2026-05-21/result.json` + 截图 |
| 5 | `node scripts/task47*-filetree-action-layer-smoke.js` | ⬜ 可选(如新增) | 未创建,非必须 |
**后续边界(非 blocker,已记录在 4-44 5.1 节与 task430 skipped 中):**
- task430 中 `context-menu-minimum``Paste Into` 菜单项未暴露(主 Sidebar Convex filetree 的 React 上下文菜单限制)
- task430 中 `dnd-readonly-conflict` 的端到端矩阵未覆盖(已有 bridge preflight 单测和 local-folder smoke
- Open Editors section 未实现(4-45 已跟踪)
- FileTree row 未同步 `data-active="true"`UI 尚未同步 active 状态)
### 4-45 — FileTree / Open Editors Lifecycle Link Checklist v1
**Checklist 4 项全部完成(有可复核证据):**
| # | 项 | 证据 |
|---|-----|------|
| 1 | 跑或修复 task430/task471 的可复现前置条件 | 4-45 7.1 节:Worker A 给 task430 添加 workspace bootstrap fallbacktask430 result.phases.workspace-bootstrap.ok: true |
| 2 | 明确哪些 smoke 已过、哪些因环境阻塞 | 4-45 7.1 节与 task430 result.json 的 `skipped` 项详细说明 |
| 3 | 补充 open editor / filetree active row / lifecycle 状态断言 | 4-45 7.1 节:`filetree-active-row``filetree-selected-row``open-editors-section` 均已注入;task430 result.json 含这些断言结果 |
| 4 | 更新 4-44 checklist 执行记录 | 4-44 5.1 节已包含 Codex 独立复跑、Browser Worker 验证等完整执行记录 |
**验收条件全部通过:**
| 验收 | 状态 | 证据 |
|------|------|------|
| `node --check scripts/task430-*` | ✅ | Codex 独立验证:系统退出码 0 |
| `node --check scripts/task471-*` | ✅ | Codex 独立验证:系统退出码 0 |
| `node scripts/task430-*` | ✅ | task430 result.json `ok:true` |
| `node scripts/task471-*` | ✅ | task471 result.json `smoke_ok:true`,所有断言通过 |
**后续边界(非 blocker,已记录在 4-45 7.1 节与 task430 result 中):**
- Open Editors DOM section 尚未实现(MNote 当前已知缺口,不做强制断言,使用 `runOptional` 包装)
- FileTree row 无 `data-active="true"` 标记(UI 尚未同步 active 状态,已记录不报错)
---
## 未归档文档及原因
| 文档 | 保留在 process/ | 原因 |
|------|----------------|------|
| `4-28-trash-restore-location-reveal-focus-v1.md` | ✅ 未移动 | 任务要求:不移动 4-28 |
| `4-34-filetree-trash-dual-browser-no-refresh-v1.md` | ✅ 未移动 | 任务要求:不移动 4-34 |
| `4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md` | ✅ 未移动 | 任务要求:不移动 4-46 |
| `4-38-filetree-resource-lifecycle-open-target-checklist-v1.md` | ✅ 未移动 | 不在本次审查范围 |
---
## 风险或需要 Codex 复核的点
1. **task430 `PASS_WITH_KNOWN_GAPS` 的含义**4-44 的归档条件(源自 `1-8-mvp-post-process-execution-order-v1.md` 第 3 节)原文为"browser smoke 全绿后归档"。当前 `task430` 结论为 `PASS_WITH_KNOWN_GAPS`——F2 rename、cut/paste move、dnd preflight guard 均通过,gap 属于该文档"非目标"范围与产品已知缺口。建议 Codex 确认这个 `PASS_WITH_KNOWN_GAPS` 满足归档条件。
2. **4-44 第 5 项可选 smoke 未创建**`node scripts/task47*-filetree-action-layer-smoke.js` 以"如新增"标注,不是必要条件。现有 task430 和 task471 已覆盖 checklist 的语义断言要求。
3. **4-28 / 4-34 的 Batch A 依赖**:按 `1-8` 执行顺序,Batch A 的 P0 流程中 4-44/4-45 先归档,4-34/4-28 仍需后续 Worker 完成 local folder no-refresh / restore focus 证据后归档。当前保持不变正确。
4. **git 未提交**:本次未执行 `git commit`,所有变更在工作树上,待 Codex 复核后提交。
---
## 验收命令输出预期
```bash
$ git diff --name-status
A design/04-tree-domain/done/4-44-filetree-action-layer-paste-drop-delete-v1.md
A design/04-tree-domain/done/4-45-filetree-open-editors-lifecycle-link-checklist-v1.md
D design/04-tree-domain/process/4-44-filetree-action-layer-paste-drop-delete-v1.md
D design/04-tree-domain/process/4-45-filetree-open-editors-lifecycle-link-checklist-v1.md
```
`A` = git 视为新文件因 move 后路径变更;实际内容无变更除了状态行从 `process``done`
@@ -0,0 +1,41 @@
# Batch A2 Worker Blocal folder restore 后 FileTree focus/reveal — 最终结果
## 结论
Worker B 目标已完成。local folder 非 md 资源从 trash workbench restore 后,FileTree 恢复行最终可稳定出现 `data-active="true"``task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js` 已由 `ok:false` 变为 `ok:true`
## 修改文件
- `rust/crates/mnote-web/src/routes/gateway.rs`
- `rust/crates/mnote-web/src/routes/mindmap_shell.rs`
- `rust/crates/mnote-web/src/routes/web_shell.rs`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- `scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js`
## 实现策略
1. trash workbench restore 后写入 `mnote.pendingLocalFolderRestoreFiletreeRowId`
2. filetree 初始渲染与 `refreshLocalFolderSidebarSnapshot()` 轮询刷新时消费 pending row-id。
3. 为避免 local folder polling 覆盖 focus,采用短时间重放聚焦的方式,稳定后再清理 pending。
4. `task473` 同步改为真实回归 smoke:只有出现 focus 证据才算通过。
## 验证命令和结果
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web layout -- --test-threads=1
cargo test --manifest-path rust/Cargo.toml -p mnote-web gateway -- --test-threads=1
node --check scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js
MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3012 PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js
```
结果:
- `layout` / `gateway` targeted tests 通过。
- `task473` 最终结果:`ok:true`
- `focusAfterRestore.attrs.data-active="true"`
## 未完成项 / 风险
- local folder empty-trash smoke 仍未补齐。
- local folder Markdown 自身 trash lifecycle 仍未单独编写独立 smoke。
- 3000 常驻进程若仍旧,后续 smoke 需确认已重启到最新代码。
@@ -0,0 +1,100 @@
# Batch A2 Worker Ctask473 语义与 4-34/4-46 文档收口 — 执行结果
## 执行人
Reasonix Worker C (Batch A2)
## 环境
| 检查项 | 状态 |
|--------|------|
| 项目根 | `/mnt/Data1T/mnote` |
| Node.js | 可用 |
| `node --check` | 通过,无语法错误 |
| 修改文件数 | 3(见下) |
| 未触及 Rust | ✅ |
| 未提交 git | ✅ |
| 未撤销/覆盖已有改动 | ✅ |
## 修改文件
| 文件 | 改动说明 |
|------|----------|
| `scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js` | `evidence.ok = true``evidence.ok = hasFocusEvidence` |
| `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md` | 5 处更新:验收矩阵非 md 行写实、3.5 审计结论更新、5 边界更新、6 完成标准更新 |
| `design/04-tree-domain/process/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md` | 追加 Batch A2 执行记录 |
## Smoke 语义变化
**改动前**task473 步骤 9 正确记录 `ok: false` 和 focus 缺口,但末尾无条件设置 `evidence.ok = true`,整体结果为 `ok: true`。脚本作为回归测试时无法区分"所有功能正常"和"已知缺口但其他正常"。
**改动后**`evidence.ok = hasFocusEvidence`。当 focus 缺口存在时 `evidence.ok = false`;如果后续实现了 restore focus`selectSidebarFileTreeDocument` 被调用、`data-selected="true"` 出现),`hasFocusEvidence` 变为 `true`,整体通过。各步骤诊断数据照常记录到 `result.json`
这一改变意味着:
- **Batch A2 目标达成**task473 现在是一个**可检测的回归测试** — restore focus 修复后 smoke 自动变绿;回归时自动变红。
- **false-positive 消除**:不再把"已知缺口但其他功能正常"混淆为 `ok: true`
- **诊断不丢**:所有 `steps``focusAfterRestore``navEventsAfterRestore``fileReappearsInFiletree` 仍写入 `result.json`
## 文档状态变化
### 4-34 `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md`
| 位置 | 旧内容 → 新内容 |
|------|----------------|
| §2 验收矩阵(local folder 非 md 行) | `待定/待定/待定/待定``✅ task435/✅ task437+464/✅ task437+473/✅ task437+464`empty-trash 标记 ❌ 未覆盖 |
| §3.5 审计结论(task163 行) | `尚未覆盖 Markdown 重命名/删除、非 md rename、非 md trash/restore/purge``task435 覆盖 Markdown+非 md create/rename/delete;非 md trash/restore/purge 由 task437+464 覆盖;local folder Markdown 自身 trash lifecycle 仍未编写` |
| §5 边界(非 md bullet | `非 md 本地资源文件 watcher/trash/restore/purge 仍单列为待补``已由 task435/437/464/473 分项覆盖;local folder Markdown trash lifecycle 仍未编写` |
| §6 完成标准 | `task436 或等价 smoke 证据``task435/437/464/473 或等价 smoke 证据`;追加 restore focus 缺口不视为完成的约束 |
### 4-46 `design/04-tree-domain/process/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md`
| 位置 | 旧内容 → 新内容 |
|------|----------------|
| §8 执行记录尾 | 追加 Batch A2 Worker C 执行记录,说明本轮目标是关闭 task473 的 focus gap 作为可检测回归测试 |
## 验收命令结果
```bash
$ node --check scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js
SYNTAX_OK
$ git -C /mnt/Data1T/mnote diff --name-status design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md \
design/04-tree-domain/process/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md \
scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js
M design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md
M design/04-tree-domain/process/4-46-batch-a-filetree-action-trash-smoke-execution-checklist-v1.md
M scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js
```
注:`git diff` 中出现的 `D design/04-tree-domain/process/4-44-*.md``D design/04-tree-domain/process/4-45-*.md` 是先前已有更改,非本 worker 引入。
## 仍未覆盖项
### 仍在 open 状态、不视为完成
1. **Restore focus / reveal 完全未实现**task473 step 9 `hasFocusEvidence===false`)。`selectSidebarFileTreeDocument` 从未在 restore 路径调用,且不支持 `local-file:` 行 ID。`4-28` §5.3 已给出短/中/长三条实现路径。
2. **local folder empty-trash smoke 未编写** — 验收矩阵中标记 ❌。
3. **local folder Markdown 自身的 trash lifecycle**(通过 tree command archive/restore/purge `.md` 文件)**未编写独立 smoke**。
### local folder 非 md 资源覆盖率(本轮已写实的覆盖)
| 子项 | 覆盖 Smoke | 状态 |
|------|-----------|------|
| 外部 create/rename/deleteno-reload | task435 | ✅ |
| API archivedelete → trash | task437 | ✅ |
| API restoretrash → 原路径) | task437 | ✅ |
| API purge(从 trash 彻底删除) | task437 | ✅ |
| Trash UI delete → 可见 | task464 | ✅ |
| Trash UI restore → 文件恢复 | task464 | ✅ |
| Trash UI purge → 文件消失 | task464 | ✅ |
| Trash workbench restore no-refreshfetch 型,无导航) | task473 | ✅ |
| Restore 后 filetree 行重现(轮询 1200ms | task473 | ✅ |
| Restore 后 filetree focus/reveal | task473 | ❌ **open** |
| empty-trash smoke | — | ❌ **open** |
## 风险评估
- 无 Rust 修改,回归风险低。
- `task473` smoke 语义从"永远 ok:true"变为"有 focus 才 ok:true",可能影响依赖其 `ok` 字段的外部脚本。但 task473 的消费者只有 Codex 复核流程(`4-46` §5),该流程应优先查看 `result.json` 全量字段而非仅 `ok`
- 4-44 / 4-45 文件缺失(非本 worker 引入)可能导致 Codex 复核时误判为越权删除;已在结果中说明。
@@ -1,6 +1,6 @@
# 4-28 Trash Restore Location Reveal Focus v1
> 状态:process
> 状态:done
> 创建时间:2026-05-15
> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环
> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 6
@@ -23,6 +23,7 @@
- `/api/tree/commands restore` 读取页面 meta 时允许包含已删除页面,避免垃圾箱页面在进入 restore mutation 前被 `getMeta` 过滤成 404。
- `restoreDocumentCommand` 透传恢复位置结果。
- Sidebar 垃圾箱恢复后刷新数据、展开恢复父链、滚动并 focus 恢复行;发生 fallback 时提示“原父页面已不存在,已恢复到根目录”。
- local folder 非 md 资源 restore 通过 `mnote.pendingLocalFolderRestoreFiletreeRowId` 记录恢复目标,filetree 在加载与轮询刷新时消费该 pending row-id 并聚焦恢复行。
## 3. 设计口径
@@ -76,36 +77,35 @@ pnpm exec tsc --noEmit --pretty false 2>&1 | rg "convex/documents.ts\\((160|161|
结果:阶段 6 修改附近无新增 TypeScript 报错。全量 `tsc` 仍受仓库既有错误影响,未作为本阶段完成条件。
## 5. 待补(Worker C 2026-05-21 追加 local folder restore focus 缺口)
## 5. 2026-05-21 local folder restore focus 收口
### 5.1 Convex 页面(已有部分证据)
- React Sidebar Drawer 的恢复后 DOM focus 仍需在 React 入口可稳定渲染页面树时补浏览器证据;当前 3000 主壳 smoke(`task429`)已覆盖"恢复后无需刷新可见 / reveal"fallback 提示仍由 React Drawer 代码路径负责。
- 双浏览器 no-refresh:A 恢复页面后,B 不刷新即可看到位置变化;该项归入阶段 8 的实时刷新矩阵。
- 已知源码问题:`selectSidebarFileTreeDocument` 存在且工作(create / navigate 路径调用),restore 路径不调用
- 已知源码问题:`selectSidebarFileTreeDocument` 存在且工作(create / navigate 路径调用),restore 路径仍未直接调用,但 local folder 已通过 pending row-id 消费实现恢复行 focus
### 5.2 Local folder 非 md 资源 restore focus新发现缺口Worker C 2026-05-21 审计
### 5.2 Local folder 非 md 资源 restore focus已完成Worker C 2026-05-21/22 复核
当前状态:**完全未实现**。审计证据及源码对照:
当前状态:**已完成**。实现通过 pending row-id 传递与 filetree 轮询消费完成,审计证据及源码对照如下
| 检查项 | 状态 | 证据来源 |
|--------|------|----------|
| `selectSidebarFileTreeDocument` 是否从 restore 路径调用 | ❌ 否 | `layout.rs:1274,1296,1333,9278`(仅 create/navigate/activate 路径调用) |
| restore 后 filetree 行是否有 `data-active` / `data-selected` | ❌ 无 | `task473` gap smoke 结果:`data-active="false"``data-selected="false"` |
| restore 后是否 `scrollIntoView``focus()` | ❌ 无 | 同上 |
| `selectSidebarFileTreeDocument` 是否从 restore 路径直接调用 | ❌ 否 | 采用 `pendingLocalFolderRestoreFiletreeRowId` + filetree 轮询消费,而不是 restore handler 直调 |
| restore 后 filetree 行是否有 `data-active` / `data-selected` | ✅ 有 | `task473` 通过结果:`data-active="true"` |
| restore 后是否 `scrollIntoView``focus()` | ✅ 有 | `applyPendingLocalFolderRestoreFocusOnce` 选中并滚动恢复行 |
| trash workbench 的 restore 后 filetree 同步机制 | 轮询 1200ms | `layout.rs:1991-2004` (`startLocalFolderSidebarWatch`) |
| trash workbench 的 restore 后是否调用 `refreshLocalFolderSidebarSnapshot` | ❌ 否,只调 `refresh()`(自身 workbench | `gateway.rs:1064-1065` |
| `selectSidebarFileTreeDocument` 是否支持 `local-file:` row-id | ❌ 否,只查 `doc:` | `layout.rs:4138` |
| trash workbench 的 restore 后是否调用 `refreshLocalFolderSidebarSnapshot` | ❌ 否,只调 `refresh()`(自身 workbench | `gateway.rs:1064-1065`,但 filetree 轮询可消费 pending focus |
| `selectSidebarFileTreeDocument` 是否支持 `local-file:` row-id | ❌ 否 | 目前 focus 由 `selectSidebarFileTreeRowById` `local:asset:` row-id 完成 |
### 5.3 下一步建议(供 Codex 参考,不在此阶段修改 Rust
### 5.3 已采用方案(供后续复核
1. **短路径(仅 focus 现有行)**trash workbench inline script 的 restore `.then()`设置 `window.__mnoteRestoredDocumentId = entryId`;下一轮 filetree poll 检测此值并 focus 对应行
2. **中路径**`gateway.rs` trash workbench script 中 restore 成功后也调用 `refreshLocalFolderSidebarSnapshot()` 并传入 focus 参数
3. **长路径**:扩展 `selectSidebarFileTreeDocument` 支持 `local-file:` 行 IDrestore 完成后通过 `CustomEvent` 跨 trash 页 → 主页面通信
4. **smoke 回归**`task473` 已记录当前缺口。后续实现后复跑 `task473` 应观察到 `data-selected="true"``data-active="true"` 出现。
1. **短路径**trash workbench inline script 的 restore `.then()`写入 `mnote.pendingLocalFolderRestoreFiletreeRowId`
2. **filetree 消费**`refreshLocalFolderSidebarSnapshot()` 与初始 render 共享 `schedulePendingLocalFolderRestoreFocus()`,在 filetree 加载/轮询后聚焦恢复行
3. **smoke 回归**`task473` 已从已知缺口回归改为可检测正向 smoke,最终应观察到 `data-active="true"` 或其他 focus 证据出现
### 5.4 2026-05-21 浏览器复核
### 5.4 2026-05-21/22 浏览器复核
- Codex 独立复跑 `node --check scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js``node scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js``ok:true``restoreNavFree.delta=0``fileReappearsInFiletree=true`
- Reasonix Browser Worker 输出目录:`tmp/reasonix-batch-a-browser-task473-2026-05-21/`,结论 `PASS_WITH_KNOWN_GAP`
- 复核结论:local folder 非 md 资源 restore 后已证明 no-refresh行恢复focus/reveal 仍未实现,不能归档为完成
- Codex 独立复跑 `node --check scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3012 node scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js``ok:true``restoreNavFree.delta=0``fileReappearsInFiletree=true``focusAfterRestore.attrs.data-active="true"`
- Reasonix Browser Worker 输出目录:`tmp/reasonix-batch-a-browser-task473-2026-05-21/`初始结论 `PASS_WITH_KNOWN_GAP`;本轮修复后,Codex 本地 smoke 已验证 gap 关闭
- 复核结论:local folder 非 md 资源 restore 后 no-refresh行恢复focus/reveal 均已完成,可归档到 `done/`
@@ -1,6 +1,6 @@
# 4-44 FileTree Action Layer Paste / Drop / Delete Checklist v1
> 状态:process
> 状态:done
>
> 日期:2026-05-20
>
@@ -1,6 +1,6 @@
# 4-45 FileTree / Open Editors Lifecycle Link Checklist v1
> 状态:process
> 状态:done
>
> 日期:2026-05-21
>
@@ -20,7 +20,7 @@
| 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 来源 |
| 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 | ❌ 未覆盖(empty-trash smoke 未编写) | task435/437/464/473 覆盖;restore focus 缺口已关闭,见 task473 |
## 3. 分批执行
@@ -95,11 +95,11 @@
- `/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` 的可见性;尚未覆盖外部 Markdown 重命名 / 删除、非 md rename、非 md trash / restore / purge。
- `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 已由 task437API)和 task464UI)覆盖;local folder Markdown 自身 trash lifecyclearchive/restore/purge through tree commands)仍未编写独立 smoke
- `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`)。同时记录 restore 后无 focus/active/selected 属性(`data-active="false"``data-selected="false"`、无 `data-focused`)——本缺口已明确记录。(结果:`tmp/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke/result.json`;浏览器证据:`tmp/reasonix-batch-a-browser-task473-2026-05-21/`
- `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/`
通过后才允许在 10-review 中把 local folder 阶段 8 子项勾选;如果仍发生 reload,只能标为“reload 型刷新可见”,不能标为 no-refresh。
@@ -116,12 +116,12 @@
- 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 双浏览器矩阵
- local folder Markdown watcher 只有部分历史覆盖,且主 tree filetree 当前是 revision polling + reload;非 md 本地资源文件 watcher / trash / restore / purge 已由 task435(外部 create/rename/delete)、task437API trash lifecycle)、task464trash UI)、task473restore no-refresh + focus/reveal)分项覆盖;local folder Markdown trash lifecycle 仍未编写独立 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 子项必须有 task436 或等价 smoke 证据;若仍发生 reload,保持未完成 no-refresh 状态。
- local folder 子项必须有 task435/437/464/473 或等价 smoke 证据;若仍发生 reload,保持未完成 no-refresh 状态。restore focus 已由 task473 关闭,不再视为 blocker。
- 每个 smoke 结果写入 `tmp/task43*-*/result.json`,并在 10-review 中记录命令、对象、实时来源和剩余边界。
- 不再用单浏览器 no-refresh、API purge 成功或 Convex query 成功替代双浏览器 UI no-refresh。
@@ -19,7 +19,7 @@
- `design/04-tree-domain/process/4-45-filetree-open-editors-lifecycle-link-checklist-v1.md`
- `design/04-tree-domain/process/4-44-filetree-action-layer-paste-drop-delete-v1.md`
- `design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md`
- `design/04-tree-domain/process/4-28-trash-restore-location-reveal-focus-v1.md`
- `design/04-tree-domain/done/4-28-trash-restore-location-reveal-focus-v1.md`
## 3. 不做事项
@@ -132,3 +132,5 @@ Codex 必须复核浏览器测试结果;不能只相信 worker 文字总结。
- `task471` 输出 `tmp/reasonix-batch-a-browser-task471-2026-05-21/`,结论 `PASS`
- `task473` 输出 `tmp/reasonix-batch-a-browser-task473-2026-05-21/`,结论 `PASS_WITH_KNOWN_GAP`
- 2026-05-21Codex 复核 Reasonix 截图和 JSONtask471 的多选删除截图与 DOM / trash index 断言一致;task430/task473 截图只作为浏览器可打开佐证,最终状态以 smoke `result.json` 为准。
- 2026-05-28Batch A2 / Worker C — 修改 task473 完成条件为要求 `hasFocusEvidence===true`,使 focus gap 从"记录缺口但 ok:true"变为"记录缺口且 ok:false(直至 focus 修复)";更新 4-34 覆盖矩阵将非 md 资源行从"待定"写实为 task435/437/464/473 分项覆盖并标注 focus gap open;更新 4-34 审计结论、边界描述和完成标准以反映实际 smoke 覆盖;追加本执行记录。本轮目标:关闭 task473 的 focus gap 作为可检测的回归测试。
- 2026-05-21/22Codex 接手 Batch A2 / Worker B 卡住项,修复 local folder restore 后 filetree focus/revealtrash workbench restore 写入 pending row-idfiletree 初始加载与 polling 刷新通过 `schedulePendingLocalFolderRestoreFocus()` 重放聚焦;`task473` 在临时 3012 新进程上实跑通过,`focusAfterRestore.attrs.data-active="true"``4-28` 已满足归档条件并移动到 `done/``4-34` 仍保留在 `process/`,因为 local folder empty-trash / Markdown trash lifecycle 仍未完整覆盖。
+34 -5
View File
@@ -60,6 +60,7 @@ pub(crate) struct RootEntryQuery {
workspace_id: Option<String>,
source_kind: Option<String>,
root_uri: Option<String>,
restore_focus_row_id: Option<String>,
}
pub async fn gateway_health(State(state): State<AppState>) -> Response {
@@ -298,8 +299,16 @@ pub async fn root_entry(
);
let sidebar_tree_html =
render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?;
let file_tree_html =
render_local_file_tree_html(root_uri, selected_active_page_id.as_deref())?;
let restore_focus_row_id = query
.restore_focus_row_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let file_tree_html = render_local_file_tree_html(
root_uri,
selected_active_page_id.as_deref(),
restore_focus_row_id,
)?;
(
workspace_id,
workspace_projection,
@@ -353,7 +362,7 @@ pub async fn root_entry(
let sidebar_tree_html =
render_local_sidebar_tree_html(&root_uri, selected_active_page_id.as_deref())?;
let file_tree_html =
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref())?;
render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?;
(
workspace_id,
workspace_projection,
@@ -567,7 +576,7 @@ pub async fn trash_entry(
let workspace_id =
crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?;
let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_html(root_uri, None).unwrap_or_default();
let file_tree_html = render_local_file_tree_html(root_uri, None, None).unwrap_or_default();
let workspace_projection = build_workspace_shell_projection(
&json!({
"active_workspace_id": workspace_id,
@@ -944,8 +953,15 @@ fn render_local_trash_workbench_html(
let original = escape_html(&entry.original_relative_path);
let trash_path = escape_html(&entry.trash_relative_path);
let kind = escape_html(&entry.resource_kind);
let filetree_row_id = if entry.resource_kind == "markdown"
|| entry.resource_kind == "markdown_bundle"
{
format!("doc:{}", entry.document_id)
} else {
format!("local:asset:{}", entry.original_relative_path)
};
let row = format!(
r#"<article class="mnote-trash-row" data-trash-row="local" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}">
r#"<article class="mnote-trash-row" data-trash-row="local" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-filetree-row-id="{filetree_row_id}">
<div class="mnote-trash-row-main">
<span class="mnote-trash-kind">{kind}</span>
<span class="mnote-trash-title">{original}</span>
@@ -958,6 +974,7 @@ fn render_local_trash_workbench_html(
</article>"#,
entry_id = escape_html(entry_id),
kind = kind,
filetree_row_id = escape_html(&filetree_row_id),
original = original,
trash_path = trash_path,
deleted_at = escape_html(&deleted_at),
@@ -1062,6 +1079,16 @@ fn render_local_trash_workbench_html(
workspaceId: workspaceId,
documentId: entryId
}}).then(function() {{
if (action === 'local-restore') {{
var row = button.closest('[data-trash-row="local"]');
var filetreeRowId = row ? (row.getAttribute('data-filetree-row-id') || '') : '';
if (filetreeRowId && window.localStorage) {{
window.localStorage.setItem('mnote.pendingLocalFolderRestoreFiletreeRowId', filetreeRowId);
}}
if (filetreeRowId) {{
window.name = 'mnote.pendingLocalFolderRestoreFiletreeRowId=' + encodeURIComponent(filetreeRowId);
}}
}}
return refresh();
}}).then(function() {{
setStatus(action === 'local-restore' ? '已恢复项目' : '已彻底删除项目', false);
@@ -2223,6 +2250,8 @@ mod tests {
assert!(html.contains("Deleted"));
assert!(html.contains(r#"data-trash-action="local-restore""#));
assert!(html.contains(r#"data-trash-action="local-purge""#));
assert!(html.contains(r#"data-filetree-row-id="doc:local-md:Deleted~2FDeleted.md""#));
assert!(html.contains("mnote.pendingLocalFolderRestoreFiletreeRowId"));
assert!(html.contains(r#"data-trash-action="local-empty-documents""#));
assert!(html.contains(r#"data-trash-action="local-empty-resources" disabled"#));
@@ -58,7 +58,7 @@ pub async fn mindmap_object_shell(
let sidebar_tree_html =
render_local_sidebar_tree_html(root_uri, Some(&doc_id)).unwrap_or_default();
let file_tree_html =
render_local_file_tree_html(root_uri, Some(&doc_id)).unwrap_or_default();
render_local_file_tree_html(root_uri, Some(&doc_id), None).unwrap_or_default();
let workspace_projection = load_workspace_shell_projection(
state.config(),
&context,
@@ -125,7 +125,7 @@ pub async fn document_page_shell(
let root_uri = query.root_uri.as_deref().unwrap_or_default();
(
render_local_sidebar_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
render_local_file_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
render_local_file_tree_html(root_uri, Some(&document_id), None).unwrap_or_default(),
)
} else {
(
@@ -4446,9 +4446,10 @@ pub(crate) fn render_local_sidebar_tree_html(
pub(crate) fn render_local_file_tree_html(
root_uri: &str,
active_document_id: Option<&str>,
active_row_id: Option<&str>,
) -> Result<String, WebError> {
let snapshot = load_local_folder_file_tree_snapshot(root_uri)?;
let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id, None);
let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id);
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
rows,
}))
@@ -127,6 +127,8 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function currentFileTreeActiveRowId() {
var explicitRowId = new URL(window.location.href).searchParams.get('restoreFocusRowId') || '';
if (explicitRowId) return explicitRowId;
var mindmapMatch = window.location.pathname.match(/^\/mindmap\/([^\/]+)\/([^\/]+)/);
if (mindmapMatch) return 'asset:' + decodeURIComponent(mindmapMatch[2]);
var documentId = currentDocumentId();
@@ -1983,6 +1985,7 @@ const SIDEBAR_TREE_JS: &str = r##"
var renderedFile = filePayload ? renderFileProjection(filePayload.result || filePayload) : false;
if (!renderedPage && !renderedFile) return false;
syncSidebarFileTreeSelection();
schedulePendingLocalFolderRestoreFocus();
restoreSidebarTreeTab();
document.documentElement.setAttribute('data-mnote-local-folder-watch-applied', 'projection');
return true;
@@ -4159,6 +4162,86 @@ const SIDEBAR_TREE_JS: &str = r##"
return true;
}
function selectSidebarFileTreeRowById(rowId, options) {
var id = String(rowId || '').trim();
if (!id) return false;
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(id) + '"]');
if (!(row instanceof HTMLElement)) return false;
selectSidebarFileTreeRow(row, { ctrlKey: false, metaKey: false, shiftKey: false });
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach(function(activeRow) {
if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'false');
});
row.setAttribute('data-active', 'true');
if (options && options.scrollIntoView !== false) {
try {
row.scrollIntoView({ block: 'nearest' });
} catch (_) {
row.scrollIntoView();
}
}
return true;
}
function pendingLocalFolderRestoreRowId() {
if (window.__mnotePendingLocalFolderRestoreFiletreeRowId) {
return String(window.__mnotePendingLocalFolderRestoreFiletreeRowId || '').trim();
}
if (!window.localStorage) return '';
var rowId = '';
try {
rowId = String(window.localStorage.getItem('mnote.pendingLocalFolderRestoreFiletreeRowId') || '').trim();
} catch (_) {
rowId = '';
}
if (!rowId && typeof window.name === 'string' && window.name.indexOf('mnote.pendingLocalFolderRestoreFiletreeRowId=') === 0) {
rowId = decodeURIComponent(window.name.slice('mnote.pendingLocalFolderRestoreFiletreeRowId='.length));
}
if (rowId) window.__mnotePendingLocalFolderRestoreFiletreeRowId = rowId;
return rowId;
}
function clearPendingLocalFolderRestoreRowId() {
window.__mnotePendingLocalFolderRestoreFiletreeRowId = '';
try {
window.localStorage.removeItem('mnote.pendingLocalFolderRestoreFiletreeRowId');
} catch (_) {}
if (typeof window.name === 'string' && window.name.indexOf('mnote.pendingLocalFolderRestoreFiletreeRowId=') === 0) {
window.name = '';
}
}
function applyPendingLocalFolderRestoreFocusOnce() {
var rowId = pendingLocalFolderRestoreRowId();
if (!rowId) return false;
document.documentElement.setAttribute('data-mnote-local-folder-restore-pending-row-id', rowId);
if (!selectSidebarFileTreeRowById(rowId, { scrollIntoView: true })) {
document.documentElement.setAttribute('data-mnote-local-folder-restore-focus-status', 'row-missing');
return false;
}
document.documentElement.setAttribute('data-mnote-local-folder-restore-focused-row-id', rowId);
document.documentElement.setAttribute('data-mnote-local-folder-restore-focus-status', 'focused');
return true;
}
function schedulePendingLocalFolderRestoreFocus() {
if (!pendingLocalFolderRestoreRowId()) return false;
if (window.__mnotePendingLocalFolderRestoreFocusTimer) return false;
var attempts = 0;
var focused = false;
focused = applyPendingLocalFolderRestoreFocusOnce() || focused;
window.__mnotePendingLocalFolderRestoreFocusTimer = window.setInterval(function() {
attempts += 1;
focused = applyPendingLocalFolderRestoreFocusOnce() || focused;
if (attempts >= 20) {
if (focused) clearPendingLocalFolderRestoreRowId();
else document.documentElement.setAttribute('data-mnote-local-folder-restore-focus-status', 'timeout');
window.clearInterval(window.__mnotePendingLocalFolderRestoreFocusTimer);
window.__mnotePendingLocalFolderRestoreFocusTimer = 0;
}
}, 250);
return focused;
}
function selectedSidebarFileTreeRowIdsForDrag(row) {
var rowId = row instanceof HTMLElement ? row.getAttribute('data-row-id') || '' : '';
if (rowId && sidebarFileTreeSelection.selectedRowIds.has(rowId)) {
@@ -9273,6 +9356,7 @@ const SIDEBAR_TREE_JS: &str = r##"
sidebarFileTreeSelection.focusedRowId = rowId;
});
syncSidebarFileTreeSelection();
schedulePendingLocalFolderRestoreFocus();
window.addEventListener('mnote:primary-document-activated', function(event) {
var detail = event && event.detail ? event.detail : {};
selectSidebarFileTreeDocument(detail.documentId, { scrollIntoView: false });
@@ -9910,6 +9994,13 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("wolai:local-assets-changed"));
}
#[test]
fn sidebar_tree_runtime_focuses_restored_local_folder_row() {
assert!(SIDEBAR_TREE_JS.contains("applyPendingLocalFolderRestoreFocusOnce"));
assert!(SIDEBAR_TREE_JS.contains("mnote.pendingLocalFolderRestoreFiletreeRowId"));
assert!(SIDEBAR_TREE_JS.contains("data-mnote-local-folder-restore-focused-row-id"));
}
#[test]
fn sidebar_runtime_routes_local_mindmap_and_office_assets() {
assert!(SIDEBAR_TREE_JS.contains("withLocalMindmapSourceParams"));
@@ -100,6 +100,12 @@ function treeUrl(root, mode) {
return url.toString();
}
function treeUrlWithRestoreFocus(root, rowId) {
const url = new URL(treeUrl(root, "filetree"));
url.searchParams.set("restoreFocusRowId", rowId);
return url.toString();
}
/**
* 构建 trash URL
*/
@@ -184,6 +190,24 @@ function checkFocusEvidence(page, rowId) {
}, rowId);
}
async function waitForFocusEvidence(page, rowId) {
const deadline = Date.now() + UI_TIMEOUT_MS;
let last = null;
while (Date.now() < deadline) {
last = await checkFocusEvidence(page, rowId);
const attrs = last?.attrs || {};
const hasFocusEvidence = last?.found && (
attrs["data-active"] === "true"
|| attrs["data-selected"] === "true"
|| attrs["data-focused"] === "true"
|| /\bis-active\b|\bis-selected\b|\bis-focused\b/.test(String(attrs.class || ""))
);
if (hasFocusEvidence) return last;
await page.waitForTimeout(250);
}
return last || checkFocusEvidence(page, rowId);
}
async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `mnote-${TASK}-`));
const rootUri = fileUrl(root);
@@ -228,6 +252,7 @@ async function main() {
assetId,
steps: [],
focusAfterRestore: null,
restoreFocusDebug: {},
navEventsAfterRestore: [],
restoreNavFree: null,
fileReappearsInFiletree: null,
@@ -236,7 +261,7 @@ async function main() {
try {
// === 步骤1:登录并打开 filetree 页 ===
await quickLogin(page);
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.goto(treeUrlWithRestoreFocus(root, assetRowIdStr), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForFileTreeRoot(page);
// 等待初始文件加载
await waitForFileTreeRow(page, "local:markdown:README.md");
@@ -279,6 +304,10 @@ async function main() {
const navAfterRestore = navigationEvents.length;
const restoreNavDelta = navAfterRestore - navBeforeRestore;
evidence.restoreFocusDebug.pendingAfterRestore = await page.evaluate(() => (
window.localStorage ? window.localStorage.getItem("mnote.pendingLocalFolderRestoreFiletreeRowId") : null
)).catch(() => null);
evidence.restoreFocusDebug.windowNameAfterRestore = await page.evaluate(() => window.name).catch(() => null);
evidence.restoreNavFree = { navBeforeRestore, navAfterRestore, delta: restoreNavDelta };
evidence.navEventsAfterRestore = navigationEvents.slice(navBeforeRestore);
@@ -314,8 +343,14 @@ async function main() {
// 回到 filetree 页 — 这一步本身是页面导航(用户主动操作)
const navBeforeFiletree = navigationEvents.length;
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const navAfterFiletreeLoad = navigationEvents.length;
await waitForFileTreeRoot(page);
evidence.restoreFocusDebug.filetreeNavDelta = navAfterFiletreeLoad - navBeforeFiletree;
evidence.restoreFocusDebug.filetreeNavEvents = navigationEvents.slice(navBeforeFiletree);
evidence.restoreFocusDebug.filetreeUrlAfterLoad = page.url();
evidence.restoreFocusDebug.localStorageKeysAfterFiletreeLoad = await page.evaluate(() => (
Object.keys(window.localStorage || {}).filter((key) => key.indexOf("mnote") >= 0)
)).catch(() => []);
// 等待轮询检测到变化后文件行重现(最长 1200ms polling + 180ms debounce
try {
await waitForFileTreeRow(page, assetRowIdStr);
@@ -341,8 +376,22 @@ async function main() {
});
// === 步骤9:检查 restore 后是否有 focus/reveal ===
const focusData = await checkFocusEvidence(page, assetRowIdStr);
const focusData = await waitForFocusEvidence(page, assetRowIdStr).catch(() => checkFocusEvidence(page, assetRowIdStr));
evidence.focusAfterRestore = focusData;
evidence.restoreFocusDebug.pendingAfterFiletreeLoad = await page.evaluate(() => (
window.localStorage ? window.localStorage.getItem("mnote.pendingLocalFolderRestoreFiletreeRowId") : null
)).catch(() => null);
evidence.restoreFocusDebug.windowNameAfterFiletreeLoad = await page.evaluate(() => window.name).catch(() => null);
evidence.restoreFocusDebug.markerAfterFiletreeLoad = await page.evaluate(() => (
document.documentElement.getAttribute("data-mnote-local-folder-restore-focused-row-id")
)).catch(() => null);
evidence.restoreFocusDebug.pendingMarkerAfterFiletreeLoad = await page.evaluate(() => (
document.documentElement.getAttribute("data-mnote-local-folder-restore-pending-row-id")
)).catch(() => null);
evidence.restoreFocusDebug.focusStatusAfterFiletreeLoad = await page.evaluate(() => (
document.documentElement.getAttribute("data-mnote-local-folder-restore-focus-status")
)).catch(() => null);
evidence.restoreFocusDebug.filetreeHtmlSnippet = await page.locator(`#sidebar-file-tree-root .tree-row[data-row-id="${assetRowIdStr}"]`).evaluate((row) => row.outerHTML).catch(() => null);
const hasFocusEvidence = focusData.found && (
focusData.attrs?.["data-active"] === "true"
@@ -362,7 +411,7 @@ async function main() {
: "restore 后有 focus 证据",
});
evidence.ok = true;
evidence.ok = hasFocusEvidence;
} catch (error) {
evidence.error = error instanceof Error ? error.stack || error.message : String(error);
// 尝试读取当前 filetree 文本以便诊断