mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(lsp): report incomplete rename and caller searches, make them cancellable
Rename and incoming calls open the project files that mention the name, like Find Usages, but dropped the reason a search could miss usages and could not be cancelled. A rename past the 300-file limit changed part of the usages without saying so. - Rename and call hierarchy return why the result may be incomplete and take a request id for cancel_request. - A rename that may have missed usages always opens the preview, with a warning. - The Callers tree shows the note above the tree or on the expanded node. - Candidates too large or not readable as text are reported as skipped. - A newer rename or hierarchy query cancels the one it replaces.
This commit is contained in:
@@ -186,8 +186,19 @@ pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos)
|
||||
}
|
||||
|
||||
/// Computes the rename edit without applying it, so the UI can preview conflicts.
|
||||
pub async fn lsp_rename(host: &Host, window: &str, doc: DocId, pos: Pos, new_name: String) -> IdeResult<WorkspaceEdit> {
|
||||
host.project(window)?.lsp.rename(doc, pos, new_name).await
|
||||
pub async fn lsp_rename(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
new_name: String,
|
||||
) -> IdeResult<Renamed> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.rename(doc, pos, new_name, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_format(
|
||||
@@ -220,11 +231,16 @@ pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeRes
|
||||
pub async fn lsp_call_hierarchy(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
host.project(window)?.lsp.call_hierarchy(doc, pos, direction).await
|
||||
) -> IdeResult<CallHierarchy> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.call_hierarchy(doc, pos, direction, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_type_hierarchy(
|
||||
@@ -335,7 +351,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
lsp_prepare_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("lsp_rename", |c| async move {
|
||||
lsp_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
|
||||
lsp_rename(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
|
||||
});
|
||||
d.add("lsp_format", |c| async move {
|
||||
lsp_format(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("options")?).await
|
||||
@@ -348,7 +364,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_code_lens", |c| async move { lsp_code_lens(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("lsp_document_links", |c| async move { lsp_document_links(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("lsp_call_hierarchy", |c| async move {
|
||||
lsp_call_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
|
||||
lsp_call_hierarchy(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_type_hierarchy", |c| async move {
|
||||
lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
|
||||
|
||||
+53
-23
@@ -1393,21 +1393,34 @@ impl LspManager {
|
||||
/// server's analysis scope that mention the name are opened for the request.
|
||||
/// Every server that can rename there does: the Angular server adds the
|
||||
/// templates to vtsls's edit, whose own edits win where both change the same text.
|
||||
pub async fn rename(&self, doc: DocId, pos: Pos, new_name: String) -> IdeResult<WorkspaceEdit> {
|
||||
pub async fn rename(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
new_name: String,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<Renamed> {
|
||||
let (snap, targets) = self.doc_targets(doc, "rename", |c| c.has("renameProvider")).await?;
|
||||
let renaming = self.rename_targets(&snap, &targets, pos).await;
|
||||
let edits = join_all(renaming.into_iter().map(|t| self.rename_on(&snap, t, pos, &new_name))).await;
|
||||
let edits = join_all(renaming.into_iter().map(|t| self.rename_on(&snap, t, pos, &new_name, &cancel))).await;
|
||||
let mut out = Outcome::new();
|
||||
let mut incomplete = None;
|
||||
for edit in edits {
|
||||
match edit {
|
||||
Ok(edit) => match &mut out.value {
|
||||
Some(merged) => convert::merge_workspace_edit(merged, edit),
|
||||
None => out.value = Some(edit),
|
||||
},
|
||||
Ok(renamed) => {
|
||||
incomplete = incomplete.or(renamed.incomplete);
|
||||
match &mut out.value {
|
||||
Some(merged) => convert::merge_workspace_edit(merged, renamed.edit),
|
||||
None => out.value = Some(renamed.edit),
|
||||
}
|
||||
}
|
||||
Err(e) => out.fail(e),
|
||||
}
|
||||
}
|
||||
out.finish(WorkspaceEdit::default())
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(Renamed { edit: out.finish(WorkspaceEdit::default())?, incomplete })
|
||||
}
|
||||
|
||||
/// The servers that can rename at `pos`, the first one when none can (a
|
||||
@@ -1424,16 +1437,27 @@ impl LspManager {
|
||||
vec![&targets[0]]
|
||||
}
|
||||
|
||||
async fn rename_on(&self, snap: &DocSnap, t: &Target, pos: Pos, new_name: &str) -> IdeResult<WorkspaceEdit> {
|
||||
async fn rename_on(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
t: &Target,
|
||||
pos: Pos,
|
||||
new_name: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Renamed> {
|
||||
let query = || async {
|
||||
let mut params = snap.pos_params(t, pos);
|
||||
params["newName"] = json!(new_name);
|
||||
let r: Option<lsp::WorkspaceEdit> = self.call(t, "textDocument/rename", params, None, LONG_TIMEOUT).await?;
|
||||
let r: Option<lsp::WorkspaceEdit> =
|
||||
self.call(t, "textDocument/rename", params, Some(cancel), LONG_TIMEOUT).await?;
|
||||
Ok(r.map(|e| convert::workspace_edit(&self.conv(t.enc()), e)).unwrap_or_default())
|
||||
};
|
||||
// The Angular server renames in the file's own project only, whatever is open.
|
||||
if t.server.name == angular::SERVER {
|
||||
return query().await;
|
||||
let edit = query().await?;
|
||||
let empty = edit.files.is_empty();
|
||||
let w = scope::Widened::only((), empty, &t.server);
|
||||
return Ok(Renamed { edit, incomplete: w.incomplete });
|
||||
}
|
||||
let files = |e: &WorkspaceEdit| {
|
||||
e.files
|
||||
@@ -1444,9 +1468,9 @@ impl LspManager {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let w = self.widened(snap, t, pos, &CancellationToken::new(), query, files).await?;
|
||||
let w = self.widened(snap, t, pos, cancel, query, files).await?;
|
||||
// The second edit covers the first: its files stay analysed.
|
||||
Ok(w.second.unwrap_or(w.first))
|
||||
Ok(Renamed { edit: w.second.unwrap_or(w.first), incomplete: w.incomplete })
|
||||
}
|
||||
|
||||
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
|
||||
@@ -1741,11 +1765,14 @@ impl LspManager {
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<CallHierarchy> {
|
||||
let (snap, targets) = self.doc_targets(doc, "call hierarchy", |c| c.has("callHierarchyProvider")).await?;
|
||||
let t = &targets[0];
|
||||
let items: Option<Vec<lsp::CallHierarchyItem>> =
|
||||
self.call(t, "textDocument/prepareCallHierarchy", snap.pos_params(t, pos), None, DEFAULT_TIMEOUT).await?;
|
||||
let cancel = &cancel;
|
||||
let items: Option<Vec<lsp::CallHierarchyItem>> = self
|
||||
.call(t, "textDocument/prepareCallHierarchy", snap.pos_params(t, pos), Some(cancel), DEFAULT_TIMEOUT)
|
||||
.await?;
|
||||
let items = items.filter(|i| !i.is_empty()).ok_or_else(no_hierarchy_target)?;
|
||||
let query = || async {
|
||||
let mut out = Vec::new();
|
||||
@@ -1754,7 +1781,7 @@ impl LspManager {
|
||||
match direction {
|
||||
HierarchyDirection::Incoming => {
|
||||
let calls: Option<Vec<lsp::CallHierarchyIncomingCall>> =
|
||||
self.call(t, "callHierarchy/incomingCalls", params, None, LONG_TIMEOUT).await?;
|
||||
self.call(t, "callHierarchy/incomingCalls", params, Some(cancel), LONG_TIMEOUT).await?;
|
||||
let conv = self.conv(t.enc());
|
||||
for c in calls.unwrap_or_default() {
|
||||
let (path, _) = uri_target(c.from.uri.as_str());
|
||||
@@ -1764,7 +1791,7 @@ impl LspManager {
|
||||
}
|
||||
HierarchyDirection::Outgoing => {
|
||||
let calls: Option<Vec<lsp::CallHierarchyOutgoingCall>> =
|
||||
self.call(t, "callHierarchy/outgoingCalls", params, None, LONG_TIMEOUT).await?;
|
||||
self.call(t, "callHierarchy/outgoingCalls", params, Some(cancel), LONG_TIMEOUT).await?;
|
||||
let conv = self.conv(t.enc());
|
||||
let (src, _) = uri_target(item.uri.as_str());
|
||||
for c in calls.unwrap_or_default() {
|
||||
@@ -1776,19 +1803,22 @@ impl LspManager {
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
let out = match direction {
|
||||
let (out, incomplete) = match direction {
|
||||
// Callers are references: they may sit in files outside the server's scope.
|
||||
HierarchyDirection::Incoming => {
|
||||
let files = |v: &Vec<HierarchyItem>| {
|
||||
v.iter().filter(|i| i.uri.is_none()).map(|i| i.path.to_path_buf()).collect()
|
||||
};
|
||||
let w = self.widened(&snap, t, pos, &CancellationToken::new(), query, files).await?;
|
||||
w.second.unwrap_or(w.first)
|
||||
let w = self.widened(&snap, t, pos, cancel, query, files).await?;
|
||||
(w.second.unwrap_or(w.first), w.incomplete)
|
||||
}
|
||||
HierarchyDirection::Outgoing => query().await?,
|
||||
HierarchyDirection::Outgoing => (query().await?, None),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
self.note_virtual(&t.server, out.iter().map(|i| i.uri.as_deref()));
|
||||
Ok(out)
|
||||
Ok(CallHierarchy { items: out, incomplete })
|
||||
}
|
||||
|
||||
pub async fn type_hierarchy(
|
||||
@@ -2246,7 +2276,7 @@ mod send_check {
|
||||
assert_send(m.hover(doc, Pos::default(), CancellationToken::new()));
|
||||
assert_send(m.code_lens(doc));
|
||||
assert_send(m.workspace_symbols("", CancellationToken::new()));
|
||||
assert_send(m.call_hierarchy(doc, Pos::default(), HierarchyDirection::Incoming));
|
||||
assert_send(m.call_hierarchy(doc, Pos::default(), HierarchyDirection::Incoming, CancellationToken::new()));
|
||||
assert_send(m.restart(ide_core::ServerId(1)));
|
||||
assert_send(m.shutdown_all());
|
||||
assert_send(m.semantic_tokens(doc));
|
||||
|
||||
+30
-10
@@ -138,6 +138,9 @@ impl LspManager {
|
||||
return Ok(Widened { first, second: None, incomplete });
|
||||
}
|
||||
let opened = self.open_transient(t, files, cancel).await;
|
||||
if opened.skipped {
|
||||
incomplete = incomplete.or(Some(ReferencesIncomplete::FilesSkipped));
|
||||
}
|
||||
if opened.files.is_empty() {
|
||||
return Ok(Widened { first, second: None, incomplete });
|
||||
}
|
||||
@@ -160,22 +163,37 @@ impl LspManager {
|
||||
}
|
||||
|
||||
/// Opens `files` on `t` (read from disk) unless the user has them open;
|
||||
/// they are closed when the returned guard drops.
|
||||
/// they are closed when the returned guard drops. Files too large or not
|
||||
/// readable as text are left out and flagged on the guard.
|
||||
async fn open_transient(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> TransientFiles<'_> {
|
||||
let read = {
|
||||
// The server already sees the user's files, however large.
|
||||
let open: HashSet<PathBuf> = self.docs.lock().values().map(|d| d.path.clone()).collect();
|
||||
let (read, skipped) = {
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
files
|
||||
.into_iter()
|
||||
.take_while(|_| !cancel.is_cancelled())
|
||||
.filter(|f| std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() <= MAX_CANDIDATE_BYTES))
|
||||
.filter_map(|f| std::fs::read_to_string(&f).ok().map(|text| (f, text)))
|
||||
.collect::<Vec<_>>()
|
||||
let mut read = Vec::new();
|
||||
let mut skipped = false;
|
||||
for f in files.into_iter().filter(|f| !open.contains(f)) {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
// A file gone since the search no longer mentions anything.
|
||||
let Ok(meta) = std::fs::metadata(&f) else { continue };
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
match (meta.len() <= MAX_CANDIDATE_BYTES).then(|| std::fs::read_to_string(&f)) {
|
||||
Some(Ok(text)) => read.push((f, text)),
|
||||
_ => skipped = true,
|
||||
}
|
||||
}
|
||||
(read, skipped)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut guard = TransientFiles { mgr: self, server: t.server.clone(), run: t.run.clone(), files: Vec::new() };
|
||||
let mut guard = TransientFiles::empty(self, t);
|
||||
guard.skipped = skipped;
|
||||
if cancel.is_cancelled() {
|
||||
return guard;
|
||||
}
|
||||
@@ -206,6 +224,8 @@ pub(crate) struct TransientFiles<'a> {
|
||||
server: Arc<Server>,
|
||||
run: Running,
|
||||
pub files: Vec<(PathBuf, lsp::Uri)>,
|
||||
/// Candidates that could not be opened.
|
||||
pub skipped: bool,
|
||||
}
|
||||
|
||||
impl TransientFiles<'_> {
|
||||
@@ -226,7 +246,7 @@ impl TransientFiles<'_> {
|
||||
|
||||
impl<'a> TransientFiles<'a> {
|
||||
pub(crate) fn empty(mgr: &'a LspManager, t: &Target) -> Self {
|
||||
Self { mgr, server: t.server.clone(), run: t.run.clone(), files: Vec::new() }
|
||||
Self { mgr, server: t.server.clone(), run: t.run.clone(), files: Vec::new(), skipped: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -331,6 +331,8 @@ pub enum ReferencesIncomplete {
|
||||
Indexing,
|
||||
/// More project files mention the name than were checked.
|
||||
TooManyFiles,
|
||||
/// Files that mention the name were too large or unreadable to check.
|
||||
FilesSkipped,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
@@ -339,3 +341,17 @@ pub struct References {
|
||||
pub locations: Vec<Location>,
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Renamed {
|
||||
pub edit: WorkspaceEdit,
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallHierarchy {
|
||||
pub items: Vec<HierarchyItem>,
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
|
||||
@@ -1145,7 +1145,7 @@ async fn navigation_rename_format_and_decorations() {
|
||||
s.respond("textDocument/prepareRename", json!({ "range": r(1,0,1,3), "placeholder": "foo" }));
|
||||
assert_eq!(h.mgr.prepare_rename(doc, Pos::new(1, 1)).await.unwrap(), Some(rng(1, 0, 1, 3)));
|
||||
s.respond("textDocument/rename", json!({ "changes": { uri.clone(): [{ "range": r(0,3,0,6), "newText": "bar" }, { "range": r(1,0,1,3), "newText": "bar" }] } }));
|
||||
let edit = h.mgr.rename(doc, Pos::new(1, 1), "bar".into()).await.unwrap();
|
||||
let edit = h.mgr.rename(doc, Pos::new(1, 1), "bar".into(), Default::default()).await.unwrap().edit;
|
||||
assert!(matches!(&edit.files[0], FileEdit::Edit { edits, .. } if edits.len() == 2));
|
||||
assert_eq!(s.requested("textDocument/rename")[0]["newName"], "bar");
|
||||
|
||||
@@ -1182,13 +1182,19 @@ async fn navigation_rename_format_and_decorations() {
|
||||
|
||||
// Nothing to prepare at the position: NotFound, so the UI can retry at the enclosing declaration.
|
||||
s.respond("textDocument/prepareCallHierarchy", json!(null));
|
||||
let err = h.mgr.call_hierarchy(doc, Pos::new(0, 1), HierarchyDirection::Incoming).await.unwrap_err();
|
||||
let err =
|
||||
h.mgr.call_hierarchy(doc, Pos::new(0, 1), HierarchyDirection::Incoming, Default::default()).await.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
|
||||
|
||||
let item = json!({ "name": "foo", "kind": 12, "uri": uri, "range": r(0,0,0,11), "selectionRange": r(0,3,0,6), "data": "id" });
|
||||
s.respond("textDocument/prepareCallHierarchy", json!([item]));
|
||||
s.respond("callHierarchy/incomingCalls", json!([{ "from": { "name": "main", "kind": 12, "uri": uri, "range": r(1,0,1,6), "selectionRange": r(1,0,1,3) }, "fromRanges": [r(1,0,1,3)] }]));
|
||||
let calls = h.mgr.call_hierarchy(doc, Pos::new(0, 4), HierarchyDirection::Incoming).await.unwrap();
|
||||
let calls = h
|
||||
.mgr
|
||||
.call_hierarchy(doc, Pos::new(0, 4), HierarchyDirection::Incoming, Default::default())
|
||||
.await
|
||||
.unwrap()
|
||||
.items;
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "main");
|
||||
assert_eq!(calls[0].from_ranges, vec![rng(1, 0, 1, 3)]);
|
||||
@@ -1233,7 +1239,7 @@ async fn rename_merges_the_servers_that_can_rename_there() {
|
||||
);
|
||||
|
||||
assert_eq!(h.mgr.prepare_rename(doc, Pos::new(0, 15)).await.unwrap(), Some(rng(0, 14, 0, 19)));
|
||||
let edit = h.mgr.rename(doc, Pos::new(0, 15), "heading".into()).await.unwrap();
|
||||
let edit = h.mgr.rename(doc, Pos::new(0, 15), "heading".into(), Default::default()).await.unwrap().edit;
|
||||
assert_eq!(edits_in(&edit, &path).len(), 2);
|
||||
assert!(h.launcher.state("ts").requested("textDocument/rename").is_empty());
|
||||
|
||||
@@ -1245,7 +1251,7 @@ async fn rename_merges_the_servers_that_can_rename_there() {
|
||||
"textDocument/rename",
|
||||
json!({ "changes": { uri: [{ "range": r(1, 0, 5), "newText": "heading: x" }] } }),
|
||||
);
|
||||
let edit = h.mgr.rename(doc, Pos::new(1, 2), "heading".into()).await.unwrap();
|
||||
let edit = h.mgr.rename(doc, Pos::new(1, 2), "heading".into(), Default::default()).await.unwrap().edit;
|
||||
assert_eq!(edits_in(&edit, &path), [(rng(0, 14, 0, 19), "heading".into()), (rng(1, 0, 1, 5), "heading: x".into())]);
|
||||
assert_eq!(edits_in(&edit, &html), [(rng(0, 3, 0, 8), "heading".into())]);
|
||||
}
|
||||
@@ -1781,7 +1787,7 @@ fn occurrences(uri: &str, text: &str) -> Vec<Value> {
|
||||
/// A server that only analyses its open documents (minus `blind` URIs), like
|
||||
/// Pyright for files outside its `include`.
|
||||
fn open_files_only(blind: Vec<String>) -> Responder {
|
||||
Arc::new(move |s, method, _| {
|
||||
Arc::new(move |s, method, params| {
|
||||
let docs: Vec<(String, String)> =
|
||||
s.docs.lock().iter().filter(|(u, _)| !blind.contains(u)).map(|(u, t)| (u.clone(), t.clone())).collect();
|
||||
let locs = || docs.iter().flat_map(|(u, t)| occurrences(u, t));
|
||||
@@ -1795,6 +1801,19 @@ fn open_files_only(blind: Vec<String>) -> Responder {
|
||||
}
|
||||
Some(json!({ "changes": changes }))
|
||||
}
|
||||
"textDocument/prepareCallHierarchy" => {
|
||||
let uri = ¶ms["textDocument"]["uri"];
|
||||
let at = json!({ "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } });
|
||||
Some(json!([{ "name": WORD, "kind": 12, "uri": uri, "range": at, "selectionRange": at }]))
|
||||
}
|
||||
"callHierarchy/incomingCalls" => Some(Value::Array(
|
||||
locs()
|
||||
.map(|l| {
|
||||
let from = json!({ "name": "caller", "kind": 12, "uri": l["uri"], "range": l["range"], "selectionRange": l["range"] });
|
||||
json!({ "from": from, "fromRanges": [l["range"]] })
|
||||
})
|
||||
.collect(),
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
@@ -1916,6 +1935,113 @@ async fn references_report_a_capped_candidate_search() {
|
||||
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::TooManyFiles));
|
||||
}
|
||||
|
||||
/// What each search widened with candidate files reports for the symbol at the start of `doc`.
|
||||
async fn incomplete_reports(h: &Harness, doc: DocId) -> Vec<(&'static str, Option<ReferencesIncomplete>)> {
|
||||
let at = Pos::new(0, 5);
|
||||
let cancel = ide_core::CancellationToken::new;
|
||||
vec![
|
||||
("references", h.mgr.references(doc, at, true, true, cancel()).await.unwrap().incomplete),
|
||||
("rename", h.mgr.rename(doc, at, "renamed".into(), cancel()).await.unwrap().incomplete),
|
||||
("callers", h.mgr.call_hierarchy(doc, at, HierarchyDirection::Incoming, cancel()).await.unwrap().incomplete),
|
||||
]
|
||||
}
|
||||
|
||||
fn expect_all(reports: Vec<(&'static str, Option<ReferencesIncomplete>)>, want: Option<ReferencesIncomplete>) {
|
||||
for (search, got) in reports {
|
||||
assert_eq!(got, want, "{search}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn every_widened_search_reports_a_capped_candidate_search() {
|
||||
let (h, s) = scope_harness();
|
||||
h.mgr.set_candidates_hook(Box::new(|q, _| ide_lsp::CandidateFiles {
|
||||
files: vec![q.root.join("b.mk")],
|
||||
limit_hit: true,
|
||||
}));
|
||||
std::fs::write(h.root.join("b.mk"), "target\n").unwrap();
|
||||
let (doc, _) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
expect_all(incomplete_reports(&h, doc).await, Some(ReferencesIncomplete::TooManyFiles));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn every_widened_search_reports_candidates_it_could_not_open() {
|
||||
let oversized = "target\n".repeat(200_000);
|
||||
// Name, content (`None`: not on disk), whether the search misses it.
|
||||
let cases: [(&str, Option<&[u8]>, bool); 4] = [
|
||||
("large.mk", Some(oversized.as_bytes()), true),
|
||||
("binary.mk", Some(b"target \xff\xfe\n"), true),
|
||||
("deleted.mk", None, false),
|
||||
("small.mk", Some(b"target\n"), false),
|
||||
];
|
||||
for (name, content, missed) in cases {
|
||||
let (h, s) = scope_harness();
|
||||
let file = h.root.join(name);
|
||||
if let Some(content) = content {
|
||||
std::fs::write(&file, content).unwrap();
|
||||
}
|
||||
let listed = file.clone();
|
||||
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
|
||||
files: vec![listed.clone()],
|
||||
limit_hit: false,
|
||||
}));
|
||||
let (doc, _) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
let want = missed.then_some(ReferencesIncomplete::FilesSkipped);
|
||||
for (search, got) in incomplete_reports(&h, doc).await {
|
||||
assert_eq!(got, want, "{name}: {search}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_large_file_the_user_has_open_is_not_reported_as_skipped() {
|
||||
let (h, s) = scope_harness();
|
||||
let large = "target\n".repeat(200_000);
|
||||
let (_, path) = h.open("large.mk", &large);
|
||||
let (doc, _) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 2).await;
|
||||
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
|
||||
files: vec![path.clone()],
|
||||
limit_hit: false,
|
||||
}));
|
||||
// `covered` would hide the file: answer as if the server had not listed it.
|
||||
*s.responder.lock() = Some(open_files_only(vec![uri_of(&h.root.join("large.mk"))]));
|
||||
expect_all(incomplete_reports(&h, doc).await, None);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rename_and_callers_can_be_cancelled() {
|
||||
let (h, s) = scope_harness();
|
||||
h.mgr.set_candidates_hook(Box::new(|_, cancel| {
|
||||
while !cancel.is_cancelled() {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
ide_lsp::CandidateFiles::default()
|
||||
}));
|
||||
let (doc, _) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
let cancel_later = || {
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
let later = cancel.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
later.cancel();
|
||||
});
|
||||
cancel
|
||||
};
|
||||
let err = h.mgr.rename(doc, Pos::new(0, 5), "renamed".into(), cancel_later()).await.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
let err =
|
||||
h.mgr.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, cancel_later()).await.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancelling_a_references_search_stops_the_candidate_search_and_closes_files() {
|
||||
let (h, s) = scope_harness();
|
||||
@@ -2096,7 +2222,7 @@ async fn rename_edits_files_outside_the_analysis_scope() {
|
||||
let (doc, a) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
let edit = h.mgr.rename(doc, Pos::new(0, 5), "renamed".into()).await.unwrap();
|
||||
let edit = h.mgr.rename(doc, Pos::new(0, 5), "renamed".into(), Default::default()).await.unwrap().edit;
|
||||
let mut paths: Vec<PathBuf> = edit
|
||||
.files
|
||||
.iter()
|
||||
|
||||
@@ -444,13 +444,13 @@ async fn angular_language_server_end_to_end() {
|
||||
let count = Pos::new(4, 20);
|
||||
let range = mgr.prepare_rename(inline, count).await.unwrap();
|
||||
assert_eq!(range, Some(Range::new(Pos::new(4, 19), Pos::new(4, 24))));
|
||||
let edit = mgr.rename(inline, count, "total".into()).await.unwrap();
|
||||
let edit = mgr.rename(inline, count, "total".into(), Default::default()).await.unwrap().edit;
|
||||
assert_eq!(edited_lines(&edit, &inline_ts), [4, 7], "{edit:?}");
|
||||
|
||||
// `title` in the class, which both servers rename: vtsls's edit and the
|
||||
// template's from the Angular server.
|
||||
let title = Pos::new(7, 3);
|
||||
let edit = mgr.rename(ts, title, "heading".into()).await.unwrap();
|
||||
let edit = mgr.rename(ts, title, "heading".into(), Default::default()).await.unwrap().edit;
|
||||
assert_eq!((edited_lines(&edit, &app_ts), edited_lines(&edit, &app_html)), (vec![7], vec![0]), "{edit:?}");
|
||||
// Its usage in the template, the declaration left out, from either file.
|
||||
for (doc, pos) in [(ts, title), (html, Pos::new(0, 8))] {
|
||||
|
||||
@@ -222,11 +222,12 @@ pub async fn lsp_prepare_rename(
|
||||
pub async fn lsp_rename(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
new_name: String,
|
||||
) -> IdeResult<WorkspaceEdit> {
|
||||
ide_host::ops::lsp::lsp_rename(&state.host, window.label(), doc, pos, new_name).await
|
||||
) -> IdeResult<Renamed> {
|
||||
ide_host::ops::lsp::lsp_rename(&state.host, window.label(), request, doc, pos, new_name).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -284,11 +285,12 @@ pub async fn lsp_document_links(
|
||||
pub async fn lsp_call_hierarchy(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
ide_host::ops::lsp::lsp_call_hierarchy(&state.host, window.label(), doc, pos, direction).await
|
||||
) -> IdeResult<CallHierarchy> {
|
||||
ide_host::ops::lsp::lsp_call_hierarchy(&state.host, window.label(), request, doc, pos, direction).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+15
-3
@@ -377,7 +377,7 @@ export const commands = {
|
||||
end: Pos,
|
||||
} | null, IdeError>(__TAURI_INVOKE("lsp_prepare_rename", { doc, pos })),
|
||||
/** Computes the rename edit without applying it, so the UI can preview conflicts. */
|
||||
lspRename: (doc: DocId, pos: Pos, newName: string) => typedError<WorkspaceEdit, IdeError>(__TAURI_INVOKE("lsp_rename", { doc, pos, newName })),
|
||||
lspRename: (request: RequestId, doc: DocId, pos: Pos, newName: string) => typedError<Renamed, IdeError>(__TAURI_INVOKE("lsp_rename", { request, doc, pos, newName })),
|
||||
lspFormat: (doc: DocId, range: {
|
||||
start: Pos,
|
||||
end: Pos,
|
||||
@@ -392,7 +392,7 @@ export const commands = {
|
||||
lspCodeLens: (doc: DocId) => typedError<CodeLens[], IdeError>(__TAURI_INVOKE("lsp_code_lens", { doc })),
|
||||
/** Links in a document (`href`s, Markdown links, `#include`s…) from its language servers. */
|
||||
lspDocumentLinks: (doc: DocId) => typedError<DocumentLink[], IdeError>(__TAURI_INVOKE("lsp_document_links", { doc })),
|
||||
lspCallHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError<HierarchyItem[], IdeError>(__TAURI_INVOKE("lsp_call_hierarchy", { doc, pos, direction })),
|
||||
lspCallHierarchy: (request: RequestId, doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError<CallHierarchy, IdeError>(__TAURI_INVOKE("lsp_call_hierarchy", { request, doc, pos, direction })),
|
||||
lspTypeHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError<HierarchyItem[], IdeError>(__TAURI_INVOKE("lsp_type_hierarchy", { doc, pos, direction })),
|
||||
lspServers: () => typedError<ServerInfo[], IdeError>(__TAURI_INVOKE("lsp_servers")),
|
||||
/** Re-emits the language servers' current diagnostics (the UI loaded after they arrived). */
|
||||
@@ -1588,6 +1588,11 @@ export type ByteRange = {
|
||||
total: number,
|
||||
};
|
||||
|
||||
export type CallHierarchy = {
|
||||
items: HierarchyItem[],
|
||||
incomplete: ReferencesIncomplete | null,
|
||||
};
|
||||
|
||||
/**
|
||||
* A plugin package available for installation from a local catalog
|
||||
* directory (the samples shipped with the IDE). Not installed by listing it.
|
||||
@@ -4814,7 +4819,9 @@ export type ReferencesIncomplete =
|
||||
/** A language server was still indexing. */
|
||||
"indexing" |
|
||||
/** More project files mention the name than were checked. */
|
||||
"tooManyFiles";
|
||||
"tooManyFiles" |
|
||||
/** Files that mention the name were too large or unreadable to check. */
|
||||
"filesSkipped";
|
||||
|
||||
/** A registry agent this machine can run (or has installed). */
|
||||
export type RegistryAgent = {
|
||||
@@ -4939,6 +4946,11 @@ export type RemoteSession = {
|
||||
/** Where [`remote_connect`] is (`fraction`: 0..1 of a download / upload). */
|
||||
export type RemoteStage = "connecting" | "checking" | "downloading" | "uploading" | "starting" | "opening";
|
||||
|
||||
export type Renamed = {
|
||||
edit: WorkspaceEdit,
|
||||
incomplete: ReferencesIncomplete | null,
|
||||
};
|
||||
|
||||
export type ReplaceEvent =
|
||||
/** A file was changed (`replacements` > 0) or had nothing left to replace. */
|
||||
{ type: "file"; path: IpcPath; replacements: number; done: number; total: number } | { type: "failed"; path: IpcPath; message: string; done: number; total: number } | { type: "done"; summary: ReplaceSummary };
|
||||
|
||||
+18
-1
@@ -3,7 +3,8 @@ import { Facet } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { whenAllSynced, type EditorDocContext } from "@/editor/api";
|
||||
import { IpcError, call, isCancelled, isTauri, unavailableText } from "@/platform/ipc";
|
||||
import type { IdeError } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { IdeError, ReferencesIncomplete } from "@/bindings";
|
||||
import { showHint } from "./hint";
|
||||
|
||||
/** The backend document an editor shows (provided by this module's contribution). */
|
||||
@@ -36,6 +37,22 @@ export async function lspRequest<T>(run: () => Promise<Result<T>>, wanted?: () =
|
||||
}
|
||||
}
|
||||
|
||||
/** Why a project-wide search may have missed usages of `word`, for a note beside its results. */
|
||||
export function incompleteNote(reason: ReferencesIncomplete, word: string | null): string {
|
||||
switch (reason) {
|
||||
case "indexing":
|
||||
return tx("May be incomplete: the language server is still indexing");
|
||||
case "tooManyFiles":
|
||||
return word
|
||||
? tx`May be incomplete: too many files mention '${word}' to search them all`
|
||||
: tx("May be incomplete: too many files mention the name to search them all");
|
||||
case "filesSkipped":
|
||||
return word
|
||||
? tx`May be incomplete: some files that mention '${word}' are too large or unreadable`
|
||||
: tx("May be incomplete: some files that mention the name are too large or unreadable");
|
||||
}
|
||||
}
|
||||
|
||||
export function isUnavailable(e: unknown): boolean {
|
||||
return e instanceof IpcError && e.kind === "unavailable";
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Rename (⇧F6): `lsp_prepare_rename` → the name is edited in place with this
|
||||
* file's occurrences linked (`inplaceRename.ts`; ⇧F6 again opens the name
|
||||
* dialog) → `lsp_rename` → preview dialog when the edit spans several files
|
||||
* (or moves files), otherwise applied directly via `apply_workspace_edit`.
|
||||
* (or moves files) or may have missed usages, otherwise applied directly via
|
||||
* `apply_workspace_edit`.
|
||||
*
|
||||
* Dumb mode: when the server is not ready / failing, or answers nothing over
|
||||
* code with syntax errors, a *local* variable is renamed in place from the
|
||||
@@ -11,10 +12,18 @@
|
||||
* symbols — is left to the server. Ambiguous cases ask first.
|
||||
*/
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { commands, isCancelled, unavailableText, type Pos, type WorkspaceEdit } from "@/platform/ipc";
|
||||
import {
|
||||
commands,
|
||||
isCancelled,
|
||||
nextRequestId,
|
||||
unavailableText,
|
||||
type Pos,
|
||||
type RequestId,
|
||||
type WorkspaceEdit,
|
||||
} from "@/platform/ipc";
|
||||
import { whenSynced, type EditorDocContext } from "@/editor/api";
|
||||
import { structure } from "@/editor/structure";
|
||||
import { docOf, isUnavailable, lspRequest, reportLspError } from "./lsp";
|
||||
import { docOf, incompleteNote, isUnavailable, lspRequest, reportLspError } from "./lsp";
|
||||
import { comparePos, offsetToPos, rangeToOffsets } from "./positions";
|
||||
import { showHint } from "./hint";
|
||||
import { caretAnchor, openPopup, type PreviewOccurrence } from "./popupStore";
|
||||
@@ -208,15 +217,25 @@ async function commitInplace(view: EditorView, doc: EditorDocContext, pos: Pos,
|
||||
await finishRename(view, doc, pos, oldName, newName);
|
||||
}
|
||||
|
||||
/** The rename waiting for the server's edit: a newer one replaces it. */
|
||||
let renameRequest: RequestId | null = null;
|
||||
|
||||
async function finishRename(view: EditorView, doc: EditorDocContext, pos: Pos, oldName: string, newName: string) {
|
||||
if (!newName || newName === oldName) return;
|
||||
let edit: WorkspaceEdit;
|
||||
if (renameRequest !== null) void commands.cancelRequest(renameRequest).catch(() => {});
|
||||
const request = nextRequestId();
|
||||
renameRequest = request;
|
||||
let renamed;
|
||||
try {
|
||||
edit = await lspRequest(() => commands.lspRename(doc.doc, pos, newName));
|
||||
renamed = await lspRequest(() => commands.lspRename(request, doc.doc, pos, newName));
|
||||
} catch (e) {
|
||||
reportLspError(view, e, true);
|
||||
return;
|
||||
} finally {
|
||||
if (renameRequest === request) renameRequest = null;
|
||||
}
|
||||
const { edit, incomplete } = renamed;
|
||||
const note = incomplete && incompleteNote(incomplete, oldName);
|
||||
const summary = summarizeWorkspaceEdit(edit);
|
||||
if (!summary.files) {
|
||||
if (await hasSyntaxErrors(doc.doc)) {
|
||||
@@ -226,10 +245,12 @@ async function finishRename(view: EditorView, doc: EditorDocContext, pos: Pos, o
|
||||
return;
|
||||
}
|
||||
}
|
||||
showHint(view, "Nothing to rename");
|
||||
if (note) showHint(view, note, { tone: "warning" });
|
||||
else showHint(view, "Nothing to rename");
|
||||
return;
|
||||
}
|
||||
if (!needsPreview(summary)) {
|
||||
// An edit that may leave usages behind is never applied unseen.
|
||||
if (!note && !needsPreview(summary)) {
|
||||
await applyEdit(view, edit);
|
||||
return;
|
||||
}
|
||||
@@ -237,7 +258,8 @@ async function finishRename(view: EditorView, doc: EditorDocContext, pos: Pos, o
|
||||
kind: "preview",
|
||||
title: tx`Rename '${oldName}' to '${newName}'`,
|
||||
summary,
|
||||
confirmLabel: "Do Refactor",
|
||||
...(note ? { warnings: [note] } : {}),
|
||||
confirmLabel: note ? "Rename Anyway" : "Do Refactor",
|
||||
onConfirm: () => void applyEdit(view, edit),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -415,15 +415,59 @@ describe("rename wiring", () => {
|
||||
cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 0, 12)));
|
||||
const edits = [r(0, 0, 12), r(1, 6, 18)].map((range) => ({ range, new_text: "message_count" }));
|
||||
const edit = { files: [{ kind: "edit", path: "/p/a.ts", edits }] };
|
||||
cmd.lspRename.mockReturnValueOnce(ok(edit));
|
||||
cmd.lspRename.mockReturnValueOnce(ok({ edit, incomplete: null }));
|
||||
const view = editor("mesage_count = 1\nprint(mesage_count)", "python", 0);
|
||||
await renameNamePart(view, { from: 0, to: 6 }, "message");
|
||||
expect(cmd.lspRename).toHaveBeenCalledWith(7, { line: 0, col: 0 }, "message_count");
|
||||
expect(cmd.lspRename).toHaveBeenCalledWith(expect.anything(), 7, { line: 0, col: 0 }, "message_count");
|
||||
expect(cmd.applyWorkspaceEdit).toHaveBeenCalledWith(edit);
|
||||
// Nothing was replaced locally: the server's edit covers every reference.
|
||||
expect(view.state.doc.toString()).toBe("mesage_count = 1\nprint(mesage_count)");
|
||||
});
|
||||
|
||||
it("shows an edit that may miss usages before applying it, even in one file", async () => {
|
||||
cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 0, 12)));
|
||||
const edit = {
|
||||
files: [{ kind: "edit", path: "/p/a.ts", edits: [{ range: r(0, 0, 12), new_text: "message_count" }] }],
|
||||
};
|
||||
cmd.lspRename.mockReturnValueOnce(ok({ edit, incomplete: "tooManyFiles" }));
|
||||
const view = editor("mesage_count = 1", "python", 0);
|
||||
await renameNamePart(view, { from: 0, to: 6 }, "message");
|
||||
expect(cmd.applyWorkspaceEdit).not.toHaveBeenCalled();
|
||||
const popup = useLspPopup.getState().popup;
|
||||
expect(popup?.kind === "preview" && popup.warnings).toEqual([
|
||||
"May be incomplete: too many files mention 'mesage_count' to search them all",
|
||||
]);
|
||||
if (popup?.kind === "preview") popup.onConfirm();
|
||||
await vi.waitFor(() => expect(cmd.applyWorkspaceEdit).toHaveBeenCalledWith(edit));
|
||||
closePopup();
|
||||
});
|
||||
|
||||
it("says why when an incomplete search found nothing to rename", async () => {
|
||||
cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 0, 12)));
|
||||
cmd.lspRename.mockReturnValueOnce(ok({ edit: { files: [] }, incomplete: "indexing" }));
|
||||
const view = editor("mesage_count = 1", "python", 0);
|
||||
await renameNamePart(view, { from: 0, to: 6 }, "message");
|
||||
expect(document.querySelector(".cm-lsp-hint")?.textContent).toContain("still indexing");
|
||||
});
|
||||
|
||||
it("cancels a rename still waiting for the server when a newer one starts", async () => {
|
||||
let answer: (v: unknown) => void = () => {};
|
||||
cmd.lspPrepareRename.mockReturnValue(ok(r(0, 0, 12)));
|
||||
cmd.lspRename.mockReturnValueOnce(new Promise((resolve) => (answer = resolve)) as never);
|
||||
cmd.lspRename.mockReturnValueOnce(ok({ edit: { files: [] }, incomplete: null }));
|
||||
const view = editor("mesage_count = 1", "python", 0);
|
||||
const first = renameNamePart(view, { from: 0, to: 6 }, "message");
|
||||
await vi.waitFor(() => expect(cmd.lspRename).toHaveBeenCalledTimes(1));
|
||||
const id = (cmd.lspRename.mock.calls[0] as unknown[])[0];
|
||||
await renameNamePart(view, { from: 0, to: 6 }, "massage");
|
||||
expect(cmd.cancelRequest).toHaveBeenCalledWith(id);
|
||||
answer({ status: "error", error: { kind: "cancelled", message: "cancelled" } });
|
||||
await first;
|
||||
expect(cmd.applyWorkspaceEdit).not.toHaveBeenCalled();
|
||||
cmd.lspPrepareRename.mockReset();
|
||||
cmd.lspPrepareRename.mockImplementation(() => unavailable());
|
||||
});
|
||||
|
||||
it("falls back to the syntax rename of a local, references included", async () => {
|
||||
cmd.syntaxLocals.mockReturnValueOnce(
|
||||
ok(local({ name: "mesage", definition: r(0, 0, 6), references: [r(1, 6, 12)] })),
|
||||
|
||||
@@ -23,6 +23,13 @@
|
||||
margin: 0 4px;
|
||||
background: var(--border);
|
||||
}
|
||||
.note {
|
||||
flex: none;
|
||||
padding: 0 8px 4px;
|
||||
font-size: var(--fs-small);
|
||||
font-style: italic;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -111,7 +111,7 @@ function HierarchyToolbar() {
|
||||
}
|
||||
|
||||
export function HierarchyView() {
|
||||
const { mode, direction, root, children, failed, loading, generation } = useHierarchy();
|
||||
const { mode, direction, root, children, failed, notes, loading, generation } = useHierarchy();
|
||||
const projectRoot = useProject((s) => s.project?.root ?? "");
|
||||
const roots = useMemo<Node[]>(() => (root ? [{ kind: "root", id: ROOT_ID }] : []), [root]);
|
||||
|
||||
@@ -183,6 +183,11 @@ export function HierarchyView() {
|
||||
{basename(item.path)}:{item.selectionRange.start.line + 1}
|
||||
</span>
|
||||
{calls > 1 && <span className={styles.muted}>{tx`(${calls} calls)`}</span>}
|
||||
{notes.has(n.id) && (
|
||||
<span className={styles.muted} title={notes.get(n.id)}>
|
||||
{notes.get(n.id)}
|
||||
</span>
|
||||
)}
|
||||
{failed.has(n.id) && (
|
||||
<span className={styles.error}>
|
||||
<T>failed to load</T>
|
||||
@@ -195,6 +200,11 @@ export function HierarchyView() {
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<HierarchyToolbar />
|
||||
{notes.has(ROOT_ID) && (
|
||||
<div className={styles.note} role="note">
|
||||
{notes.get(ROOT_ID)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.body}>
|
||||
<Tree<Node>
|
||||
key={generation}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { HierarchyItem, OutlineItem, SymbolKind } from "@/bindings";
|
||||
import type { HierarchyItem, OutlineItem, ReferencesIncomplete, SymbolKind } from "@/bindings";
|
||||
|
||||
const hint = vi.fn();
|
||||
vi.mock("@/workbench/contributions", async (orig) => ({
|
||||
@@ -49,6 +49,12 @@ const item = (name: string, fromRanges: HierarchyItem["fromRanges"] = []): Hiera
|
||||
fromRanges,
|
||||
});
|
||||
|
||||
const callHierarchy = vi.fn((): { items: HierarchyItem[]; incomplete: ReferencesIncomplete | null } => ({
|
||||
items: [item("main", [range(12, 4, 12, 8)])],
|
||||
incomplete: null,
|
||||
}));
|
||||
const cancelRequest = vi.fn(async () => undefined);
|
||||
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
@@ -57,10 +63,11 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
commands: {
|
||||
...real.commands,
|
||||
docOutline: async () => OUTLINE,
|
||||
lspCallHierarchy: async (_doc: number, pos: { line: number; col: number }) => {
|
||||
cancelRequest,
|
||||
lspCallHierarchy: async (_request: number, _doc: number, pos: { line: number; col: number }) => {
|
||||
// The server prepares a hierarchy only on the method name.
|
||||
if (pos.line !== 2 || pos.col !== 7) throw new real.IpcError({ kind: "notFound", message: "No symbol" });
|
||||
return [item("main", [range(12, 4, 12, 8)])];
|
||||
return callHierarchy();
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -73,7 +80,15 @@ vi.mock("./ipc", async (orig) => ({
|
||||
hint: (...a: unknown[]) => hint(...a),
|
||||
}));
|
||||
|
||||
const { enclosingDeclaration, hierarchyTarget, showHierarchy, useHierarchy, ROOT_ID } = await import("./hierarchy");
|
||||
const {
|
||||
enclosingDeclaration,
|
||||
hierarchyTarget,
|
||||
refreshHierarchy,
|
||||
resetHierarchy,
|
||||
showHierarchy,
|
||||
useHierarchy,
|
||||
ROOT_ID,
|
||||
} = await import("./hierarchy");
|
||||
|
||||
describe("hierarchy", () => {
|
||||
it("finds the innermost enclosing declaration of the wanted kinds", () => {
|
||||
@@ -99,6 +114,33 @@ describe("hierarchy", () => {
|
||||
expect(s.root).toEqual({ name: "list", path: "/p/Orders.java", line: 2, col: 7 });
|
||||
expect(s.children.get(ROOT_ID)?.map((n) => n.item.name)).toEqual(["main"]);
|
||||
expect(s.failed.size).toBe(0);
|
||||
expect(s.notes.size).toBe(0);
|
||||
expect(hint).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notes a level whose callers may be incomplete, until it is loaded again", async () => {
|
||||
useHierarchy.setState({ mode: "call", direction: "incoming" });
|
||||
callHierarchy.mockReturnValueOnce({ items: [item("main")], incomplete: "tooManyFiles" });
|
||||
await showHierarchy("call");
|
||||
expect(useHierarchy.getState().notes.get(ROOT_ID)).toBe(
|
||||
"May be incomplete: too many files mention 'list' to search them all",
|
||||
);
|
||||
refreshHierarchy();
|
||||
await vi.waitFor(() => expect(useHierarchy.getState().children.get(ROOT_ID)).toBeDefined());
|
||||
expect(useHierarchy.getState().notes.size).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels the requests of a tree that is replaced", async () => {
|
||||
useHierarchy.setState({ mode: "call", direction: "incoming" });
|
||||
await showHierarchy("call");
|
||||
cancelRequest.mockClear();
|
||||
let answer: (v: ReturnType<typeof callHierarchy>) => void = () => {};
|
||||
callHierarchy.mockReturnValueOnce(new Promise((resolve) => (answer = resolve)) as never);
|
||||
refreshHierarchy();
|
||||
await vi.waitFor(() => expect(callHierarchy).toHaveBeenCalled());
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
resetHierarchy();
|
||||
expect(cancelRequest).toHaveBeenCalledTimes(1);
|
||||
answer({ items: [item("stale")], incomplete: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Call / type hierarchy state, with lazily expanded levels. */
|
||||
import { create } from "zustand";
|
||||
import type { HierarchyDirection, HierarchyItem, OutlineItem, Pos, SymbolKind } from "@/bindings";
|
||||
import { IpcError, call, commands } from "@/platform/ipc";
|
||||
import type { HierarchyDirection, HierarchyItem, OutlineItem, Pos, RequestId, SymbolKind } from "@/bindings";
|
||||
import { incompleteNote } from "@/editor/lsp/lsp";
|
||||
import { IpcError, call, commands, nextRequestId } from "@/platform/ipc";
|
||||
import { toolWindowService } from "@/workbench/contributions";
|
||||
import { basename } from "@/platform/project";
|
||||
import { caretContext, hint, lspRequest, reportError, withDocument, type CaretContext } from "./ipc";
|
||||
@@ -29,6 +30,8 @@ interface HierarchyState {
|
||||
children: ReadonlyMap<string, HierarchyNode[]>;
|
||||
/** Parent ids whose children failed to load. */
|
||||
failed: ReadonlySet<string>;
|
||||
/** Parent id → why its children may be missing callers. */
|
||||
notes: ReadonlyMap<string, string>;
|
||||
loading: boolean;
|
||||
/** Bumped per (re)query so stale responses are dropped and the tree resets. */
|
||||
generation: number;
|
||||
@@ -42,16 +45,27 @@ export const useHierarchy = create<HierarchyState>(() => ({
|
||||
root: null,
|
||||
children: new Map(),
|
||||
failed: new Set(),
|
||||
notes: new Map(),
|
||||
loading: false,
|
||||
generation: 0,
|
||||
}));
|
||||
|
||||
/** Call hierarchy requests the current tree still waits for. */
|
||||
const inflight = new Set<RequestId>();
|
||||
|
||||
function cancelInflight() {
|
||||
for (const request of inflight) void commands.cancelRequest(request).catch(() => {});
|
||||
inflight.clear();
|
||||
}
|
||||
|
||||
/** The window switched projects: the tree refers to the previous one's documents. */
|
||||
export function resetHierarchy() {
|
||||
cancelInflight();
|
||||
useHierarchy.setState((s) => ({
|
||||
root: null,
|
||||
children: new Map(),
|
||||
failed: new Set(),
|
||||
notes: new Map(),
|
||||
loading: false,
|
||||
generation: s.generation + 1,
|
||||
}));
|
||||
@@ -74,12 +88,18 @@ export function hierarchyTitle(mode: HierarchyMode, direction: HierarchyDirectio
|
||||
return direction === "incoming" ? tx`Supertypes of ${name}` : tx`Subtypes of ${name}`;
|
||||
}
|
||||
|
||||
function request(mode: HierarchyMode, doc: number, line: number, col: number, direction: HierarchyDirection) {
|
||||
return lspRequest(() =>
|
||||
mode === "call"
|
||||
? commands.lspCallHierarchy(doc, { line, col }, direction)
|
||||
: commands.lspTypeHierarchy(doc, { line, col }, direction),
|
||||
);
|
||||
async function request(mode: HierarchyMode, doc: number, line: number, col: number, direction: HierarchyDirection) {
|
||||
if (mode === "type") {
|
||||
const items = await lspRequest(() => commands.lspTypeHierarchy(doc, { line, col }, direction));
|
||||
return { items, incomplete: null };
|
||||
}
|
||||
const id = nextRequestId();
|
||||
inflight.add(id);
|
||||
try {
|
||||
return await lspRequest(() => commands.lspCallHierarchy(id, doc, { line, col }, direction));
|
||||
} finally {
|
||||
inflight.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function childId(parent: string, item: HierarchyItem): string {
|
||||
@@ -87,7 +107,7 @@ export function childId(parent: string, item: HierarchyItem): string {
|
||||
return `${parent}/${item.path}:${p.line}:${p.col}:${item.name}`;
|
||||
}
|
||||
|
||||
function setChildren(generation: number, parent: string, items: HierarchyItem[]) {
|
||||
function setChildren(generation: number, parent: string, items: HierarchyItem[], note: string | null) {
|
||||
const s = useHierarchy.getState();
|
||||
if (s.generation !== generation) return;
|
||||
const seen = new Set<string>();
|
||||
@@ -98,7 +118,10 @@ function setChildren(generation: number, parent: string, items: HierarchyItem[])
|
||||
seen.add(id);
|
||||
nodes.push({ id, item });
|
||||
}
|
||||
useHierarchy.setState({ children: new Map(s.children).set(parent, nodes) });
|
||||
useHierarchy.setState({
|
||||
children: new Map(s.children).set(parent, nodes),
|
||||
...(note ? { notes: new Map(s.notes).set(parent, note) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
type RootResult = "loaded" | "noTarget" | "failed" | "stale";
|
||||
@@ -109,10 +132,13 @@ async function loadRoot(quiet = false): Promise<RootResult> {
|
||||
const root = s.root;
|
||||
if (!root) return "stale";
|
||||
const generation = s.generation + 1;
|
||||
useHierarchy.setState({ generation, children: new Map(), failed: new Set(), loading: true });
|
||||
cancelInflight();
|
||||
useHierarchy.setState({ generation, children: new Map(), failed: new Set(), notes: new Map(), loading: true });
|
||||
try {
|
||||
const items = await withDocument(root.path, (doc) => request(s.mode, doc, root.line, root.col, s.direction));
|
||||
setChildren(generation, ROOT_ID, items);
|
||||
const { items, incomplete } = await withDocument(root.path, (doc) =>
|
||||
request(s.mode, doc, root.line, root.col, s.direction),
|
||||
);
|
||||
setChildren(generation, ROOT_ID, items, incomplete && incompleteNote(incomplete, root.name));
|
||||
return "loaded";
|
||||
} catch (e) {
|
||||
if (useHierarchy.getState().generation !== generation) return "stale";
|
||||
@@ -160,8 +186,10 @@ export async function loadChildren(node: HierarchyNode) {
|
||||
const { mode, direction, generation } = useHierarchy.getState();
|
||||
const at = node.item.selectionRange.start;
|
||||
try {
|
||||
const items = await withDocument(node.item.path, (doc) => request(mode, doc, at.line, at.col, direction));
|
||||
setChildren(generation, node.id, items);
|
||||
const { items, incomplete } = await withDocument(node.item.path, (doc) =>
|
||||
request(mode, doc, at.line, at.col, direction),
|
||||
);
|
||||
setChildren(generation, node.id, items, incomplete && incompleteNote(incomplete, node.item.name));
|
||||
} catch (e) {
|
||||
if (useHierarchy.getState().generation !== generation) return;
|
||||
useHierarchy.setState((st) => ({
|
||||
|
||||
@@ -25,7 +25,8 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
vi.mock("./candidates", () => ({ filesMentioning: () => filesMentioning() }));
|
||||
|
||||
const { _addEditor } = await import("@/editor/api");
|
||||
const { fetchUsages, incompleteNote } = await import("./usages");
|
||||
const { fetchUsages } = await import("./usages");
|
||||
const { incompleteNote } = await import("@/editor/lsp/lsp");
|
||||
const { LOCAL_NOTE, TEXT_NOTE } = await import("./syntaxUsages");
|
||||
|
||||
const r = (line: number, col: number, endCol: number): Range => ({ start: { line, col }, end: { line, col: endCol } });
|
||||
@@ -55,6 +56,9 @@ describe("Find Usages fallback", () => {
|
||||
expect(incompleteNote("tooManyFiles", "x")).toBe(
|
||||
"May be incomplete: too many files mention 'x' to search them all",
|
||||
);
|
||||
expect(incompleteNote("filesSkipped", "x")).toBe(
|
||||
"May be incomplete: some files that mention 'x' are too large or unreadable",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists a certain local's references when the server is not ready", async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Find Usages (⌥F7 → "usages" tool window) and Show Usages (⌥⌘F7 → popup). */
|
||||
import { create } from "zustand";
|
||||
import type { Location, ReferencesIncomplete, RequestId } from "@/bindings";
|
||||
import type { Location, RequestId } from "@/bindings";
|
||||
import { clearLineTokens } from "@/editor/lineTokens";
|
||||
import { incompleteNote } from "@/editor/lsp/lsp";
|
||||
import { commands, isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { toolWindowService } from "@/workbench/contributions";
|
||||
import {
|
||||
@@ -158,12 +159,6 @@ interface FetchedUsages {
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** Why the server's answer may miss usages, for the results' note. */
|
||||
export function incompleteNote(reason: ReferencesIncomplete, word: string | null): string {
|
||||
if (reason === "indexing") return "May be incomplete: the language server is still indexing";
|
||||
return `May be incomplete: too many files mention ${word ? `'${word}'` : "the name"} to search them all`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -3227,6 +3227,12 @@
|
||||
"No usages found in file": "文件中未找到用法",
|
||||
"No type information found": "未找到类型信息",
|
||||
"Nothing to rename": "没有可重命名的内容",
|
||||
"Rename Anyway": "仍然重命名",
|
||||
"May be incomplete: the language server is still indexing": "结果可能不完整:语言服务器仍在建立索引",
|
||||
"May be incomplete: too many files mention '{word}' to search them all": "结果可能不完整:提及“{word}”的文件过多,无法全部搜索",
|
||||
"May be incomplete: too many files mention the name to search them all": "结果可能不完整:提及该名称的文件过多,无法全部搜索",
|
||||
"May be incomplete: some files that mention '{word}' are too large or unreadable": "结果可能不完整:部分提及“{word}”的文件过大或无法读取",
|
||||
"May be incomplete: some files that mention the name are too large or unreadable": "结果可能不完整:部分提及该名称的文件过大或无法读取",
|
||||
"The file changed; rename cancelled": "文件已更改,重命名已取消",
|
||||
"No suggestions": "无建议",
|
||||
"No intentions available": "没有可用的意图操作",
|
||||
|
||||
Reference in New Issue
Block a user