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:
lix-2026
2026-05-16 07:38:45 +08:00
parent 274779c0d8
commit 384da4e44c
85 changed files with 12570 additions and 394 deletions
@@ -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` 通过。
@@ -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 plancut 粘贴复用 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 的行内 validationRust 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` 验证自拖自身与父拖子均返回 400copy 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 assetmindmap 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 assetmindmap 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 indexrestore / 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-15Rust 3000 主壳 filetree delete plan 已对 `sourceKind=local_folder && rowKind=asset` 使用 rowId 作为本地资源 identityDelete / 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-trashB 浏览器不刷新即可在 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-trashB 浏览器文件树与 `/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 eventConvex `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-trashB 浏览器文件树与 `/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 task434mindmap / 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-trashB 浏览器文件树与 `/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 task435empty-trash 双浏览器汇总
在一次性 workspace 中执行页面垃圾箱与资源垃圾箱清空:
- A 清空页面垃圾箱后,B `/trash` 页面不刷新变为空。
- A 清空资源垃圾箱后,B `/trash` 页面资源区不刷新变为空。
- Convex query 断言对应对象不可再查询。
### 3.5 task436local 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 兼容,减少一次性改动。
### P1Active / reveal / object identity 迁移
- active page body 映射到 `{title}.md` row。
- mindmap asset 打开后保持 asset active,不降级为页面正文 row。
- 长列表 reveal 稳定。
### P2Rename / 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 覆盖。
@@ -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`
+11 -3
View File
@@ -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 P0Rust 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。
应执行:
- 文件树先补真实浏览器 smokeCtrl/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 P1VSCode 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 不是同一 executorwatch 范围也不一致
证据:
- 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 P1F2 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 / resyncB 浏览器会停留在旧数据。
- 这类问题表面上会再次表现为“必须刷新浏览器才能看到变化”。
应执行:
- 给 tree event stream 增加合同级回归:每个 `resync_required` 必须伴随后续 snapshot / resync,或客户端必须主动拉取新 snapshot。
- 验收断言包括 cursor 顺序、断线重连、重复 resync 去重。
- smoke 结果中继续记录实际事件来源,不把本地 `refreshTree()` 误标为实时同步。
## 3. 按顺序执行的 checklist
### 阶段 1P0 删除语义止血
- [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-15Rust 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-15Rust 3000 主壳已挂载 `POST /api/documents/empty-trash`,补 `documents_empty_trash_route_executes_workspace_trash_purge` 回归;`/trash` 页面已出现“清空页面垃圾箱”按钮,并只读浏览器 smoke 验证按钮存在,未点击清空以避免删除既有垃圾箱数据。
- 2026-05-15Rust 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 archivechild 随子树删除。
- 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 assetmindmap / 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-15Rust 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 assetmindmap rename 继续明确不支持,table rename 待结合 `/api/tables/{tableId}` PATCH 与表格标题语义单独收口。`empty-trash` 仍是 workspace 级批量合同,不伪装成逐资源 `tree.resource.purge`
### 阶段 6:恢复位置与 reveal/focus
- [x] 删除时记录恢复所需的 parent / orderview 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。
### 阶段 7VSCode Explorer 体验补齐
- [x] Cut / Paste moveCtrl/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-folderConvex 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 renameEnter / 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-15filetree 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;自拖自身与父拖子返回 400copy 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 TreeB2 打开 `/trash?workspaceId=tree_1778804545042_7` 观察 Trash 工作台。A 侧依次执行页面 create / archive / restore / archive+purge / create+archive+empty-trashB 侧每一步均无需刷新可见更新,`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 TreeB2 打开 `/trash?workspaceId=tree_1778806753148_2` 观察 Trash 工作台。A 侧依次执行普通附件 upload / archive / restore / archive+purge / upload+archive+empty-trashB 侧每一步均无需刷新可见更新,执行阶段 `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-trashB 侧 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 项通过。
### 阶段 9Local 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` 轮询 revisionrevision 扫描可见条目并 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 作为本地资源 identityDelete / 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。
### 阶段 10Explorer parity 收口项
- [x] F2 inline rename validation:空名、非法名、同级重名、扩展名策略行内提示,并阻断提交。
- [x] Copy Relative PathConvex 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。
+5
View File
@@ -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
+10 -10
View File
@@ -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()
+471 -1
View File
@@ -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(
+2 -4
View File
@@ -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| {
+104 -1
View File
@@ -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=\"{&quot;objectKind&quot;:&quot;page&quot;,&quot;documentId&quot;:&quot;doc_1&quot;,&quot;blockId&quot;:null,&quot;assetId&quot;: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!({
+261 -18
View File
@@ -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"
}
])
);
+18 -5
View File
@@ -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
View File
@@ -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, "工作区首页");
+11 -11
View File
@@ -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} 不应触发浏览器导航或 reloadbefore=${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
View File
@@ -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;
+223 -23
View File
@@ -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);
+3
View File
@@ -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();
});
});
+80 -26
View File
@@ -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();
});
});
+57 -20
View File
@@ -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(");
});
});
+423 -139
View File
@@ -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", () => {
+13 -2
View File
@@ -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;
+35 -1
View File
@@ -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: [
+61 -1
View File
@@ -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} />);