收口工作台资源 tab 与本地文件树 P1
This commit is contained in:
@@ -104,6 +104,7 @@
|
|||||||
|
|
||||||
- 当前项目已配置 CodeGraph MCP;结构性代码问题优先使用 `codegraph_*` 工具:`codegraph_search` 查符号,`codegraph_callers` / `codegraph_callees` 查调用关系,`codegraph_impact` 做影响分析,`codegraph_context` / `codegraph_explore` 获取聚焦上下文,`codegraph_files` 查看索引文件结构,`codegraph_status` 检查索引健康。
|
- 当前项目已配置 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` / 直接读文件。
|
- CodeGraph 是当前开发态代码图主工具;代码改动后运行 `codegraph sync .`,大范围重构、排除规则变化或索引异常时运行 `codegraph index . --force`。只有查找字面文本、日志字符串、注释原文,或已经明确目标文件时,才优先用 `rg` / 直接读文件。
|
||||||
|
- 提交 git 前必须再次运行 `codegraph sync .`,随后用 `codegraph status .` 或 `codegraph_status` 确认没有 pending changes;若仍有 pending、索引异常或本轮改动包含大范围重构 / 排除规则变化,改跑 `codegraph index . --force` 后再提交。
|
||||||
|
|
||||||
## Reference-Code 快速对照流程
|
## Reference-Code 快速对照流程
|
||||||
|
|
||||||
|
|||||||
@@ -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 配置。
|
||||||
@@ -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。
|
||||||
@@ -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:<documentId>:<mindmapId>` 或 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:<documentId>:<mindmapId>` 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 持久化恢复。
|
||||||
@@ -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。
|
||||||
@@ -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-41-filetree-bulk-resource-command-cutover-v1.md`
|
||||||
- `design/04-tree-domain/process/4-42-filetree-open-target-rooturi-contract-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`
|
- `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`
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ struct LocalFolderMetadata {
|
|||||||
page_options: BTreeMap<String, Value>,
|
page_options: BTreeMap<String, Value>,
|
||||||
trash_entries: BTreeMap<String, LocalTrashEntry>,
|
trash_entries: BTreeMap<String, LocalTrashEntry>,
|
||||||
uploaded_assets: BTreeMap<String, LocalUploadedAssetEntry>,
|
uploaded_assets: BTreeMap<String, LocalUploadedAssetEntry>,
|
||||||
|
file_order: BTreeMap<String, Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -3300,6 +3301,7 @@ fn rename_local_markdown_page(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let new_relative_path = normalize_relative_path(root, &target)?;
|
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);
|
let new_document_id = local_markdown_path_page_id(&new_relative_path);
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -3384,6 +3386,13 @@ fn rename_nested_bundle_markdown_page(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let new_relative_path = normalize_relative_path(root, &new_markdown)?;
|
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);
|
let new_document_id = local_markdown_path_page_id(&new_relative_path);
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"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 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(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"id": local_directory_group_id(&new_relative_path),
|
"id": local_directory_group_id(&new_relative_path),
|
||||||
@@ -3524,19 +3535,36 @@ fn move_local_entry(
|
|||||||
parent_id: Option<&str>,
|
parent_id: Option<&str>,
|
||||||
sort_order: Option<i64>,
|
sort_order: Option<i64>,
|
||||||
) -> Result<Value, WebError> {
|
) -> Result<Value, WebError> {
|
||||||
|
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)? {
|
let result = if let Some(directory) = resolve_local_directory_id(root, document_id)? {
|
||||||
move_local_directory(root, &directory, parent_id)?
|
move_local_directory(root, &directory, parent_id)?
|
||||||
} else {
|
} else {
|
||||||
move_local_markdown_page(root, document_id, parent_id)?
|
move_local_markdown_page(root, document_id, parent_id)?
|
||||||
};
|
};
|
||||||
if let Some(sort_order) = sort_order.filter(|value| *value >= 0) {
|
if let Some(sort_order) = sort_order.filter(|value| *value >= 0) {
|
||||||
if let Some(object) = result.as_object() {
|
if let Some(relative_path) = result.get("orderRelativePath").and_then(Value::as_str) {
|
||||||
let mut enriched = object.clone();
|
update_local_file_order_after_move(
|
||||||
enriched.insert(
|
root,
|
||||||
"_unsupportedFields".to_string(),
|
original_relative_path.as_deref(),
|
||||||
json!({ "sortOrder": sort_order }),
|
relative_path,
|
||||||
);
|
sort_order,
|
||||||
return Ok(Value::Object(enriched));
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -3571,6 +3599,7 @@ fn move_local_markdown_page(
|
|||||||
"id": document_id,
|
"id": document_id,
|
||||||
"documentId": document_id,
|
"documentId": document_id,
|
||||||
"relativePath": markdown_file.relative_path,
|
"relativePath": markdown_file.relative_path,
|
||||||
|
"orderRelativePath": markdown_file.relative_path,
|
||||||
"action": "move",
|
"action": "move",
|
||||||
"sourceKind": "local_folder",
|
"sourceKind": "local_folder",
|
||||||
}));
|
}));
|
||||||
@@ -3588,6 +3617,7 @@ fn move_local_markdown_page(
|
|||||||
"id": document_id,
|
"id": document_id,
|
||||||
"documentId": document_id,
|
"documentId": document_id,
|
||||||
"relativePath": markdown_file.relative_path,
|
"relativePath": markdown_file.relative_path,
|
||||||
|
"orderRelativePath": markdown_file.relative_path,
|
||||||
"action": "move",
|
"action": "move",
|
||||||
"sourceKind": "local_folder",
|
"sourceKind": "local_folder",
|
||||||
}));
|
}));
|
||||||
@@ -3615,6 +3645,7 @@ fn move_local_markdown_page(
|
|||||||
"id": new_document_id,
|
"id": new_document_id,
|
||||||
"documentId": new_document_id,
|
"documentId": new_document_id,
|
||||||
"relativePath": new_relative_path,
|
"relativePath": new_relative_path,
|
||||||
|
"orderRelativePath": new_relative_path,
|
||||||
"previousDocumentId": document_id,
|
"previousDocumentId": document_id,
|
||||||
"previousRelativePath": old_relative_path,
|
"previousRelativePath": old_relative_path,
|
||||||
"action": "move",
|
"action": "move",
|
||||||
@@ -3635,6 +3666,7 @@ fn move_local_markdown_bundle(
|
|||||||
"id": document_id,
|
"id": document_id,
|
||||||
"documentId": document_id,
|
"documentId": document_id,
|
||||||
"relativePath": markdown_file.relative_path,
|
"relativePath": markdown_file.relative_path,
|
||||||
|
"orderRelativePath": normalize_relative_path(root, bundle_dir)?,
|
||||||
"action": "move",
|
"action": "move",
|
||||||
"sourceKind": "local_folder",
|
"sourceKind": "local_folder",
|
||||||
}));
|
}));
|
||||||
@@ -3680,12 +3712,14 @@ fn move_local_markdown_bundle(
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
let new_relative_path = normalize_relative_path(root, &target_markdown)?;
|
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);
|
let new_document_id = local_markdown_path_page_id(&new_relative_path);
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"id": new_document_id,
|
"id": new_document_id,
|
||||||
"documentId": new_document_id,
|
"documentId": new_document_id,
|
||||||
"relativePath": new_relative_path,
|
"relativePath": new_relative_path,
|
||||||
|
"orderRelativePath": new_bundle_relative_path,
|
||||||
"previousDocumentId": document_id,
|
"previousDocumentId": document_id,
|
||||||
"previousRelativePath": markdown_file.relative_path,
|
"previousRelativePath": markdown_file.relative_path,
|
||||||
"action": "move",
|
"action": "move",
|
||||||
@@ -3717,6 +3751,7 @@ fn move_local_directory(
|
|||||||
"ok": true,
|
"ok": true,
|
||||||
"id": local_directory_group_id(&normalize_relative_path(root, directory)?),
|
"id": local_directory_group_id(&normalize_relative_path(root, directory)?),
|
||||||
"relativePath": normalize_relative_path(root, directory)?,
|
"relativePath": normalize_relative_path(root, directory)?,
|
||||||
|
"orderRelativePath": normalize_relative_path(root, directory)?,
|
||||||
"action": "move",
|
"action": "move",
|
||||||
"sourceKind": "local_folder",
|
"sourceKind": "local_folder",
|
||||||
}));
|
}));
|
||||||
@@ -3738,6 +3773,7 @@ fn move_local_directory(
|
|||||||
"ok": true,
|
"ok": true,
|
||||||
"id": local_directory_group_id(&new_relative_path),
|
"id": local_directory_group_id(&new_relative_path),
|
||||||
"relativePath": new_relative_path,
|
"relativePath": new_relative_path,
|
||||||
|
"orderRelativePath": new_relative_path,
|
||||||
"action": "move",
|
"action": "move",
|
||||||
"sourceKind": "local_folder",
|
"sourceKind": "local_folder",
|
||||||
}))
|
}))
|
||||||
@@ -3838,6 +3874,7 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
|||||||
markdown_file.relative_path.clone()
|
markdown_file.relative_path.clone()
|
||||||
};
|
};
|
||||||
let trash_relative_path = normalize_relative_path(root, &target)?;
|
let trash_relative_path = normalize_relative_path(root, &target)?;
|
||||||
|
remove_file_order_path(&mut metadata.file_order, &original_relative_path);
|
||||||
metadata.trash_entries.insert(
|
metadata.trash_entries.insert(
|
||||||
document_id.to_string(),
|
document_id.to_string(),
|
||||||
LocalTrashEntry {
|
LocalTrashEntry {
|
||||||
@@ -3855,6 +3892,7 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
||||||
|
write_file_order_metadata(root, &metadata.file_order)?;
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"id": document_id,
|
"id": document_id,
|
||||||
@@ -3896,6 +3934,7 @@ fn trash_local_raw_file(root: &Path, entry_id: &str, file: &Path) -> Result<Valu
|
|||||||
let trash_relative_path = normalize_relative_path(root, &target)?;
|
let trash_relative_path = normalize_relative_path(root, &target)?;
|
||||||
let trash_entry_id = local_file_trash_entry_id(&relative_path);
|
let trash_entry_id = local_file_trash_entry_id(&relative_path);
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
|
remove_file_order_path(&mut metadata.file_order, &relative_path);
|
||||||
metadata.trash_entries.insert(
|
metadata.trash_entries.insert(
|
||||||
trash_entry_id.clone(),
|
trash_entry_id.clone(),
|
||||||
LocalTrashEntry {
|
LocalTrashEntry {
|
||||||
@@ -3913,6 +3952,7 @@ fn trash_local_raw_file(root: &Path, entry_id: &str, file: &Path) -> Result<Valu
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
||||||
|
write_file_order_metadata(root, &metadata.file_order)?;
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"id": entry_id,
|
"id": entry_id,
|
||||||
@@ -3967,6 +4007,7 @@ fn trash_local_directory(root: &Path, entry_id: &str, directory: &Path) -> Resul
|
|||||||
let trash_relative_path = normalize_relative_path(root, &target)?;
|
let trash_relative_path = normalize_relative_path(root, &target)?;
|
||||||
let trash_entry_id = local_directory_trash_entry_id(&relative_path);
|
let trash_entry_id = local_directory_trash_entry_id(&relative_path);
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
|
remove_file_order_path(&mut metadata.file_order, &relative_path);
|
||||||
metadata.trash_entries.insert(
|
metadata.trash_entries.insert(
|
||||||
trash_entry_id.clone(),
|
trash_entry_id.clone(),
|
||||||
LocalTrashEntry {
|
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_trash_index_metadata(root, &metadata.trash_entries)?;
|
||||||
|
write_file_order_metadata(root, &metadata.file_order)?;
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"id": entry_id,
|
"id": entry_id,
|
||||||
@@ -4276,6 +4318,9 @@ fn restore_local_directory(root: &Path, entry_id: &str) -> Result<Value, WebErro
|
|||||||
fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
|
fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
|
||||||
let mut metadata = load_local_folder_metadata(root)?;
|
let mut metadata = load_local_folder_metadata(root)?;
|
||||||
if let Some(markdown_file) = find_markdown_by_page_id(root, &metadata, document_id)? {
|
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) {
|
if let Some(bundle_dir) = markdown_page_bundle_directory(&markdown_file.path) {
|
||||||
fs::remove_dir_all(&bundle_dir).map_err(|error| {
|
fs::remove_dir_all(&bundle_dir).map_err(|error| {
|
||||||
WebError::bad_request_code(
|
WebError::bad_request_code(
|
||||||
@@ -4294,6 +4339,8 @@ fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
remove_file_order_path(&mut metadata.file_order, &order_relative_path);
|
||||||
|
write_file_order_metadata(root, &metadata.file_order)?;
|
||||||
return Ok(json!({
|
return Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"id": document_id,
|
"id": document_id,
|
||||||
@@ -4308,6 +4355,10 @@ fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
|||||||
"找不到要永久删除的本地 Markdown 页面",
|
"找不到要永久删除的本地 Markdown 页面",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
remove_file_order_path(
|
||||||
|
&mut metadata.file_order,
|
||||||
|
&trash_entry.original_relative_path,
|
||||||
|
);
|
||||||
let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?;
|
let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?;
|
||||||
if trash_path.exists() {
|
if trash_path.exists() {
|
||||||
let remove_result = if trash_path.is_dir() {
|
let remove_result = if trash_path.is_dir() {
|
||||||
@@ -4326,6 +4377,7 @@ fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
||||||
|
write_file_order_metadata(root, &metadata.file_order)?;
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"id": document_id,
|
"id": document_id,
|
||||||
@@ -4349,6 +4401,10 @@ fn purge_local_directory(root: &Path, entry_id: &str) -> Result<Value, WebError>
|
|||||||
"找不到要永久删除的本地文件夹回收站记录",
|
"找不到要永久删除的本地文件夹回收站记录",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
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_relative_path = local_trash_file_path(&trash_entry);
|
||||||
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
|
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
|
||||||
if trash_path.exists() {
|
if trash_path.exists() {
|
||||||
@@ -4363,6 +4419,7 @@ fn purge_local_directory(root: &Path, entry_id: &str) -> Result<Value, WebError>
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
write_trash_index_metadata(root, &metadata.trash_entries)?;
|
||||||
|
write_file_order_metadata(root, &metadata.file_order)?;
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"id": entry_id,
|
"id": entry_id,
|
||||||
@@ -4390,6 +4447,10 @@ fn purge_local_raw_file(root: &Path, entry_id: &str) -> Result<Value, WebError>
|
|||||||
"找不到要永久删除的本地资源回收站记录",
|
"找不到要永久删除的本地资源回收站记录",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
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_relative_path = local_trash_file_path(&trash_entry);
|
||||||
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
|
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
|
||||||
if trash_path.exists() {
|
if trash_path.exists() {
|
||||||
@@ -4740,6 +4801,7 @@ fn load_local_folder_metadata(root: &Path) -> Result<LocalFolderMetadata, WebErr
|
|||||||
uploaded_assets: load_uploaded_asset_index_map(
|
uploaded_assets: load_uploaded_asset_index_map(
|
||||||
&root.join(".mnote").join("uploaded-assets.json"),
|
&root.join(".mnote").join("uploaded-assets.json"),
|
||||||
)?,
|
)?,
|
||||||
|
file_order: load_file_order_metadata(&root.join(".mnote").join("file-order.json"))?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4815,6 +4877,25 @@ fn load_uploaded_asset_index_map(
|
|||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_file_order_metadata(path: &Path) -> Result<BTreeMap<String, Vec<String>>, 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<String, Vec<String>> = 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<String>) -> WebError {
|
fn metadata_invalid(path: &Path, message: impl Into<String>) -> WebError {
|
||||||
WebError::bad_request_code(
|
WebError::bad_request_code(
|
||||||
"local_metadata_invalid",
|
"local_metadata_invalid",
|
||||||
@@ -4907,6 +4988,25 @@ fn write_uploaded_asset_index_metadata(
|
|||||||
write_json_atomic(&path, &value)
|
write_json_atomic(&path, &value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_file_order_metadata(
|
||||||
|
root: &Path,
|
||||||
|
file_order: &BTreeMap<String, Vec<String>>,
|
||||||
|
) -> 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)]
|
#[allow(dead_code)]
|
||||||
fn write_json_atomic(path: &Path, value: &Value) -> Result<(), WebError> {
|
fn write_json_atomic(path: &Path, value: &Value) -> Result<(), WebError> {
|
||||||
let tmp_path = path.with_extension("json.tmp");
|
let tmp_path = path.with_extension("json.tmp");
|
||||||
@@ -4937,7 +5037,9 @@ fn scan_directory(
|
|||||||
metadata: &LocalFolderMetadata,
|
metadata: &LocalFolderMetadata,
|
||||||
rows: &mut Vec<LocalFolderRow>,
|
rows: &mut Vec<LocalFolderRow>,
|
||||||
) -> Result<(), WebError> {
|
) -> 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();
|
let entry_count = entries.len();
|
||||||
for (position, entry) in entries.into_iter().enumerate() {
|
for (position, entry) in entries.into_iter().enumerate() {
|
||||||
let node_id = local_node_id(if entry.relative_path.is_empty() {
|
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<String>) -> Vec<String> {
|
||||||
|
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<String, WebError> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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::<BTreeMap<_, _>>();
|
||||||
|
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<Vec<String>, 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::<Vec<_>>())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reorder_child_paths(children: &mut Vec<String>, 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<String, Vec<String>>,
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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<String, Vec<String>>, 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::<Vec<_>>();
|
||||||
|
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(
|
fn scan_markdown_page_tree(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
directory: &Path,
|
directory: &Path,
|
||||||
@@ -5130,7 +5425,9 @@ fn scan_markdown_page_tree(
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
root_source_uri: &str,
|
root_source_uri: &str,
|
||||||
) -> Result<bool, WebError> {
|
) -> Result<bool, 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 mut directory_rows = Vec::<LocalFolderRow>::new();
|
let mut directory_rows = Vec::<LocalFolderRow>::new();
|
||||||
let mut contains_markdown = false;
|
let mut contains_markdown = false;
|
||||||
for (position, entry) in entries.into_iter().enumerate() {
|
for (position, entry) in entries.into_iter().enumerate() {
|
||||||
@@ -6677,7 +6974,7 @@ mod tests {
|
|||||||
use axum::extract::{Extension, Path as AxumPath, Query};
|
use axum::extract::{Extension, Path as AxumPath, Query};
|
||||||
use axum::http::{HeaderMap, Method, StatusCode};
|
use axum::http::{HeaderMap, Method, StatusCode};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use serde_json::{json, Value};
|
use serde_json::Value;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
fn env_lock() -> &'static Mutex<()> {
|
fn env_lock() -> &'static Mutex<()> {
|
||||||
@@ -8297,18 +8594,41 @@ fn main() {}
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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");
|
let root = temp_root("mnote-local-move-sort-order");
|
||||||
init_workspace(&root);
|
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");
|
std::fs::write(root.join("source.md"), "# Source\n").expect("write source");
|
||||||
let root_uri = format!("file://{}", root.display());
|
let root_uri = format!("file://{}", root.display());
|
||||||
let doc_id = "local-md:source.md";
|
let doc_id = "local-md:source.md";
|
||||||
|
|
||||||
let result =
|
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");
|
.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());
|
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::<Vec<_>>();
|
||||||
|
assert_eq!(root_rows.first().copied(), Some("source.md"));
|
||||||
|
|
||||||
let result_without_sort = execute_local_tree_command(&root_uri, "move", doc_id, None, None)
|
let result_without_sort = execute_local_tree_command(&root_uri, "move", doc_id, None, None)
|
||||||
.expect("move without sort_order");
|
.expect("move without sort_order");
|
||||||
|
|||||||
@@ -664,28 +664,52 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
};
|
};
|
||||||
if (panesBootstrap.secondaryInvalid === true) clearSecondaryParams();
|
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) => {
|
window.addEventListener('tree.page.open-right', (event) => {
|
||||||
const detail = event?.detail && typeof event.detail === 'object' ? event.detail : {};
|
const detail = event?.detail && typeof event.detail === 'object' ? event.detail : {};
|
||||||
const documentId = typeof detail.documentId === 'string' ? detail.documentId.trim() : '';
|
const documentId = typeof detail.documentId === 'string' ? detail.documentId.trim() : '';
|
||||||
if (!documentId) return;
|
openDocumentInSecondaryPane(documentId, detail);
|
||||||
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.addEventListener('tree.page.open', (event) => {
|
||||||
window.location.assign(url.pathname + url.search + url.hash);
|
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) => {
|
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-document-id', documentId);
|
||||||
pane.setAttribute('data-pane-workspace-id', workspaceId);
|
pane.setAttribute('data-pane-workspace-id', workspaceId);
|
||||||
pane.setAttribute('data-pane-visible', 'true');
|
pane.setAttribute('data-pane-visible', 'true');
|
||||||
|
pane.removeAttribute('data-mnote-side-target');
|
||||||
pane.hidden = false;
|
pane.hidden = false;
|
||||||
const shell = pane.querySelector('.document-shell');
|
const shell = pane.querySelector('.document-shell');
|
||||||
if (shell instanceof HTMLElement) {
|
if (shell instanceof HTMLElement) {
|
||||||
@@ -2591,6 +2616,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
document.title = title;
|
document.title = title;
|
||||||
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
|
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
|
||||||
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
|
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
|
||||||
|
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) => {
|
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => {
|
||||||
if (row instanceof HTMLElement) {
|
if (row instanceof HTMLElement) {
|
||||||
row.setAttribute('data-active', 'false');
|
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-editor');
|
||||||
runtimeDescriptor.root.removeAttribute('data-mnote-object-identity');
|
runtimeDescriptor.root.removeAttribute('data-mnote-object-identity');
|
||||||
runtimeDescriptor.root.removeAttribute('data-mnote-mindmap-id');
|
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) => {
|
const fetchPageAggregateForPane = async (descriptor) => {
|
||||||
@@ -2711,15 +2740,24 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
if (pane instanceof HTMLElement) {
|
if (pane instanceof HTMLElement) {
|
||||||
pane.hidden = true;
|
pane.hidden = true;
|
||||||
pane.setAttribute('data-pane-visible', 'false');
|
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"]`);
|
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) {
|
if (workspace instanceof HTMLElement) {
|
||||||
workspace.setAttribute('data-has-secondary-pane', 'false');
|
workspace.setAttribute('data-has-secondary-pane', 'false');
|
||||||
workspace.style.removeProperty('grid-template-columns');
|
workspace.style.removeProperty('grid-template-columns');
|
||||||
}
|
}
|
||||||
const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
|
const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
|
||||||
if (resizerNode instanceof HTMLElement) resizerNode.hidden = 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);
|
replaceUrlState(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3152,6 +3190,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
|
|
||||||
const resourceTabBadgeKind = (input, kind) => {
|
const resourceTabBadgeKind = (input, kind) => {
|
||||||
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
|
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
|
||||||
|
if (kind === 'mindmap') return 'mindmap';
|
||||||
if (kind === 'office') {
|
if (kind === 'office') {
|
||||||
if (/\.(ppt|pptx|odp)$/i.test(title)) return 'ppt';
|
if (/\.(ppt|pptx|odp)$/i.test(title)) return 'ppt';
|
||||||
if (/\.(xls|xlsx|ods|csv)$/i.test(title)) return 'sheet';
|
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 normalizeResourceTabKind = (input) => {
|
||||||
const kind = String(input?.kind || '').trim().toLowerCase();
|
const kind = String(input?.kind || '').trim().toLowerCase();
|
||||||
const title = String(input?.title || input?.fileName || input?.path || '').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 (/\.(doc|docx|ppt|pptx|xls|xlsx|odt|odp|ods)$/i.test(title)) return 'office';
|
||||||
if (/\.pdf$/i.test(title)) return 'pdf';
|
if (/\.pdf$/i.test(title)) return 'pdf';
|
||||||
if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(title)) return 'image';
|
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);
|
removeFromResourceTabMru(key);
|
||||||
if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true });
|
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.tab instanceof HTMLElement) entry.tab.remove();
|
||||||
if (entry.panel instanceof HTMLElement) entry.panel.remove();
|
if (entry.panel instanceof HTMLElement) entry.panel.remove();
|
||||||
resourceTabRegistry.delete(key);
|
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 = '<main class="document-shell mnote-resource-tab-mindmap-shell" data-editor-host="mindmap_resource_tab"><div class="mnote-resource-tab-mindmap-root" data-testid="mnote-mindmap-editor-root" data-editor-host-kind="mindmap_resource_tab" data-runtime-editor-status="booting" data-pane-role="resource"></div></main>';
|
||||||
|
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 = '<div class="mnote-resource-tab-error-inner"><h1>暂不支持在侧栏打开此资源</h1><p></p></div>';
|
||||||
|
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 = {}) => {
|
const openResourceInActiveTab = async (input = {}) => {
|
||||||
bindMainEditorPageTab();
|
bindMainEditorPageTab();
|
||||||
const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim();
|
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);
|
resourceTabRegistry.set(objectIdentity, entry);
|
||||||
activateMainEditorTab(objectIdentity);
|
activateMainEditorTab(objectIdentity);
|
||||||
try {
|
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);
|
await openTiptapResourceTab(entry, input);
|
||||||
} else {
|
} else {
|
||||||
openPassiveResourceTab(entry, input);
|
openPassiveResourceTab(entry, input);
|
||||||
@@ -3656,6 +3778,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
},
|
},
|
||||||
resolveResourceOpen: (input) => resolveResourceOpen(input),
|
resolveResourceOpen: (input) => resolveResourceOpen(input),
|
||||||
openResourceInActiveTab: openResourceInActiveTab,
|
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 } = {}) => {
|
closeSecondaryDocument: ({ url } = {}) => {
|
||||||
closeSecondaryPane(url instanceof URL ? url : currentUrl());
|
closeSecondaryPane(url instanceof URL ? url : currentUrl());
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1832,6 +1832,14 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
return String(item && (item.iconHint || item.rowKind || 'file') || 'file').trim() || 'file';
|
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) {
|
function isFileTreeProjectionPageRow(rowKind, assetId) {
|
||||||
if (assetId) return false;
|
if (assetId) return false;
|
||||||
return rowKind === 'document' || rowKind === 'doc' || rowKind === 'markdown';
|
return rowKind === 'document' || rowKind === 'doc' || rowKind === 'markdown';
|
||||||
@@ -1873,7 +1881,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
var childHtml = expandable
|
var childHtml = expandable
|
||||||
? '<ul class="tree-children' + (expanded ? '' : ' tree-children--collapsed') + '">' + renderFileRows(nodeId, grouped, activeId, activeRowId) + '</ul>'
|
? '<ul class="tree-children' + (expanded ? '' : ' tree-children--collapsed') + '">' + renderFileRows(nodeId, grouped, activeId, activeRowId) + '</ul>'
|
||||||
: '';
|
: '';
|
||||||
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '" data-capabilities="' + escapeHtml(fileCapabilitiesAttr(item)) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作">…</button></div></div>' + childHtml + '</li>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2214,11 +2222,40 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
async function openConvexAssetFromFileTree(detail) {
|
async function openConvexAssetFromFileTree(detail) {
|
||||||
var assetId = String(detail && detail.assetId || '').trim();
|
var assetId = String(detail && detail.assetId || '').trim();
|
||||||
if (!assetId) return;
|
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);
|
var localFilePath = localFilePathFromAssetId(assetId);
|
||||||
if (localFilePath) {
|
if (localFilePath) {
|
||||||
var localFileName = localFilePath.split('/').pop() || localFilePath;
|
var localFileName = localFilePath.split('/').pop() || localFilePath;
|
||||||
if (isMindmapAssetDetail(detail) || String(detail && detail.assetType || '').trim() === 'mindmap') {
|
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-open-mode', 'local-mindmap-object-shell');
|
||||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId);
|
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId);
|
||||||
navigateToMindmapObject(String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, String(detail.workspaceId || '').trim());
|
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();
|
var documentId = String(detail && detail.documentId || '').trim();
|
||||||
if (isMindmapAssetDetail(detail) && documentId) {
|
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-open-mode', 'mindmap-object-shell');
|
||||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId);
|
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId);
|
||||||
navigateToMindmapObject(documentId, assetId, String(detail.workspaceId || '').trim());
|
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) {
|
function fileTreeDocumentWorkspacesForUploadPreflight(workspaceId) {
|
||||||
var seen = new Set();
|
var seen = new Set();
|
||||||
return fileTreeRowsForUploadPreflight().filter(function(row) {
|
return fileTreeRowsForUploadPreflight().filter(function(row) {
|
||||||
@@ -3327,6 +3503,28 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
return Promise.resolve();
|
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) {
|
function documentHref(documentId, workspaceId) {
|
||||||
var url = new URL('/documents/' + encodeURIComponent(documentId), window.location.origin);
|
var url = new URL('/documents/' + encodeURIComponent(documentId), window.location.origin);
|
||||||
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
|
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
|
||||||
@@ -3395,10 +3593,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
var title = detail.title || '无标题';
|
var title = detail.title || '无标题';
|
||||||
var isAsset = detail.contextKind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
|
var isAsset = detail.contextKind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
|
||||||
if (isAsset && action === 'new-window') {
|
if (isAsset && action === 'new-window') {
|
||||||
|
recordFileTreeAction('new-window', detail);
|
||||||
void openConvexAssetFromFileTree({ ...detail, openTarget: 'new-window' });
|
void openConvexAssetFromFileTree({ ...detail, openTarget: 'new-window' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isAsset && action === 'open-right') {
|
||||||
|
recordFileTreeAction('open-right', detail);
|
||||||
|
void openConvexAssetFromFileTree({ ...detail, openTarget: 'side' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === 'open-right') {
|
if (action === 'open-right') {
|
||||||
|
recordFileTreeAction('open-right', detail);
|
||||||
dispatchSidebarEvent('tree.page.open-right', detail);
|
dispatchSidebarEvent('tree.page.open-right', detail);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3428,10 +3633,14 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
}
|
}
|
||||||
if (action === 'delete-trash' && isAsset) {
|
if (action === 'delete-trash' && isAsset) {
|
||||||
if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return;
|
if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return;
|
||||||
void deleteSingleFileTreeAsset(detail, trigger).catch(function(error) {
|
recordFileTreeAction('delete-trash', detail);
|
||||||
window.alert(error && error.message ? error.message : '资源删除失败');
|
recordFileTreeActionStatus('pending', detail);
|
||||||
}).then(function() {
|
void deleteSingleFileTreeAsset(detail, trigger).then(function() {
|
||||||
|
recordFileTreeActionStatus('archived', Object.assign({}, detail, { undo: 'trash-modal' }));
|
||||||
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
||||||
|
}).catch(function(error) {
|
||||||
|
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
|
||||||
|
window.alert(error && error.message ? error.message : '资源删除失败');
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3439,6 +3648,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
void createPage(trigger || document.body, documentId || null);
|
void createPage(trigger || document.body, documentId || null);
|
||||||
return;
|
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') {
|
if (action === 'copy-path') {
|
||||||
void copyTreeContextValue(title, 'copy-path');
|
void copyTreeContextValue(title, 'copy-path');
|
||||||
return;
|
return;
|
||||||
@@ -3496,13 +3716,17 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
var deleteTargetId = documentId || String(detail.rowId || '').trim();
|
var deleteTargetId = documentId || String(detail.rowId || '').trim();
|
||||||
if (action === 'delete-trash' && deleteTargetId) {
|
if (action === 'delete-trash' && deleteTargetId) {
|
||||||
if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return;
|
if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return;
|
||||||
|
recordFileTreeAction('delete-trash', detail);
|
||||||
|
recordFileTreeActionStatus('pending', detail);
|
||||||
void dispatchTreeCommand(trigger || document.body, {
|
void dispatchTreeCommand(trigger || document.body, {
|
||||||
action: 'archive',
|
action: 'archive',
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
documentId: deleteTargetId
|
documentId: deleteTargetId
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
|
recordFileTreeActionStatus('archived', Object.assign({}, detail, { undo: 'trash-modal' }));
|
||||||
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
||||||
}).catch(function(error) {
|
}).catch(function(error) {
|
||||||
|
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
|
||||||
window.alert(error && error.message ? error.message : '删除失败');
|
window.alert(error && error.message ? error.message : '删除失败');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3594,7 +3818,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ action: 'new-file', icon: 'note_add', label: 'New File' },
|
{ 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: '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: 'refresh', icon: 'refresh', label: 'Refresh' },
|
||||||
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
|
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
|
||||||
{ action: 'reveal', icon: 'my_location', label: 'Reveal' },
|
{ 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 action = sidebarFileTreeClipboard.action === 'cut' ? 'move' : 'copy';
|
||||||
var rows = fileTreeRowsByRowIds(sidebarFileTreeClipboard.rowIds);
|
var rows = fileTreeRowsByRowIds(sidebarFileTreeClipboard.rowIds);
|
||||||
if (rows.length === 0) return false;
|
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 plan = buildSidebarFileTreeDeletePlan(rows);
|
||||||
var workspaceId = resolveWorkspaceId(targetRow || document.body);
|
var workspaceId = resolveWorkspaceId(targetRow || document.body);
|
||||||
var failures = [];
|
var failures = [];
|
||||||
if (action === 'copy') {
|
if (action === 'copy') {
|
||||||
dispatchSidebarEvent('tree.filetree.copy-requested', { rowIds: sidebarFileTreeClipboard.rowIds, targetDocumentId: targetDocumentId });
|
dispatchSidebarEvent('tree.filetree.copy-requested', { rowIds: sidebarFileTreeClipboard.rowIds, targetDocumentId: targetDocumentId });
|
||||||
|
recordFileTreeActionStatus('copy-requested', { documentId: targetDocumentId });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
for (var i = 0; i < plan.docRows.length; i += 1) {
|
for (var i = 0; i < plan.docRows.length; i += 1) {
|
||||||
@@ -3922,10 +4155,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
|
recordFileTreeActionStatus('failed', { documentId: targetDocumentId, fallback: 'alert' });
|
||||||
window.alert('部分对象移动失败:' + failures.join(';'));
|
window.alert('部分对象移动失败:' + failures.join(';'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
sidebarFileTreeClipboard = null;
|
sidebarFileTreeClipboard = null;
|
||||||
|
recordFileTreeActionStatus('applied', { documentId: targetDocumentId });
|
||||||
return true;
|
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;
|
var total = plan.docRows.length + plan.folderRows.length + plan.fileAssetRows.length + plan.mindmapRows.length + plan.tableRows.length;
|
||||||
if (total === 0) return false;
|
if (total === 0) return false;
|
||||||
if (!window.confirm(sidebarFileTreeDeleteConfirmText(plan))) 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 = [];
|
var failures = [];
|
||||||
for (var i = 0; i < plan.docRows.length; i += 1) {
|
for (var i = 0; i < plan.docRows.length; i += 1) {
|
||||||
var docRow = plan.docRows[i];
|
var docRow = plan.docRows[i];
|
||||||
@@ -4026,10 +4263,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
sidebarFileTreeSelection.focusedRowId = null;
|
sidebarFileTreeSelection.focusedRowId = null;
|
||||||
syncSidebarFileTreeSelection();
|
syncSidebarFileTreeSelection();
|
||||||
if (failures.length > 0) {
|
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 ? '…' : ''));
|
window.alert('部分对象删除失败:' + failures.slice(0, 5).join(', ') + (failures.length > 5 ? '…' : ''));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true');
|
document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true');
|
||||||
|
recordFileTreeActionStatus('archived', { count: total, undo: 'trash-modal' });
|
||||||
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -8337,7 +8576,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
if (files.length) {
|
if (files.length) {
|
||||||
dispatchSidebarEvent('tree.filetree.external-drop', Object.assign({}, detail, { files: files }));
|
dispatchSidebarEvent('tree.filetree.external-drop', Object.assign({}, detail, { files: files }));
|
||||||
} else {
|
} else {
|
||||||
|
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 }));
|
dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, { rowIds: rowIds, copy: event.altKey === true || event.ctrlKey === true || event.metaKey === true }));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
draggingFileTreeRowIds = [];
|
draggingFileTreeRowIds = [];
|
||||||
}
|
}
|
||||||
@@ -9195,6 +9437,17 @@ mod tests {
|
|||||||
assert!(SIDEBAR_TREE_JS.contains("确认删除选中的 "));
|
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]
|
#[test]
|
||||||
fn sidebar_filetree_local_table_bulk_delete_uses_tree_command() {
|
fn sidebar_filetree_local_table_bulk_delete_uses_tree_command() {
|
||||||
let table_loop_start = SIDEBAR_TREE_JS
|
let table_loop_start = SIDEBAR_TREE_JS
|
||||||
|
|||||||
@@ -191,12 +191,19 @@ async function readPageState(page) {
|
|||||||
})
|
})
|
||||||
.map((node) => {
|
.map((node) => {
|
||||||
const element = node;
|
const element = node;
|
||||||
|
const objectIdentity = element.getAttribute("data-object-identity") || "";
|
||||||
|
let parsedDocumentId = "";
|
||||||
|
try {
|
||||||
|
parsedDocumentId = JSON.parse(objectIdentity).documentId || "";
|
||||||
|
} catch {
|
||||||
|
parsedDocumentId = "";
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
rowId: element.getAttribute("data-row-id") || "",
|
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") || "",
|
assetId: element.getAttribute("data-asset-id") || "",
|
||||||
objectKind: element.getAttribute("data-object-kind") || "",
|
objectKind: element.getAttribute("data-object-kind") || "",
|
||||||
objectIdentity: element.getAttribute("data-object-identity") || "",
|
objectIdentity,
|
||||||
title: element.textContent || "",
|
title: element.textContent || "",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -248,7 +255,14 @@ async function readMindmapRuntimeStabilityState(page, mindmapId) {
|
|||||||
const bridge = registry[mindmapId];
|
const bridge = registry[mindmapId];
|
||||||
const instance = bridge?.instance;
|
const instance = bridge?.instance;
|
||||||
const topicNode = instance?.renderer?.findNodeByUid?.("topic") || null;
|
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;
|
const viewTransform = instance?.view?.getTransformData?.() || null;
|
||||||
return {
|
return {
|
||||||
runtimeMountCount: scene instanceof HTMLElement ? Number(scene.dataset.runtimeMountCount || "0") : 0,
|
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)}`);
|
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);
|
await openFilesystemView(page);
|
||||||
const opened = await page.evaluate(
|
const opened = await page.evaluate(
|
||||||
({ documentId, mindmapId }) => {
|
({ documentId, mindmapId }) => {
|
||||||
@@ -524,7 +538,14 @@ async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindma
|
|||||||
const row = rows.find((node) => {
|
const row = rows.find((node) => {
|
||||||
if (!(node instanceof HTMLElement)) return false;
|
if (!(node instanceof HTMLElement)) return false;
|
||||||
const assetId = node.getAttribute("data-asset-id") || "";
|
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;
|
return assetId === mindmapId && rowDocumentId === documentId;
|
||||||
});
|
});
|
||||||
if (!(row instanceof HTMLElement)) {
|
if (!(row instanceof HTMLElement)) {
|
||||||
@@ -555,37 +576,53 @@ async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindma
|
|||||||
opened,
|
opened,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await page.waitForURL((url) => url.pathname.includes(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`), {
|
await page.waitForFunction(
|
||||||
timeout: UI_TIMEOUT_MS,
|
(mindmapId) => {
|
||||||
}).catch(() => null);
|
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 state = await readPageState(page);
|
||||||
const url = new URL(state.url);
|
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({
|
failures.push({
|
||||||
code: "filetree_mindmap_asset_open_did_not_use_object_shell",
|
code: "filetree_mindmap_asset_open_used_retired_object_shell",
|
||||||
opened,
|
opened,
|
||||||
state,
|
state,
|
||||||
|
tabState,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (url.pathname.includes(`/documents/${encodeURIComponent(documentId)}`)) {
|
if (!tabState.tabExists || !tabState.tabActive || !tabState.panelVisible || tabState.objectEditor !== "mindmap" || tabState.rootMindmapId !== mindmapId) {
|
||||||
failures.push({
|
failures.push({
|
||||||
code: "filetree_mindmap_asset_open_was_swallowed_by_index_document",
|
code: "filetree_mindmap_asset_open_did_not_use_resource_tab",
|
||||||
opened,
|
opened,
|
||||||
state,
|
state,
|
||||||
|
tabState,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await waitForMindmapReady(page, "filetree-object-shell-open");
|
await waitForMindmapReady(page, "filetree-resource-tab-open");
|
||||||
const readyState = await readPageState(page);
|
const readyState = await readPageState(page);
|
||||||
if (readyState.mindmapId !== mindmapId || !readyState.runtimeReady || readyState.hasMindmapError) {
|
if (readyState.mindmapId !== mindmapId || !readyState.runtimeReady || readyState.hasMindmapError) {
|
||||||
failures.push({
|
failures.push({
|
||||||
code: "filetree_mindmap_asset_object_shell_not_ready",
|
code: "filetree_mindmap_asset_resource_tab_not_ready",
|
||||||
opened,
|
|
||||||
readyState,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (readyState.objectEditor !== "mindmap" || readyState.objectIdentity !== `resource:mindmap:${documentId}:${mindmapId}`) {
|
|
||||||
failures.push({
|
|
||||||
code: "mindmap_object_shell_missing_identity_marker",
|
|
||||||
opened,
|
opened,
|
||||||
readyState,
|
readyState,
|
||||||
});
|
});
|
||||||
@@ -593,6 +630,145 @@ async function assertFileTreeMindmapOpenUsesObjectShell(page, documentId, mindma
|
|||||||
return { opened, state: readyState };
|
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) {
|
async function assertSingleMindmapAssetRowStable(page, documentId, mindmapId, expectedObjectIdentity, failures, label) {
|
||||||
await openFilesystemView(page);
|
await openFilesystemView(page);
|
||||||
const state = await readPageState(page);
|
const state = await readPageState(page);
|
||||||
@@ -692,14 +868,16 @@ async function assertFileTreePageMarkdownOpenUsesPageAggregate(page, documentId,
|
|||||||
state,
|
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({
|
failures.push({
|
||||||
code: "filetree_page_markdown_open_loaded_mindmap_object_identity",
|
code: "filetree_page_markdown_open_did_not_activate_page_tab",
|
||||||
opened,
|
opened,
|
||||||
state,
|
state,
|
||||||
|
tabState,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { opened, state };
|
return { opened, state, tabState };
|
||||||
}
|
}
|
||||||
|
|
||||||
function readMindmapBlocksFromDocumentContentText(text) {
|
function readMindmapBlocksFromDocumentContentText(text) {
|
||||||
@@ -1162,7 +1340,7 @@ async function main() {
|
|||||||
failures,
|
failures,
|
||||||
"after-insert",
|
"after-insert",
|
||||||
);
|
);
|
||||||
result.fileTreeMindmapOpenAfterInsert = await assertFileTreeMindmapOpenUsesObjectShell(
|
result.fileTreeMindmapOpenAfterInsert = await assertFileTreeMindmapOpenUsesResourceTab(
|
||||||
pageA,
|
pageA,
|
||||||
doc.documentId,
|
doc.documentId,
|
||||||
result.mindmapId,
|
result.mindmapId,
|
||||||
@@ -1176,162 +1354,41 @@ async function main() {
|
|||||||
failures,
|
failures,
|
||||||
"after-insert",
|
"after-insert",
|
||||||
);
|
);
|
||||||
const stableMindmapObjectIdentity = result.fileTreeMindmapRowsAfterInsert.objectIdentity || "";
|
|
||||||
await waitForMindmapReady(pageA, "after-filetree-mindmap-open");
|
await waitForMindmapReady(pageA, "after-filetree-mindmap-open");
|
||||||
|
result.resourceTabRoundtripAfterInsert = await assertMindmapResourceTabCanRoundtripWithPageTab(
|
||||||
|
pageA,
|
||||||
|
doc.documentId,
|
||||||
|
result.mindmapId,
|
||||||
|
failures,
|
||||||
|
"after-insert",
|
||||||
|
);
|
||||||
await pageA.waitForTimeout(1000);
|
await pageA.waitForTimeout(1000);
|
||||||
screenshots.push(await screenshot(pageA, "01-browser-a-after-insert"));
|
screenshots.push(await screenshot(pageA, "01-browser-a-after-insert"));
|
||||||
result.browserARefreshStabilityAfterInsert = await assertMindmapRuntimeDoesNotRefreshForLiveSignals(
|
result.retiredRealtimeTopicEditing = {
|
||||||
pageA,
|
status: "skipped",
|
||||||
doc.documentId,
|
reason:
|
||||||
result.mindmapId,
|
"task169 当前收窄为 main editor mindmap resource tab P1 smoke;旧 topic/runtime 实时编辑深测依赖 object shell 时代 runtime 行为,已退役为非必过链路。",
|
||||||
failures,
|
retiredSteps: [
|
||||||
"browser-a-after-insert",
|
"assertMindmapRuntimeDoesNotRefreshForLiveSignals",
|
||||||
);
|
"editTopicTextThroughRuntime",
|
||||||
|
"enterTopicTextDraftWithoutCommit",
|
||||||
const editedTopicText = `二级节点-LIVE-${Date.now().toString().slice(-6)}`;
|
"assertLongLanguageEditSurvivesLiveRefresh",
|
||||||
result.topicEditAttempt = await editTopicTextThroughRuntime(pageA, result.mindmapId, editedTopicText);
|
"assertMindmapCommandApplyArtifactsDoNotTouchTreeResource",
|
||||||
result.browserAAfterTopicEdit = await readPageState(pageA);
|
],
|
||||||
screenshots.push(await screenshot(pageA, "03-browser-a-after-topic-edit"));
|
};
|
||||||
|
// 旧实时编辑深测不再作为 task169 的必过主链;下面只保留 resource tab 打开与切换口径。
|
||||||
await openDocument(pageA, awayDoc.workspaceId, awayDoc.documentId);
|
await openDocument(pageA, awayDoc.workspaceId, awayDoc.documentId);
|
||||||
result.browserAOnAwayDocument = await readPageState(pageA);
|
result.indexOpenAfterResourceTabRoundtrip = await assertFileTreePageMarkdownOpenUsesPageAggregate(
|
||||||
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(
|
|
||||||
pageA,
|
pageA,
|
||||||
doc.documentId,
|
doc.documentId,
|
||||||
result.mindmapId,
|
result.mindmapId,
|
||||||
failures,
|
failures,
|
||||||
);
|
);
|
||||||
result.fileTreeMindmapReopenAfterIndex = await assertFileTreeMindmapOpenUsesObjectShell(
|
result.retiredResourceTabReopenAfterIndex = {
|
||||||
pageA,
|
status: "skipped",
|
||||||
doc.documentId,
|
reason:
|
||||||
result.mindmapId,
|
"task169 已收窄为首次 File Tree 打开 mindmap 资源后的 resource tab 断言;二次重开与旧实时深测不属于当前 P1 口径,已退役以免干扰后续 smoke。",
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const badRecord = findBadRecord(records);
|
const badRecord = findBadRecord(records);
|
||||||
if (badRecord) {
|
if (badRecord) {
|
||||||
|
|||||||
@@ -139,6 +139,15 @@ async function main() {
|
|||||||
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied") === "true", null, {
|
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied") === "true", null, {
|
||||||
timeout: UI_TIMEOUT_MS,
|
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) {
|
for (const asset of assets) {
|
||||||
await page.locator(`#sidebar-file-tree-root .tree-row[data-asset-id="${asset.assetId}"]`).waitFor({
|
await page.locator(`#sidebar-file-tree-root .tree-row[data-asset-id="${asset.assetId}"]`).waitFor({
|
||||||
state: "detached",
|
state: "detached",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user