fix(tree): stabilize filetree mindmap switching

Open filetree mindmap assets inside the primary document pane so the sidebar root is not rebuilt during rapid mindmap/page switching.

Keep filetree active rows on doc:<documentId> and asset:<mindmapId>, shorten generated mindmap filenames, and preserve legacy index rows only as compatibility input.

Add task438-task445 browser smokes and close the 4-27/4-38/4-39/4-40 tree-domain bug records.
This commit is contained in:
lix-2026
2026-05-16 07:11:06 +08:00
parent 01fdd2a8c3
commit 274779c0d8
15 changed files with 2128 additions and 155 deletions
@@ -0,0 +1,147 @@
# 4-27 [done][bug] File Tree 打开对象后 active / reveal 跳动 v1
> 更新时间:2026-05-16
>
> 分类归属:
> - `04-tree-domain/done`
> - 涉及边界:`05-editor-mainline/object editor active identity`、`file_tree row selection/focus/reveal`
>
> 用户反馈:
> - “目前文件树中思维导图点击后文件树会闪一下,然后跳到对应的 index。”
> - “文件树中,点击页面下方(滚动条到下部分)的页面,有时候会跳一下,然后就看不到点的那个文件里。”
## 1. 问题定义
File Tree 中点击某个对象后,树 UI 会发生短暂闪烁或滚动跳动,最终 active / reveal 位置可能落到同页面的 `index.md`,或者滚动到别处导致刚点击的文件不可见。
这类问题不是简单的路由跳转慢,而是 `file_tree row click -> object open intent -> active object identity -> selection/focus/reveal -> virtual/DOM tree render` 链路不一致。尤其是 mindmap asset 点击后如果页面 active identity 被降级成 document/index identity,就会把用户刚点击的 asset 行“纠正”为同页面 `index.md` 行,表现为文件树闪一下并跳到 index。
## 2. 初步判断
当前不应直接把它当成 `5-10 mindmap 污染 index.md` 的重复问题。`5-10` 已经关闭的是“mindmap 打开/保存污染正文真源”;本问题关注的是打开动作之后 File Tree 的 active row 和 scroll reveal 是否稳定。
更可能存在三类根因:
1. `tree.asset.open` 打开 mindmap 后,主编辑区 active state 只回传 `documentId`,没有保留 `objectIdentity=resource:mindmap:{documentId}:{assetId}`,导致 File Tree active row 映射回 `index:{documentId}`
2. 点击文件树下半部分页面时,导航成功、sidebar snapshot 校准、tree live delta/resync 或本地 projection rebuild 触发了 reveal 当前文档逻辑,未区分“用户刚点击的 row”与“路由推导出的默认 index row”。
3. File Tree 在大列表滚动区域内重新渲染时,expanded/selected/focused/active/reveal 状态没有按 rowId 稳定合并,导致 scroll anchor 丢失,用户看到跳动后目标行被滚出视口。
## 3. 与既有 done bug 的关系
关联但不重复:
- `bugs/05-editor-mainline/done/5-10-mindmap-filetree-index-single-truth-split-v1.md`:关闭 mindmap object 与 `index.md` 正文真源混淆。
- `bugs/04-tree-domain/done/4-26-sidebar-filetree-projection-refresh-stalled-after-tree-asset-mutations-v1.md`:关闭新建/删除/新建 mindmap 后 File Tree 不刷新的问题。
本 bug 的新增点是:对象已经可见、也能打开,但打开后的 active/reveal/scroll 行为仍不稳定。
## 4. 建议复现路径
1. 登录真实测试账号,进入 `http://localhost:3000/` 或主文档页。
2. 新建测试页面 `TEST-FILETREE-REVEAL-<timestamp>`,在页面内新建一个 mindmap。
3. 在 File Tree 中滚动到该页面及其 mindmap asset 行。
4. 点击 mindmap asset 行。
5. 记录点击前 rowId、点击后 active rowId、scrollTop、URL、主编辑区 object identity。
6. 期望:active row 应保持在 mindmap asset 或明确的 object tab row,不应自动跳到 `index:{documentId}`
7. 在长列表中滚动到下半部分,点击一个页面或其 `index.md`
8. 期望:导航后目标 row 仍在视口内,不能被 reveal 到其它位置后消失。
## 5. 验收标准
- 点击 File Tree 的 mindmap asset 后,不刷新浏览器,不发生无解释的整树闪烁。
- 点击 mindmap asset 后,File Tree active / selected / focused row 不得被降级成同页面 `index.md`,除非产品明确决定普通点击只打开 index;如果这样决定,UI 必须不展示为 asset 被打开。
- 点击长列表下半部分页面或 `index.md` 后,目标 row 在导航完成和 projection 校准后仍保持可见。
- `reveal` 逻辑必须区分用户点击产生的 explicit focus 与路由/文档 active 推导的 implicit reveal,不能让 implicit reveal 覆盖刚发生的 explicit focus。
- 增加真实浏览器 smoke,至少记录:点击前后 rowId、active object identity、scrollTop 变化、是否发生 navigation/reload、最终目标 row 是否在 viewport 内。
## 6. 建议修复方向
先补 smoke 复现,不要直接改实现。修复时优先沿以下边界排查:
1. File Tree row click 发出的 `tree.asset.open` 是否携带完整 `objectIdentity`
2. 主编辑区打开 mindmap object editor 后是否把 active identity 回传给 Sidebar / File Tree。
3. `index:{documentId}` 的 active 高亮是否只在打开 page/index 时生效,而不是覆盖 asset object。
4. 本地 projection rebuild 后是否按 rowId 恢复 expanded/selected/focused/reveal,不按 documentId 粗粒度重置。
5. scroll reveal 是否只在目标 row 不在 viewport 内时执行,并避免重复 reveal。
## 7. 当前状态
当前状态:`done`
2026-05-15 已随 `4-35` P0/P1 完成第一层止血:
- Convex File Tree 页面正文 row 不再显示为页面容器下的 `index.md`,而是直接显示为 `新页面.md` 这类 markdown row。
- 主文档壳本地 create apply 不再手工插入 `index:<documentId>` 行。
- 当前文档的 File Tree selected / focused 默认从 `index:<documentId>` 改为 `doc:<documentId>`
- 新增真实浏览器 smoke`scripts/task438-filetree-title-md-active-reveal-smoke.js`,覆盖新建页面后没有 `index.md` 行、页面正文 row 显示 `.md`、点击长列表中的页面 row 后 selected 留在 `doc:<id>`,且目标 row 仍在视口内。
- P2 继续补齐 File Tree inline rename:输入 `{title}.md` 时只把 `{title}` 作为页面标题提交,File Tree 仍显示 `{title}.md`;非法字符和同级重名会在输入框内提示,不触发 rename command。
已通过:
```bash
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
```
结果文件:`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`
关键结果:
- `ok=true`
- `beforeClick.indexRowExists=false`
- `beforeClick.rowTitle="新页面.md"`
- `afterClick.selectedRows=[{"rowId":"doc:<documentId>", ...}]`
- `afterClick.visible=true`
- `task439.afterRename.fileTreeTitle="<title>.md"`
- `task439.afterRename.pageTreeTitle="<title>"`
- `task439.invalidState.validationText="文件名不能包含 / \\ : * ? \" < > |"`
- `task439.duplicateState.validationText="同级已存在同名页面"`
- `task440.after.fileTreeTitle="<title>.md"`
- `task440.after.pageTreeTitle="<title>"`
- `task440.after.indexRowExists=false`
2026-05-15 P3 口径补充:
- 后续 File Tree active / reveal 修复不再以可见 `index.md` 行为目标;Convex 页面正文默认 row 是 `doc:<documentId>`,显示标题为 `{title}.md`
- `rowKind=index` 只作为历史 projection / command 兼容输入保留,不能重新作为默认 UI 可见行引入。
2026-05-15 P4 子问题关闭:
- mindmap asset 点击后 active / selected 从 `asset:{mindmapId}` 闪回父页面 `doc:{documentId}` 的子问题已单独关闭,记录见 `bugs/04-tree-domain/done/4-38-filetree-mindmap-click-active-row-flicker-v1.md`
- 修复点:`/mindmap/{documentId}/{mindmapId}` 对象页的 SSR filetree active row 改为 `asset:{mindmapId}`;客户端 tree live snapshot renderer 也按 `/mindmap` 路由恢复 `asset:{mindmapId}`,不再只按 `doc:{documentId}` 选中。
- 真实浏览器 smoke`MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task443-filetree-mindmap-click-active-row-smoke.js`,结果文件 `tmp/task443-filetree-mindmap-click-active-row-smoke/result.json`
2026-05-16 当前口径关闭:
- mindmap asset 连续点击和其它页面切换时,不再通过整页 `/mindmap/...` 导航重建 sidebar;主文档壳新增 `openPrimaryMindmap()`,在当前 primary pane 内挂载 mindmap 对象,并保持 `#sidebar-file-tree-root` DOM 根节点稳定。
- mindmap asset 选中语义保持为 `asset:{mindmapId}`,不会回落到父页面 `doc:{documentId}` 或历史 `index:{documentId}`
- Convex 页面正文默认 row 继续保持 `doc:{documentId}`,显示为 `{title}.md`;历史 `index` 只作为兼容输入,不再作为默认 UI active / reveal 目标。
- mindmap 文件名从 `mindmap-mindmap_...json` 这类技术名收敛为 `思维导图-xxxx.json`,避免连续切换时标题重绘造成观感跳动。
- 关闭记录见:
- `bugs/04-tree-domain/done/4-38-filetree-mindmap-click-active-row-flicker-v1.md`
- `bugs/04-tree-domain/done/4-39-filetree-create-title-cache-md-conflict-v1.md`
- `bugs/04-tree-domain/done/4-40-filetree-mindmap-switch-flicker-and-short-title-v1.md`
新增 / 复用真实浏览器 smoke:
```bash
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task443-filetree-mindmap-click-active-row-smoke.js
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task444-filetree-create-mindmap-title-cache-smoke.js
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js
```
`task445` 关键结果:
- `before.mindmapTitle = "思维导图-7712.json"`
- `afterMindmap.fileRootStable = true`
- `afterPage.fileRootStable = true`
- `afterMindmapAgain.fileRootStable = true`
- `failures = []`
后续如再次出现,应拆成独立新缺陷跟踪:
- 更长列表、搜索过滤、恢复后 reveal、双浏览器 resync 场景下是否仍稳定。
- 明确复现的“点击文件树下半部分页面后跳一下导致看不到刚点击文件”的滚动锚点问题。
@@ -0,0 +1,59 @@
# 4-38 文件树 mindmap 点击后选中态闪回父页面
## 状态
- 状态:done
- 分类:04-tree-domain
- 发现时间:2026-05-15
- 修复时间:2026-05-15
## 现象
点击页面树普通页面时视觉稳定;点击文件树中的思维导图资源行时,资源行会快速闪一下,随后文件树选中态回到父页面 `.md` 行。用户体感类似“点了 mindmap 文件,但文件树又跳回对应页面/index”。
## 根因
文件树资源点击路径先执行 `selectSidebarFileTreeRow()`,使 asset row 短暂 selected;随后 mindmap 资源通过 `tree.asset.open -> openConvexAssetFromFileTree()` 整页跳转到 `/mindmap/{documentId}/{mindmapId}`
旧实现中 `/mindmap` 对象页重新渲染文件树时只按当前 `documentId` 计算选中态:
```text
rowId === 'doc:' + activeId
```
因此页面重建或 tree live snapshot 重绘后,选中态会从 `asset:{mindmapId}` 回落到 `doc:{documentId}`,形成快速闪烁。
## 修复
- `mindmap_object_shell` 加载文件树时传入 active row:`asset:{mindmapId}`
- `collect_filetree_render_rows()` 新增 `active_row_id`,当存在 active row 时优先按 row id 选中,避免父页面 fallback 抢回 selected。
- `SIDEBAR_TREE_JS` 的 live snapshot renderer 新增 `currentFileTreeActiveRowId()`,在 `/mindmap/{doc}/{asset}` 路径下保持 `asset:{asset}` 选中,防止 SSE 重绘后再次闪回。
## 验证
Rust 定向测试:
```bash
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 mindmap_shell_returns_rust_object_shell_contract -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_rows_select_active_mindmap_asset_in_object_shell -- --nocapture
```
真实浏览器 smoke
```bash
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task443-filetree-mindmap-click-active-row-smoke.js
```
验证结果:
- `tmp/task443-filetree-mindmap-click-active-row-smoke/result.json`
- 截图:`tmp/task443-filetree-mindmap-click-active-row-smoke/after-mindmap-click.png`
- 点击后 URL 为 `/mindmap/{documentId}/{mindmapId}`
- 点击前 selected row 为 `doc:{documentId}`
- 点击后 selected row 为 `asset:{mindmapId}`
- 点击后不再出现父页面 `doc:{documentId}` selected。
## 后续口径
mindmap、附件、table 等资源对象打开后,文件树 active/selected 不能只按父 `documentId` 回落到页面行;对象页或对象视图必须优先表达资源对象身份。页面正文才选中 `doc:{documentId}`
@@ -0,0 +1,116 @@
# 4-39 文件树新建不更新与 `.md` 后缀被缓存/标题同步覆盖
## 状态
- 状态:done
- 分类:04-tree-domain
- 发现时间:2026-05-15
- 关联:`4-27``4-35``4-38`
## 现象
1. File Tree 中点击新建页面后,树不再稳定更新;刷新后最新页面显示也异常。
2. 页面树显示为 `新页面`,文件树应显示为 `新页面.md`,但部分路径会显示成 `新页面`
3. 点击 File Tree 的 mindmap 资源 `mindmap-mindmap...` 后,再点击 `新页面.md`,文件树标题会变成 `新页面``.md` 消失。
截图证据:
- `/mnt/Data1T/mnote/tmp/image copy 103.png`
- `/mnt/Data1T/mnote/tmp/image copy 104.png`
## 已确认冲突点
### 1. live snapshot filetree renderer 引用了未定义变量
`rust/crates/mnote-web/src/ssr/pages/layout.rs` 中,`renderFileRows()` 使用了 `activeRowId`,但旧函数签名没有把它作为参数传入。tree live snapshot / delta 调用 `renderSidebarSnapshot()` 时会进入该函数,可能抛出 `ReferenceError`,导致“新建后不更新”。
修复方向:
- `renderFileRows(parentId, grouped, activeId, activeRowId)` 显式传入 active row。
- 递归调用也继续传入 active row。
- `renderFileProjection()` 调用 `currentFileTreeActiveRowId()` 后传入。
### 2. live snapshot 使用原始页面标题,绕过 `.md` 文件树显示口径
本地 create 补偿路径 `applyCreatedDocumentLocally() -> upsertFileDocumentRows()` 会把页面标题 `新页面` 显示成 `新页面.md`
但 live snapshot / SSR projection 路径直接使用 projection item 的 `title`,导致文件树页面行被重绘成 `新页面`
修复方向:
- live `renderFileRows()` 对 document/doc/markdown 且非 asset 的行统一使用 `fileTreePageTitle(rawTitle)`
- 资源行仍显示资源自身标题,不套页面 `.md` 规则。
### 3. 文档 pane 标题同步绕过 `.md` 文件树显示口径
`rust/crates/mnote-web/src/routes/web_shell.rs``render_document_title_controller_script()` 中,`updateVisibleTitle()` 更新文件树 row title 时使用原始 `title`,没有经过 `fileTreePageTitle()`
这解释了“点击 mindmap 后再点 `新页面.md``.md` 消失”:pane 内导航/标题同步链路覆盖了文件树行标题。
修复方向:
- 在 title controller 中加入同等 `fileTreePageTitle()`
- page tree / breadcrumb 仍显示页面标题 `新页面`
- filetree document row 显示 `新页面.md`
### 4. pane 内导航仍用旧 `index:<documentId>` 选中规则
`web_shell.rs``updatePaneChrome()` 中,primary pane 导航后 filetree selected 判断仍是 `index:${documentId}`
当前 4-35 口径中,Convex 页面正文默认 row 已迁移为 `doc:<documentId>`,旧 `index` 规则会导致 selection 与 active row 不稳定。
修复方向:
- pane 内 primary navigation 统一使用 `doc:<documentId>`
## VSCode 参考原则
参考 `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/vscode`
- 点击打开、active、selection、reveal 是分离状态,不能互相无条件覆盖。
- refresh 应更新局部 children / item,不应无条件清空重建整棵树。
- label 显示必须由单一口径生成;不能一条路径显示后缀,另一条路径丢后缀。
- selection/focus 需要去重,相同 selection 不应重复提交。
## 验收标准
- 在 File Tree 点击 `+` 新建页面后,不刷新即可出现新页面 row。
- 新建后本地补偿、tree live snapshot、刷新后文件树页面行都显示 `{title}.md`
- 页面树仍显示 `{title}`,不显示 `.md`
- 点击 mindmap 资源后,再点击同页面或其它页面的 filetree `{title}.md`,标题不丢 `.md`
- tree live snapshot / delta 不再因为 `activeRowId is not defined` 中断。
## 已验证
- 已新增真实浏览器 smoke,覆盖 create -> live snapshot -> mindmap click -> filetree page click -> `.md` 保持。
## 验证结果
Rust 定向测试:
```bash
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 document_shell_returns_page_aggregate_snapshot -- --nocapture
```
真实浏览器 smoke
```bash
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task444-filetree-create-mindmap-title-cache-smoke.js
```
结果文件:
- `tmp/task444-filetree-create-mindmap-title-cache-smoke/result.json`
- `tmp/task444-filetree-create-mindmap-title-cache-smoke/after-page-click.png`
关键断言:
- `fileTreeCreated.title = "新页面.md"`
- `afterLive.title = "新页面.md"`
- `afterMindmapClick.title = "新页面.md"`
- `afterPageClick.title = "新页面.md"`
- `afterPageClick.selected = "true"`
- 未出现 `activeRowId` / `ReferenceError`
备注:smoke 中出现一次 `mnote standalone mindmap mount failed TypeError: Failed to fetch`,但不影响本 bug 的文件树 title / selected 验证;如后续稳定复现,应另归 05-editor-mainline 或 mindmap runtime 方向排查。
@@ -0,0 +1,65 @@
# 4-40 文件树 mindmap 连续切换闪烁与文件名过长
## 状态
- 状态:done
- 分类:04-tree-domain
- 发现时间:2026-05-15
- 关联:`4-38``4-39`
## 现象
1. 连续点击 File Tree 中的思维导图资源和其它页面时,文件树仍有闪烁感。
2. 思维导图资源文件名暴露技术 ID,例如 `mindmap-mindmap_1778854767490.json`,既长又重复。
## 根因
1. File Tree 中普通页面点击已经走 `__mnoteDocumentPaneRuntime.openPrimaryDocument()`,可以在当前页面内切换主 pane。
2. mindmap 资源点击仍通过 `window.location.assign('/mindmap/...')` 进入对象壳,导致页面壳和 sidebar 重新构建;连续切换时视觉上表现为闪烁。
3. mindmap 文件名由 Convex sidebar dataset 的 `mindmap-${mindmap_id}.json` 派生,若 `mindmap_id` 本身已经以 `mindmap_` 开头,就会显示成 `mindmap-mindmap_...json`
4. 由于线上 Convex function 可能仍返回旧文件名,mnote-web 初始渲染和 live projection 也需要做展示层兜底规范化。
## 修复
- mindmap 文件树点击改为优先调用 `openPrimaryMindmap()`,在当前 primary pane 内挂载 mindmap 对象,并用 History API 更新 URL。
- primary pane 在文档和 mindmap 之间切换时显式卸载对应 runtime,避免残留视图。
- File Tree mindmap 行展示名统一兜底为短名:`思维导图-xxxx.json`
- Convex sidebar 源和 Next fallback sidebar 源也同步改为短名口径。
- 新增真实浏览器 smoke`scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js`
## 验收标准
- 连续点击 File Tree mindmap 资源 -> 其它页面 -> 同一 mindmap 资源时,`#sidebar-file-tree-root` DOM 根节点保持稳定,不被整页重建。
- URL 可正常切换到 `/mindmap/<documentId>/<mindmapId>``/documents/<documentId>`
- mindmap 资源行选中时只选中 `asset:<mindmapId>`,不会闪回父页面行。
- mindmap 文件名不再显示 `mindmap-mindmap_...json`,长度受控。
- 新建页面的 `.md` 文件树标题口径不回退。
## 验证
真实浏览器 smoke
```bash
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task445-filetree-mindmap-switch-no-flicker-smoke.js
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task443-filetree-mindmap-click-active-row-smoke.js
MNOTE_UI_BASE_URL=http://127.0.0.1:3428 node scripts/task444-filetree-create-mindmap-title-cache-smoke.js
```
Rust / 前端定向验证:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web document_shell_returns_page_aggregate_snapshot -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p mnote-web tree_shell_filetree_renderer_outputs_initial_nested_html_contract -- --nocapture
cd wolai-frontend && pnpm test src/lib/sidebar-data.test.ts -- --runInBand
```
`task445` 关键结果:
- `before.mindmapTitle = "思维导图-7712.json"`
- `afterMindmap.fileRootStable = true`
- `afterPage.fileRootStable = true`
- `afterMindmapAgain.fileRootStable = true`
- `failures = []`
备注:`cargo test --manifest-path rust/Cargo.toml -p mnote-web -- --nocapture` 中仍有一个既有 CSS 体积阈值守卫失败:`MNOTE_CSS.len() < 70000`;本轮未修改 CSS。
+530 -130
View File
@@ -2,7 +2,7 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::command_support::{ use crate::routes::command_support::{
build_tree_target, ensure_non_empty, ensure_sort_order, build_runtime_command_plan, build_tree_target, ensure_non_empty, ensure_sort_order,
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty, execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
}; };
use crate::routes::local_folder_source::{ use crate::routes::local_folder_source::{
@@ -443,18 +443,81 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
rows rows
} }
fn short_mindmap_file_name(raw: &str, asset_id: Option<&str>) -> String {
let source = asset_id
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(raw.trim());
let digits = source
.chars()
.rev()
.take_while(|ch| ch.is_ascii_digit())
.collect::<String>()
.chars()
.rev()
.collect::<String>();
let suffix = if digits.len() >= 4 {
digits[digits.len().saturating_sub(4)..].to_string()
} else {
source
.trim_start_matches("mindmap")
.trim_start_matches(|ch| ch == '-' || ch == '_')
.chars()
.take(6)
.collect::<String>()
};
if suffix.trim().is_empty() {
"思维导图.json".to_string()
} else {
format!("思维导图-{suffix}.json")
}
}
fn normalize_filetree_mindmap_title(
raw_title: &str,
asset_id: Option<&str>,
icon_kind: &str,
resource_kind: Option<&str>,
) -> String {
let title = raw_title.trim();
let is_mindmap = icon_kind == "mindmap" || resource_kind == Some("mindmap");
let generated = title.starts_with("mindmap-") || title.starts_with("mindmap_");
if is_mindmap && (generated || title.chars().count() > 24) {
return short_mindmap_file_name(title, asset_id);
}
if title.is_empty() {
"无标题".to_string()
} else {
title.to_string()
}
}
pub(crate) fn collect_filetree_render_rows( pub(crate) fn collect_filetree_render_rows(
projection: &Value, projection: &Value,
active_document_id: Option<&str>, active_document_id: Option<&str>,
active_row_id: Option<&str>,
) -> Vec<FileTreeRenderRow> { ) -> Vec<FileTreeRenderRow> {
let active_document_id = active_document_id let active_document_id = active_document_id
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(ToOwned::to_owned); .map(ToOwned::to_owned);
let selected_ids = active_document_id let active_row_id = active_row_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let selected_ids = active_row_id
.as_deref() .as_deref()
.map(|document_id| BTreeSet::from([format!("index:{document_id}")])) .map(|row_id| BTreeSet::from([row_id.to_string()]))
.or_else(|| {
active_document_id
.as_deref()
.map(|document_id| BTreeSet::from([format!("doc:{document_id}")]))
})
.unwrap_or_default(); .unwrap_or_default();
let allow_document_fallback = active_row_id.is_none();
let active_document_id_for_fallback = active_document_id
.as_deref()
.filter(|_| allow_document_fallback);
projection projection
.get("items") .get("items")
@@ -481,18 +544,47 @@ pub(crate) fn collect_filetree_render_rows(
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(ToOwned::to_owned); .map(ToOwned::to_owned);
let selected = selected_ids.contains(row_id) let selected = selected_ids.contains(row_id)
|| active_document_id || active_document_id_for_fallback.is_some_and(|active_id| {
.as_deref() document_id.as_deref() == Some(active_id)
.is_some_and(|active_id| document_id.as_deref() == Some(active_id)); && item
.get("rowKind")
.and_then(Value::as_str)
.is_some_and(|kind| kind == "document")
});
let row_kind = item
.get("rowKind")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("document")
.to_string();
let asset_id = resource_meta
.and_then(|meta| meta.get("assetId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let icon_kind = item
.get("iconHint")
.and_then(Value::as_str)
.or_else(|| {
resource_meta
.and_then(|meta| meta.get("iconHint"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("file")
.to_string();
let raw_title = item
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("无标题");
Some(FileTreeRenderRow { Some(FileTreeRenderRow {
row_id: row_id.to_string(), row_id: row_id.to_string(),
row_kind: item row_kind: row_kind.clone(),
.get("rowKind")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("document")
.to_string(),
node_id: node_id.to_string(), node_id: node_id.to_string(),
parent_node_id: item parent_node_id: item
.get("parentNodeId") .get("parentNodeId")
@@ -500,13 +592,14 @@ pub(crate) fn collect_filetree_render_rows(
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(ToOwned::to_owned), .map(ToOwned::to_owned),
title: item title: normalize_filetree_mindmap_title(
.get("title") raw_title,
.and_then(Value::as_str) asset_id.as_deref(),
.map(str::trim) &icon_kind,
.filter(|value| !value.is_empty()) resource_meta
.unwrap_or("无标题") .and_then(|meta| meta.get("resourceKind"))
.to_string(), .and_then(Value::as_str),
),
depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32, depth: item.get("depth").and_then(Value::as_u64).unwrap_or(0) as u32,
expandable: item expandable: item
.get("expandable") .get("expandable")
@@ -521,25 +614,9 @@ pub(crate) fn collect_filetree_render_rows(
.get("expandedByDefault") .get("expandedByDefault")
.and_then(Value::as_bool) .and_then(Value::as_bool)
.unwrap_or(false), .unwrap_or(false),
icon_kind: item icon_kind,
.get("iconHint")
.and_then(Value::as_str)
.or_else(|| {
resource_meta
.and_then(|meta| meta.get("iconHint"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("file")
.to_string(),
document_id, document_id,
asset_id: resource_meta asset_id,
.and_then(|meta| meta.get("assetId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
object_identity: resource_meta object_identity: resource_meta
.and_then(|meta| meta.get("objectIdentity")) .and_then(|meta| meta.get("objectIdentity"))
.and_then(|value| serde_json::to_string(value).ok()), .and_then(|value| serde_json::to_string(value).ok()),
@@ -634,6 +711,10 @@ fn build_tree_shell_command_dispatcher(channel: &str) -> TreeShellCommandDispatc
"tree.resource.copy", "tree.resource.copy",
"tree.resource.move", "tree.resource.move",
"tree.resource.upload", "tree.resource.upload",
"tree.resource.archive",
"tree.resource.restore",
"tree.resource.purge",
"tree.resource.rename",
] ]
.into_iter() .into_iter()
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
@@ -659,7 +740,7 @@ fn build_tree_shell_renderer_input(
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(|document_id| { .map(|document_id| {
FileTreeSelectionState::from_selected(&[format!("index:{document_id}")]) FileTreeSelectionState::from_selected(&[format!("doc:{document_id}")])
}) })
.unwrap_or_default(); .unwrap_or_default();
TreeShellRendererInput::filetree(FileTreeRendererInput { TreeShellRendererInput::filetree(FileTreeRendererInput {
@@ -785,7 +866,7 @@ fn build_tree_shell_html(
.map(ToOwned::to_owned), .map(ToOwned::to_owned),
}), }),
"filetree" => render_initial_filetree_html(&FileTreeInitialRenderInput { "filetree" => render_initial_filetree_html(&FileTreeInitialRenderInput {
rows: collect_filetree_render_rows(projection, active_document_id), rows: collect_filetree_render_rows(projection, active_document_id, None),
}), }),
"picker" => render_initial_picker_html(&PickerInitialRenderInput { "picker" => render_initial_picker_html(&PickerInitialRenderInput {
rows: collect_picker_render_rows( rows: collect_picker_render_rows(
@@ -1620,9 +1701,9 @@ fn build_tree_shell_html(
typeof state.commandPath === "string" && state.commandPath.trim() typeof state.commandPath === "string" && state.commandPath.trim()
? state.commandPath.trim() ? state.commandPath.trim()
: "/api/tree/commands"; : "/api/tree/commands";
const mediaAssets = Array.isArray(state.mediaAssets) ? state.mediaAssets : []; let mediaAssets = Array.isArray(state.mediaAssets) ? state.mediaAssets : [];
const mindmapAssets = Array.isArray(state.mindmapAssets) ? state.mindmapAssets : []; let mindmapAssets = Array.isArray(state.mindmapAssets) ? state.mindmapAssets : [];
const tableAssets = Array.isArray(state.tableAssets) ? state.tableAssets : []; let tableAssets = Array.isArray(state.tableAssets) ? state.tableAssets : [];
const mindmapAssetChildren = const mindmapAssetChildren =
state.mindmapAssetChildren && typeof state.mindmapAssetChildren === "object" state.mindmapAssetChildren && typeof state.mindmapAssetChildren === "object"
? state.mindmapAssetChildren ? state.mindmapAssetChildren
@@ -1711,78 +1792,87 @@ fn build_tree_shell_html(
}; };
}; };
const rawItems = Array.isArray(hostOverride.items) ? hostOverride.items : state.items;
const normalizedItems = Array.isArray(rawItems)
? rawItems
.map((item) => {
const nodeId = normalizeText(item?.nodeId);
const resourceMeta = normalizeResourceMeta(item?.resourceMeta);
const rowKind = normalizeRowKind(item?.rowKind);
const fallbackRowId =
rowKind === "index"
? `index:${resourceMeta.documentId || nodeId.replace(/^index:/, "")}`
: rowKind === "asset"
? `asset:${resourceMeta.assetId || nodeId.replace(/^asset:/, "")}`
: rowKind === "asset_folder"
? `asset-folder:${resourceMeta.assetId || nodeId.replace(/^asset-folder:/, "")}`
: `doc:${resourceMeta.documentId || nodeId}`;
return {
rowId: normalizeText(item?.rowId, fallbackRowId),
rowKind,
nodeId,
parentNodeId: normalizeParent(item?.parentNodeId),
title: normalizeText(item?.title, rowKind === "index" ? "index.md" : "无标题"),
depth: normalizeNumber(item?.depth, 0),
childCount: normalizeNumber(item?.childCount, 0),
position: normalizeNumber(item?.position),
expandedByDefault: item?.expandedByDefault !== false,
iconHint: normalizeText(item?.iconHint),
capabilities: normalizeCapabilities(item?.capabilities),
resourceMeta,
};
})
.filter((item) => item.nodeId && !excludedIds.has(item.nodeId))
: [];
const itemById = new Map(normalizedItems.map((item) => [item.nodeId, item]));
const fileTreeRowById = new Map(normalizedItems.map((item) => [item.rowId, item]));
const childrenByParentId = new Map();
const roots = [];
const assetsByDocId = new Map();
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
const documentId = normalizeText(asset?.document_id);
const assetId = normalizeText(asset?.id);
if (!documentId || !assetId) return;
const bucket = assetsByDocId.get(documentId) || [];
bucket.push({
id: assetId,
documentId,
assetType: normalizeText(asset?.asset_type, "file"),
fileName: normalizeText(asset?.file_name, "附件"),
storagePath: normalizeText(asset?.storage_path),
});
assetsByDocId.set(documentId, bucket);
});
const compareItems = (left, right) => { const compareItems = (left, right) => {
const byPosition = left.position - right.position; const byPosition = left.position - right.position;
if (byPosition !== 0) return byPosition; if (byPosition !== 0) return byPosition;
return left.title.localeCompare(right.title, "zh-CN"); return left.title.localeCompare(right.title, "zh-CN");
}; };
normalizedItems.forEach((item) => { const normalizeTreeItems = (items) =>
const parentId = item.parentNodeId && itemById.has(item.parentNodeId) ? item.parentNodeId : null; Array.isArray(items)
if (!parentId) { ? items
roots.push(item); .map((item) => {
return; const nodeId = normalizeText(item?.nodeId);
} const resourceMeta = normalizeResourceMeta(item?.resourceMeta);
const bucket = childrenByParentId.get(parentId) || []; const rowKind = normalizeRowKind(item?.rowKind);
bucket.push(item); const fallbackRowId =
childrenByParentId.set(parentId, bucket); rowKind === "index"
}); ? `index:${resourceMeta.documentId || nodeId.replace(/^index:/, "")}`
: rowKind === "asset"
? `asset:${resourceMeta.assetId || nodeId.replace(/^asset:/, "")}`
: rowKind === "asset_folder"
? `asset-folder:${resourceMeta.assetId || nodeId.replace(/^asset-folder:/, "")}`
: `doc:${resourceMeta.documentId || nodeId}`;
return {
rowId: normalizeText(item?.rowId, fallbackRowId),
rowKind,
nodeId,
parentNodeId: normalizeParent(item?.parentNodeId),
title: normalizeText(item?.title, rowKind === "index" ? "index.md" : "无标题"),
depth: normalizeNumber(item?.depth, 0),
childCount: normalizeNumber(item?.childCount, 0),
position: normalizeNumber(item?.position),
expandedByDefault: item?.expandedByDefault !== false,
iconHint: normalizeText(item?.iconHint),
capabilities: normalizeCapabilities(item?.capabilities),
resourceMeta,
};
})
.filter((item) => item.nodeId && !excludedIds.has(item.nodeId))
: [];
roots.sort(compareItems); let rawItems = Array.isArray(hostOverride.items) ? hostOverride.items : state.items;
childrenByParentId.forEach((bucket) => bucket.sort(compareItems)); let normalizedItems = normalizeTreeItems(rawItems);
let itemById = new Map();
let fileTreeRowById = new Map();
let childrenByParentId = new Map();
let roots = [];
let assetsByDocId = new Map();
const rebuildTreeIndexes = () => {
itemById = new Map(normalizedItems.map((item) => [item.nodeId, item]));
fileTreeRowById = new Map(normalizedItems.map((item) => [item.rowId, item]));
childrenByParentId = new Map();
roots = [];
assetsByDocId = new Map();
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
const documentId = normalizeText(asset?.document_id);
const assetId = normalizeText(asset?.id);
if (!documentId || !assetId) return;
const bucket = assetsByDocId.get(documentId) || [];
bucket.push({
id: assetId,
documentId,
assetType: normalizeText(asset?.asset_type, "file"),
fileName: normalizeText(asset?.file_name, "附件"),
storagePath: normalizeText(asset?.storage_path),
});
assetsByDocId.set(documentId, bucket);
});
normalizedItems.forEach((item) => {
const parentId = item.parentNodeId && itemById.has(item.parentNodeId) ? item.parentNodeId : null;
if (!parentId) {
roots.push(item);
return;
}
const bucket = childrenByParentId.get(parentId) || [];
bucket.push(item);
childrenByParentId.set(parentId, bucket);
});
roots.sort(compareItems);
childrenByParentId.forEach((bucket) => bucket.sort(compareItems));
};
rebuildTreeIndexes();
const rendererExpandedIds = normalizeStringArray(rendererInput.expandedIds); const rendererExpandedIds = normalizeStringArray(rendererInput.expandedIds);
const expanded = new Set( const expanded = new Set(
@@ -1840,10 +1930,10 @@ fn build_tree_shell_html(
let selectedFileTreeRowIds = new Set( let selectedFileTreeRowIds = new Set(
rendererSelectedFileTreeRowIds.length > 0 rendererSelectedFileTreeRowIds.length > 0
? rendererSelectedFileTreeRowIds ? rendererSelectedFileTreeRowIds
: currentActiveDocumentId ? [`index:${currentActiveDocumentId}`] : [] : currentActiveDocumentId ? [`doc:${currentActiveDocumentId}`] : []
); );
let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? `index:${currentActiveDocumentId}` : null); let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null);
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? `index:${currentActiveDocumentId}` : null); let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null);
let visibleFileTreeRowIds = []; let visibleFileTreeRowIds = [];
let draggingPageNodeId = ""; let draggingPageNodeId = "";
let activePageDropNodeId = null; let activePageDropNodeId = null;
@@ -2792,7 +2882,7 @@ fn build_tree_shell_html(
const rowIds = resolveFileTreeActionRowIds(rowId); const rowIds = resolveFileTreeActionRowIds(rowId);
const deletableItems = rowIds const deletableItems = rowIds
.map((id) => fileTreeRowById.get(id)) .map((id) => fileTreeRowById.get(id))
.filter((item) => Boolean(getFileTreeRowDocumentId(item))); .filter((item) => Boolean(getFileTreeRowDocumentId(item)) || (sourceKind === "local_folder" && item?.rowKind === "asset"));
if (deletableItems.length === 0) { if (deletableItems.length === 0) {
setLastAction("当前选择没有可删除的页面或 Markdown 文件", "error"); setLastAction("当前选择没有可删除的页面或 Markdown 文件", "error");
return false; return false;
@@ -2806,7 +2896,7 @@ fn build_tree_shell_html(
await sendCommand({ await sendCommand({
action: "delete", action: "delete",
workspaceId, workspaceId,
documentId: getFileTreeRowDocumentId(item), documentId: getFileTreeRowDocumentId(item) || item.rowId,
}); });
} }
scheduleRefresh(); scheduleRefresh();
@@ -2977,16 +3067,62 @@ fn build_tree_shell_html(
} }
}; };
const parseTreeShellStateFromHtml = (html) => {
const doc = new DOMParser().parseFromString(html, "text/html");
const nextStateElement = doc.getElementById("tree-shell-state");
if (!nextStateElement) return null;
try {
return JSON.parse(nextStateElement.textContent || "{}");
} catch {
return null;
}
};
const applyTreeShellStateSnapshot = (nextState, options = {}) => {
if (!nextState || typeof nextState !== "object") return false;
mediaAssets = Array.isArray(nextState.mediaAssets) ? nextState.mediaAssets : [];
mindmapAssets = Array.isArray(nextState.mindmapAssets) ? nextState.mindmapAssets : [];
tableAssets = Array.isArray(nextState.tableAssets) ? nextState.tableAssets : [];
rawItems = Array.isArray(nextState.items) ? nextState.items : [];
normalizedItems = normalizeTreeItems(rawItems);
rebuildTreeIndexes();
if (currentActiveDocumentId && !itemById.has(currentActiveDocumentId)) {
currentActiveDocumentId = roots[0]?.nodeId || "";
}
if (currentFocusedDocumentId && !itemById.has(currentFocusedDocumentId)) {
currentFocusedDocumentId = currentActiveDocumentId;
}
normalizedItems
.filter((item) => item.childCount > 0 && item.expandedByDefault)
.forEach((item) => expanded.add(item.nodeId));
focusedNodeId = resolveFocusedNodeIdFromHostState();
renderTree();
if (mode === "filetree") {
emitFileTreeSelectionChange();
}
const renameRowId = normalizeText(options.renameRowId);
if (renameRowId) {
window.setTimeout(() => {
if (fileTreeRowById.has(renameRowId)) {
beginInlineRename("filetree", renameRowId);
}
}, 80);
}
return true;
};
const refreshLocalFolderSnapshot = async (options = {}) => {
const response = await fetch(window.location.href, {
headers: { "accept": "text/html" },
});
if (!response.ok) return false;
const nextState = parseTreeShellStateFromHtml(await response.text());
return applyTreeShellStateSnapshot(nextState, options);
};
const scheduleRefresh = (options = {}) => { const scheduleRefresh = (options = {}) => {
window.setTimeout(() => { window.setTimeout(() => {
const renameRowId = normalizeText(options.renameRowId); void refreshLocalFolderSnapshot(options);
if (renameRowId) {
const url = new URL(window.location.href);
url.searchParams.set("renameRowId", renameRowId);
window.location.assign(url.toString());
return;
}
window.location.reload();
}, 80); }, 80);
}; };
@@ -3103,7 +3239,8 @@ fn build_tree_shell_html(
const refreshFromLocalWatch = () => { const refreshFromLocalWatch = () => {
if (localWatchRefreshTimer) return; if (localWatchRefreshTimer) return;
localWatchRefreshTimer = window.setTimeout(() => { localWatchRefreshTimer = window.setTimeout(() => {
window.location.reload(); localWatchRefreshTimer = 0;
void refreshLocalFolderSnapshot();
}, 180); }, 180);
}; };
const pollLocalFolderRevision = async () => { const pollLocalFolderRevision = async () => {
@@ -4021,7 +4158,8 @@ fn build_tree_shell_html(
const canDelete = const canDelete =
rowKind === "markdown" || rowKind === "markdown" ||
rowKind === "document" || rowKind === "document" ||
rowKind === "index"; rowKind === "index" ||
(localSource && rowKind === "asset");
if (rowKind === "root") { if (rowKind === "root") {
return [ return [
@@ -4193,7 +4331,7 @@ fn build_tree_shell_html(
return result; return result;
} }
if (kind === "refresh") { if (kind === "refresh") {
window.location.reload(); await refreshLocalFolderSnapshot();
return; return;
} }
if (kind === "collapseAll") { if (kind === "collapseAll") {
@@ -5965,6 +6103,66 @@ pub async fn local_folder_watch(
)) ))
} }
pub async fn filetree_drop_preflight(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let workspace_id = payload
.get("workspaceId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| context.workspace.workspace_id.clone())
.ok_or_else(|| {
WebError::bad_request_code(
"filetree_drop_preflight_workspace_missing",
"缺少 workspaceId",
)
.with_context(&context)
})?;
let target_document_id = payload
.get("targetDocumentId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let envelope_context = TreeCommandEnvelopeContext::default();
let command = RuntimeCommandEnvelopeWire {
name: "tree.filetree.drop.preflight".into(),
command_id: format!("filetree_drop_preflight_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: bridge_runtime::RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: build_workspace_source_wire(&context, &workspace_id, &envelope_context),
target: Some(build_tree_target(&workspace_id, target_document_id, None)),
payload,
preflight_data: None,
reason: Some("filetree-drop-preflight tree.filetree.drop.preflight".into()),
refs: vec!["file-tree-shell".into()],
dry_run: true,
validate_only: true,
};
let plan = build_runtime_command_plan(&context, Some(&workspace_id), command)?;
let file_tree_drop_plan = plan
.args_json
.get("fileTreeDropPlan")
.cloned()
.ok_or_else(|| {
WebError::internal("filetree drop preflight 未返回计划").with_context(&context)
})?;
Ok((
StatusCode::OK,
Json(json!({
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"plan": file_tree_drop_plan,
})),
))
}
pub async fn tree_shell( pub async fn tree_shell(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
@@ -6721,7 +6919,10 @@ pub async fn reduce_tree_shell_runtime(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{create_command_wire, TreeCommandEnvelopeContext, TreeCommandRequest}; use super::{
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
TreeCommandRequest,
};
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::routes::command_support::build_runtime_command_plan; use crate::routes::command_support::build_runtime_command_plan;
@@ -6752,6 +6953,61 @@ mod tests {
})) }))
} }
#[test]
fn filetree_rows_select_active_mindmap_asset_in_object_shell() {
let projection = serde_json::json!({
"items": [
{
"rowId": "doc:doc_1",
"rowKind": "document",
"nodeId": "doc_1",
"title": "页面.md",
"resourceMeta": {
"documentId": "doc_1",
"objectIdentity": {
"objectKind": "page",
"documentId": "doc_1",
"assetId": null
}
}
},
{
"rowId": "asset:mind_1",
"rowKind": "asset",
"nodeId": "asset:mind_1",
"parentNodeId": "doc_1",
"title": "思维导图.json",
"iconHint": "mindmap",
"resourceMeta": {
"documentId": "doc_1",
"assetId": "mind_1",
"objectIdentity": {
"objectKind": "mindmap",
"documentId": "doc_1",
"assetId": "mind_1"
}
}
}
]
});
let rows = collect_filetree_render_rows(&projection, Some("doc_1"), Some("asset:mind_1"));
let doc_row = rows
.iter()
.find(|row| row.row_id == "doc:doc_1")
.expect("doc row");
let mindmap_row = rows
.iter()
.find(|row| row.row_id == "asset:mind_1")
.expect("mindmap row");
assert!(
!doc_row.selected,
"对象页应避免把父页面行重新选中,防止 mindmap 文件行点击后闪回父页面"
);
assert!(mindmap_row.selected, "mindmap 对象页应保持 asset row 选中");
}
#[tokio::test] #[tokio::test]
async fn tree_shell_returns_interactive_html_document() { async fn tree_shell_returns_interactive_html_document() {
let response = app() let response = app()
@@ -6930,6 +7186,42 @@ mod tests {
assert!(html.contains("data-document-id=\"local-md:README.md\"")); assert!(html.contains("data-document-id=\"local-md:README.md\""));
} }
#[tokio::test]
async fn local_folder_tree_shell_does_not_reload_page_for_refresh() {
let root = std::env::temp_dir().join(format!(
"mnote-local-folder-no-reload-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create local folder root");
std::fs::write(root.join("README.md"), "# Local Root\n").expect("write local md");
std::fs::write(root.join("asset.txt"), "asset").expect("write local asset");
let root_uri = format!("file://{}", root.display());
let response = app()
.oneshot(
Request::builder()
.uri(format!(
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
))
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let _ = std::fs::remove_dir_all(&root);
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("/api/tree/local-folder-watch"));
assert!(html.contains("refreshLocalFolderSnapshot"));
assert!(!html.contains("window.location.reload"));
}
#[tokio::test] #[tokio::test]
async fn tree_shell_page_mode_can_open_local_folder_md_only_snapshot() { async fn tree_shell_page_mode_can_open_local_folder_md_only_snapshot() {
let root = std::env::temp_dir().join(format!( let root = std::env::temp_dir().join(format!(
@@ -7160,6 +7452,115 @@ mod tests {
let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&root);
} }
#[tokio::test]
async fn tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index() {
let root =
std::env::temp_dir().join(format!("mnote-local-asset-trash-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::write(root.join("docs").join("photo.png"), b"png").expect("write asset");
let root_uri = format!("file://{}", root.display());
let asset_id = "local:asset:docs/photo.png";
let delete_response = app()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
)))
.expect("request"),
)
.await
.expect("response");
let delete_status = delete_response.status();
let delete_body = axum::body::to_bytes(delete_response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(
delete_status,
StatusCode::OK,
"{}",
String::from_utf8_lossy(&delete_body)
);
let delete_payload: Value = serde_json::from_slice(&delete_body).expect("delete json");
assert_eq!(
delete_payload["result"]["execution"]["canonicalCommand"],
"tree.resource.archive"
);
assert_eq!(
delete_payload["result"]["execution"]["resourceKind"],
"local_file"
);
assert_eq!(
delete_payload["result"]["execution"]["originalFilePath"],
"docs/photo.png"
);
assert!(!root.join("docs").join("photo.png").exists());
assert!(root.join(".mnote").join("trash").join("photo.png").exists());
let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
.expect("trash index");
assert!(trash_index.contains("local-file:docs/photo.png"));
assert!(trash_index.contains("resourceKind"));
let restore_response = app()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"action":"restore","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
)))
.expect("request"),
)
.await
.expect("response");
assert_eq!(restore_response.status(), StatusCode::OK);
assert!(root.join("docs").join("photo.png").exists());
assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
let delete_again_response = app()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
)))
.expect("request"),
)
.await
.expect("response");
assert_eq!(delete_again_response.status(), StatusCode::OK);
let purge_response = app()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/tree/commands")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"action":"purge","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
)))
.expect("request"),
)
.await
.expect("response");
assert_eq!(purge_response.status(), StatusCode::OK);
assert!(!root.join("docs").join("photo.png").exists());
assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
let trash_index_after =
std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
.expect("trash index after purge");
assert!(!trash_index_after.contains("local-file:docs/photo.png"));
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test] #[tokio::test]
async fn tree_command_local_folder_root_escape_returns_unified_error_envelope() { async fn tree_command_local_folder_root_escape_returns_unified_error_envelope() {
let root = std::env::temp_dir().join(format!( let root = std::env::temp_dir().join(format!(
@@ -7233,12 +7634,11 @@ mod tests {
assert!(filetree_html.contains("\"rendererInput\"")); assert!(filetree_html.contains("\"rendererInput\""));
assert!(filetree_html.contains("\"mode\":\"fileTree\"")); assert!(filetree_html.contains("\"mode\":\"fileTree\""));
assert!(filetree_html.contains("\"filetreeSelection\"")); assert!(filetree_html.contains("\"filetreeSelection\""));
assert!(filetree_html.contains("\"selectedRowIds\":[\"index:page_root\"]")); assert!(filetree_html.contains("\"selectedRowIds\":[\"doc:page_root\"]"));
assert!(!filetree_html.contains("\"selectedRowIds\":[\"doc:page_root\"")); assert!(!filetree_html.contains("\"selectedRowIds\":[\"index:page_root\""));
assert!(filetree_html.contains("\"focusedRowId\":\"index:page_root\"")); assert!(filetree_html.contains("\"focusedRowId\":\"doc:page_root\""));
assert!(filetree_html.contains("data-row-id=\"doc:page_root\"")); assert!(filetree_html.contains("data-row-id=\"doc:page_root\""));
assert!(filetree_html.contains("data-row-id=\"index:page_root\"")); assert!(!filetree_html.contains("data-row-id=\"index:page_root\" data-row-kind=\"index\""));
assert!(filetree_html.contains("data-row-id=\"index:page_root\" data-row-kind=\"index\""));
assert!(filetree_html.contains("\"commandDispatcher\"")); assert!(filetree_html.contains("\"commandDispatcher\""));
assert!(filetree_html.contains("\"runtimeArtifact\"")); assert!(filetree_html.contains("\"runtimeArtifact\""));
assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\"")); assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
+206 -14
View File
@@ -131,9 +131,15 @@ pub async fn document_page_shell(
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id)) load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
.await .await
.unwrap_or_default(), .unwrap_or_default(),
load_file_tree_html(state.config(), &context, &workspace_id, Some(&document_id)) load_file_tree_html(
.await state.config(),
.unwrap_or_default(), &context,
&workspace_id,
Some(&document_id),
None,
)
.await
.unwrap_or_default(),
) )
}; };
let workspace_sidebar_html = render_workspace_shell_sidebar_html( let workspace_sidebar_html = render_workspace_shell_sidebar_html(
@@ -268,9 +274,8 @@ fn render_hermes_settings_config_script() -> String {
] ]
.into_iter() .into_iter()
.find_map(env_or_dotenv) .find_map(env_or_dotenv)
.map(|value| value.trim().trim_end_matches('/').to_string()) .map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty()) else {
else {
return String::new(); return String::new();
}; };
let settings_url = format!("{base_url}/hermes/settings"); let settings_url = format!("{base_url}/hermes/settings");
@@ -438,6 +443,11 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
}); });
}; };
const fileTreePageTitle = (value) => {
const normalized = String(value || '无标题').trim() || '无标题';
return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;
};
const updateVisibleTitle = (input, title, documentId) => { const updateVisibleTitle = (input, title, documentId) => {
const pane = input.closest('[data-document-pane="true"]'); const pane = input.closest('[data-document-pane="true"]');
const isPrimaryDocument = documentId && document.body?.dataset.documentId === documentId; const isPrimaryDocument = documentId && document.body?.dataset.documentId === documentId;
@@ -476,7 +486,7 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
const escapedId = cssEscape(documentId); const escapedId = cssEscape(documentId);
const escapedDocRowId = cssEscape(`doc:${documentId}`); const escapedDocRowId = cssEscape(`doc:${documentId}`);
setText(`.tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title); setText(`.tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
setText(`.tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, title); setText(`.tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title));
setText(`.wolai-page-row[data-node-id="${escapedId}"] > .wolai-row-title`, title); setText(`.wolai-page-row[data-node-id="${escapedId}"] > .wolai-row-title`, title);
setText(`a[href="/documents/${escapedId}"] > .wolai-row-title`, title); setText(`a[href="/documents/${escapedId}"] > .wolai-row-title`, title);
setText(`a[href^="/documents/${escapedId}?"] > .wolai-row-title`, title); setText(`a[href^="/documents/${escapedId}?"] > .wolai-row-title`, title);
@@ -1204,11 +1214,32 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const documentSessionRegistry = new Map(); const documentSessionRegistry = new Map();
const localFolderEventRegistry = new Map(); const localFolderEventRegistry = new Map();
const paneViewRegistry = new Map(); const paneViewRegistry = new Map();
const mindmapPaneViewRegistry = new Map();
let nextViewId = 1; let nextViewId = 1;
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突'; const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突'; const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突';
const SESSION_RELEASE_DELAY_MS = 1200; const SESSION_RELEASE_DELAY_MS = 1200;
const unmountMindmapPane = (paneRole) => {
const view = mindmapPaneViewRegistry.get(paneRole);
if (!view) return;
mindmapPaneViewRegistry.delete(paneRole);
if (view.mountId != null && view.runtime && typeof view.runtime.unmount === 'function') {
try {
view.runtime.unmount(view.mountId);
} catch (error) {
console.warn('mnote mindmap pane unmount failed', error);
}
}
if (view.root instanceof HTMLElement) {
view.root.removeAttribute('data-runtime-mount-id');
view.root.removeAttribute('data-mnote-object-editor');
view.root.removeAttribute('data-mnote-object-identity');
view.root.removeAttribute('data-mnote-mindmap-id');
view.root.replaceChildren();
}
};
const parseLocalFolderEventPayload = (event) => { const parseLocalFolderEventPayload = (event) => {
try { try {
return JSON.parse(String(event?.data || '{}')); return JSON.parse(String(event?.data || '{}'));
@@ -1565,7 +1596,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
cache: 'no-store', cache: 'no-store',
headers: { accept: 'application/json' }, headers: { accept: 'application/json' },
}); });
if (!response.ok) return; if (!response.ok) {
if (session.sourceKind === 'local_folder') {
markSessionExternalConflict(session, externalConflictMessage);
}
return;
}
const payload = await response.json(); const payload = await response.json();
const nextAggregate = payload?.result; const nextAggregate = payload?.result;
const nextBody = nextAggregate?.body || {}; const nextBody = nextAggregate?.body || {};
@@ -1627,10 +1663,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (!payload) return; if (!payload) return;
Array.from(channel.sessions.values()).forEach((targetSession) => { Array.from(channel.sessions.values()).forEach((targetSession) => {
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : ''; const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
if (documentId && documentId !== targetSession.documentId) return; const eventKind = String(payload.eventKind || '');
const mayAffectMissingDocument = eventKind.includes('Remove') || eventKind.includes('Name');
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
if (documentId && !targetsCurrentDocument && !mayAffectMissingDocument) return;
targetSession.lastExternalChangeSignalAt = Date.now(); targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true; targetSession.externalChangePending = true;
if (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving) { if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) {
markSessionExternalConflict(targetSession, externalConflictMessage); markSessionExternalConflict(targetSession, externalConflictMessage);
return; return;
} }
@@ -1897,6 +1936,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
}); });
if (runtimeDescriptor.paneRole === 'primary') { if (runtimeDescriptor.paneRole === 'primary') {
document.body.dataset.documentId = documentId; document.body.dataset.documentId = documentId;
document.body.dataset.mnoteShell = 'document';
delete document.body.dataset.mindmapId;
document.title = title; document.title = title;
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]'); const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title; if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
@@ -1910,12 +1951,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
document.querySelectorAll(`.tree-row[data-node-id="${escapedId}"], .tree-row[data-document-id="${escapedId}"], .tree-row[data-doc-id="${escapedId}"]`).forEach((row) => { document.querySelectorAll(`.tree-row[data-node-id="${escapedId}"], .tree-row[data-document-id="${escapedId}"], .tree-row[data-doc-id="${escapedId}"]`).forEach((row) => {
if (!(row instanceof HTMLElement)) return; if (!(row instanceof HTMLElement)) return;
if (row.getAttribute('data-shell-mode') === 'filetree') { if (row.getAttribute('data-shell-mode') === 'filetree') {
row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === `index:${documentId}`)); row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === `doc:${documentId}`));
} else { } else {
row.setAttribute('data-active', 'true'); row.setAttribute('data-active', 'true');
} }
}); });
} }
runtimeDescriptor.root.removeAttribute('data-mnote-object-editor');
runtimeDescriptor.root.removeAttribute('data-mnote-object-identity');
runtimeDescriptor.root.removeAttribute('data-mnote-mindmap-id');
}; };
const fetchPageAggregateForPane = async (descriptor) => { const fetchPageAggregateForPane = async (descriptor) => {
@@ -1930,6 +1974,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
}; };
const replacePaneDocument = async (paneRole, descriptor, options = {}) => { const replacePaneDocument = async (paneRole, descriptor, options = {}) => {
unmountMindmapPane(paneRole);
const runtime = await loadRuntime(); const runtime = await loadRuntime();
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`); const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`);
const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`); const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`);
@@ -2028,6 +2073,123 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
replaceUrlState(url); replaceUrlState(url);
}; };
const parseJsonScriptFromDocument = (doc, id) => {
const node = doc?.getElementById?.(id);
if (!node) return null;
try {
return JSON.parse(node.textContent || 'null');
} catch (error) {
console.warn(`mnote mindmap shell JSON 解析失败: ${id}`, error);
return null;
}
};
const fetchMindmapShellBootstrap = async (url) => {
const response = await fetch(url.toString(), {
cache: 'no-store',
credentials: 'include',
headers: { accept: 'text/html' },
});
if (!response.ok) throw new Error(`mindmap_shell_failed_${response.status}`);
const html = await response.text();
const parsed = new DOMParser().parseFromString(html, 'text/html');
const bootstrap = parseJsonScriptFromDocument(parsed, '__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__');
if (!bootstrap || typeof bootstrap !== 'object') throw new Error('mindmap_shell_missing_bootstrap');
const contract = parseJsonScriptFromDocument(parsed, '__MNOTE_MINDMAP_SHELL__') || {};
const title = String(bootstrap.title || parsed.querySelector('title')?.textContent || '思维导图').trim() || '思维导图';
return { bootstrap, contract, title };
};
const setPrimaryMindmapSelection = (documentId, mindmapId) => {
const cssEscape = window.CSS && typeof window.CSS.escape === 'function' ? window.CSS.escape : (value) => String(value).replace(/["\\]/g, '\\$&');
const escapedDocId = cssEscape(documentId);
const escapedMindmapId = cssEscape(mindmapId);
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => {
if (!(row instanceof HTMLElement)) return;
row.setAttribute('data-active', 'false');
row.setAttribute('data-selected', 'false');
});
document.querySelectorAll(`.tree-row[data-shell-mode="page"][data-node-id="${escapedDocId}"]`).forEach((row) => {
if (row instanceof HTMLElement) row.setAttribute('data-active', 'true');
});
document.querySelectorAll(`.tree-row[data-shell-mode="filetree"][data-asset-id="${escapedMindmapId}"]`).forEach((row) => {
if (row instanceof HTMLElement) row.setAttribute('data-selected', 'true');
});
};
const updatePrimaryMindmapChrome = ({ documentId, mindmapId, workspaceId, title, root }) => {
const pane = root.closest('[data-document-pane="true"]');
if (pane instanceof HTMLElement) {
pane.setAttribute('data-pane-document-id', `__mindmap_object__:${documentId}:${mindmapId}`);
pane.setAttribute('data-pane-workspace-id', workspaceId || '');
pane.setAttribute('data-pane-visible', 'true');
pane.hidden = false;
}
const shell = root.closest('.document-shell');
if (shell instanceof HTMLElement) {
shell.setAttribute('data-editor-host', 'mindmap_object');
shell.setAttribute('data-document-id', documentId);
shell.setAttribute('data-workspace-id', workspaceId || '');
shell.setAttribute('data-mindmap-id', mindmapId);
}
document.body.dataset.documentId = documentId;
document.body.dataset.mindmapId = mindmapId;
document.body.dataset.mnoteShell = 'mindmap';
document.title = title;
document.querySelectorAll('[data-page-title-input="true"][data-pane-role="primary"]').forEach((node) => {
if (!(node instanceof HTMLTextAreaElement)) return;
node.value = title;
node.setAttribute('data-document-id', documentId);
node.setAttribute('data-workspace-id', workspaceId || '');
node.setAttribute('data-title-last-saved', title);
node.setAttribute('data-title-save-status', 'saved');
node.style.height = 'auto';
node.style.height = `${Math.max(48, node.scrollHeight)}px`;
});
document.querySelectorAll('[data-page-title-current="true"]').forEach((node) => {
if (node instanceof HTMLElement) node.textContent = title;
});
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
root.setAttribute('data-mnote-object-editor', 'mindmap');
root.setAttribute('data-mnote-object-identity', `resource:mindmap:${documentId}:${mindmapId}`);
root.setAttribute('data-mnote-mindmap-id', mindmapId);
setPrimaryMindmapSelection(documentId, mindmapId);
};
const replacePrimaryPaneMindmap = async ({ documentId, mindmapId, workspaceId, url }) => {
const targetUrl = url instanceof URL
? url
: new URL(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, window.location.origin);
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="primary"]`);
const observability = document.querySelector('[data-editor-host-observability][data-pane-role="primary"]');
if (!(root instanceof HTMLElement)) throw new Error('primary_pane_root_missing');
const runtime = await loadRuntime();
const { bootstrap, title } = await fetchMindmapShellBootstrap(targetUrl);
const previousView = paneViewRegistry.get('primary');
if (previousView) {
unmountEditorViewBinding(previousView);
paneViewRegistry.delete('primary');
}
unmountMindmapPane('primary');
if (observability instanceof HTMLElement) {
observability.setAttribute('data-editor-host-active', 'mindmap_object');
observability.setAttribute('data-editor-host-status', 'mounting');
}
root.replaceChildren();
root.setAttribute('data-runtime-editor-status', 'booting');
updatePrimaryMindmapChrome({ documentId, mindmapId, workspaceId, title, root });
const mountId = runtime.mount(root, bootstrap);
root.setAttribute('data-runtime-mount-id', String(mountId));
root.setAttribute('data-editor-host-kind', 'mindmap_object');
if (observability instanceof HTMLElement) {
observability.setAttribute('data-editor-host-status', 'mounted');
}
mindmapPaneViewRegistry.set('primary', { paneRole: 'primary', root, runtime, mountId });
pushUrlState(targetUrl);
return true;
};
const handleSessionChange = (session, view, event) => { const handleSessionChange = (session, view, event) => {
const payload = normalizeEnvelopePayload(event); const payload = normalizeEnvelopePayload(event);
if (!payload) return; if (!payload) return;
@@ -2221,6 +2383,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
updatePrimaryUrl(descriptor, url instanceof URL ? url : null); updatePrimaryUrl(descriptor, url instanceof URL ? url : null);
return true; return true;
}, },
openPrimaryMindmap: async ({ documentId, mindmapId, workspaceId, url } = {}) => {
const docId = typeof documentId === 'string' ? documentId.trim() : '';
const mapId = typeof mindmapId === 'string' ? mindmapId.trim() : '';
if (!docId || !mapId) return false;
await replacePrimaryPaneMindmap({
documentId: docId,
mindmapId: mapId,
workspaceId: typeof workspaceId === 'string' ? workspaceId.trim() : '',
url,
});
return true;
},
openSecondaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => { openSecondaryDocument: async ({ documentId, workspaceId, sourceKind, rootUri, url } = {}) => {
const id = typeof documentId === 'string' ? documentId.trim() : ''; const id = typeof documentId === 'string' ? documentId.trim() : '';
if (!id) return false; if (!id) return false;
@@ -2610,6 +2784,7 @@ pub(crate) async fn load_file_tree_html(
context: &RequestContext, context: &RequestContext,
workspace_id: &str, workspace_id: &str,
active_document_id: Option<&str>, active_document_id: Option<&str>,
active_row_id: Option<&str>,
) -> Option<String> { ) -> Option<String> {
let spec = ProjectionSnapshotSpec { let spec = ProjectionSnapshotSpec {
workspace_id, workspace_id,
@@ -2654,7 +2829,7 @@ pub(crate) async fn load_file_tree_html(
Err(_) => None, Err(_) => None,
}; };
result.map(|(projection, dev_fixture)| { result.map(|(projection, dev_fixture)| {
let rows = collect_filetree_render_rows(&projection, active_document_id); let rows = collect_filetree_render_rows(&projection, active_document_id, active_row_id);
let html = render_initial_filetree_html(&FileTreeInitialRenderInput { rows }); let html = render_initial_filetree_html(&FileTreeInitialRenderInput { rows });
mark_dev_fixture_html(html, dev_fixture, "file-tree") mark_dev_fixture_html(html, dev_fixture, "file-tree")
}) })
@@ -2687,7 +2862,7 @@ pub(crate) fn render_local_file_tree_html(
active_document_id: Option<&str>, active_document_id: Option<&str>,
) -> Result<String, WebError> { ) -> Result<String, WebError> {
let snapshot = load_local_folder_file_tree_snapshot(root_uri)?; let snapshot = load_local_folder_file_tree_snapshot(root_uri)?;
let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id); let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id, None);
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput { Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
rows, rows,
})) }))
@@ -2806,6 +2981,15 @@ mod tests {
assert!(html.contains( assert!(html.contains(
r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"# r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"#
)); ));
assert!(html.contains("const fileTreePageTitle = (value) => {"));
assert!(html.contains(
"return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}.md`;"
));
assert!(html.contains(
r#".tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, fileTreePageTitle(title)"#
));
assert!(html.contains("row.getAttribute('data-row-id') === `doc:${documentId}`"));
assert!(!html.contains("row.getAttribute('data-row-id') === `index:${documentId}`"));
assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title")); assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title"));
assert!(html.contains("data-testid=\"wolai-page-settings-trigger\"")); assert!(html.contains("data-testid=\"wolai-page-settings-trigger\""));
assert!(html.contains("data-mnote-action=\"open-page-settings\"")); assert!(html.contains("data-mnote-action=\"open-page-settings\""));
@@ -2815,6 +2999,10 @@ mod tests {
assert!(html.contains("data-document-pane=\"true\"")); assert!(html.contains("data-document-pane=\"true\""));
assert!(html.contains("data-pane-role=\"primary\"")); assert!(html.contains("data-pane-role=\"primary\""));
assert!(html.contains("data-document-pane-resizer=\"true\"")); assert!(html.contains("data-document-pane-resizer=\"true\""));
assert!(html.contains("openPrimaryMindmap"));
assert!(html.contains("replacePrimaryPaneMindmap"));
assert!(html.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
assert!(html.contains("data-mnote-object-identity"));
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__")); assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
assert!(html.contains("mnote.tree_live_bootstrap.v1")); assert!(html.contains("mnote.tree_live_bootstrap.v1"));
@@ -2972,6 +3160,10 @@ mod tests {
assert!(html.contains("/api/local-folder/events")); assert!(html.contains("/api/local-folder/events"));
assert!(html.contains("new EventSource(url.toString())")); assert!(html.contains("new EventSource(url.toString())"));
assert!(html.contains("localFolderEventRegistry")); assert!(html.contains("localFolderEventRegistry"));
assert!(html
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
assert!(html.contains("mayAffectMissingDocument"));
assert!(html.contains("eventKind.includes('Remove') || eventKind.includes('Name')"));
assert!(html.contains("command: 'replaceContent'")); assert!(html.contains("command: 'replaceContent'"));
assert!(html.contains("external-change-conflict")); assert!(html.contains("external-change-conflict"));
assert!(!html.contains( assert!(!html.contains(
@@ -3012,7 +3204,7 @@ mod tests {
let sidebar_html = let sidebar_html =
super::load_sidebar_tree_html(&config, &context, "ws_demo", Some("doc_1")).await; super::load_sidebar_tree_html(&config, &context, "ws_demo", Some("doc_1")).await;
let filetree_html = let filetree_html =
super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1")).await; super::load_file_tree_html(&config, &context, "ws_demo", Some("doc_1"), None).await;
let workspace_projection = super::load_workspace_shell_projection( let workspace_projection = super::load_workspace_shell_projection(
&config, &config,
&context, &context,
@@ -182,19 +182,19 @@ mod tests {
selected: true, selected: true,
}, },
FileTreeRenderRow { FileTreeRenderRow {
row_id: "index:page_root".into(), row_id: "asset:mind_1".into(),
row_kind: "index".into(), row_kind: "asset".into(),
node_id: "index:page_root".into(), node_id: "asset:mind_1".into(),
parent_node_id: Some("page_root".into()), parent_node_id: Some("page_root".into()),
title: "index.md".into(), title: "思维导图.json".into(),
depth: 1, depth: 1,
expandable: false, expandable: false,
expanded: false, expanded: false,
icon_kind: "index".into(), icon_kind: "mindmap".into(),
document_id: Some("page_root".into()), document_id: Some("page_root".into()),
asset_id: None, asset_id: Some("mind_1".into()),
object_identity: Some( object_identity: Some(
r#"{"objectKind":"index","documentId":"page_root","blockId":null,"assetId":null}"#.into(), r#"{"objectKind":"mindmap","documentId":"page_root","blockId":null,"assetId":"mind_1"}"#.into(),
), ),
selected: false, selected: false,
}, },
@@ -204,9 +204,12 @@ mod tests {
assert!(html.contains("data-rust-filetree-renderer=\"initial_v1\"")); assert!(html.contains("data-rust-filetree-renderer=\"initial_v1\""));
assert!(html.contains("data-rust-rendered-row=\"filetree\"")); assert!(html.contains("data-rust-rendered-row=\"filetree\""));
assert!(html.contains("data-testid=\"filetree-doc-row\"")); assert!(html.contains("data-testid=\"filetree-doc-row\""));
assert!(html.contains("data-testid=\"filetree-index-row\"")); assert!(html.contains("data-testid=\"filetree-asset-row\""));
assert!(!html.contains("data-testid=\"filetree-index-row\""));
assert!(!html.contains("index.md"));
assert!(html.contains("data-doc-id=\"page_root\"")); assert!(html.contains("data-doc-id=\"page_root\""));
assert!(html.contains("data-object-identity=\"{&quot;objectKind&quot;:&quot;index&quot;")); assert!(html.contains("data-asset-id=\"mind_1\""));
assert!(html.contains("data-object-identity=\"{&quot;objectKind&quot;:&quot;mindmap&quot;"));
assert!(html.contains("tree-children")); assert!(html.contains("tree-children"));
assert!(html.contains("首页 &lt;安全&gt;")); assert!(html.contains("首页 &lt;安全&gt;"));
assert!(html.contains("data-selected=\"true\"")); assert!(html.contains("data-selected=\"true\""));
@@ -0,0 +1,135 @@
#!/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", "task438-filetree-title-md-active-reveal-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 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 createPage(page) {
await page.getByRole("button", { name: "新建页面" }).click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS });
const documentId = new URL(page.url()).pathname.split("/").filter(Boolean).pop();
assert(documentId, "新建后 URL 缺少 documentId");
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 },
);
return documentId;
}
async function readFileTreeState(page, documentId) {
return page.evaluate((id) => {
const root = document.getElementById("sidebar-file-tree-root");
const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
const indexRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="index:${CSS.escape(id)}"]`);
const selectedRows = Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-selected='true']")).map((node) => ({
rowId: node.getAttribute("data-row-id") || "",
rowKind: node.getAttribute("data-row-kind") || "",
title: node.textContent?.trim() || "",
}));
const rect = row instanceof HTMLElement ? row.getBoundingClientRect() : null;
const rootRect = root instanceof HTMLElement ? root.getBoundingClientRect() : null;
const visible = Boolean(rect && rootRect && rect.bottom >= rootRect.top && rect.top <= rootRect.bottom);
return {
rowExists: row instanceof HTMLElement,
rowId: row instanceof HTMLElement ? row.getAttribute("data-row-id") || "" : "",
rowKind: row instanceof HTMLElement ? row.getAttribute("data-row-kind") || "" : "",
rowTitle: row instanceof HTMLElement ? row.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
objectIdentity: row instanceof HTMLElement ? row.getAttribute("data-object-identity") || "" : "",
indexRowExists: indexRow instanceof HTMLElement,
selectedRows,
visible,
scrollTop: root instanceof HTMLElement ? root.scrollTop : null,
rootText: root instanceof HTMLElement ? root.textContent?.slice(0, 2000) || "" : "",
};
}, documentId);
}
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 navigationEvents = [];
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) navigationEvents.push(frame.url());
});
const result = { ok: false, baseUrl: BASE_URL, documentId: null, beforeClick: null, afterClick: null, navigationEvents };
try {
await quickLogin(page);
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await openFileTree(page);
const documentId = await createPage(page);
result.documentId = documentId;
await openFileTree(page);
result.beforeClick = await readFileTreeState(page, documentId);
assert.equal(result.beforeClick.rowExists, true, "File Tree 应出现页面 markdown row");
assert.equal(result.beforeClick.indexRowExists, false, "File Tree 不应再显示 index.md row");
assert.match(result.beforeClick.rowTitle, /\.md$/, "页面正文 row 应显示为 .md 文件名");
assert.match(result.beforeClick.objectIdentity, /"objectKind":"page"/, "页面正文 row 应保持 page object identity");
await page.evaluate((id) => {
const root = document.getElementById("sidebar-file-tree-root");
const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
if (root instanceof HTMLElement) root.scrollTop = root.scrollHeight;
if (row instanceof HTMLElement) row.scrollIntoView({ block: "nearest" });
const button = row instanceof HTMLElement ? row.querySelector('[data-rust-action="open"], .tree-link') : null;
if (button instanceof HTMLElement) button.click();
}, documentId);
await page.waitForFunction(
(id) => {
const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
return row instanceof HTMLElement && row.getAttribute("data-selected") === "true";
},
documentId,
{ timeout: UI_TIMEOUT_MS },
);
result.afterClick = await readFileTreeState(page, documentId);
assert.deepEqual(
result.afterClick.selectedRows.map((row) => row.rowId),
[`doc:${documentId}`],
"点击页面正文 row 后 selected 应留在 .md row",
);
assert.equal(result.afterClick.visible, true, "点击长列表下方页面后目标 row 应仍在视口内");
result.ok = true;
} finally {
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);
});
@@ -0,0 +1,173 @@
#!/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", "task439-filetree-title-md-rename-smoke");
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 createPage(page) {
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 page.waitForFunction(
(id) => Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`)),
documentId,
{ timeout: UI_TIMEOUT_MS },
);
return documentId;
}
async function beginRename(page, documentId) {
const selector = `#sidebar-file-tree-root .tree-row[data-row-id="doc:${cssString(documentId)}"]`;
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 },
);
const row = page.locator(selector).first();
await row.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS }).catch(async () => {
await page.waitForTimeout(100);
await page.locator(selector).first().scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
});
await row.click({ timeout: UI_TIMEOUT_MS }).catch(async () => {
await page.waitForTimeout(100);
await page.locator(selector).first().click({ timeout: UI_TIMEOUT_MS });
});
await page.keyboard.press("F2");
const input = page.locator(`${selector} .tree-rename-input`).first();
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return input;
}
async function readRenameState(page, documentId) {
return page.evaluate((id) => {
const fileRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(id)}"]`);
const validation = document.querySelector('[data-testid="tree-rename-validation"], [data-mnote-rename-validation]');
return {
documentTitle: document.title,
fileTreeTitle: fileRow instanceof HTMLElement ? fileRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
pageTreeTitle: pageRow instanceof HTMLElement ? pageRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
validationText: validation instanceof HTMLElement ? validation.textContent?.trim() || "" : "",
localApplied: document.documentElement.getAttribute("data-mnote-tree-local-command-applied") || "",
activeFileRowSelected: fileRow instanceof HTMLElement ? fileRow.getAttribute("data-selected") || "" : "",
};
}, documentId);
}
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("/api/tree/commands")) {
requests.push({ url: request.url(), postData: request.postData() || "" });
}
});
const result = { ok: false, baseUrl: BASE_URL, documentId: null, targetTitle: null, afterRename: null, invalidState: null, duplicateState: null, requests };
try {
await quickLogin(page);
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await openFileTree(page);
const documentId = await createPage(page);
result.documentId = documentId;
await openFileTree(page);
const targetTitle = `P2-Rename-${Date.now().toString().slice(-6)}`;
result.targetTitle = targetTitle;
let input = await beginRename(page, documentId);
await input.fill(`${targetTitle}.md`);
await input.press("Enter");
await page.waitForFunction(
({ id, expected }) => {
const fileRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(id)}"]`);
const fileTitle = fileRow instanceof HTMLElement ? fileRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "";
const pageTitle = pageRow instanceof HTMLElement ? pageRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "";
return fileTitle === `${expected}.md` && pageTitle === expected;
},
{ id: documentId, expected: targetTitle },
{ timeout: UI_TIMEOUT_MS },
);
result.afterRename = await readRenameState(page, documentId);
assert.equal(result.afterRename.fileTreeTitle, `${targetTitle}.md`, "File Tree 应显示 .md 文件名");
assert.equal(result.afterRename.pageTreeTitle, targetTitle, "Page Tree 标题不应带 .md");
input = await beginRename(page, documentId);
await input.fill("非法/名称.md");
await input.press("Enter");
result.invalidState = await readRenameState(page, documentId);
assert.match(result.invalidState.validationText, /不能包含|非法/, "非法文件名应显示结构化校验提示");
await page.keyboard.press("Escape").catch(() => {});
const duplicateDocumentId = await createPage(page);
await openFileTree(page);
const renameRequestCountBeforeDuplicate = requests.filter((entry) => {
try {
const body = JSON.parse(entry.postData || "{}");
return body.action === "rename";
} catch (_) {
return false;
}
}).length;
input = await beginRename(page, duplicateDocumentId);
await input.fill(`${targetTitle}.md`);
await input.press("Enter");
result.duplicateState = await readRenameState(page, duplicateDocumentId);
assert.match(result.duplicateState.validationText, /同级已存在/, "同级重名应显示结构化校验提示");
const renameRequestCountAfterDuplicate = requests.filter((entry) => {
try {
const body = JSON.parse(entry.postData || "{}");
return body.action === "rename";
} catch (_) {
return false;
}
}).length;
assert.equal(renameRequestCountAfterDuplicate, renameRequestCountBeforeDuplicate, "同级重名不应提交 rename command");
result.ok = true;
} finally {
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);
});
@@ -0,0 +1,166 @@
#!/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", "task440-page-title-filetree-md-sync-smoke");
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 createPage(page) {
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 page.waitForFunction(
(id) => Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`)),
documentId,
{ timeout: UI_TIMEOUT_MS },
);
return documentId;
}
async function readState(page, documentId) {
return page.evaluate((id) => {
const fileRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(id)}"]`);
const titleInput = document.querySelector(`[data-page-title-input="true"][data-document-id="${CSS.escape(id)}"]`);
return {
documentTitle: document.title,
topbarTitle: document.querySelector("[data-page-title-current='true']")?.textContent?.trim() || "",
titleInputValue: titleInput instanceof HTMLTextAreaElement ? titleInput.value : "",
fileTreeTitle: fileRow instanceof HTMLElement ? fileRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
pageTreeTitle: pageRow instanceof HTMLElement ? pageRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "",
titleLocalApplied: document.documentElement.getAttribute("data-mnote-title-local-applied") || "",
fileRowExists: fileRow instanceof HTMLElement,
indexRowExists: document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="index:${CSS.escape(id)}"]`) instanceof HTMLElement,
titleHistory: Array.isArray(window.__mnoteTask440TitleHistory) ? window.__mnoteTask440TitleHistory.slice() : [],
};
}, documentId);
}
async function watchFileTreeTitle(page, documentId) {
await page.evaluate((id) => {
const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
const title = row instanceof HTMLElement ? row.querySelector(".tree-link-title") : null;
window.__mnoteTask440TitleHistory = [];
const push = () => {
const text = title instanceof HTMLElement ? title.textContent?.trim() || "" : "";
if (!text) return;
const history = window.__mnoteTask440TitleHistory;
if (!Array.isArray(history)) return;
if (history[history.length - 1] !== text) history.push(text);
};
push();
if (title instanceof HTMLElement) {
const observer = new MutationObserver(push);
observer.observe(title, { childList: true, characterData: true, subtree: true });
window.__mnoteTask440TitleObserver = observer;
}
}, documentId);
}
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("/api/documents/title")) {
requests.push({ url: request.url(), postData: request.postData() || "" });
}
});
const result = { ok: false, baseUrl: BASE_URL, documentId: null, targetTitle: null, before: null, after: null, requests };
try {
await quickLogin(page);
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await openFileTree(page);
const documentId = await createPage(page);
result.documentId = documentId;
await openFileTree(page);
result.before = await readState(page, documentId);
assert.equal(result.before.fileRowExists, true, "File Tree 应出现页面 markdown row");
assert.equal(result.before.indexRowExists, false, "File Tree 不应显示旧 index row");
assert.match(result.before.fileTreeTitle, /\.md$/, "新建页面 File Tree 标题应是 .md 文件名");
await watchFileTreeTitle(page, documentId);
const targetTitle = `P3-Title-${Date.now().toString().slice(-6)}`;
result.targetTitle = targetTitle;
const titleInput = page.locator(`[data-page-title-input="true"][data-pane-role="primary"][data-document-id="${cssString(documentId)}"]`).first();
await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await titleInput.fill(targetTitle);
const titleSaveResponse = page.waitForResponse(
(response) =>
response.url().includes("/api/documents/title") &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes(targetTitle),
{ timeout: UI_TIMEOUT_MS },
);
await titleInput.evaluate((node) => node.blur());
await titleSaveResponse;
await page.waitForFunction(
({ id, expected }) => {
const fileRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(id)}"]`);
const pageRow = document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(id)}"]`);
const titleInput = document.querySelector(`[data-page-title-input="true"][data-document-id="${CSS.escape(id)}"]`);
const fileTitle = fileRow instanceof HTMLElement ? fileRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "";
const pageTitle = pageRow instanceof HTMLElement ? pageRow.querySelector(".tree-link-title")?.textContent?.trim() || "" : "";
const inputValue = titleInput instanceof HTMLTextAreaElement ? titleInput.value : "";
return fileTitle === `${expected}.md` && pageTitle === expected && inputValue === expected;
},
{ id: documentId, expected: targetTitle },
{ timeout: UI_TIMEOUT_MS },
);
result.after = await readState(page, documentId);
assert.equal(result.after.fileTreeTitle, `${targetTitle}.md`, "页面标题栏编辑后 File Tree 应同步显示 .md 文件名");
assert.equal(result.after.pageTreeTitle, targetTitle, "页面标题栏编辑后 Page Tree 不应带 .md");
assert.equal(result.after.titleInputValue, targetTitle, "页面标题输入框应保持页面标题");
assert.equal(result.after.titleLocalApplied, "true", "标题保存事件应触发 Sidebar 本地同步");
assert(!result.after.titleHistory.includes(targetTitle), `File Tree 标题同步过程中不应短暂显示裸标题:${JSON.stringify(result.after.titleHistory)}`);
assert(result.after.titleHistory.includes(`${targetTitle}.md`), `File Tree 标题变化历史应包含 .md 文件名:${JSON.stringify(result.after.titleHistory)}`);
result.ok = true;
} finally {
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);
});
@@ -0,0 +1,136 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
openFilesystemView,
renameDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
const TASK = "task443-filetree-mindmap-click-active-row-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUT_DIR, "result.json");
async function writeResult(result) {
await fs.mkdir(OUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
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 readSelectedFileTreeRows(page) {
return await page.evaluate(() =>
Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]')).map((row) => ({
rowId: row.getAttribute("data-row-id") || "",
rowKind: row.getAttribute("data-row-kind") || "",
docId: row.getAttribute("data-document-id") || row.getAttribute("data-doc-id") || "",
assetId: row.getAttribute("data-asset-id") || "",
objectIdentity: row.getAttribute("data-object-identity") || "",
title: (row.textContent || "").trim().slice(0, 160),
})),
);
}
(async () => {
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
const navEvents = [];
const network = [];
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) navEvents.push({ at: Date.now(), url: frame.url() });
});
page.on("request", (request) => {
const url = request.url();
if (url.includes("/mindmap/") || url.includes("/documents/") || url.includes("/api/tree/events")) {
network.push({ type: "request", method: request.method(), url });
}
});
let doc = null;
const result = {
baseUrl: BASE_URL,
fixture: {},
beforeSelectedRows: [],
afterSelectedRows: [],
navEvents,
network,
screenshots: [],
};
try {
await ensureAuthenticated(page, context.request);
doc = await createTempDocument(context.request, null);
const stamp = Date.now().toString().slice(-8);
const title = `TEST-443-mindmap-active-${stamp}`;
await renameDocument(context.request, doc.workspaceId, doc.documentId, title);
const mindmapId = `mindmap_443_active_${stamp}`;
await createMindmap(context.request, doc.workspaceId, doc.documentId, mindmapId, `TEST-443-mind-${stamp}`);
result.fixture = { ...doc, title, mindmapId };
await openDocument(page, doc.workspaceId, doc.documentId);
await openFilesystemView(page);
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS });
result.beforeSelectedRows = await readSelectedFileTreeRows(page);
await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname.includes(`/mindmap/${encodeURIComponent(doc.documentId)}/${encodeURIComponent(mindmapId)}`), {
timeout: UI_TIMEOUT_MS,
});
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"][data-selected="true"]`, {
timeout: UI_TIMEOUT_MS,
});
result.afterUrl = page.url();
result.afterSelectedRows = await readSelectedFileTreeRows(page);
const screenshotPath = path.join(OUT_DIR, "after-mindmap-click.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshots.push(screenshotPath);
assert(
result.afterSelectedRows.some((row) => row.rowId === `asset:${mindmapId}` && row.assetId === mindmapId),
`点击 mindmap 文件行后应保持 asset row 选中: ${JSON.stringify(result.afterSelectedRows)}`,
);
assert(
!result.afterSelectedRows.some((row) => row.rowId === `doc:${doc.documentId}`),
`点击 mindmap 文件行后不应闪回父页面行选中: ${JSON.stringify(result.afterSelectedRows)}`,
);
await writeResult({ ...result, ok: true });
console.log(`ok ${TASK} ${RESULT_PATH}`);
} catch (error) {
await writeResult({
...result,
ok: false,
error: error instanceof Error ? error.stack || error.message : String(error),
currentUrl: page.url(),
});
throw error;
} finally {
if (doc) await cleanupDocuments(context.request, [doc.documentId]).catch(() => null);
await browser.close().catch(() => null);
}
})().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
@@ -0,0 +1,200 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
openFilesystemView,
renameDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
const TASK = "task444-filetree-create-mindmap-title-cache-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUT_DIR, "result.json");
async function writeResult(result) {
await fs.mkdir(OUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
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 readFileTreeRow(page, documentId) {
return await page.evaluate((docId) => {
const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(docId)}"]`);
if (!(row instanceof HTMLElement)) return null;
const title = row.querySelector(":scope > .tree-link > .tree-link-title");
const rect = row.getBoundingClientRect();
return {
rowId: row.getAttribute("data-row-id") || "",
selected: row.getAttribute("data-selected") || "",
focused: row.getAttribute("data-focused") || "",
title: title instanceof HTMLElement ? (title.textContent || "").trim() : "",
visible: rect.width > 0 && rect.height > 0,
};
}, documentId);
}
async function createPageFromFileTree(page) {
await openFilesystemView(page);
const beforeIds = await page.evaluate(() =>
Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-row-id^="doc:"]')).map((row) =>
row.getAttribute("data-row-id") || "",
),
);
await page.waitForSelector('#sidebar-file-tree-root [data-testid="filetree-create"]', { state: "attached", timeout: UI_TIMEOUT_MS });
await page.evaluate(() => {
const button = document.querySelector('#sidebar-file-tree-root [data-testid="filetree-create"]');
if (!(button instanceof HTMLElement)) throw new Error("filetree_create_button_missing");
button.click();
});
await page.waitForURL((url) => url.pathname.includes("/documents/"), { timeout: UI_TIMEOUT_MS }).catch(() => null);
const after = await page.waitForFunction(
(knownIds) => {
const rows = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-row-id^="doc:"]'));
const created = rows.find((row) => {
const rowId = row.getAttribute("data-row-id") || "";
return rowId && !knownIds.includes(rowId);
});
if (!(created instanceof HTMLElement)) return null;
const title = created.querySelector(":scope > .tree-link > .tree-link-title");
return {
documentId: (created.getAttribute("data-document-id") || created.getAttribute("data-doc-id") || "").trim(),
rowId: created.getAttribute("data-row-id") || "",
title: title instanceof HTMLElement ? (title.textContent || "").trim() : "",
};
},
beforeIds,
{ timeout: UI_TIMEOUT_MS },
);
return await after.jsonValue();
}
(async () => {
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
const consoleMessages = [];
const navEvents = [];
page.on("console", (message) => {
const text = message.text();
if (message.type() === "error" || /activeRowId|ReferenceError|filetree|tree live/i.test(text)) {
consoleMessages.push({ type: message.type(), text: text.slice(0, 2000) });
}
});
page.on("pageerror", (error) => {
consoleMessages.push({ type: "pageerror", text: error instanceof Error ? error.stack || error.message : String(error) });
});
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) navEvents.push({ at: Date.now(), url: frame.url() });
});
const createdDocuments = [];
const result = {
baseUrl: BASE_URL,
fixture: {},
fileTreeCreated: null,
afterLive: null,
afterMindmapClick: null,
afterPageClick: null,
consoleMessages,
navEvents,
screenshots: [],
};
try {
await ensureAuthenticated(page, context.request);
const root = await createTempDocument(context.request, null);
createdDocuments.push(root.documentId);
const stamp = Date.now().toString().slice(-8);
await renameDocument(context.request, root.workspaceId, root.documentId, `TEST-444-root-${stamp}`);
const mindmapId = `mindmap_444_${stamp}`;
await createMindmap(context.request, root.workspaceId, root.documentId, mindmapId, `TEST-444-mind-${stamp}`);
result.fixture = { ...root, mindmapId };
await openDocument(page, root.workspaceId, root.documentId);
await openFilesystemView(page);
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS });
result.fileTreeCreated = await createPageFromFileTree(page);
assert(result.fileTreeCreated && result.fileTreeCreated.documentId, "文件树新建后未找到新页面 row");
createdDocuments.push(result.fileTreeCreated.documentId);
assert(
result.fileTreeCreated.title.endsWith(".md"),
`文件树新建后的本地 row 应显示 .md: ${JSON.stringify(result.fileTreeCreated)}`,
);
await page.waitForTimeout(1500);
result.afterLive = await readFileTreeRow(page, result.fileTreeCreated.documentId);
assert(result.afterLive && result.afterLive.title.endsWith(".md"), `live 后文件树标题不应丢 .md: ${JSON.stringify(result.afterLive)}`);
await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname.includes(`/mindmap/${encodeURIComponent(root.documentId)}/${encodeURIComponent(mindmapId)}`), {
timeout: UI_TIMEOUT_MS,
});
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"][data-selected="true"]`, {
timeout: UI_TIMEOUT_MS,
});
result.afterMindmapClick = await readFileTreeRow(page, result.fileTreeCreated.documentId);
await page.click(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${result.fileTreeCreated.documentId}"] .tree-link`, {
timeout: UI_TIMEOUT_MS,
});
await page.waitForURL((url) => url.pathname.includes(`/documents/${encodeURIComponent(result.fileTreeCreated.documentId)}`), {
timeout: UI_TIMEOUT_MS,
});
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${result.fileTreeCreated.documentId}"][data-selected="true"]`, {
timeout: UI_TIMEOUT_MS,
});
await page.waitForTimeout(500);
result.afterPageClick = await readFileTreeRow(page, result.fileTreeCreated.documentId);
assert(
result.afterPageClick && result.afterPageClick.title.endsWith(".md"),
`mindmap 后再点页面时文件树标题不应丢 .md: ${JSON.stringify(result.afterPageClick)}`,
);
assert(
!consoleMessages.some((message) => /activeRowId|ReferenceError/i.test(message.text)),
`不应出现 activeRowId/ReferenceError: ${JSON.stringify(consoleMessages)}`,
);
const screenshotPath = path.join(OUT_DIR, "after-page-click.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshots.push(screenshotPath);
await writeResult({ ...result, ok: true, finalUrl: page.url() });
console.log(`ok ${TASK} ${RESULT_PATH}`);
} catch (error) {
await writeResult({
...result,
ok: false,
currentUrl: page.url(),
error: error instanceof Error ? error.stack || error.message : String(error),
});
throw error;
} finally {
if (createdDocuments.length) await cleanupDocuments(context.request, createdDocuments).catch(() => null);
await browser.close().catch(() => null);
}
})().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
@@ -0,0 +1,177 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
openFilesystemView,
renameDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
const TASK = "task445-filetree-mindmap-switch-no-flicker-smoke";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUT_DIR, "result.json");
async function writeResult(result) {
await fs.mkdir(OUT_DIR, { recursive: true });
await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}
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 readFileTreeState(page, documentId, mindmapId) {
return await page.evaluate(
({ documentId: docId, mindmapId: mapId }) => {
const fileRoot = document.getElementById("sidebar-file-tree-root");
const pageRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(docId)}"]`);
const mindmapRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${CSS.escape(mapId)}"]`);
const titleNode = mindmapRow?.querySelector(":scope > .tree-link > .tree-link-title");
return {
fileRootStable: Boolean(fileRoot && fileRoot === window.__task445FileRoot),
bodyShell: document.body?.dataset?.mnoteShell || "",
currentPath: window.location.pathname,
pageSelected: pageRow instanceof HTMLElement ? pageRow.getAttribute("data-selected") || "" : "",
mindmapSelected: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-selected") || "" : "",
mindmapTitle: titleNode instanceof HTMLElement ? (titleNode.textContent || "").trim() : "",
objectEditor:
document.querySelector("[data-mnote-object-editor]")?.getAttribute("data-mnote-object-editor") ||
document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')?.getAttribute("data-mnote-object-editor") ||
"",
};
},
{ documentId, mindmapId },
);
}
(async () => {
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
const navEvents = [];
const consoleMessages = [];
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) navEvents.push({ at: Date.now(), url: frame.url() });
});
page.on("console", (message) => {
if (message.type() === "error" || /mindmap|navigation|ReferenceError/i.test(message.text())) {
consoleMessages.push({ type: message.type(), text: message.text().slice(0, 2000) });
}
});
page.on("pageerror", (error) => {
consoleMessages.push({ type: "pageerror", text: error instanceof Error ? error.stack || error.message : String(error) });
});
const createdDocuments = [];
const result = {
baseUrl: BASE_URL,
fixture: {},
before: null,
afterMindmap: null,
afterPage: null,
afterMindmapAgain: null,
navEvents,
consoleMessages,
screenshots: [],
failures: [],
};
const recordFailure = (code, detail) => {
result.failures.push({ code, detail });
};
try {
await ensureAuthenticated(page, context.request);
const doc = await createTempDocument(context.request, null);
createdDocuments.push(doc.documentId);
const otherDoc = await createTempDocument(context.request, null);
createdDocuments.push(otherDoc.documentId);
const stamp = Date.now().toString().slice(-8);
await renameDocument(context.request, doc.workspaceId, doc.documentId, `TEST-445-root-${stamp}`);
await renameDocument(context.request, otherDoc.workspaceId, otherDoc.documentId, `TEST-445-other-${stamp}`);
const mindmapId = `mindmap_445_${stamp}`;
await createMindmap(context.request, doc.workspaceId, doc.documentId, mindmapId, `TEST-445-mind-${stamp}`);
result.fixture = { ...doc, otherDocumentId: otherDoc.documentId, mindmapId };
await openDocument(page, doc.workspaceId, doc.documentId);
await openFilesystemView(page);
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS });
await page.evaluate(() => {
window.__task445FileRoot = document.getElementById("sidebar-file-tree-root");
});
result.before = await readFileTreeState(page, doc.documentId, mindmapId);
if (/^mindmap-mindmap[_-]/i.test(result.before.mindmapTitle)) {
recordFailure("mindmap_title_double_technical_prefix", result.before.mindmapTitle);
}
if (result.before.mindmapTitle.length > 24) {
recordFailure("mindmap_title_too_long", result.before.mindmapTitle);
}
await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname.includes(`/mindmap/${encodeURIComponent(doc.documentId)}/${encodeURIComponent(mindmapId)}`), {
timeout: UI_TIMEOUT_MS,
});
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"][data-selected="true"]`, {
timeout: UI_TIMEOUT_MS,
});
result.afterMindmap = await readFileTreeState(page, doc.documentId, mindmapId);
if (!result.afterMindmap.fileRootStable) recordFailure("mindmap_click_replaced_sidebar_root", result.afterMindmap);
await page.click(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${otherDoc.documentId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname.includes(`/documents/${encodeURIComponent(otherDoc.documentId)}`), { timeout: UI_TIMEOUT_MS });
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${otherDoc.documentId}"][data-selected="true"]`, {
timeout: UI_TIMEOUT_MS,
});
result.afterPage = await readFileTreeState(page, otherDoc.documentId, mindmapId);
if (!result.afterPage.fileRootStable) recordFailure("page_click_after_mindmap_replaced_sidebar_root", result.afterPage);
await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.pathname.includes(`/mindmap/${encodeURIComponent(doc.documentId)}/${encodeURIComponent(mindmapId)}`), {
timeout: UI_TIMEOUT_MS,
});
await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"][data-selected="true"]`, {
timeout: UI_TIMEOUT_MS,
});
result.afterMindmapAgain = await readFileTreeState(page, doc.documentId, mindmapId);
if (!result.afterMindmapAgain.fileRootStable) recordFailure("mindmap_click_again_replaced_sidebar_root", result.afterMindmapAgain);
const screenshotPath = path.join(OUT_DIR, "after-mindmap-again.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshots.push(screenshotPath);
assert(result.failures.length === 0, `task445 failures: ${JSON.stringify(result.failures, null, 2)}`);
await writeResult({ ...result, ok: true, finalUrl: page.url() });
console.log(`ok ${TASK} ${RESULT_PATH}`);
} catch (error) {
await writeResult({
...result,
ok: false,
currentUrl: page.url(),
error: error instanceof Error ? error.stack || error.message : String(error),
});
throw error;
} finally {
if (createdDocuments.length) await cleanupDocuments(context.request, createdDocuments).catch(() => null);
await browser.close().catch(() => null);
}
})().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
+3 -1
View File
@@ -78,6 +78,8 @@ function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
function toMindmapAsset(row: MindmapRow, workspaceId: string) { function toMindmapAsset(row: MindmapRow, workspaceId: string) {
const isLegacy = row.mindmap_id.startsWith("legacy-"); const isLegacy = row.mindmap_id.startsWith("legacy-");
const suffix = row.mindmap_id.match(/(\d{4,})$/)?.[1]?.slice(-4) ?? row.mindmap_id.replace(/^mindmap[_-]?/i, "").slice(-6);
const fileName = suffix ? `思维导图-${suffix}.json` : "思维导图.json";
return { return {
id: row.mindmap_id, id: row.mindmap_id,
workspace_id: row.workspace_id ?? workspaceId, workspace_id: row.workspace_id ?? workspaceId,
@@ -87,7 +89,7 @@ function toMindmapAsset(row: MindmapRow, workspaceId: string) {
thumbnail_url: null, thumbnail_url: null,
bucket: null, bucket: null,
storage_path: null, storage_path: null,
file_name: isLegacy ? "mindmap.json" : `mindmap-${row.mindmap_id}.json`, file_name: isLegacy ? "思维导图.json" : fileName,
file_size: null, file_size: null,
mime_type: "application/json", mime_type: "application/json",
ocr_payload: undefined, ocr_payload: undefined,
+3 -1
View File
@@ -191,6 +191,8 @@ export function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
function toMindmapAsset(row: MindmapRow, workspaceId: string): MediaAsset { function toMindmapAsset(row: MindmapRow, workspaceId: string): MediaAsset {
const isLegacy = row.mindmap_id.startsWith("legacy-"); const isLegacy = row.mindmap_id.startsWith("legacy-");
const suffix = row.mindmap_id.match(/(\d{4,})$/)?.[1]?.slice(-4) ?? row.mindmap_id.replace(/^mindmap[_-]?/i, "").slice(-6);
const fileName = suffix ? `思维导图-${suffix}.json` : "思维导图.json";
return { return {
id: row.mindmap_id, id: row.mindmap_id,
workspace_id: row.workspace_id ?? workspaceId, workspace_id: row.workspace_id ?? workspaceId,
@@ -200,7 +202,7 @@ function toMindmapAsset(row: MindmapRow, workspaceId: string): MediaAsset {
thumbnail_url: null, thumbnail_url: null,
bucket: null, bucket: null,
storage_path: null, storage_path: null,
file_name: isLegacy ? "mindmap.json" : `mindmap-${row.mindmap_id}.json`, file_name: isLegacy ? "思维导图.json" : fileName,
file_size: null, file_size: null,
mime_type: "application/json", mime_type: "application/json",
ocr_payload: undefined, ocr_payload: undefined,