mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 02:06:05 -10:00
feat(usages): search Java usages in Project Files first, Project and Libraries on demand (#1837)
This commit is contained in:
1 parent
ef95cd1716
commit
a6d17543fd
26 files changed
+963
-96
No files matched your search
@@ -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<References>,
|
||||
) -> IdeResult<References> {
|
||||
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
|
||||
});
|
||||
|
||||
@@ -303,7 +303,18 @@ impl Smoke {
|
||||
let (doc, pos) = self.locate(of).await;
|
||||
let want: BTreeSet<String> = 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)))),
|
||||
|
||||
+91
-20
@@ -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<Location>) + 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<Location>, more: Vec<Location>) -> Vec<Location> {
|
||||
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<References> {
|
||||
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<References> {
|
||||
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<References> {
|
||||
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<ReferenceAccess>,
|
||||
location: lsp::Location,
|
||||
kind: ReferenceKind,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
let result: IdeResult<Vec<JavaReference>> = 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::<Vec<_>>())
|
||||
@@ -1493,10 +1532,42 @@ impl LspManager {
|
||||
let files = |v: &Vec<ClassifiedReference>| {
|
||||
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<Location> = 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 {
|
||||
|
||||
@@ -380,6 +380,10 @@ pub struct References {
|
||||
pub classified: Vec<ClassifiedReference>,
|
||||
pub locations: Vec<Location>,
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
/// 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<ReferenceAccess>,
|
||||
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<String>,
|
||||
}
|
||||
@@ -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<Value> = 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::<Vec<_>>();
|
||||
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] {
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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<Location, String> 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<Map<String, Object>> 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<String, String[]> 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<String, Object> 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<String, String[]> 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<Location> 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<Location> 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<Location, String> results, IProgressMonitor monitor) throws Exception {
|
||||
if (element == null) return;
|
||||
List<Location> 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);
|
||||
|
||||
@@ -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]})
|
||||
|
||||
@@ -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<References>,
|
||||
) -> IdeResult<References> {
|
||||
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
|
||||
}
|
||||
|
||||
+9
-2
@@ -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<References, IdeError>(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide })),
|
||||
lspReferences: (request: RequestId, doc: DocId, pos: Pos, includeDeclaration: boolean, projectWide: boolean, libraries: boolean, onPartial: Channel<References>) => typedError<References, IdeError>(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide, libraries, onPartial })),
|
||||
lspDocumentHighlights: (doc: DocId, pos: Pos) => typedError<Highlight[], IdeError>(__TAURI_INVOKE("lsp_document_highlights", { doc, pos })),
|
||||
lspWorkspaceSymbols: (request: RequestId, query: string) => typedError<WorkspaceSymbol[], IdeError>(__TAURI_INVOKE("lsp_workspace_symbols", { request, query })),
|
||||
lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError<CodeAction[], IdeError>(__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. */
|
||||
|
||||
@@ -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<object>()),
|
||||
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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<object>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
},
|
||||
}));
|
||||
let answer = "cancel";
|
||||
const ask = vi.fn(async (_request: MessageRequest) => ({ button: answer, checked: false }));
|
||||
let confirmAnswer: { checked: boolean } | null = { checked: false };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Usage[]>(() => 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`}
|
||||
</span>
|
||||
</span>
|
||||
{scope && (
|
||||
<Select<UsageScope>
|
||||
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 && (
|
||||
<span className={styles.searching} role="status" aria-label={tx("Searching for more usages")}>
|
||||
<Spinner size={12} />
|
||||
</span>
|
||||
)}
|
||||
{filter && (
|
||||
<span className={clsx(styles.filter, !items.length && styles.noMatch)} role="status">
|
||||
{filter}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<Tabs
|
||||
variant="compact"
|
||||
@@ -268,6 +276,16 @@ export function UsagesView() {
|
||||
</div>
|
||||
<div className={styles.body} onKeyDown={onKeyDown}>
|
||||
<UsageAccessFilter items={allUsages} />
|
||||
{!search.loading && !search.error && search.scope === "project" && (
|
||||
<div className={styles.summary}>
|
||||
<T>Usages in Project Files</T>
|
||||
{search.searchLibraries && (
|
||||
<button type="button" className={styles.link} onClick={() => search.searchLibraries?.()}>
|
||||
{tx("Search in Project and Libraries")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(search.loading || search.error) && (
|
||||
<div className={styles.summary} role="status" aria-live="polite">
|
||||
{search.loading ? (
|
||||
|
||||
@@ -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<object>()),
|
||||
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(
|
||||
|
||||
@@ -5,12 +5,33 @@ vi.mock("@/workbench/contributions", async (orig) => ({
|
||||
...(await orig<typeof import("@/workbench/contributions")>()),
|
||||
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<typeof import("@/platform/ipc")>();
|
||||
return { ...actual, commands: { ...actual.commands, lspReferences } };
|
||||
});
|
||||
vi.mock("./ipc", async (orig) => ({
|
||||
...(await orig<typeof import("./ipc")>()),
|
||||
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<object>()),
|
||||
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,
|
||||
|
||||
@@ -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<UsagesPopupState>(() => ({
|
||||
@@ -171,31 +190,64 @@ export const useUsagesPopup = create<UsagesPopupState>(() => ({
|
||||
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<FetchedUsages> {
|
||||
export async function fetchUsages(
|
||||
c: CaretContext,
|
||||
request: RequestId = nextRequestId(),
|
||||
onPartial?: (found: FetchedUsages) => void,
|
||||
libraries = false,
|
||||
): Promise<FetchedUsages> {
|
||||
// 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<References>();
|
||||
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<typeof setTimeout> | 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;
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
return { ...actual, commands: { ...actual.commands, lspReferences } };
|
||||
});
|
||||
vi.mock("@/platform/keymap", async (orig) => ({
|
||||
...(await orig<typeof import("@/platform/keymap")>()),
|
||||
shortcutLabel: (id: string) => (id === "find.showUsages" ? "⌥⌘F7" : ""),
|
||||
}));
|
||||
vi.mock("./ipc", async (orig) => ({
|
||||
...(await orig<typeof import("./ipc")>()),
|
||||
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<object>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
constructor() {
|
||||
channels.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
const showUsageResults = vi.fn();
|
||||
vi.mock("./usages", async (orig) => ({
|
||||
...(await orig<typeof import("./usages")>()),
|
||||
@@ -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(<UsagesPopup />);
|
||||
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(<UsagesPopup />);
|
||||
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(<UsagesPopup />);
|
||||
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(
|
||||
|
||||
@@ -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": "全选",
|
||||
|
||||
Reference in new issue
Block a user