From a6d17543fd56f334003dd0cb680f06dc8c1f3778 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:46:29 +0800 Subject: [PATCH] feat(usages): search Java usages in Project Files first, Project and Libraries on demand (#1837) --- crates/ide-host/src/ops/lsp.rs | 25 ++- crates/ide-host/tests/language_smoke.rs | 13 +- crates/ide-lsp/src/lib.rs | 111 ++++++++-- crates/ide-lsp/src/types.rs | 8 + crates/ide-lsp/tests/mock_server.rs | 94 ++++++++- crates/ide-sdk/src/java.rs | 2 +- java/java-usages/MANIFEST.MF | 2 +- java/java-usages/README.md | 26 ++- java/java-usages/openidea-java-usages.jar | Bin 122814 -> 133533 bytes .../src/ai/openidea/usages/UsagesCommand.java | 190 ++++++++++++++++-- java/java-usages/tests/verify.py | 13 ++ src-tauri/src/commands/lsp.rs | 11 +- src/bindings.ts | 11 +- src/editor/lsp/codeLens.test.ts | 16 +- src/editor/lsp/codeLens.ts | 11 +- src/editor/refactor/safeDelete.test.ts | 6 + src/editor/refactor/safeDelete.ts | 13 +- .../code-insight/UsagesPopup.module.css | 10 + src/features/code-insight/UsagesPopup.tsx | 24 ++- .../code-insight/UsagesView.module.css | 13 ++ src/features/code-insight/UsagesView.tsx | 22 +- .../code-insight/syntaxUsages.test.ts | 10 +- .../code-insight/usageResults.test.ts | 79 +++++++- src/features/code-insight/usages.ts | 184 ++++++++++++++--- .../code-insight/usagesPopup.test.tsx | 155 +++++++++++++- src/platform/locales/text/zh.json | 10 + 26 files changed, 963 insertions(+), 96 deletions(-) diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 1f802df8..583e9245 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -307,7 +307,11 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path:: /// References of the symbol at `pos`. `project_wide` also searches project files /// outside the language server's analysis scope; bulk scans over many symbols -/// leave it off. Cancel with `cancel_request(request)`. +/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK +/// are not searched; with it, those found in the sources arrive on `on_partial` before +/// the slower search for the rest, and the result has them all. Cancel with +/// `cancel_request(request)`. +#[allow(clippy::too_many_arguments)] pub async fn lsp_references( host: &Host, window: &str, @@ -316,10 +320,24 @@ pub async fn lsp_references( pos: Pos, include_declaration: bool, project_wide: bool, + libraries: bool, + on_partial: super::Channel, ) -> IdeResult { let lsp = host.project(window)?.lsp.clone(); let cancel = host.begin_request(request); - let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await; + let result = lsp + .references_streaming( + doc, + pos, + include_declaration, + project_wide, + libraries, + cancel, + Some(&|refs| { + let _ = on_partial.send(refs); + }), + ) + .await; host.end_request(request); result } @@ -789,6 +807,7 @@ pub(crate) fn register(d: &mut Dispatcher) { ); d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await }); d.add("lsp_references", |c| async move { + let on_partial = c.sender("onPartial")?; lsp_references( &c.host, &c.window, @@ -797,6 +816,8 @@ pub(crate) fn register(d: &mut Dispatcher) { c.arg("pos")?, c.arg("includeDeclaration")?, c.arg("projectWide")?, + c.arg("libraries")?, + on_partial, ) .await }); diff --git a/crates/ide-host/tests/language_smoke.rs b/crates/ide-host/tests/language_smoke.rs index 8e32b39e..ec970139 100644 --- a/crates/ide-host/tests/language_smoke.rs +++ b/crates/ide-host/tests/language_smoke.rs @@ -303,7 +303,18 @@ impl Smoke { let (doc, pos) = self.locate(of).await; let want: BTreeSet = files.iter().map(|f| f.to_string()).collect(); self.retry("find usages", self.warm_up, || async { - let found = ops::lsp::lsp_references(&self.host, WINDOW, RequestId(1), doc, pos, true, true).await; + let found = ops::lsp::lsp_references( + &self.host, + WINDOW, + RequestId(1), + doc, + pos, + true, + true, + false, + ops::Channel::new(|_| {}), + ) + .await; match &found { Ok(r) if want.is_subset(&self.files(r.locations.iter().map(|l| l.path.clone()))) => Ok(()), _ => Err(found.map(|r| self.files(r.locations.into_iter().map(|l| l.path)))), diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index a8daa057..4445ec46 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -328,6 +328,9 @@ struct SourceImplementations { /// Receives the implementations found so far, before a slower search for the rest. type Partial<'a> = Option<&'a (dyn Fn(Vec) + Send + Sync)>; +/// Receives the references found so far, before a slower search for the rest. +pub type PartialReferences<'a> = Option<&'a (dyn Fn(References) + Send + Sync)>; + /// `first`, then those of `more` not in it (same file and start). fn merge_locations(first: Vec, more: Vec) -> Vec { let mut seen: HashSet<(PathBuf, Pos)> = first.iter().map(|l| (l.path.to_path_buf(), l.range.start)).collect(); @@ -1405,21 +1408,44 @@ impl LspManager { include_declaration: bool, project_wide: bool, cancel: CancellationToken, + ) -> IdeResult { + self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await + } + + /// [`Self::references`]. Java, `project_wide`: without `libraries`, only the + /// workspace's sources are searched (`libraries_skipped`); with it, those found + /// there arrive on `partial` before the dependencies and the JDK are searched. + #[allow(clippy::too_many_arguments)] + pub async fn references_streaming( + &self, + doc: DocId, + pos: Pos, + include_declaration: bool, + project_wide: bool, + libraries: bool, + cancel: CancellationToken, + partial: PartialReferences<'_>, ) -> IdeResult { let snap = self.snapshot(doc)?; - // Scoped probes back code lenses and bulk analysis. Keep their native cost; - // configuration indexing is reserved for explicit, project-wide usages. - let configuration = if project_wide || snap.language.as_str() != "java" { - self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await? - } else { - None - }; if snap.language.as_str() != "java" - && let Some(result) = configuration + && let Some(result) = self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await? { return Ok(result); } - let mut result = self.native_references(doc, pos, include_declaration, project_wide, cancel).await?; + // Scoped probes back code lenses and bulk analysis. Keep their native cost; + // configuration indexing is reserved for explicit, project-wide usages. + let configuration = async { + if project_wide && snap.language.as_str() == "java" { + self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await + } else { + Ok(None) + } + }; + let native = + self.native_references(doc, pos, include_declaration, project_wide, libraries, cancel.clone(), partial); + let (configuration, result) = futures::join!(configuration, native); + let configuration = configuration?; + let mut result = result?; if let Some(configuration) = configuration { result.locations.extend(configuration.locations); result.incomplete = result.incomplete.or(configuration.incomplete); @@ -1428,13 +1454,16 @@ impl LspManager { Ok(result) } + #[allow(clippy::too_many_arguments)] async fn native_references( &self, doc: DocId, pos: Pos, include_declaration: bool, project_wide: bool, + libraries: bool, cancel: CancellationToken, + partial: PartialReferences<'_>, ) -> IdeResult { let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?; // The extension preserves method identity before JDT collapses references to locations. @@ -1446,28 +1475,37 @@ impl LspManager { let classified = async { let t = &targets[0]; let extension_error = Mutex::new(None); - let query = || async { + let (snap, cancel, extension_error) = (&snap, &cancel, &extension_error); + // `sources`: the workspace's source folders only, without dependency jars and the JDK. + let query = |sources: bool| async move { if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { return Err(IdeError::cancelled()); } let params = snap.pos_params(t, pos); + let mut arguments = json!([ + params["textDocument"]["uri"], + params["position"]["line"], + params["position"]["character"], + include_declaration + ]); + if sources { + arguments.as_array_mut().expect("arguments").push(json!("sources")); + } #[derive(serde::Deserialize)] struct JavaReference { #[serde(default)] access: Option, location: lsp::Location, kind: ReferenceKind, + #[serde(default)] + text: Option, } let result: IdeResult> = self .call( t, "workspace/executeCommand", - json!({ - "command": "openidea.java.usages", - "arguments": [params["textDocument"]["uri"], params["position"]["line"], - params["position"]["character"], include_declaration] - }), - Some(&cancel), + json!({ "command": "openidea.java.usages", "arguments": arguments }), + Some(cancel), LONG_TIMEOUT, ) .await; @@ -1486,6 +1524,7 @@ impl LspManager { location, kind: r.kind, access: r.access, + text: r.text, }) }) .collect::>()) @@ -1493,10 +1532,42 @@ impl LspManager { let files = |v: &Vec| { v.iter().filter(|r| r.location.uri.is_none()).map(|r| r.location.path.to_path_buf()).collect() }; + let mut libraries_skipped = false; let widened = if project_wide { - self.widened(&snap, t, pos, &cancel, query, files).await? + // A library method has thousands of usages in the dependencies and the JDK, + // which take seconds to search: those in the sources (where scope widening + // searches again) are shown first, then the rest are searched once. + let sources = self.widened(snap, t, pos, cancel, || query(true), files).await?; + if let Some(e) = extension_error.lock().take() { + return Err(e); + } + let mut seen = std::collections::HashSet::new(); + let found: Vec<_> = sources + .second + .into_iter() + .flatten() + .chain(sources.first) + .filter(|r| seen.insert(r.location.clone())) + .collect(); + let all = if !libraries { + libraries_skipped = true; + Vec::new() + } else { + if let Some(partial) = partial + && !found.is_empty() + { + partial(References { + locations: found.iter().map(|r| r.location.clone()).collect(), + classified: found.clone(), + incomplete: sources.incomplete, + libraries_skipped: false, + }); + } + query(false).await? + }; + scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete } } else { - let first = query().await?; + let first = query(false).await?; let empty = first.is_empty(); scope::Widened::only(first, empty, &t.server) }; @@ -1505,7 +1576,7 @@ impl LspManager { return Err(IdeError::cancelled()); } // Scope widening can retain the first answer after a failed second query. - if let Some(e) = extension_error.into_inner() { + if let Some(e) = extension_error.lock().take() { return Err(e); } let mut seen = std::collections::HashSet::new(); @@ -1519,7 +1590,7 @@ impl LspManager { .collect(); let locations: Vec = classified.iter().map(|r| r.location.clone()).collect(); self.note_virtual(&t.server, locations.iter().map(|l| l.uri.as_deref())); - Ok(References { locations, classified, incomplete: widened.incomplete }) + Ok(References { locations, classified, incomplete: widened.incomplete, libraries_skipped }) } .await; match classified { diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index d2bdd5ff..2b6013c8 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -380,6 +380,10 @@ pub struct References { pub classified: Vec, pub locations: Vec, pub incomplete: Option, + /// The dependencies and the JDK were not searched (asked for without `libraries`). + #[serde(default)] + #[specta(optional)] + pub libraries_skipped: bool, } #[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] @@ -503,4 +507,8 @@ pub struct ClassifiedReference { pub access: Option, pub location: Location, pub kind: ReferenceKind, + /// The usage's line, when the server has it at hand (a library class's). + #[serde(default)] + #[specta(optional)] + pub text: Option, } diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 9cfee9b1..2e475837 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -2882,7 +2882,8 @@ async fn classified_java_references_widen_without_losing_metadata() { assert!(refs.classified.iter().all(|r| matches!(r.access, Some(ReferenceAccess::ReadWrite)))); assert!(refs.classified.iter().all(|r| matches!(r.kind, ReferenceKind::Field))); assert_eq!(refs.incomplete, None); - assert_eq!(s.requested("workspace/executeCommand").len(), 3); + // Scoped; then the sources, widened; then the dependencies too. + assert_eq!(s.requested("workspace/executeCommand").len(), 4); assert!(s.requested("textDocument/references").is_empty()); eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await; for limit_hit in [true, false] { @@ -2900,6 +2901,97 @@ async fn classified_java_references_widen_without_losing_metadata() { } } +/// A library method's usages in the sources are shown before the dependencies and the JDK +/// are searched, which takes seconds; scope widening searches the sources alone again. +#[tokio::test(flavor = "multi_thread")] +async fn java_usages_in_the_sources_arrive_before_the_dependencies() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + let library = json!({"uri": "jdt://contents/lib.jar/lib/Lib.class", "range": { + "start": {"line": 3, "character": 4}, "end": {"line": 3, "character": 10}}}); + *s.responder.lock() = Some(Arc::new(move |s, method, params| { + (method == "workspace/executeCommand").then(|| { + let sources = params["arguments"].get(4) == Some(&json!("sources")); + let mut rows: Vec = s + .docs + .lock() + .iter() + .flat_map(|(uri, text)| occurrences(uri, text)) + .map(|location| json!({"location": location, "kind": "unknown"})) + .chain((!sources).then(|| json!({"location": library, "kind": "unknown", "text": " target();"}))) + .collect(); + rows.reverse(); + Value::Array(rows) + }) + })); + let b = h.root.join("b.java"); + std::fs::write(&b, "target();").unwrap(); + grep_candidates(&h); + let (doc, a) = h.open("a.java", "int target;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 1).await; + let partials = Mutex::new(Vec::new()); + let refs = h + .mgr + .references_streaming( + doc, + Pos::new(0, 5), + true, + true, + true, + Default::default(), + Some(&|r| partials.lock().push(r)), + ) + .await + .unwrap(); + let partials = partials.into_inner(); + assert_eq!(partials.len(), 1); + let paths = + |r: &References| r.locations.iter().map(|l| (l.path.to_path_buf(), l.uri.is_some())).collect::>(); + let mut shown = paths(&partials[0]); + shown.sort(); + assert_eq!(shown, [(a.clone(), false), (b.clone(), false)]); + assert_eq!(partials[0].classified.len(), 2); + // All of them, those in the sources first. + assert_eq!(refs.locations.len(), 3); + assert!(refs.locations[..2].iter().all(|l| l.uri.is_none())); + assert!(refs.locations[2].uri.is_some()); + // The server gives a library class's line along. + let line = |r: &References| r.classified.iter().find(|c| c.location.uri.is_some()).and_then(|c| c.text.clone()); + assert_eq!(line(&refs).as_deref(), Some(" target();")); + assert!(!refs.libraries_skipped); + let scopes: Vec<_> = + s.requested("workspace/executeCommand").iter().map(|p| p["arguments"].get(4).cloned()).collect(); + assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources")), None]); + // Without the libraries: the sources alone, widened, and no partial answer. + let partials = Mutex::new(Vec::new()); + let project = h + .mgr + .references_streaming( + doc, + Pos::new(0, 5), + true, + true, + false, + Default::default(), + Some(&|r| partials.lock().push(r)), + ) + .await + .unwrap(); + assert!(partials.into_inner().is_empty()); + assert!(project.libraries_skipped); + assert_eq!(project.locations.len(), 2); + assert!(project.locations.iter().all(|l| l.uri.is_none())); + let scopes: Vec<_> = + s.requested("workspace/executeCommand")[3..].iter().map(|p| p["arguments"].get(4).cloned()).collect(); + assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources"))]); + // Scoped probes (code lenses) search everything at once. + let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap(); + assert_eq!(scoped.locations.len(), 2); +} + #[tokio::test(flavor = "multi_thread")] async fn failed_java_command_falls_back_to_unclassified_native_references() { for project_wide in [false, true] { diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 40f4ccf2..93b15c7f 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -1196,7 +1196,7 @@ mod kotlin_bridge_bundle_tests { /// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure. pub fn usages_bundle(data_dir: &Path) -> Option { - let extension = data_dir.join("java-usages-1.6.0.jar"); + let extension = data_dir.join("java-usages-1.7.0.jar"); let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar"); if std::fs::read(&extension).is_ok_and(|old| old == bytes) { clean_usages_bundles(data_dir, &extension); diff --git a/java/java-usages/MANIFEST.MF b/java/java-usages/MANIFEST.MF index 78425a4a..cdcb4bef 100644 --- a/java/java-usages/MANIFEST.MF +++ b/java/java-usages/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: OpenIDEA Java usages Bundle-SymbolicName: ai.openidea.usages;singleton:=true -Bundle-Version: 1.6.0 +Bundle-Version: 1.7.0 Bundle-RequiredExecutionEnvironment: JavaSE-21 Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core, org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime, diff --git a/java/java-usages/README.md b/java/java-usages/README.md index cb596fa3..0fde8268 100644 --- a/java/java-usages/README.md +++ b/java/java-usages/README.md @@ -12,6 +12,14 @@ The Rust bridge logs the failure once per project session. Cancelled requests an changed source documents remain cancelled and do not start a fallback search. No global `includeAccessors` preference is changed. +An optional fifth argument `"sources"` restricts the search to the workspace's +source folders (no dependency jars, no JDK). A library method can have thousands of +usages in those, which take seconds to search. Find Usages searches the sources alone +(Project Files) unless asked for Project and Libraries; then it shows the sources' usages +first and runs the full search after it. Rows in library classes (`jdt:` URIs) carry their +line as `text`, from the contents the client would request for the class, so the client +need not fetch thousands of class files to show them. + ## Search plan and access semantics For source fields on the verified JDT LS implementation, a single plan merges @@ -20,10 +28,14 @@ Targets are deduplicated by JDT element identity before search, retaining native search scope and ranges. Builder results remain unknown. The optimized native plan is guarded by SHA-256 of ReferencesHandler.class: `31ff5a10c7d69d58c6a9b619ef17e3ec41b11b545751ba428618a76cc1341064` -(JDT LS 1.63.0.202610191322). Different implementations, non-field selections and -library/decompiled selections retain native discovery plus deduplicated category -searches. This conservative fallback preserves coverage when the server changes; -it does not promise the same speedup on unverified versions. +(JDT LS 1.63.0.202610191322). On that version, non-field and library/decompiled +selections resolve their target as `ReferencesHandler.findReferences` does, and every +search replicates `ReferencesHandler.search` with one difference: a class file whose +decompiled source fails to parse loses only its own matches (logged), where the server +throws and fails the whole search after searching most dependencies. Different +implementations retain native discovery plus deduplicated category searches. This +conservative fallback preserves coverage when the server changes; it does not promise +the same speedup or fault isolation on unverified versions. SearchEngine resolves exact targets. A query-local syntax AST per referenced source file supplies expression context without another reference search: simple field @@ -57,7 +69,7 @@ is not a claim of full IntelliJ feature parity. ## Rebuild and delivery The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as -`java-usages-1.6.0.jar`, and added alongside existing initialization bundles. +`java-usages-1.7.0.jar`, and added alongside existing initialization bundles. Users need no compiler, Maven or Python. Manifest/install version must change when replacing the bundle because OSGi caches bundles. @@ -87,6 +99,10 @@ Version 1.6.0 adds `openidea.java.configuration.warmup`, which builds configurat low-priority background job after import, and builds them under cancellable per-project locks instead of one global lock; its reproducible SHA-256 is `33cfa1bb8489247ffeb6fc8249966c8a2955b7b2858c8169a70486eb70ade696`. +Version 1.7.0 adds the `"sources"` scope to `openidea.java.usages`, searches each class +file's decompiled source on its own and returns the line (`text`) of each usage in a library +class; its reproducible SHA-256 is +`56d35619d5be937b028ac143ba748365a66a74c803bc112e4788a7dce98b44fc`. ```sh export PATH=/path/to/jdk25/bin:$PATH diff --git a/java/java-usages/openidea-java-usages.jar b/java/java-usages/openidea-java-usages.jar index 47428a1ed5096fd172f1376fd6e20f3023c61d52..f637e1e40235757b97fb2884eeed95aa98bf04b4 100644 GIT binary patch delta 13763 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 delta 7151 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 diff --git a/java/java-usages/src/ai/openidea/usages/UsagesCommand.java b/java/java-usages/src/ai/openidea/usages/UsagesCommand.java index 18b1fad6..128b1c00 100644 --- a/java/java-usages/src/ai/openidea/usages/UsagesCommand.java +++ b/java/java-usages/src/ai/openidea/usages/UsagesCommand.java @@ -4,13 +4,25 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.jdt.core.*; +import org.eclipse.jdt.core.search.IJavaSearchConstants; +import org.eclipse.jdt.core.search.IJavaSearchScope; +import org.eclipse.jdt.core.search.SearchEngine; +import org.eclipse.jdt.core.search.SearchMatch; +import org.eclipse.jdt.core.search.SearchParticipant; +import org.eclipse.jdt.core.search.SearchPattern; +import org.eclipse.jdt.core.search.SearchRequestor; import org.eclipse.jdt.internal.corext.codemanipulation.GetterSetterUtil; import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler; import org.eclipse.jdt.ls.core.internal.JDTUtils; import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; +import org.eclipse.jdt.ls.core.internal.SearchUtils; +import org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers; import org.eclipse.jdt.ls.core.internal.handlers.ReferencesHandler; +import org.eclipse.jdt.ls.core.internal.preferences.Preferences.SearchScope; import org.eclipse.lsp4j.*; /** Classifies search results using JDT method identities, never reference text. */ @@ -21,8 +33,14 @@ public final class UsagesCommand implements IDelegateCommandHandler { int line = ((Number) arguments.get(1)).intValue(); int column = ((Number) arguments.get(2)).intValue(); boolean declarations = Boolean.TRUE.equals(arguments.get(3)); + // "sources": the workspace's source folders only, without dependency jars or the JDK. A + // library method's usages there are found long before the full search ends. + boolean sources = arguments.size() > 4 && "sources".equals(arguments.get(4)); var preferences = JavaLanguageServerPlugin.getPreferencesManager(); var handler = new ReferencesHandler(preferences); + // The verified server's search is replicated (see `search`); others keep their own. + boolean replicated = sources || NATIVE_PLAN_SUPPORTED; + Search search = replicated ? (element, locations, m, d) -> search(element, locations, m, d, sources) : handler::search; var params = new ReferenceParams(new TextDocumentIdentifier(uri), new Position(line, column), new ReferenceContext(declarations)); Map results = new LinkedHashMap<>(); ITypeRoot root = JDTUtils.resolveTypeRoot(uri); @@ -31,40 +49,95 @@ public final class UsagesCommand implements IDelegateCommandHandler { if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); boolean accessors = preferences.getPreferences().isIncludeAccessors(); SearchPlan plan = new SearchPlan(); - // Only replace native target discovery for the exact implementation verified by - // integration tests. New server implementations retain their full native behavior. - if (selected instanceof IField field && root instanceof ICompilationUnit && - root.getResource() != null && NATIVE_PLAN_SUPPORTED) { - plan.add(field, "field", declarations); - if (accessors) { - plan.add(GetterSetterUtil.getGetter(field), "getter", false); - plan.add(GetterSetterUtil.getSetter(field), "setter", false); - addNativeBuilderTargets(handler, field, plan); - addConfiguredAccessors(field, plan); - } - } else { - // Includes non-fields, library/decompiled roots, and unverified JDT LS builds. - for (var location : handler.findReferences(params, monitor)) results.put(location, "unknown"); - if (selected instanceof IField field) { + ITypeRoot targetRoot = null; + try { + // Only replace native target discovery for the exact implementation verified by + // integration tests. New server implementations retain their full native behavior. + if (selected instanceof IField field && root instanceof ICompilationUnit && + root.getResource() != null && NATIVE_PLAN_SUPPORTED) { plan.add(field, "field", declarations); if (accessors) { plan.add(GetterSetterUtil.getGetter(field), "getter", false); plan.add(GetterSetterUtil.getSetter(field), "setter", false); + addNativeBuilderTargets(handler, field, plan); addConfiguredAccessors(field, plan); } + } else if (replicated) { + // Non-fields and library/decompiled roots: the target ReferencesHandler.findReferences + // resolves (a class file without source through its decompiled working copy), widened + // as it widens a field. + boolean decompiled = preferences.isClientSupportsClassFileContent() && preferences.getPreferences().isIncludeDecompiledSources(); + targetRoot = JDTUtils.resolveTypeRoot(uri, decompiled, monitor); + IJavaElement target = targetRoot == null ? null : + JDTUtils.findElementAtSelection(targetRoot, line, column, preferences, monitor); + if (target == null && targetRoot != null) { + target = targetRoot.getElementAt(JsonRpcHelpers.toOffset(targetRoot.getBuffer(), line, column)); + } + plan.add(target, target instanceof IField ? "field" : "unknown", declarations); + if (accessors && target instanceof IField field) { + plan.add(GetterSetterUtil.getGetter(field), "getter", false); + plan.add(GetterSetterUtil.getSetter(field), "setter", false); + if (NATIVE_PLAN_SUPPORTED) addNativeBuilderTargets(handler, field, plan); + addConfiguredAccessors(field, plan); + } + } else { + // Unverified JDT LS builds. + for (var location : handler.findReferences(params, monitor)) results.put(location, "unknown"); + if (selected instanceof IField field) { + plan.add(field, "field", declarations); + if (accessors) { + plan.add(GetterSetterUtil.getGetter(field), "getter", false); + plan.add(GetterSetterUtil.getSetter(field), "setter", false); + addConfiguredAccessors(field, plan); + } + } } - } - for (var target : plan.targets.values()) { - if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); - add(handler, target.element, target.kind(), target.declarations, results, monitor); + for (var target : plan.targets.values()) { + if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); + add(search, target.element, target.kind(), target.declarations, results, monitor); + } + } finally { + if (targetRoot != null) JDTUtils.discardClassFileWorkingCopy(targetRoot); } if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); List> response = new ArrayList<>(); UsageAccess access = new UsageAccess(monitor, results); - results.forEach((location, kind) -> response.add(Map.of("location", location, "kind", kind, "access", access.classify(location, kind)))); + Map libraryLines = new java.util.HashMap<>(); + for (var entry : results.entrySet()) { + if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); + Location location = entry.getKey(); + Map row = new LinkedHashMap<>(); + row.put("location", location); + row.put("kind", entry.getValue()); + row.put("access", access.classify(location, entry.getValue())); + String text = libraryLine(location, libraryLines, monitor); + if (text != null) row.put("text", text); + response.add(row); + } return response; } + /** + * The line of a usage in a library class (`jdt:` URI), from the contents the client would + * request for it: so the client need not fetch thousands of class files to show the lines. + */ + private static String libraryLine(Location location, Map cache, IProgressMonitor monitor) { + String uri = location.getUri(); + if (uri == null || !uri.startsWith("jdt:")) return null; + if (!cache.containsKey(uri)) { + String contents = null; + try { + contents = JavaLanguageServerPlugin.getContentProviderManager().getContent(JDTUtils.toURI(uri), monitor); + } catch (RuntimeException e) { + if (e instanceof org.eclipse.core.runtime.OperationCanceledException) throw e; + } + cache.put(uri, contents == null ? null : contents.split("\\r\\n?|\\n", -1)); + } + String[] lines = cache.get(uri); + int line = location.getRange().getStart().getLine(); + return lines != null && line >= 0 && line < lines.length ? lines[line] : null; + } + /** Resolve explicit Lombok @Accessors naming, then search exact JDT method identities. * Configuration-file-only naming is deliberately not guessed. */ @@ -191,11 +264,84 @@ public final class UsagesCommand implements IDelegateCommandHandler { (resolved[0][0].isEmpty() ? resolved[0][1] : resolved[0][0] + "." + resolved[0][1]).equals(owner.getFullyQualifiedName()); } - private static void add(ReferencesHandler handler, IJavaElement element, String kind, boolean declarations, + private interface Search { + void run(IJavaElement element, List locations, IProgressMonitor monitor, boolean declarations) throws Exception; + } + + /** + * ReferencesHandler.search of the verified server, except that a class file whose decompiled + * source fails to parse loses only its own matches: there the server throws and fails the + * whole search, after searching most of the dependencies. `sources`: the workspace's source + * folders only, without dependency jars and the JDK. + */ + private static void search(IJavaElement element, List locations, IProgressMonitor monitor, + boolean declarations, boolean sources) throws CoreException { + SearchPattern pattern = SearchPattern.createPattern(element, IJavaSearchConstants.REFERENCES); + if (pattern == null) return; + if (declarations) { + SearchPattern declaration = SearchPattern.createPattern(element, IJavaSearchConstants.DECLARATIONS); + if (declaration != null) pattern = SearchPattern.createOrPattern(pattern, declaration); + } + var preferences = JavaLanguageServerPlugin.getPreferencesManager(); + SearchScope setting = preferences.getPreferences().getSearchScope(); + int mask = IJavaSearchScope.SOURCES | IJavaSearchScope.REFERENCED_PROJECTS; + if (!sources && setting != SearchScope.projectOnly) { + mask |= IJavaSearchScope.APPLICATION_LIBRARIES; + if (insideJre(element)) mask |= IJavaSearchScope.SYSTEM_LIBRARIES; + } + IJavaProject[] projects = JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects(); + IJavaSearchScope scope = SearchEngine.createJavaSearchScope(setting == SearchScope.main, projects, mask); + boolean classFiles = preferences.isClientSupportsClassFileContent(); + boolean decompiled = preferences.getPreferences().isIncludeDecompiledSources(); + new SearchEngine().search(pattern, new SearchParticipant[] { SearchEngine.getDefaultSearchParticipant() }, scope, + new SearchRequestor() { + @Override + public void acceptSearchMatch(SearchMatch match) throws CoreException { + if (match.getAccuracy() == SearchMatch.A_INACCURATE || !(match.getElement() instanceof IJavaElement found)) return; + if (found.getAncestor(IJavaElement.COMPILATION_UNIT) instanceof ICompilationUnit unit) { + locations.add(JDTUtils.toLocation(unit, match.getOffset(), match.getLength())); + return; + } + if (!classFiles || !(found.getAncestor(IJavaElement.CLASS_FILE) instanceof IClassFile classFile)) return; + if (classFile.getSourceRange() != null) { + locations.add(JDTUtils.toLocation(classFile, match.getOffset(), match.getLength())); + return; + } + if (!decompiled) return; + try { + if (found instanceof IMember member) { + Location location = SearchUtils.searchOtherSources(member); + if (location != null) locations.add(location); + } + locations.addAll(JDTUtils.searchDecompiledSources(found, classFile, false, false, monitor)); + } catch (org.eclipse.core.runtime.OperationCanceledException e) { + throw e; + } catch (RuntimeException | CoreException e) { + JavaLanguageServerPlugin.logInfo("Usages in decompiled " + classFile.getElementName() + " skipped: " + e); + } + } + }, monitor); + } + + /** ReferencesHandler.isInsideJRE: a JDK element is searched in the JDK too. */ + private static boolean insideJre(IJavaElement element) { + if (!(element.getAncestor(IJavaElement.PACKAGE_FRAGMENT_ROOT) instanceof IPackageFragmentRoot root)) return true; + try { + IClasspathEntry entry = root.getRawClasspathEntry(); + if (entry.getEntryKind() != IClasspathEntry.CPE_CONTAINER) return false; + IClasspathContainer container = JavaCore.getClasspathContainer(entry.getPath(), root.getJavaProject()); + return container != null && container.getKind() == IClasspathContainer.K_DEFAULT_SYSTEM; + } catch (JavaModelException e) { + JavaLanguageServerPlugin.logException(e); + return true; + } + } + + private static void add(Search search, IJavaElement element, String kind, boolean declarations, Map results, IProgressMonitor monitor) throws Exception { if (element == null) return; List locations = new ArrayList<>(); - handler.search(element, locations, monitor, declarations); + search.run(element, locations, monitor, declarations); for (var location : locations) { if (kind.equals("unknown")) { results.putIfAbsent(location, kind); continue; } String previous = results.get(location); diff --git a/java/java-usages/tests/verify.py b/java/java-usages/tests/verify.py index 825c31b8..9a418812 100644 --- a/java/java-usages/tests/verify.py +++ b/java/java-usages/tests/verify.py @@ -106,6 +106,14 @@ def request(method, params): if 'error' in msg: raise RuntimeError(msg['error']) return msg.get('result') raise TimeoutError(method) +def assert_sources(line, col, full=None): + """The "sources" scope finds what the full search finds in the workspace's sources.""" + usages = lambda *scope: request('workspace/executeCommand', {'command':'openidea.java.usages','arguments':[file.as_uri(),line,col,False,*scope]}) + full = usages() if full is None else full + sources = usages('sources') + key = lambda rows: sorted(json.dumps(r, sort_keys=True) for r in rows) + assert key(sources) == key(r for r in full if r['location']['uri'].startswith('file:')), (line, col, sources, full) + return sources try: init = request('initialize', {'processId':None, 'rootUri':project.as_uri(), 'workspaceFolders':[{'uri':project.as_uri(), 'name':'accessors'}], 'capabilities':{}, 'initializationOptions':{'bundles':[str(bundle)], 'settings':{'java':{'references':{'includeAccessors':True}, 'import':{'gradle':{'enabled':False}, 'maven':{'enabled':False}}}}}}) send({'jsonrpc':'2.0','method':'initialized','params':{}}) @@ -123,6 +131,11 @@ try: assert kinds.count('getter') == expected[0], (field, kinds) assert kinds.count('setter') == expected[1], (field, kinds) assert kinds.count('field') == expected[2], (field, kinds) + assert_sources(line, col, after) + # A JDK method: in the sources, the same as the full search's sources part. + line, text = next((i, t) for i, t in enumerate(source.splitlines()) if 'println' in t) + evidence['jdkSources'] = assert_sources(line, text.index('println')) + assert evidence['jdkSources'], 'no JDK method usages in the sources' # Respect the existing global preference: the command must not switch it on. send({'jsonrpc':'2.0','method':'workspace/didChangeConfiguration','params':{'settings':{'java':{'references':{'includeAccessors':False}}}}}) disabled = request('workspace/executeCommand', {'command':'openidea.java.usages','arguments':[file.as_uri(),3,19,False]}) diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 98dc7e92..14a51a69 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -136,9 +136,13 @@ pub async fn lsp_resolve_class_file( /// References of the symbol at `pos`. `project_wide` also searches project files /// outside the language server's analysis scope; bulk scans over many symbols -/// leave it off. Cancel with `cancel_request(request)`. +/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK +/// are not searched; with it, those found in the sources arrive on `on_partial` before +/// the slower search for the rest, and the result has them all. Cancel with +/// `cancel_request(request)`. #[tauri::command] #[specta::specta] +#[allow(clippy::too_many_arguments)] pub async fn lsp_references( window: Window, state: State<'_, AppState>, @@ -147,7 +151,10 @@ pub async fn lsp_references( pos: Pos, include_declaration: bool, project_wide: bool, + libraries: bool, + on_partial: tauri::ipc::Channel, ) -> IdeResult { + let on_partial = super::sender(on_partial); ide_host::ops::lsp::lsp_references( &state.host, window.label(), @@ -156,6 +163,8 @@ pub async fn lsp_references( pos, include_declaration, project_wide, + libraries, + on_partial, ) .await } diff --git a/src/bindings.ts b/src/bindings.ts index c9ca52c9..37391432 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -424,9 +424,12 @@ export const commands = { /** * References of the symbol at `pos`. `project_wide` also searches project files * outside the language server's analysis scope; bulk scans over many symbols - * leave it off. Cancel with `cancel_request(request)`. + * leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK + * are not searched; with it, those found in the sources arrive on `on_partial` before + * the slower search for the rest, and the result has them all. Cancel with + * `cancel_request(request)`. */ - lspReferences: (request: RequestId, doc: DocId, pos: Pos, includeDeclaration: boolean, projectWide: boolean) => typedError(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide })), + lspReferences: (request: RequestId, doc: DocId, pos: Pos, includeDeclaration: boolean, projectWide: boolean, libraries: boolean, onPartial: Channel) => typedError(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide, libraries, onPartial })), lspDocumentHighlights: (doc: DocId, pos: Pos) => typedError(__TAURI_INVOKE("lsp_document_highlights", { doc, pos })), lspWorkspaceSymbols: (request: RequestId, query: string) => typedError(__TAURI_INVOKE("lsp_workspace_symbols", { request, query })), lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError(__TAURI_INVOKE("lsp_code_actions", { doc, range, only })), @@ -2113,6 +2116,8 @@ export type ClassifiedReference = { access?: ReferenceAccess | null, location: Location, kind: ReferenceKind, + /** The usage's line, when the server has it at hand (a library class's). */ + text?: string | null, }; export type ClearCachesResult = { @@ -6178,6 +6183,8 @@ export type References = { classified?: ClassifiedReference[], locations: Location[], incomplete: ReferencesIncomplete | null, + /** The dependencies and the JDK were not searched (asked for without `libraries`). */ + librariesSkipped?: boolean, }; /** Why a references search may have missed usages. */ diff --git a/src/editor/lsp/codeLens.test.ts b/src/editor/lsp/codeLens.test.ts index 69b5be68..eecad98d 100644 --- a/src/editor/lsp/codeLens.test.ts +++ b/src/editor/lsp/codeLens.test.ts @@ -15,6 +15,12 @@ vi.mock("@/platform/ipc", async (orig) => { }); const executeAction = vi.hoisted(() => vi.fn(async () => true)); vi.mock("@/platform/actions", () => ({ executeAction })); +vi.mock("@tauri-apps/api/core", async (orig) => ({ + ...(await orig()), + Channel: class { + onmessage = () => {}; + }, +})); const { lensTargets, usagesLensExtension } = await import("./codeLens"); const { lspDoc } = await import("./lsp"); @@ -89,7 +95,15 @@ describe("background usage counts", () => { it("uses the server's existing scope and leaves full searches to the explicit action", async () => { const view = editor(); await settle(); - expect(ipc.lspReferences).toHaveBeenCalledWith(expect.any(Number), 7, { line: 0, col: 0 }, false, false); + expect(ipc.lspReferences).toHaveBeenCalledWith( + expect.any(Number), + 7, + { line: 0, col: 0 }, + false, + false, + false, + expect.anything(), + ); expect(titles(view)).toEqual(["2 usages"]); view.state.facet(codeLenses)[0]!.run!(view, new MouseEvent("click")); expect(executeAction).toHaveBeenCalledWith("find.showUsages"); diff --git a/src/editor/lsp/codeLens.ts b/src/editor/lsp/codeLens.ts index 8f7d136e..b8051dea 100644 --- a/src/editor/lsp/codeLens.ts +++ b/src/editor/lsp/codeLens.ts @@ -5,6 +5,7 @@ * and symbol, and at most a few reference requests in flight. Beside each * count, the declaration's last author from VCS blame (`authorLens`). */ +import { Channel } from "@tauri-apps/api/core"; import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { codeLenses, type CodeLens } from "@/editor/api"; @@ -195,7 +196,15 @@ class UsagesLensPlugin { this.pending.add(request); // Opening candidate files for each count triggers expensive server analysis. // Only an explicit Find Usages should widen the server's scope. - return commands.lspReferences(request, doc.doc, { line: t.line, col: t.col }, false, false); + return commands.lspReferences( + request, + doc.doc, + { line: t.line, col: t.col }, + false, + false, + false, + new Channel(), + ); }, wanted); if (wanted()) { if (incomplete) this.incomplete = true; diff --git a/src/editor/refactor/safeDelete.test.ts b/src/editor/refactor/safeDelete.test.ts index eb2803ff..e990cac4 100644 --- a/src/editor/refactor/safeDelete.test.ts +++ b/src/editor/refactor/safeDelete.test.ts @@ -65,6 +65,12 @@ const showHint = vi.fn(); vi.mock("@/editor/lsp/hint", () => ({ showHint })); const executeAction = vi.fn(); vi.mock("@/platform/actions", () => ({ executeAction })); +vi.mock("@tauri-apps/api/core", async (orig) => ({ + ...(await orig()), + Channel: class { + onmessage = () => {}; + }, +})); let answer = "cancel"; const ask = vi.fn(async (_request: MessageRequest) => ({ button: answer, checked: false })); let confirmAnswer: { checked: boolean } | null = { checked: false }; diff --git a/src/editor/refactor/safeDelete.ts b/src/editor/refactor/safeDelete.ts index b7f3f651..95f754f0 100644 --- a/src/editor/refactor/safeDelete.ts +++ b/src/editor/refactor/safeDelete.ts @@ -10,6 +10,7 @@ * Files (project view): references to the file's top-level symbols from other * files, when the language server can answer (`fileUsages`). */ +import { Channel } from "@tauri-apps/api/core"; import type { EditorView } from "@codemirror/view"; import type { Text } from "@codemirror/state"; import type { LocalSymbol, Location, OutlineItem, Pos, Range, SyntaxNode } from "@/bindings"; @@ -254,7 +255,7 @@ async function findUsages(ctx: EditorDocContext, target: SafeDeleteTarget): Prom if (target.local) return target.local.references.filter((r) => outside(ctx.path, r)).map((range) => ({ path: ctx.path, range })); const { locations: refs } = await lspRequest(() => - commands.lspReferences(nextRequestId(), ctx.doc, target.at, false, true), + commands.lspReferences(nextRequestId(), ctx.doc, target.at, false, true, false, new Channel()), ); return refs.filter((l) => outside(l.path, l.range)); } @@ -554,7 +555,15 @@ export async function fileUsages(paths: readonly string[], signal?: AbortSignal) // Every symbol of every file: the server's own scope (a project search each would be too slow). refs = ( await lspRequest(() => - commands.lspReferences(nextRequestId(), doc, item.selection_range.start, false, false), + commands.lspReferences( + nextRequestId(), + doc, + item.selection_range.start, + false, + false, + false, + new Channel(), + ), ) ).locations; } catch (e) { diff --git a/src/features/code-insight/UsagesPopup.module.css b/src/features/code-insight/UsagesPopup.module.css index 92554fbe..5ff2cfd9 100644 --- a/src/features/code-insight/UsagesPopup.module.css +++ b/src/features/code-insight/UsagesPopup.module.css @@ -15,6 +15,16 @@ font-size: var(--fs-small); color: var(--fg-muted); } +.scope { + flex: none; +} + +.searching { + flex: none; + display: flex; + align-items: center; +} + .title { flex: 1; min-width: 0; diff --git a/src/features/code-insight/UsagesPopup.tsx b/src/features/code-insight/UsagesPopup.tsx index 0d072c85..cac51f3b 100644 --- a/src/features/code-insight/UsagesPopup.tsx +++ b/src/features/code-insight/UsagesPopup.tsx @@ -11,15 +11,17 @@ import { Decoration, EditorView } from "@codemirror/view"; import { codePreviewExtensions } from "@/editor/api"; import { basename } from "@/platform/project"; import { shortcutLabel } from "@/platform/keymap"; -import { Icon, IconButton, List, Popup, fileIconName, listNavigate } from "@/ui"; +import { Icon, IconButton, List, Popup, Select, Spinner, fileIconName, listNavigate } from "@/ui"; import { openLocation, readFileLines } from "./ipc"; import { dismissUsagesPopup, + setUsagesPopupScope, showUsageResults, usageCount, useUsagesPopup, type Usage, type UsageGroup, + type UsageScope, } from "./usages"; import { UsagePreview } from "./UsagesView"; import styles from "./UsagesPopup.module.css"; @@ -79,7 +81,7 @@ export function UsagesPopup() { const ROW = 24; function UsagesPopupInner() { - const { anchor, symbol, groups, note } = useUsagesPopup(); + const { anchor, symbol, groups, note, loading, scope } = useUsagesPopup(); const preview = useUsagesPopupPreview((s) => s.preview); const all = useMemo(() => groups.flatMap((g) => g.usages), [groups]); const [filter, setFilter] = useState(""); @@ -152,6 +154,24 @@ function UsagesPopupInner() { {filter || items.length !== total ? tx`${items.length} of ${total} found` : tx`${total} found`} + {scope && ( + + variant="ghost" + aria-label={tx("Scope")} + className={styles.scope} + value={scope} + options={[ + { value: "project", label: tx("Project Files") }, + { value: "libraries", label: tx("Project and Libraries") }, + ]} + onChange={setUsagesPopupScope} + /> + )} + {loading && ( + + + + )} {filter && ( {filter} diff --git a/src/features/code-insight/UsagesView.module.css b/src/features/code-insight/UsagesView.module.css index 3a5dd124..c3c62da6 100644 --- a/src/features/code-insight/UsagesView.module.css +++ b/src/features/code-insight/UsagesView.module.css @@ -41,6 +41,19 @@ .error { color: var(--danger); } +.link { + flex: none; + border: none; + background: none; + padding: 0; + font: inherit; + color: var(--link); + cursor: pointer; +} +.link:hover { + color: var(--link-hover); + text-decoration: underline; +} .body { flex: 1; min-width: 0; diff --git a/src/features/code-insight/UsagesView.tsx b/src/features/code-insight/UsagesView.tsx index 1f6faeb2..033f48dc 100644 --- a/src/features/code-insight/UsagesView.tsx +++ b/src/features/code-insight/UsagesView.tsx @@ -66,8 +66,16 @@ export function UsagesTabs() { const tabs = useUsages((s) => s.tabs); const search = useUsages(activeUsageSearch); if (!search) return null; - const label = (t: (typeof tabs)[number]) => - t.symbol ? formatMessage(tx("Usages of '{symbol}'"), { symbol: t.symbol }) : tx("Usages"); + const label = (t: (typeof tabs)[number]) => { + if (!t.symbol) return tx("Usages"); + const text = + t.scope === "project" + ? tx("Usages of '{symbol}' in Project Files") + : t.scope === "libraries" + ? tx("Usages of '{symbol}' in Project and Libraries") + : tx("Usages of '{symbol}'"); + return formatMessage(text, { symbol: t.symbol }); + }; return (
+ {!search.loading && !search.error && search.scope === "project" && ( +
+ Usages in Project Files + {search.searchLibraries && ( + + )} +
+ )} {(search.loading || search.error) && (
{search.loading ? ( diff --git a/src/features/code-insight/syntaxUsages.test.ts b/src/features/code-insight/syntaxUsages.test.ts index 11de8db0..f1957318 100644 --- a/src/features/code-insight/syntaxUsages.test.ts +++ b/src/features/code-insight/syntaxUsages.test.ts @@ -23,6 +23,12 @@ vi.mock("@/platform/ipc", async (orig) => { return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } }; }); vi.mock("./candidates", () => ({ filesMentioning: () => filesMentioning() })); +vi.mock("@tauri-apps/api/core", async (orig) => ({ + ...(await orig()), + Channel: class { + onmessage = () => {}; + }, +})); const { _addEditor } = await import("@/editor/api"); const { fetchUsages } = await import("./usages"); @@ -42,7 +48,7 @@ beforeEach(() => { describe("Find Usages fallback", () => { it("uses the server when it answers, even with nothing", async () => { cmd.lspReferences.mockReturnValueOnce(ok({ locations: [], incomplete: null })); - expect(await fetchUsages(caret)).toEqual({ groups: [], note: null }); + expect(await fetchUsages(caret)).toEqual({ groups: [], note: null, librariesSkipped: false }); expect(cmd.syntaxLocals).not.toHaveBeenCalled(); }); @@ -50,7 +56,7 @@ describe("Find Usages fallback", () => { const usage = { path: "/p/a.ts", range: r(1, 0, 1) }; cmd.lspReferences.mockReturnValueOnce(ok({ locations: [usage], incomplete: "indexing" })); const res = await fetchUsages(caret, 42 as never); - expect(cmd.lspReferences).toHaveBeenCalledWith(42, 3, { line: 1, col: 0 }, false, true); + expect(cmd.lspReferences).toHaveBeenCalledWith(42, 3, { line: 1, col: 0 }, false, true, false, expect.anything()); expect(res.groups[0]!.usages.length).toBe(1); expect(res.note).toBe(incompleteNote("indexing", "x")); expect(incompleteNote("tooManyFiles", "x")).toBe( diff --git a/src/features/code-insight/usageResults.test.ts b/src/features/code-insight/usageResults.test.ts index 02907c38..7316b862 100644 --- a/src/features/code-insight/usageResults.test.ts +++ b/src/features/code-insight/usageResults.test.ts @@ -5,12 +5,33 @@ vi.mock("@/workbench/contributions", async (orig) => ({ ...(await orig()), toolWindowService: () => ({ show }), })); +const referenceResponse = vi.fn(); +const lspReferences = vi.fn(async (..._args: unknown[]) => ({ status: "ok", data: null })); +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { ...actual, commands: { ...actual.commands, lspReferences } }; +}); vi.mock("./ipc", async (orig) => ({ ...(await orig()), - readManyFileLines: async () => new Map([["/p/b.rs", ["", " used();"]]]), + caretContext: () => ({ doc: 1, path: "/p/a.rs", line: 0, col: 0, word: "used", view: null }), + lspRequest: (request: () => unknown) => { + request(); + return referenceResponse(); + }, + readManyFileLines: async () => new Map([["/p/b.rs", ["", " used();", " used();"]]]), +})); +const channels: { onmessage: (refs: unknown) => void }[] = []; +vi.mock("@tauri-apps/api/core", async (orig) => ({ + ...(await orig()), + Channel: class { + onmessage = () => {}; + constructor() { + channels.push(this); + } + }, })); -const { activeUsageSearch, openUsageTab, pinUsageTab, resetUsages, showUsageResults, useUsages } = +const { activeUsageSearch, findUsages, openUsageTab, pinUsageTab, resetUsages, showUsageResults, useUsages } = await import("./usages"); describe("showUsageResults", () => { @@ -25,6 +46,19 @@ describe("showUsageResults", () => { ]); }); + it("shows the lines the server gave along for files it does not read", async () => { + const lib = { + path: "jdt://contents/lib/Lib.class", + range: { start: { line: 3, col: 4 }, end: { line: 3, col: 8 } }, + }; + await showUsageResults({ + symbol: "used", + locations: [lib], + classified: [{ location: lib, kind: "unknown", text: " used();" }], + }); + expect(activeUsageSearch(useUsages.getState())!.groups[0]!.usages[0]!.text).toBe(" used();"); + }); + it("ignores malformed arguments", async () => { show.mockClear(); await showUsageResults({ symbol: "x" }); @@ -32,6 +66,47 @@ describe("showUsageResults", () => { }); }); +describe("findUsages", () => { + it("searches Project Files, then Project and Libraries on request", async () => { + const at = { path: "/p/b.rs", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } }; + lspReferences.mockClear(); + referenceResponse.mockResolvedValueOnce({ locations: [at], incomplete: null, librariesSkipped: true }); + await findUsages(); + const project = activeUsageSearch(useUsages.getState())!; + expect(project).toMatchObject({ loading: false, scope: "project" }); + expect(lspReferences.mock.lastCall![5]).toBe(false); + referenceResponse.mockResolvedValueOnce({ locations: [at], incomplete: null }); + project.searchLibraries!(); + await vi.waitFor(() => expect(activeUsageSearch(useUsages.getState())!.loading).toBe(false)); + expect(lspReferences.mock.lastCall![5]).toBe(true); + const all = activeUsageSearch(useUsages.getState())!; + expect(all.scope).toBe("libraries"); + // Rerun keeps the scope. + referenceResponse.mockResolvedValueOnce({ locations: [at], incomplete: null }); + all.rerun!(); + await vi.waitFor(() => expect(lspReferences).toHaveBeenCalledTimes(3)); + expect(lspReferences.mock.lastCall![5]).toBe(true); + resetUsages(); + }); + + it("shows the usages found first while it searches for the rest", async () => { + const at = (line: number) => ({ path: "/p/b.rs", range: { start: { line, col: 4 }, end: { line, col: 8 } } }); + let resolve!: (refs: unknown) => void; + referenceResponse.mockReturnValueOnce(new Promise((r) => (resolve = r))); + const search = findUsages(); + await vi.waitFor(() => expect(channels).not.toHaveLength(0)); + channels.at(-1)!.onmessage({ locations: [at(1)], incomplete: null }); + await vi.waitFor(() => expect(activeUsageSearch(useUsages.getState())!.groups).toHaveLength(1)); + expect(activeUsageSearch(useUsages.getState())).toMatchObject({ loading: true, error: null }); + resolve({ locations: [at(1), at(2)], incomplete: null }); + await search; + const done = activeUsageSearch(useUsages.getState())!; + expect(done.loading).toBe(false); + expect(done.groups[0]!.usages.map((u) => u.line)).toEqual([1, 2]); + resetUsages(); + }); +}); + describe("usage tabs", () => { const search = (id: number) => ({ id, diff --git a/src/features/code-insight/usages.ts b/src/features/code-insight/usages.ts index 30599647..a1a8d6d1 100644 --- a/src/features/code-insight/usages.ts +++ b/src/features/code-insight/usages.ts @@ -1,9 +1,11 @@ /** Find Usages (⌥F7 → "usages" tool window) and Show Usages (⌥⌘F7 → popup). */ import { create } from "zustand"; -import type { ClassifiedReference, ReferenceAccess, ReferenceKind, Location, RequestId } from "@/bindings"; +import { Channel } from "@tauri-apps/api/core"; +import type { ClassifiedReference, ReferenceAccess, ReferenceKind, Location, References, RequestId } from "@/bindings"; import { clearLineTokens } from "@/editor/lineTokens"; import { incompleteNote } from "@/editor/lsp/lsp"; import { commands, isCancelled, nextRequestId } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; import { toolWindowService } from "@/workbench/contributions"; import { caretContext, @@ -49,7 +51,7 @@ export function groupUsages( const key = usageLocationKey(l); if (seen.has(key)) continue; seen.add(key); - const text = files.get(l.path)?.[l.range.start.line] ?? ""; + const text = files.get(l.path)?.[l.range.start.line] ?? kinds.get(key)?.text ?? ""; const endCol = l.range.end.line === l.range.start.line ? l.range.end.col : Math.max(text.length, l.range.start.col); let list = byPath.get(l.path); if (!list) byPath.set(l.path, (list = [])); @@ -86,6 +88,15 @@ export function trimPreview(u: Usage): { text: string; textCol: number; from: nu return { text, textCol: lead, from, to }; } +/** Project Files, or Project and Libraries (Java's dependency jars and JDK too). */ +export type UsageScope = "project" | "libraries"; + +/** The scope a search ran in, when there was a choice (`null` when the server has none). */ +function searchedScope(libraries: boolean, librariesSkipped: boolean, widenable: boolean): UsageScope | null { + if (librariesSkipped) return "project"; + return libraries && widenable ? "libraries" : null; +} + export interface UsageSearch { /** Identifies one search run (UI state such as expansion resets per run). */ id: number; @@ -97,6 +108,10 @@ export interface UsageSearch { note: string | null; /** Re-runs the same query (rerun button). */ rerun: (() => void) | null; + /** Where it searched, when the server can search beyond Project Files (Java's jars and JDK). */ + scope?: UsageScope | null; + /** Runs it again in Project and Libraries. */ + searchLibraries?: (() => void) | null; /** Kept when a new search runs (Pin Tab). */ pinned?: boolean; } @@ -163,6 +178,10 @@ interface UsagesPopupState { symbol: string | null; groups: UsageGroup[]; note: string | null; + /** Shows those found so far while the search goes on. */ + loading: boolean; + /** Where it searched, when the server can search beyond Project Files. */ + scope: UsageScope | null; } export const useUsagesPopup = create(() => ({ @@ -171,31 +190,64 @@ export const useUsagesPopup = create(() => ({ symbol: null, groups: [], note: null, + loading: false, + scope: null, })); interface FetchedUsages { groups: UsageGroup[]; note: string | null; + librariesSkipped: boolean; +} + +/** Paths whose lines are read to show these usages: not those the server gave along. */ +function pathsToRead(locs: readonly Location[], classified: readonly ClassifiedReference[]): string[] { + const given = new Set(classified.filter((r) => r.text != null).map((r) => usageLocationKey(r.location))); + return locs.filter((l) => !given.has(usageLocationKey(l))).map((l) => l.path); } /** * The server's references across the project (files outside its analysis scope * included); when it is not ready or fails, the syntax fallback (an empty server - * answer is respected). Without a fallback the server error stands. + * answer is respected). Without a fallback the server error stands. Without + * `libraries`, Java's dependencies and JDK are not searched (Project Files); with it, + * those found in the sources go to `onPartial` before the slower search for the rest. * Cancel with `commands.cancelRequest(request)`. */ -export async function fetchUsages(c: CaretContext, request: RequestId = nextRequestId()): Promise { +export async function fetchUsages( + c: CaretContext, + request: RequestId = nextRequestId(), + onPartial?: (found: FetchedUsages) => void, + libraries = false, +): Promise { // Usage lines are read afresh: so are the files their syntax colours come from. clearLineTokens(); let locs: Location[]; let classified: ClassifiedReference[] = []; let note: string | null = null; + let librariesSkipped = false; + let settled = false; + const channel = new Channel(); + channel.onmessage = (refs) => { + if (!onPartial) return; + void readManyFileLines(pathsToRead(refs.locations, refs.classified ?? [])).then((files) => { + if (settled) return; + onPartial({ + groups: groupUsages(refs.locations, files, refs.classified ?? []), + note: refs.incomplete ? incompleteNote(refs.incomplete, c.word) : null, + librariesSkipped: false, + }); + }); + }; try { const refs = await lspRequest(() => - commands.lspReferences(request, c.doc, { line: c.line, col: c.col }, false, true), - ); + commands.lspReferences(request, c.doc, { line: c.line, col: c.col }, false, true, libraries, channel), + ).finally(() => { + settled = true; + }); locs = refs.locations; classified = refs.classified ?? []; + librariesSkipped = refs.librariesSkipped ?? false; if (refs.incomplete) note = incompleteNote(refs.incomplete, c.word); } catch (e) { if (isCancelled(e)) throw e; @@ -210,8 +262,8 @@ export async function fetchUsages(c: CaretContext, request: RequestId = nextRequ locs = fb.locs; note = fb.note; } - const files = await readManyFileLines(locs.map((l) => l.path)); - return { groups: groupUsages(locs, files, classified), note }; + const files = await readManyFileLines(pathsToRead(locs, classified)); + return { groups: groupUsages(locs, files, classified), note, librariesSkipped }; } /** In-flight server requests: of tool window searches by search id, and of the popup. */ @@ -231,7 +283,8 @@ export function resetUsages() { if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {}); popupRequest = null; useUsages.setState({ tabs: [], active: null }); - useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null }); + useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null, loading: false, scope: null }); + popupTarget = null; } export async function findUsages() { @@ -240,9 +293,15 @@ export async function findUsages() { hint("Open a file to find usages"); return; } - const run = async (prev: number | null) => { + // `widenable`: an earlier run of this search skipped the libraries. + const run = async (prev: number | null, libraries: boolean, widenable: boolean) => { const id = ++searchSeq; - const base = { id, symbol: c.word, rerun: () => void run(id) }; + const base = { + id, + symbol: c.word, + rerun: () => void run(id, libraries, widenable), + searchLibraries: () => void run(id, true, true), + }; const loading = { ...base, loading: true, groups: [], error: null, note: null }; if (prev !== null) cancelSearch(prev); if (prev === null || !replaceUsageTab(prev, loading)) openUsageTab(loading); @@ -250,8 +309,16 @@ export async function findUsages() { const request = nextRequestId(); inflight.set(id, request); try { - const { groups, note } = await fetchUsages(c, request); - replaceUsageTab(id, { ...base, loading: false, groups, error: null, note }); + const { groups, note, librariesSkipped } = await fetchUsages( + c, + request, + (found) => { + if (inflight.get(id) === request) replaceUsageTab(id, { ...loading, groups: found.groups, note: found.note }); + }, + libraries, + ); + const scope = searchedScope(libraries, librariesSkipped, widenable); + replaceUsageTab(id, { ...base, loading: false, groups, error: null, note, scope }); } catch (e) { // Cancelled here (closed tab, rerun): a newer state already replaced it. if (isCancelled(e) && !inflight.has(id)) return; @@ -262,7 +329,7 @@ export async function findUsages() { if (inflight.get(id) === request) inflight.delete(id); } }; - await run(null); + await run(null, false, false); } /** @@ -300,34 +367,101 @@ export function dismissUsagesPopup() { useUsagesPopup.setState({ open: false }); } +/** How long Show Usages waits for all usages before it shows those found first. */ +const EARLY_POPUP_DELAY_MS = 300; +/** How long the hint after Show Usages stays (and invoking it again widens its search). */ +const HINT_MS = 4000; + +/** + * What the popup, or the hint after it, shows usages of: invoked again meanwhile, Show + * Usages searches it in Project and Libraries, wherever the caret is now. + */ +let popupTarget: { c: CaretContext; widenable: boolean; hintUntil: number } | null = null; + export async function showUsagesPopup() { + const target = popupTarget; + const popup = useUsagesPopup.getState(); + if (target?.widenable && ((popup.open && popup.scope === "project") || Date.now() < target.hintUntil)) { + await searchPopup(target.c, true, true); + return; + } const c = caretContext(); if (!c) { hint("Open a file to show usages"); return; } + await searchPopup(c, false, false); +} + +/** The popup's scope chooser: searches its symbol again in `scope`. */ +export function setUsagesPopupScope(scope: UsageScope) { + const target = popupTarget; + if (!target || scope === useUsagesPopup.getState().scope) return; + void searchPopup(target.c, scope === "libraries", target.widenable); +} + +async function searchPopup(c: CaretContext, libraries: boolean, widenable: boolean) { + // Set from the start: the scope chooser can switch while this search goes on. + popupTarget = { c, widenable, hintUntil: 0 }; + const reopened = useUsagesPopup.getState().open; + if (reopened) useUsagesPopup.setState({ loading: true, scope: libraries ? "libraries" : "project" }); if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {}); const request = nextRequestId(); popupRequest = request; + // Opened with those found first when the rest take a while; it then stays open, even + // when they were all. A quick search still jumps to a single usage. + let early = false; + let found: FetchedUsages | null = null; + let timer: ReturnType | undefined; + const openEarly = () => { + if (popupRequest !== request || !found) return; + if (!early && !reopened) useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word }); + early = true; + useUsagesPopup.setState({ ...found, loading: true, scope: widenable ? "libraries" : null }); + }; + const again = shortcutLabel("find.showUsages"); try { - const { groups, note } = await fetchUsages(c, request); + const { groups, note, librariesSkipped } = await fetchUsages( + c, + request, + (partial) => { + if (!usageCount(partial.groups)) return; + found = partial; + if (early) openEarly(); + else timer ??= setTimeout(openEarly, EARLY_POPUP_DELAY_MS); + }, + libraries, + ).finally(() => clearTimeout(timer)); if (popupRequest !== request) return; + const scope = searchedScope(libraries, librariesSkipped, widenable); + popupTarget = { c, widenable: widenable || scope !== null, hintUntil: 0 }; const total = usageCount(groups); + if (early || reopened || total > 1) { + if (!early && !reopened) useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word }); + useUsagesPopup.setState({ groups, note, loading: false, scope }); + return; + } + // As in IDEA, the hint offers to widen the search, which Show Usages does while it shows. + const widen = scope === "project" && again !== ""; + if (widen) popupTarget.hintUntil = Date.now() + HINT_MS; if (!total) { - const none = c.word ? `No usages of '${c.word}' found` : "No usages found"; - hint(note ? `${none}. ${note}` : none); + if (widen) hint(`No usages found in Project Files. Press ${again} again to search in 'Project and Libraries'`); + else if (scope === "project") hint("No usages found in Project Files"); + else { + const none = c.word ? `No usages of '${c.word}' found` : "No usages found"; + hint(note ? `${none}. ${note}` : none); + } return; } - if (total === 1) { - // IDEA jumps to the only usage and says so in the target editor. - const u = groups[0]!.usages[0]!; - await openLocation(u.path, u.line, u.col); - hint("Only one usage in Project Files"); - return; - } - useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word, groups, note }); + // IDEA jumps to the only usage and says so in the target editor. + const u = groups[0]!.usages[0]!; + await openLocation(u.path, u.line, u.col); + if (widen) hint(`Only one usage in Project Files. Press ${again} again to search in 'Project and Libraries'`); + else if (scope === "libraries") hint("Only one usage in Project and Libraries"); + else hint("Only one usage in Project Files"); } catch (e) { if (popupRequest !== request || isCancelled(e)) return; + if (early || reopened) useUsagesPopup.setState({ loading: false }); reportError(e); } finally { if (popupRequest === request) popupRequest = null; diff --git a/src/features/code-insight/usagesPopup.test.tsx b/src/features/code-insight/usagesPopup.test.tsx index 671c6a3c..9b7b15d5 100644 --- a/src/features/code-insight/usagesPopup.test.tsx +++ b/src/features/code-insight/usagesPopup.test.tsx @@ -2,16 +2,44 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; const calls: string[] = []; -const referenceResponse = vi.fn(async () => ({ locations: [loc], incomplete: null })); +const referenceResponse = vi.fn( + async (): Promise<{ locations: (typeof loc)[]; incomplete: null; librariesSkipped?: boolean }> => ({ + locations: [loc], + incomplete: null, + }), +); const loc = { path: "/p/b.ts", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } }; +const lspReferences = vi.fn(async () => ({ status: "ok", data: null })); +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { ...actual, commands: { ...actual.commands, lspReferences } }; +}); +vi.mock("@/platform/keymap", async (orig) => ({ + ...(await orig()), + shortcutLabel: (id: string) => (id === "find.showUsages" ? "⌥⌘F7" : ""), +})); vi.mock("./ipc", async (orig) => ({ ...(await orig()), caretContext: () => ({ doc: 1, path: "/p/a.ts", line: 0, col: 0, word: "used", view: null }), - lspRequest: () => referenceResponse(), + lspRequest: (request: () => unknown) => { + request(); + return referenceResponse(); + }, readManyFileLines: async () => new Map([["/p/b.ts", ["", " used();"]]]), openLocation: async (path: string, line: number) => void calls.push(`open ${path}:${line}`), hint: (text: string) => void calls.push(`hint ${text}`), })); +/** The partial-results channels of the searches, newest last. */ +const channels: { onmessage: (refs: unknown) => void }[] = []; +vi.mock("@tauri-apps/api/core", async (orig) => ({ + ...(await orig()), + Channel: class { + onmessage = () => {}; + constructor() { + channels.push(this); + } + }, +})); const showUsageResults = vi.fn(); vi.mock("./usages", async (orig) => ({ ...(await orig()), @@ -19,7 +47,7 @@ vi.mock("./usages", async (orig) => ({ })); const { UsagesPopup, filterUsages, usageLocations } = await import("./UsagesPopup"); -const { showUsagesPopup, useUsagesPopup, dismissUsagesPopup } = await import("./usages"); +const { showUsagesPopup, useUsagesPopup, dismissUsagesPopup, setUsagesPopupScope } = await import("./usages"); const usage = (path: string, line: number, text: string) => ({ path, line, col: 0, endCol: 3, text }); const groups = [ @@ -198,6 +226,127 @@ it("keeps filter controls available when all classified rows are hidden", () => expect(screen.getAllByRole("option")).toHaveLength(2); }); +it("opens with the usages found first and fills in the rest", async () => { + render(); + const at = (line: number) => ({ ...loc, range: { start: { line, col: 4 }, end: { line, col: 8 } } }); + let resolve!: (value: { locations: (typeof loc)[]; incomplete: null }) => void; + referenceResponse.mockReturnValueOnce( + new Promise((r) => { + resolve = r; + }), + ); + const before = calls.length; + const pending = showUsagesPopup(); + await act(async () => channels.at(-1)!.onmessage({ locations: [at(1)], incomplete: null })); + // Not before the rest had a moment to arrive. + expect(useUsagesPopup.getState().open).toBe(false); + await act(() => new Promise((r) => setTimeout(r, 350))); + expect(useUsagesPopup.getState()).toMatchObject({ open: true, loading: true }); + expect(screen.getByRole("status", { name: "Searching for more usages" })).toBeInTheDocument(); + expect(screen.getAllByRole("option")).toHaveLength(1); + await act(async () => channels.at(-1)!.onmessage({ locations: [at(1), at(2)], incomplete: null })); + expect(screen.getAllByRole("option")).toHaveLength(2); + await act(async () => { + resolve({ locations: [at(1), at(2), at(3)], incomplete: null }); + await pending; + }); + expect(useUsagesPopup.getState()).toMatchObject({ open: true, loading: false }); + expect(screen.queryByRole("status", { name: "Searching for more usages" })).toBeNull(); + expect(screen.getAllByRole("option")).toHaveLength(3); + expect(calls.slice(before)).toEqual([]); + act(() => dismissUsagesPopup()); +}); + +it("jumps to a single usage when all arrive quickly, and otherwise stays open", async () => { + render(); + let resolve!: (value: { locations: (typeof loc)[]; incomplete: null }) => void; + const respond = () => + referenceResponse.mockReturnValueOnce( + new Promise((r) => { + resolve = r; + }), + ); + respond(); + let before = calls.length; + let pending = showUsagesPopup(); + await act(async () => channels.at(-1)!.onmessage({ locations: [loc], incomplete: null })); + await act(async () => { + resolve({ locations: [loc], incomplete: null }); + await pending; + }); + expect(useUsagesPopup.getState().open).toBe(false); + expect(calls.slice(before)).toEqual(["open /p/b.ts:1", "hint Only one usage in Project Files"]); + respond(); + before = calls.length; + pending = showUsagesPopup(); + await act(async () => channels.at(-1)!.onmessage({ locations: [loc], incomplete: null })); + await act(() => new Promise((r) => setTimeout(r, 350))); + await act(async () => { + resolve({ locations: [loc], incomplete: null }); + await pending; + }); + expect(useUsagesPopup.getState()).toMatchObject({ open: true, loading: false }); + expect(screen.getAllByRole("option")).toHaveLength(1); + expect(calls.slice(before)).toEqual([]); + act(() => dismissUsagesPopup()); +}); + +it("searches Project Files, and Project and Libraries when invoked again", async () => { + render(); + const at = (line: number) => ({ ...loc, range: { start: { line, col: 4 }, end: { line, col: 8 } } }); + const libraries = () => (lspReferences.mock.lastCall as unknown[])[5]; + const widen = "Press ⌥⌘F7 again to search in 'Project and Libraries'"; + // Nothing in Project Files: the hint offers the libraries, searched when invoked again meanwhile. + let before = calls.length; + referenceResponse.mockResolvedValueOnce({ locations: [], incomplete: null, librariesSkipped: true }); + await showUsagesPopup(); + expect(libraries()).toBe(false); + expect(calls.slice(before)).toEqual([`hint No usages found in Project Files. ${widen}`]); + referenceResponse.mockResolvedValueOnce({ locations: [at(1), at(2)], incomplete: null }); + await act(() => showUsagesPopup()); + expect(libraries()).toBe(true); + expect(useUsagesPopup.getState()).toMatchObject({ open: true, scope: "libraries" }); + expect(screen.getByRole("button", { name: "Scope" })).toHaveTextContent("Project and Libraries"); + // The scope chooser searches Project Files again. + referenceResponse.mockResolvedValueOnce({ locations: [at(1), at(2)], incomplete: null, librariesSkipped: true }); + await act(async () => setUsagesPopupScope("project")); + await vi.waitFor(() => expect(useUsagesPopup.getState()).toMatchObject({ loading: false, scope: "project" })); + expect(libraries()).toBe(false); + expect(screen.getByRole("button", { name: "Scope" })).toHaveTextContent("Project Files"); + // Switched while Project and Libraries are still searched: that search gives way. + let finish!: (value: { locations: (typeof loc)[]; incomplete: null }) => void; + referenceResponse.mockReturnValueOnce(new Promise((r) => (finish = r))); + await act(async () => setUsagesPopupScope("libraries")); + expect(libraries()).toBe(true); + expect(useUsagesPopup.getState()).toMatchObject({ loading: true, scope: "libraries" }); + referenceResponse.mockResolvedValueOnce({ locations: [at(1), at(2)], incomplete: null, librariesSkipped: true }); + await act(async () => setUsagesPopupScope("project")); + await vi.waitFor(() => expect(useUsagesPopup.getState()).toMatchObject({ loading: false, scope: "project" })); + expect(libraries()).toBe(false); + await act(async () => finish({ locations: [at(1), at(2), at(3), at(4)], incomplete: null })); + expect(screen.getAllByRole("option")).toHaveLength(2); + // Invoked again while it shows Project Files: Project and Libraries. + referenceResponse.mockResolvedValueOnce({ locations: [at(1), at(2), at(3)], incomplete: null }); + await act(() => showUsagesPopup()); + expect(libraries()).toBe(true); + expect(screen.getAllByRole("option")).toHaveLength(3); + act(() => dismissUsagesPopup()); + // The only usage in Project Files is opened; the hint offers the libraries too. + before = calls.length; + referenceResponse.mockResolvedValueOnce({ locations: [at(1)], incomplete: null, librariesSkipped: true }); + await showUsagesPopup(); + expect(calls.slice(before)).toEqual(["open /p/b.ts:1", `hint Only one usage in Project Files. ${widen}`]); + referenceResponse.mockResolvedValueOnce({ locations: [at(1)], incomplete: null }); + before = calls.length; + await showUsagesPopup(); + expect(libraries()).toBe(true); + expect(calls.slice(before)).toEqual(["open /p/b.ts:1", "hint Only one usage in Project and Libraries"]); + // Without a hint showing, it searches the symbol at the caret in Project Files again. + referenceResponse.mockResolvedValueOnce({ locations: [at(1)], incomplete: null, librariesSkipped: true }); + await showUsagesPopup(); + expect(libraries()).toBe(false); +}); + it("discards a pending response after the current popup is dismissed", async () => { let resolve!: (value: { locations: (typeof loc)[]; incomplete: null }) => void; referenceResponse.mockReturnValueOnce( diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 55d45068..b9e5bffe 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1431,6 +1431,10 @@ "No structure to show": "没有可显示的结构", "No tools match.": "没有匹配的工具。", "No usages found": "未找到用法", + "No usages found in Project Files": "在项目文件中未找到用法", + "No usages found in Project Files. Press {again} again to search in 'Project and Libraries'": "在项目文件中未找到用法。再按一次 {again} 在“项目和库”中搜索", + "Search in Project and Libraries": "在项目和库中搜索", + "Usages in Project Files": "项目文件中的用法", "No usages of '{name}' found": "未找到 '{name}' 的用法", "Node interpreter": "Node 解释器", "None": "无", @@ -1458,6 +1462,11 @@ "Older output is discarded": "较早的输出将被丢弃", "Only in comments": "仅在注释中", "Only one usage in Project Files": "项目文件中只有一个用法", + "Only one usage in Project and Libraries": "项目和库中只有一个用法", + "Only one usage in Project Files. Press {again} again to search in 'Project and Libraries'": "项目文件中只有一个用法。再按一次 {again} 在“项目和库”中搜索", + "Project and Libraries": "项目和库", + "Usages of '{symbol}' in Project Files": "'{symbol}' 在项目文件中的用法", + "Usages of '{symbol}' in Project and Libraries": "'{symbol}' 在项目和库中的用法", "Open": "打开", "Open Diff": "打开差异", "Open File": "打开文件", @@ -2018,6 +2027,7 @@ "Searches": "搜索", "Searching": "正在搜索", "Searching for usages": "正在搜索用法", + "Searching for more usages": "正在搜索更多用法", "Second stroke": "第二次击键", "Second stroke:": "第二次击键:", "Select All": "全选",