diff --git a/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md b/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md index 8cdd47ec..b4a4aea2 100644 --- a/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md +++ b/design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md @@ -59,18 +59,21 @@ 5. 观测与验收必须落到真实浏览器: - 记录 projection request start/end、filetree interactive time、Page Aggregate TTFB、editor ready、longtask count。 - localhost 打开目标 Markdown 时,不能因页面树 projection 重建而阻塞 editor surface。 + - 必须包含一条模拟用户手动操作的 Playwright/Chromium 测试:从首页进入本地 workspace,点击星标文件夹或文件树节点,逐级导航到目标 Markdown,而不是只用 `context.request` 或直接访问最终 URL。 ### 待执行 Checklist -- [ ] 扩展 `task492-sidebar-starred-shortcuts-smoke.js`:人为延迟 `/api/tree/projections/sidebar`,断言 scoped `/api/tree/projections/file` 先完成并可见;旧“我的空间”行消失,本地 Markdown 页面树稍后出现。 -- [ ] 新增 `task497-local-page-tree-filetree-open-performance-smoke.js`:打开 `design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md`,记录 sidebar/filetree/page aggregate/editor ready 时序和 long task。 -- [ ] `gateway.rs` 本地 root 分支复用已加载的 page tree snapshot,避免同一请求重复调用 `load_local_folder_page_tree_snapshot(root_uri)`。 -- [ ] `sidebar-tree-runtime.js` 星标文件夹打开改为并行请求 sidebar projection 与 scoped file projection;file projection 可以先应用,sidebar projection 后补页面树。 -- [ ] `sidebar-tree-live-apply-runtime.js` 拆出 page/file projection 独立 apply,并为二者加入 generation / stale guard。 -- [ ] 为 scoped filetree 打开链路加入过期请求丢弃:旧 scope/rootUri 的 projection 结果不得 patch 当前 DOM。 -- [ ] 对 page tree 大 DOM 替换加分片或 idle/yield,避免同步 `innerHTML` / `replaceChildren` 产生长任务。 -- [ ] 后端补本地 page tree snapshot cache 或 watchRevision stale-while-revalidate,降低同一 root 高频重复扫描。 -- [ ] 验证:`node scripts/task492-sidebar-starred-shortcuts-smoke.js`、`node scripts/task494-filetree-lazy-loading-dedup-smoke.js`、新增 task497、相关 Rust filters、`git diff --check`、`codegraph sync/status`。 +- [x] 扩展 `task492-sidebar-starred-shortcuts-smoke.js`:人为延迟 `/api/tree/projections/sidebar`,断言 scoped `/api/tree/projections/file` 先完成并可见;旧“我的空间”行消失,本地 Markdown 页面树稍后出现。 +- [x] 新增 `task497-local-page-tree-filetree-open-performance-smoke.js`:用真实 Chromium 从首页进入本地 workspace,点击星标 `design` 文件夹,再通过 Explorer 逐级打开 `design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md`;记录 sidebar/filetree/page aggregate/editor ready 时序、network/resource timing、long task,并保存打开前后截图。 +- [x] `task497` 不允许只用 `context.request.fetch` 或直接 `page.goto(最终文档 URL)` 作为主验收;可以用 direct request 作为对照指标,但真实验收必须来自 UI 点击路径。 +- [x] `task497` 断言打开过程中旧“我的空间”页面树不会长期残留、scoped FileTree 先可交互、editor surface 可见,且目标 md 打开不会因为 page tree projection 全量重建而明显延迟。 +- [x] `gateway.rs` 本地 root 分支复用已加载的 page tree snapshot,避免同一请求重复调用 `load_local_folder_page_tree_snapshot(root_uri)`。 +- [x] `sidebar-tree-runtime.js` 星标文件夹打开改为并行请求 sidebar projection 与 scoped file projection;file projection 可以先应用,sidebar projection 后补页面树。 +- [x] `sidebar-tree-live-apply-runtime.js` 拆出 page/file projection 独立 apply,并为二者加入 generation / stale guard。 +- [x] 为 scoped filetree 打开链路加入过期请求丢弃:旧 scope/rootUri 的 projection 结果不得 patch 当前 DOM。 +- [x] 对 page tree 大 DOM 替换加分片或 idle/yield,避免同步 `innerHTML` / `replaceChildren` 产生长任务。 +- [x] 后端补本地 page tree snapshot cache 或 watchRevision stale-while-revalidate,降低同一 root 高频重复扫描。 +- [x] 验证:`node scripts/task492-sidebar-starred-shortcuts-smoke.js`、`node scripts/task494-filetree-lazy-loading-dedup-smoke.js`、新增 task497、相关 Rust filters、`git diff --check`、`codegraph sync/status`。 ## Context7 参考结论 @@ -349,6 +352,10 @@ scripts/task494-filetree-lazy-loading-dedup-smoke.js - 2026-05-26:`cargo test -p control-plane --manifest-path rust/Cargo.toml` 通过,`23 passed`。 - 2026-05-26:`cargo fmt --check --all --manifest-path rust/Cargo.toml` 通过。 - 2026-05-26:`codegraph sync .` 已执行;`codegraph status .` 仍报告 `Pending Changes: Added: 5 files`,属于当前工作树既有未提交/新增文件状态,本轮不清理。 +- 2026-05-27:`task492` RED 先失败于星标文件夹等待 sidebar projection,GREEN 后 `node scripts/task492-sidebar-starred-shortcuts-smoke.js` 通过,确认 scoped FileTree 先于延迟页面树可交互。 +- 2026-05-27:`task497` RED 先失败于真实点击星标 `design` 后 scoped FileTree 未先完成;GREEN 后 `node scripts/task497-local-page-tree-filetree-open-performance-smoke.js` 通过。最终样本:`filetreeInteractiveAt=1779857492435`,`targetClickAt=1779857492621`,`pageAggregateResponseAt=1779857492667`,`editorVisibleAt=1779857492823`,`longTasks=[]`,`directDocumentMs=8`;截图保存在 `tmp/task497-local-page-tree-filetree-open-performance-smoke/`。 +- 2026-05-27:Rust filters 通过:`root_entry_local_folder_reuses_page_tree_snapshot_for_sidebar_html`、`sidebar_runtime_applies_page_projection_with_generation_and_yield`、`sidebar_filetree_runtime`、`local_folder_file_projection`、`tree_projection_routes`、`local_folder_file_tree_children_snapshot_scans_parent_once`、`local_folder_page_tree_snapshot_reuses_cache_until_watch_revision_changes`。 +- 2026-05-27:`node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过,`targetChildrenRequests=1`,`staleScopeChildrenRequests=1`,`cachedExpandMs=33`。 ## 与 5-30 的关系 diff --git a/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js index 44ce5e91..539fb288 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js @@ -37,6 +37,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { }; var fileTreeState = fileTreeViewState; var fileTreeExpandedRelativePaths = fileTreeViewState.expandedParents; + var pageTreeState = { + pageTreeRequestGeneration: 0 + }; var fileTreeLazyCacheRootUri = ''; var fileTreeExpansionStorageRootUri = ''; var fileTreeExpansionRestoreTimer = 0; @@ -660,6 +663,29 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { fileTreeState.dirtyParents.add(key); } + function beginPageTreeRequest() { + pageTreeState.pageTreeRequestGeneration += 1; + return pageTreeState.pageTreeRequestGeneration; + } + + function isLatestPageTreeRequest(generation) { + return generation === pageTreeState.pageTreeRequestGeneration; + } + + function yieldForPageTreeRender() { + return new Promise(function(resolve) { + if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') { + window.requestIdleCallback(function() { resolve(); }, { timeout: 120 }); + return; + } + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(function() { resolve(); }); + return; + } + setTimeout(resolve, 0); + }); + } + function pageTreeChevronSvg() { return ''; } @@ -687,15 +713,26 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { }).join(''); } - function renderPageProjection(projection) { + async function renderPageProjection(projection) { + var generation = beginPageTreeRequest(); var tree = document.getElementById('sidebar-tree-root'); if (!tree) return false; var rows = projectionItems(projection).filter(function(item) { return String(item.rowKind || 'document') === 'document'; }); + await yieldForPageTreeRender(); + if (!isLatestPageTreeRequest(generation)) { + document.documentElement.setAttribute('data-mnote-page-tree-stale-projection-ignored', String(generation)); + return false; + } var activeId = currentDocumentId(); - var activeRowId = currentFileTreeActiveRowId(); - tree.innerHTML = ''; + var template = document.createElement('template'); + template.innerHTML = ''; + if (!isLatestPageTreeRequest(generation)) { + document.documentElement.setAttribute('data-mnote-page-tree-stale-projection-ignored', String(generation)); + return false; + } + tree.replaceChildren(template.content.cloneNode(true)); return true; } @@ -977,14 +1014,22 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { } function renderSidebarSnapshot(payload) { - var renderedPage = hasProjectionItems(payload) ? renderPageProjection(payload) : false; + var renderedPage = false; + if (hasProjectionItems(payload)) { + renderedPage = true; + void renderPageProjection(payload); + } var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection'); var renderedFile = fileProjection && hasProjectionItems(fileProjection) ? renderFileProjection(fileProjection) : false; return renderedPage || renderedFile; } function renderLiveSidebarSnapshot(payload) { - var renderedPage = hasProjectionItems(payload) ? renderPageProjection(payload) : false; + var renderedPage = false; + if (hasProjectionItems(payload)) { + renderedPage = true; + void renderPageProjection(payload); + } var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection'); var hasFileProjection = fileProjection && hasProjectionItems(fileProjection); if (currentFileTreeScope()) { @@ -1109,9 +1154,11 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { var sidebarPayload = sidebarResponse && sidebarResponse.ok ? await sidebarResponse.json().catch(function() { return null; }) : null; var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null; - var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload) - ? renderPageProjection(resolvedSidebarPayload) - : false; + var renderedPage = false; + if (resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)) { + renderedPage = true; + void renderPageProjection(resolvedSidebarPayload); + } if (renderedFile && fileTreeScope) { var fileRoot = document.getElementById('sidebar-file-tree-root'); if (fileRoot instanceof HTMLElement) fileRoot.setAttribute('data-mnote-filetree-scope', fileTreeScope); diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index f6172fac..c54198ea 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -735,26 +735,37 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim url.searchParams.set('sourceKind', 'local_folder'); url.searchParams.set('rootUri', rootUri); url.searchParams.set('parentRelativePath', relativePath); - var sidebarResponse = await fetch(sidebarUrl.toString(), { + var sidebarRequest = fetch(sidebarUrl.toString(), { method: 'GET', credentials: 'include', headers: { accept: 'application/json' }, cache: 'no-store' }); - var sidebarPayload = await sidebarResponse.json().catch(function() { return null; }); - if (!sidebarResponse.ok) throw new Error(sidebarPayload && (sidebarPayload.error || sidebarPayload.message) || 'local_page_tree_failed_' + sidebarResponse.status); - var response = await fetch(url.toString(), { + var fileRequest = fetch(url.toString(), { method: 'GET', credentials: 'include', headers: { accept: 'application/json' }, cache: 'no-store' }); + var response = await fileRequest; var payload = await response.json().catch(function() { return null; }); if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'scoped_filetree_failed_' + response.status); - var sidebarProjection = sidebarPayload && (sidebarPayload.result || sidebarPayload) || {}; var fileProjection = payload && (payload.result || payload); - var rendered = renderSidebarSnapshot(Object.assign({}, sidebarProjection, { - dataset: Object.assign({}, sidebarProjection.dataset || {}, { kernel_file_tree_projection: fileProjection }) + var renderedFile = renderSidebarSnapshot({ + dataset: { kernel_file_tree_projection: fileProjection } + }); + root = document.getElementById('sidebar-file-tree-root'); + if (root instanceof HTMLElement) { + root.setAttribute('data-workspace-id', workspaceId); + root.setAttribute('data-mnote-filetree-scope', relativePath); + root.setAttribute('data-mnote-filetree-scope-title', row.textContent ? row.textContent.trim() : relativePath); + } + var sidebarResponse = await sidebarRequest; + var sidebarPayload = await sidebarResponse.json().catch(function() { return null; }); + if (!sidebarResponse.ok) throw new Error(sidebarPayload && (sidebarPayload.error || sidebarPayload.message) || 'local_page_tree_failed_' + sidebarResponse.status); + var sidebarProjection = sidebarPayload && (sidebarPayload.result || sidebarPayload) || {}; + var renderedPage = renderSidebarSnapshot(Object.assign({}, sidebarProjection, { + dataset: Object.assign({}, sidebarProjection.dataset || {}) })); root = document.getElementById('sidebar-file-tree-root'); if (root instanceof HTMLElement) { @@ -762,7 +773,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim root.setAttribute('data-mnote-filetree-scope', relativePath); root.setAttribute('data-mnote-filetree-scope-title', row.textContent ? row.textContent.trim() : relativePath); } - return rendered; + return renderedFile || renderedPage; } const sidebarFileTreeOpen = createSidebarFileTreeOpenRuntime({ diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index a54e338c..970a2b51 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -14,6 +14,7 @@ use crate::routes::web_shell::{ render_document_title_controller_script, render_editor_island_adapter_script, render_editor_runtime_preload_links, render_local_file_tree_html, render_local_file_tree_html_scoped, render_local_sidebar_tree_html, + render_local_sidebar_tree_html_from_snapshot, }; use crate::transport::convex::execute_convex_mutation_by_name; use crate::workspace_shell::{ @@ -359,8 +360,10 @@ pub async fn root_entry( .first() .map(|item| item.id.as_str()), ); - let sidebar_tree_html = - render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())?; + let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot( + &page_tree_snapshot, + selected_active_page_id.as_deref(), + ); let restore_focus_row_id = query .restore_focus_row_id .as_deref() @@ -426,8 +429,10 @@ pub async fn root_entry( .first() .map(|item| item.id.as_str()), ); - let sidebar_tree_html = - render_local_sidebar_tree_html(&root_uri, selected_active_page_id.as_deref())?; + let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot( + &snapshot, + selected_active_page_id.as_deref(), + ); let file_tree_html = render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?; ( @@ -607,12 +612,12 @@ pub async fn root_entry( Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()), } }; - let editor_runtime_preload_links = - if body_extra.contains("document-editor-adapter-runtime.js") { - render_editor_runtime_preload_links() - } else { - "" - }; + let editor_runtime_preload_links = if body_extra.contains("document-editor-adapter-runtime.js") + { + render_editor_runtime_preload_links() + } else { + "" + }; let mut response = Html(format!( r#" @@ -2817,6 +2822,44 @@ mod tests { assert!(!html.contains(r#"href="/tree"#)); } + #[tokio::test] + async fn root_entry_local_folder_reuses_page_tree_snapshot_for_sidebar_html() { + let root = temp_root("mnote-root-local-folder-page-tree-snapshot-once"); + std::fs::create_dir_all(root.join("docs")).expect("create local docs"); + std::fs::write(root.join("README.md"), "# Local Root\n正文\n").expect("write root md"); + std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md"); + + let root_uri = format!("file://{}", root.display()); + crate::routes::local_folder_source::initialize_local_workspace_for_actor( + "user_real", + &root_uri, + ) + .expect("init local workspace"); + crate::routes::local_folder_source::reset_local_page_tree_snapshot_test_loads(); + let response = app_with_config("http://127.0.0.1:3100".into(), false) + .oneshot( + Request::builder() + .uri(format!( + "/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree" + )) + .header("x-mnote-actor-id", "user_real") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + crate::routes::local_folder_source::local_page_tree_snapshot_test_loads(), + 1, + "root entry should reuse the already loaded local page tree snapshot instead of scanning it twice", + ); + } + #[tokio::test] async fn root_entry_local_folder_without_active_page_keeps_resource_tab_host() { let root = temp_root("mnote-root-local-folder-no-active-page"); diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index b40f3dbe..ecc3d8f5 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -125,7 +125,9 @@ async fn project_projection( } }) .await - .map_err(|error| WebError::internal(format!("本地树 projection 构建任务失败: {error}")))??; + .map_err(|error| { + WebError::internal(format!("本地树 projection 构建任务失败: {error}")) + })??; return Ok(ok_response(&context, snapshot.projection)); } @@ -409,7 +411,13 @@ mod tests { .as_array() .expect("items") .iter() - .any(|item| item["title"] == "本地页面.md")); + .any(|item| item["title"] == "本地页面" + && item["rowKind"] == "folder" + && item["documentId"] + .as_str() + .unwrap_or("") + .starts_with("local-md:") + && item["resourceMeta"]["extra"]["source"]["relativePath"] == "本地页面")); let _ = std::fs::remove_dir_all(&root); } 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 aeaebfba..a60240a1 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -31,9 +31,53 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use time::OffsetDateTime; +#[cfg(test)] +static LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); +#[cfg(test)] +static LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +#[derive(Debug, Clone)] +struct LocalPageTreeSnapshotCacheEntry { + watch_revision: LocalFolderWatchRevision, + snapshot: ProjectionSnapshot, +} + +static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock< + Mutex>, +> = OnceLock::new(); + +fn local_page_tree_snapshot_cache( +) -> &'static Mutex> { + LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new())) +} + +#[cfg(test)] +pub(crate) fn reset_local_page_tree_snapshot_test_loads() { + LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.store(0, std::sync::atomic::Ordering::SeqCst); + LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS.store(0, std::sync::atomic::Ordering::SeqCst); + if let Some(cache) = LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get() { + if let Ok(mut cache) = cache.lock() { + cache.clear(); + } + } +} + +#[cfg(test)] +pub(crate) fn local_page_tree_snapshot_test_loads() -> u64 { + LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.load(std::sync::atomic::Ordering::SeqCst) +} + +#[cfg(test)] +fn local_page_tree_snapshot_scan_test_loads() -> u64 { + LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS.load(std::sync::atomic::Ordering::SeqCst) +} + #[derive(Debug, Clone)] struct LocalFolderEntry { path: PathBuf, @@ -2724,6 +2768,9 @@ fn load_local_folder_file_tree_scope_snapshot( pub fn load_local_folder_page_tree_snapshot( root_uri: &str, ) -> Result { + #[cfg(test)] + LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( @@ -2740,8 +2787,21 @@ pub fn load_local_folder_page_tree_snapshot( let workspace_id = local_workspace_id(&canonical_root); let root_source_uri = file_uri_for_path(&canonical_root); + let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?; + if let Ok(cache) = local_page_tree_snapshot_cache().lock() { + if let Some(entry) = cache.get(&root_source_uri) { + if entry.watch_revision.revision == watch_revision.revision + && entry.watch_revision.entry_count == watch_revision.entry_count + && entry.watch_revision.latest_modified_ms == watch_revision.latest_modified_ms + { + return Ok(entry.snapshot.clone()); + } + } + } let metadata = load_local_folder_metadata(&canonical_root)?; let mut rows = Vec::new(); + #[cfg(test)] + LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); scan_markdown_page_tree( &canonical_root, &canonical_root, @@ -2773,8 +2833,7 @@ pub fn load_local_folder_page_tree_snapshot( .iter() .map(local_folder_row_to_projection_item) .collect::>(); - let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?; - Ok(ProjectionSnapshot { + let snapshot = ProjectionSnapshot { dataset: json!({ "workspace": { "id": local_workspace_id(&canonical_root), @@ -2803,7 +2862,20 @@ pub fn load_local_folder_page_tree_snapshot( "watchRevision": watch_revision, "items": items, }), - }) + }; + if let Ok(mut cache) = local_page_tree_snapshot_cache().lock() { + if cache.len() > 64 { + cache.clear(); + } + cache.insert( + root_source_uri, + LocalPageTreeSnapshotCacheEntry { + watch_revision, + snapshot: snapshot.clone(), + }, + ); + } + Ok(snapshot) } pub fn local_folder_watch_revision(root_uri: &str) -> Result { @@ -9070,6 +9142,27 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn local_folder_page_tree_snapshot_reuses_cache_until_watch_revision_changes() { + let root = temp_root("mnote-page-tree-snapshot-cache"); + init_workspace(&root); + std::fs::write(root.join("page.md"), "# Page\n").expect("write md"); + let root_uri = format!("file://{}", root.display()); + super::reset_local_page_tree_snapshot_test_loads(); + + let first = load_local_folder_page_tree_snapshot(&root_uri).expect("first snapshot"); + let second = load_local_folder_page_tree_snapshot(&root_uri).expect("second snapshot"); + assert_eq!(first.projection, second.projection); + assert_eq!(super::local_page_tree_snapshot_scan_test_loads(), 1); + + std::fs::write(root.join("next.md"), "# Next\n").expect("write next"); + let third = load_local_folder_page_tree_snapshot(&root_uri).expect("third snapshot"); + assert_ne!(second.projection, third.projection); + assert_eq!(super::local_page_tree_snapshot_scan_test_loads(), 2); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn local_markdown_identity_uses_path_even_when_frontmatter_has_mnote_id() { let root = temp_root("mnote-local-frontmatter-path-id"); diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 2cc76a90..9d9a7d6f 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -1350,7 +1350,9 @@ pub(crate) async fn build_page_aggregate_snapshot( resolve_local_markdown_page_aggregate(&root_uri_for_build, &document_id) }) .await - .map_err(|error| WebError::internal(format!("本地 Page Aggregate 构建任务失败: {error}")))??; + .map_err(|error| { + WebError::internal(format!("本地 Page Aggregate 构建任务失败: {error}")) + })??; super::ui_preferences::apply_effective_page_preferences( state, context, @@ -1661,12 +1663,22 @@ pub(crate) fn render_local_sidebar_tree_html( active_document_id: Option<&str>, ) -> Result { let snapshot = load_local_folder_page_tree_snapshot(root_uri)?; + Ok(render_local_sidebar_tree_html_from_snapshot( + &snapshot, + active_document_id, + )) +} + +pub(crate) fn render_local_sidebar_tree_html_from_snapshot( + snapshot: &crate::routes::snapshot_support::ProjectionSnapshot, + active_document_id: Option<&str>, +) -> String { let rows = collect_page_tree_render_rows(&snapshot.projection); - Ok(render_initial_page_tree_html(&PageTreeInitialRenderInput { + render_initial_page_tree_html(&PageTreeInitialRenderInput { rows, active_node_id: active_document_id.map(ToOwned::to_owned), focused_node_id: None, - })) + }) } pub(crate) fn render_local_file_tree_html( @@ -2436,8 +2448,7 @@ mod tests { let aggregate_uri = format!( "/api/page-aggregate/local-md:Cold~20Start~2FCold~20Start.md?sourceKind=local_folder&rootUri={root_uri}" ); - super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS - .store(400, std::sync::atomic::Ordering::SeqCst); + super::LOCAL_PAGE_AGGREGATE_TEST_BLOCK_MS.store(400, std::sync::atomic::Ordering::SeqCst); struct ResetLocalAggregateBlock; impl Drop for ResetLocalAggregateBlock { fn drop(&mut self) { diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index a176d0ea..4c528808 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -554,8 +554,9 @@ mod tests { assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS .contains("var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var renderedPage = false;")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS - .contains("var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)")); + .contains("void renderPageProjection(resolvedSidebarPayload);")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("refreshFileTreeParent(fileTreeScope)")); assert!( SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-applied") @@ -1099,8 +1100,9 @@ mod tests { assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function patchFileTreeParentChildren")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS .contains("return patchFileTreeParentChildren(parentRelativePath, rows);")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var renderedPage = false;")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS - .contains("var renderedPage = resolvedSidebarPayload && hasProjectionItems(resolvedSidebarPayload)")); + .contains("void renderPageProjection(resolvedSidebarPayload);")); let render_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS .find("function renderFileProjection(projection)") .expect("renderFileProjection"); @@ -1150,6 +1152,17 @@ mod tests { assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeState.requestGeneration += 1")); } + #[test] + fn sidebar_runtime_applies_page_projection_with_generation_and_yield() { + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("pageTreeRequestGeneration: 0")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function beginPageTreeRequest")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function isLatestPageTreeRequest")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("async function renderPageProjection")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("await yieldForPageTreeRender()")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("if (!isLatestPageTreeRequest(generation))")); + } + #[test] fn sidebar_filetree_runtime_prefers_command_affected_parents() { assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS diff --git a/scripts/task492-sidebar-starred-shortcuts-smoke.js b/scripts/task492-sidebar-starred-shortcuts-smoke.js index 8bc5cbfc..6c0f16fc 100644 --- a/scripts/task492-sidebar-starred-shortcuts-smoke.js +++ b/scripts/task492-sidebar-starred-shortcuts-smoke.js @@ -105,6 +105,35 @@ async function main() { const context = await browser.newContext(); const page = await context.newPage(); const browserDiagnostics = []; + let holdStarredSidebarProjection = false; + let releaseStarredSidebarProjection = null; + let starredSidebarProjectionCompleted = false; + let scopedFileProjectionStartedAt = 0; + let scopedFileProjectionCompletedAt = 0; + await page.route("**/api/tree/projections/sidebar**", async (route) => { + const url = new URL(route.request().url()); + if (holdStarredSidebarProjection && url.searchParams.get("sourceKind") === "local_folder") { + await new Promise((resolve) => { + releaseStarredSidebarProjection = resolve; + }); + starredSidebarProjectionCompleted = true; + } + await route.continue(); + }); + await page.route("**/api/tree/projections/file**", async (route) => { + const url = new URL(route.request().url()); + if ( + holdStarredSidebarProjection + && url.pathname.endsWith("/api/tree/projections/file") + && url.searchParams.get("parentRelativePath") === "design" + ) { + scopedFileProjectionStartedAt = scopedFileProjectionStartedAt || Date.now(); + await route.continue(); + scopedFileProjectionCompletedAt = Date.now(); + return; + } + await route.continue(); + }); page.on("console", (message) => { browserDiagnostics.push(`console:${message.type()}:${message.text()}`); }); @@ -226,11 +255,31 @@ async function main() { ].join(""); }); + holdStarredSidebarProjection = true; + const shortcutClickAt = Date.now(); await cloudDesignShortcut.click({ timeout: UI_TIMEOUT_MS }); + await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"] .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/Brief.md"]').waitFor({ + state: "visible", + timeout: 1_500, + }).catch((error) => { + if (releaseStarredSidebarProjection) releaseStarredSidebarProjection(); + throw new Error(`星标文件夹 scoped FileTree 应先于页面树 projection 可交互: elapsed=${Date.now() - shortcutClickAt}ms fileStarted=${scopedFileProjectionStartedAt} fileDone=${scopedFileProjectionCompletedAt} sidebarDone=${starredSidebarProjectionCompleted}; ${error.message}`); + }); + assert.equal(starredSidebarProjectionCompleted, false, "scoped FileTree 可见时,延迟中的 page tree projection 不应已经完成"); + assert(scopedFileProjectionStartedAt > 0, "点击星标文件夹后应立即启动 scoped file projection"); + assert(scopedFileProjectionCompletedAt > 0, "点击星标文件夹后 scoped file projection 应先完成"); + if (releaseStarredSidebarProjection) releaseStarredSidebarProjection(); await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS, }); + holdStarredSidebarProjection = false; + await page.waitForFunction(() => { + const root = document.querySelector("#sidebar-tree-root"); + if (!(root instanceof HTMLElement)) return false; + const stale = root.querySelectorAll('.tree-row[data-shell-mode="page"][data-node-id="stale-my-space-page"]').length; + return stale === 0 && /Brief/.test(root.innerText || ""); + }, { timeout: UI_TIMEOUT_MS }); const scopedExplorerUrl = new URL(page.url()); assert.equal(scopedExplorerUrl.searchParams.get("sourceKind"), "local_folder", "点击星标文件夹后应切到本地文件夹 source"); assert.equal(scopedExplorerUrl.searchParams.get("rootUri"), rootUri, "点击星标文件夹后 URL 应保留显式 rootUri"); diff --git a/scripts/task497-local-page-tree-filetree-open-performance-smoke.js b/scripts/task497-local-page-tree-filetree-open-performance-smoke.js new file mode 100644 index 00000000..44502148 --- /dev/null +++ b/scripts/task497-local-page-tree-filetree-open-performance-smoke.js @@ -0,0 +1,401 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const http = require("node:http"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const { chromium } = require("playwright"); + +const TASK = "task497-local-page-tree-filetree-open-performance-smoke"; +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000); +const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); +const TARGET_RELATIVE_PATH = "design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md"; +const RESULT_DIR = path.join(__dirname, "..", "tmp", TASK); + +function resolveChromiumExecutablePath() { + const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ""; + if (explicit && fs.existsSync(explicit)) return explicit; + return [ + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/snap/bin/chromium", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ].find((candidate) => fs.existsSync(candidate)) || ""; +} + +function pickPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = address && typeof address === "object" ? address.port : 0; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +function waitForHttpOk(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const tick = () => { + const request = http.get(url, (response) => { + response.resume(); + if (response.statusCode >= 200 && response.statusCode < 500) { + resolve(); + return; + } + retry(); + }); + request.on("error", retry); + request.setTimeout(1000, () => { + request.destroy(); + retry(); + }); + }; + const retry = () => { + if (Date.now() > deadline) { + reject(new Error(`server_not_ready: ${url}`)); + return; + } + setTimeout(tick, 250); + }; + tick(); + }); +} + +function fileUrlToPath(value) { + const url = new URL(value); + return decodeURIComponent(url.pathname); +} + +async function signUp(context, baseUrl, actorId) { + const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { + email: `${actorId}@example.com`, + username: actorId, + name: actorId, + password: TEST_PASSWORD, + flow: "signUp", + }, + }, + }, + }); + assert(authResponse.ok(), `测试账号注册失败: ${authResponse.status()} ${await authResponse.text()}`); +} + +async function openDefaultLocalWorkspace(page, baseUrl) { + await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first(); + if (await createButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await createButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", { + timeout: UI_TIMEOUT_MS, + }); + } + const url = new URL(page.url()); + const rootUri = url.searchParams.get("rootUri") + || await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || ""); + assert(rootUri, "应进入 local_folder workspace"); + return { + rootUri, + workspaceId: url.searchParams.get("workspaceId") || "", + }; +} + +async function expandFolder(page, relativePath) { + const rowSelector = `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${relativePath}"]`; + await page.locator(rowSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const expanded = await page.locator(rowSelector).getAttribute("aria-expanded"); + if (expanded !== "true") { + await page.locator(`${rowSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction((selector) => { + const row = document.querySelector(selector); + return row && row.getAttribute("aria-expanded") === "true" && row.getAttribute("data-filetree-children-loaded") === "true"; + }, rowSelector, { timeout: UI_TIMEOUT_MS }); + } +} + +async function main() { + fs.mkdirSync(RESULT_DIR, { recursive: true }); + const port = await pickPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), `${TASK}-`)); + const dbPath = path.join(dataRoot, "control-plane.sqlite3"); + const actorId = `${TASK}-${process.pid}-${Date.now()}`; + const server = spawn("cargo", ["run", "-p", "mnote-web", "--bin", "mnote-web"], { + cwd: path.join(__dirname, "..", "rust"), + env: { + ...process.env, + MNOTE_WEB_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", + MNOTE_CONTROL_PLANE_DB_PATH: dbPath, + MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, + CONVEX_SELF_HOSTED_URL: "http://127.0.0.1:9", + NEXT_PUBLIC_CONVEX_URL: "http://127.0.0.1:9", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + server.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const executablePath = resolveChromiumExecutablePath(); + const browser = await chromium.launch({ + headless: true, + ...(executablePath ? { executablePath } : {}), + }); + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const page = await context.newPage(); + const browserDiagnostics = []; + const requestTimings = []; + const requestStarts = new Map(); + let holdStarredSidebarProjection = false; + let releaseStarredSidebarProjection = null; + let starredSidebarProjectionCompletedAt = 0; + let scopedFileProjectionCompletedAt = 0; + const timeline = {}; + + await page.addInitScript(() => { + window.__mnoteTask497Marks = {}; + window.__mnoteTask497LongTasks = []; + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + window.__mnoteTask497LongTasks.push({ + name: entry.name, + startTime: entry.startTime, + duration: entry.duration, + }); + } + }); + observer.observe({ type: "longtask", buffered: true }); + } catch (_) {} + }); + + await page.route("**/api/tree/projections/sidebar**", async (route) => { + const url = new URL(route.request().url()); + if (holdStarredSidebarProjection && url.searchParams.get("sourceKind") === "local_folder") { + await new Promise((resolve) => { + releaseStarredSidebarProjection = resolve; + }); + starredSidebarProjectionCompletedAt = Date.now(); + } + await route.continue(); + }); + await page.route("**/api/tree/projections/file**", async (route) => { + const url = new URL(route.request().url()); + if ( + holdStarredSidebarProjection + && url.pathname.endsWith("/api/tree/projections/file") + && url.searchParams.get("parentRelativePath") === "design" + ) { + await route.continue(); + scopedFileProjectionCompletedAt = Date.now(); + return; + } + await route.continue(); + }); + + page.on("console", (message) => { + browserDiagnostics.push(`console:${message.type()}:${message.text()}`); + }); + page.on("pageerror", (error) => { + browserDiagnostics.push(`pageerror:${error.message}`); + }); + page.on("request", (request) => { + const url = request.url(); + if ( + url.includes("/api/tree/projections/") + || url.includes("/api/page-aggregate/") + || url.includes("/api/leptos-tiptap-runtime/") + || url.includes("/documents/") + ) { + requestStarts.set(request, Date.now()); + } + }); + page.on("response", (response) => { + const request = response.request(); + const startedAt = requestStarts.get(request); + if (!startedAt) return; + const endedAt = Date.now(); + requestTimings.push({ + url: response.url(), + method: request.method(), + status: response.status(), + startedAt, + endedAt, + durationMs: endedAt - startedAt, + }); + if (response.url().includes("/api/page-aggregate/")) { + void page.evaluate(() => { + window.__mnoteTask497Marks.pageAggregateDone = performance.now(); + }).catch(() => {}); + } + try { + if (new URL(response.url()).pathname.startsWith("/documents/")) { + timeline.pageAggregateResponseAt = endedAt; + } + } catch (_) {} + }); + + try { + await waitForHttpOk(`${baseUrl}/health`, 60_000); + await signUp(context, baseUrl, actorId); + const { rootUri, workspaceId } = await openDefaultLocalWorkspace(page, baseUrl); + const rootPath = fileUrlToPath(rootUri); + fs.mkdirSync(path.join(rootPath, "design", "05-editor-mainline", "process"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, "design", "05-editor-mainline", "reference"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, "design", "04-tree-domain", "process"), { recursive: true }); + fs.writeFileSync(path.join(rootPath, "Home.md"), "# Home\n", "utf8"); + fs.writeFileSync(path.join(rootPath, TARGET_RELATIVE_PATH), "# 5-32 target\n\nTask497 target.\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "design", "05-editor-mainline", "reference", "note.md"), "# Reference\n", "utf8"); + fs.writeFileSync(path.join(rootPath, "design", "04-tree-domain", "process", "other.md"), "# Other\n", "utf8"); + + const fileTreeUrl = new URL(baseUrl); + if (workspaceId) fileTreeUrl.searchParams.set("workspaceId", workspaceId); + fileTreeUrl.searchParams.set("sourceKind", "local_folder"); + fileTreeUrl.searchParams.set("rootUri", rootUri); + fileTreeUrl.searchParams.set("treeView", "filetree"); + await page.goto(fileTreeUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-mnote-sidebar-tree-tab="filetree"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design"]').waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design"]').click({ + button: "right", + timeout: UI_TIMEOUT_MS, + }); + await page.getByText("加入/取消星标置顶").click({ timeout: UI_TIMEOUT_MS }); + await page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + + const cloudSpaceUrl = new URL(baseUrl); + cloudSpaceUrl.searchParams.set("workspaceId", "default"); + await page.goto(cloudSpaceUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const designShortcut = page.locator('.wolai-starred-section [data-mnote-shortcut-kind="folder"][data-mnote-shortcut-relative-path="design"]').first(); + await designShortcut.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.evaluate(() => { + const panel = document.querySelector("#wolai-sidebar-page-tree-panel, [data-mnote-sidebar-tree-panel='page']"); + let root = document.querySelector("#sidebar-tree-root"); + if (!(root instanceof HTMLElement) && panel instanceof HTMLElement) { + root = document.createElement("div"); + root.id = "sidebar-tree-root"; + root.className = "sidebar-tree"; + root.setAttribute("data-tree-shell-mode", "page"); + panel.appendChild(root); + } + if (root instanceof HTMLElement) { + root.innerHTML = '
'; + } + }); + + await page.screenshot({ path: path.join(RESULT_DIR, "before-starred-design.png"), fullPage: true }); + holdStarredSidebarProjection = true; + const starredClickStartedAt = Date.now(); + await designShortcut.click({ timeout: UI_TIMEOUT_MS }); + await page.locator('#sidebar-file-tree-root[data-mnote-filetree-scope="design"] .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/05-editor-mainline"]').waitFor({ + state: "visible", + timeout: 1_500, + }).catch((error) => { + if (releaseStarredSidebarProjection) releaseStarredSidebarProjection(); + throw new Error(`真实点击星标 design 后 scoped FileTree 应先于页面树 projection 可交互: elapsed=${Date.now() - starredClickStartedAt}ms scopedFileDone=${scopedFileProjectionCompletedAt} sidebarDone=${starredSidebarProjectionCompletedAt}; ${error.message}`); + }); + assert.equal(starredSidebarProjectionCompletedAt, 0, "scoped FileTree 可交互时,延迟中的页面树 projection 不应已经完成"); + if (releaseStarredSidebarProjection) releaseStarredSidebarProjection(); + holdStarredSidebarProjection = false; + await page.waitForFunction(() => { + const root = document.querySelector("#sidebar-tree-root"); + if (!(root instanceof HTMLElement)) return false; + const stale = root.querySelectorAll('.tree-row[data-shell-mode="page"][data-node-id="stale-my-space-page"]').length; + const localRows = root.querySelectorAll('.tree-row[data-shell-mode="page"]').length; + return stale === 0 && localRows > 0 && /design/.test(root.innerText || ""); + }, { timeout: UI_TIMEOUT_MS }); + const staleRowsAfterStarredOpen = await page.locator('#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="stale-my-space-page"]').count(); + assert.equal(staleRowsAfterStarredOpen, 0, "星标 design 打开后旧我的空间页面树不应长期残留"); + + await page.screenshot({ path: path.join(RESULT_DIR, "after-starred-design.png"), fullPage: true }); + timeline.filetreeInteractiveAt = Date.now(); + await expandFolder(page, "design/05-editor-mainline"); + await expandFolder(page, "design/05-editor-mainline/process"); + await page.screenshot({ path: path.join(RESULT_DIR, "before-target-open.png"), fullPage: true }); + timeline.targetClickAt = Date.now(); + await page.locator(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${TARGET_RELATIVE_PATH}"] .tree-link`).click({ + timeout: UI_TIMEOUT_MS, + }); + await page.waitForURL((nextUrl) => nextUrl.pathname.startsWith("/documents/"), { timeout: UI_TIMEOUT_MS }); + await page.locator('.document-pane[data-pane-role="primary"] .editor-surface').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + timeline.editorVisibleAt = Date.now(); + await page.screenshot({ path: path.join(RESULT_DIR, "after-target-open.png"), fullPage: true }); + + const browserMarks = await page.evaluate(() => window.__mnoteTask497Marks || {}); + const longTasks = await page.evaluate(() => window.__mnoteTask497LongTasks || []); + const currentUrl = page.url(); + const directDocumentStartedAt = Date.now(); + const directDocumentResponse = await context.request.fetch(currentUrl); + const directDocumentMs = Date.now() - directDocumentStartedAt; + + assert(timeline.filetreeInteractiveAt > 0, "应记录 scoped FileTree 可交互时间"); + assert(timeline.targetClickAt > 0, "应记录目标 md 点击时间"); + assert(timeline.editorVisibleAt > timeline.targetClickAt, `editor surface 应在目标点击后可见: ${JSON.stringify(timeline)}`); + assert( + timeline.editorVisibleAt - timeline.targetClickAt < 6_000, + `真实点击打开目标 md 不应被 page tree projection 长时间阻塞: ${JSON.stringify(timeline)}`, + ); + assert(directDocumentResponse.ok(), `direct document 对照请求失败: ${directDocumentResponse.status()}`); + + const result = { + ok: true, + task: TASK, + baseUrl, + targetRelativePath: TARGET_RELATIVE_PATH, + finalUrl: currentUrl, + timeline, + browserMarks, + longTasks, + requestTimings, + directDocumentMs, + screenshots: [ + path.join(RESULT_DIR, "before-starred-design.png"), + path.join(RESULT_DIR, "after-starred-design.png"), + path.join(RESULT_DIR, "before-target-open.png"), + path.join(RESULT_DIR, "after-target-open.png"), + ], + }; + fs.writeFileSync(path.join(RESULT_DIR, "result.json"), JSON.stringify(result, null, 2), "utf8"); + console.log(JSON.stringify(result, null, 2)); + } finally { + await browser.close(); + server.kill("SIGINT"); + fs.rmSync(dataRoot, { recursive: true, force: true }); + if (server.exitCode == null) { + setTimeout(() => server.kill("SIGKILL"), 2000).unref(); + } + if (server.exitCode && server.exitCode !== 130 && server.exitCode !== null) { + process.stderr.write(stderr); + } + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});