From 482f391fd12a64e484b2f2d243fbf3966db9ed2b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:35:37 +0800 Subject: [PATCH 1/3] fix(splash): Draw the wordmark as outlines so it can't swap fonts The splash set its wordmark in Inter, a web font with font-display: swap. When the font arrived late (on a cold dev start the dev server took up to seconds to serve it), the splash showed the system font first and switched to Inter just before fading, so the word grew and shifted. It is now an inline SVG of the same text in Inter 500, so it renders identically from the first frame. This replaces the previous attempt, which only moved where the font faces load. --- index.html | 8 +++++--- public/splash.css | 10 +++------- src/styles/fonts.css | 4 ---- src/styles/global.css | 2 ++ 4 files changed, 10 insertions(+), 14 deletions(-) delete mode 100644 src/styles/fonts.css diff --git a/index.html b/index.html index a8060de6..43471baa 100644 --- a/index.html +++ b/index.html @@ -5,18 +5,20 @@ OpenIDEA -
- OpenIDEA + + + +
diff --git a/public/splash.css b/public/splash.css index 217b6cb7..c905db33 100644 --- a/public/splash.css +++ b/public/splash.css @@ -17,22 +17,18 @@ body, .splash { background: #e9eaee; } gap: 14px; padding-bottom: 38px; box-sizing: border-box; - font: 500 15px/1.2 "Inter Variable", Inter, system-ui, -apple-system, sans-serif; color: #6c707e; - /* As on body: picking it up when the app CSS arrives would change the wordmark's weight. */ - -webkit-font-smoothing: antialiased; -webkit-user-select: none; user-select: none; cursor: default; animation: splash-in 240ms ease-out 120ms both; transition: opacity 160ms ease-out; - /* One compositing layer for its whole life: WebKit layers it only while the opacity animates, - and the fade-out's fresh layer re-snaps the centred text, which then jumps up half a pixel. */ - will-change: opacity; } .splash.splash-out { opacity: 0; pointer-events: none; } :root[data-theme^="dark"] .splash { color: #8c8f95; } -.splash svg { width: 56px; height: 56px; } +.splash-logo { width: 56px; height: 56px; } +/* 15px Inter: 2048 units per em, the line box from ascender to descender. */ +.splash-wordmark { width: 73.2px; height: 18.15px; } .splash-bar { width: 120px; height: 2px; border-radius: 1px; overflow: hidden; background: rgb(127 127 127 / 0.18); } /* Fake progress: eases towards ~90% over the 4 s cap; hideSplash() fills it before fading out. */ .splash-bar > i { diff --git a/src/styles/fonts.css b/src/styles/fonts.css deleted file mode 100644 index b51f53f4..00000000 --- a/src/styles/fonts.css +++ /dev/null @@ -1,4 +0,0 @@ -/* Linked from index.html rather than imported by global.css, so the faces are known before the - splash first paints: the dev server injects the app CSS only once the bundle runs. */ -@import "@fontsource-variable/inter"; -@import "@fontsource-variable/jetbrains-mono"; diff --git a/src/styles/global.css b/src/styles/global.css index 3434d5a0..ba8dff29 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,3 +1,5 @@ +@import "@fontsource-variable/inter"; +@import "@fontsource-variable/jetbrains-mono"; @import "./tokens.css"; /* Monochrome icons default to the theme icon grey; callers override for selected / disabled. */ From 8ec35c94ec4e0ecf1d8a0050ded449aac7ff6c74 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:41:20 +0800 Subject: [PATCH 2/3] fix(lsp): Find usages in unopened files outside the language server's scope (#89) Reference-based features only asked the language server, which analyses its configured scope (e.g. Pyright's `include`) plus open files, so usages in unopened files outside that scope were silently missing. Find Usages, Show Usages, usage counts, Safe Delete of a symbol, Rename and incoming calls now also search project files that contain the symbol's name (whole word, same language, .gitignore/excludes respected, unsaved edits included, up to 300 files, cancellable). Candidates the first answer didn't cover are opened in the server temporarily, queried and closed again. Files the user has open are never touched, and diagnostics for the temporary files are dropped. An empty result while the server is still indexing is retried once indexing ends. When results may still be incomplete (indexing, file cap), the usages UI says so. `lsp_references` takes a RequestId and is cancelled by newer searches. Not covered yet: the Safe Delete scan when deleting a file or directory. Fixes #83 Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- crates/ide-lsp/src/handler.rs | 8 + crates/ide-lsp/src/lib.rs | 204 ++++++---- crates/ide-lsp/src/scope.rs | 248 ++++++++++++ crates/ide-lsp/src/server.rs | 108 ++++++ crates/ide-lsp/src/types.rs | 17 + crates/ide-lsp/tests/mock_server.rs | 353 +++++++++++++++++- crates/ide-search/src/lib.rs | 2 + crates/ide-search/src/words.rs | 118 ++++++ src-tauri/src/commands/lsp.rs | 13 +- src-tauri/src/state.rs | 33 ++ src/bindings.ts | 19 +- src/editor/lsp/codeLens.ts | 6 +- src/editor/refactor/safeDelete.test.ts | 5 +- src/editor/refactor/safeDelete.ts | 7 +- .../code-insight/syntaxUsages.test.ts | 14 +- src/features/code-insight/usages.ts | 56 ++- .../code-insight/usagesPopup.test.tsx | 2 +- 17 files changed, 1116 insertions(+), 97 deletions(-) create mode 100644 crates/ide-lsp/src/scope.rs create mode 100644 crates/ide-search/src/words.rs diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 3e79608d..c5df7398 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -75,8 +75,12 @@ impl ClientHandler { _ => return, } }; + let ended = matches!(&event, IdeEvent::Task { done: true, .. }); mgr.sink().emit(event); self.server.refresh_busy_status(mgr.sink()); + if ended { + self.server.progress_ended(); + } } fn publish_diagnostics(&self, mgr: &LspManager, params: Value) { @@ -88,6 +92,10 @@ impl ClientHandler { } }; let Some(path) = uri_to_path(params.uri.as_str()) else { return }; + // Files opened only for a request (references search) are not the user's problems. + if self.server.suppresses_diagnostics(&path) { + return; + } let conv = mgr.conv(self.encoding()); let items = params .diagnostics diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index a608317d..3ba57aa2 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -13,6 +13,7 @@ mod handler; mod inspect; pub mod jdtls; mod launch; +mod scope; mod server; mod tcp; mod text; @@ -23,6 +24,7 @@ mod virtual_doc; pub use inspect::{FileInspection, InspectHost, InspectOptions}; pub use launch::{LaunchError, Launcher, ProcessLauncher, search_path}; +pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES}; pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path}; use caps::{ServerCaps, SyncKind}; @@ -143,6 +145,7 @@ pub struct LspManager { docs: Mutex>, apply_edit_hook: RwLock>, config_hook: RwLock>, + candidates_hook: RwLock>>, shutting_down: AtomicBool, /// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure. shown_messages: Mutex>, @@ -183,6 +186,19 @@ fn rpc_err(server: &str, method: &str, e: RpcError) -> IdeError { } } +/// Sends the user's `didOpen` for `path`, closing a transient copy of the file +/// first (one a references search opened). +fn user_did_open(server: &Server, run: &Running, path: &Path, uri: &lsp::Uri, params: Value) { + if server.take_transient(path, run.generation) + && let Err(e) = run.peer.notify("textDocument/didClose", json!({ "textDocument": { "uri": uri } })) + { + tracing::debug!("{}: didClose: {e}", server.name); + } + if let Err(e) = run.peer.notify("textDocument/didOpen", params) { + tracing::debug!("{}: didOpen: {e}", server.name); + } +} + fn unsupported(feature: &str) -> IdeError { IdeError::unavailable(format!("no running language server supports {feature}")) } @@ -235,6 +251,7 @@ impl LspManager { docs: Mutex::new(HashMap::new()), apply_edit_hook: RwLock::new(None), config_hook: RwLock::new(None), + candidates_hook: RwLock::new(None), shutting_down: AtomicBool::new(false), shown_messages: Mutex::new(HashSet::new()), virtual_origins: Mutex::new(HashMap::new()), @@ -299,10 +316,8 @@ impl LspManager { } let mut attached = Attached { server: server.clone(), opened_gen: None }; if let Some(run) = server.running() { - if run.caps.read().open_close - && let Err(e) = run.peer.notify("textDocument/didOpen", d.did_open_params()) - { - tracing::debug!("{}: didOpen: {e}", server.name); + if run.caps.read().open_close { + user_did_open(&server, &run, &d.path, &d.uri, d.did_open_params()); } attached.opened_gen = Some(run.generation); } @@ -481,6 +496,7 @@ impl LspManager { for (id, title) in server.take_progress() { self.sink.emit(ide_core::IdeEvent::Task { id, title, message: None, fraction: None, done: true }); } + server.progress_ended(); for path in server.take_diagnostic_paths() { self.sink.emit(ide_core::IdeEvent::Diagnostics { path: path.into(), @@ -596,10 +612,8 @@ impl LspManager { let params = state.did_open_params(); for a in &mut state.servers { if let Some(run) = a.server.running() { - if run.caps.read().open_close - && let Err(e) = run.peer.notify("textDocument/didOpen", params.clone()) - { - tracing::debug!("{}: didOpen: {e}", a.server.name); + if run.caps.read().open_close { + user_did_open(&a.server, &run, &state.path, &state.uri, params.clone()); } a.opened_gen = Some(run.generation); } @@ -945,40 +959,61 @@ impl LspManager { out.finish(Vec::new()) } - pub async fn references(&self, doc: DocId, pos: Pos, include_declaration: bool) -> IdeResult> { + /// References of the symbol at `pos`. With `project_wide`, files outside + /// the servers' analysis scope that mention the name are opened for the + /// search (see [`scope`]); without, only what the servers analyse counts + /// (cheap, for bulk scans over many symbols). + pub async fn references( + &self, + doc: DocId, + pos: Pos, + include_declaration: bool, + project_wide: bool, + cancel: CancellationToken, + ) -> IdeResult { let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?; let requests = targets.iter().map(|t| { - let mut params = snap.pos_params(t, pos); - params["context"] = json!({ "includeDeclaration": include_declaration }); + let (snap, cancel) = (&snap, &cancel); async move { - ( - t, - self.call::<_, Option>>( - t, - "textDocument/references", - params, - None, - LONG_TIMEOUT, - ) - .await, - ) + let query = || async move { + let mut params = snap.pos_params(t, pos); + params["context"] = json!({ "includeDeclaration": include_declaration }); + let locs: Option> = + self.call(t, "textDocument/references", params, Some(cancel), LONG_TIMEOUT).await?; + let conv = self.conv(t.enc()); + Ok(locs.unwrap_or_default().iter().filter_map(|l| conv.location(l)).collect::>()) + }; + let files = + |v: &Vec| v.iter().filter(|l| l.uri.is_none()).map(|l| l.path.to_path_buf()).collect(); + let result = if project_wide { + self.widened(snap, t, pos, cancel, query, files).await + } else { + query().await.map(|first| { + let empty = first.is_empty(); + scope::Widened::only(first, empty, &t.server) + }) + }; + (t, result) } }); let mut out = Outcome::new(); for (t, r) in join_all(requests).await { match r { - Ok(locs) => { - let conv = self.conv(t.enc()); - let locs: Vec = - locs.unwrap_or_default().iter().filter_map(|l| conv.location(l)).collect(); + Ok(w) => { + let acc = out.value.get_or_insert_with(References::default); + let locs: Vec = w.first.into_iter().chain(w.second.into_iter().flatten()).collect(); self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref())); - out.value.get_or_insert_with(Vec::new).extend(locs); + acc.locations.extend(locs); + acc.incomplete = acc.incomplete.or(w.incomplete); } Err(e) => out.fail(e), } } - let mut v = out.finish(Vec::new())?; - convert::dedup_locations(&mut v); + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let mut v = out.finish(References::default())?; + convert::dedup_locations(&mut v.locations); Ok(v) } @@ -1208,13 +1243,29 @@ impl LspManager { }) } + /// Renames across the project: like [`Self::references`], files outside the + /// server's analysis scope that mention the name are opened for the request. pub async fn rename(&self, doc: DocId, pos: Pos, new_name: String) -> IdeResult { let (snap, targets) = self.doc_targets(doc, "rename", |c| c.has("renameProvider")).await?; let t = &targets[0]; - 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?; - Ok(r.map(|e| convert::workspace_edit(&self.conv(t.enc()), e)).unwrap_or_default()) + 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?; + Ok(r.map(|e| convert::workspace_edit(&self.conv(t.enc()), e)).unwrap_or_default()) + }; + let files = |e: &WorkspaceEdit| { + e.files + .iter() + .filter_map(|f| match f { + ide_core::FileEdit::Edit { path, .. } => Some(path.to_path_buf()), + _ => None, + }) + .collect() + }; + let w = self.widened(&snap, t, pos, &CancellationToken::new(), query, files).await?; + // The second edit covers the first: its files stay analysed. + Ok(w.second.unwrap_or(w.first)) } // ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ---- @@ -1263,16 +1314,17 @@ impl LspManager { /// Some servers (tsserver) only answer for files of a loaded project: when /// `t` has no document open, one moved file of its languages is opened for the request. - fn open_for_file_op(&self, t: &Target, moves: &[(PathBuf, PathBuf, bool)]) -> Option { + fn open_for_file_op(&self, t: &Target, moves: &[(PathBuf, PathBuf, bool)]) -> scope::TransientFiles<'_> { + let mut opened = scope::TransientFiles::empty(self, t); if !t.caps().open_close { - return None; + return opened; } - let docs = self.docs.lock(); - let busy = docs - .values() - .any(|d| d.servers.iter().any(|a| a.server.id == t.server.id && a.opened_gen == Some(t.run.generation))); + let busy = + self.docs.lock().values().any(|d| { + d.servers.iter().any(|a| a.server.id == t.server.id && a.opened_gen == Some(t.run.generation)) + }); if busy { - return None; + return opened; } let languages = t.server.languages(); let candidate = @@ -1287,14 +1339,10 @@ impl LspManager { continue; } let Ok(text) = std::fs::read_to_string(&file) else { continue }; - let uri = uri::path_to_uri(&file); - let params = - json!({ "textDocument": { "uri": uri, "languageId": language.as_str(), "version": 0, "text": text } }); - if t.run.peer.notify("textDocument/didOpen", params).is_ok() { - return Some(uri); - } + opened.hold(&file, &language, &text); + break; } - None + opened } /// Asks the servers for the edits that keep references intact when files or @@ -1320,9 +1368,7 @@ impl LspManager { let opened = self.open_for_file_op(&t, &moves); let r: IdeResult> = self.call(&t, "workspace/willRenameFiles", params, Some(&cancel), LONG_TIMEOUT).await; - if let Some(uri) = opened { - let _ = t.run.peer.notify("textDocument/didClose", json!({ "textDocument": { "uri": uri } })); - } + drop(opened); match r { Ok(Some(edit)) => out.edit.files.extend(convert::workspace_edit(&self.conv(t.enc()), edit).files), Ok(None) => {} @@ -1519,32 +1565,46 @@ impl LspManager { let items: Option> = self.call(t, "textDocument/prepareCallHierarchy", snap.pos_params(t, pos), None, DEFAULT_TIMEOUT).await?; let items = items.filter(|i| !i.is_empty()).ok_or_else(no_hierarchy_target)?; - let mut out = Vec::new(); - for item in items { - let params = json!({ "item": item }); - match direction { - HierarchyDirection::Incoming => { - let calls: Option> = - self.call(t, "callHierarchy/incomingCalls", params, None, 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()); - let ranges = c.from_ranges.iter().map(|r| conv.range(&path, *r)).collect(); - out.extend(convert::call_item(&conv, &c.from, ranges)); + let query = || async { + let mut out = Vec::new(); + for item in &items { + let params = json!({ "item": item }); + match direction { + HierarchyDirection::Incoming => { + let calls: Option> = + self.call(t, "callHierarchy/incomingCalls", params, None, 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()); + let ranges = c.from_ranges.iter().map(|r| conv.range(&path, *r)).collect(); + out.extend(convert::call_item(&conv, &c.from, ranges)); + } } - } - HierarchyDirection::Outgoing => { - let calls: Option> = - self.call(t, "callHierarchy/outgoingCalls", params, None, LONG_TIMEOUT).await?; - let conv = self.conv(t.enc()); - let (src, _) = uri_target(item.uri.as_str()); - for c in calls.unwrap_or_default() { - let ranges = c.from_ranges.iter().map(|r| conv.range(&src, *r)).collect(); - out.extend(convert::call_item(&conv, &c.to, ranges)); + HierarchyDirection::Outgoing => { + let calls: Option> = + self.call(t, "callHierarchy/outgoingCalls", params, None, LONG_TIMEOUT).await?; + let conv = self.conv(t.enc()); + let (src, _) = uri_target(item.uri.as_str()); + for c in calls.unwrap_or_default() { + let ranges = c.from_ranges.iter().map(|r| conv.range(&src, *r)).collect(); + out.extend(convert::call_item(&conv, &c.to, ranges)); + } } } } - } + Ok(out) + }; + let out = 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) + } + HierarchyDirection::Outgoing => query().await?, + }; self.note_virtual(&t.server, out.iter().map(|i| i.uri.as_deref())); Ok(out) } diff --git a/crates/ide-lsp/src/scope.rs b/crates/ide-lsp/src/scope.rs new file mode 100644 index 00000000..003b1758 --- /dev/null +++ b/crates/ide-lsp/src/scope.rs @@ -0,0 +1,248 @@ +//! Reference searches beyond a server's analysis scope. +//! +//! Servers answer `textDocument/references` (and rename, incoming calls) from +//! the files they analyse: their configured scope (Pyright's `include`, a +//! `tsconfig.json`, a compilation database) plus the open documents. Usages in +//! other project files are silently missing. So, as a word index would, the +//! project files of the server's languages that contain the symbol's name as a +//! whole word (found through the host's [`CandidateFn`]) and that the first +//! answer did not cover are opened on the server for a second request, then +//! closed again. Diagnostics the server publishes for them are dropped. + +use crate::server::{Running, Server}; +use crate::types::ReferencesIncomplete; +use crate::{DocSnap, LspManager, Target}; +use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Pos}; +use lsp_types as lsp; +use serde_json::json; +use std::collections::HashSet; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +/// Files opened for one search at most. +pub const MAX_CANDIDATES: usize = 300; +/// Larger files (generated code, bundles) are not opened. +const MAX_CANDIDATE_BYTES: u64 = 1 << 20; +/// How long a search that found nothing waits for a server to finish indexing. +const INDEX_WAIT: Duration = Duration::from_secs(10); + +/// Project files that may mention a symbol. +#[derive(Clone, Debug)] +pub struct CandidateQuery { + /// Matched as a whole word, case-sensitively. + pub word: String, + /// Only files under this directory (the server's workspace root). + pub root: PathBuf, + /// Only files of these languages. + pub languages: Vec, + pub limit: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct CandidateFiles { + pub files: Vec, + /// More files matched than `limit`. + pub limit_hit: bool, +} + +/// Finds [`CandidateFiles`] (honouring the project's ignore rules); runs on a +/// blocking thread and should stop early when cancelled. +pub type CandidateFn = dyn Fn(&CandidateQuery, &CancellationToken) -> CandidateFiles + Send + Sync; + +/// A reference-based answer and, when files were opened for it, the second one. +pub(crate) struct Widened { + pub first: R, + pub second: Option, + pub incomplete: Option, +} + +impl Widened { + /// An answer from the server's analysis scope alone; `empty`: it found nothing. + pub fn only(first: R, empty: bool, server: &Server) -> Self { + let incomplete = (empty && server.is_busy()).then_some(ReferencesIncomplete::Indexing); + Self { first, second: None, incomplete } + } +} + +/// The name a search is about: the word at `pos` (a PHP `$` sigil dropped). +fn search_word(snap: &DocSnap, pos: Pos) -> Option { + let r = snap.text.word_range_at(pos)?; + let text = snap.text.as_str(); + let word = text.get(snap.text.byte_offset(r.start)..snap.text.byte_offset(r.end))?.trim_start_matches('$'); + (!word.is_empty() && !word.chars().all(|c| c.is_ascii_digit())).then(|| word.to_owned()) +} + +impl LspManager { + /// Finds the project files a references search may open (see the module docs). + pub fn set_candidates_hook(&self, hook: Box) { + *self.candidates_hook.write() = Some(Arc::from(hook)); + } + + async fn candidates(&self, t: &Target, word: String, cancel: &CancellationToken) -> Option { + let hook = self.candidates_hook.read().clone()?; + let query = CandidateQuery { + word, + root: t.server.root.clone(), + languages: t.server.languages(), + limit: MAX_CANDIDATES, + }; + let cancel = cancel.clone(); + tokio::task::spawn_blocking(move || hook(&query, &cancel)).await.ok() + } + + /// Runs `query` on `t`; then again with the project files that mention the + /// symbol at `pos` and are not in `covered(first)` opened on the server. + /// A first answer that is empty while the server indexes is retried once + /// indexing ends. + pub(crate) async fn widened( + &self, + snap: &DocSnap, + t: &Target, + pos: Pos, + cancel: &CancellationToken, + query: F, + covered: impl Fn(&R) -> Vec, + ) -> IdeResult> + where + F: Fn() -> Fut, + Fut: Future>, + { + let busy = t.server.is_busy(); + let word = search_word(snap, pos).filter(|_| t.caps().open_close); + let candidates = async { + match word { + Some(w) => self.candidates(t, w, cancel).await, + None => None, + } + }; + let (first, candidates) = futures::join!(query(), candidates); + let mut first = first?; + if busy && covered(&first).is_empty() && t.server.wait_idle(INDEX_WAIT, cancel).await { + first = query().await?; + } + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + // Servers report analysis progress often; only "nothing found" while it lasts is suspect. + let mut incomplete = + (t.server.is_busy() && covered(&first).is_empty()).then_some(ReferencesIncomplete::Indexing); + let Some(candidates) = candidates else { return Ok(Widened { first, second: None, incomplete }) }; + if candidates.limit_hit { + incomplete = incomplete.or(Some(ReferencesIncomplete::TooManyFiles)); + } + let known: HashSet = covered(&first).into_iter().chain([snap.path.clone()]).collect(); + let files: Vec = candidates.files.into_iter().filter(|f| !known.contains(f)).collect(); + if files.is_empty() { + return Ok(Widened { first, second: None, incomplete }); + } + let opened = self.open_transient(t, files, cancel).await; + if opened.files.is_empty() { + return Ok(Widened { first, second: None, incomplete }); + } + tracing::debug!("{}: opened {} more files for a references search", t.server.name, opened.files.len()); + let second = query().await; + drop(opened); + match second { + Ok(second) => { + if !covered(&second).is_empty() && incomplete == Some(ReferencesIncomplete::Indexing) { + incomplete = None; + } + Ok(Widened { first, second: Some(second), incomplete }) + } + Err(e) if cancel.is_cancelled() => Err(e), + Err(e) => { + tracing::debug!("{}: widened references search: {}", t.server.name, e.message); + Ok(Widened { first, second: None, incomplete }) + } + } + } + + /// Opens `files` on `t` (read from disk) unless the user has them open; + /// they are closed when the returned guard drops. + async fn open_transient(&self, t: &Target, files: Vec, cancel: &CancellationToken) -> TransientFiles<'_> { + let read = { + 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::>() + }) + .await + .unwrap_or_default() + }; + let mut guard = TransientFiles { mgr: self, server: t.server.clone(), run: t.run.clone(), files: Vec::new() }; + if cancel.is_cancelled() { + return guard; + } + let docs = self.docs.lock(); + for (path, text) in read { + if docs.values().any(|d| d.path == path) { + continue; + } + let uri = crate::uri::path_to_uri(&path); + if t.server.hold_transient(&path, t.run.generation) { + let language = self.languages.detect(&path, text.lines().next()); + let params = json!({ + "textDocument": { "uri": uri, "languageId": language.as_str(), "version": 0, "text": text } + }); + if let Err(e) = t.run.peer.notify("textDocument/didOpen", params) { + tracing::debug!("{}: didOpen: {e}", t.server.name); + } + } + guard.files.push((path, uri)); + } + guard + } +} + +/// Files opened by [`LspManager::open_transient`]; closed on drop. +pub(crate) struct TransientFiles<'a> { + mgr: &'a LspManager, + server: Arc, + run: Running, + pub files: Vec<(PathBuf, lsp::Uri)>, +} + +impl TransientFiles<'_> { + /// Opens one file outside a references search (a file operation needs a loaded project). + pub(crate) fn hold(&mut self, path: &Path, language: &LanguageId, text: &str) { + let _docs = self.mgr.docs.lock(); + let uri = crate::uri::path_to_uri(path); + if self.server.hold_transient(path, self.run.generation) { + let params = + json!({ "textDocument": { "uri": uri, "languageId": language.as_str(), "version": 0, "text": text } }); + if let Err(e) = self.run.peer.notify("textDocument/didOpen", params) { + tracing::debug!("{}: didOpen: {e}", self.server.name); + } + } + self.files.push((path.to_owned(), uri)); + } +} + +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() } + } +} + +impl Drop for TransientFiles<'_> { + fn drop(&mut self) { + if self.files.is_empty() { + return; + } + // Under the documents lock, so a user `didOpen` of the same file can't interleave. + let _docs = self.mgr.docs.lock(); + for (path, uri) in self.files.drain(..) { + if self.server.release_transient(&path, self.run.generation) + && let Err(e) = self.run.peer.notify("textDocument/didClose", json!({ "textDocument": { "uri": uri } })) + { + tracing::debug!("{}: didClose: {e}", self.server.name); + } + } + } +} diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 8025c0d2..04cbb9f8 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -17,6 +17,9 @@ use tokio::sync::{Notify, watch}; const STABLE_UPTIME: Duration = Duration::from_secs(180); const MAX_RESTARTS: usize = 3; const STDERR_LINES: usize = 50; +/// Diagnostics a server publishes for a transiently opened file shortly after +/// its `didClose` (analysis that was already running) are dropped too. +const TRANSIENT_GRACE: Duration = Duration::from_secs(3); /// A live, initialized instance. #[derive(Clone)] @@ -49,6 +52,17 @@ struct State { conn: Conn, } +/// A file opened on the server by the client itself (not by the user) for the +/// duration of a request, e.g. to widen a references search. +struct Transient { + /// Requests holding the file open. + holders: usize, + generation: u64, + /// The user opened the file meanwhile: its `didOpen` replaced ours. + taken: bool, + closed_at: Option, +} + pub struct Progress { pub task: TaskId, pub title: String, @@ -80,6 +94,9 @@ pub struct Server { published: Mutex>, /// Woken on every `publishDiagnostics` (Inspect Code waits on it). pub published_notify: Notify, + /// Woken when the last `$/progress` task ends. + idle_notify: Notify, + transient: Mutex>, } impl Server { @@ -102,6 +119,8 @@ impl Server { diagnostics: Mutex::new(HashMap::new()), published: Mutex::new(HashMap::new()), published_notify: Notify::new(), + idle_notify: Notify::new(), + transient: Mutex::new(HashMap::new()), } } @@ -305,6 +324,95 @@ impl Server { self.set_status(sink, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None); } + /// Whether the server reports work in progress (indexing, analysis). + pub fn is_busy(&self) -> bool { + !self.progress.lock().is_empty() + } + + /// Called after a `$/progress` task ended. + pub fn progress_ended(&self) { + if !self.is_busy() { + self.idle_notify.notify_waiters(); + } + } + + /// Waits until no `$/progress` task is active; false on timeout or cancellation. + pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let notified = self.idle_notify.notified(); + if !self.is_busy() || self.running().is_none() { + return !self.is_busy(); + } + tokio::select! { + () = notified => {} + () = tokio::time::sleep_until(deadline) => return !self.is_busy(), + () = cancel.cancelled() => return false, + } + } + } + + // ---- transiently opened files ---- + + /// Registers a holder of the transient file `path` on instance `generation`; + /// true when the caller must send `didOpen` (first holder). + pub fn hold_transient(&self, path: &Path, generation: u64) -> bool { + let mut map = self.transient.lock(); + map.retain(|_, t| t.holders > 0 || t.closed_at.is_some_and(|c| c.elapsed() < TRANSIENT_GRACE)); + let t = + map.entry(path.to_owned()).or_insert(Transient { holders: 0, generation, taken: false, closed_at: None }); + if t.holders == 0 || t.generation != generation { + *t = Transient { holders: 1, generation, taken: false, closed_at: None }; + return true; + } + t.holders += 1; + false + } + + /// Drops a holder of `path`; true when the caller must send `didClose` + /// (last holder, same instance, and the user did not open the file meanwhile). + pub fn release_transient(&self, path: &Path, generation: u64) -> bool { + let mut map = self.transient.lock(); + let Some(t) = map.get_mut(path) else { return false }; + if t.holders == 0 { + return false; + } + t.holders -= 1; + if t.holders > 0 { + return false; + } + let close = !t.taken && t.generation == generation; + if close { + t.closed_at = Some(Instant::now()); + } else { + map.remove(path); + } + close + } + + /// The user opens `path` on instance `generation`: true when a transient + /// copy is open there and must be closed before the user's `didOpen`. + pub fn take_transient(&self, path: &Path, generation: u64) -> bool { + let mut map = self.transient.lock(); + let Some(t) = map.get_mut(path) else { return false }; + let open = t.holders > 0 && !t.taken && t.generation == generation; + if t.holders > 0 { + t.taken = true; + } else { + map.remove(path); + } + open + } + + /// Whether diagnostics published for `path` belong to a transient copy + /// and must not reach the Problems view. + pub fn suppresses_diagnostics(&self, path: &Path) -> bool { + self.transient + .lock() + .get(path) + .is_some_and(|t| !t.taken && (t.holders > 0 || t.closed_at.is_some_and(|c| c.elapsed() < TRANSIENT_GRACE))) + } + // ---- bookkeeping ---- pub fn push_stderr(&self, line: String) { diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 0499d7c7..4fb820e7 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -322,3 +322,20 @@ pub struct FileRenameEdits { /// Servers that failed; their references are not updated. pub errors: Vec, } + +/// Why a references search may have missed usages. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ReferencesIncomplete { + /// A language server was still indexing. + Indexing, + /// More project files mention the name than were checked. + TooManyFiles, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct References { + pub locations: Vec, + pub incomplete: Option, +} diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 0a024516..0b26e084 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -27,9 +27,13 @@ struct MockState { /// uri → text as reconstructed by the mock from didOpen/didChange. docs: Mutex>, responses: Mutex>, + /// Computes a response from the request and the open documents (before `responses`). + responder: Mutex>, peer: Mutex>, } +type Responder = Arc Option + Send + Sync>; + impl MockState { fn notified(&self, method: &str) -> Vec { self.notifications.lock().iter().filter(|(m, _)| m == method).map(|(_, p)| p.clone()).collect() @@ -111,11 +115,17 @@ impl Handler for MockHandler { fn on_request(&self, method: &str, params: Value) -> Result { let s = &self.0; - s.requests.lock().push((method.to_owned(), params)); + s.requests.lock().push((method.to_owned(), params.clone())); match method { "initialize" => Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } })), "shutdown" => Ok(Value::Null), - _ => Ok(s.responses.lock().get(method).cloned().unwrap_or(Value::Null)), + _ => { + let responder = s.responder.lock().clone(); + if let Some(v) = responder.and_then(|f| f(s, method, ¶ms)) { + return Ok(v); + } + Ok(s.responses.lock().get(method).cloned().unwrap_or(Value::Null)) + } } } } @@ -801,7 +811,7 @@ async fn navigation_rename_format_and_decorations() { assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable); s.respond("textDocument/references", json!([{ "uri": uri, "range": r(0,3,0,6) }, { "uri": uri, "range": r(1,0,1,3) }, { "uri": uri, "range": r(1,0,1,3) }])); - let refs = h.mgr.references(doc, Pos::new(1, 1), true).await.unwrap(); + let refs = h.mgr.references(doc, Pos::new(1, 1), true, true, Default::default()).await.unwrap().locations; assert_eq!(refs.len(), 2); assert_eq!(s.requested("textDocument/references")[0]["context"]["includeDeclaration"], true); @@ -987,7 +997,7 @@ async fn virtual_locations_and_text_document_content() { "textDocument/references", json!([{ "uri": uri_of(&path), "range": r(0, 4, 0, 9) }, { "uri": lib, "range": r(2, 4, 2, 9) }]), ); - let refs = h.mgr.references(doc, Pos::new(0, 5), true).await.unwrap(); + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap().locations; assert_eq!(refs.iter().map(|l| l.uri.is_some()).collect::>(), vec![false, true]); s.respond( "textDocument/prepareTypeHierarchy", @@ -1301,3 +1311,338 @@ async fn css_family_configuration_returns_default_objects() { assert_eq!(state.requested("initialize")[0]["initializationOptions"]["provideFormatter"], true); } } + +// ------------------------------------------------------------------ references beyond the analysis scope + +const WORD: &str = "target"; + +/// Locations of [`WORD`] in `text` (ASCII, so UTF-16 columns are byte offsets). +fn occurrences(uri: &str, text: &str) -> Vec { + let mut out = Vec::new(); + for (line, l) in text.lines().enumerate() { + for (col, _) in l.match_indices(WORD) { + let end = col + WORD.len(); + out.push(json!({ "uri": uri, "range": { + "start": { "line": line, "character": col }, "end": { "line": line, "character": end } } })); + } + } + out +} + +/// 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, _| { + 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)); + match method { + "textDocument/references" => Some(Value::Array(locs().collect())), + "textDocument/rename" => { + let mut changes = serde_json::Map::new(); + for loc in locs() { + let edits = changes.entry(loc["uri"].as_str().unwrap()).or_insert_with(|| json!([])); + edits.as_array_mut().unwrap().push(json!({ "range": loc["range"], "newText": "renamed" })); + } + Some(json!({ "changes": changes })) + } + _ => None, + } + }) +} + +/// Candidate files: those directly under the root that contain the word; records the queries. +fn grep_candidates(h: &Harness) -> Arc>> { + let seen = Arc::new(Mutex::new(Vec::new())); + let log = seen.clone(); + h.mgr.set_candidates_hook(Box::new(move |q, _| { + log.lock().push(q.clone()); + let mut files: Vec = std::fs::read_dir(&q.root) + .unwrap() + .flatten() + .map(|e| e.path()) + .filter(|p| std::fs::read_to_string(p).is_ok_and(|t| t.contains(&q.word))) + .collect(); + files.sort(); + ide_lsp::CandidateFiles { files, limit_hit: false } + })); + seen +} + +fn uris(params: &[Value]) -> Vec { + params.iter().map(|p| p["textDocument"]["uri"].as_str().unwrap().to_owned()).collect() +} + +fn diagnostics_for(h: &Harness, path: &Path) -> usize { + h.events.lock().iter().filter(|e| matches!(e, IdeEvent::Diagnostics { path: p, .. } if p == path)).count() +} + +fn scope_harness() -> (Harness, Arc) { + let h = harness(&[("mock", incremental_caps())]); + let s = h.launcher.state("mock"); + *s.responder.lock() = Some(open_files_only(vec![])); + (h, s) +} + +#[tokio::test(flavor = "multi_thread")] +async fn references_open_unopened_files_that_mention_the_name() { + let (h, s) = scope_harness(); + let seen = grep_candidates(&h); + let b = h.root.join("b.mk"); + let c = h.root.join("c.mk"); + std::fs::write(&b, "call target\n").unwrap(); + std::fs::write(&c, "nothing here\n").unwrap(); + let (doc, a) = h.open("a.mk", "def target\n"); + let (_, d) = h.open("d.mk", "use target\n"); + h.wait_status("mock", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 2).await; + + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + let mut paths: Vec = refs.locations.iter().map(|l| l.path.to_path_buf()).collect(); + paths.sort(); + assert_eq!(paths, vec![a.clone(), b.clone(), d.clone()]); + assert_eq!(refs.incomplete, None); + + let q = seen.lock()[0].clone(); + assert_eq!((q.word.as_str(), q.root.as_path(), q.limit), (WORD, h.root.as_path(), ide_lsp::MAX_CANDIDATES)); + assert_eq!(q.languages, vec![h.lang.clone()]); + + // Only the unopened candidate was opened (from disk), then closed again. + let opens = s.notified("textDocument/didOpen"); + let temp: Vec<&Value> = opens.iter().filter(|p| p["textDocument"]["uri"] == uri_of(&b)).collect(); + assert_eq!(temp.len(), 1); + assert_eq!(temp[0]["textDocument"]["version"], 0); + assert_eq!(temp[0]["textDocument"]["text"], "call target\n"); + assert!(!uris(&opens).contains(&uri_of(&c))); + assert_eq!(uris(&opens).iter().filter(|u| **u == uri_of(&d)).count(), 1); + eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await; + assert_eq!(s.requested("textDocument/references").len(), 2); + assert_eq!(s.docs.lock().len(), 2); + + // When every candidate file is already in the answer, no second request is made. + std::fs::remove_file(&b).unwrap(); + h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!(s.requested("textDocument/references").len(), 3); + + // Without project_wide only the server's scope counts: no project search. + std::fs::write(&b, "call target\n").unwrap(); + let refs = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 2); + assert_eq!(seen.lock().len(), 2); +} + +#[tokio::test(flavor = "multi_thread")] +async fn references_never_reopen_or_close_the_users_files() { + let h = harness(&[("mock", incremental_caps())]); + let s = h.launcher.state("mock"); + grep_candidates(&h); + let (doc, _) = h.open("a.mk", "def target\n"); + // Open, mentions the name, but the server does not report it. + let (_, d) = h.open("d.mk", "use target\n"); + *s.responder.lock() = Some(open_files_only(vec![uri_of(&d)])); + h.wait_status("mock", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 2).await; + + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 1); + assert_eq!(uris(&s.notified("textDocument/didOpen")).iter().filter(|u| **u == uri_of(&d)).count(), 1); + assert!(s.notified("textDocument/didClose").is_empty()); + assert_eq!(s.requested("textDocument/references").len(), 1); + assert!(s.docs.lock().contains_key(&uri_of(&d))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn references_report_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; + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 2); + assert_eq!(refs.incomplete, Some(ReferencesIncomplete::TooManyFiles)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelling_a_references_search_stops_the_candidate_search_and_closes_files() { + 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; + let cancel = ide_core::CancellationToken::new(); + let later = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + later.cancel(); + }); + let err = h.mgr.references(doc, Pos::new(0, 5), true, true, cancel).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + + // Cancelled while the server answers with a file opened for the search. + let b = h.root.join("b.mk"); + std::fs::write(&b, "call target\n").unwrap(); + grep_candidates(&h); + let slow = uri_of(&b); + let inner = open_files_only(vec![]); + *s.responder.lock() = Some(Arc::new(move |s, method, params| { + if s.docs.lock().contains_key(&slow) { + std::thread::sleep(Duration::from_millis(600)); + } + inner(s, method, params) + })); + let cancel = ide_core::CancellationToken::new(); + let later = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + later.cancel(); + }); + let started = std::time::Instant::now(); + let err = h.mgr.references(doc, Pos::new(0, 5), true, true, cancel).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + assert!(started.elapsed() < Duration::from_millis(550)); + eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn diagnostics_of_files_opened_for_a_search_stay_out_of_problems() { + let h = harness(&[("mock", incremental_caps())]); + let s = h.launcher.state("mock"); + grep_candidates(&h); + let b = h.root.join("b.mk"); + std::fs::write(&b, "call target\n").unwrap(); + let (doc, a) = h.open("a.mk", "def target\n"); + let diag = json!([{ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":1}}, "message": "e" }]); + let inner = open_files_only(vec![]); + let (temp, user, d) = (uri_of(&b), uri_of(&a), diag.clone()); + *s.responder.lock() = Some(Arc::new(move |s, method, params| { + if s.docs.lock().contains_key(&temp) { + for uri in [&temp, &user] { + s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri, "diagnostics": d })).unwrap(); + } + } + inner(s, method, params) + })); + h.wait_status("mock", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 1).await; + + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 2); + // Published right after the didClose (analysis still running): dropped too. + s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&b), "diagnostics": diag })).unwrap(); + s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&a), "diagnostics": [] })).unwrap(); + eventually(|| diagnostics_for(&h, &a) == 2).await; + assert_eq!(diagnostics_for(&h, &b), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn opening_a_file_during_a_search_takes_it_over() { + let h = harness(&[("mock", incremental_caps())]); + let s = h.launcher.state("mock"); + grep_candidates(&h); + let b = h.root.join("b.mk"); + std::fs::write(&b, "call target\n").unwrap(); + let (doc, _) = h.open("a.mk", "def target\n"); + let slow = uri_of(&b); + let inner = open_files_only(vec![]); + *s.responder.lock() = Some(Arc::new(move |s, method, params| { + if s.docs.lock().get(&slow).is_some_and(|t| t.starts_with("call")) { + std::thread::sleep(Duration::from_millis(400)); + } + inner(s, method, params) + })); + h.wait_status("mock", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 1).await; + + let mgr = h.mgr.clone(); + let search = tokio::spawn(async move { mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await }); + eventually(|| s.docs.lock().contains_key(&uri_of(&b))).await; + let user_doc = DocId::next(); + h.mgr.did_open(user_doc, &b, &h.lang, 7, "edited target\n"); + search.await.unwrap().unwrap(); + + // The temporary copy was closed before the user's didOpen, and the user's stays open. + let events: Vec<(String, Value)> = s + .notifications + .lock() + .iter() + .filter(|(m, p)| m.starts_with("textDocument/did") && p["textDocument"]["uri"] == uri_of(&b)) + .map(|(m, p)| (m.clone(), p["textDocument"]["version"].clone())) + .collect(); + assert_eq!( + events, + vec![ + ("textDocument/didOpen".to_owned(), json!(0)), + ("textDocument/didClose".to_owned(), Value::Null), + ("textDocument/didOpen".to_owned(), json!(7)), + ] + ); + assert_eq!(s.docs.lock().get(&uri_of(&b)).map(String::as_str), Some("edited target\n")); + // Its diagnostics are the user's again. + s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&b), "diagnostics": [] })).unwrap(); + eventually(|| diagnostics_for(&h, &b) == 1).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_empty_answer_while_indexing_is_retried_when_indexing_ends() { + let h = harness(&[("mock", incremental_caps())]); + let s = h.launcher.state("mock"); + let (doc, path) = h.open("a.mk", "def target\n"); + h.wait_status("mock", ServerStatus::Ready).await; + let peer = s.peer(); + peer.notify("$/progress", json!({ "token": "idx", "value": { "kind": "begin", "title": "Indexing" } })).unwrap(); + h.wait_status("mock", ServerStatus::Busy).await; + let (state, uri) = (s.clone(), uri_of(&path)); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + state.respond("textDocument/references", Value::Array(occurrences(&uri, "def target\n"))); + peer.notify("$/progress", json!({ "token": "idx", "value": { "kind": "end" } })).unwrap(); + }); + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 1); + assert_eq!(refs.incomplete, None); + assert_eq!(s.requested("textDocument/references").len(), 2); + + // While the server works, results are taken as they are; finding nothing is flagged. + s.peer() + .notify("$/progress", json!({ "token": "again", "value": { "kind": "begin", "title": "Indexing" } })) + .unwrap(); + h.wait_status("mock", ServerStatus::Busy).await; + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert_eq!((refs.locations.len(), refs.incomplete), (1, None)); + s.respond("textDocument/references", json!([])); + let refs = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap(); + assert_eq!(refs.locations.len(), 0); + assert_eq!(refs.incomplete, Some(ReferencesIncomplete::Indexing)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn rename_edits_files_outside_the_analysis_scope() { + let (h, s) = scope_harness(); + grep_candidates(&h); + let b = h.root.join("b.mk"); + std::fs::write(&b, "call target\n").unwrap(); + 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 mut paths: Vec = edit + .files + .iter() + .map(|f| match f { + FileEdit::Edit { path, .. } => path.to_path_buf(), + _ => panic!("unexpected {f:?}"), + }) + .collect(); + paths.sort(); + assert_eq!(paths, vec![a, b.clone()]); + eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await; +} diff --git a/crates/ide-search/src/lib.rs b/crates/ide-search/src/lib.rs index 8147272f..97515510 100644 --- a/crates/ide-search/src/lib.rs +++ b/crates/ide-search/src/lib.rs @@ -14,6 +14,7 @@ mod mask; mod pattern; mod query; mod replace; +mod words; pub use files::{ScopeFile, scope_files}; pub use mask::FileMask; @@ -22,6 +23,7 @@ pub use replace::{ ReplaceEvent, ReplaceHost, ReplacePreview, ReplaceSummary, ReplaceTarget, apply_edits, preserve_case, preview, replace_files, replacement_edits, }; +pub use words::{WordFiles, WordQuery, files_with_word}; use grep_matcher::{Captures, Matcher}; use ide_core::{CancellationToken, IdeError, IdeResult}; diff --git a/crates/ide-search/src/words.rs b/crates/ide-search/src/words.rs new file mode 100644 index 00000000..ceb94a25 --- /dev/null +++ b/crates/ide-search/src/words.rs @@ -0,0 +1,118 @@ +//! Files that mention an identifier: the candidates a reference search checks. + +use crate::{Overlay, PathFilter, SearchContext, SearchQuery, SearchScope}; +use ide_core::{CancellationToken, IdeResult}; +use std::path::PathBuf; +use std::sync::Mutex; + +#[derive(Clone, Debug)] +pub struct WordQuery { + /// Matched as a whole word, case-sensitively. + pub word: String, + /// Directory searched (absolute, or relative to the search root). + pub dir: PathBuf, + /// File masks never searched (excluded roots, ignored names). + pub exclude: Vec, + /// Files returned at most. + pub limit: usize, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WordFiles { + pub files: Vec, + /// More files matched than `limit`; the search stopped early. + pub limit_hit: bool, +} + +/// Files under `query.dir` containing `query.word` as a whole word, in walk +/// order (unsaved buffers through `overlay`, `.gitignore`d and hidden files +/// skipped like Find in Files). Stops at `query.limit` files or when cancelled. +pub fn files_with_word( + root: &std::path::Path, + query: &WordQuery, + filter: Option>, + overlay: Overlay<'_>, + cancel: &CancellationToken, +) -> IdeResult { + let search = SearchQuery { + pattern: query.word.clone(), + is_regex: false, + case_sensitive: true, + whole_word: true, + include: Vec::new(), + exclude: query.exclude.clone(), + scope: SearchScope::Directory { path: query.dir.clone().into() }, + max_matches: u32::MAX, + context: SearchContext::Any, + }; + let stop = cancel.child_token(); + let found = Mutex::new(WordFiles::default()); + crate::search_filtered(root, &search, filter, overlay, &stop, &|file| { + let mut found = found.lock().unwrap_or_else(|e| e.into_inner()); + if found.files.len() < query.limit { + found.files.push(file.path.into_path_buf()); + } else { + found.limit_hit = true; + stop.cancel(); + } + })?; + Ok(found.into_inner().unwrap_or_else(|e| e.into_inner())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn query(word: &str, limit: usize) -> WordQuery { + WordQuery { word: word.into(), dir: PathBuf::from("."), exclude: vec!["build/**".into()], limit } + } + + #[test] + fn whole_word_matches_in_kept_files_only() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::create_dir_all(root.join("pkg/ignored")).unwrap(); + fs::create_dir_all(root.join("build")).unwrap(); + fs::write(root.join(".gitignore"), "ignored/\n").unwrap(); + fs::write(root.join("pkg/use.py"), "x.get_value(1)\n").unwrap(); + fs::write(root.join("pkg/longer.py"), "get_values()\n").unwrap(); + fs::write(root.join("pkg/case.py"), "Get_Value()\n").unwrap(); + fs::write(root.join("pkg/notes.txt"), "get_value\n").unwrap(); + fs::write(root.join("pkg/ignored/gen.py"), "get_value\n").unwrap(); + fs::write(root.join("build/out.py"), "get_value\n").unwrap(); + fs::write(root.join("pkg/open.py"), "nothing on disk\n").unwrap(); + let unsaved = root.join("pkg/open.py"); + let overlay = |p: &std::path::Path| (p == unsaved).then(|| "get_value()".to_owned()); + let py = |rel: &str| rel.ends_with(".py"); + let mut found = + files_with_word(root, &query("get_value", 10), Some(&py), &overlay, &CancellationToken::new()).unwrap(); + found.files.sort(); + assert_eq!( + found, + WordFiles { files: vec![root.join("pkg/open.py"), root.join("pkg/use.py")], limit_hit: false } + ); + } + + #[test] + fn stops_at_the_limit() { + let dir = tempfile::tempdir().unwrap(); + for i in 0..5 { + fs::write(dir.path().join(format!("f{i}.py")), "name\n").unwrap(); + } + let found = files_with_word(dir.path(), &query("name", 2), None, &|_| None, &CancellationToken::new()).unwrap(); + assert_eq!((found.files.len(), found.limit_hit), (2, true)); + let all = files_with_word(dir.path(), &query("name", 5), None, &|_| None, &CancellationToken::new()).unwrap(); + assert_eq!((all.files.len(), all.limit_hit), (5, false)); + } + + #[test] + fn a_cancelled_search_finds_nothing() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("a.py"), "name\n").unwrap(); + let cancel = CancellationToken::new(); + cancel.cancel(); + let found = files_with_word(dir.path(), &query("name", 5), None, &|_| None, &cancel).unwrap(); + assert!(found.files.is_empty()); + } +} diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 9a11ac0b..352e4c90 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -106,16 +106,25 @@ pub async fn lsp_virtual_document( project!(window, state).lsp.virtual_document(&uri).await } +/// References of the symbol at `pos`. `project_wide` also searches project files +/// outside the language server's analysis scope; bulk scans over many symbols +/// leave it off. Cancel with `cancel_request(request)`. #[tauri::command] #[specta::specta] pub async fn lsp_references( window: Window, state: State<'_, AppState>, + request: RequestId, doc: DocId, pos: Pos, include_declaration: bool, -) -> IdeResult> { - project!(window, state).lsp.references(doc, pos, include_declaration).await + project_wide: bool, +) -> IdeResult { + let lsp = project!(window, state).lsp.clone(); + let cancel = state.begin_request(request); + let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await; + state.end_request(request); + result } #[tauri::command] diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index eded2c92..0f2073ae 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -442,6 +442,12 @@ impl AppState { content_watchers: Mutex::new(content_watchers), _scratch_watcher: scratch_watcher, }); + { + let weak = Arc::downgrade(&project); + project.lsp.set_candidates_hook(Box::new(move |q, cancel| { + weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default() + })); + } self.projects.write().insert(window.to_owned(), project.clone()); Ok(project) } @@ -609,6 +615,33 @@ impl Project { out } + /// Project files of `q.languages` under `q.root` containing `q.word` as a whole + /// word (unsaved buffers included; excluded roots, ignored names and + /// `.gitignore`d files skipped): the files a references search opens on its server. + pub fn reference_candidates( + &self, + q: &ide_lsp::CandidateQuery, + cancel: &CancellationToken, + ) -> ide_lsp::CandidateFiles { + if !q.root.starts_with(&self.root) { + return ide_lsp::CandidateFiles::default(); + } + let query = ide_search::WordQuery { + word: q.word.clone(), + dir: q.root.clone(), + exclude: self.search_excludes(), + limit: q.limit, + }; + let filter = |rel: &str| q.languages.contains(&self.languages.detect(&self.root.join(rel), None)); + match ide_search::files_with_word(&self.root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) { + Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit }, + Err(e) => { + tracing::debug!("reference candidates: {}", e.message); + ide_lsp::CandidateFiles::default() + } + } + } + /// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected). pub fn exclude_globs(&self) -> Vec { exclude_globs(&self.settings.effective(), &self.detected_roots.read()) diff --git a/src/bindings.ts b/src/bindings.ts index cab0ea6c..d9149fa5 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -287,7 +287,12 @@ export const commands = { * language server returned a non-`file:` URI for. Cached per project. */ lspVirtualDocument: (uri: string) => typedError(__TAURI_INVOKE("lsp_virtual_document", { uri })), - lspReferences: (doc: DocId, pos: Pos, includeDeclaration: boolean) => typedError(__TAURI_INVOKE("lsp_references", { doc, pos, includeDeclaration })), + /** + * References of the symbol at `pos`. `project_wide` also searches project files + * outside the language server's analysis scope; bulk scans over many symbols + * leave it off. Cancel with `cancel_request(request)`. + */ + lspReferences: (request: RequestId, doc: DocId, pos: Pos, includeDeclaration: boolean, projectWide: boolean) => typedError(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide })), lspDocumentHighlights: (doc: DocId, pos: Pos) => typedError(__TAURI_INVOKE("lsp_document_highlights", { doc, pos })), lspWorkspaceSymbols: (request: RequestId, query: string) => typedError(__TAURI_INVOKE("lsp_workspace_symbols", { request, query })), lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError(__TAURI_INVOKE("lsp_code_actions", { doc, range, only })), @@ -4175,6 +4180,18 @@ export type RecentProject = { branch: string | null, }; +export type References = { + locations: Location[], + incomplete: ReferencesIncomplete | null, +}; + +/** Why a references search may have missed usages. */ +export type ReferencesIncomplete = +/** A language server was still indexing. */ +"indexing" | +/** More project files mention the name than were checked. */ +"tooManyFiles"; + export type Remote = { name: string, /** Fetch URL. */ diff --git a/src/editor/lsp/codeLens.ts b/src/editor/lsp/codeLens.ts index 66832e8f..136ce82a 100644 --- a/src/editor/lsp/codeLens.ts +++ b/src/editor/lsp/codeLens.ts @@ -9,7 +9,7 @@ import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { codeLenses, type CodeLens } from "@/editor/api"; import { executeAction } from "@/platform/actions"; -import { commands, type OutlineItem, type SymbolKind } from "@/platform/ipc"; +import { commands, nextRequestId, type OutlineItem, type SymbolKind } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { debounce, docOf, isUnavailable, lspRequest } from "./lsp"; import { posToOffset } from "./positions"; @@ -166,7 +166,9 @@ class UsagesLensPlugin { await Promise.all( todo.slice(i, i + CONCURRENCY).map(async (t) => { try { - const refs = await lspRequest(() => commands.lspReferences(doc.doc, { line: t.line, col: t.col }, false)); + const { locations: refs } = await lspRequest(() => + commands.lspReferences(nextRequestId(), doc.doc, { line: t.line, col: t.col }, false, true), + ); this.cache.set(key(t), refs.length); } catch (e) { if (isUnavailable(e)) unavailable = true; diff --git a/src/editor/refactor/safeDelete.test.ts b/src/editor/refactor/safeDelete.test.ts index d5e088c7..cfe0e53f 100644 --- a/src/editor/refactor/safeDelete.test.ts +++ b/src/editor/refactor/safeDelete.test.ts @@ -16,7 +16,10 @@ let outline: Answer = ok([]); let references: Answer = ok([]); const lspGoto = vi.fn(async () => goto()); const docOutline = vi.fn(async () => outline()); -const lspReferences = vi.fn(async () => references()); +const lspReferences = vi.fn(async () => { + const r = references(); + return r.status === "ok" ? { status: "ok" as const, data: { locations: r.data, incomplete: null } } : r; +}); const lspCodeActions = vi.fn(async () => ({ status: "ok", data: [] })); const lspApplyCodeAction = vi.fn(async () => ({ status: "ok", data: null })); const docOpen = vi.fn(async (path: string) => ({ status: "ok", data: { id: 99, path } })); diff --git a/src/editor/refactor/safeDelete.ts b/src/editor/refactor/safeDelete.ts index ee6f6609..81ceb1f5 100644 --- a/src/editor/refactor/safeDelete.ts +++ b/src/editor/refactor/safeDelete.ts @@ -13,7 +13,7 @@ import type { EditorView } from "@codemirror/view"; import type { Text } from "@codemirror/state"; import type { LocalSymbol, Location, OutlineItem, Pos, Range, SyntaxNode } from "@/bindings"; -import { call, commands, isCancelled, isTauri } from "@/platform/ipc"; +import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; import { basename } from "@/platform/project"; import { editorsFor, whenSynced, type EditorDocContext } from "@/editor/api"; @@ -252,7 +252,7 @@ async function findUsages(ctx: EditorDocContext, target: SafeDeleteTarget): Prom comparePos(range.start, target.range.end) > 0; if (target.local) return target.local.references.filter((r) => outside(ctx.path, r)).map((range) => ({ path: ctx.path, range })); - const refs = await lspRequest(() => commands.lspReferences(ctx.doc, target.at, false)); + const { locations: refs } = await lspRequest(() => commands.lspReferences(nextRequestId(), ctx.doc, target.at, false, true)); return refs.filter((l) => outside(l.path, l.range)); } @@ -517,7 +517,8 @@ export async function fileUsages(paths: readonly string[], signal?: AbortSignal) if (signal?.aborted) return; let refs: Location[]; try { - refs = await lspRequest(() => commands.lspReferences(doc, item.selection_range.start, false)); + // Every symbol of every file: the server's own scope (a project search each would be too slow). + refs = (await lspRequest(() => commands.lspReferences(nextRequestId(), doc, item.selection_range.start, false, false))).locations; } catch (e) { if (isUnavailable(e)) { reason = errorText(e); diff --git a/src/features/code-insight/syntaxUsages.test.ts b/src/features/code-insight/syntaxUsages.test.ts index 0755feaf..d675a015 100644 --- a/src/features/code-insight/syntaxUsages.test.ts +++ b/src/features/code-insight/syntaxUsages.test.ts @@ -24,7 +24,7 @@ vi.mock("@/platform/ipc", async (orig) => { vi.mock("./candidates", () => ({ filesMentioning: () => filesMentioning() })); const { _addEditor } = await import("@/editor/api"); -const { fetchUsages } = await import("./usages"); +const { fetchUsages, incompleteNote } = await import("./usages"); const { LOCAL_NOTE, TEXT_NOTE } = await import("./syntaxUsages"); const r = (line: number, col: number, endCol: number): Range => ({ start: { line, col }, end: { line, col: endCol } }); @@ -39,11 +39,21 @@ beforeEach(() => { describe("Find Usages fallback", () => { it("uses the server when it answers, even with nothing", async () => { - cmd.lspReferences.mockReturnValueOnce(ok([])); + cmd.lspReferences.mockReturnValueOnce(ok({ locations: [], incomplete: null })); expect(await fetchUsages(caret)).toEqual({ groups: [], note: null }); expect(cmd.syntaxLocals).not.toHaveBeenCalled(); }); + it("searches project-wide under the given request and says when results may be incomplete", async () => { + const usage = { path: "/p/a.ts", range: r(1, 0, 1) }; + cmd.lspReferences.mockReturnValueOnce(ok({ locations: [usage], incomplete: "indexing" })); + const res = await fetchUsages(caret, 42 as never); + expect(cmd.lspReferences).toHaveBeenCalledWith(42, 3, { line: 1, col: 0 }, false, true); + expect(res.groups[0]!.usages.length).toBe(1); + expect(res.note).toBe(incompleteNote("indexing", "x")); + expect(incompleteNote("tooManyFiles", "x")).toBe("May be incomplete: too many files mention 'x' to search them all"); + }); + it("lists a certain local's references when the server is not ready", async () => { cmd.syntaxLocals.mockReturnValueOnce( ok({ diff --git a/src/features/code-insight/usages.ts b/src/features/code-insight/usages.ts index fe967345..1791bcee 100644 --- a/src/features/code-insight/usages.ts +++ b/src/features/code-insight/usages.ts @@ -1,7 +1,7 @@ /** Find Usages (⌥F7 → "usages" tool window) and Show Usages (⌥⌘F7 → popup). */ import { create } from "zustand"; -import type { Location } from "@/bindings"; -import { commands, isCancelled } from "@/platform/ipc"; +import type { Location, ReferencesIncomplete, RequestId } from "@/bindings"; +import { commands, isCancelled, nextRequestId } from "@/platform/ipc"; import { toolWindowService } from "@/workbench/contributions"; import { caretContext, @@ -119,6 +119,7 @@ export function pinUsageTab(id: number, pinned: boolean) { } export function closeUsageTab(id: number) { + cancelSearch(id); useUsages.setState((s) => { const i = s.tabs.findIndex((t) => t.id === id); const tabs = s.tabs.filter((t) => t.id !== id); @@ -153,15 +154,25 @@ 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; when it is not ready or fails, the syntax fallback - * (an empty server answer is respected). Without a fallback the server error stands. + * The server's references across the project (files outside its analysis scope + * included); when it is not ready or fails, the syntax fallback (an empty server + * answer is respected). Without a fallback the server error stands. + * Cancel with `commands.cancelRequest(request)`. */ -export async function fetchUsages(c: CaretContext): Promise { +export async function fetchUsages(c: CaretContext, request: RequestId = nextRequestId()): Promise { let locs: Location[]; let note: string | null = null; try { - locs = await lspRequest(() => commands.lspReferences(c.doc, { line: c.line, col: c.col }, false)); + const refs = await lspRequest(() => commands.lspReferences(request, c.doc, { line: c.line, col: c.col }, false, true)); + locs = refs.locations; + if (refs.incomplete) note = incompleteNote(refs.incomplete, c.word); } catch (e) { if (isCancelled(e)) throw e; let fb = null; @@ -179,8 +190,22 @@ export async function fetchUsages(c: CaretContext): Promise { return { groups: groupUsages(locs, files), note }; } +/** In-flight server requests: of tool window searches by search id, and of the popup. */ +const inflight = new Map(); +let popupRequest: RequestId | null = null; + +function cancelSearch(id: number) { + const request = inflight.get(id); + if (request === undefined) return; + inflight.delete(id); + void commands.cancelRequest(request).catch(() => {}); +} + /** The window switched projects: results (and in-flight searches) of the previous one are dropped. */ export function resetUsages() { + for (const id of [...inflight.keys()]) cancelSearch(id); + if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {}); + popupRequest = null; useUsages.setState({ tabs: [], active: null }); useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null }); } @@ -195,15 +220,22 @@ export async function findUsages() { const id = ++searchSeq; const base = { id, symbol: c.word, rerun: () => void run(id) }; const loading = { ...base, loading: true, groups: [], error: null, note: null }; + if (prev !== null) cancelSearch(prev); if (prev === null || !replaceUsageTab(prev, loading)) openUsageTab(loading); toolWindowService().show("usages"); + const request = nextRequestId(); + inflight.set(id, request); try { - const { groups, note } = await fetchUsages(c); + const { groups, note } = await fetchUsages(c, request); replaceUsageTab(id, { ...base, loading: false, groups, error: null, note }); } catch (e) { + // Cancelled here (closed tab, rerun): a newer state already replaced it. + if (isCancelled(e) && !inflight.has(id)) return; const message = e instanceof Error ? e.message : String(e); if (!replaceUsageTab(id, { ...base, loading: false, groups: [], error: message, note: null })) return; reportError(e); + } finally { + if (inflight.get(id) === request) inflight.delete(id); } }; await run(null); @@ -232,11 +264,15 @@ export async function showUsagesPopup() { hint("Open a file to show usages"); return; } + if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {}); + const request = nextRequestId(); + popupRequest = request; try { - const { groups, note } = await fetchUsages(c); + const { groups, note } = await fetchUsages(c, request); const total = usageCount(groups); if (!total) { - hint(c.word ? `No usages of '${c.word}' found` : "No usages found"); + const none = c.word ? `No usages of '${c.word}' found` : "No usages found"; + hint(note ? `${none}. ${note}` : none); return; } if (total === 1) { @@ -249,5 +285,7 @@ export async function showUsagesPopup() { useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word, groups, note }); } catch (e) { reportError(e); + } finally { + if (popupRequest === request) popupRequest = null; } } diff --git a/src/features/code-insight/usagesPopup.test.tsx b/src/features/code-insight/usagesPopup.test.tsx index 9a0276e8..d1fd513f 100644 --- a/src/features/code-insight/usagesPopup.test.tsx +++ b/src/features/code-insight/usagesPopup.test.tsx @@ -6,7 +6,7 @@ const loc = { path: "/p/b.ts", range: { start: { line: 1, col: 4 }, end: { line: vi.mock("./ipc", async (orig) => ({ ...(await orig()), caretContext: () => ({ doc: 1, path: "/p/a.ts", line: 0, col: 0, word: "used", view: null }), - lspRequest: async () => [loc], + lspRequest: async () => ({ locations: [loc], incomplete: null }), readManyFileLines: async () => new Map([["/p/b.ts", ["", " used();"]]]), openLocation: async (path: string, line: number) => void calls.push(`open ${path}:${line}`), hint: (text: string) => void calls.push(`hint ${text}`), From 9d8280104f80cb53e79230ac686f38a1e0eaae42 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:43:59 +0800 Subject: [PATCH 3/3] fix(agents): Run Codex through @agentclientprotocol/codex-acp @zed-industries/codex-acp is archived; its last release (0.16.0) bundles an older Codex core that doesn't know newer models, so a config.toml model such as gpt-6.1-sol fell back to generic metadata with a warning. The maintained adapter, @agentclientprotocol/codex-acp, runs the Codex App Server from a current @openai/codex and lists the account's models. --- crates/ide-agent/src/provider.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs index 27435eb9..df81325a 100644 --- a/crates/ide-agent/src/provider.rs +++ b/crates/ide-agent/src/provider.rs @@ -25,7 +25,7 @@ const BUILTIN: &[(&str, &str, &[&str], &str)] = &[ &["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.97.0"], "Run `claude` in a terminal and sign in.", ), - ("codex", "Codex", &["npx", "-y", "@zed-industries/codex-acp@0.16.0"], "Run `codex login` in a terminal."), + ("codex", "Codex", &["npx", "-y", "@agentclientprotocol/codex-acp@2.3.0"], "Run `codex login` in a terminal."), ("gemini", "Gemini CLI", &["gemini", "--experimental-acp"], "Run `gemini` in a terminal and sign in."), ("copilot", "GitHub Copilot", &["copilot", "--acp"], "Run `copilot` in a terminal and sign in."), ("cursor", "Cursor", &["cursor-agent", "acp"], "Run `cursor-agent login` in a terminal."),