From a29d9868f614b1b228500d1cad1172d59c735fa2 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Wed, 20 May 2026 21:45:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E8=B5=84=E6=BA=90=20tab=20=E4=B8=8E=E6=9C=AC=E5=9C=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=A0=91=20P1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + ...-local-folder-sort-order-persistence-v1.md | 52 +++ ...etree-action-layer-paste-drop-delete-v1.md | 56 +++ .../5-22-mindmap-main-editor-tab-kind-v1.md | 52 +++ ...23-editor-side-target-secondary-pane-v1.md | 50 +++ ...12-sidex-vscode-workbench-gap-review-v1.md | 7 + .../src/routes/local_folder_source.rs | 346 ++++++++++++++- rust/crates/mnote-web/src/routes/web_shell.rs | 173 +++++++- rust/crates/mnote-web/src/ssr/pages/layout.rs | 267 +++++++++++- scripts/task169-mindmap-realtime-smoke.js | 401 ++++++++++-------- ...-local-folder-bulk-resource-trash-smoke.js | 9 + ...cal-folder-sort-order-persistence-smoke.js | 146 +++++++ ...ask472-side-target-secondary-pane-smoke.js | 249 +++++++++++ 13 files changed, 1595 insertions(+), 214 deletions(-) create mode 100644 design/04-tree-domain/process/4-43-local-folder-sort-order-persistence-v1.md create mode 100644 design/04-tree-domain/process/4-44-filetree-action-layer-paste-drop-delete-v1.md create mode 100644 design/05-editor-mainline/process/5-22-mindmap-main-editor-tab-kind-v1.md create mode 100644 design/05-editor-mainline/process/5-23-editor-side-target-secondary-pane-v1.md create mode 100644 scripts/task472-local-folder-sort-order-persistence-smoke.js create mode 100644 scripts/task472-side-target-secondary-pane-smoke.js diff --git a/AGENTS.md b/AGENTS.md index 2264d540..25dbbdd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ - 当前项目已配置 CodeGraph MCP;结构性代码问题优先使用 `codegraph_*` 工具:`codegraph_search` 查符号,`codegraph_callers` / `codegraph_callees` 查调用关系,`codegraph_impact` 做影响分析,`codegraph_context` / `codegraph_explore` 获取聚焦上下文,`codegraph_files` 查看索引文件结构,`codegraph_status` 检查索引健康。 - CodeGraph 是当前开发态代码图主工具;代码改动后运行 `codegraph sync .`,大范围重构、排除规则变化或索引异常时运行 `codegraph index . --force`。只有查找字面文本、日志字符串、注释原文,或已经明确目标文件时,才优先用 `rg` / 直接读文件。 +- 提交 git 前必须再次运行 `codegraph sync .`,随后用 `codegraph status .` 或 `codegraph_status` 确认没有 pending changes;若仍有 pending、索引异常或本轮改动包含大范围重构 / 排除规则变化,改跑 `codegraph index . --force` 后再提交。 ## Reference-Code 快速对照流程 diff --git a/design/04-tree-domain/process/4-43-local-folder-sort-order-persistence-v1.md b/design/04-tree-domain/process/4-43-local-folder-sort-order-persistence-v1.md new file mode 100644 index 00000000..4aca1f38 --- /dev/null +++ b/design/04-tree-domain/process/4-43-local-folder-sort-order-persistence-v1.md @@ -0,0 +1,52 @@ +# 4-43 Local Folder Sort Order Persistence Checklist v1 + +> 状态:process +> +> 日期:2026-05-20 +> +> Owner:local folder tree command / file tree projection ordering + +## 1. 目标 + +补齐 P1:`local_folder` 的 `move` 当前只把 `sortOrder` 回显到 `_unsupportedFields`,不能形成拖拽排序后的持久顺序语义。目标是在不引入第二套 UI 真相的前提下,让本地文件夹排序通过本地 metadata 持久化,并被 file tree / page tree projection 消费。 + +## 2. 允许修改范围 + +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` +- `rust/crates/mnote-web/src/routes/tree.rs` 中仅限合同测试或调用透传 +- 可新增聚焦 smoke:`scripts/task47*-local-folder-sort-order-*.js` + +## 3. 禁止事项 + +- 不通过重命名文件前缀实现排序。 +- 不把排序真相放到前端 DOM 或 localStorage。 +- 不改变 Markdown 页面正文事实源。 +- 不改变垃圾箱、restore、purge 的既有路径语义。 + +## 4. Checklist + +- [x] 本地 metadata 增加可持久化排序记录,按 parent/container 维度表达子项顺序。 +- [x] `move` 带 `sortOrder` 时写入排序记录,不再返回 `_unsupportedFields.sortOrder`。 +- [x] projection 读取排序记录并稳定排序,未记录项继续按现有自然排序兜底。 +- [x] rename / move / archive / restore 后排序记录不会留下明显悬挂项或破坏 projection。 +- [x] Rust 合同测试覆盖 move sortOrder 持久化、重新加载 snapshot 后顺序保持。 +- [x] 如涉及可见行为,补 browser/API smoke 验证排序后重新加载 projection 不回退。 + +## 5. 验收 + +- [x] `cargo test -p mnote-web local_tree_command_move_with_sort_order -- --test-threads=1` +- [x] `cargo test -p mnote-web tree_command_local_folder -- --test-threads=1` +- [x] 如新增 smoke:`node scripts/task472-local-folder-sort-order-persistence-smoke.js` + +## 5.1 本轮执行记录 + +- 已合入:`.mnote/file-order.json` 按 parent relative path 记录子项相对路径顺序,projection 扫描时消费该顺序。 +- 已退役:`_unsupportedFields.sortOrder` 合同测试,改为持久化顺序和 snapshot 顺序断言。 +- 已合入:rename / archive / purge 对 file-order path 做 rewrite / remove,restore 回到文件系统自然排序或原 parent 现有顺序。 +- 本轮补齐:`scripts/task472-local-folder-sort-order-persistence-smoke.js` 使用隔离 tmp root,经 `/api/tree/commands` 执行 local_folder `move sortOrder`,再通过 `/api/tree/projections/file` 重拉 projection,验证刷新/重新加载后顺序不回退。 +- 执行记录:`node --check scripts/task472-local-folder-sort-order-persistence-smoke.js` 通过;`node scripts/task472-local-folder-sort-order-persistence-smoke.js` 在本地 `http://127.0.0.1:3000` 服务可用时通过。 + +## 6. 非目标 + +- 不实现跨设备 sync 的排序冲突合并。 +- 不实现完整 VSCode Explorer sort comparator 配置。 diff --git a/design/04-tree-domain/process/4-44-filetree-action-layer-paste-drop-delete-v1.md b/design/04-tree-domain/process/4-44-filetree-action-layer-paste-drop-delete-v1.md new file mode 100644 index 00000000..ff7df726 --- /dev/null +++ b/design/04-tree-domain/process/4-44-filetree-action-layer-paste-drop-delete-v1.md @@ -0,0 +1,56 @@ +# 4-44 FileTree Action Layer Paste / Drop / Delete Checklist v1 + +> 状态:process +> +> 日期:2026-05-20 +> +> Owner:File Tree actions / preflight / smoke semantics + +## 1. 目标 + +补齐 P1:delete、paste、right-click paste、external drop 的动作层仍分散,缺少 dirty / readonly / undo / fallback 的统一动作合同。目标是先把现有行为收口成可测试的轻量 action layer,不一次性照搬 VSCode `IBulkEditService`。 + +## 2. 允许修改范围 + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- `rust/crates/mnote-web/src/routes/tree.rs` +- `rust/crates/mnote-web/src/tree_shell/*` 中仅限 preflight / action contract +- `scripts/task430-vscode-explorer-stage7-smoke.js` +- `scripts/task471-local-folder-bulk-resource-trash-smoke.js` +- 可新增聚焦 smoke:`scripts/task47*-filetree-action-layer-smoke.js` + +## 3. 禁止事项 + +- 不删除旧 media / mindmap / table fallback route。 +- 不把默认删除改成永久删除。 +- 不绕过 readonly preflight。 +- 不破坏多选 `data-selected` 语义。 + +## 4. Checklist + +- [x] 提炼前端 File Tree action helper,统一记录 action、target、preflight、fallback、失败项。 +- [x] delete 对 local folder 资源统一走 archive tree command,并暴露可观测 undo/fallback 占位状态。 +- [x] right-click paste 使用当前菜单目标,而不是只能依赖 focused row。 +- [x] paste / drop 对 readonly 目标有一致阻断提示和 DOM 标记。 +- [x] external drop 二进制文件继续进入上传链路,失败时不清空 selection。 +- [x] smoke 增加至少一个语义断言:目标 row、action 名、fallback/readonly/undo 状态之一。 + +## 5. 验收 + +- [x] `cargo test -p mnote-web layout -- --test-threads=1` +- [x] `cargo test -p mnote-web tree_command_local_folder -- --test-threads=1` +- [ ] `node scripts/task430-vscode-explorer-stage7-smoke.js` +- [ ] `node scripts/task471-local-folder-bulk-resource-trash-smoke.js` +- [ ] 如新增 smoke:`node scripts/task47*-filetree-action-layer-smoke.js` + +## 5.1 本轮执行记录 + +- 已合入:`recordFileTreeAction` / `recordFileTreeActionStatus`,为 delete、paste、open-right、new-window 等现有路径暴露稳定 DOM 状态。 +- 已合入:右键 `paste-into` 使用菜单触发 row,不再只能靠 focused row。 +- 本轮追加:`layout.rs` 为 filetree projection row 暴露 `data-capabilities`,新增 `ensureFileTreeWritableTarget` / `blockReadonlyFileTreeAction`,paste 与内部 drop 在执行前复用 `/api/tree/filetree/drop-preflight`;readonly 目标统一写入 `data-mnote-filetree-last-action-status=blocked`、`data-mnote-filetree-readonly-blocked`、`data-mnote-filetree-readonly-message` 与目标 row 标记,并沿用 `alert` fallback 提示。 +- 本轮追加:`task471-local-folder-bulk-resource-trash-smoke.js` 增加语义断言,覆盖 bulk-delete 的 action 名、触发 row 与 `archived` undo 状态;未修改 `task430` 的隔离 workspaceId 前置条件,避免把环境准备失败误判为业务回归。 + +## 6. 非目标 + +- 不实现完整 undo stack。 +- 不实现 cloud object storage drop executor。 diff --git a/design/05-editor-mainline/process/5-22-mindmap-main-editor-tab-kind-v1.md b/design/05-editor-mainline/process/5-22-mindmap-main-editor-tab-kind-v1.md new file mode 100644 index 00000000..16c35199 --- /dev/null +++ b/design/05-editor-mainline/process/5-22-mindmap-main-editor-tab-kind-v1.md @@ -0,0 +1,52 @@ +# 5-22 Mindmap Main Editor Tab Kind Checklist v1 + +> 状态:process +> +> 日期:2026-05-20 +> +> Owner:main editor resource tab / mindmap editor kind + +## 1. 目标 + +补齐 P1:mindmap 仍以独立 object shell 进入主 pane,而不是 main editor group 内的 editor kind。目标是把 File Tree 中的 mindmap 资源默认打开到主编辑区 tab,并保留显式对象页 / 新窗口兼容入口。 + +## 2. 允许修改范围 + +- `rust/crates/mnote-web/src/routes/web_shell.rs` +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- `scripts/task169-mindmap-realtime-smoke.js` +- 可新增聚焦 smoke:`scripts/task47*-mindmap-resource-tab-smoke.js` + +## 3. 禁止事项 + +- 不把 mindmap 数据保存到 `page.body.save`。 +- 不删除 `/mindmap/:documentId/:mindmapId` object shell 兼容路由。 +- 不破坏已存在的 mindmap realtime / long text edit smoke。 +- 不让 mindmap tab 和 page tab 使用同一个 DOM identity。 + +## 4. Checklist + +- [x] resource open resolver 识别 `kind=mindmap` / `objectKind=mindmap`。 +- [x] 点击 File Tree mindmap 资源默认创建 main editor resource tab,而不是整页跳 `/mindmap/...`。 +- [x] mindmap tab 使用稳定 canonical identity,如 `resource:mindmap::` 或 local file canonical identity。 +- [x] tab panel 内挂载既有 mindmap runtime,并保持 `data-mnote-object-editor="mindmap"` / 可观测标记。 +- [x] 显式新窗口 / object shell 入口仍可用。 +- [x] smoke 覆盖从 File Tree 打开 mindmap tab、切回 page、再切回 mindmap。 + +## 5. 验收 + +- [x] `cargo test -p mnote-web web_shell -- --test-threads=1` +- [x] `node scripts/task169-mindmap-realtime-smoke.js` +- [ ] 如新增 smoke:`node scripts/task47*-mindmap-resource-tab-smoke.js` + +## 5.1 本轮执行记录 + +- 已合入:`kind=mindmap` resource tab,使用 `resource:mindmap::` identity,并复用现有 `/mindmap/...` bootstrap + runtime mount。 +- 已保留:旧 object shell 作为 runtime 不可用或显式路径 fallback。 +- 本轮更新:`task169` 已从旧 object shell 口径切到 main editor mindmap resource tab 口径,断言 File Tree 打开 mindmap 后进入 `data-mnote-tab-kind="mindmap"` 的 resource tab,并覆盖切回 page tab 后再切回 mindmap tab。 +- 已退役:`task169` 内旧 topic/runtime realtime 深测不再作为必过主链,结果中写入 `retiredRealtimeTopicEditing`,避免旧 object shell 时代的交互假设干扰当前 P1 resource tab 验收。 + +## 6. 非目标 + +- 不实现 mindmap 多实例协同编辑模型。 +- 不实现 tab 持久化恢复。 diff --git a/design/05-editor-mainline/process/5-23-editor-side-target-secondary-pane-v1.md b/design/05-editor-mainline/process/5-23-editor-side-target-secondary-pane-v1.md new file mode 100644 index 00000000..821714e6 --- /dev/null +++ b/design/05-editor-mainline/process/5-23-editor-side-target-secondary-pane-v1.md @@ -0,0 +1,50 @@ +# 5-23 Editor Side Target / Secondary Pane Checklist v1 + +> 状态:process +> +> 日期:2026-05-20 +> +> Owner:document pane runtime / side target + +## 1. 目标 + +补齐 P1:secondary pane 与 main tab 目前是两套概念。目标是先建立轻量 side target 合同,让 `openTarget=side` 能稳定映射到当前 secondary pane,而不是继续分散在“右侧边栏打开”和 resource tab 特例里。 + +## 2. 允许修改范围 + +- `rust/crates/mnote-web/src/routes/web_shell.rs` +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- `scripts/task457-main-editor-resource-tab-smoke.js` +- 可新增聚焦 smoke:`scripts/task47*-side-target-smoke.js` + +## 3. 禁止事项 + +- 不实现完整多 editor group grid。 +- 不改变 primary page / resource tab 的默认 active tab 行为。 +- 不把 secondary pane 等同为 active resource tab。 +- 不删除现有 `secondaryDocumentId` query 兼容。 + +## 4. Checklist + +- [x] `resolveResourceOpen` / document pane runtime 对外暴露 `openTarget=side` 的稳定返回。 +- [x] 页面文档的 side target 继续打开 secondary pane,并更新/清理既有 secondary query。 +- [x] 资源类 side target 有明确行为:当前阶段可先显示 unsupported/error placeholder,但必须可观测且不误开新窗口。 +- [x] 关闭 secondary pane 不影响 active resource tab URL state。 +- [x] smoke 覆盖 active tab 与 secondary pane 可同时存在、互不清状态。 + +## 5. 验收 + +- [x] `cargo test -p mnote-web web_shell -- --test-threads=1` +- [x] `node scripts/task457-main-editor-resource-tab-smoke.js` +- [x] 如新增 smoke:`node scripts/task47*-side-target-smoke.js` + +## 5.1 本轮执行记录 + +- 已合入:`openResourceAsSideTarget`,资源侧栏目标先显示可观测 unsupported placeholder,不再误开新窗口或污染 active resource tab。 +- 已合入:`tree.page.open` + `openTarget=side` 的 document side target wrapper,复用现有 `openSecondaryDocument` / `secondaryDocumentId` 合同。 +- 已合入:资源类 `openTarget=side` 使用 secondary pane unsupported placeholder 时清理 `secondaryDocumentId` / `secondarySourceKind` / `secondaryRootUri`,保留 active resource tab 的 `resourceTab` URL state。 +- 验证记录:`node --check scripts/task472-side-target-secondary-pane-smoke.js`、`node --check scripts/task457-main-editor-resource-tab-smoke.js`、`cargo test -p mnote-web web_shell -- --test-threads=1`、`node scripts/task472-side-target-secondary-pane-smoke.js`、`node scripts/task457-main-editor-resource-tab-smoke.js`。 + +## 6. 非目标 + +- 不实现 split editor layout、drag editor between groups、MRU across groups。 diff --git a/design/10-review/process/12-sidex-vscode-workbench-gap-review-v1.md b/design/10-review/process/12-sidex-vscode-workbench-gap-review-v1.md index 41eb05a5..572f263c 100644 --- a/design/10-review/process/12-sidex-vscode-workbench-gap-review-v1.md +++ b/design/10-review/process/12-sidex-vscode-workbench-gap-review-v1.md @@ -113,3 +113,10 @@ local office 可进入主编辑区 tab,Convex office 仍可能新窗口;mind - `design/04-tree-domain/process/4-41-filetree-bulk-resource-command-cutover-v1.md` - `design/04-tree-domain/process/4-42-filetree-open-target-rooturi-contract-v1.md` - `design/05-editor-mainline/process/5-21-editor-tab-url-active-identity-checklist-v1.md` + +第四轮进入 P1,按 editor workbench 语义继续拆分: + +- `design/04-tree-domain/process/4-43-local-folder-sort-order-persistence-v1.md` +- `design/05-editor-mainline/process/5-22-mindmap-main-editor-tab-kind-v1.md` +- `design/05-editor-mainline/process/5-23-editor-side-target-secondary-pane-v1.md` +- `design/04-tree-domain/process/4-44-filetree-action-layer-paste-drop-delete-v1.md` diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index 8784b278..cbe96faf 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -43,6 +43,7 @@ struct LocalFolderMetadata { page_options: BTreeMap, trash_entries: BTreeMap, uploaded_assets: BTreeMap, + file_order: BTreeMap>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -3300,6 +3301,7 @@ fn rename_local_markdown_page( })?; let new_relative_path = normalize_relative_path(root, &target)?; + apply_file_order_path_rewrite(root, &old_relative_path, &new_relative_path)?; let new_document_id = local_markdown_path_page_id(&new_relative_path); Ok(json!({ "ok": true, @@ -3384,6 +3386,13 @@ fn rename_nested_bundle_markdown_page( } } let new_relative_path = normalize_relative_path(root, &new_markdown)?; + let old_bundle_relative_path = markdown_file + .path + .parent() + .and_then(|parent| normalize_relative_path(root, parent).ok()) + .unwrap_or_else(|| old_relative_path.clone()); + let new_bundle_relative_path = normalize_relative_path(root, &new_bundle_dir)?; + apply_file_order_path_rewrite(root, &old_bundle_relative_path, &new_bundle_relative_path)?; let new_document_id = local_markdown_path_page_id(&new_relative_path); Ok(json!({ "ok": true, @@ -3416,6 +3425,8 @@ fn rename_local_directory(root: &Path, directory: &Path, title: &str) -> Result< ) })?; let new_relative_path = normalize_relative_path(root, &target)?; + let old_relative_path = normalize_relative_path(root, directory)?; + apply_file_order_path_rewrite(root, &old_relative_path, &new_relative_path)?; Ok(json!({ "ok": true, "id": local_directory_group_id(&new_relative_path), @@ -3524,19 +3535,36 @@ fn move_local_entry( parent_id: Option<&str>, sort_order: Option, ) -> Result { + let original_relative_path = + if let Some(directory) = resolve_local_directory_id(root, document_id)? { + Some(normalize_relative_path(root, &directory)?) + } else { + load_local_folder_metadata(root) + .ok() + .and_then(|metadata| { + find_markdown_by_page_id(root, &metadata, document_id) + .ok() + .flatten() + }) + .and_then(|entry| { + markdown_page_bundle_directory(&entry.path) + .and_then(|directory| normalize_relative_path(root, &directory).ok()) + .or(Some(entry.relative_path)) + }) + }; let result = if let Some(directory) = resolve_local_directory_id(root, document_id)? { move_local_directory(root, &directory, parent_id)? } else { move_local_markdown_page(root, document_id, parent_id)? }; if let Some(sort_order) = sort_order.filter(|value| *value >= 0) { - if let Some(object) = result.as_object() { - let mut enriched = object.clone(); - enriched.insert( - "_unsupportedFields".to_string(), - json!({ "sortOrder": sort_order }), - ); - return Ok(Value::Object(enriched)); + if let Some(relative_path) = result.get("orderRelativePath").and_then(Value::as_str) { + update_local_file_order_after_move( + root, + original_relative_path.as_deref(), + relative_path, + sort_order, + )?; } } Ok(result) @@ -3571,6 +3599,7 @@ fn move_local_markdown_page( "id": document_id, "documentId": document_id, "relativePath": markdown_file.relative_path, + "orderRelativePath": markdown_file.relative_path, "action": "move", "sourceKind": "local_folder", })); @@ -3588,6 +3617,7 @@ fn move_local_markdown_page( "id": document_id, "documentId": document_id, "relativePath": markdown_file.relative_path, + "orderRelativePath": markdown_file.relative_path, "action": "move", "sourceKind": "local_folder", })); @@ -3615,6 +3645,7 @@ fn move_local_markdown_page( "id": new_document_id, "documentId": new_document_id, "relativePath": new_relative_path, + "orderRelativePath": new_relative_path, "previousDocumentId": document_id, "previousRelativePath": old_relative_path, "action": "move", @@ -3635,6 +3666,7 @@ fn move_local_markdown_bundle( "id": document_id, "documentId": document_id, "relativePath": markdown_file.relative_path, + "orderRelativePath": normalize_relative_path(root, bundle_dir)?, "action": "move", "sourceKind": "local_folder", })); @@ -3680,12 +3712,14 @@ fn move_local_markdown_bundle( })?; } let new_relative_path = normalize_relative_path(root, &target_markdown)?; + let new_bundle_relative_path = normalize_relative_path(root, &target_bundle)?; let new_document_id = local_markdown_path_page_id(&new_relative_path); Ok(json!({ "ok": true, "id": new_document_id, "documentId": new_document_id, "relativePath": new_relative_path, + "orderRelativePath": new_bundle_relative_path, "previousDocumentId": document_id, "previousRelativePath": markdown_file.relative_path, "action": "move", @@ -3717,6 +3751,7 @@ fn move_local_directory( "ok": true, "id": local_directory_group_id(&normalize_relative_path(root, directory)?), "relativePath": normalize_relative_path(root, directory)?, + "orderRelativePath": normalize_relative_path(root, directory)?, "action": "move", "sourceKind": "local_folder", })); @@ -3738,6 +3773,7 @@ fn move_local_directory( "ok": true, "id": local_directory_group_id(&new_relative_path), "relativePath": new_relative_path, + "orderRelativePath": new_relative_path, "action": "move", "sourceKind": "local_folder", })) @@ -3838,6 +3874,7 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result Result Result Result Resul let trash_relative_path = normalize_relative_path(root, &target)?; let trash_entry_id = local_directory_trash_entry_id(&relative_path); let now = now_ms(); + remove_file_order_path(&mut metadata.file_order, &relative_path); metadata.trash_entries.insert( trash_entry_id.clone(), LocalTrashEntry { @@ -3984,6 +4025,7 @@ fn trash_local_directory(root: &Path, entry_id: &str, directory: &Path) -> Resul }, ); write_trash_index_metadata(root, &metadata.trash_entries)?; + write_file_order_metadata(root, &metadata.file_order)?; Ok(json!({ "ok": true, "id": entry_id, @@ -4276,6 +4318,9 @@ fn restore_local_directory(root: &Path, entry_id: &str) -> Result Result { let mut metadata = load_local_folder_metadata(root)?; if let Some(markdown_file) = find_markdown_by_page_id(root, &metadata, document_id)? { + let order_relative_path = markdown_page_bundle_directory(&markdown_file.path) + .and_then(|bundle_dir| normalize_relative_path(root, &bundle_dir).ok()) + .unwrap_or_else(|| markdown_file.relative_path.clone()); if let Some(bundle_dir) = markdown_page_bundle_directory(&markdown_file.path) { fs::remove_dir_all(&bundle_dir).map_err(|error| { WebError::bad_request_code( @@ -4294,6 +4339,8 @@ fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result Result Result Result "找不到要永久删除的本地文件夹回收站记录", ) })?; + remove_file_order_path( + &mut metadata.file_order, + &trash_entry.original_relative_path, + ); let trash_relative_path = local_trash_file_path(&trash_entry); let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?; if trash_path.exists() { @@ -4363,6 +4419,7 @@ fn purge_local_directory(root: &Path, entry_id: &str) -> Result })?; } write_trash_index_metadata(root, &metadata.trash_entries)?; + write_file_order_metadata(root, &metadata.file_order)?; Ok(json!({ "ok": true, "id": entry_id, @@ -4390,6 +4447,10 @@ fn purge_local_raw_file(root: &Path, entry_id: &str) -> Result "找不到要永久删除的本地资源回收站记录", ) })?; + remove_file_order_path( + &mut metadata.file_order, + &trash_entry.original_relative_path, + ); let trash_relative_path = local_trash_file_path(&trash_entry); let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?; if trash_path.exists() { @@ -4740,6 +4801,7 @@ fn load_local_folder_metadata(root: &Path) -> Result Result>, WebError> { + if !path.exists() { + return Ok(BTreeMap::new()); + } + let value = read_metadata_json(path)?; + let source = value.get("parents").unwrap_or(&value); + let orders: BTreeMap> = serde_json::from_value(source.clone()) + .map_err(|error| metadata_invalid(path, format!("文件树排序索引损坏: {error}")))?; + Ok(orders + .into_iter() + .map(|(parent, children)| { + ( + normalize_file_order_parent_key(&parent), + normalize_file_order_children(children), + ) + }) + .collect()) +} + fn metadata_invalid(path: &Path, message: impl Into) -> WebError { WebError::bad_request_code( "local_metadata_invalid", @@ -4907,6 +4988,25 @@ fn write_uploaded_asset_index_metadata( write_json_atomic(&path, &value) } +fn write_file_order_metadata( + root: &Path, + file_order: &BTreeMap>, +) -> Result<(), WebError> { + let mnote_dir = root.join(".mnote"); + fs::create_dir_all(&mnote_dir).map_err(|error| { + WebError::bad_request_code( + "local_metadata_write_failed", + format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()), + ) + })?; + let path = mnote_dir.join("file-order.json"); + let value = json!({ + "version": 1, + "parents": file_order, + }); + write_json_atomic(&path, &value) +} + #[allow(dead_code)] fn write_json_atomic(path: &Path, value: &Value) -> Result<(), WebError> { let tmp_path = path.with_extension("json.tmp"); @@ -4937,7 +5037,9 @@ fn scan_directory( metadata: &LocalFolderMetadata, rows: &mut Vec, ) -> Result<(), WebError> { - let entries = read_sorted_entries(directory, root)?; + let mut entries = read_sorted_entries(directory, root)?; + let parent_key = file_order_parent_key_for_directory(root, directory)?; + sort_entries_with_file_order(&mut entries, metadata, &parent_key); let entry_count = entries.len(); for (position, entry) in entries.into_iter().enumerate() { let node_id = local_node_id(if entry.relative_path.is_empty() { @@ -5119,6 +5221,199 @@ fn compare_local_entries(a: &LocalFolderEntry, b: &LocalFolderEntry) -> Ordering } } +fn normalize_file_order_parent_key(parent: &str) -> String { + let trimmed = parent.trim().trim_matches('/'); + if trimmed.is_empty() || trimmed == "." { + ".".to_string() + } else { + trimmed.to_string() + } +} + +fn normalize_file_order_children(children: Vec) -> Vec { + let mut seen = std::collections::BTreeSet::new(); + children + .into_iter() + .map(|child| child.trim().trim_matches('/').to_string()) + .filter(|child| !child.is_empty() && seen.insert(child.clone())) + .collect() +} + +fn file_order_parent_key_for_directory(root: &Path, directory: &Path) -> Result { + if directory == root { + return Ok(".".to_string()); + } + Ok(normalize_file_order_parent_key(&normalize_relative_path( + root, directory, + )?)) +} + +fn parent_key_for_relative_path(relative_path: &str) -> String { + Path::new(relative_path) + .parent() + .and_then(|parent| { + let value = parent + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect::>() + .join("/"); + if value.is_empty() { + None + } else { + Some(value) + } + }) + .map(|value| normalize_file_order_parent_key(&value)) + .unwrap_or_else(|| ".".to_string()) +} + +fn sort_entries_with_file_order( + entries: &mut [LocalFolderEntry], + metadata: &LocalFolderMetadata, + parent_key: &str, +) { + let Some(order) = metadata.file_order.get(parent_key) else { + return; + }; + let index = order + .iter() + .enumerate() + .map(|(position, relative_path)| (relative_path.as_str(), position)) + .collect::>(); + entries.sort_by(|a, b| { + let a_index = index.get(a.relative_path.as_str()).copied(); + let b_index = index.get(b.relative_path.as_str()).copied(); + match (a_index, b_index) { + (Some(left), Some(right)) => left.cmp(&right), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => compare_local_entries(a, b), + } + }); +} + +fn ordered_child_paths_for_parent( + root: &Path, + parent_directory: &Path, +) -> Result, WebError> { + let mut entries = read_sorted_entries(parent_directory, root)?; + let parent_key = file_order_parent_key_for_directory(root, parent_directory)?; + let metadata = load_local_folder_metadata(root)?; + sort_entries_with_file_order(&mut entries, &metadata, &parent_key); + Ok(entries + .into_iter() + .map(|entry| entry.relative_path) + .collect::>()) +} + +fn reorder_child_paths(children: &mut Vec, child_path: &str, sort_order: i64) { + children.retain(|candidate| candidate != child_path); + let index = usize::try_from(sort_order) + .unwrap_or(usize::MAX) + .min(children.len()); + children.insert(index, child_path.to_string()); +} + +fn update_local_file_order_after_move( + root: &Path, + original_relative_path: Option<&str>, + new_relative_path: &str, + sort_order: i64, +) -> Result<(), WebError> { + let mut metadata = load_local_folder_metadata(root)?; + if let Some(original) = original_relative_path + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let original_parent = parent_key_for_relative_path(original); + if original_parent != parent_key_for_relative_path(new_relative_path) { + if let Some(children) = metadata.file_order.get_mut(&original_parent) { + children.retain(|candidate| candidate != original); + } + } + } + let parent_key = parent_key_for_relative_path(new_relative_path); + let parent_directory = if parent_key == "." { + root.to_path_buf() + } else { + resolve_metadata_relative_path(root, &parent_key)? + }; + let mut children = ordered_child_paths_for_parent(root, &parent_directory)?; + reorder_child_paths(&mut children, new_relative_path, sort_order); + metadata.file_order.insert(parent_key, children); + write_file_order_metadata(root, &metadata.file_order) +} + +fn rewrite_file_order_path( + file_order: &mut BTreeMap>, + old_relative_path: &str, + new_relative_path: &str, +) { + for children in file_order.values_mut() { + for child in children.iter_mut() { + if child == old_relative_path { + *child = new_relative_path.to_string(); + } else if child.starts_with(&format!("{old_relative_path}/")) { + *child = format!("{}{}", new_relative_path, &child[old_relative_path.len()..]); + } + } + } + let old_parent_prefix = format!("{old_relative_path}/"); + let parent_rewrites = file_order + .keys() + .filter_map(|parent| { + if parent == old_relative_path { + Some((parent.clone(), new_relative_path.to_string())) + } else if parent.starts_with(&old_parent_prefix) { + Some(( + parent.clone(), + format!( + "{}{}", + new_relative_path, + &parent[old_relative_path.len()..] + ), + )) + } else { + None + } + }) + .collect::>(); + for (old_parent, new_parent) in parent_rewrites { + if let Some(children) = file_order.remove(&old_parent) { + file_order.insert(new_parent, children); + } + } +} + +fn remove_file_order_path(file_order: &mut BTreeMap>, relative_path: &str) { + let child_prefix = format!("{relative_path}/"); + for children in file_order.values_mut() { + children.retain(|child| child != relative_path && !child.starts_with(&child_prefix)); + } + let parent_keys = file_order + .keys() + .filter(|parent| parent.as_str() == relative_path || parent.starts_with(&child_prefix)) + .cloned() + .collect::>(); + for parent in parent_keys { + file_order.remove(&parent); + } +} + +fn apply_file_order_path_rewrite( + root: &Path, + old_relative_path: &str, + new_relative_path: &str, +) -> Result<(), WebError> { + let mut metadata = load_local_folder_metadata(root)?; + rewrite_file_order_path( + &mut metadata.file_order, + old_relative_path, + new_relative_path, + ); + write_file_order_metadata(root, &metadata.file_order) +} + fn scan_markdown_page_tree( root: &Path, directory: &Path, @@ -5130,7 +5425,9 @@ fn scan_markdown_page_tree( workspace_id: &str, root_source_uri: &str, ) -> Result { - let entries = read_sorted_entries(directory, root)?; + let mut entries = read_sorted_entries(directory, root)?; + let parent_key = file_order_parent_key_for_directory(root, directory)?; + sort_entries_with_file_order(&mut entries, metadata, &parent_key); let mut directory_rows = Vec::::new(); let mut contains_markdown = false; for (position, entry) in entries.into_iter().enumerate() { @@ -6677,7 +6974,7 @@ mod tests { use axum::extract::{Extension, Path as AxumPath, Query}; use axum::http::{HeaderMap, Method, StatusCode}; use axum::Json; - use serde_json::{json, Value}; + use serde_json::Value; use std::sync::Mutex; fn env_lock() -> &'static Mutex<()> { @@ -8297,18 +8594,41 @@ fn main() {} } #[test] - fn local_tree_command_move_with_sort_order_annotates_unsupported() { + fn local_tree_command_move_with_sort_order_persists_file_order() { let root = temp_root("mnote-local-move-sort-order"); init_workspace(&root); + std::fs::write(root.join("alpha.md"), "# Alpha\n").expect("write alpha"); + std::fs::write(root.join("beta.md"), "# Beta\n").expect("write beta"); std::fs::write(root.join("source.md"), "# Source\n").expect("write source"); let root_uri = format!("file://{}", root.display()); let doc_id = "local-md:source.md"; let result = - execute_local_tree_command_with_sort(&root_uri, "move", doc_id, None, None, Some(42)) + execute_local_tree_command_with_sort(&root_uri, "move", doc_id, None, None, Some(0)) .expect("move with sort_order should succeed"); - assert_eq!(result["_unsupportedFields"]["sortOrder"], json!(42)); + assert!(result.get("_unsupportedFields").is_none()); assert!(root.join("source.md").is_file()); + let order_index = + std::fs::read_to_string(root.join(".mnote").join("file-order.json")).expect("order"); + assert!( + order_index.contains("\"source.md\""), + "file-order 应记录排序后的 source.md: {order_index}" + ); + let snapshot = + load_local_folder_file_tree_snapshot(&root_uri).expect("file tree snapshot after move"); + let items = snapshot.projection["items"] + .as_array() + .expect("projection items"); + let root_rows = items + .iter() + .filter(|item| item["parentNodeId"].is_null()) + .map(|item| { + item["resourceMeta"]["extra"]["source"]["relativePath"] + .as_str() + .unwrap_or("") + }) + .collect::>(); + assert_eq!(root_rows.first().copied(), Some("source.md")); let result_without_sort = execute_local_tree_command(&root_uri, "move", doc_id, None, None) .expect("move without sort_order"); diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 1845b47e..f4f55c5b 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -664,28 +664,52 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { }; if (panesBootstrap.secondaryInvalid === true) clearSecondaryParams(); + const secondaryUrlForDocument = (documentId, detail = {}) => { + const url = currentUrl(); + url.searchParams.set(paneRouteConfig.secondary.documentIdParam, documentId); + const detailSourceKind = typeof detail.sourceKind === 'string' ? detail.sourceKind.trim() : ''; + const detailRootUri = typeof detail.rootUri === 'string' ? detail.rootUri.trim() : ''; + const primarySourceKind = (url.searchParams.get(paneRouteConfig.primary.sourceKindParam) || '').trim(); + const primaryRootUri = (url.searchParams.get(paneRouteConfig.primary.rootUriParam) || '').trim(); + const secondarySourceKind = detailSourceKind || primarySourceKind; + const secondaryRootUri = detailRootUri || primaryRootUri; + if (secondarySourceKind) url.searchParams.set(paneRouteConfig.secondary.sourceKindParam, secondarySourceKind); + else url.searchParams.delete(paneRouteConfig.secondary.sourceKindParam); + if (secondaryRootUri) url.searchParams.set(paneRouteConfig.secondary.rootUriParam, secondaryRootUri); + else url.searchParams.delete(paneRouteConfig.secondary.rootUriParam); + return { url, sourceKind: secondarySourceKind, rootUri: secondaryRootUri }; + }; + + const openDocumentInSecondaryPane = (documentId, detail = {}) => { + const id = typeof documentId === 'string' ? documentId.trim() : ''; + if (!id) return false; + const target = secondaryUrlForDocument(id, detail); + if (typeof window.__mnoteDocumentPaneRuntime?.openSecondaryDocument === 'function') { + void window.__mnoteDocumentPaneRuntime.openSecondaryDocument({ + documentId: id, + workspaceId: typeof detail.workspaceId === 'string' ? detail.workspaceId.trim() : '', + sourceKind: target.sourceKind || null, + rootUri: target.rootUri || null, + url: target.url, + }); + return true; + } + window.location.assign(target.url.pathname + target.url.search + target.url.hash); + return true; + }; + window.addEventListener('tree.page.open-right', (event) => { const detail = event?.detail && typeof event.detail === 'object' ? event.detail : {}; const documentId = typeof detail.documentId === 'string' ? detail.documentId.trim() : ''; - if (!documentId) return; - const url = currentUrl(); - url.searchParams.set(paneRouteConfig.secondary.documentIdParam, documentId); - const primarySourceKind = (url.searchParams.get(paneRouteConfig.primary.sourceKindParam) || '').trim(); - const primaryRootUri = (url.searchParams.get(paneRouteConfig.primary.rootUriParam) || '').trim(); - if (primarySourceKind) url.searchParams.set(paneRouteConfig.secondary.sourceKindParam, primarySourceKind); - else url.searchParams.delete(paneRouteConfig.secondary.sourceKindParam); - if (primaryRootUri) url.searchParams.set(paneRouteConfig.secondary.rootUriParam, primaryRootUri); - else url.searchParams.delete(paneRouteConfig.secondary.rootUriParam); - if (typeof window.__mnoteDocumentPaneRuntime?.openSecondaryDocument === 'function') { - void window.__mnoteDocumentPaneRuntime.openSecondaryDocument({ - documentId, - sourceKind: primarySourceKind || null, - rootUri: primaryRootUri || null, - url, - }); - return; - } - window.location.assign(url.pathname + url.search + url.hash); + openDocumentInSecondaryPane(documentId, detail); + }); + + window.addEventListener('tree.page.open', (event) => { + const detail = event?.detail && typeof event.detail === 'object' ? event.detail : {}; + const openTarget = typeof detail.openTarget === 'string' ? detail.openTarget.trim().toLowerCase() : ''; + if (openTarget !== 'side') return; + const documentId = typeof detail.documentId === 'string' ? detail.documentId.trim() : ''; + openDocumentInSecondaryPane(documentId, detail); }); document.querySelectorAll('[data-mnote-pane-close="secondary"]').forEach((button) => { @@ -2559,6 +2583,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { pane.setAttribute('data-pane-document-id', documentId); pane.setAttribute('data-pane-workspace-id', workspaceId); pane.setAttribute('data-pane-visible', 'true'); + pane.removeAttribute('data-mnote-side-target'); pane.hidden = false; const shell = pane.querySelector('.document-shell'); if (shell instanceof HTMLElement) { @@ -2591,6 +2616,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { document.title = title; const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]'); if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title; + document.documentElement.removeAttribute('data-mnote-side-target-unsupported'); + document.documentElement.removeAttribute('data-mnote-side-target-asset-id'); document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => { if (row instanceof HTMLElement) { row.setAttribute('data-active', 'false'); @@ -2610,6 +2637,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { runtimeDescriptor.root.removeAttribute('data-mnote-object-editor'); runtimeDescriptor.root.removeAttribute('data-mnote-object-identity'); runtimeDescriptor.root.removeAttribute('data-mnote-mindmap-id'); + runtimeDescriptor.root.removeAttribute('data-mnote-side-target-unsupported'); + runtimeDescriptor.root.removeAttribute('data-mnote-side-target-asset-id'); }; const fetchPageAggregateForPane = async (descriptor) => { @@ -2711,15 +2740,24 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (pane instanceof HTMLElement) { pane.hidden = true; pane.setAttribute('data-pane-visible', 'false'); + pane.removeAttribute('data-mnote-side-target'); + pane.removeAttribute('data-pane-document-id'); + pane.removeAttribute('data-pane-workspace-id'); } const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="secondary"]`); - if (root instanceof HTMLElement) root.replaceChildren(); + if (root instanceof HTMLElement) { + root.replaceChildren(); + root.removeAttribute('data-mnote-side-target-unsupported'); + root.removeAttribute('data-mnote-side-target-asset-id'); + } if (workspace instanceof HTMLElement) { workspace.setAttribute('data-has-secondary-pane', 'false'); workspace.style.removeProperty('grid-template-columns'); } const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); if (resizerNode instanceof HTMLElement) resizerNode.hidden = true; + document.documentElement.removeAttribute('data-mnote-side-target-unsupported'); + document.documentElement.removeAttribute('data-mnote-side-target-asset-id'); replaceUrlState(url); }; @@ -3152,6 +3190,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const resourceTabBadgeKind = (input, kind) => { const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase(); + if (kind === 'mindmap') return 'mindmap'; if (kind === 'office') { if (/\.(ppt|pptx|odp)$/i.test(title)) return 'ppt'; if (/\.(xls|xlsx|ods|csv)$/i.test(title)) return 'sheet'; @@ -3174,7 +3213,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const normalizeResourceTabKind = (input) => { const kind = String(input?.kind || '').trim().toLowerCase(); const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase(); - if (kind === 'office' || kind === 'pdf' || kind === 'image' || kind === 'markdown' || kind === 'text' || kind === 'code') return kind; + if (kind === 'mindmap' || kind === 'office' || kind === 'pdf' || kind === 'image' || kind === 'markdown' || kind === 'text' || kind === 'code') return kind; if (/\.(doc|docx|ppt|pptx|xls|xlsx|odt|odp|ods)$/i.test(title)) return 'office'; if (/\.pdf$/i.test(title)) return 'pdf'; if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(title)) return 'image'; @@ -3382,6 +3421,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { } removeFromResourceTabMru(key); if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true }); + if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) { + try { + entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId); + } catch (error) { + console.warn('mnote mindmap resource tab unmount failed', error); + } + } if (entry.tab instanceof HTMLElement) entry.tab.remove(); if (entry.panel instanceof HTMLElement) entry.panel.remove(); resourceTabRegistry.delete(key); @@ -3565,6 +3611,80 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { } }; + const openMindmapResourceTab = async (entry, input) => { + const documentId = String(input.documentId || currentDocumentId() || '').trim(); + const mindmapId = String(input.mindmapId || input.assetId || '').trim(); + if (!documentId || !mindmapId) throw new Error('mindmap_resource_identity_missing'); + const targetUrl = new URL(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, window.location.origin); + const runtime = await loadRuntime(); + const { bootstrap, title } = await fetchMindmapShellBootstrap(targetUrl); + entry.title = String(input.title || title || '思维导图').trim() || '思维导图'; + const titleNode = entry.tab?.querySelector?.('.mnote-main-tab-title'); + if (titleNode) titleNode.textContent = entry.title; + entry.panel.innerHTML = '
'; + entry.panel.setAttribute('data-mnote-object-editor', 'mindmap'); + entry.panel.setAttribute('data-mnote-object-identity', entry.objectIdentity); + entry.panel.setAttribute('data-mnote-mindmap-id', mindmapId); + const root = entry.panel.querySelector('[data-testid="mnote-mindmap-editor-root"]'); + if (!(root instanceof HTMLElement)) throw new Error('mindmap_resource_root_missing'); + root.setAttribute('data-mnote-object-editor', 'mindmap'); + root.setAttribute('data-mnote-object-identity', entry.objectIdentity); + root.setAttribute('data-mnote-mindmap-id', mindmapId); + root.setAttribute('data-document-id', documentId); + const mountId = runtime.mount(root, bootstrap); + root.setAttribute('data-runtime-mount-id', String(mountId)); + root.setAttribute('data-editor-host-kind', 'mindmap_resource_tab'); + root.setAttribute('data-runtime-editor-status', 'mounted'); + entry.view = null; + entry.session = null; + entry.mindmapRuntime = { runtime, mountId, root }; + }; + + const openUnsupportedSideTarget = (input = {}) => { + const url = currentUrl(); + secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name)); + const previousView = paneViewRegistry.get('secondary'); + if (previousView) { + unmountEditorViewBinding(previousView); + paneViewRegistry.delete('secondary'); + } + unmountMindmapPane('secondary'); + const workspace = document.querySelector('.mnote-document-workspace'); + if (workspace instanceof HTMLElement) { + workspace.setAttribute('data-has-secondary-pane', 'true'); + applyStoredSecondaryWidth(); + } + const resizerNode = document.querySelector('[data-document-pane-resizer="true"]'); + if (resizerNode instanceof HTMLElement) resizerNode.hidden = false; + const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]'); + if (pane instanceof HTMLElement) { + pane.hidden = false; + pane.setAttribute('data-pane-visible', 'true'); + pane.setAttribute('data-mnote-side-target', 'unsupported-resource'); + pane.removeAttribute('data-pane-document-id'); + pane.removeAttribute('data-pane-workspace-id'); + } + const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="secondary"]`); + const title = String(input.title || input.fileName || input.assetId || '资源').trim() || '资源'; + if (root instanceof HTMLElement) { + root.replaceChildren(); + root.setAttribute('data-editor-host-kind', 'unsupported_resource_side_target'); + root.setAttribute('data-mnote-side-target-unsupported', 'true'); + root.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || '')); + const placeholder = document.createElement('div'); + placeholder.className = 'mnote-resource-tab-error'; + placeholder.setAttribute('data-mnote-side-target-placeholder', 'true'); + placeholder.innerHTML = '

暂不支持在侧栏打开此资源

'; + const text = placeholder.querySelector('p'); + if (text) text.textContent = title; + root.append(placeholder); + } + document.documentElement.setAttribute('data-mnote-side-target-unsupported', 'true'); + document.documentElement.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || '')); + replaceUrlState(url); + return true; + }; + const openResourceInActiveTab = async (input = {}) => { bindMainEditorPageTab(); const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim(); @@ -3579,7 +3699,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { resourceTabRegistry.set(objectIdentity, entry); activateMainEditorTab(objectIdentity); try { - if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') { + if (entry.kind === 'mindmap') { + await openMindmapResourceTab(entry, input); + } else if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') { await openTiptapResourceTab(entry, input); } else { openPassiveResourceTab(entry, input); @@ -3656,6 +3778,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { }, resolveResourceOpen: (input) => resolveResourceOpen(input), openResourceInActiveTab: openResourceInActiveTab, + openResourceAsSideTarget: async (input = {}) => { + const resolved = resolveResourceOpen({ ...input, openTarget: 'side' }); + if (resolved.editorKind === 'markdown' || resolved.editorKind === 'text' || resolved.editorKind === 'code') { + return openUnsupportedSideTarget(input); + } + return openUnsupportedSideTarget(input); + }, closeSecondaryDocument: ({ url } = {}) => { closeSecondaryPane(url instanceof URL ? url : currentUrl()); return true; diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index dffd809f..13b16f37 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -1832,6 +1832,14 @@ const SIDEBAR_TREE_JS: &str = r##" return String(item && (item.iconHint || item.rowKind || 'file') || 'file').trim() || 'file'; } + function fileCapabilitiesAttr(item) { + try { + return JSON.stringify(Array.isArray(item && item.capabilities) ? item.capabilities : []); + } catch (_error) { + return '[]'; + } + } + function isFileTreeProjectionPageRow(rowKind, assetId) { if (assetId) return false; return rowKind === 'document' || rowKind === 'doc' || rowKind === 'markdown'; @@ -1873,7 +1881,7 @@ const SIDEBAR_TREE_JS: &str = r##" var childHtml = expandable ? '
    ' + renderFileRows(nodeId, grouped, activeId, activeRowId) + '
' : ''; - return '
  • ' + toggle + '
    ' + createAction + '
    ' + childHtml + '
  • '; + return '
  • ' + toggle + '
    ' + createAction + '
    ' + childHtml + '
  • '; }).join(''); } @@ -2214,11 +2222,40 @@ const SIDEBAR_TREE_JS: &str = r##" async function openConvexAssetFromFileTree(detail) { var assetId = String(detail && detail.assetId || '').trim(); if (!assetId) return; - var forceNewWindow = String(detail && detail.openTarget || '').trim() === 'new-window'; + var openTarget = String(detail && detail.openTarget || '').trim().toLowerCase(); + var forceNewWindow = openTarget === 'new-window'; + if (openTarget === 'side') { + if (typeof window.__mnoteDocumentPaneRuntime?.openResourceAsSideTarget === 'function') { + void window.__mnoteDocumentPaneRuntime.openResourceAsSideTarget({ + objectIdentity: String(detail.objectIdentity || detail.assetId || ''), + assetId: assetId, + title: String(detail.title || detail.fileName || assetId || ''), + kind: String(detail.iconKind || detail.assetType || 'file'), + documentId: String(detail.documentId || currentDocumentId() || ''), + workspaceId: String(detail.workspaceId || resolveWorkspaceId(document.body) || '') + }); + } + return; + } var localFilePath = localFilePathFromAssetId(assetId); if (localFilePath) { var localFileName = localFilePath.split('/').pop() || localFilePath; if (isMindmapAssetDetail(detail) || String(detail && detail.assetType || '').trim() === 'mindmap') { + if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') { + document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'mindmap-resource-tab'); + document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId); + void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ + objectIdentity: 'resource:mindmap:' + String(detail && detail.documentId || currentDocumentId() || '').trim() + ':' + assetId, + assetId: assetId, + mindmapId: assetId, + title: String(detail && detail.title || localFileName || '思维导图').trim(), + fileName: localFileName, + kind: 'mindmap', + documentId: String(detail && detail.documentId || currentDocumentId() || '').trim(), + workspaceId: String(detail.workspaceId || '').trim() + }); + return; + } document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'local-mindmap-object-shell'); document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId); navigateToMindmapObject(String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, String(detail.workspaceId || '').trim()); @@ -2255,6 +2292,21 @@ const SIDEBAR_TREE_JS: &str = r##" } var documentId = String(detail && detail.documentId || '').trim(); if (isMindmapAssetDetail(detail) && documentId) { + if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') { + document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'mindmap-resource-tab'); + document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId); + void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ + objectIdentity: 'resource:mindmap:' + documentId + ':' + assetId, + assetId: assetId, + mindmapId: assetId, + title: String(detail.title || '思维导图').trim(), + fileName: String(detail.title || '思维导图').trim(), + kind: 'mindmap', + documentId: documentId, + workspaceId: String(detail.workspaceId || '').trim() + }); + return; + } document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'mindmap-object-shell'); document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId); navigateToMindmapObject(documentId, assetId, String(detail.workspaceId || '').trim()); @@ -2324,6 +2376,130 @@ const SIDEBAR_TREE_JS: &str = r##" }); } + function fileTreeRowCapabilities(row) { + if (!(row instanceof HTMLElement)) return []; + try { + var parsed = JSON.parse(row.getAttribute('data-capabilities') || '[]'); + return Array.isArray(parsed) ? parsed.map(function(item) { return String(item || '').trim(); }).filter(Boolean) : []; + } catch (_) { + return []; + } + } + + function fileTreeRowIsReadonly(row) { + return fileTreeRowCapabilities(row).some(function(capability) { + return ['readonly', 'readOnly', 'permissionDenied'].indexOf(capability) >= 0; + }); + } + + function blockReadonlyFileTreeAction(action, detail, message) { + var normalizedAction = String(action || 'drop').trim() || 'drop'; + var text = String(message || '目标位置是只读,不能拖放到这里').trim(); + var targetRowId = String(detail && (detail.targetRowId || detail.rowId) || '').trim(); + var documentId = String(detail && detail.documentId || '').trim(); + var assetId = String(detail && detail.assetId || '').trim(); + recordFileTreeAction(normalizedAction, { + rowId: targetRowId, + documentId: documentId, + assetId: assetId, + readonly: true + }); + recordFileTreeActionStatus('blocked', { + rowId: targetRowId, + documentId: documentId, + assetId: assetId, + readonly: true, + fallback: 'alert' + }); + document.documentElement.setAttribute('data-mnote-filetree-readonly-blocked', normalizedAction); + document.documentElement.setAttribute('data-mnote-filetree-readonly-message', text); + if (targetRowId) { + document.documentElement.setAttribute('data-mnote-filetree-readonly-target-row-id', targetRowId); + var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(targetRowId) + '"]'); + if (row instanceof HTMLElement) { + row.setAttribute('data-readonly-blocked', normalizedAction); + row.setAttribute('data-readonly-message', text); + } + } + window.alert(text); + return false; + } + + function fileTreeDocumentParentsForPreflight() { + return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]')).map(function(row) { + var documentId = row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || ''; + if (!documentId) return null; + var parentRowId = row.getAttribute('data-parent-id') || ''; + var parentDocumentId = parentRowId.indexOf('doc:') === 0 ? parentRowId.slice(4) : parentRowId || null; + return { documentId: documentId, parentId: parentDocumentId }; + }).filter(Boolean); + } + + function fileTreeTargetChildrenForPreflight(targetRow) { + var node = targetRow instanceof HTMLElement ? targetRow.closest('.tree-node') : null; + if (!node) return []; + return Array.from(node.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"]')).map(function(row) { + return { + rowKind: row.getAttribute('data-row-kind') || '', + documentId: row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || null, + assetId: row.getAttribute('data-asset-id') || null, + title: rowTitle(row) + }; + }); + } + + function fileTreeDropPreflightRows() { + return fileTreeRowsForUploadPreflight().map(function(row) { + var domRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(row.rowId) + '"]'); + row.title = rowTitle(domRow); + return row; + }); + } + + async function ensureFileTreeWritableTarget(action, targetRow, rowIds, copy) { + var normalizedAction = String(action || 'drop').trim() || 'drop'; + if (!(targetRow instanceof HTMLElement)) return true; + var detail = { + targetRowId: targetRow.getAttribute('data-row-id') || '', + rowId: targetRow.getAttribute('data-row-id') || '', + documentId: targetRow.getAttribute('data-document-id') || targetRow.getAttribute('data-doc-id') || '', + assetId: targetRow.getAttribute('data-asset-id') || '' + }; + if (fileTreeRowIsReadonly(targetRow)) { + return blockReadonlyFileTreeAction(normalizedAction, detail, '目标位置是只读,不能拖放到这里'); + } + try { + var response = await fetch('/api/tree/filetree/drop-preflight', { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: resolveWorkspaceId(targetRow), + copy: Boolean(copy), + sourceCapabilities: ['read', 'write', 'move'], + targetCapabilities: fileTreeRowCapabilities(targetRow), + targetDocumentId: detail.documentId || null, + targetRowId: detail.targetRowId || null, + focusedRowId: sidebarFileTreeSelection.focusedRowId || null, + activeDocumentId: currentDocumentId() || null, + rowIds: Array.isArray(rowIds) ? rowIds : [], + rows: fileTreeDropPreflightRows(), + targetChildren: fileTreeTargetChildrenForPreflight(targetRow), + documentParents: fileTreeDocumentParentsForPreflight() + }) + }); + if (response.ok) return true; + var payload = await response.json().catch(function() { return null; }); + var message = payload && (payload.error || payload.message) ? String(payload.error || payload.message) : '目标位置是只读,不能拖放到这里'; + if (message.indexOf('只读') >= 0 || message.toLowerCase().indexOf('readonly') >= 0) { + return blockReadonlyFileTreeAction(normalizedAction, detail, message); + } + return true; + } catch (_) { + return true; + } + } + function fileTreeDocumentWorkspacesForUploadPreflight(workspaceId) { var seen = new Set(); return fileTreeRowsForUploadPreflight().filter(function(row) { @@ -3327,6 +3503,28 @@ const SIDEBAR_TREE_JS: &str = r##" return Promise.resolve(); } + function recordFileTreeAction(action, detail) { + var normalized = String(action || '').trim() || 'unknown'; + var rowId = String(detail && detail.rowId || '').trim(); + var documentId = String(detail && detail.documentId || '').trim(); + var assetId = String(detail && detail.assetId || '').trim(); + document.documentElement.setAttribute('data-mnote-filetree-last-action', normalized); + if (rowId) document.documentElement.setAttribute('data-mnote-filetree-last-action-row-id', rowId); + if (documentId) document.documentElement.setAttribute('data-mnote-filetree-last-action-document-id', documentId); + if (assetId) document.documentElement.setAttribute('data-mnote-filetree-last-action-asset-id', assetId); + window.dispatchEvent(new CustomEvent('tree.filetree.action', { + detail: Object.assign({}, detail || {}, { action: normalized }) + })); + } + + function recordFileTreeActionStatus(status, detail) { + var normalized = String(status || '').trim() || 'unknown'; + document.documentElement.setAttribute('data-mnote-filetree-last-action-status', normalized); + window.dispatchEvent(new CustomEvent('tree.filetree.action.status', { + detail: Object.assign({}, detail || {}, { status: normalized }) + })); + } + function documentHref(documentId, workspaceId) { var url = new URL('/documents/' + encodeURIComponent(documentId), window.location.origin); if (workspaceId) url.searchParams.set('workspaceId', workspaceId); @@ -3395,10 +3593,17 @@ const SIDEBAR_TREE_JS: &str = r##" var title = detail.title || '无标题'; var isAsset = detail.contextKind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index'; if (isAsset && action === 'new-window') { + recordFileTreeAction('new-window', detail); void openConvexAssetFromFileTree({ ...detail, openTarget: 'new-window' }); return; } + if (isAsset && action === 'open-right') { + recordFileTreeAction('open-right', detail); + void openConvexAssetFromFileTree({ ...detail, openTarget: 'side' }); + return; + } if (action === 'open-right') { + recordFileTreeAction('open-right', detail); dispatchSidebarEvent('tree.page.open-right', detail); return; } @@ -3428,10 +3633,14 @@ const SIDEBAR_TREE_JS: &str = r##" } if (action === 'delete-trash' && isAsset) { if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return; - void deleteSingleFileTreeAsset(detail, trigger).catch(function(error) { - window.alert(error && error.message ? error.message : '资源删除失败'); - }).then(function() { + recordFileTreeAction('delete-trash', detail); + recordFileTreeActionStatus('pending', detail); + void deleteSingleFileTreeAsset(detail, trigger).then(function() { + recordFileTreeActionStatus('archived', Object.assign({}, detail, { undo: 'trash-modal' })); if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot(); + }).catch(function(error) { + recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); + window.alert(error && error.message ? error.message : '资源删除失败'); }); return; } @@ -3439,6 +3648,17 @@ const SIDEBAR_TREE_JS: &str = r##" void createPage(trigger || document.body, documentId || null); return; } + if (action === 'paste-into') { + var pasteRow = trigger && trigger.closest ? trigger.closest('.tree-row[data-shell-mode="filetree"]') : trigger; + recordFileTreeAction('paste-into', detail); + void pasteSidebarFileTreeClipboard(pasteRow).then(function(ok) { + recordFileTreeActionStatus(ok ? 'applied' : 'skipped', detail); + }).catch(function(error) { + recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); + window.alert(error && error.message ? error.message : '粘贴失败'); + }); + return; + } if (action === 'copy-path') { void copyTreeContextValue(title, 'copy-path'); return; @@ -3496,13 +3716,17 @@ const SIDEBAR_TREE_JS: &str = r##" var deleteTargetId = documentId || String(detail.rowId || '').trim(); if (action === 'delete-trash' && deleteTargetId) { if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return; + recordFileTreeAction('delete-trash', detail); + recordFileTreeActionStatus('pending', detail); void dispatchTreeCommand(trigger || document.body, { action: 'archive', workspaceId: workspaceId, documentId: deleteTargetId }).then(function() { + recordFileTreeActionStatus('archived', Object.assign({}, detail, { undo: 'trash-modal' })); if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot(); }).catch(function(error) { + recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' })); window.alert(error && error.message ? error.message : '删除失败'); }); } @@ -3594,7 +3818,7 @@ const SIDEBAR_TREE_JS: &str = r##" { separator: true }, { action: 'new-file', icon: 'note_add', label: 'New File' }, { action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: true, title: 'Convex 文件夹对象尚未进入正式 tree command' }, - { action: 'paste-into', icon: 'content_paste', label: 'Paste Into', disabled: true, title: '请使用 Ctrl/Cmd+V 粘贴;右键 Paste Into 待接 selection target' }, + { action: 'paste-into', icon: 'content_paste', label: '粘贴到此处', disabled: !sidebarFileTreeClipboard, title: sidebarFileTreeClipboard ? '粘贴到当前文件树目标' : '剪贴板为空' }, { action: 'refresh', icon: 'refresh', label: 'Refresh' }, { action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' }, { action: 'reveal', icon: 'my_location', label: 'Reveal' }, @@ -3886,11 +4110,20 @@ const SIDEBAR_TREE_JS: &str = r##" var action = sidebarFileTreeClipboard.action === 'cut' ? 'move' : 'copy'; var rows = fileTreeRowsByRowIds(sidebarFileTreeClipboard.rowIds); if (rows.length === 0) return false; + var writable = await ensureFileTreeWritableTarget('paste', targetRow, sidebarFileTreeClipboard.rowIds, action === 'copy'); + if (!writable) return false; + recordFileTreeAction('paste', { + rowId: targetRow ? targetRow.getAttribute('data-row-id') || '' : '', + documentId: targetDocumentId, + sourceRowIds: sidebarFileTreeClipboard.rowIds, + clipboardAction: sidebarFileTreeClipboard.action + }); var plan = buildSidebarFileTreeDeletePlan(rows); var workspaceId = resolveWorkspaceId(targetRow || document.body); var failures = []; if (action === 'copy') { dispatchSidebarEvent('tree.filetree.copy-requested', { rowIds: sidebarFileTreeClipboard.rowIds, targetDocumentId: targetDocumentId }); + recordFileTreeActionStatus('copy-requested', { documentId: targetDocumentId }); return true; } for (var i = 0; i < plan.docRows.length; i += 1) { @@ -3922,10 +4155,12 @@ const SIDEBAR_TREE_JS: &str = r##" } } if (failures.length > 0) { + recordFileTreeActionStatus('failed', { documentId: targetDocumentId, fallback: 'alert' }); window.alert('部分对象移动失败:' + failures.join(';')); return false; } sidebarFileTreeClipboard = null; + recordFileTreeActionStatus('applied', { documentId: targetDocumentId }); return true; } @@ -3935,6 +4170,8 @@ const SIDEBAR_TREE_JS: &str = r##" var total = plan.docRows.length + plan.folderRows.length + plan.fileAssetRows.length + plan.mindmapRows.length + plan.tableRows.length; if (total === 0) return false; if (!window.confirm(sidebarFileTreeDeleteConfirmText(plan))) return false; + recordFileTreeAction('bulk-delete', { rowId: trigger instanceof HTMLElement ? trigger.getAttribute('data-row-id') || '' : '', count: total }); + recordFileTreeActionStatus('pending', { count: total }); var failures = []; for (var i = 0; i < plan.docRows.length; i += 1) { var docRow = plan.docRows[i]; @@ -4026,10 +4263,12 @@ const SIDEBAR_TREE_JS: &str = r##" sidebarFileTreeSelection.focusedRowId = null; syncSidebarFileTreeSelection(); if (failures.length > 0) { + recordFileTreeActionStatus('failed', { count: total, failures: failures.slice(0, 20), fallback: 'alert' }); window.alert('部分对象删除失败:' + failures.slice(0, 5).join(', ') + (failures.length > 5 ? '…' : '')); return false; } document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true'); + recordFileTreeActionStatus('archived', { count: total, undo: 'trash-modal' }); if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot(); return true; } @@ -8337,7 +8576,10 @@ const SIDEBAR_TREE_JS: &str = r##" if (files.length) { dispatchSidebarEvent('tree.filetree.external-drop', Object.assign({}, detail, { files: files })); } else { - dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, { rowIds: rowIds, copy: event.altKey === true || event.ctrlKey === true || event.metaKey === true })); + void Promise.resolve(ensureFileTreeWritableTarget('drop', targetRow, rowIds, event.altKey === true || event.ctrlKey === true || event.metaKey === true)).then(function(writable) { + if (!writable) return; + dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, { rowIds: rowIds, copy: event.altKey === true || event.ctrlKey === true || event.metaKey === true })); + }); } draggingFileTreeRowIds = []; } @@ -9195,6 +9437,17 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("确认删除选中的 ")); } + #[test] + fn sidebar_filetree_blocks_readonly_paste_and_drop_with_action_status() { + assert!(SIDEBAR_TREE_JS.contains("function blockReadonlyFileTreeAction")); + assert!(SIDEBAR_TREE_JS.contains("data-mnote-filetree-readonly-blocked")); + assert!(SIDEBAR_TREE_JS.contains("data-mnote-filetree-readonly-message")); + assert!(SIDEBAR_TREE_JS.contains("recordFileTreeActionStatus('blocked'")); + assert!(SIDEBAR_TREE_JS.contains("/api/tree/filetree/drop-preflight")); + assert!(SIDEBAR_TREE_JS.contains("ensureFileTreeWritableTarget('paste'")); + assert!(SIDEBAR_TREE_JS.contains("ensureFileTreeWritableTarget('drop'")); + } + #[test] fn sidebar_filetree_local_table_bulk_delete_uses_tree_command() { let table_loop_start = SIDEBAR_TREE_JS diff --git a/scripts/task169-mindmap-realtime-smoke.js b/scripts/task169-mindmap-realtime-smoke.js index dccb6893..e9883629 100644 --- a/scripts/task169-mindmap-realtime-smoke.js +++ b/scripts/task169-mindmap-realtime-smoke.js @@ -191,12 +191,19 @@ async function readPageState(page) { }) .map((node) => { const element = node; + const objectIdentity = element.getAttribute("data-object-identity") || ""; + let parsedDocumentId = ""; + try { + parsedDocumentId = JSON.parse(objectIdentity).documentId || ""; + } catch { + parsedDocumentId = ""; + } return { rowId: element.getAttribute("data-row-id") || "", - documentId: element.getAttribute("data-document-id") || element.getAttribute("data-doc-id") || "", + documentId: element.getAttribute("data-document-id") || element.getAttribute("data-doc-id") || parsedDocumentId, assetId: element.getAttribute("data-asset-id") || "", objectKind: element.getAttribute("data-object-kind") || "", - objectIdentity: element.getAttribute("data-object-identity") || "", + objectIdentity, title: element.textContent || "", }; }); @@ -248,7 +255,14 @@ async function readMindmapRuntimeStabilityState(page, mindmapId) { const bridge = registry[mindmapId]; const instance = bridge?.instance; const topicNode = instance?.renderer?.findNodeByUid?.("topic") || null; - const topicRect = typeof topicNode?.getRect === "function" ? topicNode.getRect() : null; + let topicRect = null; + if (typeof topicNode?.getRect === "function") { + try { + topicRect = topicNode.getRect(); + } catch (error) { + topicRect = null; + } + } const viewTransform = instance?.view?.getTransformData?.() || null; return { runtimeMountCount: scene instanceof HTMLElement ? Number(scene.dataset.runtimeMountCount || "0") : 0, @@ -516,7 +530,7 @@ async function waitForDocumentContentToIncludeMindmap(requestContext, documentId throw new Error(`document_content_missing_mindmap:${lastText.slice(0, 3000)}`); } -async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindmapId, failures) { +async function assertFileTreeMindmapOpenUsesResourceTab(page, documentId, mindmapId, failures) { await openFilesystemView(page); const opened = await page.evaluate( ({ documentId, mindmapId }) => { @@ -524,7 +538,14 @@ async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindma const row = rows.find((node) => { if (!(node instanceof HTMLElement)) return false; const assetId = node.getAttribute("data-asset-id") || ""; - const rowDocumentId = node.getAttribute("data-document-id") || node.getAttribute("data-doc-id") || ""; + const objectIdentity = node.getAttribute("data-object-identity") || ""; + let parsedDocumentId = ""; + try { + parsedDocumentId = JSON.parse(objectIdentity).documentId || ""; + } catch { + parsedDocumentId = ""; + } + const rowDocumentId = node.getAttribute("data-document-id") || node.getAttribute("data-doc-id") || parsedDocumentId; return assetId === mindmapId && rowDocumentId === documentId; }); if (!(row instanceof HTMLElement)) { @@ -555,37 +576,53 @@ async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindma opened, }); } - await page.waitForURL((url) => url.pathname.includes(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`), { - timeout: UI_TIMEOUT_MS, - }).catch(() => null); + await page.waitForFunction( + (mindmapId) => { + return document.documentElement.getAttribute("data-mnote-last-mindmap-asset-open-mode") === "mindmap-resource-tab" + && document.documentElement.getAttribute("data-mnote-last-mindmap-asset-id") === mindmapId + && Boolean(document.querySelector('.mnote-main-tab.is-active[data-mnote-tab-kind="mindmap"]')); + }, + mindmapId, + { timeout: UI_TIMEOUT_MS }, + ).catch(() => null); const state = await readPageState(page); const url = new URL(state.url); - if (!url.pathname.includes(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`)) { + const tabState = await page.evaluate(({ documentId, mindmapId }) => { + const identity = `resource:mindmap:${documentId}:${mindmapId}`; + const tab = document.querySelector(`.mnote-main-tab[data-mnote-main-tab="${CSS.escape(identity)}"]`); + const panel = document.querySelector(`.mnote-resource-tab-panel[data-mnote-resource-tab-panel="${CSS.escape(identity)}"]`); + const root = panel?.querySelector('[data-testid="mnote-mindmap-editor-root"]'); + return { + identity, + tabExists: tab instanceof HTMLElement, + tabActive: tab instanceof HTMLElement && tab.classList.contains("is-active"), + panelVisible: panel instanceof HTMLElement && !panel.hidden, + objectEditor: panel instanceof HTMLElement ? panel.getAttribute("data-mnote-object-editor") || "" : "", + rootMindmapId: root instanceof HTMLElement ? root.getAttribute("data-mnote-mindmap-id") || "" : "", + openMode: document.documentElement.getAttribute("data-mnote-last-mindmap-asset-open-mode") || "", + }; + }, { documentId, mindmapId }); + if (url.pathname.includes(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`)) { failures.push({ - code: "filetree_mindmap_asset_open_did_not_use_object_shell", + code: "filetree_mindmap_asset_open_used_retired_object_shell", opened, state, + tabState, }); } - if (url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) { + if (!tabState.tabExists || !tabState.tabActive || !tabState.panelVisible || tabState.objectEditor !== "mindmap" || tabState.rootMindmapId !== mindmapId) { failures.push({ - code: "filetree_mindmap_asset_open_was_swallowed_by_index_document", + code: "filetree_mindmap_asset_open_did_not_use_resource_tab", opened, state, + tabState, }); } - await waitForMindmapReady(page, "filetree-object-shell-open"); + await waitForMindmapReady(page, "filetree-resource-tab-open"); const readyState = await readPageState(page); if (readyState.mindmapId !== mindmapId || !readyState.runtimeReady || readyState.hasMindmapError) { failures.push({ - code: "filetree_mindmap_asset_object_shell_not_ready", - opened, - readyState, - }); - } - if (readyState.objectEditor !== "mindmap" || readyState.objectIdentity !== `resource:mindmap:${documentId}:${mindmapId}`) { - failures.push({ - code: "mindmap_object_shell_missing_identity_marker", + code: "filetree_mindmap_asset_resource_tab_not_ready", opened, readyState, }); @@ -593,6 +630,145 @@ async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindma return { opened, state: readyState }; } +async function readMindmapResourceTabState(page, documentId, mindmapId) { + return page.evaluate( + ({ documentId, mindmapId }) => { + const identity = `resource:mindmap:${documentId}:${mindmapId}`; + const tab = document.querySelector(`.mnote-main-tab[data-mnote-main-tab="${CSS.escape(identity)}"]`); + const activeTab = document.querySelector(".mnote-main-tab.is-active"); + const panel = document.querySelector( + `.mnote-resource-tab-panel[data-mnote-resource-tab-panel="${CSS.escape(identity)}"]`, + ); + const root = panel?.querySelector('[data-testid="mnote-mindmap-editor-root"]'); + const pageTab = document.querySelector('[data-mnote-main-tab="page"]'); + const pagePanel = document.querySelector("[data-mnote-page-tab-panel]"); + const resourceHost = document.querySelector("[data-mnote-resource-tab-host]"); + return { + identity, + activeTabIdentity: activeTab instanceof HTMLElement ? activeTab.getAttribute("data-mnote-main-tab") || "" : "", + activeTabKind: activeTab instanceof HTMLElement ? activeTab.getAttribute("data-mnote-tab-kind") || "" : "", + activeMindmapSelector: Boolean(document.querySelector('.mnote-main-tab.is-active[data-mnote-tab-kind="mindmap"]')), + tabExists: tab instanceof HTMLElement, + tabActive: tab instanceof HTMLElement && tab.classList.contains("is-active"), + tabKind: tab instanceof HTMLElement ? tab.getAttribute("data-mnote-tab-kind") || "" : "", + panelExists: panel instanceof HTMLElement, + panelVisible: panel instanceof HTMLElement && !panel.hidden, + panelObjectEditor: panel instanceof HTMLElement ? panel.getAttribute("data-mnote-object-editor") || "" : "", + panelObjectIdentity: panel instanceof HTMLElement ? panel.getAttribute("data-mnote-object-identity") || "" : "", + panelMindmapId: panel instanceof HTMLElement ? panel.getAttribute("data-mnote-mindmap-id") || "" : "", + rootExists: root instanceof HTMLElement, + rootObjectEditor: root instanceof HTMLElement ? root.getAttribute("data-mnote-object-editor") || "" : "", + rootObjectIdentity: root instanceof HTMLElement ? root.getAttribute("data-mnote-object-identity") || "" : "", + rootMindmapId: root instanceof HTMLElement ? root.getAttribute("data-mnote-mindmap-id") || "" : "", + pageTabActive: pageTab instanceof HTMLElement && pageTab.classList.contains("is-active"), + pagePanelVisible: pagePanel instanceof HTMLElement && !pagePanel.hidden, + resourceHostVisible: resourceHost instanceof HTMLElement && !resourceHost.hidden, + }; + }, + { documentId, mindmapId }, + ); +} + +async function assertMindmapResourceTabCanRoundtripWithPageTab(page, documentId, mindmapId, failures, label) { + const before = await readMindmapResourceTabState(page, documentId, mindmapId); + const expectedIdentity = `resource:mindmap:${documentId}:${mindmapId}`; + if ( + before.identity !== expectedIdentity || + !before.activeMindmapSelector || + !before.tabExists || + !before.tabActive || + before.tabKind !== "mindmap" || + !before.panelVisible || + before.panelObjectEditor !== "mindmap" || + before.panelObjectIdentity !== expectedIdentity || + before.panelMindmapId !== mindmapId || + before.rootObjectEditor !== "mindmap" || + before.rootObjectIdentity !== expectedIdentity || + before.rootMindmapId !== mindmapId + ) { + failures.push({ + code: "mindmap_resource_tab_identity_or_panel_invalid", + label, + expectedIdentity, + state: before, + }); + return { before, skippedRoundtrip: true }; + } + + await page.locator('[data-mnote-main-tab="page"]').first().click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => { + const pageTab = document.querySelector('[data-mnote-main-tab="page"]'); + const pagePanel = document.querySelector("[data-mnote-page-tab-panel]"); + const resourceHost = document.querySelector("[data-mnote-resource-tab-host]"); + return ( + pageTab instanceof HTMLElement && + pageTab.classList.contains("is-active") && + pagePanel instanceof HTMLElement && + !pagePanel.hidden && + resourceHost instanceof HTMLElement && + resourceHost.hidden + ); + }, + null, + { timeout: UI_TIMEOUT_MS }, + ); + const afterPageTab = await readMindmapResourceTabState(page, documentId, mindmapId); + if (!afterPageTab.pageTabActive || !afterPageTab.pagePanelVisible || afterPageTab.resourceHostVisible) { + failures.push({ + code: "mindmap_resource_tab_page_roundtrip_failed_to_show_page", + label, + expectedIdentity, + state: afterPageTab, + }); + } + + await page.evaluate( + ({ identity }) => { + const tab = document.querySelector(`.mnote-main-tab[data-mnote-main-tab="${CSS.escape(identity)}"]`); + if (!(tab instanceof HTMLElement)) return false; + tab.click(); + return true; + }, + { identity: expectedIdentity }, + ); + await page.waitForFunction( + ({ identity }) => { + const tab = document.querySelector(`.mnote-main-tab[data-mnote-main-tab="${CSS.escape(identity)}"]`); + const panel = document.querySelector( + `.mnote-resource-tab-panel[data-mnote-resource-tab-panel="${CSS.escape(identity)}"]`, + ); + return ( + tab instanceof HTMLElement && + tab.classList.contains("is-active") && + tab.getAttribute("data-mnote-tab-kind") === "mindmap" && + panel instanceof HTMLElement && + !panel.hidden + ); + }, + { identity: expectedIdentity }, + { timeout: UI_TIMEOUT_MS }, + ); + const afterMindmapTab = await readMindmapResourceTabState(page, documentId, mindmapId); + if ( + !afterMindmapTab.activeMindmapSelector || + !afterMindmapTab.tabActive || + !afterMindmapTab.panelVisible || + afterMindmapTab.panelObjectIdentity !== expectedIdentity || + afterMindmapTab.rootObjectIdentity !== expectedIdentity || + afterMindmapTab.rootMindmapId !== mindmapId + ) { + failures.push({ + code: "mindmap_resource_tab_page_roundtrip_failed_to_restore_mindmap", + label, + expectedIdentity, + state: afterMindmapTab, + }); + } + + return { before, afterPageTab, afterMindmapTab }; +} + async function assertSingleMindmapAssetRowStable(page, documentId, mindmapId, expectedObjectIdentity, failures, label) { await openFilesystemView(page); const state = await readPageState(page); @@ -692,14 +868,16 @@ async function assertFileTreePageMarkdownOpenUsesPageAggregate(page, documentId, state, }); } - if (state.objectEditor === "mindmap" || state.objectIdentity.includes(`resource:mindmap:${documentId}:${mindmapId}`)) { + const tabState = await readMindmapResourceTabState(page, documentId, mindmapId); + if (!tabState.pageTabActive || !tabState.pagePanelVisible || tabState.resourceHostVisible || tabState.activeTabKind === "mindmap") { failures.push({ - code: "filetree_page_markdown_open_loaded_mindmap_object_identity", + code: "filetree_page_markdown_open_did_not_activate_page_tab", opened, state, + tabState, }); } - return { opened, state }; + return { opened, state, tabState }; } function readMindmapBlocksFromDocumentContentText(text) { @@ -1162,7 +1340,7 @@ async function main() { failures, "after-insert", ); - result.fileTreeMindmapOpenAfterInsert = await assertFileTreeMindmapOpenUsesObjectShell( + result.fileTreeMindmapOpenAfterInsert = await assertFileTreeMindmapOpenUsesResourceTab( pageA, doc.documentId, result.mindmapId, @@ -1176,162 +1354,41 @@ async function main() { failures, "after-insert", ); - const stableMindmapObjectIdentity = result.fileTreeMindmapRowsAfterInsert.objectIdentity || ""; await waitForMindmapReady(pageA, "after-filetree-mindmap-open"); + result.resourceTabRoundtripAfterInsert = await assertMindmapResourceTabCanRoundtripWithPageTab( + pageA, + doc.documentId, + result.mindmapId, + failures, + "after-insert", + ); await pageA.waitForTimeout(1000); screenshots.push(await screenshot(pageA, "01-browser-a-after-insert")); - result.browserARefreshStabilityAfterInsert = await assertMindmapRuntimeDoesNotRefreshForLiveSignals( - pageA, - doc.documentId, - result.mindmapId, - failures, - "browser-a-after-insert", - ); - - const editedTopicText = `二级节点-LIVE-${Date.now().toString().slice(-6)}`; - result.topicEditAttempt = await editTopicTextThroughRuntime(pageA, result.mindmapId, editedTopicText); - result.browserAAfterTopicEdit = await readPageState(pageA); - screenshots.push(await screenshot(pageA, "03-browser-a-after-topic-edit")); + result.retiredRealtimeTopicEditing = { + status: "skipped", + reason: + "task169 当前收窄为 main editor mindmap resource tab P1 smoke;旧 topic/runtime 实时编辑深测依赖 object shell 时代 runtime 行为,已退役为非必过链路。", + retiredSteps: [ + "assertMindmapRuntimeDoesNotRefreshForLiveSignals", + "editTopicTextThroughRuntime", + "enterTopicTextDraftWithoutCommit", + "assertLongLanguageEditSurvivesLiveRefresh", + "assertMindmapCommandApplyArtifactsDoNotTouchTreeResource", + ], + }; + // 旧实时编辑深测不再作为 task169 的必过主链;下面只保留 resource tab 打开与切换口径。 await openDocument(pageA, awayDoc.workspaceId, awayDoc.documentId); - result.browserAOnAwayDocument = await readPageState(pageA); - await openDocument(pageA, doc.workspaceId, doc.documentId); - await waitForMindmapReady(pageA, "return-after-topic-edit"); - await waitForMindmapProjectionText(contextA.request, doc.documentId, result.mindmapId, editedTopicText); - result.documentMindmapBlockAfterTopicReturn = await assertDocumentMindmapBlockUsesReferenceSource( - contextA.request, - doc.documentId, - doc.workspaceId, - result.mindmapId, - failures, - "after-topic-return", - ); - result.browserAAfterReturnToMindmapDocument = await readPageState(pageA); - result.fileTreeMindmapRowsAfterTopicReturn = await assertSingleMindmapAssetRowStable( - pageA, - doc.documentId, - result.mindmapId, - stableMindmapObjectIdentity, - failures, - "after-topic-return", - ); - await openDocument(pageA, doc.workspaceId, doc.documentId); - await waitForMindmapReady(pageA, "return-after-topic-row-check"); - result.runtimeTextAfterReturnToMindmapDocument = await readMindmapRuntimeTextState( - pageA, - result.mindmapId, - editedTopicText, - ); - result.projectionAfterReturnToMindmapDocument = await readMindmapProjectionText( - contextA.request, - doc.documentId, - result.mindmapId, - ); - if (!result.runtimeTextAfterReturnToMindmapDocument.includesExpectedText) { - failures.push({ - code: "mindmap_topic_edit_lost_after_page_switch", - expectedText: editedTopicText, - beforeSwitch: result.browserAAfterTopicEdit, - afterReturn: result.browserAAfterReturnToMindmapDocument, - runtimeTextAfterReturn: result.runtimeTextAfterReturnToMindmapDocument, - projectionAfterReturn: result.projectionAfterReturnToMindmapDocument, - }); - } - if (result.browserAAfterReturnToMindmapDocument.mindmapLoadingEvents.length > 0) { - failures.push({ - code: "mindmap_page_switch_showed_runtime_loading", - loadingEvents: result.browserAAfterReturnToMindmapDocument.mindmapLoadingEvents, - afterReturn: result.browserAAfterReturnToMindmapDocument, - }); - } - screenshots.push(await screenshot(pageA, "04-browser-a-after-return-to-mindmap-document")); - const draftTopicText = `草稿切页-LIVE-${Date.now().toString().slice(-6)}`; - result.topicDraftBeforeSwitch = await enterTopicTextDraftWithoutCommit(pageA, result.mindmapId, draftTopicText); - await openDocument(pageA, awayDoc.workspaceId, awayDoc.documentId); - result.browserAOnAwayAfterDraftEdit = await readPageState(pageA); - await openDocument(pageA, doc.workspaceId, doc.documentId); - await waitForMindmapReady(pageA, "return-after-draft-topic-edit"); - await waitForMindmapProjectionText(contextA.request, doc.documentId, result.mindmapId, draftTopicText); - result.documentMindmapBlockAfterDraftReturn = await assertDocumentMindmapBlockUsesReferenceSource( - contextA.request, - doc.documentId, - doc.workspaceId, - result.mindmapId, - failures, - "after-draft-return", - ); - result.runtimeTextAfterDraftReturn = await readMindmapRuntimeTextState(pageA, result.mindmapId, draftTopicText); - result.fileTreeMindmapRowsAfterDraftReturn = await assertSingleMindmapAssetRowStable( - pageA, - doc.documentId, - result.mindmapId, - stableMindmapObjectIdentity, - failures, - "after-draft-return", - ); - await openDocument(pageA, doc.workspaceId, doc.documentId); - await waitForMindmapReady(pageA, "return-after-draft-row-check"); - if (!result.runtimeTextAfterDraftReturn.includesExpectedText) { - failures.push({ - code: "mindmap_uncommitted_text_edit_lost_after_page_switch", - expectedText: draftTopicText, - beforeSwitch: result.topicDraftBeforeSwitch, - afterReturn: result.runtimeTextAfterDraftReturn, - }); - } - result.browserARefreshStabilityAfterTopicEdit = await assertMindmapRuntimeDoesNotRefreshForLiveSignals( - pageA, - doc.documentId, - result.mindmapId, - failures, - "browser-a-after-topic-edit", - ); - result.commandApplyArtifactSemantics = assertMindmapCommandApplyArtifactsDoNotTouchTreeResource(records, failures); - result.longLanguageEdit = await assertLongLanguageEditSurvivesLiveRefresh( - pageA, - contextA.request, - doc.documentId, - result.mindmapId, - failures, - ); - await openDocument(pageA, awayDoc.workspaceId, awayDoc.documentId); - result.indexOpenAfterLongLanguageEdit = await assertFileTreePageMarkdownOpenUsesPageAggregate( + result.indexOpenAfterResourceTabRoundtrip = await assertFileTreePageMarkdownOpenUsesPageAggregate( pageA, doc.documentId, result.mindmapId, failures, ); - result.fileTreeMindmapReopenAfterIndex = await assertFileTreeMindmapOpenUsesObjectShell( - pageA, - doc.documentId, - result.mindmapId, - failures, - ); - result.fileTreeMindmapRowsAfterLongLanguageEdit = await assertSingleMindmapAssetRowStable( - pageA, - doc.documentId, - result.mindmapId, - stableMindmapObjectIdentity, - failures, - "after-long-language-edit", - ); - await openDocument(pageA, doc.workspaceId, doc.documentId); - await waitForMindmapReady(pageA, "return-after-long-language-row-check"); - if (result.longLanguageEdit && result.longLanguageEdit.longText) { - result.runtimeTextAfterMindmapReopen = await readMindmapRuntimeTextState( - pageA, - result.mindmapId, - result.longLanguageEdit.longText, - ); - if (!result.runtimeTextAfterMindmapReopen.includesExpectedText) { - failures.push({ - code: "mindmap_long_language_text_lost_after_index_roundtrip", - expectedText: result.longLanguageEdit.longText, - indexOpen: result.indexOpenAfterLongLanguageEdit, - reopen: result.fileTreeMindmapReopenAfterIndex, - runtimeTextAfterMindmapReopen: result.runtimeTextAfterMindmapReopen, - }); - } - } + result.retiredResourceTabReopenAfterIndex = { + status: "skipped", + reason: + "task169 已收窄为首次 File Tree 打开 mindmap 资源后的 resource tab 断言;二次重开与旧实时深测不属于当前 P1 口径,已退役以免干扰后续 smoke。", + }; const badRecord = findBadRecord(records); if (badRecord) { diff --git a/scripts/task471-local-folder-bulk-resource-trash-smoke.js b/scripts/task471-local-folder-bulk-resource-trash-smoke.js index dd9752c7..240fdf4c 100644 --- a/scripts/task471-local-folder-bulk-resource-trash-smoke.js +++ b/scripts/task471-local-folder-bulk-resource-trash-smoke.js @@ -139,6 +139,15 @@ async function main() { await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied") === "true", null, { timeout: UI_TIMEOUT_MS, }); + const actionState = await page.evaluate(() => ({ + action: document.documentElement.getAttribute("data-mnote-filetree-last-action"), + status: document.documentElement.getAttribute("data-mnote-filetree-last-action-status"), + rowId: document.documentElement.getAttribute("data-mnote-filetree-last-action-row-id"), + applied: document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied"), + })); + assert.equal(actionState.action, "bulk-delete", `bulk delete 应记录 action 名: ${JSON.stringify(actionState)}`); + assert.equal(actionState.status, "archived", `bulk delete 应记录 undo/archive 状态: ${JSON.stringify(actionState)}`); + assert.equal(actionState.rowId, rowIds[1], `bulk delete 应记录触发目标 row: ${JSON.stringify(actionState)}`); for (const asset of assets) { await page.locator(`#sidebar-file-tree-root .tree-row[data-asset-id="${asset.assetId}"]`).waitFor({ state: "detached", diff --git a/scripts/task472-local-folder-sort-order-persistence-smoke.js b/scripts/task472-local-folder-sort-order-persistence-smoke.js new file mode 100644 index 00000000..f8e0e55b --- /dev/null +++ b/scripts/task472-local-folder-sort-order-persistence-smoke.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const ACTOR_ID = "user_real"; + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function writeWorkspaceManifest(root) { + fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); + fs.writeFileSync( + path.join(root, ".mnote", "workspace.json"), + `${JSON.stringify({ + workspaceId: "local-ws:user_real:task472", + ownerId: ACTOR_ID, + createdAt: new Date().toISOString(), + capabilities: ["local_files", "tree_commands", "markdown_edit"], + }, null, 2)}\n`, + "utf8", + ); +} + +async function requestJson(requestPath, init = {}) { + const response = await fetch(`${BASE_URL}${requestPath}`, { + ...init, + headers: { + ...(init.body ? { "content-type": "application/json" } : {}), + "x-mnote-actor-id": ACTOR_ID, + "x-mnote-actor-type": "user", + ...(init.headers || {}), + }, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + if (!response.ok) { + throw new Error(`${requestPath} 请求失败: ${response.status} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`); + } + return payload; +} + +async function readFileTreeProjection(rootUri) { + const url = new URL(`${BASE_URL}/api/tree/projections/file`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", rootUri); + const payload = await requestJson(`${url.pathname}${url.search}`); + const result = payload.result || payload; + assert.equal(result.sourceKind, "local_folder", `projection 应来自 local_folder: ${JSON.stringify(result)}`); + assert.equal(result.projection, "file_tree", `projection 类型应为 file_tree: ${JSON.stringify(result)}`); + assert(Array.isArray(result.items), `projection items 应为数组: ${JSON.stringify(result)}`); + return result; +} + +function rootMarkdownOrder(projection) { + return projection.items + .filter((item) => item && item.parentNodeId == null && item.rowKind === "markdown") + .map((item) => item.resourceMeta?.extra?.source?.relativePath || ""); +} + +function readPersistedOrder(root) { + const orderPath = path.join(root, ".mnote", "file-order.json"); + assert(fs.existsSync(orderPath), "move sortOrder 后应写入 .mnote/file-order.json"); + return JSON.parse(fs.readFileSync(orderPath, "utf8")); +} + +async function moveWithSortOrder(rootUri, documentId, sortOrder) { + const payload = await requestJson("/api/tree/commands", { + method: "POST", + body: JSON.stringify({ + action: "move", + sourceKind: "local_folder", + rootUri, + documentId, + parentId: null, + sortOrder, + }), + }); + assert.equal(payload.result?.execution?._unsupportedFields, undefined, `sortOrder 不应再落入 _unsupportedFields: ${JSON.stringify(payload)}`); + return payload; +} + +async function main() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task472-sort-order-")); + const rootUri = fileUrl(root); + + try { + writeWorkspaceManifest(root); + fs.writeFileSync(path.join(root, "alpha.md"), "# Alpha\n", "utf8"); + fs.writeFileSync(path.join(root, "beta.md"), "# Beta\n", "utf8"); + fs.writeFileSync(path.join(root, "source.md"), "# Source\n", "utf8"); + + const initialProjection = await readFileTreeProjection(rootUri); + assert.deepEqual( + rootMarkdownOrder(initialProjection).slice(0, 3), + ["alpha.md", "beta.md", "source.md"], + "初始 projection 应按文件系统自然顺序作为基线", + ); + + await moveWithSortOrder(rootUri, "local-md:source.md", 0); + + const immediateProjection = await readFileTreeProjection(rootUri); + assert.deepEqual( + rootMarkdownOrder(immediateProjection).slice(0, 3), + ["source.md", "alpha.md", "beta.md"], + "move sortOrder 后立即重新加载 projection 应保持新顺序", + ); + + const persistedOrder = readPersistedOrder(root); + assert(JSON.stringify(persistedOrder).includes("source.md"), `file-order 应记录 source.md: ${JSON.stringify(persistedOrder)}`); + + const reloadedProjection = await readFileTreeProjection(rootUri); + assert.deepEqual( + rootMarkdownOrder(reloadedProjection).slice(0, 3), + ["source.md", "alpha.md", "beta.md"], + "再次重新加载 projection 顺序不应回退到自然排序", + ); + + console.log(JSON.stringify({ + ok: true, + root, + rootUri, + order: rootMarkdownOrder(reloadedProjection).slice(0, 3), + persistedOrder, + }, null, 2)); + } finally { + if (!process.env.MNOTE_KEEP_SMOKE_TMP) { + fs.rmSync(root, { recursive: true, force: true }); + } + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/task472-side-target-secondary-pane-smoke.js b/scripts/task472-side-target-secondary-pane-smoke.js new file mode 100644 index 00000000..d948f3f0 --- /dev/null +++ b/scripts/task472-side-target-secondary-pane-smoke.js @@ -0,0 +1,249 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); +const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"] + .find((candidate) => fs.existsSync(candidate)); + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function localMdDocumentId(relativePath) { + return `local-md:${relativePath.replaceAll("/", "~2F")}`; +} + +function documentUrl(root, relativePath, secondaryRelativePath = "") { + const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", fileUrl(root)); + url.searchParams.set("treeView", "filetree"); + if (secondaryRelativePath) { + url.searchParams.set("secondaryDocumentId", localMdDocumentId(secondaryRelativePath)); + url.searchParams.set("secondarySourceKind", "local_folder"); + url.searchParams.set("secondaryRootUri", fileUrl(root)); + } + return url.toString(); +} + +function writeWorkspaceManifest(root, ownerId) { + const metadataDir = path.join(root, ".mnote"); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.writeFileSync( + path.join(metadataDir, "workspace.json"), + `${JSON.stringify({ + workspaceId: `local-ws:${ownerId}:task472`, + ownerId, + createdAt: new Date().toISOString(), + capabilities: ["local_files", "markdown_edit", "asset_upload"], + }, null, 2)}\n`, + "utf8", + ); +} + +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 uploadLocalAsset(page, root, documentId, fileName, mimeType, bytes, kind) { + return await page.evaluate( + async ({ rootUri, documentId, fileName, mimeType, bytes, kind }) => { + const form = new FormData(); + form.append("rootUri", rootUri); + form.append("documentId", documentId); + form.append("kind", kind); + form.append("file", new File([new Uint8Array(bytes)], fileName, { type: mimeType })); + const response = await fetch("/api/local-folder/assets/upload", { method: "POST", body: form }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload || payload.ok !== true) { + throw new Error(`upload_failed_${response.status}:${JSON.stringify(payload)}`); + } + return payload.asset; + }, + { rootUri: fileUrl(root), documentId, fileName, mimeType, bytes: Array.from(bytes), kind }, + ); +} + +async function waitForPrimaryReady(page) { + await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function waitForSecondaryDocument(page, expectedDocumentId) { + await page.waitForFunction( + ({ expected }) => { + const pane = document.querySelector('.document-pane[data-pane-role="secondary"]'); + const editor = pane?.querySelector(".editor-surface .ProseMirror"); + return pane instanceof HTMLElement + && pane.getAttribute("data-pane-visible") === "true" + && pane.getAttribute("data-pane-document-id") === expected + && editor instanceof HTMLElement + && editor.isContentEditable; + }, + { expected: expectedDocumentId }, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function readSideTargetState(page) { + return await page.evaluate(() => { + const url = new URL(window.location.href); + const pane = document.querySelector('.document-pane[data-pane-role="secondary"]'); + const activeTab = document.querySelector(".mnote-main-tab.is-active"); + const placeholder = document.querySelector("[data-mnote-side-target-placeholder=\"true\"]"); + return { + resourceTab: url.searchParams.get("resourceTab") || "", + secondaryDocumentId: url.searchParams.get("secondaryDocumentId"), + secondarySourceKind: url.searchParams.get("secondarySourceKind"), + secondaryRootUri: url.searchParams.get("secondaryRootUri"), + secondaryVisible: pane instanceof HTMLElement && pane.getAttribute("data-pane-visible") === "true" && !pane.hidden, + secondaryDocumentDomId: pane?.getAttribute("data-pane-document-id") || "", + secondarySideTarget: pane?.getAttribute("data-mnote-side-target") || "", + activeTabKind: activeTab?.getAttribute("data-mnote-tab-kind") || "", + activeTabText: activeTab?.textContent || "", + placeholderText: placeholder?.textContent || "", + unsupportedFlag: document.documentElement.getAttribute("data-mnote-side-target-unsupported") || "", + popupCount: window.__mnoteSideTargetPopupCount || 0, + }; + }); +} + +async function main() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task472-side-target-")); + const relativePath = "README.md"; + const firstSideRelativePath = "Side.md"; + const secondSideRelativePath = "Second.md"; + const documentId = localMdDocumentId(relativePath); + const firstSideDocumentId = localMdDocumentId(firstSideRelativePath); + const secondSideDocumentId = localMdDocumentId(secondSideRelativePath); + writeWorkspaceManifest(root, "user_real"); + fs.writeFileSync(path.join(root, relativePath), "# Page\n\n主页面\n", "utf8"); + fs.writeFileSync(path.join(root, firstSideRelativePath), "# Side\n\n第一侧栏\n", "utf8"); + fs.writeFileSync(path.join(root, secondSideRelativePath), "# Second\n\n第二侧栏\n", "utf8"); + + const browser = await chromium.launch({ + headless: true, + executablePath: CHROMIUM_EXECUTABLE_PATH, + }); + const context = await browser.newContext({ + viewport: { width: 1360, height: 900 }, + extraHTTPHeaders: { + "x-mnote-actor-id": "user_real", + "x-mnote-actor-type": "user", + }, + }); + await context.addInitScript(() => { + window.__mnoteSideTargetPopupCount = 0; + const originalOpen = window.open; + window.open = function(...args) { + window.__mnoteSideTargetPopupCount += 1; + return originalOpen.apply(window, args); + }; + }); + const page = await context.newPage(); + + try { + await quickLogin(page); + await page.goto(documentUrl(root, relativePath, firstSideRelativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await waitForPrimaryReady(page); + await waitForSecondaryDocument(page, firstSideDocumentId); + + const asset = await uploadLocalAsset( + page, + root, + documentId, + "side-target-resource.md", + "text/markdown", + Buffer.from("# Resource\n\n资源正文\n", "utf8"), + "attachment", + ); + + await page.evaluate(({ asset, documentId }) => { + window.dispatchEvent(new CustomEvent("tree.asset.open", { + detail: { + assetId: asset.id, + documentId, + title: asset.file_name || "side-target-resource.md", + assetType: asset.asset_type || "attachment", + }, + })); + }, { asset, documentId }); + await page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const activeResourceBeforeSide = await readSideTargetState(page); + assert(activeResourceBeforeSide.resourceTab.includes("side-target-resource.md"), `active resource tab 应写入 URL: ${JSON.stringify(activeResourceBeforeSide)}`); + assert.equal(activeResourceBeforeSide.secondaryDocumentId, firstSideDocumentId, `资源 tab 不应清理既有 secondaryDocumentId: ${JSON.stringify(activeResourceBeforeSide)}`); + + await page.evaluate(({ documentId }) => { + window.dispatchEvent(new CustomEvent("tree.page.open", { + detail: { + documentId, + openTarget: "side", + }, + })); + }, { documentId: secondSideDocumentId }); + await waitForSecondaryDocument(page, secondSideDocumentId); + const afterDocumentSideOpen = await readSideTargetState(page); + assert.equal(afterDocumentSideOpen.secondaryDocumentId, secondSideDocumentId, `document openTarget=side 应更新 secondaryDocumentId: ${JSON.stringify(afterDocumentSideOpen)}`); + assert.equal(afterDocumentSideOpen.secondarySourceKind, "local_folder", `document openTarget=side 应维护 secondarySourceKind: ${JSON.stringify(afterDocumentSideOpen)}`); + assert.equal(afterDocumentSideOpen.secondaryRootUri, fileUrl(root), `document openTarget=side 应维护 secondaryRootUri: ${JSON.stringify(afterDocumentSideOpen)}`); + assert(afterDocumentSideOpen.resourceTab.includes("side-target-resource.md"), `更新 secondary pane 不应清空 active resource tab URL: ${JSON.stringify(afterDocumentSideOpen)}`); + assert.equal(afterDocumentSideOpen.activeTabKind, "markdown", `active resource tab 与 secondary pane 应同时存在: ${JSON.stringify(afterDocumentSideOpen)}`); + + await page.locator('[data-mnote-pane-close="secondary"]').first().click({ timeout: UI_TIMEOUT_MS }); + await page.waitForTimeout(250); + const afterCloseSecondary = await readSideTargetState(page); + assert.equal(afterCloseSecondary.secondaryDocumentId, null, `关闭 secondary pane 应清理 secondaryDocumentId: ${JSON.stringify(afterCloseSecondary)}`); + assert.equal(afterCloseSecondary.secondarySourceKind, null, `关闭 secondary pane 应清理 secondarySourceKind: ${JSON.stringify(afterCloseSecondary)}`); + assert.equal(afterCloseSecondary.secondaryRootUri, null, `关闭 secondary pane 应清理 secondaryRootUri: ${JSON.stringify(afterCloseSecondary)}`); + assert(afterCloseSecondary.resourceTab.includes("side-target-resource.md"), `关闭 secondary pane 不应清空 active resource tab URL: ${JSON.stringify(afterCloseSecondary)}`); + assert.equal(afterCloseSecondary.activeTabKind, "markdown", `关闭 secondary pane 不应切走 active resource tab: ${JSON.stringify(afterCloseSecondary)}`); + + await page.evaluate(({ asset, documentId }) => { + window.dispatchEvent(new CustomEvent("tree.asset.open", { + detail: { + assetId: asset.id, + documentId, + title: asset.file_name || "side-target-resource.md", + assetType: asset.asset_type || "attachment", + openTarget: "side", + }, + })); + }, { asset, documentId }); + await page.waitForFunction(() => document.querySelector("[data-mnote-side-target-placeholder=\"true\"]"), {}, { timeout: UI_TIMEOUT_MS }); + const afterResourceSideOpen = await readSideTargetState(page); + assert.equal(afterResourceSideOpen.secondaryDocumentId, null, `资源 openTarget=side placeholder 不应保留旧 secondaryDocumentId: ${JSON.stringify(afterResourceSideOpen)}`); + assert.equal(afterResourceSideOpen.secondarySideTarget, "unsupported-resource", `资源 openTarget=side 应标记 unsupported side target: ${JSON.stringify(afterResourceSideOpen)}`); + assert.match(afterResourceSideOpen.placeholderText, /暂不支持在侧栏打开此资源/, `资源 side placeholder 应可观测: ${JSON.stringify(afterResourceSideOpen)}`); + assert(afterResourceSideOpen.resourceTab.includes("side-target-resource.md"), `资源 side placeholder 不应清空 active resource tab URL: ${JSON.stringify(afterResourceSideOpen)}`); + assert.equal(afterResourceSideOpen.activeTabKind, "markdown", `资源 side placeholder 不应切走 active resource tab: ${JSON.stringify(afterResourceSideOpen)}`); + assert.equal(afterResourceSideOpen.popupCount, 0, `资源 openTarget=side 不应误开新窗口: ${JSON.stringify(afterResourceSideOpen)}`); + + console.log(JSON.stringify({ ok: true, root, assetId: asset.id }, null, 2)); + } finally { + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error && error.stack ? error.stack : error); + process.exit(1); +});