diff --git a/design/05-editor-mainline/done/5-15-main-editor-resource-tab-vscode-open-target-v1.md b/design/05-editor-mainline/done/5-15-main-editor-resource-tab-vscode-open-target-v1.md new file mode 100644 index 00000000..074ec5d0 --- /dev/null +++ b/design/05-editor-mainline/done/5-15-main-editor-resource-tab-vscode-open-target-v1.md @@ -0,0 +1,98 @@ +# 5-15 主编辑区 Resource Tab 与 VSCode 打开目标对齐 v1 + +> 状态:done +> +> 日期:2026-05-20 + +## 目标 + +把当前资源打开行为从“资源点击直接 `window.open` 或单独右侧 pane”收口为 VSCode 风格的打开目标模型: + +- `active-tab`:在主编辑区打开或激活 resource tab。 +- `side`:保留当前右侧 pane 打开能力。 +- `new-window`:显式在新浏览器窗口打开。 + +本轮重点实现 `active-tab`,并保持已有右侧 pane 与新窗口路径不退化。 + +## VSCode 对齐口径 + +VSCode Explorer 不按文件类型散落处理打开行为,而是把资源交给 editor service: + +- 普通打开进入当前 editor group。 +- `Open to the Side` 进入 side group。 +- 同一资源应激活已有 editor tab,而不是无限重复打开。 +- side group 与主 editor group 可以同时持有各自 tab。 + +MNote 对应关系: + +- 主编辑区 tab host 对应 VSCode active editor group。 +- 当前 secondary document pane 对应 VSCode side group。 +- 浏览器新窗口只作为显式外部打开目标。 + +## 资源编辑规则 + +`.md`、`.markdown`、`.txt`、常见代码文件在主编辑区 resource tab 中继续使用 tiptap runtime 打开。 + +关键约束: + +- 页面正文 tiptap 保存到 Page Aggregate body / 页面 Markdown。 +- 资源 tiptap 保存到对应 resource 文件本身。 +- resource tab 必须使用独立 object identity 与独立 session key。 +- resource tab 保存不得调用 `page.body.save` 或 `/api/page-body/write`。 +- 上传的 `.md` 资源不能因为用 tiptap 打开而重新变成 File Tree / Page Tree 页面。 + +## 最小实现范围 + +本轮只做浏览器内主编辑区 resource tab host 的最小闭环: + +- 在文档 workspace 顶部增加 tab strip。 +- 默认页面正文是一个固定 page tab。 +- 点击本地上传或文件树资源时打开 resource tab。 +- Office tab 内嵌 `/onlyoffice?...`。 +- PDF / 图片使用 iframe/img 预览。 +- `.md` / text / code 使用 tiptap runtime 编辑,并保存回本地资源文件。 +- 右键菜单保留“在右侧边栏打开”和“在新窗口打开”。 + +Convex 远端资源本轮仅保留新窗口 fallback;主闭环优先覆盖 local-first 资源。 + +## 详细 Checklist + +- [x] 定义前端 open target:`active-tab`、`side`、`new-window`。 +- [x] 为本地资源生成稳定 `objectIdentity`:`resource:file::`。 +- [x] 新增本地资源读写 API: + - [x] `GET /api/local-folder/resource/read?rootUri=&path=` + - [x] `POST /api/local-folder/resource/write` + - [x] 读写均复用 root escape 校验。 + - [x] 写入返回 `fileVersion` / `conflictDetectionKey`。 +- [x] 主编辑区新增 resource tab strip: + - [x] page tab 默认存在。 + - [x] resource tab 重复打开时激活已有 tab。 + - [x] 关闭 resource tab 后回到 page tab。 + - [x] 打开 resource tab 不关闭 secondary pane。 +- [x] tiptap resource editor: + - [x] `.md` / `.markdown` / `.txt` / code 资源挂载 tiptap。 + - [x] session key 与页面正文 session key 隔离。 + - [x] 保存写 `/api/local-folder/resource/write`。 + - [x] 保存 payload 中保留 `tiptapDocument`,服务端转 Markdown / text 后写回文件。 +- [x] Office / PDF / 图片 resource tab: + - [x] Office 使用 `/onlyoffice?...` iframe,主编辑区默认 `mode=view`。 + - [x] PDF 使用 iframe。 + - [x] 图片使用 img。 + - [x] 不触发页面正文保存。 +- [x] 菜单与默认行为: + - [x] 文件树资源普通点击默认 `active-tab`。 + - [x] 附件普通点击默认 `active-tab`。 + - [x] “在右侧边栏打开”继续走 `side`。 + - [x] “在新窗口打开”继续走 `new-window`,Office 保留 `mode=edit`。 +- [x] Smoke: + - [x] 上传 `.md` 后点击资源,在主编辑区出现 resource tab 和 tiptap。 + - [x] 编辑 `.md` resource tab 后文件内容写回资源文件,不写页面正文。 + - [x] 上传 Office 后点击资源,在主编辑区出现 Office resource tab。 + - [x] 显式新窗口打开仍打开 `/onlyoffice` 或文件 URL。 + +## 非目标 + +- 不在本轮实现多 tab 持久化恢复。 +- 不在本轮实现 Convex 远端资源的主编辑区写回。 +- 不在本轮引入完整代码编辑器或语法高亮。 +- 不改变 Resource Tree / File Tree / Page Tree 的真源关系。 diff --git a/docs/superpowers/plans/2026-05-20-main-editor-resource-tabs.md b/docs/superpowers/plans/2026-05-20-main-editor-resource-tabs.md new file mode 100644 index 00000000..829a31f3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-main-editor-resource-tabs.md @@ -0,0 +1,136 @@ +# Main Editor Resource Tabs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 MNote 主编辑区增加 VSCode 风格 resource tab,使本地 `.md` / text / code 资源可用 tiptap 编辑,Office/PDF/图片可在主编辑区 tab 打开,并保留右侧 pane 与新窗口能力。 + +**Architecture:** 以 `resource input + open target` 为边界,前端新增主编辑区 tab host,local-first 资源读写走新的 `/api/local-folder/resource/*` API。页面正文 session 与资源 session 使用不同 object identity 和保存 endpoint,避免资源编辑污染 Page Aggregate body。 + +**Tech Stack:** Rust Axum (`mnote-web` routes), Leptos SSR shell, existing `leptos-tiptap` WASM runtime, Playwright smoke scripts. + +--- + +### Task 1: 本地资源读写 API + +**Files:** +- Modify: `rust/crates/mnote-web/src/routes/mod.rs` +- Modify: `rust/crates/mnote-web/src/routes/local_folder_source.rs` + +- [ ] **Step 1: 增加 API 路由** + +在 `routes/mod.rs` 的 local-folder route 区域添加: + +```rust +.route( + "/api/local-folder/resource/read", + get(local_folder_source::read_local_resource), +) +.route( + "/api/local-folder/resource/write", + post(local_folder_source::write_local_resource), +) +``` + +- [ ] **Step 2: 实现读写 handler** + +在 `local_folder_source.rs` 复用 `resolve_local_file_open_path`,读取 raw text 或写入 UTF-8 内容。写入请求字段: + +```json +{ + "rootUri": "file:///tmp/workspace", + "path": "Page.assets/note.md", + "contentFormat": "tiptapDocument", + "tiptapDocument": { "type": "doc", "content": [] }, + "expectedFileVersion": "sha256:..." +} +``` + +保存 `.md` 使用现有 editor-blocks -> markdown 能力;保存 `.txt` / code 使用 plain text。 + +- [ ] **Step 3: 增加单测** + +新增测试覆盖: + +- root escape 被拒绝。 +- `.md` 读写返回 `text/markdown` 和 `fileVersion`。 +- 写 `.md` 后不修改页面正文 Markdown。 + +### Task 2: 主编辑区 Resource Tab Host + +**Files:** +- Modify: `rust/crates/mnote-web/src/ssr/pages/document.rs` +- Modify: `rust/crates/mnote-web/src/ssr/styles.rs` +- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs` + +- [ ] **Step 1: SSR 增加 tab strip 与 resource host** + +在 `DocumentPage` 的 `.document-workspace` 内添加主编辑区 tab strip 和 resource pane 容器。默认 page tab 对应 primary document pane。 + +- [ ] **Step 2: web shell 新增 openResourceInActiveTab** + +新增运行时函数: + +```js +window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ + objectIdentity, + title, + kind, + rootUri, + path, + href, + officeUrl +}); +``` + +同一 `objectIdentity` 已存在时只激活 tab。 + +- [ ] **Step 3: 挂载 tiptap resource editor** + +`.md` / `.txt` / code resource tab 创建独立 root,读取 `/api/local-folder/resource/read`,转为 tiptap document 后挂载。change 事件写回 `/api/local-folder/resource/write`。 + +### Task 3: 打开路径收口 + +**Files:** +- Modify: `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs` + +- [ ] **Step 1: 文件树资源普通点击默认 active-tab** + +修改 `openConvexAssetFromFileTree(detail)`:本地资源优先调用 `openResourceInActiveTab`,失败时 fallback 到旧 `window.open`。 + +- [ ] **Step 2: 附件点击默认 active-tab** + +修改 `openEditorAttachmentDetail(detail)`:本地附件优先打开 resource tab,显式下载/新窗口保持旧行为。 + +- [ ] **Step 3: 菜单补齐 new-window** + +为文件树资源和附件菜单增加“在新窗口打开”,动作显式调用旧 `window.open` 路径。 + +### Task 4: Smoke 与回归 + +**Files:** +- Modify: `scripts/task174-rust-onlyoffice-attachment-open-smoke.js` +- Modify: `scripts/task456-resource-object-shell-sync-smoke.js` +- Create: `scripts/task457-main-editor-resource-tab-smoke.js` + +- [ ] **Step 1: 新增 resource tab smoke** + +覆盖上传 `.md`、点击打开主编辑区 tab、编辑保存回资源文件、页面正文不变。 + +- [ ] **Step 2: 更新 Office smoke** + +默认点击断言主编辑区 Office resource tab;显式新窗口动作继续断言 `/onlyoffice`。 + +- [ ] **Step 3: 运行验证** + +运行: + +```bash +cargo test -p mnote-web -- --test-threads=1 +node scripts/task457-main-editor-resource-tab-smoke.js +node scripts/task174-rust-onlyoffice-attachment-open-smoke.js +node scripts/task456-resource-object-shell-sync-smoke.js +codegraph sync . +``` + +预期:全部通过。 diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index 62cad6fb..fb51bbf5 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -206,6 +206,23 @@ struct LocalAssetUploadFields { kind: String, } +fn local_folder_ok_response( + context: &RequestContext, + result: Value, +) -> (StatusCode, HeaderMap, Json) { + ( + StatusCode::OK, + HeaderMap::new(), + Json(json!({ + "ok": true, + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "owner": "mnote-web", + "result": result, + })), + ) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalFileOpenQuery { @@ -215,6 +232,30 @@ pub struct LocalFileOpenQuery { pub download: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalResourceReadQuery { + pub root_uri: String, + pub path: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalResourceWriteRequest { + #[serde(default, alias = "root_uri")] + pub root_uri: String, + #[serde(default)] + pub path: String, + #[serde(default, alias = "expected_file_version")] + pub expected_file_version: Option, + #[serde(default, alias = "content_format")] + pub content_format: Option, + #[serde(default)] + pub content: Value, + #[serde(default, alias = "tiptap_document")] + pub tiptap_document: Option, +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalAccessValidateRootRequest { @@ -2337,6 +2378,143 @@ pub async fn open_local_file( Ok((StatusCode::OK, headers, bytes)) } +pub async fn read_local_resource( + Extension(context): Extension, + Query(query): Query, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_local_workspace_read_access(&context, &query.root_uri) + .map_err(|error| error.with_context(&context))?; + let root = parse_file_root_uri(&query.root_uri)? + .canonicalize() + .map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + .with_context(&context) + })?; + let target = resolve_local_file_open_path(&query.root_uri, &query.path)?; + let text = fs::read_to_string(&target).map_err(|error| { + WebError::bad_request_code( + "local_resource_read_failed", + format!("无法读取本地资源文件 {}: {error}", target.display()), + ) + .with_context(&context) + })?; + let file_name = target + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("资源") + .to_string(); + let content = if is_markdown_file(&file_name) { + crate::routes::local_markdown_parser::markdown_to_blocks(&text) + } else { + text_to_editor_blocks(&text, &file_name) + }; + let file_version = local_resource_conflict_detection_key(&root, &target)?; + Ok(local_folder_ok_response( + &context, + json!({ + "rootUri": query.root_uri, + "path": query.path, + "fileName": file_name, + "contentType": content_type_for_path(&target).to_str().unwrap_or("application/octet-stream"), + "text": text, + "content": content, + "contentFormat": "editorBlocks", + "fileVersion": file_version, + "conflictDetectionKey": file_version, + "sourceKind": "local_folder", + }), + )) +} + +pub async fn write_local_resource( + Extension(context): Extension, + Json(request): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let root_uri = request.root_uri.trim(); + let path = request.path.trim(); + if root_uri.is_empty() || path.is_empty() { + return Err(WebError::bad_request_code( + "local_resource_write_required_missing", + "缺少 rootUri 或 path", + ) + .with_context(&context)); + } + ensure_local_workspace_access(&context, root_uri) + .map_err(|error| error.with_context(&context))?; + let root = parse_file_root_uri(root_uri)? + .canonicalize() + .map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + .with_context(&context) + })?; + let target = resolve_local_file_open_path(root_uri, path)?; + let current_key = local_resource_conflict_detection_key(&root, &target)?; + if let Some(expected_key) = request + .expected_file_version + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if expected_key != current_key { + return Err(WebError::new( + StatusCode::CONFLICT, + "local_resource_external_change", + "本地资源文件已被外部修改,请刷新后再保存", + ) + .with_details(json!({ + "conflict": { + "code": "local_resource_external_change", + "rootUri": root_uri, + "path": path, + "currentDiskVersion": current_key, + "editorBaseVersion": expected_key, + } + })) + .with_context(&context)); + } + } + let content = local_resource_write_editor_blocks(&request); + let file_name = target + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("资源") + .to_string(); + let next_text = if is_markdown_file(&file_name) { + editor_blocks_to_markdown_for_file(&content, &root, &target) + } else { + editor_blocks_to_plain_text(&content) + }; + fs::write(&target, next_text).map_err(|error| { + WebError::bad_request_code( + "local_resource_write_failed", + format!("无法保存本地资源文件 {}: {error}", target.display()), + ) + .with_context(&context) + })?; + let next_key = local_resource_conflict_detection_key(&root, &target)?; + Ok(local_folder_ok_response( + &context, + json!({ + "ok": true, + "rootUri": root_uri, + "path": path, + "fileName": file_name, + "fileVersion": next_key, + "conflictDetectionKey": next_key, + "contentFormat": request.content_format.unwrap_or_else(|| "editorBlocks".into()), + "executedCommand": "resource.file.write", + "canonicalCommand": "resource.file.write", + "sourceKind": "local_folder", + }), + )) +} + fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result { let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { @@ -5630,6 +5808,167 @@ fn editor_blocks_to_markdown_for_file( editor_blocks_to_markdown_with_rewrite(content, Some((root, markdown_path))) } +fn local_resource_write_editor_blocks(request: &LocalResourceWriteRequest) -> Value { + let format = request + .content_format + .as_deref() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + let source = if request.content.is_null() { + request.tiptap_document.as_ref().unwrap_or(&Value::Null) + } else { + &request.content + }; + if format == "tiptapdocument" || is_tiptap_document(source) { + tiptap_document_to_editor_blocks(source) + } else { + source.clone() + } +} + +fn is_tiptap_document(value: &Value) -> bool { + value.get("type").and_then(Value::as_str) == Some("doc") + && value.get("content").and_then(Value::as_array).is_some() +} + +fn tiptap_document_to_editor_blocks(value: &Value) -> Value { + let nodes = value + .get("content") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let blocks = nodes + .iter() + .enumerate() + .map(|(index, node)| { + let node_type = node + .get("type") + .and_then(Value::as_str) + .unwrap_or("paragraph"); + let content = node.get("content").cloned().unwrap_or_else(|| json!([])); + let text = extract_block_text(&content); + let block_type = match node_type { + "heading" => "heading", + "codeBlock" | "code_block" | "code" => "codeBlock", + "blockquote" => "quote", + "bulletListItem" | "listItem" => "bulletListItem", + "orderedListItem" | "numberedListItem" => "numberedListItem", + _ => "paragraph", + }; + let mut block = json!({ + "id": format!("resource-block-{}-{}", index + 1, stable_hash_hex(&format!("{node_type}:{text}"))), + "type": block_type, + "content": content, + }); + if node_type == "heading" { + let level = node + .get("attrs") + .and_then(|attrs| attrs.get("level")) + .and_then(Value::as_u64) + .unwrap_or(1) + .clamp(1, 6); + block["props"] = json!({ "level": level }); + } + block + }) + .collect::>(); + Value::Array(blocks) +} + +fn text_to_editor_blocks(text: &str, file_name: &str) -> Value { + if is_code_like_file(file_name) { + return json!([{ + "id": "resource-block-1", + "type": "code_block", + "content": text, + "props": { + "language": file_extension(file_name).unwrap_or_default() + } + }]); + } + let blocks = text + .split('\n') + .enumerate() + .map(|(index, line)| { + json!({ + "id": format!("resource-block-{}-{}", index + 1, stable_hash_hex(line)), + "type": "paragraph", + "content": line + }) + }) + .collect::>(); + Value::Array(blocks) +} + +fn editor_blocks_to_plain_text(content: &Value) -> String { + let blocks = if let Some(array) = content.as_array() { + array.clone() + } else { + content + .get("blocks") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + }; + let text = blocks + .iter() + .map(extract_block_text) + .collect::>() + .join("\n"); + if text.ends_with('\n') { + text + } else { + format!("{text}\n") + } +} + +fn file_extension(file_name: &str) -> Option { + Path::new(file_name) + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) +} + +fn is_code_like_file(file_name: &str) -> bool { + matches!( + file_extension(file_name).as_deref(), + Some( + "rs" | "ts" + | "tsx" + | "js" + | "jsx" + | "json" + | "css" + | "scss" + | "html" + | "xml" + | "py" + | "go" + | "java" + | "kt" + | "swift" + | "c" + | "h" + | "cpp" + | "hpp" + | "sh" + | "bash" + | "zsh" + | "toml" + | "yaml" + | "yml" + | "sql" + ) + ) +} + +fn stable_hash_hex(value: &str) -> String { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + fn editor_blocks_to_markdown_with_rewrite( content: &Value, local_file_context: Option<(&Path, &Path)>, @@ -6201,6 +6540,34 @@ fn local_markdown_conflict_detection_key( )) } +fn local_resource_conflict_detection_key(root: &Path, target: &Path) -> Result { + let content = fs::read(target).map_err(|error| { + WebError::bad_request_code( + "local_resource_read_failed", + format!("无法读取本地资源文件 {}: {error}", target.display()), + ) + })?; + let meta = fs::metadata(target).map_err(|error| { + WebError::bad_request_code( + "local_resource_stat_failed", + format!("无法读取本地资源文件状态 {}: {error}", target.display()), + ) + })?; + let modified_ms = system_time_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH)); + let relative_path = target + .strip_prefix(root) + .ok() + .map(|path| path.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|| target.to_string_lossy().to_string()); + let mut hasher = DefaultHasher::new(); + content.hash(&mut hasher); + let content_hash = hasher.finish(); + Ok(format!( + "local-resource:{relative_path}:{modified_ms}:{}:{content_hash:016x}", + meta.len() + )) +} + fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Value { let outline = content .as_array() @@ -6267,19 +6634,20 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val mod tests { use super::{ add_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base, - create_local_access_grant, create_share_grant, encode_local_id_segment, - ensure_local_path_read_access, ensure_local_workspace_access_for_actor, - ensure_local_workspace_read_access_for_actor, execute_local_tree_command, - get_local_access_policy, get_share_grants, initialize_local_page_id, - initialize_local_workspace_for_actor, load_local_folder_file_tree_snapshot, - load_local_folder_page_tree_snapshot, local_folder_watch_revision, local_workspace_id, + create_local_access_grant, create_share_grant, editor_blocks_to_markdown_for_file, + encode_local_id_segment, ensure_local_path_read_access, + ensure_local_workspace_access_for_actor, ensure_local_workspace_read_access_for_actor, + execute_local_tree_command, get_local_access_policy, get_share_grants, + initialize_local_page_id, initialize_local_workspace_for_actor, + load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, + local_folder_watch_revision, local_resource_write_editor_blocks, local_workspace_id, open_local_file, record_shared_cache, record_sync_pending_change, resolve_local_markdown_page_aggregate, save_local_markdown_page, update_local_markdown_title, validate_local_access_root, write_local_markdown_asset, write_local_markdown_page_body, write_local_mindmap_data, write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery, - LocalShareGrantRequest, LocalUploadFile, SharedCacheRecordRequest, - SyncConflictReportRequest, SyncPendingChangeRequest, + LocalResourceWriteRequest, LocalShareGrantRequest, LocalUploadFile, + SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest, }; use crate::context::RequestContext; use axum::extract::{Extension, Path as AxumPath, Query}; @@ -7734,6 +8102,65 @@ fn main() {} let _ = std::fs::remove_dir_all(&root); } + #[test] + fn local_resource_markdown_write_targets_asset_not_page_body() { + let root = temp_root("mnote-local-resource-md-write"); + init_workspace(&root); + std::fs::write(root.join("Page.md"), "# Page\n\n正文\n").expect("write page"); + std::fs::create_dir_all(root.join("Page.assets")).expect("create assets"); + let asset_path = root.join("Page.assets").join("note.md"); + std::fs::write(&asset_path, "# Asset\n\n旧内容\n").expect("write asset"); + let blocks = serde_json::json!([ + {"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Asset"}]}, + {"type":"paragraph","content":[{"type":"text","text":"新内容"}]} + ]); + + let next = editor_blocks_to_markdown_for_file(&blocks, &root, &asset_path); + std::fs::write(&asset_path, next).expect("write asset next"); + + let asset_text = std::fs::read_to_string(&asset_path).expect("read asset"); + let page_text = std::fs::read_to_string(root.join("Page.md")).expect("read page"); + assert!(asset_text.contains("新内容")); + assert!(page_text.contains("正文")); + assert!(!page_text.contains("新内容")); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_resource_write_accepts_tiptap_document_payload() { + let request = LocalResourceWriteRequest { + root_uri: "file:///tmp/mnote".into(), + path: "Page.assets/note.md".into(), + content_format: Some("tiptapDocument".into()), + tiptap_document: Some(serde_json::json!({ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { "level": 2 }, + "content": [{ "type": "text", "text": "资源标题" }] + }, + { + "type": "paragraph", + "content": [{ "type": "text", "text": "资源正文" }] + } + ] + })), + ..Default::default() + }; + + let blocks = local_resource_write_editor_blocks(&request); + let markdown = editor_blocks_to_markdown_for_file( + &blocks, + std::path::Path::new("/tmp"), + std::path::Path::new("/tmp/note.md"), + ); + + assert!(markdown.contains("## 资源标题")); + assert!(markdown.contains("资源正文")); + } + #[test] fn local_tree_command_delete_folder_moves_directory_to_trash() { let root = temp_root("mnote-local-delete-folder"); diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index d4d18af9..28c320f6 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -237,6 +237,14 @@ pub fn build_router(state: AppState) -> Router { "/api/local-folder/files/open", get(local_folder_source::open_local_file), ) + .route( + "/api/local-folder/resource/read", + get(local_folder_source::read_local_resource), + ) + .route( + "/api/local-folder/resource/write", + post(local_folder_source::write_local_resource), + ) .route( "/api/tree/runtime/reduce", post(tree::reduce_tree_shell_runtime), diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 294d0b0c..a7f3f8ef 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -1391,6 +1391,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const localFolderEventRegistry = new Map(); const paneViewRegistry = new Map(); const mindmapPaneViewRegistry = new Map(); + const resourceTabRegistry = new Map(); let nextViewId = 1; const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突'; const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突'; @@ -1675,11 +1676,32 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return nextAggregate; }; + const fetchLatestResourceSnapshot = async (session) => { + const url = new URL('/api/local-folder/resource/read', window.location.origin); + url.searchParams.set('rootUri', session.rootUri || ''); + url.searchParams.set('path', session.resourcePath || ''); + const response = await fetch(url.toString(), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload || payload.ok !== true) throw new Error('resource_conflict_latest_fetch_failed_' + response.status); + const nextResource = payload.result; + if (!nextResource || typeof nextResource !== 'object') throw new Error('resource_conflict_latest_missing_snapshot'); + return nextResource; + }; + const aggregatePlainText = (aggregate) => { const body = aggregate?.body || {}; return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim(); }; + const resourceSnapshotPlainText = (snapshot) => ( + String(snapshot?.text || flattenText(toTiptapDocument(snapshot?.content, '')) || '') + .replace(/\n{3,}/g, '\n\n') + .trim() + ); + const shouldRetryTransientEmptyLocalAggregate = (session, nextAggregate) => { if (!session || session.sourceKind !== 'local_folder') return false; if (!sessionHasRecentExternalSignal(session)) return false; @@ -1746,6 +1768,29 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { setSessionStatus(session, 'synced-external-change'); }; + const applyResourceSnapshotToSession = (session, nextResource, source) => { + const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim(); + const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || ''); + const nextSerialized = JSON.stringify(nextTiptapDocument); + session.currentTiptapDocument = nextTiptapDocument; + session.currentSerialized = nextSerialized; + session.lastPersistedSerialized = nextSerialized; + if (nextConflictKey) { + session.conflictDetectionKey = nextConflictKey; + session.lastExternalConflictDetectionKey = nextConflictKey; + session.fileVersion = nextConflictKey; + } + session.dirty = false; + session.hasExternalConflict = false; + session.externalChangePending = false; + session.lastUserInputAt = 0; + clearSessionConflictSurface(session); + sessionViews(session).forEach((view) => { + if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-resource-conflict-resolved'); + }); + setSessionStatus(session, 'synced-external-change'); + }; + const openConflictDiffPanel = async (session, panel) => { const diffPanel = panel.querySelector('[data-testid="mnote-conflict-diff-panel"]'); if (!(diffPanel instanceof HTMLElement)) return; @@ -1756,14 +1801,19 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { loading.textContent = '正在读取磁盘版本...'; diffPanel.appendChild(loading); try { - const latest = await fetchLatestSessionAggregate(session); + const latest = session.sessionKind === 'resource' + ? await fetchLatestResourceSnapshot(session) + : await fetchLatestSessionAggregate(session); + const latestText = session.sessionKind === 'resource' + ? resourceSnapshotPlainText(latest) + : aggregatePlainText(latest); diffPanel.replaceChildren(); const current = document.createElement('pre'); current.setAttribute('data-testid', 'mnote-conflict-current-text'); current.textContent = sessionPlainText(session) || '(当前编辑器为空)'; const disk = document.createElement('pre'); disk.setAttribute('data-testid', 'mnote-conflict-disk-text'); - disk.textContent = aggregatePlainText(latest) || '(磁盘版本为空)'; + disk.textContent = latestText || '(磁盘版本为空)'; const currentTitle = document.createElement('h3'); currentTitle.textContent = '当前编辑器版本'; const diskTitle = document.createElement('h3'); @@ -1776,7 +1826,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { mergeTitle.textContent = '合并结果'; const mergeText = document.createElement('textarea'); mergeText.setAttribute('data-testid', 'mnote-conflict-merge-text'); - mergeText.value = sessionPlainText(session) || aggregatePlainText(latest) || ''; + mergeText.value = sessionPlainText(session) || latestText || ''; const mergeActions = document.createElement('div'); mergeActions.className = 'mnote-conflict-actions'; const useCurrent = document.createElement('button'); @@ -1800,7 +1850,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { mergeText.value = sessionPlainText(session) || ''; }); useDisk.addEventListener('click', () => { - mergeText.value = aggregatePlainText(latest) || ''; + mergeText.value = latestText || ''; }); saveMerge.addEventListener('click', () => { writeMergedConflictResult(session, panel, mergeText.value).catch((error) => { @@ -1816,13 +1866,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const acceptDiskVersion = async (session) => { setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...'); + if (session.sessionKind === 'resource') { + const latestResource = await fetchLatestResourceSnapshot(session); + applyResourceSnapshotToSession(session, latestResource, 'mnote-web-resource-conflict-accept-disk'); + return; + } const latest = await fetchLatestSessionAggregate(session); applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk'); }; const keepCurrentEditorVersion = async (session) => { setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...'); - const latest = await fetchLatestSessionAggregate(session); + const latest = session.sessionKind === 'resource' + ? await fetchLatestResourceSnapshot(session) + : await fetchLatestSessionAggregate(session); const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0]; if (hydrateView) { const liveText = normalizePlainText(currentEditorText(hydrateView)); @@ -1834,7 +1891,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { } session.currentSerialized = JSON.stringify(session.currentTiptapDocument); } - const nextKey = conflictDetectionKeyFromBody(latest.body || {}); + const nextKey = session.sessionKind === 'resource' + ? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim() + : conflictDetectionKeyFromBody(latest.body || {}); if (nextKey) { session.conflictDetectionKey = nextKey; session.lastExternalConflictDetectionKey = nextKey; @@ -1849,8 +1908,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const writeMergedConflictResult = async (session, panel, mergedText) => { setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...'); - const latest = await fetchLatestSessionAggregate(session); - const nextKey = conflictDetectionKeyFromBody(latest.body || {}); + const latest = session.sessionKind === 'resource' + ? await fetchLatestResourceSnapshot(session) + : await fetchLatestSessionAggregate(session); + const nextKey = session.sessionKind === 'resource' + ? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim() + : conflictDetectionKeyFromBody(latest.body || {}); if (nextKey) { session.conflictDetectionKey = nextKey; session.lastExternalConflictDetectionKey = nextKey; @@ -1882,7 +1945,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { text.textContent = message || externalConflictMessage; const meta = document.createElement('div'); meta.className = 'mnote-conflict-meta'; - meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`; + const fileLabel = session.sessionKind === 'resource' + ? `${session.rootUri || ''}/${session.resourcePath || session.documentId}` + : (session.rootUri || session.documentId); + meta.textContent = `文件:${fileLabel} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`; const actions = document.createElement('div'); actions.className = 'mnote-conflict-actions'; const acceptDisk = document.createElement('button'); @@ -1969,21 +2035,33 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument); const content = legacyBlocksFromEditorDocument(editorDocument); const saveEndpoint = session.saveEndpoint || '/api/documents/save'; - const savePayload = { - documentId: session.documentId, - workspaceId: session.workspaceId, - sourceKind: session.sourceKind, - rootUri: session.rootUri, - revision: session.revision, - expectedFileVersion: session.conflictDetectionKey, - contentFormat: 'editorBlocks', - editorSource: 'tiptap', - editorDocument, - content, - tiptapDocument: session.currentTiptapDocument, - blockCount: editorDocument.blocks.length, - }; - if (session.sourceKind !== 'local_folder') { + const savePayload = session.sessionKind === 'resource' + ? { + rootUri: session.rootUri, + path: session.resourcePath, + expectedFileVersion: session.conflictDetectionKey, + contentFormat: 'editorBlocks', + editorSource: 'tiptap-resource-tab', + editorDocument, + content, + tiptapDocument: session.currentTiptapDocument, + blockCount: editorDocument.blocks.length, + } + : { + documentId: session.documentId, + workspaceId: session.workspaceId, + sourceKind: session.sourceKind, + rootUri: session.rootUri, + revision: session.revision, + expectedFileVersion: session.conflictDetectionKey, + contentFormat: 'editorBlocks', + editorSource: 'tiptap', + editorDocument, + content, + tiptapDocument: session.currentTiptapDocument, + blockCount: editorDocument.blocks.length, + }; + if (session.sourceKind !== 'local_folder' && session.sessionKind !== 'resource') { savePayload.conflictDetectionKey = session.conflictDetectionKey; } const response = await fetch(saveEndpoint, { @@ -2021,7 +2099,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { syncSessionMetaToViews(session); session.lastPersistedSerialized = serialized; session.saving = false; - if (typeof window.__mnoteRecordPageHistorySnapshot === 'function') { + if (session.sessionKind !== 'resource' && typeof window.__mnoteRecordPageHistorySnapshot === 'function') { const primaryView = sessionViews(session).find((view) => view.runtimeDescriptor.paneRole === 'primary') || sessionViews(session)[0]; if (primaryView) { const plainText = sessionPlainText(session); @@ -2887,6 +2965,252 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return view; }; + const resourceTabHostNodes = () => ({ + strip: document.querySelector('[data-mnote-main-tab-strip]'), + pageTab: document.querySelector('[data-mnote-main-tab="page"]'), + pagePanel: document.querySelector('[data-mnote-page-tab-panel]'), + host: document.querySelector('[data-mnote-resource-tab-host]'), + panelRoot: document.querySelector('[data-mnote-resource-tab-panel-root]'), + }); + + const resourceIconForKind = (kind) => { + if (kind === 'office') return 'article'; + if (kind === 'pdf') return 'picture_as_pdf'; + if (kind === 'image') return 'image'; + if (kind === 'markdown') return 'notes'; + if (kind === 'code') return 'code'; + return 'draft'; + }; + + const currentWebShellWorkspaceId = () => { + try { + return currentUrl().searchParams.get('workspaceId') || ''; + } catch (_) { + return ''; + } + }; + + const normalizeResourceTabKind = (input) => { + const kind = String(input?.kind || '').trim().toLowerCase(); + const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase(); + if (kind === 'office' || kind === 'pdf' || kind === 'image' || kind === 'markdown' || kind === 'text' || kind === 'code') return kind; + if (/\.(doc|docx|ppt|pptx|xls|xlsx|odt|odp|ods)$/i.test(title)) return 'office'; + if (/\.pdf$/i.test(title)) return 'pdf'; + if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(title)) return 'image'; + if (/\.(md|markdown)$/i.test(title)) return 'markdown'; + if (/\.(txt|log)$/i.test(title)) return 'text'; + if (/\.(rs|ts|tsx|js|jsx|json|css|scss|html|xml|py|go|java|kt|swift|c|h|cpp|hpp|sh|bash|zsh|toml|yaml|yml|sql)$/i.test(title)) return 'code'; + return 'file'; + }; + + const activateMainEditorTab = (objectIdentity) => { + const nodes = resourceTabHostNodes(); + const activeResource = String(objectIdentity || '').trim(); + if (nodes.pageTab instanceof HTMLElement) { + const activePage = !activeResource; + nodes.pageTab.classList.toggle('is-active', activePage); + nodes.pageTab.setAttribute('aria-selected', activePage ? 'true' : 'false'); + } + if (nodes.pagePanel instanceof HTMLElement) nodes.pagePanel.hidden = Boolean(activeResource); + if (nodes.host instanceof HTMLElement) nodes.host.hidden = !activeResource; + resourceTabRegistry.forEach((entry, key) => { + const active = key === activeResource; + if (entry.tab instanceof HTMLElement) { + entry.tab.classList.toggle('is-active', active); + entry.tab.setAttribute('aria-selected', active ? 'true' : 'false'); + } + if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active; + }); + }; + + const closeResourceTab = (objectIdentity) => { + const key = String(objectIdentity || '').trim(); + const entry = resourceTabRegistry.get(key); + if (!entry) return; + if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true }); + if (entry.tab instanceof HTMLElement) entry.tab.remove(); + if (entry.panel instanceof HTMLElement) entry.panel.remove(); + resourceTabRegistry.delete(key); + activateMainEditorTab(''); + }; + + const createResourceTabDom = (input) => { + const nodes = resourceTabHostNodes(); + if (!(nodes.strip instanceof HTMLElement) || !(nodes.panelRoot instanceof HTMLElement)) return null; + const objectIdentity = String(input.objectIdentity || '').trim(); + const title = String(input.title || input.fileName || input.path || '资源').trim() || '资源'; + const kind = normalizeResourceTabKind(input); + const tab = document.createElement('button'); + tab.type = 'button'; + tab.className = 'mnote-main-tab'; + tab.setAttribute('role', 'tab'); + tab.setAttribute('data-mnote-main-tab', objectIdentity); + tab.setAttribute('data-mnote-tab-kind', kind); + tab.innerHTML = '×'; + const titleNode = tab.querySelector('.mnote-main-tab-title'); + if (titleNode) titleNode.textContent = title; + tab.addEventListener('click', (event) => { + const target = event.target; + if (target instanceof HTMLElement && target.closest('.mnote-main-tab-close')) { + event.preventDefault(); + event.stopPropagation(); + closeResourceTab(objectIdentity); + return; + } + activateMainEditorTab(objectIdentity); + }); + const panel = document.createElement('section'); + panel.className = 'mnote-resource-tab-panel'; + panel.setAttribute('data-mnote-resource-tab-panel', objectIdentity); + panel.setAttribute('data-resource-kind', kind); + panel.hidden = true; + nodes.strip.append(tab); + nodes.panelRoot.append(panel); + return { objectIdentity, title, kind, tab, panel, view: null, session: null }; + }; + + const localResourceReadUrl = (rootUri, path) => { + const url = new URL('/api/local-folder/resource/read', window.location.origin); + url.searchParams.set('rootUri', rootUri || ''); + url.searchParams.set('path', path || ''); + return url.toString(); + }; + + const createResourceSession = (entry, input, readResult) => { + const tiptapDocument = toTiptapDocument(readResult?.content, readResult?.text || ''); + const conflictDetectionKey = String(readResult?.fileVersion || readResult?.conflictDetectionKey || '').trim(); + const session = { + key: `resource:${input.rootUri || ''}:${input.path || entry.objectIdentity}`, + sessionKind: 'resource', + documentId: entry.objectIdentity, + workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''), + sourceKind: 'local_folder', + rootUri: String(input.rootUri || ''), + resourcePath: String(input.path || ''), + saveEndpoint: '/api/local-folder/resource/write', + pageAggregateScriptId: '', + latestAggregate: null, + title: entry.title, + currentTiptapDocument: tiptapDocument, + currentSerialized: JSON.stringify(tiptapDocument), + lastPersistedSerialized: JSON.stringify(tiptapDocument), + revision: null, + conflictDetectionKey, + fileVersion: conflictDetectionKey, + lastExternalConflictDetectionKey: conflictDetectionKey, + readOnly: false, + dirty: false, + saving: false, + hasExternalConflict: false, + externalChangePending: false, + externalRefreshSource: '', + lastExternalChangeSignalAt: 0, + lastSelfSaveSignalAt: 0, + lastExternalWriteSource: '', + lastExternalWriteRunId: '', + lastUserInputAt: 0, + saveTimer: 0, + externalRefreshTimer: 0, + releaseTimer: 0, + views: new Map(), + localFolderChannel: null, + status: 'ready', + error: null, + }; + documentSessionRegistry.set(session.key, session); + return session; + }; + + const openTiptapResourceTab = async (entry, input) => { + const runtime = await loadRuntime(); + entry.panel.innerHTML = '
'; + const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); + const observability = entry.panel.querySelector('[data-editor-host-observability]'); + if (!(root instanceof HTMLElement)) throw new Error('resource_tab_root_missing'); + const response = await fetch(localResourceReadUrl(input.rootUri, input.path), { cache: 'no-store', headers: { accept: 'application/json' } }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload || payload.ok !== true) throw new Error(payload?.error?.message || `resource_read_failed_${response.status}`); + const readResult = payload.result || {}; + const runtimeDescriptor = { + paneRole: 'resource', + root, + observability, + aggregate: { layout: { pageOptions: {} } }, + bootstrap: { + documentId: entry.objectIdentity, + workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''), + sourceKind: 'local_folder', + rootUri: String(input.rootUri || ''), + saveEndpoint: '/api/local-folder/resource/write', + }, + }; + const session = createResourceSession(entry, input, readResult); + const view = createEditorViewBinding(session, runtime, runtimeDescriptor); + const mountId = runtime.mount(root, { + documentId: session.documentId, + workspaceId: session.workspaceId, + title: session.title, + content: session.currentTiptapDocument, + revision: session.revision, + conflictDetectionKey: session.conflictDetectionKey, + readOnly: false, + editable: true, + pageOptions: {}, + }); + view.mountId = mountId; + root.setAttribute('data-runtime-mount-id', String(mountId)); + root.setAttribute('data-editor-host-kind', 'leptos_tiptap_resource'); + setStatus(runtimeDescriptor, 'mounting-editor'); + entry.view = view; + entry.session = session; + }; + + const openPassiveResourceTab = (entry, input) => { + const href = String(input.officeUrl || input.href || '').trim(); + if (entry.kind === 'image') { + entry.panel.innerHTML = ''; + const img = entry.panel.querySelector('img'); + if (img instanceof HTMLImageElement) { + img.src = href; + img.alt = entry.title; + } + return; + } + entry.panel.innerHTML = ''; + const frame = entry.panel.querySelector('iframe'); + if (frame instanceof HTMLIFrameElement) { + frame.title = entry.title; + frame.src = href; + } + }; + + const openResourceInActiveTab = async (input = {}) => { + const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim(); + if (!objectIdentity) return false; + const existing = resourceTabRegistry.get(objectIdentity); + if (existing) { + activateMainEditorTab(objectIdentity); + return true; + } + const entry = createResourceTabDom({ ...input, objectIdentity }); + if (!entry) return false; + resourceTabRegistry.set(objectIdentity, entry); + activateMainEditorTab(objectIdentity); + try { + if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') { + await openTiptapResourceTab(entry, input); + } else { + openPassiveResourceTab(entry, input); + } + activateMainEditorTab(objectIdentity); + return true; + } catch (error) { + console.warn('mnote resource tab 打开失败', error); + entry.panel.innerHTML = '
资源打开失败
'; + return false; + } + }; + const mountPane = async (runtimeDescriptor) => { const runtime = await loadRuntime(); const session = getOrCreateDocumentSession(runtimeDescriptor); @@ -2948,6 +3272,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (url instanceof URL) replaceUrlState(url); return true; }, + openResourceInActiveTab: openResourceInActiveTab, closeSecondaryDocument: ({ url } = {}) => { closeSecondaryPane(url instanceof URL ? url : currentUrl()); return true; @@ -3624,7 +3949,12 @@ mod tests { assert!(html.contains("data-document-pane=\"true\"")); assert!(html.contains("data-pane-role=\"primary\"")); assert!(html.contains("data-document-pane-resizer=\"true\"")); + assert!(html.contains("data-mnote-main-tab-strip")); + assert!(html.contains("data-mnote-resource-tab-host")); assert!(html.contains("openPrimaryMindmap")); + assert!(html.contains("openResourceInActiveTab")); + assert!(html.contains("/api/local-folder/resource/read")); + assert!(html.contains("/api/local-folder/resource/write")); assert!(html.contains("replacePrimaryPaneMindmap")); assert!(html.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__")); assert!(html.contains("data-mnote-object-identity")); diff --git a/rust/crates/mnote-web/src/ssr/pages/document.rs b/rust/crates/mnote-web/src/ssr/pages/document.rs index 405e326f..67423e49 100644 --- a/rust/crates/mnote-web/src/ssr/pages/document.rs +++ b/rust/crates/mnote-web/src/ssr/pages/document.rs @@ -297,7 +297,34 @@ pub fn DocumentPage( data-testid="mnote-document-workspace" data-has-secondary-pane={secondary_visible.to_string()} > - +
+
+ +
+
+
+ +
+ +
+
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}:task457`, + 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 main() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task457-resource-tabs-")); + const relativePath = "README.md"; + const documentId = localMdDocumentId(relativePath); + const sideRelativePath = "Side.md"; + writeWorkspaceManifest(root, "user_real"); + fs.writeFileSync(path.join(root, relativePath), "# Page\n\n页面正文保持不变\n", "utf8"); + fs.writeFileSync(path.join(root, sideRelativePath), "# Side\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", + }, + }); + const page = await context.newPage(); + try { + await quickLogin(page); + await page.goto(documentUrl(root, relativePath, sideRelativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('.document-pane[data-pane-role="secondary"][data-pane-visible="true"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const asset = await uploadLocalAsset( + page, + root, + documentId, + "resource-note.md", + "text/markdown", + Buffer.from("# Resource Note\n\n旧资源正文\n", "utf8"), + "attachment", + ); + const officeAsset = await uploadLocalAsset( + page, + root, + documentId, + "resource-office.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + Buffer.from("task457 office probe", "utf8"), + "attachment", + ); + await page.goto(documentUrl(root, relativePath, sideRelativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator(".document-pane[data-pane-role=\"primary\"] .editor-surface .ProseMirror").first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.evaluate(({ asset, documentId }) => { + window.dispatchEvent(new CustomEvent("tree.asset.open", { + detail: { + assetId: asset.id, + documentId, + title: asset.file_name || "resource-note.md", + assetType: asset.asset_type || "attachment", + }, + })); + }, { asset, documentId }); + await page.locator('[data-testid="mnote-resource-tab-host"]:not([hidden])').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('.document-pane[data-pane-role="secondary"][data-pane-visible="true"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const editor = page.locator('.mnote-resource-tab-panel:not([hidden]) .editor-surface .ProseMirror[contenteditable="true"]').first(); + await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press(process.platform === "darwin" ? "Meta+End" : "Control+End"); + await page.keyboard.type("\n新增资源正文", { delay: 5 }); + await page.waitForFunction( + () => document.querySelector('.mnote-resource-tab-panel:not([hidden]) [data-runtime-editor-status="saved"]'), + null, + { timeout: UI_TIMEOUT_MS }, + ); + + const assetPath = path.join(root, "README", "resource-note.md"); + const pageMarkdown = fs.readFileSync(path.join(root, relativePath), "utf8"); + const assetMarkdown = fs.readFileSync(assetPath, "utf8"); + assert(assetMarkdown.includes("新增资源正文"), assetMarkdown); + assert(pageMarkdown.includes("页面正文保持不变"), pageMarkdown); + assert(!pageMarkdown.includes("新增资源正文"), pageMarkdown); + + await page.evaluate(({ officeAsset, documentId }) => { + window.dispatchEvent(new CustomEvent("tree.asset.open", { + detail: { + assetId: officeAsset.id, + documentId, + title: officeAsset.file_name || "resource-office.docx", + assetType: officeAsset.asset_type || "attachment", + }, + })); + }, { officeAsset, documentId }); + await page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="office"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + const officeFrameSrc = await page.locator('.mnote-resource-tab-panel:not([hidden]) iframe.mnote-resource-tab-frame').first().getAttribute("src", { + timeout: UI_TIMEOUT_MS, + }); + assert(officeFrameSrc, "Office resource tab 应创建 iframe"); + const officeFrameUrl = new URL(officeFrameSrc, BASE_URL); + assert(officeFrameUrl.pathname === "/onlyoffice", `Office resource tab iframe 应指向 /onlyoffice,实际为 ${officeFrameSrc}`); + assert(officeFrameUrl.searchParams.get("assetId") === officeAsset.id, "Office iframe URL 应携带当前资源 assetId"); + assert(officeFrameUrl.searchParams.get("mode") === "view", "Office resource tab iframe 应使用 view 模式"); + + const [officePopup] = await Promise.all([ + page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }), + page.evaluate(({ officeAsset, documentId }) => { + window.dispatchEvent(new CustomEvent("tree.asset.open", { + detail: { + assetId: officeAsset.id, + documentId, + title: officeAsset.file_name || "resource-office.docx", + assetType: officeAsset.asset_type || "attachment", + openTarget: "new-window", + }, + })); + }, { officeAsset, documentId }), + ]); + await officePopup.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined); + const officePopupUrl = new URL(officePopup.url()); + assert(officePopupUrl.pathname === "/onlyoffice", `显式 new-window 应打开 /onlyoffice,实际为 ${officePopup.url()}`); + assert(officePopupUrl.searchParams.get("assetId") === officeAsset.id, "new-window URL 应携带当前资源 assetId"); + assert(officePopupUrl.searchParams.get("mode") === "edit", "显式 new-window 应保留 edit 模式"); + await officePopup.close().catch(() => undefined); + + console.log(JSON.stringify({ ok: true, root, assetPath, assetId: asset.id, officeAssetId: officeAsset.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); +});