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 = '
' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId, 0) : '') + '
';
+ var template = document.createElement('template');
+ template.innerHTML = '' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId, 0) : '') + '
';
+ 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);
+});