From bfa83d857cc617de6c5ed49b43876f39b3acdeb2 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:46:44 +0800 Subject: [PATCH] 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. --- crates/ide-host/src/ops/lsp.rs | 29 +++- crates/ide-lsp/src/lib.rs | 76 +++++++--- crates/ide-lsp/src/scope.rs | 40 +++-- crates/ide-lsp/src/types.rs | 16 ++ crates/ide-lsp/tests/mock_server.rs | 140 +++++++++++++++++- crates/ide-lsp/tests/real_servers.rs | 4 +- src-tauri/src/commands/lsp.rs | 10 +- src/bindings.ts | 18 ++- src/editor/lsp/lsp.ts | 19 ++- src/editor/lsp/rename.ts | 38 ++++- src/editor/lsp/syntaxFallback.test.ts | 48 +++++- .../code-insight/HierarchyView.module.css | 7 + src/features/code-insight/HierarchyView.tsx | 12 +- src/features/code-insight/hierarchy.test.ts | 50 ++++++- src/features/code-insight/hierarchy.ts | 58 ++++++-- .../code-insight/syntaxUsages.test.ts | 6 +- src/features/code-insight/usages.ts | 9 +- src/platform/locales/text/zh.json | 6 + 18 files changed, 492 insertions(+), 94 deletions(-) diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 1d398e82..e9e7bb3c 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -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 { - 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 { + 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> { - host.project(window)?.lsp.call_hierarchy(doc, pos, direction).await +) -> IdeResult { + 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 diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index fc00c840..f164c156 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -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 { + pub async fn rename( + &self, + doc: DocId, + pos: Pos, + new_name: String, + cancel: CancellationToken, + ) -> IdeResult { 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 { + async fn rename_on( + &self, + snap: &DocSnap, + t: &Target, + pos: Pos, + new_name: &str, + cancel: &CancellationToken, + ) -> IdeResult { let query = || async { let mut params = snap.pos_params(t, pos); params["newName"] = json!(new_name); - let r: Option = self.call(t, "textDocument/rename", params, None, LONG_TIMEOUT).await?; + let r: Option = + 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> { + cancel: CancellationToken, + ) -> IdeResult { let (snap, targets) = self.doc_targets(doc, "call hierarchy", |c| c.has("callHierarchyProvider")).await?; let t = &targets[0]; - let items: Option> = - self.call(t, "textDocument/prepareCallHierarchy", snap.pos_params(t, pos), None, DEFAULT_TIMEOUT).await?; + let cancel = &cancel; + let items: Option> = 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> = - 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> = - 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| { 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)); diff --git a/crates/ide-lsp/src/scope.rs b/crates/ide-lsp/src/scope.rs index 003b1758..237f33f1 100644 --- a/crates/ide-lsp/src/scope.rs +++ b/crates/ide-lsp/src/scope.rs @@ -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, cancel: &CancellationToken) -> TransientFiles<'_> { - let read = { + // The server already sees the user's files, however large. + let open: HashSet = 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::>() + 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, 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 } } } diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 4fb820e7..57ba429d 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -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, pub incomplete: Option, } + +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct Renamed { + pub edit: WorkspaceEdit, + pub incomplete: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CallHierarchy { + pub items: Vec, + pub incomplete: Option, +} diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index fca890e8..5ad879f5 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -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 { /// A server that only analyses its open documents (minus `blind` URIs), like /// Pyright for files outside its `include`. fn open_files_only(blind: Vec) -> 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) -> 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)> { + 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)>, want: Option) { + 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 = edit .files .iter() diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index fb5b1330..dd725b6b 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -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))] { diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 40294164..08c0b4c4 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -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 { - ide_host::ops::lsp::lsp_rename(&state.host, window.label(), doc, pos, new_name).await +) -> IdeResult { + 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> { - ide_host::ops::lsp::lsp_call_hierarchy(&state.host, window.label(), doc, pos, direction).await +) -> IdeResult { + ide_host::ops::lsp::lsp_call_hierarchy(&state.host, window.label(), request, doc, pos, direction).await } #[tauri::command] diff --git a/src/bindings.ts b/src/bindings.ts index 77c4f29e..ed088c48 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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(__TAURI_INVOKE("lsp_rename", { doc, pos, newName })), + lspRename: (request: RequestId, doc: DocId, pos: Pos, newName: string) => typedError(__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(__TAURI_INVOKE("lsp_code_lens", { doc })), /** Links in a document (`href`s, Markdown links, `#include`s…) from its language servers. */ lspDocumentLinks: (doc: DocId) => typedError(__TAURI_INVOKE("lsp_document_links", { doc })), - lspCallHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError(__TAURI_INVOKE("lsp_call_hierarchy", { doc, pos, direction })), + lspCallHierarchy: (request: RequestId, doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError(__TAURI_INVOKE("lsp_call_hierarchy", { request, doc, pos, direction })), lspTypeHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError(__TAURI_INVOKE("lsp_type_hierarchy", { doc, pos, direction })), lspServers: () => typedError(__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 }; diff --git a/src/editor/lsp/lsp.ts b/src/editor/lsp/lsp.ts index 64c9b446..6b301f9a 100644 --- a/src/editor/lsp/lsp.ts +++ b/src/editor/lsp/lsp.ts @@ -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(run: () => Promise>, 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"; } diff --git a/src/editor/lsp/rename.ts b/src/editor/lsp/rename.ts index a7fb9892..48fb7ec4 100644 --- a/src/editor/lsp/rename.ts +++ b/src/editor/lsp/rename.ts @@ -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), }); } diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 00027899..2b811ad5 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -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)] })), diff --git a/src/features/code-insight/HierarchyView.module.css b/src/features/code-insight/HierarchyView.module.css index b136d8aa..90e2bbd8 100644 --- a/src/features/code-insight/HierarchyView.module.css +++ b/src/features/code-insight/HierarchyView.module.css @@ -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; diff --git a/src/features/code-insight/HierarchyView.tsx b/src/features/code-insight/HierarchyView.tsx index f6776de4..6e39047c 100644 --- a/src/features/code-insight/HierarchyView.tsx +++ b/src/features/code-insight/HierarchyView.tsx @@ -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(() => (root ? [{ kind: "root", id: ROOT_ID }] : []), [root]); @@ -183,6 +183,11 @@ export function HierarchyView() { {basename(item.path)}:{item.selectionRange.start.line + 1} {calls > 1 && {tx`(${calls} calls)`}} + {notes.has(n.id) && ( + + {notes.get(n.id)} + + )} {failed.has(n.id) && ( failed to load @@ -195,6 +200,11 @@ export function HierarchyView() { return (
+ {notes.has(ROOT_ID) && ( +
+ {notes.get(ROOT_ID)} +
+ )}
key={generation} diff --git a/src/features/code-insight/hierarchy.test.ts b/src/features/code-insight/hierarchy.test.ts index 9321c3b4..46663183 100644 --- a/src/features/code-insight/hierarchy.test.ts +++ b/src/features/code-insight/hierarchy.test.ts @@ -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(); 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) => 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 }); + }); }); diff --git a/src/features/code-insight/hierarchy.ts b/src/features/code-insight/hierarchy.ts index cd5d3377..cfd3f2f4 100644 --- a/src/features/code-insight/hierarchy.ts +++ b/src/features/code-insight/hierarchy.ts @@ -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; /** Parent ids whose children failed to load. */ failed: ReadonlySet; + /** Parent id → why its children may be missing callers. */ + notes: ReadonlyMap; 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(() => ({ 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(); + +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(); @@ -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 { 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) => ({ diff --git a/src/features/code-insight/syntaxUsages.test.ts b/src/features/code-insight/syntaxUsages.test.ts index 6e19a386..11de8db0 100644 --- a/src/features/code-insight/syntaxUsages.test.ts +++ b/src/features/code-insight/syntaxUsages.test.ts @@ -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 () => { diff --git a/src/features/code-insight/usages.ts b/src/features/code-insight/usages.ts index a0c9ded3..061b28a4 100644 --- a/src/features/code-insight/usages.ts +++ b/src/features/code-insight/usages.ts @@ -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 diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 03710d06..a451e337 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "没有可用的意图操作",