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
- ? '
' + renderFileRows(nodeId, grouped, activeId, activeRowId) + '
'
+ 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);
+});