Compare commits

..
Author SHA1 Message Date
l0ng-ai 93cccb9bb5 fix(navigation): show usages on a declaration a plugin's index links elsewhere
A plugin's document index can link a declaration's name elsewhere: MyBatis Duo links a mapper method to its XML statement. Go to Declaration followed such a link at once, in the editor and in the host, so ⌘B on the method jumped to the XML instead of showing its usages as it does on any declaration (and did for plugins that answer through a definition provider). When the syntax tree puts the caret on a declaration's name, the link is no longer followed directly: the language server's answer comes along and the usages show, the plugin's target among them. Elsewhere plugin links are followed as before, without waiting for the language server.

Fixes #3207
2026-11-14 02:43:03 +08:00
42 changed files with 106 additions and 2144 deletions

No files matched your search

+1 -12
View File
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::Sdk;
use ide_tools::jdk::{JdkReleases, JdkVendor};
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate, Tools};
use ide_tools::{InstallEvent, ToolInfo, Tools};
use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
@@ -89,16 +89,6 @@ pub async fn tools_install(
Ok(info)
}
/// Newer versions of the tools the IDE installed (release downloads and npm packages).
/// Cancel with `cancel_request(request)`.
pub async fn tools_updates(host: &Host, window: &str, request: RequestId) -> IdeResult<Vec<ToolUpdate>> {
let tools = tools(host, window)?;
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || tools.updates(&cancel)).await.map_err(join_err);
host.end_request(request);
result?
}
/// Removes a tool the IDE installed.
pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult<ToolInfo> {
let tools = tools(host, window)?;
@@ -195,7 +185,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
tools_install(&c.host, &c.window, c.arg("name")?, c.arg("request")?, c.sender("onEvent")?).await
});
d.add("tools_uninstall", |c| async move { tools_uninstall(&c.host, &c.window, c.arg("name")?).await });
d.add("tools_updates", |c| async move { tools_updates(&c.host, &c.window, c.arg("request")?).await });
d.add("jdk_download_vendors", |c| async move { Ok(jdk_download_vendors(&c.host, &c.window)) });
d.add("jdk_download_releases", |c| async move {
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
+1 -1
View File
@@ -1507,7 +1507,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_rename_branch", |c| async move {
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unsetUpstream")?)
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
.await
});
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
-114
View File
@@ -481,117 +481,3 @@ async fn the_desktop_routes_exactly_what_a_server_of_this_build_answers() {
assert!(rpc::client_commands().contains(&"term_spawn"));
plugins.host().shutdown();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn renames_a_branch_with_the_clients_argument_names() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
std::fs::create_dir(&root).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git").args(args).current_dir(&root).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
};
git(&["init", "-q", "-b", "main"]);
std::fs::write(root.join("a.txt"), "a\n").unwrap();
git(&["add", "."]);
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"]);
git(&["branch", "feature"]);
let peer_slot = Arc::new(OnceLock::new());
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host, Dispatcher::standard(), peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
invoke(&peer, "open_project", json!({"path": root})).await.unwrap();
invoke(&peer, "project_trust_set", json!({"path": root, "trusted": true, "folder": false})).await.unwrap();
// The argument names the desktop client sends (`vcsRenameBranch` in src/bindings.ts).
invoke(
&peer,
"vcs_rename_branch",
json!({"repo": null, "from": "feature", "to": "renamed", "unsetUpstream": false}),
)
.await
.unwrap();
assert_eq!(
git(&["branch", "--format=%(refname:short)", "--list"]).lines().collect::<Vec<_>>(),
["main", "renamed"]
);
invoke(&peer, "close_project", json!({})).await.unwrap();
peer.kill();
}
/// A handler reads its arguments by the names the desktop client sends: `src/bindings.ts`
/// passes them in camelCase, and a name the client never sends reads as `null` (an
/// `Option` silently `None`, anything else an "invalid type: null" error).
#[test]
fn handlers_read_the_argument_names_the_client_sends() {
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let bindings = std::fs::read_to_string(repo.join("src/bindings.ts")).unwrap();
let mut sent = HashMap::<&str, BTreeSet<&str>>::new();
for part in bindings.split("__TAURI_INVOKE(\"").skip(1) {
let (command, rest) = part.split_once('"').unwrap();
let args = rest.strip_prefix(", {").and_then(|r| r.split_once('}')).map_or("", |(args, _)| args);
let names = args.split(',').map(|a| a.split(':').next().unwrap().trim()).filter(|a| !a.is_empty());
sent.insert(command, names.collect());
}
fn sources(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
sources(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let mut files = Vec::new();
sources(&repo.join("crates/ide-host/src"), &mut files);
let mut checked = 0;
let mut wrong = Vec::new();
for file in files {
let src = std::fs::read_to_string(&file).unwrap();
// `.add("name", …)`, also with the name on the next line.
let starts: Vec<usize> = src
.match_indices(".add(")
.map(|(i, m)| i + m.len())
.filter_map(|i| src[i..].trim_start().strip_prefix('"').map(|rest| src.len() - rest.len()))
.collect();
for (n, &start) in starts.iter().enumerate() {
let body = &src[start..starts.get(n + 1).copied().unwrap_or(src.len())];
let (command, body) = body.split_once('"').unwrap();
let Some(sent) = sent.get(command) else { continue };
checked += 1;
for reader in ["c.arg", "c.sender", "c.channel"] {
for (at, _) in body.match_indices(reader) {
let rest = &body[at + reader.len()..];
let rest = match rest.strip_prefix("::<") {
Some(generic) => &generic[generic.find(">(").unwrap() + 1..],
None => rest,
};
let Some(name) = rest.strip_prefix("(\"").and_then(|r| r.split_once('"')).map(|(n, _)| n) else {
continue;
};
if !sent.contains(name) {
wrong.push(format!("{command} reads `{name}`; the client sends {sent:?}"));
}
}
}
}
}
assert!(checked > 400, "found only {checked} handlers with client bindings");
assert!(wrong.is_empty(), "{wrong:#?}");
}
-97
View File
@@ -151,67 +151,6 @@ pub fn resolve_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String
}
}
/// Resolves an already-open document for a background edit without following a
/// symbolic link, junction, or other Windows reparse point. Unlike ordinary
/// project reads, background edits may have originated with another machine:
/// accepting a link would let that machine change an unexpected file when the
/// user later saves the editor buffer.
///
/// The input is deliberately project-relative. Existing path components are
/// examined with `symlink_metadata` from the canonical project root; no
/// canonicalization happens after the walk, so a link is never traversed.
pub fn resolve_background_document_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
if path.is_empty() || Path::new(path).is_absolute() {
return Err(format!("{path}: background document paths must be project-relative"));
}
let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?;
let components: Vec<_> = Path::new(path).components().collect();
if components.is_empty() || components.iter().any(|part| !matches!(part, std::path::Component::Normal(_))) {
return Err(format!("{path}: background document paths must not leave the project"));
}
let mut current = root.clone();
for (index, part) in components.iter().enumerate() {
let std::path::Component::Normal(name) = part else { unreachable!() };
if background_control_name(name) {
return Err(format!("{path}: background edits may not target VCS or IDE control files"));
}
current.push(name);
let metadata = std::fs::symlink_metadata(&current).map_err(|_| format!("{path}: no such open document"))?;
if is_link_or_reparse(&metadata) {
return Err(format!("{path}: background edits refuse symbolic links"));
}
if index + 1 < components.len() && !metadata.is_dir() {
return Err(format!("{path}: not a directory"));
}
if index + 1 == components.len() && !metadata.is_file() {
return Err(format!("{path}: not a file"));
}
}
Ok(current)
}
/// [`resolve_background_document_in_root`] over a workspace group's roots.
pub fn resolve_background_document_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String> {
first_root_resolving(roots, path, resolve_background_document_in_root)
}
fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool {
#[cfg(windows)]
let reparse = {
use std::os::windows::fs::MetadataExt;
metadata.file_attributes() & 0x400 != 0 // FILE_ATTRIBUTE_REPARSE_POINT
};
#[cfg(not(windows))]
let reparse = false;
metadata.file_type().is_symlink() || reparse
}
fn background_control_name(name: &std::ffi::OsStr) -> bool {
let name = name.to_string_lossy();
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
matches!(name.to_ascii_lowercase().as_str(), ".git" | ".hg" | ".svn" | ".idea" | ".openidea" | ".vscode")
}
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
/// without requiring it to exist: `Ok(true)` also for a missing path, so
/// callers distinguish "outside" from "absent". Existing symlinks are
@@ -766,25 +705,6 @@ mod tests {
assert!(!names_git_dir(Path::new(".github/workflows/ci.yml")));
}
#[test]
fn background_document_edits_are_relative_and_exclude_control_directories() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
std::fs::write(root.join(".git/hooks/pre-commit"), "#!/bin/sh\n").unwrap();
std::fs::write(root.join(".openidea/state"), "state\n").unwrap();
assert!(resolve_background_document_in_root(&root, "src/main.rs").is_ok());
for path in
["/etc/passwd", "../outside", "src/../.git/hooks/pre-commit", ".git/hooks/pre-commit", ".openidea/state"]
{
assert!(resolve_background_document_in_root(&root, path).is_err(), "{path}");
}
}
#[test]
fn git_folder_is_recognised_under_every_spelling_that_opens_it() {
for path in [
@@ -820,23 +740,6 @@ mod tests {
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
}
#[cfg(unix)]
#[test]
fn background_document_edits_refuse_final_and_ancestor_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
symlink(root.join("src/main.rs"), root.join("final-link.rs")).unwrap();
symlink(root.join("src"), root.join("linked-src")).unwrap();
assert!(resolve_background_document_in_root(&root, "final-link.rs").unwrap_err().contains("symbolic links"));
assert!(
resolve_background_document_in_root(&root, "linked-src/main.rs").unwrap_err().contains("symbolic links")
);
}
#[cfg(unix)]
#[test]
fn writable_paths_follow_existing_and_dangling_symlinks() {
+1 -9
View File
@@ -870,7 +870,7 @@ impl editor::Host for StoreData {
return Ok(Err(wit::Error::Invalid("edits too large".into())));
}
let root = self.table.get(&project)?.root.clone();
let path = match self.resolve_background_document_in_project(&root, &path) {
let path = match self.resolve_in_project(&root, &path) {
Ok(path) => path,
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
};
@@ -936,14 +936,6 @@ impl StoreData {
sandbox::resolve_writable_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
})
}
/// A background document edit is the only plugin operation that changes an
/// editor on behalf of a remote peer. It therefore rejects links and IDE
/// control directories instead of using the normal read resolver.
fn resolve_background_document_in_project(&self, root: &Path, path: &str) -> Result<PathBuf, String> {
sandbox::resolve_background_document_in_root(root, path).or_else(|root_error| {
sandbox::resolve_background_document_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
})
}
/// Whether a `project` resource's path lands inside the resource's root or
/// any member of its workspace group, without requiring it to exist.
fn inside_project(&self, root: &Path, path: &str) -> Result<bool, String> {
-34
View File
@@ -53,8 +53,6 @@ pub(crate) fn detect_with_profiles(
&mut out,
);
detect_projects_matching(root, ruby_project, detect_ruby, &mut out);
detect_projects(root, &["rebar.config"], detect_rebar, &mut out);
detect_projects(root, &["mix.exs"], detect_mix, &mut out);
for dir in ide_core::project_roots::discover(root, &["CMakeLists.txt"]) {
let start = out.len();
cmake::detect(&dir, if dir == root { profiles } else { None }, &mut out);
@@ -1328,38 +1326,6 @@ pub(crate) fn just_recipes(src: &str) -> Vec<String> {
// ---------------------------------------------------------------- godot
// ---------------------------------------------------------------- rebar3 / mix
/// rebar3 (Erlang): the shell with the applications started, the EUnit tests and, with
/// `*_SUITE.erl` suites, Common Test. A `rebar3` script in the project wins over the PATH.
fn detect_rebar(root: &Path, out: &mut Vec<RunConfig>) {
let rebar = wrapper(root, "rebar3", "rebar3.cmd", "rebar3");
out.push(config("rebar3:shell", "rebar3 shell", "rebar3", &rebar, &["shell"]));
out.push(config("rebar3:eunit", "rebar3 eunit", "rebar3", &rebar, &["eunit"]).test());
let suites = |dir: &Path| {
fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.any(|e| e.file_name().to_string_lossy().ends_with("_SUITE.erl"))
};
let mut apps = subdirs(&root.join("apps")).into_iter().chain(subdirs(&root.join("lib")));
if suites(&root.join("test")) || apps.any(|app| suites(&app.join("test"))) {
out.push(config("rebar3:ct", "rebar3 ct", "rebar3", &rebar, &["ct"]).test());
}
}
/// Mix (Elixir): the application in IEx (`phx.server` for Phoenix) and its ExUnit tests.
fn detect_mix(root: &Path, out: &mut Vec<RunConfig>) {
let phoenix = read(&root.join("mix.exs")).is_some_and(|src| src.contains("{:phoenix,"));
if phoenix {
out.push(config("mix:phx.server", "iex -S mix phx.server", "mix", "iex", &["-S", "mix", "phx.server"]));
} else {
out.push(config("mix:iex", "iex -S mix", "mix", "iex", &["-S", "mix"]));
}
out.push(config("mix:test", "mix test", "mix", "mix", &["test"]).test());
}
/// Godot projects (`project.godot`) at the root or one level down: "Godot Project"
/// runs the main scene (`godot --path .`), debuggable through the editor's adapter.
fn detect_godot(root: &Path, out: &mut Vec<RunConfig>) {
-33
View File
@@ -1173,36 +1173,3 @@ fn a_django_project_in_src_uses_the_environment_next_to_it() {
assert!(c.iter().any(|c| c.id == "pytest"));
assert!(!c.iter().any(|c| c.id.starts_with("pytest:")), "{:?}", c.iter().map(|c| &c.id).collect::<Vec<_>>());
}
#[test]
fn rebar3_projects() {
let dir = project(&[("rebar.config", "{erl_opts, [debug_info]}.\n"), ("src/app.erl", "-module(app).\n")]);
let c = detect(dir.path());
assert_eq!(ids(&c), ["rebar3:shell", "rebar3:eunit"]);
assert_eq!(cmdline(&c[0]), "rebar3 shell");
assert!(c[1].is_test);
// Common Test suites in an umbrella app; the project's own rebar3 script.
let dir = project(&[
("rebar.config", ""),
("rebar3", "#!/usr/bin/env escript\n"),
("apps/core/test/core_SUITE.erl", "-module(core_SUITE).\n"),
]);
let c = detect(dir.path());
assert_eq!(ids(&c), ["rebar3:shell", "rebar3:eunit", "rebar3:ct"]);
if !cfg!(windows) {
assert_eq!(cmdline(find(&c, "rebar3:ct")), "./rebar3 ct");
}
}
#[test]
fn mix_projects() {
let dir = project(&[("mix.exs", "defmodule App.MixProject do\n defp deps, do: []\nend\n")]);
let c = detect(dir.path());
assert_eq!(ids(&c), ["mix:iex", "mix:test"]);
assert_eq!(cmdline(&c[0]), "iex -S mix");
assert!(c[1].is_test);
let dir = project(&[("mix.exs", "defp deps do\n [{:phoenix, \"~> 1.7\"}]\nend\n")]);
assert_eq!(cmdline(&detect(dir.path())[0]), "iex -S mix phx.server");
}
+2 -27
View File
@@ -3,7 +3,7 @@
use crate::discover::{project_venv_python, workspace_tsdk};
use crate::exec::{path_with, search_path, which};
use crate::npm::{has_ts_api, native_typescript, own_typescript};
use crate::npm::{has_ts_api, own_typescript};
use crate::{angular, astro, godot, pinned, vue};
use crate::{bin_dir, exe, venv_of};
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
@@ -722,16 +722,6 @@ pub fn tsdk(root: &Path, s: &Settings) -> Option<PathBuf> {
}
}
/// TypeScript 7's native executable when the selected TypeScript is one (see
/// [`native_typescript`]); `None` for the bundled one and TypeScript 6 and older.
pub fn native_tsdk(root: &Path, s: &Settings) -> Option<PathBuf> {
match s.languages.typescript.version.trim() {
"" | "bundled" => None,
"workspace" => native_typescript(&root.join("node_modules/typescript")),
custom => native_typescript(&setting_path(root, custom)?),
}
}
const PYTHON_SERVERS: [&str; 7] = ["basedpyright", "pyright", "ty", "pyrefly", "pylsp", "jedi-language-server", "ruff"];
pub(crate) fn merge(base: &mut Value, layer: Value) {
@@ -967,15 +957,10 @@ pub fn resolve_run_command(config: &mut RunConfig, root: &Path, s: &Settings) {
}
pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) {
let mut path_dirs: Vec<PathBuf> = Vec::new();
if let Some(custom) = tool_command(root, s, &cfg.command) {
// An executable chosen from a Node.js installation (`~/.nvm/versions/node/v26/bin/tsc`,
// its `npx`) runs on that Node: its `#!/usr/bin/env node` finds the `node` beside it.
if let Some(bin) = custom.parent().filter(|bin| bin.join(exe("node")).is_file()) {
path_dirs.push(bin.to_path_buf());
}
cfg.command = custom.to_string_lossy().into_owned();
}
let mut path_dirs: Vec<PathBuf> = Vec::new();
let name = cfg.name.clone();
let name = name.as_str();
@@ -1150,16 +1135,6 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
_ => {}
}
}
// TypeScript 7 serves LSP itself: it replaces the tsserver-based server.
if matches!(name, "vtsls" | "typescript-language-server")
&& let Some(native) = native_tsdk(server_root, s).or_else(|| native_tsdk(root, s))
{
cfg.command = native.to_string_lossy().into_owned();
cfg.args = vec!["--lsp".into(), "--stdio".into()];
cfg.initialization_options = None;
cfg.tsserver_project = false;
return;
}
let vue_command = s.tool_path(vue::SERVER).and_then(|p| setting_path(root, p));
let vue_command = vue_command.as_deref().map_or(vue::SERVER.into(), Path::to_string_lossy);
if name == "vtsls"
+2 -14
View File
@@ -749,26 +749,14 @@ pub(crate) fn workspace_tsdk(root: &Path) -> Option<PathBuf> {
fn typescript(root: Option<&Path>) -> Vec<Sdk> {
let mut out = Vec::new();
// TypeScript 7 has no `tsserver.js`; its package directory stands for it (`native_tsdk`).
let native = || {
let package = root?.join("node_modules/typescript");
crate::npm::native_typescript(&package).map(|_| package.join("lib"))
};
if let Some(lib) = root.and_then(workspace_tsdk).or_else(native) {
if let Some(lib) = root.and_then(workspace_tsdk) {
let version = std::fs::read_to_string(lib.parent().unwrap_or(&lib).join("package.json"))
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|v| v.get("version").and_then(|v| v.as_str()).map(str::to_owned));
out.push(Sdk {
path: lib.clone().into(),
name: format!(
"TypeScript {}{}",
version.clone().unwrap_or_default(),
if crate::npm::has_ts_api(&lib) { "" } else { " (native)" }
)
.replace(" ", " ")
.trim()
.to_owned(),
name: format!("TypeScript {}", version.clone().unwrap_or_default()).trim().to_owned(),
version,
source: "workspace".into(),
home: lib.parent().map(Into::into),
-35
View File
@@ -10,41 +10,6 @@ pub(crate) fn has_ts_api(lib: &Path) -> bool {
lib.join("typescript.js").is_file()
}
/// The native executable of TypeScript 7 (`typescript` 7+, whose `tsc` is native, or
/// `@typescript/native-preview`'s `tsgo`), from its package directory or its `lib`.
/// These packages have no JavaScript API (`lib/typescript.js`); their platform package
/// (`@typescript/typescript-darwin-arm64`) holds the executable, which serves LSP itself
/// (`--lsp --stdio`).
pub(crate) fn native_typescript(dir: &Path) -> Option<PathBuf> {
let package = if dir.join("package.json").is_file() { dir } else { dir.parent()? };
if has_ts_api(&package.join("lib")) {
return None;
}
let manifest: serde_json::Value =
serde_json::from_slice(&std::fs::read(package.join("package.json")).ok()?).ok()?;
let (base, bin) = match manifest.get("name")?.as_str()? {
"typescript" => ("typescript", "tsc"),
"@typescript/native-preview" => ("native-preview", "tsgo"),
_ => return None,
};
let os = match std::env::consts::OS {
"macos" => "darwin",
"windows" => "win32",
os => os,
};
let arch = match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "x64",
arch => arch,
};
let platform = format!("{base}-{os}-{arch}");
// Next to the package (`node_modules/@typescript/…`) or nested in it.
package
.ancestors()
.map(|a| a.join("node_modules/@typescript").join(&platform).join("lib").join(crate::exe(bin)))
.find(|exe| exe.is_file())
}
/// The usable TypeScript library Node resolves from an installed server package.
pub(crate) fn own_typescript(package: &Path) -> Option<PathBuf> {
package.ancestors().map(|a| a.join("node_modules/typescript/lib")).find(|l| has_ts_api(l))
-79
View File
@@ -1325,85 +1325,6 @@ fn custom_tool_path_replaces_the_command() {
assert_eq!(cfg.command, "zls");
}
/// A server picked from a Node.js installation (nvm's `bin/tsc`, `bin/npx`) is a
/// `#!/usr/bin/env node` script: it runs on that installation's Node, ahead of the
/// project's Node.js interpreter.
#[test]
fn a_server_chosen_from_a_node_installation_runs_on_that_node() {
let tmp = tempfile::tempdir().unwrap();
let v26 = tmp.path().join("nvm/versions/node/v26.4.0/bin");
let v20 = tmp.path().join("node20/bin");
for dir in [&v26, &v20] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(exe("node")), "").unwrap();
}
std::fs::write(v26.join("tsc"), "#!/usr/bin/env node\n").unwrap();
let mut s = Settings::default();
s.languages.node.interpreter = v20.join(exe("node")).to_string_lossy().into_owned();
s.tools.insert(
"typescript-language-server".into(),
ide_core::settings::ToolSettings { path: v26.join("tsc").to_string_lossy().into_owned() },
);
let mut cfg = server("typescript-language-server");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
let path: Vec<std::path::PathBuf> = std::env::split_paths(&cfg.env["PATH"]).collect();
assert_eq!(path[..2], [v26.clone(), v20.clone()]);
// Elsewhere, the project's Node.js interpreter comes first.
s.tools.insert(
"typescript-language-server".into(),
ide_core::settings::ToolSettings { path: tmp.path().join("tools/tsc").to_string_lossy().into_owned() },
);
let mut cfg = server("typescript-language-server");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(std::env::split_paths(&cfg.env["PATH"]).next(), Some(v20));
}
/// TypeScript 7 (`typescript` 7+, `@typescript/native-preview`) has no tsserver: choosing it
/// runs its native executable's own language server instead of vtsls / typescript-language-server.
#[test]
fn typescript_7_runs_its_own_language_server() {
let tmp = tempfile::tempdir().unwrap();
let modules = tmp.path().join("node_modules");
let os = match std::env::consts::OS {
"macos" => "darwin",
"windows" => "win32",
os => os,
};
let arch = match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "x64",
arch => arch,
};
std::fs::create_dir_all(modules.join("typescript/lib")).unwrap();
std::fs::write(modules.join("typescript/package.json"), r#"{"name":"typescript","version":"7.0.2"}"#).unwrap();
let native = modules.join(format!("@typescript/typescript-{os}-{arch}/lib")).join(exe("tsc"));
std::fs::create_dir_all(native.parent().unwrap()).unwrap();
std::fs::write(&native, "").unwrap();
let mut s = Settings::default();
s.languages.typescript.version = "workspace".into();
for name in ["vtsls", "typescript-language-server"] {
let mut cfg = server(name);
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(Path::new(&cfg.command), native, "{name}");
assert_eq!(cfg.args, ["--lsp", "--stdio"]);
assert!(cfg.initialization_options.is_none());
}
// Picked as a directory (its `lib`, as for older versions), too.
s.languages.typescript.version = modules.join("typescript/lib").to_string_lossy().into_owned();
let mut cfg = server("vtsls");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(Path::new(&cfg.command), native);
// TypeScript 6 keeps the tsserver-based server, pointed at it.
std::fs::write(modules.join("typescript/lib/typescript.js"), "").unwrap();
std::fs::write(modules.join("typescript/lib/tsserver.js"), "").unwrap();
s.languages.typescript.version = "workspace".into();
let mut cfg = server("vtsls");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(cfg.command, "vtsls");
}
#[test]
fn the_godot_setting_applies_to_server_debugger_and_run_configurations() {
let tmp = tempfile::tempdir().unwrap();
-82
View File
@@ -104,17 +104,6 @@ pub enum InstallEvent {
Progress { fraction: Option<f64>, message: String },
}
/// A newer version of a tool the IDE installed, as its install source offers it now.
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ToolUpdate {
pub name: String,
/// The installed version.
pub current: String,
/// The version installing the tool again would get.
pub latest: String,
}
#[derive(Serialize, Deserialize)]
struct Manifest {
version: Option<String>,
@@ -1448,77 +1437,6 @@ impl Tools {
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) {
-23
View File
@@ -935,26 +935,3 @@ fn a_cancelled_install_leaves_the_previous_one_in_place() {
assert!(dest.join("new").exists() && !dest.join("old").exists());
assert!(aside.join("old").exists(), "the previous install is moved aside");
}
/// An npm tool the IDE installed is offered the version the user's npm registry has now.
#[cfg(unix)]
#[test]
fn npm_tools_report_a_newer_registry_version() {
let env = Env::new();
let sdk = env._tmp.path().join("node-sdk/bin");
let modules = env.root.join("node_modules");
env.script(&modules.join(".bin"), "bash-language-server", "echo 5.4.0");
fs::create_dir_all(modules.join("bash-language-server")).unwrap();
fs::write(modules.join("bash-language-server/package.json"), r#"{"version":"5.4.0"}"#).unwrap();
env.script(&sdk, "node", "exit 0");
let tools = |latest: &str| {
env.script(&sdk, "npm", &format!(r#"[ "$1 $2 $3" = "view bash-language-server version" ] && echo {latest}"#));
env.tools().with_runtime(BTreeMap::from([("node".into(), sdk.join("node"))]), BTreeMap::new())
};
let cancel = CancellationToken::new();
assert_eq!(
tools("5.6.1").updates(&cancel).unwrap(),
vec![ToolUpdate { name: "bash-language-server".into(), current: "5.4.0".into(), latest: "5.6.1".into() }]
);
assert_eq!(tools("5.4.0").updates(&cancel).unwrap(), vec![]);
}
-149
View File
@@ -1,149 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
declare global {
interface Window {
__dbLifecycle: { release: (fail: boolean) => void; releaseSave?: () => void };
}
}
async function setup(page: Page) {
await stubDesktop(page);
await page.evaluate(async () => {
const storePath = "/src/features/database/store.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const { useDatabase } = await import(storePath);
const { toolWindowService } = await import(contributionsPath);
let sources = [{ id: "lifecycle", name: "Lifecycle database", driver: "sqlite", file: "/project/data.sqlite" }];
let resolve!: (schemas: string[]) => void;
let reject!: (reason: unknown) => void;
const response = new Promise<string[]>((res, rej) => {
resolve = res;
reject = rej;
});
window.__dbLifecycle = {
release: (fail) => {
if (fail) reject({ kind: "unavailable", message: "Old metadata failed" });
else resolve(["old_schema"]);
},
};
Object.assign(window.__ipc.handlers, {
db_sources: () => sources,
db_connected: () => [],
db_connect: () => null,
db_disconnect: () => null,
db_schemas: () => response,
db_save_sources: (args: { sources: typeof sources }) => {
sources = args.sources;
return null;
},
});
useDatabase.setState({
sources,
loaded: true,
connected: new Set(),
schemas: {},
tables: {},
details: {},
objects: {},
});
toolWindowService().show("database", true);
});
const panel = page.locator('section[aria-label="Database"]');
const source = panel.getByRole("treeitem").filter({ hasText: "Lifecycle database" });
await source.locator("[data-tree-toggle]").click();
await expect.poll(async () => (await ipcCalls(page, "db_schemas")).length).toBe(1);
await expect(panel.locator('[title="Connected"]')).toHaveCount(1);
return { panel, source };
}
async function release(page: Page, fail = false) {
await page.evaluate(async (fail) => {
window.__dbLifecycle.release(fail);
// Let the promise chain and the resulting React render finish.
await new Promise<void>((done) => requestAnimationFrame(() => requestAnimationFrame(() => done())));
}, fail);
}
for (const fail of [false, true]) {
test(`deactivation ignores a delayed metadata ${fail ? "failure" : "success"}`, async ({ workbench: page }) => {
const { panel, source } = await setup(page);
await source.click();
await panel.getByRole("button", { name: /^Deactivate/ }).click();
await expect(panel.locator('[title="Connected"]')).toHaveCount(0);
await release(page, fail);
await expect(panel.locator('[title="Connected"]')).toHaveCount(0);
await expect(panel.getByText("old_schema", { exact: true })).toHaveCount(0);
await expect(panel.getByText("Old metadata failed", { exact: true })).toHaveCount(0);
});
}
test("deleting a data source ignores its delayed metadata", async ({ workbench: page }) => {
const { panel } = await setup(page);
await page.evaluate(async () => {
const path = "/src/features/database/store.ts";
const { deleteSource } = await import(path);
await deleteSource("lifecycle");
});
await expect(panel.getByText("No data sources", { exact: true })).toBeVisible();
await release(page);
await expect(panel.getByText("No data sources", { exact: true })).toBeVisible();
await expect
.poll(() =>
page.evaluate(async () => {
const path = "/src/features/database/store.ts";
const { useDatabase } = await import(path);
return { connected: [...useDatabase.getState().connected], schemas: useDatabase.getState().schemas };
}),
)
.toEqual({ connected: [], schemas: {} });
});
test("switching projects ignores the old source even when its ID is reused", async ({ workbench: page }) => {
const { panel } = await setup(page);
await page.evaluate(async () => {
const projectPath = "/src/platform/project.ts";
const storePath = "/src/features/database/store.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const { useProject } = await import(projectPath);
const { loadSources } = await import(storePath);
const { toolWindowService } = await import(contributionsPath);
window.__ipc.handlers.db_sources = () => [{ id: "lifecycle", name: "New project database", driver: "sqlite" }];
useProject.setState({ project: { ...useProject.getState().project, root: "/new-project", name: "New project" } });
await loadSources();
toolWindowService().show("database", true);
});
await expect(panel.getByText("New project database", { exact: true })).toBeVisible();
await release(page);
await expect(panel.locator('[title="Connected"]')).toHaveCount(0);
await expect(panel.getByText("old_schema", { exact: true })).toHaveCount(0);
});
test("an old source save cannot close the new project's dialog", async ({ workbench: page }) => {
await setup(page);
await page.evaluate(async () => {
const path = "/src/features/database/store.ts";
const { openSourceDialog, useDatabase } = await import(path);
window.__ipc.handlers.db_save_sources = () =>
new Promise<void>((resolve) => {
window.__dbLifecycle.releaseSave = resolve;
});
openSourceDialog(useDatabase.getState().sources[0]);
});
await page.getByRole("dialog").getByRole("button", { name: "OK", exact: true }).click();
await expect.poll(async () => (await ipcCalls(page, "db_save_sources")).length).toBe(1);
await page.evaluate(async () => {
const storePath = "/src/features/database/store.ts";
const projectPath = "/src/platform/project.ts";
const { openDialog } = await import(storePath);
const { useProject } = await import(projectPath);
useProject.setState({ project: { ...useProject.getState().project, root: "/new-project", name: "New project" } });
openDialog({ kind: "params", names: ["newParameter"], resolve: () => {} });
});
await expect(page.getByRole("dialog").getByText("newParameter", { exact: true })).toBeVisible();
await page.evaluate(async () => {
window.__dbLifecycle.releaseSave!();
await new Promise<void>((done) => requestAnimationFrame(() => requestAnimationFrame(() => done())));
});
await expect(page.getByRole("dialog").getByText("newParameter", { exact: true })).toBeVisible();
});
+1 -3
View File
@@ -135,9 +135,7 @@ interface editor {
/// Applies versioned edits to an open project document from a background
/// event. The path is project-relative and requires `project.read` and
/// `editor.write`. The host refuses VCS/IDE control directories and every
/// symlink, junction or reparse-point component before it finds the open
/// document. Stale versions are rejected without changing text.
/// `editor.write`. Stale versions are rejected without changing text.
/// These background edits are non-undoable and are not re-emitted as
/// `document-changed` events.
apply-edits-at: func(project: borrow<project>, path: string, version: u64, edits: list<text-edit>) -> result<u64, error>;
-1
View File
@@ -458,7 +458,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tools::tools_list,
tools::tools_install,
tools::tools_uninstall,
tools::tools_updates,
tools::jdk_download_vendors,
tools::jdk_download_releases,
tools::jdk_download_location,
+1 -13
View File
@@ -6,7 +6,7 @@ use ide_core::{IdeResult, RequestId};
pub use ide_host::ops::tools::*;
use ide_sdk::Sdk;
use ide_tools::jdk::{JdkReleases, JdkVendor};
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate};
use ide_tools::{InstallEvent, ToolInfo};
use std::path::PathBuf;
use tauri::ipc::Channel;
use tauri::{State, Window};
@@ -33,18 +33,6 @@ pub async fn tools_install(
.await
}
/// Newer versions of the tools the IDE installed (release downloads and npm packages).
/// Cancel with `cancel_request(request)`.
#[tauri::command]
#[specta::specta]
pub async fn tools_updates(
window: Window,
state: State<'_, AppState>,
request: RequestId,
) -> IdeResult<Vec<ToolUpdate>> {
ide_host::ops::tools::tools_updates(&state.host, window.label(), request).await
}
/// Removes a tool the IDE installed.
#[tauri::command]
#[specta::specta]
-14
View File
@@ -966,11 +966,6 @@ export const commands = {
toolsInstall: (name: string, request: RequestId, onEvent: Channel<InstallEvent>) => typedError<ToolInfo, IdeError>(__TAURI_INVOKE("tools_install", { name, request, onEvent })),
/** Removes a tool the IDE installed. */
toolsUninstall: (name: string) => typedError<ToolInfo, IdeError>(__TAURI_INVOKE("tools_uninstall", { name })),
/**
* Newer versions of the tools the IDE installed (release downloads and npm packages).
* Cancel with `cancel_request(request)`.
*/
toolsUpdates: (request: RequestId) => typedError<ToolUpdate[], IdeError>(__TAURI_INVOKE("tools_updates", { request })),
/** JDK distributions Download JDK offers. */
jdkDownloadVendors: () => __TAURI_INVOKE<JdkVendor[]>("jdk_download_vendors"),
/** Java versions `vendor` publishes; cancellable with `cancel_request(request)`. */
@@ -10227,15 +10222,6 @@ export type ToolStatus =
/** The executable chosen in settings. */
"custom" | "missing";
/** A newer version of a tool the IDE installed, as its install source offers it now. */
export type ToolUpdate = {
name: string,
/** The installed version. */
current: string,
/** The version installing the tool again would get. */
latest: string,
};
export type TransferError = {
path: string,
message: string,
+1 -76
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { OutlineItem, References } from "@/platform/ipc";
import { _addEditor, _emitEditorEvent, _removeEditor, codeLenses, documentSynced, viewShown } from "@/editor/api";
import { _addEditor, _removeEditor, codeLenses, documentSynced } from "@/editor/api";
const ipc = vi.hoisted(() => ({
docOutline: vi.fn(),
@@ -264,78 +264,3 @@ describe("background usage counts", () => {
}
});
});
describe("recounting when the project's documents change", () => {
it("recounts when another document is edited (the call sites)", async () => {
const view = editor();
await settle();
expect(titles(view)).toEqual(["2 usages"]);
_emitEditorEvent({ type: "docChanged", doc: 9, path: "/p/b.rs", baseVersion: 1, version: 2, changes: [] });
await settle();
expect(ipc.lspReferences).toHaveBeenCalledTimes(2);
ipc.lspReferences.mockResolvedValue(refs(5));
_emitEditorEvent({ type: "docChanged", doc: 9, path: "/p/b.rs", baseVersion: 2, version: 3, changes: [] });
await settle();
expect(titles(view)).toEqual(["5 usages"]);
});
it("recounts when a kept tab is shown again after another document changed while hidden", async () => {
const view = editor();
await settle();
expect(titles(view)).toEqual(["2 usages"]);
ipc.lspReferences.mockResolvedValue(refs(5));
// A recently used tab keeps its view, detached from the document, while hidden.
view.dom.remove();
_emitEditorEvent({ type: "docChanged", doc: 9, path: "/p/b.rs", baseVersion: 1, version: 2, changes: [] });
await settle();
expect(ipc.lspReferences).toHaveBeenCalledTimes(1);
document.body.append(view.dom);
view.dispatch({ effects: viewShown.of(null) });
await settle();
expect(titles(view)).toEqual(["5 usages"]);
});
it("recounts when the backend patches another document (a refactoring)", async () => {
const view = editor();
await settle();
expect(titles(view)).toEqual(["2 usages"]);
ipc.lspReferences.mockResolvedValue(refs(3));
emitLocalIdeEvent({ type: "docPatched", doc: 9, baseVersion: 1, version: 2, changes: [] });
await settle();
expect(titles(view)).toEqual(["3 usages"]);
});
it("leaves a backend patch of this document to the document change itself", async () => {
editor();
await settle();
const calls = ipc.lspReferences.mock.calls.length;
emitLocalIdeEvent({ type: "docPatched", doc: 7, baseVersion: 1, version: 2, changes: [] });
await settle();
expect(ipc.lspReferences).toHaveBeenCalledTimes(calls);
});
it("recounts when the plugin document indexes change (an XML statement indexed after its declaration)", async () => {
const view = editor();
await settle();
expect(titles(view)).toEqual(["2 usages"]);
ipc.lspReferences.mockResolvedValue(refs(5));
const { invalidateNavigationProviders } = await import("@/editor/navigationProviders");
invalidateNavigationProviders();
await settle();
expect(titles(view)).toEqual(["5 usages"]);
});
it("recounts when files change on disk or another document closes", async () => {
const view = editor();
await settle();
expect(titles(view)).toEqual(["2 usages"]);
ipc.lspReferences.mockResolvedValue(refs(3));
emitLocalIdeEvent({ type: "fsChanged", created: [], modified: ["/p/b.rs"], removed: [] });
await settle();
expect(titles(view)).toEqual(["3 usages"]);
ipc.lspReferences.mockResolvedValue(refs(4));
_emitEditorEvent({ type: "docClosed", doc: 9, path: "/p/b.rs" });
await settle();
expect(titles(view)).toEqual(["4 usages"]);
});
});
+10 -46
View File
@@ -2,21 +2,15 @@
* "N usages" code lenses above declarations (IDEA's usages inlay). Cheap by
* construction: only declarations in (and near) the viewport are counted, one
* debounced pass after edits / scrolling, results cached per document text
* and symbol until the project's documents change, and at most a few reference
* requests in flight. Counts run the same references query the Find Usages
* panel runs (including the plugin indexers' anchors), scoped to what the
* server analyses: opening candidate files or the libraries for every count
* would trigger expensive server analysis, so only an explicit Find Usages
* widens the server's scope, and a widened search can find more. Beside each
* and symbol, and at most a few reference requests in flight. Beside each
* count, the declaration's last author from VCS blame (`authorLens`).
*/
import { Channel } from "@tauri-apps/api/core";
import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { codeLenses, onEditorEvent, shownAgain, type CodeLens } from "@/editor/api";
import { onNavigationProvidersChanged } from "@/editor/navigationProviders";
import { codeLenses, shownAgain, type CodeLens } from "@/editor/api";
import { executeAction, getAction } from "@/platform/actions";
import { commands, nextRequestId, onIdeEvent, type OutlineItem, type RequestId, type SymbolKind } from "@/platform/ipc";
import { commands, nextRequestId, type OutlineItem, type RequestId, type SymbolKind } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
import { posToOffset } from "./positions";
@@ -140,7 +134,6 @@ class UsagesLensPlugin {
private offReady: () => void;
private offSettled: () => void;
private offBlame: () => void;
private offChanges: () => void;
private skipped = false;
constructor(private view: EditorView) {
@@ -149,7 +142,12 @@ class UsagesLensPlugin {
if (s.settings !== prev.settings) this.refresh();
});
// Counts computed while the server was indexing may be incomplete.
this.offReady = onLspRefresh("codeLens", () => this.invalidate());
this.offReady = onLspRefresh("codeLens", () => {
this.cache.clear();
this.incomplete.clear();
this.retries.clear();
this.refresh();
});
this.offSettled = onServerSettled(() => {
if (!this.incomplete.size) return;
this.incomplete.clear();
@@ -157,43 +155,10 @@ class UsagesLensPlugin {
this.refresh();
});
this.offBlame = onBlameInvalidated(() => this.refresh());
// Counts cover the whole project, so a change anywhere can outdate them
// while this view is hidden and skips its passes: another document's edit
// (the call sites) or a closed unsaved buffer, a backend patch (a
// refactoring), a file changed on disk, or a plugin index batch (an XML
// statement is indexed after its Java declaration). This view's own edits
// already refresh through `update`.
const offDocs = onEditorEvent((e) => {
if (e.type === "docChanged" && e.doc !== docOf(this.view)?.doc) this.invalidate();
else if (e.type === "docClosed") this.invalidate();
});
const offPatches = onIdeEvent("docPatched", (e) => {
if (e.doc !== docOf(this.view)?.doc) this.invalidate();
});
const offBackend = [
onIdeEvent("workspaceEditApplied", () => this.invalidate()),
onIdeEvent("fsChanged", () => this.invalidate()),
onNavigationProvidersChanged(() => this.invalidate()),
];
this.offChanges = () => {
offDocs();
offPatches();
offBackend.forEach((off) => off());
};
}
/** Counts are cached until anything in the project may have changed them. */
private invalidate() {
this.cache.clear();
this.incomplete.clear();
this.retries.clear();
this.refresh();
}
update(u: ViewUpdate) {
// A re-shown tab ran nothing while hidden, and its counts may have been
// invalidated meanwhile; when they are cached the pass only redraws.
if (u.docChanged || u.viewportChanged || shownAgain(u)) this.refresh();
if (u.docChanged || u.viewportChanged || (this.skipped && shownAgain(u))) this.refresh();
}
private cancel() {
@@ -324,7 +289,6 @@ class UsagesLensPlugin {
this.offReady();
this.offSettled();
this.offBlame();
this.offChanges();
this.cancel();
}
}
+5 -21
View File
@@ -268,29 +268,13 @@
gap: 6px;
min-width: 0;
}
/* The whole tree row, indent included, so a coloured source and its children form one band. */
.tintedRow:not([aria-selected="true"]):not(:hover) {
border-radius: 0;
.tinted {
flex: 1;
margin: 0 -4px;
padding: 0 4px;
border-radius: var(--radius-control, 4px);
background: color-mix(in srgb, var(--db-tint) var(--tint-strength), transparent);
}
.tintRed {
--db-tint: var(--db-color-red);
}
.tintOrange {
--db-tint: var(--db-color-orange);
}
.tintYellow {
--db-tint: var(--db-color-yellow);
}
.tintGreen {
--db-tint: var(--db-color-green);
}
.tintBlue {
--db-tint: var(--db-color-blue);
}
.tintViolet {
--db-tint: var(--db-color-violet);
}
.nodeLabel {
overflow: hidden;
text-overflow: ellipsis;
@@ -8,7 +8,6 @@ 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 }) => ({
@@ -192,20 +191,3 @@ describe("Expand All", () => {
expect(tree.getByText("SQLite").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
});
});
describe("data source colour", () => {
it("tints the whole tree row of the source and of every node under it", () => {
useDatabase.setState({
sources: [
{ id: "pg", name: "PostgreSQL", driver: "postgres", color: "red", options: { singleDatabase: true } },
{ id: "lite", name: "SQLite", driver: "sqlite" },
],
});
render(<DatabaseView />);
const tree = within(screen.getByRole("tree", { name: "Data sources" }));
for (const name of ["PostgreSQL", "public", "routines", "refresh_data"]) {
expect(tree.getByText(name).closest('[role="treeitem"]')).toHaveClass(styles.tintedRow!, styles.tintRed!);
}
expect(tree.getByText("SQLite").closest('[role="treeitem"]')).not.toHaveClass(styles.tintedRow!);
});
});
+10 -27
View File
@@ -5,8 +5,8 @@
* with counts and column types, loaded lazily. Double-click a table to open its data, a data
* source to open its query console.
*/
import { useEffect, useMemo, useRef } from "react";
import type { DbColor, DbDatabases, DbTableDetails } from "@/platform/ipc";
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
import type { DbDatabases, DbTableDetails } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import {
@@ -23,6 +23,7 @@ import {
T,
} from "@/ui";
import {
colorVar,
databaseKey,
passesObjectFilter,
passesTreeFilter,
@@ -61,16 +62,6 @@ import { DB_VIEW_FOCUS, databaseTree, openTableData } from "./treeActions";
import { newMenu, nodeMenu } from "./treeMenu";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
import { databaseRequest } from "./requests";
const ROW_TINTS: Record<DbColor, string | undefined> = {
red: styles.tintRed,
orange: styles.tintOrange,
yellow: styles.tintYellow,
green: styles.tintGreen,
blue: styles.tintBlue,
violet: styles.tintViolet,
};
const GROUP_LABELS: Record<Group, string> = {
columns: "columns",
@@ -355,10 +346,7 @@ export function DatabaseView() {
const load = async (n: Node) => {
if (n.kind === "source") {
const id = n.source.id ?? "";
const current = databaseRequest(id);
const connected = await connect(id);
if (!current()) return;
if (!connected) {
if (!(await connect(id))) {
const err = { status: "error" as const, message: tx("Cannot connect") };
if (listsDatabases(n.source)) useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: err } }));
else useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: err } }));
@@ -367,10 +355,7 @@ export function DatabaseView() {
if (listsDatabases(n.source)) await loadDatabases(id);
else await loadSchemas(id);
} else if (n.kind === "database") {
const current = databaseRequest(n.id);
const connected = await connect(n.id);
if (!current()) return;
if (!connected) {
if (!(await connect(n.id))) {
useDatabase.setState((s) => ({
schemas: { ...s.schemas, [n.id]: { status: "error", message: tx("Cannot connect") } },
}));
@@ -557,12 +542,6 @@ export function DatabaseView() {
getIcon={(n) => icon(n)}
getDetail={detail}
getColor={(n) => (n.kind === "message" && n.error ? "var(--danger)" : undefined)}
rowClassName={(n) => {
// IDEA tints every row of a coloured data source across its full width and height.
const target = nodeTarget(n);
const color = target ? sourceOf(target)?.color : null;
return color ? `${styles.tintedRow} ${ROW_TINTS[color]}` : undefined;
}}
renderNode={(n: Node) => {
const i = icon(n);
const d = detail(n);
@@ -574,9 +553,13 @@ export function DatabaseView() {
? styles.keyLabel
: styles.nodeLabel;
const live = n.kind === "source" && [...st.connected].some((t) => splitTarget(t).id === n.source.id);
const target = nodeTarget(n);
// IDEA tints every row of a coloured data source.
const tint = colorVar(target ? sourceOf(target)?.color : null);
return (
<span
className={styles.nodeRow}
className={tint ? `${styles.nodeRow} ${styles.tinted}` : styles.nodeRow}
style={tint ? ({ "--db-tint": tint } as CSSProperties) : undefined}
title={n.kind === "source" && n.source.comment ? n.source.comment : undefined}
>
{i && (
+1 -2
View File
@@ -512,7 +512,6 @@ export function SourceDialog({
};
const save = async () => {
const dialog = useDatabase.getState().dialog;
setBusy(true);
try {
await saveSource(
@@ -520,7 +519,7 @@ export function SourceDialog({
sqlite ? undefined : password === undefined ? undefined : password || null,
!sshOn || sshSecret === undefined || sshUsed.auth === "openSsh" ? undefined : sshSecret || null,
);
if (useDatabase.getState().dialog === dialog) closeDialog();
closeDialog();
} catch (e) {
setTest({ ok: false, text: e instanceof Error ? e.message : String(e) });
} finally {
+1 -6
View File
@@ -83,7 +83,6 @@ import {
} from "./store";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
import { databaseRequest, projectRequest } from "./requests";
/** `templates` feature's language-data key for finer template contexts. */
const TEMPLATE_CONTEXT_KEY = "openideaTemplateContexts";
@@ -609,14 +608,12 @@ function catalogOf(id: string): SqlCatalog | undefined {
/** Loads what `database.` / `database.schema.` at the caret needs before it is completed. */
async function loadQualified(id: string, qualifier: readonly string[]): Promise<void> {
const current = databaseRequest(id);
const sourceId = splitTarget(id).id;
// Introspection has it: nothing to load (and no reason to connect for it).
const wanted = (qualifier[0] ?? "").toLowerCase();
const read = useDatabase.getState().catalogs[sourceId]?.databases ?? {};
if (Object.keys(read).some((d) => d.toLowerCase() === wanted)) return;
if (!useDatabase.getState().databases[sourceId]) await loadDatabases(sourceId);
if (!current()) return;
const dbs = useDatabase.getState().databases[sourceId];
if (dbs?.status !== "ok") return;
const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
@@ -625,7 +622,6 @@ async function loadQualified(id: string, qualifier: readonly string[]): Promise<
if (useDatabase.getState().catalogs[sourceId]?.databases[database]) return;
const target = targetOf(sourceId, database, dbs.value.current);
if (!useDatabase.getState().schemas[target]) await loadSchemas(target);
if (!current()) return;
if (qualifier.length < 2) return;
const list = useDatabase.getState().schemas[target];
const schema = list?.status === "ok" ? list.value.find((s) => same(s, qualifier[1]!)) : undefined;
@@ -635,7 +631,6 @@ async function loadQualified(id: string, qualifier: readonly string[]): Promise<
function completionSource(path: string): CompletionSource {
return async (ctx: CompletionContext): Promise<CompletionResult | null> => {
const id = consoleSource(path);
const current = id ? databaseRequest(id) : projectRequest();
const source = sourceById(id);
const driver: DbDriver = source?.driver ?? "sqlite";
const model = id ? (useDatabase.getState().completion[id] ?? null) : null;
@@ -654,7 +649,7 @@ function completionSource(path: string): CompletionSource {
const cataloged = driver === "snowflake" && !!id;
if (cataloged && analysis.qualifier?.length) {
await loadQualified(id, analysis.qualifier).catch(() => {});
if (ctx.aborted || !current()) return null;
if (ctx.aborted) return null;
}
const candidates = sqlCandidates({
model: scoped,
+1 -19
View File
@@ -24,7 +24,6 @@ import { installValueEditorActions } from "./valueEditorActions";
import { registerResultTabActions } from "./resultTabActions";
import { SQL_LANGUAGE } from "./sqlLanguage";
import { TABLE_KIND, tableTitle } from "./tableInput";
import { databaseRequest, projectRequest } from "./requests";
import { nodeTarget, type DbNode } from "./nodes";
import { registerDatabaseTreeActions } from "./treeActions";
import {
@@ -143,14 +142,9 @@ export function activate(): () => void {
throw new Error(tx("Trust this project to connect plugin data sources"));
const id = e.arg;
if (typeof id !== "string") throw new Error(tx("Invalid data source id"));
const project = projectRequest();
await loadSources();
if (!project()) return;
const current = databaseRequest(id);
toolWindowService().show(DB_TOOL_WINDOW, true);
const connected = await connect(id);
if (!current()) return;
if (!connected) throw new Error(tx("Database connection failed"));
if (!(await connect(id))) throw new Error(tx("Database connection failed"));
if (listsDatabases(sourceById(id))) await loadDatabases(id);
else await loadSchemas(id);
},
@@ -172,9 +166,7 @@ export function activate(): () => void {
keywords: ["database", "connection", "postgres", "mysql", "sqlite"],
when: "projectOpen",
run: async () => {
const current = projectRequest();
if (!useDatabase.getState().loaded) await loadSources();
if (!current()) return;
openSourceDialog(null);
},
},
@@ -186,10 +178,8 @@ export function activate(): () => void {
keywords: ["database", "sql console"],
when: "projectOpen",
run: async ({ arg }) => {
const current = projectRequest();
const s = useDatabase.getState();
if (!s.loaded) await loadSources();
if (!current()) return;
const { sources, selectedSource } = useDatabase.getState();
// A tree node (New ▸ Query Console) opens its data source's console.
const node = arg && typeof arg === "object" && "kind" in arg ? nodeTarget(arg as DbNode) : null;
@@ -205,11 +195,9 @@ export function activate(): () => void {
icon: "run",
when: `editorTextFocus && editorLangId == ${SQL_LANGUAGE}`,
run: async () => {
const current = projectRequest();
const e = activeEditor();
if (!e || e.ctx.language !== SQL_LANGUAGE) return;
if (!useDatabase.getState().loaded) await loadSources();
if (!current()) return;
if (!consoleSource(e.ctx.path) && !useDatabase.getState().sources.length) {
openSourceDialog(null);
return;
@@ -225,11 +213,9 @@ export function activate(): () => void {
keywords: ["run all", "whole file", "statements"],
when: `editorTextFocus && editorLangId == ${SQL_LANGUAGE}`,
run: async () => {
const current = projectRequest();
const e = activeEditor();
if (!e || e.ctx.language !== SQL_LANGUAGE) return;
if (!useDatabase.getState().loaded) await loadSources();
if (!current()) return;
if (!consoleSource(e.ctx.path) && !useDatabase.getState().sources.length) {
openSourceDialog(null);
return;
@@ -269,11 +255,9 @@ export function activate(): () => void {
keywords: ["query plan", "explain", "sql"],
when: `editorTextFocus && editorLangId == ${SQL_LANGUAGE}`,
run: async () => {
const current = projectRequest();
const e = activeEditor();
if (!e || e.ctx.language !== SQL_LANGUAGE) return;
if (!useDatabase.getState().loaded) await loadSources();
if (!current()) return;
await (await import("./console")).explainInView(e.view, e.ctx.path);
},
},
@@ -285,9 +269,7 @@ export function activate(): () => void {
keywords: ["database", "sql", "history"],
when: "projectOpen",
run: async () => {
const current = projectRequest();
if (!useDatabase.getState().loaded) await loadSources();
if (!current()) return;
const e = activeEditor();
const path = e?.ctx.language === SQL_LANGUAGE ? e.ctx.path : null;
const s = useDatabase.getState();
+3 -82
View File
@@ -1,11 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DataSource, DbSourceCatalog, ProjectInfo } from "@/platform/ipc";
import type { DataSource, DbSourceCatalog } from "@/platform/ipc";
const backend = vi.hoisted(() => ({
saved: vi.fn(),
database: vi.fn(),
retain: vi.fn(),
disconnect: vi.fn(),
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
@@ -17,7 +16,6 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
dbSavedCatalog: backend.saved,
dbDatabaseCatalog: backend.database,
dbRetainCatalog: backend.retain,
dbDisconnect: backend.disconnect,
},
};
});
@@ -29,9 +27,7 @@ const HOUR = 60 * 60 * 1000;
/** A fresh copy of the module (its "asked this run" memory starts empty) over a seeded store. */
async function setup(id: string, saved: DbSourceCatalog | null, introspection?: Partial<DataSource["introspection"]>) {
vi.resetModules();
const store = await import("./store");
const { useDatabase } = store;
const { useProject } = await import("@/platform/project");
const { useDatabase } = await import("./store");
const source = {
id,
name: "warehouse",
@@ -47,18 +43,7 @@ async function setup(id: string, saved: DbSourceCatalog | null, introspection?:
backend.saved.mockResolvedValue(ok(saved));
backend.database.mockImplementation((_id: string, database: string) => Promise.resolve(ok(schemas(`${database}_T`))));
backend.retain.mockResolvedValue(ok(null));
backend.disconnect.mockResolvedValue(ok(null));
return { ...store, useProject, ...(await import("./introspection")) };
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
return { useDatabase, ...(await import("./introspection")) };
}
// Each test loads a fresh copy of the store module, which takes a while in a busy run.
@@ -131,68 +116,4 @@ describe("Snowflake introspection", () => {
expect(backend.saved).not.toHaveBeenCalled();
expect(backend.database).not.toHaveBeenCalled();
});
for (const boundary of ["disconnect", "project switch"] as const) {
it(`ignores a saved catalog returned after ${boundary}`, async () => {
const run = await setup("sf", null);
const response = deferred<ReturnType<typeof ok<DbSourceCatalog>>>();
backend.saved.mockReturnValueOnce(response.promise);
const pending = run.loadSavedCatalog("sf");
if (boundary === "disconnect") await run.disconnect("sf");
else run.useProject.setState({ project: { root: "/new-project" } as ProjectInfo });
run.useDatabase.setState({ catalogs: {} });
response.resolve(ok({ databases: [{ name: "OLD", readAt: 1, schemas: schemas("OLD_T") }] }));
await pending;
expect(run.useDatabase.getState().catalogs).toEqual({});
});
for (const outcome of ["success", "failure"] as const) {
it(`stops live introspection after ${boundary} and a late ${outcome}`, async () => {
const run = await setup("sf", null);
const response = deferred<ReturnType<typeof ok<ReturnType<typeof schemas>>>>();
backend.database.mockReturnValueOnce(response.promise);
const pending = run.introspectSource("sf");
await vi.waitFor(() => expect(backend.database).toHaveBeenCalledOnce());
if (boundary === "disconnect") await run.disconnect("sf");
else run.useProject.setState({ project: { root: "/new-project" } as ProjectInfo });
run.useDatabase.setState({ catalogs: {} });
if (outcome === "success") response.resolve(ok(schemas("OLD_T")));
else response.reject(new Error("old catalog failed"));
await pending;
expect(run.useDatabase.getState().catalogs).toEqual({});
expect(backend.database).toHaveBeenCalledOnce();
expect(backend.retain).not.toHaveBeenCalled();
});
}
}
it("loads a new saved catalog without reusing the previous project's request", async () => {
const run = await setup("sf", null);
const old = deferred<ReturnType<typeof ok<DbSourceCatalog>>>();
backend.saved.mockReturnValueOnce(old.promise);
const previous = run.loadSavedCatalog("sf");
run.useProject.setState({ project: { root: "/new-project" } as ProjectInfo });
backend.saved.mockResolvedValueOnce(ok({ databases: [{ name: "NEW", readAt: 2, schemas: schemas("NEW_T") }] }));
await run.loadSavedCatalog("sf");
old.resolve(ok({ databases: [{ name: "OLD", readAt: 1, schemas: schemas("OLD_T") }] }));
await previous;
expect(Object.keys(run.useDatabase.getState().catalogs.sf!.databases)).toEqual(["NEW"]);
expect(backend.saved).toHaveBeenCalledTimes(2);
});
it("keeps a new introspection intact when an older run finishes", async () => {
const run = await setup("sf", null);
const old = deferred<ReturnType<typeof ok<ReturnType<typeof schemas>>>>();
backend.database.mockReturnValueOnce(old.promise);
const previous = run.introspectSource("sf");
await vi.waitFor(() => expect(backend.database).toHaveBeenCalledOnce());
run.useProject.setState({ project: { root: "/new-project" } as ProjectInfo });
run.useDatabase.setState({ catalogs: {} });
await run.introspectSource("sf");
old.resolve(ok(schemas("OLD_T")));
await previous;
expect(run.useDatabase.getState().catalogs.sf?.status).toBe("done");
expect(run.useDatabase.getState().catalogs.sf?.databases.ANALYTICS).toEqual(schemas("ANALYTICS_T"));
expect(backend.retain).toHaveBeenCalledOnce();
});
});
+13 -35
View File
@@ -9,12 +9,11 @@ import { tx } from "@/platform/i18n";
import { notifications } from "@/workbench/contributions";
import { shownNames, splitTarget } from "./model";
import { loadDatabases, sourceById, useDatabase } from "./store";
import { databaseRequest } from "./requests";
const ACCEPTED_KEY = "openidea.database.introspect";
/** Data sources asked (or started) in this run, so a connect asks only once. */
const offered = new Map<string, () => boolean>();
const cancelled = new Map<string, () => boolean>();
const offered = new Set<string>();
const cancelled = new Set<string>();
function accepted(): string[] {
try {
@@ -37,7 +36,7 @@ const notice = (id: string) => `db-introspect-${id}`;
/** A saved catalog older than this is read again on the next connect. */
const STALE_MS = 24 * 60 * 60 * 1000;
const loading = new Map<string, { current: () => boolean; promise: Promise<void> }>();
const loading = new Map<string, Promise<void>>();
/**
* Brings what earlier introspection of data source `id` saved into the store (once per run).
@@ -47,11 +46,9 @@ export function loadSavedCatalog(id: string): Promise<void> {
const sourceId = splitTarget(id).id;
if (sourceById(sourceId)?.driver !== "snowflake") return Promise.resolve();
let pending = loading.get(sourceId);
if (!pending?.current()) {
const current = databaseRequest(sourceId);
const promise = call(commands.dbSavedCatalog(sourceId))
if (!pending) {
pending = call(commands.dbSavedCatalog(sourceId))
.then((saved) => {
if (!current()) return;
const databases = saved?.databases ?? [];
// An introspection started meanwhile knows better.
if (!databases.length || useDatabase.getState().catalogs[sourceId]) return;
@@ -69,16 +66,14 @@ export function loadSavedCatalog(id: string): Promise<void> {
}));
})
.catch(() => {});
pending = { current, promise };
loading.set(sourceId, pending);
}
return pending.promise;
return pending;
}
/** Stops the running introspection of data source `id` after the database it is reading. */
export function cancelIntrospection(id: string) {
const sourceId = splitTarget(id).id;
cancelled.set(sourceId, databaseRequest(sourceId));
cancelled.add(splitTarget(id).id);
}
/** Reads the schemas and tables of every database data source `id` shows (see the Schemas tab). */
@@ -86,22 +81,18 @@ export async function introspectSource(id: string): Promise<void> {
const sourceId = splitTarget(id).id;
const source = sourceById(sourceId);
if (source?.driver !== "snowflake" || useDatabase.getState().catalogs[sourceId]?.status === "running") return;
const current = databaseRequest(sourceId);
offered.set(sourceId, current);
offered.add(sourceId);
remember(sourceId);
cancelled.delete(sourceId);
const name = source.name ?? "";
const update = (patch: Partial<{ status: "running" | "done" | "cancelled"; done: number; total: number }>) =>
useDatabase.setState((s) => {
if (!current()) return s;
const old = s.catalogs[sourceId] ?? { status: "running" as const, done: 0, total: 0, databases: {} };
return { catalogs: { ...s.catalogs, [sourceId]: { ...old, ...patch } } };
});
await loadSavedCatalog(sourceId);
if (!current()) return;
update({ status: "running", done: 0, total: 0 });
if (!useDatabase.getState().databases[sourceId]) await loadDatabases(sourceId);
if (!current()) return;
const listed = useDatabase.getState().databases[sourceId];
if (listed?.status !== "ok") {
update({ status: "cancelled" });
@@ -115,8 +106,7 @@ export async function introspectSource(id: string): Promise<void> {
const failed: string[] = [];
let reason = "";
for (const database of databases) {
if (!current()) return;
if (cancelled.get(sourceId)?.()) break;
if (cancelled.has(sourceId)) break;
const position = done + 1;
const count = databases.length;
notifications().notify({
@@ -129,7 +119,6 @@ export async function introspectSource(id: string): Promise<void> {
});
try {
const schemas = await call(commands.dbDatabaseCatalog(sourceId, database));
if (!current()) return;
tables += schemas.reduce((n, schema) => n + schema.tables.length, 0);
useDatabase.setState((s) => {
const old = s.catalogs[sourceId];
@@ -139,7 +128,6 @@ export async function introspectSource(id: string): Promise<void> {
};
});
} catch (error) {
if (!current()) return;
// A database the role may not read: completion goes without it, and the summary says so.
failed.push(database);
reason ||= error instanceof Error ? error.message : String(error);
@@ -147,9 +135,7 @@ export async function introspectSource(id: string): Promise<void> {
done += 1;
update({ done });
}
if (!current()) return;
const stopped = cancelled.get(sourceId)?.() ?? false;
cancelled.delete(sourceId);
const stopped = cancelled.delete(sourceId);
const total = tables.toLocaleString("en");
const read = done - failed.length;
const unread = failed.length;
@@ -164,7 +150,6 @@ export async function introspectSource(id: string): Promise<void> {
return { catalogs: { ...s.catalogs, [sourceId]: { ...old, databases: kept, readAt } } };
});
await call(commands.dbRetainCatalog(sourceId, databases)).catch(() => {});
if (!current()) return;
}
update({ status: stopped ? "cancelled" : "done" });
const summary = tx`${total} tables and views in ${read} databases are ready for completion.`;
@@ -185,11 +170,9 @@ export async function introspectSource(id: string): Promise<void> {
export async function offerIntrospection(id: string): Promise<void> {
const sourceId = splitTarget(id).id;
const source = sourceById(sourceId);
if (source?.driver !== "snowflake" || offered.get(sourceId)?.()) return;
const current = databaseRequest(sourceId);
offered.set(sourceId, current);
if (source?.driver !== "snowflake" || offered.has(sourceId)) return;
offered.add(sourceId);
await loadSavedCatalog(sourceId);
if (!current()) return;
const known = useDatabase.getState().catalogs[sourceId];
if (known?.status === "running") return;
if (known?.readAt !== undefined && Date.now() - known.readAt < STALE_MS) return;
@@ -204,12 +187,7 @@ export async function offerIntrospection(id: string): Promise<void> {
title: tx`Introspect ${name}?`,
body: tx("Reads the names of schemas, tables and views of every database, so completion works for all of them."),
actions: [
{
label: "Introspect",
run: () => {
if (current()) void introspectSource(sourceId);
},
},
{ label: "Introspect", run: () => void introspectSource(sourceId) },
{ label: "Not Now", run: () => {} },
],
sticky: true,
@@ -1,721 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DataSource, ProjectInfo } from "@/platform/ipc";
import { useProject } from "@/platform/project";
const backend = vi.hoisted(() => ({
databases: vi.fn(),
schemas: vi.fn(),
tables: vi.fn(),
objects: vi.fn(),
details: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
completion: vi.fn(),
sources: vi.fn(),
connected: vi.fn(),
save: vi.fn(),
password: vi.fn(),
sshSecret: vi.fn(),
execute: vi.fn(),
statements: vi.fn(),
}));
const ui = vi.hoisted(() => ({ show: vi.fn(), notify: vi.fn() }));
vi.mock("@/workbench/contributions", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/workbench/contributions")>();
return { ...actual, toolWindowService: () => ({ show: ui.show }), notifications: () => ({ notify: ui.notify }) };
});
vi.mock("@/platform/ipc", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/platform/ipc")>();
return {
...actual,
commands: {
...actual.commands,
dbDatabases: backend.databases,
dbSchemas: backend.schemas,
dbTables: backend.tables,
dbSchemaObjects: backend.objects,
dbTableDetails: backend.details,
dbConnect: backend.connect,
dbDisconnect: backend.disconnect,
dbCompletion: backend.completion,
dbSources: backend.sources,
dbConnected: backend.connected,
dbSaveSources: backend.save,
dbSetPassword: backend.password,
dbSetSshSecret: backend.sshSecret,
dbExecute: backend.execute,
dbStatements: backend.statements,
},
};
});
import {
connect,
deleteSource,
disconnect,
loadCompletion,
loadDatabases,
loadDetails,
loadSchemas,
loadSources,
loadTables,
refresh,
renameSource,
revealInTree,
saveSource,
runSql,
executeConsole,
useDatabase,
withConnection,
} from "./store";
const initial = useDatabase.getState();
const initialProject = useProject.getState();
const pg: DataSource = { id: "pg", name: "Database", driver: "postgres" };
const other: DataSource = { id: "pg2", name: "Other", driver: "postgres" };
const projectA = { root: "/project-a" } as ProjectInfo;
const projectB = { root: "/project-b" } as ProjectInfo;
const ok = <T>(data: T) => ({ status: "ok" as const, data });
function deferred<T = unknown>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const details = { columns: [], indexes: [], foreignKeys: [], triggers: [] };
const completion = { defaultSchema: "public", tables: [], foreignKeys: [] };
const cases = [
{
name: "databases",
mock: backend.databases,
load: (id: string) => loadDatabases(id),
value: { current: "app", names: ["app"] },
stale: { current: "old", names: ["old"] },
read: (id: string) => useDatabase.getState().databases[id],
},
{
name: "schemas",
mock: backend.schemas,
load: (id: string) => loadSchemas(id),
value: ["public"],
stale: ["old"],
read: (id: string) => useDatabase.getState().schemas[id],
},
{
name: "tables",
mock: backend.tables,
load: (id: string) => loadTables(id, "public"),
value: [{ name: "users", kind: "table" }],
stale: [{ name: "old", kind: "table" }],
read: (id: string) => useDatabase.getState().tables[`${id}\u0000public`],
},
{
name: "details",
mock: backend.details,
load: (id: string) => loadDetails(id, "public", "users"),
value: details,
stale: { ...details, columns: [{ name: "old" }] },
read: (id: string) => useDatabase.getState().details[`${id}\u0000public\u0000users`],
},
];
let savedSources: DataSource[];
function switchProject(project: ProjectInfo) {
useProject.setState({ project });
// The database feature's project subscription clears its visible state.
useDatabase.setState({ ...initial, sources: [pg, other], loaded: true }, true);
}
beforeEach(() => {
vi.resetAllMocks();
useProject.setState({ project: projectA });
savedSources = [pg, other];
useDatabase.setState({ ...initial, sources: savedSources, loaded: true, connected: new Set(["pg"]) }, true);
for (const spec of cases) spec.mock.mockResolvedValue(ok(spec.value));
backend.connect.mockResolvedValue(ok(null));
backend.disconnect.mockResolvedValue(ok(null));
backend.objects.mockResolvedValue(ok({ routines: [], packages: [], sequences: [] }));
backend.completion.mockResolvedValue(ok(completion));
backend.sources.mockImplementation(async () => ok(savedSources));
backend.connected.mockImplementation(async () => ok([...useDatabase.getState().connected]));
backend.save.mockImplementation(async (sources: DataSource[]) => {
savedSources = sources;
return ok(null);
});
backend.password.mockResolvedValue(ok(null));
backend.sshSecret.mockResolvedValue(ok(null));
backend.statements.mockImplementation(async (_id: string, text: string) => ok([{ text, from: 0, to: text.length }]));
});
afterEach(() => {
useProject.setState(initialProject, true);
useDatabase.setState(initial, true);
});
describe("database metadata request lifetimes", () => {
it.each(cases)("loads $name normally", async (spec) => {
await spec.load("pg");
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
const boundaries = [
{ name: "disconnect", run: () => disconnect("pg") },
{ name: "delete", run: () => deleteSource("pg") },
{ name: "edit", run: () => saveSource({ ...pg, host: "new-host" }, undefined) },
{ name: "project switch with the same source ID", run: async () => switchProject(projectB) },
{
name: "project A to B to A",
run: async () => {
switchProject(projectB);
switchProject(projectA);
},
},
];
for (const boundary of boundaries) {
for (const outcome of ["success", "failure"] as const) {
it.each(cases)(`ignores late ${outcome} for $name after ${boundary.name}`, async (spec) => {
const response = deferred();
spec.mock.mockReturnValueOnce(response.promise);
const pending = spec.load("pg");
await boundary.run();
const cache = spec.read("pg");
const connected = [...useDatabase.getState().connected];
if (outcome === "success") response.resolve(ok(spec.stale));
else response.reject(new Error("old connection failed"));
await pending;
expect.soft(spec.read("pg")).toEqual(cache);
expect([...useDatabase.getState().connected]).toEqual(connected);
});
}
}
it.each(cases)("keeps the latest $name when responses arrive out of order", async (spec) => {
const old = deferred();
spec.mock.mockReturnValueOnce(old.promise);
const pending = spec.load("pg");
await spec.load("pg");
old.resolve(ok(spec.stale));
await pending;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
it.each(cases)("does not replace fresh $name with an older failure", async (spec) => {
const old = deferred();
spec.mock.mockReturnValueOnce(old.promise);
const pending = spec.load("pg");
await spec.load("pg");
old.reject(new Error("old failure"));
await pending;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
it.each(cases)("keeps fresh $name after disconnect and reconnect", async (spec) => {
const old = deferred();
spec.mock.mockReturnValueOnce(old.promise);
const pending = spec.load("pg");
await disconnect("pg");
expect(await connect("pg")).toBe(true);
await spec.load("pg");
old.resolve(ok(spec.stale));
await pending;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
expect(useDatabase.getState().connected.has("pg")).toBe(true);
});
it.each(cases)("keeps a pending fresh $name when the old reply arrives first", async (spec) => {
const old = deferred();
const fresh = deferred();
spec.mock.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
const previous = spec.load("pg");
await disconnect("pg");
await connect("pg");
const next = spec.load("pg");
old.resolve(ok(spec.stale));
await previous;
expect(spec.read("pg")).toEqual({ status: "loading" });
fresh.resolve(ok(spec.value));
await next;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
it.each(cases)("does not restore deleted $name after the source ID is reused", async (spec) => {
const old = deferred();
spec.mock.mockReturnValueOnce(old.promise);
const previous = spec.load("pg");
await deleteSource("pg");
savedSources = [pg, other];
await loadSources();
await spec.load("pg");
old.resolve(ok(spec.stale));
await previous;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
it.each(cases)("does not invalidate another source's pending $name", async (spec) => {
const response = deferred();
spec.mock.mockReturnValueOnce(response.promise);
const pending = spec.load("pg2");
await disconnect("pg");
response.resolve(ok(spec.value));
await pending;
expect(spec.read("pg2")).toEqual({ status: "ok", value: spec.value });
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("invalidates every database of a source", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg:app");
await disconnect("pg");
response.resolve(ok(["old"]));
await pending;
expect(useDatabase.getState().schemas["pg:app"]).toBeUndefined();
expect(useDatabase.getState().connected.has("pg:app")).toBe(false);
});
it("disconnects one database without invalidating its sibling", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg:other");
await disconnect("pg:app");
response.resolve(ok(["public"]));
await pending;
expect(useDatabase.getState().schemas["pg:other"]).toEqual({ status: "ok", value: ["public"] });
});
it("does not request extra schema objects after the table request becomes stale", async () => {
const response = deferred();
backend.tables.mockReturnValueOnce(response.promise);
const pending = loadTables("pg", "public");
await disconnect("pg");
response.resolve(ok([]));
await pending;
expect(backend.objects).not.toHaveBeenCalled();
});
it("ignores schema objects that were already in flight at disconnect", async () => {
const response = deferred();
backend.objects.mockReturnValueOnce(response.promise);
const pending = loadTables("pg", "public");
await vi.waitFor(() => expect(backend.objects).toHaveBeenCalledOnce());
await disconnect("pg");
response.resolve(ok({ routines: [], packages: [], sequences: [{ name: "old" }] }));
await pending;
expect(useDatabase.getState().tables["pg\u0000public"]).toBeUndefined();
expect(useDatabase.getState().objects["pg\u0000public"]).toBeUndefined();
});
it("still lists tables when optional objects cannot be read", async () => {
backend.objects.mockRejectedValueOnce(new Error("permission denied"));
await loadTables("pg", "public");
expect(useDatabase.getState().tables["pg\u0000public"]?.status).toBe("ok");
});
it("keeps metadata in flight when an unchanged source list is reloaded", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg");
await loadSources();
response.resolve(ok(["public"]));
await pending;
expect(useDatabase.getState().schemas.pg).toEqual({ status: "ok", value: ["public"] });
});
it("keeps metadata in flight when the current project is renamed", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg");
useProject.setState({ project: { ...projectA, name: "Renamed" } });
response.resolve(ok(["public"]));
await pending;
expect(useDatabase.getState().schemas.pg).toEqual({ status: "ok", value: ["public"] });
});
});
describe("connection and metadata continuations", () => {
it("reloads every level the tree had expanded after a data source is edited", async () => {
await loadSchemas("pg");
await loadTables("pg", "public");
await loadDetails("pg", "public", "users");
await saveSource({ ...pg, host: "new-host" }, undefined);
const st = useDatabase.getState();
expect(st.schemas.pg).toEqual({ status: "ok", value: ["public"] });
expect(st.tables["pg\u0000public"]?.status).toBe("ok");
expect(st.details["pg\u0000public\u0000users"]?.status).toBe("ok");
});
it("stores credentials when the save emits a source-change event before returning", async () => {
backend.save.mockImplementationOnce(async (sources: DataSource[]) => {
savedSources = sources;
await loadSources();
return ok(null);
});
await saveSource({ ...pg, host: "new-host" }, "password", "ssh-secret");
expect(backend.password).toHaveBeenCalledWith("pg", "password", false);
expect(backend.sshSecret).toHaveBeenCalledWith("pg", "ssh-secret", false);
expect(useDatabase.getState().sources.find((s) => s.id === "pg")?.host).toBe("new-host");
});
it("does not let a pending disconnect clear a newer explicit connection", async () => {
const response = deferred();
backend.disconnect.mockReturnValueOnce(response.promise);
const pending = disconnect("pg");
expect(await connect("pg")).toBe(true);
await loadSchemas("pg");
response.resolve(ok(null));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(true);
expect(useDatabase.getState().schemas.pg?.status).toBe("ok");
});
it("does not restore connected state from a listing started before disconnect", async () => {
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
const pending = loadSources();
await disconnect("pg");
response.resolve(ok(["pg"]));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("does not clear a new connection with an old empty listing", async () => {
useDatabase.setState({ connected: new Set() });
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
const pending = loadSources();
await connect("pg");
response.resolve(ok([]));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(true);
});
it("keeps listing source definitions when its connected snapshot is stale", async () => {
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
backend.sources.mockResolvedValueOnce(ok([...savedSources, { id: "new", name: "New", driver: "sqlite" }]));
const pending = loadSources();
await disconnect("pg");
response.resolve(ok(["pg"]));
await pending;
expect(useDatabase.getState().sources.map((s) => s.id)).toEqual(["pg", "pg2", "new"]);
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
for (const outcome of ["success", "failure"] as const) {
it(`ignores a source-list ${outcome} from project A after A to B to A`, async () => {
const response = deferred();
backend.sources.mockReturnValueOnce(response.promise);
const pending = loadSources();
switchProject(projectB);
switchProject(projectA);
if (outcome === "success") response.resolve(ok([{ ...pg, name: "Old" }]));
else response.reject(new Error("old sources failure"));
await pending;
expect(useDatabase.getState().sources).toEqual([pg, other]);
expect(ui.notify).not.toHaveBeenCalled();
});
}
it("can load metadata again after a disconnect fails", async () => {
backend.disconnect.mockRejectedValueOnce(new Error("disconnect failed"));
await expect(disconnect("pg")).rejects.toThrow("disconnect failed");
expect(useDatabase.getState().connected.has("pg")).toBe(true);
await loadSchemas("pg");
expect(useDatabase.getState().schemas.pg).toEqual({ status: "ok", value: ["public"] });
});
it("stops refresh between metadata stages", async () => {
const response = deferred();
backend.databases.mockReturnValueOnce(response.promise);
useDatabase.setState({ schemas: { pg: { status: "ok", value: ["public"] } } });
const pending = refresh("pg");
await disconnect("pg");
response.resolve(ok({ current: "app", names: ["app"] }));
await pending;
expect(backend.schemas).not.toHaveBeenCalled();
});
it.each(["table", undefined] as const)(
"stops tree reveal after its connection becomes stale (kind %s)",
async (kind) => {
const response = deferred();
backend.connect.mockReturnValueOnce(response.promise);
const pending = revealInTree("pg", "public", "users", kind);
await disconnect("pg");
response.resolve(ok(null));
await pending;
expect(backend.databases).not.toHaveBeenCalled();
expect(backend.schemas).not.toHaveBeenCalled();
expect(useDatabase.getState().treeReveal).toBeNull();
},
);
it.each(["table", undefined] as const)(
"does not select a tree node after its pending details become stale (kind %s)",
async (kind) => {
useDatabase.setState({
databases: { pg: { status: "ok", value: { current: "app", names: ["app"] } } },
schemas: { pg: { status: "ok", value: ["public"] } },
tables: { "pg\u0000public": { status: "ok", value: [{ name: "users", kind: "view" }] } },
});
const response = deferred();
backend.details.mockReturnValueOnce(response.promise);
const pending = revealInTree("pg", "public", "users", kind, "id");
await vi.waitFor(() => expect(backend.details).toHaveBeenCalledOnce());
await disconnect("pg");
response.resolve(ok(details));
await pending;
expect(useDatabase.getState().treeReveal).toBeNull();
expect(useDatabase.getState().treeExpanded.size).toBe(0);
expect(ui.show).not.toHaveBeenCalled();
},
);
it("infers a view's folder while revealing a current table editor", async () => {
useDatabase.setState({
databases: { pg: { status: "ok", value: { current: "app", names: ["app"] } } },
schemas: { pg: { status: "ok", value: ["public"] } },
tables: { "pg\u0000public": { status: "ok", value: [{ name: "users", kind: "view" }] } },
});
await revealInTree("pg", "public", "users", undefined);
expect(useDatabase.getState().treeReveal?.path.at(-1)).toBe("pg/public/views/users");
expect(ui.show).toHaveBeenCalledWith("database");
expect(backend.connect).not.toHaveBeenCalled();
});
it("preserves successful write results without restoring a stale connection", async () => {
const response = deferred<number>();
const pending = withConnection("pg", () => response.promise);
await disconnect("pg");
response.resolve(7);
expect(await pending).toBe(7);
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("does not prompt for credentials after the request becomes stale", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg");
await disconnect("pg");
response.resolve({ status: "error", error: { kind: "permissionDenied", message: "authentication failed" } });
let settled = false;
void pending.then(() => {
settled = true;
});
await vi.waitFor(() => expect(settled || useDatabase.getState().dialog?.kind === "password").toBe(true));
const dialog = useDatabase.getState().dialog;
expect.soft(dialog).toBeNull();
if (dialog?.kind === "password") dialog.resolve(false);
await pending;
expect(backend.schemas).toHaveBeenCalledOnce();
});
it("does not retry if disconnected while waiting for a password", async () => {
backend.schemas.mockResolvedValueOnce({
status: "error",
error: { kind: "permissionDenied", message: "authentication failed" },
});
const pending = loadSchemas("pg");
await vi.waitFor(() => expect(useDatabase.getState().dialog?.kind).toBe("password"));
const dialog = useDatabase.getState().dialog;
if (dialog?.kind !== "password") throw new Error("Expected password prompt");
await disconnect("pg");
useDatabase.setState({ dialog: null });
dialog.resolve(true);
await pending;
expect(backend.schemas).toHaveBeenCalledOnce();
expect(useDatabase.getState().schemas.pg).toBeUndefined();
});
it("still retries a current request after a password is supplied", async () => {
backend.schemas.mockResolvedValueOnce({
status: "error",
error: { kind: "permissionDenied", message: "authentication failed" },
});
const pending = loadSchemas("pg");
await vi.waitFor(() => expect(useDatabase.getState().dialog?.kind).toBe("password"));
const dialog = useDatabase.getState().dialog;
if (dialog?.kind !== "password") throw new Error("Expected password prompt");
useDatabase.setState({ dialog: null });
dialog.resolve(true);
await pending;
expect(backend.schemas).toHaveBeenCalledTimes(2);
expect(useDatabase.getState().schemas.pg?.status).toBe("ok");
});
for (const [name, mutate] of [
["save", () => saveSource({ ...pg, host: "new" }, "secret", "ssh")],
["delete", () => deleteSource("pg")],
["rename", () => renameSource("pg", "Renamed")],
] as const) {
it(`does not continue ${name} in a different project`, async () => {
const response = deferred();
backend.save.mockReturnValueOnce(response.promise);
const pending = mutate();
switchProject(projectB);
response.resolve(ok(null));
await pending;
expect(backend.sources).not.toHaveBeenCalled();
expect(backend.password).not.toHaveBeenCalled();
expect(backend.sshSecret).not.toHaveBeenCalled();
});
}
});
describe("completion requests", () => {
it("deduplicates requests in the same lifetime", async () => {
const response = deferred();
backend.completion.mockReturnValueOnce(response.promise);
const first = loadCompletion("pg");
const second = loadCompletion("pg");
expect(first).toBe(second);
response.resolve(ok(completion));
expect(await first).toEqual(completion);
expect(backend.completion).toHaveBeenCalledOnce();
});
it("ignores completion resolved after disconnect", async () => {
const response = deferred();
backend.completion.mockReturnValueOnce(response.promise);
const pending = loadCompletion("pg");
await disconnect("pg");
response.resolve(ok(completion));
expect(await pending).toBeNull();
expect(useDatabase.getState().completion.pg).toBeUndefined();
});
it("does not reuse old completion or let its cleanup remove a newer request", async () => {
const old = deferred();
const fresh = deferred();
backend.completion.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
const previous = loadCompletion("pg");
await disconnect("pg");
const next = loadCompletion("pg");
expect(next).not.toBe(previous);
old.resolve(ok({ ...completion, defaultSchema: "old" }));
expect(await previous).toBeNull();
expect(loadCompletion("pg")).toBe(next);
fresh.resolve(ok(completion));
expect(await next).toEqual(completion);
expect(backend.completion).toHaveBeenCalledTimes(2);
});
it("lets completion recover after failure", async () => {
backend.completion.mockRejectedValueOnce(new Error("not available"));
expect(await loadCompletion("pg")).toBeNull();
expect(await loadCompletion("pg")).toEqual(completion);
expect(backend.completion).toHaveBeenCalledTimes(2);
});
});
describe("overlapping database operations", () => {
it("failed reconnect does not preserve the old disconnected flag", async () => {
const response = deferred();
backend.disconnect.mockReturnValueOnce(response.promise);
const pending = disconnect("pg");
backend.connect.mockRejectedValueOnce(new Error("new connection failed"));
expect(await connect("pg")).toBe(false);
response.resolve(ok(null));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("successful implicit reconnect survives the old disconnect acknowledgement", async () => {
const response = deferred();
backend.disconnect.mockReturnValueOnce(response.promise);
const pending = disconnect("pg");
await loadSchemas("pg");
response.resolve(ok(null));
await pending;
expect(useDatabase.getState().schemas.pg?.status).toBe("ok");
expect(useDatabase.getState().connected.has("pg")).toBe(true);
});
it("unrelated local connect does not keep an edited source marked connected", async () => {
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
backend.sources.mockResolvedValueOnce(ok([{ ...pg, host: "changed" }, other]));
const pending = loadSources();
await connect("pg2");
response.resolve(ok([]));
await pending;
expect.soft(useDatabase.getState().connected.has("pg2")).toBe(true);
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
});
it("saving a source must not reconnect after a later disconnect", async () => {
const response = deferred();
backend.save.mockReturnValueOnce(response.promise);
useDatabase.setState({ databases: { pg: { status: "ok", value: { current: "app", names: ["app"] } } } });
const pending = saveSource({ ...pg, host: "changed" }, undefined);
await disconnect("pg");
savedSources = [{ ...pg, host: "changed" }, other];
response.resolve(ok(null));
await pending;
expect(backend.databases).not.toHaveBeenCalled();
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("whole-source refresh does not restart a disconnected child database", async () => {
useDatabase.setState({
connected: new Set(["pg", "pg:app", "pg:other"]),
schemas: {
"pg:app": { status: "ok", value: ["public"] },
"pg:other": { status: "ok", value: ["public"] },
},
tables: {
"pg:app\u0000public": { status: "ok", value: [{ name: "users", kind: "table" }] },
"pg:other\u0000public": { status: "ok", value: [{ name: "users", kind: "table" }] },
},
});
const response = deferred();
backend.schemas.mockImplementation((id: string) =>
id === "pg:app" ? response.promise : Promise.resolve(ok(["public"])),
);
const pending = refresh("pg");
await vi.waitFor(() => expect(backend.schemas).toHaveBeenCalledTimes(2));
await disconnect("pg:app");
response.resolve(ok(["public"]));
await pending;
expect(backend.tables.mock.calls.map((c) => c[0])).toEqual(["pg:other"]);
expect(useDatabase.getState().schemas["pg:app"]).toBeUndefined();
});
it("late successful DDL does not refresh metadata after disconnect", async () => {
const response = deferred();
backend.execute.mockReturnValueOnce(response.promise);
const text = "CREATE TABLE test (id int)";
const pending = runSql("pg", [text]);
await disconnect("pg");
response.resolve(ok([{ statement: { text, from: 0, to: text.length }, result: null, error: null }]));
expect(await pending).toBe(true);
expect.soft(backend.databases).not.toHaveBeenCalled();
expect(useDatabase.getState().output).toEqual([]);
});
it("old SQL is not executed in a new project after completion finishes", async () => {
useDatabase.setState({ consoles: { "console.sql": "pg" } });
const response = deferred();
backend.completion.mockReturnValueOnce(response.promise);
const text = "DELETE FROM users";
const pending = executeConsole("console.sql", text, 0, text.length);
await vi.waitFor(() => expect(backend.completion).toHaveBeenCalledOnce());
switchProject(projectB);
response.resolve(ok(completion));
await pending;
expect(backend.execute).not.toHaveBeenCalled();
});
it("querying a source's default database does not freeze a sibling connection snapshot", async () => {
useDatabase.setState({ connected: new Set(["pg", "pg:app"]) });
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
const pending = loadSources();
await loadSchemas("pg");
response.resolve(ok(["pg"]));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(true);
expect(useDatabase.getState().connected.has("pg:app")).toBe(false);
});
-35
View File
@@ -1,35 +0,0 @@
import { useProject } from "@/platform/project";
import { splitTarget } from "./model";
let projectGeneration = 0;
const generations = new Map<string, object>();
useProject.subscribe((state, previous) => {
if (state.project?.root === previous.project?.root) return;
projectGeneration++;
generations.clear();
});
function generation(id: string): object {
let value = generations.get(id);
if (!value) generations.set(id, (value = {}));
return value;
}
/** A return to the same project or target is a new lifetime, too. */
export function projectRequest(): () => boolean {
const project = projectGeneration;
return () => project === projectGeneration;
}
export function databaseRequest(id: string): () => boolean {
const project = projectRequest();
const sourceId = splitTarget(id).id;
const source = generation(sourceId);
const target = generation(id);
return () => project() && generations.get(sourceId) === source && generations.get(id) === target;
}
export function invalidateDatabaseRequests(id: string) {
generations.set(id, {});
}
+36 -167
View File
@@ -56,7 +56,6 @@ 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";
@@ -289,43 +288,19 @@ export function connectedTargets(id: string): string[] {
}
let sourcesRequest = 0;
const connectionIntents = new Map<string, object>();
const sourceConnectionIntents = new Map<string, object>();
const sourceMutations = new Map<string, object>();
function sourceMutation(id: string): () => boolean {
const project = projectRequest();
const intent = {};
sourceMutations.set(id, intent);
return () => project() && sourceMutations.get(id) === intent;
}
export async function loadSources(): Promise<void> {
const request = ++sourcesRequest;
const current = projectRequest();
const intents = new Map(connectionIntents);
const sourceIntents = new Map(sourceConnectionIntents);
const root = useProject.getState().project?.root;
try {
const [sources, connected] = await Promise.all([call(commands.dbSources()), call(commands.dbConnected())]);
if (request !== sourcesRequest || !current()) return;
if (request !== sourcesRequest || useProject.getState().project?.root !== root) return;
for (const old of useDatabase.getState().sources) {
if (JSON.stringify(old) !== JSON.stringify(sources.find((s) => s.id === old.id))) forget(old.id ?? "");
}
useDatabase.setState((s) => {
const server = new Set(connected);
const targets = new Set([...server, ...s.connected]);
const live = [...targets].filter((target) => {
const sourceId = splitTarget(target).id;
if (!sources.some((source) => source.id === sourceId)) return false;
const changed =
intents.get(target) !== connectionIntents.get(target) ||
sourceIntents.get(sourceId) !== sourceConnectionIntents.get(sourceId);
return changed ? s.connected.has(target) : server.has(target);
});
return { sources, connected: new Set(live), loaded: true };
});
useDatabase.setState({ sources, connected: new Set(connected), loaded: true });
} catch (e) {
if (request !== sourcesRequest || !current()) return;
if (request !== sourcesRequest || useProject.getState().project?.root !== root) return;
useDatabase.setState({ loaded: true });
notifications().notify({ level: "error", title: "Cannot read data sources", body: message(e), key: "db-sources" });
}
@@ -333,19 +308,15 @@ export async function loadSources(): Promise<void> {
/** Drops cached metadata of target `id` (refresh, disconnect); a data source id drops all its databases. */
function forget(id: string) {
invalidateDatabaseRequests(id);
const whole = !splitTarget(id).database;
const ofTarget = (t: string) => t === id || (whole && splitTarget(t).id === id);
const dropKeys = <T>(m: Record<string, T>) =>
Object.fromEntries(Object.entries(m).filter(([k]) => !ofTarget(k.split("\u0000")[0]!)));
useDatabase.setState((s) => {
const databases = { ...s.databases };
const catalogs = { ...s.catalogs };
if (whole) delete databases[id];
if (whole) delete catalogs[id];
return {
databases,
catalogs,
schemas: dropKeys(s.schemas),
completion: dropKeys(s.completion),
tables: dropKeys(s.tables),
@@ -360,30 +331,22 @@ export async function saveSource(
password: string | null | undefined,
sshSecret: string | null | undefined = undefined,
): Promise<void> {
const id = source.id ?? "";
const st = useDatabase.getState();
const loaded = id in st.databases || Object.keys(st.schemas).some((t) => splitTarget(t).id === id);
// Taken before anything is forgotten: what the tree had expanded under this source.
const levels = loadedLevels(id);
const connection = connectionIntents.get(id);
forget(id);
const current = sourceMutation(id);
const sources = useDatabase.getState().sources;
const next = sources.some((s) => s.id === source.id)
? sources.map((s) => (s.id === source.id ? source : s))
: [...sources, source];
await call(commands.dbSaveSources(next));
if (!current()) return;
// `undefined`: keep; `null`: forget; string: store.
const id = source.id ?? "";
if (password !== undefined) await call(commands.dbSetPassword(id, password, !!source.savePassword));
if (!current()) return;
if (sshSecret !== undefined) await call(commands.dbSetSshSecret(id, sshSecret, !!source.ssh?.saveSecret));
if (!current()) return;
// What the tree had loaded for this source is reloaded with the new settings, so its
// expanded nodes don't stay on Loading.
const st = useDatabase.getState();
const loaded = id in st.databases || Object.keys(st.schemas).some((t) => splitTarget(t).id === id);
await loadSources();
if (!current() || !sourceById(id)) return;
if (loaded && connectionIntents.get(id) === connection) await refresh(id, levels);
if (loaded) await refresh(id);
else forget(id);
}
/** Copy/Paste › Duplicate: a copy of data source `id` named `<name> [2]` (its password is not copied). */
@@ -407,10 +370,7 @@ export async function renameSource(id: string, name: string): Promise<void> {
const src = sourceById(id);
if (!src || !name.trim() || name.trim() === src.name) return;
const sources = useDatabase.getState().sources.map((s) => (s.id === src.id ? { ...s, name: name.trim() } : s));
forget(id);
const current = sourceMutation(id);
await call(commands.dbSaveSources(sources));
if (!current()) return;
await loadSources();
}
@@ -428,10 +388,8 @@ export async function removeSource(source: DataSource): Promise<void> {
}
export async function deleteSource(id: string): Promise<void> {
forget(id);
const current = sourceMutation(id);
await call(commands.dbSaveSources(useDatabase.getState().sources.filter((s) => s.id !== id)));
if (!current()) return;
forget(id);
await loadSources();
}
@@ -500,13 +458,9 @@ function applyConnectDefaults(id: string) {
/** Runs `op`, asking for passwords / host key trust and retrying when the connection needs them. */
export async function withConnection<T>(id: string, op: () => Promise<T>): Promise<T> {
const current = databaseRequest(id);
for (;;) {
try {
const r = await op();
// A completed write keeps its result, but must not revive an old connection.
if (!current()) return r;
connectionIntents.set(id, {});
lastUsed.set(id, Date.now());
if (!useDatabase.getState().connected.has(id)) {
useDatabase.setState((s) => ({ connected: new Set(s.connected).add(id) }));
@@ -514,19 +468,16 @@ export async function withConnection<T>(id: string, op: () => Promise<T>): Promi
}
return r;
} catch (e) {
if (!current()) throw new IpcError({ kind: "cancelled", message: "Cancelled", data: null });
const source = sourceById(id);
if (!source || !(await resolveConnectError(e, source))) throw e;
if (!current()) throw new IpcError({ kind: "cancelled", message: "Cancelled", data: null });
}
}
}
export async function connect(id: string): Promise<boolean> {
const current = databaseRequest(id);
try {
await withConnection(id, () => call(commands.dbConnect(id)));
return current();
return true;
} catch (e) {
if (!isCancelled(e))
notifications().notify({ level: "error", title: `Cannot connect to ${targetLabel(id)}`, body: message(e) });
@@ -538,21 +489,11 @@ export async function connect(id: string): Promise<boolean> {
export async function disconnect(id: string): Promise<void> {
const targets = splitTarget(id).database ? [id] : connectedTargets(id);
if (!targets.includes(id)) targets.push(id);
const current = projectRequest();
forget(id);
const intent = {};
for (const target of targets) connectionIntents.set(target, intent);
if (!splitTarget(id).database) sourceConnectionIntents.set(id, {});
await Promise.all(targets.map((t) => call(commands.dbDisconnect(t))));
if (!current()) return;
if (!splitTarget(id).database) sourceConnectionIntents.set(id, {});
forget(id);
useDatabase.setState((s) => {
const connected = new Set(s.connected);
for (const t of targets) {
if (connectionIntents.get(t) !== intent) continue;
connected.delete(t);
connectionIntents.set(t, {});
}
for (const t of targets) connected.delete(t);
return { connected };
});
}
@@ -590,14 +531,11 @@ export function listsDatabases(source: DataSource | undefined): boolean {
);
}
interface LoadedLevels {
schemas: string[];
tables: [string, string][];
details: [string, string, string][];
}
/** The metadata of target `id` (a data source id: all its databases) the tree has loaded. */
function loadedLevels(id: string): LoadedLevels {
/**
* Re-reads the metadata of target `id` (a data source id: all its databases),
* reloading the levels that were loaded so the tree keeps its expanded nodes.
*/
export async function refresh(id: string): Promise<void> {
const whole = !splitTarget(id).database;
const ofTarget = (k: string) => {
const t = k.split("\u0000")[0]!;
@@ -606,105 +544,64 @@ function loadedLevels(id: string): LoadedLevels {
const st = useDatabase.getState();
const loaded = (m: Record<string, Loaded<unknown>>) =>
Object.keys(m).filter((k) => ofTarget(k) && m[k]!.status === "ok");
return {
schemas: loaded(st.schemas),
tables: loaded(st.tables).map((k) => k.split("\u0000") as [string, string]),
details: loaded(st.details).map((k) => k.split("\u0000") as [string, string, string]),
};
}
/**
* Re-reads the metadata of target `id` (a data source id: all its databases),
* reloading the levels that were loaded (`levels`) so the tree keeps its expanded nodes.
*/
export async function refresh(id: string, levels = loadedLevels(id)): Promise<void> {
const whole = !splitTarget(id).database;
const { tables, details } = levels;
const schemas = [...levels.schemas];
const schemas = loaded(st.schemas);
const tables = loaded(st.tables).map((k) => k.split("\u0000") as [string, string]);
const details = loaded(st.details).map((k) => k.split("\u0000") as [string, string, string]);
forget(id);
const current = databaseRequest(id);
const targets = new Map(
[...new Set([...schemas, ...tables.map(([t]) => t), ...details.map(([t]) => t)])].map((target) => [
target,
databaseRequest(target),
]),
);
const valid = (target: string) => targets.get(target)?.() ?? current();
if (whole && listsDatabases(sourceById(id))) await loadDatabases(id);
else if (!schemas.includes(id)) schemas.push(id);
if (!current()) return;
await Promise.all(schemas.filter(valid).map((t) => loadSchemas(t)));
if (!current()) return;
await Promise.all(tables.filter(([t]) => valid(t)).map(([t, schema]) => loadTables(t, schema)));
if (!current()) return;
await Promise.all(schemas.map((t) => loadSchemas(t)));
await Promise.all(tables.map(([t, schema]) => loadTables(t, schema)));
const exists = (t: string, schema: string, table: string) => {
const l = useDatabase.getState().tables[tablesKey(t, schema)];
return l?.status === "ok" && l.value.some((x) => x.name === table);
};
await Promise.all(
details.filter((d) => valid(d[0]) && exists(...d)).map(([t, schema, table]) => loadDetails(t, schema, table)),
);
}
function metadataRequest(id: string, cache: "databases" | "schemas" | "tables" | "details", key = id) {
const current = databaseRequest(id);
const loading = { status: "loading" as const };
useDatabase.setState((s) => ({ ...s, [cache]: { ...s[cache], [key]: loading } }));
return () => current() && useDatabase.getState()[cache][key] === loading;
await Promise.all(details.filter((d) => exists(...d)).map(([t, schema, table]) => loadDetails(t, schema, table)));
}
export async function loadDatabases(id: string): Promise<void> {
const current = metadataRequest(id, "databases");
useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: { status: "loading" } } }));
try {
const value = await withConnection(id, () => call(commands.dbDatabases(id)));
if (!current()) return;
useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: { status: "ok", value } } }));
} catch (e) {
if (!current()) return;
useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: { status: "error", message: message(e) } } }));
}
}
export async function loadSchemas(id: string): Promise<void> {
const current = metadataRequest(id, "schemas");
useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: { status: "loading" } } }));
try {
const value = await withConnection(id, () => call(commands.dbSchemas(id)));
if (!current()) return;
useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: { status: "ok", value } } }));
} catch (e) {
if (!current()) return;
useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: { status: "error", message: message(e) } } }));
}
}
export async function loadTables(id: string, schema: string): Promise<void> {
const key = tablesKey(id, schema);
const current = metadataRequest(id, "tables", key);
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "loading" } } }));
try {
const value = await withConnection(id, () => call(commands.dbTables(id, schema)));
if (!current()) return;
// Routines / sequences are extras: a server refusing their catalog still lists tables.
const objects = await call(commands.dbSchemaObjects(id, schema)).catch(() => null);
if (!current()) return;
useDatabase.setState((s) => ({
tables: { ...s.tables, [key]: { status: "ok", value } },
objects: objects ? { ...s.objects, [key]: objects } : s.objects,
}));
} catch (e) {
if (!current()) return;
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "error", message: message(e) } } }));
}
}
export async function loadDetails(id: string, schema: string, table: string): Promise<void> {
const key = detailsKey(id, schema, table);
const current = metadataRequest(id, "details", key);
useDatabase.setState((s) => ({ details: { ...s.details, [key]: { status: "loading" } } }));
try {
const value = await withConnection(id, () => call(commands.dbTableDetails(id, schema, table)));
if (!current()) return;
useDatabase.setState((s) => ({ details: { ...s.details, [key]: { status: "ok", value } } }));
} catch (e) {
if (!current()) return;
useDatabase.setState((s) => ({ details: { ...s.details, [key]: { status: "error", message: message(e) } } }));
}
}
@@ -721,16 +618,13 @@ export async function revealInTree(
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);
@@ -738,15 +632,11 @@ 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)}`;
@@ -807,41 +697,26 @@ export async function revealOpened(): Promise<void> {
selectInTree(open, key);
}
const completionLoads = new Map<string, { current: () => boolean; promise: Promise<DbCompletion | null> }>();
const completionLoads = new Map<string, Promise<DbCompletion | null>>();
/** The schema model of `id` for completion (cached; one load at a time). */
export function loadCompletion(id: string): Promise<DbCompletion | null> {
const cached = useDatabase.getState().completion[id];
if (cached) return Promise.resolve(cached);
let pending = completionLoads.get(id);
if (!pending?.current()) {
const current = databaseRequest(id);
const promise = call(commands.dbCompletion(id))
let p = completionLoads.get(id);
if (!p) {
p = call(commands.dbCompletion(id))
.then((model) => {
if (!current()) return null;
useDatabase.setState((s) => ({ completion: { ...s.completion, [id]: model } }));
return model;
})
.catch(() => null)
.finally(() => {
if (completionLoads.get(id)?.promise === promise) completionLoads.delete(id);
});
pending = { current, promise };
completionLoads.set(id, pending);
.finally(() => completionLoads.delete(id));
completionLoads.set(id, p);
}
return pending.promise;
return p;
}
useProject.subscribe((state, previous) => {
if (state.project?.root === previous.project?.root) return;
sourcesRequest++;
connectionIntents.clear();
sourceConnectionIntents.clear();
sourceMutations.clear();
completionLoads.clear();
lastUsed.clear();
});
// ---- consoles ----------------------------------------------------------------
export function bindConsole(path: string, id: string | null) {
@@ -958,12 +833,10 @@ async function run(
skip = 0,
schema?: string,
): Promise<DbStatementOutcome[]> {
const current = databaseRequest(id);
const wasConnected = useDatabase.getState().connected.has(id);
const outcomes = await withConnection(id, () =>
call(commands.dbExecute(request, id, statements, skip, maxRows, schema || null)),
);
if (!current()) return outcomes;
if (!wasConnected) log("info", "Connected");
const prompt = schema || promptOf(id);
for (const o of outcomes) {
@@ -1101,9 +974,8 @@ export async function executeConsole(
});
return [];
}
const current = databaseRequest(id);
const statements = await consoleStatements(id, text, from, to);
if (!current() || !statements.length) return [];
if (!statements.length) return [];
const prev = useDatabase.getState().running[path];
if (prev) void commands.cancelRequest(prev);
const request = nextRequestId();
@@ -1111,9 +983,7 @@ export async function executeConsole(
try {
const pageSize = useDatabase.getState().maxRows;
const schema = consoleSchema(path) || (await loadCompletion(id))?.defaultSchema || "";
if (!current()) return [];
const outcomes = await run(id, statements, request, pageSize, 0, schema);
if (!current() || useDatabase.getState().running[path] !== request) return outcomes;
const sets = outcomes.filter((o) => o.result?.columns.length);
const n = sets.length ? nextRun++ : 0;
const tabs: ResultTab[] = sets.map((outcome, i) => {
@@ -1129,7 +999,6 @@ export async function executeConsole(
showServiceView(RESULTS_VIEW, false);
return outcomes;
} catch (e) {
if (!current()) return [];
if (!isCancelled(e)) {
log("error", message(e));
notifications().notify({ level: "error", title: "Cannot execute", body: message(e), key: "db-execute" });
+4 -19
View File
@@ -42,7 +42,6 @@ import {
} from "./store";
import { tableInput } from "./tableInput";
import { tx } from "@/platform/i18n";
import { databaseRequest } from "./requests";
/** Context key: the Database tree has focus. */
export const DB_VIEW_FOCUS = "databaseViewFocus";
@@ -100,25 +99,18 @@ async function tablesIn(id: string, schema: string): Promise<DbTableInfo[]> {
async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
const id = nodeTarget(n);
if (!id) return null;
const current = databaseRequest(id);
const one = async (schema: string, table: string) =>
readableDdl(await withConnection(id, () => call(commands.dbDdl(id, schema, table))), driverOf(id));
try {
const o = schemaObjectOf(n);
if (o) {
const text = await withConnection(id, () => call(commands.dbObjectDdl(id, o.schema, o.kind, o.name)));
return current() ? { id, text } : null;
}
if (o)
return { id, text: await withConnection(id, () => call(commands.dbObjectDdl(id, o.schema, o.kind, o.name))) };
const t = tableOf(n);
if (t) {
const text = await one(t.schema, t.table);
return current() ? { id, text } : null;
}
if (t) return { id, text: await one(t.schema, t.table) };
let schemas: string[] = [];
if (n.kind === "schema" || n.kind === "folder") schemas = [n.schema];
else if (n.kind === "source" || n.kind === "database") {
if (useDatabase.getState().schemas[id]?.status !== "ok") await loadSchemas(id);
if (!current()) return null;
const l = useDatabase.getState().schemas[id];
schemas = l?.status === "ok" ? l.value : [];
}
@@ -127,17 +119,12 @@ async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
const tables = (await tablesIn(id, schema)).filter(
(t) => n.kind !== "folder" || tableFolder(t.kind) === n.folder,
);
if (!current()) return null;
// Tables before views: views select from them.
tables.sort((a, b) => Number(a.kind !== "table") - Number(b.kind !== "table"));
for (const t of tables) {
parts.push(await one(schema, t.name));
if (!current()) return null;
}
for (const t of tables) parts.push(await one(schema, t.name));
}
return { id, text: parts.join("\n") };
} catch (e) {
if (!current()) return null;
notifications().notify({
level: "error",
title: "Cannot generate the DDL",
@@ -151,10 +138,8 @@ async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
export async function insertScript(n: DbNode, kind: ScriptKind) {
const t = tableOf(n);
if (!t) return;
const current = databaseRequest(t.id);
const key = detailsKey(t.id, t.schema, t.table);
if (useDatabase.getState().details[key]?.status !== "ok") await loadDetails(t.id, t.schema, t.table);
if (!current()) return;
const d = useDatabase.getState().details[key];
const columns = d?.status === "ok" ? d.value.columns : [];
try {
-5
View File
@@ -130,11 +130,6 @@ export function configIcon(config: Pick<RunConfig, "kind" | "isTest">): string {
return "file-csharp";
case "go":
return "file-go";
case "rebar3":
case "escript":
return "file-erlang";
case "mix":
return "file-elixir";
case "node":
return "file-js";
case "application":
-5
View File
@@ -71,7 +71,6 @@ const ALL = env({
"clang++": null,
dart: null,
elixir: null,
escript: null,
"scala-cli": null,
deno: "2.1.0",
bun: "1.1.0",
@@ -246,10 +245,6 @@ describe("single-file producers (scratches)", () => {
expect(run(scratch("a.swift"))).toMatchObject({ command: "/bin/swift", args: ["a.swift"] });
expect(run(scratch("a.dart"))).toMatchObject({ command: "/bin/dart", args: ["run", "a.dart"] });
expect(run(scratch("a.exs"))).toMatchObject({ command: "/bin/elixir", args: ["a.exs"] });
expect(run(scratch("tool.escript"))).toMatchObject({
command: "/bin/escript",
args: ["tool.escript"],
});
expect(run(scratch("a.sc"))).toMatchObject({ command: "/bin/scala-cli", args: ["run", "a.sc"] });
expect(run(scratch("a.kts"))).toMatchObject({ command: "/bin/kotlin", args: ["a.kts"] });
expect(run(scratch("a.kts"), env({ kotlinc: null }))).toMatchObject({
-6
View File
@@ -220,7 +220,6 @@ export type ScriptLanguage =
| "cpp"
| "dart"
| "elixir"
| "escript"
| "scala"
| "shebang";
@@ -260,7 +259,6 @@ export function scriptLanguage(path: string, content?: string): ScriptLanguage |
if (/\.(cpp|cc|cxx|c\+\+)$/.test(name)) return "cpp";
if (/\.dart$/.test(name)) return "dart";
if (/\.exs$/.test(name)) return "elixir";
if (/\.escript$/.test(name)) return "escript";
if (/\.(scala|sc)$/.test(name)) return "scala";
if (!name.includes(".") && shebang(content)) return "shebang";
return null;
@@ -315,8 +313,6 @@ export function toolsFor(lang: ScriptLanguage, content?: string): string[] {
return ["dart"];
case "elixir":
return ["elixir"];
case "escript":
return ["escript"];
case "scala":
return ["scala-cli"];
}
@@ -342,7 +338,6 @@ const INSTALL: Record<string, string> = {
cpp: "Install a C++ compiler: `xcode-select --install` (clang++) or `brew install gcc`.",
dart: "Install the Dart SDK (`brew install dart-sdk`).",
elixir: "Install Elixir (`brew install elixir`).",
escript: "Install Erlang/OTP (`brew install erlang`).",
scala: "Install Scala CLI (`brew install Virtuslab/scala-cli/scala-cli`).",
shell: "Install the shell named in the script's #! line.",
shebang: "Install the interpreter named in the script's #! line.",
@@ -910,7 +905,6 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
case "lua":
case "swift":
case "elixir":
case "escript":
return simple(lang, s, env, lang, () => [s.arg]);
case "r":
return simple(lang, s, env, "r", () => [s.arg]);
@@ -124,8 +124,7 @@
color: var(--fg-muted);
}
.rowName {
flex: 0 1 auto;
min-width: 0;
flex: none;
max-width: 60%;
overflow: hidden;
color: var(--fg-strong);
@@ -151,13 +150,6 @@
font-size: var(--fs-tiny);
line-height: 16px;
}
.rowBadge {
flex: none;
}
.updateTag {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
}
.listEmpty {
padding: 8px 12px;
color: var(--fg-muted);
+7 -31
View File
@@ -16,7 +16,6 @@ import { languageNames, NewServerDialog, ServerSettings, ToolSettings, useLangua
import { serverKey, type ServerEntry } from "./serverConfig";
import {
cancelInstall,
checkToolUpdates,
installTool,
refreshTools,
STATUS_LABEL,
@@ -99,14 +98,12 @@ function ToolRow({
selected,
off,
pending,
update,
onSelect,
}: {
tool: ToolInfo;
selected: boolean;
off: boolean;
pending: boolean;
update: boolean;
onSelect: () => void;
}) {
return (
@@ -119,7 +116,7 @@ function ToolRow({
title={`${tool.displayName} — ${tool.languages.join(", ")}`}
onClick={onSelect}
>
<Badge dot className={styles.rowBadge} tone={pending ? "accent" : STATUS_TONE[tool.status]}>
<Badge dot tone={pending ? "accent" : STATUS_TONE[tool.status]}>
{pending ? tx("New") : STATUS_LABEL[tool.status]}
</Badge>
<span className={styles.rowName}>{tool.displayName}</span>
@@ -138,11 +135,6 @@ function ToolRow({
<T>Disabled</T>
</span>
)}
{update && (
<span className={clsx(styles.kindTag, styles.updateTag)}>
<T>Update</T>
</span>
)}
<span className={styles.rowLanguages}>{tool.languages.join(", ")}</span>
</button>
</li>
@@ -152,7 +144,6 @@ function ToolRow({
function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
const job = useTools((s) => s.jobs[tool.name]);
const last = useTools((s) => s.logs[tool.name]);
const update = useTools((s) => (tool.status === "installed" ? s.updates[tool.name] : undefined));
const [logOpen, setLogOpen] = useState(false);
const log = job?.lines ?? last?.lines ?? [];
const lastError = !job && last?.error !== "Cancelled" ? last?.error : null;
@@ -194,21 +185,17 @@ function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
{tool.canInstall && (
<Button
size="small"
variant={tool.status === "missing" || update ? "primary" : "secondary"}
variant={tool.status === "missing" ? "primary" : "secondary"}
disabled={busy}
title={
tool.installMethod ? tx`Installs into the IDE's own tools folder via ${tool.installMethod}` : undefined
}
onClick={() => void installTool(tool.name)}
aria-label={
update
? tx`Update ${tool.displayName}`
: tool.status === "installed"
? tx`Reinstall ${tool.displayName}`
: tx`Install ${tool.displayName}`
tool.status === "installed" ? tx`Reinstall ${tool.displayName}` : tx`Install ${tool.displayName}`
}
>
{update ? "Update" : tool.status === "installed" ? "Reinstall" : "Install"}
{tool.status === "installed" ? "Reinstall" : "Install"}
</Button>
)}
{tool.canUninstall && (
@@ -218,11 +205,6 @@ function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
)}
</div>
</div>
{update && !job && (
<div className={styles.notice} role="note">
{tx`Version ${update.latest} is available.`}
</div>
)}
{job && <JobRow name={tool.displayName} job={job} />}
{lastError && (
<div className={styles.error} role="alert">
@@ -262,7 +244,7 @@ function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
}
export function LanguageServersPage() {
const { tools, loaded, loading, error, updates, checkingUpdates } = useTools();
const { tools, loaded, loading, error } = useTools();
const form = useSettingsForm();
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<Filter>("all");
@@ -275,8 +257,6 @@ export function LanguageServersPage() {
useEffect(() => {
void refreshTools();
// Asks each install source once per session; the refresh button asks again.
if (!useTools.getState().updatesChecked) void checkToolUpdates();
// Applied paths and servers change what is found and listed.
return onIdeEvent("settingsChanged", (e) => {
if (!e.key || /^(tools|languageServers)(\.|$)/.test(e.key)) void refreshTools();
@@ -327,7 +307,6 @@ export function LanguageServersPage() {
selected={t === selected}
off={servers[t.name]?.enabled === false}
pending={pending.has(t.name)}
update={t.status === "installed" && !!updates[t.name]}
onSelect={() => setSelectedName(t.name)}
/>
))}
@@ -358,17 +337,14 @@ export function LanguageServersPage() {
onClick={() => selected && form.set(serverKey(selected.name), null)}
/>
<span className={styles.toolbarSep} aria-hidden />
{loading || checkingUpdates ? (
{loading ? (
<Spinner size={14} label="Checking tools" />
) : (
<IconButton
icon="refresh"
size="small"
title="Re-check installed tools"
onClick={() => {
void refreshTools();
void checkToolUpdates();
}}
onClick={() => void refreshTools()}
/>
)}
<SearchField
+1 -27
View File
@@ -19,17 +19,7 @@ beforeEach(() => {
provideNotificationService({ notify: (n) => void notes.push(n) });
backend = new MemoryToolsBackend(sampleTools(), 1);
setToolsBackend(backend);
useTools.setState({
tools: [],
loaded: false,
loading: false,
error: null,
jobs: {},
logs: {},
updates: {},
checkingUpdates: false,
updatesChecked: false,
});
useTools.setState({ tools: [], loaded: false, loading: false, error: null, jobs: {}, logs: {} });
});
afterEach(() => setToolsBackend(null));
@@ -274,22 +264,6 @@ describe("LanguageServersPage", () => {
expect(screen.getByLabelText("gopls install log")).toHaveTextContent("Installing gopls");
});
it("offers an update when a newer version of an installed tool is out", async () => {
const user = userEvent.setup();
backend.available = [{ name: "rust-analyzer", current: "1.85.0", latest: "1.86.0" }];
renderPage();
await waitFor(() => expect(listItem("rust-analyzer")).toHaveTextContent("Update"));
expect(listItem("gopls")).not.toHaveTextContent("Update");
await user.click(listItem("rust-analyzer"));
const details = screen.getByLabelText("rust-analyzer details");
expect(details).toHaveTextContent("Version 1.86.0 is available.");
await user.click(within(details).getByRole("button", { name: "Update rust-analyzer" }));
await waitFor(() => expect(listItem("rust-analyzer")).not.toHaveTextContent("Update"));
expect(
within(screen.getByLabelText("rust-analyzer details")).getByRole("button", { name: "Reinstall rust-analyzer" }),
).toBeInTheDocument();
});
it("explains why a tool cannot be installed", async () => {
const user = userEvent.setup();
renderPage();
+1 -10
View File
@@ -3,7 +3,7 @@
* simulation in plain-browser development and tests (no network, ever).
*/
import { Channel } from "@tauri-apps/api/core";
import type { InstallEvent, RequestId, ToolInfo, ToolUpdate } from "@/bindings";
import type { InstallEvent, RequestId, ToolInfo } from "@/bindings";
import { call, commands, IpcError, isTauri } from "@/platform/ipc";
export interface LanguageChoice {
@@ -15,8 +15,6 @@ export interface ToolsBackend {
list(): Promise<ToolInfo[]>;
install(name: string, request: RequestId, onEvent: (e: InstallEvent) => void): Promise<ToolInfo>;
uninstall(name: string): Promise<ToolInfo>;
/** Newer versions of the tools the IDE installed (release downloads and npm packages). */
updates(request: RequestId): Promise<ToolUpdate[]>;
cancel(request: RequestId): void;
/** Restarts the language servers named `name` in every open project (after an install). */
restartServers(name: string): Promise<void>;
@@ -34,7 +32,6 @@ export const tauriToolsBackend: ToolsBackend = {
return call(commands.toolsInstall(name, request, channel));
},
uninstall: (name) => call(commands.toolsUninstall(name)),
updates: (request) => call(commands.toolsUpdates(request)),
cancel: (request) => void commands.cancelRequest(request).catch(() => {}),
// Every open project: Settings belongs to one project window, but all of them run the tool.
restartServers: async (name) => {
@@ -153,8 +150,6 @@ export class MemoryToolsBackend implements ToolsBackend {
/** Delay between simulated steps (ms). */
stepMs: number;
failNext: string | null = null;
/** What `updates` reports, for the tools that are installed. */
available: ToolUpdate[] = [];
private cancelled = new Set<RequestId>();
constructor(tools: ToolInfo[] = sampleTools(), stepMs = 120) {
@@ -166,10 +161,6 @@ export class MemoryToolsBackend implements ToolsBackend {
return this.tools.map((t) => ({ ...t }));
}
async updates() {
return this.available.filter((u) => this.tools.some((t) => t.name === u.name && t.status === "installed"));
}
private replace(next: ToolInfo) {
this.tools = this.tools.map((t) => (t.name === next.name ? next : t));
return { ...next };
+1 -32
View File
@@ -1,6 +1,6 @@
/** Tool list + running installs. Installs show in the status-bar task list too. */
import { create } from "zustand";
import type { RequestId, ToolInfo, ToolUpdate } from "@/bindings";
import type { RequestId, ToolInfo } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { isCancelled, nextRequestId } from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
@@ -28,11 +28,6 @@ interface ToolsState {
jobs: Readonly<Record<string, InstallJob>>;
/** Output of the last finished install per tool (kept for the log view). */
logs: Readonly<Record<string, { lines: string[]; error: string | null }>>;
/** Newer versions of the tools the IDE installed, from the last check. */
updates: Readonly<Record<string, ToolUpdate>>;
checkingUpdates: boolean;
/** Whether updates were looked for in this session (the Language Servers page looks once). */
updatesChecked: boolean;
}
export const useTools = create<ToolsState>(() => ({
@@ -42,9 +37,6 @@ export const useTools = create<ToolsState>(() => ({
error: null,
jobs: {},
logs: {},
updates: {},
checkingUpdates: false,
updatesChecked: false,
}));
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
@@ -67,25 +59,6 @@ export function refreshTools(): Promise<void> {
return refreshing;
}
let checkingUpdates: Promise<void> | null = null;
/** Looks for newer versions of the tools the IDE installed; offline, none are shown. */
export function checkToolUpdates(): Promise<void> {
checkingUpdates ??= (async () => {
useTools.setState({ checkingUpdates: true });
try {
const found = await toolsBackend().updates(nextRequestId());
useTools.setState({ updates: Object.fromEntries(found.map((u) => [u.name, u])) });
} catch {
// A failed lookup leaves the last result.
} finally {
useTools.setState({ checkingUpdates: false, updatesChecked: true });
checkingUpdates = null;
}
})();
return checkingUpdates;
}
function upsertTool(info: ToolInfo) {
useTools.setState((s) => {
const i = s.tools.findIndex((t) => t.name === info.name);
@@ -151,10 +124,6 @@ export function installTool(name: string, opts: { notificationKey?: string } = {
});
upsertTool(info);
endJob(name, null);
useTools.setState((s) => {
const { [name]: _updated, ...updates } = s.updates;
return { updates };
});
void toolsBackend()
.restartServers(name)
.catch(() => {});
-2
View File
@@ -4786,8 +4786,6 @@
"{count} file": "{count} 个文件",
"{count} files": "{count} 个文件",
"Reinstall {name}": "重新安装 {name}",
"Update {displayName}": "更新 {displayName}",
"Version {latest} is available.": "有新版本 {latest} 可用。",
"Installed in {path}; it is used for projects targeting Java {version}.": "已安装到 {path};用于以 Java {version} 为目标的项目。",
"Code insight for {languages} needs {command}. Install it via {method}?": "{languages} 的代码洞察需要 {command}。是否通过 {method} 安装?",
"Version {version}": "版本 {version}",
+1 -3
View File
@@ -135,9 +135,7 @@ interface editor {
/// Applies versioned edits to an open project document from a background
/// event. The path is project-relative and requires `project.read` and
/// `editor.write`. The host refuses VCS/IDE control directories and every
/// symlink, junction or reparse-point component before it finds the open
/// document. Stale versions are rejected without changing text.
/// `editor.write`. Stale versions are rejected without changing text.
/// These background edits are non-undoable and are not re-emitted as
/// `document-changed` events.
apply-edits-at: func(project: borrow<project>, path: string, version: u64, edits: list<text-edit>) -> result<u64, error>;