mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
Merge remote-tracking branch 'origin/main' into feat/solarized-one-theme-plugins
# Conflicts: # public/splash.css
This commit is contained in:
@@ -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."),
|
||||
|
||||
@@ -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
|
||||
|
||||
+132
-72
@@ -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<HashMap<DocId, DocState>>,
|
||||
apply_edit_hook: RwLock<Option<ApplyEditHook>>,
|
||||
config_hook: RwLock<Option<ConfigHook>>,
|
||||
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
|
||||
shutting_down: AtomicBool,
|
||||
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
|
||||
shown_messages: Mutex<HashSet<(String, String)>>,
|
||||
@@ -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<Vec<Location>> {
|
||||
/// 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<References> {
|
||||
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<Vec<lsp::Location>>>(
|
||||
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<Vec<lsp::Location>> =
|
||||
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::<Vec<Location>>())
|
||||
};
|
||||
let files =
|
||||
|v: &Vec<Location>| 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<Location> =
|
||||
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<Location> = 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<WorkspaceEdit> {
|
||||
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<lsp::WorkspaceEdit> = 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<lsp::WorkspaceEdit> = 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<lsp::Uri> {
|
||||
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<Option<lsp::WorkspaceEdit>> =
|
||||
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<Vec<lsp::CallHierarchyItem>> =
|
||||
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<Vec<lsp::CallHierarchyIncomingCall>> =
|
||||
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<Vec<lsp::CallHierarchyIncomingCall>> =
|
||||
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<Vec<lsp::CallHierarchyOutgoingCall>> =
|
||||
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<Vec<lsp::CallHierarchyOutgoingCall>> =
|
||||
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<HierarchyItem>| {
|
||||
v.iter().filter(|i| i.uri.is_none()).map(|i| i.path.to_path_buf()).collect()
|
||||
};
|
||||
let w = self.widened(&snap, t, pos, &CancellationToken::new(), query, files).await?;
|
||||
w.second.unwrap_or(w.first)
|
||||
}
|
||||
HierarchyDirection::Outgoing => query().await?,
|
||||
};
|
||||
self.note_virtual(&t.server, out.iter().map(|i| i.uri.as_deref()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -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<LanguageId>,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CandidateFiles {
|
||||
pub files: Vec<PathBuf>,
|
||||
/// 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<R> {
|
||||
pub first: R,
|
||||
pub second: Option<R>,
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
|
||||
impl<R> Widened<R> {
|
||||
/// 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<String> {
|
||||
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<CandidateFn>) {
|
||||
*self.candidates_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
async fn candidates(&self, t: &Target, word: String, cancel: &CancellationToken) -> Option<CandidateFiles> {
|
||||
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<R, F, Fut>(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
t: &Target,
|
||||
pos: Pos,
|
||||
cancel: &CancellationToken,
|
||||
query: F,
|
||||
covered: impl Fn(&R) -> Vec<PathBuf>,
|
||||
) -> IdeResult<Widened<R>>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = IdeResult<R>>,
|
||||
{
|
||||
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<PathBuf> = covered(&first).into_iter().chain([snap.path.clone()]).collect();
|
||||
let files: Vec<PathBuf> = 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<PathBuf>, 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::<Vec<_>>()
|
||||
})
|
||||
.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<Server>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Instant>,
|
||||
}
|
||||
|
||||
pub struct Progress {
|
||||
pub task: TaskId,
|
||||
pub title: String,
|
||||
@@ -80,6 +94,9 @@ pub struct Server {
|
||||
published: Mutex<HashMap<PathBuf, Instant>>,
|
||||
/// 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<HashMap<PathBuf, Transient>>,
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -322,3 +322,20 @@ pub struct FileRenameEdits {
|
||||
/// Servers that failed; their references are not updated.
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<Location>,
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
|
||||
@@ -27,9 +27,13 @@ struct MockState {
|
||||
/// uri → text as reconstructed by the mock from didOpen/didChange.
|
||||
docs: Mutex<HashMap<String, String>>,
|
||||
responses: Mutex<HashMap<String, Value>>,
|
||||
/// Computes a response from the request and the open documents (before `responses`).
|
||||
responder: Mutex<Option<Responder>>,
|
||||
peer: Mutex<Option<Peer>>,
|
||||
}
|
||||
|
||||
type Responder = Arc<dyn Fn(&MockState, &str, &Value) -> Option<Value> + Send + Sync>;
|
||||
|
||||
impl MockState {
|
||||
fn notified(&self, method: &str) -> Vec<Value> {
|
||||
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<Value, (i64, String)> {
|
||||
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<_>>(), 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<Value> {
|
||||
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<String>) -> 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<Mutex<Vec<ide_lsp::CandidateQuery>>> {
|
||||
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<PathBuf> = 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<String> {
|
||||
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<MockState>) {
|
||||
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<PathBuf> = 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<PathBuf> = 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;
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<String>,
|
||||
/// Files returned at most.
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct WordFiles {
|
||||
pub files: Vec<PathBuf>,
|
||||
/// 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<PathFilter<'_>>,
|
||||
overlay: Overlay<'_>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<WordFiles> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -5,18 +5,20 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenIDEA</title>
|
||||
<script src="/boot.js"></script>
|
||||
<link rel="stylesheet" href="/src/styles/fonts.css" />
|
||||
<link rel="stylesheet" href="/splash.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="splash" class="splash" data-tauri-drag-region>
|
||||
<svg viewBox="0 0 64 64" aria-hidden="true">
|
||||
<svg class="splash-logo" viewBox="0 0 64 64" aria-hidden="true">
|
||||
<rect width="64" height="64" rx="15" fill="#0E0E10" />
|
||||
<circle cx="26" cy="32" r="11" fill="none" stroke="#FFFFFF" stroke-width="8" />
|
||||
<rect x="43" y="18" width="8" height="28" rx="1.5" fill="#FF4F1A" />
|
||||
</svg>
|
||||
<span>OpenIDEA</span>
|
||||
<!-- "OpenIDEA" in Inter 500 as outlines: the web font loads late in dev and would swap in. -->
|
||||
<svg class="splash-wordmark" viewBox="0 -1984 9994 2478" role="img" aria-label="OpenIDEA">
|
||||
<path fill="currentColor" d="M785.8 20Q592.6 20 440.4 -71.7Q288.2 -163.4 200.4 -334.7Q112.6 -506 112.6 -744Q112.6 -983.2 200.4 -1154.6Q288.2 -1326 440.4 -1418Q592.6 -1510 785.8 -1510Q979 -1510 1130.7 -1418Q1282.4 -1326 1369.8 -1154.6Q1457.3 -983.2 1457.3 -744Q1457.3 -505.8 1369.8 -334.5Q1282.4 -163.2 1130.7 -71.6Q979 20 785.8 20ZM785.8 -187.7Q913 -187.7 1013.5 -251.6Q1114 -315.5 1172.4 -439.9Q1230.7 -564.3 1230.7 -744Q1230.7 -925.6 1172.4 -1050Q1114 -1174.5 1013.5 -1238.4Q913 -1302.3 785.8 -1302.3Q657.9 -1302.3 557 -1238Q456.1 -1173.7 397.7 -1049.2Q339.2 -924.7 339.2 -744Q339.2 -564.3 397.7 -440.3Q456.1 -316.3 557 -252Q657.9 -187.7 785.8 -187.7ZM1717.9 418V-1118H1932.2V-935.9H1950.4Q1970 -970.8 2005.4 -1016.7Q2040.9 -1062.7 2104.6 -1097.3Q2168.3 -1132 2272.6 -1132Q2407.4 -1132 2512.7 -1064Q2617.9 -996 2678.8 -867Q2739.8 -738 2739.8 -556.4Q2739.8 -375.7 2679.8 -246.2Q2619.8 -116.7 2514.5 -47.3Q2409.1 22.2 2272.7 22.2Q2171.8 22.2 2107.4 -12.4Q2043 -47 2006.6 -93.2Q1970.2 -139.3 1950.4 -175H1937.9V418ZM2224 -165.3Q2320.1 -165.3 2384.9 -217.3Q2449.6 -269.3 2482.4 -358.3Q2515.2 -447.3 2515.2 -558Q2515.2 -668.1 2482.8 -755.4Q2450.4 -842.8 2385.7 -893.7Q2321 -944.5 2224 -944.5Q2129.6 -944.5 2065 -896.1Q2000.4 -847.7 1967.4 -761.2Q1934.4 -674.6 1934.4 -558Q1934.4 -441.7 1967.8 -353.2Q2001.2 -264.7 2066.2 -215Q2131.2 -165.3 2224 -165.3ZM3465.8 23.3Q3300.7 23.3 3181.1 -48.1Q3061.4 -119.5 2996.9 -248.5Q2932.4 -377.5 2932.4 -551.1Q2932.4 -723.3 2995.8 -854Q3059.1 -984.7 3175.1 -1058.4Q3291 -1132 3446.8 -1132Q3542.6 -1132 3632 -1100.7Q3721.4 -1069.4 3792.3 -1002.3Q3863.3 -935.2 3904.7 -828.3Q3946.2 -721.5 3946.2 -570.3V-492.2H3054.8V-656.5H3832.8L3730.1 -602.3Q3730.1 -704.7 3698.2 -783.2Q3666.3 -861.7 3603.5 -905.9Q3540.8 -950 3447.5 -950Q3354.2 -950 3288.2 -905Q3222.1 -860 3187.4 -786.5Q3152.6 -712.9 3152.6 -626.1V-514.4Q3152.6 -399.4 3192.1 -319.8Q3231.6 -240.1 3302.9 -199.4Q3374.2 -158.7 3468.1 -158.7Q3529.6 -158.7 3579.9 -176.3Q3630.2 -194 3666.5 -229.4Q3702.8 -264.9 3722.2 -317.2L3928.6 -268.5Q3902.2 -181.6 3838.6 -115.9Q3775 -50.2 3680.4 -13.4Q3585.8 23.3 3465.8 23.3ZM4406.9 -662.4V0H4186.9V-1118H4396.6L4398.9 -844.3H4376.2Q4424.9 -994.3 4517.8 -1063.1Q4610.7 -1132 4742.5 -1132Q4856.6 -1132 4942 -1084.2Q5027.5 -1036.3 5075.1 -942.5Q5122.7 -848.8 5122.7 -710.4V0H4902.7V-682.9Q4902.7 -803.7 4839.8 -872.1Q4776.9 -940.5 4667.3 -940.5Q4592.9 -940.5 4533.8 -907.9Q4474.8 -875.3 4440.8 -813.3Q4406.9 -751.2 4406.9 -662.4ZM5664.4 -1490V0H5435.9V-1490ZM6478.5 0H6109.9V-199.7H6465.8Q6635 -199.7 6747.1 -263Q6859.2 -326.3 6915 -448.6Q6970.8 -571 6970.8 -747.3Q6970.8 -922.1 6915.2 -1043.3Q6859.7 -1164.4 6750.5 -1227.4Q6641.3 -1290.3 6478.4 -1290.3H6102.2V-1490H6493Q6713.2 -1490 6869.9 -1400.9Q7026.7 -1311.8 7110.7 -1145.2Q7194.7 -978.6 7194.7 -747.3Q7194.7 -514.5 7110.4 -347.1Q7026 -179.6 6865.8 -89.8Q6705.5 0 6478.5 0ZM6222.4 -1490V0H5993.9V-1490ZM7471.9 0V-1490H8409.8V-1293.6H7700.4V-849.7H8359.5V-654.7H7700.4V-196.4H8416.4V0ZM8592.9 0 9123.5 -1490H9403.2L9942.8 0H9696.3L9406.1 -828.9Q9372.2 -930 9331.8 -1066.9Q9291.4 -1203.8 9238.4 -1395.6H9284.5Q9232.7 -1202.2 9192.5 -1063.6Q9152.4 -925.1 9121.4 -828.9L8839.9 0ZM8880.4 -392.6V-583.1H9655.3V-392.6Z" />
|
||||
</svg>
|
||||
<div class="splash-bar" role="progressbar" aria-label="Loading"><i></i></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
+3
-7
@@ -20,22 +20,18 @@ body, .splash { background: var(--bg-frame, var(--boot-frame, #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: var(--fg-muted, var(--boot-fg, #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: var(--fg-muted, var(--boot-fg, #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 {
|
||||
|
||||
@@ -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<Vec<Location>> {
|
||||
project!(window, state).lsp.references(doc, pos, include_declaration).await
|
||||
project_wide: bool,
|
||||
) -> IdeResult<References> {
|
||||
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]
|
||||
|
||||
@@ -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<String> {
|
||||
exclude_globs(&self.settings.effective(), &self.detected_roots.read())
|
||||
|
||||
+18
-1
@@ -287,7 +287,12 @@ export const commands = {
|
||||
* language server returned a non-`file:` URI for. Cached per project.
|
||||
*/
|
||||
lspVirtualDocument: (uri: string) => typedError<VirtualDocument, IdeError>(__TAURI_INVOKE("lsp_virtual_document", { uri })),
|
||||
lspReferences: (doc: DocId, pos: Pos, includeDeclaration: boolean) => typedError<Location[], IdeError>(__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<References, IdeError>(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide })),
|
||||
lspDocumentHighlights: (doc: DocId, pos: Pos) => typedError<Highlight[], IdeError>(__TAURI_INVOKE("lsp_document_highlights", { doc, pos })),
|
||||
lspWorkspaceSymbols: (request: RequestId, query: string) => typedError<WorkspaceSymbol[], IdeError>(__TAURI_INVOKE("lsp_workspace_symbols", { request, query })),
|
||||
lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError<CodeAction[], IdeError>(__TAURI_INVOKE("lsp_code_actions", { doc, range, only })),
|
||||
@@ -4180,6 +4185,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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,7 +16,10 @@ let outline: Answer<OutlineItem[]> = ok([]);
|
||||
let references: Answer<Location[]> = 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 } }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<FetchedUsages> {
|
||||
export async function fetchUsages(c: CaretContext, request: RequestId = nextRequestId()): Promise<FetchedUsages> {
|
||||
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<FetchedUsages> {
|
||||
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<number, RequestId>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof import("./ipc")>()),
|
||||
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}`),
|
||||
|
||||
@@ -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";
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user