mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 20:13:56 -10:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bd12381a1 | ||
|
|
88f73630a6 | ||
|
|
504ffac10a | ||
|
|
5ee558d1a7 | ||
|
|
c09fb7191a | ||
|
|
7ee80ed736 | ||
|
|
353768a15f | ||
|
|
5e969f0bad | ||
|
|
6f60f55a1d | ||
|
|
8e5ac1dc01 | ||
|
|
4fcf4555a1 | ||
|
|
40494f2920 | ||
|
|
c016007a2d | ||
|
|
69ce1ab13d | ||
|
|
fcc8583f36 | ||
|
|
b368f21eb6 | ||
|
|
929866bbc5 | ||
|
|
3c781296d6 | ||
|
|
4aed449dc0 | ||
|
|
55431f3965 | ||
|
|
d4b14a36c7 | ||
|
|
d2c565b19d | ||
|
|
01a60ef164 | ||
|
|
b0b768d25e | ||
|
|
3240bfc423 | ||
|
|
15182507cf | ||
|
|
38bd8fbd16 | ||
|
|
2859ec9106 | ||
|
|
a4b62ad27f |
No files matched your search
@@ -309,9 +309,13 @@ 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.
|
||||
@@ -352,6 +356,30 @@ 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 || {
|
||||
@@ -2136,6 +2164,24 @@ 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());
|
||||
|
||||
@@ -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, Tools};
|
||||
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate, Tools};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The installer, reporting the executables chosen in the window's settings.
|
||||
@@ -89,6 +89,16 @@ 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)?;
|
||||
@@ -185,6 +195,7 @@ 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
|
||||
|
||||
@@ -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("unset_upstream")?)
|
||||
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unsetUpstream")?)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
|
||||
|
||||
@@ -2554,7 +2554,8 @@ 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();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
|
||||
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.flush_events_at(now + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
let item = |host: &PluginHost| {
|
||||
@@ -2992,13 +2993,18 @@ 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", 3));
|
||||
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4));
|
||||
|
||||
// Undeclared views are refused before any code runs.
|
||||
let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err();
|
||||
|
||||
@@ -481,3 +481,117 @@ 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:#?}");
|
||||
}
|
||||
@@ -223,6 +223,10 @@ 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.
|
||||
@@ -585,9 +589,10 @@ impl Peer {
|
||||
.expect("spawn jsonrpc notification thread");
|
||||
}
|
||||
|
||||
// Reader: parses and dispatches; never runs handler code itself.
|
||||
// Reader: parses and dispatches; runs no handler code beyond `normalize_incoming`.
|
||||
{
|
||||
let inner = inner.clone();
|
||||
let handler = handler.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(reader);
|
||||
let seen_message = AtomicBool::new(false);
|
||||
@@ -610,7 +615,7 @@ impl Peer {
|
||||
match msg {
|
||||
Ok(Some(body)) => {
|
||||
seen_message.store(true, Ordering::Relaxed);
|
||||
dispatch(&inner, ¬if_tx, &body);
|
||||
dispatch(&inner, &*handler, ¬if_tx, &body);
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
@@ -783,15 +788,16 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
|
||||
fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
let mut 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;
|
||||
|
||||
@@ -541,3 +541,29 @@ 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"));
|
||||
}
|
||||
@@ -391,6 +391,10 @@ 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 {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! `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;
|
||||
|
||||
@@ -22,6 +24,53 @@ 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('\\', "/")
|
||||
@@ -95,6 +144,28 @@ 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() {
|
||||
|
||||
@@ -151,6 +151,67 @@ 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(¤t).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
|
||||
@@ -705,6 +766,25 @@ 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 [
|
||||
@@ -740,6 +820,23 @@ 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() {
|
||||
|
||||
@@ -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_in_project(&root, &path) {
|
||||
let path = match self.resolve_background_document_in_project(&root, &path) {
|
||||
Ok(path) => path,
|
||||
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
|
||||
};
|
||||
@@ -936,6 +936,14 @@ 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> {
|
||||
|
||||
@@ -53,6 +53,8 @@ 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);
|
||||
@@ -1326,6 +1328,38 @@ 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>) {
|
||||
|
||||
@@ -1173,3 +1173,36 @@ 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");
|
||||
}
|
||||
@@ -957,10 +957,15 @@ 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();
|
||||
|
||||
|
||||
@@ -1325,6 +1325,40 @@ 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();
|
||||
|
||||
@@ -65,5 +65,26 @@ fn bench(c: &mut Criterion) {
|
||||
g.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench);
|
||||
// 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_main!(benches);
|
||||
@@ -143,18 +143,22 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult<SearchSummary> {
|
||||
}
|
||||
|
||||
if !ctx.ctl.should_stop() {
|
||||
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 }
|
||||
})
|
||||
});
|
||||
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 }
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let cancelled = ctx.ctl.cancelled.load(Relaxed) || req.cancel.is_cancelled();
|
||||
@@ -176,7 +180,11 @@ 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], mut visitor: impl FnMut() -> VisitFile<'s> + Send) {
|
||||
pub(crate) fn walk_files<'s>(
|
||||
roots: &[PathBuf],
|
||||
skip_dir: &'s (dyn Fn(&Path) -> bool + Sync),
|
||||
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 {
|
||||
@@ -204,6 +212,9 @@ pub(crate) fn walk_files<'s>(roots: &[PathBuf], mut visitor: impl FnMut() -> Vis
|
||||
};
|
||||
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 };
|
||||
@@ -595,3 +606,39 @@ 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());
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ pub fn scope_files(
|
||||
on_disk.push(path);
|
||||
}
|
||||
}
|
||||
walk_files(&on_disk, || {
|
||||
walk_files(&on_disk, &|path| mask.has_directory_exclusions() && mask.excludes_dir(&rel_path(root, path)), || {
|
||||
let found = &found;
|
||||
let keep = &keep;
|
||||
Box::new(move |path| {
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct FileMask {
|
||||
exclude_name: GlobSet,
|
||||
exclude_path: GlobSet,
|
||||
has_include: bool,
|
||||
exclude_dirs: GlobSet,
|
||||
}
|
||||
|
||||
impl FileMask {
|
||||
@@ -20,6 +21,7 @@ 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();
|
||||
@@ -32,10 +34,20 @@ 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)?,
|
||||
@@ -43,6 +55,20 @@ 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);
|
||||
@@ -133,6 +159,60 @@ 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();
|
||||
|
||||
@@ -797,3 +797,42 @@ 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"]);
|
||||
}
|
||||
+111
-6
@@ -104,6 +104,17 @@ 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>,
|
||||
@@ -1410,10 +1421,16 @@ 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);
|
||||
if final_dir.exists() {
|
||||
std::fs::remove_dir_all(&final_dir)?;
|
||||
}
|
||||
std::fs::rename(&pkg, &final_dir)?;
|
||||
// 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);
|
||||
});
|
||||
let target = final_dir.join(&rel);
|
||||
let bin = final_dir.join("bin");
|
||||
if is_bundle(main) {
|
||||
@@ -1428,11 +1445,80 @@ 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) {
|
||||
@@ -1563,6 +1649,25 @@ 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")]
|
||||
{
|
||||
|
||||
@@ -916,3 +916,45 @@ 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![]);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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();
|
||||
});
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
446156fc345df7c8252592f73129f3996139e5980c6ed9a296a9cbecc334eb17
|
||||
ec93db9b37437986167b4040714f5cda5cb7a2fc138e6c2dc2a9447faaf5a802
|
||||
@@ -31,10 +31,19 @@ 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 = endpoint.starts_with(OPENAI);
|
||||
let openai = is_openai(&endpoint);
|
||||
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") }
|
||||
}
|
||||
@@ -195,6 +204,29 @@ 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"]);
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b072d07c4aa4a972a71fffba355ea215da7860b8f5527c6074055f52c52a29be
|
||||
1bd90bca250bb06ab3b0f27b045110c722685d894ce79d23648cb7c795d2c033
|
||||
@@ -106,10 +106,16 @@ fn project_of(path: &str) -> Option<Project> {
|
||||
host::projects()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
|
||||
.filter(|p| p.root().is_ok_and(|r| is_under(path, &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/") {
|
||||
@@ -311,6 +317,14 @@ 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 [
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a78162e7c4c6ebd3a22c06737fd35948e11b0dd8949571617bc099ba6b4423f0
|
||||
c4c86cef9512ac03193e7d89d39b6ee41665c2d4721a78f9415d406289e9ece1
|
||||
@@ -143,8 +143,9 @@ 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(row as usize + skip);
|
||||
let insert = |before: u32| match record(before) {
|
||||
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) {
|
||||
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.
|
||||
@@ -166,8 +167,8 @@ impl CsvEditor {
|
||||
}
|
||||
}
|
||||
}
|
||||
ViewEvent::InsertRow(before) => insert(*before),
|
||||
ViewEvent::Button(b) if b == "add-row" => insert(u32::MAX),
|
||||
ViewEvent::InsertRow(before) => insert(Some(*before)),
|
||||
ViewEvent::Button(b) if b == "add-row" => insert(None),
|
||||
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 } from "./render";
|
||||
import { ENGINES, type Engine, exportName, problemsOf, renderOptions, withoutLinks } 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 = result.output;
|
||||
canvas.innerHTML = withoutLinks(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 } from "./render";
|
||||
import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions, withoutLinks } from "./render";
|
||||
|
||||
describe("the preview's rendering", () => {
|
||||
it("renders with the theme's colour as a default the graph can override", async () => {
|
||||
@@ -42,4 +42,22 @@ 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="x"");
|
||||
expect(preview.match(/<g id="node\d+"/g)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -66,3 +66,13 @@ 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 @@
|
||||
6d84abcc58c1894203506fa89855a6eade61f14add0ed72fdd7af9e3cd1298f6
|
||||
0d7731de73514b950d8579762029d98c46136f55bfdb2fa44e256cc9a9955a0f
|
||||
@@ -58,8 +58,16 @@ 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}") }];
|
||||
@@ -82,7 +90,8 @@ pub fn render(comment: Comment, lines: &[String]) -> String {
|
||||
out.push_str(open);
|
||||
out.push('\n');
|
||||
for l in lines {
|
||||
out.push_str(&format!(" * {l}\n"));
|
||||
// `*/` would end the comment early.
|
||||
out.push_str(&format!(" * {}\n", l.replace("*/", "* /")));
|
||||
}
|
||||
out.push_str(close);
|
||||
out.push('\n');
|
||||
@@ -170,10 +179,16 @@ fn project_of(path: &str) -> Option<Project> {
|
||||
host::projects()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
|
||||
.filter(|p| p.root().is_ok_and(|r| is_under(path, &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;
|
||||
|
||||
@@ -305,6 +320,15 @@ 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);
|
||||
@@ -324,6 +348,12 @@ 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);
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a303522c605ccc9804e67f85326778a380951ce14022a70bc37ba3ae63b76f79
|
||||
959cb2ab750211bd1cc894a4d804acb2dd16e95c1e65b267e7eccbb64cbdd8da
|
||||
@@ -9,11 +9,15 @@ use openidea_plugin::text::{offset, position};
|
||||
use openidea_plugin::{
|
||||
Error, Invocation, Location, Plugin, Position, Project, Range, TextQuery, host, register_plugin,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
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"];
|
||||
@@ -85,27 +89,77 @@ pub struct MapperJava {
|
||||
pub result_ids: Vec<(usize, Named)>,
|
||||
}
|
||||
|
||||
fn range_of(text: &str, start: usize, end: usize) -> Range {
|
||||
Range::new(position(text, start), position(text, end))
|
||||
/// 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))?))
|
||||
}
|
||||
|
||||
pub fn parse_xml(text: &str) -> MapperXml {
|
||||
let mut out = MapperXml::default();
|
||||
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 positions = Positions::new(text);
|
||||
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();
|
||||
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) })
|
||||
// `<` 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) })
|
||||
};
|
||||
if name == "mapper" {
|
||||
out.namespace = named("namespace");
|
||||
@@ -163,7 +217,8 @@ fn skip_string(bytes: &[u8], mut i: usize) -> usize {
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
i
|
||||
// A `\` as the last byte steps past the end.
|
||||
i.min(bytes.len())
|
||||
}
|
||||
|
||||
/// The index just past the `//…` or `/*…*/` comment opening at `bytes[i]`.
|
||||
@@ -227,7 +282,7 @@ fn scan_declaration(stmt: &str) -> Option<Declaration> {
|
||||
}
|
||||
b'(' => {
|
||||
let before = stmt[..i].trim_end();
|
||||
let start = before.rfind(|c: char| !ident(c)).map_or(0, |p| p + 1);
|
||||
let start = before.char_indices().rev().find(|&(_, c)| !ident(c)).map_or(0, |(p, c)| p + c.len_utf8());
|
||||
if start == before.len() {
|
||||
return None;
|
||||
}
|
||||
@@ -463,11 +518,8 @@ fn annotation_string_arg(args: &str, key: &str) -> Option<(String, usize, usize)
|
||||
depth = depth.saturating_sub(1);
|
||||
i += 1;
|
||||
}
|
||||
_ 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 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 &args[i..j] == key {
|
||||
let mut k = j;
|
||||
while k < bytes.len() && bytes[k].is_ascii_whitespace() {
|
||||
@@ -548,8 +600,9 @@ 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: range_of(text, i + 10 + indent, i + 10 + indent + simple.len()) });
|
||||
Some(Named { name: simple.clone(), range: positions.range(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();
|
||||
@@ -599,22 +652,21 @@ 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: range_of(text, list_base + ps, list_base + pe) })
|
||||
.map(|(name, ps, pe)| Param { name, range: positions.range(list_base + ps, list_base + pe) })
|
||||
.collect();
|
||||
let method = methods.len();
|
||||
methods.push((
|
||||
Named { name: stmt[decl.name.0..decl.name.1].to_owned(), range: range_of(text, s, e) },
|
||||
params,
|
||||
));
|
||||
methods.push((method_name, 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(range_of(text, stmt_start + ss, stmt_start + se));
|
||||
strings.push(positions.range(stmt_start + ss, stmt_start + se));
|
||||
sql_params.extend(ps.into_iter().map(|(n, ms, me)| Named {
|
||||
name: n,
|
||||
range: range_of(text, stmt_start + ms, stmt_start + me),
|
||||
range: positions.range(stmt_start + ms, stmt_start + me),
|
||||
}));
|
||||
}
|
||||
if !strings.is_empty() {
|
||||
@@ -623,10 +675,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: range_of(text, stmt_start + rs, stmt_start + re) },
|
||||
name: Named { name: v, range: positions.range(stmt_start + rs, stmt_start + re) },
|
||||
}));
|
||||
result_ids.extend(defs.into_iter().map(|(v, ds, de)| {
|
||||
(method, Named { name: v, range: range_of(text, stmt_start + ds, stmt_start + de) })
|
||||
(method, Named { name: v, range: positions.range(stmt_start + ds, stmt_start + de) })
|
||||
}));
|
||||
}
|
||||
stmt_start = k + 1;
|
||||
@@ -641,8 +693,13 @@ pub fn parse_java(text: &str) -> MapperJava {
|
||||
|
||||
/// `namespace="com.acme.UserMapper"` in an XML file, either quote.
|
||||
fn namespace_query(namespace: &str) -> TextQuery {
|
||||
let escaped: String =
|
||||
namespace.chars().flat_map(|c| matches!(c, '.' | '$').then_some('\\').into_iter().chain([c])).collect();
|
||||
let mut escaped = String::with_capacity(namespace.len());
|
||||
for c in namespace.chars() {
|
||||
if r"\.+*?()|[]{}^$#&-~".contains(c) {
|
||||
escaped.push('\\');
|
||||
}
|
||||
escaped.push(c);
|
||||
}
|
||||
TextQuery {
|
||||
pattern: format!(r#"namespace\s*=\s*["']{escaped}["']"#),
|
||||
regex: true,
|
||||
@@ -656,14 +713,22 @@ fn project_of(path: &str) -> Option<Project> {
|
||||
host::projects()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
|
||||
.filter(|p| p.root().is_ok_and(|r| is_under(path, &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 {
|
||||
@@ -687,15 +752,20 @@ 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)?)));
|
||||
self.xml_index
|
||||
.entry(root)
|
||||
.or_default()
|
||||
.insert(namespace.to_owned(), (Instant::now(), hit.as_ref().map(|(path, _)| path.clone())));
|
||||
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())));
|
||||
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()?);
|
||||
@@ -722,6 +792,14 @@ 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)
|
||||
}
|
||||
@@ -751,7 +829,7 @@ impl Navigator {
|
||||
}
|
||||
typed
|
||||
};
|
||||
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
|
||||
let start = replace_start(at, typed)?;
|
||||
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
|
||||
@@ -796,6 +874,7 @@ 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)));
|
||||
@@ -914,7 +993,7 @@ impl Providers for Navigator {
|
||||
&& STATEMENTS.iter().any(|t| line.contains(&format!("<{t}")))
|
||||
{
|
||||
let typed = &line[i + 4..];
|
||||
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
|
||||
let Some(start) = replace_start(at, typed) else { return Vec::new() };
|
||||
return java
|
||||
.methods
|
||||
.iter()
|
||||
@@ -932,7 +1011,7 @@ impl Providers for Navigator {
|
||||
&& line.contains("<include")
|
||||
{
|
||||
let typed = &line[i + 7..];
|
||||
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
|
||||
let Some(start) = replace_start(at, typed) else { return Vec::new() };
|
||||
return xml
|
||||
.sql_fragments
|
||||
.iter()
|
||||
@@ -949,12 +1028,12 @@ impl Providers for Navigator {
|
||||
&& !before[i..].contains('}')
|
||||
{
|
||||
let typed = &before[i + 2..];
|
||||
let statement =
|
||||
xml.statements.iter().rev().find(|s| offset(&doc.text, s.range.start).is_some_and(|o| o < cursor));
|
||||
let here = position(&doc.text, cursor);
|
||||
let statement = xml.statements.iter().rev().find(|s| s.range.start < here);
|
||||
let Some((_, params)) = statement.and_then(|s| java.methods.iter().find(|(m, _)| m.name == s.name)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
|
||||
let Some(start) = replace_start(at, typed) else { return Vec::new() };
|
||||
return params
|
||||
.iter()
|
||||
.filter(|p| p.name.starts_with(typed))
|
||||
@@ -971,16 +1050,12 @@ 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 {
|
||||
let (Some(start), Some(end)) = (offset(&doc.text, s.start), offset(&doc.text, s.end)) else {
|
||||
continue;
|
||||
};
|
||||
if !(start <= cursor && cursor <= end) {
|
||||
continue;
|
||||
}
|
||||
for s in sql.strings.iter().filter(|s| s.start <= here && here <= s.end) {
|
||||
let Some(start) = offset(&doc.text, s.start) else { continue };
|
||||
let seg = &doc.text[start..cursor];
|
||||
let mut token: Option<usize> = None;
|
||||
for t in ["#{", "${"] {
|
||||
@@ -993,7 +1068,7 @@ fn annotation_param_completion(doc: &Document, at: Position) -> Vec<CompletionIt
|
||||
if typed.contains('}') || !typed.chars().all(|c| ident(c) || c == '.') {
|
||||
continue;
|
||||
}
|
||||
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
|
||||
let Some(start) = replace_start(at, typed) else { continue };
|
||||
return params
|
||||
.iter()
|
||||
.filter(|p| p.name.starts_with(typed))
|
||||
@@ -1477,4 +1552,89 @@ 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);
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b7b4e6be26ff08cc0e7fa791cb956acd9afa09905720d2c0c93acdae5638a35e
|
||||
d5ec377869ded4ec0bf533d07d51610aec7a6813d43ead98a261b3f54e7d0655
|
||||
@@ -94,7 +94,23 @@ pub fn evaluate(run: &Run) -> Value {
|
||||
}
|
||||
}
|
||||
let replaced = run.replace.as_ref().map(|r| {
|
||||
let mut out = re.replace_all(&run.text, r.as_str()).into_owned();
|
||||
// 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..]);
|
||||
}
|
||||
if out.len() > MAX_REPLACED {
|
||||
let mut cut = MAX_REPLACED;
|
||||
while !out.is_char_boundary(cut) {
|
||||
@@ -241,6 +257,13 @@ 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]
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
4f585c3a1a3ae01a89811e05d27fc39121dd8e74210c1281d04ff31556d393b5
|
||||
ebbd24e08c19953895464567cbaf1335b9847e98e8a8da8a4e0b8c2886c1abd6
|
||||
@@ -347,6 +347,8 @@ 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,
|
||||
}
|
||||
|
||||
@@ -366,7 +368,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, body: Body::File }];
|
||||
let mut frames = vec![Frame { symbol: None, parent: 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;
|
||||
@@ -375,7 +377,9 @@ 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);
|
||||
let parent = frames.iter().rev().find_map(|f| f.symbol);
|
||||
// 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 add = |symbols: &mut Vec<Symbol>, name_at: usize, kind: SymbolKind, detail: Option<&str>| {
|
||||
let name = code[name_at];
|
||||
symbols.push(Symbol {
|
||||
@@ -395,7 +399,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, body: inner });
|
||||
frames.push(Frame { symbol, parent: symbol.or(parent), body: inner });
|
||||
stmt_start = i + 1;
|
||||
}
|
||||
(Kind::Punct, "}") => {
|
||||
@@ -512,7 +516,8 @@ fn outline(text: &str) -> Vec<Symbol> {
|
||||
|
||||
fn folding(text: &str) -> Vec<Fold> {
|
||||
let lexemes = lex(text);
|
||||
let line_of = |offset: usize| text[..offset].bytes().filter(|&b| b == b'\n').count() as u32;
|
||||
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 skip: Vec<Range<usize>> = lexemes
|
||||
.iter()
|
||||
.filter(|l| matches!(l.kind, Kind::Str | Kind::Comment | Kind::DocComment))
|
||||
@@ -686,4 +691,19 @@ 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));
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
81637f9a265e6bcb9839b1019b0d58b43f80535fb1fca044716904aef0c12724
|
||||
997e2ac7520a561f22d12ad7013ed576262424ffa8acd69fab219f68d00c92f1
|
||||
@@ -230,7 +230,8 @@ 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) } else { format!("{next:0width$}") };
|
||||
let text =
|
||||
if next < 0 { format!("-{:0width$}", next.unsigned_abs()) } else { format!("{next:0width$}") };
|
||||
out.push_str(&text);
|
||||
}
|
||||
Err(_) => out.push_str(&digits),
|
||||
@@ -338,7 +339,8 @@ fn html_unescape(s: &str) -> String {
|
||||
while let Some(i) = rest.find('&') {
|
||||
out.push_str(&rest[..i]);
|
||||
rest = &rest[i..];
|
||||
let Some(end) = rest[..rest.len().min(12)].find(';') else {
|
||||
// `;` is ASCII, so where it is found is a char boundary.
|
||||
let Some(end) = rest.bytes().take(12).position(|b| b == b';') else {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
continue;
|
||||
@@ -416,6 +418,7 @@ mod tests {
|
||||
assert_eq!(base64_encode(b"Man"), "TWFu");
|
||||
assert_eq!(url_encode("a b&c"), "a%20b%26c");
|
||||
assert_eq!(html_unescape("<a> AB &bogus; &"), "<a> AB &bogus; &");
|
||||
assert_eq!(html_unescape("R&D部门的预算 &aaaaaaaaaa€"), "R&D部门的预算 &aaaaaaaaaa€");
|
||||
assert_eq!(json_escape("a\"b\n"), "a\\\"b\\n");
|
||||
}
|
||||
|
||||
@@ -426,6 +429,8 @@ 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]
|
||||
|
||||
@@ -135,7 +135,9 @@ 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`. Stale versions are rejected without changing text.
|
||||
/// `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.
|
||||
/// 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>;
|
||||
|
||||
@@ -458,6 +458,7 @@ 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,
|
||||
|
||||
@@ -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};
|
||||
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate};
|
||||
use std::path::PathBuf;
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{State, Window};
|
||||
@@ -33,6 +33,18 @@ 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]
|
||||
|
||||
@@ -966,6 +966,11 @@ 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)`. */
|
||||
@@ -10222,6 +10227,15 @@ 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,
|
||||
|
||||
@@ -95,9 +95,12 @@ 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. */
|
||||
export const TEXT_WHEN = "editorTextFocus || textEditorActive && popupOpen";
|
||||
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
|
||||
/**
|
||||
* 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)";
|
||||
const TABS_WHEN = "editorTabsOpen && !toolWindowFocus";
|
||||
|
||||
function activeSlot(): EditorSlot | null {
|
||||
@@ -666,7 +669,7 @@ export function registerEditorActions(): () => void {
|
||||
id,
|
||||
title,
|
||||
category: "Edit",
|
||||
when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen`,
|
||||
when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen && !toolWindowFocus`,
|
||||
run: ({ arg, ctx }) => {
|
||||
const view = typeof arg === "string" || ctx.findBarFocus ? findPanelTarget(arg)?.view() : null;
|
||||
if (view) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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 };
|
||||
}),
|
||||
);
|
||||
@@ -5,7 +5,7 @@ import { indentUnit } from "@codemirror/language";
|
||||
import { codeLenses } from "@/editor/api";
|
||||
import type { EditorSettings } from "@/bindings";
|
||||
import { codeLensExtension } from "./codeLens";
|
||||
import { guideVars, indentGuides, rainbowIndentGuides } from "./indentGuides";
|
||||
import { braceGuide, caretGuide, 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(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
|
||||
positions: "calc(0.5px + 4ch) 0, calc(0.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(2.5px + 4ch) 0");
|
||||
expect(line(v, 2).style.getPropertyValue("--openidea-guide-positions")).toBe("calc(0.5px + 4ch) 0");
|
||||
expect(line(v, 3).style.getPropertyValue("--openidea-guide-positions")).toBe(
|
||||
"calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
|
||||
"calc(0.5px + 4ch) 0, calc(0.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(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
|
||||
"calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -77,18 +77,66 @@ 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(2.5px + 4ch) 0");
|
||||
expect(fnLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("calc(0.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(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
|
||||
positions: "calc(0.5px + 4ch) 0, calc(0.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})`));
|
||||
@@ -108,7 +156,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(2.5px + 4ch) 0, calc(2.5px + 8ch) 0");
|
||||
expect(positions(line(v, 4))).toBe("calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0");
|
||||
expect(images(line(v, 6))).toBe("var(--openidea-guide-1)");
|
||||
});
|
||||
|
||||
@@ -122,7 +170,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(2.5px + 8ch) 0");
|
||||
expect(positions(deepest(tabs)).split(", ")[1]).toBe("calc(0.5px + 8ch) 0");
|
||||
});
|
||||
|
||||
it("does not count a partial indent unit as a level", () => {
|
||||
@@ -163,7 +211,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(2.5px + 4ch) 0, calc(2.5px + 8ch) 0");
|
||||
expect(positions(lens())).toBe("calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0");
|
||||
view.dispatch({ effects: guides.reconfigure(indentGuides) });
|
||||
expect(images(lens())).toBe("var(--openidea-guide), var(--openidea-guide)");
|
||||
});
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
* 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-*`.
|
||||
* guides each depth gets its own image, coloured from `--syn-rainbow-*`. The
|
||||
* guide the caret is on takes the selected colour (`caretGuide`).
|
||||
*/
|
||||
import { Facet, RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
|
||||
import { countColumn, Facet, RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
|
||||
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
|
||||
import { getIndentUnit } from "@codemirror/language";
|
||||
import { getIndentUnit, matchBrackets } 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;
|
||||
|
||||
@@ -22,26 +26,41 @@ 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. `.cm-openidea-guides` lines and code lens rows turn them into
|
||||
* backgrounds (theme.ts).
|
||||
* 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).
|
||||
*/
|
||||
export function guideVars(levels: number, step: number, rainbow = false): { images: string; positions: string } | null {
|
||||
export function guideVars(
|
||||
levels: number,
|
||||
step: number,
|
||||
rainbow = false,
|
||||
active = 0,
|
||||
brace = 0,
|
||||
): { 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(rainbow ? `var(--openidea-guide-${((level - 1) % GUIDE_COLORS) + 1})` : "var(--openidea-guide)");
|
||||
positions.push(`calc(${LINE_INSET}px + ${level * step}ch) 0`);
|
||||
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`);
|
||||
}
|
||||
return { images: images.join(", "), positions: positions.join(", ") };
|
||||
}
|
||||
|
||||
const cache = new Map<string, Decoration | null>();
|
||||
function guideDeco(levels: number, step: number, rainbow: boolean) {
|
||||
const key = `${levels}:${step}:${rainbow ? 1 : 0}`;
|
||||
function guideDeco(levels: number, step: number, rainbow: boolean, active: number, brace: number) {
|
||||
const key = `${levels}:${step}:${rainbow ? 1 : 0}:${active}:${brace}`;
|
||||
let d = cache.get(key);
|
||||
if (d === undefined) {
|
||||
const vars = guideVars(levels, step, rainbow);
|
||||
const vars = guideVars(levels, step, rainbow, active, brace);
|
||||
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);
|
||||
@@ -71,10 +90,77 @@ 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;
|
||||
@@ -88,7 +174,7 @@ function build(view: EditorView): DecorationSet {
|
||||
} else {
|
||||
levels = Math.floor(cols / step);
|
||||
}
|
||||
const deco = guideDeco(levels, step, rainbow);
|
||||
const deco = guideDeco(levels, step, rainbow, on(caret, line.number), on(brace, line.number));
|
||||
if (deco) b.add(line.from, line.from, deco);
|
||||
}
|
||||
pos = line.to + 1;
|
||||
@@ -107,6 +193,7 @@ 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)
|
||||
|
||||
@@ -47,6 +47,7 @@ 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";
|
||||
@@ -376,6 +377,7 @@ export function baseExtensions(o: SetupOptions): Extension {
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
multiCaretMouse,
|
||||
columnModeExtension,
|
||||
bracketBlockSelect,
|
||||
visibleGoalColumns,
|
||||
occurrenceSearch,
|
||||
overwriteField,
|
||||
|
||||
@@ -137,6 +137,13 @@ 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) => {
|
||||
|
||||
@@ -9,9 +9,10 @@ import { registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useRemote } from "@/platform/remote";
|
||||
import { hideContextMenu, resolveMenuItems, useContextMenuStore } from "@/ui";
|
||||
import { getContext, setContext } from "@/platform/context";
|
||||
import { evaluateWhen, 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;
|
||||
|
||||
@@ -464,3 +465,21 @@ 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);
|
||||
});
|
||||
});
|
||||
@@ -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 || popupOpen || !toolWindowFocus)";
|
||||
export const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
|
||||
|
||||
/**
|
||||
* Languages whose servers offer Generate (constructors, accessors, overrides…).
|
||||
|
||||
@@ -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, _removeEditor, codeLenses, documentSynced } from "@/editor/api";
|
||||
import { _addEditor, _emitEditorEvent, _removeEditor, codeLenses, documentSynced, viewShown } from "@/editor/api";
|
||||
|
||||
const ipc = vi.hoisted(() => ({
|
||||
docOutline: vi.fn(),
|
||||
@@ -264,3 +264,78 @@ 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"]);
|
||||
});
|
||||
});
|
||||
+46
-10
@@ -2,15 +2,21 @@
|
||||
* "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, and at most a few reference requests in flight. Beside each
|
||||
* 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
|
||||
* 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, shownAgain, type CodeLens } from "@/editor/api";
|
||||
import { codeLenses, onEditorEvent, shownAgain, type CodeLens } from "@/editor/api";
|
||||
import { onNavigationProvidersChanged } from "@/editor/navigationProviders";
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { commands, nextRequestId, type OutlineItem, type RequestId, type SymbolKind } from "@/platform/ipc";
|
||||
import { commands, nextRequestId, onIdeEvent, 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";
|
||||
@@ -134,6 +140,7 @@ class UsagesLensPlugin {
|
||||
private offReady: () => void;
|
||||
private offSettled: () => void;
|
||||
private offBlame: () => void;
|
||||
private offChanges: () => void;
|
||||
private skipped = false;
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
@@ -142,12 +149,7 @@ class UsagesLensPlugin {
|
||||
if (s.settings !== prev.settings) this.refresh();
|
||||
});
|
||||
// Counts computed while the server was indexing may be incomplete.
|
||||
this.offReady = onLspRefresh("codeLens", () => {
|
||||
this.cache.clear();
|
||||
this.incomplete.clear();
|
||||
this.retries.clear();
|
||||
this.refresh();
|
||||
});
|
||||
this.offReady = onLspRefresh("codeLens", () => this.invalidate());
|
||||
this.offSettled = onServerSettled(() => {
|
||||
if (!this.incomplete.size) return;
|
||||
this.incomplete.clear();
|
||||
@@ -155,10 +157,43 @@ 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) {
|
||||
if (u.docChanged || u.viewportChanged || (this.skipped && shownAgain(u))) this.refresh();
|
||||
// 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();
|
||||
}
|
||||
|
||||
private cancel() {
|
||||
@@ -289,6 +324,7 @@ class UsagesLensPlugin {
|
||||
this.offReady();
|
||||
this.offSettled();
|
||||
this.offBlame();
|
||||
this.offChanges();
|
||||
this.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
+120
-22
@@ -10,7 +10,16 @@ import {
|
||||
* click), the multiple-targets chooser, and document highlights (occurrences
|
||||
* of the symbol at the caret, plus the sticky find.highlightUsages variant).
|
||||
*/
|
||||
import { EditorSelection, Prec, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
|
||||
import {
|
||||
countColumn,
|
||||
EditorSelection,
|
||||
Prec,
|
||||
StateEffect,
|
||||
StateField,
|
||||
type Extension,
|
||||
type Text,
|
||||
} from "@codemirror/state";
|
||||
import { LINE_INSET } from "@/editor/core/cm/lineInset";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
@@ -54,6 +63,7 @@ import {
|
||||
SYNTAX_NOTE,
|
||||
declarationFallback,
|
||||
indexDefinitions,
|
||||
isDeclarationName,
|
||||
isSelfTarget,
|
||||
localHighlights,
|
||||
type DeclarationTarget,
|
||||
@@ -351,6 +361,15 @@ 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,
|
||||
@@ -365,7 +384,10 @@ async function gotoResolved(
|
||||
const before = view.state.doc;
|
||||
const plugin = await contributedNavigation(view, offset).catch(() => null);
|
||||
if (view.state.doc !== before || superseded()) return;
|
||||
if (plugin) {
|
||||
// ⌘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.targets.length === 1) await navigateTo(plugin.targets[0]!);
|
||||
else {
|
||||
const items = await locationItems(plugin.targets);
|
||||
@@ -1031,6 +1053,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1038,31 +1062,94 @@ 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: "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" }),
|
||||
text: Decoration.mark({ class: KIND_CLASS.text }),
|
||||
read: Decoration.mark({ class: KIND_CLASS.read }),
|
||||
write: Decoration.mark({ class: KIND_CLASS.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, sticky: false }),
|
||||
create: () => ({ deco: Decoration.none, ranges: [], sticky: false }),
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects)
|
||||
if (e.is(setHighlights))
|
||||
if (e.is(setHighlights)) {
|
||||
const items = e.value.items.filter((i) => i.from < i.to);
|
||||
return {
|
||||
deco: Decoration.set(
|
||||
e.value.items.filter((i) => i.from < i.to).map((i) => marks[i.kind].range(i.from, i.to)),
|
||||
true,
|
||||
),
|
||||
deco: highlightDeco(tr.state.doc, tr.state.tabSize, items),
|
||||
ranges: items.map(({ from, to }) => ({ from, to })),
|
||||
sticky: e.value.sticky,
|
||||
};
|
||||
}
|
||||
// Kept (mapped) until recomputed; ⌘⇧F7 highlights survive edits until Escape.
|
||||
if (tr.docChanged) return { deco: value.deco.map(tr.changes), sticky: value.sticky };
|
||||
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,
|
||||
};
|
||||
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,
|
||||
@@ -1092,6 +1179,7 @@ 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);
|
||||
@@ -1129,7 +1217,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).deco.size) apply([]);
|
||||
if (view.state.field(highlightField).ranges.length) apply([]);
|
||||
}
|
||||
|
||||
async function syntaxHighlights(view: EditorView, doc: EditorDocContext, head: number) {
|
||||
@@ -1148,10 +1236,7 @@ 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;
|
||||
let onUsage = false;
|
||||
st?.deco.between(head, head, (from, to) => {
|
||||
if (from <= head && head <= to) onUsage = true;
|
||||
});
|
||||
const onUsage = !!st?.ranges.some(({ from, to }) => from <= head && head <= to);
|
||||
if (st?.sticky && onUsage) {
|
||||
clearHighlights(view);
|
||||
return;
|
||||
@@ -1165,8 +1250,7 @@ export function highlightUsages(view: EditorView) {
|
||||
*/
|
||||
export function gotoHighlightedUsage(view: EditorView, dir: 1 | -1): boolean {
|
||||
const st = view.state.field(highlightField, false);
|
||||
const ranges: { from: number; to: number }[] = [];
|
||||
st?.deco.between(0, view.state.doc.length, (from, to) => void ranges.push({ from, to }));
|
||||
const ranges = [...(st?.ranges ?? [])].sort((a, b) => a.from - b.from);
|
||||
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;
|
||||
@@ -1178,7 +1262,8 @@ export function gotoHighlightedUsage(view: EditorView, dir: 1 | -1): boolean {
|
||||
|
||||
const highlightPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
private request = debounce(() => void fetchHighlights(this.view, false), 300);
|
||||
// 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);
|
||||
constructor(private view: EditorView) {}
|
||||
update(u: ViewUpdate) {
|
||||
// Recomputed after caret moves and, like IDEA's daemon, after edits.
|
||||
@@ -1190,7 +1275,7 @@ const highlightPlugin = ViewPlugin.fromClass(
|
||||
}
|
||||
const clear = () => {
|
||||
this.request.cancel();
|
||||
if (st.deco.size)
|
||||
if (st.ranges.length)
|
||||
queueMicrotask(() => this.view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) }));
|
||||
};
|
||||
// Code Editing › "Usages of element at caret".
|
||||
@@ -1198,7 +1283,20 @@ const highlightPlugin = ViewPlugin.fromClass(
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
this.request();
|
||||
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();
|
||||
}
|
||||
destroy() {
|
||||
this.request.cancel();
|
||||
|
||||
@@ -45,6 +45,7 @@ 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");
|
||||
@@ -416,6 +417,48 @@ 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")]));
|
||||
|
||||
@@ -63,6 +63,14 @@ 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) => {
|
||||
|
||||
+11
-4
@@ -288,11 +288,18 @@ export const lspTheme = EditorView.theme({
|
||||
textUnderlineOffset: "2px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
".cm-lsp-occurrence": {
|
||||
backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--accent) 14%, transparent))",
|
||||
".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-write": {
|
||||
backgroundColor: "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, 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-nav-info": { padding: "4px 8px" },
|
||||
// Chooser highlight (tempHighlight.ts): the expression / occurrences a choice would take.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 || popupOpen || !toolWindowFocus)";
|
||||
const EDITOR_WHEN = "textEditorActive && (editorFocus || !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> {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { ServerStatus } from "./ServerStatus";
|
||||
import { reportProblems } from "./problemsReport";
|
||||
import { installWorkspaceEditUndo } from "./workspaceUndo";
|
||||
|
||||
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
|
||||
const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
|
||||
|
||||
function useErrorBadge(): number | null {
|
||||
return useProblems((s) => {
|
||||
|
||||
@@ -268,13 +268,29 @@
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tinted {
|
||||
flex: 1;
|
||||
margin: 0 -4px;
|
||||
padding: 0 4px;
|
||||
border-radius: var(--radius-control, 4px);
|
||||
/* 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;
|
||||
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,6 +5,10 @@ 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 }) => ({
|
||||
@@ -108,6 +112,61 @@ 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({
|
||||
@@ -133,3 +192,20 @@ 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!);
|
||||
});
|
||||
});
|
||||
@@ -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, type CSSProperties } from "react";
|
||||
import type { DbDatabases, DbTableDetails } from "@/platform/ipc";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { DbColor, DbDatabases, DbTableDetails } from "@/platform/ipc";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import {
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
T,
|
||||
} from "@/ui";
|
||||
import {
|
||||
colorVar,
|
||||
databaseKey,
|
||||
passesObjectFilter,
|
||||
passesTreeFilter,
|
||||
@@ -62,6 +61,16 @@ 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",
|
||||
@@ -143,12 +152,12 @@ export function DatabaseView() {
|
||||
useEffect(() => {
|
||||
if (!st.loaded) void loadSources();
|
||||
}, [st.loaded]);
|
||||
// Go to Declaration from a console: the rows exist once the expanded nodes render.
|
||||
// Go to Declaration from a console, Select Opened File: the rows exist once the expanded nodes render.
|
||||
useEffect(() => {
|
||||
if (!st.treeReveal) return;
|
||||
const key = st.treeReveal.key;
|
||||
const path = st.treeReveal.path;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
tree.current?.reveal(key);
|
||||
path.toReversed().some((key) => tree.current?.reveal(key));
|
||||
tree.current?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
@@ -346,7 +355,10 @@ export function DatabaseView() {
|
||||
const load = async (n: Node) => {
|
||||
if (n.kind === "source") {
|
||||
const id = n.source.id ?? "";
|
||||
if (!(await connect(id))) {
|
||||
const current = databaseRequest(id);
|
||||
const connected = await connect(id);
|
||||
if (!current()) return;
|
||||
if (!connected) {
|
||||
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 } }));
|
||||
@@ -355,7 +367,10 @@ export function DatabaseView() {
|
||||
if (listsDatabases(n.source)) await loadDatabases(id);
|
||||
else await loadSchemas(id);
|
||||
} else if (n.kind === "database") {
|
||||
if (!(await connect(n.id))) {
|
||||
const current = databaseRequest(n.id);
|
||||
const connected = await connect(n.id);
|
||||
if (!current()) return;
|
||||
if (!connected) {
|
||||
useDatabase.setState((s) => ({
|
||||
schemas: { ...s.schemas, [n.id]: { status: "error", message: tx("Cannot connect") } },
|
||||
}));
|
||||
@@ -542,6 +557,12 @@ 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);
|
||||
@@ -553,13 +574,9 @@ 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={tint ? `${styles.nodeRow} ${styles.tinted}` : styles.nodeRow}
|
||||
style={tint ? ({ "--db-tint": tint } as CSSProperties) : undefined}
|
||||
className={styles.nodeRow}
|
||||
title={n.kind === "source" && n.source.comment ? n.source.comment : undefined}
|
||||
>
|
||||
{i && (
|
||||
@@ -683,10 +700,11 @@ function DatabaseToolbar() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Header controls (shown on hover, like IDEA's): Expand All, Collapse All. */
|
||||
/** Header controls (shown on hover, like IDEA's): Select Opened File, 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} />
|
||||
</>
|
||||
|
||||
@@ -512,6 +512,7 @@ export function SourceDialog({
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const dialog = useDatabase.getState().dialog;
|
||||
setBusy(true);
|
||||
try {
|
||||
await saveSource(
|
||||
@@ -519,7 +520,7 @@ export function SourceDialog({
|
||||
sqlite ? undefined : password === undefined ? undefined : password || null,
|
||||
!sshOn || sshSecret === undefined || sshUsed.auth === "openSsh" ? undefined : sshSecret || null,
|
||||
);
|
||||
closeDialog();
|
||||
if (useDatabase.getState().dialog === dialog) closeDialog();
|
||||
} catch (e) {
|
||||
setTest({ ok: false, text: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
|
||||
@@ -83,6 +83,7 @@ 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";
|
||||
@@ -608,12 +609,14 @@ 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();
|
||||
@@ -622,6 +625,7 @@ 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;
|
||||
@@ -631,6 +635,7 @@ 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;
|
||||
@@ -649,7 +654,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) return null;
|
||||
if (ctx.aborted || !current()) return null;
|
||||
}
|
||||
const candidates = sqlCandidates({
|
||||
model: scoped,
|
||||
|
||||
@@ -24,6 +24,7 @@ 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 {
|
||||
@@ -142,9 +143,14 @@ 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);
|
||||
if (!(await connect(id))) throw new Error(tx("Database connection failed"));
|
||||
const connected = await connect(id);
|
||||
if (!current()) return;
|
||||
if (!connected) throw new Error(tx("Database connection failed"));
|
||||
if (listsDatabases(sourceById(id))) await loadDatabases(id);
|
||||
else await loadSchemas(id);
|
||||
},
|
||||
@@ -166,7 +172,9 @@ 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);
|
||||
},
|
||||
},
|
||||
@@ -178,8 +186,10 @@ 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;
|
||||
@@ -195,9 +205,11 @@ 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;
|
||||
@@ -213,9 +225,11 @@ 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;
|
||||
@@ -255,9 +269,11 @@ 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);
|
||||
},
|
||||
},
|
||||
@@ -269,7 +285,9 @@ 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();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DataSource, DbSourceCatalog } from "@/platform/ipc";
|
||||
import type { DataSource, DbSourceCatalog, ProjectInfo } 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) => {
|
||||
@@ -16,6 +17,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
dbSavedCatalog: backend.saved,
|
||||
dbDatabaseCatalog: backend.database,
|
||||
dbRetainCatalog: backend.retain,
|
||||
dbDisconnect: backend.disconnect,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -27,7 +29,9 @@ 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 { useDatabase } = await import("./store");
|
||||
const store = await import("./store");
|
||||
const { useDatabase } = store;
|
||||
const { useProject } = await import("@/platform/project");
|
||||
const source = {
|
||||
id,
|
||||
name: "warehouse",
|
||||
@@ -43,7 +47,18 @@ 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));
|
||||
return { useDatabase, ...(await import("./introspection")) };
|
||||
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 };
|
||||
}
|
||||
|
||||
// Each test loads a fresh copy of the store module, which takes a while in a busy run.
|
||||
@@ -116,4 +131,68 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,12 @@ 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 Set<string>();
|
||||
const cancelled = new Set<string>();
|
||||
const offered = new Map<string, () => boolean>();
|
||||
const cancelled = new Map<string, () => boolean>();
|
||||
|
||||
function accepted(): string[] {
|
||||
try {
|
||||
@@ -36,7 +37,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, Promise<void>>();
|
||||
const loading = new Map<string, { current: () => boolean; promise: Promise<void> }>();
|
||||
|
||||
/**
|
||||
* Brings what earlier introspection of data source `id` saved into the store (once per run).
|
||||
@@ -46,9 +47,11 @@ 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) {
|
||||
pending = call(commands.dbSavedCatalog(sourceId))
|
||||
if (!pending?.current()) {
|
||||
const current = databaseRequest(sourceId);
|
||||
const promise = 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;
|
||||
@@ -66,14 +69,16 @@ export function loadSavedCatalog(id: string): Promise<void> {
|
||||
}));
|
||||
})
|
||||
.catch(() => {});
|
||||
pending = { current, promise };
|
||||
loading.set(sourceId, pending);
|
||||
}
|
||||
return pending;
|
||||
return pending.promise;
|
||||
}
|
||||
|
||||
/** Stops the running introspection of data source `id` after the database it is reading. */
|
||||
export function cancelIntrospection(id: string) {
|
||||
cancelled.add(splitTarget(id).id);
|
||||
const sourceId = splitTarget(id).id;
|
||||
cancelled.set(sourceId, databaseRequest(sourceId));
|
||||
}
|
||||
|
||||
/** Reads the schemas and tables of every database data source `id` shows (see the Schemas tab). */
|
||||
@@ -81,18 +86,22 @@ 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;
|
||||
offered.add(sourceId);
|
||||
const current = databaseRequest(sourceId);
|
||||
offered.set(sourceId, current);
|
||||
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" });
|
||||
@@ -106,7 +115,8 @@ export async function introspectSource(id: string): Promise<void> {
|
||||
const failed: string[] = [];
|
||||
let reason = "";
|
||||
for (const database of databases) {
|
||||
if (cancelled.has(sourceId)) break;
|
||||
if (!current()) return;
|
||||
if (cancelled.get(sourceId)?.()) break;
|
||||
const position = done + 1;
|
||||
const count = databases.length;
|
||||
notifications().notify({
|
||||
@@ -119,6 +129,7 @@ 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];
|
||||
@@ -128,6 +139,7 @@ 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);
|
||||
@@ -135,7 +147,9 @@ export async function introspectSource(id: string): Promise<void> {
|
||||
done += 1;
|
||||
update({ done });
|
||||
}
|
||||
const stopped = cancelled.delete(sourceId);
|
||||
if (!current()) return;
|
||||
const stopped = cancelled.get(sourceId)?.() ?? false;
|
||||
cancelled.delete(sourceId);
|
||||
const total = tables.toLocaleString("en");
|
||||
const read = done - failed.length;
|
||||
const unread = failed.length;
|
||||
@@ -150,6 +164,7 @@ 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.`;
|
||||
@@ -170,9 +185,11 @@ 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.has(sourceId)) return;
|
||||
offered.add(sourceId);
|
||||
if (source?.driver !== "snowflake" || offered.get(sourceId)?.()) return;
|
||||
const current = databaseRequest(sourceId);
|
||||
offered.set(sourceId, current);
|
||||
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;
|
||||
@@ -187,7 +204,12 @@ 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: () => void introspectSource(sourceId) },
|
||||
{
|
||||
label: "Introspect",
|
||||
run: () => {
|
||||
if (current()) void introspectSource(sourceId);
|
||||
},
|
||||
},
|
||||
{ label: "Not Now", run: () => {} },
|
||||
],
|
||||
sticky: true,
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
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, {});
|
||||
}
|
||||
+232
-52
@@ -25,9 +25,10 @@ import {
|
||||
type RequestId,
|
||||
type SqlStatement,
|
||||
} from "@/platform/ipc";
|
||||
import { editorsFor } from "@/editor/api";
|
||||
import { activeEditor, editorsFor } from "@/editor/api";
|
||||
import {
|
||||
editorService,
|
||||
maybeEditorService,
|
||||
notifications,
|
||||
showServiceView,
|
||||
toolWindowService,
|
||||
@@ -44,14 +45,18 @@ 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";
|
||||
@@ -180,7 +185,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: { key: string; seq: number } | null;
|
||||
treeReveal: { path: string[]; seq: number } | null;
|
||||
/** Last value entered per query parameter name. */
|
||||
paramValues: Record<string, string>;
|
||||
/** Data sources in manual-commit mode. */
|
||||
@@ -284,19 +289,43 @@ 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 root = useProject.getState().project?.root;
|
||||
const current = projectRequest();
|
||||
const intents = new Map(connectionIntents);
|
||||
const sourceIntents = new Map(sourceConnectionIntents);
|
||||
try {
|
||||
const [sources, connected] = await Promise.all([call(commands.dbSources()), call(commands.dbConnected())]);
|
||||
if (request !== sourcesRequest || useProject.getState().project?.root !== root) return;
|
||||
if (request !== sourcesRequest || !current()) 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({ sources, connected: new Set(connected), loaded: true });
|
||||
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 };
|
||||
});
|
||||
} catch (e) {
|
||||
if (request !== sourcesRequest || useProject.getState().project?.root !== root) return;
|
||||
if (request !== sourcesRequest || !current()) return;
|
||||
useDatabase.setState({ loaded: true });
|
||||
notifications().notify({ level: "error", title: "Cannot read data sources", body: message(e), key: "db-sources" });
|
||||
}
|
||||
@@ -304,15 +333,19 @@ 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),
|
||||
@@ -327,22 +360,30 @@ 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 (loaded) await refresh(id);
|
||||
else forget(id);
|
||||
if (!current() || !sourceById(id)) return;
|
||||
if (loaded && connectionIntents.get(id) === connection) await refresh(id, levels);
|
||||
}
|
||||
|
||||
/** Copy/Paste › Duplicate: a copy of data source `id` named `<name> [2]` (its password is not copied). */
|
||||
@@ -366,7 +407,10 @@ 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();
|
||||
}
|
||||
|
||||
@@ -384,8 +428,10 @@ export async function removeSource(source: DataSource): Promise<void> {
|
||||
}
|
||||
|
||||
export async function deleteSource(id: string): Promise<void> {
|
||||
await call(commands.dbSaveSources(useDatabase.getState().sources.filter((s) => s.id !== id)));
|
||||
forget(id);
|
||||
const current = sourceMutation(id);
|
||||
await call(commands.dbSaveSources(useDatabase.getState().sources.filter((s) => s.id !== id)));
|
||||
if (!current()) return;
|
||||
await loadSources();
|
||||
}
|
||||
|
||||
@@ -454,9 +500,13 @@ 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) }));
|
||||
@@ -464,16 +514,19 @@ 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 true;
|
||||
return current();
|
||||
} catch (e) {
|
||||
if (!isCancelled(e))
|
||||
notifications().notify({ level: "error", title: `Cannot connect to ${targetLabel(id)}`, body: message(e) });
|
||||
@@ -485,11 +538,21 @@ 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);
|
||||
await Promise.all(targets.map((t) => call(commands.dbDisconnect(t))));
|
||||
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, {});
|
||||
useDatabase.setState((s) => {
|
||||
const connected = new Set(s.connected);
|
||||
for (const t of targets) connected.delete(t);
|
||||
for (const t of targets) {
|
||||
if (connectionIntents.get(t) !== intent) continue;
|
||||
connected.delete(t);
|
||||
connectionIntents.set(t, {});
|
||||
}
|
||||
return { connected };
|
||||
});
|
||||
}
|
||||
@@ -527,11 +590,14 @@ export function listsDatabases(source: DataSource | undefined): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
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 {
|
||||
const whole = !splitTarget(id).database;
|
||||
const ofTarget = (k: string) => {
|
||||
const t = k.split("\u0000")[0]!;
|
||||
@@ -540,86 +606,131 @@ export async function refresh(id: string): Promise<void> {
|
||||
const st = useDatabase.getState();
|
||||
const loaded = (m: Record<string, Loaded<unknown>>) =>
|
||||
Object.keys(m).filter((k) => ofTarget(k) && m[k]!.status === "ok");
|
||||
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]);
|
||||
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];
|
||||
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);
|
||||
await Promise.all(schemas.map((t) => loadSchemas(t)));
|
||||
await Promise.all(tables.map(([t, schema]) => loadTables(t, schema)));
|
||||
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;
|
||||
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) => exists(...d)).map(([t, schema, table]) => loadDetails(t, schema, 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;
|
||||
}
|
||||
|
||||
export async function loadDatabases(id: string): Promise<void> {
|
||||
useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: { status: "loading" } } }));
|
||||
const current = metadataRequest(id, "databases");
|
||||
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> {
|
||||
useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: { status: "loading" } } }));
|
||||
const current = metadataRequest(id, "schemas");
|
||||
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);
|
||||
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "loading" } } }));
|
||||
const current = metadataRequest(id, "tables", key);
|
||||
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);
|
||||
useDatabase.setState((s) => ({ details: { ...s.details, [key]: { status: "loading" } } }));
|
||||
const current = metadataRequest(id, "details", key);
|
||||
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.
|
||||
* loading and expanding the nodes that lead to it. Without `kind` the loaded
|
||||
* tables tell whether it is a table or a view.
|
||||
*/
|
||||
export async function revealInTree(
|
||||
id: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
kind: DbTableKind,
|
||||
kind: DbTableKind | undefined,
|
||||
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);
|
||||
@@ -627,47 +738,110 @@ 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}`;
|
||||
const open = [
|
||||
sourceId,
|
||||
...databaseNode,
|
||||
`${id}/${schema}`,
|
||||
folder,
|
||||
...(column ? [tableKey, `${tableKey}/columns`] : []),
|
||||
];
|
||||
useDatabase.setState((s) => ({
|
||||
treeExpanded: new Set([...s.treeExpanded, ...open]),
|
||||
treeReveal: { key: column ? `${tableKey}/columns/${column}` : tableKey, seq: (s.treeReveal?.seq ?? 0) + 1 },
|
||||
}));
|
||||
toolWindowService().show(DB_TOOL_WINDOW, true);
|
||||
selectInTree(
|
||||
[sourceId, ...databaseNode, `${id}/${schema}`, folder, ...(column ? [tableKey, `${tableKey}/columns`] : [])],
|
||||
column ? `${tableKey}/columns/${column}` : tableKey,
|
||||
);
|
||||
}
|
||||
|
||||
const completionLoads = new Map<string, Promise<DbCompletion | null>>();
|
||||
/**
|
||||
* 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) {
|
||||
useDatabase.setState((s) => ({
|
||||
treeExpanded: new Set([...s.treeExpanded, ...open]),
|
||||
treeReveal: { path: [...open, key], 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> }>();
|
||||
|
||||
/** 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 p = completionLoads.get(id);
|
||||
if (!p) {
|
||||
p = call(commands.dbCompletion(id))
|
||||
let pending = completionLoads.get(id);
|
||||
if (!pending?.current()) {
|
||||
const current = databaseRequest(id);
|
||||
const promise = call(commands.dbCompletion(id))
|
||||
.then((model) => {
|
||||
if (!current()) return null;
|
||||
useDatabase.setState((s) => ({ completion: { ...s.completion, [id]: model } }));
|
||||
return model;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => completionLoads.delete(id));
|
||||
completionLoads.set(id, p);
|
||||
.finally(() => {
|
||||
if (completionLoads.get(id)?.promise === promise) completionLoads.delete(id);
|
||||
});
|
||||
pending = { current, promise };
|
||||
completionLoads.set(id, pending);
|
||||
}
|
||||
return p;
|
||||
return pending.promise;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -784,10 +958,12 @@ 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) {
|
||||
@@ -925,8 +1101,9 @@ export async function executeConsole(
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const current = databaseRequest(id);
|
||||
const statements = await consoleStatements(id, text, from, to);
|
||||
if (!statements.length) return [];
|
||||
if (!current() || !statements.length) return [];
|
||||
const prev = useDatabase.getState().running[path];
|
||||
if (prev) void commands.cancelRequest(prev);
|
||||
const request = nextRequestId();
|
||||
@@ -934,7 +1111,9 @@ 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) => {
|
||||
@@ -950,6 +1129,7 @@ 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" });
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
readableDdl,
|
||||
refresh,
|
||||
revealInTree,
|
||||
revealOpened,
|
||||
runSql,
|
||||
sourceById,
|
||||
tablesKey,
|
||||
@@ -41,6 +42,7 @@ 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";
|
||||
@@ -98,18 +100,25 @@ 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)
|
||||
return { id, text: await withConnection(id, () => call(commands.dbObjectDdl(id, o.schema, o.kind, o.name))) };
|
||||
if (o) {
|
||||
const text = await withConnection(id, () => call(commands.dbObjectDdl(id, o.schema, o.kind, o.name)));
|
||||
return current() ? { id, text } : null;
|
||||
}
|
||||
const t = tableOf(n);
|
||||
if (t) return { id, text: await one(t.schema, t.table) };
|
||||
if (t) {
|
||||
const text = await one(t.schema, t.table);
|
||||
return current() ? { id, text } : null;
|
||||
}
|
||||
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 : [];
|
||||
}
|
||||
@@ -118,12 +127,17 @@ 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));
|
||||
for (const t of tables) {
|
||||
parts.push(await one(schema, t.name));
|
||||
if (!current()) return null;
|
||||
}
|
||||
}
|
||||
return { id, text: parts.join("\n") };
|
||||
} catch (e) {
|
||||
if (!current()) return null;
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot generate the DDL",
|
||||
@@ -137,8 +151,10 @@ 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 {
|
||||
@@ -485,6 +501,15 @@ 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",
|
||||
|
||||
@@ -130,6 +130,11 @@ 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":
|
||||
|
||||
@@ -71,6 +71,7 @@ const ALL = env({
|
||||
"clang++": null,
|
||||
dart: null,
|
||||
elixir: null,
|
||||
escript: null,
|
||||
"scala-cli": null,
|
||||
deno: "2.1.0",
|
||||
bun: "1.1.0",
|
||||
@@ -245,6 +246,10 @@ 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({
|
||||
|
||||
@@ -220,6 +220,7 @@ export type ScriptLanguage =
|
||||
| "cpp"
|
||||
| "dart"
|
||||
| "elixir"
|
||||
| "escript"
|
||||
| "scala"
|
||||
| "shebang";
|
||||
|
||||
@@ -259,6 +260,7 @@ 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;
|
||||
@@ -313,6 +315,8 @@ export function toolsFor(lang: ScriptLanguage, content?: string): string[] {
|
||||
return ["dart"];
|
||||
case "elixir":
|
||||
return ["elixir"];
|
||||
case "escript":
|
||||
return ["escript"];
|
||||
case "scala":
|
||||
return ["scala-cli"];
|
||||
}
|
||||
@@ -338,6 +342,7 @@ 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.",
|
||||
@@ -905,6 +910,7 @@ 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,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
blockScopedNamespace,
|
||||
caretAfterReformat,
|
||||
csprojFileScoped,
|
||||
csprojRootNamespace,
|
||||
inferCsharpNamespace,
|
||||
expandIncludes,
|
||||
fragmentResolver,
|
||||
composerPsr4,
|
||||
@@ -180,6 +184,43 @@ 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,6 +53,22 @@ 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,7 +302,11 @@ function NameStep({
|
||||
{error ?? (
|
||||
<>
|
||||
{where}
|
||||
{packageStyle && pkg ? (packageStyle === "php" ? ` · namespace ${pkg}` : ` · package ${pkg}`) : ""}
|
||||
{packageStyle && pkg
|
||||
? packageStyle === "php" || packageStyle === "csharp"
|
||||
? ` · namespace ${pkg}`
|
||||
: ` · package ${pkg}`
|
||||
: ""}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -294,6 +294,27 @@ 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",
|
||||
|
||||
@@ -10,8 +10,12 @@ 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,
|
||||
@@ -68,6 +72,26 @@ 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) : "";
|
||||
@@ -86,6 +110,7 @@ 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 "";
|
||||
}
|
||||
|
||||
@@ -143,9 +168,15 @@ 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;
|
||||
const packageName = await packageFor(withKind(t, kind), plan.directory);
|
||||
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 { text, caret } = renderFileTemplate(
|
||||
kind.body,
|
||||
body,
|
||||
{
|
||||
variables,
|
||||
name: plan.name,
|
||||
|
||||
@@ -254,6 +254,56 @@ 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;
|
||||
|
||||
@@ -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 || popupOpen || !toolWindowFocus)";
|
||||
const EDITOR_WHEN = "textEditorActive && !editorReadOnly && (editorFocus || !toolWindowFocus)";
|
||||
const MARKUP_PATH = /\.(html?|xhtml|shtml|vue|svelte|xml)$/i;
|
||||
|
||||
async function updateTagWithEmmet() {
|
||||
|
||||
@@ -124,7 +124,8 @@
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.rowName {
|
||||
flex: none;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
color: var(--fg-strong);
|
||||
@@ -150,6 +151,13 @@
|
||||
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);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { languageNames, NewServerDialog, ServerSettings, ToolSettings, useLangua
|
||||
import { serverKey, type ServerEntry } from "./serverConfig";
|
||||
import {
|
||||
cancelInstall,
|
||||
checkToolUpdates,
|
||||
installTool,
|
||||
refreshTools,
|
||||
STATUS_LABEL,
|
||||
@@ -98,12 +99,14 @@ function ToolRow({
|
||||
selected,
|
||||
off,
|
||||
pending,
|
||||
update,
|
||||
onSelect,
|
||||
}: {
|
||||
tool: ToolInfo;
|
||||
selected: boolean;
|
||||
off: boolean;
|
||||
pending: boolean;
|
||||
update: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -116,7 +119,7 @@ function ToolRow({
|
||||
title={`${tool.displayName} — ${tool.languages.join(", ")}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<Badge dot tone={pending ? "accent" : STATUS_TONE[tool.status]}>
|
||||
<Badge dot className={styles.rowBadge} tone={pending ? "accent" : STATUS_TONE[tool.status]}>
|
||||
{pending ? tx("New") : STATUS_LABEL[tool.status]}
|
||||
</Badge>
|
||||
<span className={styles.rowName}>{tool.displayName}</span>
|
||||
@@ -135,6 +138,11 @@ 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>
|
||||
@@ -144,6 +152,7 @@ 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;
|
||||
@@ -185,17 +194,21 @@ function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
|
||||
{tool.canInstall && (
|
||||
<Button
|
||||
size="small"
|
||||
variant={tool.status === "missing" ? "primary" : "secondary"}
|
||||
variant={tool.status === "missing" || update ? "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={
|
||||
tool.status === "installed" ? tx`Reinstall ${tool.displayName}` : tx`Install ${tool.displayName}`
|
||||
update
|
||||
? tx`Update ${tool.displayName}`
|
||||
: tool.status === "installed"
|
||||
? tx`Reinstall ${tool.displayName}`
|
||||
: tx`Install ${tool.displayName}`
|
||||
}
|
||||
>
|
||||
{tool.status === "installed" ? "Reinstall" : "Install"}
|
||||
{update ? "Update" : tool.status === "installed" ? "Reinstall" : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
{tool.canUninstall && (
|
||||
@@ -205,6 +218,11 @@ 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">
|
||||
@@ -244,7 +262,7 @@ function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
|
||||
}
|
||||
|
||||
export function LanguageServersPage() {
|
||||
const { tools, loaded, loading, error } = useTools();
|
||||
const { tools, loaded, loading, error, updates, checkingUpdates } = useTools();
|
||||
const form = useSettingsForm();
|
||||
const [query, setQuery] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
@@ -257,6 +275,8 @@ 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();
|
||||
@@ -307,6 +327,7 @@ 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)}
|
||||
/>
|
||||
))}
|
||||
@@ -337,14 +358,17 @@ export function LanguageServersPage() {
|
||||
onClick={() => selected && form.set(serverKey(selected.name), null)}
|
||||
/>
|
||||
<span className={styles.toolbarSep} aria-hidden />
|
||||
{loading ? (
|
||||
{loading || checkingUpdates ? (
|
||||
<Spinner size={14} label="Checking tools" />
|
||||
) : (
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
size="small"
|
||||
title="Re-check installed tools"
|
||||
onClick={() => void refreshTools()}
|
||||
onClick={() => {
|
||||
void refreshTools();
|
||||
void checkToolUpdates();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SearchField
|
||||
|
||||
@@ -19,7 +19,17 @@ 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: {} });
|
||||
useTools.setState({
|
||||
tools: [],
|
||||
loaded: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
jobs: {},
|
||||
logs: {},
|
||||
updates: {},
|
||||
checkingUpdates: false,
|
||||
updatesChecked: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => setToolsBackend(null));
|
||||
@@ -264,6 +274,22 @@ 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();
|
||||
|
||||
@@ -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 } from "@/bindings";
|
||||
import type { InstallEvent, RequestId, ToolInfo, ToolUpdate } from "@/bindings";
|
||||
import { call, commands, IpcError, isTauri } from "@/platform/ipc";
|
||||
|
||||
export interface LanguageChoice {
|
||||
@@ -15,6 +15,8 @@ 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>;
|
||||
@@ -32,6 +34,7 @@ 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) => {
|
||||
@@ -150,6 +153,8 @@ 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) {
|
||||
@@ -161,6 +166,10 @@ 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,6 +1,6 @@
|
||||
/** Tool list + running installs. Installs show in the status-bar task list too. */
|
||||
import { create } from "zustand";
|
||||
import type { RequestId, ToolInfo } from "@/bindings";
|
||||
import type { RequestId, ToolInfo, ToolUpdate } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
@@ -28,6 +28,11 @@ 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>(() => ({
|
||||
@@ -37,6 +42,9 @@ 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));
|
||||
@@ -59,6 +67,25 @@ 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);
|
||||
@@ -124,6 +151,10 @@ 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(() => {});
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
DIFF_KIND,
|
||||
changeNodeDiff,
|
||||
changeNodeDiffData,
|
||||
changeTreeDiffData,
|
||||
diffInput,
|
||||
openDiff,
|
||||
openVcsDiff,
|
||||
@@ -205,7 +206,8 @@ 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 follow IDEA's Show on
|
||||
* 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
|
||||
* 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
|
||||
@@ -217,7 +219,7 @@ function openNode(n: ChangeNode, roots: readonly ChangeNode[], forceDiff = false
|
||||
void editorService().openFile(n.path, { focus: true });
|
||||
return;
|
||||
}
|
||||
void openVcsDiff(changeNodeDiffData(roots, n), { preview: true });
|
||||
void openVcsDiff((forceDiff ? changeNodeDiffData : changeTreeDiffData)(roots, n), { preview: true });
|
||||
}
|
||||
|
||||
/** The setting behind Show on Double-Click (`None` follows the commit interface). */
|
||||
@@ -630,7 +632,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(changeNodeDiffData(roots, node), { preview: true, focus: false });
|
||||
void openDiff(changeTreeDiffData(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
Reference in new issue
Block a user