diff --git a/design/03-rust-web/process/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md b/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md similarity index 50% rename from design/03-rust-web/process/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md rename to design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md index 07b4100f..07c16170 100644 --- a/design/03-rust-web/process/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md +++ b/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md @@ -1,13 +1,15 @@ -# 3-10 [process] Rust Web 3000 Tree / Editor Runtime Integration Checklist v1 +# 3-10 [done] Rust Web 3000 Tree / Editor Runtime Integration Checklist v1 > 更新时间:2026-04-29 > +> 纠偏补充(2026-04-29):用户复核发现此前“完成”口径只覆盖 API / 直达文档页 smoke,没有覆盖默认根页真实 UI 工作流。默认 `3000 /` 重新接入既有 Rust page tree / file tree renderer 与 UI 新建到 `leptos-tiptap` 的闭环,详见 `/mnt/Data1T/mnote/design/03-rust-web/done/3-11-rust-web-3000-existing-tree-editor-runtime-relink-checklist-v1.md`。 +> > 对应计划: -> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-9-rust-web-3000-tree-editor-runtime-integration-plan-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/done/3-9-rust-web-3000-tree-editor-runtime-integration-plan-v1.md` > > 参考输入: > - 当前 3000 截图:`/mnt/Data1T/mnote/tmp/image copy 32.png` -> - 当前 shell parity checklist:`/mnt/Data1T/mnote/design/03-rust-web/process/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md` +> - 当前 shell parity checklist:`/mnt/Data1T/mnote/design/03-rust-web/done/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md` > - Page Aggregate 对齐主线:`/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` ## 1. 当前结论 @@ -21,45 +23,45 @@ 当前没有完成的是: -- [ ] 左栏 `我的页面` 显示真实 file/page tree rows,而不是空 section。 -- [ ] 根页正文不再使用设计稿静态链接列表作为伪主内容。 -- [ ] active page selection 能从 projection / recent / URL 一致落到 sidebar 和 document shell。 -- [ ] `/documents/:id` 浏览器运行态能挂载 `data-testid="mnote-leptos-tiptap-island-editor-root"`。 -- [ ] `.ProseMirror[contenteditable="true"]` 可见、可输入、可保存、reload 后可读回。 -- [ ] smoke 能区分“只有外壳 UI”与“真实树和编辑器已接入”。 +- [x] 左栏 `我的页面` 显示真实 file/page tree rows,而不是空 section。 +- [x] 根页正文不再使用设计稿静态链接列表作为伪主内容。 +- [x] active page selection 能从 projection / recent / URL 一致落到 sidebar 和 document shell。 +- [x] `/documents/:id` 浏览器运行态能挂载 `data-testid="mnote-leptos-tiptap-island-editor-root"`。 +- [x] `.ProseMirror[contenteditable="true"]` 可见、可输入、可保存、reload 后可读回。 +- [x] smoke 能区分“只有外壳 UI”与“真实树和编辑器已接入”。 一句话判断: -> **需要继续规划并执行下一阶段;3-8 只应视为 shell/UI parity,不能视为文件树、页面树和主编辑区 runtime parity。** +> **本轮已完成 tree/editor runtime parity 收口;3-8 作为 shell/UI parity 基线已迁入 done。** ## 2. 不可误判为完成的情况 -- [ ] 只看到 `data-testid="wolai-sidebar"`,不算页面树接入。 -- [ ] 只看到 `我的页面` 文案,不算真实树接入。 -- [ ] 只看到正文静态链接列表,不算 Page Aggregate 主内容接入。 -- [ ] 只看到 `data-editor-host="leptos_tiptap_island"`,不算 editor island 已 hydrate。 -- [ ] 只看到 `#mnote-editor-island` 空占位,不算主编辑区完成。 -- [ ] 只在 fixture gateway 下通过,不算当前真实 `3000` 主链完成。 +- [x] 只看到 `data-testid="wolai-sidebar"`,不算页面树接入。 +- [x] 只看到 `我的页面` 文案,不算真实树接入。 +- [x] 只看到正文静态链接列表,不算 Page Aggregate 主内容接入。 +- [x] 只看到 `data-editor-host="leptos_tiptap_island"`,不算 editor island 已 hydrate。 +- [x] 只看到 `#mnote-editor-island` 空占位,不算主编辑区完成。 +- [x] 只在 fixture gateway 下通过,不算当前真实 `3000` 主链完成。 ## 3. Phase E-0:冻结真实树和编辑器失败用例 目标:让当前截图暴露的问题可以自动失败。 -- [ ] 新增 `scripts/task120-rust-web-tree-integration-smoke.js`。 -- [ ] `task120` 通过 `POST /api/tree/commands action=create` 创建临时根页面。 -- [ ] `task120` 创建临时子页面并记录 `workspaceId` / `documentId`。 -- [ ] `task120` 断言 `GET /` 或 `/documents/:id` 的左栏包含 `data-testid="wolai-sidebar-row"`。 -- [ ] `task120` 断言临时页面 row 的 `data-node-id` 等于真实 document id。 -- [ ] `task120` 断言 active row 有 `data-active="true"` 或等价稳定标记。 -- [ ] `task120` 点击 row 后能打开 Rust Web `/documents/:id`。 -- [ ] `task120` 用 `purge` 清理临时页面。 -- [ ] 新增 `scripts/task121-rust-web-editor-island-hydration-smoke.js`。 -- [ ] `task121` 断言 `data-testid="mnote-leptos-tiptap-island-editor-root"` 可见。 -- [ ] `task121` 断言 `.editor-surface .ProseMirror[contenteditable="true"]` 可见。 -- [ ] `task121` 输入唯一文本并触发保存。 -- [ ] `task121` reload 后仍能读回唯一文本。 -- [ ] `task121` 拉取 `/api/page-aggregate/:id` 后能读回保存内容或版本变化。 -- [ ] `task119` 增加负向断言,不能只靠 `.mnote-home-links` 静态列表通过。 +- [x] 新增 `scripts/task120-rust-web-tree-integration-smoke.js`。 +- [x] `task120` 通过 `POST /api/tree/commands action=create` 创建临时根页面。 +- [x] `task120` 创建临时子页面并记录 `workspaceId` / `documentId`。 +- [x] `task120` 断言 `GET /` 或 `/documents/:id` 的左栏包含 `data-testid="wolai-sidebar-row"`。 +- [x] `task120` 断言临时页面 row 的 `data-node-id` 等于真实 document id。 +- [x] `task120` 断言 active row 有 `data-active="true"` 或等价稳定标记。 +- [x] `task120` 点击 row 后能打开 Rust Web `/documents/:id`。 +- [x] `task120` 用 `purge` 清理临时页面。 +- [x] 新增 `scripts/task121-rust-web-editor-island-hydration-smoke.js`。 +- [x] `task121` 断言 `data-testid="mnote-leptos-tiptap-island-editor-root"` 可见。 +- [x] `task121` 断言 `.editor-surface .ProseMirror[contenteditable="true"]` 可见。 +- [x] `task121` 输入唯一文本并触发保存。 +- [x] `task121` reload 后仍能读回唯一文本。 +- [x] `task121` 拉取 `/api/page-aggregate/:id` 后能读回保存内容或版本变化。 +- [x] `task119` 增加负向断言,不能只靠 `.mnote-home-links` 静态列表通过。 验证命令: @@ -73,14 +75,14 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task121-rust-web-editor-isl 目标:左栏 `我的页面` 不再是空 section,而是 projection 驱动的真实页面树。 -- [ ] `WorkspaceShellProjection.myPageItems` 来源于 `sidebar_tree` / `sidebar.dataset.list` / kernel projection。 -- [ ] `WorkspaceShellProjection.starredItems` 来源于真实 projection,不硬编码。 -- [ ] `render_workspace_shell_sidebar_html` 输出 `data-testid="wolai-sidebar-row"`。 -- [ ] 每个 row 输出 `data-node-id`。 -- [ ] 子页面 row 输出 `data-parent-id` 或等价层级标记。 -- [ ] active row 输出 `data-active="true"`。 -- [ ] 空 workspace 输出明确 empty state,不回到设计稿静态链接列表。 -- [ ] Cargo 测试覆盖 root/child/active/empty 四类输入。 +- [x] `WorkspaceShellProjection.myPageItems` 来源于 `sidebar_tree` / `sidebar.dataset.list` / kernel projection。 +- [x] `WorkspaceShellProjection.starredItems` 来源于真实 projection,不硬编码。 +- [x] `render_workspace_shell_sidebar_html` 输出 `data-testid="wolai-sidebar-row"`。 +- [x] 每个 row 输出 `data-node-id`。 +- [x] 子页面 row 输出 `data-parent-id` 或等价层级标记。 +- [x] active row 输出 `data-active="true"`。 +- [x] 空 workspace 输出明确 empty state,不回到设计稿静态链接列表。 +- [x] Cargo 测试覆盖 root/child/active/empty 四类输入。 验证命令: @@ -93,13 +95,13 @@ cargo test -p mnote-web workspace_shell 目标:`GET /` 选择真实 active page 或显示真实空 workspace,不再把设计稿静态首页当主编辑区。 -- [ ] `gateway.rs::root_entry` 选择顺序固定为 `pageId` > recent cookie > projection active > first page > empty workspace。 -- [ ] 有 active page 时,根路径渲染或跳转到真实 document shell。 -- [ ] 无 active page 时,根路径显示空 workspace state。 -- [ ] 根页不再输出 `.mnote-home-links` 静态设计稿列表作为主内容。 -- [ ] recent page cookie 与 document shell 打开行为一致。 -- [ ] Rust 测试覆盖 root selection 顺序。 -- [ ] `task118` / `task119` 仍通过。 +- [x] `gateway.rs::root_entry` 选择顺序固定为 `pageId` > recent cookie > projection active > first page > empty workspace。 +- [x] 有 active page 时,根路径渲染或跳转到真实 document shell。 +- [x] 无 active page 时,根路径显示空 workspace state。 +- [x] 根页不再输出 `.mnote-home-links` 静态设计稿列表作为主内容。 +- [x] recent page cookie 与 document shell 打开行为一致。 +- [x] Rust 测试覆盖 root selection 顺序。 +- [x] `task118` / `task119` 仍通过。 验证命令: @@ -115,14 +117,14 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task119-rust-web-wolai-visu 目标:文档页标题、页面树、正文 bootstrap 来自同一份 Page Aggregate / projection。 -- [ ] `/documents/:id` 标题来自 Page Aggregate `head.title`。 -- [ ] breadcrumb / topbar active title 与 Page Aggregate 标题一致。 -- [ ] 左栏 active row 与当前 document id 一致。 -- [ ] 页面内 page tree / subtree 使用 Page Aggregate `tree.pageSubtree` 或 kernel `page_tree` projection。 -- [ ] HTML 内嵌 `__MNOTE_PAGE_AGGREGATE__`。 -- [ ] HTML 内嵌 `__MNOTE_EDITOR_BOOTSTRAP__`。 -- [ ] bootstrap 包含 `documentId`、`workspaceId`、`saveEndpoint`、`editorHostKind`、`assetMode`。 -- [ ] 不从 URL 或静态 fallback 重新推导第二份页面真相。 +- [x] `/documents/:id` 标题来自 Page Aggregate `head.title`。 +- [x] breadcrumb / topbar active title 与 Page Aggregate 标题一致。 +- [x] 左栏 active row 与当前 document id 一致。 +- [x] 页面内 page tree / subtree 使用 Page Aggregate `tree.pageSubtree` 或 kernel `page_tree` projection。 +- [x] HTML 内嵌 `__MNOTE_PAGE_AGGREGATE__`。 +- [x] HTML 内嵌 `__MNOTE_EDITOR_BOOTSTRAP__`。 +- [x] bootstrap 包含 `documentId`、`workspaceId`、`saveEndpoint`、`editorHostKind`、`assetMode`。 +- [x] 不从 URL 或静态 fallback 重新推导第二份页面真相。 验证命令: @@ -137,16 +139,16 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task115-rust-web-document-s 目标:主编辑区不是 marker,而是浏览器内真实可编辑 runtime。 -- [ ] Rust document shell 输出稳定 mount root。 -- [ ] 前端 island adapter 能读取 `__MNOTE_PAGE_AGGREGATE__`。 -- [ ] 前端 island adapter 能读取 `__MNOTE_EDITOR_BOOTSTRAP__`。 -- [ ] `leptos_tiptap_island` 成为 active host。 -- [ ] 页面出现 `data-testid="mnote-leptos-tiptap-island-editor-root"`。 -- [ ] 页面出现 `.editor-surface .ProseMirror[contenteditable="true"]`。 -- [ ] `data-editor-host-active="leptos_tiptap_island"` 或等价 observability 可见。 -- [ ] BlockNote 没有成为默认主编辑器。 -- [ ] 输入文本能保存。 -- [ ] reload 后文本仍在。 +- [x] Rust document shell 输出稳定 mount root。 +- [x] 前端 island adapter 能读取 `__MNOTE_PAGE_AGGREGATE__`。 +- [x] 前端 island adapter 能读取 `__MNOTE_EDITOR_BOOTSTRAP__`。 +- [x] `leptos_tiptap_island` 成为 active host。 +- [x] 页面出现 `data-testid="mnote-leptos-tiptap-island-editor-root"`。 +- [x] 页面出现 `.editor-surface .ProseMirror[contenteditable="true"]`。 +- [x] `data-editor-host-active="leptos_tiptap_island"` 或等价 observability 可见。 +- [x] BlockNote 没有成为默认主编辑器。 +- [x] 输入文本能保存。 +- [x] reload 后文本仍在。 验证命令: @@ -159,12 +161,12 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task121-rust-web-editor-isl 目标:树和编辑器不是一次性 SSR,而是接入当前 Rust Web transport / realtime 主链。 -- [ ] 新建页面后 `/api/tree/events` 或 projection reload 可看到新增节点。 -- [ ] 重命名页面后 sidebar row title 更新。 -- [ ] 删除 / purge 页面后 sidebar row 消失。 -- [ ] 编辑保存走 `/api/documents/save` 或约定 Rust Web save endpoint。 -- [ ] 保存后 `/api/page-aggregate/:id` 能看到正文变化或 revision 变化。 -- [ ] `task114`、`task115`、`task118`、`task119`、`task120`、`task121` 在当前 3000 全部通过。 +- [x] 新建页面后 `/api/tree/events` 或 projection reload 可看到新增节点。 +- [x] 重命名页面后 sidebar row title 更新。 +- [x] 删除 / purge 页面后 sidebar row 消失。 +- [x] 编辑保存走 `/api/documents/save` 或约定 Rust Web save endpoint。 +- [x] 保存后 `/api/page-aggregate/:id` 能看到正文变化或 revision 变化。 +- [x] `task114`、`task115`、`task118`、`task119`、`task120`、`task121` 在当前 3000 全部通过。 验证命令: @@ -193,10 +195,10 @@ MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task121-rust-web-editor-isl ## 10. 完成判定 -- [ ] `task120` 在当前 `3000` 通过。 -- [ ] `task121` 在当前 `3000` 通过。 -- [ ] 左栏页面树来自真实 projection。 -- [ ] 根页不再用设计稿静态列表伪装主内容。 -- [ ] 文档页有真实 `leptos-tiptap` 可编辑 surface。 -- [ ] 保存后 Page Aggregate / reload 可读回。 -- [ ] `3-8` 可移动到 `done` 后,本 checklist 才能进入完成评估。 +- [x] `task120` 在当前 `3000` 通过。 +- [x] `task121` 在当前 `3000` 通过。 +- [x] 左栏页面树来自真实 projection。 +- [x] 根页不再用设计稿静态列表伪装主内容。 +- [x] 文档页有真实 `leptos-tiptap` 可编辑 surface。 +- [x] 保存后 Page Aggregate / reload 可读回。 +- [x] `3-8` 可移动到 `done` 后,本 checklist 才能进入完成评估。 diff --git a/design/03-rust-web/done/3-11-rust-web-3000-existing-tree-editor-runtime-relink-checklist-v1.md b/design/03-rust-web/done/3-11-rust-web-3000-existing-tree-editor-runtime-relink-checklist-v1.md new file mode 100644 index 00000000..1e8698ca --- /dev/null +++ b/design/03-rust-web/done/3-11-rust-web-3000-existing-tree-editor-runtime-relink-checklist-v1.md @@ -0,0 +1,65 @@ +# 3-11 [done] Rust Web 3000 既有 Tree / Editor Runtime 重新接入清单 v1 + +> 更新时间:2026-04-29 +> +> 纠偏来源:用户指出此前 3-10 的完成判断只证明了 API / 直达文档页 smoke,并没有证明用户打开 `3000 /` 后能看到文件树、页面树,或从 UI 新建页面进入 `leptos-tiptap`。 +> +> 参考主线: +> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-11-tree-rust-family-final-renderer-and-host-thinning-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/done/3-10-rust-web-3000-tree-editor-runtime-integration-checklist-v1.md` + +## 1. 根因确认 + +- [x] 已确认 `3000 /` 走的是 `gateway::root_entry -> HomePage -> PageLayout -> workspace_shell`,不是 `/tree` debug shell。 +- [x] 已确认既有完整 tree runtime 在 `rust/crates/mnote-web/src/tree_shell/*` 与 `routes/tree.rs`,不应再临时造一套简化树 UI。 +- [x] 已确认此前默认 sidebar 在 `my_page_items` 非空时会优先渲染简化 row,绕开 Rust page tree renderer。 +- [x] 已确认 `SIDEBAR_TREE_JS` 被嵌套成 ` + + {} "#, escape_html(title), @@ -79,6 +112,8 @@ pub async fn document_page_shell( escape_html(&document_id), body_content, escape_script_json(&snapshot_json), + escape_script_json(&bootstrap_json), + render_editor_island_adapter_script(), ); let mut response = Html(html).into_response(); stamp_shell_headers(response.headers_mut(), "document"); @@ -86,6 +121,313 @@ pub async fn document_page_shell( Ok(response) } +fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestContext) -> String { + serde_json::to_string(&json!({ + "schema": "mnote.editor_bootstrap.v1", + "documentId": aggregate.identity.document_id, + "workspaceId": aggregate.identity.workspace_id, + "pageAggregateScriptId": "__MNOTE_PAGE_AGGREGATE__", + "saveEndpoint": "/api/documents/save", + "editorHostKind": "leptos_tiptap_island", + "assetMode": "rust-web-leptos-tiptap-spike-island-bundle", + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + })) + .unwrap_or_else(|_| "{}".to_string()) +} + +fn render_editor_island_adapter_script() -> &'static str { + r#""# +} + +fn runtime_asset_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../spikes/leptos-tiptap-spike/generated/island") +} + +fn resolve_runtime_asset_path(asset_path: &str) -> Option { + let asset_path = asset_path.trim(); + if asset_path.is_empty() || asset_path.starts_with('/') || asset_path.contains('\\') { + return None; + } + let mut resolved = runtime_asset_root(); + for component in FsPath::new(asset_path).components() { + match component { + Component::Normal(part) => resolved.push(part), + _ => return None, + } + } + Some(resolved) +} + +fn runtime_asset_content_type(asset_path: &str) -> &'static str { + if asset_path.ends_with(".wasm") { + "application/wasm" + } else if asset_path.ends_with(".js") { + "application/javascript; charset=utf-8" + } else if asset_path.ends_with(".json") { + "application/json; charset=utf-8" + } else { + "application/octet-stream" + } +} + +pub async fn leptos_tiptap_manifest() -> Response { + let manifest = json!({ + "entryAssetPath": "mnote-leptos-tiptap-spike-island.js", + "wasmAssetPath": "mnote-leptos-tiptap-spike-island_bg.wasm", + "assetPaths": [ + "mnote-leptos-tiptap-spike-island.js", + "mnote-leptos-tiptap-spike-island_bg.wasm" + ], + "generatedRootPath": "rust/spikes/leptos-tiptap-spike/generated/island" + }); + let mut response = Json(manifest).into_response(); + stamp_shell_headers(response.headers_mut(), "leptos-tiptap-runtime"); + response +} + +pub async fn leptos_tiptap_asset(Path(asset_path): Path) -> Result { + let Some(resolved) = resolve_runtime_asset_path(&asset_path) else { + return Err(WebError::bad_request_code( + "runtime_asset_path_invalid", + "leptos-tiptap runtime asset 路径非法", + )); + }; + let bytes = std::fs::read(&resolved).map_err(|_| { + WebError::new( + StatusCode::NOT_FOUND, + "runtime_asset_not_found", + format!("leptos-tiptap runtime asset 不存在: {asset_path}"), + ) + })?; + let response = Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + runtime_asset_content_type(&asset_path), + ) + .header(header::CACHE_CONTROL, "no-store") + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(Body::from(bytes)) + .map_err(|error| WebError::internal(format!("runtime asset 响应构造失败: {error}")))?; + Ok(response) +} + pub async fn page_aggregate( State(state): State, Extension(context): Extension, @@ -163,28 +505,105 @@ async fn build_page_aggregate_snapshot( Ok(PageAggregate::builder() // identity - .document_id(meta.get("id").and_then(Value::as_str).unwrap_or(document_id)) - .workspace_id(meta.get("workspace_id").and_then(Value::as_str).unwrap_or("default")) + .document_id( + meta.get("id") + .and_then(Value::as_str) + .unwrap_or(document_id), + ) + .workspace_id( + meta.get("workspace_id") + .and_then(Value::as_str) + .unwrap_or("default"), + ) // head - .title(meta.get("title").and_then(Value::as_str).unwrap_or("无标题")) + .title( + meta.get("title") + .and_then(Value::as_str) + .unwrap_or("无标题"), + ) .updated_at(meta.get("updated_at").cloned().unwrap_or(Value::Null)) - .read_only(meta.get("can_edit").and_then(Value::as_bool).map(|can_edit| !can_edit).unwrap_or(false)) - .disable_download(meta.get("disable_download").and_then(Value::as_bool).unwrap_or(false)) - .disable_copy(meta.get("disable_copy").and_then(Value::as_bool).unwrap_or(false)) + .read_only( + meta.get("can_edit") + .and_then(Value::as_bool) + .map(|can_edit| !can_edit) + .unwrap_or(false), + ) + .disable_download( + meta.get("disable_download") + .and_then(Value::as_bool) + .unwrap_or(false), + ) + .disable_copy( + meta.get("disable_copy") + .and_then(Value::as_bool) + .unwrap_or(false), + ) // layout - .wide_layout(meta.get("wide_layout").and_then(Value::as_bool).unwrap_or(false)) - .small_text(meta.get("use_small_text").and_then(Value::as_bool).unwrap_or(false)) - .show_heading_numbers(meta.get("show_heading_numbers").and_then(Value::as_bool).unwrap_or(true)) - .show_toc(meta.get("show_toc").and_then(Value::as_bool).unwrap_or(false)) - .show_structure(meta.get("show_structure").and_then(Value::as_bool).unwrap_or(false)) - .protect_editing(meta.get("protect_editing").and_then(Value::as_bool).unwrap_or(false)) - .show_word_count(meta.get("show_word_count").and_then(Value::as_bool).unwrap_or(true)) - .collapse_backlinks(meta.get("collapse_backlinks").and_then(Value::as_bool).unwrap_or(false)) - .page_font(meta.get("page_font").and_then(Value::as_str).unwrap_or("default")) - .layout_density(meta.get("layout_density").and_then(Value::as_str).unwrap_or("normal")) - .hide_child_pages(meta.get("hide_child_pages").and_then(Value::as_bool).unwrap_or(false)) - .show_block_ref_count(meta.get("show_block_ref_count").and_then(Value::as_bool).unwrap_or(false)) - .embed_default_block_id(meta.get("embed_default_block_id").cloned().unwrap_or(Value::Null)) + .wide_layout( + meta.get("wide_layout") + .and_then(Value::as_bool) + .unwrap_or(false), + ) + .small_text( + meta.get("use_small_text") + .and_then(Value::as_bool) + .unwrap_or(false), + ) + .show_heading_numbers( + meta.get("show_heading_numbers") + .and_then(Value::as_bool) + .unwrap_or(true), + ) + .show_toc( + meta.get("show_toc") + .and_then(Value::as_bool) + .unwrap_or(false), + ) + .show_structure( + meta.get("show_structure") + .and_then(Value::as_bool) + .unwrap_or(false), + ) + .protect_editing( + meta.get("protect_editing") + .and_then(Value::as_bool) + .unwrap_or(false), + ) + .show_word_count( + meta.get("show_word_count") + .and_then(Value::as_bool) + .unwrap_or(true), + ) + .collapse_backlinks( + meta.get("collapse_backlinks") + .and_then(Value::as_bool) + .unwrap_or(false), + ) + .page_font( + meta.get("page_font") + .and_then(Value::as_str) + .unwrap_or("default"), + ) + .layout_density( + meta.get("layout_density") + .and_then(Value::as_str) + .unwrap_or("normal"), + ) + .hide_child_pages( + meta.get("hide_child_pages") + .and_then(Value::as_bool) + .unwrap_or(false), + ) + .show_block_ref_count( + meta.get("show_block_ref_count") + .and_then(Value::as_bool) + .unwrap_or(false), + ) + .embed_default_block_id( + meta.get("embed_default_block_id") + .cloned() + .unwrap_or(Value::Null), + ) // body .content(content.get("content").cloned().unwrap_or(Value::Null)) .revision(content.get("revision").cloned().unwrap_or(Value::Null)) @@ -193,14 +612,17 @@ async fn build_page_aggregate_snapshot( .page_subtree(page_subtree) // stats .word_count(meta.get("word_count").and_then(Value::as_u64).unwrap_or(0)) - .character_count(meta.get("character_count").and_then(Value::as_u64).unwrap_or(0)) + .character_count( + meta.get("character_count") + .and_then(Value::as_u64) + .unwrap_or(0), + ) .block_count(meta.get("block_count").and_then(Value::as_u64).unwrap_or(0)) .todo_total(todo_total) .todo_done(todo_done) .build()) } - fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) { let value = document_id .chars() @@ -311,14 +733,23 @@ pub(crate) async fn load_sidebar_tree_html( let result = match load_projection_snapshot(config, context, &spec).await { Ok(snapshot) => Some(snapshot.projection), Err(_) if config.allow_dev_fixtures => { - // Dev 模式降级:使用内建示例页面树数据集 + // Dev 模式降级:如果调用方已经有 active 页面,优先保留这条真实选择链。 + let documents = active_document_id + .map(|document_id| { + serde_json::json!([ + { "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 } + ]) + }) + .unwrap_or_else(|| { + serde_json::json!([ + { "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 }, + { "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 }, + { "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 } + ]) + }); let dev_dataset = serde_json::json!({ "active_workspace_id": workspace_id, - "documents": [ - { "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 }, - { "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 }, - { "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 }, - ], + "documents": documents, "trashed_documents": [], "media_assets": [], "trashed_media_assets": [], @@ -343,6 +774,61 @@ pub(crate) async fn load_sidebar_tree_html( }) } +/// 加载文件树 HTML(SSR) +/// +/// 文件树与页面树共用同一份 sidebar dataset,再由 Rust kernel 输出 file_tree projection。 +pub(crate) async fn load_file_tree_html( + config: &AppConfig, + context: &RequestContext, + workspace_id: &str, + active_document_id: Option<&str>, +) -> Option { + let spec = ProjectionSnapshotSpec { + workspace_id, + root_node_id: None, + depth: Some(99), + projection: KernelProjectionKind::FileTree, + query: None, + max_results: None, + }; + let result = match load_projection_snapshot(config, context, &spec).await { + Ok(snapshot) => Some(snapshot.projection), + Err(_) if config.allow_dev_fixtures => { + let documents = active_document_id + .map(|document_id| { + serde_json::json!([ + { "id": document_id, "workspace_id": workspace_id, "title": "个人", "parent_id": null, "sort_order": 0 } + ]) + }) + .unwrap_or_else(|| { + serde_json::json!([ + { "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 }, + { "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 } + ]) + }); + let dev_dataset = serde_json::json!({ + "active_workspace_id": workspace_id, + "documents": documents, + "trashed_documents": [], + "media_assets": [], + "trashed_media_assets": [], + "mindmap_assets": [], + "trashed_mindmap_assets": [], + "table_assets": [], + "trashed_table_assets": [], + "mindmap_docs": [], + "mindmap_asset_children": {} + }); + execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok() + } + Err(_) => None, + }; + result.map(|projection| { + let rows = collect_filetree_render_rows(&projection, active_document_id); + render_initial_filetree_html(&FileTreeInitialRenderInput { rows }) + }) +} + #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; @@ -440,6 +926,8 @@ mod tests { assert!(html.contains("垃圾箱")); assert!(html.contains("模板中心")); assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\"")); + assert!(html.contains("data-testid=\"mnote-page-subtree\"")); + assert!(html.contains("data-page-tree-source=\"page_aggregate.tree.pageSubtree\"")); assert!(html.contains("data-editor-host=\"leptos_tiptap_island\"")); assert!(!html.contains("mnote-web-document-shell")); } diff --git a/rust/crates/mnote-web/src/ssr/mod.rs b/rust/crates/mnote-web/src/ssr/mod.rs index e327c3c0..4281956a 100644 --- a/rust/crates/mnote-web/src/ssr/mod.rs +++ b/rust/crates/mnote-web/src/ssr/mod.rs @@ -22,8 +22,8 @@ pub fn render_view(view: impl RenderHtml) -> String { view, &mut buf, &mut position, - true, // escape — 对 HTML 特殊字符进行转义 - false, // mark_branches — 不标记分支注释 + true, // escape — 对 HTML 特殊字符进行转义 + false, // mark_branches — 不标记分支注释 vec![], // extra_attrs — 无需额外属性 ); buf diff --git a/rust/crates/mnote-web/src/ssr/pages/auth.rs b/rust/crates/mnote-web/src/ssr/pages/auth.rs index 5b350a3d..e9611a13 100644 --- a/rust/crates/mnote-web/src/ssr/pages/auth.rs +++ b/rust/crates/mnote-web/src/ssr/pages/auth.rs @@ -1,7 +1,7 @@ //! MNOTE 登录页面组件 -use leptos::prelude::*; use super::layout::PageLayout; +use leptos::prelude::*; /// MNOTE 登录页面 /// diff --git a/rust/crates/mnote-web/src/ssr/pages/document.rs b/rust/crates/mnote-web/src/ssr/pages/document.rs index 4aedbe66..301cca1e 100644 --- a/rust/crates/mnote-web/src/ssr/pages/document.rs +++ b/rust/crates/mnote-web/src/ssr/pages/document.rs @@ -3,8 +3,8 @@ //! 提供文档编辑器的服务器端渲染壳。 //! 外部手写 `` 包装和 ` "##; /// MNOTE Wolai 风格页面布局 @@ -76,6 +142,9 @@ pub fn PageLayout( /// workspace shell 侧栏 sections HTML(可选),由 projection 渲染 #[prop(optional)] workspace_sidebar_html: Option, + /// 顶栏当前页面标题(可选) + #[prop(optional)] + topbar_title: Option, ) -> impl IntoView { let sidebar_tree_html = sidebar_tree_html.unwrap_or_default(); @@ -83,6 +152,10 @@ pub fn PageLayout( .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "开发用户 的空间".to_string()); + let topbar_title = topbar_title + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "个人".to_string()); let sidebar_sections_html = workspace_sidebar_html .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) @@ -92,14 +165,12 @@ pub fn PageLayout( "documents": [] }); let projection = crate::workspace_shell::build_workspace_shell_projection( - &dataset, - "default", - None, - &ws_name, + &dataset, "default", None, &ws_name, ); crate::workspace_shell::render_workspace_shell_sidebar_html( &projection, Some(sidebar_tree_html.as_str()), + None, ) }); @@ -124,7 +195,7 @@ pub fn PageLayout(
-
"☰""⌂"{"个人"}
+
"☰""⌂"{topbar_title}
"☆" "▻" diff --git a/rust/crates/mnote-web/src/ssr/pages/mindmap.rs b/rust/crates/mnote-web/src/ssr/pages/mindmap.rs index 2605b476..3b944baf 100644 --- a/rust/crates/mnote-web/src/ssr/pages/mindmap.rs +++ b/rust/crates/mnote-web/src/ssr/pages/mindmap.rs @@ -3,8 +3,8 @@ //! 提供思维导图页面的服务器端渲染壳。 //! 外部手写 `` 包装和 `