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:
l0ng-ai
2026-10-30 21:46:44 +08:00
parent 33d8d87ca2
commit bfa83d857c
18 changed files with 492 additions and 94 deletions
+23 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
}
}
+16
View File
@@ -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>,
}
+133 -7
View File
@@ -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 = &params["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()
+2 -2
View File
@@ -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))] {
+6 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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";
}
+30 -8
View File
@@ -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),
});
}
+46 -2
View File
@@ -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;
+11 -1
View File
@@ -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}
+46 -4
View File
@@ -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 });
});
});
+43 -15
View File
@@ -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 () => {
+2 -7
View File
@@ -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
+6
View File
@@ -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": "没有可用的意图操作",