- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目 - 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线 - 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径 验证: - cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1 - cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1 - git diff --check - git diff --cached --check - codegraph index . --force && codegraph status . - codegraph sync . && codegraph status .
239 lines
12 KiB
Markdown
239 lines
12 KiB
Markdown
# 4-49 Local File Operation 与 Watch Event 合同 v1
|
||
|
||
> 状态:done
|
||
> Owner:04-tree-domain / 03-rust-web
|
||
> 背景:Sidex / VSCode Explorer 对照显示,MNote local_folder 的 tree command、watch event、refresh/reveal 和 opened resource 生命周期之间仍缺少稳定合同。当前前端常靠 `relativePath` / `previousRelativePath` 反推刷新父级,watch event 又容易退回全量 resync,导致大目录、批量操作和已打开资源场景下出现重复刷新、状态漂移和错误恢复困难。
|
||
|
||
## 1. 结论
|
||
|
||
local_folder 命令结果必须从“执行成功 + 少量路径字段”升级为“文件操作事件合同”:
|
||
|
||
1. 后端声明 `affectedParents`,前端不再猜 old/new/current parent。
|
||
2. 命令结果携带 `revealTarget` / `selectTarget`,刷新后可稳定落焦。
|
||
3. watch event 按 root 合并批次,输出 `changedPaths`、`affectedParents`、`eventKinds`。
|
||
4. opened editor/resource 通过 participant 参与 rename/move/delete,处理 buffer rekey、stale、close 或 redirect。
|
||
5. 批量 paste/delete/move 用 batch id 聚合执行结果和刷新点。
|
||
|
||
## 2. Sidex / VSCode 对照
|
||
|
||
### 2.1 可借鉴的行为
|
||
|
||
- ExplorerService 对 create/copy/move/delete 明确计算 old parent、new parent、nested parent,并刷新对应 item。
|
||
- file changes 先进入延迟队列,批量判断是否影响已解析模型,再决定是否 refresh。
|
||
- WorkingCopyFileService 在 move/delete 前后处理 dirty working copy,失败时发结构化 fail event。
|
||
- BulkFileEdits 按批次执行,收集 per-item result 和 undo 操作。
|
||
|
||
### 2.2 不照搬的内容
|
||
|
||
- 不引入 VSCode undo/redo service 全模型。
|
||
- 不引入完整 working copy service container。
|
||
- 不改变 MNote 当前 local-first 文件系统事实源。
|
||
- 不把 Convex command 分支重新变成本地默认路径。
|
||
|
||
## 3. 当前缺口
|
||
|
||
### 3.1 command result 刷新边界不足
|
||
|
||
当前 `local_folder` command 走 `execute_local_tree_command_with_sort` 后返回 `execution`,前端再用 `relativePath`、`previousRelativePath`、`parentRelativePath` 和 request body 推断刷新父级。这个推断对 rename、move、trash、restore、bulk paste 都容易遗漏 old parent 或 new parent。
|
||
|
||
### 3.2 批量操作没有一致提交点
|
||
|
||
paste / bulk delete 当前逐项 dispatch command,单项成功后可能立即 patch DOM。多个结果交错时,UI 会经历多次 refresh,失败项也缺少结构化状态。
|
||
|
||
### 3.3 watch event 粒度偏粗
|
||
|
||
watcher 可以观察 path 事件,但 live event route 仍容易 rebuild full snapshot / resync。前端 fallback 有 debounce,服务端 SSE/resync 路径缺同等批处理语义。
|
||
|
||
### 3.4 opened resource 生命周期缺 participant
|
||
|
||
rename/move/delete 对已打开 Markdown buffer、mindmap、office resource tab 的影响仍分散:
|
||
|
||
- Markdown buffer key 绑定 relative path。
|
||
- resource tab identity 由前端字符串拼接。
|
||
- delete/archive 后是否 close、mark stale、redirect,没有统一参与层。
|
||
|
||
## 4. 目标
|
||
|
||
- 所有 local_folder tree command 成功结果都能说明影响哪些 parent。
|
||
- 前端 refresh scheduler 只消费 `affectedParents`,不解析 action-specific 路径字段。
|
||
- 批量操作只在 batch 完成后统一 refresh/apply。
|
||
- watcher event 按 root 合并,优先刷新 visible/loaded affected parents。
|
||
- opened resource 在文件操作前后收到 participant 通知,能处理 dirty、rekey、stale、close。
|
||
- 所有失败路径发结构化 error,包含 phase/rootUri/action/path/batchId。
|
||
|
||
## 5. 非目标
|
||
|
||
- 不实现全局 undo/redo。
|
||
- 不重写 trash 物理存储。
|
||
- 不改变现有 tree command URL。
|
||
- 不把 resource tab group persistence 放进本设计;那属于 editor-mainline 后续。
|
||
|
||
## 6. 协议草案
|
||
|
||
### 6.1 Command result
|
||
|
||
建议 local command result 至少包含:
|
||
|
||
```json
|
||
{
|
||
"schema": "mnote.local_file_operation_result.v1",
|
||
"operationId": "uuid-or-short-id",
|
||
"batchId": "",
|
||
"action": "rename",
|
||
"rootUri": "file:///mnt/Data1T/mnote",
|
||
"resource": {
|
||
"documentId": "local-md:design/foo.md",
|
||
"relativePath": "design/foo.md",
|
||
"objectIdentity": "resource:file:file:///mnt/Data1T/mnote:design/foo.md"
|
||
},
|
||
"previousResource": {
|
||
"documentId": "local-md:design/old.md",
|
||
"relativePath": "design/old.md",
|
||
"objectIdentity": "resource:file:file:///mnt/Data1T/mnote:design/old.md"
|
||
},
|
||
"affectedParents": [
|
||
{ "relativePath": "design", "reason": "old-parent" },
|
||
{ "relativePath": "design/new", "reason": "new-parent" }
|
||
],
|
||
"revealTarget": {
|
||
"relativePath": "design/foo.md",
|
||
"rowId": "local:file:design/foo.md"
|
||
},
|
||
"selectTarget": {
|
||
"relativePath": "design/foo.md",
|
||
"rowId": "local:file:design/foo.md"
|
||
}
|
||
}
|
||
```
|
||
|
||
约束:
|
||
|
||
- rename same-parent 也必须返回 affected parent。
|
||
- move cross-parent 必须返回 old parent 和 new parent。
|
||
- delete/archive 必须返回 old parent 和 next focus hint。
|
||
- restore 必须返回 restored parent 和 reveal target。
|
||
- `objectIdentity` 必须来自后端 canonical identity,不由前端拼接。
|
||
|
||
### 6.2 Batch result
|
||
|
||
批量操作返回:
|
||
|
||
```json
|
||
{
|
||
"schema": "mnote.local_file_operation_batch_result.v1",
|
||
"batchId": "batch-20260527-001",
|
||
"action": "paste",
|
||
"succeeded": 3,
|
||
"failed": 1,
|
||
"results": [],
|
||
"affectedParents": [],
|
||
"primaryRevealTarget": {}
|
||
}
|
||
```
|
||
|
||
前端规则:
|
||
|
||
- batch 未完成前不做全量 refresh。
|
||
- per-item 可做乐观占位,但最终以 batch result 的 affected parents 为准。
|
||
- 部分失败时展示结构化失败列表,不用 `alert` 拼字符串作为唯一反馈。
|
||
|
||
### 6.3 Watch event batch
|
||
|
||
服务端按 root 聚合短窗口:
|
||
|
||
```json
|
||
{
|
||
"schema": "mnote.local_folder_watch_batch.v1",
|
||
"rootUri": "file:///mnt/Data1T/mnote",
|
||
"watchRevision": {},
|
||
"changedPaths": [
|
||
{ "relativePath": "design/foo.md", "kind": "modified" }
|
||
],
|
||
"affectedParents": [
|
||
{ "relativePath": "design", "reason": "child-modified" }
|
||
],
|
||
"eventKinds": ["modified"],
|
||
"fallbackResync": false
|
||
}
|
||
```
|
||
|
||
前端规则:
|
||
|
||
- visible/loaded affected parent:局部 refresh。
|
||
- collapsed/loaded parent:标记 stale。
|
||
- 未 loaded parent:只记录 dirty,不加载。
|
||
- `fallbackResync=true`:刷新当前 scope parent,保留 expanded cache。
|
||
|
||
### 6.4 FileOperationParticipant
|
||
|
||
后端或 runtime 层新增参与点:
|
||
|
||
```text
|
||
beforeLocalFileOperation
|
||
afterLocalFileOperation
|
||
onLocalFileOperationFailed
|
||
```
|
||
|
||
参与者:
|
||
|
||
- `DocumentBufferStore`:rename/move 后 rekey,delete/archive 后 mark stale 或 close。
|
||
- `ResourceTabRuntime`:resource identity 改变时更新 tab key,目标被删除时显示 stale/closed state。
|
||
- `SearchIndex`:按 changed path 增量刷新,避免 query 时 rebuild。
|
||
- `FileTreeRefreshScheduler`:收集 affected parents。
|
||
|
||
## 7. Checklist
|
||
|
||
- [x] 审查 `execute_local_tree_command_with_sort` 每个 action 当前返回字段。
|
||
- [x] 定义 Rust `LocalFileOperationResult` / `AffectedParent` / `RevealTarget` 类型。
|
||
- [x] 让 create/rename/move/archive/restore/purge 返回统一 `affectedParents`。
|
||
- [x] 增加过渡期 `affectedParents` 合成器,根据后端已有 `previousRelativePath` / `relativePath` / `parentRelativePath` 声明 old/new/current parent。
|
||
- [x] 修改前端 `fileTreeRefreshParentsForCommand`,优先消费 `result.affectedParents`。
|
||
- [x] 为 legacy result 保留兼容 fallback,但加日志标记。
|
||
- [x] 给 bulk paste/delete 增加 batch id,完成后统一 refresh。
|
||
- [x] watch registry 或 event route 增加 root 级 debounce batch。
|
||
- [x] `tree:resync` payload 缺失或 rebuild 失败时发结构化 error event。
|
||
- [x] 加 `DocumentBufferStore` participant 设计切片:rename/move rekey,delete/archive mark stale。
|
||
- [x] 补 smoke:批量删除、跨父级移动、rename opened markdown、watch batch 不全量折叠。
|
||
|
||
## 8. 验收
|
||
|
||
- rename same-parent 只刷新该 parent,目标 row 保持 selected/focused。
|
||
- move cross-parent 刷新 old parent 和 new parent,不刷新 workspace root。
|
||
- bulk delete 10 个文件只触发一次 batch refresh。
|
||
- 外部连续创建/删除多个文件时,watch event 合并成一个 batch。
|
||
- 已打开 Markdown rename 后,buffer key 和 tab identity 更新,不出现旧路径保存覆盖。
|
||
- 已打开 resource 被 archive/delete 后,tab 进入 stale/closed state,不继续写旧路径。
|
||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_tree_command -- --test-threads=1` 通过。
|
||
- `node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js` 通过。
|
||
- 新增批量/watch smoke 通过。
|
||
|
||
## 9. 风险
|
||
|
||
- 如果 command result 同时保留新旧字段,前端可能继续依赖旧推断。迁移期需要明确优先级:`affectedParents` 优先,旧字段只作 fallback。
|
||
- participant 不能阻塞文件系统操作太久;dirty/conflict 处理需要有明确 timeout 或失败策略。
|
||
- watch batch 不能吞掉不可恢复错误;需要把 watcher unhealthy / fallback polling 状态暴露给前端。
|
||
|
||
## 10. 执行记录
|
||
|
||
- 2026-05-27:在 `execute_local_tree_command_with_sort(...)` 出口新增 `add_local_tree_command_affected_parents(...)`,基于现有 command result 字段生成 `affectedParents`。
|
||
- 2026-05-27:新增 `local_tree_command_move_returns_affected_parents`,验证跨父级 move 返回旧父级 root 和新父级 `docs`。
|
||
- 2026-05-27:前端 `fileTreeRefreshParentsForCommand(...)` 新增 `addAffectedParentsFromCommandResult(...)`,优先消费 `result.affectedParents`;旧字段推断保留为 fallback。
|
||
- 2026-05-27:新增 `sidebar_filetree_runtime_prefers_command_affected_parents` 契约测试。
|
||
- 2026-05-27:完成 `execute_local_tree_command_with_sort(...)` action 返回字段审查;发现 delete/archive/purge、目录 move、legacy wrapper 是主要缺口。
|
||
- 2026-05-27:新增 Rust `LocalFileOperationResult` / `AffectedParent` / `RevealTarget` / `LocalFileOperationResource` 类型,并在 local_folder 命令出口统一补 `schema= mnote.local_file_operation_result.v1`、`operationId`、`rootUri`、`resource/previousResource`、`affectedParents`、`revealTarget/selectTarget`。
|
||
- 2026-05-27:补齐 delete/archive/purge 的 `originalRelativePath`、目录 move 的 `previousRelativePath`;前端 affectedParents fallback 支持 `result.execution.affectedParents`,legacy fallback 标记 `data-mnote-filetree-command-refresh-fallback`。
|
||
- 2026-05-27:bulk paste/delete 增加 `batchId`,`tree:local-command` 按 batch 暂存 affected parents,`tree:local-command-batch-complete` 后统一 refresh;修复 bulk delete local asset 缺少 `removeFileTreeAssetRow` 依赖导致命令成功但 UI 记录失败的问题。
|
||
- 2026-05-27:local folder tree live SSE 从逐事件 full resync 改为 120ms root 级 `watch_batch`,payload 输出 `changedPaths/affectedParents/eventKinds/fallbackResync=false`;重建失败/缺 payload 输出 `mnote.tree_live_error.v1` 结构化 error。
|
||
- 2026-05-27:`DocumentBufferStore` 增加 local file operation participant:rename/move rekey opened Markdown buffer,delete/archive/purge mark deleted;tree command local_folder 分支执行后调用 participant。
|
||
- 2026-05-27:扩展 smoke:`task494` 覆盖 watch batch 不折叠、opened Markdown rename buffer rekey、未加载深层 reveal;`task471` 覆盖 bulk delete batchId 与 batch 完成后统一 refresh。
|
||
- 验证:
|
||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_tree_command_move_returns_affected_parents -- --nocapture`,通过。
|
||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime -- --nocapture`,5 passed。
|
||
- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js`,通过。
|
||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_tree_command_ -- --nocapture --test-threads=1`,11 passed。
|
||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_events -- --nocapture --test-threads=1`,7 passed。
|
||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web document_buffer_ -- --nocapture --test-threads=1`,13 passed。
|
||
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime -- --nocapture --test-threads=1`,10 passed。
|
||
- `node scripts/task494-filetree-lazy-loading-dedup-smoke.js`,通过,覆盖 lazy dedup、scope generation、watch batch、reveal、opened rename buffer rekey。
|
||
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:38291 node scripts/task471-local-folder-bulk-resource-trash-smoke.js`,通过,覆盖 bulk delete batchId 与 batch refresh。
|