Optimize local folder file tree lazy loading

This commit is contained in:
lix-2026
2026-05-26 12:30:01 +08:00
parent 8cd8b44db3
commit 4744757883
10 changed files with 728 additions and 41 deletions
@@ -2554,6 +2554,31 @@ fn local_workspace_manifest_json(manifest: &LocalWorkspaceManifest) -> Value {
pub fn load_local_folder_file_tree_snapshot(
root_uri: &str,
) -> Result<ProjectionSnapshot, WebError> {
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<ProjectionSnapshot, WebError> {
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<ProjectionSnapshot, WebError> {
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<ProjectionSnapshot, WebError> {
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::<Vec<_>>();
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<String>,
depth: u32,
max_depth: Option<u32>,
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<String> {
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<String> {
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::<Vec<_>>()
.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<LocalFolderRow>,
) -> 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::<BTreeSet<_>>();
let mut seen = rows
.iter()
.map(|row| row.row_id.clone())
.collect::<BTreeSet<_>>();
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<Vec<LocalFolderEntry>, 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<Vec<LocalFolderE
Ok(entries)
}
fn count_visible_children(directory: &Path, root: &Path) -> Result<u32, WebError> {
Ok(read_sorted_entries(directory, root)?.len() as u32)
fn has_visible_child(directory: &Path, root: &Path) -> Result<bool, WebError> {
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<String> {
@@ -6192,6 +6362,17 @@ fn normalize_relative_path(root: &Path, path: &Path) -> Result<String, WebError>
.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<PathBuf, WebError> {
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<LocalFolderWatchRevision, WebError> {
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::<Vec<_>>();
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()