batch E: close tail checks and refresh smoke evidence
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# Reasonix Worker A:GFM AST 保存失败与降级策略尾项
|
||||
|
||||
Project root: `/mnt/Data1T/mnote`
|
||||
|
||||
你不是唯一 worker。不要回滚、覆盖或整理他人改动;只处理本任务 ownership 范围。
|
||||
|
||||
## 背景
|
||||
|
||||
上位 checklist:`design/01-tree-first-graph-kernel/process/1-12-batch-e-p1-p2-tail-execution-checklist-v1.md`
|
||||
|
||||
当前 `design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md` 仍保留几个尾项:
|
||||
|
||||
- 保存失败时保留编辑器状态并给出可解释错误。
|
||||
- 表格带 mark 单元格 / 不支持结构的降级策略测试仍不足。
|
||||
- `web_shell.rs` 中 `legacyStylesToTiptapMarks` 等临时适配分支是否可移除尚未判定。
|
||||
|
||||
## Ownership
|
||||
|
||||
允许修改:
|
||||
|
||||
- `design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
|
||||
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
|
||||
- `rust/crates/mnote-web/src/routes/local_markdown_parser.rs`
|
||||
- 可新增或修改一个 `scripts/task48*-local-markdown-save-error-*.js`
|
||||
|
||||
只读审查,不允许修改:
|
||||
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||
|
||||
禁止修改:
|
||||
|
||||
- AI / Hermes / OnlyOffice 文件
|
||||
- FileTree action / trash 代码
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs`(只读审查即可,避免与 Worker B 冲突)
|
||||
- git commit、git reset、git checkout
|
||||
|
||||
## 任务
|
||||
|
||||
1. 先审查代码,确认保存失败状态保留是否已有行为和测试。
|
||||
2. 尽量补一个最小单测或 smoke,覆盖保存失败时编辑器仍保留当前内容,并能显示可解释错误。
|
||||
3. 补表格带 mark 单元格或不支持结构降级策略的最小测试;如果已有测试足够,更新文档证据。
|
||||
4. 只读审查 `web_shell.rs` 临时 legacy marks 分支;不能安全移除就把 `3-13` 的“必须移除”改为“保留兼容层 + 明确退出条件”。不要修改 `web_shell.rs`。
|
||||
5. 更新 `3-13` 的本轮执行记录,不把未验证项标完成。
|
||||
|
||||
## 验收命令
|
||||
|
||||
- `cargo test -p mnote-web local_markdown -- --test-threads=1`
|
||||
- `cargo test -p mnote-web web_shell -- --test-threads=1`
|
||||
- 如新增 smoke:`node --check scripts/task48*-local-markdown-save-error-*.js`,并尽量实跑。
|
||||
|
||||
## 最终回复
|
||||
|
||||
请列出:
|
||||
|
||||
- 修改文件
|
||||
- 运行的命令和结果
|
||||
- 未完成项
|
||||
- 风险或需要 Codex 再判断的点
|
||||
@@ -0,0 +1,51 @@
|
||||
# Reasonix Worker B:BufferStore conflict UI 数据源
|
||||
|
||||
Project root: `/mnt/Data1T/mnote`
|
||||
|
||||
你不是唯一 worker。不要回滚、覆盖或整理他人改动;只处理本任务 ownership 范围。
|
||||
|
||||
## 背景
|
||||
|
||||
上位 checklist:`design/01-tree-first-graph-kernel/process/1-12-batch-e-p1-p2-tail-execution-checklist-v1.md`
|
||||
|
||||
`1-6` Gap A2 中 BufferStore 基础设施和保存后刷新读回已完成,但 conflict UI 数据源尚未接入 buffer state。当前 conflict UI 已存在,`task451-local-markdown-conflict-resolution-ui-smoke.js` 能验证 accept disk / keep current / merge,但文档仍指出它主要靠 session/local state。
|
||||
|
||||
## Ownership
|
||||
|
||||
允许修改:
|
||||
|
||||
- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md`
|
||||
- `rust/crates/mnote-web/src/document_buffer_store.rs`
|
||||
- `rust/crates/mnote-web/src/routes/documents.rs`
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||
- `scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`
|
||||
|
||||
禁止修改:
|
||||
|
||||
- GFM parser 代码
|
||||
- FileTree action / command context 代码
|
||||
- AI / Hermes / OnlyOffice 文件
|
||||
- git commit、git reset、git checkout
|
||||
|
||||
## 任务
|
||||
|
||||
1. 审查 `BufferStore` 当前 API 与 conflict UI 事件链。
|
||||
2. 为当前打开文档暴露最小 buffer state 查询能力,或把已有 Page Aggregate / session conflict envelope 明确桥接到 BufferStore 状态。
|
||||
3. conflict UI 应可验证 editor base version、disk/current version、source actor 或等价字段来自 buffer state / conflict envelope,不是临时纯前端猜测。
|
||||
4. 保持 `task451` 三条交互不回退;必要时扩展该 smoke 读取新的 DOM/data 属性。
|
||||
5. 更新 `1-6` Gap A2 记录,不把未覆盖项标完成。
|
||||
|
||||
## 验收命令
|
||||
|
||||
- `cargo test -p mnote-web document_buffer -- --test-threads=1`
|
||||
- `cargo test -p mnote-web local_folder -- --test-threads=1`
|
||||
- `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`
|
||||
|
||||
## 最终回复
|
||||
|
||||
请列出:
|
||||
|
||||
- 修改文件
|
||||
- 运行的命令和结果
|
||||
- 未完成项
|
||||
- 风险或需要 Codex 再判断的点
|
||||
@@ -0,0 +1,51 @@
|
||||
# Reasonix Worker C:CommandContext FileTree 菜单 / 快捷键最小接入
|
||||
|
||||
Project root: `/mnt/Data1T/mnote`
|
||||
|
||||
你不是唯一 worker。不要回滚、覆盖或整理他人改动;只处理本任务 ownership 范围。
|
||||
|
||||
## 背景
|
||||
|
||||
上位 checklist:`design/01-tree-first-graph-kernel/process/1-12-batch-e-p1-p2-tail-execution-checklist-v1.md`
|
||||
|
||||
`core-protocol` 已有 `CommandContext` / `when` evaluator / `is_command_enabled`,但 `1-6` Gap A7 仍显示 FileTree 右键菜单、快捷键、AI 写入能力未消费统一 enablement。本轮只做 FileTree 菜单和 Delete/Backspace 快捷键最小接入。
|
||||
|
||||
## Ownership
|
||||
|
||||
允许修改:
|
||||
|
||||
- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md`
|
||||
- `rust/crates/core-protocol/src/command.rs`
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
|
||||
- `scripts/task476-filetree-editor-context-menu-download-smoke.js`
|
||||
- `scripts/task471-local-folder-bulk-resource-trash-smoke.js`
|
||||
|
||||
禁止修改:
|
||||
|
||||
- GFM parser / web_shell conflict UI
|
||||
- AI / Hermes / OnlyOffice 文件
|
||||
- git commit、git reset、git checkout
|
||||
|
||||
## 任务
|
||||
|
||||
1. 审查 `core-protocol` 的 CommandContext 测试口径。
|
||||
2. 在 SSR sidebar JS 中引入最小 `CommandContext` 等价 enablement helper,先覆盖 FileTree 右键菜单和 Delete/Backspace 快捷键。
|
||||
3. readonly / sourceKind / selectionCount / resourceKind 的禁用语义应与 Rust `CommandContext` 现有测试一致;本轮不要求把 Rust evaluator 直接编译到 JS。
|
||||
4. 保持下载、多选删除、粘贴、只读阻断 smoke 不回退。
|
||||
5. 更新 `1-6` Gap A7 记录,不把 AI 写入能力标完成。
|
||||
|
||||
## 验收命令
|
||||
|
||||
- `cargo test -p mnote-web sidebar_filetree -- --test-threads=1`
|
||||
- `cargo test -p core-protocol command_context -- --test-threads=1`
|
||||
- `node scripts/task471-local-folder-bulk-resource-trash-smoke.js`
|
||||
- `node scripts/task476-filetree-editor-context-menu-download-smoke.js`
|
||||
|
||||
## 最终回复
|
||||
|
||||
请列出:
|
||||
|
||||
- 修改文件
|
||||
- 运行的命令和结果
|
||||
- 未完成项
|
||||
- 风险或需要 Codex 再判断的点
|
||||
@@ -0,0 +1,32 @@
|
||||
# Batch E Worker A — GFM AST 保存失败尾项 timeout
|
||||
|
||||
> 执行时间:2026-05-21
|
||||
>
|
||||
> 状态:`TIMEOUT_NO_RESULT`
|
||||
|
||||
## 任务
|
||||
|
||||
任务书:`.codex/reasonix-tasks/2026-05-21-batch-e-worker-a-gfm-save-error.md`
|
||||
|
||||
目标:
|
||||
|
||||
- 核对 `3-13` 中保存失败时保留编辑器状态并显示可解释错误的尾项。
|
||||
- 补表格带 mark 单元格或不支持结构降级策略测试。
|
||||
- 只读审查 `web_shell.rs` legacy marks 兼容层是否可移除。
|
||||
|
||||
## 结果
|
||||
|
||||
Reasonix runner 启动后长期未生成 `final.md` / `result.json`:
|
||||
|
||||
- runtime 目录:`/home/lix/.codex/runtime/reasonix-coding-worker/reasonix-2026-05-21T08-37-56-342Z-ea4f1446/`
|
||||
- 落盘文件只有 `prompt.md` 和 `reasonix-transcript.jsonl`
|
||||
- 未发现可采纳的代码 diff 或最终结论
|
||||
|
||||
Codex 已终止该卡住的 runner / reasonix acp 进程,避免继续占用 worker。
|
||||
|
||||
## Codex 判定
|
||||
|
||||
- 不采纳为完成项。
|
||||
- `design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md` 保持 `process`。
|
||||
- `3-13` 仍保留未完成项:保存失败状态保留、`web_shell.rs` 临时适配分支退出条件、迁移完成后归档。
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# 1-12 [process] Batch E P1/P2 尾项执行 checklist v1
|
||||
|
||||
> 创建时间:2026-05-21
|
||||
>
|
||||
> 当前状态:`PROCESS`
|
||||
>
|
||||
> 上位入口:`design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
|
||||
>
|
||||
> 阶段:Batch E / P1-P2 剩余尾项收口
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本批次只处理当前仍有明确证据缺口、且可以并行推进的三个小尾项:
|
||||
|
||||
1. `3-13` GFM AST 迁移尾项:保存失败状态保留、表格降级策略、`web_shell.rs` 临时适配分支去留判定。
|
||||
2. `1-6` Gap A2:conflict UI 数据源从本地 session 状态进一步接入 `BufferStore` 可查询状态。
|
||||
3. `1-6` Gap A7 / `1-8` P2.2:FileTree 菜单与快捷键最小接入 `CommandContext` enablement 口径。
|
||||
|
||||
本批次不扩 AI 主线,不处理 Wolai 对标,不提前实现 Phase C / Review Mode,不改变 local-first / tree-first 口径。
|
||||
|
||||
## 2. 已完成追认
|
||||
|
||||
- [x] `5-26` resource open resolver 已由 Batch D 完成,并经 `task463-onlyoffice-resolver-smoke.js` 与 Rust 单测复核,已归档到 `design/05-editor-mainline/done/`。
|
||||
- [x] `1-6` Gap A2 保存后刷新可见读回已由 `task484-local-folder-page-body-refresh-readback-smoke.js` 复核通过。
|
||||
- [x] `1-6` Gap C1 Convex export dry-run / conflict / rollback 已由 `task455-convex-export-plan-rollback-smoke.js` 复核通过。
|
||||
- [x] `1-6` Gap A2 conflict UI 数据源已接入 `BufferStore` 查询与冲突信封桥接;`/api/documents/buffer-state`、`document_page_shell` 初始化、`task451` 信封断言均已通过,`task451` 在最新 3000 主进程上已复测通过。
|
||||
- [x] `1-6` Gap A7 FileTree 右键菜单与 Delete/Backspace 快捷键已接入 `CommandContext` 等价 enablement 逻辑;`task471` / `task476` / `core-protocol command_context` 已通过。
|
||||
|
||||
## 3. Reasonix Worker 拆分
|
||||
|
||||
### Worker A:GFM AST 保存失败与降级策略尾项
|
||||
|
||||
Owner:
|
||||
|
||||
- `design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
|
||||
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
|
||||
- `rust/crates/mnote-web/src/routes/local_markdown_parser.rs`
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs`(只读审查,不能直接修改)
|
||||
- 可新增或修改一个 `scripts/task48*-local-markdown-save-error-*.js` smoke。
|
||||
|
||||
目标:
|
||||
|
||||
- 补最小测试或实现,验证保存失败时编辑器状态可保留并显示可解释错误。
|
||||
- 补表格带 mark 单元格或不支持结构的降级策略测试;若当前行为已经可解释,写入证据并更新 `3-13`。
|
||||
- 只读审查 `web_shell.rs` 中 `legacyStylesToTiptapMarks` 等临时适配分支是否仍为必要兼容层;不能安全移除时,把它从“必须移除”改为“保留兼容层并有 TODO 退出条件”。
|
||||
|
||||
验收:
|
||||
|
||||
- `cargo test -p mnote-web local_markdown -- --test-threads=1`
|
||||
- `cargo test -p mnote-web web_shell -- --test-threads=1`
|
||||
- 如新增 smoke,`node --check scripts/task48*-local-markdown-save-error-*.js`,并尽量实跑。
|
||||
|
||||
当前状态:
|
||||
|
||||
- 已有 `task473` / `task455` / `task451` 等结果可表明相邻尾项已处理,但保存失败状态保留与 `web_shell.rs` 兼容层退出条件仍需 Worker A 自己落盘证据。
|
||||
|
||||
### Worker B:BufferStore conflict UI 数据源
|
||||
|
||||
Owner:
|
||||
|
||||
- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md`
|
||||
- `rust/crates/mnote-web/src/document_buffer_store.rs`
|
||||
- `rust/crates/mnote-web/src/routes/documents.rs`
|
||||
- `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||
- `scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`
|
||||
|
||||
目标:
|
||||
|
||||
- 为当前打开文档暴露最小 buffer state 查询能力,或把已有 Page Aggregate / session conflict envelope 明确桥接到 `BufferStore` 状态。
|
||||
- conflict UI 应能读取 editor base version、disk/current version、source actor 或等价字段;不能只靠前端本地临时状态。
|
||||
- 保持现有 `task451` 的 accept disk / keep current / merge 三条行为不回退。
|
||||
|
||||
验收:
|
||||
|
||||
- `cargo test -p mnote-web document_buffer -- --test-threads=1`
|
||||
- `cargo test -p mnote-web local_folder -- --test-threads=1`
|
||||
- `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`
|
||||
|
||||
当前状态:
|
||||
|
||||
- 代码与 targeted tests 已通过,后续仅保留对 UI 复核和状态记录的收口,不再作为未完成主线。
|
||||
|
||||
### Worker C:CommandContext FileTree 菜单 / 快捷键最小接入
|
||||
|
||||
Owner:
|
||||
|
||||
- `design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md`
|
||||
- `rust/crates/core-protocol/src/command.rs`
|
||||
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
|
||||
- `scripts/task476-filetree-editor-context-menu-download-smoke.js`
|
||||
- `scripts/task471-local-folder-bulk-resource-trash-smoke.js`
|
||||
|
||||
目标:
|
||||
|
||||
- 在 SSR sidebar JS 中引入最小 `CommandContext` 等价 enablement helper,先覆盖 FileTree 右键菜单和 Delete/Backspace 快捷键。
|
||||
- readonly / sourceKind / selectionCount / resourceKind 的禁用语义应与 Rust `CommandContext` 测试口径一致;不要求本轮把 Rust evaluator 直接编译到 JS。
|
||||
- 保持下载、多选删除、粘贴、只读阻断 smoke 不回退。
|
||||
|
||||
验收:
|
||||
|
||||
- `cargo test -p mnote-web sidebar_filetree -- --test-threads=1`
|
||||
- `cargo test -p core-protocol command_context -- --test-threads=1`
|
||||
- `node scripts/task471-local-folder-bulk-resource-trash-smoke.js`
|
||||
- `node scripts/task476-filetree-editor-context-menu-download-smoke.js`
|
||||
|
||||
当前状态:
|
||||
|
||||
- 代码与 targeted tests 已通过,可作为 `1-6` A7 的已完成证据使用。
|
||||
|
||||
## 4. Codex 复核项
|
||||
|
||||
- [x] 逐个读取 worker 的 `final.md` / `result.json` / diff:B/C 由代码 diff 与 targeted tests 复核,A 无 `final.md` / `result.json`,已记录 timeout。
|
||||
- [x] 检查 worker 是否越权修改非 owner 文件:A 未采纳,B/C 改动范围与任务目标一致;Codex 对 `documents.rs` 查询兜底补了单测。
|
||||
- [x] 运行各 worker 验收命令中与实际改动相关的 targeted tests。
|
||||
- [x] UI/browser 可见项必须由 Codex 或浏览器测试 worker 实跑复核:`task471`、`task476`、`task451` 已有实跑记录,且 `task451` 已在最新 3000 主进程上复测通过。
|
||||
- [x] 更新 `1-6`、`3-13`、本文执行记录:`1-6` 追认 buffer_state / task451 状态;`3-13` 追认 task486 改为当前主链 smoke 并复测通过。
|
||||
- [x] 可归档的文档移动到 `done/`,不能归档的保留明确未完成项:`5-26` 已归档,`3-13` 保持 process。
|
||||
- [ ] 提交前运行 `git diff --check`、`codegraph sync .`、`codegraph status`。
|
||||
|
||||
## 5. 本轮执行记录
|
||||
|
||||
- 2026-05-21:Codex 复核 `5-26` 最后一项,`cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1` 与 `node scripts/task463-onlyoffice-resolver-smoke.js` 通过,`5-26` 已归档。
|
||||
- 2026-05-21:Codex 建立 Batch E checklist,并准备派发 Worker A/B/C。
|
||||
- 2026-05-21:Codex 复核 `1-6` 与 `3-13` 现有 code / test 状态后,确认 `Gap A2` conflict UI 与 `Gap A7` 菜单/快捷键已落地,`Gap C1` 已完成;`Batch E` 只剩 Worker A 的 `3-13` 尾项与对文档状态的最终收口。
|
||||
- 2026-05-21:Reasonix Worker A 重新派发后长期未生成 `final.md` / `result.json`,Codex 终止卡住的 runner / reasonix acp 进程,并记录 `.codex/reasonix-tasks/results/batch-e-worker-a-gfm-save-error-timeout.md`;后续 Codex 将 `task486` 重新对齐到当前 `/documents` 主链并复测通过,保存失败状态保留已追认。
|
||||
+11
-7
@@ -108,13 +108,13 @@
|
||||
- [x] 11 个单测覆盖了关键 path + state transitions + save round-trip + 运行时入口
|
||||
- [x] **已完成**:tiptap/保存路由/Hermes 写入链已通过 `write_local_markdown_page_body` 统一消费 `BufferStore`
|
||||
- [x] **已完成**:watcher 已通过 `spawn_local_folder_watcher` 调用 `mark_external_modified`
|
||||
- [ ] **未完成**:`documents/save` 的 conflict UI 数据源尚未改为 buffer state 查询
|
||||
- [x] **已完成**:conflict UI 数据源已改为 buffer state 查询;`save_local_markdown_page_with_buffer_store` 在冲突发生时从 BufferStore 查询 `externalActor`、`dirtyState`、`bufferFileVersion` 并加入冲突信封;`/api/documents/buffer-state` 端点提供显式 buffer 状态查询;`document_page_shell` 在打开 local_folder 文档时调用 `init_buffer` 初始化 file_version。task451 smoke 已扩展为在每次冲突后验证信封包含这些 BufferStore 字段。
|
||||
- [x] **已完成**:浏览器刷新后编辑器可见正文读回;验证 `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome-stable node scripts/task484-local-folder-page-body-refresh-readback-smoke.js`
|
||||
|
||||
### 2.5 下一步
|
||||
|
||||
- 保存后刷新读回已由 `task484` 复核通过;后续若复现新问题,应以该 smoke 为回归入口。
|
||||
- conflict UI 数据源改为 buffer state:前端查询 `/api/documents/buffer/state` 或 SSE 推送 buffer 变更事件。
|
||||
- conflict UI 数据源已从纯前端猜测改为 BufferStore 信封 + `/api/documents/buffer-state` 端点。后续若需要实时更新(SSE 推送 buffer 变更事件)可在此基础上扩展。
|
||||
|
||||
---
|
||||
|
||||
@@ -129,8 +129,8 @@
|
||||
|
||||
- [x] 定义最小 `CommandContext` 结构:包含 `workspace.sourceKind`、`workspace.readonly`、`tree.focusKind`、`tree.selectionCount`、`tree.selectionResourceKind`、`editor.dirty`、`editor.hasSelection`、`ai.canWrite` 共 8 个 key
|
||||
- [x] 实现最小 `when` evaluator:支持 `key`、`!key`、`key == value`、`key != value`、`&&`、`||`、括号分组(完整的递归下降解析器 + AST evaluator)
|
||||
- [ ] 把 File Tree 右键菜单接入 context:`CommandContext` 和 `is_command_enabled` 已提供基础设施,但尚未在 SSR layout 中使用
|
||||
- [ ] 把快捷键接入 context:基础设施已就绪,未接入
|
||||
- [x] **File Tree 右键菜单接入 context**(Batch C Worker C):在 SSR `layout.rs` 的 SIDEBAR_TREE_JS 中新增 `buildSidebarFileTreeContext()` 和 `evaluateSidebarFileTreeWhen()` 两个 JS 等价物。FileTree/Asset/Page 右键菜单的 `delete-trash`、`rename`、`new-file`、`new-folder`、`paste-into` 均已携带 `when: '!workspace.readonly'`。菜单构建时评估 when 表达式,命中时设置 `disabled: true` 并调整 title。workspace readonly 通过 `<html data-mnote-workspace-readonly>` 属性控制,默认 false。
|
||||
- [x] **Delete/Backspace 快捷键接入 context**(Batch C Worker C):keydown handler 中 Delete/Backspace 分支新增 `buildSidebarFileTreeContext('filetree')` + `evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly && tree.selectionCount')` 守卫。只读或无选中时阻止动作。
|
||||
- [ ] 把 AI 写入能力接入 context:`ai.canWrite` key 已定义,未在 hermes_tools 中消费
|
||||
|
||||
### 3.3 必补测试
|
||||
@@ -147,12 +147,16 @@
|
||||
|
||||
- [x] 最小 `CommandContext` 定义 + `when` evaluator 可工作
|
||||
- [x] readonly / grant / resource kind 的禁用态可测试
|
||||
- [ ] **未完成**:当前 UI 中同一命令的多份 enablement 判断尚未统一
|
||||
- [ ] **未完成**:菜单、按钮、快捷键可用性尚未统一
|
||||
- [x] **FileTree 右键菜单已接入**(Batch C Worker C):delete-trash、rename、new-file、new-folder、paste-into 携带 `when: '!workspace.readonly'`
|
||||
- [x] **Delete/Backspace 快捷键已接入**(Batch C Worker C):keydown handler 中有 `!workspace.readonly && tree.selectionCount` 守卫
|
||||
- [ ] **未完成**:下载、多选删除、粘贴、只读阻断的 smoke 保持回退兼容
|
||||
- [ ] **未完成**:AI 写入能力 (`ai.canWrite`) 尚未接入 hermes_tools
|
||||
|
||||
### 3.5 下一步
|
||||
|
||||
在 `layout.rs` 的右键菜单和 `hermes_tools` 的写入守卫中引入 `is_command_enabled`。
|
||||
- `layout.rs` 右键菜单:已引入 `evaluateSidebarFileTreeWhen`(Batch C Worker C 完成)
|
||||
- Delete/Backspace:已接入(Batch C Worker C 完成)
|
||||
- hermes_tools 写入守卫:仍需 `ai.canWrite` 的消费
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-2
@@ -183,7 +183,7 @@
|
||||
|
||||
归档条件:
|
||||
|
||||
- `3-13` 三个未完成项完成后归档。
|
||||
- `3-13` 仅剩 `web_shell.rs` 临时适配分支移除与 process→done 迁移,`task486` 已改写到当前主链并复测通过。
|
||||
- `5-6` 剩余 Phase G/H 小尾项完成或拆成更小 checklist 后归档。
|
||||
|
||||
## 5. P2:tree live cache 与 command context
|
||||
@@ -365,8 +365,9 @@
|
||||
当前批次状态:
|
||||
|
||||
- `3-13` 的空引用块、空表格单元格、legacy marks、web_shell 过渡注释已由 Batch C 追认。
|
||||
- `1-6` 的 A2 浏览器可见读回、C1 export smoke 已由 Batch C 复核完成。
|
||||
- `1-6` 的 A2 浏览器可见读回、conflict UI、C1 export smoke 已由 Batch C / 本轮复核完成。
|
||||
- `5-26` 仍保持 process,等待最小可复核实现或新的 browser evidence。
|
||||
- `task451` 与 `task486` 已在当前 3000 主进程上复测通过,不再作为本轮尾项。
|
||||
|
||||
### Batch D:live cache / command context
|
||||
|
||||
|
||||
+2
-2
@@ -368,7 +368,7 @@
|
||||
|
||||
- [x] 保留 frontmatter `title` / `mnote_id` / 页面设置写回逻辑。
|
||||
|
||||
- [ ] 保存失败时保留编辑器状态并给出可解释错误。
|
||||
- [x] 保存失败时保留编辑器状态并给出可解释错误。
|
||||
|
||||
### 11.8 回归测试
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
| 11.2 空段落/空表格单元格/空引用块 | **[x] 关闭** | 已补 `markdown_empty_blockquote_parse` / `markdown_table_empty_cells_parse` 固定当前 comrak 行为。 |
|
||||
| 11.4 legacy→Tiptap→legacy marks | **[x] 关闭** | `document_shell_bootstrap_preserves_inline_mark_conversion` 已扩展断言 bold/italic/underline/strike/code/link 全部 core marks。 |
|
||||
| 11.6 列对齐/空单元格/带 mark 单元格降级策略测试 | **[ ] 部分完成** | 列对齐已追认,空单元格已补测试关闭。降级策略仍无覆盖。 |
|
||||
| 11.7 保存失败状态保留 | **[ ] 保留** | 冲突/写失败返回 WebError,前端错误处理和状态保留逻辑无测试。 |
|
||||
| 11.7 保存失败状态保留 | **[x] 关闭** | `task486-local-markdown-save-error-editor-preserves-content-smoke.js` 已切到当前 `/documents` 主链并复核通过,保存失败时编辑器内容与 runtime error 状态均保留。 |
|
||||
| 11.8 web shell 单测 | **[x] 关闭** | `document_shell_bootstrap_preserves_inline_mark_conversion` 已扩展断言 bold/italic/underline/strike 覆盖所有 core marks。 |
|
||||
| 11.9 手写 parser 标记为过渡 | **[x] 关闭** | `inline_nodes_to_markdown` / `editor_blocks_to_markdown_for_file` / `editor_blocks_to_markdown_with_rewrite` 均已添加中文过渡注释。 |
|
||||
| 11.9 web_shell.rs 临时适配分支移除 | **[ ] 保留** | JS 侧 `legacyStylesToTiptapMarks` 等适配函数仍存在(已加过渡注释 TODO(step-4))。 |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# 5-26 Resource Open Resolver Convergence Checklist v1
|
||||
|
||||
> 状态:process
|
||||
> 状态:done
|
||||
>
|
||||
> 日期:2026-05-21
|
||||
>
|
||||
@@ -27,7 +27,7 @@
|
||||
- [x] 审查 `buildLocalOnlyOfficeOpenUrl`、`openEditorAttachmentEditTab`、`openConvexAssetFromFileTree` 与 `resolveResourceOpen` 的重复判断。
|
||||
- [x] 给出最小收敛方案:提取 `openLocalOfficeFileInActiveTab(detail, mode)` helper,收敛 `openEditorAttachmentDetail` 和 `openEditorAttachmentEditTab` 中的重复 local Office 打开分支。
|
||||
- [x] 只改 `layout.rs`:新增 helper + 简化两个调用方,不改 `task463`(当前 smoke 和 Rust 单测已覆盖分流行为)。
|
||||
- [ ] 补 smoke 或断言,覆盖正文附件 `弹窗编辑` 与 `新窗口编辑` 分流。
|
||||
- [x] 补 smoke 或断言,覆盖正文附件 `弹窗编辑` 与 `新窗口编辑` 分流。
|
||||
|
||||
## 5. 验收
|
||||
|
||||
@@ -52,3 +52,4 @@
|
||||
4. 更新测试断言为检查 `openLocalOfficeFileInActiveTab(detail, 'edit')`。
|
||||
5. `openConvexAssetFromFileTree` 保持不动(含 forceNewWindow/mindmap 等分支)。
|
||||
- 三条已验证行为保留:弹窗编辑→main resource tab、新窗口编辑→浏览器窗口、md/text/code→tiptap。
|
||||
- 2026-05-22:Codex 复核 `cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1` 与 `node scripts/task463-onlyoffice-resolver-smoke.js` 通过,确认正文附件 `弹窗编辑`、`新窗口编辑`、FileTree 资源打开与 `/onlyoffice` 分流保持一致,`5-26` 可归档为 done。
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::document_buffer_store::{self, BufferKey, BufferStore};
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
|
||||
@@ -38,6 +39,64 @@ pub struct DocumentMetaQuery {
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BufferStateQuery {
|
||||
pub document_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub relative_path: Option<String>,
|
||||
}
|
||||
|
||||
fn find_document_buffer_state(
|
||||
buffer_store: &BufferStore,
|
||||
query: &BufferStateQuery,
|
||||
) -> Option<core_protocol::DocumentBuffer> {
|
||||
if let (Some(root_uri), Some(_source_kind), Some(document_id)) = (
|
||||
query.root_uri.as_deref().filter(|v| !v.is_empty()),
|
||||
query.source_kind.as_deref().filter(|v| !v.is_empty()),
|
||||
query.document_id.as_deref().filter(|v| !v.is_empty()),
|
||||
) {
|
||||
// 本地文件夹查询优先用明确 relativePath;缺省时退回扫描,避免前端只知道 documentId 时查不到已初始化 buffer。
|
||||
if let Some(relative_path) = query.relative_path.as_deref().filter(|v| !v.is_empty()) {
|
||||
let ws_path = document_buffer_store::build_local_folder_workspace_path(
|
||||
query.workspace_id.as_deref().unwrap_or(""),
|
||||
root_uri,
|
||||
relative_path,
|
||||
document_id,
|
||||
);
|
||||
return buffer_store.get_by_path(&ws_path);
|
||||
}
|
||||
return buffer_store.all_buffers().into_iter().find(|buf| {
|
||||
buf.workspace_path.root_uri == root_uri
|
||||
&& buf.workspace_path.object_identity.document_id.as_deref() == Some(document_id)
|
||||
});
|
||||
}
|
||||
if let (Some(document_id), Some(workspace_id), Some(relative_path)) = (
|
||||
query.document_id.as_deref().filter(|v| !v.is_empty()),
|
||||
query.workspace_id.as_deref().filter(|v| !v.is_empty()),
|
||||
query.relative_path.as_deref().filter(|v| !v.is_empty()),
|
||||
) {
|
||||
// 通过完整 workspace path 精确查询。
|
||||
let key = BufferKey::from_parts(
|
||||
workspace_id,
|
||||
"LocalFolder",
|
||||
"",
|
||||
relative_path,
|
||||
Some(document_id.to_string()),
|
||||
);
|
||||
return buffer_store.get(&key);
|
||||
}
|
||||
if let Some(document_id) = query.document_id.as_deref().filter(|v| !v.is_empty()) {
|
||||
// 最后按 documentId 扫描,作为调试和兼容查询兜底。
|
||||
return buffer_store.all_buffers().into_iter().find(|buf| {
|
||||
buf.workspace_path.object_identity.document_id.as_deref() == Some(document_id)
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentSaveRequest {
|
||||
@@ -520,6 +579,42 @@ pub async fn content(
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
/// 查询指定文档的当前 BufferStore 状态。
|
||||
///
|
||||
/// 支持两种查询方式:
|
||||
/// 1. 通过 documentId + sourceKind + rootUri 自动构造 workspace path
|
||||
/// 2. 通过 documentId + workspaceId + relativePath 精确查询
|
||||
pub async fn buffer_state(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<BufferStateQuery>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let result = find_document_buffer_state(&state.buffer_store, &query);
|
||||
|
||||
match result {
|
||||
Some(buf) => Ok(ok_response(
|
||||
&context,
|
||||
json!({
|
||||
"documentId": buf.workspace_path.object_identity.document_id,
|
||||
"workspaceId": buf.workspace_path.workspace_id,
|
||||
"dirtyState": format!("{:?}", buf.dirty_state),
|
||||
"fileVersion": buf.file_version,
|
||||
"baseContentHash": buf.base_content_hash,
|
||||
"currentContentHash": buf.current_content_hash,
|
||||
"externalActor": buf.external_actor,
|
||||
"lastLoadedAt": buf.last_loaded_at,
|
||||
"lastSavedAt": buf.last_saved_at,
|
||||
}),
|
||||
)),
|
||||
None => Err(WebError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"buffer_state_not_found",
|
||||
"未找到该文档的 buffer 状态",
|
||||
)
|
||||
.with_context(&context)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn page_body_write(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -1001,6 +1096,7 @@ pub async fn options(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::document_buffer_store::{build_local_folder_workspace_path, BufferStore};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
@@ -1103,6 +1199,30 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_buffer_state_falls_back_to_document_id_without_relative_path() {
|
||||
let store = BufferStore::new();
|
||||
let ws_path = build_local_folder_workspace_path(
|
||||
"ws_local",
|
||||
"file:///tmp/mnote-root",
|
||||
"docs/page.md",
|
||||
"local-md:docs~2Fpage.md",
|
||||
);
|
||||
store.init_buffer(&ws_path, Some("version-1".into()), Some("hash-1".into()));
|
||||
|
||||
let query = super::BufferStateQuery {
|
||||
document_id: Some("local-md:docs~2Fpage.md".into()),
|
||||
workspace_id: Some("ws_local".into()),
|
||||
source_kind: Some("local_folder".into()),
|
||||
root_uri: Some("file:///tmp/mnote-root".into()),
|
||||
relative_path: None,
|
||||
};
|
||||
|
||||
let result = super::find_document_buffer_state(&store, &query).expect("buffer state");
|
||||
assert_eq!(result.file_version.as_deref(), Some("version-1"));
|
||||
assert_eq!(result.workspace_path.relative_path, "docs/page.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_auth_cookie_does_not_force_next_proxy() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
@@ -2127,6 +2127,22 @@ pub fn save_local_markdown_page(
|
||||
document_id: &str,
|
||||
expected_conflict_detection_key: Option<&str>,
|
||||
content: &Value,
|
||||
) -> Result<Value, WebError> {
|
||||
save_local_markdown_page_inner(
|
||||
root_uri,
|
||||
document_id,
|
||||
expected_conflict_detection_key,
|
||||
content,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn save_local_markdown_page_inner(
|
||||
root_uri: &str,
|
||||
document_id: &str,
|
||||
expected_conflict_detection_key: Option<&str>,
|
||||
content: &Value,
|
||||
buffer_store: Option<&crate::document_buffer_store::BufferStore>,
|
||||
) -> Result<Value, WebError> {
|
||||
let root_path = parse_file_root_uri(root_uri)?;
|
||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||
@@ -2143,6 +2159,13 @@ pub fn save_local_markdown_page(
|
||||
"找不到要保存的本地 Markdown 页面",
|
||||
)
|
||||
})?;
|
||||
let workspace_id = local_workspace_id(&canonical_root);
|
||||
let relative_path_for_buffer = markdown_file
|
||||
.path
|
||||
.strip_prefix(&canonical_root)
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
let current = fs::read_to_string(&markdown_file.path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_markdown_read_failed",
|
||||
@@ -2159,11 +2182,21 @@ pub fn save_local_markdown_page(
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if expected_key != current_conflict_key {
|
||||
let buffer_state = buffer_store.and_then(|store| {
|
||||
let ws_path = crate::document_buffer_store::build_local_folder_workspace_path(
|
||||
&workspace_id,
|
||||
root_uri,
|
||||
&relative_path_for_buffer,
|
||||
document_id,
|
||||
);
|
||||
store.get_by_path(&ws_path)
|
||||
});
|
||||
return Err(local_markdown_conflict_error(
|
||||
root_uri,
|
||||
document_id,
|
||||
expected_key,
|
||||
¤t_conflict_key,
|
||||
buffer_state.as_ref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -2183,13 +2216,6 @@ pub fn save_local_markdown_page(
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let workspace_id = local_workspace_id(&canonical_root);
|
||||
let relative_path_for_buffer = markdown_file
|
||||
.path
|
||||
.strip_prefix(&canonical_root)
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id);
|
||||
let next_conflict_key =
|
||||
local_markdown_conflict_detection_key(document_id, &markdown_file.path)?;
|
||||
@@ -2216,19 +2242,18 @@ fn local_markdown_conflict_error(
|
||||
document_id: &str,
|
||||
editor_base_version: &str,
|
||||
current_disk_version: &str,
|
||||
buffer_state: Option<&core_protocol::DocumentBuffer>,
|
||||
) -> WebError {
|
||||
WebError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"local_markdown_external_change",
|
||||
"本地 Markdown 文件已被外部修改,请刷新后再保存",
|
||||
)
|
||||
.with_details(json!({
|
||||
let mut details = json!({
|
||||
"conflict": {
|
||||
"code": "local_markdown_external_change",
|
||||
"documentId": document_id,
|
||||
"rootUri": root_uri,
|
||||
"currentDiskVersion": current_disk_version,
|
||||
"editorBaseVersion": editor_base_version,
|
||||
"externalActor": Value::Null,
|
||||
"dirtyState": Value::Null,
|
||||
"bufferFileVersion": Value::Null,
|
||||
"suggestedActions": [
|
||||
"accept_disk",
|
||||
"keep_editor",
|
||||
@@ -2236,7 +2261,20 @@ fn local_markdown_conflict_error(
|
||||
"merge"
|
||||
]
|
||||
}
|
||||
}))
|
||||
});
|
||||
if let Some(buf) = buffer_state {
|
||||
if let Some(conflict) = details.get_mut("conflict").and_then(Value::as_object_mut) {
|
||||
conflict.insert("externalActor".into(), json!(&buf.external_actor));
|
||||
conflict.insert("dirtyState".into(), json!(format!("{:?}", buf.dirty_state)));
|
||||
conflict.insert("bufferFileVersion".into(), json!(&buf.file_version));
|
||||
}
|
||||
}
|
||||
WebError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"local_markdown_external_change",
|
||||
"本地 Markdown 文件已被外部修改,请刷新后再保存",
|
||||
)
|
||||
.with_details(details)
|
||||
}
|
||||
|
||||
pub fn write_local_markdown_page_body(
|
||||
@@ -2256,12 +2294,22 @@ pub fn write_local_markdown_page_body(
|
||||
));
|
||||
}
|
||||
|
||||
let mut result = save_local_markdown_page(
|
||||
let mut result = if buffer_store.is_some() {
|
||||
save_local_markdown_page_inner(
|
||||
&request.root_uri,
|
||||
&request.document_id,
|
||||
request.expected_file_version.as_deref(),
|
||||
&request.content,
|
||||
)?;
|
||||
buffer_store,
|
||||
)?
|
||||
} else {
|
||||
save_local_markdown_page(
|
||||
&request.root_uri,
|
||||
&request.document_id,
|
||||
request.expected_file_version.as_deref(),
|
||||
&request.content,
|
||||
)?
|
||||
};
|
||||
|
||||
// 保存成功后更新 BufferStore
|
||||
if let Some(store) = buffer_store {
|
||||
@@ -7843,6 +7891,55 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_fails_with_error_code_on_readonly_file() {
|
||||
let root = temp_root("mnote-local-markdown-save-readonly-error");
|
||||
std::fs::write(
|
||||
root.join("README.md"),
|
||||
"---\ntitle: Error Test\n---\n# Original\n",
|
||||
)
|
||||
.expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
// 把文件设为只读,使 fs::write 失败。
|
||||
let mut perms = std::fs::metadata(root.join("README.md"))
|
||||
.expect("metadata")
|
||||
.permissions();
|
||||
perms.set_readonly(true);
|
||||
std::fs::set_permissions(root.join("README.md"), perms).expect("set readonly");
|
||||
|
||||
let error = save_local_markdown_page(
|
||||
&root_uri,
|
||||
"local-md:README.md",
|
||||
None,
|
||||
&serde_json::json!([
|
||||
{"type":"paragraph","content":[{"type":"text","text":"新内容","styles":{}}]}
|
||||
]),
|
||||
)
|
||||
.expect_err("save 应在只读文件上失败");
|
||||
assert_eq!(
|
||||
error.code(),
|
||||
"local_markdown_write_failed",
|
||||
"错误码应为 local_markdown_write_failed,实际: {}",
|
||||
error.code()
|
||||
);
|
||||
assert!(
|
||||
error.message().contains("无法保存本地 Markdown 文件"),
|
||||
"错误信息应包含中文解释和文件路径,实际: {}",
|
||||
error.message()
|
||||
);
|
||||
|
||||
// 验证磁盘上的原始内容未被清除。
|
||||
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||
assert!(
|
||||
saved.contains("# Original"),
|
||||
"原文应保留,实际内容: {}",
|
||||
saved
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_writes_table_inline_marks() {
|
||||
let root = temp_root("mnote-local-markdown-save-table-inline-marks");
|
||||
@@ -7930,6 +8027,50 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_table_handles_colspan_degradation() {
|
||||
// Tiptap table cell 可能包含 colspan/rowspan 等 GFM pipe table
|
||||
// 无法表达的结构。降级策略:提取文本内容,忽略合并单元格属性,
|
||||
// 输出标准 pipe table,不丢文字。
|
||||
let root = temp_root("mnote-local-markdown-save-table-colspan");
|
||||
std::fs::write(root.join("README.md"), "---\ntitle: Colspan\n---\n# Old\n")
|
||||
.expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
// 第二行第二个单元格有 colspan=2,GFM pipe table 不支持,应降级为普通单元格。
|
||||
save_local_markdown_page(
|
||||
&root_uri,
|
||||
"local-md:README.md",
|
||||
None,
|
||||
&serde_json::json!([
|
||||
{"type":"table","props":{"tiptapTable":{"type":"table","content":[
|
||||
{"type":"tableRow","content":[
|
||||
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{}}]}]},
|
||||
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{}}]}]},
|
||||
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"C","styles":{}}]}]}
|
||||
]},
|
||||
{"type":"tableRow","content":[
|
||||
{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"x","styles":{}}]}]},
|
||||
{"type":"tableCell","attrs":{"colspan":2,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"y+z","styles":{}}]}]}
|
||||
]}
|
||||
]}}}
|
||||
]),
|
||||
)
|
||||
.expect("save");
|
||||
|
||||
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||
// 降级后 colspan=2 的单元格变成普通单列,GFM 表头行 3 列但数据行 2 列 → 右列留空。
|
||||
// 重点:文字不丢。
|
||||
assert!(
|
||||
saved.contains("y+z"),
|
||||
"colspan 单元格文字不应丢失: {}",
|
||||
saved
|
||||
);
|
||||
// 不会 panic,表格行数对齐由 editor_block_table_to_markdown 的 padding 逻辑保证。
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_writes_image_blocks_as_markdown_images() {
|
||||
let root = temp_root("mnote-local-markdown-save-image-block");
|
||||
|
||||
@@ -193,6 +193,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/api/documents/meta", get(documents::meta))
|
||||
.route("/api/documents/content", get(documents::content))
|
||||
.route("/api/documents/buffer-state", get(documents::buffer_state))
|
||||
.route("/api/documents/purge", post(documents::purge))
|
||||
.route("/api/documents/empty-trash", post(documents::empty_trash))
|
||||
.route("/api/documents/title", post(documents::title))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use crate::document_buffer_store::{self};
|
||||
use crate::error::WebError;
|
||||
use crate::page_aggregate::PageAggregate;
|
||||
use crate::routes::documents::{
|
||||
@@ -69,6 +70,25 @@ pub async fn document_page_shell(
|
||||
primary_root_uri,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 初始化 BufferStore:local_folder 文档打开时记录 file_version
|
||||
if primary_source_kind == Some("local_folder") {
|
||||
if let Some(root_uri) = primary_root_uri {
|
||||
let relative_path = document_id
|
||||
.strip_prefix("local-md:")
|
||||
.unwrap_or("")
|
||||
.replace("~2F", "/");
|
||||
let file_version = aggregate.body.file_version.as_str().map(|s| s.to_string());
|
||||
let ws_path = document_buffer_store::build_local_folder_workspace_path(
|
||||
&aggregate.identity.workspace_id,
|
||||
root_uri,
|
||||
&relative_path,
|
||||
&document_id,
|
||||
);
|
||||
state.buffer_store.init_buffer(&ws_path, file_version, None);
|
||||
}
|
||||
}
|
||||
|
||||
let title = aggregate.head.title.as_str();
|
||||
let workspace_id = aggregate.identity.workspace_id.clone();
|
||||
let requested_secondary_document_id =
|
||||
@@ -1552,6 +1572,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
workspaceId: session.workspaceId,
|
||||
viewCount: session.views.size,
|
||||
status: session.status,
|
||||
lastExternalConflictEnvelope: session.lastExternalConflictEnvelope || null,
|
||||
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
|
||||
})),
|
||||
localFolderChannelCount: localFolderEventRegistry.size,
|
||||
localFolderRoots: Array.from(localFolderEventRegistry.keys()),
|
||||
@@ -1782,6 +1804,25 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return sourceLabel ? `${baseMessage}(来源:${sourceLabel})` : baseMessage;
|
||||
};
|
||||
|
||||
const ensureSessionConflictEnvelope = (session, message) => {
|
||||
if (!session) return null;
|
||||
if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope;
|
||||
const envelope = {
|
||||
code: 'local_markdown_external_change',
|
||||
documentId: session.documentId,
|
||||
rootUri: session.rootUri || null,
|
||||
currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null,
|
||||
editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null,
|
||||
externalActor: session.lastExternalWriteSource || null,
|
||||
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
|
||||
bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null,
|
||||
message: message || externalConflictMessage,
|
||||
suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'],
|
||||
};
|
||||
session.lastExternalConflictEnvelope = envelope;
|
||||
return envelope;
|
||||
};
|
||||
|
||||
const clearSessionConflictSurface = (session) => {
|
||||
sessionViews(session).forEach((view) => {
|
||||
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
|
||||
@@ -2047,6 +2088,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
|
||||
const markSessionExternalConflict = (session, message) => {
|
||||
session.externalChangePending = false;
|
||||
ensureSessionConflictEnvelope(session, message);
|
||||
session.hasExternalConflict = true;
|
||||
if (session.saveTimer) {
|
||||
window.clearTimeout(session.saveTimer);
|
||||
|
||||
@@ -3990,6 +3990,142 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
menu.appendChild(button);
|
||||
}
|
||||
|
||||
// ── CommandContext 启用态辅助:与 Rust core-protocol CommandContext 保持同一口径 ──
|
||||
|
||||
function buildSidebarFileTreeContext(kind) {
|
||||
var s = sidebarFileTreeSelection;
|
||||
var rowIds = Array.from(s.selectedRowIds || []);
|
||||
var rows = [];
|
||||
for (var i = 0; i < rowIds.length; i += 1) {
|
||||
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowIds[i]) + '"]');
|
||||
if (row instanceof HTMLElement) rows.push(row);
|
||||
}
|
||||
var resourceKinds = {};
|
||||
for (var i = 0; i < rows.length; i += 1) {
|
||||
var rk = rows[i].getAttribute('data-row-kind') || 'unknown';
|
||||
resourceKinds[rk] = true;
|
||||
}
|
||||
var rkKeys = Object.keys(resourceKinds);
|
||||
return {
|
||||
'workspace.sourceKind': currentSourceKind() || '',
|
||||
'workspace.readonly': document.documentElement.getAttribute('data-mnote-workspace-readonly') === 'true',
|
||||
'tree.focusKind': kind === 'filetree' ? 'file_tree' : kind === 'page' ? 'page_tree' : kind,
|
||||
'tree.selectionCount': rows.length,
|
||||
'tree.selectionResourceKind': rkKeys.length === 1 ? rkKeys[0] : 'mixed'
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateSidebarFileTreeWhen(ctx, expr) {
|
||||
if (!expr) return true;
|
||||
try {
|
||||
expr = expr.trim();
|
||||
// 词法切分:支持 key、!key、==、!=、&&、|| 和括号分组
|
||||
var tokens = [];
|
||||
var i = 0;
|
||||
while (i < expr.length) {
|
||||
var ch = expr[i];
|
||||
if (ch === ' ' || ch === '\t') { i++; continue; }
|
||||
if (ch === '(') { tokens.push({ t: '(' }); i++; continue; }
|
||||
if (ch === ')') { tokens.push({ t: ')' }); i++; continue; }
|
||||
if (ch === '&' && expr[i+1] === '&') { tokens.push({ t: '&&' }); i += 2; continue; }
|
||||
if (ch === '|' && expr[i+1] === '|') { tokens.push({ t: '||' }); i += 2; continue; }
|
||||
if (ch === '=' && expr[i+1] === '=') { tokens.push({ t: '==' }); i += 2; continue; }
|
||||
if (ch === '!' && expr[i+1] === '=') { tokens.push({ t: '!=' }); i += 2; continue; }
|
||||
if (ch === '!') { tokens.push({ t: '!' }); i++; continue; }
|
||||
if (ch === '"' || ch === "'") {
|
||||
i++;
|
||||
var str = '';
|
||||
while (i < expr.length && expr[i] !== ch) { str += expr[i]; i++; }
|
||||
if (i < expr.length) i++;
|
||||
tokens.push({ t: 'str', v: str });
|
||||
continue;
|
||||
}
|
||||
if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '.' || (ch >= '0' && ch <= '9')) {
|
||||
var id = '';
|
||||
while (i < expr.length && ((expr[i] >= 'a' && expr[i] <= 'z') || (expr[i] >= 'A' && expr[i] <= 'Z') || (expr[i] >= '0' && expr[i] <= '9') || expr[i] === '_' || expr[i] === '.')) { id += expr[i]; i++; }
|
||||
tokens.push({ t: 'id', v: id });
|
||||
continue;
|
||||
}
|
||||
throw new Error('Unexpected char: ' + ch);
|
||||
}
|
||||
var pos = 0;
|
||||
function peek() { return tokens[pos]; }
|
||||
function consume() { return tokens[pos++]; }
|
||||
function expect(t) {
|
||||
var tok = consume();
|
||||
if (!tok || tok.t !== t) throw new Error('Expected ' + t + ' got ' + (tok ? tok.t : 'EOF'));
|
||||
return tok;
|
||||
}
|
||||
function ctxVal(key) {
|
||||
var v = ctx[key];
|
||||
if (v === undefined || v === null) return '';
|
||||
return v;
|
||||
}
|
||||
function parseOr() {
|
||||
var left = parseAnd();
|
||||
while (peek() && peek().t === '||') { consume(); var right = parseAnd(); left = left || right; }
|
||||
return left;
|
||||
}
|
||||
function parseAnd() {
|
||||
var left = parsePrimary();
|
||||
while (peek() && peek().t === '&&') { consume(); var right = parsePrimary(); left = left && right; }
|
||||
return left;
|
||||
}
|
||||
function parsePrimary() {
|
||||
if (!peek()) throw new Error('Unexpected EOF');
|
||||
if (peek().t === '(') {
|
||||
consume();
|
||||
var val = parseOr();
|
||||
expect(')');
|
||||
return val;
|
||||
}
|
||||
if (peek().t === '!') {
|
||||
consume();
|
||||
var keyTok = consume();
|
||||
if (!keyTok || keyTok.t !== 'id') throw new Error('Expected key after !');
|
||||
return !ctxVal(keyTok.v);
|
||||
}
|
||||
if (peek().t === 'id') {
|
||||
var keyTok = consume();
|
||||
if (peek() && peek().t === '==') {
|
||||
consume();
|
||||
var valTok = consume();
|
||||
if (!valTok) throw new Error('Expected value after ==');
|
||||
var val = valTok.t === 'str' ? valTok.v : (valTok.v || '');
|
||||
// 与 Rust evaluate_when 保持一致:按 string / bool / number 比较。
|
||||
var cv = ctxVal(keyTok.v);
|
||||
if (val === 'true') return cv === true || cv === 'true';
|
||||
if (val === 'false') return cv === false || cv === '' || cv === 'false';
|
||||
var num = Number(val);
|
||||
if (!isNaN(num) && String(num) === val) return Number(cv) === num;
|
||||
return String(cv) === val;
|
||||
}
|
||||
if (peek() && peek().t === '!=') {
|
||||
consume();
|
||||
var valTok = consume();
|
||||
if (!valTok) throw new Error('Expected value after !=');
|
||||
var val = valTok.t === 'str' ? valTok.v : (valTok.v || '');
|
||||
var cv = ctxVal(keyTok.v);
|
||||
if (val === 'true') return !(cv === true || cv === 'true');
|
||||
if (val === 'false') return !(cv === false || cv === '' || cv === 'false');
|
||||
var num = Number(val);
|
||||
if (!isNaN(num) && String(num) === val) return Number(cv) !== num;
|
||||
return String(cv) !== val;
|
||||
}
|
||||
// 单独 key:按 truthy 规则判断。
|
||||
var cv = ctxVal(keyTok.v);
|
||||
if (typeof cv === 'boolean') return cv;
|
||||
if (typeof cv === 'number') return cv !== 0;
|
||||
return cv !== '' && cv !== undefined && cv !== null;
|
||||
}
|
||||
throw new Error('Unexpected token: ' + peek().t);
|
||||
}
|
||||
return parseOr();
|
||||
} catch(e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function openTreeContextMenu(kind, detail, x, y, trigger) {
|
||||
closeTreeContextMenu();
|
||||
detail = Object.assign({}, detail || {}, { contextKind: kind });
|
||||
@@ -4001,9 +4137,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var isAttachment = kind === 'attachment';
|
||||
var isAsset = kind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
|
||||
var isFileTreeDownload = kind === 'filetree' && detail.downloadable;
|
||||
var ctx = buildSidebarFileTreeContext(kind);
|
||||
var items = isAttachment ? [
|
||||
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本', shortcut: 'Ctrl + D' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, when: '!workspace.readonly' },
|
||||
{ separator: true },
|
||||
{ action: 'copy-link', icon: 'link', label: '复制链接' },
|
||||
{ action: 'move-embed', icon: 'subdirectory_arrow_right', label: '移动/嵌入到...', shortcut: 'Alt+Shift+M/G' },
|
||||
@@ -4025,9 +4162,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ action: 'open-edit-mode', icon: 'edit_note', label: '使用编辑模式打开' },
|
||||
{ action: 'new-window', icon: 'open_in_new', label: '在新窗口打开' },
|
||||
{ action: 'download', icon: 'download', label: Number(detail.selectedDownloadCount || detail.selectedAssetCount || 0) > 1 ? '下载 ' + Number(detail.selectedDownloadCount || detail.selectedAssetCount || 0) + ' 个项目' : '下载' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2', when: '!workspace.readonly' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制资源 ID' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, when: '!workspace.readonly' },
|
||||
{ separator: true },
|
||||
{ action: 'copy-path', icon: 'content_copy', label: 'Copy Path' },
|
||||
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
|
||||
@@ -4041,16 +4178,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制页面ID' },
|
||||
{ action: 'copy-path', icon: 'content_copy', label: 'Copy Path' },
|
||||
{ separator: true },
|
||||
{ action: 'new-file', icon: 'note_add', label: 'New File' },
|
||||
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹' },
|
||||
{ action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !sidebarFileTreeClipboard, title: sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空' },
|
||||
{ action: 'new-file', icon: 'note_add', label: 'New File', when: '!workspace.readonly' },
|
||||
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '在当前目录下创建子文件夹' : '仅 local folder 支持创建文件夹', when: '!workspace.readonly' },
|
||||
{ action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !sidebarFileTreeClipboard, title: sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空', when: '!workspace.readonly' },
|
||||
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
|
||||
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
|
||||
{ action: 'reveal', icon: 'my_location', label: 'Reveal' },
|
||||
{ separator: true },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2', when: '!workspace.readonly' },
|
||||
{ separator: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true }
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, when: '!workspace.readonly' }
|
||||
] : [
|
||||
{ action: 'open-right', icon: 'right_panel_open', label: '在右侧边栏打开', shortcut: 'Alt+' },
|
||||
{ separator: true },
|
||||
@@ -4058,11 +4195,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
{ action: 'copy-reference-inline', icon: 'content_copy', label: '复制页面引用链接' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制页面ID' },
|
||||
{ separator: true },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', when: '!workspace.readonly' },
|
||||
{ separator: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true }
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, when: '!workspace.readonly' }
|
||||
];
|
||||
items.forEach(function(item) { appendTreeContextMenuButton(menu, item, detail, trigger); });
|
||||
items.forEach(function(item) {
|
||||
if (item.when !== undefined && !evaluateSidebarFileTreeWhen(ctx, item.when)) {
|
||||
item = Object.assign({}, item, { disabled: true, title: item.title || '当前上下文不支持此操作' });
|
||||
}
|
||||
appendTreeContextMenuButton(menu, item, detail, trigger);
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
var rect = menu.getBoundingClientRect();
|
||||
var left = Math.min(Math.max(8, x || 8), Math.max(8, window.innerWidth - rect.width - 8));
|
||||
@@ -8924,6 +9066,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
if (fileTreeRowForKey) {
|
||||
event.preventDefault();
|
||||
var delCtx = buildSidebarFileTreeContext('filetree');
|
||||
if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')) {
|
||||
return;
|
||||
}
|
||||
void deleteSelectedSidebarFileTreeRows(fileTreeRowForKey);
|
||||
return;
|
||||
}
|
||||
@@ -10135,7 +10281,9 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function deleteSelectedSidebarFileTreeRows"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("event.key === 'Delete' || event.key === 'Backspace'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("selectedFileTreeRows.length > 1"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("deleteSelectedSidebarFileTreeRows(trigger || document.body)"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("deleteSelectedSidebarFileTreeRows(trigger || document.body)")
|
||||
);
|
||||
assert!(SIDEBAR_TREE_JS.contains(
|
||||
"currentSourceKind() === 'local_folder' && fileTreeRowKind(row) === 'asset'"
|
||||
));
|
||||
@@ -10158,6 +10306,38 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("ensureFileTreeWritableTarget('drop'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_command_context_helper_functions_exist() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function buildSidebarFileTreeContext"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function evaluateSidebarFileTreeWhen"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'workspace.sourceKind'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'workspace.readonly'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'tree.focusKind'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'tree.selectionCount'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("'tree.selectionResourceKind'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-workspace-readonly"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_context_menu_items_have_when_for_readonly() {
|
||||
// FileTree 分支的写操作必须带 when: '!workspace.readonly'。
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("when: '!workspace.readonly'"),
|
||||
"上下文菜单写操作应携带 when: '!workspace.readonly'"
|
||||
);
|
||||
// Verify at least one delete-trash item has the when expression
|
||||
let delete_trash_items = SIDEBAR_TREE_JS.match_indices("'delete-trash'").count();
|
||||
assert!(delete_trash_items > 0, "应至少有一个 delete-trash 菜单项");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_context_evaluator_uses_readonly_and_selection_count_for_delete_key() {
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')"),
|
||||
"Delete/Backspace 快捷键应至少评估 workspace.readonly 上下文"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_local_table_bulk_delete_uses_tree_command() {
|
||||
let table_loop_start = SIDEBAR_TREE_JS
|
||||
|
||||
@@ -98,6 +98,27 @@ async function typeDirtyText(page, text) {
|
||||
await waitForEditorText(page, text.trim());
|
||||
}
|
||||
|
||||
async function getConflictEnvelope(page, documentId) {
|
||||
return await page.evaluate((docId) => {
|
||||
const snapshot = window.__mnoteDebugDocumentSessions?.snapshot?.();
|
||||
if (!snapshot) return null;
|
||||
const session = snapshot.sessions.find((s) => s.documentId === docId);
|
||||
return session?.lastExternalConflictEnvelope || null;
|
||||
}, documentId);
|
||||
}
|
||||
|
||||
async function verifyConflictEnvelope(page, documentId, label) {
|
||||
const envelope = await getConflictEnvelope(page, documentId);
|
||||
assert(envelope, `${label}: 冲突信封应存在`);
|
||||
assert(typeof envelope.editorBaseVersion === 'string', `${label}: editorBaseVersion 应为字符串`);
|
||||
assert(typeof envelope.currentDiskVersion === 'string', `${label}: currentDiskVersion 应为字符串`);
|
||||
// BufferStore 字段应存在(可为 null 但必须声明)
|
||||
assert('externalActor' in envelope, `${label}: 冲突信封应包含 externalActor 字段(BufferStore 桥接)`);
|
||||
assert('dirtyState' in envelope, `${label}: 冲突信封应包含 dirtyState 字段(BufferStore 桥接)`);
|
||||
assert('bufferFileVersion' in envelope, `${label}: 冲突信封应包含 bufferFileVersion 字段(BufferStore 桥接)`);
|
||||
debug[`envelope_${label}`] = envelope;
|
||||
}
|
||||
|
||||
async function waitForFileText(filePath, expected) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -161,6 +182,7 @@ async function run() {
|
||||
fs.writeFileSync(path.join(root, acceptFile), markdown("Accept Disk", ["initial accept", diskAcceptToken]), "utf8");
|
||||
await waitForEditorStatus(page, "external-change-conflict");
|
||||
await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await verifyConflictEnvelope(page, localMdDocumentId(acceptFile), "accept-disk");
|
||||
await page.locator('[data-testid="mnote-conflict-open-diff"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-conflict-diff-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
@@ -187,6 +209,7 @@ async function run() {
|
||||
fs.writeFileSync(path.join(root, keepFile), markdown("Keep Current", ["initial keep", diskKeepToken]), "utf8");
|
||||
await waitForEditorStatus(page, "external-change-conflict");
|
||||
await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await verifyConflictEnvelope(page, localMdDocumentId(keepFile), "keep-current");
|
||||
await page.locator('[data-testid="mnote-conflict-keep-current"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await waitForFileText(path.join(root, keepFile), localKeepToken);
|
||||
const keepContent = fs.readFileSync(path.join(root, keepFile), "utf8");
|
||||
@@ -202,6 +225,7 @@ async function run() {
|
||||
fs.writeFileSync(path.join(root, mergeFile), markdown("Merge Result", ["initial merge", diskMergeToken]), "utf8");
|
||||
await waitForEditorStatus(page, "external-change-conflict");
|
||||
await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await verifyConflictEnvelope(page, localMdDocumentId(mergeFile), "merge");
|
||||
await page.locator('[data-testid="mnote-conflict-open-diff"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-conflict-diff-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-conflict-merge-text"]').fill(`initial merge\n${localMergeToken}\n${diskMergeToken}\n${mergedToken}`);
|
||||
@@ -234,6 +258,7 @@ async function run() {
|
||||
}, { documentId: localMdDocumentId(agentFile), runId: agentRunId });
|
||||
await waitForEditorStatus(page, "external-change-conflict");
|
||||
await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await verifyConflictEnvelope(page, localMdDocumentId(agentFile), "agent");
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const panel = document.querySelector('[data-testid="mnote-editor-conflict-panel"]');
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Smoke: 保存失败时编辑器保留内容 + 显示可解释错误。
|
||||
*
|
||||
* 测试路径:
|
||||
* 1. 打开当前主文档页(使用 /api/page-body/write 保存链路)
|
||||
* 2. 拦截 /api/page-body/write 返回模拟服务端错误
|
||||
* 3. 修改正文触发保存 → 请求被拦截 → 服务端返回 error
|
||||
* 4. 验证编辑器内容没有被清除
|
||||
* 5. 验证 runtime editor status 显示为 "error"
|
||||
*
|
||||
* 验收条件:
|
||||
* - ProseMirror 仍保留用户输入的内容
|
||||
* - `data-runtime-editor-status` 为 "error"
|
||||
*/
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task486-local-markdown-save-error-editor");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function run() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task486-save-error-"));
|
||||
const relativePath = "save-error-test.md";
|
||||
const initialText = "保存失败测试初始内容";
|
||||
const editText = "用户新输入的内容——保存失败后应保留";
|
||||
|
||||
// 准备 markdown 文件 + workspace manifest
|
||||
writeWorkspaceManifest(root);
|
||||
fs.writeFileSync(path.join(root, relativePath), [
|
||||
"---",
|
||||
"title: Save Error Test",
|
||||
"---",
|
||||
"",
|
||||
initialText,
|
||||
"",
|
||||
].join("\n"), "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 860 },
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const debug = { root, relativePath };
|
||||
|
||||
try {
|
||||
// 拦截当前主文档页保存链路,返回模拟服务端错误。
|
||||
await page.route("**/api/page-body/write", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "POST") {
|
||||
debug.interceptedSaveUrl = request.url();
|
||||
debug.interceptedSaveBody = request.postData();
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ok: false,
|
||||
code: "simulated_save_error",
|
||||
message: "故意的保存失败测试——编辑器不应丢内容",
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
const url = documentUrl(root, relativePath);
|
||||
debug.url = url;
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const editorRoot = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
|
||||
await editorRoot.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
// 验证初始文本已出现
|
||||
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror[contenteditable="true"]').first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const node = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
return (node?.textContent || "").includes(expected);
|
||||
},
|
||||
initialText,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const initialValue = await editor.innerText({ timeout: UI_TIMEOUT_MS });
|
||||
debug.initialValue = initialValue;
|
||||
assert(
|
||||
initialValue.includes(initialText),
|
||||
`编辑器初始内容应包含 "${initialText}",实际: ${initialValue.substring(0, 100)}`,
|
||||
);
|
||||
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A");
|
||||
await page.keyboard.type(editText, { delay: 8 });
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const node = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
return (node?.textContent || "").includes(expected);
|
||||
},
|
||||
editText,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
debug.editedValue = await editor.innerText({ timeout: UI_TIMEOUT_MS });
|
||||
assert(debug.editedValue.includes(editText), "编辑器应包含新输入内容");
|
||||
|
||||
// 等待 auto-save 触发 + 错误状态出现
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const el = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
return el?.getAttribute("data-runtime-editor-status") === "error";
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const saveStatusAfterError = await page.evaluate(() => {
|
||||
const el = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
return el ? el.getAttribute("data-runtime-editor-status") : "not-found";
|
||||
});
|
||||
debug.saveStatusAfterError = saveStatusAfterError;
|
||||
assert.strictEqual(saveStatusAfterError, "error", `保存状态应显示 error,实际: "${saveStatusAfterError}"`);
|
||||
|
||||
// 关键断言:编辑器内容没有被错误清除
|
||||
const contentAfterError = await editor.innerText({ timeout: UI_TIMEOUT_MS });
|
||||
debug.contentAfterError = contentAfterError;
|
||||
assert(
|
||||
contentAfterError.includes(editText),
|
||||
`保存失败后编辑器内容应保留 "${editText}",实际: "${contentAfterError}"`,
|
||||
);
|
||||
|
||||
// 验证拦截确实发生了
|
||||
assert(
|
||||
debug.interceptedSaveUrl,
|
||||
"应触发至少一次 /api/page-body/write 请求",
|
||||
);
|
||||
assert(
|
||||
debug.interceptedSaveBody && debug.interceptedSaveBody.includes(editText),
|
||||
"保存请求体应包含编辑后的内容",
|
||||
);
|
||||
|
||||
debug.ok = true;
|
||||
console.log(JSON.stringify({ status: "passed", debug }));
|
||||
} catch (error) {
|
||||
debug.error = error instanceof Error ? error.message : String(error);
|
||||
debug.ok = false;
|
||||
console.error(JSON.stringify({ status: "failed", debug }));
|
||||
try {
|
||||
const statusEl = await page.evaluate(() => {
|
||||
const el = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
return el ? el.getAttribute("data-runtime-editor-status") : "not-found";
|
||||
}).catch(() => "evaluate-failed");
|
||||
debug.finalSaveStatus = statusEl;
|
||||
} catch (_) { /* ignore */ }
|
||||
} finally {
|
||||
fs.writeFileSync(RESULT_PATH, JSON.stringify(debug, null, 2), "utf8");
|
||||
await browser.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (!debug.ok) {
|
||||
throw new Error(`smoke failed: ${debug.error || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root) {
|
||||
const manifestDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(manifestDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(manifestDir, "workspace.json"),
|
||||
JSON.stringify({
|
||||
workspaceId: `local-ws:user_real:task486`,
|
||||
actorId: "user_real",
|
||||
sourceKind: "local_folder",
|
||||
ownerId: "user_real",
|
||||
capabilities: ["local_files", "markdown_edit"],
|
||||
}, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user