From 4744757883edcaa7e8c1ade7db40f04f3e3d893b Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 26 May 2026 12:30:01 +0800 Subject: [PATCH] Optimize local folder file tree lazy loading --- .../sidebar-tree-live-apply-runtime.js | 141 ++++++++- rust/crates/mnote-web/src/routes/gateway.rs | 28 +- rust/crates/mnote-web/src/routes/kernel.rs | 76 ++++- .../src/routes/local_folder_source.rs | 286 +++++++++++++++++- rust/crates/mnote-web/src/routes/mod.rs | 4 + rust/crates/mnote-web/src/routes/tree.rs | 3 +- rust/crates/mnote-web/src/routes/web_shell.rs | 4 +- rust/crates/mnote-web/src/ssr/pages/layout.rs | 9 + .../src/tree_shell/filetree_renderer.rs | 51 +++- ...91-filetree-lazy-expanded-refresh-smoke.js | 167 ++++++++++ 10 files changed, 728 insertions(+), 41 deletions(-) create mode 100644 scripts/task491-filetree-lazy-expanded-refresh-smoke.js 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 265284ef..e3378868 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 @@ -18,6 +18,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { shortMindmapFileName, syncSidebarFileTreeSelection, } = dependencies; + var fileTreeLazyChildrenCache = new Map(); + var fileTreeExpandedRelativePaths = new Set(); + var fileTreeLazyCacheRootUri = ''; function updateTitleEverywhere(documentId, title) { if (!documentId) return; @@ -580,12 +583,13 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { var objectIdentity = fileObjectIdentity(item); var ownerDocumentId = fileOwnerDocumentId(item, documentId, objectIdentity); var iconKind = iconKindOf(item); + var cachedChildren = relativePath ? (fileTreeLazyChildrenCache.get(relativePath) || []) : []; var title = isFileTreeProjectionPageRow(rowKind, assetId) ? fileTreePageTitle(rawTitle) : normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity); var children = grouped.get(nodeId) || []; - var expandable = Boolean(item.expandable || item.childCount > 0 || children.length); - var expanded = expandable && item.expandedByDefault !== false; + var expandable = Boolean(item.expandable || item.childCount > 0 || children.length || cachedChildren.length); + var expanded = expandable && (fileTreeExpandedRelativePaths.has(relativePath) || item.expandedByDefault !== false); var selected = activeRowId ? rowId === activeRowId : rowId === 'doc:' + activeId; var testId = rowKind === 'document' ? 'filetree-doc-row' : rowKind === 'index' ? 'filetree-index-row' : 'filetree-asset-row'; var toggle = expandable @@ -594,16 +598,44 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { var createAction = rowKind === 'document' ? '' : ''; - var childHtml = expandable - ? '' + var childRowsHtml = children.length + ? renderFileRows(nodeId, grouped, activeId, activeRowId) + : cachedChildren.length + ? renderFileRows('', groupRowsByParent(cachedChildren), activeId, activeRowId) + : ''; + var childHtml = expandable && expanded && childRowsHtml + ? '' : ''; return '
  • ' + toggle + '
    ' + createAction + '
    ' + childHtml + '
  • '; }).join(''); } + function ensureFileTreeLazyCacheScope() { + var rootUri = currentRootUri(); + if (rootUri === fileTreeLazyCacheRootUri) return; + fileTreeLazyCacheRootUri = rootUri; + fileTreeLazyChildrenCache.clear(); + fileTreeExpandedRelativePaths.clear(); + } + + function rememberFileTreeExpansionState() { + document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]').forEach(function(row) { + if (!(row instanceof HTMLElement)) return; + var relativePath = localFileTreeRelativePathFromRow(row); + if (!relativePath) return; + if (row.getAttribute('aria-expanded') === 'true') { + fileTreeExpandedRelativePaths.add(relativePath); + } else { + fileTreeExpandedRelativePaths.delete(relativePath); + } + }); + } + function renderFileProjection(projection) { var tree = document.getElementById('sidebar-file-tree-root'); if (!tree) return false; + ensureFileTreeLazyCacheScope(); + rememberFileTreeExpansionState(); var rows = projectionItems(projection); var activeId = currentDocumentId(); var activeRowId = currentFileTreeActiveRowId(); @@ -763,18 +795,105 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return true; } + function localFileTreeRelativePathFromRow(row) { + if (!(row instanceof HTMLElement)) return ''; + var direct = String(row.getAttribute('data-local-relative-path') || '').trim(); + if (direct) return direct; + var runtimeFn = fileTreeRuntimeFunction('fileTreeRowLocalRelativePath'); + if (runtimeFn) { + try { + return String(runtimeFn(row, { localFilePathFromAssetId: localFilePathFromAssetId }) || '').trim(); + } catch (_) {} + } + var rowId = String(row.getAttribute('data-row-id') || '').trim(); + var nodeId = String(row.getAttribute('data-node-id') || '').trim(); + if (rowId.indexOf('local:folder:') === 0) return rowId.slice('local:folder:'.length); + if (rowId.indexOf('local:markdown:') === 0) return rowId.slice('local:markdown:'.length); + if (rowId.indexOf('local:asset:') === 0) return rowId.slice('local:asset:'.length); + if (nodeId.indexOf('local:node:') === 0) return nodeId.slice('local:node:'.length); + return ''; + } + + function setTreeRowExpanded(row, button, expanded) { + row.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + if (button) { + button.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + button.textContent = expanded ? '▾' : '▸'; + } + var relativePath = localFileTreeRelativePathFromRow(row); + if (!relativePath) return; + if (expanded) fileTreeExpandedRelativePaths.add(relativePath); + else fileTreeExpandedRelativePaths.delete(relativePath); + } + + function renderCachedFileTreeChildren(row, button, relativePath) { + var cachedRows = fileTreeLazyChildrenCache.get(relativePath) || []; + if (!cachedRows.length) return false; + var node = row.closest('.tree-node'); + if (!node) return false; + var children = node.querySelector(':scope > .tree-children'); + if (!children) { + children = document.createElement('ul'); + children.className = 'tree-children'; + node.appendChild(children); + } + children.innerHTML = renderFileRows('', groupRowsByParent(cachedRows), currentDocumentId(), currentFileTreeActiveRowId()); + children.classList.remove('tree-children--collapsed'); + row.setAttribute('data-filetree-children-loaded', 'true'); + setTreeRowExpanded(row, button, true); + syncSidebarFileTreeSelection(); + return true; + } + + async function loadFileTreeChildren(row, button) { + if (!(row instanceof HTMLElement)) return false; + if (row.getAttribute('data-shell-mode') !== 'filetree') return false; + if (currentSourceKind() !== 'local_folder') return false; + ensureFileTreeLazyCacheScope(); + var relativePath = localFileTreeRelativePathFromRow(row); + if (relativePath && renderCachedFileTreeChildren(row, button, relativePath)) return true; + if (row.getAttribute('data-filetree-children-loaded') === 'true') return false; + if (row.getAttribute('data-filetree-children-loading') === 'true') return true; + var rootUri = currentRootUri(); + if (!rootUri || !relativePath) return false; + row.setAttribute('data-filetree-children-loading', 'true'); + try { + var url = new URL('/api/tree/projections/file/children', window.location.origin); + var workspaceId = currentWorkspaceId(); + if (workspaceId) url.searchParams.set('workspaceId', workspaceId); + url.searchParams.set('sourceKind', 'local_folder'); + url.searchParams.set('rootUri', rootUri); + url.searchParams.set('parentRelativePath', relativePath); + var response = await fetch(url.toString(), { headers: { accept: 'application/json' } }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok) throw new Error(payload && (payload.error || payload.message) || 'filetree_children_failed_' + response.status); + var projection = readProjection(payload && (payload.result || payload)); + var rows = projectionItems(projection); + fileTreeLazyChildrenCache.set(relativePath, rows); + if (!rows.length) { + row.setAttribute('data-filetree-children-loaded', 'true'); + setTreeRowExpanded(row, button, true); + return true; + } + return renderCachedFileTreeChildren(row, button, relativePath); + } catch (error) { + setTreeLiveApplyError(error && error.message ? error.message : '文件树子目录加载失败'); + return false; + } finally { + row.removeAttribute('data-filetree-children-loading'); + } + } + function toggleChildren(row, button) { var li = row && row.parentElement; var children = li ? li.querySelector(':scope > .tree-children') : null; - if (!children) return; + if (!children) { + void loadFileTreeChildren(row, button); + return; + } children.classList.toggle('tree-children--collapsed'); var collapsed = children.classList.contains('tree-children--collapsed'); - row.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); - if (button && button.getAttribute('data-testid') === 'tree-node-toggle') { - button.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); - } else if (button) { - button.textContent = collapsed ? '▸' : '▾'; - } + setTreeRowExpanded(row, button, !collapsed); } diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index 509cc830..90587975 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -67,6 +67,7 @@ pub(crate) struct RootEntryQuery { workspace_id: Option, source_kind: Option, root_uri: Option, + tree_view: Option, restore_focus_row_id: Option, } @@ -294,6 +295,11 @@ pub async fn root_entry( .filter(|value| !value.is_empty()) .is_none() && requested_page_id.is_none(); + let requests_filetree_first = query + .tree_view + .as_deref() + .map(str::trim) + .is_some_and(|value| value == "filetree"); let ( workspace_id, workspace_projection, @@ -312,7 +318,14 @@ pub async fn root_entry( WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?; - let snapshot = load_local_folder_page_tree_snapshot(root_uri)?; + let snapshot = if requests_filetree_first { + crate::routes::local_folder_source::load_local_folder_file_tree_snapshot_with_reveal( + root_uri, + requested_page_id.as_deref(), + )? + } else { + load_local_folder_page_tree_snapshot(root_uri)? + }; let workspace_id = snapshot .dataset .get("workspace") @@ -339,8 +352,11 @@ 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 = if requests_filetree_first { + String::new() + } else { + render_local_sidebar_tree_html(root_uri, selected_active_page_id.as_deref())? + }; let restore_focus_row_id = query .restore_focus_row_id .as_deref() @@ -2755,8 +2771,9 @@ mod tests { let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains(r#"data-mnote-shell="workspace""#)); assert!(html.contains("local_folder")); - assert!(html.contains("Local Root")); + assert!(html.contains("README.md")); assert!(html.contains(r#"data-row-id="local:folder:docs""#)); + assert!(!html.contains(r#"data-row-id="local:markdown:docs/child.md""#)); assert!(html.contains(r#"data-row-id="local:asset:plain.txt""#)); assert!(!html.contains("legacy_next_compat_disabled")); assert!(!html.contains(r#"href="/tree"#)); @@ -2798,7 +2815,8 @@ mod tests { .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains(r#"data-mnote-shell="workspace""#)); - assert!(html.contains(r#"data-row-id="local:asset:attachments/report-a.pdf""#)); + assert!(html.contains(r#"data-row-id="local:folder:attachments""#)); + assert!(!html.contains(r#"data-row-id="local:asset:attachments/report-a.pdf""#)); assert!(html.contains(r#"data-testid="mnote-document-workspace""#)); assert!(html.contains(r#"data-mnote-resource-tab-host="true""#)); assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index 922289b9..cb046dbc 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -2,7 +2,8 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::local_folder_source::{ - ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_snapshot, + ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_children_snapshot, + load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_snapshot, }; use crate::routes::query_support::resolve_effective_workspace_id; @@ -28,6 +29,7 @@ pub struct KernelProjectionQuery { pub max_results: Option, pub source_kind: Option, pub root_uri: Option, + pub parent_relative_path: Option, } #[derive(Debug, Deserialize)] @@ -88,7 +90,27 @@ async fn project_projection( ensure_local_workspace_read_access_with_state(&state, &context, root_uri) .map_err(|error| error.with_context(&context))?; let snapshot = if projection == KernelProjectionKind::FileTree { - load_local_folder_file_tree_snapshot(root_uri)? + if let Some(parent_relative_path) = query + .parent_relative_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + load_local_folder_file_tree_children_snapshot(root_uri, parent_relative_path)? + } else if query + .root_node_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + { + load_local_folder_file_tree_snapshot_with_reveal( + root_uri, + query.root_node_id.as_deref(), + )? + } else { + load_local_folder_file_tree_snapshot(root_uri)? + } } else { load_local_folder_page_tree_snapshot(root_uri)? }; @@ -371,6 +393,56 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[tokio::test] + async fn local_folder_file_projection_can_load_direct_children_by_parent_path() { + let root = std::env::temp_dir().join(format!( + "mnote-local-kernel-children-projection-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("docs").join("nested")).expect("create nested dirs"); + std::fs::write(root.join("docs").join("README.md"), "# README\n").expect("write readme"); + std::fs::write(root.join("docs").join("nested").join("deep.md"), "# Deep\n") + .expect("write deep"); + let root_uri = format!("file://{}", root.display()); + initialize_local_workspace_for_actor("dev-user", &root_uri).expect("init local workspace"); + + let response = app() + .oneshot( + Request::builder() + .uri(format!( + "/api/tree/projections/file?workspaceId=local-ws:dev-user:my-space&sourceKind=local_folder&rootUri={root_uri}&parentRelativePath=docs" + )) + .header("x-mnote-actor-id", "dev-user") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + let items = payload["result"]["items"].as_array().expect("items"); + let titles = items + .iter() + .filter_map(|item| item["title"].as_str()) + .collect::>(); + + assert_eq!(payload["result"]["parentRelativePath"], "docs"); + assert!(titles.contains(&"README.md")); + assert!(titles.contains(&"nested")); + assert!(!titles.contains(&"deep.md")); + assert!(items + .iter() + .all(|item| item["parentNodeId"].as_str() == Some("local:node:docs"))); + + let _ = std::fs::remove_dir_all(&root); + } + #[tokio::test] async fn tree_projection_routes_keep_ok_response_shape() { let response = app() 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 123407e3..3302a0b6 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -2554,6 +2554,31 @@ fn local_workspace_manifest_json(manifest: &LocalWorkspaceManifest) -> Value { pub fn load_local_folder_file_tree_snapshot( root_uri: &str, +) -> Result { + load_local_folder_file_tree_scope_snapshot(root_uri, None, None) +} + +pub fn load_local_folder_file_tree_snapshot_with_reveal( + root_uri: &str, + reveal_document_id: Option<&str>, +) -> Result { + let reveal_relative_path = reveal_document_id + .and_then(local_markdown_relative_path_from_document_id) + .map(|path| path.replace('\\', "/")); + load_local_folder_file_tree_scope_snapshot(root_uri, None, reveal_relative_path.as_deref()) +} + +pub fn load_local_folder_file_tree_children_snapshot( + root_uri: &str, + parent_relative_path: &str, +) -> Result { + load_local_folder_file_tree_scope_snapshot(root_uri, Some(parent_relative_path), None) +} + +fn load_local_folder_file_tree_scope_snapshot( + root_uri: &str, + parent_relative_path: Option<&str>, + reveal_relative_path: Option<&str>, ) -> Result { let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { @@ -2572,23 +2597,56 @@ pub fn load_local_folder_file_tree_snapshot( let workspace_id = local_workspace_id(&canonical_root); let root_source_uri = file_uri_for_path(&canonical_root); let metadata = load_local_folder_metadata(&canonical_root)?; + let parent_relative_path = parent_relative_path + .map(str::trim) + .filter(|value| !value.is_empty() && *value != ".") + .unwrap_or(""); + let scan_root = if parent_relative_path.is_empty() { + canonical_root.clone() + } else { + resolve_metadata_relative_path(&canonical_root, parent_relative_path)? + }; + if !scan_root.is_dir() { + return Err(WebError::bad_request_code( + "local_folder_parent_not_directory", + "FileTree 懒加载 parentRelativePath 必须指向目录", + )); + } + let parent_node_id = if parent_relative_path.is_empty() { + None + } else { + Some(local_node_id(parent_relative_path)) + }; + let depth = local_folder_relative_depth(parent_relative_path); let mut rows = Vec::new(); scan_directory( &canonical_root, - &canonical_root, - None, - 0, + &scan_root, + parent_node_id, + depth, + Some(depth), &root_source_uri, &workspace_id, &metadata, &mut rows, )?; + if parent_relative_path.is_empty() { + append_file_tree_reveal_rows( + &canonical_root, + reveal_relative_path, + &root_source_uri, + &workspace_id, + &metadata, + &mut rows, + )?; + } let items = rows .iter() .map(local_folder_row_to_projection_item) .collect::>(); - let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?; + let watch_revision = + local_folder_watch_revision_for_directory(&canonical_root, &scan_root, &root_source_uri)?; let dataset = json!({ "workspace": { "id": &workspace_id, @@ -2607,6 +2665,8 @@ pub fn load_local_folder_file_tree_snapshot( "projection": "file_tree", "sourceKind": "local_folder", "rootUri": root_source_uri, + "parentRelativePath": parent_relative_path, + "lazy": true, "watchRevision": watch_revision, "items": items, }); @@ -6044,6 +6104,7 @@ fn scan_directory( directory: &Path, parent_node_id: Option, depth: u32, + max_depth: Option, root_source_uri: &str, workspace_id: &str, metadata: &LocalFolderMetadata, @@ -6052,7 +6113,6 @@ fn scan_directory( let mut entries = read_sorted_entries(directory, root)?; let parent_key = file_order_parent_key_for_directory(root, directory)?; sort_entries_with_file_order(&mut entries, metadata, &parent_key); - let entry_count = entries.len(); for (position, entry) in entries.into_iter().enumerate() { let node_id = local_node_id(if entry.relative_path.is_empty() { "." @@ -6060,7 +6120,11 @@ fn scan_directory( &entry.relative_path }); let child_count = if entry.is_dir && !entry.is_symlink { - count_visible_children(&entry.path, root)? + if has_visible_child(&entry.path, root)? { + 1 + } else { + 0 + } } else { 0 }; @@ -6088,7 +6152,7 @@ fn scan_directory( source_uri: file_uri_for_path(&entry.path), child_count, expandable: entry.is_dir && child_count > 0, - expanded_by_default: depth < 1 && entry.is_dir && entry_count <= 80, + expanded_by_default: false, document_id: if let Some(asset) = uploaded_asset { Some(asset.document_id.clone()) } else if is_markdown_file(&entry.file_name) { @@ -6100,12 +6164,13 @@ fn scan_directory( workspace_id: workspace_id.to_string(), root_source_uri: root_source_uri.to_string(), }); - if entry.is_dir && !entry.is_symlink { + if entry.is_dir && !entry.is_symlink && max_depth.map_or(true, |limit| depth < limit) { scan_directory( root, &entry.path, Some(node_id), depth + 1, + max_depth, root_source_uri, workspace_id, metadata, @@ -6117,6 +6182,91 @@ fn scan_directory( Ok(()) } +fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option { + let encoded = document_id.trim().strip_prefix("local-md:")?; + decode_local_id_segment(encoded).ok() +} + +fn ancestor_directories_for_relative_path(relative_path: &str) -> Vec { + let normalized = relative_path.trim().trim_matches('/'); + if normalized.is_empty() || normalized == "." { + return Vec::new(); + } + let mut ancestors = Vec::new(); + let mut current = Path::new(normalized).parent(); + while let Some(parent) = current { + let value = parent + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect::>() + .join("/"); + if value.is_empty() || value == "." { + break; + } + ancestors.push(value); + current = parent.parent(); + } + ancestors.reverse(); + ancestors +} + +fn append_file_tree_reveal_rows( + root: &Path, + reveal_relative_path: Option<&str>, + root_source_uri: &str, + workspace_id: &str, + metadata: &LocalFolderMetadata, + rows: &mut Vec, +) -> Result<(), WebError> { + let Some(reveal_relative_path) = reveal_relative_path + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(()); + }; + let ancestors = ancestor_directories_for_relative_path(reveal_relative_path); + if ancestors.is_empty() { + return Ok(()); + } + let ancestor_set = ancestors.iter().cloned().collect::>(); + let mut seen = rows + .iter() + .map(|row| row.row_id.clone()) + .collect::>(); + + for ancestor in &ancestors { + let directory = resolve_metadata_relative_path(root, ancestor)?; + if !directory.is_dir() { + continue; + } + let depth = local_folder_relative_depth(ancestor); + let mut scoped_rows = Vec::new(); + scan_directory( + root, + &directory, + Some(local_node_id(ancestor)), + depth, + Some(depth), + root_source_uri, + workspace_id, + metadata, + &mut scoped_rows, + )?; + for row in scoped_rows { + if seen.insert(row.row_id.clone()) { + rows.push(row); + } + } + } + + for row in rows.iter_mut() { + if ancestor_set.contains(&row.relative_path) { + row.expanded_by_default = true; + } + } + Ok(()) +} + fn read_sorted_entries(directory: &Path, root: &Path) -> Result, WebError> { let read_dir = fs::read_dir(directory).map_err(|error| { WebError::bad_request_code( @@ -6160,8 +6310,28 @@ fn read_sorted_entries(directory: &Path, root: &Path) -> Result Result { - Ok(read_sorted_entries(directory, root)?.len() as u32) +fn has_visible_child(directory: &Path, root: &Path) -> Result { + let read_dir = fs::read_dir(directory).map_err(|error| { + WebError::bad_request_code( + "local_folder_scan_failed", + format!("无法读取本地目录 {}: {error}", directory.display()), + ) + })?; + for entry in read_dir { + let entry = entry.map_err(|error| { + WebError::bad_request_code( + "local_folder_scan_failed", + format!("读取目录项失败: {error}"), + ) + })?; + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + let relative_path = normalize_relative_path(root, &path)?; + if !should_ignore_entry(&relative_path, &file_name) { + return Ok(true); + } + } + Ok(false) } fn local_entry_capabilities(entry: &LocalFolderEntry) -> Vec { @@ -6192,6 +6362,17 @@ fn normalize_relative_path(root: &Path, path: &Path) -> Result .join("/")) } +fn local_folder_relative_depth(relative_path: &str) -> u32 { + let trimmed = relative_path.trim().trim_matches('/'); + if trimmed.is_empty() || trimmed == "." { + return 0; + } + trimmed + .split('/') + .filter(|segment| !segment.trim().is_empty()) + .count() as u32 +} + fn resolve_metadata_relative_path(root: &Path, relative_path: &str) -> Result { let relative = Path::new(relative_path); if relative.is_absolute() @@ -6884,6 +7065,44 @@ fn local_folder_watch_revision_for_root( }) } +fn local_folder_watch_revision_for_directory( + root: &Path, + directory: &Path, + root_source_uri: &str, +) -> Result { + let mut hasher = DefaultHasher::new(); + let mut entry_count = 0usize; + let mut latest_modified_ms = 0u128; + + for entry in read_sorted_entries(directory, root)? { + entry.relative_path.hash(&mut hasher); + entry.file_name.hash(&mut hasher); + entry.is_dir.hash(&mut hasher); + entry.is_symlink.hash(&mut hasher); + entry.is_readonly.hash(&mut hasher); + if let Ok(meta) = fs::symlink_metadata(&entry.path) { + meta.len().hash(&mut hasher); + if let Ok(modified) = meta.modified() { + if let Ok(delta) = modified.duration_since(UNIX_EPOCH) { + let modified_ms = delta.as_millis(); + modified_ms.hash(&mut hasher); + if modified_ms > latest_modified_ms { + latest_modified_ms = modified_ms; + } + } + } + } + entry_count += 1; + } + + Ok(LocalFolderWatchRevision { + root_uri: root_source_uri.to_string(), + revision: format!("{:016x}", hasher.finish()), + entry_count, + latest_modified_ms, + }) +} + fn file_uri_for_path(path: &Path) -> String { format!("file://{}", path.display()) } @@ -8244,7 +8463,8 @@ mod tests { ensure_local_workspace_write_access_with_state, execute_local_tree_command, execute_local_tree_command_with_sort, get_local_access_policy, get_share_grants, get_share_links, get_user_access_policy, initialize_local_page_id, - initialize_local_workspace_for_actor, load_local_folder_file_tree_snapshot, + initialize_local_workspace_for_actor, load_local_folder_file_tree_children_snapshot, + load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_snapshot, local_folder_watch_revision, local_markdown_path_page_id, local_resource_write_editor_blocks, local_workspace_id, open_local_file, read_local_resource, record_shared_cache, record_sync_pending_change, @@ -10285,6 +10505,38 @@ fn main() {} let _ = std::fs::remove_dir_all(&root); } + #[test] + fn local_file_tree_snapshot_loads_root_level_only() { + let root = temp_root("mnote-local-file-tree-root-only"); + init_workspace(&root); + std::fs::create_dir_all(root.join("docs").join("nested")).expect("create nested dirs"); + std::fs::write(root.join("docs").join("README.md"), "# README\n").expect("write readme"); + std::fs::write(root.join("docs").join("nested").join("deep.md"), "# Deep\n") + .expect("write deep"); + std::fs::write(root.join("root.md"), "# Root\n").expect("write root"); + let root_uri = format!("file://{}", root.display()); + + let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("load file tree"); + let items = snapshot.projection["items"].as_array().expect("items"); + let titles = items + .iter() + .filter_map(|item| item["title"].as_str()) + .collect::>(); + + assert!(titles.contains(&"docs")); + assert!(titles.contains(&"root.md")); + assert!(!titles.contains(&"README.md")); + assert!(!titles.contains(&"deep.md")); + let docs = items + .iter() + .find(|item| item["title"].as_str() == Some("docs")) + .expect("docs row"); + assert_eq!(docs["parentNodeId"].as_str(), None); + assert_eq!(docs["expandable"].as_bool(), Some(true)); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn local_tree_command_refreshes_search_index_after_rename() { let root = temp_root("mnote-local-tree-refresh-search-index"); @@ -11333,7 +11585,8 @@ fn main() {} std::fs::write(root.join("Page").join("report.docx"), b"docx").expect("write docx"); let root_uri = format!("file://{}", root.display()); - let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("file tree"); + let snapshot = + load_local_folder_file_tree_children_snapshot(&root_uri, "Page").expect("file tree"); let items = snapshot.projection["items"].as_array().expect("items"); let mindmap = items .iter() @@ -11600,7 +11853,11 @@ fn main() {} .expect("write Child.md"); // 读取文件树。 - let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("load file tree"); + let snapshot = load_local_folder_file_tree_snapshot_with_reveal( + &root_uri, + Some("local-md:Root~2FChild~2FChild.md"), + ) + .expect("load file tree"); let items = snapshot.projection["items"].as_array().expect("items"); // 文件树应保留 Root 文件夹和其中的 Root.md 真实条目。 @@ -11703,7 +11960,8 @@ fn main() {} .expect("uploaded asset index"); assert!(uploaded_asset_index.contains("docs/README/notes.md")); - let snapshot = load_local_folder_file_tree_snapshot(&root_uri).expect("file tree"); + let snapshot = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/README") + .expect("file tree"); let items = snapshot.projection["items"].as_array().expect("items"); let notes_row = items .iter() diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 2b72371e..d6e46028 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -391,6 +391,10 @@ pub fn build_router(state: AppState) -> Router { ) .route("/api/tree/projections/page", get(kernel::project_tree_page)) .route("/api/tree/projections/file", get(kernel::project_tree_file)) + .route( + "/api/tree/projections/file/children", + get(kernel::project_tree_file), + ) .route( "/api/kernel/projections/sidebar", get(kernel::project_sidebar), diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index ce77cf71..3d32fcd5 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -2741,7 +2741,8 @@ mod tests { let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("local_folder")); assert!(html.contains("README.md")); - assert!(html.contains("child.md")); + assert!(html.contains("docs")); + assert!(!html.contains("child.md")); assert!(html.contains("image.png")); assert!(html.contains("data-row-kind=\"folder\"")); assert!(html.contains("data-row-kind=\"markdown\"")); diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 67794c2f..2712e75d 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -10,7 +10,7 @@ use crate::routes::documents::{ use crate::routes::gateway::default_workspace_name_for_context; use crate::routes::local_folder_source::{ ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context, - load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, + load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate, }; use crate::routes::query_support::execute_runtime_query_against_data; @@ -1594,7 +1594,7 @@ pub(crate) fn render_local_file_tree_html( active_document_id: Option<&str>, active_row_id: Option<&str>, ) -> Result { - let snapshot = load_local_folder_file_tree_snapshot(root_uri)?; + let snapshot = load_local_folder_file_tree_snapshot_with_reveal(root_uri, active_document_id)?; let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id); Ok(render_initial_filetree_html(&FileTreeInitialRenderInput { diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index dd7c32ba..06c54ae9 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -997,6 +997,15 @@ mod tests { assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("rowId === 'index:' + activeId")); } + #[test] + fn sidebar_filetree_runtime_supports_lazy_children_projection() { + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file/children")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("parentRelativePath")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-filetree-children-loaded")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeLazyChildrenCache")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("fileTreeExpandedRelativePaths")); + } + #[test] fn sidebar_tree_delete_to_trash_dispatches_archive_not_purge() { let delete_trash_start = SIDEBAR_FILETREE_COMMAND_RUNTIME_JS diff --git a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs index 5eb231f6..cef0b00d 100644 --- a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs @@ -108,13 +108,9 @@ fn render_filetree_row( create_action_html = create_action_html, title = escape_html(&row.title), )); - if row.expandable { + if row.expandable && row.expanded { if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) { - html.push_str(if row.expanded { - r#"
      "# - } else { - r#"
        "# - }); + html.push_str(r#"
          "#); for child in children { render_filetree_row(html, child, children_by_parent); } @@ -221,4 +217,47 @@ mod tests { assert!(html.contains("title=\"思维导图.json\"")); assert!(html.contains("data-selected=\"true\"")); } + + #[test] + fn tree_shell_filetree_renderer_does_not_ssr_collapsed_descendants() { + let html = render_initial_filetree_html(&FileTreeInitialRenderInput { + rows: vec![ + FileTreeRenderRow { + row_id: "local:folder:docs".into(), + row_kind: "folder".into(), + node_id: "local:node:docs".into(), + parent_node_id: None, + title: "docs".into(), + depth: 0, + expandable: true, + expanded: false, + icon_kind: "folder".into(), + document_id: None, + asset_id: None, + object_identity: None, + selected: false, + }, + FileTreeRenderRow { + row_id: "local:markdown:docs/README.md".into(), + row_kind: "markdown".into(), + node_id: "local:node:docs/README.md".into(), + parent_node_id: Some("local:node:docs".into()), + title: "README.md".into(), + depth: 1, + expandable: false, + expanded: false, + icon_kind: "file".into(), + document_id: Some("local-md:docs~2FREADME.md".into()), + asset_id: None, + object_identity: None, + selected: false, + }, + ], + }); + + assert!(html.contains("data-row-id=\"local:folder:docs\"")); + assert!(!html.contains("data-row-id=\"local:markdown:docs/README.md\"")); + assert!(!html.contains("README.md")); + assert!(!html.contains("tree-children--collapsed")); + } } diff --git a/scripts/task491-filetree-lazy-expanded-refresh-smoke.js b/scripts/task491-filetree-lazy-expanded-refresh-smoke.js new file mode 100644 index 00000000..04bdc6d2 --- /dev/null +++ b/scripts/task491-filetree-lazy-expanded-refresh-smoke.js @@ -0,0 +1,167 @@ +const fs = require("fs"); +const path = require("path"); +const { chromium } = require("playwright"); + +const runtimePath = path.join( + __dirname, + "..", + "rust", + "crates", + "mnote-web", + "browser", + "sidebar-tree-live-apply-runtime.js", +); + +async function main() { + const source = fs + .readFileSync(runtimePath, "utf8") + .replace( + "export const createSidebarTreeLiveApplyRuntime =", + "window.createSidebarTreeLiveApplyRuntime =", + ); + + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + await page.goto("http://127.0.0.1:3000/", { waitUntil: "domcontentloaded" }); + await page.setContent( + '', + ); + await page.addScriptTag({ content: source }); + + const result = await page.evaluate(async () => { + const escapeHtml = (value) => + String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + const rootProjection = { + dataset: { + kernel_file_tree_projection: { + items: [ + { + rowId: "local:folder:design", + rowKind: "folder", + nodeId: "local:node:design", + parentNodeId: null, + title: "design", + depth: 0, + childCount: 1, + expandable: true, + expandedByDefault: false, + resourceMeta: { + extra: { + source: { + relativePath: "design", + }, + }, + }, + }, + ], + }, + }, + }; + const childrenProjection = { + result: { + items: [ + { + rowId: "local:folder:design/03-rust-web", + rowKind: "folder", + nodeId: "local:node:design/03-rust-web", + parentNodeId: "local:node:design", + title: "03-rust-web", + depth: 1, + childCount: 0, + expandable: false, + expandedByDefault: false, + resourceMeta: { + extra: { + source: { + relativePath: "design/03-rust-web", + }, + }, + }, + }, + ], + }, + }; + + window.fetch = async () => ({ + ok: true, + json: async () => childrenProjection, + }); + + const runtime = window.createSidebarTreeLiveApplyRuntime({ + activeSidebarTreeMode: () => "filetree", + cssEscape: (value) => CSS.escape(String(value || "")), + currentDocumentId: () => "local-md:AGENTS.md", + currentFileTreeActiveRowId: () => "", + currentRootUri: () => "file:///tmp/mnote-smoke", + currentSourceKind: () => "local_folder", + currentWorkspaceId: () => "local:test", + escapeHtml, + fileTreeRuntimeFunction: () => null, + localFilePathFromAssetId: () => "", + navigateToDocument: () => {}, + refreshEditorLocalAttachmentExistence: () => {}, + restoreSidebarTreeTab: () => {}, + rowTitle: (row) => (row ? row.textContent || "" : ""), + schedulePendingLocalFolderRestoreFocus: () => {}, + shortMindmapFileName: () => "mindmap.json", + syncSidebarFileTreeSelection: () => {}, + }); + + const rendered = runtime.renderSidebarSnapshot(rootProjection); + const designRow = document.querySelector('[data-row-id="local:folder:design"]'); + if (!rendered || !designRow) { + return { + rendered, + loadedBeforeRefresh: false, + expandedBeforeRefresh: false, + loadedAfterRefresh: false, + expandedAfterRefresh: false, + html: document.getElementById("sidebar-file-tree-root").innerHTML, + }; + } + const toggle = designRow && designRow.querySelector('[data-rust-action="toggle"]'); + runtime.toggleChildren(designRow, toggle); + + await new Promise((resolve) => setTimeout(resolve, 100)); + const loadedBeforeRefresh = Boolean( + document.querySelector('[data-row-id="local:folder:design/03-rust-web"]'), + ); + const expandedBeforeRefresh = designRow.getAttribute("aria-expanded") === "true"; + + runtime.renderSidebarSnapshot(rootProjection); + const loadedAfterRefresh = Boolean( + document.querySelector('[data-row-id="local:folder:design/03-rust-web"]'), + ); + const refreshedDesignRow = document.querySelector('[data-row-id="local:folder:design"]'); + const expandedAfterRefresh = + refreshedDesignRow && refreshedDesignRow.getAttribute("aria-expanded") === "true"; + + return { + loadedBeforeRefresh, + expandedBeforeRefresh, + loadedAfterRefresh, + expandedAfterRefresh, + error: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "", + html: document.getElementById("sidebar-file-tree-root").innerHTML, + }; + }); + + await browser.close(); + + if (!result.loadedBeforeRefresh || !result.expandedBeforeRefresh) { + throw new Error(`懒加载前置步骤失败: ${JSON.stringify(result, null, 2)}`); + } + if (!result.loadedAfterRefresh || !result.expandedAfterRefresh) { + throw new Error(`FileTree root projection 刷新后丢失已展开子树: ${JSON.stringify(result, null, 2)}`); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});