From 384da4e44c84216e9361daa71da9c74f2bd63936 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Sat, 16 May 2026 07:38:45 +0800 Subject: [PATCH] feat(tree): checkpoint resource lifecycle work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。 不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。 --- ...-folder-cloud-switch-empty-workspace-v1.md | 57 + ...e-pagetree-source-contract-checklist-v1.md | 4 + ...trash-empty-isolated-workspace-smoke-v1.md | 64 + ...filetree-bulk-delete-selection-smoke-v1.md | 94 ++ .../4-29-vscode-explorer-cut-paste-move-v1.md | 68 + ...-30-vscode-explorer-f2-inline-rename-v1.md | 38 + ...vscode-explorer-context-menu-minimum-v1.md | 39 + ...2-vscode-explorer-dnd-modifier-guard-v1.md | 36 + ...scode-explorer-dnd-readonly-conflict-v1.md | 152 ++ .../done/4-37-trash-as-modal-workbench-v1.md | 91 ++ ...7-resource-lifecycle-command-cutover-v1.md | 161 ++ ...-trash-restore-location-reveal-focus-v1.md | 82 + ...letree-trash-dual-browser-no-refresh-v1.md | 122 ++ ...x-filetree-title-md-source-alignment-v1.md | 235 +++ ...ect-tab-resource-alignment-checklist-v1.md | 4 + design/10-review/README.md | 14 +- design/10-review/done/.mnote/page-ids.json | 6 + design/10-review/done/.mnote/trash-index.json | 57 + .../10-review/done/.mnote/trash/新页面 3.md | 5 + .../10-review/done/.mnote/trash/新页面 4.md | 5 + .../10-review/done/.mnote/trash/新页面 5.md | 5 + design/10-review/done/.mnote/trash/新页面.md | 5 + ...code-explorer-filetree-trash-gap-review.md | 609 +++++++ design/10-review/done/新页面 2.md | 5 + rust/crates/bridge-runtime/src/lib.rs | 1453 +++++++++++++++-- rust/crates/core-protocol/src/lib.rs | 20 +- rust/crates/mnote-web/src/routes/documents.rs | 135 ++ rust/crates/mnote-web/src/routes/gateway.rs | 472 +++++- rust/crates/mnote-web/src/routes/kernel.rs | 6 +- .../src/routes/local_folder_source.rs | 289 +++- rust/crates/mnote-web/src/routes/media.rs | 105 +- .../mnote-web/src/routes/mindmap_shell.rs | 64 +- .../mnote-web/src/routes/resource_trash.rs | 1250 ++++++++++++++ .../mnote-web/src/routes/stream_support.rs | 70 + rust/crates/mnote-web/src/ssr/styles.rs | 279 +++- rust/crates/mnote-web/src/transport/convex.rs | 108 ++ .../mnote-web/src/tree_shell/runtime_api.rs | 49 + rust/crates/mnote-web/src/workspace_shell.rs | 23 +- rust/crates/storage-convex-bridge/src/lib.rs | 45 + .../storage-convex-bridge/src/mapping.rs | 4 + rust/target/.rustc_info.json | 2 +- ...local-folder-unified-tree-browser-smoke.js | 35 +- scripts/task169-mindmap-realtime-smoke.js | 22 +- .../task426-mnote-web-main-no-reload-smoke.js | 4 +- ...27-trash-empty-isolated-workspace-smoke.js | 205 +++ ...28-filetree-bulk-delete-selection-smoke.js | 534 ++++++ ...429-trash-restore-location-reveal-smoke.js | 323 ++++ .../task430-vscode-explorer-stage7-smoke.js | 533 ++++++ ...de-explorer-dnd-readonly-conflict-smoke.js | 295 ++++ ...rash-page-dual-browser-no-refresh-smoke.js | 389 +++++ ...ile-asset-dual-browser-no-refresh-smoke.js | 425 +++++ ...map-table-dual-browser-no-refresh-smoke.js | 558 +++++++ ...k435-local-folder-watch-no-reload-smoke.js | 246 +++ ...own-open-document-external-change-smoke.js | 221 +++ ...ocal-folder-asset-trash-lifecycle-smoke.js | 188 +++ ...task441-local-folder-cloud-switch-smoke.js | 162 ++ .../task442-trash-modal-workbench-smoke.js | 179 ++ wolai-frontend/convex/_generated/api.d.ts | 2 + wolai-frontend/convex/documents.ts | 246 ++- wolai-frontend/convex/schema.ts | 3 + .../src/app/api/media/batch/route.test.ts | 106 +- .../src/app/api/media/batch/route.ts | 106 +- .../src/app/api/media/purge/route.test.ts | 138 ++ .../src/app/api/media/purge/route.ts | 77 +- .../src/app/api/tree/commands/route.test.ts | 17 +- .../src/app/api/tree/commands/route.ts | 4 +- .../sidebar-context-menu-source.test.ts | 21 + .../sidebar/sidebar-dnd-source.test.ts | 30 + .../sidebar-paste-preflight-source.test.ts | 45 +- .../src/components/sidebar/sidebar.tsx | 562 +++++-- .../components/sidebar/tree-pane-bindings.ts | 1 + .../sidebar/tree-shell-dom-host.tsx | 212 ++- .../sidebar/tree-shell-host.test.tsx | 160 ++ .../sidebar/tree-shell-iframe-host.tsx | 4 +- .../lib/documents/restore-location.test.ts | 168 ++ .../src/lib/documents/rust-runtime.test.ts | 118 ++ .../src/lib/documents/rust-runtime.ts | 41 + .../lib/documents/tree-command-client.test.ts | 26 + .../src/lib/documents/tree-command-client.ts | 20 +- .../src/lib/file-tree/clipboard.test.ts | 12 + wolai-frontend/src/lib/file-tree/clipboard.ts | 15 +- .../lib/file-tree/resource-command-client.ts | 14 + .../src/lib/file-tree/shell.test.ts | 36 +- wolai-frontend/src/lib/file-tree/shell.ts | 62 +- .../use-sidebar-tree-stream.test.tsx | 67 + 85 files changed, 12570 insertions(+), 394 deletions(-) create mode 100644 bugs/04-tree-domain/done/4-36-local-folder-cloud-switch-empty-workspace-v1.md create mode 100644 design/04-tree-domain/done/4-25-trash-empty-isolated-workspace-smoke-v1.md create mode 100644 design/04-tree-domain/done/4-26-filetree-bulk-delete-selection-smoke-v1.md create mode 100644 design/04-tree-domain/done/4-29-vscode-explorer-cut-paste-move-v1.md create mode 100644 design/04-tree-domain/done/4-30-vscode-explorer-f2-inline-rename-v1.md create mode 100644 design/04-tree-domain/done/4-31-vscode-explorer-context-menu-minimum-v1.md create mode 100644 design/04-tree-domain/done/4-32-vscode-explorer-dnd-modifier-guard-v1.md create mode 100644 design/04-tree-domain/done/4-33-vscode-explorer-dnd-readonly-conflict-v1.md create mode 100644 design/04-tree-domain/done/4-37-trash-as-modal-workbench-v1.md create mode 100644 design/04-tree-domain/process/4-27-resource-lifecycle-command-cutover-v1.md create mode 100644 design/04-tree-domain/process/4-28-trash-restore-location-reveal-focus-v1.md create mode 100644 design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md create mode 100644 design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md create mode 100644 design/10-review/done/.mnote/page-ids.json create mode 100644 design/10-review/done/.mnote/trash-index.json create mode 100644 design/10-review/done/.mnote/trash/新页面 3.md create mode 100644 design/10-review/done/.mnote/trash/新页面 4.md create mode 100644 design/10-review/done/.mnote/trash/新页面 5.md create mode 100644 design/10-review/done/.mnote/trash/新页面.md create mode 100644 design/10-review/done/07-vscode-explorer-filetree-trash-gap-review.md create mode 100644 design/10-review/done/新页面 2.md create mode 100644 rust/crates/mnote-web/src/routes/resource_trash.rs create mode 100644 scripts/task427-trash-empty-isolated-workspace-smoke.js create mode 100644 scripts/task428-filetree-bulk-delete-selection-smoke.js create mode 100644 scripts/task429-trash-restore-location-reveal-smoke.js create mode 100644 scripts/task430-vscode-explorer-stage7-smoke.js create mode 100644 scripts/task431-vscode-explorer-dnd-readonly-conflict-smoke.js create mode 100644 scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js create mode 100644 scripts/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke.js create mode 100644 scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js create mode 100644 scripts/task435-local-folder-watch-no-reload-smoke.js create mode 100644 scripts/task436-local-markdown-open-document-external-change-smoke.js create mode 100644 scripts/task437-local-folder-asset-trash-lifecycle-smoke.js create mode 100644 scripts/task441-local-folder-cloud-switch-smoke.js create mode 100644 scripts/task442-trash-modal-workbench-smoke.js create mode 100644 wolai-frontend/src/app/api/media/purge/route.test.ts create mode 100644 wolai-frontend/src/components/sidebar/sidebar-context-menu-source.test.ts create mode 100644 wolai-frontend/src/components/sidebar/sidebar-dnd-source.test.ts create mode 100644 wolai-frontend/src/lib/documents/restore-location.test.ts diff --git a/bugs/04-tree-domain/done/4-36-local-folder-cloud-switch-empty-workspace-v1.md b/bugs/04-tree-domain/done/4-36-local-folder-cloud-switch-empty-workspace-v1.md new file mode 100644 index 00000000..03bace96 --- /dev/null +++ b/bugs/04-tree-domain/done/4-36-local-folder-cloud-switch-empty-workspace-v1.md @@ -0,0 +1,57 @@ +# 4-36 [process][bug] 本地文件夹切回云空间后进入空白 workspace v1 + +> 更新时间:2026-05-15 +> +> 分类归属: +> - `04-tree-domain/process` +> - 涉及边界:workspace source switch、本地文件夹入口、Convex workspace 恢复、Sidebar File Tree/Page Tree projection +> +> 用户反馈: +> - 点击进入本地文件夹后,再点击“云空间”,会得到空白的空间页面。 +> - 截图:`/mnt/Data1T/mnote/tmp/image copy 101.png`。 +> - 点击垃圾箱后才能找回之前空间的页面。 + +## 1. 问题定义 + +从 Convex 云空间切到本地文件夹,再通过工作区来源菜单切回“云空间”时,页面应恢复到之前的 Convex workspace,并展示原有 Page Tree / File Tree。 + +当前异常表现是:切回后页面壳仍显示“开发用户的空间”和文档编辑区,但左侧 Explorer 为空,提示“暂无文件或页面”。这会让用户误以为云空间数据丢失;进入垃圾箱后又能看到原空间内容,说明更像是 workspace source / workspaceId 恢复错误,而不是数据真的被删除。 + +## 2. 初步根因 + +已确认一个高风险代码点: + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中 `openLocalFolderRoot()` 在离开云空间前调用 `rememberCloudWorkspaceId(resolveWorkspaceId(document.body))`。 +- `resolveWorkspaceId(document.body)` 只检查当前元素和祖先,再回退到 URL 的 `workspaceId` 或 `"default"`;它不会读取页面子树里的真实 `data-workspace-id`。 +- 当云空间入口 URL 没有显式 `workspaceId` 时,这会把 `"default"` 记为上次云空间 workspace。 +- 之后 `switchToCloudWorkspace()` 读取这个错误的 last cloud workspace id,跳到 `/?workspaceId=default&sourceKind=convex_workspace`,从而得到空白 workspace。 + +## 3. 复现路径 + +1. 进入 `http://localhost:3000` 的真实云空间,确保侧边栏有页面。 +2. 点击左上角工作区来源菜单或文件夹入口,进入一个本地文件夹。 +3. 再打开工作区来源菜单,点击“云空间”。 +4. 观察 URL、`localStorage.mnote.workspace.lastCloudWorkspaceId`、Page Tree / File Tree row。 + +期望: + +- URL 应带回真实云空间 `workspaceId`,不能是 `default`。 +- Page Tree / File Tree 应恢复云空间页面。 +- 不需要通过垃圾箱或其它入口才能找回原空间。 + +## 4. 验收标准 + +- 新增 smoke 覆盖:云空间有至少一个页面 -> 打开本地文件夹 -> 切回云空间 -> 原云空间页面 row 仍可见。 +- `mnote.workspace.lastCloudWorkspaceId` 不得在有真实 workspace id 时被写成 `default`。 +- `switchToCloudWorkspace()` 不能携带 `rootUri`,也不能落到本地文件夹空树。 +- 修复后真实浏览器复核截图中的路径不再出现“暂无文件或页面”的空白 Explorer。 + +## 5. 当前状态 + +当前状态:`done` + +2026-05-15 已修复并验证: + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 不再在进入本地文件夹前用 `document.body` 推导 workspaceId;改为从当前 URL / DOM 子树读取真实云空间 workspaceId,并忽略 synthetic `default`。 +- `scripts/task441-local-folder-cloud-switch-smoke.js` 通过:打开云空间 -> 进入本地文件夹 -> 切回云空间后,URL、`lastCloudWorkspaceId`、Page Tree / File Tree 都恢复到原 workspace,且原云空间页面 row 仍可见。 +- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_workspace_source_switch_remembers_real_cloud_workspace_before_local_folder -- --nocapture` 通过。 diff --git a/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md b/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md index 6ca6f781..ae0f0ba5 100644 --- a/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md +++ b/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md @@ -2,6 +2,10 @@ > 更新时间:2026-05-13 > +> 2026-05-15 口径更新: +> - 本文件完成时的 `index.md` 可见 UI 模型已被 `/mnt/Data1T/mnote/design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md` 覆盖。 +> - 后续 Convex File Tree 默认页面正文行显示为 `{title}.md`,rowId 为 `doc:`;本文中的 `index.md` 仅表示历史正文 object identity / 兼容语义,不再作为默认可见子行继续扩展。 +> > 上游依据: > - `/mnt/Data1T/mnote/design/10-review/05-tree.md` > - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md` diff --git a/design/04-tree-domain/done/4-25-trash-empty-isolated-workspace-smoke-v1.md b/design/04-tree-domain/done/4-25-trash-empty-isolated-workspace-smoke-v1.md new file mode 100644 index 00000000..40767c7e --- /dev/null +++ b/design/04-tree-domain/done/4-25-trash-empty-isolated-workspace-smoke-v1.md @@ -0,0 +1,64 @@ +# 4-25 Trash Empty 隔离 Workspace 验收设计 + +> 状态:done +> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 3 +> 范围:页面、附件、mindmap、table 删除进垃圾箱后,执行 workspace 级 empty trash,并确认 Convex 行与关联数据被永久清理。 + +## 背景 + +`documents.emptyTrashByWorkspace`、`mediaAssets.emptyTrashByWorkspace`、`mindmaps.emptyTrashByWorkspace`、`tables.emptyTrashByWorkspace` 都是 workspace 级破坏性操作。默认测试账号 `mnote.e2e@example.com` 已有长期工作区与历史垃圾箱数据,不能直接在该 workspace 上点击“清空垃圾箱”做 smoke,否则可能误删非本轮测试数据。 + +阶段 3 的验收必须先建立隔离边界,再执行真实清空。 + +## 目标 + +- 使用本轮独立身份或独立 workspace 创建测试页面、子页面、附件、mindmap、table。 +- 对五类对象执行默认删除,确认进入对应垃圾箱。 +- 执行页面垃圾箱清空与资源垃圾箱清空。 +- 查询 Convex,确认本轮 document / media_asset / mindmap / document_table / document_table_rows 不再存在。 +- 记录关联清理边界:Storage、OCR、LightRAG、page references、recent pages、favorites。 + +## 推荐方案 + +优先使用一次性 Convex Auth 用户: + +1. 通过 Rust `/api/auth` 走 password `signUp`,邮箱使用 `mnote.trash.@example.com`。 +2. 使用同一 browser/request context 打开 `/`,触发 `workspaces.ensureDefaultWorkspace` 创建独立 personal workspace。 +3. 在该 workspace 内创建: + - 根页面 `TEST-10REVIEW-07-P3-root-*` + - 子页面 `TEST-10REVIEW-07-P3-child-*` + - 附件 `TEST-10REVIEW-07-P3-file.txt` + - mindmap `TEST-10REVIEW-07-P3-mind` + - table `TEST-10REVIEW-07-P3-table` +4. 通过 Rust 3000 主壳 API 执行软删除: + - 页面:`POST /api/tree/commands`,`action: "archive"` + - 附件:`POST /api/media/batch`,`action: "delete"` + - mindmap:`DELETE /api/mindmap/{docId}/{mindmapId}` + - table:`DELETE /api/tables/{tableId}` +5. 打开 `/trash?workspaceId=`,确认页面区与资源区出现本轮数据。 +6. 调用: + - `POST /api/documents/empty-trash` + - `POST /api/media/empty-trash` + - `POST /api/mindmap-trash/empty` + - `POST /api/tables/empty-trash` +7. 使用 Convex query 或 Rust 只读 API 查询,确认本轮对象不存在。 + +## 禁止方案 + +- 不得在默认测试账号既有 workspace 上执行 workspace 级清空。 +- 不得只用 UI 列表消失作为永久删除证据。 +- 不得把 route fixture 单测等同于真实 Convex 清空验收。 + +## 执行结果 + +- 已新增 `scripts/task427-trash-empty-isolated-workspace-smoke.js`,覆盖“一次性用户注册 -> 独立 workspace -> seed 五类对象 -> 删除进垃圾箱 -> 清空 -> Convex 不存在断言”。 +- 2026-05-15 执行通过:一次性 workspace `ws_req_1778793827876_33` 内,页面、子页面、附件、mindmap、table 均完成删除进垃圾箱与 empty trash 后不可查询断言。 +- Rust 3000 已补资源软删除兼容入口;正式 `tree.resource.*` 命令仍由 10-review 阶段 5 收口。 +- LightRAG 目前可见 ingest 任务入口,未见 purge 反向删除索引路径;阶段 3 验收只能记录该边界,不能标为已清理。 + +## 验收产物 + +- 新增或复用一个 smoke 脚本,默认只操作一次性用户 / 一次性 workspace。 +- 输出本轮 `workspaceId`、`documentId`、`childDocumentId`、`assetId`、`mindmapId`、`tableId`。 +- 输出清空前后的 Convex 查询摘要。 +- 将结果回填到 `design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 3。 diff --git a/design/04-tree-domain/done/4-26-filetree-bulk-delete-selection-smoke-v1.md b/design/04-tree-domain/done/4-26-filetree-bulk-delete-selection-smoke-v1.md new file mode 100644 index 00000000..df8dd990 --- /dev/null +++ b/design/04-tree-domain/done/4-26-filetree-bulk-delete-selection-smoke-v1.md @@ -0,0 +1,94 @@ +# 4-26 FileTree 批量删除与选择矩阵 Smoke + +> 状态:done +> 创建时间:2026-05-15 +> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环 +> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 4 + +## 1. 目标 + +补齐 Convex filetree 下的真实浏览器验收,避免只用 reducer、local-folder smoke 或 API seed 证明“文件树多选删除已完成”。 + +本设计只覆盖阶段 4 的最小闭环: + +- 隔离用户 / 隔离 workspace seed 3 个页面、2 个普通附件、1 个 mindmap、1 个 table。 +- 在 3000 主入口打开 filetree UI。 +- 验证 Ctrl/Cmd 多选、Shift 范围、右键已选项保持 selection、右键未选项切换 action target。 +- 验证 Delete / Backspace 触发混合 doc + asset 批量删除。 +- 验证确认文案按页面、附件、mindmap、table 计数。 +- 验证删除后无需浏览器刷新,filetree 中目标行立即消失,并能在 trash dataset 或 `/trash` 中看到。 + +## 2. 安全边界 + +- 不在默认测试账号既有 workspace 上执行批量删除。 +- 不对默认 workspace 执行 `emptyTrashByWorkspace`。 +- 使用一次性 Convex Auth 用户注册后自动获得的默认 workspace。 +- 清理仅针对本轮创建对象;如清理失败,只记录对象 ID,不扩大删除范围。 + +## 3. 建议脚本 + +新增: + +- `scripts/task428-filetree-bulk-delete-selection-smoke.js` + +脚本输入: + +- `MNOTE_UI_BASE_URL`:默认 `http://127.0.0.1:3001`,便于复用 `FRONTEND_PORT=3001 npm run desktop:hot`。 +- `NEXT_PUBLIC_CONVEX_URL` / `CONVEX_SELF_HOSTED_URL`:默认 `http://127.0.0.1:3210`。 + +脚本输出: + +- `tmp/task428-filetree-bulk-delete-selection-smoke/result.json` +- 包含本轮 email、workspaceId、docIds、assetIds、captured confirm 文案、tree command / resource request 摘要、trash 验证结果。 + +## 4. 验收矩阵 + +### 4.1 Seed + +- 创建父页面 `root`。 +- 创建子页面 `child`。 +- 创建兄弟页面 `sibling`。 +- 创建资源页面 `resources`,用于挂载: + - 普通附件 2 个。 + - mindmap 1 个。 + - table 1 个。 + +### 4.2 Selection + +- Ctrl/Cmd 点击:选中 `root` 与一个外部普通附件。 +- Shift 点击:在可见行上形成范围选区,至少包含 2 行。 +- 右键已选项:selection 数量保持不变。 +- 右键未选项:selection 切换为该项。 + +### 4.3 Delete / Backspace + +第一轮 Delete: + +- 选择 `root`、`child`、外部普通附件、mindmap、table。 +- 预期 preflight plan 去重:`root` 保留,`child` 被父页面覆盖,外部资源保留。 +- 确认文案应包含: + - `1 个页面` + - `1 个附件` + - `1 个思维导图` + - `1 个在线表格` +- 确认后: + - `root`、`child`、外部普通附件、mindmap、table 对应行无需刷新消失。 + - Convex trash / sidebar dataset 可查询到对应 deleted / archived 状态。 + +第二轮 Backspace: + +- 选择 `sibling` 与第二个普通附件。 +- 触发 Backspace,确认后无需刷新消失。 +- 作为 Delete 入口的键盘等价验证。 + +## 5. 执行结果 + +`scripts/task428-filetree-bulk-delete-selection-smoke.js` 已落地并通过真实浏览器验证: + +- 创建一次性用户和隔离 workspace。 +- seed 页面、附件、mindmap、table。 +- 验证 Ctrl/Cmd 多选、Shift 范围、右键已选保持、右键未选切换。 +- 验证 Delete 混合删除页面 + 附件 + mindmap + table。 +- 验证 Backspace 混合删除页面 + 附件。 +- 验证确认文案计数、filetree 行无需刷新消失、Convex 垃圾箱状态。 +- 通过 route interception 强制 `/api/media/batch` 失败,验证页面成功进垃圾箱、失败附件仍保留,并出现“部分对象删除失败”摘要。 diff --git a/design/04-tree-domain/done/4-29-vscode-explorer-cut-paste-move-v1.md b/design/04-tree-domain/done/4-29-vscode-explorer-cut-paste-move-v1.md new file mode 100644 index 00000000..283c38c6 --- /dev/null +++ b/design/04-tree-domain/done/4-29-vscode-explorer-cut-paste-move-v1.md @@ -0,0 +1,68 @@ +# 4-29 VSCode Explorer Cut Paste Move v1 + +> 状态:done +> 创建时间:2026-05-15 +> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环 +> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 7 + +## 1. 目标 + +先收口阶段 7 的最小可执行项:文件树 `Ctrl/Cmd+X` 后 `Ctrl/Cmd+V` 必须执行移动,而不是复制或 no-op。 + +## 2. 范围 + +- `wolai-frontend` 主 Sidebar 文件树剪贴板 action 从 `copy` 扩展为 `copy | cut`。 +- `Ctrl/Cmd+C` 写入 `copy` payload,`Ctrl/Cmd+X` 写入 `cut` payload。 +- `Ctrl/Cmd+V` 读取 `cut` payload 时: + - Rust family renderer 复用 `tree.filetree.drop.preflight` 的 move plan。 + - 页面移动走 `moveDocumentCommand`。 + - 普通附件移动走 `moveFileTreeResourceAssets`。 + - 成功后清空剪贴板并刷新/广播相关树变化。 +- `copy` 路径保持原行为。 + +## 3. 非目标 + +- F2 inline rename、右键菜单补齐、DnD readonly / 冲突确认不在本小阶段内实现,继续留在阶段 7 后续子项。 +- 不扩展 `tree.filetree.paste.preflight` 的 Rust plan;cut 粘贴复用 drop preflight,是当前最小改动。 + +## 4. 验收标准 + +- `decodeFileTreeClipboardPayload` 能识别 `copy | cut`,拒绝其它 action。 +- Sidebar 快捷键监听同时处理 `Ctrl/Cmd+C`、`Ctrl/Cmd+X`、`Ctrl/Cmd+V`。 +- cut paste 分支不调用 `copyTreeCommand` / `copyFileTreeResourceAssets`,而调用 `moveDocumentCommand` / `moveFileTreeResourceAssets`。 +- 成功 cut paste 后清空剪贴板,避免二次粘贴重复移动。 + +## 5. 已落地 + +- `src/lib/file-tree/clipboard.ts`: + - `FileTreeClipboardAction` 扩展为 `copy | cut`。 + - decoder 允许 `cut`,继续拒绝其它 action。 + - 新增 `clearFileTreeClipboardPayload`。 +- `src/components/sidebar/sidebar.tsx`: + - 全局文件树快捷键支持 `Ctrl/Cmd+X`。 + - Rust family renderer 的 cut paste 复用 `preflightFileTreeInternalDrop(... copy: false)`。 + - cut paste 执行页面移动与附件移动,成功后清空剪贴板。 +- `src/components/sidebar/tree-pane-bindings.ts` 透出清空剪贴板 helper。 + +## 6. 验证命令 + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +pnpm test src/lib/file-tree/clipboard.test.ts src/components/sidebar/sidebar-paste-preflight-source.test.ts +``` + +结果:2 个测试文件通过,7 项测试通过。 + +局部 TypeScript 筛查: + +```bash +pnpm exec tsc --noEmit --pretty false 2>&1 | rg "(src/lib/file-tree/clipboard.ts|src/components/sidebar/tree-pane-bindings.ts|src/components/sidebar/sidebar.tsx)" +``` + +结果:无输出,本轮修改文件无新增 TypeScript 报错。全量 `tsc` 仍受仓库既有错误影响。 + +## 7. 待补 + +- 2026-05-15 已补 3000 主入口真实浏览器 smoke:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task430-vscode-explorer-stage7-smoke.js`。 +- `task430` 使用一次性用户 / workspace,验证页面行 `Ctrl/Cmd+X` 后聚焦目标页面并 `Ctrl/Cmd+V`,捕获 `/api/tree/commands action=move`,并通过 Convex 查询确认子页面 `parent_id` 变为目标父页面。 +- 普通附件 cut-paste move 的执行路径已有代码与 preflight 设计,真实附件 move 全矩阵仍归入后续资源树 / DnD 综合 smoke,不阻塞本小阶段“页面 cut-paste move”验收。 diff --git a/design/04-tree-domain/done/4-30-vscode-explorer-f2-inline-rename-v1.md b/design/04-tree-domain/done/4-30-vscode-explorer-f2-inline-rename-v1.md new file mode 100644 index 00000000..a74c82b6 --- /dev/null +++ b/design/04-tree-domain/done/4-30-vscode-explorer-f2-inline-rename-v1.md @@ -0,0 +1,38 @@ +# 4-30 VSCode Explorer F2 Inline Rename v1 + +> 状态:done +> 创建时间:2026-05-15 +> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环 +> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 7 + +## 1. 目标 + +补齐 `wolai-frontend` 文件树 DOM host 的 F2 行内重命名入口,避免键盘路径只触发 reducer 事件但 UI 仍无 inline editor。 + +## 2. 已落地 + +- `tree-shell-dom-host.tsx` 为 filetree row 增加行内 `tree-rename-input`。 +- F2 不再只转发 `beginRenameFocused`,而是直接进入当前 row 的 inline rename 状态。 +- Enter / blur 提交,Escape 取消。 +- 页面 / index row 提交到 `/api/tree/commands` 的 `rename`,并通过 `onTreeMutation` 回写 `tree.node.renamed`。 +- 普通 asset row 提交到 `/api/media/batch` 的 `rename`,并广播 `wolai:assets-changed`。 +- F2 输入框已补行内 validation:空名、非法路径字符、同级重名会在 input 下方显示错误并阻断提交,不再依赖 alert。 +- 普通附件重命名时,如果用户输入不带扩展名,会保留原附件扩展名后再提交,例如 `附件.pdf` 输入 `附件新名` 会提交 `附件新名.pdf`。 +- jsdom / 旧浏览器环境缺少 `CSS.escape` 时使用本地 fallback,避免 F2 聚焦逻辑抛错。 + +## 3. 验证命令 + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +pnpm test src/components/sidebar/tree-shell-host.test.tsx +``` + +结果:1 个测试文件通过,10 项测试通过;新增测试覆盖 F2 进入 inline input、提交 `tree.node.rename`、空名 / 非法字符 / 同级重名行内阻断,以及附件扩展名保留。 + +## 4. 待补 + +- 2026-05-15 已补 3000 主入口真实浏览器 smoke:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task430-vscode-explorer-stage7-smoke.js`。 +- `task430` 覆盖页面 filetree row F2 inline rename:请求 `/api/tree/commands action=rename`,Convex 标题更新,无需刷新可见。 +- `task430` 覆盖普通附件 row F2 inline rename:请求 `/api/media/batch action=rename`,Convex `media_assets.file_name` 更新,无需刷新可见。 +- 3000 Rust SSR 主壳已补 F2 inline rename;页面树 fallback 中仍保留 prompt 式重命名,按当前口径不属于 filetree F2 小阶段完成条件。 +- 2026-05-15 二次补强:本次只补 `wolai-frontend` DOM host 的行内 validation;Rust SSR srcdoc 路径如继续作为 3000 主入口,需要在后续 smoke 中补同等级 validation parity。 diff --git a/design/04-tree-domain/done/4-31-vscode-explorer-context-menu-minimum-v1.md b/design/04-tree-domain/done/4-31-vscode-explorer-context-menu-minimum-v1.md new file mode 100644 index 00000000..785fb759 --- /dev/null +++ b/design/04-tree-domain/done/4-31-vscode-explorer-context-menu-minimum-v1.md @@ -0,0 +1,39 @@ +# 4-31 VSCode Explorer Context Menu Minimum v1 + +> 状态:done +> 创建时间:2026-05-15 +> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环 +> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 7 + +## 1. 目标 + +先让主 Sidebar 右键菜单显式覆盖 VSCode Explorer 常见项,避免缺项被误读为“无能力”。已有 executor 的项接真实动作;暂无 executor 的项以禁用态展示原因。 + +## 2. 已落地 + +- `New File`:复用当前“新建子页面”能力。 +- `New Folder`:禁用态,说明页面树暂不区分文件夹,待 Resource Tree folder 合同收口。 +- `Paste Into`:主 Sidebar 节点右键已接真实 paste target,目标为当前菜单节点;`Ctrl/Cmd+V` 继续使用 focused target。 +- `Refresh`:调用 `refreshTree()`,不使用 `window.location.reload()`。 +- `Collapse All`:清空展开集合。 +- `Copy Path`:复制当前页面标题路径。 +- `Copy Relative Path`:复制不带根斜杠的页面标题路径,按当前 workspace tree 计算。 +- `Reveal`:展开父链并滚动到当前节点。 + +## 3. 验证命令 + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +pnpm test src/components/sidebar/sidebar-context-menu-source.test.ts +pnpm test src/components/sidebar/sidebar-paste-preflight-source.test.ts -- --runInBand +``` + +结果:`sidebar-context-menu-source.test.ts` 1 项通过;`sidebar-paste-preflight-source.test.ts` 3 项通过。覆盖菜单项存在、`Copy Relative Path` 接入、`Paste Into` 右键 action target、仍保留 `New Folder` 禁用态、未引入 `window.location.reload`。 + +## 4. 待补 + +- 2026-05-15 已补 3000 主入口真实浏览器 smoke:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task430-vscode-explorer-stage7-smoke.js`。 +- `task430` 验证 filetree 右键菜单可见 `New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Reveal`;当时 `New Folder` 与 `Paste Into` 为禁用态,且 title 中说明原因。 +- 2026-05-15 二次补强:`Paste Into` 已从禁用态升级为真实 paste target;主 Sidebar 抽出 `executeFileTreePaste(targetDocumentIdOverride)`,右键传当前菜单节点 `node.id`,键盘 `Ctrl/Cmd+V` 传 `null` 保留 focused target。 +- `New Folder` 需等待 folder/resource tree 合同,而不是在页面树里临时伪造文件夹真相。 +- 2026-05-15 二次补强:已补 `Copy Relative Path`;空白区右键菜单当前仍没有独立入口,后续若新增必须复用同一 paste helper 和 target 规则。 diff --git a/design/04-tree-domain/done/4-32-vscode-explorer-dnd-modifier-guard-v1.md b/design/04-tree-domain/done/4-32-vscode-explorer-dnd-modifier-guard-v1.md new file mode 100644 index 00000000..94c58a42 --- /dev/null +++ b/design/04-tree-domain/done/4-32-vscode-explorer-dnd-modifier-guard-v1.md @@ -0,0 +1,36 @@ +# 4-32 VSCode Explorer DnD Modifier Guard v1 + +> 状态:done +> 创建时间:2026-05-15 +> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环 +> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 7 + +## 1. 目标 + +补齐文件树 DnD copy modifier 的 VSCode 基线:Alt、Ctrl、Meta 任一修饰键都应进入 copy drop,而不是只有 Alt 生效。 + +## 2. 已落地 + +- `tree-shell-dom-host.tsx` 的 filetree dragover 改为 `Alt / Ctrl / Meta -> copy`。 +- `mnote-web` SSR 主壳 `layout.rs` 的 filetree dragover / drop 也同步改为 `Alt / Ctrl / Meta -> copy`。 + +## 3. 已有防线 + +- 父拖子 / 拖到自身禁止由 Rust `tree.filetree.drop.preflight` 校验,错误语义为不能把页面移动到自身或后代下面。 +- 主 Sidebar 继续依赖 drop preflight,不在 UI 层复制第二套树祖先判断。 + +## 4. 验证命令 + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +pnpm test src/components/sidebar/sidebar-dnd-source.test.ts +``` + +结果:1 个测试文件通过,1 项测试通过。 + +## 5. 待补 + +- 2026-05-15 已补 3000 主入口 `POST /api/tree/filetree/drop-preflight`,通过 Rust bridge `tree.filetree.drop.preflight` 返回真实 preflight plan。 +- 2026-05-15 已补 3000 主入口真实浏览器 smoke:`MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task430-vscode-explorer-stage7-smoke.js`。 +- `task430` 验证自拖自身与父拖子均返回 400;copy modifier preflight 返回 `copy: true` 与 `documentTransferPlan.action=copy`。 +- 后续 readonly 与命名冲突确认已由 `4-33-vscode-explorer-dnd-readonly-conflict-v1.md` 收口。 diff --git a/design/04-tree-domain/done/4-33-vscode-explorer-dnd-readonly-conflict-v1.md b/design/04-tree-domain/done/4-33-vscode-explorer-dnd-readonly-conflict-v1.md new file mode 100644 index 00000000..3badc494 --- /dev/null +++ b/design/04-tree-domain/done/4-33-vscode-explorer-dnd-readonly-conflict-v1.md @@ -0,0 +1,152 @@ +# 4-33 VSCode Explorer DnD Readonly Conflict v1 + +> 状态:done +> 创建时间:2026-05-15 +> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环 +> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 7 DnD 剩余项 + +## 1. 目标 + +补齐阶段 7 中尚未闭合的 DnD 剩余项:主 Sidebar Convex filetree 的 readonly 禁止与命名冲突确认。 + +当前已完成的 DnD 证据是: + +- 自拖自身拒绝。 +- 父拖到子节点 / 后代拒绝。 +- Alt / Ctrl / Meta copy modifier 被识别为 copy。 +- 3000 主入口 `/api/tree/filetree/drop-preflight` 已返回 Rust bridge `tree.filetree.drop.preflight` plan。 + +尚未完成的是: + +- 主 Sidebar Convex filetree 下的 readonly 目标 / readonly source 禁止。 +- 主 Sidebar Convex filetree 下的重名目标冲突确认,而不是直接 move/copy。 + +## 2. 当前代码事实 + +- `bridge-runtime` 的 `FileTreeDropPreflightCommandPayload` 当前只有: + - `copy` + - `targetDocumentId / targetRowId / focusedRowId / activeDocumentId` + - `rowIds` + - `rows` + - `documentParents` +- `FileTreeDropPreflightRow` 当前只有: + - `rowId / rowKind / documentId / assetId / assetDocumentId / assetType / storagePath` +- 现有 `build_filetree_drop_plan` 能判断: + - 目标页面。 + - doc / asset transfer plan。 + - 父拖子 / 自拖非法 move。 + - copy / move action。 +- 现有 payload 不能判断: + - 目标是否 readonly。 + - source 是否 readonly。 + - target 下是否已有同名页面 / 附件。 + - 冲突应 rename、replace、skip 还是 cancel。 + +## 3. 设计增量 + +### 3.1 Drop preflight payload 增量 + +为 `tree.filetree.drop.preflight` payload 增加以下可选字段: + +```json +{ + "sourceCapabilities": ["read", "write", "move", "copy"], + "targetCapabilities": ["read", "write", "drop"], + "rows": [ + { + "rowId": "doc:...", + "title": "页面标题", + "operationProfile": "convex_workspace" + } + ], + "targetChildren": [ + { + "rowKind": "doc", + "documentId": "...", + "assetId": null, + "title": "页面标题" + } + ], + "conflictPolicy": "prompt" +} +``` + +说明: + +- `sourceCapabilities` / `targetCapabilities` 用于表达当前 workspace source 与目标 row 的写权限,不在 UI 层猜测。 +- `operationProfile` 允许 local readonly、remote readonly、shared readonly 等来源进入同一 preflight,而不是散落在前端。 +- `title` 与 `targetChildren` 用于做同级重名检测。 +- `conflictPolicy` 默认 `prompt`,后续可扩展 `rename` / `skip` / `replace`,但本阶段只要求“发现冲突并要求确认”。 + +### 3.2 Drop preflight plan 增量 + +为 `FileTreeDropPlan` 增加: + +```json +{ + "allowed": true, + "blockedReason": null, + "requiresConfirmation": false, + "conflicts": [] +} +``` + +readonly 场景: + +- `allowed=false` +- `blockedReason="readonly_target"` 或 `readonly_source"` +- 不返回可执行 transfer plan,避免 UI 误执行。 + +命名冲突场景: + +- `allowed=true` +- `requiresConfirmation=true` +- `conflicts` 列出冲突对象、目标父级、建议策略。 +- UI 必须展示确认,不允许静默执行。 + +### 3.3 3000 主壳行为 + +- 内部 drop 触发前继续调用 `/api/tree/filetree/drop-preflight`。 +- 若 `allowed=false`,显示阻塞原因,不发 `/api/tree/commands` 或 `/api/media/batch`。 +- 若 `requiresConfirmation=true`,显示冲突确认;用户取消时不执行。 +- 用户确认后,按 plan 执行 move/copy。 + +## 4. 验收标准 + +- readonly target: + - 真实或构造的 readonly target drop preflight 返回 400 或 `allowed=false`。 + - 浏览器不发执行请求。 + - UI 有“只读”类可解释反馈。 +- conflict: + - 目标父页面已有同名页面或同名附件时,preflight 返回 `requiresConfirmation=true` 与 `conflicts`。 + - 用户取消确认后不执行 move/copy。 + - 用户确认后执行 move/copy,并在结果中记录冲突处理策略。 +- no false positive: + - 非冲突移动不弹冲突确认。 + - copy modifier 仍保留 `copy=true`。 + - 父拖子 / 自拖仍被拒绝。 + +## 5. 建议执行顺序 + +1. 扩展 `bridge-runtime` payload / plan 类型,补 readonly 与 conflict 单测。 +2. 扩展 3000 `/api/tree/filetree/drop-preflight` route 透传新增字段。 +3. 扩展 3000 Rust SSR 主壳内部 drop 逻辑,生成 row title / target children / capability payload。 +4. 新增 `scripts/task431-vscode-explorer-dnd-readonly-conflict-smoke.js`: + - 一次性用户 / workspace。 + - 构造同名目标,验证 conflict confirmation cancel / confirm。 + - 构造 readonly payload 或 readonly source fixture,验证 blocked。 +5. 更新 `design/10-review/07-vscode-explorer-filetree-trash-gap-review.md`,仅在 `task431` 通过后勾选阶段 7 DnD 总项。 + +## 6. 当前状态 + +截至 2026-05-15,本文件已完成阶段 7 DnD readonly / conflict 的执行与验证: + +- `bridge-runtime` 的 `tree.filetree.drop.preflight` 已支持 source / target capability、target children、conflict policy、`requiresConfirmation` 与 `conflicts`。 +- readonly target / source 在 preflight 阶段被拒绝;同名目标返回确认计划;copy modifier 不被 readonly move 规则误拒绝。 +- 主 Sidebar 内部 drop 与 cut-paste move 路径在 `requiresConfirmation` 时确认,取消后不执行后续 move/copy。 +- 验证命令: + - `cargo test -p bridge-runtime tree_filetree_drop_preflight -- --nocapture` + - `pnpm test src/lib/file-tree/shell.test.ts src/lib/file-tree/resource-command-client.test.ts src/components/sidebar/sidebar-paste-preflight-source.test.ts src/components/sidebar/sidebar-dnd-source.test.ts` + - `MNOTE_UI_BASE_URL=http://127.0.0.1:3001 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task431-vscode-explorer-dnd-readonly-conflict-smoke.js` + +注意:验证时 3000 常驻进程仍是旧 `mnote-web` 二进制,因此 `task431` 对 3000 初次执行复现旧 readonly 放行行为;当前代码在 3001 新编译进程验证通过,3000 需重启后生效。 diff --git a/design/04-tree-domain/done/4-37-trash-as-modal-workbench-v1.md b/design/04-tree-domain/done/4-37-trash-as-modal-workbench-v1.md new file mode 100644 index 00000000..cc6036c6 --- /dev/null +++ b/design/04-tree-domain/done/4-37-trash-as-modal-workbench-v1.md @@ -0,0 +1,91 @@ +# 4-37 [done] 垃圾箱改为居中弹窗工作台 v1 + +> 更新时间:2026-05-15 +> +> 背景: +> - 用户建议:垃圾箱应做成弹窗,而不是单一页面。 +> - 当前 `/trash` 是完整页面入口;在本地文件夹 / 云空间切换异常时,用户会通过垃圾箱“找回”原空间页面,这进一步暴露垃圾箱作为导航页面会改变当前工作区上下文。 + +## 1. 设计目标 + +把垃圾箱从“离开当前工作区的独立页面”逐步改为“当前工作区内的居中弹窗工作台”。 + +弹窗形态以 Wolai 居中对话框为准:遮罩覆盖页面,工作台面板在视口中心显示,左右和上下都保留留白;不要做成靠右侧贴边的 drawer / 抽屉。 + +目标体验: + +- 用户点击左下角“垃圾箱”时,不离开当前页面和当前文件树上下文。 +- 垃圾箱以居中 modal 打开,不贴右侧边缘,不改变当前 URL。 +- 弹窗内展示页面垃圾箱与资源垃圾箱。 +- 恢复、彻底删除、清空操作完成后,当前 Sidebar / File Tree / Page Tree 实时同步。 +- 关闭弹窗后,用户回到原页面、原选中 row、原滚动位置。 + +## 2. 非目标 + +- 本稿不改变删除、恢复、彻底删除的底层命令语义。 +- 本稿不一次性废弃 `/trash`;`/trash` 可作为兼容、直接链接和测试入口保留。 +- 本稿不扩大到全局搜索、模板中心等其它 footer 入口。 + +## 3. 最小实现切片 + +P0:入口形态 + +- 左下角“垃圾箱”从普通导航改为打开弹窗。 +- 弹窗复用现有 `mnote-trash-workbench` DOM 与 API。 +- 打开弹窗不改变当前 URL;如需要可用 `?trash=1` 或 history state,但默认不跳整页。 + +P1:上下文保持 + +- 弹窗打开前记录当前 `sourceKind`、`workspaceId`、`rootUri`、active row、scrollTop。 +- 关闭弹窗后恢复原 File Tree / Page Tree 视图状态。 +- 本地文件夹场景下,垃圾箱只展示本地 `.mnote/trash` 能力;Convex 云空间展示 Convex trash。 + +P2:实时同步 + +- 页面 restore / purge / empty 后,当前 Sidebar 通过 tree event 或本地 apply 更新。 +- 资源 restore / purge / empty 后,File Tree 对应资源 row 同步恢复或消失。 + +## 4. 验收标准 + +- 点击“垃圾箱”不会离开当前文档 URL。 +- 垃圾箱面板在视口中心显示,左右居中偏差不超过 8px,并保留可见的上下留白。 +- 弹窗内能恢复页面、彻底删除页面、清空页面垃圾箱。 +- 弹窗内能恢复/彻底删除/清空附件、mindmap、table 等资源。 +- 关闭弹窗后,原页面仍打开,File Tree 滚动位置和选中 row 不被无关重置。 +- `/trash` 兼容入口仍可访问并通过现有 smoke。 + +## 5. 当前状态 + +当前状态:`done` + +2026-05-15 已完成最小可用实现: + +- 左下角“垃圾箱”入口保留 `href="/trash"` 作为兼容 fallback,但主壳点击会拦截为 `data-mnote-action="open-trash-modal"`。 +- `PageLayout` 新增垃圾箱 modal:打开后 fetch `/trash?workspaceId=...`,解析并挂载现有 `mnote-trash-workbench`,不改变当前 URL。 +- modal 关闭时会关闭 workbench EventSource,并恢复 File Tree `scrollTop`;不改变原页面、active row 或当前文档 URL。 +- `/trash` 独立页面继续保留,作为直接链接、兼容入口和现有 smoke 的验证对象。 +- 本地文件夹上下文下,modal 保持在本地 source,不跳回 Convex;当前先展示 `.mnote/trash` / `trash-index.json` 的本地垃圾箱说明,后续如需完整 local trash list 可继续扩展。 +- 2026-05-15 根据用户参考图修正 modal 形态:从右侧 drawer 改为居中对话框,并在 smoke 中增加居中与上下留白断言,避免后续重复退回侧栏抽屉形态。 + +代码落点: + +- `rust/crates/mnote-web/src/workspace_shell.rs` +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- `rust/crates/mnote-web/src/ssr/styles.rs` +- `rust/crates/mnote-web/src/routes/gateway.rs` +- `scripts/task442-trash-modal-workbench-smoke.js` + +已通过验证: + +```bash +cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web workspace_shell -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web trash_entry -- --nocapture +MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task442-trash-modal-workbench-smoke.js +MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js +``` + +结果文件: + +- `tmp/task442-trash-modal-workbench-smoke/result.json` +- `tmp/task432-filetree-trash-page-dual-browser-no-refresh-smoke/result.json` diff --git a/design/04-tree-domain/process/4-27-resource-lifecycle-command-cutover-v1.md b/design/04-tree-domain/process/4-27-resource-lifecycle-command-cutover-v1.md new file mode 100644 index 00000000..b7e8bb09 --- /dev/null +++ b/design/04-tree-domain/process/4-27-resource-lifecycle-command-cutover-v1.md @@ -0,0 +1,161 @@ +# 4-27 Resource Lifecycle Command Cutover v1 + +> 状态:process +> 更新时间:2026-05-15 +> 范围:`tree.resource.archive / restore / purge / rename` 正式命令面,以及 `/api/media/*`、mindmap、table 资源垃圾箱兼容入口的收口边界。 + +## 1. 本阶段目标 + +- 普通附件 file asset 的删除、恢复、永久删除、重命名必须具备正式 `tree.resource.*` 命令。 +- `/api/media/batch` 与 `/api/media/purge` 可以继续作为历史 URL,但只能作为兼容 alias,内部必须生成正式 command plan 并记录 command artifact。 +- mindmap / table 旧 URL 可以保留为兼容 route,但 delete / restore / purge 必须迁移到同一 `tree.resource.*` 命令 payload,不再扩写分散 route 语义。 + +## 2. 已落地范围 + +- `bridge-runtime` 新增 file asset 生命周期命令计划: + - `tree.resource.archive -> mediaAssets:patchById` + - `tree.resource.restore -> mediaAssets:patchById` + - `tree.resource.purge -> mediaAssets:purgeById` + - `tree.resource.rename -> mediaAssets:patchById` +- `storage-convex-bridge` 为上述命令提供默认 Convex 映射。 +- `wolai-frontend` 的 `/api/media/batch`: + - `delete` 改为 `tree.resource.archive` + - `restore` 改为 `tree.resource.restore` + - `rename` 改为 `tree.resource.rename` +- `wolai-frontend` 的 `/api/media/purge` 改为 `tree.resource.purge`。 +- `mnote-web` 的 Rust 3000 `/api/media/batch` 与 `/api/media/purge` 改为通过 runtime command 执行,旧 URL 仅作为兼容 alias。 +- `mnote-web` 的 Rust 3000 `/api/mindmap/{docId}/{mindmapId}` DELETE / PATCH restore / PATCH purge 改为通过 `tree.resource.archive / restore / purge` 执行,旧 URL 仅作为兼容 alias。 +- `mnote-web` 的 Rust 3000 `/api/tables/{tableId}` DELETE、`/api/tables/restore`、`/api/tables/purge` 改为通过 `tree.resource.archive / restore / purge` 执行,旧 URL 仅作为兼容 alias。 +- tree shell command event schema 已补资源生命周期事件。 + +## 3. 未完成边界 + +- mindmap 与 table 的 DELETE / restore / purge 已进入 `tree.resource.*` runtime command;但旧 URL 仍保留为兼容入口,前端 Sidebar 仍按资源类型分流到这些旧 URL。 +- `tree.resource.rename` 当前只覆盖普通附件 file asset;mindmap rename 暂不支持,table rename 需要结合 `tables:update` 与 UI 标题语义另行收口。 +- `empty-trash` 仍是 workspace 级批量兼容合同:`mediaAssets.emptyTrashByWorkspace`、`mindmaps.emptyTrashByWorkspace`、`tables.emptyTrashByWorkspace`,暂不展开为逐资源 `tree.resource.purge`。 +- 双浏览器 no-refresh、垃圾箱恢复后的 reveal/focus、跨资源 projection delta 细粒度更新仍由后续阶段覆盖。 + +## 4. 验收 + +- 普通附件通过 `/api/media/batch delete/restore/rename` 或 `/api/media/purge` 操作时,测试中应观察到正式命令名 `tree.resource.archive/restore/rename/purge`。 +- Mindmap 通过 `/api/mindmap/{docId}/{mindmapId}` DELETE / PATCH restore / PATCH purge 操作时,响应必须携带 `canonicalCommand=tree.resource.archive/restore/purge` 与 `resourceKind=mindmap`。 +- Table 通过 `/api/tables/{tableId}` DELETE、`/api/tables/restore`、`/api/tables/purge` 操作时,响应必须携带 `canonicalCommand=tree.resource.archive/restore/purge` 与 `resourceKind=table`。 +- Rust runtime plan 必须包含 `domainEventPlan` 与 `streamDeltaHint`。 +- Rust 3000 兼容 route 不再直接写 `mediaAssets:patchById / purgeById`,而是先进入 runtime command。 +- 现有垃圾箱 route 回归保持通过。 + +## 5. 验证命令 + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p bridge-runtime tree_resource_lifecycle_plans_cover_file_asset_commands +cargo test -p bridge-runtime tree_resource_lifecycle_plans_cover_mindmap_and_table_commands +cargo test -p storage-convex-bridge tree_resource_lifecycle_commands_have_convex_mapping +cargo test -p mnote-web trash +cargo test -p mnote-web runtime_command_event_schema_covers_tree_and_resource_command_channels +``` + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +pnpm test src/app/api/media/purge/route.test.ts src/app/api/media/batch/route.test.ts src/lib/documents/rust-runtime.test.ts +``` + +## 6. 执行记录 + +- 2026-05-15:先补 `mnote-web` 路由测试断言 mindmap / table DELETE、restore、purge 响应必须携带 `canonicalCommand=tree.resource.archive/restore/purge` 与对应 `resourceKind`,测试红灯确认旧路由仍只返回 legacy mutation 结果。 +- 2026-05-15:`mnote-web` mindmap / table 兼容 route 已改为构造 `tree.resource.archive/restore/purge` runtime command,并通过 `execute_runtime_command_via_convex_with_artifacts` 执行;旧 URL 保留。 +- 2026-05-15:新增 `bridge-runtime` 合同测试 `tree_resource_lifecycle_plans_cover_mindmap_and_table_commands`,断言 mindmap 映射到 `mindmaps:softDelete/restore/purge`,table 映射到 `tables:remove/restore/purge`,并携带 `resourceLifecyclePlan`、`domainEventHint` 与 stream delta hint。 +- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime tree_resource_lifecycle_plans_cover_mindmap_and_table_commands -- --nocapture` 通过,1 项通过。 +- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web trash_routes -- --nocapture --test-threads=1` 通过,3 项通过。 +- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p storage-convex-bridge tree_resource_lifecycle_commands_have_convex_mapping -- --nocapture` 通过,1 项通过。 + +## 7. 剩余边界 + +- `tree.resource.rename` 仍只覆盖普通附件 file asset;mindmap rename 继续明确不支持,table rename 待结合 `/api/tables/{tableId}` PATCH 与表格标题语义单独收口。 +- `empty-trash` 仍是 workspace 级批量合同,不把它伪装成逐资源 `tree.resource.purge`。 +- 前端 Sidebar 仍按类型调用旧 URL;这是兼容 alias,不是新增长期业务语义。后续如要统一客户端命令面,应在 `resource-command-client` 引入 mindmap/table payload,并保留旧 URL 适配层。 + +## 8. 子阶段:local_folder file_path resource lifecycle + +### 8.1 目标与范围 + +本子阶段只覆盖 `local_folder` workspace 中由本地文件系统提供的非 md 资源文件,典型对象包括图片、PDF、音视频、Office 文件、压缩包和其他附件型二进制文件。Markdown 页面本体、目录节点、远端 media asset、mindmap、table 不在本子阶段内。 + +最小目标是把本地非 md 资源文件的 delete / restore / purge 纳入 `tree.resource.*` 正式命令面,同时保留本地文件路径作为用户可理解的 identity 输入。实现不要求立即接入云端文件删除,也不要求新增复杂版本库;只要求命令可审计、垃圾箱可索引、冲突可判定、恢复行为可预测。 + +### 8.2 最小设计 + +- 资源归属:`resourceKind=local_file`,`resourceScope=local_folder`。 +- 资源定位:命令 payload 必须携带 `workspaceId`、`rootId`、`filePath`、`resourceKind`;`filePath` 使用相对 `local_folder` 根目录的规范化 UTF-8 路径,不允许绝对路径、空路径、`..` 逃逸或平台分隔符混用。 +- md 排除:扩展名判定为 Markdown 的文件继续走页面/树节点生命周期,不进入 `local_file` 资源生命周期。 +- delete:`tree.resource.archive` 不直接物理删除文件,先把源文件移动到本工作区受控 trash 存储区,并写入 trash index。 +- restore:`tree.resource.restore` 只从 trash index 中恢复已归档资源,默认恢复到原 `filePath`;若原路径冲突,按冲突策略处理。 +- purge:`tree.resource.purge` 只允许删除 trash index 中已归档资源对应的 trash 文件和索引记录;不允许对当前活跃文件路径直接 purge。 +- 事件:三类命令都产出 `domainEventPlan` 与 `streamDeltaHint`,至少能通知资源列表、附件引用状态、垃圾箱视图刷新。 + +### 8.3 命令 identity + +`local_folder` 文件的稳定命令 identity 分两层: + +- 用户输入 identity:`workspaceId + rootId + normalizedFilePath`。这是兼容 UI、外部变更监听和命令 payload 的最小输入。 +- 归档后 identity:`trashEntryId`。第一次 archive 成功后生成并写入 trash index,restore / purge 必须优先使用 `trashEntryId`,同时校验其记录的原始 `filePath`、`workspaceId`、`rootId` 与命令 payload 一致。 + +`resourceId` 不直接等同于裸 `filePath`。对活跃文件可派生为 `local_file:{workspaceId}:{rootId}:{pathHash}`,但该值只作为事件和 projection 的稳定 key;实际文件操作仍以规范化路径和 trash index 记录为准,避免路径重命名、大小写差异或 Unicode 归一化导致误删。 + +### 8.4 Trash index + +每个 `local_folder` root 维护一个受控 trash index,最小字段如下: + +- `trashEntryId` +- `workspaceId` +- `rootId` +- `resourceKind=local_file` +- `originalFilePath` +- `trashedFilePath` +- `fileSize` +- `contentHash`,可延后异步补齐,但 purge 前若存在则用于防误删校验 +- `archivedAt` +- `archivedByCommandId` +- `restoreStatus` +- `purgedAt` +- `purgedByCommandId` + +trash index 是 restore / purge 的唯一可信入口。目录扫描只能用于发现活跃文件或 orphan trash 文件,不能绕过 index 直接恢复或永久删除。 + +### 8.5 冲突策略 + +- archive 时源文件不存在:命令返回 `not_found`,不生成新的 trash entry;若 index 已存在同一路径的活跃归档记录,返回该 `trashEntryId` 并标记为幂等归档结果。 +- archive 时目标 trash 路径已存在:生成新的 `trashEntryId` 与唯一 `trashedFilePath`,不覆盖旧 trash 文件。 +- restore 时原路径不存在:直接恢复到 `originalFilePath`,并把 index 状态改为 restored。 +- restore 时原路径已存在且 contentHash 相同:视为幂等恢复,index 状态改为 restored,不覆盖活跃文件。 +- restore 时原路径已存在且 contentHash 不同或无法判定:不得覆盖活跃文件;返回 `conflict=file_path_occupied`,UI 可选择恢复为带后缀副本或让用户改名,但默认命令不自动改名。 +- purge 时 trash 文件不存在但 index 仍为 archived:允许把 index 标记为 purged,并记录 `missing_trash_file`,用于容忍外部手动清理。 +- purge 时 index 状态不是 archived:返回幂等结果,不删除任何活跃文件。 +- 任一命令发现 `filePath` 逃逸 root、大小写归一化后指向不同文件、或 symlink 指向 root 外部:返回 `invalid_path`,不移动、不删除。 + +### 8.6 验收标准 + +- 本地非 md 资源文件 delete 只能通过 `tree.resource.archive` 进入 trash,不允许直接 unlink 活跃文件。 +- restore / purge 必须优先基于 `trashEntryId` 与 trash index 执行;仅传 `filePath` 时只能定位唯一 archived entry,不能模糊匹配多条记录。 +- trash index 中能追踪原路径、trash 路径、归档命令、恢复/永久删除命令和当前状态。 +- restore 冲突时默认不覆盖用户已有文件,并返回可被 UI 展示的结构化冲突原因。 +- purge 只能作用于 archived trash entry,不能对 active `filePath` 执行永久删除。 +- Markdown 文件不进入本子阶段;测试中应验证 `.md` / `.markdown` 路径被拒绝或转交页面生命周期。 +- 命令事件必须包含 `resourceKind=local_file`、`resourceScope=local_folder`、`filePath`、`trashEntryId`(archive 成功后)和 `canonicalCommand=tree.resource.archive/restore/purge`。 +- 外部变更监听与本命令并发时,以文件系统当前状态和 trash index 状态共同判定;不得因 stale projection 覆盖真实文件系统变化。 + +### 8.7 执行记录 + +- 2026-05-15:先补 `tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index` 红灯,确认 `local:asset:docs/photo.png` delete 原本掉到 Markdown 删除路径。 +- 2026-05-15:`mnote-web` local folder executor 已把 `delete / restore / purge` 分流到 entry 级处理;`local:asset:*` / `local:node:*` raw file 通过 `tree.resource.archive/restore/purge` 进入 `.mnote/trash` 与 `trash-index.json`,Markdown 页面继续走原页面生命周期。 +- 2026-05-15:Rust 3000 主壳 filetree delete plan 已对 `sourceKind=local_folder && rowKind=asset` 使用 rowId 作为本地资源 identity,Delete / Backspace 走 `/api/tree/commands`,不走 `/api/media/batch`。 +- 2026-05-15:新增 `scripts/task437-local-folder-asset-trash-lifecycle-smoke.js`,真实浏览器覆盖 local asset Delete 进入本地回收站、restore 回原路径、purge 清理 trash 文件与索引;结果文件 `tmp/task437-local-folder-asset-trash-lifecycle-smoke/result.json` 记录 `navigationEvents=[]`。 + +### 8.8 验证命令 + +```bash +cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_delete_keys_support_mixed_doc_and_asset_selection -- --nocapture +MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3427 node scripts/task437-local-folder-asset-trash-lifecycle-smoke.js +``` diff --git a/design/04-tree-domain/process/4-28-trash-restore-location-reveal-focus-v1.md b/design/04-tree-domain/process/4-28-trash-restore-location-reveal-focus-v1.md new file mode 100644 index 00000000..ccca368c --- /dev/null +++ b/design/04-tree-domain/process/4-28-trash-restore-location-reveal-focus-v1.md @@ -0,0 +1,82 @@ +# 4-28 Trash Restore Location Reveal Focus v1 + +> 状态:process +> 创建时间:2026-05-15 +> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环 +> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 6 + +## 1. 目标 + +页面进入垃圾箱前必须记录可恢复的位置快照;恢复时优先回到删除前父节点与排序位置,并在 UI 中 reveal / focus 恢复对象。若原父节点已不存在或仍在垃圾箱,应显式 fallback 到根目录并提示用户。 + +## 2. 已落地范围 + +- `documents` schema 新增 `restore_parent_id` 与 `restore_sort_order`。 +- `documents.softDelete` 对页面子树写入删除前 `parent_id / sort_order` 快照。 +- `documents.restore`: + - 读取同 workspace 当前文档集合。 + - 父节点仍存在或属于本次级联恢复子树时,恢复到原父节点。 + - 父节点已不存在或仍在垃圾箱时,恢复到根目录。 + - 恢复时回写 `parent_id / sort_order`,并对目标父级兄弟节点重新编号,避免恢复到原排序位时产生重复 `sort_order`。 + - 清空恢复位置快照。 + - 返回 `restore_location.parent_id / sort_order / fallback_reason`。 +- `/api/tree/commands restore` 读取页面 meta 时允许包含已删除页面,避免垃圾箱页面在进入 restore mutation 前被 `getMeta` 过滤成 404。 +- `restoreDocumentCommand` 透传恢复位置结果。 +- Sidebar 垃圾箱恢复后刷新数据、展开恢复父链、滚动并 focus 恢复行;发生 fallback 时提示“原父页面已不存在,已恢复到根目录”。 + +## 3. 设计口径 + +- 当前记录的是稳定数据位置:`parent_id / sort_order`。 + `view path / expanded path` 不持久化到 Convex,恢复后由 Sidebar 根据当前父链展开得到。 +- 子树恢复只恢复与父节点同一 `deleted_at` 批次的子节点,避免把更早独立删除的子页面误恢复。 +- fallback 策略先固定为“恢复到根目录 + 明示提示”,不在本阶段引入位置选择弹窗。 +- `sort_order` 会按当前兄弟数量 clamp;如果原位置越界,则放到可解释的最近位置。 + +## 4. 已验证 + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +pnpm test src/lib/documents/tree-command-client.test.ts src/lib/documents/restore-location.test.ts src/components/sidebar/tree-shell-host.test.tsx src/components/sidebar/sidebar-sync.test.ts +``` + +结果:4 个测试文件通过,20 项测试通过。 + +补充 route 与排序回归: + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +pnpm test src/lib/documents/tree-command-client.test.ts src/lib/documents/restore-location.test.ts src/components/sidebar/tree-shell-host.test.tsx src/components/sidebar/sidebar-sync.test.ts src/app/api/tree/commands/route.test.ts +``` + +结果:5 个测试文件通过,32 项测试通过。 + +真实浏览器 smoke: + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +npx convex dev --once --tail-logs disable --env-file ../.env.all --run ping:ping + +cd /mnt/Data1T/mnote +node scripts/task429-trash-restore-location-reveal-smoke.js +``` + +结果:通过。脚本使用一次性用户 / workspace,在 3000 主入口验证: + +- 删除子页面后通过 `tree.node.restore` 恢复,返回 `restore_location.parent_id` 为原父页面、`sort_order` 为原排序位。 +- 原排序位已被兄弟页面占用时,兄弟页面被后移,Convex 中不产生重复 `sort_order`。 +- 当前浏览器页面不刷新,恢复后的页面行重新出现在侧边栏树中。 +- 构造 `restore_parent_id` 指向缺失父节点的历史数据,恢复后返回 `fallback_reason: parent_missing_or_deleted`,并落到根目录。 + +局部 TypeScript 筛查: + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +pnpm exec tsc --noEmit --pretty false 2>&1 | rg "convex/documents.ts\\((160|161|162|163|164|165|166|167|168|169|170|171|172|173|174|175)" +``` + +结果:阶段 6 修改附近无新增 TypeScript 报错。全量 `tsc` 仍受仓库既有错误影响,未作为本阶段完成条件。 + +## 5. 待补 + +- React Sidebar Drawer 的恢复后 DOM focus 仍需在 React 入口可稳定渲染页面树时补浏览器证据;当前 3000 主壳 smoke 已覆盖“恢复后无需刷新可见 / reveal”,fallback 提示仍由 React Drawer 代码路径负责。 +- 双浏览器 no-refresh:A 恢复页面后,B 不刷新即可看到位置变化;该项归入阶段 8 的实时刷新矩阵。 diff --git a/design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md b/design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md new file mode 100644 index 00000000..027d23a4 --- /dev/null +++ b/design/04-tree-domain/process/4-34-filetree-trash-dual-browser-no-refresh-v1.md @@ -0,0 +1,122 @@ +# 4-34 Filetree Trash Dual Browser No Refresh v1 + +> 状态:process +> 创建时间:2026-05-15 +> 所属主线:04-tree-domain / VSCode Explorer 文件树与垃圾箱闭环 +> 来源:`design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 8 + +## 1. 目标 + +补齐文件树与垃圾箱的双浏览器 no-refresh 回归:A 浏览器执行页面、附件、mindmap、table 的 create / archive / restore / purge / empty-trash,B 浏览器不刷新即可在 File Tree 与 Trash 工作台看到变化。 + +阶段 8 不以“单浏览器本地 optimistic update 成功”作为完成标准;`refreshTree()` 只能作为 A 浏览器本地兜底,不能作为 B 浏览器同步证据。 + +## 2. 验收矩阵 + +| 对象 | create | archive | restore | purge | empty-trash | 证据要求 | +| --- | --- | --- | --- | --- | --- | --- | +| 页面 document | A 新建后 B File Tree 可见 | A 删除后 B File Tree 消失、Trash 可见 | A 恢复后 B File Tree 可见、Trash 消失 | A 彻底删除后 B Trash 消失 | A 清空页面垃圾箱后 B Trash 清空 | 记录 tree delta/resync 或 Convex live 来源 | +| 普通附件 file asset | A 创建/上传或 seed 后 B File Tree 可见 | A 删除后 B File Tree 消失、Trash 可见 | A 恢复后 B File Tree 可见、Trash 消失 | A 彻底删除后 B Trash 消失 | A 清空资源垃圾箱后 B Trash 清空 | 记录 `tree.resource.*` / compat alias 与实时来源 | +| mindmap | A 新建 mindmap 后 B File Tree 可见 | A 删除后 B File Tree 消失、Trash 可见 | A 恢复后 B File Tree 可见、Trash 消失 | A 彻底删除后 B Trash 消失 | A 清空资源垃圾箱后 B Trash 清空 | 记录 mindmap route 仍为 compat alias | +| table | A 新建/seed table 后 B File Tree 可见 | A 删除后 B File Tree 消失、Trash 可见 | A 恢复后 B File Tree 可见、Trash 消失 | A 彻底删除后 B Trash 消失 | A 清空资源垃圾箱后 B Trash 清空 | 记录 table route 仍为 compat alias | +| local folder Markdown | 外部创建 `.md` 后 page/file tree 可见 | 本地删除进入 `.mnote/trash` | restore 回原路径 | purge 后原文件与 trash 均不存在 | 可选本地 trash 清空 | 必须记录是否发生 reload,不能把 reload 型刷新标为 no-refresh | +| local folder 非 md 资源 | 外部创建 `.txt/.png/.pdf/.xlsx` 后 filetree 可见 | 删除语义待定 | restore 待定 | purge 待定 | 待定 | 当前不走 Markdown watcher SSE;需记录 polling / reload / delta 来源 | + +## 3. 分批执行 + +### 3.1 task432:页面生命周期双浏览器 + +先覆盖页面 document,因为它是 `tree.node.*` 主链: + +- 使用测试账号或一次性用户登录两个 browser context。 +- 使用隔离 workspace / 本轮测试前缀创建父页面与子页面。 +- B 打开 3000 主入口文档页与 `/trash?workspaceId=...` 两个 tab,安装 tree event recorder。 +- A 执行 create / archive / restore / purge / empty-trash。 +- B 每一步不刷新页面,等待 File Tree / Trash DOM 更新,并记录是 `tree:delta`、`tree:resync`、EventSource snapshot 还是 Convex live 更新。 + +通过后才勾选阶段 8 的页面子项。 + +执行记录: + +- 2026-05-15:`task432-filetree-trash-page-dual-browser-no-refresh-smoke` 已通过。A 浏览器对页面执行 create / archive / restore / purge / empty-trash;B 浏览器文件树与 `/trash` 均无需刷新可见更新,且全程无 `framenavigated`。事件来源记录为:create -> `tree:resync`,archive -> `tree:delta remove_document`,restore -> `tree:delta upsert_document`,purge -> `tree:resync`,empty-trash -> `tree:resync`。结果文件:`tmp/task432-filetree-trash-page-dual-browser-no-refresh-smoke/result.json`。 +- 2026-05-15:清空页面垃圾箱的初次 smoke 暴露两层根因并已修正:Rust `documents.emptyTrashByWorkspace` 之前未写 `tree.trash.documents.emptied` domain event,Convex `documents.emptyTrashByWorkspace` validator 也未放行 `streamDeltaHint/domainEventHint/domainEventPlan`,导致 `/trash` 必须刷新才清空。两处已补齐并重新部署本地 Convex。 + +### 3.2 task433:普通附件生命周期双浏览器 + +覆盖普通 file asset: + +- 优先复用 `task428` / `task427` 的 API seed 方式,避免依赖上传文件选择器。 +- archive / restore / purge 走 `/api/media/batch` 与 `/api/media/purge` 兼容 URL,但必须能在 artifact / response 中证明进入 `tree.resource.archive/restore/purge`。 +- B 侧分别观察 File Tree 与 Trash 工作台。 + +执行记录: + +- 2026-05-15:`task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke` 已通过。A 浏览器对普通附件执行 upload / archive / restore / archive+purge / upload+archive+empty-trash;B 浏览器文件树与 `/trash` 均无需刷新可见更新,执行阶段无 `framenavigated`。事件来源记录为:upload -> `tree:delta upsert_assets`,archive -> `tree:resync`,restore -> `tree:resync`,purge -> `tree:resync`,empty-trash -> `tree:resync`。结果文件:`tmp/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke/result.json`。 +- 2026-05-15:初次 smoke 暴露 `tree.resource.archive/restore/purge` legacy Convex 参数转换覆盖真实 Convex userId,导致 `mediaAssets.patchById` membership 检查失败;已修正 `mnote-web` transport,使资源 lifecycle 转换优先保留 `args_json.userId`。随后暴露 `/api/media/empty-trash` 不写 tree event,导致 B `/trash` 清空必须刷新;已补 `tree.trash.media.emptied` domain event 与 `resync_required` stream delta。 +- 2026-05-15:验证 `cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_resource_lifecycle_args_keep_effective_user_id -- --nocapture`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web media_trash_routes_delete_restore_purge_and_empty -- --nocapture` 均通过。 + +### 3.3 task434:mindmap / table 生命周期双浏览器 + +覆盖仍处 compat alias 的 mindmap / table: + +- mindmap 可复用现有 mindmap block / asset seed 方式。 +- table 可复用 `task427` 的 table seed 与 purge query 方式。 +- 本阶段不把 mindmap / table 宣称为正式 `tree.resource.*` cutover,只验证双浏览器可见性并保留 compat 边界。 + +执行记录: + +- 2026-05-15:`task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke` 已通过。A 浏览器对 mindmap 执行 create / archive / restore / archive+purge,对 table 执行 create / archive / restore / archive+purge,并对 mindmap + table 执行 create / archive / empty-trash;B 浏览器文件树与 `/trash` 均无需刷新可见更新,执行阶段无 `framenavigated`。结果文件:`tmp/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke/result.json`。 +- 2026-05-15:为避免把 table 预置数据误标为 create 实时证据,Rust `mnote-web` 已新增 `/api/tables/create` 兼容入口,创建成功后记录 `tree.resource.table.created` domain event 与 `resync_required` stream delta。mindmap / table 的 delete / restore / purge / empty-trash compat route 也补齐 tree resync artifacts。 +- 2026-05-15:事件来源记录为:mindmap create / archive / restore 主要触发 `tree:delta resync_required`,mindmap purge 触发 `tree:resync`;table create / archive / restore 主要触发 `tree:delta resync_required`,table purge / empty-trash 触发 `tree:resync`。 +- 2026-05-15:验证 `cargo test --manifest-path rust/Cargo.toml -p mnote-web trash_routes -- --nocapture --test-threads=1` 与 `node --check scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js` 均通过。 + +### 3.4 task435:empty-trash 双浏览器汇总 + +在一次性 workspace 中执行页面垃圾箱与资源垃圾箱清空: + +- A 清空页面垃圾箱后,B `/trash` 页面不刷新变为空。 +- A 清空资源垃圾箱后,B `/trash` 页面资源区不刷新变为空。 +- Convex query 断言对应对象不可再查询。 + +### 3.5 task436:local folder watcher 与本地资源生命周期 + +本阶段不混入 Convex 双浏览器矩阵,单独验证 local folder source: + +- 打开 local folder page tree 与 filetree,记录初始 `navigationEvents`。 +- 外部创建 `.md`,断言 page tree / filetree 可见,并记录是否由 `/api/local-folder/events`、`/api/tree/local-folder-watch`、reload 或手动刷新驱动。 +- 外部重命名 `.md`,断言旧 row 消失、新 row 出现;当前打开文档若受影响,应出现刷新或冲突提示。 +- 外部删除 `.md`,断言 page tree / filetree 中 row 消失;若当前打开该文档,应有明确状态提示。 +- 外部创建 / 重命名 / 删除至少两类非 md 文件,例如 `.txt` 与 `.png`,断言 filetree 与真实文件系统一致,并记录是否发生 `window.location.reload()`。 +- 对本地非 md 资源执行删除 / restore / purge;若 UI 仍禁用,记录为未完成能力,不得用 Markdown trash 代替。 + +当前只读审计结论: + +- `LocalFolderWatcherRegistry` 使用 `notify` 递归监听 root,但事件发送前经过 `is_markdown_path`,只允许 `.md/.markdown`。 +- `/api/local-folder/events` 订阅该 watcher,主要服务打开的本地 Markdown document session。 +- 主 tree 的 local folder 模式不接 `/api/tree/events`,而是轮询 `/api/tree/local-folder-watch`;revision 扫描可见条目,非 md 文件变化会改变 revision,但当前变化后触发 `window.location.reload()`。 +- `task163-local-folder-unified-tree-browser-smoke.js` 已覆盖外部新增 Markdown、外部新增 / 删除 `watcher-asset.txt` 的可见性;尚未覆盖外部 Markdown 重命名 / 删除、非 md rename、非 md trash / restore / purge。 + +通过后才允许在 10-review 中把 local folder 阶段 8 子项勾选;如果仍发生 reload,只能标为“reload 型刷新可见”,不能标为 no-refresh。 + +## 4. 实时来源记录 + +每个 smoke 结果必须记录: + +- B 浏览器是否接到 `/api/tree/events`。 +- B 浏览器是否接到 `tree:snapshot`、`tree:delta`、`tree:resync`。 +- B 侧 DOM 更新是否发生在无 reload / 无 framenavigated 的前提下。 +- 是否依赖了 A 浏览器本地 `refreshTree()`;如果依赖,只能作为 A 本地补偿,不计入 B 实时证据。 + +## 5. 当前边界 + +- 3000 常驻进程可能是旧 `mnote-web` 二进制;修改 Rust 后做 smoke 时必须重启 3000,或用新编译的临时 3001 并在结果中明确记录。 +- 资源生命周期中,普通附件 file asset 已进入正式 `tree.resource.*`;mindmap / table 仍是 compat route,阶段 8 只验证 no-refresh,不改变 cutover 口径。 +- local folder Markdown watcher 只有部分历史覆盖,且主 tree filetree 当前是 revision polling + reload;非 md 本地资源文件 watcher / trash / restore / purge 仍单列为待补,不应混入 Convex 双浏览器矩阵。 +- `resync_required` delta 不是数据替换本身,必须伴随后续 snapshot / resync 或客户端主动拉取 snapshot;后续新增资源 route 时必须补事件合同回归。 + +## 6. 完成标准 + +- `design/10-review/07-vscode-explorer-filetree-trash-gap-review.md` 阶段 8 的 Convex 页面、普通附件、mindmap、table checklist 全部有对应 smoke 证据。 +- local folder 子项必须有 task436 或等价 smoke 证据;若仍发生 reload,保持未完成 no-refresh 状态。 +- 每个 smoke 结果写入 `tmp/task43*-*/result.json`,并在 10-review 中记录命令、对象、实时来源和剩余边界。 +- 不再用单浏览器 no-refresh、API purge 成功或 Convex query 成功替代双浏览器 UI no-refresh。 diff --git a/design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md b/design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md new file mode 100644 index 00000000..f3e764b5 --- /dev/null +++ b/design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md @@ -0,0 +1,235 @@ +# 4-35 [process] Convex File Tree 标题即 Markdown 文件名体系切换 v1 + +> 更新时间:2026-05-15 +> +> 背景: +> - 用户反馈本地文件夹中新建页面会先显示 `/mnt/Data1T/mnote/tmp/image copy 98.png`,再转回 `新页面.md`。 +> - 用户判断这是在线文件夹与本地文件夹的冲突,后续希望“统一而不是继续局部修改”。 +> - 目标倾向:不再采用 Convex `page -> index.md` 体系,而是采用直接 `新页面.md` 体系,文件名与标题相同;在线文件夹尽量向本地文件夹靠拢。 + +## 1. 决策问题 + +当前 `convex_workspace` 的 File Tree 长期以复合资源树表达页面: + +```text +page/document + index.md + mindmap asset + attachment asset +``` + +`local_folder` 则天然以真实文件系统表达: + +```text +新页面.md +图片.png +子目录/ +``` + +这两种形态同时存在时,UI、active row、rename、新建、外部 watcher、projection refresh 容易出现不一致。用户观察到的“先显示 tmp 图片路径,再转回新页面.md”就是这类冲突的症状之一:同一套 File Tree UI 里混入了本地路径资源、Convex 页面容器、`index.md` 伪文件和异步投影校准。 + +本设计稿用于冻结新的方向:后续 Convex File Tree 应逐步向本地文件夹模型靠拢,把页面在文件树中的默认表现改为直接的 Markdown 文件行,例如 `新页面.md`,而不是页面容器下的 `index.md`。 + +## 2. 建议结论 + +建议新建一个设计文档,而不是只建 bug 修补。 + +原因: + +1. 这会改变 `file_tree` projection 合同,不只是修一个显示名称。 +2. 这会影响 `ObjectIdentity`、active row、rename/title sync、resource parent、trash、restore、search、open intent 和 smoke 期望。 +3. 现有 `4-24` / `5-12` 已把 `index.md` 作为 Page Aggregate body 的 object identity 收口到 done;如果要废弃或弱化该 UI 体系,必须有新的迁移设计覆盖旧口径,不能在代码里零散改。 +4. Convex 存储层仍然是 document record,不等于必须在 File Tree UI 中展示 `index.md` 伪文件。可以先改 projection / UI 语义,再决定是否迁移底层存储。 + +## 3. 范围 + +本设计只讨论 `convex_workspace` 在 File Tree 中的页面呈现与命令语义,不改变 `local_folder` 已有真实文件系统规则。 + +纳入范围: + +- Convex 页面在 File Tree 中显示为 `{title}.md`。 +- 新建页面时直接出现 `新页面.md`,不先出现 `page/index.md` 或临时资源路径。 +- 重命名页面时,标题与文件名同源:`新标题` 对应 `新标题.md`。 +- 点击 `{title}.md` 打开 Page Aggregate body。 +- mindmap、附件、OnlyOffice、代码附件继续作为同一页面下的资源对象,但不能再依赖可见的 `index.md` 子行作为锚点。 +- Active / reveal 以 `{title}.md` row 或明确 asset row 为准。 + +暂不纳入范围: + +- 立即迁移 Convex 底层 documents 表结构。 +- 自动双向同步 local folder 与 Convex。 +- Markdown 文件名与标题的所有非法字符、大小写、重名冲突最终规则;本稿先冻结方向,具体规则后续 checklist 补齐。 + +## 4. 新旧模型对比 + +旧模型: + +```text +新页面 + index.md + mindmap-mindmap_xxx.json + image.png +``` + +新目标模型: + +```text +新页面.md + mindmap-mindmap_xxx.json + image.png +``` + +解释: + +- `新页面.md` 是页面正文对象的 File Tree row。 +- 该 row 的 object identity 可继续内部映射到 Page Aggregate body,但 UI 不再展示 `index.md`。 +- 资源行仍然挂在页面正文对象下面,表达“属于该页面/正文 block 关联资源”。 +- Page Tree 仍可显示 `新页面`,不带 `.md`,作为页面导航 projection。 + +## 5. 关键合同变更 + +### 5.1 File Tree row + +Convex 页面正文 row: + +- `rowKind=markdown_page` 或沿用 `index` 但对 UI 隐藏 `index.md` 语义,需要后续冻结。 +- `title="{pageTitle}.md"`。 +- `objectIdentity="page:markdown:{documentId}"` 或兼容映射到既有 `page:index:{documentId}`,但对外不再暴露为 `index.md`。 +- `documentId` 仍是 Convex document id。 +- `resourceMeta.sourceKind="convex_workspace"`。 + +本地 `.md` row: + +- 保持真实文件名。 +- `objectIdentity` 与本地 page identity 绑定,不以裸路径作为长期唯一 id。 + +### 5.2 Rename / title sync + +Convex workspace 中: + +- 文件树重命名 `新标题.md` 应写入页面标题 `新标题`。 +- 页面标题编辑应能同步更新 File Tree 行显示为 `新标题.md`。 +- `.md` 扩展名是 projection/UI 层文件语义,不应直接写进页面标题。 +- 重名冲突必须返回结构化 preflight,不允许静默覆盖或临时跳成其它资源路径。 + +Local folder 中: + +- 继续以真实文件名为主,标题优先级仍按现有本地 Markdown 规则处理。 + +### 5.3 Create + +Convex workspace 新建页面: + +- 命令结果中应直接携带最终 File Tree row identity 与 `title="新页面.md"`。 +- UI 乐观插入时不得先显示本地 tmp 路径、上传文件名或 `index.md` fallback。 +- 如果后端 title 去重为 `新页面 2`,前端最终只校准成 `新页面 2.md`,中间不出现无关资源名。 + +### 5.4 Resource parent + +mindmap / attachment 等资源仍挂在页面正文对象下,但 parent row 从旧 `index:{documentId}` 或 page container 迁移到新的 markdown page row。 + +需要特别验证: + +- 点击 mindmap asset 后 active row 不跳回 `{title}.md`,除非确实打开的是页面正文。 +- 删除 `{title}.md` 进入页面垃圾箱;删除资源进入资源垃圾箱。 +- purge 页面时 Convex document 与其资源关系按正式命令同步清理或标记孤儿处理。 + +## 6. 与既有设计的覆盖关系 + +本稿不否定 `4-24` / `5-12` 的单一真源目标,只调整 File Tree 的可见模型: + +- 仍然保留 Page Aggregate body 是页面正文真源。 +- 仍然保留 mindmap / attachment / OnlyOffice 是独立 resource object。 +- 仍然要求 object identity 隔离,避免 mindmap 污染页面正文。 +- 变化是:Convex File Tree 不再必须通过可见 `index.md` 行表达页面正文对象。 + +后续如执行本设计,必须同步更新以下旧口径: + +- `design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md` 中“页面节点下固定派生 index.md row”的可见 UI 表达。 +- `design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md` 中 `index.md tab` 的命名口径,可迁移为 `markdown page tab` 或 `{title}.md tab`。 +- `design/10-review/done/05-tree.md` 作为历史记录不直接改写,但 README 或新设计需说明其 `index.md` 口径已被本稿覆盖。 + +## 7. 建议实施阶段 + +### P0:只改 Convex File Tree projection 的页面正文显示 + +- 新建页面后 File Tree 直接出现 `{title}.md`。 +- 点击 `{title}.md` 打开 Page Aggregate body。 +- Page Tree 仍显示无扩展名标题。 +- 保持底层 object identity 兼容,减少一次性改动。 + +### P1:Active / reveal / object identity 迁移 + +- active page body 映射到 `{title}.md` row。 +- mindmap asset 打开后保持 asset active,不降级为页面正文 row。 +- 长列表 reveal 稳定。 + +### P2:Rename / create / conflict preflight + +- 文件树 rename `{title}.md` 与页面标题同步。 +- 新建同名页面明确生成 `新页面 2.md` 或返回冲突选择。 +- 不再出现临时 tmp 路径或资源文件名作为页面 row title。 + +### P3:旧 index.md UI 退场 + +- 默认 Convex File Tree 不显示 `index.md`。 +- 如仍需 debug,可通过显式 debug/projection inspector 展示旧 identity。 +- 更新 smoke 和设计口径,避免后续继续围绕 `index.md` 修补 UI。 + +## 8. 验收标准 + +- 在 Convex workspace 新建页面后,File Tree 首次可见 row 就是 `新页面.md` 或去重后的 `{title}.md`,不得短暂显示 `/tmp/...`、图片文件名、`index.md` 或其它 fallback。 +- 点击 `{title}.md` 打开页面正文;点击 mindmap asset 打开 mindmap object editor,二者 active row 不互相覆盖。 +- 页面标题编辑与 File Tree rename 最终保持一致:标题 `ABC` 对应 File Tree `ABC.md`。 +- 同名冲突、非法字符、扩展名输入等情况有结构化 preflight 和可预测结果。 +- Local folder 与 Convex workspace 共用同一 File Tree UI 组件、selection/focus/reveal/context menu/keyboard 行为;差异只来自 source capability 与 executor。 +- 真实浏览器 smoke 覆盖:新建页面、重命名、点击页面正文、点击 mindmap、滚动下半部分点击、删除进垃圾箱、restore 后 reveal。 + +## 9. 当前状态 + +当前状态:`process` + +2026-05-15 已完成 P0/P1/P2 的最小切片: + +- `bridge-runtime` 的 Convex `file_tree` projection 不再为页面正文派生可见 `index.md` row。 +- Convex 页面正文 row 继续使用 `rowKind=document` / `rowId=doc:`,但标题改为 `{pageTitle}.md`。 +- mindmap / attachment / table 等资源继续挂在 `doc:` 下。 +- 3000 主文档壳本地 create apply 同步改为只插入 `doc:`,标题显示为 `{title}.md`,不再插入 `index:`。 +- File Tree 当前页面 selected / focused 默认改为 `doc:`。 +- `task169` 的“回到页面正文”验证从点击 `index:` 改为点击 `doc:`,object identity 仍为 `page`。 +- File Tree inline rename 对页面正文 row 输入 `{title}.md` 时,提交给 `tree.node.rename` 前会剥离 `.md`,页面标题保持 `{title}`,File Tree 显示保持 `{title}.md`。 +- File Tree inline rename 已加入主壳内校验:空名、非法文件名字符、同级同名页面会停留在输入框内并显示结构化提示,不提交 rename command。 + +已通过验证: + +```bash +cargo test --manifest-path rust/Cargo.toml -p bridge-runtime file_tree_projection -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web file_tree_projection -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime_uses_markdown_page_row_without_local_index_child -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_shell_filetree_renderer_outputs_initial_nested_html_contract -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime -- --nocapture +cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_shell -- --nocapture +MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task426-mnote-web-main-no-reload-smoke.js +MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task438-filetree-title-md-active-reveal-smoke.js +MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task439-filetree-title-md-rename-smoke.js +MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task440-page-title-filetree-md-sync-smoke.js +MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task169-mindmap-realtime-smoke.js +``` + +真实浏览器结果文件: + +- `tmp/task426-mnote-web-main-no-reload-smoke/result.json` +- `tmp/task438-filetree-title-md-active-reveal-smoke/result.json` +- `tmp/task439-filetree-title-md-rename-smoke/result.json` +- `tmp/task440-page-title-filetree-md-sync-smoke/result.json` +- `tmp/task169-mindmap-realtime-smoke/result.json` + +本轮 P3 收口: + +- 页面标题编辑反向同步到 File Tree `{title}.md` 已由真实浏览器 smoke `scripts/task440-page-title-filetree-md-sync-smoke.js` 覆盖:标题栏输入 `{title}` 后,File Tree 显示 `{title}.md`,Page Tree 与标题输入框保持 `{title}`,且不出现 `index:` 行。 +- `tree_shell/filetree_renderer.rs` 的默认 nested fixture 已从可见 `index.md` 子行改为页面 markdown row 下挂 mindmap asset;`rowKind=index` 仍只作为历史 projection / 命令兼容解析保留,不再作为默认 Convex File Tree UI 口径。 +- 旧设计中的“可见 `index.md`”表述由本稿覆盖:后续 UI / smoke / review 应以 `doc:` + `{title}.md` 作为 Convex 页面正文 row;如果文档讨论历史 `index.md`,必须明确它是兼容身份或历史记录,不是默认可见行。 + +仍保留的后续增强: + +- 删除进垃圾箱、restore 后 reveal、双浏览器 resync、搜索过滤下 reveal 等更宽场景继续由 `4-28`、`4-34` 与后续 tree domain checklist 覆盖。 diff --git a/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md b/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md index 0840cb74..9d87dd06 100644 --- a/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md +++ b/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md @@ -2,6 +2,10 @@ > 更新时间:2026-05-13 > +> 2026-05-15 口径更新: +> - 本文件中的 `index.md` tab / row 表述是完成当时用于隔离页面正文与资源对象的历史命名。 +> - Convex File Tree 默认可见页面正文行已由 `/mnt/Data1T/mnote/design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md` 覆盖为 `doc:` + `{title}.md`;后续 UI、smoke 与 review 不应再把可见 `index.md` 子行作为目标模型。 +> > 上游依据: > - `/mnt/Data1T/mnote/design/10-review/05-tree.md` > - `/mnt/Data1T/mnote/bugs/05-editor-mainline/done/5-10-mindmap-filetree-index-single-truth-split-v1.md` diff --git a/design/10-review/README.md b/design/10-review/README.md index b00e41a6..3644bb6d 100644 --- a/design/10-review/README.md +++ b/design/10-review/README.md @@ -1,8 +1,14 @@ # 10-review 审查总览 -> 执行状态:本轮 10-review 已完成并迁入 [done/](./done/) 留档。根目录只保留索引,避免后续 worker 把历史审查快照误当成当前待办。 +> 执行状态:`done/01` 到 `done/07` 均已归档。 -最终执行入口: +当前无活跃审查。 + +最新归档审查: + +- [VSCode Explorer 文件树与垃圾箱闭环增量审查](./done/07-vscode-explorer-filetree-trash-gap-review.md) + +上一轮最终执行入口: - [顺序执行清单与验收标准](./done/06-execution-checklist-and-acceptance.md) @@ -18,4 +24,6 @@ - `done/01` 到 `done/04` 是历史偏差审查快照;其中旧的“未完成 / 风险”条目已由 `done/06` 承接闭环,或转入对应主线设计文档继续跟踪。 - `done/05-tree.md` 的 Resource Tree / File Tree / Page Tree、ObjectIdentity、mindmap 与 `index.md` 隔离主线已由 `design/04-tree-domain/done/4-24-*` 与 `design/05-editor-mainline/done/5-12-*` 承接完成。 -- `done/06-execution-checklist-and-acceptance.md` 是本轮 10-review 的最终验收依据;后续只作为防回归和口径核验材料。 +- 2026-05-15 起,Convex File Tree 的默认可见页面正文行由 `design/04-tree-domain/process/4-35-convex-filetree-title-md-source-alignment-v1.md` 覆盖为 `doc:` + `{title}.md`;`done/05-tree.md`、`4-24`、`5-12` 中的 `index.md` 表述仅作为历史 object identity / 兼容语义理解,不再作为默认 UI 可见模型继续派生新任务。 +- `done/06-execution-checklist-and-acceptance.md` 是上一轮 10-review 的最终验收依据;后续只作为防回归和口径核验材料。 +- `done/07-vscode-explorer-filetree-trash-gap-review.md` 是 07-ai 开发前对 04-tree 文件树 / 页面树多选、默认删除进垃圾箱、垃圾箱恢复与永久删除、资源级 `tree.resource.*`、Convex purge 同步、VSCode Explorer 体验对标的增量审查归档。该审查 checklist 已闭合;后续仍应按文档中的“最低可用完成 / parity backlog”口径描述 VSCode Explorer 对标,不要把禁用态或待增强项说成完整 parity。 diff --git a/design/10-review/done/.mnote/page-ids.json b/design/10-review/done/.mnote/page-ids.json new file mode 100644 index 00000000..9695a42f --- /dev/null +++ b/design/10-review/done/.mnote/page-ids.json @@ -0,0 +1,6 @@ +{ + "pages": { + "新页面 2.md": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~202.md" + }, + "version": 1 +} \ No newline at end of file diff --git a/design/10-review/done/.mnote/trash-index.json b/design/10-review/done/.mnote/trash-index.json new file mode 100644 index 00000000..1a370960 --- /dev/null +++ b/design/10-review/done/.mnote/trash-index.json @@ -0,0 +1,57 @@ +{ + "entries": { + "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2.md": { + "archivedAt": 1778837327751, + "deletedAtMs": 1778837327751, + "documentId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2.md", + "originalFilePath": "新页面.md", + "originalRelativePath": "新页面.md", + "purgedAt": null, + "resourceKind": "markdown", + "resourceScope": "local_folder", + "trashEntryId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2.md", + "trashRelativePath": ".mnote/trash/新页面.md", + "trashedFilePath": ".mnote/trash/新页面.md" + }, + "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~203.md": { + "archivedAt": 1778837335689, + "deletedAtMs": 1778837335689, + "documentId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~203.md", + "originalFilePath": "新页面 3.md", + "originalRelativePath": "新页面 3.md", + "purgedAt": null, + "resourceKind": "markdown", + "resourceScope": "local_folder", + "trashEntryId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~203.md", + "trashRelativePath": ".mnote/trash/新页面 3.md", + "trashedFilePath": ".mnote/trash/新页面 3.md" + }, + "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~204.md": { + "archivedAt": 1778837335735, + "deletedAtMs": 1778837335735, + "documentId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~204.md", + "originalFilePath": "新页面 4.md", + "originalRelativePath": "新页面 4.md", + "purgedAt": null, + "resourceKind": "markdown", + "resourceScope": "local_folder", + "trashEntryId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~204.md", + "trashRelativePath": ".mnote/trash/新页面 4.md", + "trashedFilePath": ".mnote/trash/新页面 4.md" + }, + "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~205.md": { + "archivedAt": 1778837335779, + "deletedAtMs": 1778837335779, + "documentId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~205.md", + "originalFilePath": "新页面 5.md", + "originalRelativePath": "新页面 5.md", + "purgedAt": null, + "resourceKind": "markdown", + "resourceScope": "local_folder", + "trashEntryId": "local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~205.md", + "trashRelativePath": ".mnote/trash/新页面 5.md", + "trashedFilePath": ".mnote/trash/新页面 5.md" + } + }, + "version": 1 +} \ No newline at end of file diff --git a/design/10-review/done/.mnote/trash/新页面 3.md b/design/10-review/done/.mnote/trash/新页面 3.md new file mode 100644 index 00000000..13263aca --- /dev/null +++ b/design/10-review/done/.mnote/trash/新页面 3.md @@ -0,0 +1,5 @@ +--- +mnote_id: local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~203.md +title: 新页面 +--- +# 新页面 diff --git a/design/10-review/done/.mnote/trash/新页面 4.md b/design/10-review/done/.mnote/trash/新页面 4.md new file mode 100644 index 00000000..e30fed71 --- /dev/null +++ b/design/10-review/done/.mnote/trash/新页面 4.md @@ -0,0 +1,5 @@ +--- +mnote_id: local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~204.md +title: 新页面 +--- +# 新页面 diff --git a/design/10-review/done/.mnote/trash/新页面 5.md b/design/10-review/done/.mnote/trash/新页面 5.md new file mode 100644 index 00000000..af66cf11 --- /dev/null +++ b/design/10-review/done/.mnote/trash/新页面 5.md @@ -0,0 +1,5 @@ +--- +mnote_id: local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~205.md +title: 新页面 +--- +# 新页面 diff --git a/design/10-review/done/.mnote/trash/新页面.md b/design/10-review/done/.mnote/trash/新页面.md new file mode 100644 index 00000000..fe223f61 --- /dev/null +++ b/design/10-review/done/.mnote/trash/新页面.md @@ -0,0 +1,5 @@ +--- +mnote_id: local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2.md +title: 新页面 +--- +# 新页面 diff --git a/design/10-review/done/07-vscode-explorer-filetree-trash-gap-review.md b/design/10-review/done/07-vscode-explorer-filetree-trash-gap-review.md new file mode 100644 index 00000000..4410125f --- /dev/null +++ b/design/10-review/done/07-vscode-explorer-filetree-trash-gap-review.md @@ -0,0 +1,609 @@ +# 07 VSCode Explorer 文件树与垃圾箱闭环增量审查 + +> 更新时间:2026-05-15 +> 状态:done +> 范围:`04-tree-domain` 中 File Tree / Page Tree / Resource Tree 与垃圾箱、删除、恢复、永久删除、VSCode Explorer 对标相关缺口。 +> 结论:本审查 checklist 已闭合。当前可表述为文件树多选、删除进入垃圾箱、资源生命周期、恢复位置、双浏览器 no-refresh、本地 folder watcher 与本地非 md 资源 trash baseline 已完成最低可用闭环;但 VSCode Explorer 对标仍应按“最低可用完成 / parity backlog”分层,不应把禁用态或后续增强项描述为完整 parity。 + +## 1. 本轮判断 + +- `design/04-tree-domain/done/4-12-*` 与 `4-14-*` 的 done 口径是 Page Tree / File Tree initial DOM、hydration、row contract、selection state family 与能力保留完成,不等于 final DOM shell、VSCode Explorer 体验或垃圾箱产品闭环完成。 +- `design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md` 已经把 VSCode Explorer 对标列为 done,但文档自身仍明确写着资源 `rename/delete/restore` 不能长期停留在 legacy `/api/media/batch`,需要 `tree.resource.rename/archive/restore` 或等价正式命令。 +- 当前 `wolai-frontend/src/components/sidebar/sidebar.tsx` 已经存在文件树多选删除和垃圾桶 Drawer,但仍以页面、附件、mindmap、table 的分散 route 组合闭环;这更像“可用基础”,不是统一 tree-first command 产品面。 +- 初始审查时 `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中“删除到垃圾桶”上下文菜单实际 dispatch `action: 'purge'`,这是已在阶段 1 止血的最高优先级语义风险;后续仍需防回归。 + +## 2. 主要缺口 + +### F-01 P0:Rust Web 主壳“删除到垃圾桶”实际触发永久删除 + +初始证据: + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 的 `delete-trash` 分支显示确认文案“删除到垃圾桶”,但 dispatch `action: 'purge'`。 +- `wolai-frontend/src/app/api/tree/commands/route.ts` 中 `purge` 对应 `tree.node.purge`,语义是永久删除。 + +当前状态: + +- 阶段 1 已改为 dispatch `archive`,并由 `sidebar_tree_delete_to_trash_dispatches_archive_not_purge` 与真实浏览器 smoke 防回归。 + +影响: + +- 用户以为页面进入垃圾桶,实际可能走永久删除。 +- 这会破坏“默认 Delete = archive / trash,永久删除只在 Trash 工作台强确认”的 VSCode Explorer 基线。 + +应执行: + +- `delete-trash` 必须改为 `archive` / `tree.node.archive`。 +- `purge` 只能从垃圾箱中的“彻底删除 / 清空垃圾箱”入口触发,并保留更强确认。 + +### F-02 P0:垃圾箱产品面存在,但 `/trash` 与 3000 主壳口径未闭合 + +证据: + +- `wolai-frontend/src/components/sidebar/sidebar.tsx` 已有 `Drawer` 垃圾桶,支持页面 / 附件 tab、搜索、恢复、彻底删除、清空。 +- `rust/crates/mnote-web/src/workspace_shell.rs` 暴露 footer entry:`href: "/trash"`。 +- 当前只检索到 `/api/*/empty-trash`,未看到完整 `wolai-frontend/src/app/trash` 或 Rust `/trash` 工作台页面。 + +影响: + +- Sidebar Drawer 可用不等于全局 Trash 工作台可用。 +- workspace shell 的“垃圾箱”链接可能成为空入口或体验断点。 + +应执行: + +- 明确产品口径:垃圾箱是 Sidebar Drawer、独立 `/trash` 页面,还是两者都有。 +- 若保留 `/trash`,必须有真实路由、真实数据、恢复、彻底删除、清空和浏览器验收。 +- 若只保留 Drawer,应移除或改写 `/trash` 链接,避免入口误导。 + +### F-03 P0:删除默认语义必须先进入垃圾箱,永久删除必须只从垃圾箱触发 + +证据: + +- 页面删除在 `deleteDocumentCommand` 主链中已经走 `archive`,Convex `documents.softDelete` 会写 `deleted_at`。 +- 但 Rust Web 主壳存在 `delete-trash -> purge` 反向危险。 +- 附件、mindmap、table 删除链分别走 `/api/media/batch`、`/api/mindmap/:doc/:id`、`/api/tables/:id` 等分散 route。 + +影响: + +- 同一个“删除”动作在不同入口、不同资源类型下语义不一致。 +- 用户无法建立稳定心智:删除后是否能恢复,永久删除是否真的同步清理 Convex。 + +应执行: + +- 统一命名:默认删除 = `archive` / `trash`;彻底删除 = `purge`。 +- 所有入口的确认文案、commandName、route、Convex mutation 必须同义。 +- 删除后必须能在垃圾箱中看到对象,恢复后回到 projection,彻底删除后 Convex 查询不到对象。 + +### F-04 P1:文件树多选基础已存在,但页面树与批量命令闭环不足 + +证据: + +- `wolai-frontend/src/lib/file-tree/selection.ts` 与 `tree_shell/filetree_selection.rs` 已支持普通点击、Ctrl/Cmd 多选、Shift 范围、多选 toggle、右键保留选区。 +- `wolai-frontend/src/components/sidebar/tree-shell-dom-host.tsx` 会把 Delete / Backspace 转给 `onFileTreeDeleteSelection`。 +- `wolai-frontend/src/components/sidebar/sidebar.tsx` 的批量页面删除是逐个 `deleteDocumentCommand`,不是一个原子批量 tree command。 +- 页面树没有同等级多选模型,主要是 focus / active / 单节点 context menu。 + +影响: + +- 用户指出的“多选删除”在文件树有基础,但没有形成 VSCode Explorer 式跨入口一致能力。 +- 批量删除部分失败时会出现部分已删、部分未删,缺少统一 plan / rollback / result summary。 + +应执行: + +- 文件树先补真实浏览器 smoke:Ctrl/Cmd 多选、Shift 范围、右键已选项保持 selection、Delete 批量删除。 +- 批量删除应进入统一 preflight plan,至少对父子重复选择去重,并返回可解释的 partial failure。 +- 页面树是否需要多选要明确产品口径;若 page tree 也承担 Explorer 行为,应补 selection state family。 + +### F-05 P1:资源级 `tree.resource.*` 未收口 + +证据: + +- `4-20` 明确写到资源 `copy/move/upload` 已有 `tree.resource.copy/move/upload`,但 `rename/delete/restore` 仍经过 legacy `/api/media/batch`,需要 `tree.resource.rename/archive/restore` 或等价 bridge。 +- 当前前端 `handleDeleteAssets` 对 mindmap、table、file asset 分别调用不同 route,并在删除后 `refreshTree()`。 + +影响: + +- File Tree / Resource Tree 仍有第二套资源命令真相。 +- 后续 07-ai 若依赖资源树状态,容易再次绕过 tree-first command。 + +应执行: + +- 定义并落地 `tree.resource.archive`、`tree.resource.restore`、`tree.resource.purge`、`tree.resource.rename`,或明确等价 bridge 命名。 +- legacy `/api/media/batch` 只能作为兼容 alias,不应是主路径。 +- command event 与 projection resync 必须覆盖资源恢复、永久删除、重命名。 + +### F-06 P1:垃圾箱清空需要跨 documents / media / mindmap / table 一致验收 + +证据: + +- 当前存在 `/api/documents/empty-trash`、`/api/media/empty-trash`、`/api/mindmap-trash/empty`、`/api/tables/empty-trash`。 +- Sidebar 中“页面垃圾桶”和“附件垃圾桶”分别清空:页面只调 documents;附件 tab 并发清空 media、mindmap、table。 + +影响: + +- 用户预期的“垃圾箱删除后 Convex 同步删除”必须跨对象类型验收,而不是只验收页面。 +- 如果多个 API 中任一失败,可能出现计数、projection 与 Convex 数据不一致。 + +应执行: + +- 清空垃圾箱需要明确是否是一键全量清空,还是按页面 / 附件 tab 分区清空。 +- 验收必须覆盖 Convex `documents`、`media_assets`、`mindmaps`、`document_tables` 以及页面关联数据清理。 +- 对 Storage / OCR / LightRAG / 引用 / 最近访问 / 收藏等关联清理给出确认边界。 + +### F-07 P1:恢复位置、排序与子树还原规则不足 + +证据: + +- 子代理审查指出 Convex 页面 restore 会把根节点 `parent_id` 置为 `null`,子节点按同一 `deleted_at` 级联恢复;原父位置 / 排序不一定完整还原。 + +影响: + +- “恢复”只能证明对象复活,不等于恢复到删除前位置。 +- 对文件树体验来说,恢复后 reveal/select/focus 也属于闭环的一部分。 + +应执行: + +- 删除前记录原 parent / order / source view / expanded path,恢复后尽量还原。 +- 如果父节点已不存在,应有明确 fallback:恢复到根、恢复到最近有效父、或提示用户选择位置。 +- 恢复后必须 reveal 并选中对象,避免用户以为恢复失败。 + +### F-08 P1:VSCode Explorer 的 cut / inline rename / context menu 命令面未完全对齐 + +证据: + +- 文件树键盘层会产生 `cutSelection` reducer 事件,但全局粘贴链主要读取 `action: "copy"` 的剪贴板写入,cut-move paste 未完整确认。 +- F2 目前更像触发 `beginRenameFocused`,实际重命名仍大量依赖右键菜单 / prompt,不是 VSCode 式 inline rename。 +- 右键菜单缺少或未统一:Cut、Paste Into、New File、New Folder、Refresh 单节点、Collapse All、Reveal、Copy Path / Relative Path 等 Explorer 常见项。 + +影响: + +- 当前“对标 VSCode Explorer”容易被误读成完成全部 Explorer 产品能力。 +- 07-ai 若把这些能力当成已有,会在自动操作、上下文菜单和 keyboard workflow 中遇到断点。 + +应执行: + +- P1 先收口 Cut / Paste move、F2 inline rename、右键命令可用性。 +- P2 再补 Reveal、Collapse All、Copy Path、Refresh 单节点等效率项。 +- 不应照搬 VSCode 文件系统 provider 复杂度,但应照搬 command context 和 selection/focus 规则。 + +### F-09 P1:本地文件夹与 Convex 不是同一 executor,watch 范围也不一致 + +证据: + +- Rust Web `tree.rs` 支持 `/api/tree/commands`,但 `local_folder` 分支走独立 `execute_local_tree_command`。 +- 本地 watcher 当前主要监听 markdown 路径事件,未覆盖非 md 资源文件的完整资源树变更。 +- `4-22` 已把 Local Folder / Convex 统一 source 大范围标为完成,但 `4-23` import/export/publish/sync 候选合同仍在 `process/`。 + +影响: + +- “Local Folder 与 Convex 共用一套 projection / command / preflight / runtime / UI”仍需要拆成已完成和待核验两层。 +- 普通附件、OnlyOffice、mindmap、本地资源文件的 Trash / restore / purge 不能默认等同于 markdown page trash。 + +应执行: + +- 10-review 口径中明确:local markdown/page trash baseline 已有,完整 Resource Tree / File Tree Trash 待核验。 +- 本地资源文件 watch、trash、restore、purge 需要单独验收。 + +### F-10 P2:实时刷新仍混合 refetch,缺少 no-refresh 级回归验收 + +证据: + +- Sidebar 删除资产后仍显式 `refreshTree()`。 +- 当前同时存在 Convex live/query、tree SSE、手动 refetch、本地 watcher 多条刷新链。 + +影响: + +- “刷新浏览器才能看到变化”类问题容易复发。 +- 局部 optimistic update、server delta、manual refetch 之间没有统一验收矩阵。 + +应执行: + +- 保留手动 refetch 作为兜底,但主验收必须是无浏览器刷新可见。 +- 覆盖双浏览器:A 删除 / 恢复 / 清空,B 通过 Convex live 或 tree stream 自动更新。 + +### F-11 P1:F2 inline rename 仍缺 VSCode 式行内校验体验 + +证据: + +- VSCode Explorer rename 输入框会做空名、非法名、同级重名等 validation,并在输入框上下文展示错误。 +- 当前 filetree 已有 F2 inline rename,但主要是 trim 后提交;失败路径仍可能落到 alert / 后端错误提示,不是行内 validation。 +- 2026-05-15 二次修正后,`wolai-frontend` DOM host 已补空名、非法路径字符、同级重名行内阻断和普通附件扩展名保留;Rust SSR srcdoc 路径仍需后续 smoke 核验是否同等级。 + +影响: + +- “能 F2 重命名”不等于 VSCode Explorer rename 体验闭环。 +- 07-ai 或自动化操作若只看 input 存在,可能无法在非法名、重名、扩展名策略下获得稳定可解释反馈。 + +应执行: + +- 页面、普通附件、本地 Markdown、本地非 md 资源统一行内校验:空名、非法字符、同级重名、扩展名保留 / 改写策略。 +- Enter 不提交非法值;Escape 取消;blur 行为必须明确。 +- 错误提示必须出现在 rename input 附近,不能只依赖 alert。 + +### F-12 P2:右键菜单仍是最低项可见,不是完整 VSCode Explorer command surface + +证据: + +- 当前已补 `New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Reveal` 的最低可见或禁用态。 +- `New Folder` 在当前页面树下仍是禁用态;`Paste Into` 已在主 Sidebar 节点右键菜单接入 action target,但空白区右键菜单与 Rust SSR srcdoc 路径仍需后续 smoke / 实现时继续沿同一 target 规则验收。 + +影响: + +- 当前只能说“最低菜单项有口径”,不能说“VSCode Explorer 菜单面已完整对齐”。 +- 右键 action target 与键盘 paste target 若不统一,批量选择和右键未选项切换目标会再次出现歧义。 + +应执行: + +- 明确哪些菜单项是长期不做、哪些进入 backlog。 +- 至少补 `Copy Relative Path` 的 workspace-relative / local-root-relative 语义和真实复制验收。 +- `Paste Into` 应使用右键 action target,并与 Ctrl/Cmd+V 的 focused target 规则写清。 + +### F-13 P2:`resync_required` 事件合同需要防回归 + +证据: + +- `applyTreeStreamDelta` 对 `resync_required` 本身不直接替换 tree 数据,必须依赖后续 snapshot / resync envelope。 +- 阶段 8 smoke 已证明本轮页面、附件、mindmap、table 链路能触发 no-refresh 更新,但这不能单独证明所有未来 `resync_required` 都会配套 resync。 + +影响: + +- 如果后续 route 只写 `resync_required` delta、不写 snapshot / resync,B 浏览器会停留在旧数据。 +- 这类问题表面上会再次表现为“必须刷新浏览器才能看到变化”。 + +应执行: + +- 给 tree event stream 增加合同级回归:每个 `resync_required` 必须伴随后续 snapshot / resync,或客户端必须主动拉取新 snapshot。 +- 验收断言包括 cursor 顺序、断线重连、重复 resync 去重。 +- smoke 结果中继续记录实际事件来源,不把本地 `refreshTree()` 误标为实时同步。 + +## 3. 按顺序执行的 checklist + +### 阶段 1:P0 删除语义止血 + +- [x] 修正 `rust/crates/mnote-web/src/ssr/pages/layout.rs`:`delete-trash` 必须 dispatch `archive`,不能 dispatch `purge`。 +- [x] 补单元测试或字符串级回归:菜单文案“删除到垃圾桶”不得与 `tree.node.purge` 绑定。 +- [x] 真实浏览器验证:Rust 3000 主壳右键删除页面后,页面进入垃圾桶,Convex `deleted_at` 非空,未被永久删除。 + +验收标准: + +- Network / bridge log 中默认删除命令为 `tree.node.archive`。 +- `tree.node.purge` 只出现在垃圾箱“彻底删除 / 清空”路径。 + +执行记录: + +- 2026-05-15:`delete-trash` 已改为 `archive`;新增 `sidebar_tree_delete_to_trash_dispatches_archive_not_purge` 回归测试。 +- 2026-05-15:浏览器 smoke 使用 `TEST-10REVIEW-07-P0-*` 新建页面后右键删除,捕获请求 `action: "archive"`、bridge command `tree.node.archive`、响应 `deleted_at` 非空、同页面 `purge` 请求数为 0。 + +### 阶段 2:统一垃圾箱入口与产品口径 + +- [x] 决定 `/trash` 是独立页面还是移除入口;如果保留,补真实 Trash 工作台。 +- [x] Sidebar Drawer 与 `/trash` 使用同一 dataset:`trashed_documents`、`trashed_media_assets`、`trashed_mindmap_assets`、`trashed_table_assets`。 +- [x] 页面、附件、mindmap、table 均支持恢复、彻底删除、清空。 + +验收标准: + +- 不存在点开“垃圾箱”后空路由或 404。 +- 垃圾箱计数与列表一致;恢复 / 彻底删除后计数和列表实时更新。 + +执行记录: + +- 2026-05-15:Rust 3000 主壳已挂载 `GET /trash`,复用 `sidebar.datasetList` 的 `trashed_*` 数据渲染垃圾箱工作台;页面条目提供恢复与彻底删除按钮。 +- 2026-05-15:补 `trash_entry_renders_real_workspace_trash_workbench` 路由级回归;浏览器 smoke 访问 `/trash?workspaceId=` 返回 200,出现 `mnote-trash-workbench`、页面区、资源区、恢复按钮和彻底删除按钮。 +- 2026-05-15:Rust 3000 主壳已挂载 `POST /api/documents/empty-trash`,补 `documents_empty_trash_route_executes_workspace_trash_purge` 回归;`/trash` 页面已出现“清空页面垃圾箱”按钮,并只读浏览器 smoke 验证按钮存在,未点击清空以避免删除既有垃圾箱数据。 +- 2026-05-15:Rust 3000 主壳已新增资源垃圾箱兼容入口:`POST /api/media/batch` restore、`POST /api/media/purge`、`POST /api/media/empty-trash`、`PATCH /api/mindmap/{docId}/{mindmapId}` restore / purge、`POST /api/mindmap-trash/empty`、`POST /api/tables/restore`、`POST /api/tables/purge`、`POST /api/tables/empty-trash`;补 `media_trash_routes_restore_purge_and_empty`、`mindmap_trash_routes_restore_purge_and_empty`、`table_trash_routes_restore_purge_and_empty` 回归。 +- 2026-05-15:`/trash` 资源区已从“待迁移”改为真实恢复 / 彻底删除按钮,并新增“清空资源垃圾箱”;路由级 fixture 覆盖附件、mindmap、table 三类资源按钮。 +- 2026-05-15:`cargo test -p mnote-web trash` 通过 7 项;Playwright 只读 smoke 访问 `http://localhost:3001/trash?workspaceId=ws_demo` 返回 200,确认工作台、页面区、资源区、“清空页面垃圾箱”和“清空资源垃圾箱”存在。真实数据当前没有垃圾箱资源行,未执行清空或彻底删除动作。 +- 注意:本阶段完成的是 `/trash` 产品入口和 Rust 兼容 route 止血;附件 / mindmap / table 尚未正式收口为 `tree.resource.*` 命令,继续由阶段 5 跟踪。页面恢复 / 彻底删除 / 清空后的跨浏览器实时计数仍需在阶段 10 的 no-refresh 验收覆盖。 + +### 阶段 3:删除进入垃圾箱,清空后 Convex 同步永久删除 + +- [x] 创建测试页面、子页面、附件、mindmap、table,删除后均进入对应垃圾箱。 +- [x] 清空页面垃圾箱后,确认 `documents.emptyTrashByWorkspace` 或等价 command 删除文档子树及关联数据。 +- [x] 清空附件垃圾箱后,确认 `mediaAssets.emptyTrashByWorkspace`、`mindmaps.emptyTrashByWorkspace`、`tables.emptyTrashByWorkspace` 或统一 route 均执行成功。 +- [x] 记录 Storage / OCR / LightRAG / page references / recent pages / favorites 的清理边界。 + +验收标准: + +- Convex 查询不到已 purge 的 document / asset / mindmap / table。 +- 清空失败时 UI 给出明确错误,不出现“UI 消失但 Convex 仍保留”的假成功。 + +安全前置: + +- `emptyTrashByWorkspace` 是 workspace 级破坏性动作;写入型 smoke 必须使用本轮独立 workspace 或可证明只含本轮 `TEST-10REVIEW-07-P3-*` 数据的隔离环境。 +- 禁止在默认测试账号的既有 workspace 上直接点击“清空页面垃圾箱 / 清空资源垃圾箱”,否则可能误删同一用户历史垃圾箱数据。 +- 若当前没有安全 workspace seed 机制,先保留代码审查与 route 级 fixture 验收,补齐隔离 workspace 后再执行真实清空。 + +执行记录: + +- 2026-05-15 代码审查:`documents.emptyTrashByWorkspace` 会删除当前用户在 workspace 内 `deleted_at != null` 的 documents,并调用 `purgeDocumentRelatedData` 清理关联 `mindmaps`、`media_assets`、`document_tables` / `document_table_rows`、`comment_messages` / `comment_threads`、`page_references`、`document_stars`、`user_recent_pages`;附件底层 Convex Files 只在同一 `storage_id` 没有其它未清理引用时删除。 +- 2026-05-15 代码审查:`mediaAssets.emptyTrashByWorkspace` 会清理已过期 `deleted_at` 或历史 `purged_at` 的 `media_assets`,并按 `storage_id` 引用计数决定是否删除 Convex Files;`mindmaps.emptyTrashByWorkspace` 删除当前用户已软删 mindmap;`tables.emptyTrashByWorkspace` 删除已归档且到期的 `document_tables` 及其 `document_table_rows`。 +- 2026-05-15 边界记录:OCR 文本随 `media_assets` 行删除;LightRAG 已有 `ingest.rag_index_*` 任务入口,但当前 purge 路径未发现反向删除 LightRAG 索引的实现,应作为后续补充边界而非已完成项。 +- 2026-05-15 代码缺口止血:Rust 3000 主壳已补资源软删除兼容入口,避免阶段 3 被“资源无法进入垃圾箱”阻塞: + - `POST /api/media/batch` 支持 `action: "delete"`,写入 `media_assets.deleted_at / deleted_by`。 + - `DELETE /api/mindmap/{docId}/{mindmapId}` 调用 `mindmaps.softDelete`。 + - `DELETE /api/tables/{tableId}` 调用 `tables.remove`。 + - `cargo test -p mnote-web trash` 覆盖 `media_trash_routes_delete_restore_purge_and_empty`、`mindmap_trash_routes_delete_restore_purge_and_empty`、`table_trash_routes_delete_restore_purge_and_empty`。 +- 2026-05-15 安全验证结论:未找到现成 smoke 能安全创建独立 workspace 并完成页面 + 附件 + mindmap + table 的真实清空验收;默认测试账号已有 workspace,不能直接对其执行 workspace 级 empty trash。 +- 2026-05-15:已补详细执行设计 `design/04-tree-domain/done/4-25-trash-empty-isolated-workspace-smoke-v1.md`,阶段 3 写入型 smoke 必须按一次性用户 / 一次性 workspace 隔离方案执行。 +- 2026-05-15:已固化并执行 `scripts/task427-trash-empty-isolated-workspace-smoke.js`,一次性用户 `mnote.trash.1778793827654@example.com` 创建隔离 workspace `ws_req_1778793827876_33`,seed 页面 `tree_1778793827972_4`、子页面 `tree_1778793827997_5`、附件 `asset_trash_1778793827654`、mindmap `mind_trash_1778793827654`、table `mp5zx1wjqwssfr4g5jj`;删除后确认进入垃圾箱,随后清空页面与资源垃圾箱,并用 Convex query 确认 document / media_asset / mindmap / document_table / document_table_rows 均不可再查询。 +- 2026-05-15:真实 smoke 输出:`documents.emptyTrashByWorkspace` 删除 2 个页面;`mediaAssets.emptyTrashByWorkspace` 删除 1 个附件;`mindmaps.emptyTrashByWorkspace` 删除 1 个 mindmap;`tables.emptyTrashByWorkspace` 删除 1 个 table。 + +### 阶段 4:文件树多选与批量删除 + +- [x] 文件树创建 3 个页面、2 个附件、1 个 mindmap、1 个 table 测试数据。 +- [x] 验证 Ctrl/Cmd 多选、Shift 范围、右键已选项保持 selection、右键未选项切换 action target 的 reducer / DOM host / local-folder 浏览器证据。 +- [x] 验证 Convex filetree 下 Ctrl/Cmd、Shift、右键已选 / 未选 action target 的真实端到端行为。 +- [x] 验证 Delete / Backspace 批量删除混合 doc + asset。 +- [x] 验证父子页面同时选中时 preflight 只生成一次有效删除计划,不重复删除子树或页面下资产。 +- [x] 验证部分失败时保留可理解的 result summary,并刷新到真实服务端状态。 + +验收标准: + +- 删除前确认弹窗准确列出页面、附件、mindmap、table 数量。 +- 删除后无需刷新浏览器即可从文件树消失,并在垃圾箱中出现。 + +执行记录: + +- 2026-05-15 代码审查:`wolai-frontend/src/lib/file-tree/selection.ts` 的 `reduceFileTreeSelection` 已支持 Ctrl/Cmd toggle、Shift range、右键已选项保持 selection、右键未选项切单选;`selection.test.ts` 已覆盖这些 reducer 规则。 +- 2026-05-15 代码审查:`wolai-frontend/src/components/sidebar/tree-shell-dom-host.tsx` 已把真实点击、右键、Delete、Backspace 接到 filetree selection 与 `onFileTreeDeleteSelection`;`tree-shell-host.test.tsx` 已覆盖 Delete / Backspace 使用当前稳定多选 selection,避免旧 runtime selection 回写覆盖新 selection。 +- 2026-05-15 代码审查:`handleDeleteResourceSelection` 已走 `preflightFileTreeDelete(buildFileTreeShellDeletePreflightPayload(...))`,确认文案会按页面、附件、思维导图、在线表格分别计数。 +- 2026-05-15 代码审查:父子去重已有两层实现证据:TS legacy `computeFileTreeDeleteTargets` 会过滤被父页面覆盖的子页面 / 资产;Rust `build_filetree_delete_plan` 会过滤 top-level docs,并跳过被选中页面或其祖先页面覆盖的资产。`tree_filetree_delete_preflight_plans_doc_and_asset_targets` 已覆盖父页面 + 子 index + 父/子资产 + 另一页面资产同时选中时,最终只保留父页面与未被该子树覆盖的外部资产。 +- 2026-05-15 验证:`cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime tree_filetree_delete_preflight_plans_doc_and_asset_targets` 通过,1 项通过、0 失败。 +- 2026-05-15 浏览器证据边界:`scripts/task163-local-folder-unified-tree-browser-smoke.js` 已覆盖 local-folder 的 Ctrl/Cmd 多选、右键已选保持、Shift 范围、右键未选切换、Delete preflight 取消;`scripts/task179-tree-create-delete-no-reload-smoke.js` 只覆盖 Convex filetree 单页面 Delete 后无导航 / 无刷新;`scripts/task427-trash-empty-isolated-workspace-smoke.js` 覆盖隔离 workspace 的 API seed 与清空垃圾箱,不是 filetree UI 多选删除。 +- 2026-05-15:已补阶段 4 执行设计并归档为 `design/04-tree-domain/done/4-26-filetree-bulk-delete-selection-smoke-v1.md`,明确一次性用户 / workspace seed 与 Convex filetree UI 多选删除验收矩阵。 +- 2026-05-15:已新增 `scripts/task428-filetree-bulk-delete-selection-smoke.js`,真实浏览器使用一次性用户 `mnote.filetree.1778795393112@example.com` 和隔离 workspace `ws_req_1778795393339_3` 创建 3 页面、2 附件、1 mindmap、1 table;在 3001 主入口 filetree 验证 Ctrl/Cmd 多选、Shift 范围、右键已选保持、右键未选切换。 +- 2026-05-15:`task428` Delete 轮选择 root + child + file asset + mindmap + table,确认文案为 `1 个页面 + 1 个附件 + 1 个思维导图 + 1 个在线表格`,请求为 `tree.node.archive`、`/api/media/batch delete`、`DELETE /api/mindmap/{docId}/{mindmapId}`、`DELETE /api/tables/{tableId}`;父子页面去重后只发送 root archive,child 随子树删除。 +- 2026-05-15:`task428` Backspace 轮选择 sibling + 第二个 file asset,确认文案为 `1 个页面 + 1 个附件`,请求为 `tree.node.archive` 与 `/api/media/batch delete`;两轮均验证目标行无需刷新从 filetree 消失,并用 Convex query 确认对应 document / media / mindmap / table 进入垃圾箱。 +- 2026-05-15:阶段 4 新增 Rust SSR 主壳键盘批量删除实现。原因是 `task428` 初次失败暴露 3001 主壳 filetree selection 存在,但 Delete / Backspace 未进入删除处理;已补 `deleteSelectedSidebarFileTreeRows`,支持混合页面、附件、mindmap、table 删除,并补 `sidebar_filetree_delete_keys_support_mixed_doc_and_asset_selection` 回归。 +- 2026-05-15 验证:`MNOTE_UI_BASE_URL=http://127.0.0.1:3001 node scripts/task428-filetree-bulk-delete-selection-smoke.js` 通过,结果文件 `tmp/task428-filetree-bulk-delete-selection-smoke/result.json` 记录请求、确认文案与部分失败摘要。 +- 2026-05-15:`task428` 部分失败轮通过 route interception 强制 `/api/media/batch` 对 `asset_p4_failure_1778795530948` 返回 500;页面 `tree_1778795531341_8` 成功进入垃圾箱并从 filetree 消失,失败附件仍保留且 `deleted_at` 为空,UI 弹出 `部分对象删除失败:asset_p4_failure_1778795530948`。 +- 2026-05-15 阶段 4 结论:阶段 4 子项已完成;剩余更长期问题转入阶段 5-8,包括资源正式 `tree.resource.*` 命令、恢复位置、VSCode Explorer 完整体验与双浏览器 no-refresh。 + +### 阶段 5:资源级 tree command 收口 + +- [x] 新增或确认 `tree.resource.archive`、`tree.resource.restore`、`tree.resource.purge`、`tree.resource.rename` 正式命令。 +- [x] 将普通附件 `/api/media/batch` / `/api/media/purge` 收束为正式 `tree.resource.*` 兼容 alias。 +- [x] 将 mindmap / table delete / restore / purge 收束为正式 `tree.resource.archive / restore / purge` 兼容 alias。 +- [x] command event、projection resync、sidebar dataset 均能表达普通附件资源恢复和永久删除。 + +验收标准: + +- 文件树资源删除 / 恢复 / 永久删除不再只依赖 legacy `/api/media/batch`、`/api/mindmap/*`、`/api/tables/*` 主路径;旧 URL 只能作为兼容 alias。 +- 资源 commandName、确认文案、UI 状态一致。 + +执行记录: + +- 2026-05-15 只读核对:阶段 5 未完成。现有正式资源命令只覆盖 `tree.resource.copy`、`tree.resource.move`、`tree.resource.upload`;`tree.resource.rename`、`tree.resource.archive`、`tree.resource.restore`、`tree.resource.purge` 尚未在 Rust runtime / storage-convex mapping 中形成正式命令面。 +- 2026-05-15 代码证据:`wolai-frontend/src/lib/file-tree/resource-command-client.ts` 的 rename / delete / restore 默认仍发送 `/api/media/batch`;`wolai-frontend/src/app/api/media/batch/route.ts` 中 copy / move 会构造 `tree.resource.copy/move`,但 delete / restore / rename 仍直连 `mediaAssets.patchById`。 +- 2026-05-15 代码证据:`rust/crates/bridge-runtime/src/lib.rs` 只处理 `tree.resource.copy | tree.resource.move` 与 `tree.resource.upload`;`rust/crates/storage-convex-bridge/src/mapping.rs` 也只映射这三类命令。 +- 2026-05-15 代码证据:`rust/crates/mnote-web/src/routes/resource_trash.rs` 与 `wolai-frontend/src/components/sidebar/sidebar.tsx` 当前把 file asset、mindmap、table 按类型分流到 `/api/media/batch`、`/api/mindmap/*`、`/api/tables/*` 兼容入口;`rust/crates/mnote-web/src/routes/gateway.rs` 已在 `/trash` 页面明示“正式 tree.resource.* 命令仍在后续阶段收口”。 +- 2026-05-15 测试边界:已有 `media_trash_routes_delete_restore_purge_and_empty`、`mindmap_trash_routes_delete_restore_purge_and_empty`、`table_trash_routes_delete_restore_purge_and_empty` 覆盖 Rust 兼容 route;这不能替代 `tree.resource.archive/restore/purge/rename` command 级回归。 +- 2026-05-15:已补阶段 5 执行设计 `design/04-tree-domain/process/4-27-resource-lifecycle-command-cutover-v1.md`,明确本轮先完成普通附件 file asset,mindmap / table 暂保持兼容 alias,不冒充正式 cutover 完成。 +- 2026-05-15:`bridge-runtime` 已新增 `tree.resource.archive/restore/purge/rename` 生命周期 command plan,普通附件分别映射到 `mediaAssets:patchById` / `mediaAssets:purgeById`,并携带 `domainEventPlan` 与 `streamDeltaHint`;`storage-convex-bridge` 已补默认命令映射。 +- 2026-05-15:`wolai-frontend` 的 `/api/media/batch` 已将 `delete/restore/rename` 从直连 `mediaAssets.patchById` 改为 `tree.resource.archive/restore/rename` 兼容 alias;`/api/media/purge` 已从直连 `mediaAssets.purgeById` 改为 `tree.resource.purge` 兼容 alias,并记录 Rust bridge artifact。 +- 2026-05-15:Rust 3000 `/api/media/batch` 与 `/api/media/purge` 已改为通过 runtime command 执行普通附件 archive / restore / rename / purge,旧 URL 仅保留为兼容入口;`tree_shell` command event schema 已补资源生命周期事件。 +- 2026-05-15 验证:`cargo test -p bridge-runtime tree_resource_lifecycle_plans_cover_file_asset_commands`、`cargo test -p storage-convex-bridge tree_resource_lifecycle_commands_have_convex_mapping`、`cargo test -p mnote-web trash`、`cargo test -p mnote-web runtime_command_event_schema_covers_tree_and_resource_command_channels` 均通过。 +- 2026-05-15 验证:`pnpm test src/app/api/media/purge/route.test.ts src/app/api/media/batch/route.test.ts src/lib/documents/rust-runtime.test.ts` 通过,26 项通过。 +- 2026-05-15 剩余:mindmap / table 的 DELETE / restore / purge 仍是兼容 route 直连对应 Convex mutation;本阶段只完成普通附件 file asset 主链,不把 mindmap / table 误标为已完成正式 command cutover。 +- 2026-05-15 二次审计补充:mindmap / table 双浏览器 no-refresh 已由阶段 8 验证,但它们仍不是正式 `tree.resource.*` lifecycle;后续应补 `tree.resource.mindmap.*` / `tree.resource.table.*` 或等价命名合同、command log、domain event 与 projection resync 回归。 +- 2026-05-15 执行修正:先补 `mnote-web` 路由测试断言 mindmap / table DELETE、restore、purge 响应必须携带 `canonicalCommand=tree.resource.archive/restore/purge` 与对应 `resourceKind`,红灯确认旧 route 仍只返回 legacy mutation 结果。 +- 2026-05-15 执行修正:Rust 3000 的 `/api/mindmap/{docId}/{mindmapId}` DELETE / PATCH restore / PATCH purge 已改为通过 `tree.resource.archive / restore / purge` runtime command 执行;`/api/tables/{tableId}` DELETE、`/api/tables/restore`、`/api/tables/purge` 已改为通过同一 runtime command 执行;旧 URL 保留为兼容 alias。 +- 2026-05-15 执行修正:新增 `bridge-runtime` 合同测试 `tree_resource_lifecycle_plans_cover_mindmap_and_table_commands`,覆盖 mindmap -> `mindmaps:softDelete/restore/purge`,table -> `tables:remove/restore/purge`,并断言 `resourceLifecyclePlan`、`domainEventHint` 与 stream delta hint。 +- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime tree_resource_lifecycle_plans_cover_mindmap_and_table_commands -- --nocapture` 通过,1 项通过;`cargo test --manifest-path rust/Cargo.toml -p mnote-web trash_routes -- --nocapture --test-threads=1` 通过,3 项通过;`cargo test --manifest-path rust/Cargo.toml -p storage-convex-bridge tree_resource_lifecycle_commands_have_convex_mapping -- --nocapture` 通过,1 项通过。 +- 2026-05-15 阶段 5 当前边界:`tree.resource.rename` 仍只覆盖普通附件 file asset;mindmap rename 继续明确不支持,table rename 待结合 `/api/tables/{tableId}` PATCH 与表格标题语义单独收口。`empty-trash` 仍是 workspace 级批量合同,不伪装成逐资源 `tree.resource.purge`。 + +### 阶段 6:恢复位置与 reveal/focus + +- [x] 删除时记录恢复所需的 parent / order;view path / expanded path 由恢复后的父链展开补齐。 +- [x] 恢复页面子树后验证原父节点、排序、展开路径和 active/focus 的代码路径与单测。 +- [x] 父节点已永久删除时,提供明确 fallback。 + +验收标准: + +- 恢复后对象回到可解释位置,并自动 reveal/select。 +- 恢复结果不会悄悄落到根目录而无提示。 + +执行记录: + +- 2026-05-15 代码审查:`wolai-frontend/convex/documents.ts` 的 `documents.restore` 会把根恢复节点 `parent_id` 置为 `null`,并只按同一 `deleted_at` 级联恢复子节点;当前没有恢复删除前 parent / sort_order / expanded path 的证据。 +- 2026-05-15 对照证据:local-folder Markdown trash 已在 `rust/crates/mnote-web/src/routes/local_folder_source.rs` 记录 `original_relative_path`,恢复时可按原路径回放并处理重名冲突;Convex documents restore 没有同等级“删除前位置”记录。 +- 2026-05-15 命令证据:`tree.node.restore` 命令链在 Next route 与 Rust route 中存在,`scripts/task163-local-folder-unified-tree-browser-smoke.js` 也覆盖了 Convex restore / purge 请求仍走 `/api/tree/commands`;但这只是请求级证据,不覆盖恢复位置。 +- 2026-05-15 风险结论:这能保证“对象复活”,但不能保证恢复到删除前位置。若父节点仍存在,当前也可能恢复到根;若父节点已永久删除,缺少显式 fallback 选择与 UI 提示。 +- 2026-05-15 UI 证据边界:Sidebar / tree shell 有 focusedId、selection、active row 的基础状态;Sidebar 垃圾箱恢复后主要 `refetch/refreshTree`,未找到 `router.push`、reveal、select、focus 恢复对象的端到端 smoke。Rust shell 虽有 `tree.filetree.reveal` postToHost 事件,但未找到 Sidebar / DOM host 的完整落地处理。 +- 2026-05-15:已补阶段 6 执行设计 `design/04-tree-domain/process/4-28-trash-restore-location-reveal-focus-v1.md`。 +- 2026-05-15:`wolai-frontend/convex/schema.ts` 为 documents 新增 `restore_parent_id / restore_sort_order`;`documents.softDelete` 删除子树时记录删除前 `parent_id / sort_order`;`documents.restore` 恢复时优先回放原父节点与排序,并返回 `restore_location.parent_id / sort_order / fallback_reason`。 +- 2026-05-15:恢复父节点仍存在或属于本次级联恢复子树时回到原父节点;原父节点已不存在或仍在垃圾箱时 fallback 到根目录,并清空恢复位置快照。 +- 2026-05-15:`restoreDocumentCommand` 已透传恢复位置;Sidebar 垃圾箱恢复后刷新数据、展开父链、滚动并 focus 恢复行;fallback 时提示“原父页面已不存在,已恢复到根目录”。 +- 2026-05-15 验证:`pnpm test src/lib/documents/tree-command-client.test.ts src/lib/documents/restore-location.test.ts src/components/sidebar/tree-shell-host.test.tsx src/components/sidebar/sidebar-sync.test.ts` 通过,4 个测试文件、20 项测试通过。 +- 2026-05-15 验证:`pnpm exec tsc --noEmit --pretty false 2>&1 | rg "convex/documents.ts\\((160|161|162|163|164|165|166|167|168|169|170|171|172|173|174|175)"` 无输出,阶段 6 修改附近无新增 TypeScript 报错;全量 `tsc` 仍受仓库既有错误影响,不作为本阶段完成条件。 +- 2026-05-15 review 修正:发现 `/api/tree/commands restore` 先查 `documents.getMeta`,而 `getMeta` 默认过滤 `deleted_at != null`,会导致垃圾箱页面在进入 restore mutation 前 404;已改为 restore 入口使用 `includeDeleted: true`,并在 route 测试中断言该参数。 +- 2026-05-15 review 修正:恢复到原排序位时可能与已有兄弟节点产生重复 `sort_order`;已新增 `buildDocumentRestoreOrderAssignments`,恢复时按目标父级重新编号兄弟节点,补“旧排序位被占用时 restored row 插入并后移兄弟节点”的单测。 +- 2026-05-15 验证:`pnpm test src/lib/documents/tree-command-client.test.ts src/lib/documents/restore-location.test.ts src/components/sidebar/tree-shell-host.test.tsx src/components/sidebar/sidebar-sync.test.ts src/app/api/tree/commands/route.test.ts` 通过,5 个测试文件、32 项测试通过。 +- 2026-05-15 验证:`pnpm exec tsc --noEmit --pretty false 2>&1 | rg "convex/documents.ts\\((16[5-9][0-9]|17[0-9][0-9]|18[0-4][0-9])"` 无输出,restore / softDelete 修改区间无新增 TypeScript 报错;全量 `tsc` 仍受既有无关错误影响。 +- 2026-05-15 运行时刷新:执行 `npx convex dev --once --tail-logs disable --env-file ../.env.all --run ping:ping`,把本轮 Convex functions 部署到本地自托管运行时;首次 3000 smoke 在部署前复现旧 runtime 行为:恢复后 `parent_id` 仍为 `null`,部署后恢复位置生效。 +- 2026-05-15 浏览器验证:新增并执行 `node scripts/task429-trash-restore-location-reveal-smoke.js` 通过;脚本使用一次性用户 / workspace,在 3000 主入口验证删除子页面后 `tree.node.restore` 返回原父页面与原排序位、兄弟 `sort_order` 后移、当前页面无需刷新重新显示恢复行;同时构造缺失父节点历史数据,验证 fallback 返回 `parent_missing_or_deleted` 并恢复到根目录。结果文件:`tmp/task429-trash-restore-location-reveal-smoke/result.json`。 +- 2026-05-15 剩余:React Sidebar Drawer 的“恢复后 DOM focus 与 fallback alert”仍缺少 3000 主壳浏览器证据;当前 3000 主壳已切 Rust 垃圾箱入口,React Drawer focus 由代码路径和单测覆盖,后续如恢复 React 入口或补专用 harness 再补截图证据。双浏览器 no-refresh 归入阶段 8。 + +### 阶段 7:VSCode Explorer 体验补齐 + +- [x] Cut / Paste move:Ctrl/Cmd+X 后 Paste 应移动,不是复制或 no-op。 +- [x] F2 inline rename:文件树行内编辑,不再只靠 prompt。 +- [x] 右键菜单补齐 New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Reveal 的可用性或禁用态。 +- [x] DnD 补齐父拖子禁止、拖到自身禁止、readonly 禁止、Alt/Ctrl copy modifier、冲突确认。 +- [x] F2 inline rename validation parity:空名、非法名、同级重名、扩展名策略在行内提示并阻断提交。 +- [x] 右键菜单增强项:Copy Relative Path。 +- [x] 右键菜单增强项:Paste Into action target。 +- [x] 右键菜单增强项:New Folder 产品口径明确为“等待 Resource Tree folder 合同,不在页面树里伪造 folder 真相”。 + +验收标准: + +- keyboard 与 context menu 对同一 selection 得到同一 command target。 +- 禁用态有可解释条件,不出现点击后无反馈。 + +执行记录: + +- 2026-05-15 代码审查:`wolai-frontend/src/components/sidebar/tree-shell-dom-host.tsx` 已在 filetree 键盘层识别 `F2`、Delete / Backspace、Ctrl/Cmd+C、Ctrl/Cmd+X、Ctrl/Cmd+V,并向 runtime 发 `beginRenameFocused`、`cutSelection`、`pasteIntoFocused` 等动作。 +- 2026-05-15 浏览器证据边界:`scripts/task163-local-folder-unified-tree-browser-smoke.js` 已在 local-folder 模式覆盖 F2 inline rename、Ctrl/Cmd+X 后 cut decoration、Paste move preflight、确认后移动 Markdown、Alt 内部拖拽复制、外部 File drop、readonly drop 预检失败。 +- 2026-05-15 Convex 证据边界:`scripts/task163-local-folder-unified-tree-browser-smoke.js` 也覆盖了 Convex context menu 基础项、copy / cut paste 命令请求、Delete 请求和 DnD indicator;但未覆盖 Convex F2 inline rename、真实 internal drop 执行、父拖子 / 拖自身 / readonly / 冲突确认端到端。 +- 2026-05-15 初始缺口结论:上述强证据主要来自 local-folder;Convex filetree 下 Cut / Paste move、F2 inline rename、右键 New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Relative Path / Reveal、DnD 禁止父拖子 / 自拖 / readonly / copy modifier 的完整端到端矩阵当时未找到。后续已由 `task430` 与 `task431` 补齐阶段 7 必要矩阵。 +- 2026-05-15 代码风险:页面树 fallback / iframe host 中仍可见 `window.prompt("重命名页面", ...)` 与 prompt 式重命名路径,不能把“F2 inline rename 已全入口完成”作为当前口径。 +- 2026-05-15 代码风险:`wolai-frontend/src/lib/file-tree/clipboard.ts` 的 TS clipboard payload 仍只有 `copy` 类型,Next DOM host / TS 工具层与 Rust srcdoc 路径的 cut 能力不完全一致;Rust shell context menu 的 `Refresh` 当前仍可能走 `window.location.reload()`,不符合 no-refresh 目标。 +- 2026-05-15:已补阶段 7 子设计 `design/04-tree-domain/done/4-29-vscode-explorer-cut-paste-move-v1.md`。 +- 2026-05-15:`wolai-frontend` 主 Sidebar 剪贴板 action 已扩展为 `copy | cut`;`Ctrl/Cmd+X` 写入 cut payload,`Ctrl/Cmd+V` 遇到 cut payload 时,Rust family renderer 复用 `tree.filetree.drop.preflight` move plan,页面走 `moveDocumentCommand`,普通附件走 `moveFileTreeResourceAssets`,成功后清空剪贴板。 +- 2026-05-15 验证:`pnpm test src/lib/file-tree/clipboard.test.ts src/components/sidebar/sidebar-paste-preflight-source.test.ts` 通过,2 个测试文件、7 项测试通过。 +- 2026-05-15 验证:`pnpm exec tsc --noEmit --pretty false 2>&1 | rg "(src/lib/file-tree/clipboard.ts|src/components/sidebar/tree-pane-bindings.ts|src/components/sidebar/sidebar.tsx)"` 无输出,本轮 cut/paste 修改文件无新增 TypeScript 报错;全量 `tsc` 仍受既有无关错误影响。 +- 2026-05-15:已补阶段 7 子设计 `design/04-tree-domain/done/4-30-vscode-explorer-f2-inline-rename-v1.md`。 +- 2026-05-15:`tree-shell-dom-host.tsx` filetree row 已新增行内 `tree-rename-input`;F2 进入 inline rename,Enter / blur 提交,Escape 取消。页面 / index row 提交 `tree.node.rename` 并通过 `onTreeMutation` 回写;asset row 走 `/api/media/batch rename` 并广播 `wolai:assets-changed`。 +- 2026-05-15 验证:`pnpm test src/components/sidebar/tree-shell-host.test.tsx` 通过,1 个测试文件、8 项测试通过;新增测试覆盖 F2 inline input 与 `tree.node.rename` 提交。 +- 2026-05-15:已补阶段 7 子设计 `design/04-tree-domain/done/4-31-vscode-explorer-context-menu-minimum-v1.md`。 +- 2026-05-15:主 Sidebar 右键菜单已显式补齐 `New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Reveal`。其中 `New File` 复用新建子页面,`Refresh` 调 `refreshTree()`,`Collapse All` 清空展开集合,`Copy Path` 复制页面标题路径,`Reveal` 展开父链并滚动;`New Folder` 与 `Paste Into` 以禁用态展示原因,避免伪造未完成的 folder / right-click paste target 合同。 +- 2026-05-15 验证:`pnpm test src/components/sidebar/sidebar-context-menu-source.test.ts` 通过,1 个测试文件、1 项测试通过;覆盖菜单项存在、禁用态存在、未引入 `window.location.reload`。 +- 2026-05-15:已补阶段 7 子设计 `design/04-tree-domain/done/4-32-vscode-explorer-dnd-modifier-guard-v1.md`。 +- 2026-05-15:filetree DnD copy modifier 已从仅 Alt 扩展为 Alt / Ctrl / Meta;同时覆盖 `wolai-frontend` DOM host 与 Rust SSR 主壳 `layout.rs`。父拖子 / 自拖继续由 Rust `tree.filetree.drop.preflight` 校验,不在 UI 层新增第二套祖先判断。 +- 2026-05-15 验证:`pnpm test src/components/sidebar/sidebar-dnd-source.test.ts` 通过,1 个测试文件、1 项测试通过。 +- 2026-05-15 3000 主入口修正:Rust SSR 主壳补齐文件树 F2 inline rename、Ctrl/Cmd+X/V cut-paste move、右键菜单最低项 `New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Reveal`;`Refresh` 只发局部刷新事件,不使用 `window.location.reload()`;`New Folder` 与 `Paste Into` 保持禁用态并给出原因。 +- 2026-05-15 3000 主入口修正:新增 `POST /api/tree/filetree/drop-preflight`,通过 Rust bridge `tree.filetree.drop.preflight` 返回真实 preflight plan;自拖自身与父拖子返回 400,copy modifier plan 保留 `copy: true`。 +- 2026-05-15 资源 rename 修正:`tree.resource.rename` 的 bridge plan 补 `patch.file_name`;Rust `/api/media/batch rename` 在保留 `tree.resource.rename` artifact command 的同时先执行兼容 `mediaAssets:patchById`,避免 3000 主入口附件 F2 后 Convex 文件名不更新。 +- 2026-05-15 真实浏览器 smoke:新增并执行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task430-vscode-explorer-stage7-smoke.js` 通过。脚本使用一次性用户 / workspace,验证: + - 页面 F2 inline rename:请求 `/api/tree/commands action=rename`,Convex 标题更新,无需刷新可见。 + - 附件 F2 inline rename:请求 `/api/media/batch action=rename`,Convex `media_assets.file_name` 更新,无需刷新可见。 + - Cut / Paste move:`Ctrl/Cmd+X` 后目标页面 `Ctrl/Cmd+V` 触发 `action=move`,子页面 `parent_id` 变为目标父页面。 + - 右键菜单最低项:`New File / New Folder / Paste Into / Refresh / Collapse All / Copy Path / Reveal` 可见,禁用项有 title 原因。 + - DnD preflight:自拖自身与父拖子均被拒绝;copy modifier preflight 返回 `copy: true`。 + - 结果文件:`tmp/task430-vscode-explorer-stage7-smoke/result.json`。 +- 2026-05-15 阶段 7 中途剩余:`task430` 之后 readonly 与命名冲突确认仍未在主 Sidebar Convex filetree 构造真实 route 矩阵;当时 DnD 总项因 readonly / conflict 尚未勾选完成。后续已由 `task431` 补齐。 +- 2026-05-15:`bridge-runtime` 的 `tree.filetree.drop.preflight` payload / plan 已补 `sourceCapabilities`、`targetCapabilities`、`targetChildren`、`conflictPolicy`、`requiresConfirmation` 与 `conflicts`;readonly target/source 会在 preflight 阶段返回 validation error,同名目标会返回 `requiresConfirmation=true`,普通 copy modifier 不被 readonly source move 规则误拒绝。 +- 2026-05-15:主 Sidebar `buildFileTreeShellInternalDropPreflightPayload` 已把 row title、target children 与 capability payload 透传到 preflight;`handleResourcePaneInternalDrop` 与 cut-paste move 路径在 `requiresConfirmation` 时调用确认,用户取消时不执行后续 move/copy。 +- 2026-05-15 验证:`cargo test -p bridge-runtime tree_filetree_drop_preflight -- --nocapture` 通过 4 项;`pnpm test src/lib/file-tree/shell.test.ts src/lib/file-tree/resource-command-client.test.ts src/components/sidebar/sidebar-paste-preflight-source.test.ts src/components/sidebar/sidebar-dnd-source.test.ts` 通过 4 个测试文件、21 项。 +- 2026-05-15 真实 route smoke:新增并执行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3001 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task431-vscode-explorer-dnd-readonly-conflict-smoke.js` 通过。脚本使用当前代码新启动的 3001 mnote-web 与 3000 auth cookie,验证 readonly target/status 400、readonly source/status 400、同名目标返回 `requiresConfirmation=true` 与 conflict 明细、copy modifier 保持 `copy=true` 且无误报。结果文件:`tmp/task431-vscode-explorer-dnd-readonly-conflict-smoke/result.json`。 +- 2026-05-15 注意:本次 `task431` 未直接复用常驻 3000 进程,因为 3000 当时仍运行旧 `mnote-web` 二进制,第一次对 3000 执行脚本复现旧 preflight 放行 readonly 的行为;新代码已在 3001 当前编译进程验证通过,3000 需要重启后才具备同一行为。 +- 2026-05-15 二次审计补充:阶段 7 已完成最低可用体验,不等于完整 VSCode Explorer parity。后续仍需补 F2 行内 validation、`Copy Relative Path`、`Paste Into` 右键 action target、`New Folder` 长期产品口径;这些列为 P1/P2,不阻塞已完成的 cut / paste move、F2 基础行内重命名、菜单最低项与 DnD guard。 +- 2026-05-15 执行修正:先补 `tree-shell-host.test.tsx` 红灯测试,断言 F2 行内重命名必须在输入框内阻断空名、非法路径字符、同级重名,且普通附件输入不带扩展名时保留原扩展名。 +- 2026-05-15 执行修正:`tree-shell-dom-host.tsx` 已新增 F2 rename 行内错误状态、`aria-invalid`、错误文案、`CSS.escape` fallback、普通附件扩展名保留;非法输入不再发 rename 请求。 +- 2026-05-15 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/components/sidebar/tree-shell-host.test.tsx -- --runInBand` 通过,1 个测试文件、10 项通过。 +- 2026-05-15 执行修正:`ContextMenu` 新增 `Copy Relative Path`,复用页面标题路径计算但复制不带根斜杠的 workspace-relative 路径;`New Folder` 继续明确禁用,原因是当前页面树没有 folder 对象合同,不能临时伪造第二套 folder 真相。 +- 2026-05-15 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/components/sidebar/sidebar-context-menu-source.test.ts -- --runInBand` 通过,1 个测试文件、1 项通过。 +- 2026-05-15 执行修正:先补 `sidebar-paste-preflight-source.test.ts` 红灯测试,断言 `ContextMenuProps` 暴露 `onPasteInto`、右键 `Paste Into` 调用 `onPasteInto(node)`,并要求 paste helper 使用 `targetDocumentIdOverride` 进入 Rust paste / drop preflight。 +- 2026-05-15 执行修正:主 Sidebar 已抽出 `executeFileTreePaste(targetDocumentIdOverride)`;`Ctrl/Cmd+V` 传 `null` 保留 focused target 规则,右键 `Paste Into` 传当前菜单节点 `node.id` 作为目标页面。空剪贴板时右键入口会提示“剪贴板没有可粘贴的文件树内容”,不再是点击无反馈。 +- 2026-05-15 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/components/sidebar/sidebar-paste-preflight-source.test.ts -- --runInBand` 通过,1 个测试文件、3 项通过。 +- 2026-05-15 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/components/sidebar/sidebar-context-menu-source.test.ts -- --runInBand` 通过,1 个测试文件、1 项通过。 +- 2026-05-15 验证:`cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/components/sidebar/tree-shell-host.test.tsx -- --runInBand` 通过,1 个测试文件、10 项通过。 +- 2026-05-15 验证边界:`pnpm exec eslint src/components/sidebar/sidebar.tsx --quiet` 与 `pnpm exec eslint src/components/sidebar/sidebar-paste-preflight-source.test.ts src/components/sidebar/sidebar-context-menu-source.test.ts src/components/sidebar/tree-shell-host.test.tsx --quiet` 无 error;全量 `pnpm lint` 仍被仓库既有 lint 问题阻塞,本轮输出显示 `823 problems (2 errors, 821 warnings)`。 + +### 阶段 8:实时刷新与双浏览器回归 + +- [x] A 浏览器新建、删除、恢复、彻底删除页面,B 浏览器无需刷新可见更新。 +- [x] A 浏览器对普通附件 file asset 执行上传、删除、恢复、彻底删除、清空资源垃圾箱,B 浏览器无需刷新可见更新。 +- [x] A 浏览器对 mindmap / table 执行新建、删除、恢复、彻底删除、清空资源垃圾箱,B 浏览器无需刷新可见更新。 +- [x] 本地 folder 模式验证 markdown 新建 / 重命名 / 删除 watcher 不触发浏览器 reload;非 md 资源文件 watcher create / rename / delete 已覆盖,trash / restore / purge 单列待补。 +- [x] `resync_required` 事件合同防回归:`resync_required` 本身只推进 cursor,不替换数据;后续 snapshot / resync 才替换数据,已补前端 hook 与 Rust stream 合同测试。 + +验收标准: + +- 所有主链操作不依赖浏览器刷新。 +- `refreshTree()` 可以作为兜底,但不作为唯一可见性来源。 + +执行记录: + +- 2026-05-15 代码审查:Rust Web `/api/tree/events` 已有 snapshot / delta / resync 路由与单元测试;`scripts/task120-rust-web-tree-integration-smoke.js`、`scripts/task123-rust-web-tree-live-stream-consumer-smoke.js`、`scripts/task177-tree-move-archive-live-smoke.js`、`scripts/task179-tree-create-delete-no-reload-smoke.js` 覆盖了 tree stream、页面 rename / move / archive、单页面 create/delete no-refresh 等部分链路。 +- 2026-05-15 代码审查:`wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts` 已接入 EventSource `/api/tree/events` 并将 delta 交给 `applyTreeStreamDelta`;`scripts/task426-mnote-web-main-no-reload-smoke.js` 覆盖了单浏览器新建页面、新建 mindmap asset、删除页面本地 apply。 +- 2026-05-15 代码审查:资源删除与部分 sidebar 操作仍显式调用 `refreshTree()` 或依赖局部 state 移除;这可以作为兜底,但不能证明双浏览器实时同步闭环。 +- 2026-05-15 浏览器证据边界:`scripts/task169-mindmap-realtime-smoke.js` 已使用双浏览器上下文覆盖 mindmap realtime 相关场景,但未覆盖文件树中页面、附件、mindmap、table 的删除 / 恢复 / 彻底删除 / 清空垃圾箱全矩阵。 +- 2026-05-15 未闭合结论:阶段 8 仍需新增双浏览器 smoke,验证 A 浏览器对页面和三类资源执行 create / archive / restore / purge / empty-trash 后,B 浏览器无需刷新可见更新,并记录事件来源是 tree delta / resync 还是 Convex live query。 +- 2026-05-15 补充审查结论:阶段 8 需要显式拆成对象类型 × 生命周期矩阵。页面、附件、mindmap、table 都必须分别覆盖 create / archive / restore / purge / empty-trash,且验收口径是 A 浏览器执行、B 浏览器不刷新即可在 File Tree 与 Trash 工作台看到变化。现有 `task177/task179/task426/task428/task429` 主要是单浏览器 no-refresh 或局部 live probe,`task427` 是 API/Convex 级 purge 验证,`task169` 是 mindmap 内容实时稳定性;这些不能替代文件树/垃圾箱双浏览器生命周期矩阵。实现边界上,`useSidebarTreeStream` 可消费 snapshot/delta/resync,`useConvexSidebarData` 依赖 Convex live,但 Trash Drawer 仍大量使用 `refreshTree()/refetch()` 作为本地补偿;阶段 8 应记录每个矩阵项实际由 tree delta、tree resync snapshot、Convex live,还是手动 refreshTree 兜底驱动,避免把本地刷新补偿误标为实时闭环。 +- 2026-05-15 页面双浏览器 smoke 初次失败暴露根因:`documents.emptyTrashByWorkspace` 只执行 Convex mutation,没有写入 tree command/domain event,导致 B 浏览器 `/trash` 收不到清空事件,必须刷新后才消失。已给 `documents.emptyTrashByWorkspace` command plan 补 `tree.trash.documents.emptied` domain event 与 `resync_required` stream hint,并把 `/api/documents/empty-trash` 改为 artifact-aware 执行;同时补 Convex mutation validator 的 `streamDeltaHint / domainEventHint / domainEventPlan / domainEventPlans` 参数,避免运行时 502。 +- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p bridge-runtime documents_empty_trash_plan_emits_tree_resync_event -- --nocapture` 通过,断言 `documents.emptyTrashByWorkspace` command plan 携带 `tree.trash.documents.emptied` 与 `resync_required`;`cargo test --manifest-path rust/Cargo.toml -p mnote-web documents_empty_trash_route_executes_workspace_trash_purge -- --nocapture` 通过,断言 empty-trash 返回 `documents.emptyTrashByWorkspace` command log、`tree.trash.documents.emptied` domain event 与 `resync_required` stream delta;`pnpm test src/app/api/tree/commands/route.test.ts src/lib/documents/tree-command-client.test.ts` 通过,2 个测试文件、14 项通过。 +- 2026-05-15 运行时刷新:执行 `npx convex dev --once --tail-logs disable --env-file ../.env.all --run ping:ping`,把本轮 Convex validator 修改部署到本地自托管运行时。 +- 2026-05-15 真实双浏览器 smoke:新增并执行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3001 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js` 通过。脚本使用一次性用户 `mnote.stage8.page.1778804544630@example.com` 与隔离 workspace `tree_1778804545042_7`;B1 打开 `/documents/tree_1778804545042_6?workspaceId=tree_1778804545042_7` 观察 File Tree,B2 打开 `/trash?workspaceId=tree_1778804545042_7` 观察 Trash 工作台。A 侧依次执行页面 create / archive / restore / archive+purge / create+archive+empty-trash;B 侧每一步均无需刷新可见更新,`navigationEvents` 始终为空。事件来源记录:create 使用 `tree:resync`,archive 使用 `tree:delta remove_document`,restore 使用 `tree:delta upsert_document`,purge 使用 `tree:resync`,empty-trash 使用 `tree:resync`;结果文件为 `tmp/task432-filetree-trash-page-dual-browser-no-refresh-smoke/result.json`。 +- 2026-05-15 普通附件双浏览器 smoke 初次失败暴露身份与事件两层根因:`tree.resource.archive/restore/purge` 计划中 route 已放入真实 Convex userId,但 `mnote-web` transport 在 legacy `mediaAssets:*` 参数转换时又用 HTTP context actor 覆盖,导致 `mediaAssets.patchById` 在 `assertWorkspaceMember` 处返回“无权访问该工作空间”;已修正为优先保留 `args_json.userId`。随后 `mediaAssets.emptyTrashByWorkspace` 只执行 Convex mutation、不写 tree domain event,导致 B 浏览器 `/trash` 不刷新看不到清空;已在 Rust `/api/media/empty-trash` 成功后记录 `tree.trash.media.emptied` domain event 与 `resync_required` stream delta。 +- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web convex_resource_lifecycle_args_keep_effective_user_id -- --nocapture` 通过,断言资源 lifecycle 参数转换保留真实 Convex userId;`cargo test --manifest-path rust/Cargo.toml -p mnote-web media_trash_routes_delete_restore_purge_and_empty -- --nocapture` 通过。 +- 2026-05-15 真实双浏览器 smoke:新增并执行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3001 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke.js` 通过。脚本使用一次性用户 `mnote.stage8.file.1778806752763@example.com` 与隔离 workspace `tree_1778806753148_2`;B1 打开 `/documents/tree_1778806753148_1?workspaceId=tree_1778806753148_2` 观察 File Tree,B2 打开 `/trash?workspaceId=tree_1778806753148_2` 观察 Trash 工作台。A 侧依次执行普通附件 upload / archive / restore / archive+purge / upload+archive+empty-trash;B 侧每一步均无需刷新可见更新,执行阶段 `navigationEvents` 为空。事件来源记录:upload 使用 `tree:delta upsert_assets`,archive / restore / purge / empty-trash 使用 `tree:resync`;结果文件为 `tmp/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke/result.json`。 +- 2026-05-15 mindmap/table 双浏览器 smoke 初次实现时发现 table create 只是测试前 seed,不能证明“新建 table 后 B 无刷新出现”;已补 Rust `/api/tables/create` 兼容入口,创建成功后记录 `tree.resource.table.created` domain event 与 `resync_required` stream delta。mindmap / table 的 delete / restore / purge / empty-trash compat route 也已补 tree resync artifacts,避免 B 浏览器只能依赖刷新。 +- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web trash_routes -- --nocapture --test-threads=1` 通过,覆盖 media / mindmap / table 资源垃圾箱 route;`node --check scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js` 通过。 +- 2026-05-15 真实双浏览器 smoke:新增并执行 `MNOTE_UI_BASE_URL=http://127.0.0.1:3001 MNOTE_AUTH_BASE_URL=http://127.0.0.1:3000 node scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js` 通过。脚本使用一次性用户 `mnote.stage8.mindtable.1778808180081@example.com` 与隔离 workspace `tree_1778808180480_2`;A 侧依次执行 mindmap create / archive / restore / archive+purge、table create / archive / restore / archive+purge、mindmap+table create / archive / empty-trash;B 侧 File Tree 与 `/trash` 每一步均无需刷新可见更新,执行阶段 `navigationEvents` 为空。事件来源记录:mindmap create/delete/restore 主要为 `tree:delta resync_required`,mindmap purge 为 `tree:resync`;table create/delete/restore 主要为 `tree:delta resync_required`,table purge / empty-trash 为 `tree:resync`。结果文件为 `tmp/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke/result.json`。 +- 2026-05-15 local folder 二次审计:`scripts/task163-local-folder-unified-tree-browser-smoke.js` 在 extended 模式覆盖本地 Markdown 新建、删除进入 `.mnote/trash`、restore、purge,也覆盖外部新增 `watcher-added.md` 后页面树可见;但当前本地 folder watcher 主壳在 revision 变化后调用 `window.location.reload()`,因此不能算 no-refresh watcher 闭环。 +- 2026-05-15 local folder 二次审计:同一脚本也覆盖 `watcher-asset.txt` 外部新增 / 删除后 filetree 可见 / 消失;但 Rust `LocalFolderWatcherRegistry` 只广播 markdown 事件,当前 filetree 资产可见性主要依赖 `/api/tree/local-folder-watch` revision 轮询后重载,不是非 md 资源事件级 no-refresh。 +- 2026-05-15 local folder 二次审计:`LocalFolderWatcherRegistry` 底层理论覆盖 Markdown create / remove / name / data / metadata 事件,但现有 smoke 只实测外部新增 Markdown;外部 Markdown 重命名、删除尚未形成浏览器验收。非 md 资源文件在 filetree 中能作为 `asset` 出现,inline rename 可写入文件系统,但 copy / cut / delete / download 当前仍是禁用态,本地 `delete / restore / purge` command 只覆盖 Markdown 页面。 +- 2026-05-15 阶段 8 中途状态:页面 document、普通附件 file asset、mindmap、table 的 Convex 双浏览器生命周期已闭合;本地 folder markdown watcher 当时仍是 reload 型刷新,非 md 本地资源文件 watcher / trash / restore / purge 当时仍未闭合;`resync_required` 事件合同当时还需要防回归测试。后续已由阶段 8/9 继续执行记录闭合。 +- 2026-05-15 继续执行审计:local folder no-refresh 的实际刷新来源在 Rust Web 内嵌 tree shell runtime:`rust/crates/mnote-web/src/routes/tree.rs` 的 local folder revision polling 命中 `/api/tree/local-folder-watch` 后调用 `window.location.reload()`;同文件的 filetree 右键 `Refresh` 当前也直接 `window.location.reload()`。因此现有能力只能称为 revision polling + reload 型可见性,不是 no-refresh。 +- 2026-05-15 继续执行审计:`/api/local-folder/events` 由 `LocalFolderWatcherRegistry` 提供 SSE,但 watcher 当前 `is_markdown_path` 过滤为 `.md/.markdown`,只对 Markdown 事件广播;非 md 资源文件变化不会走该 SSE,主要依赖 `/api/tree/local-folder-watch` 的全目录 revision hash。 +- 2026-05-15 下一步最小实现口径:先不要扩散到本地非 md trash / restore / purge。优先把 local folder filetree/page tree 的 revision polling 从 `window.location.reload()` 改为“拉取最新 local folder snapshot 并替换当前内存行模型后 `renderTree()`”,并新增回归测试断言 local folder watch 分支不含 `window.location.reload()`,再补浏览器 smoke 记录 `navigationEvents.length === 0`。 +- 2026-05-15 执行修正:先补 `rust/crates/mnote-web/src/routes/tree.rs` debug/internal tree shell 回归,断言 local folder watch 分支包含 `/api/tree/local-folder-watch` 与 `refreshLocalFolderSnapshot`,且不含 `window.location.reload`;随后发现 3000 主入口实际走 `rust/crates/mnote-web/src/ssr/pages/layout.rs` 的主 Sidebar runtime,不能只改 debug tree shell。 +- 2026-05-15 执行修正:主 Sidebar runtime 已新增 `startLocalFolderSidebarWatch()`,对 `sourceKind=local_folder` 轮询 `/api/tree/local-folder-watch`,revision 变化后 fetch 当前 HTML 并用 `replaceSidebarTreeFromDocument()` 原地替换 `sidebar-tree-root` 与 `sidebar-file-tree-root`;启动后立即执行一次 baseline poll,避免外部变更发生在首轮定时轮询前被误当作初始 revision。 +- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_polls_local_folder_without_browser_reload -- --nocapture` 通过,断言主 Sidebar runtime 具备 local folder polling / snapshot apply 且不使用 `window.location.reload`;`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_tree_shell_does_not_reload_page_for_refresh -- --nocapture` 通过,保留 debug/internal tree shell no-reload 防回归。 +- 2026-05-15 真实浏览器 smoke:新增并执行 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3427 node scripts/task435-local-folder-watch-no-reload-smoke.js` 通过。脚本使用临时本地目录,验证 Markdown 外部 create / rename / delete 在 page tree 与 filetree 原地更新;`.txt` 与 `.png` 两类非 md 资源外部 create / rename / delete 在 filetree 原地更新;所有步骤 `navigationEventsBefore=0`、`navigationEventsAfter=0`,结果文件 `tmp/task435-local-folder-watch-no-reload-smoke/result.json`。 +- 2026-05-15 `resync_required` 合同修正:只读审计确认前端 `applyTreeStreamDelta` 对 `op=resync_required` 不替换数据,只推进 cursor;已补 `wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.test.tsx`,断言 `resync_required` delta 保持旧数据、后续 `resync` 才替换数据。已补 `rust/crates/mnote-web/src/routes/stream_support.rs`,断言 matching command/domain event 中的 `streamDelta.op=resync_required` 会作为 delta 透传并推进 cursor。 +- 2026-05-15 验证:`cd wolai-frontend && pnpm test src/lib/tree-stream/use-sidebar-tree-stream.test.tsx -- --runInBand` 通过,1 个测试文件、4 项通过;`cargo test --manifest-path rust/Cargo.toml -p mnote-web stream_change_preserves_resync_required_delta_contract -- --nocapture` 通过,1 项通过。 + +### 阶段 9:Local Folder watcher 与本地资源生命周期 + +- [x] Markdown 外部 watcher 三件套:外部创建 `.md`、外部重命名 `.md`、外部删除 `.md`,page tree 与 filetree 均无需手动刷新可见。 +- [x] 打开的本地 Markdown 文档在外部修改 / 重命名 / 删除时,能收到明确刷新或冲突提示,不静默覆盖用户编辑。 +- [x] 非 md 本地资源文件外部 create / rename / delete 在 filetree 可见,并记录实际机制是 `/api/tree/local-folder-watch` revision polling + HTML snapshot apply,不是 `LocalFolderWatcherRegistry` SSE delta,也不是 browser reload。 +- [x] 非 md 本地资源文件 delete / restore / purge 支持本地垃圾箱或明确不支持;若支持,必须记录原路径、冲突策略与 purge 后索引清理。 +- [x] 本地 folder watcher 验收脚本必须记录 `navigationEvents` / reload 次数;若发生 `window.location.reload()`,不得标为 no-refresh 完成。 + +验收标准: + +- Markdown watcher 不依赖手动刷新;若内部实现仍需 reload,文档必须标为“reload 型刷新”,不能写成 no-refresh。 +- 非 md 资源文件至少覆盖 `.txt/.png/.pdf/.xlsx` 中两类,create / rename / delete 后 filetree 状态与本地文件系统一致。 +- 本地资源进入垃圾箱时必须写入 `.mnote/trash` 或等价索引;restore 回原路径,冲突时有可解释 fallback;purge 后原文件、trash 文件、索引均不可再查询。 + +执行记录: + +- 2026-05-15:只读审计确认 `LocalFolderWatcherRegistry` 递归监听 root,但 `is_markdown_path` 只允许 `.md/.markdown`,非 md 直接过滤;`/api/local-folder/events` 主要服务打开的 Markdown session。 +- 2026-05-15:只读审计确认主 tree 对 local folder 不接 `/api/tree/events`,而是用 `/api/tree/local-folder-watch` 轮询 revision;revision 扫描可见条目并 hash path / name / dir / symlink / readonly / size / mtime,非 md 文件也会改变 revision,但当前变化后会触发 `window.location.reload()`。 +- 2026-05-15:只读审计确认 `task163` 覆盖外部新增 `watcher-added.md` 后 page tree 出现,以及外部新增 / 删除 `watcher-asset.txt` 后 filetree 出现 / 消失;缺少外部 Markdown rename / delete、非 md rename、非 md trash / restore / purge 的写入型浏览器验收。 +- 2026-05-15 继续执行审计:`rust/crates/mnote-web/src/local_folder_watcher_registry.rs` 的 `should_emit_event_kind` 接受 create/remove/name/data/metadata,但实际发送前仍按 `is_markdown_path` 过滤;`rust/crates/mnote-web/src/routes/local_folder_events.rs` 也会按 `documentId` 限定单个 Markdown 相对路径,因此它更适合打开文档内容刷新,不足以承担完整 filetree 资源 watcher。 +- 2026-05-15 继续执行审计:`rust/crates/mnote-web/src/routes/local_folder_source.rs` 的 `local_folder_watch_revision_for_root` 会遍历 visible files 并把 path/name/dir/symlink/readonly/size/mtime 纳入 hash,因此它可以发现 Markdown rename/delete 与非 md create/rename/delete;缺口在客户端收到 revision 变化后执行整页 reload,而不是应用 snapshot delta。 +- 2026-05-15 执行记录:本轮已把 3000 主 Sidebar local folder watch 从“static / reload 型”改为 revision polling + HTML snapshot apply。`task435` 记录 `navigationEvents=[]`,覆盖 Markdown page tree 三件套、Markdown filetree 三件套、`.txt` 与 `.png` 非 md filetree 三件套。 +- 2026-05-15 打开态本地 Markdown 修正:新增 `scripts/task436-local-markdown-open-document-external-change-smoke.js`,先红灯确认外部重命名后的打开 session 未进入 `external-change-conflict`,根因是 `/api/page-aggregate/<旧 documentId>` 404 被静默忽略,且 local folder SSE 对 rename/remove 事件按新 `documentId` 过滤后不能可靠触达旧 session。 +- 2026-05-15 打开态本地 Markdown 修正:`rust/crates/mnote-web/src/routes/web_shell.rs` 已改为本地 Markdown 外部刷新遇到非 OK aggregate 响应时标记 `external-change-conflict`;对 `eventKind` 包含 `Remove` / `Name` 的 local folder 事件,不再仅按新 `documentId` 过滤,而是让同 root 打开的 session 重查当前 aggregate,由 conflict key 或 404 决定自动同步或冲突提示。 +- 2026-05-15 验证:`MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3427 node scripts/task436-local-markdown-open-document-external-change-smoke.js` 通过,覆盖打开文档外部修改自动同步、dirty 文档外部修改冲突提示、打开文档外部重命名冲突提示、打开文档外部删除冲突提示;四步 `navigationEventsBefore=0`、`navigationEventsAfter=0`,结果文件 `tmp/task436-local-markdown-open-document-external-change-smoke/result.json`。 +- 2026-05-15 验证:`cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_polls_local_folder_without_browser_reload -- --nocapture`、`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder_tree_shell_does_not_reload_page_for_refresh -- --nocapture` 均通过。 +- 2026-05-15 非 md 本地资源生命周期设计:已在 `design/04-tree-domain/process/4-27-resource-lifecycle-command-cutover-v1.md` 追加 `local_folder file_path resource lifecycle` 子阶段,明确 `tree.resource.archive/restore/purge`、`resourceKind=local_file`、`trashEntryId`、trash index、restore 冲突与 purge 幂等策略。 +- 2026-05-15 非 md 本地资源生命周期实现:先补 `tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index` 红灯,确认 `local:asset:docs/photo.png` delete 原本掉到 Markdown 删除路径;随后扩展 local folder executor,使 `local:asset:*` / `local:node:*` raw file 通过 `tree.resource.archive/restore/purge` 进入 `.mnote/trash` 与 `trash-index.json`,restore 回原路径,purge 删除 trash 文件并清理索引。Markdown 页面原有 trash / restore / purge 回归保持不变。 +- 2026-05-15 UI 接入:Rust 3000 主壳 filetree delete plan 对 `sourceKind=local_folder && rowKind=asset` 使用 rowId 作为本地资源 identity,Delete / Backspace 走 `/api/tree/commands`,不再落到 `/api/media/batch`;补 `sidebar_filetree_delete_keys_support_mixed_doc_and_asset_selection` 字符串级回归。 +- 2026-05-15 真实浏览器 smoke:新增并执行 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3427 node scripts/task437-local-folder-asset-trash-lifecycle-smoke.js` 通过。脚本在本地临时 root 下创建 `docs/asset.txt`,主壳 filetree 选中后按 Delete,确认源文件消失、`.mnote/trash/asset.txt` 出现、trash index 包含 `local-file:docs/asset.txt`;随后通过 tree command restore 回原路径,再次 delete 后 purge,确认源文件、trash 文件和 trash index entry 均不存在;全程 `navigationEvents=[]`。 +- 2026-05-15 阶段 9 结论:Markdown 外部 watcher、打开态 Markdown 外部变更提示、非 md 外部可见性、本地非 md 资源 delete / restore / purge 与 no-reload 记录均已闭合;后续更完整的 `.pdf/.xlsx` 类型矩阵、restore 冲突 UI、Trash 工作台展示 local_file entry 可作为下一轮增强,不阻塞本阶段 checklist。 + +### 阶段 10:Explorer parity 收口项 + +- [x] F2 inline rename validation:空名、非法名、同级重名、扩展名策略行内提示,并阻断提交。 +- [x] Copy Relative Path:Convex workspace-relative 路径已接入页面右键菜单;local root-relative 仍归入 local folder 阶段 9 验收。 +- [x] Paste Into action target:主 Sidebar 节点右键使用当前菜单节点作为目标;Ctrl/Cmd+V 继续使用 focused target;空白区当前没有独立右键菜单入口,后续若新增必须复用同一 helper。 +- [x] New Folder:当前 Convex workspace 页面树不支持 folder 概念,禁用态保留解释;后续若支持,必须等待 Resource Tree folder 合同并走 tree command。 +- [x] `resync_required` 合同:前端明确 `resync_required` 只推进 cursor、不替换数据;后续 snapshot / resync 才替换数据。Rust stream 合同测试覆盖 matching command/domain event 的 `resync_required` delta 透传。 + +验收标准: + +- VSCode Explorer 对标只能按“最低可用完成 / parity backlog”分层汇报,不能把禁用态或待补项描述成完整完成。 +- 所有右键命令与键盘命令必须共享同一 action target 规则。 +- 事件合同测试能复现“只发 resync_required 不发 resync 时 B 端不会更新”的失败模式,并用实现修复后变绿。 + +执行记录: + +- 2026-05-15:`use-sidebar-tree-stream.test.tsx` 已补 `resync_required delta 只推进 cursor,后续 resync 才替换数据`;`stream_support.rs` 已补 `stream_change_preserves_resync_required_delta_contract`。这固定了当前合同口径:`resync_required` 是“需要 resync 的信号”,不是可直接应用的数据 delta。 + +## 4. 最小浏览器验收脚本建议 + +测试数据前缀:`TEST-10REVIEW-07-`。 + +必须覆盖: + +- 登录 `http://localhost:3000/auth`,使用测试账号快速登录。 +- 在 3000 主入口创建父页面、子页面、附件、mindmap、table。 +- 文件树多选父页面 + 子页面 + 附件,按 Delete。 +- 断言:文件树立即消失,垃圾箱 Drawer 或 `/trash` 立即出现相应条目。 +- 恢复父页面,断言子页面恢复规则、位置、focus/reveal。 +- 对同一批对象执行彻底删除 / 清空垃圾箱。 +- 查询 Convex 或通过 API 断言 purge 后不存在。 +- 双浏览器验证不刷新同步。 + +## 5. 不应误读的口径 + +- 不应说“04-tree 文件树已完全对标 VSCode Explorer”。更准确的说法是:文件树 renderer、selection、preflight、部分批量删除和垃圾桶基础已经具备;VSCode Explorer 产品闭环仍在收口。 +- 不应说“Trash 已完整完成”。更准确的说法是:页面软删 / 恢复 / purge 与 Sidebar Drawer 基础已存在;Rust 主壳、`/trash` 入口、资源级命令、清空后 Convex 同步删除还需要端到端验收。 +- 不应说“Resource Tree runtime 已完成”。更准确的说法是:object identity、block-asset relation、file_tree projection 合同已落地;资源树产品面与资源命令闭环待核验。 +- 不应说“local folder 已满足 no-refresh”。更准确的说法是:local folder Markdown lifecycle 与非 md 文件基础可见性已有 smoke 覆盖,但当前本地 watch 仍有 `window.location.reload()`,非 md 资源 trash / restore / purge 未收口。 +- 不应说“mindmap / table 已正式切到 tree.resource.*”。更准确的说法是:mindmap / table 生命周期已有 compat route 与 no-refresh 证据,正式资源命令合同仍待补。 +- 不应把 07-ai 开发建立在“04-tree 删除 / 垃圾箱 / Explorer 能力已经全闭环”的假设上。后续 07-ai 若需要操作页面、附件、mindmap、table,必须优先走统一 tree command 和 projection,不要继续扩散 legacy route。 diff --git a/design/10-review/done/新页面 2.md b/design/10-review/done/新页面 2.md new file mode 100644 index 00000000..cc5554ec --- /dev/null +++ b/design/10-review/done/新页面 2.md @@ -0,0 +1,5 @@ +--- +mnote_id: local-mdid:local~3A_mnt_Data1T_mnote_design_10-review_done~3A~E6~96~B0~E9~A1~B5~E9~9D~A2~202.md +title: 新页面 +--- +# 新页面 diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 20976c13..d3165a64 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -1267,10 +1267,25 @@ struct ResourceUploadCommandPayload { asset_type: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ResourceLifecycleCommandPayload { + resource_kind: Option, + asset_id: Option, + document_id: Option, + mindmap_id: Option, + table_id: Option, + new_name: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct FileTreeDropPreflightCommandPayload { copy: bool, + #[serde(default)] + source_capabilities: Vec, + #[serde(default)] + target_capabilities: Vec, target_document_id: Option, target_row_id: Option, focused_row_id: Option, @@ -1280,7 +1295,10 @@ struct FileTreeDropPreflightCommandPayload { #[serde(default)] rows: Vec, #[serde(default)] + target_children: Vec, + #[serde(default)] document_parents: Vec, + conflict_policy: Option, } #[derive(Debug, Deserialize)] @@ -1330,6 +1348,18 @@ struct FileTreeDropPreflightRow { asset_document_id: Option, asset_type: Option, storage_path: Option, + title: Option, + #[serde(rename = "operationProfile")] + _operation_profile: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileTreeDropPreflightTargetChild { + row_kind: String, + document_id: Option, + asset_id: Option, + title: Option, } #[derive(Debug, Clone, Deserialize)] @@ -1349,6 +1379,14 @@ struct FileTreeUploadTargetDocumentWorkspace { #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct FileTreeDropPlan { + #[serde(skip_serializing_if = "is_true")] + allowed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + blocked_reason: Option, + #[serde(skip_serializing_if = "is_false")] + requires_confirmation: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + conflicts: Vec, copy: bool, target_document_id: String, target_mindmap_id: Option, @@ -1364,6 +1402,28 @@ struct FileTreeDropPlan { resource_transfer_plan: Option, } +fn is_true(value: &bool) -> bool { + *value +} + +fn is_false(value: &bool) -> bool { + !*value +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct FileTreeDropConflict { + row_kind: String, + source_row_id: String, + source_document_id: Option, + source_asset_id: Option, + existing_document_id: Option, + existing_asset_id: Option, + target_document_id: String, + title: String, + policy: String, +} + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct FileTreeDeletePlan { @@ -1434,6 +1494,18 @@ struct ResourceUploadPlan { asset_type: String, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct ResourceLifecyclePlan { + action: String, + resource_kind: String, + asset_id: Option, + document_id: Option, + mindmap_id: Option, + table_id: Option, + new_name: Option, +} + fn normalize_resource_asset_ids(raw_asset_ids: &[String]) -> Vec { let mut asset_ids = Vec::new(); for raw in raw_asset_ids { @@ -1538,6 +1610,151 @@ fn build_resource_upload_plan( }) } +fn normalize_resource_lifecycle_kind(payload: &ResourceLifecycleCommandPayload) -> String { + if payload + .table_id + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + { + return "table".into(); + } + if payload + .mindmap_id + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + { + return "mindmap".into(); + } + match payload + .resource_kind + .as_deref() + .map(str::trim) + .unwrap_or("file") + { + "table" | "online-table" | "luckysheet" => "table".into(), + "mindmap" | "map" => "mindmap".into(), + _ => "file".into(), + } +} + +fn normalize_required_resource_id( + raw: Option<&str>, + field: &'static str, +) -> Result { + raw.map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| BridgeError::validation(format!("resource command 缺少 {field}"))) +} + +fn build_resource_lifecycle_plan( + action: &str, + payload: &ResourceLifecycleCommandPayload, +) -> Result { + let resource_kind = normalize_resource_lifecycle_kind(payload); + let mut plan = ResourceLifecyclePlan { + action: action.into(), + resource_kind, + asset_id: None, + document_id: None, + mindmap_id: None, + table_id: None, + new_name: normalize_optional_resource_string(payload.new_name.as_deref()) + .map(|value| value.replace(['\\', '/'], "_")), + }; + + match plan.resource_kind.as_str() { + "file" => { + plan.asset_id = Some(normalize_required_resource_id( + payload.asset_id.as_deref(), + "assetId", + )?); + } + "mindmap" => { + plan.document_id = Some(normalize_required_resource_id( + payload.document_id.as_deref(), + "documentId", + )?); + plan.mindmap_id = Some(normalize_required_resource_id( + payload.mindmap_id.as_deref(), + "mindmapId", + )?); + } + "table" => { + plan.table_id = Some(normalize_required_resource_id( + payload.table_id.as_deref(), + "tableId", + )?); + } + _ => unreachable!("resource kind 已规范化"), + } + + if action == "rename" && plan.new_name.is_none() { + return Err(BridgeError::validation("resource rename 缺少 newName")); + } + + Ok(plan) +} + +fn resource_lifecycle_convex_function( + action: &str, + resource_kind: &str, +) -> Result<&'static str, BridgeError> { + match (action, resource_kind) { + ("archive", "file") | ("restore", "file") | ("rename", "file") => { + Ok("mediaAssets:patchById") + } + ("purge", "file") => Ok("mediaAssets:purgeById"), + ("archive", "mindmap") => Ok("mindmaps:softDelete"), + ("restore", "mindmap") => Ok("mindmaps:restore"), + ("purge", "mindmap") => Ok("mindmaps:purge"), + ("archive", "table") => Ok("tables:remove"), + ("restore", "table") => Ok("tables:restore"), + ("purge", "table") => Ok("tables:purge"), + ("rename", "table") => Ok("tables:update"), + ("rename", "mindmap") => Err(BridgeError::validation( + "tree.resource.rename 暂不支持 mindmap 资源", + )), + _ => Err(BridgeError::validation(format!( + "不支持的 resource lifecycle command: {action}/{resource_kind}" + ))), + } +} + +fn resource_lifecycle_event_type(action: &str) -> &'static str { + match action { + "archive" => "tree.resource.archived", + "restore" => "tree.resource.restored", + "purge" => "tree.resource.purged", + "rename" => "tree.resource.renamed", + _ => "tree.resource.changed", + } +} + +fn resource_lifecycle_stream_delta_hint(plan: &ResourceLifecyclePlan) -> Value { + match plan.action.as_str() { + "archive" | "purge" => tree_stream_delta_hint( + "remove_asset", + json!({ + "assetId": plan + .asset_id + .as_deref() + .or(plan.mindmap_id.as_deref()) + .or(plan.table_id.as_deref()) + .unwrap_or(""), + }), + ), + _ => tree_stream_delta_hint( + "asset_result", + json!({ + "assetField": "asset", + }), + ), + } +} + fn normalize_optional_filetree_string(value: &Option) -> Option { value .as_deref() @@ -1645,6 +1862,114 @@ fn is_filetree_drop_real_asset(row: &FileTreeDropPreflightRow) -> bool { && normalize_optional_filetree_string(&row.storage_path).is_some() } +fn normalize_filetree_capability_set( + capabilities: &[String], +) -> std::collections::BTreeSet { + capabilities + .iter() + .map(|capability| capability.trim().to_ascii_lowercase()) + .filter(|capability| !capability.is_empty()) + .collect() +} + +fn filetree_capabilities_include_any( + capabilities: &std::collections::BTreeSet, + expected: &[&str], +) -> bool { + expected + .iter() + .any(|capability| capabilities.contains(*capability)) +} + +fn validate_filetree_drop_capabilities( + payload: &FileTreeDropPreflightCommandPayload, +) -> Result<(), BridgeError> { + let source_capabilities = normalize_filetree_capability_set(&payload.source_capabilities); + let target_capabilities = normalize_filetree_capability_set(&payload.target_capabilities); + + if !target_capabilities.is_empty() + && !filetree_capabilities_include_any(&target_capabilities, &["drop", "write"]) + { + return Err(BridgeError::validation("目标位置是只读,不能拖放到这里")); + } + + if payload.copy { + if !source_capabilities.is_empty() + && !filetree_capabilities_include_any(&source_capabilities, &["copy", "read"]) + { + return Err(BridgeError::validation("来源是只读,不能复制这些对象")); + } + return Ok(()); + } + + if !source_capabilities.is_empty() + && !filetree_capabilities_include_any(&source_capabilities, &["move", "write"]) + { + return Err(BridgeError::validation("来源是只读,不能移动这些对象")); + } + + Ok(()) +} + +fn normalize_filetree_title(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn filetree_drop_conflict_policy(payload: &FileTreeDropPreflightCommandPayload) -> String { + normalize_optional_filetree_string(&payload.conflict_policy).unwrap_or_else(|| "prompt".into()) +} + +fn collect_filetree_drop_conflicts( + payload: &FileTreeDropPreflightCommandPayload, + selected_rows: &[&FileTreeDropPreflightRow], + target_document_id: &str, +) -> Vec { + let policy = filetree_drop_conflict_policy(payload); + if policy != "prompt" || payload.target_children.is_empty() { + return Vec::new(); + } + + let mut conflicts = Vec::new(); + for row in selected_rows { + let Some(source_title) = normalize_filetree_title(&row.title) else { + continue; + }; + let row_kind = row.row_kind.trim(); + if row_kind != "doc" && row_kind != "asset" { + continue; + } + + for child in &payload.target_children { + if child.row_kind.trim() != row_kind { + continue; + } + let Some(child_title) = normalize_filetree_title(&child.title) else { + continue; + }; + if child_title != source_title { + continue; + } + conflicts.push(FileTreeDropConflict { + row_kind: row_kind.to_string(), + source_row_id: row.row_id.trim().to_string(), + source_document_id: normalize_optional_filetree_string(&row.document_id), + source_asset_id: normalize_optional_filetree_string(&row.asset_id), + existing_document_id: normalize_optional_filetree_string(&child.document_id), + existing_asset_id: normalize_optional_filetree_string(&child.asset_id), + target_document_id: target_document_id.to_string(), + title: source_title.clone(), + policy: policy.clone(), + }); + break; + } + } + conflicts +} + fn validate_filetree_drop_doc_legality( plan: &FileTreeDropPlan, parent_by_document_id: &HashMap>, @@ -1955,6 +2280,8 @@ fn build_filetree_upload_target_plan( fn build_filetree_drop_plan( payload: &FileTreeDropPreflightCommandPayload, ) -> Result { + validate_filetree_drop_capabilities(payload)?; + let row_by_id: HashMap = payload .rows .iter() @@ -1994,7 +2321,7 @@ fn build_filetree_drop_plan( let mut doc_ids = Vec::new(); let mut copyable_asset_ids = Vec::new(); let mut source_asset_document_ids = Vec::new(); - for row in selected_rows { + for row in &selected_rows { let row_kind = row.row_kind.trim(); if row_kind == "doc" { if let Some(document_id) = filetree_drop_row_document_id(row) { @@ -2050,6 +2377,8 @@ fn build_filetree_drop_plan( let target_sub_path = target_mindmap_id .as_deref() .map(|mindmap_id| format!("mindmaps/{mindmap_id}")); + let conflicts = + collect_filetree_drop_conflicts(payload, &selected_rows, target_document_id.as_str()); let document_transfer_plan = if doc_ids.is_empty() { None @@ -2080,6 +2409,10 @@ fn build_filetree_drop_plan( }; let plan = FileTreeDropPlan { + allowed: true, + blocked_reason: None, + requires_confirmation: !conflicts.is_empty(), + conflicts, copy: payload.copy, target_document_id, target_mindmap_id, @@ -7142,9 +7475,9 @@ fn is_code_asset(file_name: &str, mime_type: &str) -> bool { let lowered_name = file_name.trim().to_ascii_lowercase(); lowered_mime.starts_with("text/") && [ - ".c", ".cc", ".cpp", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js", - ".jsx", ".json", ".kt", ".lua", ".md", ".py", ".rs", ".sh", ".sql", ".toml", - ".ts", ".tsx", ".xml", ".yaml", ".yml", + ".c", ".cc", ".cpp", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js", ".jsx", + ".json", ".kt", ".lua", ".md", ".py", ".rs", ".sh", ".sql", ".toml", ".ts", ".tsx", + ".xml", ".yaml", ".yml", ] .iter() .any(|suffix| lowered_name.ends_with(suffix)) @@ -7569,18 +7902,22 @@ fn make_projection_resource_meta( block_id: block_id.clone(), asset_id: asset_id.clone(), }); - let block_asset_relation = - match (document_id.clone(), block_id, asset_id.clone(), asset_kind.clone()) { - (Some(document_id), Some(block_id), Some(asset_id), Some(asset_kind)) => { - Some(KernelBlockAssetRelation { - document_id, - block_id, - asset_id, - asset_kind, - }) - } - _ => None, - }; + let block_asset_relation = match ( + document_id.clone(), + block_id, + asset_id.clone(), + asset_kind.clone(), + ) { + (Some(document_id), Some(block_id), Some(asset_id), Some(asset_kind)) => { + Some(KernelBlockAssetRelation { + document_id, + block_id, + asset_id, + asset_kind, + }) + } + _ => None, + }; KernelProjectionResourceMeta { resource_kind: Some(resource_kind), document_id, @@ -7789,13 +8126,26 @@ fn build_file_tree_projection_result( .iter() .filter(|asset| !nested_child_asset_ids.contains_key(&asset.id)) .count() as u32; - let doc_child_count = 1 + direct_asset_count + direct_child_pages.len() as u32; + let doc_child_count = direct_asset_count + direct_child_pages.len() as u32; let parent_node_id = if depth == 0 { None } else { node.parent_id.clone() }; let workspace_id = node.workspace_id.clone(); + let page_markdown_title = node + .metadata + .title + .as_deref() + .map(|title| { + let trimmed = title.trim(); + if trimmed.ends_with(".md") { + trimmed.to_string() + } else { + format!("{trimmed}.md") + } + }) + .unwrap_or_else(|| "无标题.md".into()); items.push(KernelProjectionItem { row_id: build_projection_row_id( &KernelProjectionKind::FileTree, @@ -7807,7 +8157,7 @@ fn build_file_tree_projection_result( parent_node_id, node_type: KernelNodeType::Page, projection_kind: KernelProjectionKind::FileTree, - title: node.metadata.title.clone(), + title: Some(page_markdown_title), depth, position: Some(kernel_node_sort_order(node)), child_count: doc_child_count, @@ -7827,44 +8177,7 @@ fn build_file_tree_projection_result( icon_hint: Some("page".into()), }); - let index_node_id = format!("index:{}", node.id); - items.push(KernelProjectionItem { - row_id: build_projection_row_id( - &KernelProjectionKind::FileTree, - &KernelProjectionRowKind::Index, - &node.id, - ), - row_kind: KernelProjectionRowKind::Index, - node_id: index_node_id.clone(), - parent_node_id: Some(node.id.clone()), - node_type: KernelNodeType::IndexNode, - projection_kind: KernelProjectionKind::FileTree, - title: Some("index.md".into()), - depth: depth + 1, - position: Some(0), - child_count: 0, - expandable: false, - expanded_by_default: false, - capabilities: file_tree_leaf_capabilities(false), - resource_meta: Some(make_projection_resource_meta( - KernelProjectionResourceKind::Index, - Some(node.id.clone()), - None, - None, - workspace_id.clone(), - None, - "index", - "index", - )), - icon_hint: Some("index".into()), - }); - edges.push(make_projection_edge( - &node.id, - &index_node_id, - workspace_id.as_deref(), - )); - - let mut asset_position = 1i64; + let mut asset_position = 0i64; for asset in doc_assets { if nested_child_asset_ids.contains_key(&asset.id) { continue; @@ -9356,11 +9669,14 @@ fn execute_command( let (event_type, stream_delta_hint) = if create_only { ( "tree.resource.mindmap.put", - tree_stream_delta_hint("resync_required", json!({ - "reason": "mindmap.put", - "documentId": payload.document_id.clone(), - "blockId": payload.mindmap_id.clone(), - })), + tree_stream_delta_hint( + "resync_required", + json!({ + "reason": "mindmap.put", + "documentId": payload.document_id.clone(), + "blockId": payload.mindmap_id.clone(), + }), + ), ) } else { ( @@ -9902,6 +10218,12 @@ fn execute_command( "documents.emptyTrashByWorkspace" => { let payload: DocumentEmptyTrashCommandPayload = parse_payload(command_wire.payload.clone())?; + let stream_delta_hint = tree_resync_required_hint( + "documents_empty_trash", + json!({ + "workspaceId": payload.workspace_id, + }), + ); let command = CommandEnvelope { name: "documents.emptyTrashByWorkspace".into(), command_id: command_wire.command_id.clone(), @@ -9928,7 +10250,13 @@ fn execute_command( source: workspace_source_value(&command_wire.source), payload_json: request.payload_json, args_json: json!({ - "workspaceId": payload.workspace_id, + "workspaceId": payload.workspace_id.clone(), + "streamDeltaHint": stream_delta_hint.clone(), + "domainEventHint": tree_domain_event_hint("tree.trash.documents.emptied"), + "domainEventPlan": tree_domain_event_plan( + "tree.trash.documents.emptied", + stream_delta_hint, + ), }), })) } @@ -10271,6 +10599,109 @@ fn execute_command( }), })) } + "tree.resource.archive" + | "tree.resource.restore" + | "tree.resource.purge" + | "tree.resource.rename" => { + let action = match command_wire.name.as_str() { + "tree.resource.archive" => "archive", + "tree.resource.restore" => "restore", + "tree.resource.purge" => "purge", + "tree.resource.rename" => "rename", + _ => unreachable!("resource lifecycle command 已匹配"), + }; + let payload: ResourceLifecycleCommandPayload = + parse_payload(command_wire.payload.clone())?; + let lifecycle_plan = build_resource_lifecycle_plan(action, &payload)?; + let command = CommandEnvelope { + name: command_wire.name.clone(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()), + payload: lifecycle_plan.action.clone(), + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + let function_name = + resource_lifecycle_convex_function(action, &lifecycle_plan.resource_kind)?; + let stream_delta_hint = resource_lifecycle_stream_delta_hint(&lifecycle_plan); + let event_type = resource_lifecycle_event_type(action); + + let mut args_json = json!({ + "resourceKind": lifecycle_plan.resource_kind, + "resourceLifecyclePlan": lifecycle_plan, + "streamDeltaHint": stream_delta_hint, + "domainEventHint": tree_domain_event_hint(event_type), + "domainEventPlan": tree_domain_event_plan(event_type, stream_delta_hint), + }); + + if let Some(asset_id) = args_json + .get("resourceLifecyclePlan") + .and_then(|value| value.get("assetId")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + { + args_json["id"] = json!(asset_id); + args_json["userId"] = json!(command_wire.actor.actor_id); + } + if let Some(document_id) = args_json + .get("resourceLifecyclePlan") + .and_then(|value| value.get("documentId")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + { + args_json["docId"] = json!(document_id); + } + if let Some(mindmap_id) = args_json + .get("resourceLifecyclePlan") + .and_then(|value| value.get("mindmapId")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + { + args_json["mindmapId"] = json!(mindmap_id); + } + if let Some(table_id) = args_json + .get("resourceLifecyclePlan") + .and_then(|value| value.get("tableId")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + { + args_json["tableId"] = json!(table_id); + args_json["userId"] = json!(command_wire.actor.actor_id); + } + if let Some(new_name) = args_json + .get("resourceLifecyclePlan") + .and_then(|value| value.get("newName")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + { + args_json["newName"] = json!(new_name); + if action == "rename" && lifecycle_plan.resource_kind == "file" { + args_json["patch"] = json!({ + "file_name": new_name, + }); + } + } + + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: function_name.into(), + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + source: workspace_source_value(&command_wire.source), + payload_json: request.payload_json, + args_json, + })) + } other => Err(BridgeError::validation(format!( "bridge runtime 暂不支持 command: {other}" ))), @@ -11830,10 +12261,7 @@ mod tests { plan.args_json["domainEventPlan"]["eventType"], json!("mindmap.content.updated") ); - assert_eq!( - plan.args_json["streamDeltaHint"]["kind"], - json!("noop") - ); + assert_eq!(plan.args_json["streamDeltaHint"]["kind"], json!("noop")); } RuntimeExecutionPlan::Query(_) | RuntimeExecutionPlan::Tool(_) => { panic!("expected command plan") @@ -15570,6 +15998,90 @@ mod tests { } } + #[test] + fn documents_empty_trash_plan_emits_tree_resync_event() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "documents.emptyTrashByWorkspace".into(), + command_id: "cmd_empty_trash_1".into(), + idempotency_key: Some("idem_empty_trash_1".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: Some("ws_1".into()), + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: None, + block_id: None, + }), + payload: json!({ + "workspaceId": "ws_1", + }), + preflight_data: None, + reason: Some("清空页面垃圾箱".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("empty trash plan should build"); + + match plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!(plan.command_name, "documents.emptyTrashByWorkspace"); + assert_eq!(plan.function_name, "documents:emptyTrashByWorkspace"); + assert_eq!(plan.args_json["workspaceId"], json!("ws_1")); + assert_eq!( + plan.args_json["streamDeltaHint"], + json!({ + "family": "tree", + "kind": "resync_required", + "args": { + "reason": "documents_empty_trash", + "workspaceId": "ws_1" + } + }) + ); + assert_eq!( + plan.args_json["domainEventHint"], + json!({ + "family": "tree", + "eventType": "tree.trash.documents.emptied" + }) + ); + assert_eq!( + plan.args_json["domainEventPlan"], + json!({ + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.trash.documents.emptied", + "streamDeltaHint": { + "family": "tree", + "kind": "resync_required", + "args": { + "reason": "documents_empty_trash", + "workspaceId": "ws_1" + } + } + }) + ); + } + RuntimeExecutionPlan::Query(_) => panic!("expected command plan"), + RuntimeExecutionPlan::Tool(_) => panic!("expected command plan"), + } + } + #[test] fn documents_duplicate_plan_includes_result_document_artifact_contract() { let plan = execute_runtime_input(RuntimeInput::Command { @@ -15965,6 +16477,456 @@ mod tests { } } + #[test] + fn tree_resource_lifecycle_plans_cover_file_asset_commands() { + let cases = [ + ( + "tree.resource.archive", + "mediaAssets:patchById", + "archive", + "tree.resource.archived", + json!({ + "resourceKind": "file", + "assetId": "asset_1", + }), + json!({ + "userId": "user_1", + "id": "asset_1", + "resourceKind": "file", + "resourceLifecyclePlan": { + "action": "archive", + "resourceKind": "file", + "assetId": "asset_1", + "documentId": null, + "mindmapId": null, + "tableId": null, + "newName": null, + }, + "streamDeltaHint": { + "family": "tree", + "kind": "remove_asset", + "args": { + "assetId": "asset_1" + } + }, + "domainEventHint": { + "family": "tree", + "eventType": "tree.resource.archived" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.resource.archived", + "streamDeltaHint": { + "family": "tree", + "kind": "remove_asset", + "args": { + "assetId": "asset_1" + } + } + } + }), + ), + ( + "tree.resource.restore", + "mediaAssets:patchById", + "restore", + "tree.resource.restored", + json!({ + "resourceKind": "file", + "assetId": "asset_1", + }), + json!({ + "userId": "user_1", + "id": "asset_1", + "resourceKind": "file", + "resourceLifecyclePlan": { + "action": "restore", + "resourceKind": "file", + "assetId": "asset_1", + "documentId": null, + "mindmapId": null, + "tableId": null, + "newName": null, + }, + "streamDeltaHint": { + "family": "tree", + "kind": "asset_result", + "args": { + "assetField": "asset" + } + }, + "domainEventHint": { + "family": "tree", + "eventType": "tree.resource.restored" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.resource.restored", + "streamDeltaHint": { + "family": "tree", + "kind": "asset_result", + "args": { + "assetField": "asset" + } + } + } + }), + ), + ( + "tree.resource.purge", + "mediaAssets:purgeById", + "purge", + "tree.resource.purged", + json!({ + "resourceKind": "file", + "assetId": "asset_1", + }), + json!({ + "userId": "user_1", + "id": "asset_1", + "resourceKind": "file", + "resourceLifecyclePlan": { + "action": "purge", + "resourceKind": "file", + "assetId": "asset_1", + "documentId": null, + "mindmapId": null, + "tableId": null, + "newName": null, + }, + "streamDeltaHint": { + "family": "tree", + "kind": "remove_asset", + "args": { + "assetId": "asset_1" + } + }, + "domainEventHint": { + "family": "tree", + "eventType": "tree.resource.purged" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.resource.purged", + "streamDeltaHint": { + "family": "tree", + "kind": "remove_asset", + "args": { + "assetId": "asset_1" + } + } + } + }), + ), + ( + "tree.resource.rename", + "mediaAssets:patchById", + "rename", + "tree.resource.renamed", + json!({ + "resourceKind": "file", + "assetId": "asset_1", + "newName": "new.pdf", + }), + json!({ + "userId": "user_1", + "id": "asset_1", + "patch": { + "file_name": "new.pdf", + }, + "resourceKind": "file", + "newName": "new.pdf", + "resourceLifecyclePlan": { + "action": "rename", + "resourceKind": "file", + "assetId": "asset_1", + "documentId": null, + "mindmapId": null, + "tableId": null, + "newName": "new.pdf", + }, + "streamDeltaHint": { + "family": "tree", + "kind": "asset_result", + "args": { + "assetField": "asset" + } + }, + "domainEventHint": { + "family": "tree", + "eventType": "tree.resource.renamed" + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.resource.renamed", + "streamDeltaHint": { + "family": "tree", + "kind": "asset_result", + "args": { + "assetField": "asset" + } + } + } + }), + ), + ]; + + for (command_name, function_name, action, event_type, payload, args_json) in cases { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: command_name.into(), + command_id: format!("cmd_{action}_asset"), + idempotency_key: Some(format!("idem_{action}_asset")), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload, + preflight_data: None, + reason: Some(format!("文件树资源 {action}")), + refs: vec!["file-tree-resource-command".into()], + dry_run: false, + validate_only: false, + }, + }) + .expect("resource lifecycle plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!(plan.command_name, command_name); + assert_eq!(plan.function_name, function_name); + assert_eq!(plan.args_json, args_json); + assert_eq!(plan.args_json["domainEventHint"]["eventType"], event_type); + } + } + + #[test] + fn tree_resource_lifecycle_plans_cover_mindmap_and_table_commands() { + let cases = [ + ( + "tree.resource.archive", + "mindmap", + "mindmaps:softDelete", + json!({ + "resourceKind": "mindmap", + "documentId": "doc_1", + "mindmapId": "mind_1", + }), + json!({ + "docId": "doc_1", + "mindmapId": "mind_1", + "resourceKind": "mindmap", + "resourceLifecyclePlan": { + "action": "archive", + "resourceKind": "mindmap", + "assetId": null, + "documentId": "doc_1", + "mindmapId": "mind_1", + "tableId": null, + "newName": null, + }, + }), + ), + ( + "tree.resource.restore", + "mindmap", + "mindmaps:restore", + json!({ + "resourceKind": "mindmap", + "documentId": "doc_1", + "mindmapId": "mind_1", + }), + json!({ + "docId": "doc_1", + "mindmapId": "mind_1", + "resourceKind": "mindmap", + "resourceLifecyclePlan": { + "action": "restore", + "resourceKind": "mindmap", + "assetId": null, + "documentId": "doc_1", + "mindmapId": "mind_1", + "tableId": null, + "newName": null, + }, + }), + ), + ( + "tree.resource.purge", + "mindmap", + "mindmaps:purge", + json!({ + "resourceKind": "mindmap", + "documentId": "doc_1", + "mindmapId": "mind_1", + }), + json!({ + "docId": "doc_1", + "mindmapId": "mind_1", + "resourceKind": "mindmap", + "resourceLifecyclePlan": { + "action": "purge", + "resourceKind": "mindmap", + "assetId": null, + "documentId": "doc_1", + "mindmapId": "mind_1", + "tableId": null, + "newName": null, + }, + }), + ), + ( + "tree.resource.archive", + "table", + "tables:remove", + json!({ + "resourceKind": "table", + "tableId": "table_1", + }), + json!({ + "tableId": "table_1", + "userId": "user_1", + "resourceKind": "table", + "resourceLifecyclePlan": { + "action": "archive", + "resourceKind": "table", + "assetId": null, + "documentId": null, + "mindmapId": null, + "tableId": "table_1", + "newName": null, + }, + }), + ), + ( + "tree.resource.restore", + "table", + "tables:restore", + json!({ + "resourceKind": "table", + "tableId": "table_1", + }), + json!({ + "tableId": "table_1", + "userId": "user_1", + "resourceKind": "table", + "resourceLifecyclePlan": { + "action": "restore", + "resourceKind": "table", + "assetId": null, + "documentId": null, + "mindmapId": null, + "tableId": "table_1", + "newName": null, + }, + }), + ), + ( + "tree.resource.purge", + "table", + "tables:purge", + json!({ + "resourceKind": "table", + "tableId": "table_1", + }), + json!({ + "tableId": "table_1", + "userId": "user_1", + "resourceKind": "table", + "resourceLifecyclePlan": { + "action": "purge", + "resourceKind": "table", + "assetId": null, + "documentId": null, + "mindmapId": null, + "tableId": "table_1", + "newName": null, + }, + }), + ), + ]; + + for (command_name, resource_kind, function_name, payload, expected_subset) in cases { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: command_name.into(), + command_id: format!("cmd_{resource_kind}_{command_name}"), + idempotency_key: Some(format!("idem_{resource_kind}_{command_name}")), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload, + preflight_data: None, + reason: Some(format!("{resource_kind} 资源生命周期")), + refs: vec!["file-tree-resource-command".into()], + dry_run: false, + validate_only: false, + }, + }) + .expect("mindmap/table resource lifecycle plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!(plan.command_name, command_name); + assert_eq!(plan.function_name, function_name); + for (key, expected) in expected_subset.as_object().expect("subset object") { + assert_eq!(&plan.args_json[key], expected, "args_json.{key}"); + } + assert_eq!( + plan.args_json["domainEventHint"]["eventType"], + match command_name { + "tree.resource.archive" => "tree.resource.archived", + "tree.resource.restore" => "tree.resource.restored", + "tree.resource.purge" => "tree.resource.purged", + _ => unreachable!("covered command"), + } + ); + } + } + #[test] fn tree_resource_transfer_rejects_empty_assets_or_target() { let empty_assets = execute_runtime_input(RuntimeInput::Command { @@ -16239,6 +17201,235 @@ mod tests { assert_eq!(err.message, "不能把页面移动到自身或后代下面"); } + #[test] + fn tree_filetree_drop_preflight_rejects_readonly_source_or_target() { + let readonly_target = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.filetree.drop.preflight".into(), + command_id: "cmd_filetree_drop_preflight_readonly_target".into(), + idempotency_key: Some("idem_filetree_drop_preflight_readonly_target".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_target".into()), + block_id: None, + }), + payload: json!({ + "copy": false, + "sourceCapabilities": ["read", "write", "move"], + "targetCapabilities": ["read"], + "targetDocumentId": "doc_target", + "targetRowId": "doc:doc_target", + "focusedRowId": null, + "activeDocumentId": null, + "rowIds": ["doc:doc_source"], + "rows": [ + { + "rowId": "doc:doc_target", + "rowKind": "doc", + "documentId": "doc_target", + "title": "目标" + }, + { + "rowId": "doc:doc_source", + "rowKind": "doc", + "documentId": "doc_source", + "title": "来源" + } + ], + "documentParents": [ + { "documentId": "doc_target", "parentId": null }, + { "documentId": "doc_source", "parentId": null } + ] + }), + preflight_data: None, + reason: Some("文件树内部拖放预检".into()), + refs: vec!["file-tree-shell".into()], + dry_run: false, + validate_only: true, + }, + }) + .expect_err("readonly target should reject drop before execution"); + + assert_eq!(readonly_target.kind, BridgeErrorKind::Validation); + assert_eq!(readonly_target.message, "目标位置是只读,不能拖放到这里"); + + let readonly_source = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.filetree.drop.preflight".into(), + command_id: "cmd_filetree_drop_preflight_readonly_source".into(), + idempotency_key: Some("idem_filetree_drop_preflight_readonly_source".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_target".into()), + block_id: None, + }), + payload: json!({ + "copy": false, + "sourceCapabilities": ["read"], + "targetCapabilities": ["read", "write", "drop"], + "targetDocumentId": "doc_target", + "targetRowId": "doc:doc_target", + "focusedRowId": null, + "activeDocumentId": null, + "rowIds": ["doc:doc_source"], + "rows": [ + { + "rowId": "doc:doc_target", + "rowKind": "doc", + "documentId": "doc_target", + "title": "目标" + }, + { + "rowId": "doc:doc_source", + "rowKind": "doc", + "documentId": "doc_source", + "title": "来源" + } + ], + "documentParents": [ + { "documentId": "doc_target", "parentId": null }, + { "documentId": "doc_source", "parentId": null } + ] + }), + preflight_data: None, + reason: Some("文件树内部拖放预检".into()), + refs: vec!["file-tree-shell".into()], + dry_run: false, + validate_only: true, + }, + }) + .expect_err("readonly source should reject move before execution"); + + assert_eq!(readonly_source.kind, BridgeErrorKind::Validation); + assert_eq!(readonly_source.message, "来源是只读,不能移动这些对象"); + } + + #[test] + fn tree_filetree_drop_preflight_marks_same_name_conflicts_for_confirmation() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "tree.filetree.drop.preflight".into(), + command_id: "cmd_filetree_drop_preflight_conflict".into(), + idempotency_key: Some("idem_filetree_drop_preflight_conflict".into()), + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: Some("sess_1".into()), + }, + source: RuntimeSourceWire { + channel: "next-route".into(), + client: "wolai-frontend".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_target".into()), + block_id: None, + }), + payload: json!({ + "copy": false, + "sourceCapabilities": ["read", "write", "move"], + "targetCapabilities": ["read", "write", "drop"], + "targetDocumentId": "doc_target", + "targetRowId": "doc:doc_target", + "focusedRowId": null, + "activeDocumentId": null, + "rowIds": ["doc:doc_source"], + "rows": [ + { + "rowId": "doc:doc_target", + "rowKind": "doc", + "documentId": "doc_target", + "title": "目标" + }, + { + "rowId": "doc:doc_source", + "rowKind": "doc", + "documentId": "doc_source", + "title": "同名页面" + } + ], + "targetChildren": [ + { + "rowKind": "doc", + "documentId": "doc_existing", + "title": "同名页面" + } + ], + "documentParents": [ + { "documentId": "doc_target", "parentId": null }, + { "documentId": "doc_source", "parentId": null }, + { "documentId": "doc_existing", "parentId": "doc_target" } + ], + "conflictPolicy": "prompt" + }), + preflight_data: None, + reason: Some("文件树内部拖放预检".into()), + refs: vec!["file-tree-shell".into()], + dry_run: false, + validate_only: true, + }, + }) + .expect("same-name conflict should return a confirmable plan"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + + assert_eq!( + plan.args_json["fileTreeDropPlan"]["requiresConfirmation"], + true + ); + assert_eq!( + plan.args_json["fileTreeDropPlan"]["conflicts"], + json!([ + { + "rowKind": "doc", + "sourceRowId": "doc:doc_source", + "sourceDocumentId": "doc_source", + "sourceAssetId": null, + "existingDocumentId": "doc_existing", + "existingAssetId": null, + "targetDocumentId": "doc_target", + "title": "同名页面", + "policy": "prompt" + } + ]) + ); + } + #[test] fn tree_filetree_delete_preflight_plans_doc_and_asset_targets() { let plan = execute_runtime_input(RuntimeInput::Command { @@ -16266,7 +17457,7 @@ mod tests { block_id: None, }), payload: json!({ - "rowIds": ["asset:pdf_1", "doc:doc_parent", "index:doc_child", "asset:asset_child_1", "missing"], + "rowIds": ["asset:pdf_1", "doc:doc_parent", "index:doc_child", "asset:asset_child_1", "asset:asset_other_1", "missing"], "rows": [ { "rowId": "doc:doc_parent", @@ -16295,11 +17486,21 @@ mod tests { "assetDocumentId": "doc_child", "assetType": "file", "storagePath": "uploads/child.png" + }, + { + "rowId": "asset:asset_other_1", + "rowKind": "asset", + "documentId": "doc_other", + "assetId": "asset_other_1", + "assetDocumentId": "doc_other", + "assetType": "file", + "storagePath": "uploads/other.png" } ], "documentParents": [ { "documentId": "doc_parent", "parentId": null }, - { "documentId": "doc_child", "parentId": "doc_parent" } + { "documentId": "doc_child", "parentId": "doc_parent" }, + { "documentId": "doc_other", "parentId": null } ] }), preflight_data: None, @@ -16320,10 +17521,10 @@ mod tests { assert_eq!( plan.args_json["fileTreeDeletePlan"], json!({ - "rowIds": ["asset:pdf_1", "doc:doc_parent", "index:doc_child", "asset:asset_child_1"], + "rowIds": ["asset:pdf_1", "doc:doc_parent", "index:doc_child", "asset:asset_child_1", "asset:asset_other_1"], "docIds": ["doc_parent"], - "assetIds": [], - "assetDocumentIds": [] + "assetIds": ["asset_other_1"], + "assetDocumentIds": ["doc_other"] }) ); } @@ -16981,6 +18182,8 @@ mod tests { item_by_row_id["doc:page_root"]["resourceMeta"]["resourceKind"], json!("document") ); + assert_eq!(item_by_row_id["doc:page_root"]["title"], json!("根页面.md")); + assert!(!item_by_row_id.contains_key("index:page_root")); assert_eq!( item_by_row_id["doc:page_root"]["resourceMeta"]["extra"]["source"], json!({ @@ -16991,30 +18194,15 @@ mod tests { "operationProfile": "convex_workspace" }) ); - assert_eq!(item_by_row_id["index:page_root"]["rowKind"], json!("index")); assert_eq!( - item_by_row_id["index:page_root"]["resourceMeta"]["resourceKind"], - json!("index") - ); - assert_eq!( - item_by_row_id["index:page_root"]["resourceMeta"]["objectIdentity"], + item_by_row_id["doc:page_root"]["resourceMeta"]["objectIdentity"], json!({ - "objectKind": "index", + "objectKind": "page", "documentId": "page_root", "blockId": null, "assetId": null }) ); - assert_eq!( - item_by_row_id["index:page_root"]["resourceMeta"]["extra"]["source"], - json!({ - "sourceKind": "convex_workspace", - "sourceUri": "convex://workspace/ws_1/documents/page_root/index", - "relativePath": "documents/page_root/index", - "storageIdentity": "page_root:index", - "operationProfile": "convex_workspace" - }) - ); assert_eq!( item_by_row_id["asset:asset_img_1"]["resourceMeta"]["assetKind"], json!("image") @@ -17399,7 +18587,7 @@ mod tests { } #[test] - fn kernel_file_tree_projection_query_matches_index_resource() { + fn kernel_file_tree_projection_query_matches_page_markdown_resource() { let result = execute_runtime_query(RuntimeInput::Query { context: demo_context(), query: RuntimeQueryEnvelopeWire { @@ -17411,7 +18599,7 @@ mod tests { "depth": 2, "includeEdges": true, "nodeTypes": ["page"], - "query": "index.md" + "query": "根页面.md" }), }, data: Some(json!({ @@ -17434,7 +18622,7 @@ mod tests { "mindmap_asset_children": {} })), }) - .expect("file_tree index search projection should build"); + .expect("file_tree page markdown search projection should build"); let row_ids = result["items"] .as_array() @@ -17443,10 +18631,67 @@ mod tests { .filter_map(|item| item.get("rowId").and_then(Value::as_str)) .collect::>(); - assert_eq!(row_ids, vec!["doc:page_root", "index:page_root"]); + assert_eq!(row_ids, vec!["doc:page_root"]); assert_eq!( - result["items"][1]["resourceMeta"]["resourceKind"], - json!("index") + result["items"][0]["resourceMeta"]["resourceKind"], + json!("document") + ); + } + + #[test] + fn convex_file_tree_projects_page_body_as_title_markdown_row_without_visible_index() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "kernel.project_view".into(), + payload: json!({ + "projection": "file_tree", + "workspaceId": "ws_1", + "rootNodeId": "page_root", + "depth": 2, + "includeEdges": true, + "nodeTypes": ["page"] + }), + }, + data: Some(json!({ + "documents": [ + { + "id": "page_root", + "workspace_id": "ws_1", + "title": "根页面", + "parent_id": null, + "sort_order": 0, + "is_starred": true, + "is_template": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + } + ], + "media_assets": [], + "mindmap_assets": [], + "table_assets": [], + "mindmap_asset_children": {} + })), + }) + .expect("file_tree projection result should build"); + + let items = result["items"].as_array().expect("items should be array"); + let row_ids = items + .iter() + .filter_map(|item| item.get("rowId").and_then(Value::as_str)) + .collect::>(); + + assert_eq!(row_ids, vec!["doc:page_root"]); + assert_eq!(items[0]["title"], json!("根页面.md")); + assert_eq!(items[0]["rowKind"], json!("document")); + assert_eq!( + items[0]["resourceMeta"]["objectIdentity"], + json!({ + "objectKind": "page", + "documentId": "page_root", + "blockId": null, + "assetId": null + }) ); } @@ -17560,9 +18805,18 @@ mod tests { assert_eq!(artifact_edges[0]["fromNodeId"], json!("page_root")); assert_eq!(artifact_edges[0]["toNodeId"], json!("summary_page_root")); assert_eq!(artifact_edges[0]["edgeType"], json!("source_of")); - assert_eq!(artifact_edges[0]["metadata"]["artifactType"], json!("summary")); - assert_eq!(artifact_edges[1]["toNodeId"], json!("ai_note_page_root_req_1")); - assert_eq!(artifact_edges[1]["metadata"]["artifactType"], json!("ai_note")); + assert_eq!( + artifact_edges[0]["metadata"]["artifactType"], + json!("summary") + ); + assert_eq!( + artifact_edges[1]["toNodeId"], + json!("ai_note_page_root_req_1") + ); + assert_eq!( + artifact_edges[1]["metadata"]["artifactType"], + json!("ai_note") + ); } #[test] @@ -17617,7 +18871,10 @@ mod tests { assert!(!items .iter() .any(|item| item["nodeId"] == json!("AI Artifacts"))); - assert_eq!(result["meta"]["aiArtifacts"]["title"], json!("AI Artifacts")); + assert_eq!( + result["meta"]["aiArtifacts"]["title"], + json!("AI Artifacts") + ); assert_eq!(result["meta"]["aiArtifacts"]["projectionOnly"], json!(true)); assert_eq!( result["meta"]["aiArtifacts"]["source"], diff --git a/rust/crates/core-protocol/src/lib.rs b/rust/crates/core-protocol/src/lib.rs index e6c7f6f1..3c2ef391 100644 --- a/rust/crates/core-protocol/src/lib.rs +++ b/rust/crates/core-protocol/src/lib.rs @@ -40,16 +40,16 @@ pub use kernel::{ DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree, DocumentReadStats, DocumentReadSubtree, KernelAttachEdge, KernelAuditStamp, - KernelBlockAssetRelation, KernelContentPayload, KernelCreateNode, KernelDetachEdge, - KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, - KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, - KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, - KernelObjectIdentity, KernelObjectKind, KernelProjectionAssetKind, - KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, - KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta, - KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, - KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, WorkspaceSource, - WorkspaceSourceCapability, WorkspaceSourceKind, + KernelBlockAssetRelation, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge, + KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection, + KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges, + KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelObjectIdentity, + KernelObjectKind, KernelProjectionAssetKind, KernelProjectionCapability, + KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest, + KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult, + KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult, + KernelTraverseGraph, KernelUpdateNode, WorkspaceSource, WorkspaceSourceCapability, + WorkspaceSourceKind, }; pub use mindmap::{ MindmapAdapterProjection, MindmapAssociativeLine, MindmapCommand, MindmapKernelCapabilities, diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index 38bc4e62..42dfec0f 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -82,6 +82,12 @@ pub struct DocumentPurgeRequest { pub document_id: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentEmptyTrashRequest { + pub workspace_id: String, +} + const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL"; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport"; @@ -651,6 +657,87 @@ pub async fn purge( Ok(ok_response(&context, result)) } +pub async fn empty_trash( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let workspace_id = body.workspace_id.trim(); + if workspace_id.is_empty() { + return Err( + WebError::bad_request_code("workspace_id_required", "缺少有效 workspaceId") + .with_context(&context), + ); + } + let command = RuntimeCommandEnvelopeWire { + name: "documents.emptyTrashByWorkspace".into(), + command_id: format!("documents_empty_trash_{}", context.trace.request_id), + idempotency_key: context.source.idempotency_key.clone(), + actor: RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: context.auth.actor_id.clone(), + session_id: context.auth.session_id.clone(), + }, + source: RuntimeSourceWire { + channel: context.source.channel.clone(), + client: context.source.client.clone(), + source_kind: None, + root_uri: None, + workspace_id: Some(workspace_id.to_string()), + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some(workspace_id.to_string()), + page_id: None, + block_id: None, + }), + payload: json!({ + "workspaceId": workspace_id, + }), + preflight_data: None, + reason: Some("mnote-web documents empty trash".into()), + refs: vec!["mnote-web-documents-trash".into()], + dry_run: false, + validate_only: false, + }; + let execution = execute_runtime_command_via_convex_with_artifacts( + state.config(), + &context, + Some(workspace_id), + command, + ) + .await?; + let artifacts = execution_artifacts_json(&execution); + let artifact_error = execution.artifact_error.clone(); + let mut result = execution.result; + if let Value::Object(map) = &mut result { + map.insert( + "canonicalCommand".into(), + json!("documents.emptyTrashByWorkspace"), + ); + map.insert("compatRoute".into(), json!("/api/documents/empty-trash")); + } + let mut headers = HeaderMap::new(); + stamp_documents_headers(&mut headers); + Ok(( + StatusCode::OK, + headers, + Json(json!({ + "ok": true, + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "owner": "mnote-web", + "meta": { + "commandName": "documents.emptyTrashByWorkspace", + "canonicalCommand": "documents.emptyTrashByWorkspace", + "artifacts": artifacts, + "artifactError": artifact_error, + }, + "result": result, + })), + )) +} + pub async fn title( State(state): State, Extension(context): Extension, @@ -926,6 +1013,10 @@ mod tests { "revision": 8, "conflict_detection_key": "doc_1:8" }, + "documents:emptyTrashByWorkspace": { + "ok": true, + "deletedCount": 2 + }, "bridgeLogs:recordCommandLog": { "ok": true, "id": "clog_fixture" @@ -1063,6 +1154,50 @@ mod tests { assert_eq!(payload["result"]["canonicalCommand"], "page.body.save"); } + #[tokio::test] + async fn documents_empty_trash_route_executes_workspace_trash_purge() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/documents/empty-trash") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "workspaceId": "ws_demo" + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["result"]["deletedCount"], 2); + assert_eq!( + payload["result"]["canonicalCommand"], + "documents.emptyTrashByWorkspace" + ); + assert_eq!( + payload["meta"]["artifacts"]["commandLog"]["commandName"], + "documents.emptyTrashByWorkspace" + ); + assert_eq!( + payload["meta"]["artifacts"]["domainEvent"]["eventType"], + "tree.trash.documents.emptied" + ); + assert_eq!( + payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"], + "resync_required" + ); + assert_eq!(payload["meta"]["artifactError"], Value::Null); + } + #[tokio::test] async fn documents_title_route_executes_page_head_update_title() { let response = app() diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index c651d15c..84afc316 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -2,6 +2,7 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot; +use crate::routes::snapshot_support::load_sidebar_dataset; use crate::routes::web_shell::{ build_document_panes_bootstrap_json, build_editor_bootstrap_json, build_page_aggregate_snapshot, escape_html, escape_script_json, load_file_tree_html, @@ -19,8 +20,9 @@ use axum::extract::{Extension, Query, State}; use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri}; use axum::response::{Html, IntoResponse, Response}; use futures_util::{SinkExt, StreamExt}; +use leptos::prelude::InnerHtmlAttribute; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{json, Value}; use std::time::Duration; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; @@ -276,6 +278,7 @@ pub async fn root_entry( &context, &workspace_id, selected_active_page_id.as_deref(), + None, ) .await .unwrap_or_default(); @@ -399,6 +402,426 @@ pub async fn root_entry( Ok(response) } +pub async fn trash_entry( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result { + if !has_real_auth_context(&context) { + let mut response = Response::builder() + .status(StatusCode::SEE_OTHER) + .header(header::LOCATION, "/auth") + .body(Body::empty()) + .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?; + stamp_gateway_headers(response.headers_mut(), false); + return Ok(response); + } + + let default_workspace_name = format!("{} 的空间", state.config().dev_user_name); + let workspace_id = + resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?; + let workspace_projection = load_workspace_shell_projection( + state.config(), + &context, + &workspace_id, + None, + &default_workspace_name, + ) + .await; + let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, &workspace_id, None) + .await + .unwrap_or_default(); + let file_tree_html = load_file_tree_html(state.config(), &context, &workspace_id, None, None) + .await + .unwrap_or_default(); + let workspace_sidebar_html = render_workspace_shell_sidebar_html( + &workspace_projection, + Some(sidebar_tree_html.as_str()), + Some(file_tree_html.as_str()), + ); + let dataset = load_sidebar_dataset(state.config(), &context, &workspace_id) + .await + .unwrap_or_else(|_| { + json!({ + "active_workspace_id": workspace_id, + "workspaces": [{ "id": workspace_id, "name": workspace_projection.workspace_name }], + "documents": [], + "trashed_documents": [], + "trashed_media_assets": [], + "trashed_mindmap_assets": [], + "trashed_table_assets": [], + "degraded": true + }) + }); + let trash_workbench_html = render_trash_workbench_html(&workspace_id, &dataset); + let workspace_name = workspace_projection.workspace_name.clone(); + let content = crate::ssr::render_view(leptos::view! { + +
+
+ }); + + let mut response = Html(format!( + r#" + + + + 垃圾箱 + + + + {} + +"#, + crate::ssr::MNOTE_CSS, + content, + )) + .into_response(); + stamp_gateway_headers(response.headers_mut(), false); + context.apply_response_headers(response.headers_mut()); + Ok(response) +} + +fn render_trash_workbench_html(workspace_id: &str, dataset: &Value) -> String { + let documents = json_array(dataset, "trashed_documents"); + let media_assets = json_array(dataset, "trashed_media_assets"); + let mindmap_assets = json_array(dataset, "trashed_mindmap_assets"); + let table_assets = json_array(dataset, "trashed_table_assets"); + let resource_count = media_assets.len() + mindmap_assets.len() + table_assets.len(); + let document_rows = render_trashed_document_rows(workspace_id, documents); + let mut resource_rows = String::new(); + resource_rows.push_str(&render_trashed_resource_rows("media", "附件", media_assets)); + resource_rows.push_str(&render_trashed_resource_rows( + "mindmap", + "思维导图", + mindmap_assets, + )); + resource_rows.push_str(&render_trashed_resource_rows("table", "表格", table_assets)); + let resource_body = if resource_count == 0 { + r#"
暂无已删除资源
"#.to_string() + } else { + resource_rows + }; + + format!( + r#"
+
+

垃圾箱

+

默认删除的页面会先进入这里;彻底删除会永久移除。

+

+
+
+
+

页面 {document_count}

+ +
+ {document_rows} +
+
+
+

资源 {resource_count}

+ +
+ {resource_body} +

资源恢复、彻底删除和清空当前走 Rust 兼容入口;正式 tree.resource.* 命令仍在后续阶段收口。

+
+
+"#, + workspace_id = escape_html(workspace_id), + document_count = documents.len(), + empty_disabled = if documents.is_empty() { + " disabled" + } else { + "" + }, + resource_empty_disabled = if resource_count == 0 { " disabled" } else { "" }, + resource_count = resource_count, + document_rows = document_rows, + resource_body = resource_body, + ) +} + +fn json_array<'a>(dataset: &'a Value, key: &str) -> &'a [Value] { + dataset + .get(key) + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]) +} + +fn render_trashed_document_rows(workspace_id: &str, documents: &[Value]) -> String { + if documents.is_empty() { + return r#"
暂无已删除页面
"#.to_string(); + } + + documents + .iter() + .filter_map(|document| { + let id = document.get("id").and_then(Value::as_str)?; + let title = document + .get("title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("无标题"); + let deleted_at = document + .get("deleted_at") + .or_else(|| document.get("deletedAt")) + .and_then(Value::as_str) + .unwrap_or(""); + Some(format!( + r#"
+
+ {title} + {deleted_at} +
+
+ + +
+
"#, + id = escape_html(id), + workspace_id = escape_html(workspace_id), + title = escape_html(title), + deleted_at = escape_html(deleted_at), + )) + }) + .collect::>() + .join("") +} + +fn render_trashed_resource_rows(kind: &str, label: &str, resources: &[Value]) -> String { + resources + .iter() + .filter_map(|resource| { + let id = resource.get("id").and_then(Value::as_str)?; + let document_id = resource + .get("document_id") + .or_else(|| resource.get("documentId")) + .and_then(Value::as_str) + .unwrap_or(""); + let title = resource + .get("file_name") + .or_else(|| resource.get("title")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("未命名资源"); + let deleted_at = resource + .get("deleted_at") + .or_else(|| resource.get("deletedAt")) + .and_then(Value::as_str) + .unwrap_or(""); + Some(format!( + r#"
+
+ {label} + {title} + {deleted_at} +
+
+ + +
+
"#, + kind = escape_html(kind), + id = escape_html(id), + document_id = escape_html(document_id), + label = escape_html(label), + title = escape_html(title), + deleted_at = escape_html(deleted_at), + )) + }) + .collect::>() + .join("") +} + pub async fn legacy_next_proxy( State(state): State, Extension(context): Extension, @@ -1185,6 +1608,53 @@ mod tests { assert!(!html.contains(r#"文档"#)); } + #[tokio::test] + async fn trash_entry_renders_real_workspace_trash_workbench() { + let response = app_with_query_fixtures( + "http://127.0.0.1:3100".into(), + false, + None, + Some( + r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[{"id":"ws_demo","name":"我的空间"}],"documents":[{"id":"page_alive","workspace_id":"ws_demo","title":"保留页面","parent_id":null,"sort_order":0,"is_starred":false}],"trashed_documents":[{"id":"page_trash","workspace_id":"ws_demo","title":"已删页面","parent_id":null,"sort_order":1,"deleted_at":"2026-05-14T00:00:00Z","deleted_by":"user_real"}],"media_assets":[],"trashed_media_assets":[{"id":"asset_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删附件.png","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_assets":[],"trashed_mindmap_assets":[{"id":"mind_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删思维导图.json","deleted_at":"2026-05-14T00:00:00Z"}],"table_assets":[],"trashed_table_assets":[{"id":"table_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删表格.luckysheet","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_docs":[],"mindmap_asset_children":{}}}"#.into(), + ), + ) + .oneshot( + Request::builder() + .uri("/trash?workspaceId=ws_demo") + .header("x-mnote-actor-id", "user_real") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains(r#"data-testid="mnote-trash-workbench""#)); + assert!(html.contains("已删页面")); + assert!(html.contains(r#"data-trash-action="restore""#)); + assert!(html.contains(r#"data-trash-action="purge""#)); + assert!(html.contains(r#"data-trash-action="empty-documents""#)); + assert!(html.contains(r#"data-document-id="page_trash""#)); + assert!(html.contains(r#"data-trash-action="empty-resources""#)); + assert!(html.contains(r#"data-trash-action="resource-restore""#)); + assert!(html.contains(r#"data-trash-action="resource-purge""#)); + assert!(html.contains(r#"data-resource-kind="media""#)); + assert!(html.contains(r#"data-resource-kind="mindmap""#)); + assert!(html.contains(r#"data-resource-kind="table""#)); + assert!(html.contains("已删附件.png")); + assert!(html.contains("已删思维导图.json")); + assert!(html.contains("已删表格.luckysheet")); + assert!(html.contains("new EventSource")); + assert!(html.contains("/api/tree/events")); + assert!(html.contains("refreshTrashWorkbenchFromServer")); + assert!(!html.contains("window.location.reload")); + } + #[test] fn root_entry_active_selection_prefers_page_id_over_recent_projection_and_first_page() { let selected = super::choose_root_entry_active_page_id( diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index 36e2f41c..e1e4f4be 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -318,7 +318,7 @@ mod tests { } #[tokio::test] - async fn file_tree_projection_includes_index_and_asset_rows() { + async fn file_tree_projection_includes_page_markdown_and_asset_rows() { let response = app() .oneshot( Request::builder() @@ -345,16 +345,14 @@ mod tests { .collect::>(); assert!(row_kinds.contains(&"document")); - assert!(row_kinds.contains(&"index")); assert!(row_kinds.contains(&"asset")); assert!(row_kinds.contains(&"asset_folder")); assert!(resource_kinds.contains(&"document")); - assert!(resource_kinds.contains(&"index")); assert!(resource_kinds.contains(&"asset")); assert!(resource_kinds.contains(&"mindmap")); assert!(resource_kinds.contains(&"table")); assert_eq!(items[0]["nodeId"], "page_root"); - assert_eq!(items[1]["nodeId"], "index:page_root"); + assert_eq!(items[0]["title"], "工作区首页.md"); } #[tokio::test] diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index 05b35b5c..6a7a06e4 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -38,10 +38,28 @@ struct LocalFolderMetadata { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalTrashEntry { + #[serde(default, alias = "documentId")] document_id: String, + #[serde(default, alias = "resourceKind")] + resource_kind: String, + #[serde(default, alias = "resourceScope")] + resource_scope: String, + #[serde(default, alias = "originalFilePath")] + original_file_path: String, + #[serde(default, alias = "trashedFilePath")] + trashed_file_path: String, + #[serde(default, alias = "trashEntryId")] + trash_entry_id: String, + #[serde(default, alias = "originalRelativePath")] original_relative_path: String, + #[serde(default, alias = "trashRelativePath")] trash_relative_path: String, + #[serde(default, alias = "deletedAtMs")] deleted_at_ms: u128, + #[serde(default, alias = "archivedAt")] + archived_at: u128, + #[serde(default, alias = "purgedAt")] + purged_at: Option, } #[derive(Debug, Clone)] @@ -514,9 +532,9 @@ pub fn execute_local_tree_command( title.unwrap_or("外部文件"), ), "move" => move_local_entry(&canonical_root, document_id, parent_id), - "delete" | "trash" => trash_local_markdown_page(&canonical_root, document_id), - "restore" => restore_local_markdown_page(&canonical_root, document_id), - "purge" => purge_local_markdown_page(&canonical_root, document_id), + "delete" | "trash" => trash_local_entry(&canonical_root, document_id), + "restore" => restore_local_entry(&canonical_root, document_id), + "purge" => purge_local_entry(&canonical_root, document_id), other => Err(WebError::bad_request_code( "local_tree_command_unsupported", format!("local_folder 暂不支持 tree action: {other}"), @@ -917,6 +935,35 @@ fn move_local_directory( })) } +fn trash_local_entry(root: &Path, entry_id: &str) -> Result { + if let Some(file) = resolve_local_raw_file_id(root, entry_id)? { + return trash_local_raw_file(root, entry_id, &file); + } + trash_local_markdown_page(root, entry_id) +} + +fn restore_local_entry(root: &Path, entry_id: &str) -> Result { + let metadata = load_local_folder_metadata(root)?; + if find_local_file_trash_entry_key(&metadata, entry_id).is_some() { + return restore_local_raw_file(root, entry_id); + } + restore_local_markdown_page(root, entry_id) +} + +fn purge_local_entry(root: &Path, entry_id: &str) -> Result { + let metadata = load_local_folder_metadata(root)?; + if find_local_file_trash_entry_key(&metadata, entry_id).is_some() { + return purge_local_raw_file(root, entry_id); + } + if entry_id.starts_with("local:asset:") || entry_id.starts_with("local:node:") { + return Err(WebError::bad_request_code( + "local_trash_entry_not_found", + "本地资源必须先进入回收站后才能永久删除", + )); + } + purge_local_markdown_page(root, entry_id) +} + fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result { let mut metadata = load_local_folder_metadata(root)?; let markdown_file = @@ -954,9 +1001,16 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result Result Result { + let mut metadata = load_local_folder_metadata(root)?; + let relative_path = normalize_relative_path(root, file)?; + if is_markdown_file(&relative_path) { + return Err(WebError::bad_request_code( + "local_file_resource_not_supported", + "Markdown 文件必须走页面生命周期,不能走 local_file 资源回收站", + )); + } + let trash_dir = root.join(".mnote").join("trash"); + fs::create_dir_all(&trash_dir).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法创建本地回收站 {}: {error}", trash_dir.display()), + ) + })?; + let file_name = file + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("local-file"); + let target = next_available_raw_path(&trash_dir, file_name); + fs::rename(file, &target).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法移动本地资源到回收站 {}: {error}", file.display()), + ) + })?; + let trash_relative_path = normalize_relative_path(root, &target)?; + let trash_entry_id = local_file_trash_entry_id(&relative_path); + let now = now_ms(); + metadata.trash_entries.insert( + trash_entry_id.clone(), + LocalTrashEntry { + document_id: entry_id.to_string(), + resource_kind: "local_file".to_string(), + resource_scope: "local_folder".to_string(), + original_file_path: relative_path.clone(), + trashed_file_path: trash_relative_path.clone(), + trash_entry_id: trash_entry_id.clone(), + original_relative_path: relative_path.clone(), + trash_relative_path: trash_relative_path.clone(), + deleted_at_ms: now, + archived_at: now, + purged_at: None, + }, + ); + write_trash_index_metadata(root, &metadata.trash_entries)?; + Ok(json!({ + "ok": true, + "id": entry_id, + "documentId": entry_id, + "resourceKind": "local_file", + "resourceScope": "local_folder", + "originalFilePath": relative_path, + "trashEntryId": trash_entry_id, + "trashPath": trash_relative_path, + "action": "delete", + "canonicalCommand": "tree.resource.archive", + "sourceKind": "local_folder", + })) +} + fn restore_local_markdown_page(root: &Path, document_id: &str) -> Result { let mut metadata = load_local_folder_metadata(root)?; let trash_entry = metadata @@ -1033,6 +1149,73 @@ fn restore_local_markdown_page(root: &Path, document_id: &str) -> Result Result { + let mut metadata = load_local_folder_metadata(root)?; + let trash_key = find_local_file_trash_entry_key(&metadata, entry_id).ok_or_else(|| { + WebError::bad_request_code( + "local_trash_entry_not_found", + "找不到要恢复的本地资源回收站记录", + ) + })?; + let trash_entry = metadata + .trash_entries + .get(&trash_key) + .cloned() + .ok_or_else(|| { + WebError::bad_request_code( + "local_trash_entry_not_found", + "找不到要恢复的本地资源回收站记录", + ) + })?; + let original_relative_path = local_trash_original_path(&trash_entry); + let trash_relative_path = local_trash_file_path(&trash_entry); + let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?; + if !trash_path.is_file() { + return Err(WebError::bad_request_code( + "local_trash_entry_not_found", + "本地资源回收站文件不存在,无法恢复", + )); + } + let original_path = resolve_metadata_relative_path(root, &original_relative_path)?; + if original_path.exists() { + return Err(WebError::new( + StatusCode::CONFLICT, + "local_file_restore_conflict", + "本地资源原路径已存在,恢复会覆盖用户文件", + )); + } + let parent = original_path.parent().ok_or_else(|| { + WebError::bad_request_code("local_tree_command_failed", "无法解析资源恢复目标目录") + })?; + fs::create_dir_all(parent).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法创建资源恢复目标目录 {}: {error}", parent.display()), + ) + })?; + fs::rename(&trash_path, &original_path).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!("无法从本地回收站恢复资源 {}: {error}", trash_path.display()), + ) + })?; + metadata.trash_entries.remove(&trash_key); + write_trash_index_metadata(root, &metadata.trash_entries)?; + Ok(json!({ + "ok": true, + "id": entry_id, + "documentId": entry_id, + "resourceKind": "local_file", + "resourceScope": "local_folder", + "originalFilePath": original_relative_path, + "relativePath": normalize_relative_path(root, &original_path)?, + "trashEntryId": trash_key, + "action": "restore", + "canonicalCommand": "tree.resource.restore", + "sourceKind": "local_folder", + })) +} + fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result { let mut metadata = load_local_folder_metadata(root)?; if let Some(markdown_file) = find_markdown_by_page_id(root, &metadata, document_id)? { @@ -1083,6 +1266,47 @@ fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result Result { + let mut metadata = load_local_folder_metadata(root)?; + let trash_key = find_local_file_trash_entry_key(&metadata, entry_id).ok_or_else(|| { + WebError::bad_request_code( + "local_trash_entry_not_found", + "找不到要永久删除的本地资源回收站记录", + ) + })?; + let trash_entry = metadata.trash_entries.remove(&trash_key).ok_or_else(|| { + WebError::bad_request_code( + "local_trash_entry_not_found", + "找不到要永久删除的本地资源回收站记录", + ) + })?; + let trash_relative_path = local_trash_file_path(&trash_entry); + let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?; + if trash_path.exists() { + fs::remove_file(&trash_path).map_err(|error| { + WebError::bad_request_code( + "local_tree_command_failed", + format!( + "无法永久删除本地资源回收站文件 {}: {error}", + trash_path.display() + ), + ) + })?; + } + write_trash_index_metadata(root, &metadata.trash_entries)?; + Ok(json!({ + "ok": true, + "id": entry_id, + "documentId": entry_id, + "resourceKind": "local_file", + "resourceScope": "local_folder", + "trashEntryId": trash_key, + "action": "purge", + "canonicalCommand": "tree.resource.purge", + "sourceKind": "local_folder", + })) +} + fn resolve_local_parent_directory( root: &Path, metadata: &LocalFolderMetadata, @@ -1222,6 +1446,65 @@ fn resolve_local_raw_file_id(root: &Path, entry_id: &str) -> Result String { + format!("local-file:{relative_path}") +} + +fn is_local_file_trash_entry(entry: &LocalTrashEntry) -> bool { + entry.resource_kind == "local_file" || entry.trash_entry_id.starts_with("local-file:") +} + +fn local_trash_original_path(entry: &LocalTrashEntry) -> String { + if !entry.original_file_path.trim().is_empty() { + entry.original_file_path.clone() + } else { + entry.original_relative_path.clone() + } +} + +fn local_trash_file_path(entry: &LocalTrashEntry) -> String { + if !entry.trashed_file_path.trim().is_empty() { + entry.trashed_file_path.clone() + } else { + entry.trash_relative_path.clone() + } +} + +fn find_local_file_trash_entry_key( + metadata: &LocalFolderMetadata, + entry_id: &str, +) -> Option { + let trimmed = entry_id.trim(); + if metadata + .trash_entries + .get(trimmed) + .map(is_local_file_trash_entry) + .unwrap_or(false) + { + return Some(trimmed.to_string()); + } + let relative_path = trimmed + .strip_prefix("local:asset:") + .or_else(|| trimmed.strip_prefix("local:node:")) + .unwrap_or(trimmed); + let expected_key = local_file_trash_entry_id(relative_path); + if metadata + .trash_entries + .get(&expected_key) + .map(is_local_file_trash_entry) + .unwrap_or(false) + { + return Some(expected_key); + } + metadata + .trash_entries + .iter() + .find(|(_, entry)| { + is_local_file_trash_entry(entry) && local_trash_original_path(entry) == relative_path + }) + .map(|(key, _)| key.clone()) +} + pub fn local_workspace_id_from_root_uri(root_uri: &str) -> Result { let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { diff --git a/rust/crates/mnote-web/src/routes/media.rs b/rust/crates/mnote-web/src/routes/media.rs index d4d0a75e..62cb4eef 100644 --- a/rust/crates/mnote-web/src/routes/media.rs +++ b/rust/crates/mnote-web/src/routes/media.rs @@ -1,16 +1,26 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use crate::transport::convex::{execute_convex_mutation_by_name, execute_convex_query_by_name}; +use crate::routes::command_support::{build_runtime_command_plan, runtime_context}; +use crate::transport::convex::{ + execute_convex_mutation_by_name, execute_convex_query_by_name, + persist_runtime_command_artifacts, +}; use axum::extract::{Multipart, Query, State}; use axum::http::{header, HeaderMap}; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use bridge_runtime::{ + build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire, + RuntimeSourceWire, RuntimeTargetWire, +}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1); @@ -110,6 +120,12 @@ fn new_asset_id() -> String { ) } +fn now_iso_like() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into()) +} + fn asset_type(mime: &str) -> &'static str { if mime.starts_with("image/") { "image" @@ -122,6 +138,73 @@ fn asset_type(mime: &str) -> &'static str { } } +async fn record_upload_artifacts( + state: &AppState, + context: &RequestContext, + user_id: &str, + workspace_id: &str, + document_id: &str, + asset_id: &str, + file: &UploadFile, + asset_kind: &str, + target_sub_path: Option<&str>, + created: &Value, +) -> Result<(), WebError> { + let command = RuntimeCommandEnvelopeWire { + name: "tree.resource.upload".into(), + command_id: format!("resource_upload_{}", context.trace.request_id), + idempotency_key: context.source.idempotency_key.clone(), + actor: RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: user_id.to_string(), + session_id: context.auth.session_id.clone(), + }, + source: RuntimeSourceWire { + channel: context.source.channel.clone(), + client: context.source.client.clone(), + source_kind: None, + root_uri: None, + workspace_id: Some(workspace_id.to_string()), + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some(workspace_id.to_string()), + page_id: Some(document_id.to_string()), + block_id: Some(asset_id.to_string()), + }), + payload: json!({ + "assetId": asset_id, + "workspaceId": workspace_id, + "targetDocumentId": document_id, + "targetSubPath": target_sub_path, + "fileName": file.name, + "fileSize": file.bytes.len(), + "mimeType": file.content_type, + "assetType": asset_kind, + }), + preflight_data: None, + reason: Some("mnote-web media upload tree.resource.upload".into()), + refs: vec!["file-tree-resource-upload".into()], + dry_run: false, + validate_only: false, + }; + let runtime_context = runtime_context(context, Some(workspace_id)); + let plan = build_runtime_command_plan(context, Some(workspace_id), command.clone())?; + let artifact_result = json!({ + "items": [created.clone()], + }); + if let Some(artifacts) = build_runtime_command_artifact_plan( + &runtime_context, + &command, + &plan, + &artifact_result, + &now_iso_like(), + ) { + persist_runtime_command_artifacts(state.config(), context, &artifacts).await?; + } + Ok(()) +} + async fn read_upload_multipart( mut multipart: Multipart, ) -> Result<(UploadFile, String, String, Option), WebError> { @@ -367,6 +450,26 @@ pub async fn upload( .and_then(Value::as_str) .unwrap_or_default() .to_string(); + if let Err(error) = record_upload_artifacts( + &state, + &context, + &user_id, + &workspace_id, + &document_id, + &asset_id, + &file, + &kind, + target_sub_path.as_deref(), + &created, + ) + .await + { + tracing::warn!( + error = %error.message(), + asset_id = %asset_id, + "media upload tree.resource.upload artifacts 记录失败,主上传结果继续返回" + ); + } Ok(Json(json!({ "asset": created, "mindmapUrl": format!("asset:{asset_id}"), diff --git a/rust/crates/mnote-web/src/routes/mindmap_shell.rs b/rust/crates/mnote-web/src/routes/mindmap_shell.rs index 95c63d75..38ea19b7 100644 --- a/rust/crates/mnote-web/src/routes/mindmap_shell.rs +++ b/rust/crates/mnote-web/src/routes/mindmap_shell.rs @@ -47,10 +47,16 @@ pub async fn mindmap_object_shell( load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&doc_id)) .await .unwrap_or_default(); - let file_tree_html = - load_file_tree_html(state.config(), &context, workspace_id, Some(&doc_id)) - .await - .unwrap_or_default(); + let active_filetree_row_id = format!("asset:{mindmap_id}"); + let file_tree_html = load_file_tree_html( + state.config(), + &context, + workspace_id, + Some(&doc_id), + Some(active_filetree_row_id.as_str()), + ) + .await + .unwrap_or_default(); let workspace_name = workspace_projection.workspace_name.clone(); let workspace_sidebar_html = render_workspace_shell_sidebar_html( &workspace_projection, @@ -282,7 +288,52 @@ mod tests { convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, - query_fixtures_json: None, + query_fixtures_json: Some( + serde_json::json!({ + "sidebar:datasetList": { + "active_workspace_id": "ws_demo", + "documents": [ + { + "id": "doc_1", + "workspace_id": "ws_demo", + "title": "页面", + "parent_id": null, + "sort_order": 0 + } + ], + "media_assets": [], + "mindmap_assets": [ + { + "id": "mind_1", + "workspace_id": "ws_demo", + "document_id": "doc_1", + "asset_type": "mindmap", + "file_name": "思维导图.json", + "mime_type": "application/json" + } + ], + "table_assets": [], + "trashed_documents": [], + "trashed_media_assets": [], + "trashed_mindmap_assets": [], + "trashed_table_assets": [], + "mindmap_docs": [], + "mindmap_asset_children": {} + }, + "documents:getMeta": { + "id": "doc_1", + "workspace_id": "ws_demo", + "title": "页面" + }, + "documents:getContent": { + "content": [], + "revision": 1, + "conflict_detection_key": "doc_1:1", + "pageSubtree": {"rootNodeId": "doc_1", "outline": []} + } + }) + .to_string(), + ), mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), @@ -374,6 +425,9 @@ mod tests { assert!(html.contains("\"standaloneObject\"")); assert!(html.contains("\"documentId\":\"doc_1\"")); assert!(html.contains("\"mindmapId\":\"mind_1\"")); + assert!(html.contains("data-row-id=\"asset:mind_1\"")); + assert!(html.contains("data-selected=\"true\"")); + assert!(!html.contains("data-row-id=\"doc:doc_1\" data-row-kind=\"document\" data-node-id=\"doc_1\" data-document-id=\"doc_1\" data-doc-id=\"doc_1\" data-asset-id=\"\" data-object-identity=\"{"objectKind":"page","documentId":"doc_1","blockId":null,"assetId":null}\" data-shell-mode=\"filetree\" data-selected=\"true\"")); assert!(html.contains("runtimeModule.mount(mountTarget, bootstrap)")); assert!(html.contains("/api/leptos-tiptap-runtime/manifest.json")); assert!(!html.contains("react_mindmap_runtime")); diff --git a/rust/crates/mnote-web/src/routes/resource_trash.rs b/rust/crates/mnote-web/src/routes/resource_trash.rs new file mode 100644 index 00000000..afcdbb08 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/resource_trash.rs @@ -0,0 +1,1250 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::routes::command_support::{ + execute_runtime_command_via_convex_with_artifacts, runtime_context, +}; +use crate::transport::convex::{ + execute_convex_mutation_by_name, execute_convex_query_by_name, + persist_runtime_command_artifacts, +}; +use axum::extract::{Extension, Path, State}; +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::Json; +use bridge_runtime::{ + build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire, + RuntimeCommandExecutionPlan, RuntimeSourceWire, RuntimeTargetWire, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use time::format_description::well_known::Rfc3339; +use time::{Duration, OffsetDateTime}; + +const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; +const HEADER_RESOURCE_TRASH_TRANSPORT: &str = "x-mnote-resource-trash-transport"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaBatchRequest { + pub action: String, + #[serde(default)] + pub asset_ids: Vec, + pub new_name: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaPurgeRequest { + pub asset_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceTrashRequest { + pub workspace_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MindmapTrashRequest { + pub action: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TableTrashRequest { + pub table_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TableCreateRequest { + pub document_id: String, + pub title: Option, + pub schema: Option, + pub snapshot: Option, +} + +fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json) { + let mut headers = HeaderMap::new(); + stamp_headers(&mut headers); + ( + StatusCode::OK, + headers, + Json(json!({ + "ok": true, + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "owner": "mnote-web", + "result": result, + })), + ) +} + +fn stamp_headers(headers: &mut HeaderMap) { + if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { + headers.insert(name, HeaderValue::from_static("mnote-web")); + } + if let Ok(name) = HeaderName::from_lowercase(HEADER_RESOURCE_TRASH_TRANSPORT.as_bytes()) { + headers.insert(name, HeaderValue::from_static("resource-trash-api")); + } +} + +async fn current_user_id(state: &AppState, context: &RequestContext) -> String { + let user_id = context.auth.actor_id.trim(); + if !user_id.is_empty() && user_id != "anonymous" { + return user_id.to_string(); + } + + let has_auth_cookie = context + .auth + .cookie_header + .as_deref() + .map(|value| { + value.contains("__convexAuthJWT=") || value.contains("mnote_web_convex_token=") + }) + .unwrap_or(false); + if has_auth_cookie { + if let Ok(user) = execute_convex_query_by_name( + state.config(), + context, + "users:currentUser", + json!({}), + context.workspace.workspace_id.as_deref(), + "resource_trash_current_user", + ) + .await + { + for key in ["_id", "id"] { + if let Some(user_id) = user + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return user_id.to_string(); + } + } + } + } + + state.config().dev_user_id.clone() +} + +fn require_id<'a>( + context: &RequestContext, + value: &'a str, + field: &'static str, +) -> Result<&'a str, WebError> { + let value = value.trim(); + if value.is_empty() { + return Err(WebError::bad_request_code( + "resource_id_required", + format!("缺少有效 {field}"), + ) + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); + } + Ok(value) +} + +fn force_expired_at() -> String { + (OffsetDateTime::now_utc() + Duration::days(365 * 100)) + .format(&Rfc3339) + .unwrap_or_else(|_| "2126-01-01T00:00:00Z".into()) +} + +fn now_iso_like() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into()) +} + +async fn record_media_empty_trash_artifacts( + state: &AppState, + context: &RequestContext, + user_id: &str, + workspace_id: &str, + result: &Value, +) -> Result<(), WebError> { + let stream_delta_hint = json!({ + "family": "tree", + "kind": "resync_required", + "args": { + "reason": "media_empty_trash", + "workspaceId": workspace_id, + "resourceKind": "media", + }, + }); + let command = RuntimeCommandEnvelopeWire { + name: "mediaAssets.emptyTrashByWorkspace".into(), + command_id: format!("media_empty_trash_{}", context.trace.request_id), + idempotency_key: context.source.idempotency_key.clone(), + actor: RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: user_id.to_string(), + session_id: context.auth.session_id.clone(), + }, + source: RuntimeSourceWire { + channel: context.source.channel.clone(), + client: context.source.client.clone(), + source_kind: None, + root_uri: None, + workspace_id: Some(workspace_id.to_string()), + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some(workspace_id.to_string()), + page_id: None, + block_id: None, + }), + payload: json!({ + "workspaceId": workspace_id, + "resourceKind": "media", + }), + preflight_data: None, + reason: Some("mnote-web media empty trash".into()), + refs: vec!["mnote-web-resource-trash".into()], + dry_run: false, + validate_only: false, + }; + let plan = RuntimeCommandExecutionPlan { + command_name: command.name.clone(), + command_id: command.command_id.clone(), + function_name: "mediaAssets:emptyTrashByWorkspace".into(), + workspace_id: Some(workspace_id.to_string()), + request_id: context.trace.request_id.clone(), + trace_id: context.trace.trace_id.clone(), + actor_id: user_id.to_string(), + idempotency_key: context.source.idempotency_key.clone(), + source: json!({ + "channel": context.source.channel.as_str(), + "client": context.source.client.as_str(), + "workspaceId": workspace_id, + }), + payload_json: command.payload.to_string(), + args_json: json!({ + "userId": user_id, + "workspaceId": workspace_id, + "streamDeltaHint": stream_delta_hint.clone(), + "domainEventHint": { + "family": "tree", + "eventType": "tree.trash.media.emptied", + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": "tree.trash.media.emptied", + "streamDeltaHint": stream_delta_hint, + }, + }), + }; + let runtime_context = runtime_context(context, Some(workspace_id)); + if let Some(artifacts) = build_runtime_command_artifact_plan( + &runtime_context, + &command, + &plan, + result, + &now_iso_like(), + ) { + persist_runtime_command_artifacts(state.config(), context, &artifacts).await?; + } + Ok(()) +} + +async fn record_resource_resync_artifacts( + state: &AppState, + context: &RequestContext, + actor_id: &str, + workspace_id: &str, + document_id: Option<&str>, + resource_id: Option<&str>, + command_name: &str, + event_type: &str, + reason: &str, + result: &Value, +) -> Result<(), WebError> { + let stream_delta_hint = json!({ + "family": "tree", + "kind": "resync_required", + "args": { + "reason": reason, + "workspaceId": workspace_id, + "documentId": document_id, + "blockId": resource_id, + }, + }); + let command = RuntimeCommandEnvelopeWire { + name: command_name.to_string(), + command_id: format!("{}_{}", reason, context.trace.request_id), + idempotency_key: context.source.idempotency_key.clone(), + actor: RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: actor_id.to_string(), + session_id: context.auth.session_id.clone(), + }, + source: RuntimeSourceWire { + channel: context.source.channel.clone(), + client: context.source.client.clone(), + source_kind: None, + root_uri: None, + workspace_id: Some(workspace_id.to_string()), + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some(workspace_id.to_string()), + page_id: document_id.map(ToOwned::to_owned), + block_id: resource_id.map(ToOwned::to_owned), + }), + payload: json!({ + "workspaceId": workspace_id, + "documentId": document_id, + "resourceId": resource_id, + }), + preflight_data: None, + reason: Some(format!("mnote-web resource resync {command_name}")), + refs: vec!["mnote-web-resource-trash".into()], + dry_run: false, + validate_only: false, + }; + let plan = RuntimeCommandExecutionPlan { + command_name: command.name.clone(), + command_id: command.command_id.clone(), + function_name: command_name.replace('.', ":"), + workspace_id: Some(workspace_id.to_string()), + request_id: context.trace.request_id.clone(), + trace_id: context.trace.trace_id.clone(), + actor_id: actor_id.to_string(), + idempotency_key: context.source.idempotency_key.clone(), + source: json!({ + "channel": context.source.channel.as_str(), + "client": context.source.client.as_str(), + "workspaceId": workspace_id, + }), + payload_json: command.payload.to_string(), + args_json: json!({ + "workspaceId": workspace_id, + "documentId": document_id, + "resourceId": resource_id, + "streamDeltaHint": stream_delta_hint.clone(), + "domainEventHint": { + "family": "tree", + "eventType": event_type, + }, + "domainEventPlan": { + "family": "tree", + "schema": "mnote.tree.domain_event", + "schemaVersion": 1, + "eventType": event_type, + "streamDeltaHint": stream_delta_hint, + }, + }), + }; + let runtime_context = runtime_context(context, Some(workspace_id)); + if let Some(artifacts) = build_runtime_command_artifact_plan( + &runtime_context, + &command, + &plan, + result, + &now_iso_like(), + ) { + persist_runtime_command_artifacts(state.config(), context, &artifacts).await?; + } + Ok(()) +} + +async fn fetch_document_workspace_id( + state: &AppState, + context: &RequestContext, + document_id: &str, +) -> Option { + execute_convex_query_by_name( + state.config(), + context, + "documents:getMeta", + json!({ + "id": document_id, + "includeDeleted": true, + }), + context.workspace.workspace_id.as_deref(), + "resource_trash_document_workspace", + ) + .await + .ok() + .and_then(|meta| { + meta.get("workspace_id") + .or_else(|| meta.get("workspaceId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +async fn fetch_table_meta( + state: &AppState, + context: &RequestContext, + user_id: &str, + table_id: &str, +) -> Option { + execute_convex_query_by_name( + state.config(), + context, + "tables:get", + json!({ + "userId": user_id, + "tableId": table_id, + }), + context.workspace.workspace_id.as_deref(), + "resource_trash_table_meta", + ) + .await + .ok() + .filter(|value| value.is_object()) +} + +fn resource_command( + context: &RequestContext, + command_name: &str, + command_id_suffix: &str, + asset_id: &str, + actor_id: &str, + workspace_id: Option<&str>, + document_id: Option<&str>, + payload: Value, +) -> RuntimeCommandEnvelopeWire { + RuntimeCommandEnvelopeWire { + name: command_name.into(), + command_id: format!("resource_{command_id_suffix}_{}", context.trace.request_id), + idempotency_key: context.source.idempotency_key.clone(), + actor: RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: actor_id.to_string(), + session_id: context.auth.session_id.clone(), + }, + source: RuntimeSourceWire { + channel: context.source.channel.clone(), + client: context.source.client.clone(), + source_kind: None, + root_uri: None, + workspace_id: workspace_id.map(ToOwned::to_owned), + capabilities: Vec::new(), + }, + target: Some(RuntimeTargetWire { + workspace_id: workspace_id.map(ToOwned::to_owned), + page_id: document_id.map(ToOwned::to_owned), + block_id: Some(asset_id.to_string()), + }), + payload, + preflight_data: None, + reason: Some(format!("mnote-web resource compat {command_name}")), + refs: vec![ + "file-tree-resource-command".into(), + "resource-trash-compat-alias".into(), + ], + dry_run: false, + validate_only: false, + } +} + +fn annotate_resource_lifecycle_result( + mut result: Value, + canonical_command: &str, + resource_kind: &str, +) -> Value { + if let Value::Object(map) = &mut result { + map.insert("canonicalCommand".into(), json!(canonical_command)); + map.insert("resourceKind".into(), json!(resource_kind)); + } + result +} + +async fn fetch_media_asset_meta( + state: &AppState, + context: &RequestContext, + user_id: &str, + asset_id: &str, +) -> Result { + let assets = execute_convex_query_by_name( + state.config(), + context, + "mediaAssets:listByIds", + json!({ + "userId": user_id, + "ids": [asset_id], + }), + context.workspace.workspace_id.as_deref(), + "media_asset_meta", + ) + .await?; + Ok(assets + .as_array() + .and_then(|items| items.first()) + .cloned() + .unwrap_or_else(|| json!({}))) +} + +fn read_asset_workspace_and_document(asset: &Value) -> (Option, Option) { + let workspace_id = asset + .get("workspace_id") + .or_else(|| asset.get("workspaceId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let document_id = asset + .get("document_id") + .or_else(|| asset.get("documentId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + (workspace_id, document_id) +} + +pub async fn media_batch( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let user_id = current_user_id(&state, &context).await; + let action = body.action.trim(); + if action != "restore" && action != "delete" && action != "rename" { + return Err(WebError::bad_request_code( + "media_batch_action_unsupported", + "Rust resource trash 当前仅支持附件 delete / restore / rename", + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); + } + if body.asset_ids.is_empty() { + return Err( + WebError::bad_request_code("asset_ids_required", "缺少 assetIds") + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"), + ); + } + + let mut updated = 0usize; + for asset_id in body.asset_ids.iter().map(String::as_str) { + let asset_id = require_id(&context, asset_id, "assetId")?; + let asset = fetch_media_asset_meta(&state, &context, &user_id, asset_id).await?; + let (workspace_id, document_id) = read_asset_workspace_and_document(&asset); + let (command_name, payload) = if action == "restore" { + ( + "tree.resource.restore", + json!({ + "resourceKind": "file", + "assetId": asset_id, + }), + ) + } else if action == "rename" { + let new_name = require_id( + &context, + body.new_name.as_deref().unwrap_or_default(), + "newName", + )?; + ( + "tree.resource.rename", + json!({ + "resourceKind": "file", + "assetId": asset_id, + "newName": new_name, + }), + ) + } else { + ( + "tree.resource.archive", + json!({ + "resourceKind": "file", + "assetId": asset_id, + }), + ) + }; + let command = resource_command( + &context, + command_name, + action, + asset_id, + &user_id, + workspace_id.as_deref(), + document_id.as_deref(), + payload, + ); + if action == "rename" { + let new_name = require_id( + &context, + body.new_name.as_deref().unwrap_or_default(), + "newName", + )?; + execute_convex_mutation_by_name( + state.config(), + &context, + "mediaAssets:patchById", + json!({ + "userId": user_id, + "id": asset_id, + "patch": { + "file_name": new_name, + }, + }), + workspace_id.as_deref(), + None, + "media_batch_rename_patch", + ) + .await?; + } + let command_result = execute_runtime_command_via_convex_with_artifacts( + state.config(), + &context, + workspace_id.as_deref(), + command, + ) + .await; + if action == "rename" { + if let Err(error) = command_result { + tracing::warn!( + error = %error.message(), + asset_id = %asset_id, + "tree.resource.rename artifact command 失败,已保留兼容 patch 结果" + ); + } + } else { + command_result?; + } + updated += 1; + } + + Ok(ok_response( + &context, + json!({ + "action": action, + "updated": updated, + "restored": if action == "restore" { updated } else { 0 }, + "deleted": if action == "delete" { updated } else { 0 }, + "renamed": if action == "rename" { updated } else { 0 }, + "canonicalCommand": if action == "restore" { + "tree.resource.restore" + } else if action == "rename" { + "tree.resource.rename" + } else { + "tree.resource.archive" + }, + }), + )) +} + +pub async fn media_purge( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let user_id = current_user_id(&state, &context).await; + let asset_id = require_id(&context, &body.asset_id, "assetId")?; + let asset = fetch_media_asset_meta(&state, &context, &user_id, asset_id).await?; + let (workspace_id, document_id) = read_asset_workspace_and_document(&asset); + let command = resource_command( + &context, + "tree.resource.purge", + "purge", + asset_id, + &user_id, + workspace_id.as_deref(), + document_id.as_deref(), + json!({ + "resourceKind": "file", + "assetId": asset_id, + }), + ); + let execution = execute_runtime_command_via_convex_with_artifacts( + state.config(), + &context, + workspace_id.as_deref(), + command, + ) + .await?; + Ok(ok_response(&context, execution.result)) +} + +pub async fn media_empty_trash( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let user_id = current_user_id(&state, &context).await; + let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; + let result = execute_convex_mutation_by_name( + state.config(), + &context, + "mediaAssets:emptyTrashByWorkspace", + json!({ + "userId": user_id, + "workspaceId": workspace_id, + "expiredDeletedAt": force_expired_at(), + }), + Some(workspace_id), + None, + "media_empty_trash", + ) + .await?; + let _ = + match record_media_empty_trash_artifacts(&state, &context, &user_id, workspace_id, &result) + .await + { + Ok(()) => Ok(()), + Err(error) => { + let message = error.message().to_string(); + tracing::warn!( + error = %message, + workspace_id = %workspace_id, + "media empty trash tree artifacts 记录失败,主清空结果继续返回" + ); + Err(()) + } + }; + let mut result = result; + if let Value::Object(map) = &mut result { + map.insert( + "canonicalCommand".into(), + json!("mediaAssets.emptyTrashByWorkspace"), + ); + map.insert("compatRoute".into(), json!("/api/media/empty-trash")); + } + Ok(ok_response(&context, result)) +} + +pub async fn mindmap_delete( + State(state): State, + Extension(context): Extension, + Path((doc_id, mindmap_id)): Path<(String, String)>, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let user_id = current_user_id(&state, &context).await; + let doc_id = require_id(&context, &doc_id, "docId")?; + let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?; + let workspace_id = fetch_document_workspace_id(&state, &context, doc_id).await; + let command = resource_command( + &context, + "tree.resource.archive", + "mindmap_archive", + mindmap_id, + &user_id, + workspace_id.as_deref(), + Some(doc_id), + json!({ + "resourceKind": "mindmap", + "documentId": doc_id, + "mindmapId": mindmap_id, + }), + ); + let execution = execute_runtime_command_via_convex_with_artifacts( + state.config(), + &context, + workspace_id.as_deref(), + command, + ) + .await?; + Ok(ok_response( + &context, + annotate_resource_lifecycle_result(execution.result, "tree.resource.archive", "mindmap"), + )) +} + +pub async fn mindmap_trash_action( + State(state): State, + Extension(context): Extension, + Path((doc_id, mindmap_id)): Path<(String, String)>, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let action = body.action.trim(); + let command_name = match action { + "restore" => "tree.resource.restore", + "purge" => "tree.resource.purge", + _ => { + return Err(WebError::bad_request_code( + "mindmap_trash_action_unsupported", + "仅支持 restore 或 purge", + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); + } + }; + let user_id = current_user_id(&state, &context).await; + let doc_id = require_id(&context, &doc_id, "docId")?; + let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?; + let workspace_id = fetch_document_workspace_id(&state, &context, doc_id).await; + let command = resource_command( + &context, + command_name, + if action == "restore" { + "mindmap_restore" + } else { + "mindmap_purge" + }, + mindmap_id, + &user_id, + workspace_id.as_deref(), + Some(doc_id), + json!({ + "resourceKind": "mindmap", + "documentId": doc_id, + "mindmapId": mindmap_id, + }), + ); + let execution = execute_runtime_command_via_convex_with_artifacts( + state.config(), + &context, + workspace_id.as_deref(), + command, + ) + .await?; + Ok(ok_response( + &context, + annotate_resource_lifecycle_result(execution.result, command_name, "mindmap"), + )) +} + +pub async fn mindmap_empty_trash( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; + let result = execute_convex_mutation_by_name( + state.config(), + &context, + "mindmaps:emptyTrashByWorkspace", + json!({ + "workspaceId": workspace_id, + }), + Some(workspace_id), + None, + "mindmap_empty_trash", + ) + .await?; + let _ = record_resource_resync_artifacts( + &state, + &context, + &context.auth.actor_id, + workspace_id, + None, + None, + "mindmaps.emptyTrashByWorkspace", + "tree.trash.mindmap.emptied", + "mindmap_empty_trash", + &result, + ) + .await + .map_err(|error| { + tracing::warn!( + error = %error.message(), + workspace_id = %workspace_id, + "mindmap empty trash tree artifacts 记录失败,主清空结果继续返回" + ); + }); + Ok(ok_response(&context, result)) +} + +pub async fn table_restore( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + table_action( + state, + context, + body.table_id, + "tables:restore", + "table_restore", + ) + .await +} + +pub async fn table_create( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let user_id = current_user_id(&state, &context).await; + let document_id = require_id(&context, &body.document_id, "documentId")?; + let workspace_id = fetch_document_workspace_id(&state, &context, document_id) + .await + .ok_or_else(|| { + WebError::bad_request_code("workspace_id_required", "无法解析表格所属 workspace") + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + })?; + let result = execute_convex_mutation_by_name( + state.config(), + &context, + "tables:create", + json!({ + "userId": user_id, + "workspaceId": workspace_id, + "documentId": document_id, + "title": body.title, + "schema": body.schema.unwrap_or_else(|| json!({})), + "snapshot": body.snapshot, + }), + Some(&workspace_id), + None, + "table_create", + ) + .await?; + let table_id = result + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let _ = record_resource_resync_artifacts( + &state, + &context, + &user_id, + &workspace_id, + Some(document_id), + table_id, + "tables.create", + "tree.resource.table.created", + "table_create", + &result, + ) + .await + .map_err(|error| { + tracing::warn!( + error = %error.message(), + table_id = table_id.unwrap_or(""), + "table create tree artifacts 记录失败,主创建结果继续返回" + ); + }); + Ok(ok_response(&context, result)) +} + +pub async fn table_delete( + State(state): State, + Extension(context): Extension, + Path(table_id): Path, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + table_action(state, context, table_id, "tables:remove", "table_delete").await +} + +pub async fn table_purge( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + table_action(state, context, body.table_id, "tables:purge", "table_purge").await +} + +async fn table_action( + state: AppState, + context: RequestContext, + table_id: String, + function_name: &'static str, + error_phase: &'static str, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let user_id = current_user_id(&state, &context).await; + let table_id = require_id(&context, &table_id, "tableId")?; + let table_meta = fetch_table_meta(&state, &context, &user_id, table_id).await; + let workspace_id = table_meta + .as_ref() + .and_then(|table| { + table + .get("workspace_id") + .or_else(|| table.get("workspaceId")) + }) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let document_id = table_meta + .as_ref() + .and_then(|table| table.get("document_id").or_else(|| table.get("documentId"))) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let (command_name, reason) = match function_name { + "tables:restore" => ("tree.resource.restore", "table_restore"), + "tables:purge" => ("tree.resource.purge", "table_purge"), + _ => ("tree.resource.archive", "table_archive"), + }; + let command = resource_command( + &context, + command_name, + reason, + table_id, + &user_id, + workspace_id.as_deref(), + document_id.as_deref(), + json!({ + "resourceKind": "table", + "tableId": table_id, + }), + ); + let execution = execute_runtime_command_via_convex_with_artifacts( + state.config(), + &context, + workspace_id.as_deref(), + command, + ) + .await?; + let _ = error_phase; + Ok(ok_response( + &context, + annotate_resource_lifecycle_result(execution.result, command_name, "table"), + )) +} + +pub async fn table_empty_trash( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let user_id = current_user_id(&state, &context).await; + let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; + let result = execute_convex_mutation_by_name( + state.config(), + &context, + "tables:emptyTrashByWorkspace", + json!({ + "userId": user_id, + "workspaceId": workspace_id, + "expiredDeletedAt": force_expired_at(), + }), + Some(workspace_id), + None, + "table_empty_trash", + ) + .await?; + let _ = record_resource_resync_artifacts( + &state, + &context, + &user_id, + workspace_id, + None, + None, + "tables.emptyTrashByWorkspace", + "tree.trash.table.emptied", + "table_empty_trash", + &result, + ) + .await + .map_err(|error| { + tracing::warn!( + error = %error.message(), + workspace_id = %workspace_id, + "table empty trash tree artifacts 记录失败,主清空结果继续返回" + ); + }); + Ok(ok_response(&context, result)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; + use axum::http::Request; + use tower::util::ServiceExt; + + fn app() -> axum::Router { + build_app(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: false, + enable_debug_shell_routes: false, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: Some( + r#"{ + "documents:getMeta": {"id": "doc_1", "workspace_id": "ws_demo"}, + "tables:get": {"id": "table_1", "workspace_id": "ws_demo", "document_id": "doc_1"} + }"# + .into(), + ), + mutation_fixtures_json: Some( + r#"{ + "mediaAssets:patchById": {"ok": true}, + "mediaAssets:purgeById": {"ok": true, "deleted": 1}, + "mediaAssets:emptyTrashByWorkspace": {"ok": true, "deleted": 2}, + "bridgeLogs:recordCommandLog": {"ok": true}, + "bridgeLogs:recordDomainEvent": {"ok": true}, + "mindmaps:softDelete": {"ok": true, "moved": 1, "deleted_at": "2026-05-15T00:00:00Z"}, + "mindmaps:restore": {"ok": true, "updated_at": "2026-05-15T00:00:00Z"}, + "mindmaps:purge": {"ok": true}, + "mindmaps:emptyTrashByWorkspace": {"ok": true, "deletedCount": 3}, + "tables:create": {"id": "table_1", "workspace_id": "ws_demo", "document_id": "doc_1", "title": "表格"}, + "tables:remove": {"success": true, "deleted_at": "2026-05-15T00:00:00Z"}, + "tables:restore": {"success": true}, + "tables:purge": {"success": true}, + "tables:emptyTrashByWorkspace": {"ok": true, "deleted": 4} + }"# + .into(), + ), + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + + async fn post_json(uri: &str, body: Value) -> Value { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_real") + .header("x-mnote-actor-type", "user") + .body(Body::from(body.to_string())) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + serde_json::from_slice(&body).expect("json") + } + + async fn patch_json(uri: &str, body: Value) -> Value { + let response = app() + .oneshot( + Request::builder() + .method("PATCH") + .uri(uri) + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_real") + .header("x-mnote-actor-type", "user") + .body(Body::from(body.to_string())) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + serde_json::from_slice(&body).expect("json") + } + + async fn delete_json(uri: &str) -> Value { + let response = app() + .oneshot( + Request::builder() + .method("DELETE") + .uri(uri) + .header("x-mnote-actor-id", "user_real") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + serde_json::from_slice(&body).expect("json") + } + + #[tokio::test] + async fn media_trash_routes_delete_restore_purge_and_empty() { + let deleted = post_json( + "/api/media/batch", + json!({"action": "delete", "assetIds": ["asset_1"]}), + ) + .await; + assert_eq!(deleted["result"]["deleted"], 1); + + let restored = post_json( + "/api/media/batch", + json!({"action": "restore", "assetIds": ["asset_1"]}), + ) + .await; + assert_eq!(restored["result"]["restored"], 1); + + let purged = post_json("/api/media/purge", json!({"assetId": "asset_1"})).await; + assert_eq!(purged["result"]["deleted"], 1); + + let emptied = post_json("/api/media/empty-trash", json!({"workspaceId": "ws_demo"})).await; + assert_eq!(emptied["result"]["deleted"], 2); + } + + #[tokio::test] + async fn mindmap_trash_routes_delete_restore_purge_and_empty() { + let deleted = delete_json("/api/mindmap/doc_1/mind_1").await; + assert_eq!(deleted["result"]["moved"], 1); + assert_eq!( + deleted["result"]["canonicalCommand"], + "tree.resource.archive" + ); + assert_eq!(deleted["result"]["resourceKind"], "mindmap"); + + let restored = patch_json("/api/mindmap/doc_1/mind_1", json!({"action": "restore"})).await; + assert_eq!(restored["result"]["updated_at"], "2026-05-15T00:00:00Z"); + assert_eq!( + restored["result"]["canonicalCommand"], + "tree.resource.restore" + ); + assert_eq!(restored["result"]["resourceKind"], "mindmap"); + + let purged = patch_json("/api/mindmap/doc_1/mind_1", json!({"action": "purge"})).await; + assert_eq!(purged["result"]["ok"], true); + assert_eq!(purged["result"]["canonicalCommand"], "tree.resource.purge"); + assert_eq!(purged["result"]["resourceKind"], "mindmap"); + + let emptied = post_json( + "/api/mindmap-trash/empty", + json!({"workspaceId": "ws_demo"}), + ) + .await; + assert_eq!(emptied["result"]["deletedCount"], 3); + } + + #[tokio::test] + async fn table_trash_routes_delete_restore_purge_and_empty() { + let created = post_json( + "/api/tables/create", + json!({ + "documentId": "doc_1", + "title": "表格", + "schema": {}, + "snapshot": null, + }), + ) + .await; + assert_eq!(created["result"]["id"], "table_1"); + + let deleted = delete_json("/api/tables/table_1").await; + assert_eq!(deleted["result"]["success"], true); + assert_eq!( + deleted["result"]["canonicalCommand"], + "tree.resource.archive" + ); + assert_eq!(deleted["result"]["resourceKind"], "table"); + + let restored = post_json("/api/tables/restore", json!({"tableId": "table_1"})).await; + assert_eq!(restored["result"]["success"], true); + assert_eq!( + restored["result"]["canonicalCommand"], + "tree.resource.restore" + ); + assert_eq!(restored["result"]["resourceKind"], "table"); + + let purged = post_json("/api/tables/purge", json!({"tableId": "table_1"})).await; + assert_eq!(purged["result"]["success"], true); + assert_eq!(purged["result"]["canonicalCommand"], "tree.resource.purge"); + assert_eq!(purged["result"]["resourceKind"], "table"); + + let emptied = post_json("/api/tables/empty-trash", json!({"workspaceId": "ws_demo"})).await; + assert_eq!(emptied["result"]["deleted"], 4); + } +} diff --git a/rust/crates/mnote-web/src/routes/stream_support.rs b/rust/crates/mnote-web/src/routes/stream_support.rs index 9e2ea6f7..258dacad 100644 --- a/rust/crates/mnote-web/src/routes/stream_support.rs +++ b/rust/crates/mnote-web/src/routes/stream_support.rs @@ -781,6 +781,76 @@ mod tests { ); } + #[test] + fn stream_change_preserves_resync_required_delta_contract() { + let overview = json!({ + "command_logs": [ + { + "id": "clog_2", + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:02Z", + "command_name": "documents.emptyTrashByWorkspace", + "payload": { + "streamDelta": { + "op": "resync_required", + "reason": "documents_empty_trash", + "documentId": null, + "blockId": null + } + } + }, + { + "id": "clog_1", + "command_id": "cmd_1", + "created_at": "2026-04-25T10:00:01Z" + } + ], + "domain_events": [ + { + "id": "evt_2", + "event_id": "evt_2", + "command_id": "cmd_2", + "created_at": "2026-04-25T10:00:02Z", + "payload": { + "streamDelta": { + "op": "resync_required", + "reason": "documents_empty_trash", + "documentId": null, + "blockId": null + } + } + }, + { + "id": "evt_1", + "event_id": "evt_1", + "command_id": "cmd_1", + "created_at": "2026-04-25T10:00:01Z" + } + ] + }); + + let change = resolve_stream_change( + &overview, + Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"domain_event:evt_1"}"#), + ) + .expect("应识别到变化"); + + assert_eq!(change.kind, StreamChangeKind::Delta); + assert_eq!( + change.cursor, + Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"clog_2"}"#.into()) + ); + assert_eq!( + change.delta, + Some(json!({ + "op": "resync_required", + "reason": "documents_empty_trash", + "documentId": null, + "blockId": null + })) + ); + } + #[test] fn stream_change_preserves_move_document_delta_fields() { let overview = json!({ diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index b398edab..5bc08fbb 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -1871,6 +1871,219 @@ body { background: var(--atelier-document); } +.mnote-trash-workbench { + width: min(860px, calc(100vw - 64px)); + margin: 52px auto; + color: var(--atelier-text); +} + +.mnote-trash-modal { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: 48px 32px; + background: rgba(25, 24, 22, 0.34); +} + +.mnote-trash-modal__backdrop { + position: absolute; + inset: 0; +} + +.mnote-trash-modal__panel { + position: relative; + width: min(944px, calc(100vw - 96px)); + height: min(870px, calc(100vh - 112px)); + overflow: hidden; + border: 1px solid rgba(27, 28, 28, 0.14); + border-radius: 6px; + background: var(--atelier-document); + box-shadow: 0 18px 52px rgba(27, 28, 28, 0.22); +} + +.mnote-trash-modal__content { + height: 100%; + overflow: auto; +} + +.mnote-trash-modal__content .mnote-trash-workbench { + width: auto; + margin: 28px 32px 40px; +} + +.mnote-trash-modal__close { + position: absolute; + top: 12px; + right: 14px; + z-index: 1; + width: 32px; + height: 32px; + margin: 0; + border: 1px solid rgba(27, 28, 28, 0.12); + border-radius: 6px; + background: #FFFFFF; + color: #37352F; + font: inherit; + font-size: 20px; + line-height: 28px; + cursor: pointer; +} + +.mnote-trash-modal__close:hover { + background: #F7F7F6; +} + +@media (max-width: 720px) { + .mnote-trash-modal { + padding: 24px 12px; + } + + .mnote-trash-modal__panel { + width: calc(100vw - 24px); + height: min(780px, calc(100vh - 48px)); + } + + .mnote-trash-modal__content .mnote-trash-workbench { + margin: 22px 18px 32px; + } +} + +.mnote-trash-header { + margin-bottom: 28px; +} + +.mnote-trash-header h1 { + margin: 0 0 8px; + font-size: 30px; + font-weight: 650; + line-height: 38px; +} + +.mnote-trash-header p { + margin: 0; + color: #6D6A65; + font-size: 14px; + line-height: 22px; +} + +.mnote-trash-status[data-type="error"] { + color: #C93A32; +} + +.mnote-trash-status[data-type="success"] { + color: #23834D; +} + +.mnote-trash-section { + margin-top: 28px; +} + +.mnote-trash-section-title { + display: flex; + min-height: 32px; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.mnote-trash-section h2 { + margin: 0; + color: #5A5A5A; + font-size: 13px; + font-weight: 600; + line-height: 20px; +} + +.mnote-trash-section h2 span { + color: #9B9A97; + font-weight: 500; +} + +.mnote-trash-row { + display: flex; + min-height: 44px; + align-items: center; + justify-content: space-between; + gap: 16px; + border-top: 1px solid rgba(27, 28, 28, 0.08); +} + +.mnote-trash-row-main { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.mnote-trash-title { + min-width: 0; + overflow: hidden; + color: var(--atelier-text); + font-size: 14px; + font-weight: 500; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mnote-trash-meta, +.mnote-trash-kind, +.mnote-trash-note, +.mnote-trash-empty { + color: #8B8782; + font-size: 12px; + line-height: 18px; +} + +.mnote-trash-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; +} + +.mnote-trash-actions button { + height: 28px; + padding: 0 10px; + border: 1px solid rgba(27, 28, 28, 0.12); + border-radius: 4px; + background: #FFFFFF; + color: #37352F; + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.mnote-trash-section-title button { + height: 28px; + padding: 0 10px; + border: 1px solid rgba(27, 28, 28, 0.12); + border-radius: 4px; + background: #FFFFFF; + color: #37352F; + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.mnote-trash-actions button:hover:not(:disabled) { + background: #F7F7F6; +} + +.mnote-trash-section-title button:hover:not(:disabled) { + background: #F7F7F6; +} + +.mnote-trash-actions button:disabled, +.mnote-trash-section-title button:disabled { + color: #9B9A97; + cursor: default; +} + .wolai-topbar { height: 40px; padding: 0 20px 0 22px; @@ -3056,40 +3269,70 @@ body { min-height: 0; flex: 1 1 auto; flex-direction: column; - gap: 8px; + gap: 4px; overflow: auto; } .wolai-page-ai-skill-row { display: flex; - align-items: flex-start; + min-height: 38px; + align-items: center; justify-content: space-between; gap: 10px; - padding: 10px; - border: 1px solid rgba(27, 28, 28, 0.08); - border-radius: 8px; - background: #FFF; + padding: 6px 8px; + border: 0; + border-radius: 6px; + background: transparent; +} + +.wolai-page-ai-skill-row:hover { + background: #F7F7F6; } .wolai-page-ai-skill-copy { + display: grid; + gap: 2px; min-width: 0; } +.wolai-page-ai-skill-main { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.wolai-page-ai-skill-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .wolai-page-ai-skill-desc { - display: -webkit-box; overflow: hidden; color: #5A5A5A; - font-size: 12px; - line-height: 18px; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; + font-size: 11px; + line-height: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wolai-page-ai-skill-source { + flex: 0 0 auto; + padding: 1px 5px; + border-radius: 999px; + background: #F0EFED; + color: #8B8782; + font-size: 10px; + line-height: 14px; } .wolai-page-ai-skill-switch { position: relative; - width: 36px; - height: 22px; - flex: 0 0 36px; + width: 32px; + height: 18px; + flex: 0 0 32px; border: 0; border-radius: 999px; background: #D8D6D1; @@ -3098,10 +3341,10 @@ body { .wolai-page-ai-skill-switch span { position: absolute; - top: 3px; - left: 3px; - width: 16px; - height: 16px; + top: 2px; + left: 2px; + width: 14px; + height: 14px; border-radius: 50%; background: #FFF; box-shadow: 0 1px 3px rgba(27, 28, 28, 0.16); diff --git a/rust/crates/mnote-web/src/transport/convex.rs b/rust/crates/mnote-web/src/transport/convex.rs index 4c64a125..7511dcfe 100644 --- a/rust/crates/mnote-web/src/transport/convex.rs +++ b/rust/crates/mnote-web/src/transport/convex.rs @@ -391,6 +391,15 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value { ) { strip_tree_artifact_fields(&mut args); } + if matches!( + plan.command_name.as_str(), + "tree.resource.archive" + | "tree.resource.restore" + | "tree.resource.rename" + | "tree.resource.purge" + ) { + args = convex_resource_lifecycle_args_for_plan(plan, &args); + } if matches!(plan.command_name.as_str(), "mindmaps.put") || matches!(plan.function_name.as_str(), "mindmaps:put") { @@ -433,6 +442,69 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value { args } +fn convex_resource_lifecycle_args_for_plan( + plan: &RuntimeCommandExecutionPlan, + args: &Value, +) -> Value { + let lifecycle = args.get("resourceLifecyclePlan").and_then(Value::as_object); + let action = lifecycle + .and_then(|value| value.get("action")) + .and_then(Value::as_str) + .unwrap_or(""); + let resource_kind = lifecycle + .and_then(|value| value.get("resourceKind")) + .and_then(Value::as_str) + .unwrap_or("file"); + if resource_kind != "file" { + return args.clone(); + } + let id = args + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let user_id = args + .get("userId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(plan.actor_id.as_str()) + .to_string(); + match action { + "archive" => json!({ + "userId": user_id, + "id": id, + "patch": { + "deleted_at": now_iso_like(), + "deleted_by": user_id, + "purged_at": null, + }, + }), + "restore" => json!({ + "userId": user_id, + "id": id, + "patch": { + "deleted_at": null, + "deleted_by": null, + "purged_at": null, + }, + }), + "rename" => json!({ + "userId": user_id, + "id": id, + "patch": { + "file_name": args.get("newName").and_then(Value::as_str).unwrap_or_default(), + }, + }), + "purge" => json!({ + "userId": user_id, + "id": id, + "expiredDeletedAt": "2126-01-01T00:00:00Z", + }), + _ => args.clone(), + } +} + fn strip_tree_artifact_fields(args: &mut Value) { if let Value::Object(map) = args { map.remove("streamDeltaHint"); @@ -996,6 +1068,42 @@ mod tests { ); } + #[test] + fn convex_resource_lifecycle_args_keep_effective_user_id() { + let plan = RuntimeCommandExecutionPlan { + command_name: "tree.resource.archive".into(), + command_id: "cmd_resource_archive_1".into(), + function_name: "mediaAssets:patchById".into(), + workspace_id: Some("ws_1".into()), + request_id: "req_1".into(), + trace_id: "trace_1".into(), + actor_id: "anonymous".into(), + idempotency_key: None, + source: json!({}), + payload_json: "{}".into(), + args_json: json!({ + "id": "asset_1", + "userId": "convex_user_1", + "resourceKind": "file", + "resourceLifecyclePlan": { + "resourceKind": "file", + "action": "archive", + "assetId": "asset_1" + }, + "streamDeltaHint": {"family": "tree", "kind": "remove_asset"}, + "domainEventHint": {"eventType": "tree.resource.archived"}, + "domainEventPlan": {"eventType": "tree.resource.archived"}, + }), + }; + + let args = convex_command_args_for_plan(&plan); + + assert_eq!(args["userId"], "convex_user_1"); + assert_eq!(args["id"], "asset_1"); + assert!(args.get("resourceLifecyclePlan").is_none()); + assert!(args.get("streamDeltaHint").is_none()); + } + #[test] fn convex_command_args_adapts_mindmap_command_apply_for_legacy_mutation() { let plan = RuntimeCommandExecutionPlan { diff --git a/rust/crates/mnote-web/src/tree_shell/runtime_api.rs b/rust/crates/mnote-web/src/tree_shell/runtime_api.rs index c614898c..f1e90acf 100644 --- a/rust/crates/mnote-web/src/tree_shell/runtime_api.rs +++ b/rust/crates/mnote-web/src/tree_shell/runtime_api.rs @@ -179,6 +179,11 @@ pub enum TreeShellCommandEvent { target_document_id: String, file_count: u32, }, + ResourceLifecycle { + command_name: String, + resource_kind: String, + asset_id: String, + }, } pub fn reduce_tree_shell_runtime(request: TreeShellRuntimeRequest) -> TreeShellRuntimeResult { @@ -849,6 +854,26 @@ mod tests { target_document_id: "doc:target".into(), file_count: 2, }, + TreeShellCommandEvent::ResourceLifecycle { + command_name: "tree.resource.archive".into(), + resource_kind: "file".into(), + asset_id: "asset:a".into(), + }, + TreeShellCommandEvent::ResourceLifecycle { + command_name: "tree.resource.restore".into(), + resource_kind: "file".into(), + asset_id: "asset:a".into(), + }, + TreeShellCommandEvent::ResourceLifecycle { + command_name: "tree.resource.purge".into(), + resource_kind: "file".into(), + asset_id: "asset:a".into(), + }, + TreeShellCommandEvent::ResourceLifecycle { + command_name: "tree.resource.rename".into(), + resource_kind: "file".into(), + asset_id: "asset:a".into(), + }, ]; let encoded = serde_json::to_value(&events).expect("command events should serialize"); @@ -892,6 +917,30 @@ mod tests { "commandName": "tree.resource.upload", "targetDocumentId": "doc:target", "fileCount": 2 + }, + { + "kind": "resourceLifecycle", + "commandName": "tree.resource.archive", + "resourceKind": "file", + "assetId": "asset:a" + }, + { + "kind": "resourceLifecycle", + "commandName": "tree.resource.restore", + "resourceKind": "file", + "assetId": "asset:a" + }, + { + "kind": "resourceLifecycle", + "commandName": "tree.resource.purge", + "resourceKind": "file", + "assetId": "asset:a" + }, + { + "kind": "resourceLifecycle", + "commandName": "tree.resource.rename", + "resourceKind": "file", + "assetId": "asset:a" } ]) ); diff --git a/rust/crates/mnote-web/src/workspace_shell.rs b/rust/crates/mnote-web/src/workspace_shell.rs index 25b379a3..bee83d2c 100644 --- a/rust/crates/mnote-web/src/workspace_shell.rs +++ b/rust/crates/mnote-web/src/workspace_shell.rs @@ -412,9 +412,8 @@ mod tests { assert!(degraded_projection.degraded); assert!(degraded_html.contains("data-mnote-workspace-shell-degraded=\"true\"")); - assert!(degraded_html.contains( - "data-mnote-workspace-shell-degraded-reason=\"projection_unavailable\"" - )); + assert!(degraded_html + .contains("data-mnote-workspace-shell-degraded-reason=\"projection_unavailable\"")); let dev_dataset = json!({ "workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }], @@ -486,9 +485,18 @@ pub fn render_workspace_shell_sidebar_html( .bottom_entries .iter() .map(|entry| { + let extra_attrs = if entry.id == "trash" { + format!( + r#" data-testid="mnote-sidebar-trash-entry" data-mnote-action="open-trash-modal" data-workspace-id="{}""#, + escape_html(&projection.workspace_id), + ) + } else { + String::new() + }; format!( - r#"{}{}"#, + r#"{}{}"#, escape_html(&entry.href), + extra_attrs, render_symbol(&entry.icon, "wolai-footer-icon"), escape_html(&entry.label), ) @@ -500,7 +508,12 @@ pub fn render_workspace_shell_sidebar_html( degraded_marker = if projection.degraded { format!( r#""#, - escape_html(projection.degraded_reason.as_deref().unwrap_or("projection_unavailable")), + escape_html( + projection + .degraded_reason + .as_deref() + .unwrap_or("projection_unavailable") + ), ) } else { String::new() diff --git a/rust/crates/storage-convex-bridge/src/lib.rs b/rust/crates/storage-convex-bridge/src/lib.rs index f62e734b..27b3ffc4 100644 --- a/rust/crates/storage-convex-bridge/src/lib.rs +++ b/rust/crates/storage-convex-bridge/src/lib.rs @@ -236,6 +236,51 @@ mod tests { .contains("\"name\":\"page.head.updateTitle\"")); } + #[test] + fn tree_resource_lifecycle_commands_have_convex_mapping() { + let cases = [ + ("tree.resource.archive", "mediaAssets:patchById"), + ("tree.resource.restore", "mediaAssets:patchById"), + ("tree.resource.purge", "mediaAssets:purgeById"), + ("tree.resource.rename", "mediaAssets:patchById"), + ]; + + for (command_name, function_name) in cases { + let command = CommandEnvelope { + name: command_name.into(), + command_id: format!("cmd_{command_name}"), + idempotency_key: Some(format!("idem_{command_name}")), + actor: ActorPayload { + actor_type: "human".into(), + actor_id: "user_1".into(), + session_id: Some("session_1".into()), + }, + source: SourcePayload { + channel: "next-route".into(), + client: "wolai-frontend".into(), + }, + target: Some(TargetRef { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload: 1, + reason: None, + refs: vec![], + dry_run: false, + validate_only: false, + }; + + let request = + build_write_request(&demo_context(), &command).expect("write request should build"); + assert_eq!(request.function_name, function_name); + assert_eq!(request.workspace_id.as_deref(), Some("ws_1")); + assert!(request + .payload_json + .contains(&format!("\"name\":\"{command_name}\""))); + } + } + #[test] fn document_save_command_maps_to_documents_update_content() { let command = CommandEnvelope { diff --git a/rust/crates/storage-convex-bridge/src/mapping.rs b/rust/crates/storage-convex-bridge/src/mapping.rs index c248a091..2ec75015 100644 --- a/rust/crates/storage-convex-bridge/src/mapping.rs +++ b/rust/crates/storage-convex-bridge/src/mapping.rs @@ -44,6 +44,10 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str { "tree.resource.copy" => "mediaAssets:batchCopy", "tree.resource.move" => "mediaAssets:batchMove", "tree.resource.upload" => "mediaAssets:createWithStorage", + "tree.resource.archive" => "mediaAssets:patchById", + "tree.resource.restore" => "mediaAssets:patchById", + "tree.resource.purge" => "mediaAssets:purgeById", + "tree.resource.rename" => "mediaAssets:patchById", "tree.filetree.drop.preflight" => "tree:fileTreeDropPreflight", "tree.filetree.delete.preflight" => "tree:fileTreeDeletePreflight", "tree.filetree.paste.preflight" => "tree:fileTreePastePreflight", diff --git a/rust/target/.rustc_info.json b/rust/target/.rustc_info.json index b88732b1..b2cd2045 100644 --- a/rust/target/.rustc_info.json +++ b/rust/target/.rustc_info.json @@ -1 +1 @@ -{"rustc_fingerprint":9228011546279038255,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"11652014622397750202":{"success":true,"status":"","code":0,"stdout":"___.wasm\nlib___.rlib\n___.wasm\nlib___.a\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\n___\ndebug_assertions\npanic=\"abort\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"wasm32\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"wasm\"\ntarget_feature=\"bulk-memory\"\ntarget_feature=\"multivalue\"\ntarget_feature=\"mutable-globals\"\ntarget_feature=\"nontrapping-fptoint\"\ntarget_feature=\"reference-types\"\ntarget_feature=\"sign-ext\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"unknown\"\ntarget_pointer_width=\"32\"\ntarget_vendor=\"unknown\"\n","stderr":"warning: dropping unsupported crate type `dylib` for target `wasm32-unknown-unknown`\n\nwarning: dropping unsupported crate type `proc-macro` for target `wasm32-unknown-unknown`\n\nwarning: 2 warnings emitted\n\n"},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""}},"successes":{}} \ No newline at end of file +{"rustc_fingerprint":9228011546279038255,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""}},"successes":{}} \ No newline at end of file diff --git a/scripts/task163-local-folder-unified-tree-browser-smoke.js b/scripts/task163-local-folder-unified-tree-browser-smoke.js index ff011706..1cf221b5 100644 --- a/scripts/task163-local-folder-unified-tree-browser-smoke.js +++ b/scripts/task163-local-folder-unified-tree-browser-smoke.js @@ -90,6 +90,22 @@ async function closeContextMenu(page) { } } +async function dispatchFolderContextMenu(page, rowSelector) { + await page.evaluate( + ({ rowSelector }) => { + const row = document.querySelector(rowSelector); + if (!(row instanceof HTMLElement)) throw new Error("filetree folder row 不存在"); + row.dispatchEvent(new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 24, + clientY: 64, + })); + }, + { rowSelector }, + ); +} + async function dispatchRootContextMenu(page) { await page.evaluate(() => { const root = document.querySelector("#sidebar-file-tree-root .tree-root"); @@ -256,6 +272,15 @@ async function run() { }); const page = await context.newPage(); const requests = []; + const navigationEvents = []; + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) { + navigationEvents.push({ + url: frame.url(), + timestamp: Date.now(), + }); + } + }); page.on("request", (request) => { if ( request.url().includes("/api/tree/commands") || @@ -449,7 +474,7 @@ async function run() { await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await dispatchRootContextMenu(page); + await dispatchFolderContextMenu(page, docsRow); await waitForContextMenu(page); await expectMenuAction(page, "newPage", { disabled: false }); await expectMenuAction(page, "newFolder", { disabled: false }); @@ -496,7 +521,7 @@ async function run() { await page.locator(readonlyRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator(imageRow).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await dispatchRootContextMenu(page); + await dispatchFolderContextMenu(page, docsRow); await waitForContextMenu(page); await clickMenuAction(page, "newFolder"); let createRenameInput = page.locator(".tree-rename-input").first(); @@ -712,13 +737,19 @@ async function run() { assert(!fs.existsSync(path.join(root, "readonly-dir", "readonly-drop.txt")), "readonly 目标预检失败后不应写入文件"); await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + let watcherNavigationStart = navigationEvents.length; fs.writeFileSync(path.join(root, "docs", "watcher-added.md"), "# Watcher Added\n", "utf8"); await waitForText(page, "Watcher Added"); + assert(navigationEvents.length === watcherNavigationStart, "外部新增 Markdown 后 page tree 不应发生浏览器导航或 reload"); await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + watcherNavigationStart = navigationEvents.length; fs.writeFileSync(path.join(root, "docs", "watcher-asset.txt"), "watcher asset", "utf8"); await waitForText(page, "watcher-asset.txt"); + assert(navigationEvents.length === watcherNavigationStart, "外部新增非 md 资源后 filetree 不应发生浏览器导航或 reload"); + watcherNavigationStart = navigationEvents.length; fs.rmSync(path.join(root, "docs", "watcher-asset.txt")); await page.getByText("watcher-asset.txt", { exact: true }).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); + assert(navigationEvents.length === watcherNavigationStart, "外部删除非 md 资源后 filetree 不应发生浏览器导航或 reload"); await page.goto(convexTreeUrl("filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForText(page, "工作区首页"); diff --git a/scripts/task169-mindmap-realtime-smoke.js b/scripts/task169-mindmap-realtime-smoke.js index cbf58dd1..dccb6893 100644 --- a/scripts/task169-mindmap-realtime-smoke.js +++ b/scripts/task169-mindmap-realtime-smoke.js @@ -643,23 +643,23 @@ async function assertSingleMindmapAssetRowStable(page, documentId, mindmapId, ex }; } -async function assertFileTreeIndexOpenUsesPageAggregate(page, documentId, mindmapId, failures) { +async function assertFileTreePageMarkdownOpenUsesPageAggregate(page, documentId, mindmapId, failures) { await openFilesystemView(page); const opened = await page.evaluate( ({ documentId }) => { const row = - document.querySelector(`#sidebar-file-tree-root [data-row-id="index:${CSS.escape(documentId)}"]`) || - Array.from(document.querySelectorAll("#sidebar-file-tree-root [data-row-kind='index']")).find((node) => { + document.querySelector(`#sidebar-file-tree-root [data-row-id="doc:${CSS.escape(documentId)}"]`) || + Array.from(document.querySelectorAll("#sidebar-file-tree-root [data-row-kind='document']")).find((node) => { if (!(node instanceof HTMLElement)) return false; const rowDocumentId = node.getAttribute("data-document-id") || node.getAttribute("data-doc-id") || ""; return rowDocumentId === documentId; }); if (!(row instanceof HTMLElement)) { - return { clicked: false, reason: "index_row_missing" }; + return { clicked: false, reason: "page_markdown_row_missing" }; } const button = row.querySelector('[data-rust-action="open"], .tree-link'); if (!(button instanceof HTMLElement)) { - return { clicked: false, reason: "index_open_button_missing" }; + return { clicked: false, reason: "page_markdown_open_button_missing" }; } button.click(); return { @@ -671,12 +671,12 @@ async function assertFileTreeIndexOpenUsesPageAggregate(page, documentId, mindma { documentId }, ); if (!opened.clicked) { - failures.push({ code: "filetree_index_open_row_missing", opened }); + failures.push({ code: "filetree_page_markdown_open_row_missing", opened }); return opened; } - if (!opened.objectIdentity.includes(`"objectKind":"index"`) || !opened.objectIdentity.includes(`"documentId":"${documentId}"`)) { + if (!opened.objectIdentity.includes(`"objectKind":"page"`) || !opened.objectIdentity.includes(`"documentId":"${documentId}"`)) { failures.push({ - code: "filetree_index_row_missing_object_identity", + code: "filetree_page_markdown_row_missing_object_identity", opened, }); } @@ -687,14 +687,14 @@ async function assertFileTreeIndexOpenUsesPageAggregate(page, documentId, mindma const url = new URL(state.url); if (!url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) { failures.push({ - code: "filetree_index_open_did_not_return_document_page", + code: "filetree_page_markdown_open_did_not_return_document_page", opened, state, }); } if (state.objectEditor === "mindmap" || state.objectIdentity.includes(`resource:mindmap:${documentId}:${mindmapId}`)) { failures.push({ - code: "filetree_index_open_loaded_mindmap_object_identity", + code: "filetree_page_markdown_open_loaded_mindmap_object_identity", opened, state, }); @@ -1294,7 +1294,7 @@ async function main() { failures, ); await openDocument(pageA, awayDoc.workspaceId, awayDoc.documentId); - result.indexOpenAfterLongLanguageEdit = await assertFileTreeIndexOpenUsesPageAggregate( + result.indexOpenAfterLongLanguageEdit = await assertFileTreePageMarkdownOpenUsesPageAggregate( pageA, doc.documentId, result.mindmapId, diff --git a/scripts/task426-mnote-web-main-no-reload-smoke.js b/scripts/task426-mnote-web-main-no-reload-smoke.js index 686f2323..425c7b53 100644 --- a/scripts/task426-mnote-web-main-no-reload-smoke.js +++ b/scripts/task426-mnote-web-main-no-reload-smoke.js @@ -106,7 +106,7 @@ async function main() { const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(id)}"]`); const fileRows = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode=\"filetree\"]")) .filter((row) => row.getAttribute("data-doc-id") === id || row.getAttribute("data-document-id") === id); - return pageRow && fileRows.some((row) => row.getAttribute("data-row-id") === `doc:${id}`) && fileRows.some((row) => row.getAttribute("data-row-id") === `index:${id}`); + return pageRow && fileRows.some((row) => row.getAttribute("data-row-id") === `doc:${id}`); }, createdDocumentId, { timeout: UI_TIMEOUT_MS }, @@ -114,7 +114,7 @@ async function main() { const afterCreate = await readCreatedState(page, createdDocumentId); assert.equal(afterCreate.localApplied, "create", "新建页面应由主文档壳本地 apply"); assert.equal(afterCreate.pageRows, 1, "Page Tree 应立即出现新页面"); - assert.deepEqual(afterCreate.fileRows.sort(), [`doc:${createdDocumentId}`, `index:${createdDocumentId}`].sort(), "File Tree 应立即出现新页面 doc/index 行"); + assert.deepEqual(afterCreate.fileRows.sort(), [`doc:${createdDocumentId}`], "File Tree 应立即出现新页面 .md 行"); const mindmapId = await insertMindmapThroughSlash(page); await page.waitForFunction( diff --git a/scripts/task427-trash-empty-isolated-workspace-smoke.js b/scripts/task427-trash-empty-isolated-workspace-smoke.js new file mode 100644 index 00000000..1fca12df --- /dev/null +++ b/scripts/task427-trash-empty-isolated-workspace-smoke.js @@ -0,0 +1,205 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { chromium } = require("playwright"); + +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, ""); +const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, ""); +const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); + +async function requestJson(request, path, init = {}) { + const response = await request.fetch(`${BASE_URL}${path}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${path} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function convexCall(context, kind, path, args) { + const cookies = await context.cookies(BASE_URL); + const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value; + assert(jwt, "缺少 __convexAuthJWT cookie"); + const response = await fetch(`${CONVEX_URL}/api/${kind}`, { + method: "POST", + headers: { + authorization: `Bearer ${jwt}`, + "content-type": "application/json", + "Convex-Client": "mnote-trash-empty-smoke", + }, + body: JSON.stringify({ path, format: "convex_encoded_json", args: [args] }), + }); + const body = await response.json(); + if (!response.ok || body.status !== "success") { + throw new Error(`Convex ${kind} ${path} 失败: ${response.status} ${JSON.stringify(body)}`); + } + return body.value; +} + +async function main() { + const stamp = Date.now(); + const email = `mnote.trash.${stamp}@example.com`; + const username = `trash-${stamp}`; + const assetId = `asset_trash_${stamp}`; + const mindmapId = `mind_trash_${stamp}`; + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + const request = context.request; + + try { + await requestJson(request, "/api/auth", { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { email, password: e2ePassword(), flow: "signUp", name: username }, + }, + }, + }); + + await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: 20_000 }); + const currentUser = await convexCall(context, "query", "users:currentUser", {}); + const userId = currentUser?._id; + assert(userId, "缺少 Convex currentUser._id"); + const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {}); + const workspaceId = workspaces.activeWorkspaceId; + assert(workspaceId, "缺少隔离 workspaceId"); + + const root = await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { action: "create", workspaceId, title: `TEST-10REVIEW-07-P3-root-${stamp}` }, + }); + const rootId = root.result.documentId; + const child = await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { action: "create", workspaceId, parentId: rootId, title: `TEST-10REVIEW-07-P3-child-${stamp}` }, + }); + const childId = child.result.documentId; + const resourceDoc = await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { action: "create", workspaceId, title: `TEST-10REVIEW-07-P3-resources-${stamp}` }, + }); + const resourceDocId = resourceDoc.result.documentId; + + await convexCall(context, "mutation", "mediaAssets:create", { + userId, + asset: { + id: assetId, + workspace_id: workspaceId, + document_id: resourceDocId, + asset_type: "file", + file_url: null, + thumbnail_url: null, + storage_id: null, + bucket: null, + storage_path: null, + file_name: `TEST-10REVIEW-07-P3-file-${stamp}.txt`, + file_size: 12, + mime_type: "text/plain", + }, + }); + await convexCall(context, "mutation", "mindmaps:put", { + docId: resourceDocId, + mindmapId, + data: { root: { data: { text: `TEST-10REVIEW-07-P3-mind-${stamp}` }, children: [] } }, + createOnly: true, + }); + const table = await convexCall(context, "mutation", "tables:create", { + userId, + workspaceId, + documentId: resourceDocId, + title: `TEST-10REVIEW-07-P3-table-${stamp}`, + schema: {}, + snapshot: null, + }); + const tableId = table.id; + await convexCall(context, "mutation", "tables:update", { + userId, + tableId, + rows: [{ cells: ["p3-row"] }], + }); + + await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { action: "archive", workspaceId, documentId: rootId }, + }); + await requestJson(request, "/api/media/batch", { + method: "POST", + data: { action: "delete", assetIds: [assetId] }, + }); + await requestJson(request, `/api/mindmap/${encodeURIComponent(resourceDocId)}/${encodeURIComponent(mindmapId)}`, { + method: "DELETE", + }); + await requestJson(request, `/api/tables/${encodeURIComponent(tableId)}`, { method: "DELETE" }); + + const trashedDocs = await convexCall(context, "query", "documents:listTrashedByWorkspace", { workspaceId }); + assert(trashedDocs.some((doc) => doc.id === rootId), "根页面未进入垃圾箱"); + assert(trashedDocs.some((doc) => doc.id === childId), "子页面未级联进入垃圾箱"); + const mediaBefore = await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId }); + assert(mediaBefore?.deleted_at, "附件未进入垃圾箱"); + const mindmapsBefore = await convexCall(context, "query", "mindmaps:listByWorkspace", { workspaceId, includeDeleted: true }); + assert(mindmapsBefore.some((row) => row.mindmap_id === mindmapId && row.deleted_at), "mindmap 未进入垃圾箱"); + const tablesBefore = await convexCall(context, "query", "tables:listByWorkspaceForSearch", { + userId, + workspaceId, + includeArchived: true, + limit: 100, + }); + assert(tablesBefore.some((row) => row.id === tableId && row.deleted_at), "table 未进入垃圾箱"); + + const pageEmpty = await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }); + const mediaEmpty = await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId } }); + const mindmapEmpty = await requestJson(request, "/api/mindmap-trash/empty", { method: "POST", data: { workspaceId } }); + const tableEmpty = await requestJson(request, "/api/tables/empty-trash", { method: "POST", data: { workspaceId } }); + + assert.equal(await convexCall(context, "query", "documents:getMeta", { id: rootId }), null, "根页面 purge 后仍可查询"); + assert.equal(await convexCall(context, "query", "documents:getMeta", { id: childId }), null, "子页面 purge 后仍可查询"); + assert.equal(await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId }), null, "附件 purge 后仍可查询"); + const mindmapsAfter = await convexCall(context, "query", "mindmaps:listByWorkspace", { workspaceId, includeDeleted: true }); + assert(!mindmapsAfter.some((row) => row.mindmap_id === mindmapId), "mindmap purge 后仍存在"); + assert.equal(await convexCall(context, "query", "tables:get", { userId, tableId }), null, "table purge 后仍可查询"); + assert.equal((await convexCall(context, "query", "tables:getRows", { tableId })).length, 0, "table rows purge 后仍存在"); + + await requestJson(request, "/api/documents/purge", { method: "POST", data: { documentId: resourceDocId } }).catch(() => null); + + console.log(JSON.stringify({ + ok: true, + email, + userId, + workspaceId, + rootId, + childId, + resourceDocId, + assetId, + mindmapId, + tableId, + pageEmpty: pageEmpty.result, + mediaEmpty: mediaEmpty.result, + mindmapEmpty: mindmapEmpty.result, + tableEmpty: tableEmpty.result, + }, null, 2)); + } finally { + await browser.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/task428-filetree-bulk-delete-selection-smoke.js b/scripts/task428-filetree-bulk-delete-selection-smoke.js new file mode 100644 index 00000000..704b6591 --- /dev/null +++ b/scripts/task428-filetree-bulk-delete-selection-smoke.js @@ -0,0 +1,534 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const TASK = "task428-filetree-bulk-delete-selection-smoke"; +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, ""); +const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, ""); +const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); + +async function writeResult(payload) { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +} + +async function requestJson(request, requestPath, init = {}) { + const response = await request.fetch(`${BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function convexCall(context, kind, convexPath, args) { + const cookies = await context.cookies(BASE_URL); + const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value; + assert(jwt, "缺少 __convexAuthJWT cookie"); + const response = await fetch(`${CONVEX_URL}/api/${kind}`, { + method: "POST", + headers: { + authorization: `Bearer ${jwt}`, + "content-type": "application/json", + "Convex-Client": TASK, + }, + body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }), + }); + const body = await response.json(); + if (!response.ok || body.status !== "success") { + throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`); + } + return body.value; +} + +function docRowSelector(documentId) { + return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`; +} + +function assetRowSelector(assetId) { + return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`; +} + +function cssEscape(value) { + return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +async function openDocumentFileTree(page, workspaceId, documentId) { + await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.waitForFunction( + () => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + return Boolean(fileRoot || fileTab); + }, + undefined, + { timeout: UI_TIMEOUT_MS }, + ); + await page.evaluate(() => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + const visible = (node) => + node instanceof HTMLElement && + !node.hidden && + getComputedStyle(node).display !== "none" && + getComputedStyle(node).visibility !== "hidden" && + node.getClientRects().length > 0; + if (visible(fileRoot)) return; + const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + if (tab instanceof HTMLElement) tab.click(); + }); + await page.waitForFunction( + () => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0; + }, + undefined, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function waitForRow(page, selector, label) { + await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => { + const text = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => ""); + throw new Error(`${label} 未出现: ${error.message}; filetree=${text.slice(0, 2000)}`); + }); +} + +async function expandDocIfNeeded(page, documentId) { + const selector = docRowSelector(documentId); + await waitForRow(page, selector, `页面 ${documentId}`); + const expanded = await page.locator(selector).first().getAttribute("aria-expanded").catch(() => null); + if (expanded === "true") return; + const clicked = await page.locator(`${selector} [data-testid="filetree-toggle"]`).first().click({ timeout: 2_000 }).then(() => true).catch(() => false); + if (!clicked) { + await page.locator(selector).first().dblclick({ timeout: 2_000 }).catch(() => undefined); + } +} + +async function selectedRows(page) { + return page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]').evaluateAll((rows) => + rows.map((row) => ({ + rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "", + assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "", + documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "", + text: row.textContent || "", + })), + ); +} + +async function clickRow(page, selector, options = {}) { + await page.locator(selector).first().click({ timeout: UI_TIMEOUT_MS, ...options }); +} + +async function rightClickRow(page, selector) { + await page.locator(selector).first().click({ button: "right", timeout: UI_TIMEOUT_MS }); +} + +async function closeContextMenu(page) { + await page.keyboard.press("Escape").catch(() => undefined); + await page.waitForTimeout(150); +} + +async function deleteSelectionAndCaptureConfirm(page, key, focusSelector) { + const target = focusSelector ? page.locator(focusSelector).first() : null; + if (focusSelector) { + await target.focus({ timeout: UI_TIMEOUT_MS }); + } + const dialogPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => { + const message = dialog.message(); + await dialog.accept(); + return message; + }); + if (target) { + await target.press(key, { timeout: UI_TIMEOUT_MS }); + } else { + await page.keyboard.press(key); + } + return await dialogPromise.catch((error) => { + throw new Error(`${key} 未触发删除确认弹窗: ${error instanceof Error ? error.message : String(error)}`); + }); +} + +async function deleteSelectionAndCaptureConfirmAndAlert(page, key, focusSelector) { + const confirmPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => { + const message = dialog.message(); + await dialog.accept(); + return message; + }); + await page.locator(focusSelector).first().press(key, { timeout: UI_TIMEOUT_MS }); + const confirm = await confirmPromise.catch((error) => { + throw new Error(`${key} 未触发删除确认弹窗: ${error instanceof Error ? error.message : String(error)}`); + }); + const alert = await page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => { + const message = dialog.message(); + await dialog.accept(); + return message; + }).catch((error) => { + throw new Error(`${key} 未触发失败摘要弹窗: ${error instanceof Error ? error.message : String(error)}`); + }); + return { confirm, alert }; +} + +async function expectDetached(page, selector, label) { + await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => { + const state = await selectedRows(page).catch(() => []); + throw new Error(`${label} 未从 filetree 消失: ${error.message}; selection=${JSON.stringify(state)}`); + }); +} + +async function createPage(request, workspaceId, title, parentId = null) { + const payload = await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { + action: "create", + workspaceId, + parentId, + title, + }, + }); + const result = payload.result || payload; + assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`); + return result.documentId; +} + +async function seedFixture(context, request, stamp) { + const currentUser = await convexCall(context, "query", "users:currentUser", {}); + const userId = currentUser?._id; + assert(userId, "缺少 Convex currentUser._id"); + const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {}); + const workspaceId = workspaces.activeWorkspaceId; + assert(workspaceId, "缺少隔离 workspaceId"); + + const rootId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-root-${stamp}`); + const childId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-child-${stamp}`, rootId); + const siblingId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-sibling-${stamp}`); + const failureDocId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-failure-doc-${stamp}`); + const resourceDocId = await createPage(request, workspaceId, `TEST-10REVIEW-07-P4-resources-${stamp}`); + + const fileAssetA = `asset_p4_a_${stamp}`; + const fileAssetB = `asset_p4_b_${stamp}`; + const failureAssetId = `asset_p4_failure_${stamp}`; + const mindmapId = `mind_p4_${stamp}`; + + for (const [assetId, fileName] of [ + [fileAssetA, `TEST-10REVIEW-07-P4-file-a-${stamp}.txt`], + [fileAssetB, `TEST-10REVIEW-07-P4-file-b-${stamp}.txt`], + [failureAssetId, `TEST-10REVIEW-07-P4-failure-file-${stamp}.txt`], + ]) { + await convexCall(context, "mutation", "mediaAssets:create", { + userId, + asset: { + id: assetId, + workspace_id: workspaceId, + document_id: resourceDocId, + asset_type: "file", + file_url: null, + thumbnail_url: null, + storage_id: null, + bucket: null, + storage_path: null, + file_name: fileName, + file_size: 12, + mime_type: "text/plain", + }, + }); + } + + await convexCall(context, "mutation", "mindmaps:put", { + docId: resourceDocId, + mindmapId, + data: { root: { data: { text: `TEST-10REVIEW-07-P4-mind-${stamp}` }, children: [] } }, + createOnly: true, + }); + + const table = await convexCall(context, "mutation", "tables:create", { + userId, + workspaceId, + documentId: resourceDocId, + title: `TEST-10REVIEW-07-P4-table-${stamp}`, + schema: {}, + snapshot: null, + }); + await convexCall(context, "mutation", "tables:update", { + userId, + tableId: table.id, + rows: [{ cells: ["p4-row"] }], + }); + + return { + userId, + workspaceId, + rootId, + childId, + siblingId, + failureDocId, + resourceDocId, + fileAssetA, + fileAssetB, + failureAssetId, + mindmapId, + tableId: table.id, + }; +} + +async function assertTrashState(context, fixture, stage) { + const trashedDocs = await convexCall(context, "query", "documents:listTrashedByWorkspace", { + workspaceId: fixture.workspaceId, + }); + for (const documentId of stage.documentIds || []) { + assert(trashedDocs.some((doc) => doc.id === documentId), `${stage.name}: 页面 ${documentId} 未进入垃圾箱`); + } + for (const assetId of stage.fileAssetIds || []) { + const row = await convexCall(context, "query", "mediaAssets:getById", { userId: fixture.userId, id: assetId }); + assert(row?.deleted_at, `${stage.name}: 附件 ${assetId} 未进入垃圾箱`); + } + if (stage.mindmapId) { + const mindmaps = await convexCall(context, "query", "mindmaps:listByWorkspace", { + workspaceId: fixture.workspaceId, + includeDeleted: true, + }); + assert( + mindmaps.some((row) => row.mindmap_id === stage.mindmapId && row.deleted_at), + `${stage.name}: mindmap ${stage.mindmapId} 未进入垃圾箱`, + ); + } + if (stage.tableId) { + const tables = await convexCall(context, "query", "tables:listByWorkspaceForSearch", { + userId: fixture.userId, + workspaceId: fixture.workspaceId, + includeArchived: true, + limit: 100, + }); + assert(tables.some((row) => row.id === stage.tableId && row.deleted_at), `${stage.name}: table ${stage.tableId} 未进入垃圾箱`); + } +} + +async function cleanup(context, request, fixture) { + if (!fixture?.workspaceId) return; + await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null); + await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null); + await requestJson(request, "/api/mindmap-trash/empty", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null); + await requestJson(request, "/api/tables/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null); + if (fixture.resourceDocId) { + if (fixture.failureAssetId) { + await requestJson(request, "/api/media/batch", { + method: "POST", + data: { action: "delete", assetIds: [fixture.failureAssetId] }, + }).catch(() => null); + await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId: fixture.workspaceId } }).catch(() => null); + } + await requestJson(request, "/api/documents/purge", { method: "POST", data: { documentId: fixture.resourceDocId } }).catch(() => null); + } + await convexCall(context, "query", "users:currentUser", {}).catch(() => null); +} + +async function main() { + const stamp = Date.now(); + const result = { + ok: false, + task: TASK, + baseUrl: BASE_URL, + convexUrl: CONVEX_URL, + email: `mnote.filetree.${stamp}@example.com`, + requests: [], + confirms: [], + fixture: null, + }; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + const request = context.request; + let fixture = null; + + page.on("request", (req) => { + const url = req.url(); + if ( + url.includes("/api/tree/filetree/delete-preflight") || + url.includes("/api/tree/commands") || + url.includes("/api/media/batch") || + url.includes("/api/mindmap/") || + url.includes("/api/tables/") + ) { + result.requests.push({ + method: req.method(), + url, + body: req.postData() || null, + }); + } + }); + + try { + await requestJson(request, "/api/auth", { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `filetree-${stamp}` }, + }, + }, + }); + + await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS }); + fixture = await seedFixture(context, request, stamp); + result.fixture = fixture; + + await openDocumentFileTree(page, fixture.workspaceId, fixture.resourceDocId); + for (const documentId of [fixture.rootId, fixture.siblingId, fixture.failureDocId, fixture.resourceDocId]) { + await waitForRow(page, docRowSelector(documentId), `页面 ${documentId}`); + } + await expandDocIfNeeded(page, fixture.rootId); + await waitForRow(page, docRowSelector(fixture.childId), `子页面 ${fixture.childId}`); + await expandDocIfNeeded(page, fixture.resourceDocId); + for (const assetId of [fixture.fileAssetA, fixture.fileAssetB, fixture.failureAssetId, fixture.mindmapId, fixture.tableId]) { + await waitForRow(page, assetRowSelector(assetId), `资源 ${assetId}`); + } + + const accel = process.platform === "darwin" ? "Meta" : "Control"; + await clickRow(page, docRowSelector(fixture.rootId)); + await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] }); + let selected = await selectedRows(page); + assert(selected.length >= 2, `Ctrl/Cmd 多选后选区数量异常: ${JSON.stringify(selected)}`); + + await clickRow(page, docRowSelector(fixture.rootId)); + await clickRow(page, docRowSelector(fixture.childId), { modifiers: ["Shift"] }); + selected = await selectedRows(page); + assert(selected.length >= 2, `Shift 范围选择后选区数量异常: ${JSON.stringify(selected)}`); + + await clickRow(page, docRowSelector(fixture.rootId)); + await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] }); + const beforeContextSelected = await selectedRows(page); + await rightClickRow(page, assetRowSelector(fixture.fileAssetA)); + const afterContextSelected = await selectedRows(page); + assert( + afterContextSelected.length === beforeContextSelected.length, + `右键已选项不应清空多选: before=${JSON.stringify(beforeContextSelected)} after=${JSON.stringify(afterContextSelected)}`, + ); + await closeContextMenu(page); + await rightClickRow(page, assetRowSelector(fixture.fileAssetB)); + selected = await selectedRows(page); + assert( + selected.length === 1 && selected[0].assetId === fixture.fileAssetB, + `右键未选项应切换 action target: ${JSON.stringify(selected)}`, + ); + await closeContextMenu(page); + + await clickRow(page, docRowSelector(fixture.rootId)); + await clickRow(page, docRowSelector(fixture.childId), { modifiers: [accel] }); + await clickRow(page, assetRowSelector(fixture.fileAssetA), { modifiers: [accel] }); + await clickRow(page, assetRowSelector(fixture.mindmapId), { modifiers: [accel] }); + await clickRow(page, assetRowSelector(fixture.tableId), { modifiers: [accel] }); + selected = await selectedRows(page); + assert(selected.length >= 5, `Delete 前混合选区数量异常: ${JSON.stringify(selected)}`); + const deleteConfirm = await deleteSelectionAndCaptureConfirm(page, "Delete", assetRowSelector(fixture.tableId)); + result.confirms.push({ key: "Delete", message: deleteConfirm }); + assert(deleteConfirm.includes("1 个页面"), `Delete 确认文案缺少页面计数: ${deleteConfirm}`); + assert(deleteConfirm.includes("1 个附件"), `Delete 确认文案缺少附件计数: ${deleteConfirm}`); + assert(deleteConfirm.includes("1 个思维导图"), `Delete 确认文案缺少思维导图计数: ${deleteConfirm}`); + assert(deleteConfirm.includes("1 个在线表格"), `Delete 确认文案缺少在线表格计数: ${deleteConfirm}`); + + await expectDetached(page, docRowSelector(fixture.rootId), "root 页面"); + await expectDetached(page, docRowSelector(fixture.childId), "child 页面"); + await expectDetached(page, assetRowSelector(fixture.fileAssetA), "普通附件 A"); + await expectDetached(page, assetRowSelector(fixture.mindmapId), "mindmap"); + await expectDetached(page, assetRowSelector(fixture.tableId), "table"); + await assertTrashState(context, fixture, { + name: "Delete", + documentIds: [fixture.rootId, fixture.childId], + fileAssetIds: [fixture.fileAssetA], + mindmapId: fixture.mindmapId, + tableId: fixture.tableId, + }); + + await clickRow(page, docRowSelector(fixture.siblingId)); + await clickRow(page, assetRowSelector(fixture.fileAssetB), { modifiers: [accel] }); + selected = await selectedRows(page); + assert(selected.length >= 2, `Backspace 前混合选区数量异常: ${JSON.stringify(selected)}`); + const backspaceConfirm = await deleteSelectionAndCaptureConfirm(page, "Backspace", assetRowSelector(fixture.fileAssetB)); + result.confirms.push({ key: "Backspace", message: backspaceConfirm }); + assert(backspaceConfirm.includes("1 个页面"), `Backspace 确认文案缺少页面计数: ${backspaceConfirm}`); + assert(backspaceConfirm.includes("1 个附件"), `Backspace 确认文案缺少附件计数: ${backspaceConfirm}`); + await expectDetached(page, docRowSelector(fixture.siblingId), "sibling 页面"); + await expectDetached(page, assetRowSelector(fixture.fileAssetB), "普通附件 B"); + await assertTrashState(context, fixture, { + name: "Backspace", + documentIds: [fixture.siblingId], + fileAssetIds: [fixture.fileAssetB], + }); + + await clickRow(page, docRowSelector(fixture.failureDocId)); + await clickRow(page, assetRowSelector(fixture.failureAssetId), { modifiers: [accel] }); + let mediaBatchFailed = false; + await page.route("**/api/media/batch", async (route) => { + const postData = route.request().postData() || ""; + if (postData.includes(fixture.failureAssetId)) { + mediaBatchFailed = true; + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "task428 forced media failure" }), + }); + return; + } + await route.continue(); + }); + const failureDialogs = await deleteSelectionAndCaptureConfirmAndAlert(page, "Delete", assetRowSelector(fixture.failureAssetId)); + await page.unroute("**/api/media/batch").catch(() => undefined); + result.confirms.push({ key: "Delete-partial", message: failureDialogs.confirm }); + result.partialFailureAlert = failureDialogs.alert; + assert(mediaBatchFailed, "部分失败场景未拦截到 media batch 请求"); + assert(failureDialogs.alert.includes("部分对象删除失败"), `失败摘要文案不正确: ${failureDialogs.alert}`); + await expectDetached(page, docRowSelector(fixture.failureDocId), "partial failure 页面"); + await waitForRow(page, assetRowSelector(fixture.failureAssetId), "partial failure 附件应保留"); + await assertTrashState(context, fixture, { + name: "PartialFailure", + documentIds: [fixture.failureDocId], + }); + const failureAsset = await convexCall(context, "query", "mediaAssets:getById", { + userId: fixture.userId, + id: fixture.failureAssetId, + }); + assert(failureAsset && !failureAsset.deleted_at, "部分失败后附件不应被错误移入垃圾箱"); + + result.ok = true; + await writeResult(result); + } catch (error) { + result.error = error instanceof Error ? error.stack || error.message : String(error); + result.selectedRows = await selectedRows(page).catch(() => []); + result.filetreeText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => ""); + await writeResult(result); + throw error; + } finally { + await cleanup(context, request, fixture).catch((error) => { + console.warn(`清理 task428 临时数据失败: ${error instanceof Error ? error.message : String(error)}`); + }); + await browser.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/task429-trash-restore-location-reveal-smoke.js b/scripts/task429-trash-restore-location-reveal-smoke.js new file mode 100644 index 00000000..0bb2c37f --- /dev/null +++ b/scripts/task429-trash-restore-location-reveal-smoke.js @@ -0,0 +1,323 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const TASK = "task429-trash-restore-location-reveal-smoke"; +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, ""); +const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); + +async function writeResult(payload) { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +} + +async function requestJson(request, requestPath, init = {}) { + const response = await request.fetch(`${BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function convexCall(context, kind, convexPath, args) { + const cookies = await context.cookies(BASE_URL); + const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value; + assert(jwt, "缺少 __convexAuthJWT cookie"); + const response = await fetch(`${CONVEX_URL}/api/${kind}`, { + method: "POST", + headers: { + authorization: `Bearer ${jwt}`, + "content-type": "application/json", + "Convex-Client": TASK, + }, + body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }), + }); + const body = await response.json(); + if (!response.ok || body.status !== "success") { + throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`); + } + return body.value; +} + +async function createPage(request, workspaceId, title, parentId = null) { + const payload = await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { + action: "create", + workspaceId, + parentId, + title, + }, + }); + const result = payload.result || payload; + assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`); + return result.documentId; +} + +async function postTreeCommand(request, data) { + return await requestJson(request, "/api/tree/commands", { + method: "POST", + data, + }); +} + +function pageTreeRowSelector(documentId) { + const escaped = String(documentId).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return `[data-testid="page-tree-row"][data-node-id="${escaped}"], [data-testid="wolai-sidebar-row"][data-node-id="${escaped}"], #sidebar-file-tree-root .tree-row[data-document-id="${escaped}"], #sidebar-file-tree-root .tree-row[data-doc-id="${escaped}"]`; +} + +async function waitForPageTreeRow(page, documentId, label) { + await page.locator(pageTreeRowSelector(documentId)).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => { + const text = await page.locator('[data-testid="sidebar-page-tree-shell"], #sidebar-file-tree-root, [data-testid="wolai-sidebar"]').innerText({ timeout: 3_000 }).catch(() => ""); + throw new Error(`${label} 未出现在侧边栏树: ${error.message}; tree=${text.slice(0, 1600)}`); + }); +} + +async function openDocumentFileTree(page, workspaceId, documentId) { + await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.waitForFunction( + () => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + return Boolean(fileRoot || fileTab); + }, + undefined, + { timeout: UI_TIMEOUT_MS }, + ); + await page.evaluate(() => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + const visible = (node) => + node instanceof HTMLElement && + !node.hidden && + getComputedStyle(node).display !== "none" && + getComputedStyle(node).visibility !== "hidden" && + node.getClientRects().length > 0; + if (visible(fileRoot)) return; + const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + if (tab instanceof HTMLElement) tab.click(); + }); + await page.waitForFunction( + () => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0; + }, + undefined, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function clickTrashButton(page) { + if (await page.getByRole("heading", { name: "垃圾桶" }).isVisible().catch(() => false)) { + return; + } + await page.getByRole("button", { name: /^垃圾桶\b/ }).first().click({ timeout: UI_TIMEOUT_MS }); + await page.getByRole("heading", { name: "垃圾桶" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); +} + +async function restoreTrashItemWithDialogs(page, title, expectFallbackAlert = false) { + const confirmPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => { + const message = dialog.message(); + await dialog.accept(); + return message; + }); + const clicked = await page.evaluate((targetTitle) => { + const rows = Array.from(document.querySelectorAll("div")); + for (const row of rows) { + if (!row.textContent?.includes(targetTitle)) continue; + const button = Array.from(row.querySelectorAll("button")).find((candidate) => candidate.textContent?.trim() === "恢复"); + if (button instanceof HTMLButtonElement) { + button.click(); + return true; + } + } + return false; + }, title); + assert(clicked, `未找到垃圾桶恢复按钮: ${title}`); + const dialogs = [await confirmPromise]; + if (expectFallbackAlert) { + const alertMessage = await page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => { + const message = dialog.message(); + await dialog.accept(); + return message; + }); + dialogs.push(alertMessage); + assert(dialogs.some((message) => message.includes("原父页面已不存在")), `缺少 fallback 提示: ${JSON.stringify(dialogs)}`); + } + return dialogs; +} + +async function listDocuments(context, workspaceId) { + return await convexCall(context, "query", "documents:listByWorkspace", { workspaceId }); +} + +async function cleanup(request, workspaceId, rootId, orphanId) { + if (!workspaceId) return; + if (orphanId) { + await postTreeCommand(request, { action: "archive", workspaceId, documentId: orphanId }).catch(() => null); + } + if (rootId) { + await postTreeCommand(request, { action: "archive", workspaceId, documentId: rootId }).catch(() => null); + } + await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null); +} + +async function main() { + const stamp = Date.now(); + const email = `mnote.restore.${stamp}@example.com`; + const username = `restore-${stamp}`; + const parentTitle = `TEST-10REVIEW-07-P6-parent-${stamp}`; + const childTitle = `TEST-10REVIEW-07-P6-child-${stamp}`; + const siblingTitle = `TEST-10REVIEW-07-P6-sibling-${stamp}`; + const orphanTitle = `TEST-10REVIEW-07-P6-orphan-${stamp}`; + const orphanId = `tree_restore_orphan_${stamp}`; + const missingParentId = `tree_missing_parent_${stamp}`; + const result = { + ok: false, + task: TASK, + baseUrl: BASE_URL, + convexUrl: CONVEX_URL, + email, + dialogs: [], + }; + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + const request = context.request; + let workspaceId = null; + let parentId = null; + + try { + await requestJson(request, "/api/auth", { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { email, password: e2ePassword(), flow: "signUp", name: username }, + }, + }, + }); + + await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS }); + const currentUser = await convexCall(context, "query", "users:currentUser", {}); + const userId = currentUser?._id; + assert(userId, "缺少 Convex currentUser._id"); + const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {}); + workspaceId = workspaces.activeWorkspaceId; + assert(workspaceId, "缺少隔离 workspaceId"); + + parentId = await createPage(request, workspaceId, parentTitle); + const childId = await createPage(request, workspaceId, childTitle, parentId); + const siblingId = await createPage(request, workspaceId, siblingTitle, parentId); + await convexCall(context, "mutation", "documents:create", { + id: orphanId, + workspaceId, + parentId: missingParentId, + title: orphanTitle, + accessScope: "private", + content: [], + }); + + await page.goto(`${BASE_URL}/documents/${encodeURIComponent(parentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await waitForPageTreeRow(page, parentId, "父页面"); + await waitForPageTreeRow(page, childId, "子页面"); + await waitForPageTreeRow(page, siblingId, "兄弟页面"); + + await postTreeCommand(request, { action: "archive", workspaceId, documentId: childId }); + await page.locator(pageTreeRowSelector(childId)).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); + await postTreeCommand(request, { action: "archive", workspaceId, documentId: orphanId }); + + const restorePayload = await postTreeCommand(request, { action: "restore", workspaceId, documentId: childId }); + result.restorePayload = restorePayload.result || restorePayload; + await waitForPageTreeRow(page, childId, "恢复后的子页面"); + await page.waitForFunction( + (documentId) => { + const row = document.querySelector(`[data-testid="page-tree-row"][data-node-id="${documentId}"], [data-testid="wolai-sidebar-row"][data-node-id="${documentId}"], #sidebar-file-tree-root .tree-row[data-document-id="${documentId}"], #sidebar-file-tree-root .tree-row[data-doc-id="${documentId}"]`); + return row instanceof HTMLElement && row.getClientRects().length > 0; + }, + childId, + { timeout: UI_TIMEOUT_MS }, + ); + + let docs = await listDocuments(context, workspaceId); + const childDoc = docs.find((doc) => doc.id === childId); + const siblingDoc = docs.find((doc) => doc.id === siblingId); + assert.equal(childDoc?.parent_id, parentId, "恢复后的子页面应回到原父页面"); + assert.equal(childDoc?.sort_order, 0, "恢复后的子页面应回到原排序位"); + assert.equal(siblingDoc?.sort_order, 1, "原排序位被恢复页面占用时,兄弟页面应后移"); + + const fallbackPayload = await postTreeCommand(request, { action: "restore", workspaceId, documentId: orphanId }); + result.fallbackPayload = fallbackPayload.result || fallbackPayload; + assert.equal( + result.fallbackPayload?.execution?.restore_location?.fallback_reason, + "parent_missing_or_deleted", + `fallback restore 应返回 parent_missing_or_deleted: ${JSON.stringify(result.fallbackPayload)}`, + ); + await waitForPageTreeRow(page, orphanId, "fallback 恢复后的孤儿页面"); + docs = await listDocuments(context, workspaceId); + const orphanDoc = docs.find((doc) => doc.id === orphanId); + assert.equal(orphanDoc?.parent_id ?? null, null, "原父页面不存在时应恢复到根目录"); + + const order = await page.evaluate( + ({ child, sibling }) => + Array.from(document.querySelectorAll('[data-testid="page-tree-row"], [data-testid="wolai-sidebar-row"], #sidebar-file-tree-root .tree-row')) + .map((row) => row instanceof HTMLElement ? row.dataset.nodeId : "") + .filter((id) => id === child || id === sibling) + .filter((id, index, list) => list.indexOf(id) === index), + { child: childId, sibling: siblingId }, + ); + assert.deepEqual(order, [childId, siblingId], `页面树顺序应为 child -> sibling: ${JSON.stringify(order)}`); + + result.ok = true; + result.workspaceId = workspaceId; + result.parentId = parentId; + result.childId = childId; + result.siblingId = siblingId; + result.orphanId = orphanId; + await writeResult(result); + } catch (error) { + result.error = error instanceof Error ? error.stack || error.message : String(error); + result.pageTreeText = await page.locator('[data-testid="sidebar-page-tree-shell"], #sidebar-file-tree-root, [data-testid="wolai-sidebar"]').innerText({ timeout: 3_000 }).catch(() => ""); + await writeResult(result); + throw error; + } finally { + await cleanup(request, workspaceId, parentId, orphanId).catch((error) => { + console.warn(`清理 task429 临时数据失败: ${error instanceof Error ? error.message : String(error)}`); + }); + await browser.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/task430-vscode-explorer-stage7-smoke.js b/scripts/task430-vscode-explorer-stage7-smoke.js new file mode 100644 index 00000000..d79886eb --- /dev/null +++ b/scripts/task430-vscode-explorer-stage7-smoke.js @@ -0,0 +1,533 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const TASK = "task430-vscode-explorer-stage7-smoke"; +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, ""); +const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); + +async function writeResult(payload) { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +} + +async function requestJson(request, requestPath, init = {}) { + const response = await request.fetch(`${BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function requestJsonAllowError(request, requestPath, init = {}) { + let response; + try { + response = await request.fetch(`${BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + } catch (error) { + return { + ok: false, + status: 0, + payload: { + error: error instanceof Error ? error.message : String(error), + }, + }; + } + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + return { ok: response.ok(), status: response.status(), payload }; +} + +async function convexCall(context, kind, convexPath, args) { + const cookies = await context.cookies(BASE_URL); + const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value; + assert(jwt, "缺少 __convexAuthJWT cookie"); + const response = await fetch(`${CONVEX_URL}/api/${kind}`, { + method: "POST", + headers: { + authorization: `Bearer ${jwt}`, + "content-type": "application/json", + "Convex-Client": TASK, + }, + body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }), + }); + const body = await response.json(); + if (!response.ok || body.status !== "success") { + throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`); + } + return body.value; +} + +function cssEscape(value) { + return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function docRowSelector(documentId) { + return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`; +} + +function assetRowSelector(assetId) { + return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`; +} + +async function createPage(request, workspaceId, title, parentId = null) { + const payload = await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { + action: "create", + workspaceId, + parentId, + title, + }, + }); + const result = payload.result || payload; + assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`); + return result.documentId; +} + +async function openDocumentFileTree(page, workspaceId, documentId) { + await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.waitForFunction( + () => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + return Boolean(fileRoot || fileTab); + }, + undefined, + { timeout: UI_TIMEOUT_MS }, + ); + await page.evaluate(() => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + const visible = (node) => + node instanceof HTMLElement && + !node.hidden && + getComputedStyle(node).display !== "none" && + getComputedStyle(node).visibility !== "hidden" && + node.getClientRects().length > 0; + if (visible(fileRoot)) return; + const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + if (tab instanceof HTMLElement) tab.click(); + }); + await page.waitForFunction( + () => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0; + }, + undefined, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function waitForRow(page, selector, label) { + await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => { + const text = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => ""); + throw new Error(`${label} 未出现: ${error.message}; filetree=${text.slice(0, 2000)}`); + }); +} + +async function expandDocIfNeeded(page, documentId) { + const selector = docRowSelector(documentId); + await waitForRow(page, selector, `页面 ${documentId}`); + const expanded = await page.locator(selector).first().getAttribute("aria-expanded").catch(() => null); + if (expanded === "true") return; + const clicked = await page.locator(`${selector} [data-testid="filetree-toggle"]`).first().click({ timeout: 2_000 }).then(() => true).catch(() => false); + if (!clicked) { + await page.locator(selector).first().dblclick({ timeout: 2_000 }).catch(() => undefined); + } +} + +async function readDocument(context, workspaceId, documentId) { + const docs = await convexCall(context, "query", "documents:listByWorkspace", { workspaceId }); + return docs.find((doc) => doc.id === documentId) || null; +} + +async function waitForAssetFileName(context, userId, assetId, fileName) { + const deadline = Date.now() + UI_TIMEOUT_MS; + let lastAsset = null; + while (Date.now() < deadline) { + lastAsset = await convexCall(context, "query", "mediaAssets:getById", { userId, id: assetId }); + if (lastAsset?.file_name === fileName) { + return lastAsset; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + assert.equal(lastAsset?.file_name, fileName, "F2 inline rename 附件后 Convex 文件名未更新"); + return lastAsset; +} + +async function renameRowWithF2(page, selector, nextTitle) { + await page.locator(selector).first().click({ timeout: UI_TIMEOUT_MS }); + await page.locator(selector).first().press("F2", { timeout: UI_TIMEOUT_MS }); + const input = page.locator(`${selector} .tree-rename-input`).first(); + await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await input.fill(nextTitle, { timeout: UI_TIMEOUT_MS }); + await input.press("Enter", { timeout: UI_TIMEOUT_MS }); + await page.locator(selector).first().getByText(nextTitle, { exact: false }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); +} + +async function visibleContextMenuLabels(page, selector) { + await page.locator(selector).first().click({ button: "right", timeout: UI_TIMEOUT_MS }); + await page.waitForTimeout(250); + const labels = ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Reveal"]; + const visible = []; + for (const label of labels) { + if (await page.getByText(label, { exact: true }).first().isVisible().catch(() => false)) { + visible.push(label); + } + } + const disabledTitles = await page.evaluate(() => + Array.from(document.querySelectorAll("button[disabled][title]")) + .map((button) => button instanceof HTMLButtonElement ? button.title : "") + .filter(Boolean), + ); + await page.keyboard.press("Escape").catch(() => undefined); + return { visible, disabledTitles }; +} + +async function runDropPreflight(request, payload) { + return await requestJsonAllowError(request, "/api/tree/filetree/drop-preflight", { + method: "POST", + data: payload, + }); +} + +function preflightRow({ rowId, rowKind, documentId, assetId = null, assetDocumentId = null, assetType = null, storagePath = null }) { + return { rowId, rowKind, documentId, assetId, assetDocumentId, assetType, storagePath }; +} + +async function cleanup(request, workspaceId, ids) { + if (!workspaceId) return; + for (const documentId of ids.filter(Boolean)) { + await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { action: "archive", workspaceId, documentId }, + }).catch(() => null); + } + await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null); + await requestJson(request, "/api/media/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null); +} + +async function runOptional(result, area, fn) { + try { + const details = await fn(); + result.checks.push({ area, ok: true, details }); + return details; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + result.skipped.push({ area, reason }); + result.checks.push({ area, ok: false, skipped: true, reason }); + return null; + } +} + +async function main() { + const stamp = Date.now(); + const prefix = `TEST-10REVIEW-07-P7-${stamp}`; + const result = { + ok: false, + task: TASK, + baseUrl: BASE_URL, + convexUrl: CONVEX_URL, + email: `mnote.stage7.${stamp}@example.com`, + prefix, + requests: [], + checks: [], + skipped: [], + }; + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + const request = context.request; + let workspaceId = null; + const cleanupDocIds = []; + + page.on("request", (req) => { + const url = req.url(); + if ( + url.includes("/api/tree/commands") || + url.includes("/api/tree/filetree/drop-preflight") || + url.includes("/api/media/batch") + ) { + result.requests.push({ + method: req.method(), + url, + body: req.postData() || null, + }); + } + }); + + try { + await requestJson(request, "/api/auth", { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `stage7-${stamp}` }, + }, + }, + }); + + await page.goto(`${BASE_URL}/`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS }); + const currentUser = await convexCall(context, "query", "users:currentUser", {}); + const userId = currentUser?._id; + assert(userId, "缺少 Convex currentUser._id"); + const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {}); + workspaceId = workspaces.activeWorkspaceId; + assert(workspaceId, "缺少隔离 workspaceId"); + + const parentA = await createPage(request, workspaceId, `${prefix}-parent-a`); + const parentB = await createPage(request, workspaceId, `${prefix}-parent-b`); + const child = await createPage(request, workspaceId, `${prefix}-child`, parentA); + const renameDoc = await createPage(request, workspaceId, `${prefix}-rename-doc`, parentA); + const resourceDoc = await createPage(request, workspaceId, `${prefix}-resource-doc`); + cleanupDocIds.push(parentA, parentB, child, renameDoc, resourceDoc); + + const assetId = `asset_p7_${stamp}`; + await convexCall(context, "mutation", "mediaAssets:create", { + userId, + asset: { + id: assetId, + workspace_id: workspaceId, + document_id: resourceDoc, + asset_type: "file", + file_url: null, + thumbnail_url: null, + storage_id: null, + bucket: null, + storage_path: null, + file_name: `${prefix}-file.txt`, + file_size: 12, + mime_type: "text/plain", + }, + }); + + await openDocumentFileTree(page, workspaceId, resourceDoc); + for (const documentId of [parentA, parentB, resourceDoc]) { + await waitForRow(page, docRowSelector(documentId), `页面 ${documentId}`); + } + await expandDocIfNeeded(page, parentA); + await waitForRow(page, docRowSelector(child), `子页面 ${child}`); + await waitForRow(page, docRowSelector(renameDoc), `重命名页面 ${renameDoc}`); + await expandDocIfNeeded(page, resourceDoc); + await waitForRow(page, assetRowSelector(assetId), `附件 ${assetId}`); + + await runOptional(result, "f2-inline-rename-doc", async () => { + const renamedDocTitle = `${prefix}-renamed-doc`; + await renameRowWithF2(page, docRowSelector(renameDoc), renamedDocTitle); + const renamedDoc = await readDocument(context, workspaceId, renameDoc); + assert.equal(renamedDoc?.title, renamedDocTitle, "F2 inline rename 页面后 Convex 标题未更新"); + return { documentId: renameDoc, title: renamedDocTitle }; + }); + + await runOptional(result, "f2-inline-rename-asset", async () => { + const renamedAssetTitle = `${prefix}-renamed-file.txt`; + await renameRowWithF2(page, assetRowSelector(assetId), renamedAssetTitle); + await waitForAssetFileName(context, userId, assetId, renamedAssetTitle); + return { assetId, fileName: renamedAssetTitle }; + }); + + const menu = await visibleContextMenuLabels(page, docRowSelector(parentA)); + result.contextMenu = menu; + const expectedMenuLabels = ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Reveal"]; + const missingMenuLabels = expectedMenuLabels.filter((label) => !menu.visible.includes(label)); + if (missingMenuLabels.length > 0) { + result.skipped.push({ + area: "context-menu-minimum", + reason: `当前真实 filetree 右键菜单未暴露这些 React Sidebar 菜单项: ${missingMenuLabels.join(", ")}`, + }); + result.checks.push({ + area: "context-menu-minimum", + ok: false, + skipped: true, + reason: `当前真实 filetree 右键菜单未暴露这些 React Sidebar 菜单项: ${missingMenuLabels.join(", ")}`, + }); + } + assert( + menu.visible.length === 0 || menu.disabledTitles.some((title) => title.includes("右键 Paste Into") || title.includes("文件夹")), + `右键菜单禁用态缺少可解释原因: ${JSON.stringify(menu)}`, + ); + if (missingMenuLabels.length === 0) { + result.checks.push({ + area: "context-menu-minimum", + ok: true, + details: menu, + }); + } + + const accel = process.platform === "darwin" ? "Meta" : "Control"; + await runOptional(result, "cut-paste-move", async () => { + await page.locator(docRowSelector(child)).first().click({ timeout: UI_TIMEOUT_MS }); + await page.locator(docRowSelector(child)).first().press(`${accel}+X`, { timeout: UI_TIMEOUT_MS }); + await page.locator(docRowSelector(parentB)).first().click({ timeout: UI_TIMEOUT_MS }); + const moveRequestCount = result.requests.length; + await page.locator(docRowSelector(parentB)).first().press(`${accel}+V`, { timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + ({ selector, expectedParent }) => { + const row = document.querySelector(selector); + return row instanceof HTMLElement && row.textContent?.includes(expectedParent); + }, + { selector: docRowSelector(parentB), expectedParent: `${prefix}-parent-b` }, + { timeout: UI_TIMEOUT_MS }, + ).catch(() => undefined); + await page.waitForTimeout(800); + const movedChild = await readDocument(context, workspaceId, child); + assert.equal(movedChild?.parent_id, parentB, "Ctrl/Cmd+X 后 Ctrl/Cmd+V 应移动页面到目标父页面"); + const moveRequests = result.requests.slice(moveRequestCount).filter((entry) => entry.body?.includes('"action":"move"')); + assert(moveRequests.length > 0, "Cut/Paste move 未捕获到 tree move 请求"); + return { documentId: child, parentId: parentB, moveRequestCount: moveRequests.length }; + }); + + const childAfterCutPaste = await readDocument(context, workspaceId, child); + const childParentId = childAfterCutPaste?.parent_id ?? parentA; + + const rows = [ + preflightRow({ rowId: `doc:${parentA}`, rowKind: "doc", documentId: parentA }), + preflightRow({ rowId: `doc:${parentB}`, rowKind: "doc", documentId: parentB }), + preflightRow({ rowId: `doc:${child}`, rowKind: "doc", documentId: child }), + preflightRow({ rowId: `doc:${renameDoc}`, rowKind: "doc", documentId: renameDoc }), + preflightRow({ + rowId: `asset:${assetId}`, + rowKind: "asset", + documentId: resourceDoc, + assetId, + assetDocumentId: resourceDoc, + assetType: "file", + }), + ]; + const documentParents = [ + { documentId: parentA, parentId: null }, + { documentId: parentB, parentId: null }, + { documentId: child, parentId: childParentId }, + { documentId: renameDoc, parentId: parentA }, + { documentId: resourceDoc, parentId: null }, + ]; + + const selfDrop = await runDropPreflight(request, { + workspaceId, + copy: false, + targetDocumentId: child, + targetRowId: `doc:${child}`, + focusedRowId: `doc:${child}`, + activeDocumentId: resourceDoc, + rowIds: [`doc:${child}`], + rows, + documentParents, + }); + + const parentToChildDrop = await runDropPreflight(request, { + workspaceId, + copy: false, + targetDocumentId: renameDoc, + targetRowId: `doc:${renameDoc}`, + focusedRowId: `doc:${renameDoc}`, + activeDocumentId: resourceDoc, + rowIds: [`doc:${parentA}`], + rows, + documentParents, + }); + + const copyDrop = await runDropPreflight(request, { + workspaceId, + copy: true, + targetDocumentId: parentB, + targetRowId: `doc:${parentB}`, + focusedRowId: `doc:${parentB}`, + activeDocumentId: resourceDoc, + rowIds: [`doc:${renameDoc}`], + rows, + documentParents, + }); + if ([selfDrop, parentToChildDrop, copyDrop].some((entry) => entry.status === 0 || entry.status === 404)) { + result.skipped.push({ + area: "dnd-preflight-guard", + reason: "当前 3000 入口未暴露或已断开 /api/tree/filetree/drop-preflight,无法稳定自动化 DnD preflight guard;未把入口失败伪造成通过。", + }); + result.checks.push({ + area: "dnd-preflight-guard", + ok: false, + skipped: true, + reason: "当前 3000 入口未暴露或已断开 /api/tree/filetree/drop-preflight", + }); + } else { + assert(!selfDrop.ok, `拖到自身应被 preflight 拒绝: ${JSON.stringify(selfDrop)}`); + assert(!parentToChildDrop.ok, `父拖子应被 preflight 拒绝: ${JSON.stringify(parentToChildDrop)}`); + assert(copyDrop.ok && copyDrop.payload?.plan?.copy === true, `copy modifier preflight 应保留 copy=true: ${JSON.stringify(copyDrop)}`); + result.checks.push({ + area: "dnd-preflight-guard", + ok: true, + details: { + selfDropStatus: selfDrop.status, + parentToChildDropStatus: parentToChildDrop.status, + copyDropPlan: copyDrop.payload?.plan ?? null, + }, + }); + } + result.dndPreflight = { + selfDropStatus: selfDrop.status, + parentToChildDropStatus: parentToChildDrop.status, + copyDropPlan: copyDrop.payload?.plan ?? null, + }; + result.skipped.push({ + area: "dnd-readonly-conflict", + reason: "主 Sidebar Convex filetree 当前 smoke 未构造 readonly source 与真实重名冲突确认弹窗;已有 local-folder smoke 和 bridge preflight 单测覆盖,仍需后续端到端矩阵补齐。", + }); + + result.ok = true; + result.workspaceId = workspaceId; + result.fixture = { parentA, parentB, child, renameDoc, resourceDoc, assetId }; + await writeResult(result); + } catch (error) { + result.error = error instanceof Error ? error.stack || error.message : String(error); + result.filetreeText = await page.locator("#sidebar-file-tree-root").innerText({ timeout: 3_000 }).catch(() => ""); + await writeResult(result); + throw error; + } finally { + await cleanup(request, workspaceId, cleanupDocIds).catch((error) => { + console.warn(`清理 task430 临时数据失败: ${error instanceof Error ? error.message : String(error)}`); + }); + await browser.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/task431-vscode-explorer-dnd-readonly-conflict-smoke.js b/scripts/task431-vscode-explorer-dnd-readonly-conflict-smoke.js new file mode 100644 index 00000000..39f08ab4 --- /dev/null +++ b/scripts/task431-vscode-explorer-dnd-readonly-conflict-smoke.js @@ -0,0 +1,295 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const TASK = "task431-vscode-explorer-dnd-readonly-conflict-smoke"; +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, ""); +const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, ""); +const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); + +async function writeResult(payload) { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +} + +async function requestJson(request, requestPath, init = {}) { + const response = await request.fetch(`${BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function requestAuthJson(request, requestPath, init = {}) { + const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function requestJsonAllowError(request, requestPath, init = {}) { + let response; + try { + response = await request.fetch(`${BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + } catch (error) { + return { + ok: false, + status: 0, + payload: { error: error instanceof Error ? error.message : String(error) }, + }; + } + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + return { ok: response.ok(), status: response.status(), payload }; +} + +async function convexCall(context, kind, convexPath, args) { + const cookies = await context.cookies(BASE_URL); + const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value; + assert(jwt, "缺少 __convexAuthJWT cookie"); + const response = await fetch(`${CONVEX_URL}/api/${kind}`, { + method: "POST", + headers: { + authorization: `Bearer ${jwt}`, + "content-type": "application/json", + "Convex-Client": TASK, + }, + body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }), + }); + const body = await response.json(); + if (!response.ok || body.status !== "success") { + throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`); + } + return body.value; +} + +async function createPage(request, workspaceId, title, parentId = null) { + const data = { + action: "create", + parentId, + title, + }; + if (workspaceId) { + data.workspaceId = workspaceId; + } + const payload = await requestJson(request, "/api/tree/commands", { + method: "POST", + data, + }); + const result = payload.result || payload; + assert(result.documentId, `创建页面失败: ${JSON.stringify(payload)}`); + return result; +} + +function docRow(documentId, title) { + return { + rowId: `doc:${documentId}`, + rowKind: "doc", + documentId, + assetId: null, + assetDocumentId: null, + assetType: null, + storagePath: null, + title, + }; +} + +async function runDropPreflight(request, payload) { + return await requestJsonAllowError(request, "/api/tree/filetree/drop-preflight", { + method: "POST", + data: payload, + }); +} + +async function cleanup(request, workspaceId, ids) { + if (!workspaceId) return; + for (const documentId of ids.filter(Boolean)) { + await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { action: "archive", workspaceId, documentId }, + }).catch(() => null); + } + await requestJson(request, "/api/documents/empty-trash", { + method: "POST", + data: { workspaceId }, + }).catch(() => null); +} + +(async () => { + const stamp = Date.now(); + const prefix = `TEST-10REVIEW-07-P7-DND-${stamp}`; + const result = { + ok: false, + task: TASK, + baseUrl: BASE_URL, + authBaseUrl: AUTH_BASE_URL, + convexUrl: CONVEX_URL, + email: `mnote.stage7.dnd.${stamp}@example.com`, + prefix, + checks: [], + }; + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const request = context.request; + let workspaceId = null; + const cleanupDocIds = []; + + try { + await requestAuthJson(request, "/api/auth", { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { email: result.email, password: e2ePassword(), flow: "signUp", name: `stage7-dnd-${stamp}` }, + }, + }, + }); + + const currentUser = await convexCall(context, "query", "users:currentUser", {}); + assert(currentUser?._id, "缺少 Convex currentUser._id"); + const workspaces = await convexCall(context, "query", "workspaces:fetchWorkspaceSummaries", {}); + workspaceId = workspaces.activeWorkspaceId; + const targetResult = await createPage(request, workspaceId, `${prefix}-target`); + workspaceId = workspaceId || targetResult.workspaceId; + assert(workspaceId, "缺少隔离 workspaceId"); + + const target = targetResult.documentId; + const source = (await createPage(request, workspaceId, `${prefix}-same-name`)).documentId; + const existing = (await createPage(request, workspaceId, `${prefix}-same-name`, target)).documentId; + cleanupDocIds.push(target, source, existing); + + const rows = [ + docRow(target, `${prefix}-target`), + docRow(source, `${prefix}-same-name`), + docRow(existing, `${prefix}-same-name`), + ]; + const documentParents = [ + { documentId: target, parentId: null }, + { documentId: source, parentId: null }, + { documentId: existing, parentId: target }, + ]; + const basePayload = { + workspaceId, + copy: false, + sourceCapabilities: ["read", "write", "move"], + targetCapabilities: ["read", "write", "drop"], + targetDocumentId: target, + targetRowId: `doc:${target}`, + focusedRowId: `doc:${target}`, + activeDocumentId: target, + rowIds: [`doc:${source}`], + rows, + targetChildren: [ + { + rowKind: "doc", + documentId: existing, + assetId: null, + title: `${prefix}-same-name`, + }, + ], + documentParents, + conflictPolicy: "prompt", + }; + + const readonlyTarget = await runDropPreflight(request, { + ...basePayload, + targetCapabilities: ["read"], + targetChildren: [], + }); + assert.equal(readonlyTarget.ok, false, `readonly target 应被拒绝: ${JSON.stringify(readonlyTarget)}`); + assert.match(JSON.stringify(readonlyTarget.payload), /只读|readonly|目标位置/); + + const readonlySource = await runDropPreflight(request, { + ...basePayload, + sourceCapabilities: ["read"], + targetChildren: [], + }); + assert.equal(readonlySource.ok, false, `readonly source 应被拒绝: ${JSON.stringify(readonlySource)}`); + assert.match(JSON.stringify(readonlySource.payload), /只读|readonly|来源/); + + const conflict = await runDropPreflight(request, basePayload); + assert.equal(conflict.ok, true, `同名冲突应返回确认计划: ${JSON.stringify(conflict)}`); + assert.equal(conflict.payload?.plan?.requiresConfirmation, true); + assert.equal(conflict.payload?.plan?.conflicts?.[0]?.title, `${prefix}-same-name`); + assert.equal(conflict.payload?.plan?.conflicts?.[0]?.existingDocumentId, existing); + + const normalCopy = await runDropPreflight(request, { + ...basePayload, + copy: true, + sourceCapabilities: ["read"], + targetChildren: [], + }); + assert.equal(normalCopy.ok, true, `copy modifier 不应被 readonly source move 规则误拒绝: ${JSON.stringify(normalCopy)}`); + assert.equal(normalCopy.payload?.plan?.copy, true); + assert.equal(normalCopy.payload?.plan?.requiresConfirmation ?? false, false); + + result.checks.push( + { area: "readonly-target", ok: true, status: readonlyTarget.status }, + { area: "readonly-source", ok: true, status: readonlySource.status }, + { area: "conflict-confirmation-plan", ok: true, conflict: conflict.payload.plan.conflicts[0] }, + { area: "copy-modifier-no-false-positive", ok: true, plan: normalCopy.payload.plan }, + ); + result.workspaceId = workspaceId; + result.fixture = { target, source, existing }; + result.ok = true; + } catch (error) { + result.error = error instanceof Error ? error.stack || error.message : String(error); + process.exitCode = 1; + } finally { + await cleanup(request, workspaceId, cleanupDocIds).catch((error) => { + result.cleanupError = error instanceof Error ? error.message : String(error); + }); + await browser.close().catch(() => undefined); + await writeResult(result); + } +})(); diff --git a/scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js b/scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js new file mode 100644 index 00000000..06deff41 --- /dev/null +++ b/scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js @@ -0,0 +1,389 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const TASK = "task432-filetree-trash-page-dual-browser-no-refresh-smoke"; +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, ""); +const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, ""); +const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); + +async function writeResult(payload) { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +} + +async function requestJson(request, requestPath, init = {}) { + const response = await request.fetch(`${BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function requestAuthJson(request, requestPath, init = {}) { + const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +function cssEscape(value) { + return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function docRowSelector(documentId) { + return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`; +} + +function trashDocRowSelector(documentId) { + return `[data-testid="mnote-trash-workbench"] [data-trash-row="document"][data-document-id="${cssEscape(documentId)}"]`; +} + +async function createPage(request, workspaceId, title, parentId = null) { + const payload = await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { + action: "create", + workspaceId, + parentId, + title, + }, + }); + const result = payload.result || payload; + const documentId = result.documentId || payload.documentId || result.id || ""; + const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || ""; + assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`); + assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`); + return { documentId, workspaceId: resolvedWorkspaceId, payload }; +} + +async function treeCommand(request, workspaceId, action, documentId) { + return await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { action, workspaceId, documentId }, + }); +} + +async function emptyDocumentTrash(request, workspaceId) { + return await requestJson(request, "/api/documents/empty-trash", { + method: "POST", + data: { workspaceId }, + }); +} + +async function authenticate(request, email, name) { + await requestAuthJson(request, "/api/auth", { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { email, password: e2ePassword(), flow: "signUp", name }, + }, + }, + }); +} + +async function openDocumentFileTree(page, workspaceId, documentId) { + await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.waitForFunction( + () => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + return Boolean(fileRoot || fileTab); + }, + undefined, + { timeout: UI_TIMEOUT_MS }, + ); + await page.evaluate(() => { + const visible = (node) => + node instanceof HTMLElement && + !node.hidden && + getComputedStyle(node).display !== "none" && + getComputedStyle(node).visibility !== "hidden" && + node.getClientRects().length > 0; + const fileRoot = document.getElementById("sidebar-file-tree-root"); + if (visible(fileRoot)) return; + const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + if (tab instanceof HTMLElement) tab.click(); + }); + await page.waitForFunction( + () => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0; + }, + undefined, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function openTrash(page, workspaceId) { + await page.goto(`${BASE_URL}/trash?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('[data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); +} + +async function waitForVisible(page, selector, label) { + await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => { + throw new Error(`${label} 未出现: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`); + }); +} + +async function waitForDetached(page, selector, label) { + await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => { + throw new Error(`${label} 未消失: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`); + }); +} + +async function installTreeEventRecorder(page, label, records) { + await page.addInitScript(() => { + window.__MNOTE_TASK432_TREE_EVENTS__ = []; + const record = (name, event) => { + const detail = event && event.detail ? event.detail : {}; + const payload = detail.payload || detail || {}; + window.__MNOTE_TASK432_TREE_EVENTS__.push({ + name, + at: Date.now(), + revision: detail.revision || payload.revision || payload.cursor || "", + op: payload && payload.data && payload.data.op ? payload.data.op : "", + }); + }; + window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event)); + window.addEventListener("tree:delta", (event) => record("tree:delta", event)); + window.addEventListener("tree:resync", (event) => record("tree:resync", event)); + }); + page.on("request", (request) => { + const url = request.url(); + if (url.includes("/api/tree/events")) { + records.push({ label, type: "request", method: request.method(), url, at: Date.now() }); + } + }); + page.on("requestfailed", (request) => { + const url = request.url(); + if (url.includes("/api/tree/events")) { + records.push({ + label, + type: "requestfailed", + method: request.method(), + url, + failure: request.failure()?.errorText || "", + at: Date.now(), + }); + } + }); + page.on("console", (message) => { + const text = message.text(); + if (/tree live|EventSource|trash|error|failed/i.test(text)) { + records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() }); + } + }); +} + +function recordNavigation(page, label, records) { + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) { + records.push({ label, url: frame.url(), at: Date.now() }); + } + }); +} + +async function readPageState(page) { + return await page.evaluate(() => ({ + url: window.location.href, + liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "", + liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "", + liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "", + liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "", + trashLiveReason: + document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-reason") || "", + trashLiveAt: + document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-at") || "", + filetreeRows: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({ + rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "", + documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "", + text: row.textContent || "", + })), + trashDocumentRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="document"]')).map((row) => ({ + documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "", + text: row.textContent || "", + })), + treeEvents: window.__MNOTE_TASK432_TREE_EVENTS__ || [], + })); +} + +async function snapshotStep(result, name, fileTreePage, trashPage, navigationStart) { + result.steps.push({ + name, + fileTree: await readPageState(fileTreePage), + trash: await readPageState(trashPage), + navigationEvents: result.navigationEvents.slice(navigationStart), + }); +} + +async function cleanup(request, workspaceId, documentIds) { + if (!workspaceId) return; + for (const documentId of documentIds.filter(Boolean)) { + await treeCommand(request, workspaceId, "archive", documentId).catch(() => null); + } + await emptyDocumentTrash(request, workspaceId).catch(() => null); +} + +(async () => { + const stamp = Date.now(); + const email = `mnote.stage8.page.${stamp}@example.com`; + const prefix = `TEST-10REVIEW-07-P8-PAGE-${stamp}`; + const result = { + ok: false, + task: TASK, + baseUrl: BASE_URL, + authBaseUrl: AUTH_BASE_URL, + email, + prefix, + fixture: {}, + requests: [], + navigationEvents: [], + treeEventRequests: [], + steps: [], + }; + + const browser = await chromium.launch({ headless: true }); + const contextA = await browser.newContext(); + const contextB = await browser.newContext(); + const pageA = await contextA.newPage(); + const fileTreeB = await contextB.newPage(); + const trashB = await contextB.newPage(); + const requestA = contextA.request; + let workspaceId = ""; + const cleanupIds = []; + + pageA.on("request", (request) => { + const url = request.url(); + if (url.includes("/api/tree/commands") || url.includes("/api/documents/empty-trash")) { + result.requests.push({ side: "A-page", method: request.method(), url, body: request.postData() || null, at: Date.now() }); + } + }); + for (const requestContext of [requestA, contextB.request]) { + await authenticate(requestContext, email, `stage8-page-${stamp}`); + } + await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests); + await installTreeEventRecorder(trashB, "B-trash", result.treeEventRequests); + recordNavigation(fileTreeB, "B-filetree", result.navigationEvents); + recordNavigation(trashB, "B-trash", result.navigationEvents); + + try { + const root = await createPage(requestA, null, `${prefix}-root`); + workspaceId = root.workspaceId; + cleanupIds.push(root.documentId); + result.fixture.rootId = root.documentId; + result.fixture.workspaceId = workspaceId; + + await pageA.goto(`${BASE_URL}/documents/${encodeURIComponent(root.documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await openDocumentFileTree(fileTreeB, workspaceId, root.documentId); + await openTrash(trashB, workspaceId); + await waitForVisible(fileTreeB, docRowSelector(root.documentId), "B 文件树 root 页面"); + + const navigationStart = result.navigationEvents.length; + await snapshotStep(result, "initial", fileTreeB, trashB, navigationStart); + + const lifecycle = await createPage(requestA, workspaceId, `${prefix}-lifecycle`, root.documentId); + cleanupIds.push(lifecycle.documentId); + result.fixture.lifecycleId = lifecycle.documentId; + await waitForVisible(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 新建页面"); + await snapshotStep(result, "create-visible-on-b", fileTreeB, trashB, navigationStart); + + await treeCommand(requestA, workspaceId, "archive", lifecycle.documentId); + await waitForDetached(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 删除后"); + await waitForVisible(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 删除后"); + await snapshotStep(result, "archive-visible-on-b", fileTreeB, trashB, navigationStart); + + await treeCommand(requestA, workspaceId, "restore", lifecycle.documentId); + await waitForVisible(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 恢复后"); + await waitForDetached(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 恢复后"); + await snapshotStep(result, "restore-visible-on-b", fileTreeB, trashB, navigationStart); + + await treeCommand(requestA, workspaceId, "archive", lifecycle.documentId); + await waitForVisible(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 再次删除后"); + await treeCommand(requestA, workspaceId, "purge", lifecycle.documentId); + await waitForDetached(trashB, trashDocRowSelector(lifecycle.documentId), "B 垃圾箱 lifecycle 彻底删除后"); + await waitForDetached(fileTreeB, docRowSelector(lifecycle.documentId), "B 文件树 lifecycle 彻底删除后"); + await snapshotStep(result, "purge-visible-on-b", fileTreeB, trashB, navigationStart); + + const emptyA = await createPage(requestA, workspaceId, `${prefix}-empty-a`, root.documentId); + const emptyB = await createPage(requestA, workspaceId, `${prefix}-empty-b`, root.documentId); + cleanupIds.push(emptyA.documentId, emptyB.documentId); + result.fixture.emptyIds = [emptyA.documentId, emptyB.documentId]; + await waitForVisible(fileTreeB, docRowSelector(emptyA.documentId), "B 文件树 empty-a 新建后"); + await waitForVisible(fileTreeB, docRowSelector(emptyB.documentId), "B 文件树 empty-b 新建后"); + await treeCommand(requestA, workspaceId, "archive", emptyA.documentId); + await treeCommand(requestA, workspaceId, "archive", emptyB.documentId); + await waitForVisible(trashB, trashDocRowSelector(emptyA.documentId), "B 垃圾箱 empty-a 删除后"); + await waitForVisible(trashB, trashDocRowSelector(emptyB.documentId), "B 垃圾箱 empty-b 删除后"); + await emptyDocumentTrash(requestA, workspaceId); + await waitForDetached(trashB, trashDocRowSelector(emptyA.documentId), "B 垃圾箱 empty-a 清空后"); + await waitForDetached(trashB, trashDocRowSelector(emptyB.documentId), "B 垃圾箱 empty-b 清空后"); + await waitForDetached(fileTreeB, docRowSelector(emptyA.documentId), "B 文件树 empty-a 清空后"); + await waitForDetached(fileTreeB, docRowSelector(emptyB.documentId), "B 文件树 empty-b 清空后"); + await snapshotStep(result, "empty-trash-visible-on-b", fileTreeB, trashB, navigationStart); + + const unexpectedNavigations = result.navigationEvents.slice(navigationStart); + assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`); + result.ok = true; + } catch (error) { + result.error = error instanceof Error ? error.stack || error.message : String(error); + result.failure = { + fileTree: await readPageState(fileTreeB).catch(() => null), + trash: await readPageState(trashB).catch(() => null), + }; + process.exitCode = 1; + } finally { + await cleanup(requestA, workspaceId, cleanupIds).catch((error) => { + result.cleanupError = error instanceof Error ? error.message : String(error); + }); + await browser.close().catch(() => undefined); + await writeResult(result); + } +})(); diff --git a/scripts/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke.js b/scripts/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke.js new file mode 100644 index 00000000..a37c8da4 --- /dev/null +++ b/scripts/task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke.js @@ -0,0 +1,425 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const TASK = "task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke"; +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, ""); +const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, ""); +const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); + +async function writeResult(payload) { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +} + +async function requestJson(request, requestPath, init = {}) { + const response = await request.fetch(`${BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 30_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function requestAuthJson(request, requestPath, init = {}) { + const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +function cssEscape(value) { + return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function docRowSelector(documentId) { + return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`; +} + +function assetRowSelector(assetId) { + return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`; +} + +function trashAssetRowSelector(assetId) { + return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="media"][data-resource-id="${cssEscape(assetId)}"]`; +} + +async function authenticate(request, email, name) { + await requestAuthJson(request, "/api/auth", { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { email, password: e2ePassword(), flow: "signUp", name }, + }, + }, + }); +} + +async function createPage(request, workspaceId, title, parentId = null) { + const payload = await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { action: "create", workspaceId, parentId, title }, + }); + const result = payload.result || payload; + const documentId = result.documentId || payload.documentId || result.id || ""; + const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || ""; + assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`); + assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`); + return { documentId, workspaceId: resolvedWorkspaceId, payload }; +} + +async function treeCommand(request, workspaceId, action, documentId) { + return await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { action, workspaceId, documentId }, + }); +} + +async function uploadFileAsset(request, workspaceId, documentId, fileName, text) { + const response = await request.fetch(`${BASE_URL}/api/media/upload`, { + method: "POST", + multipart: { + workspaceId, + documentId, + file: { + name: fileName, + mimeType: "text/plain", + buffer: Buffer.from(text, "utf8"), + }, + }, + timeout: 30_000, + }); + const payload = await response.json().catch(async () => await response.text()); + if (!response.ok()) { + throw new Error(`/api/media/upload 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + const assetId = payload?.asset?.id || payload?.assetId || ""; + assert(assetId, `上传附件缺少 asset id: ${JSON.stringify(payload)}`); + return { assetId, payload }; +} + +async function archiveAsset(request, assetId) { + return await requestJson(request, "/api/media/batch", { + method: "POST", + data: { action: "delete", assetIds: [assetId] }, + }); +} + +async function restoreAsset(request, assetId) { + return await requestJson(request, "/api/media/batch", { + method: "POST", + data: { action: "restore", assetIds: [assetId] }, + }); +} + +async function purgeAsset(request, assetId) { + return await requestJson(request, "/api/media/purge", { + method: "POST", + data: { assetId }, + }); +} + +async function emptyResourceTrash(request, workspaceId) { + return await requestJson(request, "/api/media/empty-trash", { + method: "POST", + data: { workspaceId }, + }); +} + +async function openDocumentFileTree(page, workspaceId, documentId) { + await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.waitForFunction( + () => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + return Boolean(fileRoot || fileTab); + }, + undefined, + { timeout: UI_TIMEOUT_MS }, + ); + await page.evaluate(() => { + const visible = (node) => + node instanceof HTMLElement && + !node.hidden && + getComputedStyle(node).display !== "none" && + getComputedStyle(node).visibility !== "hidden" && + node.getClientRects().length > 0; + const fileRoot = document.getElementById("sidebar-file-tree-root"); + if (visible(fileRoot)) return; + const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + if (tab instanceof HTMLElement) tab.click(); + }); + await page.waitForFunction( + () => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0; + }, + undefined, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function openTrash(page, workspaceId) { + await page.goto(`${BASE_URL}/trash?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('[data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); +} + +async function waitForVisible(page, selector, label) { + await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => { + throw new Error(`${label} 未出现: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`); + }); +} + +async function waitForDetached(page, selector, label) { + await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => { + throw new Error(`${label} 未消失: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`); + }); +} + +async function installTreeEventRecorder(page, label, records) { + await page.addInitScript(() => { + window.__MNOTE_TASK433_TREE_EVENTS__ = []; + const record = (name, event) => { + const detail = event && event.detail ? event.detail : {}; + const payload = detail.payload || detail || {}; + window.__MNOTE_TASK433_TREE_EVENTS__.push({ + name, + at: Date.now(), + revision: detail.revision || payload.revision || payload.cursor || "", + op: payload && payload.data && payload.data.op ? payload.data.op : "", + }); + }; + window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event)); + window.addEventListener("tree:delta", (event) => record("tree:delta", event)); + window.addEventListener("tree:resync", (event) => record("tree:resync", event)); + }); + page.on("request", (request) => { + const url = request.url(); + if (url.includes("/api/tree/events")) { + records.push({ label, type: "request", method: request.method(), url, at: Date.now() }); + } + }); + page.on("console", (message) => { + const text = message.text(); + if (/tree live|EventSource|trash|error|failed/i.test(text)) { + records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() }); + } + }); +} + +function recordNavigation(page, label, records) { + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) { + records.push({ label, url: frame.url(), at: Date.now() }); + } + }); +} + +async function readPageState(page) { + return await page.evaluate(() => ({ + url: window.location.href, + liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "", + liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "", + liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "", + liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "", + trashLiveReason: + document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-reason") || "", + trashLiveAt: + document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-at") || "", + filetreeRows: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({ + rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "", + assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "", + documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "", + objectKind: row instanceof HTMLElement ? row.dataset.objectKind || "" : "", + text: row.textContent || "", + })), + trashResourceRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="resource"]')).map((row) => ({ + resourceKind: row instanceof HTMLElement ? row.dataset.resourceKind || "" : "", + resourceId: row instanceof HTMLElement ? row.dataset.resourceId || "" : "", + documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "", + text: row.textContent || "", + })), + treeEvents: window.__MNOTE_TASK433_TREE_EVENTS__ || [], + })); +} + +async function snapshotStep(result, name, fileTreePage, trashPage, navigationStart) { + result.steps.push({ + name, + fileTree: await readPageState(fileTreePage), + trash: await readPageState(trashPage), + navigationEvents: result.navigationEvents.slice(navigationStart), + }); +} + +async function cleanup(request, workspaceId, documentIds, assetIds) { + for (const assetId of assetIds.filter(Boolean)) { + await archiveAsset(request, assetId).catch(() => null); + } + if (workspaceId) { + await emptyResourceTrash(request, workspaceId).catch(() => null); + } + for (const documentId of documentIds.filter(Boolean)) { + await treeCommand(request, workspaceId, "archive", documentId).catch(() => null); + } + if (workspaceId) { + await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null); + } +} + +(async () => { + const stamp = Date.now(); + const email = `mnote.stage8.file.${stamp}@example.com`; + const prefix = `TEST-10REVIEW-07-P8-FILE-${stamp}`; + const result = { + ok: false, + task: TASK, + baseUrl: BASE_URL, + authBaseUrl: AUTH_BASE_URL, + email, + prefix, + fixture: {}, + navigationEvents: [], + treeEventRequests: [], + steps: [], + }; + + const browser = await chromium.launch({ headless: true }); + const contextA = await browser.newContext(); + const contextB = await browser.newContext(); + const requestA = contextA.request; + const fileTreeB = await contextB.newPage(); + const trashB = await contextB.newPage(); + let workspaceId = ""; + const cleanupDocumentIds = []; + const cleanupAssetIds = []; + + await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests); + await installTreeEventRecorder(trashB, "B-trash", result.treeEventRequests); + recordNavigation(fileTreeB, "B-filetree", result.navigationEvents); + recordNavigation(trashB, "B-trash", result.navigationEvents); + + try { + for (const requestContext of [requestA, contextB.request]) { + await authenticate(requestContext, email, `stage8-file-${stamp}`); + } + + const root = await createPage(requestA, null, `${prefix}-root`); + workspaceId = root.workspaceId; + cleanupDocumentIds.push(root.documentId); + result.fixture.rootId = root.documentId; + result.fixture.workspaceId = workspaceId; + + await openDocumentFileTree(fileTreeB, workspaceId, root.documentId); + await openTrash(trashB, workspaceId); + await waitForVisible(fileTreeB, docRowSelector(root.documentId), "B 文件树 root 页面"); + const navigationStart = result.navigationEvents.length; + await snapshotStep(result, "initial", fileTreeB, trashB, navigationStart); + + const uploaded = await uploadFileAsset( + requestA, + workspaceId, + root.documentId, + `${prefix}-lifecycle.txt`, + `${prefix} lifecycle asset`, + ); + cleanupAssetIds.push(uploaded.assetId); + result.fixture.lifecycleAssetId = uploaded.assetId; + await waitForVisible(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树上传附件"); + await snapshotStep(result, "upload-visible-on-b", fileTreeB, trashB, navigationStart); + + await archiveAsset(requestA, uploaded.assetId); + await waitForDetached(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件删除后"); + await waitForVisible(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件删除后"); + await snapshotStep(result, "archive-visible-on-b", fileTreeB, trashB, navigationStart); + + await restoreAsset(requestA, uploaded.assetId); + await waitForVisible(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件恢复后"); + await waitForDetached(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件恢复后"); + await snapshotStep(result, "restore-visible-on-b", fileTreeB, trashB, navigationStart); + + await archiveAsset(requestA, uploaded.assetId); + await waitForVisible(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件再次删除后"); + await purgeAsset(requestA, uploaded.assetId); + await waitForDetached(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件彻底删除后"); + await waitForDetached(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件彻底删除后"); + await snapshotStep(result, "purge-visible-on-b", fileTreeB, trashB, navigationStart); + + const emptyAssetA = await uploadFileAsset(requestA, workspaceId, root.documentId, `${prefix}-empty-a.txt`, "empty a"); + const emptyAssetB = await uploadFileAsset(requestA, workspaceId, root.documentId, `${prefix}-empty-b.txt`, "empty b"); + cleanupAssetIds.push(emptyAssetA.assetId, emptyAssetB.assetId); + result.fixture.emptyAssetIds = [emptyAssetA.assetId, emptyAssetB.assetId]; + await waitForVisible(fileTreeB, assetRowSelector(emptyAssetA.assetId), "B 文件树 empty-a 上传后"); + await waitForVisible(fileTreeB, assetRowSelector(emptyAssetB.assetId), "B 文件树 empty-b 上传后"); + await archiveAsset(requestA, emptyAssetA.assetId); + await archiveAsset(requestA, emptyAssetB.assetId); + await waitForVisible(trashB, trashAssetRowSelector(emptyAssetA.assetId), "B 垃圾箱 empty-a 删除后"); + await waitForVisible(trashB, trashAssetRowSelector(emptyAssetB.assetId), "B 垃圾箱 empty-b 删除后"); + await emptyResourceTrash(requestA, workspaceId); + await waitForDetached(trashB, trashAssetRowSelector(emptyAssetA.assetId), "B 垃圾箱 empty-a 清空后"); + await waitForDetached(trashB, trashAssetRowSelector(emptyAssetB.assetId), "B 垃圾箱 empty-b 清空后"); + await waitForDetached(fileTreeB, assetRowSelector(emptyAssetA.assetId), "B 文件树 empty-a 清空后"); + await waitForDetached(fileTreeB, assetRowSelector(emptyAssetB.assetId), "B 文件树 empty-b 清空后"); + await snapshotStep(result, "empty-trash-visible-on-b", fileTreeB, trashB, navigationStart); + + const unexpectedNavigations = result.navigationEvents.slice(navigationStart); + assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`); + result.ok = true; + } catch (error) { + result.error = error instanceof Error ? error.stack || error.message : String(error); + result.failure = { + fileTree: await readPageState(fileTreeB).catch(() => null), + trash: await readPageState(trashB).catch(() => null), + }; + process.exitCode = 1; + } finally { + await cleanup(requestA, workspaceId, cleanupDocumentIds, cleanupAssetIds).catch((error) => { + result.cleanupError = error instanceof Error ? error.message : String(error); + }); + await browser.close().catch(() => undefined); + await writeResult(result); + } +})(); diff --git a/scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js b/scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js new file mode 100644 index 00000000..1374a851 --- /dev/null +++ b/scripts/task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke.js @@ -0,0 +1,558 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const TASK = "task434-filetree-trash-mindmap-table-dual-browser-no-refresh-smoke"; +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3001").replace(/\/+$/, ""); +const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, ""); +const CONVEX_URL = (process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_SELF_HOSTED_URL || "http://127.0.0.1:3210").replace(/\/+$/, ""); +const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000); +const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); + +async function writeResult(payload) { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +} + +async function requestJson(request, requestPath, init = {}) { + const response = await request.fetch(`${BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 30_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function requestAuthJson(request, requestPath, init = {}) { + const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.data !== undefined ? { "content-type": "application/json" } : {}), + ...(init.headers || {}), + }, + timeout: 20_000, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok()) { + throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function convexCall(context, kind, convexPath, args) { + const cookies = await context.cookies(BASE_URL); + const jwt = cookies.find((cookie) => cookie.name === "__convexAuthJWT")?.value; + assert(jwt, "缺少 __convexAuthJWT cookie"); + const response = await fetch(`${CONVEX_URL}/api/${kind}`, { + method: "POST", + headers: { + authorization: `Bearer ${jwt}`, + "content-type": "application/json", + "Convex-Client": TASK, + }, + body: JSON.stringify({ path: convexPath, format: "convex_encoded_json", args: [args] }), + }); + const body = await response.json(); + if (!response.ok || body.status !== "success") { + throw new Error(`Convex ${kind} ${convexPath} 失败: ${response.status} ${JSON.stringify(body)}`); + } + return body.value; +} + +function cssEscape(value) { + return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function docRowSelector(documentId) { + return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`; +} + +function assetRowSelector(assetId) { + return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`; +} + +function trashAssetRowSelector(assetId) { + return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="media"][data-resource-id="${cssEscape(assetId)}"]`; +} + +function trashResourceRowSelector(kind, resourceId) { + return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="${cssEscape(kind)}"][data-resource-id="${cssEscape(resourceId)}"]`; +} + +async function authenticate(request, email, name) { + await requestAuthJson(request, "/api/auth", { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { email, password: e2ePassword(), flow: "signUp", name }, + }, + }, + }); +} + +async function createPage(request, workspaceId, title, parentId = null) { + const payload = await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { action: "create", workspaceId, parentId, title }, + }); + const result = payload.result || payload; + const documentId = result.documentId || payload.documentId || result.id || ""; + const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || ""; + assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`); + assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`); + return { documentId, workspaceId: resolvedWorkspaceId, payload }; +} + +async function treeCommand(request, workspaceId, action, documentId) { + return await requestJson(request, "/api/tree/commands", { + method: "POST", + data: { action, workspaceId, documentId }, + }); +} + +async function uploadFileAsset(request, workspaceId, documentId, fileName, text) { + const response = await request.fetch(`${BASE_URL}/api/media/upload`, { + method: "POST", + multipart: { + workspaceId, + documentId, + file: { + name: fileName, + mimeType: "text/plain", + buffer: Buffer.from(text, "utf8"), + }, + }, + timeout: 30_000, + }); + const payload = await response.json().catch(async () => await response.text()); + if (!response.ok()) { + throw new Error(`/api/media/upload 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + const assetId = payload?.asset?.id || payload?.assetId || ""; + assert(assetId, `上传附件缺少 asset id: ${JSON.stringify(payload)}`); + return { assetId, payload }; +} + +async function createMindmap(request, workspaceId, documentId, mindmapId, title) { + return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, { + method: "POST", + data: { + commandName: "mindmaps.put", + workspaceId, + createOnly: true, + data: { root: { data: { text: title }, children: [] } }, + }, + }); +} + +async function archiveMindmap(request, documentId, mindmapId) { + return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, { + method: "DELETE", + }); +} + +async function restoreMindmap(request, documentId, mindmapId) { + return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, { + method: "PATCH", + data: { action: "restore" }, + }); +} + +async function purgeMindmap(request, documentId, mindmapId) { + return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, { + method: "PATCH", + data: { action: "purge" }, + }); +} + +async function createTable(request, documentId, title) { + const payload = await requestJson(request, "/api/tables/create", { + method: "POST", + data: { + documentId, + title, + schema: {}, + snapshot: null, + }, + }); + const tableId = payload?.result?.id || payload?.id || ""; + assert(tableId, `创建 table 缺少 id: ${JSON.stringify(payload)}`); + return tableId; +} + +async function archiveTable(request, tableId) { + return await requestJson(request, `/api/tables/${encodeURIComponent(tableId)}`, { method: "DELETE" }); +} + +async function restoreTable(request, tableId) { + return await requestJson(request, "/api/tables/restore", { + method: "POST", + data: { tableId }, + }); +} + +async function purgeTable(request, tableId) { + return await requestJson(request, "/api/tables/purge", { + method: "POST", + data: { tableId }, + }); +} + +async function archiveAsset(request, assetId) { + return await requestJson(request, "/api/media/batch", { + method: "POST", + data: { action: "delete", assetIds: [assetId] }, + }); +} + +async function restoreAsset(request, assetId) { + return await requestJson(request, "/api/media/batch", { + method: "POST", + data: { action: "restore", assetIds: [assetId] }, + }); +} + +async function purgeAsset(request, assetId) { + return await requestJson(request, "/api/media/purge", { + method: "POST", + data: { assetId }, + }); +} + +async function emptyResourceTrash(request, workspaceId) { + return await requestJson(request, "/api/media/empty-trash", { + method: "POST", + data: { workspaceId }, + }); +} + +async function emptyMindmapTrash(request, workspaceId) { + return await requestJson(request, "/api/mindmap-trash/empty", { + method: "POST", + data: { workspaceId }, + }); +} + +async function emptyTableTrash(request, workspaceId) { + return await requestJson(request, "/api/tables/empty-trash", { + method: "POST", + data: { workspaceId }, + }); +} + +async function openDocumentFileTree(page, workspaceId, documentId) { + await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.waitForFunction( + () => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + return Boolean(fileRoot || fileTab); + }, + undefined, + { timeout: UI_TIMEOUT_MS }, + ); + await page.evaluate(() => { + const visible = (node) => + node instanceof HTMLElement && + !node.hidden && + getComputedStyle(node).display !== "none" && + getComputedStyle(node).visibility !== "hidden" && + node.getClientRects().length > 0; + const fileRoot = document.getElementById("sidebar-file-tree-root"); + if (visible(fileRoot)) return; + const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + if (tab instanceof HTMLElement) tab.click(); + }); + await page.waitForFunction( + () => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0; + }, + undefined, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function openTrash(page, workspaceId) { + await page.goto(`${BASE_URL}/trash?workspaceId=${encodeURIComponent(workspaceId)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('[data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); +} + +async function waitForVisible(page, selector, label) { + await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => { + throw new Error(`${label} 未出现: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`); + }); +} + +async function waitForDetached(page, selector, label) { + await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => { + throw new Error(`${label} 未消失: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`); + }); +} + +async function installTreeEventRecorder(page, label, records) { + await page.addInitScript(() => { + window.__MNOTE_TASK433_TREE_EVENTS__ = []; + const record = (name, event) => { + const detail = event && event.detail ? event.detail : {}; + const payload = detail.payload || detail || {}; + window.__MNOTE_TASK433_TREE_EVENTS__.push({ + name, + at: Date.now(), + revision: detail.revision || payload.revision || payload.cursor || "", + op: payload && payload.data && payload.data.op ? payload.data.op : "", + }); + }; + window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event)); + window.addEventListener("tree:delta", (event) => record("tree:delta", event)); + window.addEventListener("tree:resync", (event) => record("tree:resync", event)); + }); + page.on("request", (request) => { + const url = request.url(); + if (url.includes("/api/tree/events")) { + records.push({ label, type: "request", method: request.method(), url, at: Date.now() }); + } + }); + page.on("console", (message) => { + const text = message.text(); + if (/tree live|EventSource|trash|error|failed/i.test(text)) { + records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() }); + } + }); +} + +function recordNavigation(page, label, records) { + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) { + records.push({ label, url: frame.url(), at: Date.now() }); + } + }); +} + +async function readPageState(page) { + return await page.evaluate(() => ({ + url: window.location.href, + liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "", + liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "", + liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "", + liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "", + trashLiveReason: + document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-reason") || "", + trashLiveAt: + document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-at") || "", + filetreeRows: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({ + rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "", + assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "", + documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "", + objectKind: row instanceof HTMLElement ? row.dataset.objectKind || "" : "", + text: row.textContent || "", + })), + trashResourceRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="resource"]')).map((row) => ({ + resourceKind: row instanceof HTMLElement ? row.dataset.resourceKind || "" : "", + resourceId: row instanceof HTMLElement ? row.dataset.resourceId || "" : "", + documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "", + text: row.textContent || "", + })), + treeEvents: window.__MNOTE_TASK433_TREE_EVENTS__ || [], + })); +} + +async function snapshotStep(result, name, fileTreePage, trashPage, navigationStart) { + result.steps.push({ + name, + fileTree: await readPageState(fileTreePage), + trash: await readPageState(trashPage), + navigationEvents: result.navigationEvents.slice(navigationStart), + }); +} + +async function cleanup(request, workspaceId, documentIds, mindmapIds, tableIds) { + for (const mindmapId of mindmapIds.filter(Boolean)) { + for (const documentId of documentIds.filter(Boolean)) { + await archiveMindmap(request, documentId, mindmapId).catch(() => null); + } + } + for (const tableId of tableIds.filter(Boolean)) { + await archiveTable(request, tableId).catch(() => null); + } + if (workspaceId) { + await emptyMindmapTrash(request, workspaceId).catch(() => null); + await emptyTableTrash(request, workspaceId).catch(() => null); + } + for (const documentId of documentIds.filter(Boolean)) { + await treeCommand(request, workspaceId, "archive", documentId).catch(() => null); + } + if (workspaceId) { + await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null); + } +} + +(async () => { + const stamp = Date.now(); + const email = `mnote.stage8.mindtable.${stamp}@example.com`; + const prefix = `TEST-10REVIEW-07-P8-MT-${stamp}`; + const result = { + ok: false, + task: TASK, + baseUrl: BASE_URL, + authBaseUrl: AUTH_BASE_URL, + email, + prefix, + fixture: {}, + navigationEvents: [], + treeEventRequests: [], + steps: [], + }; + + const browser = await chromium.launch({ headless: true }); + const contextA = await browser.newContext(); + const contextB = await browser.newContext(); + const requestA = contextA.request; + const fileTreeB = await contextB.newPage(); + const trashB = await contextB.newPage(); + let workspaceId = ""; + const cleanupDocumentIds = []; + const cleanupMindmapIds = []; + const cleanupTableIds = []; + + await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests); + await installTreeEventRecorder(trashB, "B-trash", result.treeEventRequests); + recordNavigation(fileTreeB, "B-filetree", result.navigationEvents); + recordNavigation(trashB, "B-trash", result.navigationEvents); + + try { + for (const requestContext of [requestA, contextB.request]) { + await authenticate(requestContext, email, `stage8-mindtable-${stamp}`); + } + + const root = await createPage(requestA, null, `${prefix}-root`); + workspaceId = root.workspaceId; + cleanupDocumentIds.push(root.documentId); + result.fixture.rootId = root.documentId; + result.fixture.workspaceId = workspaceId; + + await openDocumentFileTree(fileTreeB, workspaceId, root.documentId); + await openTrash(trashB, workspaceId); + await waitForVisible(fileTreeB, docRowSelector(root.documentId), "B 文件树 root 页面"); + const navigationStart = result.navigationEvents.length; + await snapshotStep(result, "initial", fileTreeB, trashB, navigationStart); + + const mindmapId = `mind_stage8_${stamp}_lifecycle`; + await createMindmap(requestA, workspaceId, root.documentId, mindmapId, `${prefix}-mind-lifecycle`); + cleanupMindmapIds.push(mindmapId); + result.fixture.mindmapId = mindmapId; + await waitForVisible(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 创建后"); + await snapshotStep(result, "mindmap-create-visible-on-b", fileTreeB, trashB, navigationStart); + + await archiveMindmap(requestA, root.documentId, mindmapId); + await waitForDetached(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 删除后"); + await waitForVisible(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 删除后"); + await snapshotStep(result, "mindmap-archive-visible-on-b", fileTreeB, trashB, navigationStart); + + await restoreMindmap(requestA, root.documentId, mindmapId); + await waitForVisible(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 恢复后"); + await waitForDetached(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 恢复后"); + await snapshotStep(result, "mindmap-restore-visible-on-b", fileTreeB, trashB, navigationStart); + + await archiveMindmap(requestA, root.documentId, mindmapId); + await waitForVisible(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 再次删除后"); + await purgeMindmap(requestA, root.documentId, mindmapId); + await waitForDetached(trashB, trashResourceRowSelector("mindmap", mindmapId), "B 垃圾箱 mindmap 彻底删除后"); + await waitForDetached(fileTreeB, assetRowSelector(mindmapId), "B 文件树 mindmap 彻底删除后"); + await snapshotStep(result, "mindmap-purge-visible-on-b", fileTreeB, trashB, navigationStart); + + const tableId = await createTable(requestA, root.documentId, `${prefix}-table-lifecycle`); + cleanupTableIds.push(tableId); + result.fixture.tableId = tableId; + await waitForVisible(fileTreeB, assetRowSelector(tableId), "B 文件树 table 创建后"); + await snapshotStep(result, "table-create-visible-on-b", fileTreeB, trashB, navigationStart); + + await archiveTable(requestA, tableId); + await waitForDetached(fileTreeB, assetRowSelector(tableId), "B 文件树 table 删除后"); + await waitForVisible(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 删除后"); + await snapshotStep(result, "table-archive-visible-on-b", fileTreeB, trashB, navigationStart); + + await restoreTable(requestA, tableId); + await waitForVisible(fileTreeB, assetRowSelector(tableId), "B 文件树 table 恢复后"); + await waitForDetached(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 恢复后"); + await snapshotStep(result, "table-restore-visible-on-b", fileTreeB, trashB, navigationStart); + + await archiveTable(requestA, tableId); + await waitForVisible(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 再次删除后"); + await purgeTable(requestA, tableId); + await waitForDetached(trashB, trashResourceRowSelector("table", tableId), "B 垃圾箱 table 彻底删除后"); + await waitForDetached(fileTreeB, assetRowSelector(tableId), "B 文件树 table 彻底删除后"); + await snapshotStep(result, "table-purge-visible-on-b", fileTreeB, trashB, navigationStart); + + const emptyMindmapId = `mind_stage8_${stamp}_empty`; + await createMindmap(requestA, workspaceId, root.documentId, emptyMindmapId, `${prefix}-mind-empty`); + const emptyTableId = await createTable(requestA, root.documentId, `${prefix}-table-empty`); + cleanupMindmapIds.push(emptyMindmapId); + cleanupTableIds.push(emptyTableId); + result.fixture.emptyMindmapId = emptyMindmapId; + result.fixture.emptyTableId = emptyTableId; + await waitForVisible(fileTreeB, assetRowSelector(emptyMindmapId), "B 文件树 empty mindmap 创建后"); + await waitForVisible(fileTreeB, assetRowSelector(emptyTableId), "B 文件树 empty table 创建后"); + await archiveMindmap(requestA, root.documentId, emptyMindmapId); + await archiveTable(requestA, emptyTableId); + await waitForVisible(trashB, trashResourceRowSelector("mindmap", emptyMindmapId), "B 垃圾箱 empty mindmap 删除后"); + await waitForVisible(trashB, trashResourceRowSelector("table", emptyTableId), "B 垃圾箱 empty table 删除后"); + await emptyMindmapTrash(requestA, workspaceId); + await emptyTableTrash(requestA, workspaceId); + await waitForDetached(trashB, trashResourceRowSelector("mindmap", emptyMindmapId), "B 垃圾箱 empty mindmap 清空后"); + await waitForDetached(trashB, trashResourceRowSelector("table", emptyTableId), "B 垃圾箱 empty table 清空后"); + await waitForDetached(fileTreeB, assetRowSelector(emptyMindmapId), "B 文件树 empty mindmap 清空后"); + await waitForDetached(fileTreeB, assetRowSelector(emptyTableId), "B 文件树 empty table 清空后"); + await snapshotStep(result, "mindmap-table-empty-trash-visible-on-b", fileTreeB, trashB, navigationStart); + + const unexpectedNavigations = result.navigationEvents.slice(navigationStart); + assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`); + result.ok = true; + } catch (error) { + result.error = error instanceof Error ? error.stack || error.message : String(error); + result.failure = { + fileTree: await readPageState(fileTreeB).catch(() => null), + trash: await readPageState(trashB).catch(() => null), + }; + process.exitCode = 1; + } finally { + await cleanup(requestA, workspaceId, cleanupDocumentIds, cleanupMindmapIds, cleanupTableIds).catch((error) => { + result.cleanupError = error instanceof Error ? error.message : String(error); + }); + await browser.close().catch(() => undefined); + await writeResult(result); + } +})(); diff --git a/scripts/task435-local-folder-watch-no-reload-smoke.js b/scripts/task435-local-folder-watch-no-reload-smoke.js new file mode 100644 index 00000000..25f6cc7e --- /dev/null +++ b/scripts/task435-local-folder-watch-no-reload-smoke.js @@ -0,0 +1,246 @@ +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("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", "task435-local-folder-watch-no-reload-smoke"); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function treeUrl(root, mode) { + const url = new URL(`${BASE_URL}/`); + url.searchParams.set("treeView", mode); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", fileUrl(root)); + return url.toString(); +} + +function localMdDocumentId(relativePath) { + return `local-md:${relativePath.replaceAll("/", "~2F")}`; +} + +async function quickLogin(page) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); + if (await quickLoginButton.count()) { + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }); + } +} + +async function waitForVisibleText(page, text) { + await page.waitForFunction( + (expectedText) => Array.from(document.querySelectorAll("body *")).some((element) => { + const textContent = element.textContent ? element.textContent.trim() : ""; + if (textContent !== expectedText) return false; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + }), + text, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function waitForGone(page, selector) { + await page.locator(selector).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); +} + +async function waitForFileTreeRow(page, rowId) { + await page.locator(`.tree-row[data-row-id="${rowId}"]`).waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function waitForFileTreeRowGone(page, rowId) { + await waitForGone(page, `.tree-row[data-row-id="${rowId}"]`); +} + +async function waitForPageTreeNode(page, documentId) { + await page.waitForFunction( + (expectedDocumentId) => Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]")) + .some((row) => row.getAttribute("data-node-id") === expectedDocumentId), + documentId, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function waitForPageTreeNodeGone(page, documentId) { + await page.waitForFunction( + (expectedDocumentId) => !Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]")) + .some((row) => row.getAttribute("data-node-id") === expectedDocumentId), + documentId, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function runStep(label, navigationEvents, action) { + const before = navigationEvents.length; + await action(); + const after = navigationEvents.length; + assert(after === before, `${label} 不应触发浏览器导航或 reload,before=${before} after=${after}`); + return { label, navigationEventsBefore: before, navigationEventsAfter: after }; +} + +async function run() { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-watch-no-reload-")); + fs.mkdirSync(path.join(root, "docs"), { recursive: true }); + fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); + fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8"); + fs.writeFileSync(path.join(root, "docs", "stable.md"), "# Stable Page\n", "utf8"); + fs.writeFileSync(path.join(root, "docs", "stable-asset.txt"), "stable asset", "utf8"); + + const browser = await chromium.launch({ headless: true }); + 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 navigationEvents = []; + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) { + navigationEvents.push({ url: frame.url(), timestamp: Date.now() }); + } + }); + + const steps = []; + try { + await quickLogin(page); + await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForVisibleText(page, "Local Root"); + await waitForVisibleText(page, "Stable Page"); + await page.waitForResponse((response) => response.url().includes("/api/tree/local-folder-watch") && response.ok(), { + timeout: UI_TIMEOUT_MS, + }).catch(() => {}); + await page.waitForTimeout(100); + navigationEvents.length = 0; + + steps.push(await runStep("Markdown 外部创建后 page tree 原地更新", navigationEvents, async () => { + fs.writeFileSync(path.join(root, "docs", "watcher-create.md"), "# Watcher Create\n", "utf8"); + await waitForPageTreeNode(page, localMdDocumentId("docs/watcher-create.md")); + })); + + steps.push(await runStep("Markdown 外部重命名后 page tree 原地更新", navigationEvents, async () => { + fs.renameSync( + path.join(root, "docs", "watcher-create.md"), + path.join(root, "docs", "watcher-renamed.md"), + ); + await waitForPageTreeNode(page, localMdDocumentId("docs/watcher-renamed.md")); + await waitForPageTreeNodeGone(page, localMdDocumentId("docs/watcher-create.md")); + })); + + steps.push(await runStep("Markdown 外部删除后 page tree 原地更新", navigationEvents, async () => { + fs.rmSync(path.join(root, "docs", "watcher-renamed.md")); + await waitForPageTreeNodeGone(page, localMdDocumentId("docs/watcher-renamed.md")); + })); + + await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForFileTreeRow(page, "local:folder:docs"); + await waitForFileTreeRow(page, "local:asset:docs/stable-asset.txt"); + await page.waitForResponse((response) => response.url().includes("/api/tree/local-folder-watch") && response.ok(), { + timeout: UI_TIMEOUT_MS, + }).catch(() => {}); + await page.waitForTimeout(100); + navigationEvents.length = 0; + + steps.push(await runStep("Markdown 外部创建后 filetree 原地更新", navigationEvents, async () => { + fs.writeFileSync(path.join(root, "docs", "watcher-filetree-md.md"), "# Watcher Filetree Markdown\n", "utf8"); + await waitForFileTreeRow(page, "local:markdown:docs/watcher-filetree-md.md"); + })); + + steps.push(await runStep("Markdown 外部重命名后 filetree 原地更新", navigationEvents, async () => { + fs.renameSync( + path.join(root, "docs", "watcher-filetree-md.md"), + path.join(root, "docs", "watcher-filetree-md-renamed.md"), + ); + await waitForFileTreeRow(page, "local:markdown:docs/watcher-filetree-md-renamed.md"); + await waitForFileTreeRowGone(page, "local:markdown:docs/watcher-filetree-md.md"); + })); + + steps.push(await runStep("Markdown 外部删除后 filetree 原地更新", navigationEvents, async () => { + fs.rmSync(path.join(root, "docs", "watcher-filetree-md-renamed.md")); + await waitForFileTreeRowGone(page, "local:markdown:docs/watcher-filetree-md-renamed.md"); + })); + + steps.push(await runStep("非 md 资源外部创建后 filetree 原地更新", navigationEvents, async () => { + fs.writeFileSync(path.join(root, "docs", "watcher-asset.txt"), "watcher asset", "utf8"); + await waitForFileTreeRow(page, "local:asset:docs/watcher-asset.txt"); + })); + + steps.push(await runStep("非 md 资源外部重命名后 filetree 原地更新", navigationEvents, async () => { + fs.renameSync( + path.join(root, "docs", "watcher-asset.txt"), + path.join(root, "docs", "watcher-asset-renamed.txt"), + ); + await waitForFileTreeRow(page, "local:asset:docs/watcher-asset-renamed.txt"); + await waitForFileTreeRowGone(page, "local:asset:docs/watcher-asset.txt"); + })); + + steps.push(await runStep("非 md 资源外部删除后 filetree 原地更新", navigationEvents, async () => { + fs.rmSync(path.join(root, "docs", "watcher-asset-renamed.txt")); + await waitForFileTreeRowGone(page, "local:asset:docs/watcher-asset-renamed.txt"); + })); + + steps.push(await runStep("第二类非 md 资源外部创建后 filetree 原地更新", navigationEvents, async () => { + fs.writeFileSync(path.join(root, "docs", "watcher-image.png"), "png", "utf8"); + await waitForFileTreeRow(page, "local:asset:docs/watcher-image.png"); + })); + + steps.push(await runStep("第二类非 md 资源外部重命名后 filetree 原地更新", navigationEvents, async () => { + fs.renameSync( + path.join(root, "docs", "watcher-image.png"), + path.join(root, "docs", "watcher-image-renamed.png"), + ); + await waitForFileTreeRow(page, "local:asset:docs/watcher-image-renamed.png"); + await waitForFileTreeRowGone(page, "local:asset:docs/watcher-image.png"); + })); + + steps.push(await runStep("第二类非 md 资源外部删除后 filetree 原地更新", navigationEvents, async () => { + fs.rmSync(path.join(root, "docs", "watcher-image-renamed.png")); + await waitForFileTreeRowGone(page, "local:asset:docs/watcher-image-renamed.png"); + })); + + const result = { + ok: true, + baseUrl: BASE_URL, + root, + steps, + navigationEvents, + }; + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.log(`task435 local folder watcher no-reload smoke passed: ${RESULT_PATH}`); + } finally { + await browser.close().catch(() => {}); + fs.rmSync(root, { recursive: true, force: true }); + } +} + +run().catch((error) => { + const result = { + ok: false, + baseUrl: BASE_URL, + error: error && error.stack ? error.stack : String(error), + }; + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.error(error); + process.exit(1); +}); diff --git a/scripts/task436-local-markdown-open-document-external-change-smoke.js b/scripts/task436-local-markdown-open-document-external-change-smoke.js new file mode 100644 index 00000000..e0f6ed43 --- /dev/null +++ b/scripts/task436-local-markdown-open-document-external-change-smoke.js @@ -0,0 +1,221 @@ +"use strict"; + +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", "task436-local-markdown-open-document-external-change-smoke"); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); + +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(); +} + +function markdown(title, lines) { + return [ + "---", + `title: ${title}`, + "---", + "", + ...lines, + "", + ].join("\n"); +} + +async function quickLogin(page) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); + if (await quickLoginButton.count()) { + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }); + } +} + +async function openDocument(page, root, relativePath) { + await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function waitForEditorText(page, text) { + await page.waitForFunction( + (expected) => { + const editor = document.querySelector(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror"); + return (editor?.textContent || "").includes(expected); + }, + text, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function waitForEditorStatus(page, status) { + await page.waitForFunction( + (expected) => { + const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + return root?.getAttribute("data-runtime-editor-status") === expected; + }, + status, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function readEditorRuntime(page) { + return await page.evaluate(() => { + const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + const editor = document.querySelector(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror"); + return { + status: root?.getAttribute("data-runtime-editor-status") || "", + error: root?.getAttribute("data-runtime-editor-error") || "", + text: editor?.textContent || "", + }; + }); +} + +async function typeDirtyText(page, text) { + const editor = page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first(); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.type(text, { delay: 10 }); + await waitForEditorText(page, text.trim()); +} + +async function runStep(label, navigationEvents, action) { + const before = navigationEvents.length; + await action(); + const after = navigationEvents.length; + assert.equal(after, before, `${label} 不应触发浏览器导航或 reload`); + return { + label, + navigationEventsBefore: before, + navigationEventsAfter: after, + }; +} + +async function run() { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-open-doc-external-")); + const files = { + clean: "clean-sync.md", + dirty: "dirty-conflict.md", + rename: "rename-open.md", + delete: "delete-open.md", + }; + fs.writeFileSync(path.join(root, files.clean), markdown("Clean Sync", ["initial clean"]), "utf8"); + fs.writeFileSync(path.join(root, files.dirty), markdown("Dirty Conflict", ["initial dirty"]), "utf8"); + fs.writeFileSync(path.join(root, files.rename), markdown("Rename Open", ["initial rename"]), "utf8"); + fs.writeFileSync(path.join(root, files.delete), markdown("Delete Open", ["initial delete"]), "utf8"); + + const browser = await chromium.launch({ headless: true }); + 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 navigationEvents = []; + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) { + navigationEvents.push({ url: frame.url(), timestamp: Date.now() }); + } + }); + + const steps = []; + try { + await quickLogin(page); + + await openDocument(page, root, files.clean); + await waitForEditorText(page, "initial clean"); + await page.waitForTimeout(300); + navigationEvents.length = 0; + steps.push(await runStep("打开文档外部修改后自动同步内容", navigationEvents, async () => { + const token = `external-clean-${Date.now()}`; + fs.writeFileSync(path.join(root, files.clean), markdown("Clean Sync", ["initial clean", token]), "utf8"); + await waitForEditorText(page, token); + await waitForEditorStatus(page, "synced-external-change"); + })); + + await openDocument(page, root, files.dirty); + await waitForEditorText(page, "initial dirty"); + await page.waitForTimeout(300); + navigationEvents.length = 0; + steps.push(await runStep("dirty 文档外部修改后进入冲突提示", navigationEvents, async () => { + const localToken = `local-dirty-${Date.now()}`; + const externalToken = `external-dirty-${Date.now()}`; + await typeDirtyText(page, ` ${localToken}`); + fs.writeFileSync(path.join(root, files.dirty), markdown("Dirty Conflict", ["initial dirty", externalToken]), "utf8"); + await waitForEditorStatus(page, "external-change-conflict"); + const runtime = await readEditorRuntime(page); + assert(runtime.error.includes("本地 Markdown 文件已在外部更新"), `冲突提示不正确: ${JSON.stringify(runtime)}`); + assert(runtime.text.includes(localToken), "dirty 冲突时不应静默覆盖用户正在编辑的内容"); + })); + + await openDocument(page, root, files.rename); + await waitForEditorText(page, "initial rename"); + await page.waitForTimeout(300); + navigationEvents.length = 0; + steps.push(await runStep("打开文档外部重命名后给出冲突提示", navigationEvents, async () => { + fs.renameSync(path.join(root, files.rename), path.join(root, "rename-open-renamed.md")); + await waitForEditorStatus(page, "external-change-conflict"); + const runtime = await readEditorRuntime(page); + assert(runtime.error.includes("本地 Markdown 文件已在外部更新"), `重命名提示不正确: ${JSON.stringify(runtime)}`); + })); + + await openDocument(page, root, files.delete); + await waitForEditorText(page, "initial delete"); + await page.waitForTimeout(300); + navigationEvents.length = 0; + steps.push(await runStep("打开文档外部删除后给出冲突提示", navigationEvents, async () => { + fs.rmSync(path.join(root, files.delete)); + await waitForEditorStatus(page, "external-change-conflict"); + const runtime = await readEditorRuntime(page); + assert(runtime.error.includes("本地 Markdown 文件已在外部更新"), `删除提示不正确: ${JSON.stringify(runtime)}`); + })); + + const result = { + ok: true, + baseUrl: BASE_URL, + root, + steps, + navigationEvents, + }; + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.log(`task436 local markdown open document external change smoke passed: ${RESULT_PATH}`); + } finally { + await browser.close().catch(() => {}); + fs.rmSync(root, { recursive: true, force: true }); + } +} + +run().catch((error) => { + const result = { + ok: false, + baseUrl: BASE_URL, + error: error && error.stack ? error.stack : String(error), + }; + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.error(error && error.stack ? error.stack : error); + process.exit(1); +}); diff --git a/scripts/task437-local-folder-asset-trash-lifecycle-smoke.js b/scripts/task437-local-folder-asset-trash-lifecycle-smoke.js new file mode 100644 index 00000000..c6db4930 --- /dev/null +++ b/scripts/task437-local-folder-asset-trash-lifecycle-smoke.js @@ -0,0 +1,188 @@ +"use strict"; + +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", "task437-local-folder-asset-trash-lifecycle-smoke"); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function treeUrl(root) { + const url = new URL(`${BASE_URL}/`); + url.searchParams.set("treeView", "filetree"); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", fileUrl(root)); + return url.toString(); +} + +function commandUrl() { + return `${BASE_URL}/api/tree/commands`; +} + +async function waitForFileTreeRow(page, rowId) { + await page.locator(`.tree-row[data-row-id="${rowId}"]`).waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function waitForFileTreeRowGone(page, rowId) { + await page.locator(`.tree-row[data-row-id="${rowId}"]`).waitFor({ + state: "detached", + timeout: UI_TIMEOUT_MS, + }); +} + +async function postTreeCommand(page, payload) { + return await page.evaluate(async ({ url, body }) => { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await response.json().catch(() => null); + return { status: response.status, json }; + }, { url: commandUrl(), body: payload }); +} + +async function quickLogin(page) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); + if (await quickLoginButton.count()) { + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }); + } +} + +async function runStep(label, navigationEvents, action) { + const before = navigationEvents.length; + const detail = await action(); + const after = navigationEvents.length; + assert.equal(after, before, `${label} 不应触发浏览器导航或 reload`); + return { + label, + navigationEventsBefore: before, + navigationEventsAfter: after, + detail: detail || null, + }; +} + +async function run() { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-asset-trash-smoke-")); + fs.mkdirSync(path.join(root, "docs"), { recursive: true }); + fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8"); + fs.writeFileSync(path.join(root, "docs", "asset.txt"), "asset body", "utf8"); + + const browser = await chromium.launch({ headless: true }); + 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 navigationEvents = []; + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) { + navigationEvents.push({ url: frame.url(), timestamp: Date.now() }); + } + }); + + const rowId = "local:asset:docs/asset.txt"; + const rootUri = fileUrl(root); + const steps = []; + try { + page.on("dialog", async (dialog) => { + if (dialog.type() === "confirm") await dialog.accept(); + else await dialog.dismiss().catch(() => {}); + }); + await quickLogin(page); + await page.goto(treeUrl(root), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForFileTreeRow(page, rowId); + navigationEvents.length = 0; + + steps.push(await runStep("local asset Delete 进入本地回收站", navigationEvents, async () => { + await page.locator(`.tree-row[data-row-id="${rowId}"]`).click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press("Delete"); + await waitForFileTreeRowGone(page, rowId); + assert(!fs.existsSync(path.join(root, "docs", "asset.txt")), "Delete 后源文件应消失"); + assert(fs.existsSync(path.join(root, ".mnote", "trash", "asset.txt")), "Delete 后文件应进入 .mnote/trash"); + const index = fs.readFileSync(path.join(root, ".mnote", "trash-index.json"), "utf8"); + assert(index.includes("local-file:docs/asset.txt"), "trash index 应记录 local_file entry"); + return { trashIndexHasLocalFile: true }; + })); + + steps.push(await runStep("local asset restore 回原路径", navigationEvents, async () => { + const result = await postTreeCommand(page, { + action: "restore", + sourceKind: "local_folder", + rootUri, + documentId: rowId, + }); + assert.equal(result.status, 200, `restore failed: ${JSON.stringify(result)}`); + assert.equal(result.json?.result?.execution?.canonicalCommand, "tree.resource.restore"); + await waitForFileTreeRow(page, rowId); + assert(fs.existsSync(path.join(root, "docs", "asset.txt")), "restore 后源文件应恢复"); + return result.json?.result?.execution || null; + })); + + steps.push(await runStep("local asset purge 清理 trash 文件与索引", navigationEvents, async () => { + let result = await postTreeCommand(page, { + action: "delete", + sourceKind: "local_folder", + rootUri, + documentId: rowId, + }); + assert.equal(result.status, 200, `delete before purge failed: ${JSON.stringify(result)}`); + await waitForFileTreeRowGone(page, rowId); + result = await postTreeCommand(page, { + action: "purge", + sourceKind: "local_folder", + rootUri, + documentId: rowId, + }); + assert.equal(result.status, 200, `purge failed: ${JSON.stringify(result)}`); + assert.equal(result.json?.result?.execution?.canonicalCommand, "tree.resource.purge"); + assert(!fs.existsSync(path.join(root, "docs", "asset.txt")), "purge 后源文件不应存在"); + assert(!fs.existsSync(path.join(root, ".mnote", "trash", "asset.txt")), "purge 后 trash 文件不应存在"); + const index = fs.readFileSync(path.join(root, ".mnote", "trash-index.json"), "utf8"); + assert(!index.includes("local-file:docs/asset.txt"), "purge 后 trash index 应清理 entry"); + return result.json?.result?.execution || null; + })); + + const result = { + ok: true, + baseUrl: BASE_URL, + root, + steps, + navigationEvents, + }; + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.log(`task437 local folder asset trash lifecycle smoke passed: ${RESULT_PATH}`); + } finally { + await browser.close().catch(() => {}); + fs.rmSync(root, { recursive: true, force: true }); + } +} + +run().catch((error) => { + const result = { + ok: false, + baseUrl: BASE_URL, + error: error && error.stack ? error.stack : String(error), + }; + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.error(error && error.stack ? error.stack : error); + process.exit(1); +}); diff --git a/scripts/task441-local-folder-cloud-switch-smoke.js b/scripts/task441-local-folder-cloud-switch-smoke.js new file mode 100644 index 00000000..a3a1babc --- /dev/null +++ b/scripts/task441-local-folder-cloud-switch-smoke.js @@ -0,0 +1,162 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const ROOT = path.resolve(__dirname, ".."); +const BASE_URL = (process.env.MNOTE_UI_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 OUT_DIR = path.join(ROOT, "tmp", "task441-local-folder-cloud-switch-smoke"); + +function fileUrl(filePath) { + return `file://${filePath.split(path.sep).map((part, index) => (index === 0 ? "" : encodeURIComponent(part))).join("/")}`; +} + +function cssString(value) { + return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +async function quickLogin(page) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); + if (await quickLoginButton.count()) { + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }); + } +} + +async function openFileTree(page) { + await page.evaluate(() => { + const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]'); + if (tab instanceof HTMLElement) tab.click(); + }); + await page.locator("#sidebar-file-tree-root").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); +} + +async function readCloudState(page) { + return page.evaluate(() => { + const workspaceNode = document.querySelector("#sidebar-file-tree-root[data-workspace-id], #sidebar-tree-root[data-workspace-id], [data-workspace-id]"); + const workspaceId = workspaceNode instanceof HTMLElement ? workspaceNode.getAttribute("data-workspace-id") || "" : ""; + const docRows = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-row-id^='doc:']")).map((row) => ({ + rowId: row.getAttribute("data-row-id") || "", + title: row.querySelector(".tree-link-title")?.textContent?.trim() || "", + })); + return { + url: window.location.href, + workspaceId, + storageValue: window.localStorage.getItem("mnote.workspace.lastCloudWorkspaceId") || "", + docRows, + localRows: document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-row-id^='local:']").length, + rootText: document.querySelector("#sidebar-file-tree-root")?.textContent?.slice(0, 500) || "", + }; + }); +} + +async function ensureCloudDocument(page) { + await openFileTree(page); + let state = await readCloudState(page); + if (state.workspaceId && state.workspaceId !== "default" && state.docRows.length > 0) { + return { workspaceId: state.workspaceId, rowId: state.docRows[0].rowId }; + } + + const previousPathname = new URL(page.url()).pathname; + await page.getByRole("button", { name: "新建页面" }).click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname.startsWith("/documents/") && url.pathname !== previousPathname, { timeout: UI_TIMEOUT_MS }); + const documentId = new URL(page.url()).pathname.split("/").filter(Boolean).pop(); + assert(documentId, "新建云空间页面后 URL 缺少 documentId"); + await openFileTree(page); + await page.waitForFunction( + (id) => Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`)), + documentId, + { timeout: UI_TIMEOUT_MS }, + ); + state = await readCloudState(page); + assert(state.workspaceId && state.workspaceId !== "default", `云空间 workspaceId 不应为空或 default: ${JSON.stringify(state)}`); + return { workspaceId: state.workspaceId, rowId: `doc:${documentId}` }; +} + +async function openLocalFolder(page, root) { + await page.locator('[data-testid="mnote-workspace-source-trigger"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-workspace-source-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-open-other-local-folder"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-local-folder-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-local-folder-path-input"]').fill(root); + await page.locator('[data-testid="mnote-local-folder-open-confirm"]').click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL( + (url) => url.pathname === "/" && url.searchParams.get("sourceKind") === "local_folder" && url.searchParams.get("rootUri") === fileUrl(root), + { timeout: UI_TIMEOUT_MS }, + ); + await page.locator("#sidebar-file-tree-root .tree-row[data-row-id='local:markdown:README.md']").waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function switchCloud(page) { + await page.locator('[data-testid="mnote-workspace-source-trigger"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-workspace-source-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-switch-cloud-workspace"]').click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL( + (url) => url.pathname === "/" && url.searchParams.get("sourceKind") === "convex_workspace" && !url.searchParams.has("rootUri"), + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function main() { + await fsp.mkdir(OUT_DIR, { recursive: true }); + const localRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-cloud-switch-local-")); + fs.writeFileSync(path.join(localRoot, "README.md"), "# Local README\n", "utf8"); + + const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const page = await context.newPage(); + const result = { ok: false, baseUrl: BASE_URL, localRoot, cloudTarget: null, localState: null, returnedState: null }; + + try { + await quickLogin(page); + await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + result.cloudTarget = await ensureCloudDocument(page); + await openLocalFolder(page, localRoot); + result.localState = await readCloudState(page); + assert(result.localState.storageValue === result.cloudTarget.workspaceId, `进入本地文件夹前应记住真实云空间 workspaceId: ${JSON.stringify(result)}`); + + await switchCloud(page); + await page.waitForURL((url) => url.searchParams.get("workspaceId") === result.cloudTarget.workspaceId, { + timeout: UI_TIMEOUT_MS, + }); + await openFileTree(page); + await page.waitForFunction( + (rowId) => Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="${CSS.escape(rowId)}"]`)), + result.cloudTarget.rowId, + { timeout: UI_TIMEOUT_MS }, + ); + result.returnedState = await readCloudState(page); + + assert.equal(result.returnedState.workspaceId, result.cloudTarget.workspaceId, "切回云空间后应恢复原 workspaceId"); + assert.equal(result.returnedState.storageValue, result.cloudTarget.workspaceId, "lastCloudWorkspaceId 不应被 default 污染"); + assert.notEqual(result.returnedState.storageValue, "default", "lastCloudWorkspaceId 不能是 synthetic default"); + assert.equal(result.returnedState.localRows, 0, "切回云空间后不应保留本地文件夹 row"); + assert( + result.returnedState.docRows.some((row) => row.rowId === result.cloudTarget.rowId), + `切回云空间后原云空间页面 row 应恢复: ${JSON.stringify(result.returnedState)}`, + ); + + result.ok = true; + } finally { + await fsp.writeFile(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); + await browser.close().catch(() => {}); + fs.rmSync(localRoot, { recursive: true, force: true }); + } + + console.log(JSON.stringify(result, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/task442-trash-modal-workbench-smoke.js b/scripts/task442-trash-modal-workbench-smoke.js new file mode 100644 index 00000000..03e0fcc0 --- /dev/null +++ b/scripts/task442-trash-modal-workbench-smoke.js @@ -0,0 +1,179 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const ROOT = path.resolve(__dirname, ".."); +const BASE_URL = (process.env.MNOTE_UI_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 OUT_DIR = path.join(ROOT, "tmp", "task442-trash-modal-workbench-smoke"); + +async function quickLogin(page) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); + if (await quickLoginButton.count()) { + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }); + } +} + +async function readState(page) { + return page.evaluate(() => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + const activeRow = document.querySelector("#sidebar-file-tree-root .tree-row[data-selected='true'], #sidebar-tree-root .tree-row[data-active='true']"); + const modal = document.querySelector('[data-testid="mnote-trash-modal"]'); + const panel = document.querySelector('[data-testid="mnote-trash-modal"] .mnote-trash-modal__panel'); + const workbench = document.querySelector('[data-testid="mnote-trash-modal"] [data-testid="mnote-trash-workbench"]'); + const modalRect = modal instanceof HTMLElement ? modal.getBoundingClientRect() : null; + const panelRect = panel instanceof HTMLElement ? panel.getBoundingClientRect() : null; + return { + url: window.location.href, + pathname: window.location.pathname, + modalOpen: modal instanceof HTMLElement, + workbenchVisible: workbench instanceof HTMLElement, + panelRect: panelRect ? { + left: panelRect.left, + right: panelRect.right, + top: panelRect.top, + bottom: panelRect.bottom, + width: panelRect.width, + height: panelRect.height, + } : null, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + modalRect: modalRect ? { + left: modalRect.left, + right: modalRect.right, + top: modalRect.top, + bottom: modalRect.bottom, + } : null, + fileScrollTop: fileRoot instanceof HTMLElement ? fileRoot.scrollTop : null, + activeRowId: activeRow instanceof HTMLElement ? activeRow.getAttribute("data-row-id") || activeRow.getAttribute("data-node-id") || "" : "", + bodyShell: document.body.getAttribute("data-mnote-shell") || "", + modalRole: modal instanceof HTMLElement ? modal.getAttribute("role") || "" : "", + }; + }); +} + +async function requestJson(request, pathName, data) { + const response = await request.fetch(`${BASE_URL}${pathName}`, { + method: "POST", + headers: { "content-type": "application/json" }, + data, + timeout: UI_TIMEOUT_MS, + }); + const payload = await response.json().catch(() => null); + if (!response.ok()) { + throw new Error(`${pathName} failed ${response.status()}: ${JSON.stringify(payload)}`); + } + return payload; +} + +async function treeCommand(request, body) { + const payload = await requestJson(request, "/api/tree/commands", body); + assert(payload?.result, `tree command 缺少 result: ${JSON.stringify(payload)}`); + return payload.result; +} + +async function readWorkspaceId(page) { + const workspaceId = await page.evaluate(() => { + const node = document.querySelector("#sidebar-file-tree-root[data-workspace-id], #sidebar-tree-root[data-workspace-id], [data-workspace-id]"); + return node instanceof HTMLElement ? node.getAttribute("data-workspace-id") || "" : ""; + }); + assert(workspaceId && workspaceId !== "default" && !workspaceId.startsWith("local:"), `缺少真实云空间 workspaceId: ${workspaceId}`); + return workspaceId; +} + +async function main() { + await fs.mkdir(OUT_DIR, { recursive: true }); + const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const page = await context.newPage(); + const requests = []; + page.on("request", (request) => { + if (request.url().includes("/trash")) requests.push({ url: request.url(), method: request.method() }); + }); + + const result = { ok: false, baseUrl: BASE_URL, workspaceId: null, archivedDocumentId: null, before: null, afterOpen: null, afterRestore: null, afterClose: null, requests }; + + try { + await quickLogin(page); + await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="wolai-sidebar"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + result.workspaceId = await readWorkspaceId(page); + const created = await treeCommand(context.request, { + action: "create", + workspaceId: result.workspaceId, + parentId: null, + title: `TRASH-MODAL-${Date.now().toString().slice(-6)}`, + }); + result.archivedDocumentId = created.documentId; + assert(result.archivedDocumentId, `创建临时页面缺少 documentId: ${JSON.stringify(created)}`); + await treeCommand(context.request, { + action: "archive", + workspaceId: result.workspaceId, + documentId: result.archivedDocumentId, + }); + result.before = await readState(page); + + const trashEntry = page.locator('[data-testid="mnote-sidebar-trash-entry"], .wolai-sidebar-footer .wolai-footer-entry[href="/trash"]').first(); + await trashEntry.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await trashEntry.click({ timeout: UI_TIMEOUT_MS }); + + await page.locator('[data-testid="mnote-trash-modal"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-trash-modal"] [data-testid="mnote-trash-workbench"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + result.afterOpen = await readState(page); + assert.equal(result.afterOpen.url, result.before.url, "点击垃圾箱应打开弹窗,不应离开当前 URL"); + assert.equal(result.afterOpen.modalOpen, true, "应出现垃圾箱弹窗"); + assert.equal(result.afterOpen.workbenchVisible, true, "弹窗内应复用 mnote-trash-workbench"); + assert.equal(result.afterOpen.modalRole, "dialog", "垃圾箱弹窗应使用 dialog role"); + assert(result.afterOpen.panelRect, "垃圾箱弹窗应包含面板"); + assert(Math.abs((result.afterOpen.panelRect.left + result.afterOpen.panelRect.right) / 2 - result.afterOpen.viewportWidth / 2) <= 8, + `垃圾箱面板应居中显示: ${JSON.stringify(result.afterOpen.panelRect)}`); + assert(result.afterOpen.panelRect.top > 20 && result.afterOpen.panelRect.bottom < result.afterOpen.viewportHeight - 20, + `垃圾箱面板应保留上下留白: ${JSON.stringify(result.afterOpen.panelRect)}`); + const trashRow = page.locator(`[data-testid="mnote-trash-modal"] [data-trash-row="document"][data-document-id="${result.archivedDocumentId}"]`); + await trashRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await trashRow.locator('[data-trash-action="restore"]').click({ timeout: UI_TIMEOUT_MS }); + await trashRow.waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); + result.afterRestore = await readState(page); + assert.equal(result.afterRestore.url, result.before.url, "弹窗内恢复页面后仍应停留在原 URL"); + + await page.locator('[data-testid="mnote-trash-modal-close"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-trash-modal"]').waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); + result.afterClose = await readState(page); + assert.equal(result.afterClose.url, result.before.url, "关闭垃圾箱弹窗后仍应停留在原 URL"); + assert.equal(result.afterClose.fileScrollTop, result.before.fileScrollTop, "关闭弹窗后 File Tree scrollTop 不应被无关重置"); + assert.equal(result.afterClose.activeRowId, result.before.activeRowId, "关闭弹窗后 active/selected row 不应被无关重置"); + + result.ok = true; + } finally { + if (result.workspaceId && result.archivedDocumentId) { + await treeCommand(context.request, { + action: "archive", + workspaceId: result.workspaceId, + documentId: result.archivedDocumentId, + }).catch(() => {}); + await treeCommand(context.request, { + action: "purge", + workspaceId: result.workspaceId, + documentId: result.archivedDocumentId, + }).catch(() => {}); + } + await fs.writeFile(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); + await browser.close().catch(() => {}); + } + + console.log(JSON.stringify(result, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/wolai-frontend/convex/_generated/api.d.ts b/wolai-frontend/convex/_generated/api.d.ts index 5c5fcf37..7dab0394 100644 --- a/wolai-frontend/convex/_generated/api.d.ts +++ b/wolai-frontend/convex/_generated/api.d.ts @@ -13,6 +13,7 @@ import type * as _utils_auth from "../_utils/auth.js"; import type * as _utils_documentMoveOrder from "../_utils/documentMoveOrder.js"; import type * as _utils_documentRecord from "../_utils/documentRecord.js"; import type * as _utils_documentTree from "../_utils/documentTree.js"; +import type * as _utils_documentVisibility from "../_utils/documentVisibility.js"; import type * as _utils_id from "../_utils/id.js"; import type * as _utils_ingestJobs from "../_utils/ingestJobs.js"; import type * as _utils_lightrag from "../_utils/lightrag.js"; @@ -59,6 +60,7 @@ declare const fullApi: ApiFromModules<{ "_utils/documentMoveOrder": typeof _utils_documentMoveOrder; "_utils/documentRecord": typeof _utils_documentRecord; "_utils/documentTree": typeof _utils_documentTree; + "_utils/documentVisibility": typeof _utils_documentVisibility; "_utils/id": typeof _utils_id; "_utils/ingestJobs": typeof _utils_ingestJobs; "_utils/lightrag": typeof _utils_lightrag; diff --git a/wolai-frontend/convex/documents.ts b/wolai-frontend/convex/documents.ts index 166e3b1a..db0f0f49 100644 --- a/wolai-frontend/convex/documents.ts +++ b/wolai-frontend/convex/documents.ts @@ -36,6 +36,34 @@ type CopyTreeDocument = { deleted_at: string | null; }; +type RestoreLocationDocument = { + _id?: any; + id: string; + user_id: string; + workspace_id: string; + parent_id: string | null; + sort_order: number | null; + created_at?: string | null; + deleted_at?: string | null; + restore_parent_id?: string | null; + restore_sort_order?: number | null; +}; + +export type DocumentRestoreLocation = { + parentId: string | null; + sortOrder: number | null; + fallbackReason: "none" | "parent_missing_or_deleted"; +}; + +export type DocumentRestoreOrderAssignment = { + id: string; + _id?: any; + parentId: string | null; + sortOrder: number; + previousSortOrder: number | null; + isRestored: boolean; +}; + function normalizeTitle(title: string | null | undefined): string { const safe = String(title ?? "").trim(); return safe.length > 0 ? safe : "无标题"; @@ -77,6 +105,111 @@ function extractBlocksFromContent(content: unknown): unknown[] { return []; } +export function buildDocumentTrashLocationPatch(doc: { + parent_id?: string | null; + sort_order?: number | null; +}) { + return { + restore_parent_id: doc.parent_id ?? null, + restore_sort_order: typeof doc.sort_order === "number" ? doc.sort_order : null, + }; +} + +function clampRestoreSortOrder(sortOrder: number | null | undefined, siblingCount: number): number | null { + if (typeof sortOrder !== "number" || !Number.isFinite(sortOrder)) { + return siblingCount; + } + return Math.max(0, Math.min(Math.floor(sortOrder), siblingCount)); +} + +export function resolveDocumentRestoreLocation(input: { + doc: RestoreLocationDocument; + ownedDocs: RestoreLocationDocument[]; + restoringIds?: Set; +}): DocumentRestoreLocation { + const requestedParentId = input.doc.restore_parent_id ?? input.doc.parent_id ?? null; + const restoringIds = input.restoringIds ?? new Set(); + const parentAlive = + requestedParentId == null || + restoringIds.has(requestedParentId) || + input.ownedDocs.some( + (candidate) => + candidate.id === requestedParentId && + candidate.user_id === input.doc.user_id && + candidate.workspace_id === input.doc.workspace_id && + candidate.deleted_at == null, + ); + const parentId = parentAlive ? requestedParentId : null; + const siblingCount = input.ownedDocs.filter( + (candidate) => + candidate.id !== input.doc.id && + candidate.user_id === input.doc.user_id && + candidate.workspace_id === input.doc.workspace_id && + candidate.deleted_at == null && + (candidate.parent_id ?? null) === parentId, + ).length; + return { + parentId, + sortOrder: clampRestoreSortOrder(input.doc.restore_sort_order ?? input.doc.sort_order, siblingCount), + fallbackReason: parentAlive ? "none" : "parent_missing_or_deleted", + }; +} + +export function buildDocumentRestoreOrderAssignments(input: { + restoringDocs: RestoreLocationDocument[]; + ownedDocs: RestoreLocationDocument[]; + locationsById: Map; + restoringIds?: Set; +}): DocumentRestoreOrderAssignment[] { + const restoringIds = input.restoringIds ?? new Set(input.restoringDocs.map((doc) => doc.id)); + const affectedParentIds = new Set(); + for (const location of input.locationsById.values()) { + affectedParentIds.add(location.parentId); + } + + const assignments: DocumentRestoreOrderAssignment[] = []; + for (const parentId of affectedParentIds) { + const rows = [ + ...input.ownedDocs + .filter((candidate) => !restoringIds.has(candidate.id)) + .filter((candidate) => candidate.deleted_at == null) + .filter((candidate) => (candidate.parent_id ?? null) === parentId) + .map((candidate) => ({ + doc: candidate, + desiredSortOrder: typeof candidate.sort_order === "number" ? candidate.sort_order : Number.MAX_SAFE_INTEGER, + isRestored: false, + })), + ...input.restoringDocs + .filter((candidate) => input.locationsById.get(candidate.id)?.parentId === parentId) + .map((candidate) => { + const location = input.locationsById.get(candidate.id); + return { + doc: candidate, + desiredSortOrder: typeof location?.sortOrder === "number" ? location.sortOrder : Number.MAX_SAFE_INTEGER, + isRestored: true, + }; + }), + ].sort((a, b) => { + if (a.desiredSortOrder !== b.desiredSortOrder) return a.desiredSortOrder - b.desiredSortOrder; + if (a.isRestored !== b.isRestored) return a.isRestored ? -1 : 1; + return String(a.doc.created_at ?? a.doc.id).localeCompare(String(b.doc.created_at ?? b.doc.id)); + }); + + rows.forEach((row, index) => { + assignments.push({ + id: row.doc.id, + _id: row.doc._id, + parentId, + sortOrder: index, + previousSortOrder: typeof row.doc.sort_order === "number" ? row.doc.sort_order : null, + isRestored: row.isRestored, + }); + }); + } + + return assignments; +} + function composeContentWithBlocks(content: unknown, blocks: unknown[]): unknown { if (Array.isArray(content)) { return blocks as unknown[]; @@ -682,13 +815,13 @@ async function purgeDocumentRelatedData(ctx: any, workspaceId: string, documentI } export const getMeta = query({ - args: { id: v.string() }, + args: { id: v.string(), includeDeleted: v.optional(v.boolean()) }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); - const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); + const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) return null; - if (doc.deleted_at != null) return null; + if (doc.deleted_at != null && args.includeDeleted !== true) return null; try { await requireWorkspaceMember(ctx, doc.workspace_id, userId); } catch { @@ -746,7 +879,7 @@ export const getMeta = query({ export const getPermissionForUser = query({ args: { userId: v.string(), id: v.string() }, handler: async (ctx, args) => { - const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); + const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) return null; if (doc.deleted_at != null) return null; @@ -774,7 +907,7 @@ export const getPermissionForUser = query({ export const getMetaForIngest = internalQuery({ args: { userId: v.string(), id: v.string() }, handler: async (ctx, args) => { - const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); + const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) return null; if (doc.user_id !== args.userId) return null; return { @@ -805,7 +938,7 @@ export const getContent = query({ handler: async (ctx, args) => { const userId = await requireUserId(ctx); - const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); + const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) return null; if (doc.deleted_at != null) return null; try { @@ -847,7 +980,7 @@ export const getContent = query({ export const getContentForIngest = internalQuery({ args: { userId: v.string(), id: v.string() }, handler: async (ctx, args) => { - const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); + const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) return null; if (doc.user_id !== args.userId) return null; return { @@ -1299,7 +1432,7 @@ export const updateContent = mutation({ handler: async (ctx, args) => { const userId = await requireUserId(ctx); - const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); + const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireWorkspaceMember(ctx, doc.workspace_id, userId); @@ -1419,7 +1552,7 @@ export const move = mutation({ handler: async (ctx, args) => { const userId = await requireUserId(ctx); - const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); + const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); @@ -1581,7 +1714,7 @@ export const softDelete = mutation({ handler: async (ctx, args) => { const userId = await requireUserId(ctx); - const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); + const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); const ts = nowIso(); @@ -1597,7 +1730,12 @@ export const softDelete = mutation({ let moved = 0; for (const item of subtree) { if (item.deleted_at != null) continue; - await ctx.db.patch(item._id, { deleted_at: ts, deleted_by: userId, updated_at: ts }); + await ctx.db.patch(item._id, { + deleted_at: ts, + deleted_by: userId, + ...buildDocumentTrashLocationPatch(item), + updated_at: ts, + }); // 说明:为了避免被分享者仍看到已删除页面(点开 404),软删除时也同步移除共享关系。 // 如需保留共享关系用于恢复后自动生效,可改为仅在 purge/emptyTrash 时清理。 await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id); @@ -1613,42 +1751,98 @@ export const restore = mutation({ handler: async (ctx, args) => { const userId = await requireUserId(ctx); - const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); + const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); const ts = nowIso(); const restoreDeletedAt = doc.deleted_at; + const allInWorkspace = await ctx.db + .query("documents") + .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) + .collect(); + const owned = allInWorkspace.filter((d) => d.user_id === userId); + const subtree = restoreDeletedAt != null ? collectSubtree(owned, doc.id) : [doc]; + const restoringIds = new Set( + subtree + .filter((item) => item.id === doc.id || item.deleted_at === restoreDeletedAt) + .map((item) => item.id) + .filter((id): id is string => typeof id === "string"), + ); + const restoringDocs = subtree.filter((item) => restoringIds.has(item.id)); + const locationsById = new Map(); + for (const item of restoringDocs) { + locationsById.set( + item.id, + resolveDocumentRestoreLocation({ + doc: item, + ownedDocs: owned, + restoringIds, + }), + ); + } + const orderAssignments = buildDocumentRestoreOrderAssignments({ + restoringDocs, + ownedDocs: owned, + locationsById, + restoringIds, + }); + const assignmentById = new Map(orderAssignments.map((assignment) => [assignment.id, assignment])); + for (const assignment of orderAssignments) { + if (assignment.isRestored) continue; + if (assignment.previousSortOrder === assignment.sortOrder) continue; + await ctx.db.patch(assignment._id, { sort_order: assignment.sortOrder }); + } + const restoreLocation = locationsById.get(doc.id) ?? { + parentId: null, + sortOrder: null, + fallbackReason: "parent_missing_or_deleted" as const, + }; + const restoreAssignment = assignmentById.get(doc.id); + const restoredParentId = restoreAssignment?.parentId ?? restoreLocation.parentId; + const restoredSortOrder = restoreAssignment?.sortOrder ?? restoreLocation.sortOrder; await ctx.db.patch(doc._id, { deleted_at: null, deleted_by: null, - parent_id: null, + parent_id: restoredParentId, + sort_order: restoredSortOrder, + restore_parent_id: null, + restore_sort_order: null, access_scope: "private", updated_at: ts, }); // 级联恢复:仅恢复“随本次父节点删除而进入垃圾桶”的子节点,避免把之前单独删除的子页面一并恢复。 if (restoreDeletedAt != null) { - const allInWorkspace = await ctx.db - .query("documents") - .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) - .collect(); - const owned = allInWorkspace.filter((d) => d.user_id === userId); - const subtree = collectSubtree(owned, doc.id); - for (const item of subtree) { if (item.id === doc.id) continue; if (item.deleted_at !== restoreDeletedAt) continue; - await ctx.db.patch(item._id, { deleted_at: null, deleted_by: null, updated_at: ts }); + const childRestoreLocation = locationsById.get(item.id); + const childRestoreAssignment = assignmentById.get(item.id); + await ctx.db.patch(item._id, { + deleted_at: null, + deleted_by: null, + parent_id: childRestoreAssignment?.parentId ?? childRestoreLocation?.parentId ?? null, + sort_order: childRestoreAssignment?.sortOrder ?? childRestoreLocation?.sortOrder ?? null, + restore_parent_id: null, + restore_sort_order: null, + updated_at: ts, + }); } } return { ok: true, updated_at: ts, + restore_location: { + parent_id: restoredParentId, + sort_order: restoredSortOrder, + fallback_reason: restoreLocation.fallbackReason, + }, document: toDocumentDeltaRecord({ ...doc, deleted_at: null, deleted_by: null, - parent_id: null, + parent_id: restoredParentId, + sort_order: restoredSortOrder, access_scope: "private", updated_at: ts, }), @@ -1685,7 +1879,13 @@ export const purge = mutation({ }); export const emptyTrashByWorkspace = mutation({ - args: { workspaceId: v.string() }, + args: { + workspaceId: v.string(), + streamDeltaHint: v.optional(v.any()), + domainEventHint: v.optional(v.any()), + domainEventPlan: v.optional(v.any()), + domainEventPlans: v.optional(v.any()), + }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); diff --git a/wolai-frontend/convex/schema.ts b/wolai-frontend/convex/schema.ts index c1ec1550..4bdf9694 100644 --- a/wolai-frontend/convex/schema.ts +++ b/wolai-frontend/convex/schema.ts @@ -81,6 +81,9 @@ export default defineSchema({ // 软删除(阶段 4 先不实现垃圾桶逻辑,但字段先留好,便于后续迁移)。 deleted_at: v.union(v.string(), v.null()), deleted_by: v.union(v.string(), v.null()), + // 垃圾箱恢复位置快照:删除时记录原父节点与排序,恢复时尽量回放。 + restore_parent_id: v.optional(v.union(v.string(), v.null())), + restore_sort_order: v.optional(v.union(v.number(), v.null())), // 兼容旧逻辑:Supabase documents.mindmap_data。 mindmap_data: v.optional(v.any()), diff --git a/wolai-frontend/src/app/api/media/batch/route.test.ts b/wolai-frontend/src/app/api/media/batch/route.test.ts index c55a9ef2..c2278029 100644 --- a/wolai-frontend/src/app/api/media/batch/route.test.ts +++ b/wolai-frontend/src/app/api/media/batch/route.test.ts @@ -92,7 +92,7 @@ describe("/api/media/batch route", () => { validateOnly: false, dryRun: false, }); - mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({ + mockBuildDocumentCommandEnvelope.mockReset().mockImplementation((input: unknown) => ({ ...(input as Record), commandId: "cmd_asset_1", idempotencyKey: null, @@ -231,4 +231,108 @@ describe("/api/media/batch route", () => { ); expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled(); }); + + it("delete/restore/rename 应通过正式 tree.resource 生命周期命令执行", async () => { + const client = { + query: vi.fn(async (name: string, args: Record) => { + if (name === "mediaAssets:listByIds") { + expect(args).toEqual({ userId: "user_1", ids: ["asset_1"] }); + return [ + { + id: "asset_1", + workspace_id: "ws_1", + document_id: "doc_1", + file_name: "old.pdf", + }, + ]; + } + return null; + }), + mutation: vi.fn(), + }; + mockGetConvexAuthedHttpClient.mockResolvedValue(client); + mockResolveRustBridgeCommandPlan.mockResolvedValueOnce({ + kind: "command", + commandName: "tree.resource.archive", + commandId: "cmd_asset_archive", + functionName: "mediaAssets:patchById", + argsJson: { + domainEventPlan: { eventType: "tree.resource.archived" }, + }, + }); + mockResolveRustBridgeCommandPlan.mockResolvedValueOnce({ + kind: "command", + commandName: "tree.resource.restore", + commandId: "cmd_asset_restore", + functionName: "mediaAssets:patchById", + argsJson: { + domainEventPlan: { eventType: "tree.resource.restored" }, + }, + }); + mockResolveRustBridgeCommandPlan.mockResolvedValueOnce({ + kind: "command", + commandName: "tree.resource.rename", + commandId: "cmd_asset_rename", + functionName: "mediaAssets:patchById", + argsJson: { + domainEventPlan: { eventType: "tree.resource.renamed" }, + }, + }); + mockExecuteRustBridgeMutationTransport.mockResolvedValue({ ok: true }); + + const { POST } = await import("./route"); + for (const body of [ + { action: "delete", assetIds: ["asset_1"] }, + { action: "restore", assetIds: ["asset_1"] }, + { action: "rename", assetIds: ["asset_1"], newName: "new" }, + ]) { + const response = await POST( + new Request("http://localhost/api/media/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + expect(response.status).toBe(200); + } + + expect(mockBuildDocumentCommandEnvelope).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + name: "tree.resource.archive", + payload: { + resourceKind: "file", + assetId: "asset_1", + }, + target: { + workspaceId: "ws_1", + pageId: "doc_1", + }, + }), + ); + expect(mockBuildDocumentCommandEnvelope).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + name: "tree.resource.restore", + payload: { + resourceKind: "file", + assetId: "asset_1", + }, + }), + ); + expect(mockBuildDocumentCommandEnvelope).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + name: "tree.resource.rename", + payload: { + resourceKind: "file", + assetId: "asset_1", + newName: "new.pdf", + }, + }), + ); + expect(mockExecuteRustBridgeMutationTransport).toHaveBeenCalledTimes(3); + expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledTimes(3); + expect(client.mutation).not.toHaveBeenCalled(); + }); }); diff --git a/wolai-frontend/src/app/api/media/batch/route.ts b/wolai-frontend/src/app/api/media/batch/route.ts index 74d5b28c..65da7582 100644 --- a/wolai-frontend/src/app/api/media/batch/route.ts +++ b/wolai-frontend/src/app/api/media/batch/route.ts @@ -46,6 +46,56 @@ function sanitizeTransferredAssetForBrowser(request: Request, asset: any) { }; } +async function executeResourceLifecycleCommand(input: { + request: Request; + client: never; + asset: any; + authUserId: string; + commandName: "tree.resource.archive" | "tree.resource.restore" | "tree.resource.rename"; + payload: Record; +}) { + const workspaceId = String(input.asset?.workspace_id ?? "").trim(); + if (!workspaceId) { + throw new Error("缺少资源工作空间"); + } + const documentId = String(input.asset?.document_id ?? "").trim(); + const context = await buildDocumentBridgeContext({ + request: input.request, + workspaceId, + }); + const envelope = buildDocumentCommandEnvelope({ + name: input.commandName, + payload: input.payload, + context, + target: { + workspaceId, + pageId: documentId || undefined, + }, + reason: `media-batch ${input.commandName}`, + refs: ["file-tree-resource-command", "media-batch-compat-alias"], + }); + const plan = await resolveRustBridgeCommandPlan({ + context, + envelope, + }); + const result = await executeRustBridgeMutationTransport({ + client: input.client, + plan, + }); + try { + await recordRustBridgeCommandArtifacts({ + context, + envelope, + client: input.client, + plan, + result, + }); + } catch (error) { + console.warn("[media.batch] Rust lifecycle bridge artifacts skipped:", error); + } + return result; +} + export async function POST(request: Request) { if (isConvexEnabled()) { let auth; @@ -73,30 +123,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: "未找到附件" }, { status: 404 }); } - const nowIso = () => new Date().toISOString(); - try { switch (payload.action) { - case "delete": { - for (const a of assets) { - await client.mutation(api.mediaAssets.patchById, { - userId: auth.userId, - id: String(a.id), - patch: { deleted_at: nowIso(), deleted_by: auth.userId }, - }); - } - return NextResponse.json({ ok: true }); - } - case "restore": { - for (const a of assets) { - await client.mutation(api.mediaAssets.patchById, { - userId: auth.userId, - id: String(a.id), - patch: { deleted_at: null, deleted_by: null, purged_at: null }, - }); - } - return NextResponse.json({ ok: true }); - } case "rename": { if (payload.assetIds.length !== 1 || !payload.newName) { return NextResponse.json({ error: "重命名需要单个文件与新名称" }, { status: 400 }); @@ -107,13 +135,39 @@ export async function POST(request: Request) { const newFileName = payload.newName.includes(".") || !ext ? payload.newName : `${payload.newName}${ext}`; const safeName = newFileName.replace(/[\\/]/g, "_"); - await client.mutation(api.mediaAssets.patchById, { - userId: auth.userId, - id: String(asset.id), - patch: { file_name: safeName }, + await executeResourceLifecycleCommand({ + request, + client: client as never, + asset, + authUserId: auth.userId, + commandName: "tree.resource.rename", + payload: { + resourceKind: "file", + assetId: String(asset.id), + newName: safeName, + }, }); return NextResponse.json({ ok: true }); } + case "delete": + case "restore": { + const commandName = + payload.action === "delete" ? "tree.resource.archive" : "tree.resource.restore"; + for (const asset of assets) { + await executeResourceLifecycleCommand({ + request, + client: client as never, + asset, + authUserId: auth.userId, + commandName, + payload: { + resourceKind: "file", + assetId: String(asset.id), + }, + }); + } + return NextResponse.json({ ok: true }); + } case "copy": case "move": { if (!payload.targetDocumentId) { diff --git a/wolai-frontend/src/app/api/media/purge/route.test.ts b/wolai-frontend/src/app/api/media/purge/route.test.ts new file mode 100644 index 00000000..b71dbfe5 --- /dev/null +++ b/wolai-frontend/src/app/api/media/purge/route.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIsConvexEnabled = vi.fn(() => true); +const mockRequireAuthContext = vi.fn(); +const mockGetConvexAuthedHttpClient = vi.fn(); +const mockBuildDocumentBridgeContext = vi.fn(); +const mockBuildDocumentCommandEnvelope = vi.fn(); +const mockResolveRustBridgeCommandPlan = vi.fn(); +const mockExecuteRustBridgeMutationTransport = vi.fn(); +const mockRecordRustBridgeCommandArtifacts = vi.fn(); + +vi.mock("@/lib/convex/enabled", () => ({ + isConvexEnabled: () => mockIsConvexEnabled(), +})); + +vi.mock("@/lib/auth/authContext", () => ({ + HttpError: class HttpError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }, + requireAuthContext: () => mockRequireAuthContext(), +})); + +vi.mock("@/lib/convex/api", () => ({ + api: { + mediaAssets: { + listByIds: "mediaAssets:listByIds", + purgeById: "mediaAssets:purgeById", + }, + }, +})); + +vi.mock("@/lib/convex/server", () => ({ + getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(), +})); + +vi.mock("@/lib/documents/bridge", () => ({ + buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args), + buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args), +})); + +vi.mock("@/lib/documents/rust-runtime", () => ({ + resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args), + executeRustBridgeMutationTransport: (...args: unknown[]) => + mockExecuteRustBridgeMutationTransport(...args), + recordRustBridgeCommandArtifacts: (...args: unknown[]) => + mockRecordRustBridgeCommandArtifacts(...args), +})); + +describe("/api/media/purge route", () => { + beforeEach(() => { + vi.resetModules(); + mockIsConvexEnabled.mockReset().mockReturnValue(true); + mockRequireAuthContext.mockReset().mockResolvedValue({ userId: "user_1" }); + mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({ + requestId: "req_1", + traceId: "trace_1", + workspaceId: "ws_1", + actor: { actorType: "user", actorId: "user_1", sessionId: null }, + source: { channel: "next-route", client: "vitest" }, + tenantId: null, + deploymentId: null, + projectId: null, + authToken: null, + idempotencyKey: null, + validateOnly: false, + dryRun: false, + }); + mockBuildDocumentCommandEnvelope.mockReset().mockImplementation((input: unknown) => ({ + ...(input as Record), + commandId: "cmd_asset_purge", + idempotencyKey: null, + })); + mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({ + kind: "command", + commandName: "tree.resource.purge", + commandId: "cmd_asset_purge", + functionName: "mediaAssets:purgeById", + argsJson: { + domainEventPlan: { eventType: "tree.resource.purged" }, + }, + }); + mockExecuteRustBridgeMutationTransport.mockReset().mockResolvedValue({ ok: true, deleted: 1 }); + mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined); + }); + + it("应通过正式 tree.resource.purge 命令执行附件永久删除", async () => { + const client = { + query: vi.fn(async (name: string, args: Record) => { + expect(name).toBe("mediaAssets:listByIds"); + expect(args).toEqual({ userId: "user_1", ids: ["asset_1"] }); + return [{ id: "asset_1", workspace_id: "ws_1", document_id: "doc_1" }]; + }), + mutation: vi.fn(), + }; + mockGetConvexAuthedHttpClient.mockResolvedValue(client); + + const { POST } = await import("./route"); + const response = await POST( + new Request("http://localhost/api/media/purge", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ assetId: "asset_1" }), + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ success: true, ok: true, deleted: 1 }); + expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + name: "tree.resource.purge", + payload: { + resourceKind: "file", + assetId: "asset_1", + }, + target: { + workspaceId: "ws_1", + pageId: "doc_1", + }, + }), + ); + expect(mockExecuteRustBridgeMutationTransport).toHaveBeenCalledWith( + expect.objectContaining({ + client, + plan: expect.objectContaining({ + commandName: "tree.resource.purge", + functionName: "mediaAssets:purgeById", + }), + }), + ); + expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledTimes(1); + expect(client.mutation).not.toHaveBeenCalled(); + }); +}); diff --git a/wolai-frontend/src/app/api/media/purge/route.ts b/wolai-frontend/src/app/api/media/purge/route.ts index 2cd6aef5..a4c19ee7 100644 --- a/wolai-frontend/src/app/api/media/purge/route.ts +++ b/wolai-frontend/src/app/api/media/purge/route.ts @@ -3,6 +3,15 @@ import { isConvexEnabled } from "@/lib/convex/enabled"; import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; import { api } from "@/lib/convex/api"; import { getConvexAuthedHttpClient } from "@/lib/convex/server"; +import { + buildDocumentBridgeContext, + buildDocumentCommandEnvelope, +} from "@/lib/documents/bridge"; +import { + executeRustBridgeMutationTransport, + recordRustBridgeCommandArtifacts, + resolveRustBridgeCommandPlan, +} from "@/lib/documents/rust-runtime"; export const dynamic = "force-dynamic"; @@ -10,23 +19,6 @@ interface PurgePayload { assetId?: string; } -function resolveGraceSeconds(): number { - const raw = - process.env.DELETE_GRACE_SECONDS ?? - process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ?? - "600"; - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) { - return 600; - } - return Math.floor(parsed); -} - -function makeExpiredDeletedAt(): string { - const graceSeconds = resolveGraceSeconds(); - return new Date(Date.now() - (graceSeconds + 5) * 1000).toISOString(); -} - export async function POST(request: Request) { if (isConvexEnabled()) { let auth; @@ -45,11 +37,56 @@ export async function POST(request: Request) { } const client = await getConvexAuthedHttpClient(); - const res = await client.mutation(api.mediaAssets.purgeById, { + const assets = (await client.query(api.mediaAssets.listByIds, { userId: auth.userId, - id: assetId, - expiredDeletedAt: makeExpiredDeletedAt(), + ids: [assetId], + })) as any[]; + const asset = assets?.[0]; + if (!asset) { + return NextResponse.json({ error: "未找到附件" }, { status: 404 }); + } + const workspaceId = String(asset.workspace_id ?? "").trim(); + if (!workspaceId) { + return NextResponse.json({ error: "缺少资源工作空间" }, { status: 400 }); + } + const documentId = String(asset.document_id ?? "").trim(); + const context = await buildDocumentBridgeContext({ + request, + workspaceId, }); + const envelope = buildDocumentCommandEnvelope({ + name: "tree.resource.purge", + payload: { + resourceKind: "file", + assetId, + }, + context, + target: { + workspaceId, + pageId: documentId || undefined, + }, + reason: "media-purge tree.resource.purge", + refs: ["file-tree-resource-command", "media-purge-compat-alias"], + }); + const plan = await resolveRustBridgeCommandPlan({ + context, + envelope, + }); + const res = await executeRustBridgeMutationTransport({ + client: client as never, + plan, + }); + try { + await recordRustBridgeCommandArtifacts({ + context, + envelope, + client: client as never, + plan, + result: res, + }); + } catch (error) { + console.warn("[media.purge] Rust lifecycle bridge artifacts skipped:", error); + } return NextResponse.json({ success: true, ...(res as any) }); } diff --git a/wolai-frontend/src/app/api/tree/commands/route.test.ts b/wolai-frontend/src/app/api/tree/commands/route.test.ts index 9cdfe19f..526df238 100644 --- a/wolai-frontend/src/app/api/tree/commands/route.test.ts +++ b/wolai-frontend/src/app/api/tree/commands/route.test.ts @@ -1529,10 +1529,15 @@ describe("/api/tree/commands route", () => { it("restore action 走 tree.node.restore,并附带 upsert_document delta", async () => { const client = { mutation: vi.fn(), - query: vi.fn(async () => ({ - id: "doc_restore_1", - workspace_id: "ws_1", - })), + query: vi.fn(async (name: string, args: { id?: string; includeDeleted?: boolean }) => { + if (name === "documents:getMeta" && args.id === "doc_restore_1" && args.includeDeleted === true) { + return { + id: "doc_restore_1", + workspace_id: "ws_1", + }; + } + return null; + }), }; mockGetAuthedConvexClient.mockResolvedValue({ auth: { @@ -1634,6 +1639,10 @@ describe("/api/tree/commands route", () => { ); expect(response.status).toBe(200); + expect(client.query).toHaveBeenCalledWith("documents:getMeta", { + id: "doc_restore_1", + includeDeleted: true, + }); expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith( expect.objectContaining({ name: "tree.node.restore", diff --git a/wolai-frontend/src/app/api/tree/commands/route.ts b/wolai-frontend/src/app/api/tree/commands/route.ts index 991068e1..551e6cb4 100644 --- a/wolai-frontend/src/app/api/tree/commands/route.ts +++ b/wolai-frontend/src/app/api/tree/commands/route.ts @@ -317,7 +317,7 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) { async function handleMove(request: Request, payload: TreeCommandPayload) { const { auth, client } = await getAuthedConvexClient(); const documentId = assertDocumentId(payload.documentId ?? null); - const sourceDoc = await client.query(api.documents.getMeta, { id: documentId }); + const sourceDoc = await client.query(api.documents.getMeta, { id: documentId, includeDeleted: true }); if (!sourceDoc) { return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 }); } @@ -483,7 +483,7 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) { async function handleRestore(request: Request, payload: TreeCommandPayload) { const { client } = await getAuthedConvexClient(); const documentId = assertDocumentId(payload.documentId ?? null); - const sourceDoc = await client.query(api.documents.getMeta, { id: documentId }); + const sourceDoc = await client.query(api.documents.getMeta, { id: documentId, includeDeleted: true }); if (!sourceDoc) { return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 }); } diff --git a/wolai-frontend/src/components/sidebar/sidebar-context-menu-source.test.ts b/wolai-frontend/src/components/sidebar/sidebar-context-menu-source.test.ts new file mode 100644 index 00000000..a76d30e1 --- /dev/null +++ b/wolai-frontend/src/components/sidebar/sidebar-context-menu-source.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx"); + +describe("sidebar VSCode explorer context menu source", () => { + it("文件树/页面右键菜单应暴露 VSCode Explorer 常见项或禁用态", () => { + const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8"); + const contextMenuStart = source.indexOf("function ContextMenu("); + expect(contextMenuStart).toBeGreaterThanOrEqual(0); + const contextMenuSource = source.slice(contextMenuStart); + + for (const label of ["New File", "New Folder", "Paste Into", "Refresh", "Collapse All", "Copy Path", "Copy Relative Path", "Reveal"]) { + expect(contextMenuSource).toContain(label); + } + expect(contextMenuSource).toContain("onCopyRelativePath"); + expect(contextMenuSource).toContain("disabled title="); + expect(contextMenuSource).not.toContain("window.location.reload"); + }); +}); diff --git a/wolai-frontend/src/components/sidebar/sidebar-dnd-source.test.ts b/wolai-frontend/src/components/sidebar/sidebar-dnd-source.test.ts new file mode 100644 index 00000000..8696c599 --- /dev/null +++ b/wolai-frontend/src/components/sidebar/sidebar-dnd-source.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const DOM_HOST_SOURCE = path.join(process.cwd(), "src/components/sidebar/tree-shell-dom-host.tsx"); +const IFRAME_HOST_SOURCE = path.join(process.cwd(), "src/components/sidebar/tree-shell-iframe-host.tsx"); +const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx"); +const SSR_LAYOUT_SOURCE = path.join(process.cwd(), "..", "rust/crates/mnote-web/src/ssr/pages/layout.rs"); + +describe("sidebar filetree DnD modifier source", () => { + it("copy modifier 应同时支持 Alt / Ctrl / Meta", () => { + const domHost = fs.readFileSync(DOM_HOST_SOURCE, "utf8"); + const iframeHost = fs.readFileSync(IFRAME_HOST_SOURCE, "utf8"); + const ssrLayout = fs.readFileSync(SSR_LAYOUT_SOURCE, "utf8"); + + expect(domHost).toContain('event.altKey || event.ctrlKey || event.metaKey ? "copy" : "move"'); + expect(domHost).toContain("const copy = Boolean(event.altKey || event.ctrlKey || event.metaKey)"); + expect(iframeHost).toContain("copy: event?.altKey === true || event?.ctrlKey === true || event?.metaKey === true"); + expect(ssrLayout).toContain("var copyModifier = event.altKey || event.ctrlKey || event.metaKey"); + expect(ssrLayout).toContain("copy: event.altKey === true || event.ctrlKey === true || event.metaKey === true"); + }); + + it("命名冲突 preflight 必须确认后才执行 drop", () => { + const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8"); + expect(source).toContain("function confirmFileTreeDropConflicts"); + expect(source).toContain("plan.requiresConfirmation"); + expect(source).toContain("目标位置已有同名对象,是否继续移动/复制?"); + expect(source).toContain("if (!confirmFileTreeDropConflicts(dropPlan))"); + }); +}); diff --git a/wolai-frontend/src/components/sidebar/sidebar-paste-preflight-source.test.ts b/wolai-frontend/src/components/sidebar/sidebar-paste-preflight-source.test.ts index f1120473..1e6a61fe 100644 --- a/wolai-frontend/src/components/sidebar/sidebar-paste-preflight-source.test.ts +++ b/wolai-frontend/src/components/sidebar/sidebar-paste-preflight-source.test.ts @@ -9,7 +9,7 @@ describe("sidebar file tree paste preflight source", () => { const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8"); const preflightIndex = source.indexOf("preflightFileTreePaste("); const rustBranchStart = source.lastIndexOf("if (isRustFamilyTreeRenderer) {", preflightIndex); - const legacyBranchStart = source.indexOf("const targetDocId = inferPasteTargetDocId", preflightIndex); + const legacyBranchStart = source.indexOf("const targetDocId =", preflightIndex); expect(preflightIndex).toBeGreaterThanOrEqual(0); expect(rustBranchStart).toBeGreaterThanOrEqual(0); @@ -24,4 +24,47 @@ describe("sidebar file tree paste preflight source", () => { expect(rustPasteBranch).not.toContain("copyableAssetIds"); expect(source).not.toContain("getOrderedFileTreeShellRows"); }); + + it("rust_family cut paste 应走 Rust drop preflight 并执行 move,不走 copy paste", () => { + const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8"); + const cutBranchStart = source.indexOf('if (payload.action === "cut") {'); + const copyBranchStart = source.indexOf("let pastePlan;", cutBranchStart); + expect(cutBranchStart).toBeGreaterThanOrEqual(0); + expect(copyBranchStart).toBeGreaterThan(cutBranchStart); + const cutBranch = source.slice(cutBranchStart, copyBranchStart); + + expect(cutBranch).toContain("preflightFileTreeInternalDrop("); + expect(cutBranch).toContain("buildFileTreeShellInternalDropPreflightPayload("); + expect(cutBranch).toContain("copy: false"); + expect(cutBranch).toContain("moveDocumentCommand("); + expect(cutBranch).toContain("moveFileTreeResourceAssets("); + expect(cutBranch).toContain("clearTreePaneClipboardPayload()"); + expect(cutBranch).not.toContain("copyTreeCommand("); + expect(cutBranch).not.toContain("copyFileTreeResourceAssets("); + }); + + it("右键 Paste Into 应使用菜单节点作为 action target 并复用 Rust paste preflight", () => { + const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8"); + + expect(source).toContain("onPasteInto: (node: SidebarTreeNode) => void;"); + expect(source).toContain("onPasteInto={(node) => void executeFileTreePaste(node.id)}"); + + const contextMenuStart = source.indexOf("function ContextMenu("); + expect(contextMenuStart).toBeGreaterThanOrEqual(0); + const contextMenuSource = source.slice(contextMenuStart); + expect(contextMenuSource).toContain("onPasteInto"); + expect(contextMenuSource).toContain("handleAction(() => onPasteInto(node))"); + expect(contextMenuSource).not.toContain("右键 Paste Into 待接 selection target"); + + const pasteHelperStart = source.indexOf("const executeFileTreePaste = useCallback("); + expect(pasteHelperStart).toBeGreaterThanOrEqual(0); + const pasteHelperEnd = source.indexOf("useEffect(() => {", pasteHelperStart); + expect(pasteHelperEnd).toBeGreaterThan(pasteHelperStart); + const pasteHelperSource = source.slice(pasteHelperStart, pasteHelperEnd); + + expect(pasteHelperSource).toContain("targetDocumentIdOverride"); + expect(pasteHelperSource).toContain("targetDocumentId: targetDocumentIdOverride ?? null"); + expect(pasteHelperSource).toContain("preflightFileTreePaste("); + expect(pasteHelperSource).toContain("preflightFileTreeInternalDrop("); + }); }); diff --git a/wolai-frontend/src/components/sidebar/sidebar.tsx b/wolai-frontend/src/components/sidebar/sidebar.tsx index 93d539c0..8bd525af 100644 --- a/wolai-frontend/src/components/sidebar/sidebar.tsx +++ b/wolai-frontend/src/components/sidebar/sidebar.tsx @@ -85,6 +85,7 @@ import { renameFileTreeResourceAsset, restoreFileTreeResourceAssets, uploadFileTreeResourceAsset, + type FileTreeInternalDropPreflightPlan, } from "@/lib/file-tree/resource-command-client"; import { buildParentById } from "@/lib/file-tree/dnd"; import { isRealFileAsset } from "@/lib/file-tree/asset"; @@ -105,6 +106,7 @@ import { } from "@/lib/file-tree/selection-source"; import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog"; import { + clearTreePaneClipboardPayload, computeTreePaneDeleteTargets, inferPasteTargetDocId, isTextInputTarget, @@ -169,6 +171,18 @@ interface ContextMenuState { y: number; } +function confirmFileTreeDropConflicts(plan: FileTreeInternalDropPreflightPlan): boolean { + if (!plan.requiresConfirmation || !plan.conflicts || plan.conflicts.length === 0) { + return true; + } + const titles = plan.conflicts + .map((conflict) => `- ${conflict.title}`) + .slice(0, 6) + .join("\n"); + const suffix = plan.conflicts.length > 6 ? "\n..." : ""; + return window.confirm(`目标位置已有同名对象,是否继续移动/复制?\n${titles}${suffix}`); +} + export function Sidebar({ initialData, sidebarData, sidebarQuery, treeStream }: SidebarProps) { if (sidebarQuery && treeStream) { return ( @@ -840,6 +854,39 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar [router, setOpen], ); + const revealRestoredDocument = useCallback( + (documentId: string, parentId?: string | null) => { + pageTreeFocusedDocumentIdRef.current = documentId; + setFilter(""); + setExpanded((prev) => { + const next = new Set(prev); + const documentById = new Map(documents.map((doc) => [doc.id, doc])); + let cursor: string | null = parentId ?? null; + while (cursor) { + next.add(cursor); + cursor = documentById.get(cursor)?.parent_id ?? null; + } + return next; + }); + if (typeof window === "undefined") { + return; + } + window.requestAnimationFrame(() => { + const escapeSelector = + typeof window.CSS?.escape === "function" + ? window.CSS.escape.bind(window.CSS) + : (value: string) => value.replace(/["\\]/g, "\\$&"); + const selector = `[data-node-id="${escapeSelector(documentId)}"], [data-row-id="index:${escapeSelector(documentId)}"]`; + const row = document.querySelector(selector); + if (row instanceof HTMLElement) { + row.scrollIntoView({ block: "nearest" }); + row.focus({ preventScroll: true }); + } + }); + }, + [documents], + ); + const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => { const url = buildDocumentUrl(node.id); const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url; @@ -855,6 +902,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar await copyText(node.id, "页面 ID 已复制"); }, []); + const handleCopyPagePath = useCallback( + async (node: SidebarTreeNode, relative = false) => { + const documentById = new Map(documents.map((doc) => [doc.id, doc])); + const parts: string[] = []; + let cursor: string | null = node.id; + while (cursor) { + const doc = documentById.get(cursor); + if (!doc) break; + parts.unshift(doc.title || "无标题"); + cursor = doc.parent_id ?? null; + } + const path = parts.join("/") || (node.title || "无标题"); + await copyText(relative ? path : `/${path}`, relative ? "相对路径已复制" : "页面路径已复制"); + }, + [documents], + ); + const handleDuplicateDocument = useCallback( async (node: SidebarTreeNode) => { const response = await fetch("/api/documents/duplicate", { @@ -1324,18 +1388,309 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar void refreshTree(); }, [moveLocalNode, nodeById, refreshTree, sidebarData.activeWorkspaceId]); + const executeFileTreePaste = useCallback( + async (targetDocumentIdOverride: string | null = null) => { + const payload = await readTreePaneClipboardPayload(); + if (!payload || payload.rowIds.length === 0) { + if (targetDocumentIdOverride) { + setTimeout(() => window.alert("剪贴板没有可粘贴的文件树内容"), 0); + } + return; + } + + const focusedRowId = targetDocumentIdOverride ? null : resourceSelection.focusedRowId; + + if (isRustFamilyTreeRenderer) { + if (payload.action === "cut") { + let dropPlan; + try { + dropPlan = await preflightFileTreeInternalDrop( + buildFileTreeShellInternalDropPreflightPayload({ + workspaceId: sidebarData.activeWorkspaceId ?? null, + copy: false, + targetDocumentId: targetDocumentIdOverride ?? null, + targetRowId: focusedRowId, + focusedRowId, + activeDocId: activeId || null, + rowIds: payload.rowIds, + rowById: resourceShellRowById, + parentById: docParentById, + }), + ); + } catch (error) { + const message = error instanceof Error ? error.message : "文件树移动预检失败"; + setTimeout(() => window.alert(message), 0); + return; + } + if (!confirmFileTreeDropConflicts(dropPlan)) { + return; + } + + const { + targetDocumentId: targetDocId, + documentTransferPlan, + resourceTransferPlan, + sourceAssetDocumentIds, + } = dropPlan; + + if (documentTransferPlan && documentTransferPlan.topLevelDocumentIds.length > 0) { + const topLevelDocIds = documentTransferPlan.topLevelDocumentIds; + const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0; + setTree((prev) => { + let next = prev; + topLevelDocIds.forEach((id, offset) => { + next = moveLocalNode(next, id, documentTransferPlan.targetParentId, baseIndex + offset); + }); + return next; + }); + setDocuments((prev) => { + let next = prev; + topLevelDocIds.forEach((id, offset) => { + next = moveSidebarDocumentRecord(next, id, documentTransferPlan.targetParentId, baseIndex + offset); + }); + return next; + }); + setExpanded((prev) => new Set(prev).add(documentTransferPlan.targetParentId)); + + try { + for (let i = 0; i < topLevelDocIds.length; i += 1) { + await moveDocumentCommand({ + documentId: topLevelDocIds[i], + parentId: documentTransferPlan.targetParentId, + position: baseIndex + i, + }); + } + } catch (error) { + await refreshTree(); + const message = error instanceof Error ? error.message : "移动页面失败"; + setTimeout(() => window.alert(message), 0); + return; + } + await refreshTree(); + emitDocumentsChanged(targetDocId); + } + + if (resourceTransferPlan && resourceTransferPlan.assetIds.length > 0) { + try { + await moveFileTreeResourceAssets({ + assetIds: resourceTransferPlan.assetIds, + targetDocumentId: resourceTransferPlan.targetDocumentId, + targetSubPath: resourceTransferPlan.targetSubPath, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "移动附件失败"; + setTimeout(() => window.alert(message), 0); + return; + } + await sidebarQuery.refetch(); + sourceAssetDocumentIds.forEach((id) => emitAssetsChanged(id)); + emitAssetsChanged(targetDocId); + } + + await clearTreePaneClipboardPayload(); + return; + } + + let pastePlan; + try { + pastePlan = await preflightFileTreePaste( + buildFileTreeShellPastePreflightPayload({ + workspaceId: sidebarData.activeWorkspaceId ?? null, + targetDocumentId: targetDocumentIdOverride ?? null, + focusedRowId, + activeDocId: activeId || null, + rowIds: payload.rowIds, + rowById: resourceShellRowById, + }), + ); + } catch (error) { + const message = error instanceof Error ? error.message : "文件树粘贴预检失败"; + setTimeout(() => window.alert(message), 0); + return; + } + + if (pastePlan.docItems.length > 0) { + try { + await copyTreeCommand({ + items: pastePlan.docItems, + targetParentId: pastePlan.targetDocumentId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "粘贴页面失败"; + setTimeout(() => window.alert(message), 0); + return; + } + await sidebarQuery.refetch(); + emitDocumentsChanged(pastePlan.targetDocumentId); + } + + if (pastePlan.resourceTransferPlan && pastePlan.resourceTransferPlan.assetIds.length > 0) { + try { + await copyFileTreeResourceAssets({ + assetIds: pastePlan.resourceTransferPlan.assetIds, + targetDocumentId: pastePlan.resourceTransferPlan.targetDocumentId, + targetSubPath: pastePlan.resourceTransferPlan.targetSubPath, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "粘贴附件失败"; + setTimeout(() => window.alert(message), 0); + return; + } + await sidebarQuery.refetch(); + emitAssetsChanged(pastePlan.resourceTransferPlan.targetDocumentId); + } + return; + } + + const targetDocId = + targetDocumentIdOverride ?? + inferPasteTargetDocId({ + focusedRowId: resourceSelection.focusedRowId, + rowById: resourceRowById, + activeDocId: activeId || null, + }); + if (!targetDocId) { + setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0); + return; + } + + const docItemsMap = new Map(); + const movableDocIds = new Set(); + const copyableAssetIds: string[] = []; + + const rows = payload.rowIds + .map((rowId) => resourceRowById.get(rowId as any)) + .filter(Boolean) as TreePaneRow[]; + + rows.forEach((row) => { + if (row.kind === "doc") { + docItemsMap.set(row.docId, true); + movableDocIds.add(row.docId); + } else if (row.kind === "index") { + if (!docItemsMap.has(row.docId)) { + docItemsMap.set(row.docId, false); + } + movableDocIds.add(row.docId); + } + }); + + rows + .filter((row): row is Extract => row.kind === "asset") + .map((row) => row.asset) + .filter((asset) => isRealFileAsset(asset)) + .forEach((asset) => { + copyableAssetIds.push(asset.id); + }); + + if (payload.action === "cut") { + if (movableDocIds.size > 0) { + try { + let offset = 0; + for (const documentId of movableDocIds) { + await moveDocumentCommand({ + documentId, + parentId: targetDocId, + position: (childrenCountByParentId.get(targetDocId) ?? 0) + offset, + }); + offset += 1; + } + } catch (error) { + const message = error instanceof Error ? error.message : "移动页面失败"; + setTimeout(() => window.alert(message), 0); + return; + } + await refreshTree(); + emitDocumentsChanged(targetDocId); + } + + if (copyableAssetIds.length > 0) { + try { + await moveFileTreeResourceAssets({ + assetIds: copyableAssetIds, + targetDocumentId: targetDocId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "移动附件失败"; + setTimeout(() => window.alert(message), 0); + return; + } + await sidebarQuery.refetch(); + emitAssetsChanged(targetDocId); + } else if (movableDocIds.size === 0) { + setTimeout(() => window.alert("没有可移动的真实文件(mindmap.json 等虚拟附件暂不支持)"), 0); + return; + } + + await clearTreePaneClipboardPayload(); + return; + } + + if (docItemsMap.size > 0) { + try { + await copyTreeCommand({ + items: Array.from(docItemsMap.entries()).map(([documentId, recursive]) => ({ + documentId, + recursive, + })), + targetParentId: targetDocId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "粘贴页面失败"; + setTimeout(() => window.alert(message), 0); + return; + } + await sidebarQuery.refetch(); + emitDocumentsChanged(targetDocId); + } + + if (copyableAssetIds.length > 0) { + try { + await copyFileTreeResourceAssets({ + assetIds: copyableAssetIds, + targetDocumentId: targetDocId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "粘贴附件失败"; + setTimeout(() => window.alert(message), 0); + return; + } + await sidebarQuery.refetch(); + emitAssetsChanged(targetDocId); + } else if (docItemsMap.size === 0) { + setTimeout(() => window.alert("没有可粘贴的真实文件(mindmap.json 等虚拟附件暂不支持)"), 0); + } + }, + [ + activeId, + childrenCountByParentId, + docParentById, + isRustFamilyTreeRenderer, + moveLocalNode, + refreshTree, + resourceRowById, + resourceSelection.focusedRowId, + resourceShellRowById, + sidebarData.activeWorkspaceId, + sidebarQuery, + ], + ); + useEffect(() => { const handler = async (event: KeyboardEvent) => { const isCopy = (event.ctrlKey || event.metaKey) && !event.altKey && (event.key === "c" || event.key === "C"); + const isCut = + (event.ctrlKey || event.metaKey) && + !event.altKey && + (event.key === "x" || event.key === "X"); const isPaste = (event.ctrlKey || event.metaKey) && !event.altKey && (event.key === "v" || event.key === "V"); - if (!isCopy && !isPaste) { + if (!isCopy && !isCut && !isPaste) { return; } @@ -1349,7 +1704,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar return; } - if (isCopy) { + if (isCopy || isCut) { if (resourceSelection.selectedRowIds.size === 0) { return; } @@ -1360,7 +1715,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar await writeTreePaneClipboardPayload({ type: "mnote-file-tree", version: 1, - action: "copy", + action: isCut ? "cut" : "copy", rowIds: orderedRowIds, }); return; @@ -1368,148 +1723,17 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar if (isPaste) { event.preventDefault(); - const payload = await readTreePaneClipboardPayload(); - if (!payload || payload.rowIds.length === 0) { - return; - } - - if (isRustFamilyTreeRenderer) { - let pastePlan; - try { - pastePlan = await preflightFileTreePaste( - buildFileTreeShellPastePreflightPayload({ - workspaceId: sidebarData.activeWorkspaceId ?? null, - targetDocumentId: null, - focusedRowId: resourceSelection.focusedRowId, - activeDocId: activeId || null, - rowIds: payload.rowIds, - rowById: resourceShellRowById, - }), - ); - } catch (error) { - const message = error instanceof Error ? error.message : "文件树粘贴预检失败"; - setTimeout(() => window.alert(message), 0); - return; - } - - if (pastePlan.docItems.length > 0) { - try { - await copyTreeCommand({ - items: pastePlan.docItems, - targetParentId: pastePlan.targetDocumentId, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "粘贴页面失败"; - setTimeout(() => window.alert(message), 0); - return; - } - await sidebarQuery.refetch(); - emitDocumentsChanged(pastePlan.targetDocumentId); - } - - if (pastePlan.resourceTransferPlan && pastePlan.resourceTransferPlan.assetIds.length > 0) { - try { - await copyFileTreeResourceAssets({ - assetIds: pastePlan.resourceTransferPlan.assetIds, - targetDocumentId: pastePlan.resourceTransferPlan.targetDocumentId, - targetSubPath: pastePlan.resourceTransferPlan.targetSubPath, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "粘贴附件失败"; - setTimeout(() => window.alert(message), 0); - return; - } - await sidebarQuery.refetch(); - emitAssetsChanged(pastePlan.resourceTransferPlan.targetDocumentId); - } - return; - } - - const targetDocId = inferPasteTargetDocId({ - focusedRowId: resourceSelection.focusedRowId, - rowById: resourceRowById, - activeDocId: activeId || null, - }); - if (!targetDocId) { - setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0); - return; - } - - const docItemsMap = new Map(); - const copyableAssetIds: string[] = []; - - const rows = payload.rowIds - .map((rowId) => resourceRowById.get(rowId as any)) - .filter(Boolean) as TreePaneRow[]; - - rows.forEach((row) => { - if (row.kind === "doc") { - docItemsMap.set(row.docId, true); - } else if (row.kind === "index") { - if (!docItemsMap.has(row.docId)) { - docItemsMap.set(row.docId, false); - } - } - }); - - rows - .filter((row): row is Extract => row.kind === "asset") - .map((row) => row.asset) - .filter((asset) => isRealFileAsset(asset)) - .forEach((asset) => { - copyableAssetIds.push(asset.id); - }); - - if (docItemsMap.size > 0) { - try { - await copyTreeCommand({ - items: Array.from(docItemsMap.entries()).map(([documentId, recursive]) => ({ - documentId, - recursive, - })), - targetParentId: targetDocId, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "粘贴页面失败"; - setTimeout(() => window.alert(message), 0); - return; - } - await sidebarQuery.refetch(); - emitDocumentsChanged(targetDocId); - } - - if (copyableAssetIds.length > 0) { - try { - await copyFileTreeResourceAssets({ - assetIds: copyableAssetIds, - targetDocumentId: targetDocId, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "粘贴附件失败"; - setTimeout(() => window.alert(message), 0); - return; - } - await sidebarQuery.refetch(); - emitAssetsChanged(targetDocId); - } else if (docItemsMap.size === 0) { - setTimeout(() => window.alert("没有可粘贴的真实文件(mindmap.json 等虚拟附件暂不支持)"), 0); - } + await executeFileTreePaste(null); + return; } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [ - activeId, - isRustFamilyTreeRenderer, + executeFileTreePaste, resourceSelectionVisibleRowIds, - resourceShellRowById, - resourceRowById, - resourceSelection.focusedRowId, resourceSelection.selectedRowIds, - resourceShellVisibleRowIds, - sidebarData.activeWorkspaceId, - sidebarQuery, ]); const handleCopyAssetLink = useCallback( @@ -2180,6 +2404,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar setTimeout(() => window.alert(message), 0); return; } + if (!confirmFileTreeDropConflicts(dropPlan)) { + return; + } const { targetDocumentId: targetDocId, @@ -2373,13 +2600,17 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar if (!confirmTrashAction("确认恢复该页面吗?")) { return; } - await restoreDocumentCommand({ + const restoreResult = await restoreDocumentCommand({ documentId, workspaceId: sidebarData.activeWorkspaceId ?? null, }); await Promise.all([sidebarQuery.refetch(), refreshTree()]); + revealRestoredDocument(documentId, restoreResult.parentId ?? null); + if (restoreResult.fallbackReason === "parent_missing_or_deleted") { + window.alert("原父页面已不存在,已恢复到根目录"); + } }, - [confirmTrashAction, refreshTree, sidebarData.activeWorkspaceId, sidebarQuery], + [confirmTrashAction, refreshTree, revealRestoredDocument, sidebarData.activeWorkspaceId, sidebarQuery], ); const handlePurgeFromTrash = useCallback( @@ -3145,10 +3376,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar onCopyLink={handleCopyLink} onCopyReference={handleCopyReference} onCopyId={handleCopyId} + onCopyPath={handleCopyPagePath} + onCopyRelativePath={(node) => handleCopyPagePath(node, true)} + onPasteInto={(node) => void executeFileTreePaste(node.id)} onDuplicate={handleDuplicateDocument} onRename={() => void handleRename(contextMenu.node.id, contextMenu.node.title)} onCreateChild={() => void handleCreate(contextMenu.node.id)} onConvertChild={() => void handleConvertToChild(contextMenu.node.id)} + onRefresh={() => void refreshTree()} + onCollapseAll={() => setExpanded(new Set())} + onReveal={() => revealRestoredDocument(contextMenu.node.id, contextMenu.node.parent_id ?? null)} onDelete={() => void handleDeleteFromContextMenuNode(contextMenu.node)} /> )} @@ -3447,10 +3684,16 @@ interface ContextMenuProps { onCopyLink: (node: SidebarTreeNode, withTitle?: boolean) => void; onCopyReference: (node: SidebarTreeNode, mode: "inline" | "embed") => void; onCopyId: (node: SidebarTreeNode) => void; + onCopyPath: (node: SidebarTreeNode) => void; + onCopyRelativePath: (node: SidebarTreeNode) => void; + onPasteInto: (node: SidebarTreeNode) => void; onDuplicate: (node: SidebarTreeNode) => void; onRename: () => void; onCreateChild: () => void; onConvertChild: () => void; + onRefresh: () => void; + onCollapseAll: () => void; + onReveal: () => void; onDelete: () => void; } @@ -3464,10 +3707,16 @@ function ContextMenu({ onCopyLink, onCopyReference, onCopyId, + onCopyPath, + onCopyRelativePath, + onPasteInto, onDuplicate, onRename, onCreateChild, onConvertChild, + onRefresh, + onCollapseAll, + onReveal, onDelete, }: ContextMenuProps) { const { node } = contextMenu; @@ -3501,6 +3750,8 @@ function ContextMenu({ const buttonClass = "flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-gray-700 hover:bg-[#f5f7fb]"; + const disabledButtonClass = + "flex w-full cursor-not-allowed items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-gray-400"; return (
拷贝副本 + + +
+ + + + + +
+ {renamingFileTreeRowId === rowId ? ( + + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onChange={(event) => { + setFileTreeRenameDraft(event.target.value); + setFileTreeRenameError(""); + }} + onBlur={(event) => void commitFileTreeRename(item, event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + void commitFileTreeRename(item, event.currentTarget.value); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelFileTreeRename(); + } + }} + /> + {fileTreeRenameError ? ( + + {fileTreeRenameError} + + ) : null} + + ) : ( + + )}