Optimize local folder file tree lazy loading
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user