Порівняти коміти

...
Автор SHA1 Повідомлення Дата
l0ng-ai 5bd12381a1 fix(lsp): run a server chosen from a Node.js installation on that Node
A language server path set to a script in a Node.js installation's bin
directory (nvm's bin/tsc or bin/npx) runs through #!/usr/bin/env node, which
found the project's Node.js interpreter or whatever node came first on the
IDE's PATH. TypeScript 7's tsc is an extensionless ES module that Node 20.9
refuses (ERR_UNKNOWN_FILE_EXTENSION), so tsc --lsp crashed although the
chosen installation runs Node 26. That installation's bin directory now
comes first on the server's PATH.
2026-11-14 04:31:32 +08:00
l0ng-ai 88f73630a6 feat(tools): offer updates for the language servers and debuggers the IDE installed (#3247)
Settings › Languages & Frameworks › Language Servers looks for newer versions of the tools in the IDE's tools folder once per session and from its refresh button: release downloads resolve their latest release as an install does, npm packages ask npm view through the user's registry. A tool with a newer version gets an Update tag, the available version in its details and an Update button that installs it.
2026-11-14 03:38:51 +08:00
l0ng-ai 504ffac10a test(rpc): check handler argument names against the client bindings (#3244)
Remote command handlers read arguments by name; a name the desktop client doesn't send reads as null (#3231). The test checks every c.arg / c.sender / c.channel name of each .add("command", …) registration in crates/ide-host/src against that command's arguments in src/bindings.ts.
2026-11-14 03:21:40 +08:00
l0ng-ai 5ee558d1a7 fix(database): keep expanded nodes loaded after editing a data source (#3243)
saveSource forgot the source's cached metadata before writing the new settings, so the following refresh found nothing loaded and reloaded only the first level; expanded schemas and tables stayed on Loading. saveSource now captures the loaded levels before forgetting them and passes them to refresh.
2026-11-14 03:16:32 +08:00
LunarFellerтаLunarFeller c09fb7191a fix(database): ignore stale metadata after deactivation (#3237)
A metadata reply that arrived after its data source was deactivated, deleted, edited or after a project switch could bring back the Connected indicator and the cleared schemas, tables, columns or completion, and an obsolete chain could issue further requests. Requests now carry project / source / target lifetimes (requests.ts); forget() starts a new lifetime, and late replies, introspection steps and follow-up requests of an ended lifetime are dropped. Completed writes keep their result but no longer revive an old connection.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 03:11:39 +08:00
János Kranczlerтаjanoskranczler 7ee80ed736 fix(database): tint the whole tree row of a coloured data source (#3240)
The data source colour was applied to the node's content span, so the tint started after the indent and left gaps between rows. The tint now goes on the tree row itself through Tree's rowClassName, covering the indent, chevron and full row height for the source and every node under it; selected and hovered rows keep their own colours.

Co-authored-by: janoskranczler <[email protected]>
2026-11-14 03:05:14 +08:00
Lucas 353768a15f fix(plugins): refuse linked and control paths in background document edits (#3236)
editor.apply-edits-at applies edits that may come from another machine (a collaboration session), so it no longer uses the ordinary read resolver. The path must be project-relative, every component is checked with symlink_metadata from the canonical root, and symbolic links, junctions and other reparse points are refused, as are VCS and IDE control directories (.git, .hg, .svn, .idea, .openidea, .vscode, including Windows alternate spellings). The WIT docs describe the rule in both wit/ and the SDK copy.

Co-authored-by: SorPuti <[email protected]>
2026-11-14 02:59:48 +08:00
Mr0Bread 5e969f0bad fix(vcs): rename a branch in remote windows (#3232)
Rename Branch always failed in remote development windows with `argument unset_upstream: invalid type: null, expected a boolean`: the remote JSON-RPC handler read the argument as `unset_upstream` while the client sends `unsetUpstream`. A sweep of every RPC handler's argument names against the client bindings found no other mismatch.

Closes #3231

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 02:56:14 +08:00
Vadim LazukтаInf1mum 6f60f55a1d fix(editor): recount the usages code lenses when the project's documents change (#3235)
The usages code lens cached each count for as long as its own document's text stood still, so edits to the call sites in other files never reached it and a count taken while the project's indexes were still building stayed on display; the lens could disagree with Find Usages (4 vs 5). Counts are now dropped when another document is edited or closed, the backend patches a document or applies a workspace edit, files change on disk, or a plugin index batch completes, and a tab shown again recounts. The query itself is unchanged.

Fixes #3217

Co-authored-by: Inf1mum <[email protected]>
2026-11-14 02:54:41 +08:00
l0ng-ai 8e5ac1dc01 feat(run): detect rebar3 and Mix projects and run escripts (#3239)
Erlang and Elixir projects had no run configurations. A rebar.config now gives rebar3 shell, rebar3 eunit and, with *_SUITE.erl suites (also in umbrella apps), rebar3 ct; a rebar3 script in the project is used over the PATH. A mix.exs gives iex -S mix (iex -S mix phx.server for Phoenix) and mix test. An .escript file runs with escript. The configurations show the Erlang and Elixir file icons.

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

Fixes #3207
2026-11-14 02:43:33 +08:00
44 змінених файлів з 2140 додано та 104 видалено

No files matched your search

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