Parallelize notebook and note scanning for faster loads on Android

This commit is contained in:
Yuri Karamian
2026-06-24 23:28:28 +02:00
parent 5c36f2aa71
commit 11cc2daa4b
+47 -59
View File
@@ -52,11 +52,10 @@ pub fn scan_notebooks(vault_path: &str) -> Result<Vec<NotebookEntry>, String> {
} }
fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> { fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
let mut entries = Vec::new();
let root = Path::new(vault_root); let root = Path::new(vault_root);
let Ok(read_dir) = fs::read_dir(dir) else { let Ok(read_dir) = fs::read_dir(dir) else {
return entries; return Vec::new();
}; };
// Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count) // Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count)
@@ -76,29 +75,29 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
.cmp(&b.file_name().to_string_lossy().to_lowercase()) .cmp(&b.file_name().to_string_lossy().to_lowercase())
}); });
// Store note_count for the parent directory (used when this is the top-level call) // Scan sibling notebooks in parallel: each subtree is independent, so
// Each directory pushes its own entry with its own note_count // overlapping the per-directory read_dir calls hides FUSE latency on Android.
for dir_entry in dirs { // par_iter().collect() preserves the (already-sorted) order.
let path = dir_entry.path(); let paths: Vec<PathBuf> = dirs.iter().map(|e| e.path()).collect();
let name = dir_entry.file_name().to_string_lossy().to_string(); paths
.par_iter()
.map(|path| {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let relative = path let relative = path
.strip_prefix(root) .strip_prefix(root)
.unwrap_or(&path) .unwrap_or(path)
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let (children, child_note_count) = scan_dir_with_count(path, vault_root);
let (children, child_note_count) = scan_dir_with_count(&path, vault_root); NotebookEntry {
entries.push(NotebookEntry {
name, name,
path: path.to_string_lossy().to_string(), path: path.to_string_lossy().to_string(),
relative_path: relative, relative_path: relative,
children, children,
note_count: child_note_count, note_count: child_note_count,
});
} }
})
entries .collect()
} }
/// Combined scan: returns (children, note_count) in a single read_dir pass. /// Combined scan: returns (children, note_count) in a single read_dir pass.
@@ -130,26 +129,26 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
.cmp(&b.file_name().to_string_lossy().to_lowercase()) .cmp(&b.file_name().to_string_lossy().to_lowercase())
}); });
let mut entries = Vec::new(); let paths: Vec<PathBuf> = subdirs.iter().map(|e| e.path()).collect();
for dir_entry in subdirs { let entries: Vec<NotebookEntry> = paths
let path = dir_entry.path(); .par_iter()
let name = dir_entry.file_name().to_string_lossy().to_string(); .map(|path| {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let relative = path let relative = path
.strip_prefix(root) .strip_prefix(root)
.unwrap_or(&path) .unwrap_or(path)
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let (children, child_note_count) = scan_dir_with_count(path, vault_root);
let (children, child_note_count) = scan_dir_with_count(&path, vault_root); NotebookEntry {
entries.push(NotebookEntry {
name, name,
path: path.to_string_lossy().to_string(), path: path.to_string_lossy().to_string(),
relative_path: relative, relative_path: relative,
children, children,
note_count: child_note_count, note_count: child_note_count,
});
} }
})
.collect();
(entries, note_count) (entries, note_count)
} }
@@ -197,47 +196,36 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
// On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS // On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS
#[cfg(mobile)] #[cfg(mobile)]
{ {
let mut notes = Vec::new(); // Collect candidate .md paths first, then read their metadata in parallel.
// Log what read_dir sees // Overlapping the per-file reads hides FUSE latency on Android (matches the
if let Ok(rd) = fs::read_dir(root) { // desktop path below). The previous version also did an extra read_dir purely
let items: Vec<_> = rd.filter_map(|e| e.ok()).collect(); // for debug logging, which doubled the directory I/O on the sandboxed FS.
log::info!("scan_notes: read_dir found {} entries in {:?}", items.len(), root); let paths: Vec<PathBuf> = if notebook_path.is_some() {
for (i, entry) in items.iter().enumerate().take(5) { match fs::read_dir(root) {
let ft = entry.file_type().map(|ft| if ft.is_file() { "file" } else if ft.is_dir() { "dir" } else { "other" }).unwrap_or("err"); Ok(rd) => rd
log::info!(" [{}] {:?} type={}", i, entry.file_name(), ft); .filter_map(|e| e.ok())
} .map(|e| e.path())
} else { .filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
log::info!("scan_notes: read_dir FAILED for {:?}", root); .collect(),
} Err(_) => Vec::new(),
if notebook_path.is_some() {
if let Ok(rd) = fs::read_dir(root) {
for entry in rd.filter_map(|e| e.ok()) {
let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false);
if is_file && entry.path().extension().and_then(|x| x.to_str()) == Some("md") {
if let Ok(note) = read_note_entry_metadata_only(&entry.path(), vault_root) {
notes.push(note);
}
}
}
} }
} else { } else {
let hn_dir = helixnotes_dir(vault_path); let hn_dir = helixnotes_dir(vault_path);
for entry in WalkDir::new(root) WalkDir::new(root)
.into_iter() .into_iter()
// filter_entry skips descending into hidden dirs (unlike filter) // filter_entry skips descending into hidden dirs (unlike filter)
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir)) .filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| { .map(|e| e.path().to_path_buf())
e.file_type().is_file() .filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
&& e.path().extension().and_then(|x| x.to_str()) == Some("md") .collect()
}) };
{
if let Ok(note) = read_note_entry_metadata_only(entry.path(), vault_root) { let mut notes: Vec<NoteEntry> = paths
notes.push(note); .par_iter()
} .filter_map(|path| read_note_entry_metadata_only(path, vault_root).ok())
} .collect();
}
log::info!("scan_notes: mobile scan found {} notes", notes.len()); log::info!("scan_notes: mobile scan found {} notes", notes.len());
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
return Ok(notes); return Ok(notes);