From a12966fb363fe0cbcaa16cd32a795239fcecd275 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:25:45 +0800 Subject: [PATCH] fix(plugins): list project files from the index instead of walking the disk project.find-files walked the whole tree on every call. MyBatis Navigator calls it from its editor providers, whose calls are interrupted after 500 ms; on a large project the walk alone took longer, each interruption counted as a crash, and after three the plugin was stopped. The host now answers from the project index (VCS-ignored and hidden files left out, as in the walk) and only walks the disk when no index covers the root yet. --- crates/ide-host/src/plugins/api.rs | 78 ++++++++++++++++++++++++++++++ crates/ide-host/tests/unity.rs | 2 + crates/ide-index/src/lib.rs | 22 +++++++++ 3 files changed, 102 insertions(+) diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 7c3d2b45..ad05517b 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -30,6 +30,11 @@ pub trait PluginWorkspace: Send + Sync { fn projects(&self) -> Vec; /// Unsaved text of the open document at `path`. fn buffer_text(&self, path: &Path) -> Option; + /// Up to `limit` files of the project index below `root` whose root-relative path satisfies + /// `matches`; `None` when no index covers `root` yet (the caller walks the disk instead). + fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option> { + None + } /// Version and text of the open document at `path`. fn document(&self, path: &Path) -> Option<(u64, String)>; /// Applies `edits` to the open document at `path` if it is still at @@ -459,6 +464,19 @@ impl HostApi for PluginApi { .build() .map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))? .compile_matcher(); + // Hidden files and folders are left out, as in the walk below. + let visible = |rel: &Path| !rel.iter().any(|c| c.to_str().is_some_and(|c| c.starts_with('.'))); + // The project index already lists the files: walking a large tree on every call can take + // longer than an editor provider's deadline (the plugin is then stopped as crashed). + if let Some(files) = self + .shared + .workspace() + .and_then(|w| w.indexed_files(root, &|rel| visible(rel) && matcher.is_match(rel), limit)) + { + let mut out: Vec = files.iter().map(|p| to_ipc(p)).collect(); + out.sort(); + return Ok(out); + } let mut out = Vec::new(); for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() { if n >= MAX_WALK || out.len() >= limit { @@ -738,6 +756,12 @@ impl PluginWorkspace for HostWorkspace { HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path)) } + fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option> { + // The index of the project rooted at `root` (a workspace member's files are in its own). + let project = HostWorkspace::projects(self).into_iter().find(|p| p.root == root)?; + project.index.files_matching(root, matches, limit) + } + fn document(&self, path: &Path) -> Option<(u64, String)> { let (project, doc) = self.open_document(path)?; let snap = project.documents.snapshot(doc).ok()?; @@ -821,6 +845,60 @@ mod tests { assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root)); } + /// A project index that lists `files` (root-relative), whatever is on disk. + struct Indexed(PathBuf, Vec<&'static str>); + impl PluginWorkspace for Indexed { + fn projects(&self) -> Vec { + vec![self.0.clone()] + } + fn buffer_text(&self, _: &Path) -> Option { + None + } + fn document(&self, _: &Path) -> Option<(u64, String)> { + None + } + fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result { + Err(HostError::Stale) + } + fn trusted(&self, _: &Path) -> bool { + true + } + fn project_of(&self, _: &str) -> Option { + None + } + fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec) -> Result<(), HostError> { + Ok(()) + } + fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option> { + (root == self.0).then(|| { + self.1.iter().map(Path::new).filter(|rel| matches(rel)).take(limit).map(|rel| root.join(rel)).collect() + }) + } + } + + #[test] + fn find_files_uses_the_project_index_when_there_is_one() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + let api = api(&root, Arc::default()); + *api.shared.workspace.write() = Some(Arc::new(Indexed( + root.clone(), + vec!["src/b/UserMapper.xml", "src/a/OrderMapper.xml", ".idea/workspace.xml", "pom.xml"], + ))); + // Listed by the index, not walked: none of these exist on disk. + assert_eq!( + api.find_files(&root, "**/*Mapper.xml", 10).unwrap(), + [to_ipc(&root.join("src/a/OrderMapper.xml")), to_ipc(&root.join("src/b/UserMapper.xml"))] + ); + assert_eq!(api.find_files(&root, "**/*.xml", 10).unwrap().len(), 3, "hidden folders are left out"); + assert_eq!(api.find_files(&root, "**/*.xml", 1).unwrap().len(), 1); + // Another root: no index covers it, so the disk is walked. + let other = root.join("other"); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write(other.join("a.xml"), "").unwrap(); + assert_eq!(api.find_files(&other, "*.xml", 10).unwrap(), [to_ipc(&other.join("a.xml"))]); + } + #[test] fn programs_run_only_as_approved() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/tests/unity.rs b/crates/ide-host/tests/unity.rs index f38fdbdd..148d0f40 100644 --- a/crates/ide-host/tests/unity.rs +++ b/crates/ide-host/tests/unity.rs @@ -35,6 +35,8 @@ async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_in assert!(unity.projects.is_empty() && unity.solutions.is_empty()); assert!(project.index.wait_idle(Duration::from_secs(20))); assert_eq!(project.index.find_files("Player.cs", 100).len(), 1); + let listed = project.index.files_matching(&project.root, |rel| rel.ends_with("Player.cs"), 10).unwrap(); + assert_eq!(listed.len(), 1); for name in ["Cached.cs", "Temporary.cs", "Editor.log"] { assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}"); } diff --git a/crates/ide-index/src/lib.rs b/crates/ide-index/src/lib.rs index 711373fe..be02babc 100644 --- a/crates/ide-index/src/lib.rs +++ b/crates/ide-index/src/lib.rs @@ -197,6 +197,28 @@ impl ProjectIndex { matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit) } + /// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to + /// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is + /// indexed yet (first scan without a cache). + pub fn files_matching(&self, dir: &Path, matches: impl Fn(&Path) -> bool, limit: usize) -> Option> { + let d = self.shared.data.read(); + if d.file_count == 0 { + return None; + } + let mut out = Vec::new(); + for file in d.files.iter().flatten().filter(|f| !f.ignored) { + if out.len() >= limit { + break; + } + if let Ok(rel) = file.path.strip_prefix(dir) + && matches(rel) + { + out.push(file.path.clone()); + } + } + Some(out) + } + /// Fuzzy search for folders that hold indexed files; `rel_path` ends in `/`. pub fn find_dirs(&self, query: &str, limit: usize) -> Vec { matching::find_dirs(&self.shared.data.read(), query, limit)