Compare commits

..
Author SHA1 Message Date
l0ng-ai cbb7bc952b fix(vim): draw the Normal-mode block cursor solid, with the character in the background colour
The focused block cursor was a 60% caret-coloured fill. On dark themes the caret is light
grey, so it read as a slightly brighter character and the cursor was easy to lose. It is now a
solid block in the caret colour with the character under it in the editor background colour,
as IdeaVim's block caret; the unfocused 1px outline and partial blocks are unchanged.

Fixes #3197
2026-11-14 01:27:00 +08:00
108 changed files with 293 additions and 3746 deletions

No files matched your search

-46
View File
@@ -309,13 +309,9 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
let target = position_document(host, window, doc, pos).await?;
// ⌘B on a declaration shows its usages, also when a plugin's index links the name elsewhere
// (a MyBatis mapper method to its XML statement): the server's answer comes along for the
// editor to tell.
if target == doc
&& kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
&& !on_declaration_name(host, window, doc, pos).await
{
let project = host.project(window)?;
// A notebook cell is a language-server document only: no plugin navigates in it.
@@ -356,30 +352,6 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
definition_targets(result, found, in_string)
}
/// Whether the syntax tree puts the identifier at `pos` as a declaration's name (`int f()`'s `f`).
async fn on_declaration_name(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(false);
}
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
let cancel = ide_core::CancellationToken::new();
let Ok(Some(at)) = project.structure.node_at(&s, Range::new(pos, pos), &cancel) else { return Ok(false) };
Ok(is_declaration_name(&at))
})
.await
.unwrap_or(false)
}
fn is_declaration_name(at: &ide_syntax::NodeAt) -> bool {
at.node.field.as_deref() == Some("name")
&& at.ancestors.first().is_some_and(|parent| {
["declaration", "definition", "declarator"].iter().any(|kind| parent.kind.ends_with(kind))
})
}
async fn in_string_literal(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
@@ -2164,24 +2136,6 @@ mod tests {
assert_eq!(paths(definition_targets(Ok(vec![helper, guess]), Vec::new(), false)), ["/p/app/helpers.php"]);
}
#[test]
fn declaration_names_are_told_apart_from_uses() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let java = ide_core::LanguageId::new("java");
let text = "interface ShareMapper {\n int batchCancel(List<Long> ids);\n}\nclass S { int f(ShareMapper m) { return m.batchCancel(null); } }\n";
let snap = ide_syntax::Snapshot::text(&java, text);
let cancel = ide_core::CancellationToken::new();
let at = |line, col| {
let pos = Pos::new(line, col);
is_declaration_name(&structure.node_at(&snap, Range::new(pos, pos), &cancel).unwrap().unwrap())
};
assert!(at(1, 10), "the mapper method's name");
assert!(at(0, 12), "the interface's name");
assert!(!at(3, 45), "a call of the method");
assert!(!at(1, 4), "its return type");
}
#[test]
fn positions_inside_string_literals_are_told_apart() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
+1 -12
View File
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::Sdk;
use ide_tools::jdk::{JdkReleases, JdkVendor};
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate, Tools};
use ide_tools::{InstallEvent, ToolInfo, Tools};
use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
@@ -89,16 +89,6 @@ pub async fn tools_install(
Ok(info)
}
/// Newer versions of the tools the IDE installed (release downloads and npm packages).
/// Cancel with `cancel_request(request)`.
pub async fn tools_updates(host: &Host, window: &str, request: RequestId) -> IdeResult<Vec<ToolUpdate>> {
let tools = tools(host, window)?;
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || tools.updates(&cancel)).await.map_err(join_err);
host.end_request(request);
result?
}
/// Removes a tool the IDE installed.
pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult<ToolInfo> {
let tools = tools(host, window)?;
@@ -195,7 +185,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
tools_install(&c.host, &c.window, c.arg("name")?, c.arg("request")?, c.sender("onEvent")?).await
});
d.add("tools_uninstall", |c| async move { tools_uninstall(&c.host, &c.window, c.arg("name")?).await });
d.add("tools_updates", |c| async move { tools_updates(&c.host, &c.window, c.arg("request")?).await });
d.add("jdk_download_vendors", |c| async move { Ok(jdk_download_vendors(&c.host, &c.window)) });
d.add("jdk_download_releases", |c| async move {
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
+1 -1
View File
@@ -1507,7 +1507,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_rename_branch", |c| async move {
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unsetUpstream")?)
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
.await
});
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
+2 -8
View File
@@ -2554,8 +2554,7 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
f.install(&official("git-toolbox"));
// Focusing an editor wakes it to show the branch (nothing fetched yet).
let now = Instant::now();
let focused = f.project.join("src/notes.txt").to_string_lossy().into_owned();
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(focused))]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
f.host.flush_events_at(now + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
let item = |host: &PluginHost| {
@@ -2993,18 +2992,13 @@ fn official_csv_editor_edits_the_shared_document() {
f.host.view_event("openidea.csv-editor", "table", cell, inv.clone()).unwrap();
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n");
// Add Row appends, also on wasm32 (where its row index used to wrap to the header).
let add = ide_plugin::ViewEvent::Button { id: "add-row".into() };
f.host.view_event("openidea.csv-editor", "table", add, inv.clone()).unwrap();
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n,\n");
// Settings: without a header row every record is a row.
let mut values = serde_json::Map::new();
values.insert("firstRowIsHeader".into(), serde_json::Value::Bool(false));
f.workspace.settings.lock().insert("openidea.csv-editor".into(), values);
let view = f.host.render_view("openidea.csv-editor", "table", None, inv.clone()).unwrap();
let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") };
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4));
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3));
// Undeclared views are refused before any code runs.
let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err();
-114
View File
@@ -481,117 +481,3 @@ async fn the_desktop_routes_exactly_what_a_server_of_this_build_answers() {
assert!(rpc::client_commands().contains(&"term_spawn"));
plugins.host().shutdown();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn renames_a_branch_with_the_clients_argument_names() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
std::fs::create_dir(&root).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git").args(args).current_dir(&root).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
};
git(&["init", "-q", "-b", "main"]);
std::fs::write(root.join("a.txt"), "a\n").unwrap();
git(&["add", "."]);
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"]);
git(&["branch", "feature"]);
let peer_slot = Arc::new(OnceLock::new());
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host, Dispatcher::standard(), peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
invoke(&peer, "open_project", json!({"path": root})).await.unwrap();
invoke(&peer, "project_trust_set", json!({"path": root, "trusted": true, "folder": false})).await.unwrap();
// The argument names the desktop client sends (`vcsRenameBranch` in src/bindings.ts).
invoke(
&peer,
"vcs_rename_branch",
json!({"repo": null, "from": "feature", "to": "renamed", "unsetUpstream": false}),
)
.await
.unwrap();
assert_eq!(
git(&["branch", "--format=%(refname:short)", "--list"]).lines().collect::<Vec<_>>(),
["main", "renamed"]
);
invoke(&peer, "close_project", json!({})).await.unwrap();
peer.kill();
}
/// A handler reads its arguments by the names the desktop client sends: `src/bindings.ts`
/// passes them in camelCase, and a name the client never sends reads as `null` (an
/// `Option` silently `None`, anything else an "invalid type: null" error).
#[test]
fn handlers_read_the_argument_names_the_client_sends() {
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let bindings = std::fs::read_to_string(repo.join("src/bindings.ts")).unwrap();
let mut sent = HashMap::<&str, BTreeSet<&str>>::new();
for part in bindings.split("__TAURI_INVOKE(\"").skip(1) {
let (command, rest) = part.split_once('"').unwrap();
let args = rest.strip_prefix(", {").and_then(|r| r.split_once('}')).map_or("", |(args, _)| args);
let names = args.split(',').map(|a| a.split(':').next().unwrap().trim()).filter(|a| !a.is_empty());
sent.insert(command, names.collect());
}
fn sources(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
sources(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let mut files = Vec::new();
sources(&repo.join("crates/ide-host/src"), &mut files);
let mut checked = 0;
let mut wrong = Vec::new();
for file in files {
let src = std::fs::read_to_string(&file).unwrap();
// `.add("name", …)`, also with the name on the next line.
let starts: Vec<usize> = src
.match_indices(".add(")
.map(|(i, m)| i + m.len())
.filter_map(|i| src[i..].trim_start().strip_prefix('"').map(|rest| src.len() - rest.len()))
.collect();
for (n, &start) in starts.iter().enumerate() {
let body = &src[start..starts.get(n + 1).copied().unwrap_or(src.len())];
let (command, body) = body.split_once('"').unwrap();
let Some(sent) = sent.get(command) else { continue };
checked += 1;
for reader in ["c.arg", "c.sender", "c.channel"] {
for (at, _) in body.match_indices(reader) {
let rest = &body[at + reader.len()..];
let rest = match rest.strip_prefix("::<") {
Some(generic) => &generic[generic.find(">(").unwrap() + 1..],
None => rest,
};
let Some(name) = rest.strip_prefix("(\"").and_then(|r| r.split_once('"')).map(|(n, _)| n) else {
continue;
};
if !sent.contains(name) {
wrong.push(format!("{command} reads `{name}`; the client sends {sent:?}"));
}
}
}
}
}
assert!(checked > 400, "found only {checked} handlers with client bindings");
assert!(wrong.is_empty(), "{wrong:#?}");
}
+4 -10
View File
@@ -223,10 +223,6 @@ pub trait Handler: Send + Sync + 'static {
/// Called once the response to a `method` request is queued: what it sends follows the response.
fn after_response(&self, _method: &str) {}
/// Adjusts every message from the peer (responses included) before it is dispatched, e.g.
/// to repair values the peer spells more loosely than the types that read them accept.
fn normalize_incoming(&self, _message: &mut Value) {}
}
/// A [`Handler`] that ignores notifications and rejects requests.
@@ -589,10 +585,9 @@ impl Peer {
.expect("spawn jsonrpc notification thread");
}
// Reader: parses and dispatches; runs no handler code beyond `normalize_incoming`.
// Reader: parses and dispatches; never runs handler code itself.
{
let inner = inner.clone();
let handler = handler.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(reader);
let seen_message = AtomicBool::new(false);
@@ -615,7 +610,7 @@ impl Peer {
match msg {
Ok(Some(body)) => {
seen_message.store(true, Ordering::Relaxed);
dispatch(&inner, &*handler, &notif_tx, &body);
dispatch(&inner, &notif_tx, &body);
}
Ok(None) => break,
Err(e) => {
@@ -788,16 +783,15 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
}
}
fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
let mut msg: Value = match serde_json::from_slice(body) {
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("jsonrpc: unparsable message: {e}");
return;
}
};
handler.normalize_incoming(&mut msg);
let Value::Object(mut obj) = msg else {
tracing::warn!("jsonrpc: ignoring non-object message");
return;
-26
View File
@@ -541,29 +541,3 @@ async fn output_before_the_handshake_is_reported_not_dropped() {
assert!(lines.iter().any(|l| l == "python: not found"), "{lines:?}");
assert!(lines.iter().any(|l| l.ends_with(". . .") && l.len() > 6), "{lines:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn incoming_messages_are_normalized_before_dispatch() {
struct Server;
impl Handler for Server {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, _: &str, params: Value) -> Result<Value, (i64, String)> {
Ok(params)
}
}
struct Client;
impl Handler for Client {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, _: &str, _: Value) -> Result<Value, (i64, String)> {
Err((codes::METHOD_NOT_FOUND, "no".into()))
}
fn normalize_incoming(&self, message: &mut Value) {
if let Some(result) = message.get_mut("result") {
*result = json!("normalized");
}
}
}
let (client, _server) = pair(Arc::new(Client), Arc::new(Server));
let v: Value = client.request("echo", "raw", None).await.unwrap();
assert_eq!(v, json!("normalized"));
}
-4
View File
@@ -391,10 +391,6 @@ fn tsserver_ran_out_of_memory(text: &str) -> bool {
}
impl Handler for ClientHandler {
fn normalize_incoming(&self, message: &mut Value) {
crate::uri::encode_loose_file_uris(message);
}
fn on_notification(&self, method: &str, params: Value) {
let Some(mgr) = self.mgr.upgrade() else { return };
match method {
-71
View File
@@ -1,8 +1,6 @@
//! `file://` URI ⇄ path conversion (RFC 8089, VS Code-compatible encoding).
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use serde_json::Value;
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::str::FromStr;
@@ -24,53 +22,6 @@ const PATH: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
/// What RFC 3986 doesn't allow in a URI and a server may still leave unencoded in a `file:`
/// URI (non-ASCII is always encoded); `%` stays, it starts the escapes already there.
const LOOSE: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'<')
.add(b'>')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
/// Percent-encodes the characters URIs don't allow in every `file:` URI in `value`, string
/// values and object keys alike (a workspace edit's `changes`). Eclipse's jdtls sends a path
/// as is (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response.
pub fn encode_loose_file_uris(value: &mut Value) {
match value {
Value::String(s) => {
if let Some(encoded) = encode_loose(s) {
*s = encoded;
}
}
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
Value::Object(map) => {
if map.keys().any(|k| encode_loose(k).is_some()) {
*map = std::mem::take(map).into_iter().map(|(k, v)| (encode_loose(&k).unwrap_or(k), v)).collect();
}
map.values_mut().for_each(encode_loose_file_uris);
}
_ => {}
}
}
fn encode_loose(s: &str) -> Option<String> {
if !s.get(..5).is_some_and(|scheme| scheme.eq_ignore_ascii_case("file:")) {
return None;
}
match Cow::from(utf8_percent_encode(s, LOOSE)) {
Cow::Owned(encoded) => Some(encoded),
Cow::Borrowed(_) => None,
}
}
pub fn path_to_uri_string(path: &Path) -> String {
let s = if cfg!(windows) {
dunce::simplified(path).to_string_lossy().replace('\\', "/")
@@ -144,28 +95,6 @@ pub use ide_core::text::is_virtual_uri;
mod tests {
use super::*;
#[test]
fn file_uris_a_server_leaves_unencoded_are_encoded() {
// jdtls on a project under a Chinese folder (`java.usages` / references response).
let mut v = serde_json::json!({ "result": [{
"location": { "uri": "file:///H:/项目/online disk/A.java", "range": { "start": { "line": 1, "character": 2 }, "end": { "line": 1, "character": 3 } } },
"kind": "unknown"
}], "edit": { "changes": { "file:///tmp/目录/b.java": [] } }, "text": "项目" });
let raw = v["result"][0]["location"].clone();
assert!(serde_json::from_value::<lsp_types::Location>(raw).is_err(), "lsp-types rejects it as sent");
encode_loose_file_uris(&mut v);
let uri = v["result"][0]["location"]["uri"].as_str().unwrap();
assert_eq!(uri, "file:///H:/%E9%A1%B9%E7%9B%AE/online%20disk/A.java");
let location: lsp_types::Location = serde_json::from_value(v["result"][0]["location"].clone()).unwrap();
assert!(uri_to_path(location.uri.as_str()).unwrap().ends_with("项目/online disk/A.java"));
assert!(v["edit"]["changes"].get("file:///tmp/%E7%9B%AE%E5%BD%95/b.java").is_some());
assert_eq!(v["text"], "项目", "only file: URIs change");
// Already encoded: untouched.
let mut encoded = serde_json::json!("file:///a%20b/%E9%A1%B9.java");
encode_loose_file_uris(&mut encoded);
assert_eq!(encoded, "file:///a%20b/%E9%A1%B9.java");
}
#[cfg(unix)]
#[test]
fn round_trip() {
-97
View File
@@ -151,67 +151,6 @@ pub fn resolve_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String
}
}
/// Resolves an already-open document for a background edit without following a
/// symbolic link, junction, or other Windows reparse point. Unlike ordinary
/// project reads, background edits may have originated with another machine:
/// accepting a link would let that machine change an unexpected file when the
/// user later saves the editor buffer.
///
/// The input is deliberately project-relative. Existing path components are
/// examined with `symlink_metadata` from the canonical project root; no
/// canonicalization happens after the walk, so a link is never traversed.
pub fn resolve_background_document_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
if path.is_empty() || Path::new(path).is_absolute() {
return Err(format!("{path}: background document paths must be project-relative"));
}
let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?;
let components: Vec<_> = Path::new(path).components().collect();
if components.is_empty() || components.iter().any(|part| !matches!(part, std::path::Component::Normal(_))) {
return Err(format!("{path}: background document paths must not leave the project"));
}
let mut current = root.clone();
for (index, part) in components.iter().enumerate() {
let std::path::Component::Normal(name) = part else { unreachable!() };
if background_control_name(name) {
return Err(format!("{path}: background edits may not target VCS or IDE control files"));
}
current.push(name);
let metadata = std::fs::symlink_metadata(&current).map_err(|_| format!("{path}: no such open document"))?;
if is_link_or_reparse(&metadata) {
return Err(format!("{path}: background edits refuse symbolic links"));
}
if index + 1 < components.len() && !metadata.is_dir() {
return Err(format!("{path}: not a directory"));
}
if index + 1 == components.len() && !metadata.is_file() {
return Err(format!("{path}: not a file"));
}
}
Ok(current)
}
/// [`resolve_background_document_in_root`] over a workspace group's roots.
pub fn resolve_background_document_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String> {
first_root_resolving(roots, path, resolve_background_document_in_root)
}
fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool {
#[cfg(windows)]
let reparse = {
use std::os::windows::fs::MetadataExt;
metadata.file_attributes() & 0x400 != 0 // FILE_ATTRIBUTE_REPARSE_POINT
};
#[cfg(not(windows))]
let reparse = false;
metadata.file_type().is_symlink() || reparse
}
fn background_control_name(name: &std::ffi::OsStr) -> bool {
let name = name.to_string_lossy();
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
matches!(name.to_ascii_lowercase().as_str(), ".git" | ".hg" | ".svn" | ".idea" | ".openidea" | ".vscode")
}
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
/// without requiring it to exist: `Ok(true)` also for a missing path, so
/// callers distinguish "outside" from "absent". Existing symlinks are
@@ -766,25 +705,6 @@ mod tests {
assert!(!names_git_dir(Path::new(".github/workflows/ci.yml")));
}
#[test]
fn background_document_edits_are_relative_and_exclude_control_directories() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
std::fs::write(root.join(".git/hooks/pre-commit"), "#!/bin/sh\n").unwrap();
std::fs::write(root.join(".openidea/state"), "state\n").unwrap();
assert!(resolve_background_document_in_root(&root, "src/main.rs").is_ok());
for path in
["/etc/passwd", "../outside", "src/../.git/hooks/pre-commit", ".git/hooks/pre-commit", ".openidea/state"]
{
assert!(resolve_background_document_in_root(&root, path).is_err(), "{path}");
}
}
#[test]
fn git_folder_is_recognised_under_every_spelling_that_opens_it() {
for path in [
@@ -820,23 +740,6 @@ mod tests {
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
}
#[cfg(unix)]
#[test]
fn background_document_edits_refuse_final_and_ancestor_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
symlink(root.join("src/main.rs"), root.join("final-link.rs")).unwrap();
symlink(root.join("src"), root.join("linked-src")).unwrap();
assert!(resolve_background_document_in_root(&root, "final-link.rs").unwrap_err().contains("symbolic links"));
assert!(
resolve_background_document_in_root(&root, "linked-src/main.rs").unwrap_err().contains("symbolic links")
);
}
#[cfg(unix)]
#[test]
fn writable_paths_follow_existing_and_dangling_symlinks() {
+1 -9
View File
@@ -870,7 +870,7 @@ impl editor::Host for StoreData {
return Ok(Err(wit::Error::Invalid("edits too large".into())));
}
let root = self.table.get(&project)?.root.clone();
let path = match self.resolve_background_document_in_project(&root, &path) {
let path = match self.resolve_in_project(&root, &path) {
Ok(path) => path,
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
};
@@ -936,14 +936,6 @@ impl StoreData {
sandbox::resolve_writable_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
})
}
/// A background document edit is the only plugin operation that changes an
/// editor on behalf of a remote peer. It therefore rejects links and IDE
/// control directories instead of using the normal read resolver.
fn resolve_background_document_in_project(&self, root: &Path, path: &str) -> Result<PathBuf, String> {
sandbox::resolve_background_document_in_root(root, path).or_else(|root_error| {
sandbox::resolve_background_document_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
})
}
/// Whether a `project` resource's path lands inside the resource's root or
/// any member of its workspace group, without requiring it to exist.
fn inside_project(&self, root: &Path, path: &str) -> Result<bool, String> {
-34
View File
@@ -53,8 +53,6 @@ pub(crate) fn detect_with_profiles(
&mut out,
);
detect_projects_matching(root, ruby_project, detect_ruby, &mut out);
detect_projects(root, &["rebar.config"], detect_rebar, &mut out);
detect_projects(root, &["mix.exs"], detect_mix, &mut out);
for dir in ide_core::project_roots::discover(root, &["CMakeLists.txt"]) {
let start = out.len();
cmake::detect(&dir, if dir == root { profiles } else { None }, &mut out);
@@ -1328,38 +1326,6 @@ pub(crate) fn just_recipes(src: &str) -> Vec<String> {
// ---------------------------------------------------------------- godot
// ---------------------------------------------------------------- rebar3 / mix
/// rebar3 (Erlang): the shell with the applications started, the EUnit tests and, with
/// `*_SUITE.erl` suites, Common Test. A `rebar3` script in the project wins over the PATH.
fn detect_rebar(root: &Path, out: &mut Vec<RunConfig>) {
let rebar = wrapper(root, "rebar3", "rebar3.cmd", "rebar3");
out.push(config("rebar3:shell", "rebar3 shell", "rebar3", &rebar, &["shell"]));
out.push(config("rebar3:eunit", "rebar3 eunit", "rebar3", &rebar, &["eunit"]).test());
let suites = |dir: &Path| {
fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.any(|e| e.file_name().to_string_lossy().ends_with("_SUITE.erl"))
};
let mut apps = subdirs(&root.join("apps")).into_iter().chain(subdirs(&root.join("lib")));
if suites(&root.join("test")) || apps.any(|app| suites(&app.join("test"))) {
out.push(config("rebar3:ct", "rebar3 ct", "rebar3", &rebar, &["ct"]).test());
}
}
/// Mix (Elixir): the application in IEx (`phx.server` for Phoenix) and its ExUnit tests.
fn detect_mix(root: &Path, out: &mut Vec<RunConfig>) {
let phoenix = read(&root.join("mix.exs")).is_some_and(|src| src.contains("{:phoenix,"));
if phoenix {
out.push(config("mix:phx.server", "iex -S mix phx.server", "mix", "iex", &["-S", "mix", "phx.server"]));
} else {
out.push(config("mix:iex", "iex -S mix", "mix", "iex", &["-S", "mix"]));
}
out.push(config("mix:test", "mix test", "mix", "mix", &["test"]).test());
}
/// Godot projects (`project.godot`) at the root or one level down: "Godot Project"
/// runs the main scene (`godot --path .`), debuggable through the editor's adapter.
fn detect_godot(root: &Path, out: &mut Vec<RunConfig>) {
-33
View File
@@ -1173,36 +1173,3 @@ fn a_django_project_in_src_uses_the_environment_next_to_it() {
assert!(c.iter().any(|c| c.id == "pytest"));
assert!(!c.iter().any(|c| c.id.starts_with("pytest:")), "{:?}", c.iter().map(|c| &c.id).collect::<Vec<_>>());
}
#[test]
fn rebar3_projects() {
let dir = project(&[("rebar.config", "{erl_opts, [debug_info]}.\n"), ("src/app.erl", "-module(app).\n")]);
let c = detect(dir.path());
assert_eq!(ids(&c), ["rebar3:shell", "rebar3:eunit"]);
assert_eq!(cmdline(&c[0]), "rebar3 shell");
assert!(c[1].is_test);
// Common Test suites in an umbrella app; the project's own rebar3 script.
let dir = project(&[
("rebar.config", ""),
("rebar3", "#!/usr/bin/env escript\n"),
("apps/core/test/core_SUITE.erl", "-module(core_SUITE).\n"),
]);
let c = detect(dir.path());
assert_eq!(ids(&c), ["rebar3:shell", "rebar3:eunit", "rebar3:ct"]);
if !cfg!(windows) {
assert_eq!(cmdline(find(&c, "rebar3:ct")), "./rebar3 ct");
}
}
#[test]
fn mix_projects() {
let dir = project(&[("mix.exs", "defmodule App.MixProject do\n defp deps, do: []\nend\n")]);
let c = detect(dir.path());
assert_eq!(ids(&c), ["mix:iex", "mix:test"]);
assert_eq!(cmdline(&c[0]), "iex -S mix");
assert!(c[1].is_test);
let dir = project(&[("mix.exs", "defp deps do\n [{:phoenix, \"~> 1.7\"}]\nend\n")]);
assert_eq!(cmdline(&detect(dir.path())[0]), "iex -S mix phx.server");
}
+1 -6
View File
@@ -957,15 +957,10 @@ pub fn resolve_run_command(config: &mut RunConfig, root: &Path, s: &Settings) {
}
pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) {
let mut path_dirs: Vec<PathBuf> = Vec::new();
if let Some(custom) = tool_command(root, s, &cfg.command) {
// An executable chosen from a Node.js installation (`~/.nvm/versions/node/v26/bin/tsc`,
// its `npx`) runs on that Node: its `#!/usr/bin/env node` finds the `node` beside it.
if let Some(bin) = custom.parent().filter(|bin| bin.join(exe("node")).is_file()) {
path_dirs.push(bin.to_path_buf());
}
cfg.command = custom.to_string_lossy().into_owned();
}
let mut path_dirs: Vec<PathBuf> = Vec::new();
let name = cfg.name.clone();
let name = name.as_str();
-34
View File
@@ -1325,40 +1325,6 @@ fn custom_tool_path_replaces_the_command() {
assert_eq!(cfg.command, "zls");
}
/// A server picked from a Node.js installation (nvm's `bin/tsc`, `bin/npx`) is a
/// `#!/usr/bin/env node` script: it runs on that installation's Node, ahead of the
/// project's Node.js interpreter.
#[test]
fn a_server_chosen_from_a_node_installation_runs_on_that_node() {
let tmp = tempfile::tempdir().unwrap();
let v26 = tmp.path().join("nvm/versions/node/v26.4.0/bin");
let v20 = tmp.path().join("node20/bin");
for dir in [&v26, &v20] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(exe("node")), "").unwrap();
}
std::fs::write(v26.join("tsc"), "#!/usr/bin/env node\n").unwrap();
let mut s = Settings::default();
s.languages.node.interpreter = v20.join(exe("node")).to_string_lossy().into_owned();
s.tools.insert(
"typescript-language-server".into(),
ide_core::settings::ToolSettings { path: v26.join("tsc").to_string_lossy().into_owned() },
);
let mut cfg = server("typescript-language-server");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
let path: Vec<std::path::PathBuf> = std::env::split_paths(&cfg.env["PATH"]).collect();
assert_eq!(path[..2], [v26.clone(), v20.clone()]);
// Elsewhere, the project's Node.js interpreter comes first.
s.tools.insert(
"typescript-language-server".into(),
ide_core::settings::ToolSettings { path: tmp.path().join("tools/tsc").to_string_lossy().into_owned() },
);
let mut cfg = server("typescript-language-server");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(std::env::split_paths(&cfg.env["PATH"]).next(), Some(v20));
}
#[test]
fn the_godot_setting_applies_to_server_debugger_and_run_configurations() {
let tmp = tempfile::tempdir().unwrap();
+1 -22
View File
@@ -65,26 +65,5 @@ fn bench(c: &mut Criterion) {
g.finish();
}
// No .ignore file: the query's subtree masks must prevent traversal themselves.
fn excluded_subtrees(c: &mut Criterion) {
let dir = tempfile::tempdir().unwrap();
for module in 0..100 {
let root = dir.path().join(format!("modules/m{module}"));
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.go"), "needle").unwrap();
for dependency in 0..50 {
let dep = root.join(format!("node_modules/dep{dependency}/lib"));
std::fs::create_dir_all(&dep).unwrap();
std::fs::write(dep.join("index.js"), "needle").unwrap();
}
}
let q = SearchQuery { exclude: vec!["**/node_modules/**".into()], ..query("needle", false) };
assert_eq!(run(dir.path(), &q), 100);
let mut group = c.benchmark_group("search_excluded_subtrees");
group.sample_size(15);
group.bench_function("5k_dependencies", |b| b.iter(|| black_box(run(dir.path(), &q))));
group.finish();
}
criterion_group!(benches, bench, excluded_subtrees);
criterion_group!(benches, bench);
criterion_main!(benches);
+13 -60
View File
@@ -143,22 +143,18 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult<SearchSummary> {
}
if !ctx.ctl.should_stop() {
walk_files(
&on_disk,
&|path| ctx.mask.has_directory_exclusions() && ctx.mask.excludes_dir(&rel_path(ctx.root, path)),
|| {
let mut searcher = searcher_builder.build();
let matcher = matcher.clone();
let ctx = &ctx;
Box::new(move |path| {
if ctx.ctl.should_stop() {
return WalkState::Quit;
}
ctx.search_file(path, &matcher, &mut searcher);
if ctx.ctl.stopped() { WalkState::Quit } else { WalkState::Continue }
})
},
);
walk_files(&on_disk, || {
let mut searcher = searcher_builder.build();
let matcher = matcher.clone();
let ctx = &ctx;
Box::new(move |path| {
if ctx.ctl.should_stop() {
return WalkState::Quit;
}
ctx.search_file(path, &matcher, &mut searcher);
if ctx.ctl.stopped() { WalkState::Quit } else { WalkState::Continue }
})
});
}
let cancelled = ctx.ctl.cancelled.load(Relaxed) || req.cancel.is_cancelled();
@@ -180,11 +176,7 @@ pub(crate) type VisitFile<'s> = Box<dyn FnMut(&Path) -> WalkState + Send + 's>;
/// them, while ignored files elsewhere (`.env`) and hidden files and folders
/// (`.github`, `.eslintrc`) are visited. `.git`, symlinks and archives (jar,
/// zip: browsed, not searched) are never visited.
pub(crate) fn walk_files<'s>(
roots: &[PathBuf],
skip_dir: &'s (dyn Fn(&Path) -> bool + Sync),
mut visitor: impl FnMut() -> VisitFile<'s> + Send,
) {
pub(crate) fn walk_files<'s>(roots: &[PathBuf], mut visitor: impl FnMut() -> VisitFile<'s> + Send) {
let Some((first, rest)) = roots.split_first() else { return };
let mut walk = WalkBuilder::new(first);
for path in rest {
@@ -212,9 +204,6 @@ pub(crate) fn walk_files<'s>(
};
let Some(file_type) = entry.file_type() else { return WalkState::Continue };
if file_type.is_dir() {
if skip_dir(entry.path()) {
return WalkState::Skip;
}
// The walker drops ignored files unseen, so the files of every walked
// directory come from listing it; the walker only picks the directories.
let Ok(children) = std::fs::read_dir(entry.path()) else { return WalkState::Continue };
@@ -606,39 +595,3 @@ mod tests {
}
}
}
#[cfg(test)]
mod walk_tests {
use super::*;
use std::sync::Mutex;
#[test]
fn excluded_subtree_is_not_visited() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("edge/device/build/deep")).unwrap();
std::fs::create_dir_all(root.join("edge/device/src")).unwrap();
std::fs::write(root.join("edge/device/build/deep/generated.go"), "hit").unwrap();
std::fs::write(root.join("edge/device/src/main.go"), "hit").unwrap();
let mask = FileMask::new(&[], &["**/build/**".into()]).unwrap();
let seen = Mutex::new(Vec::new());
walk_files(&[root.to_path_buf()], &|path| mask.excludes_dir(&rel_path(root, path)), || {
let seen = &seen;
Box::new(move |path| {
// No file-level mask here: a visit means the walker entered the subtree.
seen.lock().unwrap().push(rel_path(root, path));
WalkState::Continue
})
});
assert_eq!(*seen.lock().unwrap(), ["edge/device/src/main.go"]);
seen.lock().unwrap().clear();
walk_files(&[root.join("edge/device/build/deep")], &|path| mask.excludes_dir(&rel_path(root, path)), || {
let seen = &seen;
Box::new(move |path| {
seen.lock().unwrap().push(rel_path(root, path));
WalkState::Continue
})
});
assert!(seen.lock().unwrap().is_empty());
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ pub fn scope_files(
on_disk.push(path);
}
}
walk_files(&on_disk, &|path| mask.has_directory_exclusions() && mask.excludes_dir(&rel_path(root, path)), || {
walk_files(&on_disk, || {
let found = &found;
let keep = &keep;
Box::new(move |path| {
-80
View File
@@ -12,7 +12,6 @@ pub struct FileMask {
exclude_name: GlobSet,
exclude_path: GlobSet,
has_include: bool,
exclude_dirs: GlobSet,
}
impl FileMask {
@@ -21,7 +20,6 @@ impl FileMask {
pub fn new(include: &[String], exclude: &[String]) -> IdeResult<Self> {
let mut inc = Lists::default();
let mut exc = Lists::default();
let mut dirs = Lists::default();
for (entries, is_include_list) in [(include, true), (exclude, false)] {
for mask in entries.iter().flat_map(|e| e.split(',')) {
let mask = mask.trim();
@@ -34,20 +32,10 @@ impl FileMask {
}
let target = if is_include_list != negated { &mut inc } else { &mut exc };
target.push(mask)?;
// Only a trailing /** guarantees that every descendant is excluded.
// File masks and inclusion masks cannot safely prune a directory.
if is_include_list == negated
&& !mask.contains('\\')
&& let Some(prefix) = mask.strip_suffix("/**")
&& !prefix.is_empty()
{
dirs.push(&format!("{prefix}/"))?;
}
}
}
Ok(Self {
has_include: inc.count > 0,
exclude_dirs: dirs.path.build().map_err(glob_error)?,
include_name: inc.name.build().map_err(glob_error)?,
include_path: inc.path.build().map_err(glob_error)?,
exclude_name: exc.name.build().map_err(glob_error)?,
@@ -55,20 +43,6 @@ impl FileMask {
})
}
pub(crate) fn has_directory_exclusions(&self) -> bool {
!self.exclude_dirs.is_empty()
}
/// Whether an exclusion covers every file below this directory. This is
/// deliberately conservative; ordinary masks still filter individual files.
pub(crate) fn excludes_dir(&self, rel_path: &str) -> bool {
if self.exclude_dirs.is_empty() {
return false;
}
let path = format!("{rel_path}/");
path.match_indices('/').any(|(i, _)| self.exclude_dirs.is_match(&path[..=i]))
}
/// `rel_path` uses `/` separators.
pub fn matches(&self, rel_path: &str) -> bool {
let name = rel_path.rsplit('/').next().unwrap_or(rel_path);
@@ -159,60 +133,6 @@ mod tests {
assert!(!m.matches("Cargo.lock"));
}
#[test]
fn only_complete_subtree_exclusions_prune_directories() {
let m = mask(&["*.go"], &["**/node_modules/**", "edge/device/build/**"]);
for dir in [
"node_modules",
"edge/node_modules",
"edge/device/build",
"edge/device/build/deep",
"edge/node_modules/pkg/lib",
] {
assert!(m.excludes_dir(dir), "{dir}");
}
for dir in ["edge", "edge/device", "internal/build", "src.go"] {
assert!(!m.excludes_dir(dir), "{dir}");
}
for pattern in ["*.go", "build", "build/*", "build/", "build/**/*.go"] {
assert!(!mask(&[], &[pattern]).excludes_dir("build"), "{pattern}");
}
}
#[test]
fn directory_pruning_preserves_mask_negation_semantics() {
assert!(mask(&["!vendor/**"], &[]).excludes_dir("vendor"));
assert!(!mask(&[], &["!vendor/**"]).excludes_dir("vendor"));
// Exclusions already win over inclusions in FileMask, irrespective of order.
let m = mask(&["vendor/keep.go"], &["vendor/**"]);
assert!(m.excludes_dir("vendor"));
assert!(!m.matches("vendor/keep.go"));
assert!(mask(&[], &["./vendor/**, **/node_modules/**"]).excludes_dir("vendor"));
}
#[test]
fn directory_pruning_uses_the_same_case_rules_as_files() {
let m = mask(&[], &["Vendor/**"]);
let case_insensitive = cfg!(any(target_os = "macos", target_os = "windows"));
assert_eq!(m.excludes_dir("vendor"), case_insensitive);
assert_eq!(m.matches("vendor/a.go"), !case_insensitive);
}
#[test]
fn pruned_directory_descendants_are_already_excluded() {
for pattern in ["vendor/**", "**/build/**", "modules/*/build/**", "[ab]/**", "**/**"] {
let m = mask(&[], &[pattern]);
for dir in ["vendor", "vendor/deep", "build", "a", "modules/a/build", "modules/a/src", "x/build/y"] {
if m.excludes_dir(dir) {
for file in ["main.go", "deep/lib.go", ".hidden/config", "node_modules/a.js"] {
assert!(!m.matches(&format!("{dir}/{file}")), "{pattern}: {dir}/{file}");
}
}
}
}
assert!(!mask(&[], &[r"escaped\*/**"]).excludes_dir("escaped*"));
}
#[test]
fn invalid_glob() {
let err = FileMask::new(&["a[".into()], &[]).unwrap_err();
-39
View File
@@ -797,42 +797,3 @@ fn search_context_reads_non_utf8_files() {
let p = project(&[("a.rs", &gbk("foo(\"新增\"); // 中文 foo\n"))]);
assert_eq!(run_context(p.path(), C::InComments), vec![("a.rs".to_owned(), 0, vec![(17, 20)])]);
}
#[test]
fn subtree_masks_preserve_results_across_search_scopes() {
let p = project(&[
("edge/build/deep/generated.go", b"hit"),
("edge/src/main.go", b"hit"),
("internal/build/build.go", b"hit"),
("edge/build-notes.go", b"hit"),
]);
let q = SearchQuery { exclude: vec!["edge/build/**".into()], ..query("hit") };
let (views, _) = run(p.path(), &q);
assert_eq!(paths(&views), ["edge/build-notes.go", "edge/src/main.go", "internal/build/build.go"]);
let mask = ide_search::FileMask::new(&q.include, &q.exclude).unwrap();
for scope in [
SearchScope::Directory { path: "edge/build".into() },
SearchScope::Files { paths: vec!["edge/build/deep/generated.go".into()] },
] {
let (views, _) = run(p.path(), &SearchQuery { scope: scope.clone(), ..q.clone() });
assert!(views.is_empty());
assert!(ide_search::scope_files(p.path(), &scope, &mask, None, &CancellationToken::new()).unwrap().is_empty());
}
// A file-name exclusion must not prune a same-named directory.
let (views, _) = run(p.path(), &SearchQuery { exclude: vec!["build".into()], ..query("hit") });
assert_eq!(views.len(), 4);
}
#[test]
fn ignore_whitelists_do_not_override_explicit_subtree_masks() {
let p = project(&[
(".gitignore", b"generated/*\n!generated/keep/\n"),
("generated/keep/.gitignore", b"!keep.go\n"),
("generated/keep/keep.go", b"needle"),
("src/main.go", b"needle"),
]);
let (views, _) = run(p.path(), &query("needle"));
assert_eq!(paths(&views), ["generated/keep/keep.go", "src/main.go"]);
let (views, _) = run(p.path(), &SearchQuery { exclude: vec!["generated/**".into()], ..query("needle") });
assert_eq!(paths(&views), ["src/main.go"]);
}
+6 -111
View File
@@ -104,17 +104,6 @@ pub enum InstallEvent {
Progress { fraction: Option<f64>, message: String },
}
/// A newer version of a tool the IDE installed, as its install source offers it now.
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ToolUpdate {
pub name: String,
/// The installed version.
pub current: String,
/// The version installing the tool again would get.
pub latest: String,
}
#[derive(Serialize, Deserialize)]
struct Manifest {
version: Option<String>,
@@ -1421,16 +1410,10 @@ impl Tools {
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("`{main}` was not found in {}", dl.name)))?;
let rel = found.strip_prefix(&pkg).expect("inside pkg").to_path_buf();
let final_dir = self.root.join(spec.name);
// Downloads made by the IDE itself are not quarantined, but archives may carry the flag.
clear_quarantine(&pkg);
// The previous install is removed in the background once replaced: deleting a large
// tool takes a while, and there is nothing left to cancel by then.
let previous = self.root.join(format!(".previous-{}", spec.name));
let _ = std::fs::remove_dir_all(&previous);
put_in_place(&pkg, &final_dir, &previous, cancel)?;
std::thread::spawn(move || {
let _ = std::fs::remove_dir_all(previous);
});
if final_dir.exists() {
std::fs::remove_dir_all(&final_dir)?;
}
std::fs::rename(&pkg, &final_dir)?;
let target = final_dir.join(&rel);
let bin = final_dir.join("bin");
if is_bundle(main) {
@@ -1445,80 +1428,11 @@ impl Tools {
archive::link_executable(&bin, main, &target)?;
}
}
// Downloads made by the IDE itself are not quarantined, but archives may carry the flag.
clear_quarantine(&final_dir);
self.write_manifest(spec.name, &Manifest { version: Some(dl.version.clone()), source: Some(dl.url.clone()) })
}
/// Newer versions of the tools the IDE installed into its tools folder, for the install
/// sources that can be asked: release downloads (GitHub, Open VSX, Maven Central, …) and
/// npm packages. A tool whose lookup fails (offline, rate limited) is left out.
pub fn updates(&self, cancel: &CancellationToken) -> IdeResult<Vec<ToolUpdate>> {
let installed: Vec<(&ToolSpec, Method, String)> = TOOLS
.iter()
.filter_map(|spec| {
let method = self.install_method(spec);
Some((spec, method, self.installed_version(spec, method)?))
})
.collect();
let updates = std::thread::scope(|scope| {
let lookups: Vec<_> = installed
.iter()
.map(|(spec, method, current)| {
scope.spawn(move || match self.for_method(*method).latest_version(*method, cancel) {
Ok(Some(latest)) if latest != *current => {
Some(ToolUpdate { name: spec.name.into(), current: current.clone(), latest })
}
Ok(_) => None,
Err(e) => {
tracing::info!("update check for {}: {}", spec.name, e.message);
None
}
})
})
.collect();
lookups.into_iter().filter_map(|lookup| lookup.join().ok().flatten()).collect()
});
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(updates)
}
/// The version of `spec` in the IDE's tools folder, when `method` (how it was installed)
/// can tell the latest one.
fn installed_version(&self, spec: &ToolSpec, method: Method) -> Option<String> {
match method {
Method::Release { .. } => {
managed_bin_dir(&self.root, spec, self.platform).and_then(|dir| self.exe_in(&dir, spec.commands[0]))?;
self.manifest(spec.name)?.version
}
// A pinned package (`typescript@6`) stays on its major version.
Method::Npm { packages } if npm_name(packages[0]) == packages[0] => self.npm_package_version(packages[0]),
_ => None,
}
}
/// The version installing with `method` would get now.
fn latest_version(&self, method: Method, cancel: &CancellationToken) -> IdeResult<Option<String>> {
match method {
Method::Release { source, assets, .. } => {
let http = Http::new(&self.system_path)?;
Ok(Some(self.resolve_release(&http, source, assets, cancel)?.version))
}
Method::Npm { packages } => {
let (manager, _) = self.npm_manager()?;
let mut c = self.command(manager);
// The registry the user's npm configuration names (a mirror, a proxy). A cold npm
// on a slow registry takes several seconds per package, more when run side by side.
c.args(["view", packages[0], "version"]).current_dir(&self.root);
match run_quiet(c, Duration::from_secs(60)) {
Some((true, out)) => Ok(out.lines().map(str::trim).rfind(|l| !l.is_empty()).map(String::from)),
_ => Err(IdeError::unavailable(format!("cannot look up the latest {}", packages[0]))),
}
}
_ => Ok(None),
}
}
/// Removes a tool the IDE installed.
pub fn uninstall(&self, name: &str, langs: &[LanguageConfig]) -> IdeResult<ToolInfo> {
match registry::find(name) {
@@ -1649,25 +1563,6 @@ fn fetch_verified(
}
/// Removes the quarantine flag archives may carry, so macOS runs what was unpacked.
/// Moves the unpacked `pkg` to `dest`, moving a previous install aside to `aside`.
/// Cancelling stops it until then: the last point where the previous install is untouched.
fn put_in_place(pkg: &Path, dest: &Path, aside: &Path, cancel: &CancellationToken) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let replaced = dest.exists();
if replaced {
std::fs::rename(dest, aside)?;
}
if let Err(e) = std::fs::rename(pkg, dest) {
if replaced {
let _ = std::fs::rename(aside, dest);
}
return Err(e.into());
}
Ok(())
}
fn clear_quarantine(dir: &Path) {
#[cfg(target_os = "macos")]
{
-42
View File
@@ -916,45 +916,3 @@ exit 1
let args = fs::read_to_string(log).unwrap();
assert!(args.contains(&format!("--python {}", sdk.join("python3.14").display())), "{args}");
}
#[test]
fn a_cancelled_install_leaves_the_previous_one_in_place() {
let tmp = tempfile::tempdir().unwrap();
let (pkg, dest, aside) = (tmp.path().join("pkg"), tmp.path().join("tool"), tmp.path().join(".previous-tool"));
fs::create_dir_all(&pkg).unwrap();
fs::write(pkg.join("new"), "").unwrap();
fs::create_dir_all(&dest).unwrap();
fs::write(dest.join("old"), "").unwrap();
let cancel = CancellationToken::new();
cancel.cancel();
let err = put_in_place(&pkg, &dest, &aside, &cancel).unwrap_err();
assert_eq!(err.kind, ErrorKind::Cancelled);
assert!(dest.join("old").exists() && pkg.join("new").exists() && !aside.exists());
put_in_place(&pkg, &dest, &aside, &CancellationToken::new()).unwrap();
assert!(dest.join("new").exists() && !dest.join("old").exists());
assert!(aside.join("old").exists(), "the previous install is moved aside");
}
/// An npm tool the IDE installed is offered the version the user's npm registry has now.
#[cfg(unix)]
#[test]
fn npm_tools_report_a_newer_registry_version() {
let env = Env::new();
let sdk = env._tmp.path().join("node-sdk/bin");
let modules = env.root.join("node_modules");
env.script(&modules.join(".bin"), "bash-language-server", "echo 5.4.0");
fs::create_dir_all(modules.join("bash-language-server")).unwrap();
fs::write(modules.join("bash-language-server/package.json"), r#"{"version":"5.4.0"}"#).unwrap();
env.script(&sdk, "node", "exit 0");
let tools = |latest: &str| {
env.script(&sdk, "npm", &format!(r#"[ "$1 $2 $3" = "view bash-language-server version" ] && echo {latest}"#));
env.tools().with_runtime(BTreeMap::from([("node".into(), sdk.join("node"))]), BTreeMap::new())
};
let cancel = CancellationToken::new();
assert_eq!(
tools("5.6.1").updates(&cancel).unwrap(),
vec![ToolUpdate { name: "bash-language-server".into(), current: "5.4.0".into(), latest: "5.6.1".into() }]
);
assert_eq!(tools("5.4.0").updates(&cancel).unwrap(), vec![]);
}
-149
View File
@@ -1,149 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
declare global {
interface Window {
__dbLifecycle: { release: (fail: boolean) => void; releaseSave?: () => void };
}
}
async function setup(page: Page) {
await stubDesktop(page);
await page.evaluate(async () => {
const storePath = "/src/features/database/store.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const { useDatabase } = await import(storePath);
const { toolWindowService } = await import(contributionsPath);
let sources = [{ id: "lifecycle", name: "Lifecycle database", driver: "sqlite", file: "/project/data.sqlite" }];
let resolve!: (schemas: string[]) => void;
let reject!: (reason: unknown) => void;
const response = new Promise<string[]>((res, rej) => {
resolve = res;
reject = rej;
});
window.__dbLifecycle = {
release: (fail) => {
if (fail) reject({ kind: "unavailable", message: "Old metadata failed" });
else resolve(["old_schema"]);
},
};
Object.assign(window.__ipc.handlers, {
db_sources: () => sources,
db_connected: () => [],
db_connect: () => null,
db_disconnect: () => null,
db_schemas: () => response,
db_save_sources: (args: { sources: typeof sources }) => {
sources = args.sources;
return null;
},
});
useDatabase.setState({
sources,
loaded: true,
connected: new Set(),
schemas: {},
tables: {},
details: {},
objects: {},
});
toolWindowService().show("database", true);
});
const panel = page.locator('section[aria-label="Database"]');
const source = panel.getByRole("treeitem").filter({ hasText: "Lifecycle database" });
await source.locator("[data-tree-toggle]").click();
await expect.poll(async () => (await ipcCalls(page, "db_schemas")).length).toBe(1);
await expect(panel.locator('[title="Connected"]')).toHaveCount(1);
return { panel, source };
}
async function release(page: Page, fail = false) {
await page.evaluate(async (fail) => {
window.__dbLifecycle.release(fail);
// Let the promise chain and the resulting React render finish.
await new Promise<void>((done) => requestAnimationFrame(() => requestAnimationFrame(() => done())));
}, fail);
}
for (const fail of [false, true]) {
test(`deactivation ignores a delayed metadata ${fail ? "failure" : "success"}`, async ({ workbench: page }) => {
const { panel, source } = await setup(page);
await source.click();
await panel.getByRole("button", { name: /^Deactivate/ }).click();
await expect(panel.locator('[title="Connected"]')).toHaveCount(0);
await release(page, fail);
await expect(panel.locator('[title="Connected"]')).toHaveCount(0);
await expect(panel.getByText("old_schema", { exact: true })).toHaveCount(0);
await expect(panel.getByText("Old metadata failed", { exact: true })).toHaveCount(0);
});
}
test("deleting a data source ignores its delayed metadata", async ({ workbench: page }) => {
const { panel } = await setup(page);
await page.evaluate(async () => {
const path = "/src/features/database/store.ts";
const { deleteSource } = await import(path);
await deleteSource("lifecycle");
});
await expect(panel.getByText("No data sources", { exact: true })).toBeVisible();
await release(page);
await expect(panel.getByText("No data sources", { exact: true })).toBeVisible();
await expect
.poll(() =>
page.evaluate(async () => {
const path = "/src/features/database/store.ts";
const { useDatabase } = await import(path);
return { connected: [...useDatabase.getState().connected], schemas: useDatabase.getState().schemas };
}),
)
.toEqual({ connected: [], schemas: {} });
});
test("switching projects ignores the old source even when its ID is reused", async ({ workbench: page }) => {
const { panel } = await setup(page);
await page.evaluate(async () => {
const projectPath = "/src/platform/project.ts";
const storePath = "/src/features/database/store.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const { useProject } = await import(projectPath);
const { loadSources } = await import(storePath);
const { toolWindowService } = await import(contributionsPath);
window.__ipc.handlers.db_sources = () => [{ id: "lifecycle", name: "New project database", driver: "sqlite" }];
useProject.setState({ project: { ...useProject.getState().project, root: "/new-project", name: "New project" } });
await loadSources();
toolWindowService().show("database", true);
});
await expect(panel.getByText("New project database", { exact: true })).toBeVisible();
await release(page);
await expect(panel.locator('[title="Connected"]')).toHaveCount(0);
await expect(panel.getByText("old_schema", { exact: true })).toHaveCount(0);
});
test("an old source save cannot close the new project's dialog", async ({ workbench: page }) => {
await setup(page);
await page.evaluate(async () => {
const path = "/src/features/database/store.ts";
const { openSourceDialog, useDatabase } = await import(path);
window.__ipc.handlers.db_save_sources = () =>
new Promise<void>((resolve) => {
window.__dbLifecycle.releaseSave = resolve;
});
openSourceDialog(useDatabase.getState().sources[0]);
});
await page.getByRole("dialog").getByRole("button", { name: "OK", exact: true }).click();
await expect.poll(async () => (await ipcCalls(page, "db_save_sources")).length).toBe(1);
await page.evaluate(async () => {
const storePath = "/src/features/database/store.ts";
const projectPath = "/src/platform/project.ts";
const { openDialog } = await import(storePath);
const { useProject } = await import(projectPath);
useProject.setState({ project: { ...useProject.getState().project, root: "/new-project", name: "New project" } });
openDialog({ kind: "params", names: ["newParameter"], resolve: () => {} });
});
await expect(page.getByRole("dialog").getByText("newParameter", { exact: true })).toBeVisible();
await page.evaluate(async () => {
window.__dbLifecycle.releaseSave!();
await new Promise<void>((done) => requestAnimationFrame(() => requestAnimationFrame(() => done())));
});
await expect(page.getByRole("dialog").getByText("newParameter", { exact: true })).toBeVisible();
});
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ec93db9b37437986167b4040714f5cda5cb7a2fc138e6c2dc2a9447faaf5a802
446156fc345df7c8252592f73129f3996139e5980c6ed9a296a9cbecc334eb17
+1 -33
View File
@@ -31,19 +31,10 @@ struct Config {
enabled: bool,
}
/// Whether `endpoint` is OpenAI's API host itself, so the key may go to it:
/// `https`, exactly `api.openai.com` (or `:443`), and no `user@` before the host
/// (`https://api.openai.com@localhost` is localhost).
pub fn is_openai(endpoint: &str) -> bool {
let Some(rest) = endpoint.strip_prefix("https://") else { return false };
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
matches!(authority, "api.openai.com" | "api.openai.com:443")
}
fn config() -> Config {
let get = |k: &str| storage::get(k).ok().flatten();
let endpoint = get("endpoint").unwrap_or_else(|| LOCAL.into());
let openai = is_openai(&endpoint);
let openai = endpoint.starts_with(OPENAI);
let model = get("model").unwrap_or_else(|| (if openai { OPENAI_MODEL } else { LOCAL_MODEL }).into());
Config { endpoint, model, openai, enabled: get("enabled").as_deref() != Some("false") }
}
@@ -204,29 +195,6 @@ mod tests {
assert!(sent.headers.contains(&("Authorization".into(), "Bearer sk-test".into())));
}
#[test]
fn the_key_goes_to_openai_only() {
assert!(is_openai(OPENAI));
assert!(is_openai("https://api.openai.com/"));
assert!(is_openai("https://api.openai.com:443/v1"));
for other in [
"https://api.openai.com@localhost:9999",
"https://api.openai.com:x@localhost",
"https://api.openai.com.example.com",
"https://api.openai.com\\@localhost",
"http://api.openai.com",
"http://localhost:11434",
] {
assert!(!is_openai(other), "{other}");
}
let host = MockHost::install().with_capabilities(&["storage"]);
AiCompletion::default()
.run_command("set-endpoint", &host.invocation(), Some("https://api.openai.com@localhost:9999"))
.unwrap();
assert!(!config().openai, "no key for a look-alike endpoint");
}
#[test]
fn quiet_when_cancelled_denied_or_mid_word() {
let host = MockHost::install().with_capabilities(&["storage"]);
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1bd90bca250bb06ab3b0f27b045110c722685d894ce79d23648cb7c795d2c033
b072d07c4aa4a972a71fffba355ea215da7860b8f5527c6074055f52c52a29be
+1 -15
View File
@@ -106,16 +106,10 @@ fn project_of(path: &str) -> Option<Project> {
host::projects()
.ok()?
.into_iter()
.filter(|p| p.root().is_ok_and(|r| is_under(path, &r)))
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
/// Whether `path` is `root` or inside it (`/proj-x/a` is not inside `/proj`).
fn is_under(path: &str, root: &str) -> bool {
path.strip_prefix(root)
.is_some_and(|rest| rest.is_empty() || root.ends_with(['/', '\\']) || rest.starts_with(['/', '\\']))
}
fn windows_path(path: &str) -> String {
let path = path.replace('\\', "/");
if let Some(rest) = path.strip_prefix("//?/UNC/") {
@@ -317,14 +311,6 @@ mod tests {
assert!(!report_matches_file("/project/A\\B.java", "/project/A/B.java"));
}
#[test]
fn a_project_contains_only_its_own_paths() {
assert!(is_under("/proj/A.java", "/proj"));
assert!(is_under("C:\\proj\\A.java", "C:\\proj"));
assert!(is_under("/A.java", "/"));
assert!(!is_under("/proj-x/A.java", "/proj"));
}
#[test]
fn windows_reports_match_the_saved_file_and_keep_quick_fixes() {
for (root, reported) in [
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
c4c86cef9512ac03193e7d89d39b6ee41665c2d4721a78f9415d406289e9ece1
a78162e7c4c6ebd3a22c06737fd35948e11b0dd8949571617bc099ba6b4423f0
+4 -5
View File
@@ -143,9 +143,8 @@ impl CsvEditor {
let d = csv.delimiter;
let width = csv.records.iter().map(|r| r.fields.len()).max().unwrap_or(1).max(1);
let empty_record = d.to_string().repeat(width - 1);
let record = |row: u32| csv.records.get(usize::try_from(row).ok()?.checked_add(skip)?);
// Before row `before`; at the end for `None` or a row past the end.
let insert = |before: Option<u32>| match before.and_then(record) {
let record = |row: u32| csv.records.get(row as usize + skip);
let insert = |before: u32| match record(before) {
Some(r) => vec![edit(text, r.span.start..r.span.start, format!("{empty_record}\n"))],
None => {
// Append; the last record may lack its line break.
@@ -167,8 +166,8 @@ impl CsvEditor {
}
}
}
ViewEvent::InsertRow(before) => insert(Some(*before)),
ViewEvent::Button(b) if b == "add-row" => insert(None),
ViewEvent::InsertRow(before) => insert(*before),
ViewEvent::Button(b) if b == "add-row" => insert(u32::MAX),
ViewEvent::DeleteRows(rows) => {
let mut rows: Vec<u32> = rows.clone();
rows.sort_unstable();
@@ -6,7 +6,7 @@
* which turns it into the file's problems.
*/
import { instance, type Viz } from "@viz-js/viz";
import { ENGINES, type Engine, exportName, problemsOf, renderOptions, withoutLinks } from "./render";
import { ENGINES, type Engine, exportName, problemsOf, renderOptions } from "./render";
const RENDER_DELAY_MS = 150;
const MIN_ZOOM = 0.1;
@@ -73,7 +73,7 @@ function start(bridge: OpenideaBridge) {
const problems = problemsOf(result);
report(problems);
if (result.status === "success") {
canvas.innerHTML = withoutLinks(result.output);
canvas.innerHTML = result.output;
canvas.classList.remove("stale");
banner.hidden = true;
if (fitted) fit();
@@ -1,6 +1,6 @@
import { instance } from "@viz-js/viz";
import { describe, expect, it } from "vitest";
import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions, withoutLinks } from "./render";
import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions } from "./render";
describe("the preview's rendering", () => {
it("renders with the theme's colour as a default the graph can override", async () => {
@@ -42,22 +42,4 @@ describe("the preview's rendering", () => {
expect(exportName("Flow.GV", "png")).toBe("Flow.png");
expect(exportName("", "svg")).toBe("graph.svg");
});
it("previews a graph's links without them", async () => {
const viz = await instance();
const js = "javascript:alert(1)";
const graph = `digraph {
URL="${js}"; target="_top"
a [URL="${js}" href="https://example.com" label="a href=\\"x\\""]
a -> b [URL="${js}" edgeURL="${js}" headURL="${js}" tailURL="${js}" labelURL="${js}" label="e" headlabel="h" taillabel="t"]
subgraph cluster_x { URL="${js}"; c }
}`;
const result = viz.render(graph, renderOptions("dot", null));
const svg = result.status === "success" ? result.output : "";
expect(svg).toContain(js);
const preview = withoutLinks(svg);
expect(preview).not.toMatch(/\s(xlink:)?(href|target)="|javascript:/);
expect(preview).toContain("a href=&quot;x&quot;");
expect(preview.match(/<g id="node\d+"/g)).toHaveLength(3);
});
});
@@ -66,13 +66,3 @@ export function exportName(file: string, extension: string): string {
const base = file.replace(/\.(dot|gv)$/i, "") || "graph";
return `${base}.${extension}`;
}
/**
* Graphviz's SVG without its links, for the preview: Graphviz writes a graph's
* `URL` / `href` attributes as given (`javascript:` too), and the preview's
* page may run inline script. Exports keep them. Graphviz quotes attribute
* values with `"` and escapes the ones inside, so a value never holds one.
*/
export function withoutLinks(svg: string): string {
return svg.replace(/\s(?:xlink:)?(?:href|target)="[^"]*"/g, "");
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0d7731de73514b950d8579762029d98c46136f55bfdb2fa44e256cc9a9955a0f
6d84abcc58c1894203506fa89855a6eade61f14add0ed72fdd7af9e3cd1298f6
+2 -32
View File
@@ -58,16 +58,8 @@ pub fn has_header(text: &str) -> bool {
})
}
/// `value` on one line: a line break (or any control character, or the
/// Unicode line separators JavaScript ends a comment at) would put the rest of
/// the setting into the file as code.
fn one_line(value: &str) -> String {
value.chars().map(|c| if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') { ' ' } else { c }).collect()
}
/// The header's lines, without comment markers.
pub fn header_lines(year: i64, owner: &str, license: &str) -> Vec<String> {
let (owner, license) = (one_line(owner), one_line(license));
let owner = owner.trim();
let mut lines =
vec![if owner.is_empty() { format!("Copyright (c) {year}") } else { format!("Copyright (c) {year} {owner}") }];
@@ -90,8 +82,7 @@ pub fn render(comment: Comment, lines: &[String]) -> String {
out.push_str(open);
out.push('\n');
for l in lines {
// `*/` would end the comment early.
out.push_str(&format!(" * {}\n", l.replace("*/", "* /")));
out.push_str(&format!(" * {l}\n"));
}
out.push_str(close);
out.push('\n');
@@ -179,16 +170,10 @@ fn project_of(path: &str) -> Option<Project> {
host::projects()
.ok()?
.into_iter()
.filter(|p| p.root().is_ok_and(|r| is_under(path, &r)))
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
/// Whether `path` is `root` or inside it (`/proj-x/a` is not inside `/proj`).
fn is_under(path: &str, root: &str) -> bool {
path.strip_prefix(root)
.is_some_and(|rest| rest.is_empty() || root.ends_with(['/', '\\']) || rest.starts_with(['/', '\\']))
}
#[derive(Default)]
pub struct LicenseHeader;
@@ -320,15 +305,6 @@ mod tests {
assert_eq!(header_lines(2026, " ", ""), ["Copyright (c) 2026"]);
}
#[test]
fn settings_cannot_add_code_to_the_file() {
let lines = header_lines(2026, "ACME\nimport os\r\u{2028}x", "MIT\nrm -rf");
assert_eq!(lines, ["Copyright (c) 2026 ACME import os x", "SPDX-License-Identifier: MIT rm -rf"]);
assert_eq!(render(comment_for("a.py").unwrap(), &lines).lines().filter(|l| !l.starts_with('#')).count(), 1);
let css = render(comment_for("s.css").unwrap(), &header_lines(2026, "X */ body { }", ""));
assert_eq!(css.matches("*/").count(), 1, "{css}");
}
#[test]
fn keeps_shebang_and_encoding_lines_first() {
assert_eq!(insert_line("fn main() {}\n"), 0);
@@ -348,12 +324,6 @@ mod tests {
assert!(header_edit("a.rs", "// Copyright (c) 2020 X\n", 2026, "", "").is_none());
}
#[test]
fn a_project_contains_only_its_own_paths() {
assert!(is_under("/proj/a.rs", "/proj"));
assert!(!is_under("/proj-x/a.rs", "/proj"));
}
#[test]
fn years() {
assert_eq!(year_of_day(0), 1970);
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
959cb2ab750211bd1cc894a4d804acb2dd16e95c1e65b267e7eccbb64cbdd8da
a303522c605ccc9804e67f85326778a380951ce14022a70bc37ba3ae63b76f79
+51 -211
View File
@@ -9,15 +9,11 @@ use openidea_plugin::text::{offset, position};
use openidea_plugin::{
Error, Invocation, Location, Plugin, Position, Project, Range, TextQuery, host, register_plugin,
};
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
/// How long a namespace → XML file lookup is trusted.
const INDEX_TTL: Duration = Duration::from_secs(30);
/// Lookups kept per project before the expired ones are dropped.
const MAX_INDEXED: usize = 1024;
/// Other mappers one file's references are looked up in (each is a search).
const MAX_OTHER_MAPPERS: usize = 32;
/// Files declaring one namespace that are tried (normally one).
const MAX_MAPPERS: u32 = 64;
const STATEMENTS: [&str; 4] = ["select", "insert", "update", "delete"];
@@ -89,77 +85,27 @@ pub struct MapperJava {
pub result_ids: Vec<(usize, Named)>,
}
/// Positions of many byte offsets in one text, asked in about increasing
/// order: each one scans only from the offset asked before it, so a file with
/// many names costs about one pass, not one pass per name.
struct Positions<'a> {
text: &'a str,
at: usize,
pos: Position,
}
fn utf16_len(s: &str) -> u32 {
s.chars().map(|c| c.len_utf16() as u32).sum()
}
impl<'a> Positions<'a> {
fn new(text: &'a str) -> Self {
Positions { text, at: 0, pos: Position::new(0, 0) }
}
fn of(&mut self, at: usize) -> Position {
let at = at.min(self.text.len());
if at >= self.at {
for c in self.text[self.at..at].chars() {
if c == '\n' {
self.pos = Position::new(self.pos.line + 1, 0);
} else {
self.pos.character += c.len_utf16() as u32;
}
}
} else {
let back = &self.text[at..self.at];
match back.matches('\n').count() as u32 {
0 => self.pos.character -= utf16_len(back),
lines => {
let line_start = self.text[..at].rfind('\n').map_or(0, |i| i + 1);
self.pos = Position::new(self.pos.line - lines, utf16_len(&self.text[line_start..at]));
}
}
}
self.at = at;
self.pos
}
fn range(&mut self, start: usize, end: usize) -> Range {
Range::new(self.of(start), self.of(end))
}
}
/// Where a completion that replaces `typed` (just before `at`, on its line) starts.
fn replace_start(at: Position, typed: &str) -> Option<Position> {
if typed.contains('\n') {
return None;
}
Some(Position::new(at.line, at.character.checked_sub(utf16_len(typed))?))
fn range_of(text: &str, start: usize, end: usize) -> Range {
Range::new(position(text, start), position(text, end))
}
pub fn parse_xml(text: &str) -> MapperXml {
let mut out = MapperXml::default();
let mut positions = Positions::new(text);
let attr = |tag_start: usize, name: &str| -> Option<(String, usize, usize)> {
let tag_end = text[tag_start..].find('>').map_or(text.len(), |i| tag_start + i);
let tag = &text[tag_start..tag_end];
let i = tag.find(&format!("{name}=\""))? + name.len() + 2;
let j = tag[i..].find('"')? + i;
Some((tag[i..j].to_owned(), tag_start + i, tag_start + j))
};
let mut at = 0;
while let Some(i) = text[at..].find('<') {
let start = at + i + 1;
at = start;
let name: String = text[start..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
// `<` cannot appear inside an attribute value, so a tag without its
// `>` ends at the next `<`: no tag is searched past the next one.
let tag_end = text[start..].find(['<', '>']).map_or(text.len(), |i| start + i);
let tag = &text[start..tag_end];
let mut named = |attr_name: &str| -> Option<Named> {
let i = tag.find(&format!("{attr_name}=\""))? + attr_name.len() + 2;
let j = tag[i..].find('"')? + i;
Some(Named { name: tag[i..j].to_owned(), range: positions.range(start + i, start + j) })
let named = |attr_name: &str| -> Option<Named> {
let (v, s, e) = attr(start, attr_name)?;
Some(Named { name: v, range: range_of(text, s, e) })
};
if name == "mapper" {
out.namespace = named("namespace");
@@ -217,8 +163,7 @@ fn skip_string(bytes: &[u8], mut i: usize) -> usize {
_ => i += 1,
}
}
// A `\` as the last byte steps past the end.
i.min(bytes.len())
i
}
/// The index just past the `//…` or `/*…*/` comment opening at `bytes[i]`.
@@ -282,7 +227,7 @@ fn scan_declaration(stmt: &str) -> Option<Declaration> {
}
b'(' => {
let before = stmt[..i].trim_end();
let start = before.char_indices().rev().find(|&(_, c)| !ident(c)).map_or(0, |(p, c)| p + c.len_utf8());
let start = before.rfind(|c: char| !ident(c)).map_or(0, |p| p + 1);
if start == before.len() {
return None;
}
@@ -518,8 +463,11 @@ fn annotation_string_arg(args: &str, key: &str) -> Option<(String, usize, usize)
depth = depth.saturating_sub(1);
i += 1;
}
_ if depth == 0 && args.is_char_boundary(i) && args[i..].chars().next().is_some_and(ident) => {
let j = args[i..].char_indices().find(|&(_, c)| !ident(c)).map_or(args.len(), |(n, _)| i + n);
_ if depth == 0 && ident(bytes[i] as char) => {
let mut j = i + 1;
while j < bytes.len() && ident(bytes[j] as char) {
j += 1;
}
if &args[i..j] == key {
let mut k = j;
while k < bytes.len() && bytes[k].is_ascii_whitespace() {
@@ -600,9 +548,8 @@ pub fn parse_java(text: &str) -> MapperJava {
Some(p) => format!("{p}.{simple}"),
None => simple.clone(),
});
let mut positions = Positions::new(text);
let decl =
Some(Named { name: simple.clone(), range: positions.range(i + 10 + indent, i + 10 + indent + simple.len()) });
Some(Named { name: simple.clone(), range: range_of(text, i + 10 + indent, i + 10 + indent + simple.len()) });
let body_start = text[i..].find('{').map_or(text.len(), |b| i + b + 1);
let mut methods = Vec::new();
let mut annotation_sql = Vec::new();
@@ -652,21 +599,22 @@ pub fn parse_java(text: &str) -> MapperJava {
{
let (s, e) = (stmt_start + decl.name.0, stmt_start + decl.name.1);
let list_base = stmt_start + decl.parens.0 + 1;
let method_name =
Named { name: stmt[decl.name.0..decl.name.1].to_owned(), range: positions.range(s, e) };
let params = params_of(&stmt[decl.parens.0 + 1..decl.parens.1])
.into_iter()
.map(|(name, ps, pe)| Param { name, range: positions.range(list_base + ps, list_base + pe) })
.map(|(name, ps, pe)| Param { name, range: range_of(text, list_base + ps, list_base + pe) })
.collect();
let method = methods.len();
methods.push((method_name, params));
methods.push((
Named { name: stmt[decl.name.0..decl.name.1].to_owned(), range: range_of(text, s, e) },
params,
));
let mut strings = Vec::new();
let mut sql_params = Vec::new();
for (ss, se, ps) in annotation_sql_of(&stmt[..decl.parens.0]) {
strings.push(positions.range(stmt_start + ss, stmt_start + se));
strings.push(range_of(text, stmt_start + ss, stmt_start + se));
sql_params.extend(ps.into_iter().map(|(n, ms, me)| Named {
name: n,
range: positions.range(stmt_start + ms, stmt_start + me),
range: range_of(text, stmt_start + ms, stmt_start + me),
}));
}
if !strings.is_empty() {
@@ -675,10 +623,10 @@ pub fn parse_java(text: &str) -> MapperJava {
let (refs, defs) = result_map_of(&stmt[..decl.parens.0]);
result_refs.extend(refs.into_iter().map(|(v, rs, re)| ResultMapRef {
method,
name: Named { name: v, range: positions.range(stmt_start + rs, stmt_start + re) },
name: Named { name: v, range: range_of(text, stmt_start + rs, stmt_start + re) },
}));
result_ids.extend(defs.into_iter().map(|(v, ds, de)| {
(method, Named { name: v, range: positions.range(stmt_start + ds, stmt_start + de) })
(method, Named { name: v, range: range_of(text, stmt_start + ds, stmt_start + de) })
}));
}
stmt_start = k + 1;
@@ -693,13 +641,8 @@ pub fn parse_java(text: &str) -> MapperJava {
/// `namespace="com.acme.UserMapper"` in an XML file, either quote.
fn namespace_query(namespace: &str) -> TextQuery {
let mut escaped = String::with_capacity(namespace.len());
for c in namespace.chars() {
if r"\.+*?()|[]{}^$#&-~".contains(c) {
escaped.push('\\');
}
escaped.push(c);
}
let escaped: String =
namespace.chars().flat_map(|c| matches!(c, '.' | '$').then_some('\\').into_iter().chain([c])).collect();
TextQuery {
pattern: format!(r#"namespace\s*=\s*["']{escaped}["']"#),
regex: true,
@@ -713,22 +656,14 @@ fn project_of(path: &str) -> Option<Project> {
host::projects()
.ok()?
.into_iter()
.filter(|p| p.root().is_ok_and(|r| is_under(path, &r)))
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
/// Whether `path` is `root` or inside it (`/proj-x/a` is not inside `/proj`).
fn is_under(path: &str, root: &str) -> bool {
path.strip_prefix(root)
.is_some_and(|rest| rest.is_empty() || root.ends_with(['/', '\\']) || rest.starts_with(['/', '\\']))
}
#[derive(Default)]
pub struct Navigator {
/// project root → namespace → (when it was looked up, the XML mapping it, if any).
xml_index: BTreeMap<String, BTreeMap<String, (Instant, Option<String>)>>,
/// Other mappers looked up for the file being linked (see [`MAX_OTHER_MAPPERS`]).
other_mappers: BTreeSet<String>,
}
impl Navigator {
@@ -752,20 +687,15 @@ impl Navigator {
let mut paths: Vec<&str> = found.matches.iter().map(|m| m.location.path.as_str()).collect();
paths.dedup();
let hit = paths.into_iter().find_map(|path| Some((path.to_owned(), read(path)?)));
let index = self.xml_index.entry(root).or_default();
if index.len() >= MAX_INDEXED {
index.retain(|_, (at, _)| at.elapsed() < INDEX_TTL);
}
index.insert(namespace.to_owned(), (Instant::now(), hit.as_ref().map(|(path, _)| path.clone())));
self.xml_index
.entry(root)
.or_default()
.insert(namespace.to_owned(), (Instant::now(), hit.as_ref().map(|(path, _)| path.clone())));
hit
}
/// The interface `namespace` names (`com.acme.UserMapper` → `**/com/acme/UserMapper.java`).
fn java_for(project: &Project, namespace: &str) -> Option<(String, MapperJava)> {
// A Java name only: anything else would be read as a glob.
if namespace.is_empty() || !namespace.chars().all(|c| ident(c) || c == '.') {
return None;
}
let rel = format!("**/{}.java", namespace.replace('.', "/"));
let path = project.find_files(&rel, 1).ok()?.into_iter().next()?;
let java = parse_java(&project.read_file(&path).ok()?);
@@ -792,14 +722,6 @@ impl Navigator {
if ns == here_ns {
return pick(xml, doc.path.clone());
}
// Each other mapper is a project search: a file naming thousands of them
// resolves the first few rather than running out of time.
if !self.other_mappers.contains(ns) {
if self.other_mappers.len() >= MAX_OTHER_MAPPERS {
return None;
}
self.other_mappers.insert(ns.to_owned());
}
let (path, other) = self.xml_for(project, ns)?;
pick(&other, path)
}
@@ -829,7 +751,7 @@ impl Navigator {
}
typed
};
let start = replace_start(at, typed)?;
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
let mut ids: Vec<String> = java.result_ids.iter().map(|(_, d)| d.name.clone()).collect();
if let Some(project) = project_of(&doc.path)
&& let Some(name) = &java.name
@@ -874,7 +796,6 @@ impl Navigator {
}));
}
// resultMap / sql references resolve even when the interface is missing.
self.other_mappers.clear();
for r in xml.result_map_refs.iter().chain(xml.extends_refs.iter()) {
if let Some(target) = self.anchor_target(&project, doc, &xml, &ns.name, &r.name, false) {
out.push((r.clone(), target, format!("resultMap {}", r.name)));
@@ -993,7 +914,7 @@ impl Providers for Navigator {
&& STATEMENTS.iter().any(|t| line.contains(&format!("<{t}")))
{
let typed = &line[i + 4..];
let Some(start) = replace_start(at, typed) else { return Vec::new() };
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
return java
.methods
.iter()
@@ -1011,7 +932,7 @@ impl Providers for Navigator {
&& line.contains("<include")
{
let typed = &line[i + 7..];
let Some(start) = replace_start(at, typed) else { return Vec::new() };
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
return xml
.sql_fragments
.iter()
@@ -1028,12 +949,12 @@ impl Providers for Navigator {
&& !before[i..].contains('}')
{
let typed = &before[i + 2..];
let here = position(&doc.text, cursor);
let statement = xml.statements.iter().rev().find(|s| s.range.start < here);
let statement =
xml.statements.iter().rev().find(|s| offset(&doc.text, s.range.start).is_some_and(|o| o < cursor));
let Some((_, params)) = statement.and_then(|s| java.methods.iter().find(|(m, _)| m.name == s.name)) else {
return Vec::new();
};
let Some(start) = replace_start(at, typed) else { return Vec::new() };
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
return params
.iter()
.filter(|p| p.name.starts_with(typed))
@@ -1050,12 +971,16 @@ impl Providers for Navigator {
/// After `#{` / `${` inside an annotation SQL string: the method's parameters.
fn annotation_param_completion(doc: &Document, at: Position) -> Vec<CompletionItem> {
let Some(cursor) = offset(&doc.text, at) else { return Vec::new() };
let here = position(&doc.text, cursor);
let java = parse_java(&doc.text);
for sql in &java.annotation_sql {
let (_, params) = &java.methods[sql.method];
for s in sql.strings.iter().filter(|s| s.start <= here && here <= s.end) {
let Some(start) = offset(&doc.text, s.start) else { continue };
for s in &sql.strings {
let (Some(start), Some(end)) = (offset(&doc.text, s.start), offset(&doc.text, s.end)) else {
continue;
};
if !(start <= cursor && cursor <= end) {
continue;
}
let seg = &doc.text[start..cursor];
let mut token: Option<usize> = None;
for t in ["#{", "${"] {
@@ -1068,7 +993,7 @@ fn annotation_param_completion(doc: &Document, at: Position) -> Vec<CompletionIt
if typed.contains('}') || !typed.chars().all(|c| ident(c) || c == '.') {
continue;
}
let Some(start) = replace_start(at, typed) else { continue };
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
return params
.iter()
.filter(|p| p.name.starts_with(typed))
@@ -1552,89 +1477,4 @@ mod tests {
let items = n.completion(&closed, Position::new(7, col as u32), None);
assert_eq!(items.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(), ["userMap"]);
}
#[test]
fn positions_agree_with_the_sdk_in_any_order() {
let text = "a\n中文 😀 x\r\n\n é\nlast";
let mut positions = Positions::new(text);
let boundaries: Vec<usize> = (0..=text.len()).filter(|&i| text.is_char_boundary(i)).collect();
let order = boundaries.iter().chain(boundaries.iter().rev()).chain(boundaries.iter().step_by(3).rev());
for &at in order.chain([0, text.len(), 5, 1, text.len()].iter()) {
assert_eq!(positions.of(at), position(text, at), "at {at}");
}
}
/// Text a mapper may hold (or be crafted to hold) must not crash the plugin.
#[test]
fn odd_java_does_not_panic() {
// A no-break space before the name: the char before it is 2 bytes.
let java = parse_java("package a;\ninterface M {\n User\u{00A0}findById(long id);\n}\n");
assert_eq!(java.methods.iter().map(|(m, _)| m.name.as_str()).collect::<Vec<_>>(), ["findById"]);
// A non-ASCII identifier among `@Results` arguments.
let java = parse_java("interface M { @Results(é) User f(); @Results(id = 常量.ID, x = \"y\") User g(); }");
assert_eq!(java.methods.len(), 2);
// A string the annotation scanner reads as a char literal, ending in `\`.
for text in [
"package a;\ninterface M {\n @Select('\"' \\) void f();\n}",
"interface M { @Select(/*\"*/ \\) void f(); }",
] {
parse_java(text);
}
assert_eq!(skip_string(b"\"a\\", 0), 3);
}
#[test]
fn a_tag_without_its_end_stops_at_the_next() {
let xml = parse_xml("<mapper namespace=\"a.B\"\n<select id=\"x\"\n<sql id=\"y\"/>");
assert_eq!(xml.namespace.map(|n| n.name).as_deref(), Some("a.B"));
assert_eq!(xml.statements.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["x"]);
assert_eq!(xml.sql_fragments[0].range, Range::new(Position::new(2, 9), Position::new(2, 10)));
// Many unclosed tags parse in one pass, not one pass per tag.
let many = "<select id=\"a\" ".repeat(50_000);
assert_eq!(parse_xml(&many).statements.len(), 50_000);
}
#[test]
fn completion_before_an_unclosed_parameter_on_an_earlier_line() {
let _host = setup();
let text = "<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\">\n where id = #{i\nx";
let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", text);
assert!(Navigator::default().completion(&xml, Position::new(3, 0), None).is_empty());
}
#[test]
fn namespaces_are_searched_and_globbed_literally() {
let pattern = namespace_query("a|b.x[0]").pattern;
assert!(pattern.contains(r"a\|b\.x\[0\]"), "{pattern}");
let _host = setup();
let project = host::projects().unwrap().remove(0);
assert!(Navigator::java_for(&project, "com.acme.UserMapper").is_some());
assert!(Navigator::java_for(&project, "com.*.UserMapper").is_none());
assert!(Navigator::java_for(&project, "").is_none());
}
#[test]
fn a_project_contains_only_its_own_paths() {
assert!(is_under("/proj/a.xml", "/proj"));
assert!(is_under("/proj/a.xml", "/proj/"));
assert!(is_under("C:\\proj\\a.xml", "C:\\proj"));
assert!(is_under("/proj", "/proj"));
assert!(!is_under("/proj-x/a.xml", "/proj"));
}
#[test]
fn a_file_naming_many_mappers_looks_up_a_few() {
let host = MockHost::install();
let searches = std::rc::Rc::new(std::cell::Cell::new(0));
let count = searches.clone();
host.on_search(move |_, _| {
count.set(count.get() + 1);
Ok(TextSearch { matches: Vec::new(), limit_hit: false })
});
let refs: String = (0..200).map(|n| format!(" <include refid=\"ns{n}.x\"/>\n")).collect();
let text = format!("<mapper namespace=\"a.Self\">\n{refs}</mapper>\n");
let xml = doc("/project/src/m.xml", "xml", &text);
assert!(Navigator::default().line_markers(&xml).is_empty());
assert_eq!(searches.get(), MAX_OTHER_MAPPERS);
}
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
d5ec377869ded4ec0bf533d07d51610aec7a6813d43ead98a261b3f54e7d0655
b7b4e6be26ff08cc0e7fa791cb956acd9afa09905720d2c0c93acdae5638a35e
+1 -24
View File
@@ -94,23 +94,7 @@ pub fn evaluate(run: &Run) -> Value {
}
}
let replaced = run.replace.as_ref().map(|r| {
// Built match by match and cut off at the limit: `replace_all` would
// first build all of it (an empty match at every character, each
// replaced with a long text, runs out of memory).
let mut out = String::new();
let mut last = 0;
for caps in re.captures_iter(&run.text) {
let m = caps.get(0).expect("group 0");
out.push_str(&run.text[last..m.start()]);
caps.expand(r, &mut out);
last = m.end();
if out.len() > MAX_REPLACED {
break;
}
}
if out.len() <= MAX_REPLACED {
out.push_str(&run.text[last..]);
}
let mut out = re.replace_all(&run.text, r.as_str()).into_owned();
if out.len() > MAX_REPLACED {
let mut cut = MAX_REPLACED;
while !out.is_char_boundary(cut) {
@@ -257,13 +241,6 @@ mod tests {
assert!(bad["error"].as_str().unwrap().contains("unclosed"), "{bad}");
assert_eq!(bad["count"], 0);
assert_eq!(run("", "", "x", None)["count"], 0);
let long = run("(?:)", "", &"a".repeat(100_000), Some(&"b".repeat(10_000)));
let replaced = long["replaced"].as_str().unwrap();
assert!(replaced.len() <= MAX_REPLACED + '…'.len_utf8() && replaced.ends_with('…'));
for (pattern, text, with) in [("a*", "baac", "[$0]"), (r"(\w)(\d)", "a1 b2 c", "$2$1"), ("x", "y", "z")] {
let expected = regex::Regex::new(pattern).unwrap().replace_all(text, with).into_owned();
assert_eq!(run(pattern, "", text, Some(with))["replaced"], expected);
}
}
#[test]
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ebbd24e08c19953895464567cbaf1335b9847e98e8a8da8a4e0b8c2886c1abd6
4f585c3a1a3ae01a89811e05d27fc39121dd8e74210c1281d04ff31556d393b5
+4 -24
View File
@@ -347,8 +347,6 @@ fn highlights(text: &str) -> Vec<Token> {
/// An open `{`: the symbol whose body it is, and what may be declared inside.
struct Frame {
symbol: Option<usize>,
/// The innermost symbol of this frame or the ones around it.
parent: Option<usize>,
body: Body,
}
@@ -368,7 +366,7 @@ fn outline(text: &str) -> Vec<Symbol> {
let code: Vec<Lexeme> = lex(text).into_iter().filter(|l| !l.is_trivia()).collect();
let word = |i: usize| code.get(i).map_or("", |l| l.text(text));
let mut symbols: Vec<Symbol> = Vec::new();
let mut frames = vec![Frame { symbol: None, parent: None, body: Body::File }];
let mut frames = vec![Frame { symbol: None, body: Body::File }];
// A declaration waiting for its body (`{`) or its end (`;`).
let mut pending: Option<(usize, Body)> = None;
let mut stmt_start = 0usize;
@@ -377,9 +375,7 @@ fn outline(text: &str) -> Vec<Symbol> {
let l = code[i];
let w = l.text(text);
let body = frames.last().map_or(Body::File, |f| f.body);
// From the top frame alone: walking every open `{` per token is
// quadratic in deeply nested code.
let parent = frames.last().and_then(|f| f.parent);
let parent = frames.iter().rev().find_map(|f| f.symbol);
let add = |symbols: &mut Vec<Symbol>, name_at: usize, kind: SymbolKind, detail: Option<&str>| {
let name = code[name_at];
symbols.push(Symbol {
@@ -399,7 +395,7 @@ fn outline(text: &str) -> Vec<Symbol> {
Some((s, b)) => (Some(s), b),
None => (None, if body == Body::Code || body == Body::Contract { Body::Code } else { body }),
};
frames.push(Frame { symbol, parent: symbol.or(parent), body: inner });
frames.push(Frame { symbol, body: inner });
stmt_start = i + 1;
}
(Kind::Punct, "}") => {
@@ -516,8 +512,7 @@ fn outline(text: &str) -> Vec<Symbol> {
fn folding(text: &str) -> Vec<Fold> {
let lexemes = lex(text);
let newlines: Vec<usize> = text.bytes().enumerate().filter(|&(_, b)| b == b'\n').map(|(i, _)| i).collect();
let line_of = |offset: usize| newlines.partition_point(|&n| n < offset) as u32;
let line_of = |offset: usize| text[..offset].bytes().filter(|&b| b == b'\n').count() as u32;
let skip: Vec<Range<usize>> = lexemes
.iter()
.filter(|l| matches!(l.kind, Kind::Str | Kind::Comment | Kind::DocComment))
@@ -691,19 +686,4 @@ function free(uint x) pure returns (uint) { return x; }
let _ = folding(text);
}
}
/// Deep nesting and many comments stay about linear (both used to be quadratic).
#[test]
fn large_inputs_stay_linear() {
let deep = format!("contract C {{ function f() public {{ {} }} }}", "{".repeat(200_000));
let symbols = outline(&deep);
assert_eq!(
symbols.iter().map(|s| (s.name.as_str(), s.parent)).collect::<Vec<_>>(),
[("C", None), ("f", Some(0))]
);
let comments = "/* a\n */\n".repeat(100_000);
let folds = folding(&comments);
assert_eq!(folds.len(), 100_000);
assert_eq!((folds[99_999].start_line, folds[99_999].end_line), (199_998, 199_999));
}
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
997e2ac7520a561f22d12ad7013ed576262424ffa8acd69fab219f68d00c92f1
81637f9a265e6bcb9839b1019b0d58b43f80535fb1fca044716904aef0c12724
+2 -7
View File
@@ -230,8 +230,7 @@ fn step_numbers(s: &str, by: i64) -> String {
out.pop(); // the minus sign
}
let width = digits.len();
let text =
if next < 0 { format!("-{:0width$}", next.unsigned_abs()) } else { format!("{next:0width$}") };
let text = if next < 0 { format!("-{:0width$}", -next) } else { format!("{next:0width$}") };
out.push_str(&text);
}
Err(_) => out.push_str(&digits),
@@ -339,8 +338,7 @@ fn html_unescape(s: &str) -> String {
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
rest = &rest[i..];
// `;` is ASCII, so where it is found is a char boundary.
let Some(end) = rest.bytes().take(12).position(|b| b == b';') else {
let Some(end) = rest[..rest.len().min(12)].find(';') else {
out.push('&');
rest = &rest[1..];
continue;
@@ -418,7 +416,6 @@ mod tests {
assert_eq!(base64_encode(b"Man"), "TWFu");
assert_eq!(url_encode("a b&c"), "a%20b%26c");
assert_eq!(html_unescape("&lt;a&gt; &#x41;&#66; &bogus; &"), "<a> AB &bogus; &");
assert_eq!(html_unescape("R&D部门的预算 &aaaaaaaaaa€"), "R&D部门的预算 &aaaaaaaaaa€");
assert_eq!(json_escape("a\"b\n"), "a\\\"b\\n");
}
@@ -429,8 +426,6 @@ mod tests {
assert_eq!(t("unique-lines", "a\nb\na"), "a\nb");
assert_eq!(t("increment", "v1.09 and -1"), "v2.10 and 0");
assert_eq!(t("decrement", "x0"), "x-1");
assert_eq!(t("decrement", "-9223372036854775807"), "-9223372036854775808");
assert_eq!(t("decrement", "-9223372036854775808"), "-9223372036854775808");
}
#[test]
+1 -3
View File
@@ -135,9 +135,7 @@ interface editor {
/// Applies versioned edits to an open project document from a background
/// event. The path is project-relative and requires `project.read` and
/// `editor.write`. The host refuses VCS/IDE control directories and every
/// symlink, junction or reparse-point component before it finds the open
/// document. Stale versions are rejected without changing text.
/// `editor.write`. Stale versions are rejected without changing text.
/// These background edits are non-undoable and are not re-emitted as
/// `document-changed` events.
apply-edits-at: func(project: borrow<project>, path: string, version: u64, edits: list<text-edit>) -> result<u64, error>;
-1
View File
@@ -458,7 +458,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tools::tools_list,
tools::tools_install,
tools::tools_uninstall,
tools::tools_updates,
tools::jdk_download_vendors,
tools::jdk_download_releases,
tools::jdk_download_location,
+1 -13
View File
@@ -6,7 +6,7 @@ use ide_core::{IdeResult, RequestId};
pub use ide_host::ops::tools::*;
use ide_sdk::Sdk;
use ide_tools::jdk::{JdkReleases, JdkVendor};
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate};
use ide_tools::{InstallEvent, ToolInfo};
use std::path::PathBuf;
use tauri::ipc::Channel;
use tauri::{State, Window};
@@ -33,18 +33,6 @@ pub async fn tools_install(
.await
}
/// Newer versions of the tools the IDE installed (release downloads and npm packages).
/// Cancel with `cancel_request(request)`.
#[tauri::command]
#[specta::specta]
pub async fn tools_updates(
window: Window,
state: State<'_, AppState>,
request: RequestId,
) -> IdeResult<Vec<ToolUpdate>> {
ide_host::ops::tools::tools_updates(&state.host, window.label(), request).await
}
/// Removes a tool the IDE installed.
#[tauri::command]
#[specta::specta]
-14
View File
@@ -966,11 +966,6 @@ export const commands = {
toolsInstall: (name: string, request: RequestId, onEvent: Channel<InstallEvent>) => typedError<ToolInfo, IdeError>(__TAURI_INVOKE("tools_install", { name, request, onEvent })),
/** Removes a tool the IDE installed. */
toolsUninstall: (name: string) => typedError<ToolInfo, IdeError>(__TAURI_INVOKE("tools_uninstall", { name })),
/**
* Newer versions of the tools the IDE installed (release downloads and npm packages).
* Cancel with `cancel_request(request)`.
*/
toolsUpdates: (request: RequestId) => typedError<ToolUpdate[], IdeError>(__TAURI_INVOKE("tools_updates", { request })),
/** JDK distributions Download JDK offers. */
jdkDownloadVendors: () => __TAURI_INVOKE<JdkVendor[]>("jdk_download_vendors"),
/** Java versions `vendor` publishes; cancellable with `cancel_request(request)`. */
@@ -10227,15 +10222,6 @@ export type ToolStatus =
/** The executable chosen in settings. */
"custom" | "missing";
/** A newer version of a tool the IDE installed, as its install source offers it now. */
export type ToolUpdate = {
name: string,
/** The installed version. */
current: string,
/** The version installing the tool again would get. */
latest: string,
};
export type TransferError = {
path: string,
message: string,
+4 -7
View File
@@ -95,12 +95,9 @@ import { readFileLines, withDocument } from "@/editor/lsp/files";
import { pathToCopy } from "@/platform/copyPath";
import { shortcutLabel } from "@/platform/keymap";
/**
* Text-editing actions: the editor has focus, or a popup (Find Action) runs them on it. Not a
* tool window's speed search: it opens a layer too, but the keys belong to its tree (⌘⌫).
*/
export const TEXT_WHEN = "editorTextFocus || textEditorActive && popupOpen && !toolWindowFocus";
const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
/** Text-editing actions: the editor has focus, or a popup (Find Action) runs them on it. */
export const TEXT_WHEN = "editorTextFocus || textEditorActive && popupOpen";
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
const TABS_WHEN = "editorTabsOpen && !toolWindowFocus";
function activeSlot(): EditorSlot | null {
@@ -669,7 +666,7 @@ export function registerEditorActions(): () => void {
id,
title,
category: "Edit",
when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen && !toolWindowFocus`,
when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen`,
run: ({ arg, ctx }) => {
const view = typeof arg === "string" || ctx.findBarFocus ? findPanelTarget(arg)?.view() : null;
if (view) {
@@ -1,38 +0,0 @@
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { braceBlock, braceNextTo } from "./bracketBlockSelect";
const DOC = "int f(Object raw) {\n if (a) {\n g(b[0]);\n }\n}";
const state = EditorState.create({ doc: DOC });
describe("double-clicking a curly brace", () => {
it("selects the block from the opening brace to its match, both included (IDEA)", () => {
const open = DOC.indexOf("{");
expect(braceBlock(state, open)).toEqual({ from: open, to: DOC.length });
});
it("selects the same block from the closing brace", () => {
const inner = DOC.indexOf("{", DOC.indexOf("if"));
const close = DOC.indexOf("}");
expect(braceBlock(state, close)).toEqual({ from: inner, to: close + 1 });
});
it("takes the brace next to the caret the click gives, past the end of the line too", () => {
const open = DOC.indexOf("{");
const lineEnd = state.doc.line(1).to;
expect(braceNextTo(state, lineEnd, false)).toBe(open);
expect(braceNextTo(state, open, false)).toBe(open);
const close = DOC.lastIndexOf("}");
expect(braceNextTo(state, close, false)).toBe(close);
// On a word, or away from braces, the double-click selects the word.
expect(braceNextTo(state, open, true)).toBeNull();
expect(braceNextTo(state, DOC.indexOf("raw"), false)).toBeNull();
});
it("leaves parentheses, brackets, other characters and unmatched braces to the word selection", () => {
expect(braceBlock(state, DOC.indexOf("("))).toBeNull();
expect(braceBlock(state, DOC.indexOf("["))).toBeNull();
expect(braceBlock(state, DOC.indexOf("raw"))).toBeNull();
expect(braceBlock(EditorState.create({ doc: "f {" }), 2)).toBeNull();
});
});
-53
View File
@@ -1,53 +0,0 @@
/**
* A double-click that puts the caret next to a curly brace selects the block it opens or
* closes, both braces included, as IDEA does: past the end of a line ending in `{` too.
* A double-click on a word still selects the word.
*/
import { matchBrackets } from "@codemirror/language";
import { EditorSelection, Prec, type EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
/** The range from the brace at `at` to its match, both included, or null when it has none. */
export function braceBlock(state: EditorState, at: number): { from: number; to: number } | null {
const ch = state.sliceDoc(at, at + 1);
if (ch !== "{" && ch !== "}") return null;
const match = ch === "{" ? matchBrackets(state, at, 1) : matchBrackets(state, at + 1, -1);
if (!match?.matched || !match.end) return null;
return { from: Math.min(match.start.from, match.end.from), to: Math.max(match.start.to, match.end.to) };
}
const isBrace = (ch: string) => ch === "{" || ch === "}";
/**
* The brace next to the caret position `pos` a click at `x` gives, unless the click is on a
* word character (that double-click selects the word).
*/
export function braceNextTo(state: EditorState, pos: number, onWord: boolean): number | null {
if (onWord) return null;
if (pos > 0 && isBrace(state.sliceDoc(pos - 1, pos))) return pos - 1;
if (isBrace(state.sliceDoc(pos, pos + 1))) return pos;
return null;
}
/** Whether the click at `x` is on a word character around `pos`. */
function clickOnWord(view: EditorView, pos: number, x: number): boolean {
for (const at of [pos - 1, pos]) {
if (at < 0 || at >= view.state.doc.length) continue;
const box = view.coordsForChar(at);
if (box && x >= box.left && x <= box.right) return /[\p{L}\p{N}_$]/u.test(view.state.sliceDoc(at, at + 1));
}
return false;
}
export const bracketBlockSelect = Prec.high(
EditorView.mouseSelectionStyle.of((view, event) => {
if (event.detail !== 2 || event.button !== 0 || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
return null;
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
const at = pos == null ? null : braceNextTo(view.state, pos, clickOnWord(view, pos, event.clientX));
const block = at == null ? null : braceBlock(view.state, at);
if (!block) return null;
const selection = EditorSelection.single(block.from, block.to);
return { get: () => selection, update: () => false };
}),
);
+10 -58
View File
@@ -5,7 +5,7 @@ import { indentUnit } from "@codemirror/language";
import { codeLenses } from "@/editor/api";
import type { EditorSettings } from "@/bindings";
import { codeLensExtension } from "./codeLens";
import { braceGuide, caretGuide, guideVars, indentGuides, rainbowIndentGuides } from "./indentGuides";
import { guideVars, indentGuides, rainbowIndentGuides } from "./indentGuides";
import { heavyExtension } from "./setup";
const SOURCE = [
@@ -46,23 +46,23 @@ describe("indent guides", () => {
expect(guideVars(1, 4)).toBeNull();
expect(guideVars(3, 4)).toEqual({
images: "var(--openidea-guide), var(--openidea-guide)",
positions: "calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
positions: "calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
});
});
it("positions each guide the same way on lines of different depth", () => {
const v = mount(SOURCE);
expect(line(v, 1).classList.contains("cm-openidea-guides")).toBe(false);
expect(line(v, 2).style.getPropertyValue("--openidea-guide-positions")).toBe("calc(0.5px + 4ch) 0");
expect(line(v, 2).style.getPropertyValue("--openidea-guide-positions")).toBe("calc(2.5px + 4ch) 0");
expect(line(v, 3).style.getPropertyValue("--openidea-guide-positions")).toBe(
"calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
"calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
);
});
it("keeps guides through blank lines inside a block", () => {
const v = mount(SOURCE);
expect(line(v, 4).style.getPropertyValue("--openidea-guide-positions")).toBe(
"calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
"calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
);
});
@@ -77,66 +77,18 @@ describe("indent guides", () => {
]);
const [implLens, fnLens] = [...v.contentDOM.querySelectorAll<HTMLElement>(".cm-openidea-lens")];
expect(v.contentDOM.classList.contains("cm-openidea-guidesOn")).toBe(true);
expect(fnLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("calc(0.5px + 4ch) 0");
expect(fnLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("calc(2.5px + 4ch) 0");
expect(implLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("");
});
});
describe("the caret's indent guide", () => {
const at = (lineNo: number, col: number) => {
const state = EditorState.create({ doc: SOURCE });
return state.update({ selection: { anchor: state.doc.line(lineNo).from + col } }).state;
};
it("is the guide in the caret's column whose block holds the caret line (IDEA)", () => {
// `for` opens the guide at column 4, drawn on lines 3-7 (`if` … its `}`).
expect(caretGuide(at(2, 4), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(caretGuide(at(4, 4), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(caretGuide(at(4, 8), 4)).toEqual({ level: 2, from: 4, to: 6 });
});
it("is none in column 0, between indent units, in text, or on the closing line", () => {
expect(caretGuide(at(4, 0), 4)).toBeNull();
expect(caretGuide(at(4, 6), 4)).toBeNull();
expect(caretGuide(at(2, 12), 4)).toBeNull();
expect(caretGuide(at(8, 4), 4)).toBeNull();
});
it("is the matched brace colour's guide while the caret is at a brace of a multi-line block", () => {
const state = (pos: number) => EditorState.create({ doc: SOURCE, selection: { anchor: pos } });
const forOpen = SOURCE.indexOf("{", SOURCE.indexOf("for"));
// `for x in y {`: its guide is at column 4, on lines 3-7.
expect(braceGuide(state(forOpen + 1), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(braceGuide(state(forOpen), 4)).toEqual({ level: 1, from: 3, to: 7 });
const forClose = SOURCE.lastIndexOf(" }") + 4;
expect(braceGuide(state(forClose + 1), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(braceGuide(state(SOURCE.indexOf("push")), 4)).toBeNull();
expect(braceGuide(EditorState.create({ doc: "a {\n b { c }\n}", selection: { anchor: 9 } }), 4)).toBeNull();
});
it("draws the brace guide over the caret's", () => {
const v = mount(SOURCE);
v.dispatch({ selection: { anchor: SOURCE.indexOf("{", SOURCE.indexOf("for")) + 1 } });
expect(images(line(v, 3))).toBe("var(--openidea-guide-brace), var(--openidea-guide)");
});
it("draws that guide on its lines in the selected colour", () => {
const v = mount(SOURCE);
v.dispatch({ selection: { anchor: v.state.doc.line(4).from + 8 } });
expect(images(line(v, 3))).toBe("var(--openidea-guide), var(--openidea-guide-active)");
expect(images(line(v, 6))).toBe("var(--openidea-guide)");
v.dispatch({ selection: { anchor: v.state.doc.line(4).from + 12 } });
expect(images(line(v, 3))).toBe("var(--openidea-guide), var(--openidea-guide)");
});
});
describe("rainbow indent guides", () => {
it("gives each depth its own image, cycling through the five colours", () => {
expect(guideVars(0, 4, true)).toBeNull();
expect(guideVars(1, 4, true)).toBeNull();
expect(guideVars(3, 4, true)).toEqual({
images: "var(--openidea-guide-1), var(--openidea-guide-2)",
positions: "calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
positions: "calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
});
const deep = guideVars(8, 2, true)!;
expect(deep.images.split(", ")).toEqual([1, 2, 3, 4, 5, 1, 2].map((n) => `var(--openidea-guide-${n})`));
@@ -156,7 +108,7 @@ describe("rainbow indent guides", () => {
expect(images(line(v, 2))).toBe("var(--openidea-guide-1)");
expect(images(line(v, 3))).toBe("var(--openidea-guide-1), var(--openidea-guide-2)");
expect(images(line(v, 4))).toBe("var(--openidea-guide-1), var(--openidea-guide-2)");
expect(positions(line(v, 4))).toBe("calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0");
expect(positions(line(v, 4))).toBe("calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0");
expect(images(line(v, 6))).toBe("var(--openidea-guide-1)");
});
@@ -170,7 +122,7 @@ describe("rainbow indent guides", () => {
spaces.destroy();
const tabs = mount(body("\t"), [EditorState.tabSize.of(4)], rainbowIndentGuides, "\t");
expect(images(deepest(tabs))).toBe(expected);
expect(positions(deepest(tabs)).split(", ")[1]).toBe("calc(0.5px + 8ch) 0");
expect(positions(deepest(tabs)).split(", ")[1]).toBe("calc(2.5px + 8ch) 0");
});
it("does not count a partial indent unit as a level", () => {
@@ -211,7 +163,7 @@ describe("rainbow indent guides", () => {
});
const lens = () => view!.contentDOM.querySelector<HTMLElement>(".cm-openidea-lens")!;
expect(images(lens())).toBe("var(--openidea-guide-1), var(--openidea-guide-2)");
expect(positions(lens())).toBe("calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0");
expect(positions(lens())).toBe("calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0");
view.dispatch({ effects: guides.reconfigure(indentGuides) });
expect(images(lens())).toBe("var(--openidea-guide), var(--openidea-guide)");
});
+12 -99
View File
@@ -6,17 +6,13 @@
* so a guide stays straight across lines of different depth. Blank lines take
* the smaller level of their neighbours and code lens rows the level of the
* line they annotate (`guideVars`), so guides stay continuous. With rainbow
* guides each depth gets its own image, coloured from `--syn-rainbow-*`. The
* guide the caret is on takes the selected colour (`caretGuide`).
* guides each depth gets its own image, coloured from `--syn-rainbow-*`.
*/
import { countColumn, Facet, RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
import { Facet, RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { getIndentUnit, matchBrackets } from "@codemirror/language";
import { getIndentUnit } from "@codemirror/language";
import { LINE_INSET } from "./lineInset";
/** How far (px) a guide sits left of its column, so a caret in that column is right of it (IDEA's indent guide shift). */
const GUIDE_SHIFT = 2;
/** Number of `--openidea-guide-<n>` rainbow colours; deeper guides repeat them. */
export const GUIDE_COLORS = 5;
@@ -26,41 +22,26 @@ export const rainbowGuides = Facet.define<boolean, boolean>({ combine: (values)
/**
* Values of `--openidea-guide-images` / `--openidea-guide-positions` for an
* indentation `levels` deep (in units of `step` columns), or null when it has
* no guides; the guide at level `active` takes the selected colour, the one at
* level `brace` the matched brace one. `.cm-openidea-guides` lines and code lens
* rows turn them into backgrounds (theme.ts).
* no guides. `.cm-openidea-guides` lines and code lens rows turn them into
* backgrounds (theme.ts).
*/
export function guideVars(
levels: number,
step: number,
rainbow = false,
active = 0,
brace = 0,
): { images: string; positions: string } | null {
export function guideVars(levels: number, step: number, rainbow = false): { images: string; positions: string } | null {
if (levels < 2) return null;
const images: string[] = [];
const positions: string[] = [];
for (let level = 1; level < levels; level++) {
images.push(
level === brace
? "var(--openidea-guide-brace)"
: level === active
? "var(--openidea-guide-active)"
: rainbow
? `var(--openidea-guide-${((level - 1) % GUIDE_COLORS) + 1})`
: "var(--openidea-guide)",
);
positions.push(`calc(${LINE_INSET - GUIDE_SHIFT}px + ${level * step}ch) 0`);
images.push(rainbow ? `var(--openidea-guide-${((level - 1) % GUIDE_COLORS) + 1})` : "var(--openidea-guide)");
positions.push(`calc(${LINE_INSET}px + ${level * step}ch) 0`);
}
return { images: images.join(", "), positions: positions.join(", ") };
}
const cache = new Map<string, Decoration | null>();
function guideDeco(levels: number, step: number, rainbow: boolean, active: number, brace: number) {
const key = `${levels}:${step}:${rainbow ? 1 : 0}:${active}:${brace}`;
function guideDeco(levels: number, step: number, rainbow: boolean) {
const key = `${levels}:${step}:${rainbow ? 1 : 0}`;
let d = cache.get(key);
if (d === undefined) {
const vars = guideVars(levels, step, rainbow, active, brace);
const vars = guideVars(levels, step, rainbow);
const style = vars && `--openidea-guide-images: ${vars.images}; --openidea-guide-positions: ${vars.positions}`;
d = style ? Decoration.line({ attributes: { class: "cm-openidea-guides", style } }) : null;
cache.set(key, d);
@@ -90,77 +71,10 @@ function levelOf(state: EditorState, lineNo: number, step: number, dir: 1 | -1):
return 0;
}
const BLOCK_SCAN = 10_000;
/** A guide: its level (in indent units) and the lines (`from`..`to`) it is drawn on. */
export interface Guide {
level: number;
from: number;
to: number;
}
/** The guide line `start` opens at column `col` (its indentation): down to the last deeper line. */
function blockGuide(state: EditorState, start: number, col: number, step: number): Guide | null {
if (col === 0 || col % step) return null;
let end = start;
for (let n = start + 1; n <= state.doc.lines && n - start < BLOCK_SCAN; n++) {
const cols = indentColumns(state.doc.line(n).text, state.tabSize);
if (cols === null) continue;
if (cols <= col) break;
end = n;
}
return end === start ? null : { level: col / step, from: start + 1, to: end };
}
/**
* The guide the caret is on, as IDEA's `IndentsModel.getCaretIndentGuide`: the caret's
* column is the guide's and its line lies in the guide's block, from the line opening it
* to the last deeper one.
*/
export function caretGuide(state: EditorState, step: number): Guide | null {
const head = state.selection.main.head;
const caretLine = state.doc.lineAt(head);
const col = countColumn(caretLine.text.slice(0, head - caretLine.from), state.tabSize);
if (col === 0 || col % step) return null;
let start = caretLine.number;
for (let i = 0; ; start--, i++) {
if (start < 1 || i > BLOCK_SCAN) return null;
const cols = indentColumns(state.doc.line(start).text, state.tabSize);
if (cols === null || cols > col) continue;
if (cols !== col) return null;
break;
}
const guide = blockGuide(state, start, col, step);
return guide && caretLine.number <= guide.to ? guide : null;
}
/**
* The guide of the block whose braces the caret is at, while they match on different lines:
* IDEA draws it in the matched brace colour (`MATCHED_BRACES_INDENT_GUIDE_COLOR`).
*/
export function braceGuide(state: EditorState, step: number): Guide | null {
const head = state.selection.main.head;
const match =
matchBrackets(state, head, -1) ??
(head > 0 ? matchBrackets(state, head - 1, 1) : null) ??
matchBrackets(state, head, 1) ??
(head < state.doc.length ? matchBrackets(state, head + 1, -1) : null);
if (!match?.matched || !match.end) return null;
const open = Math.min(match.start.from, match.end.from);
if (state.sliceDoc(open, open + 1) !== "{") return null;
const openLine = state.doc.lineAt(open);
if (openLine.number === state.doc.lineAt(Math.max(match.start.from, match.end.from)).number) return null;
const col = indentColumns(openLine.text, state.tabSize) ?? 0;
return blockGuide(state, openLine.number, col, step);
}
function build(view: EditorView): DecorationSet {
const state = view.state;
const step = Math.max(1, getIndentUnit(state));
const rainbow = state.facet(rainbowGuides);
const caret = caretGuide(state, step);
const brace = braceGuide(state, step);
const on = (g: Guide | null, n: number) => (g && n >= g.from && n <= g.to ? g.level : 0);
const b = new RangeSetBuilder<Decoration>();
for (const { from, to } of view.visibleRanges) {
let pos = from;
@@ -174,7 +88,7 @@ function build(view: EditorView): DecorationSet {
} else {
levels = Math.floor(cols / step);
}
const deco = guideDeco(levels, step, rainbow, on(caret, line.number), on(brace, line.number));
const deco = guideDeco(levels, step, rainbow);
if (deco) b.add(line.from, line.from, deco);
}
pos = line.to + 1;
@@ -193,7 +107,6 @@ export const indentGuides: Extension = [
update(u: ViewUpdate) {
if (
u.docChanged ||
u.selectionSet ||
u.viewportChanged ||
u.startState.tabSize !== u.state.tabSize ||
u.startState.facet(rainbowGuides) !== u.state.facet(rainbowGuides)
-2
View File
@@ -47,7 +47,6 @@ import { caretExtension, fontZoom, methodLinesField, prefsExtension, softWrapAct
import { commentTokens } from "./languages";
import { multiCaretMouse } from "./multiCaret";
import { columnModeExtension } from "./columnMode";
import { bracketBlockSelect } from "./bracketBlockSelect";
import { visibleGoalColumns } from "./verticalMotion";
import { tabKeymap } from "./smartKeys";
import { editorBaseKeymap } from "./baseKeymap";
@@ -377,7 +376,6 @@ export function baseExtensions(o: SetupOptions): Extension {
EditorState.allowMultipleSelections.of(true),
multiCaretMouse,
columnModeExtension,
bracketBlockSelect,
visibleGoalColumns,
occurrenceSearch,
overwriteField,
-7
View File
@@ -137,13 +137,6 @@ export const openideaTheme = EditorView.theme({
"var(--bg-editor-selection-inactive, color-mix(in srgb, var(--accent) 14%, transparent))",
"--editor-indent-guide": "var(--border-indent-guide, var(--border))",
"--openidea-guide": "linear-gradient(var(--editor-indent-guide), var(--editor-indent-guide))",
// The guide the caret is on (IDEA's Selected indent guide).
"--editor-indent-guide-active": "var(--border-indent-guide-active, var(--editor-indent-guide))",
"--openidea-guide-active": "linear-gradient(var(--editor-indent-guide-active), var(--editor-indent-guide-active))",
// The guide of the block whose braces match at the caret (IDEA's Matched braces indent guide).
"--editor-indent-guide-brace":
"var(--border-indent-guide-brace, var(--bg-matched-brace, var(--editor-indent-guide-active)))",
"--openidea-guide-brace": "linear-gradient(var(--editor-indent-guide-brace), var(--editor-indent-guide-brace))",
// Rainbow indent guides: one image per depth, so they follow the colour scheme.
...Object.fromEntries(
Array.from({ length: GUIDE_COLORS }, (_, i) => {
+1 -20
View File
@@ -9,10 +9,9 @@ import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { useRemote } from "@/platform/remote";
import { hideContextMenu, resolveMenuItems, useContextMenuStore } from "@/ui";
import { evaluateWhen, getContext, setContext } from "@/platform/context";
import { getContext, setContext } from "@/platform/context";
import { installKeyboardDispatcher } from "@/platform/keymap";
import { TEXT_WHEN } from "./actions";
import { EDITOR_WHEN } from "@/editor/lsp/actions";
let ctrl: EditorController | null = null;
@@ -465,21 +464,3 @@ describe("EditorController", () => {
}
});
});
describe("editor action when clauses", () => {
const editorBehind = { textEditorActive: true, editorTextFocus: false, editorFocus: false, popupOpen: true };
it.each([
["TEXT_WHEN", TEXT_WHEN],
["EDITOR_WHEN", EDITOR_WHEN],
])("%s runs on the editor from a popup such as Find Action", (_, when) => {
expect(evaluateWhen(when, { ...editorBehind, toolWindowFocus: false })).toBe(true);
});
it.each([
["TEXT_WHEN", TEXT_WHEN],
["EDITOR_WHEN", EDITOR_WHEN],
])("%s leaves keys to a tool window's speed search, e.g. ⌘⌫ to Delete Line", (_, when) => {
expect(evaluateWhen(when, { ...editorBehind, toolWindowFocus: true })).toBe(false);
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ import {
import { evaluateWhen } from "@/platform/context";
/** Editor actions apply to the active text editor unless a tool window or popup owns the keyboard. */
export const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
export const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
/**
* Languages whose servers offer Generate (constructors, accessors, overrides…).
+1 -76
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { OutlineItem, References } from "@/platform/ipc";
import { _addEditor, _emitEditorEvent, _removeEditor, codeLenses, documentSynced, viewShown } from "@/editor/api";
import { _addEditor, _removeEditor, codeLenses, documentSynced } from "@/editor/api";
const ipc = vi.hoisted(() => ({
docOutline: vi.fn(),
@@ -264,78 +264,3 @@ describe("background usage counts", () => {
}
});
});
describe("recounting when the project's documents change", () => {
it("recounts when another document is edited (the call sites)", async () => {
const view = editor();
await settle();
expect(titles(view)).toEqual(["2 usages"]);
_emitEditorEvent({ type: "docChanged", doc: 9, path: "/p/b.rs", baseVersion: 1, version: 2, changes: [] });
await settle();
expect(ipc.lspReferences).toHaveBeenCalledTimes(2);
ipc.lspReferences.mockResolvedValue(refs(5));
_emitEditorEvent({ type: "docChanged", doc: 9, path: "/p/b.rs", baseVersion: 2, version: 3, changes: [] });
await settle();
expect(titles(view)).toEqual(["5 usages"]);
});
it("recounts when a kept tab is shown again after another document changed while hidden", async () => {
const view = editor();
await settle();
expect(titles(view)).toEqual(["2 usages"]);
ipc.lspReferences.mockResolvedValue(refs(5));
// A recently used tab keeps its view, detached from the document, while hidden.
view.dom.remove();
_emitEditorEvent({ type: "docChanged", doc: 9, path: "/p/b.rs", baseVersion: 1, version: 2, changes: [] });
await settle();
expect(ipc.lspReferences).toHaveBeenCalledTimes(1);
document.body.append(view.dom);
view.dispatch({ effects: viewShown.of(null) });
await settle();
expect(titles(view)).toEqual(["5 usages"]);
});
it("recounts when the backend patches another document (a refactoring)", async () => {
const view = editor();
await settle();
expect(titles(view)).toEqual(["2 usages"]);
ipc.lspReferences.mockResolvedValue(refs(3));
emitLocalIdeEvent({ type: "docPatched", doc: 9, baseVersion: 1, version: 2, changes: [] });
await settle();
expect(titles(view)).toEqual(["3 usages"]);
});
it("leaves a backend patch of this document to the document change itself", async () => {
editor();
await settle();
const calls = ipc.lspReferences.mock.calls.length;
emitLocalIdeEvent({ type: "docPatched", doc: 7, baseVersion: 1, version: 2, changes: [] });
await settle();
expect(ipc.lspReferences).toHaveBeenCalledTimes(calls);
});
it("recounts when the plugin document indexes change (an XML statement indexed after its declaration)", async () => {
const view = editor();
await settle();
expect(titles(view)).toEqual(["2 usages"]);
ipc.lspReferences.mockResolvedValue(refs(5));
const { invalidateNavigationProviders } = await import("@/editor/navigationProviders");
invalidateNavigationProviders();
await settle();
expect(titles(view)).toEqual(["5 usages"]);
});
it("recounts when files change on disk or another document closes", async () => {
const view = editor();
await settle();
expect(titles(view)).toEqual(["2 usages"]);
ipc.lspReferences.mockResolvedValue(refs(3));
emitLocalIdeEvent({ type: "fsChanged", created: [], modified: ["/p/b.rs"], removed: [] });
await settle();
expect(titles(view)).toEqual(["3 usages"]);
ipc.lspReferences.mockResolvedValue(refs(4));
_emitEditorEvent({ type: "docClosed", doc: 9, path: "/p/b.rs" });
await settle();
expect(titles(view)).toEqual(["4 usages"]);
});
});
+10 -46
View File
@@ -2,21 +2,15 @@
* "N usages" code lenses above declarations (IDEA's usages inlay). Cheap by
* construction: only declarations in (and near) the viewport are counted, one
* debounced pass after edits / scrolling, results cached per document text
* and symbol until the project's documents change, and at most a few reference
* requests in flight. Counts run the same references query the Find Usages
* panel runs (including the plugin indexers' anchors), scoped to what the
* server analyses: opening candidate files or the libraries for every count
* would trigger expensive server analysis, so only an explicit Find Usages
* widens the server's scope, and a widened search can find more. Beside each
* and symbol, and at most a few reference requests in flight. Beside each
* count, the declaration's last author from VCS blame (`authorLens`).
*/
import { Channel } from "@tauri-apps/api/core";
import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { codeLenses, onEditorEvent, shownAgain, type CodeLens } from "@/editor/api";
import { onNavigationProvidersChanged } from "@/editor/navigationProviders";
import { codeLenses, shownAgain, type CodeLens } from "@/editor/api";
import { executeAction, getAction } from "@/platform/actions";
import { commands, nextRequestId, onIdeEvent, type OutlineItem, type RequestId, type SymbolKind } from "@/platform/ipc";
import { commands, nextRequestId, type OutlineItem, type RequestId, type SymbolKind } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
import { posToOffset } from "./positions";
@@ -140,7 +134,6 @@ class UsagesLensPlugin {
private offReady: () => void;
private offSettled: () => void;
private offBlame: () => void;
private offChanges: () => void;
private skipped = false;
constructor(private view: EditorView) {
@@ -149,7 +142,12 @@ class UsagesLensPlugin {
if (s.settings !== prev.settings) this.refresh();
});
// Counts computed while the server was indexing may be incomplete.
this.offReady = onLspRefresh("codeLens", () => this.invalidate());
this.offReady = onLspRefresh("codeLens", () => {
this.cache.clear();
this.incomplete.clear();
this.retries.clear();
this.refresh();
});
this.offSettled = onServerSettled(() => {
if (!this.incomplete.size) return;
this.incomplete.clear();
@@ -157,43 +155,10 @@ class UsagesLensPlugin {
this.refresh();
});
this.offBlame = onBlameInvalidated(() => this.refresh());
// Counts cover the whole project, so a change anywhere can outdate them
// while this view is hidden and skips its passes: another document's edit
// (the call sites) or a closed unsaved buffer, a backend patch (a
// refactoring), a file changed on disk, or a plugin index batch (an XML
// statement is indexed after its Java declaration). This view's own edits
// already refresh through `update`.
const offDocs = onEditorEvent((e) => {
if (e.type === "docChanged" && e.doc !== docOf(this.view)?.doc) this.invalidate();
else if (e.type === "docClosed") this.invalidate();
});
const offPatches = onIdeEvent("docPatched", (e) => {
if (e.doc !== docOf(this.view)?.doc) this.invalidate();
});
const offBackend = [
onIdeEvent("workspaceEditApplied", () => this.invalidate()),
onIdeEvent("fsChanged", () => this.invalidate()),
onNavigationProvidersChanged(() => this.invalidate()),
];
this.offChanges = () => {
offDocs();
offPatches();
offBackend.forEach((off) => off());
};
}
/** Counts are cached until anything in the project may have changed them. */
private invalidate() {
this.cache.clear();
this.incomplete.clear();
this.retries.clear();
this.refresh();
}
update(u: ViewUpdate) {
// A re-shown tab ran nothing while hidden, and its counts may have been
// invalidated meanwhile; when they are cached the pass only redraws.
if (u.docChanged || u.viewportChanged || shownAgain(u)) this.refresh();
if (u.docChanged || u.viewportChanged || (this.skipped && shownAgain(u))) this.refresh();
}
private cancel() {
@@ -324,7 +289,6 @@ class UsagesLensPlugin {
this.offReady();
this.offSettled();
this.offBlame();
this.offChanges();
this.cancel();
}
}
+22 -120
View File
@@ -10,16 +10,7 @@ import {
* click), the multiple-targets chooser, and document highlights (occurrences
* of the symbol at the caret, plus the sticky find.highlightUsages variant).
*/
import {
countColumn,
EditorSelection,
Prec,
StateEffect,
StateField,
type Extension,
type Text,
} from "@codemirror/state";
import { LINE_INSET } from "@/editor/core/cm/lineInset";
import { EditorSelection, Prec, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import {
Decoration,
EditorView,
@@ -63,7 +54,6 @@ import {
SYNTAX_NOTE,
declarationFallback,
indexDefinitions,
isDeclarationName,
isSelfTarget,
localHighlights,
type DeclarationTarget,
@@ -361,15 +351,6 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
}
}
/** Whether the caret is on the name of a declaration, by the syntax tree (no language server wait). */
async function onDeclarationName(view: EditorView, offset: number): Promise<boolean> {
const doc = docOf(view);
if (!doc) return false;
const pos = offsetToPos(view.state.doc, offset);
const at = await structure.nodeAt(doc.doc, { start: pos, end: pos }).catch(() => null);
return isDeclarationName(at);
}
async function gotoResolved(
view: EditorView,
kind: GotoKind,
@@ -384,10 +365,7 @@ async function gotoResolved(
const before = view.state.doc;
const plugin = await contributedNavigation(view, offset).catch(() => null);
if (view.state.doc !== before || superseded()) return;
// ⌘B on a declaration shows its usages, also when a plugin links the name elsewhere (a
// MyBatis mapper method to its XML statement).
if (plugin && !(kind === "definition" && (await onDeclarationName(view, offset)))) {
if (superseded()) return;
if (plugin) {
if (plugin.targets.length === 1) await navigateTo(plugin.targets[0]!);
else {
const items = await locationItems(plugin.targets);
@@ -1053,8 +1031,6 @@ function navigateFromPointer(view: EditorView, e: MouseEvent): boolean {
interface HighlightState {
deco: DecorationSet;
/** The highlighted usages (the decorations also hold whole-line backgrounds). */
ranges: readonly { from: number; to: number }[];
sticky: boolean;
}
@@ -1062,94 +1038,31 @@ type HighlightItem = { from: number; to: number; kind: Highlight["kind"] };
const setHighlights = StateEffect.define<{ items: HighlightItem[]; sticky: boolean }>();
const OCCURRENCE = "cm-lsp-occurrence";
const KIND_CLASS = {
text: OCCURRENCE,
read: `${OCCURRENCE} ${OCCURRENCE}-read`,
write: `${OCCURRENCE} ${OCCURRENCE}-write`,
};
const marks = {
text: Decoration.mark({ class: KIND_CLASS.text }),
read: Decoration.mark({ class: KIND_CLASS.read }),
write: Decoration.mark({ class: KIND_CLASS.write }),
text: Decoration.mark({ class: "cm-lsp-occurrence" }),
read: Decoration.mark({ class: "cm-lsp-occurrence cm-lsp-occurrence-read" }),
write: Decoration.mark({ class: "cm-lsp-occurrence cm-lsp-occurrence-write" }),
};
const fullLines = {
text: Decoration.line({ class: KIND_CLASS.text }),
read: Decoration.line({ class: KIND_CLASS.read }),
write: Decoration.line({ class: KIND_CLASS.write }),
};
/**
* Highlights as IDEA paints a text range: one on a single line marks its text; one over
* several lines (a `return` statement's exit point) is a block, from its start to the right
* edge, whole lines between, and its last line up to its end.
*/
function highlightDeco(doc: Text, tabSize: number, items: readonly HighlightItem[]): DecorationSet {
const out = [];
for (const i of items) {
if (i.from >= i.to) continue;
const first = doc.lineAt(i.from);
const last = doc.lineAt(i.to);
if (first.number === last.number) {
out.push(marks[i.kind].range(i.from, i.to));
continue;
}
const col = countColumn(first.text.slice(0, i.from - first.from), tabSize);
const tail = Decoration.line({
class: i.kind === "write" ? "cm-lsp-occurrence-tail cm-lsp-occurrence-tail-write" : "cm-lsp-occurrence-tail",
attributes: { style: `--lsp-occurrence-from: calc(${LINE_INSET}px + ${col}ch)` },
});
out.push(tail.range(first.from));
for (let n = first.number + 1; n < last.number; n++) out.push(fullLines[i.kind].range(doc.line(n).from));
if (i.to > last.from) out.push(marks[i.kind].range(last.from, i.to));
}
return Decoration.set(out, true);
}
const highlightField = StateField.define<HighlightState>({
create: () => ({ deco: Decoration.none, ranges: [], sticky: false }),
create: () => ({ deco: Decoration.none, sticky: false }),
update(value, tr) {
for (const e of tr.effects)
if (e.is(setHighlights)) {
const items = e.value.items.filter((i) => i.from < i.to);
if (e.is(setHighlights))
return {
deco: highlightDeco(tr.state.doc, tr.state.tabSize, items),
ranges: items.map(({ from, to }) => ({ from, to })),
deco: Decoration.set(
e.value.items.filter((i) => i.from < i.to).map((i) => marks[i.kind].range(i.from, i.to)),
true,
),
sticky: e.value.sticky,
};
}
// Kept (mapped) until recomputed; ⌘⇧F7 highlights survive edits until Escape.
if (tr.docChanged)
return {
deco: value.deco.map(tr.changes),
ranges: value.ranges.map((r) => ({ from: tr.changes.mapPos(r.from, 1), to: tr.changes.mapPos(r.to, -1) })),
sticky: value.sticky,
};
if (tr.docChanged) return { deco: value.deco.map(tr.changes), sticky: value.sticky };
return value;
},
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
});
/**
* The latest usages found per editor, for its current document: a caret landing in one shows
* them at once while the server is asked again, as IDEA's highlighting follows the caret.
*/
const recentHighlights = new WeakMap<EditorView, { doc: Text; sets: HighlightItem[][] }>();
const RECENT_SETS = 8;
function rememberHighlights(view: EditorView, doc: Text, items: HighlightItem[]) {
if (!items.length) return;
let recent = recentHighlights.get(view);
if (recent?.doc !== doc) recentHighlights.set(view, (recent = { doc, sets: [] }));
recent.sets = [items, ...recent.sets].slice(0, RECENT_SETS);
}
function recentHighlightsAt(view: EditorView, doc: Text, head: number): HighlightItem[] | null {
const recent = recentHighlights.get(view);
if (recent?.doc !== doc) return null;
return recent.sets.find((set) => set.some((i) => i.from <= head && head <= i.to)) ?? null;
}
/** IDEA's status bar text while ⌘⇧F7 highlights are shown. */
export const useUsagesStatus = create<{ text: string | null; owner: EditorView | null }>(() => ({
text: null,
@@ -1179,7 +1092,6 @@ async function fetchHighlights(view: EditorView, sticky: boolean) {
const stale = () => view.state.doc !== state.doc || view.state.selection.main.head !== head;
const apply = (items: HighlightItem[]) => {
view.dispatch({ effects: setHighlights.of({ items, sticky: sticky && items.length > 0 }) });
rememberHighlights(view, state.doc, items);
if (!sticky) return;
if (!items.length) {
if (useUsagesStatus.getState().owner === view) setUsagesStatus(null, null);
@@ -1217,7 +1129,7 @@ async function fetchHighlights(view: EditorView, sticky: boolean) {
if (lspError) reportLspError(view, lspError, sticky);
else if (sticky) showHint(view, NO_SERVER);
// Drop stale marks from an earlier caret position.
if (view.state.field(highlightField).ranges.length) apply([]);
if (view.state.field(highlightField).deco.size) apply([]);
}
async function syntaxHighlights(view: EditorView, doc: EditorDocContext, head: number) {
@@ -1236,7 +1148,10 @@ async function syntaxHighlights(view: EditorView, doc: EditorDocContext, head: n
export function highlightUsages(view: EditorView) {
const st = view.state.field(highlightField, false);
const head = view.state.selection.main.head;
const onUsage = !!st?.ranges.some(({ from, to }) => from <= head && head <= to);
let onUsage = false;
st?.deco.between(head, head, (from, to) => {
if (from <= head && head <= to) onUsage = true;
});
if (st?.sticky && onUsage) {
clearHighlights(view);
return;
@@ -1250,7 +1165,8 @@ export function highlightUsages(view: EditorView) {
*/
export function gotoHighlightedUsage(view: EditorView, dir: 1 | -1): boolean {
const st = view.state.field(highlightField, false);
const ranges = [...(st?.ranges ?? [])].sort((a, b) => a.from - b.from);
const ranges: { from: number; to: number }[] = [];
st?.deco.between(0, view.state.doc.length, (from, to) => void ranges.push({ from, to }));
const head = view.state.selection.main.head;
const current = ranges.findIndex((r) => r.from <= head && head <= r.to);
if (ranges.length < 2 || current < 0) return false;
@@ -1262,8 +1178,7 @@ export function gotoHighlightedUsage(view: EditorView, dir: 1 | -1): boolean {
const highlightPlugin = ViewPlugin.fromClass(
class {
// Keyboard moves wait a little, so holding an arrow key does not ask for every line.
private request = debounce(() => void fetchHighlights(this.view, false), 100);
private request = debounce(() => void fetchHighlights(this.view, false), 300);
constructor(private view: EditorView) {}
update(u: ViewUpdate) {
// Recomputed after caret moves and, like IDEA's daemon, after edits.
@@ -1275,7 +1190,7 @@ const highlightPlugin = ViewPlugin.fromClass(
}
const clear = () => {
this.request.cancel();
if (st.ranges.length)
if (st.deco.size)
queueMicrotask(() => this.view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) }));
};
// Code Editing › "Usages of element at caret".
@@ -1283,20 +1198,7 @@ const highlightPlugin = ViewPlugin.fromClass(
clear();
return;
}
const doc = u.state.doc;
const head = u.state.selection.main.head;
const known = u.docChanged ? null : recentHighlightsAt(this.view, doc, head);
if (known)
queueMicrotask(() => {
const now = this.view.state;
if (now.doc === doc && now.selection.main.head === head)
this.view.dispatch({ effects: setHighlights.of({ items: known, sticky: false }) });
});
// A click asks at once (IDEA highlights as the caret lands).
if (u.transactions.some((tr) => tr.isUserEvent("select.pointer"))) {
this.request.cancel();
void fetchHighlights(this.view, false);
} else this.request();
this.request();
}
destroy() {
this.request.cancel();
-43
View File
@@ -45,7 +45,6 @@ provideEditorService({ openFile } as never);
const fb = await import("./syntaxFallback");
const { gotoTargets, navigationExtension, highlightUsages } = await import("./navigation");
const { registerNavigationProvider } = await import("@/editor/navigationProviders");
const { startRename, renameNamePart } = await import("./rename");
const { lspDoc } = await import("./lsp");
const { invalidateConfigurationNavigation } = await import("./configurationNavigation");
@@ -417,48 +416,6 @@ describe("navigation wiring", () => {
expect(openFile).not.toHaveBeenCalled();
});
it("shows usages on a declaration a plugin's index links elsewhere, and follows the link from a call", async () => {
// MyBatis Duo indexes the mapper method's name as a link to its XML statement.
const xml = { path: "/p/UserMapper.xml", range: r(3, 16, 24) };
const off = registerNavigationProvider(async (view) => {
const at = view.state.doc.toString().indexOf("findName");
return { from: at, to: at + 8, targets: [xml] };
});
try {
// The syntax tree puts the caret on the method's name; the host then answers both.
cmd.syntaxNodeAt.mockReturnValueOnce(
ok({
node: node("identifier", r(3, 11, 19), "name"),
ancestors: [node("method_declaration", { start: { line: 3, col: 4 }, end: { line: 3, col: 29 } })],
}),
);
cmd.lspGoto.mockReturnValueOnce(ok([xml, { path: "/p/UserMapper.java", range: r(3, 11, 19) }]));
const view = editor(
"package demo;\n\npublic interface UserMapper {\n String findName(Long id);\n}\n",
"java",
58,
"/p/UserMapper.java",
);
await gotoTargets(view, "definition");
expect(executeAction).toHaveBeenCalledWith("find.showUsages");
expect(openFile).not.toHaveBeenCalled();
// A call: the plugin's link is followed without asking the language server.
executeAction.mockClear();
cmd.lspGoto.mockClear();
cmd.syntaxNodeAt.mockReturnValueOnce(
ok({ node: node("identifier", r(1, 26, 34), "name"), ancestors: [node("method_invocation", r(1, 24, 38))] }),
);
const call = editor("class S {\n void f(UserMapper m) { m.findName(1L); }\n}\n", "java", 37, "/p/S.java");
await gotoTargets(call, "definition");
expect(openFile).toHaveBeenCalledWith("/p/UserMapper.xml", expect.objectContaining({ focus: true }));
expect(executeAction).not.toHaveBeenCalledWith("find.showUsages");
expect(cmd.lspGoto).not.toHaveBeenCalled();
} finally {
off();
}
});
it("looks past a server answer that points at the import itself", async () => {
cmd.lspGoto.mockReturnValueOnce(ok([{ path: "/p/a.py", range: r(0, 37, 43) }]));
cmd.navIndexDefinitions.mockReturnValueOnce(ok([sym("logger", "variable", "/deps/torchtitan/tools/logging.py")]));
-8
View File
@@ -63,14 +63,6 @@ export function usageRole(at: NodeAt | null): UsageRole {
return "other";
}
const DECLARATION = /(declaration|definition|declarator)$/;
/** Whether the syntax tree puts the identifier at `at` as the name of a declaration (`int f()`'s `f`). */
export function isDeclarationName(at: NodeAt | null): boolean {
const parent = at?.ancestors[0];
return !!parent && at.node.field === "name" && DECLARATION.test(parent.kind);
}
/** Index symbols that may be the declaration of `name` used in `role`. */
export function filterSymbols(matches: readonly SymbolMatch[], name: string, role: UsageRole): SymbolMatch[] {
return matches.filter((m) => {
+4 -11
View File
@@ -288,18 +288,11 @@ export const lspTheme = EditorView.theme({
textUnderlineOffset: "2px",
cursor: "pointer",
},
".cm-lsp-occurrence, .cm-lsp-occurrence-tail": {
"--lsp-occurrence-bg": "var(--bg-identifier-under-caret, color-mix(in srgb, var(--accent) 14%, transparent))",
".cm-lsp-occurrence": {
backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--accent) 14%, transparent))",
},
".cm-lsp-occurrence-write, .cm-lsp-occurrence-tail-write": {
"--lsp-occurrence-bg": "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, transparent))",
},
".cm-lsp-occurrence": { backgroundColor: "var(--lsp-occurrence-bg)" },
// First line of a highlight over several lines: from its start column to the right edge,
// over the line's own background (the caret row's).
".cm-lsp-occurrence-tail": {
backgroundImage:
"linear-gradient(to right, transparent var(--lsp-occurrence-from), var(--lsp-occurrence-bg) var(--lsp-occurrence-from))",
".cm-lsp-occurrence-write": {
backgroundColor: "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, transparent))",
},
".cm-lsp-nav-info": { padding: "4px 8px" },
// Chooser highlight (tempHighlight.ts): the expression / occurrences a choice would take.
-125
View File
@@ -1,125 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { Highlight, IdeError, Range } from "@/bindings";
type R<T> = Promise<{ status: "ok"; data: T } | { status: "error"; error: IdeError }>;
const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
const cmd = {
lspHover: vi.fn((): R<unknown> => ok(null)),
lspDocumentLinks: vi.fn(() => ok([])),
cancelRequest: vi.fn(() => ok(null)),
lspDocumentHighlights: vi.fn((): R<Highlight[]> => ok([])),
};
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } };
});
const { navigationExtension, highlightUsages } = await import("./navigation");
const { lspDoc } = await import("./lsp");
const range = (line: number, col: number, endLine: number, endCol: number): Range => ({
start: { line, col },
end: { line: endLine, col: endCol },
});
// 0: int f() { 4: }
// 1: return a 5: int g(String a) {
// 2: .b() 6: return a;
// 3: .c(); 7: }
const DOC = "int f() {\n return a\n .b()\n .c();\n}\nint g(String a) {\n return a;\n}";
const views: EditorView[] = [];
function editor(anchor = 0) {
const view = new EditorView({
state: EditorState.create({
doc: DOC,
selection: { anchor },
extensions: [
lspDoc.of({ doc: 7, path: "/p/A.java", language: "java", uiMode: null, readOnly: false }),
navigationExtension(),
],
}),
parent: document.body,
});
views.push(view);
return view;
}
const lines = (v: EditorView) => [...v.contentDOM.querySelectorAll<HTMLElement>(".cm-line")];
const at = (line: number, col: number) => DOC.split("\n").slice(0, line).join("\n").length + (line ? 1 : 0) + col;
const click = (v: EditorView, pos: number) => v.dispatch({ selection: { anchor: pos }, userEvent: "select.pointer" });
beforeEach(() => {
cmd.lspDocumentHighlights.mockReset().mockImplementation(() => ok([]));
});
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
describe("highlights of the element at the caret", () => {
it("paints a highlight over several lines as one block, as IDEA paints a text range", async () => {
cmd.lspDocumentHighlights.mockImplementation(() => ok([{ range: range(1, 4, 3, 13), kind: "text" }]));
const v = editor(at(1, 4));
highlightUsages(v);
await vi.waitFor(() => expect(lines(v)[1]!.classList.contains("cm-lsp-occurrence-tail")).toBe(true));
// From `return` (column 4) to the right edge, the whole middle line, the last line up to `;`.
expect(lines(v)[1]!.style.getPropertyValue("--lsp-occurrence-from")).toBe("calc(2.5px + 4ch)");
expect(lines(v)[2]!.classList.contains("cm-lsp-occurrence")).toBe(true);
const last = lines(v)[3]!.querySelector(".cm-lsp-occurrence")!;
expect(last.textContent).toBe(" .c();");
expect(lines(v)[0]!.className).not.toContain("cm-lsp-occurrence");
expect(lines(v)[4]!.className).not.toContain("cm-lsp-occurrence");
});
it("keeps a single-line highlight on its text", async () => {
cmd.lspDocumentHighlights.mockImplementation(() => ok([{ range: range(6, 11, 6, 12), kind: "read" }]));
const v = editor(at(6, 11));
highlightUsages(v);
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence-read")).toHaveLength(1));
expect(lines(v)[6]!.classList.contains("cm-lsp-occurrence")).toBe(false);
});
it("asks at once after a click, and after a pause for keyboard moves", async () => {
const v = editor(0);
click(v, at(6, 11));
await vi.waitFor(() => expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(1));
v.dispatch({ selection: { anchor: at(6, 4) }, userEvent: "select" });
await new Promise((r) => setTimeout(r, 20));
expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(2));
});
it("shows the usages found before at once when the caret comes back to one", async () => {
const a = [
{ range: range(5, 13, 5, 14), kind: "write" as const },
{ range: range(6, 11, 6, 12), kind: "read" as const },
];
cmd.lspDocumentHighlights.mockImplementationOnce(() => ok(a));
const v = editor(0);
click(v, at(6, 11));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(2));
click(v, at(1, 4));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0));
// The server is slow now: the remembered usages of `a` show without waiting for it.
cmd.lspDocumentHighlights.mockImplementation(() => new Promise(() => {}));
click(v, at(5, 13));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(2));
});
it("forgets them after an edit", async () => {
cmd.lspDocumentHighlights.mockImplementationOnce(() => ok([{ range: range(6, 11, 6, 12), kind: "read" }]));
const v = editor(0);
click(v, at(6, 11));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(1));
v.dispatch({ changes: { from: 0, insert: "\n" } });
click(v, 0);
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0));
// Back on `a` with a slow server: the results of the document before the edit are not reused.
cmd.lspDocumentHighlights.mockImplementation(() => new Promise(() => {}));
click(v, at(6, 11) + 1);
await vi.waitFor(() => expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(3));
await new Promise((r) => setTimeout(r, 20));
expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0);
});
});
+1 -1
View File
@@ -22,7 +22,7 @@ import { gotoTestOrSubject, type TestNavDeps } from "./gotoTest";
import { baseName, memberAt, methodStop, superMethods } from "./structureNav";
import { tx } from "@/platform/i18n";
const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
/** A backend document id for `path` while `fn` runs (open editors are reused). */
async function withDoc<T>(path: string, fn: (doc: number) => Promise<T>): Promise<T> {
+1 -1
View File
@@ -26,7 +26,7 @@ import { ServerStatus } from "./ServerStatus";
import { reportProblems } from "./problemsReport";
import { installWorkspaceEditUndo } from "./workspaceUndo";
const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
function useErrorBadge(): number | null {
return useProblems((s) => {
+5 -21
View File
@@ -268,29 +268,13 @@
gap: 6px;
min-width: 0;
}
/* The whole tree row, indent included, so a coloured source and its children form one band. */
.tintedRow:not([aria-selected="true"]):not(:hover) {
border-radius: 0;
.tinted {
flex: 1;
margin: 0 -4px;
padding: 0 4px;
border-radius: var(--radius-control, 4px);
background: color-mix(in srgb, var(--db-tint) var(--tint-strength), transparent);
}
.tintRed {
--db-tint: var(--db-color-red);
}
.tintOrange {
--db-tint: var(--db-color-orange);
}
.tintYellow {
--db-tint: var(--db-color-yellow);
}
.tintGreen {
--db-tint: var(--db-color-green);
}
.tintBlue {
--db-tint: var(--db-color-blue);
}
.tintViolet {
--db-tint: var(--db-color-violet);
}
.nodeLabel {
overflow: hidden;
text-overflow: ellipsis;
@@ -5,10 +5,6 @@ import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { registerDatabaseTreeActions } from "./treeActions";
import { tablesKey, useDatabase } from "./store";
import { tableInput } from "./tableInput";
import { useEditorRegistry } from "@/editor/api";
import { provideEditorService, provideToolWindowService, type EditorInput } from "@/workbench/contributions";
import styles from "./Database.module.css";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
@@ -112,61 +108,6 @@ describe("routine grouping", () => {
});
});
describe("Select Opened File", () => {
const nextFrame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
let active: EditorInput | null = null;
const show = vi.fn();
beforeEach(() => {
provideEditorService({ activeInput: () => active } as never);
provideToolWindowService({ show } as never);
useDatabase.setState({
tables: {
[tablesKey("pg", "public")]: {
status: "ok",
value: [
{ name: "people", kind: "table" },
{ name: "adults", kind: "view" },
],
},
},
schemas: { pg: { status: "ok", value: ["public", "audit"] } },
treeExpanded: new Set(),
});
});
afterEach(() => {
active = null;
useEditorRegistry.setState({ active: null });
});
it("selects the active table editor's view in the views folder", async () => {
render(<DatabaseView />);
active = tableInput("pg", "public", "adults");
await act(() => executeAction("db.selectOpened"));
await nextFrame();
expect(useDatabase.getState().selectedNode?.key).toBe("pg/public/views/adults");
expect(show).toHaveBeenCalledWith("database");
expect(document.activeElement).toBe(screen.getByRole("tree", { name: "Data sources" }));
});
it("selects the nearest shown ancestor of a dropped table, and a console's schema", async () => {
render(<DatabaseView />);
active = tableInput("pg", "public", "dropped");
await act(() => executeAction("db.selectOpened"));
await nextFrame();
expect(useDatabase.getState().selectedNode?.key).toBe("pg/public/tables");
const path = "/project/audit.sql";
useDatabase.setState({ consoles: { [path]: "pg" }, consoleSchemas: { [path]: "audit" } });
active = { kind: "file", key: path, path };
useEditorRegistry.setState({ active: { ctx: { path, language: "sql" } } as never });
await act(() => executeAction("db.selectOpened"));
await nextFrame();
expect(useDatabase.getState().selectedNode?.key).toBe("pg/audit");
});
});
describe("Expand All", () => {
it("opens every loaded node and leaves unloaded ones closed", async () => {
useDatabase.setState({
@@ -192,20 +133,3 @@ describe("Expand All", () => {
expect(tree.getByText("SQLite").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
});
});
describe("data source colour", () => {
it("tints the whole tree row of the source and of every node under it", () => {
useDatabase.setState({
sources: [
{ id: "pg", name: "PostgreSQL", driver: "postgres", color: "red", options: { singleDatabase: true } },
{ id: "lite", name: "SQLite", driver: "sqlite" },
],
});
render(<DatabaseView />);
const tree = within(screen.getByRole("tree", { name: "Data sources" }));
for (const name of ["PostgreSQL", "public", "routines", "refresh_data"]) {
expect(tree.getByText(name).closest('[role="treeitem"]')).toHaveClass(styles.tintedRow!, styles.tintRed!);
}
expect(tree.getByText("SQLite").closest('[role="treeitem"]')).not.toHaveClass(styles.tintedRow!);
});
});
+14 -32
View File
@@ -5,8 +5,8 @@
* with counts and column types, loaded lazily. Double-click a table to open its data, a data
* source to open its query console.
*/
import { useEffect, useMemo, useRef } from "react";
import type { DbColor, DbDatabases, DbTableDetails } from "@/platform/ipc";
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
import type { DbDatabases, DbTableDetails } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import {
@@ -23,6 +23,7 @@ import {
T,
} from "@/ui";
import {
colorVar,
databaseKey,
passesObjectFilter,
passesTreeFilter,
@@ -61,16 +62,6 @@ import { DB_VIEW_FOCUS, databaseTree, openTableData } from "./treeActions";
import { newMenu, nodeMenu } from "./treeMenu";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
import { databaseRequest } from "./requests";
const ROW_TINTS: Record<DbColor, string | undefined> = {
red: styles.tintRed,
orange: styles.tintOrange,
yellow: styles.tintYellow,
green: styles.tintGreen,
blue: styles.tintBlue,
violet: styles.tintViolet,
};
const GROUP_LABELS: Record<Group, string> = {
columns: "columns",
@@ -152,12 +143,12 @@ export function DatabaseView() {
useEffect(() => {
if (!st.loaded) void loadSources();
}, [st.loaded]);
// Go to Declaration from a console, Select Opened File: the rows exist once the expanded nodes render.
// Go to Declaration from a console: the rows exist once the expanded nodes render.
useEffect(() => {
if (!st.treeReveal) return;
const path = st.treeReveal.path;
const key = st.treeReveal.key;
const frame = requestAnimationFrame(() => {
path.toReversed().some((key) => tree.current?.reveal(key));
tree.current?.reveal(key);
tree.current?.focus();
});
return () => cancelAnimationFrame(frame);
@@ -355,10 +346,7 @@ export function DatabaseView() {
const load = async (n: Node) => {
if (n.kind === "source") {
const id = n.source.id ?? "";
const current = databaseRequest(id);
const connected = await connect(id);
if (!current()) return;
if (!connected) {
if (!(await connect(id))) {
const err = { status: "error" as const, message: tx("Cannot connect") };
if (listsDatabases(n.source)) useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: err } }));
else useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: err } }));
@@ -367,10 +355,7 @@ export function DatabaseView() {
if (listsDatabases(n.source)) await loadDatabases(id);
else await loadSchemas(id);
} else if (n.kind === "database") {
const current = databaseRequest(n.id);
const connected = await connect(n.id);
if (!current()) return;
if (!connected) {
if (!(await connect(n.id))) {
useDatabase.setState((s) => ({
schemas: { ...s.schemas, [n.id]: { status: "error", message: tx("Cannot connect") } },
}));
@@ -557,12 +542,6 @@ export function DatabaseView() {
getIcon={(n) => icon(n)}
getDetail={detail}
getColor={(n) => (n.kind === "message" && n.error ? "var(--danger)" : undefined)}
rowClassName={(n) => {
// IDEA tints every row of a coloured data source across its full width and height.
const target = nodeTarget(n);
const color = target ? sourceOf(target)?.color : null;
return color ? `${styles.tintedRow} ${ROW_TINTS[color]}` : undefined;
}}
renderNode={(n: Node) => {
const i = icon(n);
const d = detail(n);
@@ -574,9 +553,13 @@ export function DatabaseView() {
? styles.keyLabel
: styles.nodeLabel;
const live = n.kind === "source" && [...st.connected].some((t) => splitTarget(t).id === n.source.id);
const target = nodeTarget(n);
// IDEA tints every row of a coloured data source.
const tint = colorVar(target ? sourceOf(target)?.color : null);
return (
<span
className={styles.nodeRow}
className={tint ? `${styles.nodeRow} ${styles.tinted}` : styles.nodeRow}
style={tint ? ({ "--db-tint": tint } as CSSProperties) : undefined}
title={n.kind === "source" && n.source.comment ? n.source.comment : undefined}
>
{i && (
@@ -700,11 +683,10 @@ function DatabaseToolbar() {
);
}
/** Header controls (shown on hover, like IDEA's): Select Opened File, Expand All, Collapse All. */
/** Header controls (shown on hover, like IDEA's): Expand All, Collapse All. */
export function DatabaseHeaderActions() {
return (
<>
<IconButton icon="locate" action="db.selectOpened" size="small" iconSize={16} />
<IconButton icon="expand-all" action="db.expandAll" size="small" iconSize={16} />
<IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />
</>
+1 -2
View File
@@ -512,7 +512,6 @@ export function SourceDialog({
};
const save = async () => {
const dialog = useDatabase.getState().dialog;
setBusy(true);
try {
await saveSource(
@@ -520,7 +519,7 @@ export function SourceDialog({
sqlite ? undefined : password === undefined ? undefined : password || null,
!sshOn || sshSecret === undefined || sshUsed.auth === "openSsh" ? undefined : sshSecret || null,
);
if (useDatabase.getState().dialog === dialog) closeDialog();
closeDialog();
} catch (e) {
setTest({ ok: false, text: e instanceof Error ? e.message : String(e) });
} finally {
+1 -6
View File
@@ -83,7 +83,6 @@ import {
} from "./store";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
import { databaseRequest, projectRequest } from "./requests";
/** `templates` feature's language-data key for finer template contexts. */
const TEMPLATE_CONTEXT_KEY = "openideaTemplateContexts";
@@ -609,14 +608,12 @@ function catalogOf(id: string): SqlCatalog | undefined {
/** Loads what `database.` / `database.schema.` at the caret needs before it is completed. */
async function loadQualified(id: string, qualifier: readonly string[]): Promise<void> {
const current = databaseRequest(id);
const sourceId = splitTarget(id).id;
// Introspection has it: nothing to load (and no reason to connect for it).
const wanted = (qualifier[0] ?? "").toLowerCase();
const read = useDatabase.getState().catalogs[sourceId]?.databases ?? {};
if (Object.keys(read).some((d) => d.toLowerCase() === wanted)) return;
if (!useDatabase.getState().databases[sourceId]) await loadDatabases(sourceId);
if (!current()) return;
const dbs = useDatabase.getState().databases[sourceId];
if (dbs?.status !== "ok") return;
const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
@@ -625,7 +622,6 @@ async function loadQualified(id: string, qualifier: readonly string[]): Promise<
if (useDatabase.getState().catalogs[sourceId]?.databases[database]) return;
const target = targetOf(sourceId, database, dbs.value.current);
if (!useDatabase.getState().schemas[target]) await loadSchemas(target);
if (!current()) return;
if (qualifier.length < 2) return;
const list = useDatabase.getState().schemas[target];
const schema = list?.status === "ok" ? list.value.find((s) => same(s, qualifier[1]!)) : undefined;
@@ -635,7 +631,6 @@ async function loadQualified(id: string, qualifier: readonly string[]): Promise<
function completionSource(path: string): CompletionSource {
return async (ctx: CompletionContext): Promise<CompletionResult | null> => {
const id = consoleSource(path);
const current = id ? databaseRequest(id) : projectRequest();
const source = sourceById(id);
const driver: DbDriver = source?.driver ?? "sqlite";
const model = id ? (useDatabase.getState().completion[id] ?? null) : null;
@@ -654,7 +649,7 @@ function completionSource(path: string): CompletionSource {
const cataloged = driver === "snowflake" && !!id;
if (cataloged && analysis.qualifier?.length) {
await loadQualified(id, analysis.qualifier).catch(() => {});
if (ctx.aborted || !current()) return null;
if (ctx.aborted) return null;
}
const candidates = sqlCandidates({
model: scoped,
+1 -19
View File
@@ -24,7 +24,6 @@ import { installValueEditorActions } from "./valueEditorActions";
import { registerResultTabActions } from "./resultTabActions";
import { SQL_LANGUAGE } from "./sqlLanguage";
import { TABLE_KIND, tableTitle } from "./tableInput";
import { databaseRequest, projectRequest } from "./requests";
import { nodeTarget, type DbNode } from "./nodes";
import { registerDatabaseTreeActions } from "./treeActions";
import {
@@ -143,14 +142,9 @@ export function activate(): () => void {
throw new Error(tx("Trust this project to connect plugin data sources"));
const id = e.arg;
if (typeof id !== "string") throw new Error(tx("Invalid data source id"));
const project = projectRequest();
await loadSources();
if (!project()) return;
const current = databaseRequest(id);
toolWindowService().show(DB_TOOL_WINDOW, true);
const connected = await connect(id);
if (!current()) return;
if (!connected) throw new Error(tx("Database connection failed"));
if (!(await connect(id))) throw new Error(tx("Database connection failed"));
if (listsDatabases(sourceById(id))) await loadDatabases(id);
else await loadSchemas(id);
},
@@ -172,9 +166,7 @@ export function activate(): () => void {
keywords: ["database", "connection", "postgres", "mysql", "sqlite"],
when: "projectOpen",
run: async () => {
const current = projectRequest();
if (!useDatabase.getState().loaded) await loadSources();
if (!current()) return;
openSourceDialog(null);
},
},
@@ -186,10 +178,8 @@ export function activate(): () => void {
keywords: ["database", "sql console"],
when: "projectOpen",
run: async ({ arg }) => {
const current = projectRequest();
const s = useDatabase.getState();
if (!s.loaded) await loadSources();
if (!current()) return;
const { sources, selectedSource } = useDatabase.getState();
// A tree node (New ▸ Query Console) opens its data source's console.
const node = arg && typeof arg === "object" && "kind" in arg ? nodeTarget(arg as DbNode) : null;
@@ -205,11 +195,9 @@ export function activate(): () => void {
icon: "run",
when: `editorTextFocus && editorLangId == ${SQL_LANGUAGE}`,
run: async () => {
const current = projectRequest();
const e = activeEditor();
if (!e || e.ctx.language !== SQL_LANGUAGE) return;
if (!useDatabase.getState().loaded) await loadSources();
if (!current()) return;
if (!consoleSource(e.ctx.path) && !useDatabase.getState().sources.length) {
openSourceDialog(null);
return;
@@ -225,11 +213,9 @@ export function activate(): () => void {
keywords: ["run all", "whole file", "statements"],
when: `editorTextFocus && editorLangId == ${SQL_LANGUAGE}`,
run: async () => {
const current = projectRequest();
const e = activeEditor();
if (!e || e.ctx.language !== SQL_LANGUAGE) return;
if (!useDatabase.getState().loaded) await loadSources();
if (!current()) return;
if (!consoleSource(e.ctx.path) && !useDatabase.getState().sources.length) {
openSourceDialog(null);
return;
@@ -269,11 +255,9 @@ export function activate(): () => void {
keywords: ["query plan", "explain", "sql"],
when: `editorTextFocus && editorLangId == ${SQL_LANGUAGE}`,
run: async () => {
const current = projectRequest();
const e = activeEditor();
if (!e || e.ctx.language !== SQL_LANGUAGE) return;
if (!useDatabase.getState().loaded) await loadSources();
if (!current()) return;
await (await import("./console")).explainInView(e.view, e.ctx.path);
},
},
@@ -285,9 +269,7 @@ export function activate(): () => void {
keywords: ["database", "sql", "history"],
when: "projectOpen",
run: async () => {
const current = projectRequest();
if (!useDatabase.getState().loaded) await loadSources();
if (!current()) return;
const e = activeEditor();
const path = e?.ctx.language === SQL_LANGUAGE ? e.ctx.path : null;
const s = useDatabase.getState();
+3 -82
View File
@@ -1,11 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DataSource, DbSourceCatalog, ProjectInfo } from "@/platform/ipc";
import type { DataSource, DbSourceCatalog } from "@/platform/ipc";
const backend = vi.hoisted(() => ({
saved: vi.fn(),
database: vi.fn(),
retain: vi.fn(),
disconnect: vi.fn(),
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
@@ -17,7 +16,6 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
dbSavedCatalog: backend.saved,
dbDatabaseCatalog: backend.database,
dbRetainCatalog: backend.retain,
dbDisconnect: backend.disconnect,
},
};
});
@@ -29,9 +27,7 @@ const HOUR = 60 * 60 * 1000;
/** A fresh copy of the module (its "asked this run" memory starts empty) over a seeded store. */
async function setup(id: string, saved: DbSourceCatalog | null, introspection?: Partial<DataSource["introspection"]>) {
vi.resetModules();
const store = await import("./store");
const { useDatabase } = store;
const { useProject } = await import("@/platform/project");
const { useDatabase } = await import("./store");
const source = {
id,
name: "warehouse",
@@ -47,18 +43,7 @@ async function setup(id: string, saved: DbSourceCatalog | null, introspection?:
backend.saved.mockResolvedValue(ok(saved));
backend.database.mockImplementation((_id: string, database: string) => Promise.resolve(ok(schemas(`${database}_T`))));
backend.retain.mockResolvedValue(ok(null));
backend.disconnect.mockResolvedValue(ok(null));
return { ...store, useProject, ...(await import("./introspection")) };
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
return { useDatabase, ...(await import("./introspection")) };
}
// Each test loads a fresh copy of the store module, which takes a while in a busy run.
@@ -131,68 +116,4 @@ describe("Snowflake introspection", () => {
expect(backend.saved).not.toHaveBeenCalled();
expect(backend.database).not.toHaveBeenCalled();
});
for (const boundary of ["disconnect", "project switch"] as const) {
it(`ignores a saved catalog returned after ${boundary}`, async () => {
const run = await setup("sf", null);
const response = deferred<ReturnType<typeof ok<DbSourceCatalog>>>();
backend.saved.mockReturnValueOnce(response.promise);
const pending = run.loadSavedCatalog("sf");
if (boundary === "disconnect") await run.disconnect("sf");
else run.useProject.setState({ project: { root: "/new-project" } as ProjectInfo });
run.useDatabase.setState({ catalogs: {} });
response.resolve(ok({ databases: [{ name: "OLD", readAt: 1, schemas: schemas("OLD_T") }] }));
await pending;
expect(run.useDatabase.getState().catalogs).toEqual({});
});
for (const outcome of ["success", "failure"] as const) {
it(`stops live introspection after ${boundary} and a late ${outcome}`, async () => {
const run = await setup("sf", null);
const response = deferred<ReturnType<typeof ok<ReturnType<typeof schemas>>>>();
backend.database.mockReturnValueOnce(response.promise);
const pending = run.introspectSource("sf");
await vi.waitFor(() => expect(backend.database).toHaveBeenCalledOnce());
if (boundary === "disconnect") await run.disconnect("sf");
else run.useProject.setState({ project: { root: "/new-project" } as ProjectInfo });
run.useDatabase.setState({ catalogs: {} });
if (outcome === "success") response.resolve(ok(schemas("OLD_T")));
else response.reject(new Error("old catalog failed"));
await pending;
expect(run.useDatabase.getState().catalogs).toEqual({});
expect(backend.database).toHaveBeenCalledOnce();
expect(backend.retain).not.toHaveBeenCalled();
});
}
}
it("loads a new saved catalog without reusing the previous project's request", async () => {
const run = await setup("sf", null);
const old = deferred<ReturnType<typeof ok<DbSourceCatalog>>>();
backend.saved.mockReturnValueOnce(old.promise);
const previous = run.loadSavedCatalog("sf");
run.useProject.setState({ project: { root: "/new-project" } as ProjectInfo });
backend.saved.mockResolvedValueOnce(ok({ databases: [{ name: "NEW", readAt: 2, schemas: schemas("NEW_T") }] }));
await run.loadSavedCatalog("sf");
old.resolve(ok({ databases: [{ name: "OLD", readAt: 1, schemas: schemas("OLD_T") }] }));
await previous;
expect(Object.keys(run.useDatabase.getState().catalogs.sf!.databases)).toEqual(["NEW"]);
expect(backend.saved).toHaveBeenCalledTimes(2);
});
it("keeps a new introspection intact when an older run finishes", async () => {
const run = await setup("sf", null);
const old = deferred<ReturnType<typeof ok<ReturnType<typeof schemas>>>>();
backend.database.mockReturnValueOnce(old.promise);
const previous = run.introspectSource("sf");
await vi.waitFor(() => expect(backend.database).toHaveBeenCalledOnce());
run.useProject.setState({ project: { root: "/new-project" } as ProjectInfo });
run.useDatabase.setState({ catalogs: {} });
await run.introspectSource("sf");
old.resolve(ok(schemas("OLD_T")));
await previous;
expect(run.useDatabase.getState().catalogs.sf?.status).toBe("done");
expect(run.useDatabase.getState().catalogs.sf?.databases.ANALYTICS).toEqual(schemas("ANALYTICS_T"));
expect(backend.retain).toHaveBeenCalledOnce();
});
});
+13 -35
View File
@@ -9,12 +9,11 @@ import { tx } from "@/platform/i18n";
import { notifications } from "@/workbench/contributions";
import { shownNames, splitTarget } from "./model";
import { loadDatabases, sourceById, useDatabase } from "./store";
import { databaseRequest } from "./requests";
const ACCEPTED_KEY = "openidea.database.introspect";
/** Data sources asked (or started) in this run, so a connect asks only once. */
const offered = new Map<string, () => boolean>();
const cancelled = new Map<string, () => boolean>();
const offered = new Set<string>();
const cancelled = new Set<string>();
function accepted(): string[] {
try {
@@ -37,7 +36,7 @@ const notice = (id: string) => `db-introspect-${id}`;
/** A saved catalog older than this is read again on the next connect. */
const STALE_MS = 24 * 60 * 60 * 1000;
const loading = new Map<string, { current: () => boolean; promise: Promise<void> }>();
const loading = new Map<string, Promise<void>>();
/**
* Brings what earlier introspection of data source `id` saved into the store (once per run).
@@ -47,11 +46,9 @@ export function loadSavedCatalog(id: string): Promise<void> {
const sourceId = splitTarget(id).id;
if (sourceById(sourceId)?.driver !== "snowflake") return Promise.resolve();
let pending = loading.get(sourceId);
if (!pending?.current()) {
const current = databaseRequest(sourceId);
const promise = call(commands.dbSavedCatalog(sourceId))
if (!pending) {
pending = call(commands.dbSavedCatalog(sourceId))
.then((saved) => {
if (!current()) return;
const databases = saved?.databases ?? [];
// An introspection started meanwhile knows better.
if (!databases.length || useDatabase.getState().catalogs[sourceId]) return;
@@ -69,16 +66,14 @@ export function loadSavedCatalog(id: string): Promise<void> {
}));
})
.catch(() => {});
pending = { current, promise };
loading.set(sourceId, pending);
}
return pending.promise;
return pending;
}
/** Stops the running introspection of data source `id` after the database it is reading. */
export function cancelIntrospection(id: string) {
const sourceId = splitTarget(id).id;
cancelled.set(sourceId, databaseRequest(sourceId));
cancelled.add(splitTarget(id).id);
}
/** Reads the schemas and tables of every database data source `id` shows (see the Schemas tab). */
@@ -86,22 +81,18 @@ export async function introspectSource(id: string): Promise<void> {
const sourceId = splitTarget(id).id;
const source = sourceById(sourceId);
if (source?.driver !== "snowflake" || useDatabase.getState().catalogs[sourceId]?.status === "running") return;
const current = databaseRequest(sourceId);
offered.set(sourceId, current);
offered.add(sourceId);
remember(sourceId);
cancelled.delete(sourceId);
const name = source.name ?? "";
const update = (patch: Partial<{ status: "running" | "done" | "cancelled"; done: number; total: number }>) =>
useDatabase.setState((s) => {
if (!current()) return s;
const old = s.catalogs[sourceId] ?? { status: "running" as const, done: 0, total: 0, databases: {} };
return { catalogs: { ...s.catalogs, [sourceId]: { ...old, ...patch } } };
});
await loadSavedCatalog(sourceId);
if (!current()) return;
update({ status: "running", done: 0, total: 0 });
if (!useDatabase.getState().databases[sourceId]) await loadDatabases(sourceId);
if (!current()) return;
const listed = useDatabase.getState().databases[sourceId];
if (listed?.status !== "ok") {
update({ status: "cancelled" });
@@ -115,8 +106,7 @@ export async function introspectSource(id: string): Promise<void> {
const failed: string[] = [];
let reason = "";
for (const database of databases) {
if (!current()) return;
if (cancelled.get(sourceId)?.()) break;
if (cancelled.has(sourceId)) break;
const position = done + 1;
const count = databases.length;
notifications().notify({
@@ -129,7 +119,6 @@ export async function introspectSource(id: string): Promise<void> {
});
try {
const schemas = await call(commands.dbDatabaseCatalog(sourceId, database));
if (!current()) return;
tables += schemas.reduce((n, schema) => n + schema.tables.length, 0);
useDatabase.setState((s) => {
const old = s.catalogs[sourceId];
@@ -139,7 +128,6 @@ export async function introspectSource(id: string): Promise<void> {
};
});
} catch (error) {
if (!current()) return;
// A database the role may not read: completion goes without it, and the summary says so.
failed.push(database);
reason ||= error instanceof Error ? error.message : String(error);
@@ -147,9 +135,7 @@ export async function introspectSource(id: string): Promise<void> {
done += 1;
update({ done });
}
if (!current()) return;
const stopped = cancelled.get(sourceId)?.() ?? false;
cancelled.delete(sourceId);
const stopped = cancelled.delete(sourceId);
const total = tables.toLocaleString("en");
const read = done - failed.length;
const unread = failed.length;
@@ -164,7 +150,6 @@ export async function introspectSource(id: string): Promise<void> {
return { catalogs: { ...s.catalogs, [sourceId]: { ...old, databases: kept, readAt } } };
});
await call(commands.dbRetainCatalog(sourceId, databases)).catch(() => {});
if (!current()) return;
}
update({ status: stopped ? "cancelled" : "done" });
const summary = tx`${total} tables and views in ${read} databases are ready for completion.`;
@@ -185,11 +170,9 @@ export async function introspectSource(id: string): Promise<void> {
export async function offerIntrospection(id: string): Promise<void> {
const sourceId = splitTarget(id).id;
const source = sourceById(sourceId);
if (source?.driver !== "snowflake" || offered.get(sourceId)?.()) return;
const current = databaseRequest(sourceId);
offered.set(sourceId, current);
if (source?.driver !== "snowflake" || offered.has(sourceId)) return;
offered.add(sourceId);
await loadSavedCatalog(sourceId);
if (!current()) return;
const known = useDatabase.getState().catalogs[sourceId];
if (known?.status === "running") return;
if (known?.readAt !== undefined && Date.now() - known.readAt < STALE_MS) return;
@@ -204,12 +187,7 @@ export async function offerIntrospection(id: string): Promise<void> {
title: tx`Introspect ${name}?`,
body: tx("Reads the names of schemas, tables and views of every database, so completion works for all of them."),
actions: [
{
label: "Introspect",
run: () => {
if (current()) void introspectSource(sourceId);
},
},
{ label: "Introspect", run: () => void introspectSource(sourceId) },
{ label: "Not Now", run: () => {} },
],
sticky: true,
@@ -1,721 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DataSource, ProjectInfo } from "@/platform/ipc";
import { useProject } from "@/platform/project";
const backend = vi.hoisted(() => ({
databases: vi.fn(),
schemas: vi.fn(),
tables: vi.fn(),
objects: vi.fn(),
details: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
completion: vi.fn(),
sources: vi.fn(),
connected: vi.fn(),
save: vi.fn(),
password: vi.fn(),
sshSecret: vi.fn(),
execute: vi.fn(),
statements: vi.fn(),
}));
const ui = vi.hoisted(() => ({ show: vi.fn(), notify: vi.fn() }));
vi.mock("@/workbench/contributions", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/workbench/contributions")>();
return { ...actual, toolWindowService: () => ({ show: ui.show }), notifications: () => ({ notify: ui.notify }) };
});
vi.mock("@/platform/ipc", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/platform/ipc")>();
return {
...actual,
commands: {
...actual.commands,
dbDatabases: backend.databases,
dbSchemas: backend.schemas,
dbTables: backend.tables,
dbSchemaObjects: backend.objects,
dbTableDetails: backend.details,
dbConnect: backend.connect,
dbDisconnect: backend.disconnect,
dbCompletion: backend.completion,
dbSources: backend.sources,
dbConnected: backend.connected,
dbSaveSources: backend.save,
dbSetPassword: backend.password,
dbSetSshSecret: backend.sshSecret,
dbExecute: backend.execute,
dbStatements: backend.statements,
},
};
});
import {
connect,
deleteSource,
disconnect,
loadCompletion,
loadDatabases,
loadDetails,
loadSchemas,
loadSources,
loadTables,
refresh,
renameSource,
revealInTree,
saveSource,
runSql,
executeConsole,
useDatabase,
withConnection,
} from "./store";
const initial = useDatabase.getState();
const initialProject = useProject.getState();
const pg: DataSource = { id: "pg", name: "Database", driver: "postgres" };
const other: DataSource = { id: "pg2", name: "Other", driver: "postgres" };
const projectA = { root: "/project-a" } as ProjectInfo;
const projectB = { root: "/project-b" } as ProjectInfo;
const ok = <T>(data: T) => ({ status: "ok" as const, data });
function deferred<T = unknown>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const details = { columns: [], indexes: [], foreignKeys: [], triggers: [] };
const completion = { defaultSchema: "public", tables: [], foreignKeys: [] };
const cases = [
{
name: "databases",
mock: backend.databases,
load: (id: string) => loadDatabases(id),
value: { current: "app", names: ["app"] },
stale: { current: "old", names: ["old"] },
read: (id: string) => useDatabase.getState().databases[id],
},
{
name: "schemas",
mock: backend.schemas,
load: (id: string) => loadSchemas(id),
value: ["public"],
stale: ["old"],
read: (id: string) => useDatabase.getState().schemas[id],
},
{
name: "tables",
mock: backend.tables,
load: (id: string) => loadTables(id, "public"),
value: [{ name: "users", kind: "table" }],
stale: [{ name: "old", kind: "table" }],
read: (id: string) => useDatabase.getState().tables[`${id}\u0000public`],
},
{
name: "details",
mock: backend.details,
load: (id: string) => loadDetails(id, "public", "users"),
value: details,
stale: { ...details, columns: [{ name: "old" }] },
read: (id: string) => useDatabase.getState().details[`${id}\u0000public\u0000users`],
},
];
let savedSources: DataSource[];
function switchProject(project: ProjectInfo) {
useProject.setState({ project });
// The database feature's project subscription clears its visible state.
useDatabase.setState({ ...initial, sources: [pg, other], loaded: true }, true);
}
beforeEach(() => {
vi.resetAllMocks();
useProject.setState({ project: projectA });
savedSources = [pg, other];
useDatabase.setState({ ...initial, sources: savedSources, loaded: true, connected: new Set(["pg"]) }, true);
for (const spec of cases) spec.mock.mockResolvedValue(ok(spec.value));
backend.connect.mockResolvedValue(ok(null));
backend.disconnect.mockResolvedValue(ok(null));
backend.objects.mockResolvedValue(ok({ routines: [], packages: [], sequences: [] }));
backend.completion.mockResolvedValue(ok(completion));
backend.sources.mockImplementation(async () => ok(savedSources));
backend.connected.mockImplementation(async () => ok([...useDatabase.getState().connected]));
backend.save.mockImplementation(async (sources: DataSource[]) => {
savedSources = sources;
return ok(null);
});
backend.password.mockResolvedValue(ok(null));
backend.sshSecret.mockResolvedValue(ok(null));
backend.statements.mockImplementation(async (_id: string, text: string) => ok([{ text, from: 0, to: text.length }]));
});
afterEach(() => {
useProject.setState(initialProject, true);
useDatabase.setState(initial, true);
});
describe("database metadata request lifetimes", () => {
it.each(cases)("loads $name normally", async (spec) => {
await spec.load("pg");
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
const boundaries = [
{ name: "disconnect", run: () => disconnect("pg") },
{ name: "delete", run: () => deleteSource("pg") },
{ name: "edit", run: () => saveSource({ ...pg, host: "new-host" }, undefined) },
{ name: "project switch with the same source ID", run: async () => switchProject(projectB) },
{
name: "project A to B to A",
run: async () => {
switchProject(projectB);
switchProject(projectA);
},
},
];
for (const boundary of boundaries) {
for (const outcome of ["success", "failure"] as const) {
it.each(cases)(`ignores late ${outcome} for $name after ${boundary.name}`, async (spec) => {
const response = deferred();
spec.mock.mockReturnValueOnce(response.promise);
const pending = spec.load("pg");
await boundary.run();
const cache = spec.read("pg");
const connected = [...useDatabase.getState().connected];
if (outcome === "success") response.resolve(ok(spec.stale));
else response.reject(new Error("old connection failed"));
await pending;
expect.soft(spec.read("pg")).toEqual(cache);
expect([...useDatabase.getState().connected]).toEqual(connected);
});
}
}
it.each(cases)("keeps the latest $name when responses arrive out of order", async (spec) => {
const old = deferred();
spec.mock.mockReturnValueOnce(old.promise);
const pending = spec.load("pg");
await spec.load("pg");
old.resolve(ok(spec.stale));
await pending;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
it.each(cases)("does not replace fresh $name with an older failure", async (spec) => {
const old = deferred();
spec.mock.mockReturnValueOnce(old.promise);
const pending = spec.load("pg");
await spec.load("pg");
old.reject(new Error("old failure"));
await pending;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
it.each(cases)("keeps fresh $name after disconnect and reconnect", async (spec) => {
const old = deferred();
spec.mock.mockReturnValueOnce(old.promise);
const pending = spec.load("pg");
await disconnect("pg");
expect(await connect("pg")).toBe(true);
await spec.load("pg");
old.resolve(ok(spec.stale));
await pending;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
expect(useDatabase.getState().connected.has("pg")).toBe(true);
});
it.each(cases)("keeps a pending fresh $name when the old reply arrives first", async (spec) => {
const old = deferred();
const fresh = deferred();
spec.mock.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
const previous = spec.load("pg");
await disconnect("pg");
await connect("pg");
const next = spec.load("pg");
old.resolve(ok(spec.stale));
await previous;
expect(spec.read("pg")).toEqual({ status: "loading" });
fresh.resolve(ok(spec.value));
await next;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
it.each(cases)("does not restore deleted $name after the source ID is reused", async (spec) => {
const old = deferred();
spec.mock.mockReturnValueOnce(old.promise);
const previous = spec.load("pg");
await deleteSource("pg");
savedSources = [pg, other];
await loadSources();
await spec.load("pg");
old.resolve(ok(spec.stale));
await previous;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
it.each(cases)("does not invalidate another source's pending $name", async (spec) => {
const response = deferred();
spec.mock.mockReturnValueOnce(response.promise);
const pending = spec.load("pg2");
await disconnect("pg");
response.resolve(ok(spec.value));
await pending;
expect(spec.read("pg2")).toEqual({ status: "ok", value: spec.value });
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("invalidates every database of a source", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg:app");
await disconnect("pg");
response.resolve(ok(["old"]));
await pending;
expect(useDatabase.getState().schemas["pg:app"]).toBeUndefined();
expect(useDatabase.getState().connected.has("pg:app")).toBe(false);
});
it("disconnects one database without invalidating its sibling", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg:other");
await disconnect("pg:app");
response.resolve(ok(["public"]));
await pending;
expect(useDatabase.getState().schemas["pg:other"]).toEqual({ status: "ok", value: ["public"] });
});
it("does not request extra schema objects after the table request becomes stale", async () => {
const response = deferred();
backend.tables.mockReturnValueOnce(response.promise);
const pending = loadTables("pg", "public");
await disconnect("pg");
response.resolve(ok([]));
await pending;
expect(backend.objects).not.toHaveBeenCalled();
});
it("ignores schema objects that were already in flight at disconnect", async () => {
const response = deferred();
backend.objects.mockReturnValueOnce(response.promise);
const pending = loadTables("pg", "public");
await vi.waitFor(() => expect(backend.objects).toHaveBeenCalledOnce());
await disconnect("pg");
response.resolve(ok({ routines: [], packages: [], sequences: [{ name: "old" }] }));
await pending;
expect(useDatabase.getState().tables["pg\u0000public"]).toBeUndefined();
expect(useDatabase.getState().objects["pg\u0000public"]).toBeUndefined();
});
it("still lists tables when optional objects cannot be read", async () => {
backend.objects.mockRejectedValueOnce(new Error("permission denied"));
await loadTables("pg", "public");
expect(useDatabase.getState().tables["pg\u0000public"]?.status).toBe("ok");
});
it("keeps metadata in flight when an unchanged source list is reloaded", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg");
await loadSources();
response.resolve(ok(["public"]));
await pending;
expect(useDatabase.getState().schemas.pg).toEqual({ status: "ok", value: ["public"] });
});
it("keeps metadata in flight when the current project is renamed", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg");
useProject.setState({ project: { ...projectA, name: "Renamed" } });
response.resolve(ok(["public"]));
await pending;
expect(useDatabase.getState().schemas.pg).toEqual({ status: "ok", value: ["public"] });
});
});
describe("connection and metadata continuations", () => {
it("reloads every level the tree had expanded after a data source is edited", async () => {
await loadSchemas("pg");
await loadTables("pg", "public");
await loadDetails("pg", "public", "users");
await saveSource({ ...pg, host: "new-host" }, undefined);
const st = useDatabase.getState();
expect(st.schemas.pg).toEqual({ status: "ok", value: ["public"] });
expect(st.tables["pg\u0000public"]?.status).toBe("ok");
expect(st.details["pg\u0000public\u0000users"]?.status).toBe("ok");
});
it("stores credentials when the save emits a source-change event before returning", async () => {
backend.save.mockImplementationOnce(async (sources: DataSource[]) => {
savedSources = sources;
await loadSources();
return ok(null);
});
await saveSource({ ...pg, host: "new-host" }, "password", "ssh-secret");
expect(backend.password).toHaveBeenCalledWith("pg", "password", false);
expect(backend.sshSecret).toHaveBeenCalledWith("pg", "ssh-secret", false);
expect(useDatabase.getState().sources.find((s) => s.id === "pg")?.host).toBe("new-host");
});
it("does not let a pending disconnect clear a newer explicit connection", async () => {
const response = deferred();
backend.disconnect.mockReturnValueOnce(response.promise);
const pending = disconnect("pg");
expect(await connect("pg")).toBe(true);
await loadSchemas("pg");
response.resolve(ok(null));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(true);
expect(useDatabase.getState().schemas.pg?.status).toBe("ok");
});
it("does not restore connected state from a listing started before disconnect", async () => {
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
const pending = loadSources();
await disconnect("pg");
response.resolve(ok(["pg"]));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("does not clear a new connection with an old empty listing", async () => {
useDatabase.setState({ connected: new Set() });
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
const pending = loadSources();
await connect("pg");
response.resolve(ok([]));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(true);
});
it("keeps listing source definitions when its connected snapshot is stale", async () => {
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
backend.sources.mockResolvedValueOnce(ok([...savedSources, { id: "new", name: "New", driver: "sqlite" }]));
const pending = loadSources();
await disconnect("pg");
response.resolve(ok(["pg"]));
await pending;
expect(useDatabase.getState().sources.map((s) => s.id)).toEqual(["pg", "pg2", "new"]);
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
for (const outcome of ["success", "failure"] as const) {
it(`ignores a source-list ${outcome} from project A after A to B to A`, async () => {
const response = deferred();
backend.sources.mockReturnValueOnce(response.promise);
const pending = loadSources();
switchProject(projectB);
switchProject(projectA);
if (outcome === "success") response.resolve(ok([{ ...pg, name: "Old" }]));
else response.reject(new Error("old sources failure"));
await pending;
expect(useDatabase.getState().sources).toEqual([pg, other]);
expect(ui.notify).not.toHaveBeenCalled();
});
}
it("can load metadata again after a disconnect fails", async () => {
backend.disconnect.mockRejectedValueOnce(new Error("disconnect failed"));
await expect(disconnect("pg")).rejects.toThrow("disconnect failed");
expect(useDatabase.getState().connected.has("pg")).toBe(true);
await loadSchemas("pg");
expect(useDatabase.getState().schemas.pg).toEqual({ status: "ok", value: ["public"] });
});
it("stops refresh between metadata stages", async () => {
const response = deferred();
backend.databases.mockReturnValueOnce(response.promise);
useDatabase.setState({ schemas: { pg: { status: "ok", value: ["public"] } } });
const pending = refresh("pg");
await disconnect("pg");
response.resolve(ok({ current: "app", names: ["app"] }));
await pending;
expect(backend.schemas).not.toHaveBeenCalled();
});
it.each(["table", undefined] as const)(
"stops tree reveal after its connection becomes stale (kind %s)",
async (kind) => {
const response = deferred();
backend.connect.mockReturnValueOnce(response.promise);
const pending = revealInTree("pg", "public", "users", kind);
await disconnect("pg");
response.resolve(ok(null));
await pending;
expect(backend.databases).not.toHaveBeenCalled();
expect(backend.schemas).not.toHaveBeenCalled();
expect(useDatabase.getState().treeReveal).toBeNull();
},
);
it.each(["table", undefined] as const)(
"does not select a tree node after its pending details become stale (kind %s)",
async (kind) => {
useDatabase.setState({
databases: { pg: { status: "ok", value: { current: "app", names: ["app"] } } },
schemas: { pg: { status: "ok", value: ["public"] } },
tables: { "pg\u0000public": { status: "ok", value: [{ name: "users", kind: "view" }] } },
});
const response = deferred();
backend.details.mockReturnValueOnce(response.promise);
const pending = revealInTree("pg", "public", "users", kind, "id");
await vi.waitFor(() => expect(backend.details).toHaveBeenCalledOnce());
await disconnect("pg");
response.resolve(ok(details));
await pending;
expect(useDatabase.getState().treeReveal).toBeNull();
expect(useDatabase.getState().treeExpanded.size).toBe(0);
expect(ui.show).not.toHaveBeenCalled();
},
);
it("infers a view's folder while revealing a current table editor", async () => {
useDatabase.setState({
databases: { pg: { status: "ok", value: { current: "app", names: ["app"] } } },
schemas: { pg: { status: "ok", value: ["public"] } },
tables: { "pg\u0000public": { status: "ok", value: [{ name: "users", kind: "view" }] } },
});
await revealInTree("pg", "public", "users", undefined);
expect(useDatabase.getState().treeReveal?.path.at(-1)).toBe("pg/public/views/users");
expect(ui.show).toHaveBeenCalledWith("database");
expect(backend.connect).not.toHaveBeenCalled();
});
it("preserves successful write results without restoring a stale connection", async () => {
const response = deferred<number>();
const pending = withConnection("pg", () => response.promise);
await disconnect("pg");
response.resolve(7);
expect(await pending).toBe(7);
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("does not prompt for credentials after the request becomes stale", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg");
await disconnect("pg");
response.resolve({ status: "error", error: { kind: "permissionDenied", message: "authentication failed" } });
let settled = false;
void pending.then(() => {
settled = true;
});
await vi.waitFor(() => expect(settled || useDatabase.getState().dialog?.kind === "password").toBe(true));
const dialog = useDatabase.getState().dialog;
expect.soft(dialog).toBeNull();
if (dialog?.kind === "password") dialog.resolve(false);
await pending;
expect(backend.schemas).toHaveBeenCalledOnce();
});
it("does not retry if disconnected while waiting for a password", async () => {
backend.schemas.mockResolvedValueOnce({
status: "error",
error: { kind: "permissionDenied", message: "authentication failed" },
});
const pending = loadSchemas("pg");
await vi.waitFor(() => expect(useDatabase.getState().dialog?.kind).toBe("password"));
const dialog = useDatabase.getState().dialog;
if (dialog?.kind !== "password") throw new Error("Expected password prompt");
await disconnect("pg");
useDatabase.setState({ dialog: null });
dialog.resolve(true);
await pending;
expect(backend.schemas).toHaveBeenCalledOnce();
expect(useDatabase.getState().schemas.pg).toBeUndefined();
});
it("still retries a current request after a password is supplied", async () => {
backend.schemas.mockResolvedValueOnce({
status: "error",
error: { kind: "permissionDenied", message: "authentication failed" },
});
const pending = loadSchemas("pg");
await vi.waitFor(() => expect(useDatabase.getState().dialog?.kind).toBe("password"));
const dialog = useDatabase.getState().dialog;
if (dialog?.kind !== "password") throw new Error("Expected password prompt");
useDatabase.setState({ dialog: null });
dialog.resolve(true);
await pending;
expect(backend.schemas).toHaveBeenCalledTimes(2);
expect(useDatabase.getState().schemas.pg?.status).toBe("ok");
});
for (const [name, mutate] of [
["save", () => saveSource({ ...pg, host: "new" }, "secret", "ssh")],
["delete", () => deleteSource("pg")],
["rename", () => renameSource("pg", "Renamed")],
] as const) {
it(`does not continue ${name} in a different project`, async () => {
const response = deferred();
backend.save.mockReturnValueOnce(response.promise);
const pending = mutate();
switchProject(projectB);
response.resolve(ok(null));
await pending;
expect(backend.sources).not.toHaveBeenCalled();
expect(backend.password).not.toHaveBeenCalled();
expect(backend.sshSecret).not.toHaveBeenCalled();
});
}
});
describe("completion requests", () => {
it("deduplicates requests in the same lifetime", async () => {
const response = deferred();
backend.completion.mockReturnValueOnce(response.promise);
const first = loadCompletion("pg");
const second = loadCompletion("pg");
expect(first).toBe(second);
response.resolve(ok(completion));
expect(await first).toEqual(completion);
expect(backend.completion).toHaveBeenCalledOnce();
});
it("ignores completion resolved after disconnect", async () => {
const response = deferred();
backend.completion.mockReturnValueOnce(response.promise);
const pending = loadCompletion("pg");
await disconnect("pg");
response.resolve(ok(completion));
expect(await pending).toBeNull();
expect(useDatabase.getState().completion.pg).toBeUndefined();
});
it("does not reuse old completion or let its cleanup remove a newer request", async () => {
const old = deferred();
const fresh = deferred();
backend.completion.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
const previous = loadCompletion("pg");
await disconnect("pg");
const next = loadCompletion("pg");
expect(next).not.toBe(previous);
old.resolve(ok({ ...completion, defaultSchema: "old" }));
expect(await previous).toBeNull();
expect(loadCompletion("pg")).toBe(next);
fresh.resolve(ok(completion));
expect(await next).toEqual(completion);
expect(backend.completion).toHaveBeenCalledTimes(2);
});
it("lets completion recover after failure", async () => {
backend.completion.mockRejectedValueOnce(new Error("not available"));
expect(await loadCompletion("pg")).toBeNull();
expect(await loadCompletion("pg")).toEqual(completion);
expect(backend.completion).toHaveBeenCalledTimes(2);
});
});
describe("overlapping database operations", () => {
it("failed reconnect does not preserve the old disconnected flag", async () => {
const response = deferred();
backend.disconnect.mockReturnValueOnce(response.promise);
const pending = disconnect("pg");
backend.connect.mockRejectedValueOnce(new Error("new connection failed"));
expect(await connect("pg")).toBe(false);
response.resolve(ok(null));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("successful implicit reconnect survives the old disconnect acknowledgement", async () => {
const response = deferred();
backend.disconnect.mockReturnValueOnce(response.promise);
const pending = disconnect("pg");
await loadSchemas("pg");
response.resolve(ok(null));
await pending;
expect(useDatabase.getState().schemas.pg?.status).toBe("ok");
expect(useDatabase.getState().connected.has("pg")).toBe(true);
});
it("unrelated local connect does not keep an edited source marked connected", async () => {
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
backend.sources.mockResolvedValueOnce(ok([{ ...pg, host: "changed" }, other]));
const pending = loadSources();
await connect("pg2");
response.resolve(ok([]));
await pending;
expect.soft(useDatabase.getState().connected.has("pg2")).toBe(true);
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
});
it("saving a source must not reconnect after a later disconnect", async () => {
const response = deferred();
backend.save.mockReturnValueOnce(response.promise);
useDatabase.setState({ databases: { pg: { status: "ok", value: { current: "app", names: ["app"] } } } });
const pending = saveSource({ ...pg, host: "changed" }, undefined);
await disconnect("pg");
savedSources = [{ ...pg, host: "changed" }, other];
response.resolve(ok(null));
await pending;
expect(backend.databases).not.toHaveBeenCalled();
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("whole-source refresh does not restart a disconnected child database", async () => {
useDatabase.setState({
connected: new Set(["pg", "pg:app", "pg:other"]),
schemas: {
"pg:app": { status: "ok", value: ["public"] },
"pg:other": { status: "ok", value: ["public"] },
},
tables: {
"pg:app\u0000public": { status: "ok", value: [{ name: "users", kind: "table" }] },
"pg:other\u0000public": { status: "ok", value: [{ name: "users", kind: "table" }] },
},
});
const response = deferred();
backend.schemas.mockImplementation((id: string) =>
id === "pg:app" ? response.promise : Promise.resolve(ok(["public"])),
);
const pending = refresh("pg");
await vi.waitFor(() => expect(backend.schemas).toHaveBeenCalledTimes(2));
await disconnect("pg:app");
response.resolve(ok(["public"]));
await pending;
expect(backend.tables.mock.calls.map((c) => c[0])).toEqual(["pg:other"]);
expect(useDatabase.getState().schemas["pg:app"]).toBeUndefined();
});
it("late successful DDL does not refresh metadata after disconnect", async () => {
const response = deferred();
backend.execute.mockReturnValueOnce(response.promise);
const text = "CREATE TABLE test (id int)";
const pending = runSql("pg", [text]);
await disconnect("pg");
response.resolve(ok([{ statement: { text, from: 0, to: text.length }, result: null, error: null }]));
expect(await pending).toBe(true);
expect.soft(backend.databases).not.toHaveBeenCalled();
expect(useDatabase.getState().output).toEqual([]);
});
it("old SQL is not executed in a new project after completion finishes", async () => {
useDatabase.setState({ consoles: { "console.sql": "pg" } });
const response = deferred();
backend.completion.mockReturnValueOnce(response.promise);
const text = "DELETE FROM users";
const pending = executeConsole("console.sql", text, 0, text.length);
await vi.waitFor(() => expect(backend.completion).toHaveBeenCalledOnce());
switchProject(projectB);
response.resolve(ok(completion));
await pending;
expect(backend.execute).not.toHaveBeenCalled();
});
it("querying a source's default database does not freeze a sibling connection snapshot", async () => {
useDatabase.setState({ connected: new Set(["pg", "pg:app"]) });
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
const pending = loadSources();
await loadSchemas("pg");
response.resolve(ok(["pg"]));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(true);
expect(useDatabase.getState().connected.has("pg:app")).toBe(false);
});
-35
View File
@@ -1,35 +0,0 @@
import { useProject } from "@/platform/project";
import { splitTarget } from "./model";
let projectGeneration = 0;
const generations = new Map<string, object>();
useProject.subscribe((state, previous) => {
if (state.project?.root === previous.project?.root) return;
projectGeneration++;
generations.clear();
});
function generation(id: string): object {
let value = generations.get(id);
if (!value) generations.set(id, (value = {}));
return value;
}
/** A return to the same project or target is a new lifetime, too. */
export function projectRequest(): () => boolean {
const project = projectGeneration;
return () => project === projectGeneration;
}
export function databaseRequest(id: string): () => boolean {
const project = projectRequest();
const sourceId = splitTarget(id).id;
const source = generation(sourceId);
const target = generation(id);
return () => project() && generations.get(sourceId) === source && generations.get(id) === target;
}
export function invalidateDatabaseRequests(id: string) {
generations.set(id, {});
}
+49 -229
View File
@@ -25,10 +25,9 @@ import {
type RequestId,
type SqlStatement,
} from "@/platform/ipc";
import { activeEditor, editorsFor } from "@/editor/api";
import { editorsFor } from "@/editor/api";
import {
editorService,
maybeEditorService,
notifications,
showServiceView,
toolWindowService,
@@ -45,18 +44,14 @@ import {
idleTargets,
newSourceId,
splitTarget,
targetOf,
} from "./model";
import { tableFolder, type DbNode } from "./nodes";
import { SQL_LANGUAGE } from "./sqlLanguage";
import { TABLE_KIND, type TableRef } from "./tableInput";
import type { ExportData, ExportFormat } from "./export";
import type { DbObjectRef } from "./objects";
import { formatted } from "./sql/format";
import { bindParams, findParams } from "./sql/params";
import { isReadOnlyQuery } from "./sql/readOnly";
import { pageFetch } from "./resultView";
import { databaseRequest, invalidateDatabaseRequests, projectRequest } from "./requests";
import { tx } from "@/platform/i18n";
export const DB_TOOL_WINDOW = "database";
@@ -185,7 +180,7 @@ interface DatabaseState {
/** Expanded nodes of the Database tree. */
treeExpanded: Set<string>;
/** A tree node to select and scroll to (Go to Declaration from a console). */
treeReveal: { path: string[]; seq: number } | null;
treeReveal: { key: string; seq: number } | null;
/** Last value entered per query parameter name. */
paramValues: Record<string, string>;
/** Data sources in manual-commit mode. */
@@ -289,43 +284,19 @@ export function connectedTargets(id: string): string[] {
}
let sourcesRequest = 0;
const connectionIntents = new Map<string, object>();
const sourceConnectionIntents = new Map<string, object>();
const sourceMutations = new Map<string, object>();
function sourceMutation(id: string): () => boolean {
const project = projectRequest();
const intent = {};
sourceMutations.set(id, intent);
return () => project() && sourceMutations.get(id) === intent;
}
export async function loadSources(): Promise<void> {
const request = ++sourcesRequest;
const current = projectRequest();
const intents = new Map(connectionIntents);
const sourceIntents = new Map(sourceConnectionIntents);
const root = useProject.getState().project?.root;
try {
const [sources, connected] = await Promise.all([call(commands.dbSources()), call(commands.dbConnected())]);
if (request !== sourcesRequest || !current()) return;
if (request !== sourcesRequest || useProject.getState().project?.root !== root) return;
for (const old of useDatabase.getState().sources) {
if (JSON.stringify(old) !== JSON.stringify(sources.find((s) => s.id === old.id))) forget(old.id ?? "");
}
useDatabase.setState((s) => {
const server = new Set(connected);
const targets = new Set([...server, ...s.connected]);
const live = [...targets].filter((target) => {
const sourceId = splitTarget(target).id;
if (!sources.some((source) => source.id === sourceId)) return false;
const changed =
intents.get(target) !== connectionIntents.get(target) ||
sourceIntents.get(sourceId) !== sourceConnectionIntents.get(sourceId);
return changed ? s.connected.has(target) : server.has(target);
});
return { sources, connected: new Set(live), loaded: true };
});
useDatabase.setState({ sources, connected: new Set(connected), loaded: true });
} catch (e) {
if (request !== sourcesRequest || !current()) return;
if (request !== sourcesRequest || useProject.getState().project?.root !== root) return;
useDatabase.setState({ loaded: true });
notifications().notify({ level: "error", title: "Cannot read data sources", body: message(e), key: "db-sources" });
}
@@ -333,19 +304,15 @@ export async function loadSources(): Promise<void> {
/** Drops cached metadata of target `id` (refresh, disconnect); a data source id drops all its databases. */
function forget(id: string) {
invalidateDatabaseRequests(id);
const whole = !splitTarget(id).database;
const ofTarget = (t: string) => t === id || (whole && splitTarget(t).id === id);
const dropKeys = <T>(m: Record<string, T>) =>
Object.fromEntries(Object.entries(m).filter(([k]) => !ofTarget(k.split("\u0000")[0]!)));
useDatabase.setState((s) => {
const databases = { ...s.databases };
const catalogs = { ...s.catalogs };
if (whole) delete databases[id];
if (whole) delete catalogs[id];
return {
databases,
catalogs,
schemas: dropKeys(s.schemas),
completion: dropKeys(s.completion),
tables: dropKeys(s.tables),
@@ -360,30 +327,22 @@ export async function saveSource(
password: string | null | undefined,
sshSecret: string | null | undefined = undefined,
): Promise<void> {
const id = source.id ?? "";
const st = useDatabase.getState();
const loaded = id in st.databases || Object.keys(st.schemas).some((t) => splitTarget(t).id === id);
// Taken before anything is forgotten: what the tree had expanded under this source.
const levels = loadedLevels(id);
const connection = connectionIntents.get(id);
forget(id);
const current = sourceMutation(id);
const sources = useDatabase.getState().sources;
const next = sources.some((s) => s.id === source.id)
? sources.map((s) => (s.id === source.id ? source : s))
: [...sources, source];
await call(commands.dbSaveSources(next));
if (!current()) return;
// `undefined`: keep; `null`: forget; string: store.
const id = source.id ?? "";
if (password !== undefined) await call(commands.dbSetPassword(id, password, !!source.savePassword));
if (!current()) return;
if (sshSecret !== undefined) await call(commands.dbSetSshSecret(id, sshSecret, !!source.ssh?.saveSecret));
if (!current()) return;
// What the tree had loaded for this source is reloaded with the new settings, so its
// expanded nodes don't stay on Loading.
const st = useDatabase.getState();
const loaded = id in st.databases || Object.keys(st.schemas).some((t) => splitTarget(t).id === id);
await loadSources();
if (!current() || !sourceById(id)) return;
if (loaded && connectionIntents.get(id) === connection) await refresh(id, levels);
if (loaded) await refresh(id);
else forget(id);
}
/** Copy/Paste › Duplicate: a copy of data source `id` named `<name> [2]` (its password is not copied). */
@@ -407,10 +366,7 @@ export async function renameSource(id: string, name: string): Promise<void> {
const src = sourceById(id);
if (!src || !name.trim() || name.trim() === src.name) return;
const sources = useDatabase.getState().sources.map((s) => (s.id === src.id ? { ...s, name: name.trim() } : s));
forget(id);
const current = sourceMutation(id);
await call(commands.dbSaveSources(sources));
if (!current()) return;
await loadSources();
}
@@ -428,10 +384,8 @@ export async function removeSource(source: DataSource): Promise<void> {
}
export async function deleteSource(id: string): Promise<void> {
forget(id);
const current = sourceMutation(id);
await call(commands.dbSaveSources(useDatabase.getState().sources.filter((s) => s.id !== id)));
if (!current()) return;
forget(id);
await loadSources();
}
@@ -500,13 +454,9 @@ function applyConnectDefaults(id: string) {
/** Runs `op`, asking for passwords / host key trust and retrying when the connection needs them. */
export async function withConnection<T>(id: string, op: () => Promise<T>): Promise<T> {
const current = databaseRequest(id);
for (;;) {
try {
const r = await op();
// A completed write keeps its result, but must not revive an old connection.
if (!current()) return r;
connectionIntents.set(id, {});
lastUsed.set(id, Date.now());
if (!useDatabase.getState().connected.has(id)) {
useDatabase.setState((s) => ({ connected: new Set(s.connected).add(id) }));
@@ -514,19 +464,16 @@ export async function withConnection<T>(id: string, op: () => Promise<T>): Promi
}
return r;
} catch (e) {
if (!current()) throw new IpcError({ kind: "cancelled", message: "Cancelled", data: null });
const source = sourceById(id);
if (!source || !(await resolveConnectError(e, source))) throw e;
if (!current()) throw new IpcError({ kind: "cancelled", message: "Cancelled", data: null });
}
}
}
export async function connect(id: string): Promise<boolean> {
const current = databaseRequest(id);
try {
await withConnection(id, () => call(commands.dbConnect(id)));
return current();
return true;
} catch (e) {
if (!isCancelled(e))
notifications().notify({ level: "error", title: `Cannot connect to ${targetLabel(id)}`, body: message(e) });
@@ -538,21 +485,11 @@ export async function connect(id: string): Promise<boolean> {
export async function disconnect(id: string): Promise<void> {
const targets = splitTarget(id).database ? [id] : connectedTargets(id);
if (!targets.includes(id)) targets.push(id);
const current = projectRequest();
forget(id);
const intent = {};
for (const target of targets) connectionIntents.set(target, intent);
if (!splitTarget(id).database) sourceConnectionIntents.set(id, {});
await Promise.all(targets.map((t) => call(commands.dbDisconnect(t))));
if (!current()) return;
if (!splitTarget(id).database) sourceConnectionIntents.set(id, {});
forget(id);
useDatabase.setState((s) => {
const connected = new Set(s.connected);
for (const t of targets) {
if (connectionIntents.get(t) !== intent) continue;
connected.delete(t);
connectionIntents.set(t, {});
}
for (const t of targets) connected.delete(t);
return { connected };
});
}
@@ -590,14 +527,11 @@ export function listsDatabases(source: DataSource | undefined): boolean {
);
}
interface LoadedLevels {
schemas: string[];
tables: [string, string][];
details: [string, string, string][];
}
/** The metadata of target `id` (a data source id: all its databases) the tree has loaded. */
function loadedLevels(id: string): LoadedLevels {
/**
* Re-reads the metadata of target `id` (a data source id: all its databases),
* reloading the levels that were loaded so the tree keeps its expanded nodes.
*/
export async function refresh(id: string): Promise<void> {
const whole = !splitTarget(id).database;
const ofTarget = (k: string) => {
const t = k.split("\u0000")[0]!;
@@ -606,131 +540,86 @@ function loadedLevels(id: string): LoadedLevels {
const st = useDatabase.getState();
const loaded = (m: Record<string, Loaded<unknown>>) =>
Object.keys(m).filter((k) => ofTarget(k) && m[k]!.status === "ok");
return {
schemas: loaded(st.schemas),
tables: loaded(st.tables).map((k) => k.split("\u0000") as [string, string]),
details: loaded(st.details).map((k) => k.split("\u0000") as [string, string, string]),
};
}
/**
* Re-reads the metadata of target `id` (a data source id: all its databases),
* reloading the levels that were loaded (`levels`) so the tree keeps its expanded nodes.
*/
export async function refresh(id: string, levels = loadedLevels(id)): Promise<void> {
const whole = !splitTarget(id).database;
const { tables, details } = levels;
const schemas = [...levels.schemas];
const schemas = loaded(st.schemas);
const tables = loaded(st.tables).map((k) => k.split("\u0000") as [string, string]);
const details = loaded(st.details).map((k) => k.split("\u0000") as [string, string, string]);
forget(id);
const current = databaseRequest(id);
const targets = new Map(
[...new Set([...schemas, ...tables.map(([t]) => t), ...details.map(([t]) => t)])].map((target) => [
target,
databaseRequest(target),
]),
);
const valid = (target: string) => targets.get(target)?.() ?? current();
if (whole && listsDatabases(sourceById(id))) await loadDatabases(id);
else if (!schemas.includes(id)) schemas.push(id);
if (!current()) return;
await Promise.all(schemas.filter(valid).map((t) => loadSchemas(t)));
if (!current()) return;
await Promise.all(tables.filter(([t]) => valid(t)).map(([t, schema]) => loadTables(t, schema)));
if (!current()) return;
await Promise.all(schemas.map((t) => loadSchemas(t)));
await Promise.all(tables.map(([t, schema]) => loadTables(t, schema)));
const exists = (t: string, schema: string, table: string) => {
const l = useDatabase.getState().tables[tablesKey(t, schema)];
return l?.status === "ok" && l.value.some((x) => x.name === table);
};
await Promise.all(
details.filter((d) => valid(d[0]) && exists(...d)).map(([t, schema, table]) => loadDetails(t, schema, table)),
);
}
function metadataRequest(id: string, cache: "databases" | "schemas" | "tables" | "details", key = id) {
const current = databaseRequest(id);
const loading = { status: "loading" as const };
useDatabase.setState((s) => ({ ...s, [cache]: { ...s[cache], [key]: loading } }));
return () => current() && useDatabase.getState()[cache][key] === loading;
await Promise.all(details.filter((d) => exists(...d)).map(([t, schema, table]) => loadDetails(t, schema, table)));
}
export async function loadDatabases(id: string): Promise<void> {
const current = metadataRequest(id, "databases");
useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: { status: "loading" } } }));
try {
const value = await withConnection(id, () => call(commands.dbDatabases(id)));
if (!current()) return;
useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: { status: "ok", value } } }));
} catch (e) {
if (!current()) return;
useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: { status: "error", message: message(e) } } }));
}
}
export async function loadSchemas(id: string): Promise<void> {
const current = metadataRequest(id, "schemas");
useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: { status: "loading" } } }));
try {
const value = await withConnection(id, () => call(commands.dbSchemas(id)));
if (!current()) return;
useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: { status: "ok", value } } }));
} catch (e) {
if (!current()) return;
useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: { status: "error", message: message(e) } } }));
}
}
export async function loadTables(id: string, schema: string): Promise<void> {
const key = tablesKey(id, schema);
const current = metadataRequest(id, "tables", key);
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "loading" } } }));
try {
const value = await withConnection(id, () => call(commands.dbTables(id, schema)));
if (!current()) return;
// Routines / sequences are extras: a server refusing their catalog still lists tables.
const objects = await call(commands.dbSchemaObjects(id, schema)).catch(() => null);
if (!current()) return;
useDatabase.setState((s) => ({
tables: { ...s.tables, [key]: { status: "ok", value } },
objects: objects ? { ...s.objects, [key]: objects } : s.objects,
}));
} catch (e) {
if (!current()) return;
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "error", message: message(e) } } }));
}
}
export async function loadDetails(id: string, schema: string, table: string): Promise<void> {
const key = detailsKey(id, schema, table);
const current = metadataRequest(id, "details", key);
useDatabase.setState((s) => ({ details: { ...s.details, [key]: { status: "loading" } } }));
try {
const value = await withConnection(id, () => call(commands.dbTableDetails(id, schema, table)));
if (!current()) return;
useDatabase.setState((s) => ({ details: { ...s.details, [key]: { status: "ok", value } } }));
} catch (e) {
if (!current()) return;
useDatabase.setState((s) => ({ details: { ...s.details, [key]: { status: "error", message: message(e) } } }));
}
}
/**
* Selects a table (or one of its columns) in the Database tool window,
* loading and expanding the nodes that lead to it. Without `kind` the loaded
* tables tell whether it is a table or a view.
* loading and expanding the nodes that lead to it.
*/
export async function revealInTree(
id: string,
schema: string,
table: string,
kind: DbTableKind | undefined,
kind: DbTableKind,
column?: string,
): Promise<void> {
const current = databaseRequest(id);
const loaded = (l: Loaded<unknown> | undefined) => l?.status === "ok";
const { id: sourceId, database } = splitTarget(id);
let databaseNode: string[] = [];
if (listsDatabases(sourceById(id))) {
if (!loaded(useDatabase.getState().databases[sourceId])) {
if (!(await connect(sourceId))) return;
if (!current()) return;
await loadDatabases(sourceId);
if (!current()) return;
}
const dbs = useDatabase.getState().databases[sourceId];
const name = database ?? (dbs?.status === "ok" ? dbs.value.current : null);
@@ -738,110 +627,47 @@ export async function revealInTree(
}
if (!loaded(useDatabase.getState().schemas[id])) {
if (!(await connect(id))) return;
if (!current()) return;
await loadSchemas(id);
if (!current()) return;
}
if (!loaded(useDatabase.getState().tables[tablesKey(id, schema)])) await loadTables(id, schema);
if (!current()) return;
if (column && !loaded(useDatabase.getState().details[detailsKey(id, schema, table)]))
await loadDetails(id, schema, table);
if (!current()) return;
const tables = useDatabase.getState().tables[tablesKey(id, schema)];
kind ??= (tables?.status === "ok" ? tables.value.find((t) => t.name === table)?.kind : undefined) ?? "table";
const folder = `${id}/${schema}/${tableFolder(kind)}`;
const tableKey = `${folder}/${table}`;
selectInTree(
[sourceId, ...databaseNode, `${id}/${schema}`, folder, ...(column ? [tableKey, `${tableKey}/columns`] : [])],
column ? `${tableKey}/columns/${column}` : tableKey,
);
}
/**
* Expands `open` and selects node `key` in the Database tool window, showing it.
* A node the tree doesn't show (filtered out, dropped) selects its nearest shown ancestor.
*/
function selectInTree(open: string[], key: string) {
const open = [
sourceId,
...databaseNode,
`${id}/${schema}`,
folder,
...(column ? [tableKey, `${tableKey}/columns`] : []),
];
useDatabase.setState((s) => ({
treeExpanded: new Set([...s.treeExpanded, ...open]),
treeReveal: { path: [...open, key], seq: (s.treeReveal?.seq ?? 0) + 1 },
treeReveal: { key: column ? `${tableKey}/columns/${column}` : tableKey, seq: (s.treeReveal?.seq ?? 0) + 1 },
}));
// Not `show(…, true)`: activation focuses the filter field after the view focuses the tree.
toolWindowService().show(DB_TOOL_WINDOW);
toolWindowService().show(DB_TOOL_WINDOW, true);
}
/**
* Select Opened File: the active table editor's table, else the schema (or
* database / data source) a SQL console runs in. A console reveals only nodes
* already loaded: it never connects.
*/
export async function revealOpened(): Promise<void> {
const input = maybeEditorService()?.activeInput?.();
if (input?.kind === TABLE_KIND) {
const { id, schema, table } = input.data as TableRef;
return revealInTree(id, schema, table, undefined);
}
const editor = activeEditor();
if (editor?.ctx.language !== SQL_LANGUAGE || (input && input.path !== editor.ctx.path)) return;
const target = consoleSource(editor.ctx.path);
if (!target) return;
const s = useDatabase.getState();
const { id: sourceId, database } = splitTarget(target);
const open: string[] = [];
let key = sourceId;
let nodeTarget = target;
if (listsDatabases(sourceById(sourceId))) {
const dbs = s.databases[sourceId];
const name = database ?? (dbs?.status === "ok" ? dbs.value.current : null);
if (dbs?.status !== "ok" || !name) return selectInTree(open, key);
open.push(key);
key = databaseKey(sourceId, name);
nodeTarget = targetOf(sourceId, name, dbs.value.current);
}
const schema = consoleSchema(editor.ctx.path);
const schemas = s.schemas[nodeTarget];
if (schema && schemas?.status === "ok" && schemas.value.includes(schema)) {
open.push(key);
key = `${nodeTarget}/${schema}`;
}
selectInTree(open, key);
}
const completionLoads = new Map<string, { current: () => boolean; promise: Promise<DbCompletion | null> }>();
const completionLoads = new Map<string, Promise<DbCompletion | null>>();
/** The schema model of `id` for completion (cached; one load at a time). */
export function loadCompletion(id: string): Promise<DbCompletion | null> {
const cached = useDatabase.getState().completion[id];
if (cached) return Promise.resolve(cached);
let pending = completionLoads.get(id);
if (!pending?.current()) {
const current = databaseRequest(id);
const promise = call(commands.dbCompletion(id))
let p = completionLoads.get(id);
if (!p) {
p = call(commands.dbCompletion(id))
.then((model) => {
if (!current()) return null;
useDatabase.setState((s) => ({ completion: { ...s.completion, [id]: model } }));
return model;
})
.catch(() => null)
.finally(() => {
if (completionLoads.get(id)?.promise === promise) completionLoads.delete(id);
});
pending = { current, promise };
completionLoads.set(id, pending);
.finally(() => completionLoads.delete(id));
completionLoads.set(id, p);
}
return pending.promise;
return p;
}
useProject.subscribe((state, previous) => {
if (state.project?.root === previous.project?.root) return;
sourcesRequest++;
connectionIntents.clear();
sourceConnectionIntents.clear();
sourceMutations.clear();
completionLoads.clear();
lastUsed.clear();
});
// ---- consoles ----------------------------------------------------------------
export function bindConsole(path: string, id: string | null) {
@@ -958,12 +784,10 @@ async function run(
skip = 0,
schema?: string,
): Promise<DbStatementOutcome[]> {
const current = databaseRequest(id);
const wasConnected = useDatabase.getState().connected.has(id);
const outcomes = await withConnection(id, () =>
call(commands.dbExecute(request, id, statements, skip, maxRows, schema || null)),
);
if (!current()) return outcomes;
if (!wasConnected) log("info", "Connected");
const prompt = schema || promptOf(id);
for (const o of outcomes) {
@@ -1101,9 +925,8 @@ export async function executeConsole(
});
return [];
}
const current = databaseRequest(id);
const statements = await consoleStatements(id, text, from, to);
if (!current() || !statements.length) return [];
if (!statements.length) return [];
const prev = useDatabase.getState().running[path];
if (prev) void commands.cancelRequest(prev);
const request = nextRequestId();
@@ -1111,9 +934,7 @@ export async function executeConsole(
try {
const pageSize = useDatabase.getState().maxRows;
const schema = consoleSchema(path) || (await loadCompletion(id))?.defaultSchema || "";
if (!current()) return [];
const outcomes = await run(id, statements, request, pageSize, 0, schema);
if (!current() || useDatabase.getState().running[path] !== request) return outcomes;
const sets = outcomes.filter((o) => o.result?.columns.length);
const n = sets.length ? nextRun++ : 0;
const tabs: ResultTab[] = sets.map((outcome, i) => {
@@ -1129,7 +950,6 @@ export async function executeConsole(
showServiceView(RESULTS_VIEW, false);
return outcomes;
} catch (e) {
if (!current()) return [];
if (!isCancelled(e)) {
log("error", message(e));
notifications().notify({ level: "error", title: "Cannot execute", body: message(e), key: "db-execute" });
+4 -29
View File
@@ -31,7 +31,6 @@ import {
readableDdl,
refresh,
revealInTree,
revealOpened,
runSql,
sourceById,
tablesKey,
@@ -42,7 +41,6 @@ import {
} from "./store";
import { tableInput } from "./tableInput";
import { tx } from "@/platform/i18n";
import { databaseRequest } from "./requests";
/** Context key: the Database tree has focus. */
export const DB_VIEW_FOCUS = "databaseViewFocus";
@@ -100,25 +98,18 @@ async function tablesIn(id: string, schema: string): Promise<DbTableInfo[]> {
async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
const id = nodeTarget(n);
if (!id) return null;
const current = databaseRequest(id);
const one = async (schema: string, table: string) =>
readableDdl(await withConnection(id, () => call(commands.dbDdl(id, schema, table))), driverOf(id));
try {
const o = schemaObjectOf(n);
if (o) {
const text = await withConnection(id, () => call(commands.dbObjectDdl(id, o.schema, o.kind, o.name)));
return current() ? { id, text } : null;
}
if (o)
return { id, text: await withConnection(id, () => call(commands.dbObjectDdl(id, o.schema, o.kind, o.name))) };
const t = tableOf(n);
if (t) {
const text = await one(t.schema, t.table);
return current() ? { id, text } : null;
}
if (t) return { id, text: await one(t.schema, t.table) };
let schemas: string[] = [];
if (n.kind === "schema" || n.kind === "folder") schemas = [n.schema];
else if (n.kind === "source" || n.kind === "database") {
if (useDatabase.getState().schemas[id]?.status !== "ok") await loadSchemas(id);
if (!current()) return null;
const l = useDatabase.getState().schemas[id];
schemas = l?.status === "ok" ? l.value : [];
}
@@ -127,17 +118,12 @@ async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
const tables = (await tablesIn(id, schema)).filter(
(t) => n.kind !== "folder" || tableFolder(t.kind) === n.folder,
);
if (!current()) return null;
// Tables before views: views select from them.
tables.sort((a, b) => Number(a.kind !== "table") - Number(b.kind !== "table"));
for (const t of tables) {
parts.push(await one(schema, t.name));
if (!current()) return null;
}
for (const t of tables) parts.push(await one(schema, t.name));
}
return { id, text: parts.join("\n") };
} catch (e) {
if (!current()) return null;
notifications().notify({
level: "error",
title: "Cannot generate the DDL",
@@ -151,10 +137,8 @@ async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
export async function insertScript(n: DbNode, kind: ScriptKind) {
const t = tableOf(n);
if (!t) return;
const current = databaseRequest(t.id);
const key = detailsKey(t.id, t.schema, t.table);
if (useDatabase.getState().details[key]?.status !== "ok") await loadDetails(t.id, t.schema, t.table);
if (!current()) return;
const d = useDatabase.getState().details[key];
const columns = d?.status === "ok" ? d.value.columns : [];
try {
@@ -501,15 +485,6 @@ export function registerDatabaseTreeActions(): () => void {
title: "Separate Procedures and Functions",
run: toggleSeparateProceduresAndFunctions,
}),
tree({
id: "db.selectOpened",
title: "Select Opened File",
icon: "locate",
keywords: ["locate", "scroll from editor", "scroll from source", "select in database view"],
// From the editor: a user-assigned shortcut works outside the Database view too.
keyWhen: undefined,
run: () => void revealOpened(),
}),
tree({
id: "db.expandAll",
title: "Expand All",
-5
View File
@@ -130,11 +130,6 @@ export function configIcon(config: Pick<RunConfig, "kind" | "isTest">): string {
return "file-csharp";
case "go":
return "file-go";
case "rebar3":
case "escript":
return "file-erlang";
case "mix":
return "file-elixir";
case "node":
return "file-js";
case "application":
-5
View File
@@ -71,7 +71,6 @@ const ALL = env({
"clang++": null,
dart: null,
elixir: null,
escript: null,
"scala-cli": null,
deno: "2.1.0",
bun: "1.1.0",
@@ -246,10 +245,6 @@ describe("single-file producers (scratches)", () => {
expect(run(scratch("a.swift"))).toMatchObject({ command: "/bin/swift", args: ["a.swift"] });
expect(run(scratch("a.dart"))).toMatchObject({ command: "/bin/dart", args: ["run", "a.dart"] });
expect(run(scratch("a.exs"))).toMatchObject({ command: "/bin/elixir", args: ["a.exs"] });
expect(run(scratch("tool.escript"))).toMatchObject({
command: "/bin/escript",
args: ["tool.escript"],
});
expect(run(scratch("a.sc"))).toMatchObject({ command: "/bin/scala-cli", args: ["run", "a.sc"] });
expect(run(scratch("a.kts"))).toMatchObject({ command: "/bin/kotlin", args: ["a.kts"] });
expect(run(scratch("a.kts"), env({ kotlinc: null }))).toMatchObject({
-6
View File
@@ -220,7 +220,6 @@ export type ScriptLanguage =
| "cpp"
| "dart"
| "elixir"
| "escript"
| "scala"
| "shebang";
@@ -260,7 +259,6 @@ export function scriptLanguage(path: string, content?: string): ScriptLanguage |
if (/\.(cpp|cc|cxx|c\+\+)$/.test(name)) return "cpp";
if (/\.dart$/.test(name)) return "dart";
if (/\.exs$/.test(name)) return "elixir";
if (/\.escript$/.test(name)) return "escript";
if (/\.(scala|sc)$/.test(name)) return "scala";
if (!name.includes(".") && shebang(content)) return "shebang";
return null;
@@ -315,8 +313,6 @@ export function toolsFor(lang: ScriptLanguage, content?: string): string[] {
return ["dart"];
case "elixir":
return ["elixir"];
case "escript":
return ["escript"];
case "scala":
return ["scala-cli"];
}
@@ -342,7 +338,6 @@ const INSTALL: Record<string, string> = {
cpp: "Install a C++ compiler: `xcode-select --install` (clang++) or `brew install gcc`.",
dart: "Install the Dart SDK (`brew install dart-sdk`).",
elixir: "Install Elixir (`brew install elixir`).",
escript: "Install Erlang/OTP (`brew install erlang`).",
scala: "Install Scala CLI (`brew install Virtuslab/scala-cli/scala-cli`).",
shell: "Install the shell named in the script's #! line.",
shebang: "Install the interpreter named in the script's #! line.",
@@ -910,7 +905,6 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
case "lua":
case "swift":
case "elixir":
case "escript":
return simple(lang, s, env, lang, () => [s.arg]);
case "r":
return simple(lang, s, env, "r", () => [s.arg]);
+1 -1
View File
@@ -25,7 +25,7 @@ import { loadEmmet } from "./emmet/model";
import { promptAbbreviation, UpdateTagDialogHost } from "./emmet/UpdateTagDialog";
import { tx } from "@/platform/i18n";
const EDITOR_WHEN = "textEditorActive && !editorReadOnly && (editorFocus || !toolWindowFocus)";
const EDITOR_WHEN = "textEditorActive && !editorReadOnly && (editorFocus || popupOpen || !toolWindowFocus)";
const MARKUP_PATH = /\.(html?|xhtml|shtml|vue|svelte|xml)$/i;
async function updateTagWithEmmet() {
@@ -124,8 +124,7 @@
color: var(--fg-muted);
}
.rowName {
flex: 0 1 auto;
min-width: 0;
flex: none;
max-width: 60%;
overflow: hidden;
color: var(--fg-strong);
@@ -151,13 +150,6 @@
font-size: var(--fs-tiny);
line-height: 16px;
}
.rowBadge {
flex: none;
}
.updateTag {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
}
.listEmpty {
padding: 8px 12px;
color: var(--fg-muted);
+7 -31
View File
@@ -16,7 +16,6 @@ import { languageNames, NewServerDialog, ServerSettings, ToolSettings, useLangua
import { serverKey, type ServerEntry } from "./serverConfig";
import {
cancelInstall,
checkToolUpdates,
installTool,
refreshTools,
STATUS_LABEL,
@@ -99,14 +98,12 @@ function ToolRow({
selected,
off,
pending,
update,
onSelect,
}: {
tool: ToolInfo;
selected: boolean;
off: boolean;
pending: boolean;
update: boolean;
onSelect: () => void;
}) {
return (
@@ -119,7 +116,7 @@ function ToolRow({
title={`${tool.displayName} — ${tool.languages.join(", ")}`}
onClick={onSelect}
>
<Badge dot className={styles.rowBadge} tone={pending ? "accent" : STATUS_TONE[tool.status]}>
<Badge dot tone={pending ? "accent" : STATUS_TONE[tool.status]}>
{pending ? tx("New") : STATUS_LABEL[tool.status]}
</Badge>
<span className={styles.rowName}>{tool.displayName}</span>
@@ -138,11 +135,6 @@ function ToolRow({
<T>Disabled</T>
</span>
)}
{update && (
<span className={clsx(styles.kindTag, styles.updateTag)}>
<T>Update</T>
</span>
)}
<span className={styles.rowLanguages}>{tool.languages.join(", ")}</span>
</button>
</li>
@@ -152,7 +144,6 @@ function ToolRow({
function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
const job = useTools((s) => s.jobs[tool.name]);
const last = useTools((s) => s.logs[tool.name]);
const update = useTools((s) => (tool.status === "installed" ? s.updates[tool.name] : undefined));
const [logOpen, setLogOpen] = useState(false);
const log = job?.lines ?? last?.lines ?? [];
const lastError = !job && last?.error !== "Cancelled" ? last?.error : null;
@@ -194,21 +185,17 @@ function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
{tool.canInstall && (
<Button
size="small"
variant={tool.status === "missing" || update ? "primary" : "secondary"}
variant={tool.status === "missing" ? "primary" : "secondary"}
disabled={busy}
title={
tool.installMethod ? tx`Installs into the IDE's own tools folder via ${tool.installMethod}` : undefined
}
onClick={() => void installTool(tool.name)}
aria-label={
update
? tx`Update ${tool.displayName}`
: tool.status === "installed"
? tx`Reinstall ${tool.displayName}`
: tx`Install ${tool.displayName}`
tool.status === "installed" ? tx`Reinstall ${tool.displayName}` : tx`Install ${tool.displayName}`
}
>
{update ? "Update" : tool.status === "installed" ? "Reinstall" : "Install"}
{tool.status === "installed" ? "Reinstall" : "Install"}
</Button>
)}
{tool.canUninstall && (
@@ -218,11 +205,6 @@ function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
)}
</div>
</div>
{update && !job && (
<div className={styles.notice} role="note">
{tx`Version ${update.latest} is available.`}
</div>
)}
{job && <JobRow name={tool.displayName} job={job} />}
{lastError && (
<div className={styles.error} role="alert">
@@ -262,7 +244,7 @@ function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
}
export function LanguageServersPage() {
const { tools, loaded, loading, error, updates, checkingUpdates } = useTools();
const { tools, loaded, loading, error } = useTools();
const form = useSettingsForm();
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<Filter>("all");
@@ -275,8 +257,6 @@ export function LanguageServersPage() {
useEffect(() => {
void refreshTools();
// Asks each install source once per session; the refresh button asks again.
if (!useTools.getState().updatesChecked) void checkToolUpdates();
// Applied paths and servers change what is found and listed.
return onIdeEvent("settingsChanged", (e) => {
if (!e.key || /^(tools|languageServers)(\.|$)/.test(e.key)) void refreshTools();
@@ -327,7 +307,6 @@ export function LanguageServersPage() {
selected={t === selected}
off={servers[t.name]?.enabled === false}
pending={pending.has(t.name)}
update={t.status === "installed" && !!updates[t.name]}
onSelect={() => setSelectedName(t.name)}
/>
))}
@@ -358,17 +337,14 @@ export function LanguageServersPage() {
onClick={() => selected && form.set(serverKey(selected.name), null)}
/>
<span className={styles.toolbarSep} aria-hidden />
{loading || checkingUpdates ? (
{loading ? (
<Spinner size={14} label="Checking tools" />
) : (
<IconButton
icon="refresh"
size="small"
title="Re-check installed tools"
onClick={() => {
void refreshTools();
void checkToolUpdates();
}}
onClick={() => void refreshTools()}
/>
)}
<SearchField
+1 -27
View File
@@ -19,17 +19,7 @@ beforeEach(() => {
provideNotificationService({ notify: (n) => void notes.push(n) });
backend = new MemoryToolsBackend(sampleTools(), 1);
setToolsBackend(backend);
useTools.setState({
tools: [],
loaded: false,
loading: false,
error: null,
jobs: {},
logs: {},
updates: {},
checkingUpdates: false,
updatesChecked: false,
});
useTools.setState({ tools: [], loaded: false, loading: false, error: null, jobs: {}, logs: {} });
});
afterEach(() => setToolsBackend(null));
@@ -274,22 +264,6 @@ describe("LanguageServersPage", () => {
expect(screen.getByLabelText("gopls install log")).toHaveTextContent("Installing gopls");
});
it("offers an update when a newer version of an installed tool is out", async () => {
const user = userEvent.setup();
backend.available = [{ name: "rust-analyzer", current: "1.85.0", latest: "1.86.0" }];
renderPage();
await waitFor(() => expect(listItem("rust-analyzer")).toHaveTextContent("Update"));
expect(listItem("gopls")).not.toHaveTextContent("Update");
await user.click(listItem("rust-analyzer"));
const details = screen.getByLabelText("rust-analyzer details");
expect(details).toHaveTextContent("Version 1.86.0 is available.");
await user.click(within(details).getByRole("button", { name: "Update rust-analyzer" }));
await waitFor(() => expect(listItem("rust-analyzer")).not.toHaveTextContent("Update"));
expect(
within(screen.getByLabelText("rust-analyzer details")).getByRole("button", { name: "Reinstall rust-analyzer" }),
).toBeInTheDocument();
});
it("explains why a tool cannot be installed", async () => {
const user = userEvent.setup();
renderPage();
+1 -10
View File
@@ -3,7 +3,7 @@
* simulation in plain-browser development and tests (no network, ever).
*/
import { Channel } from "@tauri-apps/api/core";
import type { InstallEvent, RequestId, ToolInfo, ToolUpdate } from "@/bindings";
import type { InstallEvent, RequestId, ToolInfo } from "@/bindings";
import { call, commands, IpcError, isTauri } from "@/platform/ipc";
export interface LanguageChoice {
@@ -15,8 +15,6 @@ export interface ToolsBackend {
list(): Promise<ToolInfo[]>;
install(name: string, request: RequestId, onEvent: (e: InstallEvent) => void): Promise<ToolInfo>;
uninstall(name: string): Promise<ToolInfo>;
/** Newer versions of the tools the IDE installed (release downloads and npm packages). */
updates(request: RequestId): Promise<ToolUpdate[]>;
cancel(request: RequestId): void;
/** Restarts the language servers named `name` in every open project (after an install). */
restartServers(name: string): Promise<void>;
@@ -34,7 +32,6 @@ export const tauriToolsBackend: ToolsBackend = {
return call(commands.toolsInstall(name, request, channel));
},
uninstall: (name) => call(commands.toolsUninstall(name)),
updates: (request) => call(commands.toolsUpdates(request)),
cancel: (request) => void commands.cancelRequest(request).catch(() => {}),
// Every open project: Settings belongs to one project window, but all of them run the tool.
restartServers: async (name) => {
@@ -153,8 +150,6 @@ export class MemoryToolsBackend implements ToolsBackend {
/** Delay between simulated steps (ms). */
stepMs: number;
failNext: string | null = null;
/** What `updates` reports, for the tools that are installed. */
available: ToolUpdate[] = [];
private cancelled = new Set<RequestId>();
constructor(tools: ToolInfo[] = sampleTools(), stepMs = 120) {
@@ -166,10 +161,6 @@ export class MemoryToolsBackend implements ToolsBackend {
return this.tools.map((t) => ({ ...t }));
}
async updates() {
return this.available.filter((u) => this.tools.some((t) => t.name === u.name && t.status === "installed"));
}
private replace(next: ToolInfo) {
this.tools = this.tools.map((t) => (t.name === next.name ? next : t));
return { ...next };
+1 -32
View File
@@ -1,6 +1,6 @@
/** Tool list + running installs. Installs show in the status-bar task list too. */
import { create } from "zustand";
import type { RequestId, ToolInfo, ToolUpdate } from "@/bindings";
import type { RequestId, ToolInfo } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { isCancelled, nextRequestId } from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
@@ -28,11 +28,6 @@ interface ToolsState {
jobs: Readonly<Record<string, InstallJob>>;
/** Output of the last finished install per tool (kept for the log view). */
logs: Readonly<Record<string, { lines: string[]; error: string | null }>>;
/** Newer versions of the tools the IDE installed, from the last check. */
updates: Readonly<Record<string, ToolUpdate>>;
checkingUpdates: boolean;
/** Whether updates were looked for in this session (the Language Servers page looks once). */
updatesChecked: boolean;
}
export const useTools = create<ToolsState>(() => ({
@@ -42,9 +37,6 @@ export const useTools = create<ToolsState>(() => ({
error: null,
jobs: {},
logs: {},
updates: {},
checkingUpdates: false,
updatesChecked: false,
}));
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
@@ -67,25 +59,6 @@ export function refreshTools(): Promise<void> {
return refreshing;
}
let checkingUpdates: Promise<void> | null = null;
/** Looks for newer versions of the tools the IDE installed; offline, none are shown. */
export function checkToolUpdates(): Promise<void> {
checkingUpdates ??= (async () => {
useTools.setState({ checkingUpdates: true });
try {
const found = await toolsBackend().updates(nextRequestId());
useTools.setState({ updates: Object.fromEntries(found.map((u) => [u.name, u])) });
} catch {
// A failed lookup leaves the last result.
} finally {
useTools.setState({ checkingUpdates: false, updatesChecked: true });
checkingUpdates = null;
}
})();
return checkingUpdates;
}
function upsertTool(info: ToolInfo) {
useTools.setState((s) => {
const i = s.tools.findIndex((t) => t.name === info.name);
@@ -151,10 +124,6 @@ export function installTool(name: string, opts: { notificationKey?: string } = {
});
upsertTool(info);
endJob(name, null);
useTools.setState((s) => {
const { [name]: _updated, ...updates } = s.updates;
return { updates };
});
void toolsBackend()
.restartServers(name)
.catch(() => {});
@@ -5,12 +5,6 @@ import type { RemoteOp } from "@/bindings";
const mocks = vi.hoisted(() => ({
ops: [] as Array<{ repo: string | null; op: RemoteOp }>,
failing: new Set<string>(),
ask: vi.fn(),
}));
vi.mock("@/workbench/messages", async (importOriginal) => ({
...(await importOriginal<typeof import("@/workbench/messages")>()),
ask: mocks.ask,
}));
vi.mock("@tauri-apps/api/core", async (importOriginal) => ({
@@ -56,8 +50,6 @@ let notes: Note[] = [];
beforeEach(() => {
mocks.ops = [];
mocks.failing = new Set();
mocks.ask.mockReset();
mocks.ask.mockResolvedValue({ button: "force", checked: false });
notes = [];
useVcs.setState({ root: "/p", roots: ["/p/a", "/p/b", "/p/c"], repo: "/p/a", remoteBusy: null, available: false });
provideNotificationService({ notify: (n) => void notes.push(n as Note), expire: () => {} });
@@ -118,66 +110,3 @@ it("pushes each repository to its own remote and target branch, reporting the on
await waitFor(() => expect(notes.map((n) => n.title)).toContain("Push partially failed"));
expect(notes.find((n) => n.title === "Push partially failed")?.body).toContain("Not pushed: b.");
});
const forceOf = (ops: typeof mocks.ops) =>
ops.map(({ repo, op }) => [repo, "force_with_lease" in op ? op.force_with_lease : null]);
const askedTargets = () => mocks.ask.mock.calls.map(([r]) => (r as { body: string }).body.match(/to '(.*)'/)?.[1]);
async function openPushDialog() {
render(<VcsDialogHost />);
await act(() => showPushDialog());
await screen.findByRole("textbox", { name: "Target branch of a" });
}
it("asks to confirm each forced target and pushes nothing when one is declined", async () => {
mocks.ask.mockResolvedValueOnce({ button: "force", checked: false }).mockResolvedValueOnce({ button: null });
await openPushDialog();
fireEvent.change(screen.getByRole("textbox", { name: "Target branch of a" }), { target: { value: "feat-a" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Force (with lease)" }));
fireEvent.click(screen.getByRole("button", { name: "Force Push" }));
await waitFor(() => expect(mocks.ask).toHaveBeenCalledTimes(2));
expect(askedTargets()).toEqual(["origin/feat-a", "origin/feat-x"]);
expect(mocks.ops).toEqual([]);
// Declining keeps the dialog open, as in the single-repository dialog.
expect(screen.getByRole("button", { name: "Force Push" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
});
it("force pushes with lease once every target is confirmed", async () => {
await openPushDialog();
fireEvent.click(screen.getByRole("checkbox", { name: "Force (with lease)" }));
fireEvent.click(screen.getByRole("button", { name: "Force Push" }));
await waitFor(() => expect(mocks.ops).toHaveLength(3));
// The three repositories push to the same target: one confirmation.
expect(askedTargets()).toEqual(["origin/feat-x"]);
expect(forceOf(mocks.ops)).toEqual([
["/p/a", true],
["/p/b", true],
["/p/c", true],
]);
});
it("does not ask about a protected target, which is never forced", async () => {
await openPushDialog();
fireEvent.change(screen.getByRole("textbox", { name: "Target branch of a" }), { target: { value: "main" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Force (with lease)" }));
fireEvent.click(screen.getByRole("button", { name: "Force Push" }));
await waitFor(() => expect(mocks.ops).toHaveLength(3));
expect(askedTargets()).toEqual(["origin/feat-x"]);
expect(forceOf(mocks.ops)).toEqual([
["/p/a", false],
["/p/b", true],
["/p/c", true],
]);
});
it("does not ask without Force", async () => {
await openPushDialog();
fireEvent.click(screen.getByRole("button", { name: "Push" }));
await waitFor(() => expect(mocks.ops).toHaveLength(3));
expect(mocks.ask).not.toHaveBeenCalled();
});
-9
View File
@@ -342,15 +342,6 @@ function MultiPushDialog({ only, onClose }: MultiPushDialogProps) {
// Like the single-repository dialog: tags go only with plain pushes, so a retargeted repository hides them.
const retargeted = picked.some((r) => pushOperation(choiceOf(r, null)).op === "pushBranch");
const push = async () => {
// Like the single-repository dialog: each forced target is confirmed first (once, when several
// repositories push to the same one), and declining one pushes nothing.
const forced = new Set<string>();
for (const r of force ? picked : []) {
const target = targetNameOf(r);
const remote = shownRemoteOf(r);
if (!isProtectedBranch(target)) forced.add(remote ? `${remote}/${target}` : target);
}
for (const target of forced) if (!(await confirmForcePush(target))) return;
onClose();
// Every picked repository is pushed even when another fails; the outcome is summed up.
const session: PushSession = { method: null };
+1 -28
View File
@@ -1,13 +1,5 @@
import { expect, it, vi } from "vitest";
import {
executeAction,
executeActionOrThrow,
getAction,
registerAction,
registerActions,
useActions,
useRunningActions,
} from "./actions";
import { executeAction, executeActionOrThrow, registerAction, useRunningActions } from "./actions";
const running = (id: string) => useRunningActions.getState().running.get(id) ?? 0;
@@ -99,22 +91,3 @@ it("checks argument-specific enablement before dispatch", async () => {
dispose();
}
});
it("registers a batch with one store update and disposes every action", () => {
const updates: number[] = [];
const off = useActions.subscribe((s) => updates.push(s.actions.size));
const dispose = registerActions([
{ id: "test.batch.one", title: "One", run: () => {} },
{ id: "test.batch.two", title: "Two", run: () => {} },
]);
try {
expect(updates).toHaveLength(1);
expect(getAction("test.batch.one")?.title).toBe("One");
expect(getAction("test.batch.two")?.title).toBe("Two");
} finally {
dispose();
off();
}
expect(getAction("test.batch.one")).toBeUndefined();
expect(getAction("test.batch.two")).toBeUndefined();
});
+4 -19
View File
@@ -116,9 +116,10 @@ useI18n.subscribe((s, prev) => {
useActions.setState({ actions: new Map([...sources].map(([id, a]) => [id, localize(a)])) });
});
/** Adds `action` to the written sources; returns a disposer that removes it. */
function addSource(action: Action): () => void {
/** Registers an action; returns a disposer. Re-registering an id replaces it. */
export function registerAction(action: Action): () => void {
sources.set(action.id, action);
useActions.setState((s) => ({ actions: new Map(s.actions).set(action.id, localize(action)) }));
return () => {
if (sources.get(action.id) !== action) return;
sources.delete(action.id);
@@ -130,24 +131,8 @@ function addSource(action: Action): () => void {
};
}
/** Registers an action; returns a disposer. Re-registering an id replaces it. */
export function registerAction(action: Action): () => void {
const dispose = addSource(action);
useActions.setState((s) => ({ actions: new Map(s.actions).set(action.id, localize(action)) }));
return dispose;
}
export function registerActions(actions: Action[]): () => void {
const disposers = actions.map(addSource);
// One state update for the batch: features register dozens of actions at start-up,
// and cloning the whole map per action made that O(n²).
if (actions.length) {
useActions.setState((s) => {
const next = new Map(s.actions);
for (const action of actions) next.set(action.id, localize(action));
return { actions: next };
});
}
const disposers = actions.map(registerAction);
return () => disposers.forEach((d) => d());
}
+1 -1
View File
@@ -223,7 +223,7 @@ it("renames in the project view although the preset shares Alt+Shift+R with the
const dbRename = vi.fn();
const refactorRename = vi.fn();
// The claimants as they ship: `when` + `keyWhen` exactly as registered.
const EDITOR = "textEditorActive && (editorFocus || !toolWindowFocus)";
const EDITOR = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
const disposers = [
registerAction({
id: "project.rename",
-1
View File
@@ -404,7 +404,6 @@ export const intellijMacos: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.selectOpened": [],
"db.expandAll": [],
"db.collapseAll": [],
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
-1
View File
@@ -402,7 +402,6 @@ export const intellijWindows: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.selectOpened": [],
"db.expandAll": [],
"db.collapseAll": [],
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more