Compare commits

..
Author SHA1 Message Date
l0ng-ai d68315131f fix(dev): apply the worktree's Vite URL when dev:app is given a project path
`pnpm dev:app <path>` appended `--config {devUrl}` after the path, and `tauri dev`
hands the first positional argument and everything after it to the app. The config was
dropped: tauri ran `pnpm dev` on port 1420 as well, and a full reload loaded that server
instead of the worktree's, failing with "Could not load startup script". The config now goes
before the first positional argument.
2026-11-14 01:10:07 +08:00
119 changed files with 304 additions and 3982 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,10 +1,6 @@
import { describe, expect, it } from "vitest";
import {
blockScopedNamespace,
caretAfterReformat,
csprojFileScoped,
csprojRootNamespace,
inferCsharpNamespace,
expandIncludes,
fragmentResolver,
composerPsr4,
@@ -184,43 +180,6 @@ describe("PHP namespaces", () => {
});
});
describe("C# namespaces", () => {
it("come from the project's root namespace and the folders below the .csproj", () => {
expect(csprojRootNamespace('<Project Sdk="Microsoft.NET.Sdk"></Project>', "My.App.csproj")).toBe("My.App");
expect(csprojRootNamespace("<RootNamespace>Acme.Shop</RootNamespace>", "Web.csproj")).toBe("Acme.Shop");
expect(csprojRootNamespace("", "my-app.csproj")).toBe("my_app");
expect(inferCsharpNamespace("/Services/Billing", "Acme.Shop")).toBe("Acme.Shop.Services.Billing");
expect(inferCsharpNamespace("", "Acme.Shop")).toBe("Acme.Shop");
expect(inferCsharpNamespace("/2024-data", "App")).toBe("App._2024_data");
});
it("are file-scoped from C# 10 and blocks before it (.NET Framework and Unity projects)", () => {
const sdk = (tf: string) => `<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup>${tf}</PropertyGroup></Project>`;
expect(csprojFileScoped(sdk("<TargetFramework>net8.0</TargetFramework>"))).toBe(true);
expect(csprojFileScoped(sdk("<TargetFrameworks>net6.0;net8.0</TargetFrameworks>"))).toBe(true);
expect(csprojFileScoped(sdk("<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>"))).toBe(false);
expect(csprojFileScoped(sdk("<TargetFramework>net48</TargetFramework>"))).toBe(false);
expect(csprojFileScoped(sdk("<TargetFramework>net5.0</TargetFramework>"))).toBe(false);
expect(
csprojFileScoped(sdk("<TargetFramework>netstandard2.1</TargetFramework><LangVersion>latest</LangVersion>")),
).toBe(true);
expect(
csprojFileScoped("<TargetFrameworkVersion>v4.7.1</TargetFrameworkVersion><LangVersion>9.0</LangVersion>"),
).toBe(false);
});
it("render C# classes with a file-scoped namespace, a block, or none", () => {
const cls = tpl("csharp").kinds[0]!.body;
const vars = { name: "OrderService", fileName: "OrderService.cs", packageName: "Acme.Shop" };
expect(renderFileTemplate(cls, vars).text).toBe("namespace Acme.Shop;\n\npublic class OrderService\n{\n \n}\n");
expect(renderFileTemplate(blockScopedNamespace(cls), vars).text).toBe(
"namespace Acme.Shop\n{\n public class OrderService\n {\n \n }\n}\n",
);
expect(renderFileTemplate(cls, { name: "Foo", fileName: "Foo.cs" }).text).toBe("public class Foo\n{\n \n}\n");
expect(tpl("csharp").kinds.map((k) => k.label)).toEqual(["Class", "Interface", "Struct", "Enum", "Record"]);
});
});
describe("rendering", () => {
it("substitutes variables and places the caret", () => {
const kind = tpl("java").kinds[0]!;
@@ -53,22 +53,6 @@ describe("plugin package inference uses built-in rules", () => {
);
expect(await packageFor({ ...plugin("java"), fileName: ".dockerignore" }, "/p/src/main/java/com/acme")).toBe("");
});
it("uses the nearest .csproj for C#", async () => {
useProject.setState({ project: { root: "/p", name: "p" } as never });
const csproj = { kind: "file", name: "Shop.csproj", path: "/p/src/Shop/Shop.csproj" };
const readDir = vi
.mocked(commands.fsReadDir)
.mockImplementation((async (dir: string) => (dir === "/p/src/Shop" ? [csproj] : [])) as never);
mocks.read.mockResolvedValue(
"<Project><PropertyGroup><RootNamespace>Acme.Shop</RootNamespace></PropertyGroup></Project>",
);
try {
expect(await packageFor(plugin("cs"), "/p/src/Shop/Orders/Api")).toBe("Acme.Shop.Orders.Api");
expect(await packageFor(plugin("cs"), "/p/tools")).toBe("");
} finally {
readDir.mockReset();
}
});
it("uses an existing Go package", async () => {
useProject.setState({ project: { root: "/p", name: "p" } as never });
mocks.read.mockResolvedValue("package service\n");
@@ -302,11 +302,7 @@ function NameStep({
{error ?? (
<>
{where}
{packageStyle && pkg
? packageStyle === "php" || packageStyle === "csharp"
? ` · namespace ${pkg}`
: ` · package ${pkg}`
: ""}
{packageStyle && pkg ? (packageStyle === "php" ? ` · namespace ${pkg}` : ` · package ${pkg}`) : ""}
</>
)}
</div>
-21
View File
@@ -294,27 +294,6 @@ export const BUILTIN_FILE_TEMPLATES: readonly FileTemplate[] = [
},
],
},
{
id: "csharp",
name: "C# Class",
icon: "class",
extension: "cs",
nameStyle: "pascal",
packageStyle: "csharp",
kinds: (
[
["class", "Class", "class"],
["interface", "Interface", "interface"],
["struct", "Struct", "struct"],
["enum", "Enum", "enum"],
["record", "Record", "record"],
] as const
).map(([id, label, keyword]) => ({
id,
label,
body: `namespace $PACKAGE$;\n\n#parse("File Header")\npublic ${keyword} $NAME$\n{\n $END$\n}\n`,
})),
},
{
id: "kotlin",
name: "Kotlin Class/File",
+2 -33
View File
@@ -10,12 +10,8 @@ import { activeEditor, editorsFor, type TextEditorHandle } from "@/editor/api";
import { editorService, maybeEditorService, notifications, resolveFileEditor } from "@/workbench/contributions";
import {
GO_PACKAGE_RE,
blockScopedNamespace,
caretAfterReformat,
composerPsr4,
csprojFileScoped,
csprojRootNamespace,
inferCsharpNamespace,
inferGoPackage,
inferJvmPackage,
inferPhpNamespace,
@@ -72,26 +68,6 @@ async function composerNamespace(directory: string, root: string | null): Promis
}
}
/** C# namespace from the nearest .csproj between `directory` and the project root. */
async function csharpNamespace(directory: string, root: string | null): Promise<{ name: string; fileScoped: boolean }> {
const none = { name: "", fileScoped: true };
if (!isTauri()) return none;
const start = directory.replace(/\/+$/, "");
const top = root?.replace(/\/+$/, "") ?? null;
for (let dir = start; ; dir = dirname(dir)) {
const csproj = await call(commands.fsReadDir(dir)).then(
(entries) => entries.find((e) => e.kind === "file" && /\.csproj$/i.test(e.name)),
() => undefined,
);
if (csproj) {
const text = await call(commands.fsReadText(csproj.path)).catch(() => "");
const rootNamespace = csprojRootNamespace(text, csproj.name);
return { name: inferCsharpNamespace(start.slice(dir.length), rootNamespace), fileScoped: csprojFileScoped(text) };
}
if (!top || dir === top || !dir.startsWith(`${top}/`)) return none;
}
}
export function packageStyleFor(t: FileTemplate): FileTemplate["packageStyle"] {
const dot = t.fileName?.lastIndexOf(".") ?? -1;
const extension = t.fileName === undefined ? t.extension : dot > 0 ? t.fileName.slice(dot + 1) : "";
@@ -110,7 +86,6 @@ export async function packageFor(t: FileTemplate, directory: string): Promise<st
if (style === "jvm") return inferJvmPackage(directory, root, packageRoots());
if (style === "go") return inferGoPackage(directory, root, await siblingGoPackage(directory));
if (style === "php") return composerNamespace(directory, root);
if (style === "csharp") return (await csharpNamespace(directory, root)).name;
return "";
}
@@ -168,15 +143,9 @@ export async function createFromTemplate(
const plan = planFile(withKind(t, kind), directory, input);
if (!plan) throw new Error(tx("Invalid file name"));
const project = useProject.getState().project;
let body = kind.body;
let packageName: string;
if (packageStyleFor(withKind(t, kind)) === "csharp") {
const ns = await csharpNamespace(plan.directory, project?.root ?? null);
packageName = ns.name;
if (ns.name && !ns.fileScoped) body = blockScopedNamespace(body);
} else packageName = await packageFor(withKind(t, kind), plan.directory);
const packageName = await packageFor(withKind(t, kind), plan.directory);
const { text, caret } = renderFileTemplate(
body,
kind.body,
{
variables,
name: plan.name,
-50
View File
@@ -254,56 +254,6 @@ export function inferPhpNamespace(directory: string, psr4: ReadonlyArray<readonl
return parts.every((p) => /^[A-Za-z_\x80-\uffff][\w\x80-\uffff]*$/.test(p)) ? parts.join("\\") : "";
}
/** A folder or project name as a C# identifier: other characters become `_`, as Rider does. */
function csharpIdentifier(s: string): string {
const id = s.replace(/[^\p{L}\p{N}_]/gu, "_");
return /^\p{N}/u.test(id) ? `_${id}` : id;
}
/** A .csproj's `<RootNamespace>`, else its file name (`My.App.csproj` → `My.App`). */
export function csprojRootNamespace(text: string, fileName: string): string {
const explicit = /<RootNamespace>\s*([^<]*?)\s*<\/RootNamespace>/i.exec(text)?.[1];
const base = explicit || fileName.replace(/\.csproj$/i, "");
return base.split(".").filter(Boolean).map(csharpIdentifier).join(".");
}
/**
* Whether a .csproj's C# version has file-scoped namespaces (C# 10): its `<LangVersion>`,
* else every target framework is .NET 6 or later. .NET Framework (Unity), .NET Standard and
* .NET Core projects stay on C# 9 or earlier.
*/
export function csprojFileScoped(text: string): boolean {
const lang = /<LangVersion>\s*([^<]*?)\s*<\/LangVersion>/i.exec(text)?.[1]?.toLowerCase();
if (lang && /^(latest|latestmajor|preview)$/.test(lang)) return true;
if (lang && /^\d+(\.\d+)?$/.test(lang)) return Number(lang) >= 10;
if (/<TargetFrameworkVersion>/i.test(text)) return false;
const frameworks = /<TargetFrameworks?>\s*([^<]*?)\s*<\/TargetFrameworks?>/i.exec(text)?.[1];
if (!frameworks) return true;
return frameworks
.split(";")
.map((f) => f.trim())
.filter(Boolean)
.every((f) => Number(/^net(\d+)\.\d/.exec(f)?.[1] ?? 0) >= 6);
}
/** The namespace of a folder `relDir` below the .csproj: the root namespace plus each folder. */
export function inferCsharpNamespace(relDir: string, rootNamespace: string): string {
const folders = relDir.split("/").filter(Boolean).map(csharpIdentifier);
return [...rootNamespace.split(".").filter(Boolean), ...folders].join(".");
}
/** A body with `namespace $PACKAGE$;` rewritten as a namespace block, for C# before 10. */
export function blockScopedNamespace(body: string): string {
const m = /^namespace \$PACKAGE\$;\n\n?/m.exec(body);
if (!m) return body;
const rest = body.slice(m.index + m[0].length).replace(/\n+$/, "");
const indented = rest
.split("\n")
.map((l) => (l ? ` ${l}` : l))
.join("\n");
return `${body.slice(0, m.index)}namespace $PACKAGE$\n{\n${indented}\n}\n`;
}
export interface FileTemplateVars {
name: string;
fileName: string;
+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(() => {});
+3 -5
View File
@@ -82,7 +82,6 @@ import {
DIFF_KIND,
changeNodeDiff,
changeNodeDiffData,
changeTreeDiffData,
diffInput,
openDiff,
openVcsDiff,
@@ -206,8 +205,7 @@ const bottomLeft = (el: HTMLElement) => {
/**
* Activates a change (Enter, double-click, Show Diff); F7 continues through the other
* files of its group, in tree order (Enter and double-click: of the whole tree, on into the
* unversioned files, as IDEA's Commit diff preview). Enter and double-click follow IDEA's Show on
* files of its group, in tree order. Enter and double-click follow IDEA's Show on
* Double-Click (opensDiffOnActivate): the source file, or the diff where the commit
* interface puts it (openVcsDiff: a preview editor tab, or under the modal interface the
* front diff window, behind the modal Commit dialog, #1863). `forceDiff` is Show Diff and
@@ -219,7 +217,7 @@ function openNode(n: ChangeNode, roots: readonly ChangeNode[], forceDiff = false
void editorService().openFile(n.path, { focus: true });
return;
}
void openVcsDiff((forceDiff ? changeNodeDiffData : changeTreeDiffData)(roots, n), { preview: true });
void openVcsDiff(changeNodeDiffData(roots, n), { preview: true });
}
/** The setting behind Show on Double-Click (`None` follows the commit interface). */
@@ -632,7 +630,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
const active = maybeEditorService()?.activeInput?.();
if (active?.kind !== DIFF_KIND || active.key !== followedDiff.current) return;
if (diffInput(changeNodeDiff(node)).key === active.key) return;
void openDiff(changeTreeDiffData(roots, node), { preview: true, focus: false });
void openDiff(changeNodeDiffData(roots, node), { preview: true, focus: false });
};
// Reveal only after the ancestors have expanded, without moving keyboard focus.
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more