feat(tree): checkpoint resource lifecycle work
提交当前顶层 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。
This commit is contained in:
@@ -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` 通过。
|
||||
+4
@@ -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:<documentId>`;本文中的 `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`
|
||||
|
||||
@@ -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.<timestamp>@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=<isolated>`,确认页面区与资源区出现本轮数据。
|
||||
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。
|
||||
@@ -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` 失败,验证页面成功进垃圾箱、失败附件仍保留,并出现“部分对象删除失败”摘要。
|
||||
@@ -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”验收。
|
||||
@@ -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。
|
||||
@@ -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 规则。
|
||||
@@ -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` 收口。
|
||||
@@ -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 需重启后生效。
|
||||
@@ -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`
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 的实时刷新矩阵。
|
||||
@@ -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。
|
||||
@@ -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:<documentId>`,但标题改为 `{pageTitle}.md`。
|
||||
- mindmap / attachment / table 等资源继续挂在 `doc:<documentId>` 下。
|
||||
- 3000 主文档壳本地 create apply 同步改为只插入 `doc:<documentId>`,标题显示为 `{title}.md`,不再插入 `index:<documentId>`。
|
||||
- File Tree 当前页面 selected / focused 默认改为 `doc:<documentId>`。
|
||||
- `task169` 的“回到页面正文”验证从点击 `index:<documentId>` 改为点击 `doc:<documentId>`,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:<documentId>` 行。
|
||||
- `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:<documentId>` + `{title}.md` 作为 Convex 页面正文 row;如果文档讨论历史 `index.md`,必须明确它是兼容身份或历史记录,不是默认可见行。
|
||||
|
||||
仍保留的后续增强:
|
||||
|
||||
- 删除进垃圾箱、restore 后 reveal、双浏览器 resync、搜索过滤下 reveal 等更宽场景继续由 `4-28`、`4-34` 与后续 tree domain checklist 覆盖。
|
||||
+4
@@ -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:<documentId>` + `{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`
|
||||
|
||||
@@ -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:<documentId>` + `{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。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: 新页面
|
||||
---
|
||||
# 新页面
|
||||
@@ -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: 新页面
|
||||
---
|
||||
# 新页面
|
||||
@@ -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: 新页面
|
||||
---
|
||||
# 新页面
|
||||
@@ -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: 新页面
|
||||
---
|
||||
# 新页面
|
||||
@@ -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=<active>` 返回 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-<timestamp>`。
|
||||
|
||||
必须覆盖:
|
||||
|
||||
- 登录 `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。
|
||||
@@ -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: 新页面
|
||||
---
|
||||
# 新页面
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<DocumentEmptyTrashRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -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()
|
||||
|
||||
@@ -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<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<RootEntryQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
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! {
|
||||
<crate::ssr::pages::layout::PageLayout
|
||||
current_nav="trash"
|
||||
sidebar_tree_html={sidebar_tree_html.clone()}
|
||||
workspace_name={workspace_name.clone()}
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
topbar_title={"垃圾箱".to_string()}
|
||||
>
|
||||
<div inner_html={trash_workbench_html}></div>
|
||||
</crate::ssr::pages::layout::PageLayout>
|
||||
});
|
||||
|
||||
let mut response = Html(format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>垃圾箱</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
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#"<div class="mnote-trash-empty">暂无已删除资源</div>"#.to_string()
|
||||
} else {
|
||||
resource_rows
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<section class="mnote-trash-workbench" data-testid="mnote-trash-workbench" data-workspace-id="{workspace_id}" data-trash-fetch-path="/trash">
|
||||
<header class="mnote-trash-header">
|
||||
<h1>垃圾箱</h1>
|
||||
<p>默认删除的页面会先进入这里;彻底删除会永久移除。</p>
|
||||
<p class="mnote-trash-status" data-trash-status role="status" aria-live="polite"></p>
|
||||
</header>
|
||||
<section class="mnote-trash-section" data-testid="mnote-trash-documents">
|
||||
<div class="mnote-trash-section-title">
|
||||
<h2>页面 <span data-trash-document-count>{document_count}</span></h2>
|
||||
<button type="button" data-trash-action="empty-documents"{empty_disabled}>清空页面垃圾箱</button>
|
||||
</div>
|
||||
{document_rows}
|
||||
</section>
|
||||
<section class="mnote-trash-section" data-testid="mnote-trash-resources">
|
||||
<div class="mnote-trash-section-title">
|
||||
<h2>资源 <span data-trash-resource-count>{resource_count}</span></h2>
|
||||
<button type="button" data-trash-action="empty-resources"{resource_empty_disabled}>清空资源垃圾箱</button>
|
||||
</div>
|
||||
{resource_body}
|
||||
<p class="mnote-trash-note">资源恢复、彻底删除和清空当前走 Rust 兼容入口;正式 tree.resource.* 命令仍在后续阶段收口。</p>
|
||||
</section>
|
||||
</section>
|
||||
<script>
|
||||
(function() {{
|
||||
var root = document.querySelector('[data-testid="mnote-trash-workbench"]');
|
||||
if (!root) return;
|
||||
var workspaceId = root.getAttribute('data-workspace-id') || '';
|
||||
function setStatus(message, failed) {{
|
||||
var status = root.querySelector('[data-trash-status]');
|
||||
if (!status) return;
|
||||
status.textContent = message || '';
|
||||
status.setAttribute('data-type', failed ? 'error' : 'success');
|
||||
}}
|
||||
function refreshTrashWorkbenchFromServer(reason) {{
|
||||
if (!workspaceId) return Promise.resolve(false);
|
||||
var url = new URL(root.getAttribute('data-trash-fetch-path') || '/trash', window.location.origin);
|
||||
url.searchParams.set('workspaceId', workspaceId);
|
||||
url.searchParams.set('liveRefreshReason', reason || 'tree-event');
|
||||
return fetch(url.toString(), {{
|
||||
method: 'GET',
|
||||
headers: {{ 'x-mnote-trash-live-refresh': '1' }}
|
||||
}}).then(function(response) {{
|
||||
return response.text().then(function(html) {{
|
||||
if (!response.ok) throw new Error('trash_live_refresh_failed_' + response.status);
|
||||
var parsed = new DOMParser().parseFromString(html, 'text/html');
|
||||
var nextRoot = parsed.querySelector('[data-testid="mnote-trash-workbench"]');
|
||||
if (!nextRoot) throw new Error('trash_live_refresh_missing_workbench');
|
||||
root.innerHTML = nextRoot.innerHTML;
|
||||
root.setAttribute('data-live-refresh-reason', reason || 'tree-event');
|
||||
root.setAttribute('data-live-refresh-at', String(Date.now()));
|
||||
return true;
|
||||
}});
|
||||
}}).catch(function(error) {{
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
return false;
|
||||
}});
|
||||
}}
|
||||
function startTrashWorkbenchLiveRefresh() {{
|
||||
if (!workspaceId || !('EventSource' in window)) return;
|
||||
var url = new URL('/api/tree/events', window.location.origin);
|
||||
url.searchParams.set('workspaceId', workspaceId);
|
||||
url.searchParams.set('pollMs', '1000');
|
||||
var source = new EventSource(url.toString());
|
||||
root.__mnoteTrashEventSource = source;
|
||||
['snapshot', 'delta', 'resync'].forEach(function(kind) {{
|
||||
source.addEventListener(kind, function() {{
|
||||
refreshTrashWorkbenchFromServer(kind);
|
||||
}});
|
||||
}});
|
||||
source.onerror = function() {{
|
||||
root.setAttribute('data-live-refresh-error', 'eventsource_error');
|
||||
}};
|
||||
}}
|
||||
function readJson(response) {{
|
||||
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
|
||||
if (!response.ok) throw new Error((payload && payload.message) || 'trash_request_failed_' + response.status);
|
||||
return payload;
|
||||
}});
|
||||
}}
|
||||
function decrement(selector) {{
|
||||
var count = root.querySelector(selector);
|
||||
if (!count) return;
|
||||
var nextCount = Math.max(0, Number(count.textContent || '0') - 1);
|
||||
count.textContent = String(nextCount);
|
||||
}}
|
||||
function postJson(url, body) {{
|
||||
return fetch(url, {{
|
||||
method: 'POST',
|
||||
headers: {{ 'content-type': 'application/json' }},
|
||||
body: JSON.stringify(body || {{}})
|
||||
}}).then(readJson);
|
||||
}}
|
||||
function patchJson(url, body) {{
|
||||
return fetch(url, {{
|
||||
method: 'PATCH',
|
||||
headers: {{ 'content-type': 'application/json' }},
|
||||
body: JSON.stringify(body || {{}})
|
||||
}}).then(readJson);
|
||||
}}
|
||||
function runResourceAction(kind, action, resourceId, documentId) {{
|
||||
if (kind === 'media') {{
|
||||
return action === 'restore'
|
||||
? postJson('/api/media/batch', {{ action: 'restore', assetIds: [resourceId] }})
|
||||
: postJson('/api/media/purge', {{ assetId: resourceId }});
|
||||
}}
|
||||
if (kind === 'mindmap') {{
|
||||
if (!documentId) return Promise.reject(new Error('mindmap_document_id_required'));
|
||||
return patchJson('/api/mindmap/' + encodeURIComponent(documentId) + '/' + encodeURIComponent(resourceId), {{ action: action }});
|
||||
}}
|
||||
if (kind === 'table') {{
|
||||
return postJson(action === 'restore' ? '/api/tables/restore' : '/api/tables/purge', {{ tableId: resourceId }});
|
||||
}}
|
||||
return Promise.reject(new Error('resource_kind_unsupported'));
|
||||
}}
|
||||
root.addEventListener('click', function(event) {{
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-trash-action]') : null;
|
||||
if (!button || button.disabled) return;
|
||||
var action = button.getAttribute('data-trash-action');
|
||||
var documentId = button.getAttribute('data-document-id') || '';
|
||||
if (action === 'empty-documents') {{
|
||||
if (!window.confirm('清空页面垃圾箱后无法恢复,确定继续吗?')) return;
|
||||
button.disabled = true;
|
||||
fetch('/api/documents/empty-trash', {{
|
||||
method: 'POST',
|
||||
headers: {{ 'content-type': 'application/json' }},
|
||||
body: JSON.stringify({{ workspaceId: workspaceId }})
|
||||
}}).then(function(response) {{
|
||||
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
|
||||
if (!response.ok) throw new Error((payload && payload.message) || 'trash_empty_failed_' + response.status);
|
||||
root.querySelectorAll('[data-trash-row="document"]').forEach(function(row) {{ row.remove(); }});
|
||||
var count = root.querySelector('[data-trash-document-count]');
|
||||
if (count) count.textContent = '0';
|
||||
setStatus('已清空页面垃圾箱', false);
|
||||
}});
|
||||
}}).catch(function(error) {{
|
||||
button.disabled = false;
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
}});
|
||||
return;
|
||||
}}
|
||||
if (action === 'empty-resources') {{
|
||||
if (!window.confirm('清空资源垃圾箱后无法恢复,确定继续吗?')) return;
|
||||
button.disabled = true;
|
||||
Promise.all([
|
||||
postJson('/api/media/empty-trash', {{ workspaceId: workspaceId }}),
|
||||
postJson('/api/mindmap-trash/empty', {{ workspaceId: workspaceId }}),
|
||||
postJson('/api/tables/empty-trash', {{ workspaceId: workspaceId }})
|
||||
]).then(function() {{
|
||||
root.querySelectorAll('[data-trash-row="resource"]').forEach(function(row) {{ row.remove(); }});
|
||||
var count = root.querySelector('[data-trash-resource-count]');
|
||||
if (count) count.textContent = '0';
|
||||
setStatus('已清空资源垃圾箱', false);
|
||||
}}).catch(function(error) {{
|
||||
button.disabled = false;
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
}});
|
||||
return;
|
||||
}}
|
||||
if (action === 'resource-restore' || action === 'resource-purge') {{
|
||||
var resourceId = button.getAttribute('data-resource-id') || '';
|
||||
var kind = button.getAttribute('data-resource-kind') || '';
|
||||
var resourceDocumentId = button.getAttribute('data-document-id') || '';
|
||||
if (!resourceId) return;
|
||||
if (action === 'resource-purge' && !window.confirm('彻底删除资源后无法恢复,确定继续吗?')) return;
|
||||
button.disabled = true;
|
||||
runResourceAction(kind, action === 'resource-restore' ? 'restore' : 'purge', resourceId, resourceDocumentId).then(function() {{
|
||||
var row = button.closest('[data-trash-row]');
|
||||
if (row) row.remove();
|
||||
decrement('[data-trash-resource-count]');
|
||||
setStatus(action === 'resource-restore' ? '已恢复资源' : '已彻底删除资源', false);
|
||||
}}).catch(function(error) {{
|
||||
button.disabled = false;
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
}});
|
||||
return;
|
||||
}}
|
||||
if (!documentId) return;
|
||||
if (action === 'purge' && !window.confirm('彻底删除后无法恢复,确定继续吗?')) return;
|
||||
button.disabled = true;
|
||||
fetch('/api/tree/commands', {{
|
||||
method: 'POST',
|
||||
headers: {{ 'content-type': 'application/json' }},
|
||||
body: JSON.stringify({{
|
||||
action: action,
|
||||
workspaceId: workspaceId,
|
||||
documentId: documentId
|
||||
}})
|
||||
}}).then(function(response) {{
|
||||
return response.json().catch(function() {{ return null; }}).then(function(payload) {{
|
||||
if (!response.ok) throw new Error((payload && payload.message) || 'trash_action_failed_' + response.status);
|
||||
var row = button.closest('[data-trash-row]');
|
||||
if (row) row.remove();
|
||||
decrement('[data-trash-document-count]');
|
||||
setStatus(action === 'restore' ? '已恢复页面' : '已彻底删除页面', false);
|
||||
}});
|
||||
}}).catch(function(error) {{
|
||||
button.disabled = false;
|
||||
setStatus(error && error.message ? error.message : String(error), true);
|
||||
}});
|
||||
}});
|
||||
startTrashWorkbenchLiveRefresh();
|
||||
}})();
|
||||
</script>"#,
|
||||
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#"<div class="mnote-trash-empty">暂无已删除页面</div>"#.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#"<article class="mnote-trash-row" data-trash-row="document" data-document-id="{id}">
|
||||
<div class="mnote-trash-row-main">
|
||||
<a href="/documents/{id}?workspaceId={workspace_id}" class="mnote-trash-title">{title}</a>
|
||||
<span class="mnote-trash-meta">{deleted_at}</span>
|
||||
</div>
|
||||
<div class="mnote-trash-actions">
|
||||
<button type="button" data-trash-action="restore" data-document-id="{id}">恢复</button>
|
||||
<button type="button" data-trash-action="purge" data-document-id="{id}">彻底删除</button>
|
||||
</div>
|
||||
</article>"#,
|
||||
id = escape_html(id),
|
||||
workspace_id = escape_html(workspace_id),
|
||||
title = escape_html(title),
|
||||
deleted_at = escape_html(deleted_at),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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#"<article class="mnote-trash-row" data-trash-row="resource" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">
|
||||
<div class="mnote-trash-row-main">
|
||||
<span class="mnote-trash-kind">{label}</span>
|
||||
<span class="mnote-trash-title">{title}</span>
|
||||
<span class="mnote-trash-meta">{deleted_at}</span>
|
||||
</div>
|
||||
<div class="mnote-trash-actions">
|
||||
<button type="button" data-trash-action="resource-restore" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">恢复</button>
|
||||
<button type="button" data-trash-action="resource-purge" data-resource-kind="{kind}" data-resource-id="{id}" data-document-id="{document_id}">彻底删除</button>
|
||||
</div>
|
||||
</article>"#,
|
||||
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::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
pub async fn legacy_next_proxy(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -1185,6 +1608,53 @@ mod tests {
|
||||
assert!(!html.contains(r#"<a href="/documents">文档</a>"#));
|
||||
}
|
||||
|
||||
#[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(
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
|
||||
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]
|
||||
|
||||
@@ -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<u128>,
|
||||
}
|
||||
|
||||
#[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<Value, WebError> {
|
||||
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<Value, WebError> {
|
||||
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<Value, WebError> {
|
||||
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<Value, WebError> {
|
||||
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<Value, We
|
||||
document_id.to_string(),
|
||||
LocalTrashEntry {
|
||||
document_id: document_id.to_string(),
|
||||
resource_kind: "markdown".to_string(),
|
||||
resource_scope: "local_folder".to_string(),
|
||||
original_file_path: markdown_file.relative_path.clone(),
|
||||
trashed_file_path: trash_relative_path.clone(),
|
||||
trash_entry_id: document_id.to_string(),
|
||||
original_relative_path: markdown_file.relative_path,
|
||||
trash_relative_path: trash_relative_path.clone(),
|
||||
deleted_at_ms: now_ms(),
|
||||
archived_at: now_ms(),
|
||||
purged_at: None,
|
||||
},
|
||||
);
|
||||
write_page_ids_metadata(root, &metadata.page_ids)?;
|
||||
@@ -971,6 +1025,68 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
||||
}))
|
||||
}
|
||||
|
||||
fn trash_local_raw_file(root: &Path, entry_id: &str, file: &Path) -> Result<Value, WebError> {
|
||||
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<Value, WebError> {
|
||||
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<Value,
|
||||
}))
|
||||
}
|
||||
|
||||
fn restore_local_raw_file(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
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<Value, WebError> {
|
||||
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<Value, We
|
||||
}))
|
||||
}
|
||||
|
||||
fn purge_local_raw_file(root: &Path, entry_id: &str) -> Result<Value, WebError> {
|
||||
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<Option<PathB
|
||||
}
|
||||
}
|
||||
|
||||
fn local_file_trash_entry_id(relative_path: &str) -> 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<String> {
|
||||
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<String, WebError> {
|
||||
let root_path = parse_file_root_uri(root_uri)?;
|
||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||
|
||||
@@ -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<String>), 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}"),
|
||||
|
||||
@@ -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"));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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!({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
@@ -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#"<a href="{}" class="wolai-footer-entry">{}{}</a>"#,
|
||||
r#"<a href="{}" class="wolai-footer-entry"{}>{}{}</a>"#,
|
||||
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#"<span hidden data-mnote-workspace-shell-degraded="true" data-mnote-workspace-shell-degraded-reason="{}"></span>"#,
|
||||
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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":{}}
|
||||
{"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":{}}
|
||||
@@ -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, "工作区首页");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
+2
@@ -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;
|
||||
|
||||
@@ -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<string>;
|
||||
}): DocumentRestoreLocation {
|
||||
const requestedParentId = input.doc.restore_parent_id ?? input.doc.parent_id ?? null;
|
||||
const restoringIds = input.restoringIds ?? new Set<string>();
|
||||
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<string, DocumentRestoreLocation>;
|
||||
restoringIds?: Set<string>;
|
||||
}): DocumentRestoreOrderAssignment[] {
|
||||
const restoringIds = input.restoringIds ?? new Set(input.restoringDocs.map((doc) => doc.id));
|
||||
const affectedParentIds = new Set<string | null>();
|
||||
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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<string, DocumentRestoreLocation>();
|
||||
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);
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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<string, unknown>),
|
||||
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<string, unknown>) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}) {
|
||||
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) {
|
||||
|
||||
@@ -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<string, unknown>),
|
||||
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<string, unknown>) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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))");
|
||||
});
|
||||
});
|
||||
@@ -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(");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, boolean>();
|
||||
const movableDocIds = new Set<string>();
|
||||
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<TreePaneRow, { kind: "asset" }> => 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<string, boolean>();
|
||||
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<TreePaneRow, { kind: "asset" }> => 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 (
|
||||
<div
|
||||
@@ -3594,6 +3845,39 @@ function ContextMenu({
|
||||
<Copy className="h-4 w-4 text-gray-500" />
|
||||
<span>拷贝副本</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => handleAction(() => onCopyPath(node))}>
|
||||
<Copy className="h-4 w-4 text-gray-500" />
|
||||
<span>Copy Path</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => handleAction(() => onCopyRelativePath(node))}>
|
||||
<Copy className="h-4 w-4 text-gray-500" />
|
||||
<span>Copy Relative Path</span>
|
||||
</button>
|
||||
<div className="my-1 border-t border-[#f2f2f2]" />
|
||||
<button type="button" className={buttonClass} onClick={() => handleAction(onCreateChild)}>
|
||||
<Plus className="h-4 w-4 text-gray-500" />
|
||||
<span>New File</span>
|
||||
</button>
|
||||
<button type="button" className={disabledButtonClass} disabled title="页面树暂不区分文件夹,待 Resource Tree folder 合同收口">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>New Folder</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => handleAction(() => onPasteInto(node))}>
|
||||
<Copy className="h-4 w-4 text-gray-500" />
|
||||
<span>Paste Into</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => handleAction(onRefresh)}>
|
||||
<SearchIcon className="h-4 w-4 text-gray-500" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => handleAction(onCollapseAll)}>
|
||||
<ChevronRight className="h-4 w-4 text-gray-500" />
|
||||
<span>Collapse All</span>
|
||||
</button>
|
||||
<button type="button" className={buttonClass} onClick={() => handleAction(onReveal)}>
|
||||
<SearchIcon className="h-4 w-4 text-gray-500" />
|
||||
<span>Reveal</span>
|
||||
</button>
|
||||
<div className="my-1 border-t border-[#f2f2f2]" />
|
||||
<button type="button" className={buttonClass} onClick={() => handleAction(onRename)}>
|
||||
<Edit3 className="h-4 w-4 text-gray-500" />
|
||||
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
export type { FileTreeRow as TreePaneRow } from "@/lib/file-tree/types";
|
||||
export { computeFileTreeDeleteTargets as computeTreePaneDeleteTargets } from "@/lib/file-tree/delete";
|
||||
export {
|
||||
clearFileTreeClipboardPayload as clearTreePaneClipboardPayload,
|
||||
inferPasteTargetDocId,
|
||||
isTextInputTarget,
|
||||
readFileTreeClipboardPayload as readTreePaneClipboardPayload,
|
||||
|
||||
@@ -153,6 +153,36 @@ const normalizeString = (value: unknown, defaultValue = "") => {
|
||||
return trimmed || defaultValue;
|
||||
};
|
||||
|
||||
const cssEscapeIdent = (value: string) => {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(value);
|
||||
}
|
||||
return value.replace(/["\\]/g, "\\$&");
|
||||
};
|
||||
|
||||
const FILETREE_RENAME_ILLEGAL_PATTERN = /[\\/:*?"<>|\x00-\x1F]/;
|
||||
|
||||
const normalizeRenameComparable = (value: string) =>
|
||||
value.trim().normalize("NFC").toLocaleLowerCase("zh-CN");
|
||||
|
||||
const extensionFromFileName = (fileName: string) => {
|
||||
const trimmed = fileName.trim();
|
||||
const dotIndex = trimmed.lastIndexOf(".");
|
||||
if (dotIndex <= 0 || dotIndex === trimmed.length - 1) {
|
||||
return "";
|
||||
}
|
||||
return trimmed.slice(dotIndex);
|
||||
};
|
||||
|
||||
const normalizeFileTreeRenameTitle = (item: TreeShellDomProjectionItem, nextDraft: string) => {
|
||||
const title = nextDraft.trim();
|
||||
if (toShellRowKind(item) !== "asset" || !title || title.includes(".")) {
|
||||
return title;
|
||||
}
|
||||
const extension = extensionFromFileName(item.title || "");
|
||||
return extension ? `${title}${extension}` : title;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
@@ -358,6 +388,9 @@ export function TreeShellRustDomShellHost({
|
||||
const [pickerState, setPickerState] = useState<PickerRuntimeState>(() => ({
|
||||
activeItemKey: activePickerItemKey,
|
||||
}));
|
||||
const [renamingFileTreeRowId, setRenamingFileTreeRowId] = useState<string | null>(null);
|
||||
const [fileTreeRenameDraft, setFileTreeRenameDraft] = useState("");
|
||||
const [fileTreeRenameError, setFileTreeRenameError] = useState("");
|
||||
const pickerCommandSeqRef = useRef<number | null>(null);
|
||||
const fileTreeStateRef = useRef(fileTreeState);
|
||||
const fileTreeSelectionVersionRef = useRef(0);
|
||||
@@ -818,6 +851,124 @@ export function TreeShellRustDomShellHost({
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingFileTreeRowId) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
`.tree-rename-input[data-rename-id="${cssEscapeIdent(renamingFileTreeRowId)}"]`,
|
||||
);
|
||||
input?.focus();
|
||||
input?.select();
|
||||
});
|
||||
}, [renamingFileTreeRowId]);
|
||||
|
||||
const beginFileTreeRename = useCallback((item: TreeShellDomProjectionItem) => {
|
||||
const rowId = toRowId(item);
|
||||
const rowKind = toShellRowKind(item);
|
||||
if (rowKind !== "doc" && rowKind !== "index" && rowKind !== "asset") {
|
||||
return;
|
||||
}
|
||||
setRenamingFileTreeRowId(rowId);
|
||||
setFileTreeRenameDraft(item.title || "无标题");
|
||||
setFileTreeRenameError("");
|
||||
}, []);
|
||||
|
||||
const cancelFileTreeRename = useCallback(() => {
|
||||
setRenamingFileTreeRowId(null);
|
||||
setFileTreeRenameDraft("");
|
||||
setFileTreeRenameError("");
|
||||
}, []);
|
||||
|
||||
const validateFileTreeRename = useCallback(
|
||||
(item: TreeShellDomProjectionItem, nextDraft: string) => {
|
||||
const title = normalizeFileTreeRenameTitle(item, nextDraft);
|
||||
if (!title) {
|
||||
return "名称不能为空";
|
||||
}
|
||||
if (FILETREE_RENAME_ILLEGAL_PATTERN.test(title)) {
|
||||
return '名称不能包含 / \\ : * ? " < > |';
|
||||
}
|
||||
const rowId = toRowId(item);
|
||||
const comparableTitle = normalizeRenameComparable(title);
|
||||
const hasDuplicateSibling = fileTreeItems.some((candidate) => {
|
||||
if (toRowId(candidate) === rowId) return false;
|
||||
if ((candidate.parentNodeId ?? null) !== (item.parentNodeId ?? null)) return false;
|
||||
return normalizeRenameComparable(candidate.title || "无标题") === comparableTitle;
|
||||
});
|
||||
if (hasDuplicateSibling) {
|
||||
return "同级已存在同名项目";
|
||||
}
|
||||
return "";
|
||||
},
|
||||
[fileTreeItems],
|
||||
);
|
||||
|
||||
const commitFileTreeRename = useCallback(
|
||||
async (item: TreeShellDomProjectionItem, nextDraft = fileTreeRenameDraft) => {
|
||||
const rowId = toRowId(item);
|
||||
if (renamingFileTreeRowId !== rowId) return;
|
||||
const title = normalizeFileTreeRenameTitle(item, nextDraft);
|
||||
const validationError = validateFileTreeRename(item, nextDraft);
|
||||
if (validationError) {
|
||||
setFileTreeRenameError(validationError);
|
||||
return;
|
||||
}
|
||||
if (title === (item.title || "无标题")) {
|
||||
cancelFileTreeRename();
|
||||
return;
|
||||
}
|
||||
setFileTreeRenameError("");
|
||||
|
||||
const rowKind = toShellRowKind(item);
|
||||
const documentId = toDocumentId(item);
|
||||
const assetId = toAssetId(item);
|
||||
try {
|
||||
if ((rowKind === "doc" || rowKind === "index") && documentId) {
|
||||
const result = await runTreeCommand({ action: "rename", documentId, title });
|
||||
const commandResult = readTreeCommandResult(result);
|
||||
onTreeMutation?.({
|
||||
type: "tree.node.renamed",
|
||||
documentId,
|
||||
title: normalizeString(commandResult.title, title),
|
||||
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
|
||||
updatedAt: normalizeString(commandResult.updatedAt) || null,
|
||||
execution: commandResult.execution ?? null,
|
||||
});
|
||||
} else if (rowKind === "asset" && assetId) {
|
||||
const response = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ action: "rename", assetIds: [assetId], newName: title }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload && typeof payload.error === "string" ? payload.error : "重命名附件失败");
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("wolai:assets-changed", {
|
||||
detail: { docId: documentId || undefined, assetIds: [assetId] },
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "重命名失败");
|
||||
return;
|
||||
} finally {
|
||||
cancelFileTreeRename();
|
||||
}
|
||||
},
|
||||
[
|
||||
cancelFileTreeRename,
|
||||
fileTreeRenameDraft,
|
||||
onTreeMutation,
|
||||
renamingFileTreeRowId,
|
||||
runTreeCommand,
|
||||
validateFileTreeRename,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
const dispatchPageCommandEvents = useCallback(
|
||||
async (commandEvents?: Array<Record<string, unknown>>) => {
|
||||
for (const event of commandEvents ?? []) {
|
||||
@@ -1057,13 +1208,17 @@ export function TreeShellRustDomShellHost({
|
||||
|
||||
const handleFileTreeKeyDown = useCallback(
|
||||
async (item: TreeShellDomProjectionItem, event: ReactKeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "F2") {
|
||||
event.preventDefault();
|
||||
beginFileTreeRename(item);
|
||||
return;
|
||||
}
|
||||
const actionByKey: Record<string, Record<string, unknown> | undefined> = {
|
||||
ArrowDown: { kind: "focusNext" },
|
||||
ArrowUp: { kind: "focusPrevious" },
|
||||
Home: { kind: "focusFirst" },
|
||||
End: { kind: "focusLast" },
|
||||
Enter: { kind: "openFocused" },
|
||||
F2: { kind: "beginRenameFocused" },
|
||||
Delete: { kind: "deleteSelection" },
|
||||
Backspace: { kind: "deleteSelection" },
|
||||
Escape: { kind: "escape" },
|
||||
@@ -1096,7 +1251,7 @@ export function TreeShellRustDomShellHost({
|
||||
rowKind: toShellRowKind(item),
|
||||
});
|
||||
},
|
||||
[applyFileTreeHostEvents, onFileTreeDeleteSelection, readFileTreeState, reduceFileTreeAction],
|
||||
[applyFileTreeHostEvents, beginFileTreeRename, onFileTreeDeleteSelection, readFileTreeState, reduceFileTreeAction],
|
||||
);
|
||||
|
||||
const handleFileTreeDrop = useCallback(
|
||||
@@ -1114,7 +1269,7 @@ export function TreeShellRustDomShellHost({
|
||||
}
|
||||
const rowIds = readFiletreeDragRowIds(event.dataTransfer);
|
||||
if (rowIds.length === 0) return;
|
||||
const copy = Boolean(event.altKey);
|
||||
const copy = Boolean(event.altKey || event.ctrlKey || event.metaKey);
|
||||
const { result } = await reduceFileTreeAction({ kind: "dispatchInternalDrop", targetRowId: rowId, rowIds, copy });
|
||||
applyFileTreeHostEvents(result?.hostEvents, {
|
||||
rowId,
|
||||
@@ -1276,15 +1431,58 @@ export function TreeShellRustDomShellHost({
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = event.altKey ? "copy" : "move";
|
||||
event.dataTransfer.dropEffect = event.altKey || event.ctrlKey || event.metaKey ? "copy" : "move";
|
||||
void reduceFileTreeAction({ kind: "updateDropTarget", rowId });
|
||||
}}
|
||||
onDrop={(event) => void handleFileTreeDrop(item, event)}
|
||||
>
|
||||
<span className="tree-spacer h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<button type="button" className="tree-link min-w-0 flex-1 truncate text-left" onClick={() => void handleFileTreeOpen(item)}>
|
||||
{item.title || "无标题"}
|
||||
</button>
|
||||
{renamingFileTreeRowId === rowId ? (
|
||||
<span className="min-w-0 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
className={cn(
|
||||
"tree-rename-input w-full min-w-0 rounded border bg-white px-1 text-sm text-gray-800 outline-none",
|
||||
fileTreeRenameError ? "border-red-400" : "border-[#93c5fd]",
|
||||
)}
|
||||
data-rename-id={rowId}
|
||||
value={fileTreeRenameDraft}
|
||||
aria-label={`重命名 ${item.title || "无标题"}`}
|
||||
aria-invalid={fileTreeRenameError ? "true" : "false"}
|
||||
aria-describedby={fileTreeRenameError ? `tree-rename-error-${rowId}` : undefined}
|
||||
onClick={(event) => 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 ? (
|
||||
<span
|
||||
id={`tree-rename-error-${rowId}`}
|
||||
data-testid="tree-rename-validation"
|
||||
className="mt-0.5 block truncate text-xs text-red-600"
|
||||
>
|
||||
{fileTreeRenameError}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="tree-link min-w-0 flex-1 truncate text-left" onClick={() => void handleFileTreeOpen(item)}>
|
||||
{item.title || "无标题"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="filetree-action-menu"
|
||||
|
||||
@@ -122,6 +122,27 @@ describe("tree-shell-host", () => {
|
||||
iconHint: "page",
|
||||
},
|
||||
},
|
||||
{
|
||||
projectionKind: "file_tree",
|
||||
rowId: "asset:asset_pdf",
|
||||
rowKind: "asset",
|
||||
nodeId: "asset_pdf",
|
||||
parentNodeId: "doc_2",
|
||||
title: "附件.pdf",
|
||||
depth: 1,
|
||||
childCount: 0,
|
||||
position: 3,
|
||||
expandedByDefault: false,
|
||||
iconHint: "pdf",
|
||||
capabilities: ["select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "pdf",
|
||||
documentId: "doc_2",
|
||||
assetId: "asset_pdf",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "pdf",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function queryFileTreeRow(rowId: string) {
|
||||
@@ -142,6 +163,7 @@ describe("tree-shell-host", () => {
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => void;
|
||||
onTreeMutation?: (payload: any) => void;
|
||||
} = {}) {
|
||||
act(() => {
|
||||
root.render(
|
||||
@@ -152,6 +174,7 @@ describe("tree-shell-host", () => {
|
||||
activeDocumentId={input.activeDocumentId ?? null}
|
||||
inlineFileTreeItems={fileTreeItems}
|
||||
onFileTreeDeleteSelection={input.onFileTreeDeleteSelection}
|
||||
onTreeMutation={input.onTreeMutation}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -367,4 +390,141 @@ describe("tree-shell-host", () => {
|
||||
|
||||
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
|
||||
});
|
||||
|
||||
it("F2 应进入 filetree 行内重命名,并用 tree.node.rename 提交页面标题", async () => {
|
||||
const handleTreeMutation = vi.fn();
|
||||
renderDomHost({ onTreeMutation: handleTreeMutation });
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
expect(row2).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "F2" }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
const input = container.querySelector('.tree-rename-input[data-rename-id="index:doc_2"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
act(() => {
|
||||
input!.value = "Doc 2 Renamed";
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const renameRequest = pendingRuntimeRequests.find(
|
||||
(entry) => (entry.request as { action?: unknown }).action === "rename",
|
||||
);
|
||||
expect(renameRequest?.request).toMatchObject({
|
||||
action: "rename",
|
||||
documentId: "doc_2",
|
||||
title: "Doc 2 Renamed",
|
||||
workspaceId: "ws_1",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renameRequest?.resolve({
|
||||
result: {
|
||||
documentId: "doc_2",
|
||||
title: "Doc 2 Renamed",
|
||||
workspaceId: "ws_1",
|
||||
updatedAt: "2026-05-15T00:00:00Z",
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(handleTreeMutation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "tree.node.renamed",
|
||||
documentId: "doc_2",
|
||||
title: "Doc 2 Renamed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("F2 行内重命名应在输入框内阻断空名、非法字符和同级重名", async () => {
|
||||
renderDomHost();
|
||||
|
||||
const row2 = queryFileTreeRow("index:doc_2");
|
||||
expect(row2).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
row2?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "F2" }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
const input = container.querySelector('.tree-rename-input[data-rename-id="index:doc_2"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
input!.value = " ";
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.querySelector('[data-testid="tree-rename-validation"]')?.textContent).toContain("名称不能为空");
|
||||
expect(input?.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(pendingRuntimeRequests.some((entry) => (entry.request as { action?: unknown }).action === "rename")).toBe(false);
|
||||
|
||||
act(() => {
|
||||
input!.value = "非法/名称";
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.querySelector('[data-testid="tree-rename-validation"]')?.textContent).toContain("不能包含");
|
||||
expect(pendingRuntimeRequests.some((entry) => (entry.request as { action?: unknown }).action === "rename")).toBe(false);
|
||||
|
||||
act(() => {
|
||||
input!.value = "Doc 1 索引";
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.querySelector('[data-testid="tree-rename-validation"]')?.textContent).toContain("同级已存在");
|
||||
expect(pendingRuntimeRequests.some((entry) => (entry.request as { action?: unknown }).action === "rename")).toBe(false);
|
||||
});
|
||||
|
||||
it("F2 重命名附件未输入扩展名时应保留原扩展名", async () => {
|
||||
renderDomHost();
|
||||
|
||||
const assetRow = queryFileTreeRow("asset:asset_pdf");
|
||||
expect(assetRow).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
assetRow?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "F2" }));
|
||||
});
|
||||
await flushRuntimeDispatch();
|
||||
|
||||
const input = container.querySelector('.tree-rename-input[data-rename-id="asset:asset_pdf"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
input!.value = "附件新名";
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
input!.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const renameRequest = pendingRuntimeRequests.find((entry) => {
|
||||
const request = entry.request as { action?: unknown; assetIds?: unknown };
|
||||
return request.action === "rename" && Array.isArray(request.assetIds);
|
||||
});
|
||||
expect(renameRequest?.request).toMatchObject({
|
||||
action: "rename",
|
||||
assetIds: ["asset_pdf"],
|
||||
newName: "附件新名.pdf",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2545,7 +2545,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
kind: "dispatch_internal_drop",
|
||||
targetRowId: normalizeText(target?.rowId) || null,
|
||||
rowIds: internalRowIds,
|
||||
copy: event?.altKey === true,
|
||||
copy: event?.altKey === true || event?.ctrlKey === true || event?.metaKey === true,
|
||||
};
|
||||
const fallbackHostEvent = hasExternalFiles
|
||||
? {
|
||||
@@ -2558,7 +2558,7 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
|
||||
kind: "internalDrop",
|
||||
target,
|
||||
rowIds: internalRowIds,
|
||||
copy: event?.altKey === true,
|
||||
copy: event?.altKey === true || event?.ctrlKey === true || event?.metaKey === true,
|
||||
runtimeRequired: true,
|
||||
};
|
||||
const runtimeState = readFileTreeRuntimeState();
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDocumentRestoreOrderAssignments,
|
||||
buildDocumentTrashLocationPatch,
|
||||
resolveDocumentRestoreLocation,
|
||||
} from "../../../convex/documents";
|
||||
|
||||
const baseDoc = {
|
||||
user_id: "user_1",
|
||||
workspace_id: "ws_1",
|
||||
deleted_at: null,
|
||||
};
|
||||
|
||||
describe("document trash restore location", () => {
|
||||
it("删除时应记录原父节点与排序位置", () => {
|
||||
expect(buildDocumentTrashLocationPatch({ parent_id: "parent_1", sort_order: 3 })).toEqual({
|
||||
restore_parent_id: "parent_1",
|
||||
restore_sort_order: 3,
|
||||
});
|
||||
expect(buildDocumentTrashLocationPatch({ parent_id: null, sort_order: null })).toEqual({
|
||||
restore_parent_id: null,
|
||||
restore_sort_order: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("父节点仍存在时应恢复到原父节点与原排序", () => {
|
||||
const doc = {
|
||||
...baseDoc,
|
||||
id: "doc_1",
|
||||
parent_id: "parent_1",
|
||||
sort_order: 1,
|
||||
deleted_at: "2026-05-15T00:00:00Z",
|
||||
restore_parent_id: "parent_1",
|
||||
restore_sort_order: 1,
|
||||
};
|
||||
const location = resolveDocumentRestoreLocation({
|
||||
doc,
|
||||
ownedDocs: [
|
||||
doc,
|
||||
{ ...baseDoc, id: "parent_1", parent_id: null, sort_order: 0 },
|
||||
{ ...baseDoc, id: "sibling_1", parent_id: "parent_1", sort_order: 0 },
|
||||
{ ...baseDoc, id: "sibling_2", parent_id: "parent_1", sort_order: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(location).toEqual({
|
||||
parentId: "parent_1",
|
||||
sortOrder: 1,
|
||||
fallbackReason: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("父节点已不存在或仍在垃圾箱时应显式 fallback 到根目录", () => {
|
||||
const doc = {
|
||||
...baseDoc,
|
||||
id: "doc_1",
|
||||
parent_id: "parent_1",
|
||||
sort_order: 5,
|
||||
deleted_at: "2026-05-15T00:00:00Z",
|
||||
restore_parent_id: "parent_1",
|
||||
restore_sort_order: 5,
|
||||
};
|
||||
const location = resolveDocumentRestoreLocation({
|
||||
doc,
|
||||
ownedDocs: [
|
||||
doc,
|
||||
{
|
||||
...baseDoc,
|
||||
id: "parent_1",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
deleted_at: "2026-05-15T00:00:00Z",
|
||||
},
|
||||
{ ...baseDoc, id: "root_1", parent_id: null, sort_order: 0 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(location).toEqual({
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
fallbackReason: "parent_missing_or_deleted",
|
||||
});
|
||||
});
|
||||
|
||||
it("随同恢复的父子树应允许子节点回到正在恢复的父节点下", () => {
|
||||
const parent = {
|
||||
...baseDoc,
|
||||
id: "parent_1",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
deleted_at: "2026-05-15T00:00:00Z",
|
||||
restore_parent_id: null,
|
||||
restore_sort_order: 0,
|
||||
};
|
||||
const child = {
|
||||
...baseDoc,
|
||||
id: "child_1",
|
||||
parent_id: "parent_1",
|
||||
sort_order: 0,
|
||||
deleted_at: "2026-05-15T00:00:00Z",
|
||||
restore_parent_id: "parent_1",
|
||||
restore_sort_order: 0,
|
||||
};
|
||||
|
||||
const location = resolveDocumentRestoreLocation({
|
||||
doc: child,
|
||||
ownedDocs: [parent, child],
|
||||
restoringIds: new Set(["parent_1", "child_1"]),
|
||||
});
|
||||
|
||||
expect(location).toEqual({
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
fallbackReason: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("恢复到已被占用排序位时应重排兄弟节点,避免 sort_order 重复", () => {
|
||||
const restoring = {
|
||||
...baseDoc,
|
||||
id: "doc_1",
|
||||
parent_id: "parent_1",
|
||||
sort_order: 1,
|
||||
deleted_at: "2026-05-15T00:00:00Z",
|
||||
restore_parent_id: "parent_1",
|
||||
restore_sort_order: 1,
|
||||
};
|
||||
const siblingBefore = {
|
||||
...baseDoc,
|
||||
id: "sibling_0",
|
||||
parent_id: "parent_1",
|
||||
sort_order: 0,
|
||||
};
|
||||
const siblingAtOldSlot = {
|
||||
...baseDoc,
|
||||
id: "sibling_1",
|
||||
parent_id: "parent_1",
|
||||
sort_order: 1,
|
||||
};
|
||||
const locationsById = new Map([
|
||||
[
|
||||
restoring.id,
|
||||
{
|
||||
parentId: "parent_1",
|
||||
sortOrder: 1,
|
||||
fallbackReason: "none" as const,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildDocumentRestoreOrderAssignments({
|
||||
restoringDocs: [restoring],
|
||||
ownedDocs: [restoring, siblingBefore, siblingAtOldSlot],
|
||||
locationsById,
|
||||
restoringIds: new Set([restoring.id]),
|
||||
}).map((item) => ({
|
||||
id: item.id,
|
||||
sortOrder: item.sortOrder,
|
||||
isRestored: item.isRestored,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "sibling_0", sortOrder: 0, isRestored: false },
|
||||
{ id: "doc_1", sortOrder: 1, isRestored: true },
|
||||
{ id: "sibling_1", sortOrder: 2, isRestored: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,8 @@ vi.mock("@/lib/convex/api", () => ({
|
||||
mediaAssets: {
|
||||
batchCopy: "mediaAssets.batchCopy",
|
||||
batchMove: "mediaAssets.batchMove",
|
||||
patchById: "mediaAssets.patchById",
|
||||
purgeById: "mediaAssets.purgeById",
|
||||
},
|
||||
mindmaps: {
|
||||
get: "mindmaps.get",
|
||||
@@ -530,6 +532,122 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("tree.resource 生命周期命令应映射到媒体资源 transport", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
const basePlan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "tree.resource.archive",
|
||||
commandId: "cmd_asset_archive",
|
||||
functionName: "mediaAssets:patchById",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
userId: "user_1",
|
||||
id: "asset_1",
|
||||
resourceKind: "file",
|
||||
resourceLifecyclePlan: {
|
||||
action: "archive",
|
||||
resourceKind: "file",
|
||||
assetId: "asset_1",
|
||||
documentId: null,
|
||||
mindmapId: null,
|
||||
tableId: null,
|
||||
newName: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await executeRustBridgeMutationTransport({
|
||||
client: { mutation } as unknown as ConvexHttpClient,
|
||||
plan: basePlan,
|
||||
});
|
||||
|
||||
await executeRustBridgeMutationTransport({
|
||||
client: { mutation } as unknown as ConvexHttpClient,
|
||||
plan: {
|
||||
...basePlan,
|
||||
commandName: "tree.resource.restore",
|
||||
commandId: "cmd_asset_restore",
|
||||
argsJson: {
|
||||
...basePlan.argsJson,
|
||||
resourceLifecyclePlan: {
|
||||
...(basePlan.argsJson.resourceLifecyclePlan as Record<string, unknown>),
|
||||
action: "restore",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await executeRustBridgeMutationTransport({
|
||||
client: { mutation } as unknown as ConvexHttpClient,
|
||||
plan: {
|
||||
...basePlan,
|
||||
commandName: "tree.resource.rename",
|
||||
commandId: "cmd_asset_rename",
|
||||
argsJson: {
|
||||
...basePlan.argsJson,
|
||||
newName: "renamed.pdf",
|
||||
resourceLifecyclePlan: {
|
||||
...(basePlan.argsJson.resourceLifecyclePlan as Record<string, unknown>),
|
||||
action: "rename",
|
||||
newName: "renamed.pdf",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await executeRustBridgeMutationTransport({
|
||||
client: { mutation } as unknown as ConvexHttpClient,
|
||||
plan: {
|
||||
...basePlan,
|
||||
commandName: "tree.resource.purge",
|
||||
commandId: "cmd_asset_purge",
|
||||
functionName: "mediaAssets:purgeById",
|
||||
argsJson: {
|
||||
...basePlan.argsJson,
|
||||
resourceLifecyclePlan: {
|
||||
...(basePlan.argsJson.resourceLifecyclePlan as Record<string, unknown>),
|
||||
action: "purge",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenNthCalledWith(1, "mediaAssets.patchById", {
|
||||
userId: "user_1",
|
||||
id: "asset_1",
|
||||
patch: expect.objectContaining({
|
||||
deleted_by: "user_1",
|
||||
purged_at: null,
|
||||
}),
|
||||
});
|
||||
expect(mutation).toHaveBeenNthCalledWith(2, "mediaAssets.patchById", {
|
||||
userId: "user_1",
|
||||
id: "asset_1",
|
||||
patch: {
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
purged_at: null,
|
||||
},
|
||||
});
|
||||
expect(mutation).toHaveBeenNthCalledWith(3, "mediaAssets.patchById", {
|
||||
userId: "user_1",
|
||||
id: "asset_1",
|
||||
patch: {
|
||||
file_name: "renamed.pdf",
|
||||
},
|
||||
});
|
||||
expect(mutation).toHaveBeenNthCalledWith(4, "mediaAssets.purgeById", {
|
||||
userId: "user_1",
|
||||
id: "asset_1",
|
||||
expiredDeletedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("materializeRustTreeStreamDelta", () => {
|
||||
|
||||
@@ -1259,6 +1259,8 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
||||
mediaAssets: {
|
||||
batchCopy: unknown;
|
||||
batchMove: unknown;
|
||||
patchById: unknown;
|
||||
purgeById: unknown;
|
||||
};
|
||||
mindmaps: {
|
||||
applyCommand: unknown;
|
||||
@@ -1361,6 +1363,45 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
||||
),
|
||||
},
|
||||
);
|
||||
case "mediaAssets:patchById": {
|
||||
const lifecyclePlan = readOptionalRecordArg(input.plan.argsJson, "resourceLifecyclePlan");
|
||||
const action = typeof lifecyclePlan?.action === "string" ? lifecyclePlan.action : "";
|
||||
let patch: Record<string, unknown>;
|
||||
if (action === "archive") {
|
||||
patch = {
|
||||
deleted_at: new Date().toISOString(),
|
||||
deleted_by: input.plan.actorId,
|
||||
purged_at: null,
|
||||
};
|
||||
} else if (action === "restore") {
|
||||
patch = {
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
purged_at: null,
|
||||
};
|
||||
} else if (action === "rename") {
|
||||
patch = {
|
||||
file_name: assertStringArg(input.plan.argsJson, "newName"),
|
||||
};
|
||||
} else {
|
||||
throw new DocumentBridgeError(
|
||||
`不支持的媒体资源生命周期动作: ${action || "unknown"}`,
|
||||
500,
|
||||
"TRANSPORT_ERROR",
|
||||
);
|
||||
}
|
||||
return mutation(runtimeApi.mediaAssets.patchById, {
|
||||
userId: input.plan.actorId,
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
patch,
|
||||
});
|
||||
}
|
||||
case "mediaAssets:purgeById":
|
||||
return mutation(runtimeApi.mediaAssets.purgeById, {
|
||||
userId: input.plan.actorId,
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
expiredDeletedAt: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
case "mediaAssets:replaceStorageFromUpload":
|
||||
return mutation(api.mediaAssets.replaceStorageFromUpload, {
|
||||
userId: assertStringArg(input.plan.argsJson, "userId"),
|
||||
|
||||
@@ -104,4 +104,30 @@ describe("tree-command-client", () => {
|
||||
|
||||
await expect(deleteDocumentCommand({ documentId: "doc_1" })).rejects.toThrow("删除失败");
|
||||
});
|
||||
|
||||
it("restoreDocumentCommand 应返回恢复位置与 fallback 原因", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
result: {
|
||||
action: "restore",
|
||||
execution: {
|
||||
restore_location: {
|
||||
parent_id: "parent_1",
|
||||
sort_order: 2,
|
||||
fallback_reason: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await expect(restoreDocumentCommand({ documentId: "doc_1" })).resolves.toMatchObject({
|
||||
success: true,
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
fallbackReason: "none",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +116,14 @@ type RestoreDocumentInput = {
|
||||
workspaceId?: string | null;
|
||||
};
|
||||
|
||||
export type RestoreDocumentCommandResult = {
|
||||
success: true;
|
||||
parentId?: string | null;
|
||||
sortOrder?: number | null;
|
||||
fallbackReason?: "none" | "parent_missing_or_deleted" | string | null;
|
||||
meta?: DocumentCommandMeta;
|
||||
};
|
||||
|
||||
type PurgeDocumentInput = {
|
||||
documentId: string;
|
||||
};
|
||||
@@ -308,7 +316,7 @@ export async function deleteDocumentCommand(
|
||||
|
||||
export async function restoreDocumentCommand(
|
||||
input: RestoreDocumentInput,
|
||||
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
|
||||
): Promise<RestoreDocumentCommandResult> {
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "restore",
|
||||
@@ -317,9 +325,19 @@ export async function restoreDocumentCommand(
|
||||
},
|
||||
"恢复失败,请稍后再试",
|
||||
);
|
||||
const restoreLocation = response.result?.execution?.restore_location as
|
||||
| {
|
||||
parent_id?: string | null;
|
||||
sort_order?: number | null;
|
||||
fallback_reason?: string | null;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
parentId: restoreLocation?.parent_id,
|
||||
sortOrder: restoreLocation?.sort_order,
|
||||
fallbackReason: restoreLocation?.fallback_reason,
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearFileTreeClipboardPayload,
|
||||
decodeFileTreeClipboardPayload,
|
||||
encodeFileTreeClipboardPayload,
|
||||
inferPasteTargetDocId,
|
||||
@@ -11,8 +12,19 @@ describe("file-tree clipboard payload", () => {
|
||||
const payload = { type: "mnote-file-tree" as const, version: 1 as const, action: "copy" as const, rowIds: ["doc:a", "asset:x"] };
|
||||
const text = encodeFileTreeClipboardPayload(payload);
|
||||
expect(decodeFileTreeClipboardPayload(text)).toEqual(payload);
|
||||
const cutPayload = { ...payload, action: "cut" as const };
|
||||
expect(decodeFileTreeClipboardPayload(encodeFileTreeClipboardPayload(cutPayload))).toEqual(cutPayload);
|
||||
expect(
|
||||
decodeFileTreeClipboardPayload(
|
||||
encodeFileTreeClipboardPayload({ ...payload, action: "move" as any }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(decodeFileTreeClipboardPayload("not-a-payload")).toBeNull();
|
||||
});
|
||||
|
||||
it("可清空内存剪贴板", async () => {
|
||||
await clearFileTreeClipboardPayload();
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferPasteTargetDocId", () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { FileTreeRow } from "./types";
|
||||
import { parseFileTreeRowId } from "./types";
|
||||
|
||||
export type FileTreeClipboardAction = "copy";
|
||||
export type FileTreeClipboardAction = "copy" | "cut";
|
||||
|
||||
export type FileTreeClipboardPayloadV1 = {
|
||||
type: "mnote-file-tree";
|
||||
@@ -45,7 +45,7 @@ export function decodeFileTreeClipboardPayload(text: string): FileTreeClipboardP
|
||||
const raw = decodeBase64(base64);
|
||||
const parsed = JSON.parse(raw) as Partial<FileTreeClipboardPayloadV1>;
|
||||
if (parsed?.type !== "mnote-file-tree" || parsed.version !== 1) return null;
|
||||
if (parsed.action !== "copy") return null;
|
||||
if (parsed.action !== "copy" && parsed.action !== "cut") return null;
|
||||
if (!Array.isArray(parsed.rowIds) || parsed.rowIds.some((id) => typeof id !== "string")) return null;
|
||||
return parsed as FileTreeClipboardPayloadV1;
|
||||
} catch {
|
||||
@@ -78,6 +78,17 @@ export async function readFileTreeClipboardPayload(): Promise<FileTreeClipboardP
|
||||
return decodeFileTreeClipboardPayload(text);
|
||||
}
|
||||
|
||||
export async function clearFileTreeClipboardPayload(): Promise<void> {
|
||||
memoryClipboardText = null;
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText("");
|
||||
} catch {
|
||||
// ignore, memory fallback 已清空
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isTextInputTarget(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
|
||||
@@ -39,6 +39,20 @@ type UploadResourceAssetInput = {
|
||||
};
|
||||
|
||||
export type FileTreeInternalDropPreflightPlan = {
|
||||
allowed?: boolean;
|
||||
blockedReason?: string | null;
|
||||
requiresConfirmation?: boolean;
|
||||
conflicts?: Array<{
|
||||
rowKind: string;
|
||||
sourceRowId: string;
|
||||
sourceDocumentId: string | null;
|
||||
sourceAssetId: string | null;
|
||||
existingDocumentId: string | null;
|
||||
existingAssetId: string | null;
|
||||
targetDocumentId: string;
|
||||
title: string;
|
||||
policy: string;
|
||||
}>;
|
||||
copy: boolean;
|
||||
targetDocumentId: string;
|
||||
targetMindmapId: string | null;
|
||||
|
||||
@@ -302,7 +302,9 @@ describe("file-tree shell helpers", () => {
|
||||
expect(result).toEqual({
|
||||
workspaceId: "ws_1",
|
||||
copy: false,
|
||||
targetDocumentId: null,
|
||||
sourceCapabilities: ["read", "write", "move"],
|
||||
targetCapabilities: ["read", "write", "drop"],
|
||||
targetDocumentId: "doc_root",
|
||||
targetRowId: "asset-folder:mind_1",
|
||||
focusedRowId: null,
|
||||
activeDocumentId: null,
|
||||
@@ -316,6 +318,7 @@ describe("file-tree shell helpers", () => {
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "mindmap",
|
||||
storagePath: "mindmaps/mind_1/mindmap.json",
|
||||
title: "mindmap.json",
|
||||
},
|
||||
{
|
||||
rowId: "asset:asset_child_1",
|
||||
@@ -325,6 +328,7 @@ describe("file-tree shell helpers", () => {
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "file",
|
||||
storagePath: "mindmaps/mind_1/assets/node.png",
|
||||
title: "node.png",
|
||||
},
|
||||
{
|
||||
rowId: "doc:doc_root",
|
||||
@@ -334,6 +338,7 @@ describe("file-tree shell helpers", () => {
|
||||
assetDocumentId: null,
|
||||
assetType: null,
|
||||
storagePath: null,
|
||||
title: "根页面",
|
||||
},
|
||||
{
|
||||
rowId: "asset:pdf_1",
|
||||
@@ -343,12 +348,34 @@ describe("file-tree shell helpers", () => {
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "file",
|
||||
storagePath: "uploads/guide.pdf",
|
||||
title: "guide.pdf",
|
||||
},
|
||||
],
|
||||
targetChildren: [
|
||||
{
|
||||
rowKind: "asset-folder",
|
||||
documentId: "doc_root",
|
||||
assetId: "mind_1",
|
||||
title: "mindmap.json",
|
||||
},
|
||||
{
|
||||
rowKind: "asset",
|
||||
documentId: "doc_root",
|
||||
assetId: "asset_child_1",
|
||||
title: "node.png",
|
||||
},
|
||||
{
|
||||
rowKind: "asset",
|
||||
documentId: "doc_root",
|
||||
assetId: "pdf_1",
|
||||
title: "guide.pdf",
|
||||
},
|
||||
],
|
||||
documentParents: [
|
||||
{ documentId: "doc_root", parentId: null },
|
||||
{ documentId: "doc_child", parentId: "doc_root" },
|
||||
],
|
||||
conflictPolicy: "prompt",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -381,6 +408,7 @@ describe("file-tree shell helpers", () => {
|
||||
assetDocumentId: null,
|
||||
assetType: null,
|
||||
storagePath: null,
|
||||
title: "根页面",
|
||||
},
|
||||
{
|
||||
rowId: "asset:pdf_1",
|
||||
@@ -390,6 +418,7 @@ describe("file-tree shell helpers", () => {
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "file",
|
||||
storagePath: "uploads/guide.pdf",
|
||||
title: "guide.pdf",
|
||||
},
|
||||
],
|
||||
documentParents: [
|
||||
@@ -430,6 +459,7 @@ describe("file-tree shell helpers", () => {
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "mindmap",
|
||||
storagePath: "mindmaps/mind_1/mindmap.json",
|
||||
title: "mindmap.json",
|
||||
},
|
||||
{
|
||||
rowId: "index:doc_root",
|
||||
@@ -439,6 +469,7 @@ describe("file-tree shell helpers", () => {
|
||||
assetDocumentId: null,
|
||||
assetType: null,
|
||||
storagePath: null,
|
||||
title: "根页面",
|
||||
},
|
||||
{
|
||||
rowId: "asset:pdf_1",
|
||||
@@ -448,6 +479,7 @@ describe("file-tree shell helpers", () => {
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "file",
|
||||
storagePath: "uploads/guide.pdf",
|
||||
title: "guide.pdf",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -488,6 +520,7 @@ describe("file-tree shell helpers", () => {
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "file",
|
||||
storagePath: "mindmaps/mind_1/assets/node.png",
|
||||
title: "node.png",
|
||||
},
|
||||
{
|
||||
rowId: "asset-folder:mind_1",
|
||||
@@ -497,6 +530,7 @@ describe("file-tree shell helpers", () => {
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "mindmap",
|
||||
storagePath: "mindmaps/mind_1/mindmap.json",
|
||||
title: "mindmap.json",
|
||||
},
|
||||
],
|
||||
documentWorkspaces: [
|
||||
|
||||
@@ -28,18 +28,31 @@ export type FileTreeShellInternalDropPreflightRow = {
|
||||
assetDocumentId: string | null;
|
||||
assetType: string | null;
|
||||
storagePath: string | null;
|
||||
title: string | null;
|
||||
operationProfile?: string | null;
|
||||
};
|
||||
|
||||
export type FileTreeShellInternalDropPreflightTargetChild = {
|
||||
rowKind: FileTreeShellRowKind;
|
||||
documentId: string | null;
|
||||
assetId: string | null;
|
||||
title: string | null;
|
||||
};
|
||||
|
||||
export type FileTreeShellInternalDropPreflightPayload = {
|
||||
workspaceId: string | null;
|
||||
copy: boolean;
|
||||
sourceCapabilities: string[];
|
||||
targetCapabilities: string[];
|
||||
targetDocumentId: string | null;
|
||||
targetRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
activeDocumentId: string | null;
|
||||
rowIds: string[];
|
||||
rows: FileTreeShellInternalDropPreflightRow[];
|
||||
targetChildren: FileTreeShellInternalDropPreflightTargetChild[];
|
||||
documentParents: Array<{ documentId: string; parentId: string | null }>;
|
||||
conflictPolicy: "prompt";
|
||||
};
|
||||
|
||||
export type FileTreeShellDeletePreflightPayload = {
|
||||
@@ -194,6 +207,13 @@ function normalizeShellText(value: string | null | undefined): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function getFileTreeShellRowTitle(row: FileTreeShellRow): string | null {
|
||||
if (row.rowKind === "doc" || row.rowKind === "index") {
|
||||
return normalizeShellText(row.node?.title ?? null);
|
||||
}
|
||||
return normalizeShellText(row.asset?.file_name ?? null);
|
||||
}
|
||||
|
||||
function buildFileTreeShellDropPreflightRow(
|
||||
row: FileTreeShellRow,
|
||||
): FileTreeShellInternalDropPreflightRow {
|
||||
@@ -205,9 +225,34 @@ function buildFileTreeShellDropPreflightRow(
|
||||
assetDocumentId: normalizeShellText(row.asset?.document_id ?? null),
|
||||
assetType: normalizeShellText(row.asset?.asset_type ?? null),
|
||||
storagePath: normalizeShellText(row.asset?.storage_path ?? null),
|
||||
title: getFileTreeShellRowTitle(row),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFileTreeShellTargetChildren(input: {
|
||||
targetDocumentId: string | null;
|
||||
rowById: Map<string, FileTreeShellRow>;
|
||||
parentById: Map<string, string | null>;
|
||||
}): FileTreeShellInternalDropPreflightTargetChild[] {
|
||||
const targetDocumentId = normalizeShellText(input.targetDocumentId);
|
||||
if (!targetDocumentId) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(input.rowById.values())
|
||||
.filter((row) => {
|
||||
if (row.rowKind === "doc" || row.rowKind === "index") {
|
||||
return input.parentById.get(row.documentId) === targetDocumentId;
|
||||
}
|
||||
return row.documentId === targetDocumentId;
|
||||
})
|
||||
.map((row) => ({
|
||||
rowKind: row.rowKind,
|
||||
documentId: normalizeShellText(row.documentId),
|
||||
assetId: normalizeShellText(row.assetId),
|
||||
title: getFileTreeShellRowTitle(row),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildFileTreeShellInternalDropPreflightPayload(input: {
|
||||
workspaceId: string | null;
|
||||
copy: boolean;
|
||||
@@ -237,20 +282,35 @@ export function buildFileTreeShellInternalDropPreflightPayload(input: {
|
||||
.map((rowId) => input.rowById.get(rowId) ?? null)
|
||||
.filter((row): row is FileTreeShellRow => Boolean(row))
|
||||
.map(buildFileTreeShellDropPreflightRow);
|
||||
const targetDocumentId =
|
||||
normalizeShellText(input.targetDocumentId) ??
|
||||
inferFileTreeShellTargetDocumentId({
|
||||
focusedRowId: targetRowId ?? focusedRowId,
|
||||
rowById: input.rowById,
|
||||
activeDocId: input.activeDocId,
|
||||
});
|
||||
|
||||
return {
|
||||
workspaceId: normalizeShellText(input.workspaceId),
|
||||
copy: input.copy,
|
||||
targetDocumentId: normalizeShellText(input.targetDocumentId),
|
||||
sourceCapabilities: ["read", "write", input.copy ? "copy" : "move"],
|
||||
targetCapabilities: ["read", "write", "drop"],
|
||||
targetDocumentId,
|
||||
targetRowId,
|
||||
focusedRowId,
|
||||
activeDocumentId: normalizeShellText(input.activeDocId),
|
||||
rowIds,
|
||||
rows,
|
||||
targetChildren: buildFileTreeShellTargetChildren({
|
||||
targetDocumentId,
|
||||
rowById: input.rowById,
|
||||
parentById: input.parentById,
|
||||
}),
|
||||
documentParents: Array.from(input.parentById.entries()).map(([documentId, parentId]) => ({
|
||||
documentId,
|
||||
parentId: normalizeShellText(parentId),
|
||||
})),
|
||||
conflictPolicy: "prompt",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +254,73 @@ describe("useSidebarTreeStream", () => {
|
||||
expect(MockEventSource.instances[0]?.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("resync_required delta 只推进 cursor,后续 resync 才替换数据", async () => {
|
||||
await act(async () => {
|
||||
root.render(<Harness onState={onState} />);
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
MockEventSource.instances[0]?.emit("snapshot", buildSnapshotEnvelope("旧标题"));
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(onState).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
status: "live",
|
||||
cursor: "evt_2",
|
||||
data: expect.objectContaining({
|
||||
documents: [expect.objectContaining({ title: "旧标题" })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
MockEventSource.instances[0]?.emit("delta", {
|
||||
stream: "workspace",
|
||||
workspaceId: "ws_1",
|
||||
cursor: "evt_3",
|
||||
projection: "sidebar_tree",
|
||||
data: {
|
||||
op: "resync_required",
|
||||
reason: "documents_empty_trash",
|
||||
},
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(onState).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
status: "live",
|
||||
cursor: "evt_3",
|
||||
data: expect.objectContaining({
|
||||
documents: [expect.objectContaining({ title: "旧标题" })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
MockEventSource.instances[0]?.emit("resync", buildSnapshotEnvelope("新标题"));
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(onState).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
status: "live",
|
||||
cursor: "evt_2",
|
||||
data: expect.objectContaining({
|
||||
documents: [expect.objectContaining({ title: "新标题" })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("收到 snapshot 后连接中断也应切到 fallback,并保留最近一次 stream 数据", async () => {
|
||||
await act(async () => {
|
||||
root.render(<Harness onState={onState} />);
|
||||
|
||||
Reference in New Issue
Block a user