mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 18:37:39 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93cccb9bb5 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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:#?}");
|
||||
}
|
||||
@@ -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(¤t).map_err(|_| format!("{path}: no such open document"))?;
|
||||
if is_link_or_reparse(&metadata) {
|
||||
return Err(format!("{path}: background edits refuse symbolic links"));
|
||||
}
|
||||
if index + 1 < components.len() && !metadata.is_dir() {
|
||||
return Err(format!("{path}: not a directory"));
|
||||
}
|
||||
if index + 1 == components.len() && !metadata.is_file() {
|
||||
return Err(format!("{path}: not a file"));
|
||||
}
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
/// [`resolve_background_document_in_root`] over a workspace group's roots.
|
||||
pub fn resolve_background_document_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String> {
|
||||
first_root_resolving(roots, path, resolve_background_document_in_root)
|
||||
}
|
||||
|
||||
fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool {
|
||||
#[cfg(windows)]
|
||||
let reparse = {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
metadata.file_attributes() & 0x400 != 0 // FILE_ATTRIBUTE_REPARSE_POINT
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let reparse = false;
|
||||
metadata.file_type().is_symlink() || reparse
|
||||
}
|
||||
|
||||
fn background_control_name(name: &std::ffi::OsStr) -> bool {
|
||||
let name = name.to_string_lossy();
|
||||
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
|
||||
matches!(name.to_ascii_lowercase().as_str(), ".git" | ".hg" | ".svn" | ".idea" | ".openidea" | ".vscode")
|
||||
}
|
||||
|
||||
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
|
||||
/// without requiring it to exist: `Ok(true)` also for a missing path, so
|
||||
/// callers distinguish "outside" from "absent". Existing symlinks are
|
||||
@@ -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() {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -957,15 +957,10 @@ pub fn resolve_run_command(config: &mut RunConfig, root: &Path, s: &Settings) {
|
||||
}
|
||||
|
||||
pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) {
|
||||
let mut path_dirs: Vec<PathBuf> = Vec::new();
|
||||
if let Some(custom) = tool_command(root, s, &cfg.command) {
|
||||
// An executable chosen from a Node.js installation (`~/.nvm/versions/node/v26/bin/tsc`,
|
||||
// its `npx`) runs on that Node: its `#!/usr/bin/env node` finds the `node` beside it.
|
||||
if let Some(bin) = custom.parent().filter(|bin| bin.join(exe("node")).is_file()) {
|
||||
path_dirs.push(bin.to_path_buf());
|
||||
}
|
||||
cfg.command = custom.to_string_lossy().into_owned();
|
||||
}
|
||||
let mut path_dirs: Vec<PathBuf> = Vec::new();
|
||||
let name = cfg.name.clone();
|
||||
let name = name.as_str();
|
||||
|
||||
|
||||
@@ -1325,40 +1325,6 @@ fn custom_tool_path_replaces_the_command() {
|
||||
assert_eq!(cfg.command, "zls");
|
||||
}
|
||||
|
||||
/// A server picked from a Node.js installation (nvm's `bin/tsc`, `bin/npx`) is a
|
||||
/// `#!/usr/bin/env node` script: it runs on that installation's Node, ahead of the
|
||||
/// project's Node.js interpreter.
|
||||
#[test]
|
||||
fn a_server_chosen_from_a_node_installation_runs_on_that_node() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let v26 = tmp.path().join("nvm/versions/node/v26.4.0/bin");
|
||||
let v20 = tmp.path().join("node20/bin");
|
||||
for dir in [&v26, &v20] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join(exe("node")), "").unwrap();
|
||||
}
|
||||
std::fs::write(v26.join("tsc"), "#!/usr/bin/env node\n").unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.node.interpreter = v20.join(exe("node")).to_string_lossy().into_owned();
|
||||
s.tools.insert(
|
||||
"typescript-language-server".into(),
|
||||
ide_core::settings::ToolSettings { path: v26.join("tsc").to_string_lossy().into_owned() },
|
||||
);
|
||||
let mut cfg = server("typescript-language-server");
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
let path: Vec<std::path::PathBuf> = std::env::split_paths(&cfg.env["PATH"]).collect();
|
||||
assert_eq!(path[..2], [v26.clone(), v20.clone()]);
|
||||
|
||||
// Elsewhere, the project's Node.js interpreter comes first.
|
||||
s.tools.insert(
|
||||
"typescript-language-server".into(),
|
||||
ide_core::settings::ToolSettings { path: tmp.path().join("tools/tsc").to_string_lossy().into_owned() },
|
||||
);
|
||||
let mut cfg = server("typescript-language-server");
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
assert_eq!(std::env::split_paths(&cfg.env["PATH"]).next(), Some(v20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_godot_setting_applies_to_server_debugger_and_run_configurations() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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![]);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!);
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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" });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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(() => {});
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user