Compare commits

..
Author SHA1 Message Date
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
LunarFellerandLunarFeller 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 Kranczlerandjanoskranczler 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 LazukandInf1mum 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
l0ng-ai 40494f2920 fix(lsp): accept file URIs a language server leaves unencoded (#3234)
jdtls on Windows sends a project path as is in its URIs (file:///H:/项目/…). lsp-types rejects such a URI, so the whole response failed: Find Usages, Go to Declaration, references, implementations and call hierarchy fell back to a text search for any project under a non-ASCII folder. Every message from a language server now has the characters URIs don't allow (non-ASCII, spaces, brackets…) percent-encoded in its file: URIs, string values and object keys (a workspace edit's changes) alike, before it is read.

Fixes #3205
2026-11-14 02:29:59 +08:00
l0ng-ai c016007a2d fix(ui): give .slint files their own icon (#3230)
Slint files showed the generic file icon, and `icons.test.tsx` (every detected language file has a specific icon) failed on main.
2026-11-14 02:18:56 +08:00
Mr0Bread 69ce1ab13d fix(editor): keep editor actions off a tool window's speed search (#3227)
Text and editor actions also run while a popup is open, so Find Action can run them on the editor. A tool window's speed search opens a popup layer too while focus stays in the tool window, so ⌘⌫ in the Project view's speed search ran Delete Line on the editor behind it. The popup branch of TEXT_WHEN, the Find occurrence actions and EDITOR_WHEN (navigation, LSP, code insight and template actions) now also require that no tool window has focus; Find Action and menus render outside tool windows and keep working.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 02:15:19 +08:00
Mr0Bread fcc8583f36 fix(ui): ⌘⌫ erases a tree's speed search query (#3226)
While a tree's speed search has a query, the keymap dispatcher ran ⌫ shortcuts before the tree saw the key: in the Database view ⌘⌫ opened Drop or Remove Data Source. The shared Tree now claims ⌫ with any modifier while a query is active and edits the query like a text field: ⌘⌫ erases it, ⌥⌫ / Ctrl+⌫ erase the last word. Without a query the view's shortcuts run as before.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 02:15:07 +08:00
acaoandamphetamlne b368f21eb6 perf(search): prune explicitly excluded subtrees (#3221)
Search and scope enumeration walked into directories that an exclusion mask covers entirely and dropped their files one by one. A directory is now skipped before its children are read when an exclusion with a trailing `/**` (e.g. `**/node_modules/**`) covers it; every file below such a directory is already excluded by the same mask, so results are unchanged. Other masks keep filtering individual files.

Co-authored-by: amphetamlne <[email protected]>
2026-11-14 02:13:05 +08:00
l0ng-ai 929866bbc5 fix(tools): honour Cancel until a downloaded tool replaces the installed one (#3228)
After unpacking, the IDE removed the previous install, moved the new one into place and cleared the quarantine flag without checking for Cancel or reporting progress, so the install log kept saying Extracting… and Cancel did nothing; on a slower disk, deleting a large tool such as kotlin-lsp (about 1 GB) took seconds. Cancel is now checked right before the swap, as JDK downloads already do; the quarantine flag is cleared before it, and the previous install is moved aside and deleted in the background.

Refs #2925
2026-11-14 02:12:33 +08:00
Mr0Bread 3c781296d6 feat(database): Select Opened File in the Database tool window header (#3223)
Adds Select Opened File (db.selectOpened) to the Database tool window header and Find Action. For a table editor it selects the table or view, loading and expanding the path to it; for a SQL console or SQL file it selects the schema the console runs in (or its database / data source when the schema isn't loaded) without connecting.

The reveal path shared with Go to Declaration now selects the nearest shown ancestor when the target row isn't shown (filtered out or dropped), and keeps focus in the tree instead of the filter field.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-14 01:55:52 +08:00
l0ng-ai 4aed449dc0 fix(editor): keep the usage highlight class names out of the text catalog check (#3225)
The usage highlight's class for plain occurrences sat under a `text` key, which the text catalog check reads as UI text, so `textCatalog.test.ts` failed on main. The class names are now built from one constant.
2026-11-14 01:51:25 +08:00
Akbar Husain 55431f3965 perf(startup): batch action registration (#3222)
registerActions() registered actions one at a time, and each registerAction() cloned the whole action map, so registering a feature's actions at start-up was quadratic. A batch now adds every source and updates the store once; replacement, disposal and localisation are unchanged.

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 01:50:31 +08:00
l0ng-ai d4b14a36c7 test(plugins): focus an absolute path in the git-toolbox schedule test (#3224)
Editor focus events are routed by the focused file's project, so a relative path is dropped before it reaches the plugin and the test never woke Git Toolbox. Editors always report absolute paths; the test now does too.
2026-11-14 01:46:28 +08:00
rohitkumardandaandrohitdanda d2c565b19d fix(plugins): harden the official plugins against crafted input (#3219)
Several official plugins trusted crafted input: paths outside the project were accepted where only project files were meant, some loops and replacements had no upper bound, and CSV Add Row wrapped its row index on wasm32.

- ai-completion: only treat a real OpenAI host as OpenAI
- checkstyle, license-header, mybatis: keep resolved paths under the project root
- license-header: keep the generated header on one line where one is expected
- csv-editor: Add Row appends on wasm32 instead of overwriting the header
- regex-tester: cap the replaced output
- solidity: linearisation stops on cyclic inheritance
- mybatis: bound the mappers and statements it indexes
- string-tools, graphviz: guard the remaining crafted-input cases
- Rebuilt the changed components from source.

Co-authored-by: rohitdanda <[email protected]>
2026-11-14 01:42:03 +08:00
zhangdagouandPLZY 01a60ef164 fix(editor): make usage highlighting follow the caret at once and paint multi-line usages as a block (#3220)
Usage highlighting at the caret waited a fixed 300 ms after every caret move, so clicking around lagged. A click now asks at once and keyboard moves wait 100 ms; with the document unchanged, a caret landing on usages found recently shows them immediately while they are refreshed. A highlight over several lines (a return statement's exit point) is painted as a block: from its start to the right edge, whole lines between, and the last line up to its end.

Co-authored-by: PLZY <[email protected]>
2026-11-14 01:33:42 +08:00
zhangdagouandPLZY b0b768d25e feat(editor): highlight the caret's indent guide and select a block by double-clicking its brace (#3213)
The indent guide in the caret's column, within the block the caret line belongs to, takes the selected indent guide colour, and guides sit slightly left of their column so a caret there is to the right of the line. With the caret at a brace of a multi-line block, that block's guide takes the matched-brace colour. A double-click that puts the caret next to { or } (also past the end of a line ending in {) selects the block, both braces included; a double-click on a word still selects the word.

Co-authored-by: PLZY <[email protected]>
2026-11-14 01:30:56 +08:00
l0ng-ai 3240bfc423 fix(vim): draw the Normal-mode block cursor solid, with the character in the background colour (#3218)
The focused block cursor was a 60% caret-coloured fill. On dark themes the caret is light
grey, so it read as a slightly brighter character and the cursor was easy to lose. It is now a
solid block in the caret colour with the character under it in the editor background colour,
as IdeaVim's block caret; the unfocused 1px outline and partial blocks are unchanged.

Fixes #3197
2026-11-14 01:27:32 +08:00
Mansur Akhmadiandmansur-akhmadi 15182507cf fix(vcs): confirm force pushes in the multi-repository Push dialog (#3215)
With Force (with lease) ticked, the multi-repository Push dialog force pushed every picked repository without the Force Push confirmation the single-repository dialog shows. Each forced target is now confirmed first, once when several repositories push to the same target; protected targets are never forced and not asked about, "Don't warn about this target" is respected, and declining pushes nothing and keeps the dialog open.

Closes #3214

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-14 01:24:29 +08:00
zhangdagouandPLZY 38bd8fbd16 fix(vcs): let the Commit diff go on past the last change into unversioned files (#3211)
A diff opened from the Commit tool window (double-click / Enter, and the preview that follows the selection) stopped at the last file of its group, so F7 never reached the new files under Unversioned Files. Its chain now covers every file of the tree in tree order, as the Commit diff preview does; Show Diff (Ctrl+D) still keeps to the file's group.

Co-authored-by: PLZY <[email protected]>
2026-11-14 01:22:54 +08:00
l0ng-ai 2859ec9106 feat(templates): New ▸ C# Class with the project's namespace (#3216)
Class, Interface, Struct, Enum and Record. The namespace is the nearest .csproj's
RootNamespace (else its file name) plus the folders below it. Projects on C# 10 or later
(.NET 6+, or LangVersion 10/latest) get a file-scoped namespace; .NET Framework, Unity,
.NET Standard and .NET Core projects get a namespace block.

Fixes #3190
2026-11-14 01:17:26 +08:00
112 changed files with 3814 additions and 302 deletions

No files matched your search

+46
View File
@@ -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
View File
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::Sdk;
use ide_tools::jdk::{JdkReleases, JdkVendor};
use ide_tools::{InstallEvent, ToolInfo, 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
View File
@@ -1507,7 +1507,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_rename_branch", |c| async move {
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("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 });
+8 -2
View File
@@ -2554,7 +2554,8 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
f.install(&official("git-toolbox"));
// Focusing an editor wakes it to show the branch (nothing fetched yet).
let now = Instant::now();
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
let focused = f.project.join("src/notes.txt").to_string_lossy().into_owned();
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(focused))]);
f.host.flush_events_at(now + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
let item = |host: &PluginHost| {
@@ -2992,13 +2993,18 @@ fn official_csv_editor_edits_the_shared_document() {
f.host.view_event("openidea.csv-editor", "table", cell, inv.clone()).unwrap();
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n");
// Add Row appends, also on wasm32 (where its row index used to wrap to the header).
let add = ide_plugin::ViewEvent::Button { id: "add-row".into() };
f.host.view_event("openidea.csv-editor", "table", add, inv.clone()).unwrap();
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n,\n");
// Settings: without a header row every record is a row.
let mut values = serde_json::Map::new();
values.insert("firstRowIsHeader".into(), serde_json::Value::Bool(false));
f.workspace.settings.lock().insert("openidea.csv-editor".into(), values);
let view = f.host.render_view("openidea.csv-editor", "table", None, inv.clone()).unwrap();
let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") };
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3));
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4));
// Undeclared views are refused before any code runs.
let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err();
+114
View File
@@ -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:#?}");
}
+10 -4
View File
@@ -223,6 +223,10 @@ pub trait Handler: Send + Sync + 'static {
/// Called once the response to a `method` request is queued: what it sends follows the response.
fn after_response(&self, _method: &str) {}
/// Adjusts every message from the peer (responses included) before it is dispatched, e.g.
/// to repair values the peer spells more loosely than the types that read them accept.
fn normalize_incoming(&self, _message: &mut Value) {}
}
/// A [`Handler`] that ignores notifications and rejects requests.
@@ -585,9 +589,10 @@ impl Peer {
.expect("spawn jsonrpc notification thread");
}
// Reader: parses and dispatches; never runs handler code itself.
// Reader: parses and dispatches; runs no handler code beyond `normalize_incoming`.
{
let inner = inner.clone();
let handler = handler.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(reader);
let seen_message = AtomicBool::new(false);
@@ -610,7 +615,7 @@ impl Peer {
match msg {
Ok(Some(body)) => {
seen_message.store(true, Ordering::Relaxed);
dispatch(&inner, &notif_tx, &body);
dispatch(&inner, &*handler, &notif_tx, &body);
}
Ok(None) => break,
Err(e) => {
@@ -783,15 +788,16 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
}
}
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
let msg: Value = match serde_json::from_slice(body) {
let mut msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("jsonrpc: unparsable message: {e}");
return;
}
};
handler.normalize_incoming(&mut msg);
let Value::Object(mut obj) = msg else {
tracing::warn!("jsonrpc: ignoring non-object message");
return;
+26
View File
@@ -541,3 +541,29 @@ async fn output_before_the_handshake_is_reported_not_dropped() {
assert!(lines.iter().any(|l| l == "python: not found"), "{lines:?}");
assert!(lines.iter().any(|l| l.ends_with(". . .") && l.len() > 6), "{lines:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn incoming_messages_are_normalized_before_dispatch() {
struct Server;
impl Handler for Server {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, _: &str, params: Value) -> Result<Value, (i64, String)> {
Ok(params)
}
}
struct Client;
impl Handler for Client {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, _: &str, _: Value) -> Result<Value, (i64, String)> {
Err((codes::METHOD_NOT_FOUND, "no".into()))
}
fn normalize_incoming(&self, message: &mut Value) {
if let Some(result) = message.get_mut("result") {
*result = json!("normalized");
}
}
}
let (client, _server) = pair(Arc::new(Client), Arc::new(Server));
let v: Value = client.request("echo", "raw", None).await.unwrap();
assert_eq!(v, json!("normalized"));
}
+4
View File
@@ -391,6 +391,10 @@ fn tsserver_ran_out_of_memory(text: &str) -> bool {
}
impl Handler for ClientHandler {
fn normalize_incoming(&self, message: &mut Value) {
crate::uri::encode_loose_file_uris(message);
}
fn on_notification(&self, method: &str, params: Value) {
let Some(mgr) = self.mgr.upgrade() else { return };
match method {
+71
View File
@@ -1,6 +1,8 @@
//! `file://` URI ⇄ path conversion (RFC 8089, VS Code-compatible encoding).
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use serde_json::Value;
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::str::FromStr;
@@ -22,6 +24,53 @@ const PATH: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
/// What RFC 3986 doesn't allow in a URI and a server may still leave unencoded in a `file:`
/// URI (non-ASCII is always encoded); `%` stays, it starts the escapes already there.
const LOOSE: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'<')
.add(b'>')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
/// Percent-encodes the characters URIs don't allow in every `file:` URI in `value`, string
/// values and object keys alike (a workspace edit's `changes`). Eclipse's jdtls sends a path
/// as is (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response.
pub fn encode_loose_file_uris(value: &mut Value) {
match value {
Value::String(s) => {
if let Some(encoded) = encode_loose(s) {
*s = encoded;
}
}
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
Value::Object(map) => {
if map.keys().any(|k| encode_loose(k).is_some()) {
*map = std::mem::take(map).into_iter().map(|(k, v)| (encode_loose(&k).unwrap_or(k), v)).collect();
}
map.values_mut().for_each(encode_loose_file_uris);
}
_ => {}
}
}
fn encode_loose(s: &str) -> Option<String> {
if !s.get(..5).is_some_and(|scheme| scheme.eq_ignore_ascii_case("file:")) {
return None;
}
match Cow::from(utf8_percent_encode(s, LOOSE)) {
Cow::Owned(encoded) => Some(encoded),
Cow::Borrowed(_) => None,
}
}
pub fn path_to_uri_string(path: &Path) -> String {
let s = if cfg!(windows) {
dunce::simplified(path).to_string_lossy().replace('\\', "/")
@@ -95,6 +144,28 @@ pub use ide_core::text::is_virtual_uri;
mod tests {
use super::*;
#[test]
fn file_uris_a_server_leaves_unencoded_are_encoded() {
// jdtls on a project under a Chinese folder (`java.usages` / references response).
let mut v = serde_json::json!({ "result": [{
"location": { "uri": "file:///H:/项目/online disk/A.java", "range": { "start": { "line": 1, "character": 2 }, "end": { "line": 1, "character": 3 } } },
"kind": "unknown"
}], "edit": { "changes": { "file:///tmp/目录/b.java": [] } }, "text": "项目" });
let raw = v["result"][0]["location"].clone();
assert!(serde_json::from_value::<lsp_types::Location>(raw).is_err(), "lsp-types rejects it as sent");
encode_loose_file_uris(&mut v);
let uri = v["result"][0]["location"]["uri"].as_str().unwrap();
assert_eq!(uri, "file:///H:/%E9%A1%B9%E7%9B%AE/online%20disk/A.java");
let location: lsp_types::Location = serde_json::from_value(v["result"][0]["location"].clone()).unwrap();
assert!(uri_to_path(location.uri.as_str()).unwrap().ends_with("项目/online disk/A.java"));
assert!(v["edit"]["changes"].get("file:///tmp/%E7%9B%AE%E5%BD%95/b.java").is_some());
assert_eq!(v["text"], "项目", "only file: URIs change");
// Already encoded: untouched.
let mut encoded = serde_json::json!("file:///a%20b/%E9%A1%B9.java");
encode_loose_file_uris(&mut encoded);
assert_eq!(encoded, "file:///a%20b/%E9%A1%B9.java");
}
#[cfg(unix)]
#[test]
fn round_trip() {
+97
View File
@@ -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
View File
@@ -870,7 +870,7 @@ impl editor::Host for StoreData {
return Ok(Err(wit::Error::Invalid("edits too large".into())));
}
let root = self.table.get(&project)?.root.clone();
let path = match self.resolve_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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+22 -1
View File
@@ -65,5 +65,26 @@ fn bench(c: &mut Criterion) {
g.finish();
}
criterion_group!(benches, bench);
// No .ignore file: the query's subtree masks must prevent traversal themselves.
fn excluded_subtrees(c: &mut Criterion) {
let dir = tempfile::tempdir().unwrap();
for module in 0..100 {
let root = dir.path().join(format!("modules/m{module}"));
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.go"), "needle").unwrap();
for dependency in 0..50 {
let dep = root.join(format!("node_modules/dep{dependency}/lib"));
std::fs::create_dir_all(&dep).unwrap();
std::fs::write(dep.join("index.js"), "needle").unwrap();
}
}
let q = SearchQuery { exclude: vec!["**/node_modules/**".into()], ..query("needle", false) };
assert_eq!(run(dir.path(), &q), 100);
let mut group = c.benchmark_group("search_excluded_subtrees");
group.sample_size(15);
group.bench_function("5k_dependencies", |b| b.iter(|| black_box(run(dir.path(), &q))));
group.finish();
}
criterion_group!(benches, bench, excluded_subtrees);
criterion_main!(benches);
+60 -13
View File
@@ -143,18 +143,22 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult<SearchSummary> {
}
if !ctx.ctl.should_stop() {
walk_files(&on_disk, || {
let mut searcher = searcher_builder.build();
let matcher = matcher.clone();
let ctx = &ctx;
Box::new(move |path| {
if ctx.ctl.should_stop() {
return WalkState::Quit;
}
ctx.search_file(path, &matcher, &mut searcher);
if ctx.ctl.stopped() { WalkState::Quit } else { WalkState::Continue }
})
});
walk_files(
&on_disk,
&|path| ctx.mask.has_directory_exclusions() && ctx.mask.excludes_dir(&rel_path(ctx.root, path)),
|| {
let mut searcher = searcher_builder.build();
let matcher = matcher.clone();
let ctx = &ctx;
Box::new(move |path| {
if ctx.ctl.should_stop() {
return WalkState::Quit;
}
ctx.search_file(path, &matcher, &mut searcher);
if ctx.ctl.stopped() { WalkState::Quit } else { WalkState::Continue }
})
},
);
}
let cancelled = ctx.ctl.cancelled.load(Relaxed) || req.cancel.is_cancelled();
@@ -176,7 +180,11 @@ pub(crate) type VisitFile<'s> = Box<dyn FnMut(&Path) -> WalkState + Send + 's>;
/// them, while ignored files elsewhere (`.env`) and hidden files and folders
/// (`.github`, `.eslintrc`) are visited. `.git`, symlinks and archives (jar,
/// zip: browsed, not searched) are never visited.
pub(crate) fn walk_files<'s>(roots: &[PathBuf], mut visitor: impl FnMut() -> VisitFile<'s> + Send) {
pub(crate) fn walk_files<'s>(
roots: &[PathBuf],
skip_dir: &'s (dyn Fn(&Path) -> bool + Sync),
mut visitor: impl FnMut() -> VisitFile<'s> + Send,
) {
let Some((first, rest)) = roots.split_first() else { return };
let mut walk = WalkBuilder::new(first);
for path in rest {
@@ -204,6 +212,9 @@ pub(crate) fn walk_files<'s>(roots: &[PathBuf], mut visitor: impl FnMut() -> Vis
};
let Some(file_type) = entry.file_type() else { return WalkState::Continue };
if file_type.is_dir() {
if skip_dir(entry.path()) {
return WalkState::Skip;
}
// The walker drops ignored files unseen, so the files of every walked
// directory come from listing it; the walker only picks the directories.
let Ok(children) = std::fs::read_dir(entry.path()) else { return WalkState::Continue };
@@ -595,3 +606,39 @@ mod tests {
}
}
}
#[cfg(test)]
mod walk_tests {
use super::*;
use std::sync::Mutex;
#[test]
fn excluded_subtree_is_not_visited() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("edge/device/build/deep")).unwrap();
std::fs::create_dir_all(root.join("edge/device/src")).unwrap();
std::fs::write(root.join("edge/device/build/deep/generated.go"), "hit").unwrap();
std::fs::write(root.join("edge/device/src/main.go"), "hit").unwrap();
let mask = FileMask::new(&[], &["**/build/**".into()]).unwrap();
let seen = Mutex::new(Vec::new());
walk_files(&[root.to_path_buf()], &|path| mask.excludes_dir(&rel_path(root, path)), || {
let seen = &seen;
Box::new(move |path| {
// No file-level mask here: a visit means the walker entered the subtree.
seen.lock().unwrap().push(rel_path(root, path));
WalkState::Continue
})
});
assert_eq!(*seen.lock().unwrap(), ["edge/device/src/main.go"]);
seen.lock().unwrap().clear();
walk_files(&[root.join("edge/device/build/deep")], &|path| mask.excludes_dir(&rel_path(root, path)), || {
let seen = &seen;
Box::new(move |path| {
seen.lock().unwrap().push(rel_path(root, path));
WalkState::Continue
})
});
assert!(seen.lock().unwrap().is_empty());
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ pub fn scope_files(
on_disk.push(path);
}
}
walk_files(&on_disk, || {
walk_files(&on_disk, &|path| mask.has_directory_exclusions() && mask.excludes_dir(&rel_path(root, path)), || {
let found = &found;
let keep = &keep;
Box::new(move |path| {
+80
View File
@@ -12,6 +12,7 @@ pub struct FileMask {
exclude_name: GlobSet,
exclude_path: GlobSet,
has_include: bool,
exclude_dirs: GlobSet,
}
impl FileMask {
@@ -20,6 +21,7 @@ impl FileMask {
pub fn new(include: &[String], exclude: &[String]) -> IdeResult<Self> {
let mut inc = Lists::default();
let mut exc = Lists::default();
let mut dirs = Lists::default();
for (entries, is_include_list) in [(include, true), (exclude, false)] {
for mask in entries.iter().flat_map(|e| e.split(',')) {
let mask = mask.trim();
@@ -32,10 +34,20 @@ impl FileMask {
}
let target = if is_include_list != negated { &mut inc } else { &mut exc };
target.push(mask)?;
// Only a trailing /** guarantees that every descendant is excluded.
// File masks and inclusion masks cannot safely prune a directory.
if is_include_list == negated
&& !mask.contains('\\')
&& let Some(prefix) = mask.strip_suffix("/**")
&& !prefix.is_empty()
{
dirs.push(&format!("{prefix}/"))?;
}
}
}
Ok(Self {
has_include: inc.count > 0,
exclude_dirs: dirs.path.build().map_err(glob_error)?,
include_name: inc.name.build().map_err(glob_error)?,
include_path: inc.path.build().map_err(glob_error)?,
exclude_name: exc.name.build().map_err(glob_error)?,
@@ -43,6 +55,20 @@ impl FileMask {
})
}
pub(crate) fn has_directory_exclusions(&self) -> bool {
!self.exclude_dirs.is_empty()
}
/// Whether an exclusion covers every file below this directory. This is
/// deliberately conservative; ordinary masks still filter individual files.
pub(crate) fn excludes_dir(&self, rel_path: &str) -> bool {
if self.exclude_dirs.is_empty() {
return false;
}
let path = format!("{rel_path}/");
path.match_indices('/').any(|(i, _)| self.exclude_dirs.is_match(&path[..=i]))
}
/// `rel_path` uses `/` separators.
pub fn matches(&self, rel_path: &str) -> bool {
let name = rel_path.rsplit('/').next().unwrap_or(rel_path);
@@ -133,6 +159,60 @@ mod tests {
assert!(!m.matches("Cargo.lock"));
}
#[test]
fn only_complete_subtree_exclusions_prune_directories() {
let m = mask(&["*.go"], &["**/node_modules/**", "edge/device/build/**"]);
for dir in [
"node_modules",
"edge/node_modules",
"edge/device/build",
"edge/device/build/deep",
"edge/node_modules/pkg/lib",
] {
assert!(m.excludes_dir(dir), "{dir}");
}
for dir in ["edge", "edge/device", "internal/build", "src.go"] {
assert!(!m.excludes_dir(dir), "{dir}");
}
for pattern in ["*.go", "build", "build/*", "build/", "build/**/*.go"] {
assert!(!mask(&[], &[pattern]).excludes_dir("build"), "{pattern}");
}
}
#[test]
fn directory_pruning_preserves_mask_negation_semantics() {
assert!(mask(&["!vendor/**"], &[]).excludes_dir("vendor"));
assert!(!mask(&[], &["!vendor/**"]).excludes_dir("vendor"));
// Exclusions already win over inclusions in FileMask, irrespective of order.
let m = mask(&["vendor/keep.go"], &["vendor/**"]);
assert!(m.excludes_dir("vendor"));
assert!(!m.matches("vendor/keep.go"));
assert!(mask(&[], &["./vendor/**, **/node_modules/**"]).excludes_dir("vendor"));
}
#[test]
fn directory_pruning_uses_the_same_case_rules_as_files() {
let m = mask(&[], &["Vendor/**"]);
let case_insensitive = cfg!(any(target_os = "macos", target_os = "windows"));
assert_eq!(m.excludes_dir("vendor"), case_insensitive);
assert_eq!(m.matches("vendor/a.go"), !case_insensitive);
}
#[test]
fn pruned_directory_descendants_are_already_excluded() {
for pattern in ["vendor/**", "**/build/**", "modules/*/build/**", "[ab]/**", "**/**"] {
let m = mask(&[], &[pattern]);
for dir in ["vendor", "vendor/deep", "build", "a", "modules/a/build", "modules/a/src", "x/build/y"] {
if m.excludes_dir(dir) {
for file in ["main.go", "deep/lib.go", ".hidden/config", "node_modules/a.js"] {
assert!(!m.matches(&format!("{dir}/{file}")), "{pattern}: {dir}/{file}");
}
}
}
}
assert!(!mask(&[], &[r"escaped\*/**"]).excludes_dir("escaped*"));
}
#[test]
fn invalid_glob() {
let err = FileMask::new(&["a[".into()], &[]).unwrap_err();
+39
View File
@@ -797,3 +797,42 @@ fn search_context_reads_non_utf8_files() {
let p = project(&[("a.rs", &gbk("foo(\"新增\"); // 中文 foo\n"))]);
assert_eq!(run_context(p.path(), C::InComments), vec![("a.rs".to_owned(), 0, vec![(17, 20)])]);
}
#[test]
fn subtree_masks_preserve_results_across_search_scopes() {
let p = project(&[
("edge/build/deep/generated.go", b"hit"),
("edge/src/main.go", b"hit"),
("internal/build/build.go", b"hit"),
("edge/build-notes.go", b"hit"),
]);
let q = SearchQuery { exclude: vec!["edge/build/**".into()], ..query("hit") };
let (views, _) = run(p.path(), &q);
assert_eq!(paths(&views), ["edge/build-notes.go", "edge/src/main.go", "internal/build/build.go"]);
let mask = ide_search::FileMask::new(&q.include, &q.exclude).unwrap();
for scope in [
SearchScope::Directory { path: "edge/build".into() },
SearchScope::Files { paths: vec!["edge/build/deep/generated.go".into()] },
] {
let (views, _) = run(p.path(), &SearchQuery { scope: scope.clone(), ..q.clone() });
assert!(views.is_empty());
assert!(ide_search::scope_files(p.path(), &scope, &mask, None, &CancellationToken::new()).unwrap().is_empty());
}
// A file-name exclusion must not prune a same-named directory.
let (views, _) = run(p.path(), &SearchQuery { exclude: vec!["build".into()], ..query("hit") });
assert_eq!(views.len(), 4);
}
#[test]
fn ignore_whitelists_do_not_override_explicit_subtree_masks() {
let p = project(&[
(".gitignore", b"generated/*\n!generated/keep/\n"),
("generated/keep/.gitignore", b"!keep.go\n"),
("generated/keep/keep.go", b"needle"),
("src/main.go", b"needle"),
]);
let (views, _) = run(p.path(), &query("needle"));
assert_eq!(paths(&views), ["generated/keep/keep.go", "src/main.go"]);
let (views, _) = run(p.path(), &SearchQuery { exclude: vec!["generated/**".into()], ..query("needle") });
assert_eq!(paths(&views), ["src/main.go"]);
}
+111 -6
View File
@@ -104,6 +104,17 @@ pub enum InstallEvent {
Progress { fraction: Option<f64>, message: String },
}
/// A newer version of a tool the IDE installed, as its install source offers it now.
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ToolUpdate {
pub name: String,
/// The installed version.
pub current: String,
/// The version installing the tool again would get.
pub latest: String,
}
#[derive(Serialize, Deserialize)]
struct Manifest {
version: Option<String>,
@@ -1410,10 +1421,16 @@ impl Tools {
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("`{main}` was not found in {}", dl.name)))?;
let rel = found.strip_prefix(&pkg).expect("inside pkg").to_path_buf();
let final_dir = self.root.join(spec.name);
if final_dir.exists() {
std::fs::remove_dir_all(&final_dir)?;
}
std::fs::rename(&pkg, &final_dir)?;
// Downloads made by the IDE itself are not quarantined, but archives may carry the flag.
clear_quarantine(&pkg);
// The previous install is removed in the background once replaced: deleting a large
// tool takes a while, and there is nothing left to cancel by then.
let previous = self.root.join(format!(".previous-{}", spec.name));
let _ = std::fs::remove_dir_all(&previous);
put_in_place(&pkg, &final_dir, &previous, cancel)?;
std::thread::spawn(move || {
let _ = std::fs::remove_dir_all(previous);
});
let target = final_dir.join(&rel);
let bin = final_dir.join("bin");
if is_bundle(main) {
@@ -1428,11 +1445,80 @@ impl Tools {
archive::link_executable(&bin, main, &target)?;
}
}
// Downloads made by the IDE itself are not quarantined, but archives may carry the flag.
clear_quarantine(&final_dir);
self.write_manifest(spec.name, &Manifest { version: Some(dl.version.clone()), source: Some(dl.url.clone()) })
}
/// Newer versions of the tools the IDE installed into its tools folder, for the install
/// sources that can be asked: release downloads (GitHub, Open VSX, Maven Central, …) and
/// npm packages. A tool whose lookup fails (offline, rate limited) is left out.
pub fn updates(&self, cancel: &CancellationToken) -> IdeResult<Vec<ToolUpdate>> {
let installed: Vec<(&ToolSpec, Method, String)> = TOOLS
.iter()
.filter_map(|spec| {
let method = self.install_method(spec);
Some((spec, method, self.installed_version(spec, method)?))
})
.collect();
let updates = std::thread::scope(|scope| {
let lookups: Vec<_> = installed
.iter()
.map(|(spec, method, current)| {
scope.spawn(move || match self.for_method(*method).latest_version(*method, cancel) {
Ok(Some(latest)) if latest != *current => {
Some(ToolUpdate { name: spec.name.into(), current: current.clone(), latest })
}
Ok(_) => None,
Err(e) => {
tracing::info!("update check for {}: {}", spec.name, e.message);
None
}
})
})
.collect();
lookups.into_iter().filter_map(|lookup| lookup.join().ok().flatten()).collect()
});
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(updates)
}
/// The version of `spec` in the IDE's tools folder, when `method` (how it was installed)
/// can tell the latest one.
fn installed_version(&self, spec: &ToolSpec, method: Method) -> Option<String> {
match method {
Method::Release { .. } => {
managed_bin_dir(&self.root, spec, self.platform).and_then(|dir| self.exe_in(&dir, spec.commands[0]))?;
self.manifest(spec.name)?.version
}
// A pinned package (`typescript@6`) stays on its major version.
Method::Npm { packages } if npm_name(packages[0]) == packages[0] => self.npm_package_version(packages[0]),
_ => None,
}
}
/// The version installing with `method` would get now.
fn latest_version(&self, method: Method, cancel: &CancellationToken) -> IdeResult<Option<String>> {
match method {
Method::Release { source, assets, .. } => {
let http = Http::new(&self.system_path)?;
Ok(Some(self.resolve_release(&http, source, assets, cancel)?.version))
}
Method::Npm { packages } => {
let (manager, _) = self.npm_manager()?;
let mut c = self.command(manager);
// The registry the user's npm configuration names (a mirror, a proxy). A cold npm
// on a slow registry takes several seconds per package, more when run side by side.
c.args(["view", packages[0], "version"]).current_dir(&self.root);
match run_quiet(c, Duration::from_secs(60)) {
Some((true, out)) => Ok(out.lines().map(str::trim).rfind(|l| !l.is_empty()).map(String::from)),
_ => Err(IdeError::unavailable(format!("cannot look up the latest {}", packages[0]))),
}
}
_ => Ok(None),
}
}
/// Removes a tool the IDE installed.
pub fn uninstall(&self, name: &str, langs: &[LanguageConfig]) -> IdeResult<ToolInfo> {
match registry::find(name) {
@@ -1563,6 +1649,25 @@ fn fetch_verified(
}
/// Removes the quarantine flag archives may carry, so macOS runs what was unpacked.
/// Moves the unpacked `pkg` to `dest`, moving a previous install aside to `aside`.
/// Cancelling stops it until then: the last point where the previous install is untouched.
fn put_in_place(pkg: &Path, dest: &Path, aside: &Path, cancel: &CancellationToken) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let replaced = dest.exists();
if replaced {
std::fs::rename(dest, aside)?;
}
if let Err(e) = std::fs::rename(pkg, dest) {
if replaced {
let _ = std::fs::rename(aside, dest);
}
return Err(e.into());
}
Ok(())
}
fn clear_quarantine(dir: &Path) {
#[cfg(target_os = "macos")]
{
+42
View File
@@ -916,3 +916,45 @@ exit 1
let args = fs::read_to_string(log).unwrap();
assert!(args.contains(&format!("--python {}", sdk.join("python3.14").display())), "{args}");
}
#[test]
fn a_cancelled_install_leaves_the_previous_one_in_place() {
let tmp = tempfile::tempdir().unwrap();
let (pkg, dest, aside) = (tmp.path().join("pkg"), tmp.path().join("tool"), tmp.path().join(".previous-tool"));
fs::create_dir_all(&pkg).unwrap();
fs::write(pkg.join("new"), "").unwrap();
fs::create_dir_all(&dest).unwrap();
fs::write(dest.join("old"), "").unwrap();
let cancel = CancellationToken::new();
cancel.cancel();
let err = put_in_place(&pkg, &dest, &aside, &cancel).unwrap_err();
assert_eq!(err.kind, ErrorKind::Cancelled);
assert!(dest.join("old").exists() && pkg.join("new").exists() && !aside.exists());
put_in_place(&pkg, &dest, &aside, &CancellationToken::new()).unwrap();
assert!(dest.join("new").exists() && !dest.join("old").exists());
assert!(aside.join("old").exists(), "the previous install is moved aside");
}
/// An npm tool the IDE installed is offered the version the user's npm registry has now.
#[cfg(unix)]
#[test]
fn npm_tools_report_a_newer_registry_version() {
let env = Env::new();
let sdk = env._tmp.path().join("node-sdk/bin");
let modules = env.root.join("node_modules");
env.script(&modules.join(".bin"), "bash-language-server", "echo 5.4.0");
fs::create_dir_all(modules.join("bash-language-server")).unwrap();
fs::write(modules.join("bash-language-server/package.json"), r#"{"version":"5.4.0"}"#).unwrap();
env.script(&sdk, "node", "exit 0");
let tools = |latest: &str| {
env.script(&sdk, "npm", &format!(r#"[ "$1 $2 $3" = "view bash-language-server version" ] && echo {latest}"#));
env.tools().with_runtime(BTreeMap::from([("node".into(), sdk.join("node"))]), BTreeMap::new())
};
let cancel = CancellationToken::new();
assert_eq!(
tools("5.6.1").updates(&cancel).unwrap(),
vec![ToolUpdate { name: "bash-language-server".into(), current: "5.4.0".into(), latest: "5.6.1".into() }]
);
assert_eq!(tools("5.4.0").updates(&cancel).unwrap(), vec![]);
}
+149
View File
@@ -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();
});
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
446156fc345df7c8252592f73129f3996139e5980c6ed9a296a9cbecc334eb17
ec93db9b37437986167b4040714f5cda5cb7a2fc138e6c2dc2a9447faaf5a802
+33 -1
View File
@@ -31,10 +31,19 @@ struct Config {
enabled: bool,
}
/// Whether `endpoint` is OpenAI's API host itself, so the key may go to it:
/// `https`, exactly `api.openai.com` (or `:443`), and no `user@` before the host
/// (`https://api.openai.com@localhost` is localhost).
pub fn is_openai(endpoint: &str) -> bool {
let Some(rest) = endpoint.strip_prefix("https://") else { return false };
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
matches!(authority, "api.openai.com" | "api.openai.com:443")
}
fn config() -> Config {
let get = |k: &str| storage::get(k).ok().flatten();
let endpoint = get("endpoint").unwrap_or_else(|| LOCAL.into());
let openai = endpoint.starts_with(OPENAI);
let openai = is_openai(&endpoint);
let model = get("model").unwrap_or_else(|| (if openai { OPENAI_MODEL } else { LOCAL_MODEL }).into());
Config { endpoint, model, openai, enabled: get("enabled").as_deref() != Some("false") }
}
@@ -195,6 +204,29 @@ mod tests {
assert!(sent.headers.contains(&("Authorization".into(), "Bearer sk-test".into())));
}
#[test]
fn the_key_goes_to_openai_only() {
assert!(is_openai(OPENAI));
assert!(is_openai("https://api.openai.com/"));
assert!(is_openai("https://api.openai.com:443/v1"));
for other in [
"https://api.openai.com@localhost:9999",
"https://api.openai.com:x@localhost",
"https://api.openai.com.example.com",
"https://api.openai.com\\@localhost",
"http://api.openai.com",
"http://localhost:11434",
] {
assert!(!is_openai(other), "{other}");
}
let host = MockHost::install().with_capabilities(&["storage"]);
AiCompletion::default()
.run_command("set-endpoint", &host.invocation(), Some("https://api.openai.com@localhost:9999"))
.unwrap();
assert!(!config().openai, "no key for a look-alike endpoint");
}
#[test]
fn quiet_when_cancelled_denied_or_mid_word() {
let host = MockHost::install().with_capabilities(&["storage"]);
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b072d07c4aa4a972a71fffba355ea215da7860b8f5527c6074055f52c52a29be
1bd90bca250bb06ab3b0f27b045110c722685d894ce79d23648cb7c795d2c033
+15 -1
View File
@@ -106,10 +106,16 @@ fn project_of(path: &str) -> Option<Project> {
host::projects()
.ok()?
.into_iter()
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
.filter(|p| p.root().is_ok_and(|r| is_under(path, &r)))
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
/// Whether `path` is `root` or inside it (`/proj-x/a` is not inside `/proj`).
fn is_under(path: &str, root: &str) -> bool {
path.strip_prefix(root)
.is_some_and(|rest| rest.is_empty() || root.ends_with(['/', '\\']) || rest.starts_with(['/', '\\']))
}
fn windows_path(path: &str) -> String {
let path = path.replace('\\', "/");
if let Some(rest) = path.strip_prefix("//?/UNC/") {
@@ -311,6 +317,14 @@ mod tests {
assert!(!report_matches_file("/project/A\\B.java", "/project/A/B.java"));
}
#[test]
fn a_project_contains_only_its_own_paths() {
assert!(is_under("/proj/A.java", "/proj"));
assert!(is_under("C:\\proj\\A.java", "C:\\proj"));
assert!(is_under("/A.java", "/"));
assert!(!is_under("/proj-x/A.java", "/proj"));
}
#[test]
fn windows_reports_match_the_saved_file_and_keep_quick_fixes() {
for (root, reported) in [
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
a78162e7c4c6ebd3a22c06737fd35948e11b0dd8949571617bc099ba6b4423f0
c4c86cef9512ac03193e7d89d39b6ee41665c2d4721a78f9415d406289e9ece1
+5 -4
View File
@@ -143,8 +143,9 @@ impl CsvEditor {
let d = csv.delimiter;
let width = csv.records.iter().map(|r| r.fields.len()).max().unwrap_or(1).max(1);
let empty_record = d.to_string().repeat(width - 1);
let record = |row: u32| csv.records.get(row as usize + skip);
let insert = |before: u32| match record(before) {
let record = |row: u32| csv.records.get(usize::try_from(row).ok()?.checked_add(skip)?);
// Before row `before`; at the end for `None` or a row past the end.
let insert = |before: Option<u32>| match before.and_then(record) {
Some(r) => vec![edit(text, r.span.start..r.span.start, format!("{empty_record}\n"))],
None => {
// Append; the last record may lack its line break.
@@ -166,8 +167,8 @@ impl CsvEditor {
}
}
}
ViewEvent::InsertRow(before) => insert(*before),
ViewEvent::Button(b) if b == "add-row" => insert(u32::MAX),
ViewEvent::InsertRow(before) => insert(Some(*before)),
ViewEvent::Button(b) if b == "add-row" => insert(None),
ViewEvent::DeleteRows(rows) => {
let mut rows: Vec<u32> = rows.clone();
rows.sort_unstable();
@@ -6,7 +6,7 @@
* which turns it into the file's problems.
*/
import { instance, type Viz } from "@viz-js/viz";
import { ENGINES, type Engine, exportName, problemsOf, renderOptions } from "./render";
import { ENGINES, type Engine, exportName, problemsOf, renderOptions, withoutLinks } from "./render";
const RENDER_DELAY_MS = 150;
const MIN_ZOOM = 0.1;
@@ -73,7 +73,7 @@ function start(bridge: OpenideaBridge) {
const problems = problemsOf(result);
report(problems);
if (result.status === "success") {
canvas.innerHTML = result.output;
canvas.innerHTML = withoutLinks(result.output);
canvas.classList.remove("stale");
banner.hidden = true;
if (fitted) fit();
@@ -1,6 +1,6 @@
import { instance } from "@viz-js/viz";
import { describe, expect, it } from "vitest";
import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions } from "./render";
import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions, withoutLinks } from "./render";
describe("the preview's rendering", () => {
it("renders with the theme's colour as a default the graph can override", async () => {
@@ -42,4 +42,22 @@ describe("the preview's rendering", () => {
expect(exportName("Flow.GV", "png")).toBe("Flow.png");
expect(exportName("", "svg")).toBe("graph.svg");
});
it("previews a graph's links without them", async () => {
const viz = await instance();
const js = "javascript:alert(1)";
const graph = `digraph {
URL="${js}"; target="_top"
a [URL="${js}" href="https://example.com" label="a href=\\"x\\""]
a -> b [URL="${js}" edgeURL="${js}" headURL="${js}" tailURL="${js}" labelURL="${js}" label="e" headlabel="h" taillabel="t"]
subgraph cluster_x { URL="${js}"; c }
}`;
const result = viz.render(graph, renderOptions("dot", null));
const svg = result.status === "success" ? result.output : "";
expect(svg).toContain(js);
const preview = withoutLinks(svg);
expect(preview).not.toMatch(/\s(xlink:)?(href|target)="|javascript:/);
expect(preview).toContain("a href=&quot;x&quot;");
expect(preview.match(/<g id="node\d+"/g)).toHaveLength(3);
});
});
@@ -66,3 +66,13 @@ export function exportName(file: string, extension: string): string {
const base = file.replace(/\.(dot|gv)$/i, "") || "graph";
return `${base}.${extension}`;
}
/**
* Graphviz's SVG without its links, for the preview: Graphviz writes a graph's
* `URL` / `href` attributes as given (`javascript:` too), and the preview's
* page may run inline script. Exports keep them. Graphviz quotes attribute
* values with `"` and escapes the ones inside, so a value never holds one.
*/
export function withoutLinks(svg: string): string {
return svg.replace(/\s(?:xlink:)?(?:href|target)="[^"]*"/g, "");
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
6d84abcc58c1894203506fa89855a6eade61f14add0ed72fdd7af9e3cd1298f6
0d7731de73514b950d8579762029d98c46136f55bfdb2fa44e256cc9a9955a0f
+32 -2
View File
@@ -58,8 +58,16 @@ pub fn has_header(text: &str) -> bool {
})
}
/// `value` on one line: a line break (or any control character, or the
/// Unicode line separators JavaScript ends a comment at) would put the rest of
/// the setting into the file as code.
fn one_line(value: &str) -> String {
value.chars().map(|c| if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') { ' ' } else { c }).collect()
}
/// The header's lines, without comment markers.
pub fn header_lines(year: i64, owner: &str, license: &str) -> Vec<String> {
let (owner, license) = (one_line(owner), one_line(license));
let owner = owner.trim();
let mut lines =
vec![if owner.is_empty() { format!("Copyright (c) {year}") } else { format!("Copyright (c) {year} {owner}") }];
@@ -82,7 +90,8 @@ pub fn render(comment: Comment, lines: &[String]) -> String {
out.push_str(open);
out.push('\n');
for l in lines {
out.push_str(&format!(" * {l}\n"));
// `*/` would end the comment early.
out.push_str(&format!(" * {}\n", l.replace("*/", "* /")));
}
out.push_str(close);
out.push('\n');
@@ -170,10 +179,16 @@ fn project_of(path: &str) -> Option<Project> {
host::projects()
.ok()?
.into_iter()
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
.filter(|p| p.root().is_ok_and(|r| is_under(path, &r)))
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
/// Whether `path` is `root` or inside it (`/proj-x/a` is not inside `/proj`).
fn is_under(path: &str, root: &str) -> bool {
path.strip_prefix(root)
.is_some_and(|rest| rest.is_empty() || root.ends_with(['/', '\\']) || rest.starts_with(['/', '\\']))
}
#[derive(Default)]
pub struct LicenseHeader;
@@ -305,6 +320,15 @@ mod tests {
assert_eq!(header_lines(2026, " ", ""), ["Copyright (c) 2026"]);
}
#[test]
fn settings_cannot_add_code_to_the_file() {
let lines = header_lines(2026, "ACME\nimport os\r\u{2028}x", "MIT\nrm -rf");
assert_eq!(lines, ["Copyright (c) 2026 ACME import os x", "SPDX-License-Identifier: MIT rm -rf"]);
assert_eq!(render(comment_for("a.py").unwrap(), &lines).lines().filter(|l| !l.starts_with('#')).count(), 1);
let css = render(comment_for("s.css").unwrap(), &header_lines(2026, "X */ body { }", ""));
assert_eq!(css.matches("*/").count(), 1, "{css}");
}
#[test]
fn keeps_shebang_and_encoding_lines_first() {
assert_eq!(insert_line("fn main() {}\n"), 0);
@@ -324,6 +348,12 @@ mod tests {
assert!(header_edit("a.rs", "// Copyright (c) 2020 X\n", 2026, "", "").is_none());
}
#[test]
fn a_project_contains_only_its_own_paths() {
assert!(is_under("/proj/a.rs", "/proj"));
assert!(!is_under("/proj-x/a.rs", "/proj"));
}
#[test]
fn years() {
assert_eq!(year_of_day(0), 1970);
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
a303522c605ccc9804e67f85326778a380951ce14022a70bc37ba3ae63b76f79
959cb2ab750211bd1cc894a4d804acb2dd16e95c1e65b267e7eccbb64cbdd8da
+211 -51
View File
@@ -9,11 +9,15 @@ use openidea_plugin::text::{offset, position};
use openidea_plugin::{
Error, Invocation, Location, Plugin, Position, Project, Range, TextQuery, host, register_plugin,
};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::time::{Duration, Instant};
/// How long a namespace → XML file lookup is trusted.
const INDEX_TTL: Duration = Duration::from_secs(30);
/// Lookups kept per project before the expired ones are dropped.
const MAX_INDEXED: usize = 1024;
/// Other mappers one file's references are looked up in (each is a search).
const MAX_OTHER_MAPPERS: usize = 32;
/// Files declaring one namespace that are tried (normally one).
const MAX_MAPPERS: u32 = 64;
const STATEMENTS: [&str; 4] = ["select", "insert", "update", "delete"];
@@ -85,27 +89,77 @@ pub struct MapperJava {
pub result_ids: Vec<(usize, Named)>,
}
fn range_of(text: &str, start: usize, end: usize) -> Range {
Range::new(position(text, start), position(text, end))
/// Positions of many byte offsets in one text, asked in about increasing
/// order: each one scans only from the offset asked before it, so a file with
/// many names costs about one pass, not one pass per name.
struct Positions<'a> {
text: &'a str,
at: usize,
pos: Position,
}
fn utf16_len(s: &str) -> u32 {
s.chars().map(|c| c.len_utf16() as u32).sum()
}
impl<'a> Positions<'a> {
fn new(text: &'a str) -> Self {
Positions { text, at: 0, pos: Position::new(0, 0) }
}
fn of(&mut self, at: usize) -> Position {
let at = at.min(self.text.len());
if at >= self.at {
for c in self.text[self.at..at].chars() {
if c == '\n' {
self.pos = Position::new(self.pos.line + 1, 0);
} else {
self.pos.character += c.len_utf16() as u32;
}
}
} else {
let back = &self.text[at..self.at];
match back.matches('\n').count() as u32 {
0 => self.pos.character -= utf16_len(back),
lines => {
let line_start = self.text[..at].rfind('\n').map_or(0, |i| i + 1);
self.pos = Position::new(self.pos.line - lines, utf16_len(&self.text[line_start..at]));
}
}
}
self.at = at;
self.pos
}
fn range(&mut self, start: usize, end: usize) -> Range {
Range::new(self.of(start), self.of(end))
}
}
/// Where a completion that replaces `typed` (just before `at`, on its line) starts.
fn replace_start(at: Position, typed: &str) -> Option<Position> {
if typed.contains('\n') {
return None;
}
Some(Position::new(at.line, at.character.checked_sub(utf16_len(typed))?))
}
pub fn parse_xml(text: &str) -> MapperXml {
let mut out = MapperXml::default();
let attr = |tag_start: usize, name: &str| -> Option<(String, usize, usize)> {
let tag_end = text[tag_start..].find('>').map_or(text.len(), |i| tag_start + i);
let tag = &text[tag_start..tag_end];
let i = tag.find(&format!("{name}=\""))? + name.len() + 2;
let j = tag[i..].find('"')? + i;
Some((tag[i..j].to_owned(), tag_start + i, tag_start + j))
};
let mut positions = Positions::new(text);
let mut at = 0;
while let Some(i) = text[at..].find('<') {
let start = at + i + 1;
at = start;
let name: String = text[start..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
let named = |attr_name: &str| -> Option<Named> {
let (v, s, e) = attr(start, attr_name)?;
Some(Named { name: v, range: range_of(text, s, e) })
// `<` cannot appear inside an attribute value, so a tag without its
// `>` ends at the next `<`: no tag is searched past the next one.
let tag_end = text[start..].find(['<', '>']).map_or(text.len(), |i| start + i);
let tag = &text[start..tag_end];
let mut named = |attr_name: &str| -> Option<Named> {
let i = tag.find(&format!("{attr_name}=\""))? + attr_name.len() + 2;
let j = tag[i..].find('"')? + i;
Some(Named { name: tag[i..j].to_owned(), range: positions.range(start + i, start + j) })
};
if name == "mapper" {
out.namespace = named("namespace");
@@ -163,7 +217,8 @@ fn skip_string(bytes: &[u8], mut i: usize) -> usize {
_ => i += 1,
}
}
i
// A `\` as the last byte steps past the end.
i.min(bytes.len())
}
/// The index just past the `//…` or `/*…*/` comment opening at `bytes[i]`.
@@ -227,7 +282,7 @@ fn scan_declaration(stmt: &str) -> Option<Declaration> {
}
b'(' => {
let before = stmt[..i].trim_end();
let start = before.rfind(|c: char| !ident(c)).map_or(0, |p| p + 1);
let start = before.char_indices().rev().find(|&(_, c)| !ident(c)).map_or(0, |(p, c)| p + c.len_utf8());
if start == before.len() {
return None;
}
@@ -463,11 +518,8 @@ fn annotation_string_arg(args: &str, key: &str) -> Option<(String, usize, usize)
depth = depth.saturating_sub(1);
i += 1;
}
_ if depth == 0 && ident(bytes[i] as char) => {
let mut j = i + 1;
while j < bytes.len() && ident(bytes[j] as char) {
j += 1;
}
_ if depth == 0 && args.is_char_boundary(i) && args[i..].chars().next().is_some_and(ident) => {
let j = args[i..].char_indices().find(|&(_, c)| !ident(c)).map_or(args.len(), |(n, _)| i + n);
if &args[i..j] == key {
let mut k = j;
while k < bytes.len() && bytes[k].is_ascii_whitespace() {
@@ -548,8 +600,9 @@ pub fn parse_java(text: &str) -> MapperJava {
Some(p) => format!("{p}.{simple}"),
None => simple.clone(),
});
let mut positions = Positions::new(text);
let decl =
Some(Named { name: simple.clone(), range: range_of(text, i + 10 + indent, i + 10 + indent + simple.len()) });
Some(Named { name: simple.clone(), range: positions.range(i + 10 + indent, i + 10 + indent + simple.len()) });
let body_start = text[i..].find('{').map_or(text.len(), |b| i + b + 1);
let mut methods = Vec::new();
let mut annotation_sql = Vec::new();
@@ -599,22 +652,21 @@ pub fn parse_java(text: &str) -> MapperJava {
{
let (s, e) = (stmt_start + decl.name.0, stmt_start + decl.name.1);
let list_base = stmt_start + decl.parens.0 + 1;
let method_name =
Named { name: stmt[decl.name.0..decl.name.1].to_owned(), range: positions.range(s, e) };
let params = params_of(&stmt[decl.parens.0 + 1..decl.parens.1])
.into_iter()
.map(|(name, ps, pe)| Param { name, range: range_of(text, list_base + ps, list_base + pe) })
.map(|(name, ps, pe)| Param { name, range: positions.range(list_base + ps, list_base + pe) })
.collect();
let method = methods.len();
methods.push((
Named { name: stmt[decl.name.0..decl.name.1].to_owned(), range: range_of(text, s, e) },
params,
));
methods.push((method_name, params));
let mut strings = Vec::new();
let mut sql_params = Vec::new();
for (ss, se, ps) in annotation_sql_of(&stmt[..decl.parens.0]) {
strings.push(range_of(text, stmt_start + ss, stmt_start + se));
strings.push(positions.range(stmt_start + ss, stmt_start + se));
sql_params.extend(ps.into_iter().map(|(n, ms, me)| Named {
name: n,
range: range_of(text, stmt_start + ms, stmt_start + me),
range: positions.range(stmt_start + ms, stmt_start + me),
}));
}
if !strings.is_empty() {
@@ -623,10 +675,10 @@ pub fn parse_java(text: &str) -> MapperJava {
let (refs, defs) = result_map_of(&stmt[..decl.parens.0]);
result_refs.extend(refs.into_iter().map(|(v, rs, re)| ResultMapRef {
method,
name: Named { name: v, range: range_of(text, stmt_start + rs, stmt_start + re) },
name: Named { name: v, range: positions.range(stmt_start + rs, stmt_start + re) },
}));
result_ids.extend(defs.into_iter().map(|(v, ds, de)| {
(method, Named { name: v, range: range_of(text, stmt_start + ds, stmt_start + de) })
(method, Named { name: v, range: positions.range(stmt_start + ds, stmt_start + de) })
}));
}
stmt_start = k + 1;
@@ -641,8 +693,13 @@ pub fn parse_java(text: &str) -> MapperJava {
/// `namespace="com.acme.UserMapper"` in an XML file, either quote.
fn namespace_query(namespace: &str) -> TextQuery {
let escaped: String =
namespace.chars().flat_map(|c| matches!(c, '.' | '$').then_some('\\').into_iter().chain([c])).collect();
let mut escaped = String::with_capacity(namespace.len());
for c in namespace.chars() {
if r"\.+*?()|[]{}^$#&-~".contains(c) {
escaped.push('\\');
}
escaped.push(c);
}
TextQuery {
pattern: format!(r#"namespace\s*=\s*["']{escaped}["']"#),
regex: true,
@@ -656,14 +713,22 @@ fn project_of(path: &str) -> Option<Project> {
host::projects()
.ok()?
.into_iter()
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
.filter(|p| p.root().is_ok_and(|r| is_under(path, &r)))
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
/// Whether `path` is `root` or inside it (`/proj-x/a` is not inside `/proj`).
fn is_under(path: &str, root: &str) -> bool {
path.strip_prefix(root)
.is_some_and(|rest| rest.is_empty() || root.ends_with(['/', '\\']) || rest.starts_with(['/', '\\']))
}
#[derive(Default)]
pub struct Navigator {
/// project root → namespace → (when it was looked up, the XML mapping it, if any).
xml_index: BTreeMap<String, BTreeMap<String, (Instant, Option<String>)>>,
/// Other mappers looked up for the file being linked (see [`MAX_OTHER_MAPPERS`]).
other_mappers: BTreeSet<String>,
}
impl Navigator {
@@ -687,15 +752,20 @@ impl Navigator {
let mut paths: Vec<&str> = found.matches.iter().map(|m| m.location.path.as_str()).collect();
paths.dedup();
let hit = paths.into_iter().find_map(|path| Some((path.to_owned(), read(path)?)));
self.xml_index
.entry(root)
.or_default()
.insert(namespace.to_owned(), (Instant::now(), hit.as_ref().map(|(path, _)| path.clone())));
let index = self.xml_index.entry(root).or_default();
if index.len() >= MAX_INDEXED {
index.retain(|_, (at, _)| at.elapsed() < INDEX_TTL);
}
index.insert(namespace.to_owned(), (Instant::now(), hit.as_ref().map(|(path, _)| path.clone())));
hit
}
/// The interface `namespace` names (`com.acme.UserMapper` → `**/com/acme/UserMapper.java`).
fn java_for(project: &Project, namespace: &str) -> Option<(String, MapperJava)> {
// A Java name only: anything else would be read as a glob.
if namespace.is_empty() || !namespace.chars().all(|c| ident(c) || c == '.') {
return None;
}
let rel = format!("**/{}.java", namespace.replace('.', "/"));
let path = project.find_files(&rel, 1).ok()?.into_iter().next()?;
let java = parse_java(&project.read_file(&path).ok()?);
@@ -722,6 +792,14 @@ impl Navigator {
if ns == here_ns {
return pick(xml, doc.path.clone());
}
// Each other mapper is a project search: a file naming thousands of them
// resolves the first few rather than running out of time.
if !self.other_mappers.contains(ns) {
if self.other_mappers.len() >= MAX_OTHER_MAPPERS {
return None;
}
self.other_mappers.insert(ns.to_owned());
}
let (path, other) = self.xml_for(project, ns)?;
pick(&other, path)
}
@@ -751,7 +829,7 @@ impl Navigator {
}
typed
};
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
let start = replace_start(at, typed)?;
let mut ids: Vec<String> = java.result_ids.iter().map(|(_, d)| d.name.clone()).collect();
if let Some(project) = project_of(&doc.path)
&& let Some(name) = &java.name
@@ -796,6 +874,7 @@ impl Navigator {
}));
}
// resultMap / sql references resolve even when the interface is missing.
self.other_mappers.clear();
for r in xml.result_map_refs.iter().chain(xml.extends_refs.iter()) {
if let Some(target) = self.anchor_target(&project, doc, &xml, &ns.name, &r.name, false) {
out.push((r.clone(), target, format!("resultMap {}", r.name)));
@@ -914,7 +993,7 @@ impl Providers for Navigator {
&& STATEMENTS.iter().any(|t| line.contains(&format!("<{t}")))
{
let typed = &line[i + 4..];
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
let Some(start) = replace_start(at, typed) else { return Vec::new() };
return java
.methods
.iter()
@@ -932,7 +1011,7 @@ impl Providers for Navigator {
&& line.contains("<include")
{
let typed = &line[i + 7..];
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
let Some(start) = replace_start(at, typed) else { return Vec::new() };
return xml
.sql_fragments
.iter()
@@ -949,12 +1028,12 @@ impl Providers for Navigator {
&& !before[i..].contains('}')
{
let typed = &before[i + 2..];
let statement =
xml.statements.iter().rev().find(|s| offset(&doc.text, s.range.start).is_some_and(|o| o < cursor));
let here = position(&doc.text, cursor);
let statement = xml.statements.iter().rev().find(|s| s.range.start < here);
let Some((_, params)) = statement.and_then(|s| java.methods.iter().find(|(m, _)| m.name == s.name)) else {
return Vec::new();
};
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
let Some(start) = replace_start(at, typed) else { return Vec::new() };
return params
.iter()
.filter(|p| p.name.starts_with(typed))
@@ -971,16 +1050,12 @@ impl Providers for Navigator {
/// After `#{` / `${` inside an annotation SQL string: the method's parameters.
fn annotation_param_completion(doc: &Document, at: Position) -> Vec<CompletionItem> {
let Some(cursor) = offset(&doc.text, at) else { return Vec::new() };
let here = position(&doc.text, cursor);
let java = parse_java(&doc.text);
for sql in &java.annotation_sql {
let (_, params) = &java.methods[sql.method];
for s in &sql.strings {
let (Some(start), Some(end)) = (offset(&doc.text, s.start), offset(&doc.text, s.end)) else {
continue;
};
if !(start <= cursor && cursor <= end) {
continue;
}
for s in sql.strings.iter().filter(|s| s.start <= here && here <= s.end) {
let Some(start) = offset(&doc.text, s.start) else { continue };
let seg = &doc.text[start..cursor];
let mut token: Option<usize> = None;
for t in ["#{", "${"] {
@@ -993,7 +1068,7 @@ fn annotation_param_completion(doc: &Document, at: Position) -> Vec<CompletionIt
if typed.contains('}') || !typed.chars().all(|c| ident(c) || c == '.') {
continue;
}
let start = Position::new(at.line, at.character - typed.encode_utf16().count() as u32);
let Some(start) = replace_start(at, typed) else { continue };
return params
.iter()
.filter(|p| p.name.starts_with(typed))
@@ -1477,4 +1552,89 @@ mod tests {
let items = n.completion(&closed, Position::new(7, col as u32), None);
assert_eq!(items.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(), ["userMap"]);
}
#[test]
fn positions_agree_with_the_sdk_in_any_order() {
let text = "a\n中文 😀 x\r\n\n é\nlast";
let mut positions = Positions::new(text);
let boundaries: Vec<usize> = (0..=text.len()).filter(|&i| text.is_char_boundary(i)).collect();
let order = boundaries.iter().chain(boundaries.iter().rev()).chain(boundaries.iter().step_by(3).rev());
for &at in order.chain([0, text.len(), 5, 1, text.len()].iter()) {
assert_eq!(positions.of(at), position(text, at), "at {at}");
}
}
/// Text a mapper may hold (or be crafted to hold) must not crash the plugin.
#[test]
fn odd_java_does_not_panic() {
// A no-break space before the name: the char before it is 2 bytes.
let java = parse_java("package a;\ninterface M {\n User\u{00A0}findById(long id);\n}\n");
assert_eq!(java.methods.iter().map(|(m, _)| m.name.as_str()).collect::<Vec<_>>(), ["findById"]);
// A non-ASCII identifier among `@Results` arguments.
let java = parse_java("interface M { @Results(é) User f(); @Results(id = 常量.ID, x = \"y\") User g(); }");
assert_eq!(java.methods.len(), 2);
// A string the annotation scanner reads as a char literal, ending in `\`.
for text in [
"package a;\ninterface M {\n @Select('\"' \\) void f();\n}",
"interface M { @Select(/*\"*/ \\) void f(); }",
] {
parse_java(text);
}
assert_eq!(skip_string(b"\"a\\", 0), 3);
}
#[test]
fn a_tag_without_its_end_stops_at_the_next() {
let xml = parse_xml("<mapper namespace=\"a.B\"\n<select id=\"x\"\n<sql id=\"y\"/>");
assert_eq!(xml.namespace.map(|n| n.name).as_deref(), Some("a.B"));
assert_eq!(xml.statements.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["x"]);
assert_eq!(xml.sql_fragments[0].range, Range::new(Position::new(2, 9), Position::new(2, 10)));
// Many unclosed tags parse in one pass, not one pass per tag.
let many = "<select id=\"a\" ".repeat(50_000);
assert_eq!(parse_xml(&many).statements.len(), 50_000);
}
#[test]
fn completion_before_an_unclosed_parameter_on_an_earlier_line() {
let _host = setup();
let text = "<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\">\n where id = #{i\nx";
let xml = doc("/project/src/main/resources/mapper/UserMapper.xml", "xml", text);
assert!(Navigator::default().completion(&xml, Position::new(3, 0), None).is_empty());
}
#[test]
fn namespaces_are_searched_and_globbed_literally() {
let pattern = namespace_query("a|b.x[0]").pattern;
assert!(pattern.contains(r"a\|b\.x\[0\]"), "{pattern}");
let _host = setup();
let project = host::projects().unwrap().remove(0);
assert!(Navigator::java_for(&project, "com.acme.UserMapper").is_some());
assert!(Navigator::java_for(&project, "com.*.UserMapper").is_none());
assert!(Navigator::java_for(&project, "").is_none());
}
#[test]
fn a_project_contains_only_its_own_paths() {
assert!(is_under("/proj/a.xml", "/proj"));
assert!(is_under("/proj/a.xml", "/proj/"));
assert!(is_under("C:\\proj\\a.xml", "C:\\proj"));
assert!(is_under("/proj", "/proj"));
assert!(!is_under("/proj-x/a.xml", "/proj"));
}
#[test]
fn a_file_naming_many_mappers_looks_up_a_few() {
let host = MockHost::install();
let searches = std::rc::Rc::new(std::cell::Cell::new(0));
let count = searches.clone();
host.on_search(move |_, _| {
count.set(count.get() + 1);
Ok(TextSearch { matches: Vec::new(), limit_hit: false })
});
let refs: String = (0..200).map(|n| format!(" <include refid=\"ns{n}.x\"/>\n")).collect();
let text = format!("<mapper namespace=\"a.Self\">\n{refs}</mapper>\n");
let xml = doc("/project/src/m.xml", "xml", &text);
assert!(Navigator::default().line_markers(&xml).is_empty());
assert_eq!(searches.get(), MAX_OTHER_MAPPERS);
}
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b7b4e6be26ff08cc0e7fa791cb956acd9afa09905720d2c0c93acdae5638a35e
d5ec377869ded4ec0bf533d07d51610aec7a6813d43ead98a261b3f54e7d0655
+24 -1
View File
@@ -94,7 +94,23 @@ pub fn evaluate(run: &Run) -> Value {
}
}
let replaced = run.replace.as_ref().map(|r| {
let mut out = re.replace_all(&run.text, r.as_str()).into_owned();
// Built match by match and cut off at the limit: `replace_all` would
// first build all of it (an empty match at every character, each
// replaced with a long text, runs out of memory).
let mut out = String::new();
let mut last = 0;
for caps in re.captures_iter(&run.text) {
let m = caps.get(0).expect("group 0");
out.push_str(&run.text[last..m.start()]);
caps.expand(r, &mut out);
last = m.end();
if out.len() > MAX_REPLACED {
break;
}
}
if out.len() <= MAX_REPLACED {
out.push_str(&run.text[last..]);
}
if out.len() > MAX_REPLACED {
let mut cut = MAX_REPLACED;
while !out.is_char_boundary(cut) {
@@ -241,6 +257,13 @@ mod tests {
assert!(bad["error"].as_str().unwrap().contains("unclosed"), "{bad}");
assert_eq!(bad["count"], 0);
assert_eq!(run("", "", "x", None)["count"], 0);
let long = run("(?:)", "", &"a".repeat(100_000), Some(&"b".repeat(10_000)));
let replaced = long["replaced"].as_str().unwrap();
assert!(replaced.len() <= MAX_REPLACED + '…'.len_utf8() && replaced.ends_with('…'));
for (pattern, text, with) in [("a*", "baac", "[$0]"), (r"(\w)(\d)", "a1 b2 c", "$2$1"), ("x", "y", "z")] {
let expected = regex::Regex::new(pattern).unwrap().replace_all(text, with).into_owned();
assert_eq!(run(pattern, "", text, Some(with))["replaced"], expected);
}
}
#[test]
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
4f585c3a1a3ae01a89811e05d27fc39121dd8e74210c1281d04ff31556d393b5
ebbd24e08c19953895464567cbaf1335b9847e98e8a8da8a4e0b8c2886c1abd6
+24 -4
View File
@@ -347,6 +347,8 @@ fn highlights(text: &str) -> Vec<Token> {
/// An open `{`: the symbol whose body it is, and what may be declared inside.
struct Frame {
symbol: Option<usize>,
/// The innermost symbol of this frame or the ones around it.
parent: Option<usize>,
body: Body,
}
@@ -366,7 +368,7 @@ fn outline(text: &str) -> Vec<Symbol> {
let code: Vec<Lexeme> = lex(text).into_iter().filter(|l| !l.is_trivia()).collect();
let word = |i: usize| code.get(i).map_or("", |l| l.text(text));
let mut symbols: Vec<Symbol> = Vec::new();
let mut frames = vec![Frame { symbol: None, body: Body::File }];
let mut frames = vec![Frame { symbol: None, parent: None, body: Body::File }];
// A declaration waiting for its body (`{`) or its end (`;`).
let mut pending: Option<(usize, Body)> = None;
let mut stmt_start = 0usize;
@@ -375,7 +377,9 @@ fn outline(text: &str) -> Vec<Symbol> {
let l = code[i];
let w = l.text(text);
let body = frames.last().map_or(Body::File, |f| f.body);
let parent = frames.iter().rev().find_map(|f| f.symbol);
// From the top frame alone: walking every open `{` per token is
// quadratic in deeply nested code.
let parent = frames.last().and_then(|f| f.parent);
let add = |symbols: &mut Vec<Symbol>, name_at: usize, kind: SymbolKind, detail: Option<&str>| {
let name = code[name_at];
symbols.push(Symbol {
@@ -395,7 +399,7 @@ fn outline(text: &str) -> Vec<Symbol> {
Some((s, b)) => (Some(s), b),
None => (None, if body == Body::Code || body == Body::Contract { Body::Code } else { body }),
};
frames.push(Frame { symbol, body: inner });
frames.push(Frame { symbol, parent: symbol.or(parent), body: inner });
stmt_start = i + 1;
}
(Kind::Punct, "}") => {
@@ -512,7 +516,8 @@ fn outline(text: &str) -> Vec<Symbol> {
fn folding(text: &str) -> Vec<Fold> {
let lexemes = lex(text);
let line_of = |offset: usize| text[..offset].bytes().filter(|&b| b == b'\n').count() as u32;
let newlines: Vec<usize> = text.bytes().enumerate().filter(|&(_, b)| b == b'\n').map(|(i, _)| i).collect();
let line_of = |offset: usize| newlines.partition_point(|&n| n < offset) as u32;
let skip: Vec<Range<usize>> = lexemes
.iter()
.filter(|l| matches!(l.kind, Kind::Str | Kind::Comment | Kind::DocComment))
@@ -686,4 +691,19 @@ function free(uint x) pure returns (uint) { return x; }
let _ = folding(text);
}
}
/// Deep nesting and many comments stay about linear (both used to be quadratic).
#[test]
fn large_inputs_stay_linear() {
let deep = format!("contract C {{ function f() public {{ {} }} }}", "{".repeat(200_000));
let symbols = outline(&deep);
assert_eq!(
symbols.iter().map(|s| (s.name.as_str(), s.parent)).collect::<Vec<_>>(),
[("C", None), ("f", Some(0))]
);
let comments = "/* a\n */\n".repeat(100_000);
let folds = folding(&comments);
assert_eq!(folds.len(), 100_000);
assert_eq!((folds[99_999].start_line, folds[99_999].end_line), (199_998, 199_999));
}
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
81637f9a265e6bcb9839b1019b0d58b43f80535fb1fca044716904aef0c12724
997e2ac7520a561f22d12ad7013ed576262424ffa8acd69fab219f68d00c92f1
+7 -2
View File
@@ -230,7 +230,8 @@ fn step_numbers(s: &str, by: i64) -> String {
out.pop(); // the minus sign
}
let width = digits.len();
let text = if next < 0 { format!("-{:0width$}", -next) } else { format!("{next:0width$}") };
let text =
if next < 0 { format!("-{:0width$}", next.unsigned_abs()) } else { format!("{next:0width$}") };
out.push_str(&text);
}
Err(_) => out.push_str(&digits),
@@ -338,7 +339,8 @@ fn html_unescape(s: &str) -> String {
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
rest = &rest[i..];
let Some(end) = rest[..rest.len().min(12)].find(';') else {
// `;` is ASCII, so where it is found is a char boundary.
let Some(end) = rest.bytes().take(12).position(|b| b == b';') else {
out.push('&');
rest = &rest[1..];
continue;
@@ -416,6 +418,7 @@ mod tests {
assert_eq!(base64_encode(b"Man"), "TWFu");
assert_eq!(url_encode("a b&c"), "a%20b%26c");
assert_eq!(html_unescape("&lt;a&gt; &#x41;&#66; &bogus; &"), "<a> AB &bogus; &");
assert_eq!(html_unescape("R&D部门的预算 &aaaaaaaaaa€"), "R&D部门的预算 &aaaaaaaaaa€");
assert_eq!(json_escape("a\"b\n"), "a\\\"b\\n");
}
@@ -426,6 +429,8 @@ mod tests {
assert_eq!(t("unique-lines", "a\nb\na"), "a\nb");
assert_eq!(t("increment", "v1.09 and -1"), "v2.10 and 0");
assert_eq!(t("decrement", "x0"), "x-1");
assert_eq!(t("decrement", "-9223372036854775807"), "-9223372036854775808");
assert_eq!(t("decrement", "-9223372036854775808"), "-9223372036854775808");
}
#[test]
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -6,7 +6,7 @@ use ide_core::{IdeResult, RequestId};
pub use ide_host::ops::tools::*;
use ide_sdk::Sdk;
use ide_tools::jdk::{JdkReleases, JdkVendor};
use ide_tools::{InstallEvent, ToolInfo};
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
View File
@@ -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,
+7 -4
View File
@@ -95,9 +95,12 @@ import { readFileLines, withDocument } from "@/editor/lsp/files";
import { pathToCopy } from "@/platform/copyPath";
import { shortcutLabel } from "@/platform/keymap";
/** Text-editing actions: the editor has focus, or a popup (Find Action) runs them on it. */
export const TEXT_WHEN = "editorTextFocus || textEditorActive && popupOpen";
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
/**
* Text-editing actions: the editor has focus, or a popup (Find Action) runs them on it. Not a
* tool window's speed search: it opens a layer too, but the keys belong to its tree (⌘⌫).
*/
export const TEXT_WHEN = "editorTextFocus || textEditorActive && popupOpen && !toolWindowFocus";
const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
const TABS_WHEN = "editorTabsOpen && !toolWindowFocus";
function activeSlot(): EditorSlot | null {
@@ -666,7 +669,7 @@ export function registerEditorActions(): () => void {
id,
title,
category: "Edit",
when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen`,
when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen && !toolWindowFocus`,
run: ({ arg, ctx }) => {
const view = typeof arg === "string" || ctx.findBarFocus ? findPanelTarget(arg)?.view() : null;
if (view) {
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { braceBlock, braceNextTo } from "./bracketBlockSelect";
const DOC = "int f(Object raw) {\n if (a) {\n g(b[0]);\n }\n}";
const state = EditorState.create({ doc: DOC });
describe("double-clicking a curly brace", () => {
it("selects the block from the opening brace to its match, both included (IDEA)", () => {
const open = DOC.indexOf("{");
expect(braceBlock(state, open)).toEqual({ from: open, to: DOC.length });
});
it("selects the same block from the closing brace", () => {
const inner = DOC.indexOf("{", DOC.indexOf("if"));
const close = DOC.indexOf("}");
expect(braceBlock(state, close)).toEqual({ from: inner, to: close + 1 });
});
it("takes the brace next to the caret the click gives, past the end of the line too", () => {
const open = DOC.indexOf("{");
const lineEnd = state.doc.line(1).to;
expect(braceNextTo(state, lineEnd, false)).toBe(open);
expect(braceNextTo(state, open, false)).toBe(open);
const close = DOC.lastIndexOf("}");
expect(braceNextTo(state, close, false)).toBe(close);
// On a word, or away from braces, the double-click selects the word.
expect(braceNextTo(state, open, true)).toBeNull();
expect(braceNextTo(state, DOC.indexOf("raw"), false)).toBeNull();
});
it("leaves parentheses, brackets, other characters and unmatched braces to the word selection", () => {
expect(braceBlock(state, DOC.indexOf("("))).toBeNull();
expect(braceBlock(state, DOC.indexOf("["))).toBeNull();
expect(braceBlock(state, DOC.indexOf("raw"))).toBeNull();
expect(braceBlock(EditorState.create({ doc: "f {" }), 2)).toBeNull();
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* A double-click that puts the caret next to a curly brace selects the block it opens or
* closes, both braces included, as IDEA does: past the end of a line ending in `{` too.
* A double-click on a word still selects the word.
*/
import { matchBrackets } from "@codemirror/language";
import { EditorSelection, Prec, type EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
/** The range from the brace at `at` to its match, both included, or null when it has none. */
export function braceBlock(state: EditorState, at: number): { from: number; to: number } | null {
const ch = state.sliceDoc(at, at + 1);
if (ch !== "{" && ch !== "}") return null;
const match = ch === "{" ? matchBrackets(state, at, 1) : matchBrackets(state, at + 1, -1);
if (!match?.matched || !match.end) return null;
return { from: Math.min(match.start.from, match.end.from), to: Math.max(match.start.to, match.end.to) };
}
const isBrace = (ch: string) => ch === "{" || ch === "}";
/**
* The brace next to the caret position `pos` a click at `x` gives, unless the click is on a
* word character (that double-click selects the word).
*/
export function braceNextTo(state: EditorState, pos: number, onWord: boolean): number | null {
if (onWord) return null;
if (pos > 0 && isBrace(state.sliceDoc(pos - 1, pos))) return pos - 1;
if (isBrace(state.sliceDoc(pos, pos + 1))) return pos;
return null;
}
/** Whether the click at `x` is on a word character around `pos`. */
function clickOnWord(view: EditorView, pos: number, x: number): boolean {
for (const at of [pos - 1, pos]) {
if (at < 0 || at >= view.state.doc.length) continue;
const box = view.coordsForChar(at);
if (box && x >= box.left && x <= box.right) return /[\p{L}\p{N}_$]/u.test(view.state.sliceDoc(at, at + 1));
}
return false;
}
export const bracketBlockSelect = Prec.high(
EditorView.mouseSelectionStyle.of((view, event) => {
if (event.detail !== 2 || event.button !== 0 || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
return null;
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
const at = pos == null ? null : braceNextTo(view.state, pos, clickOnWord(view, pos, event.clientX));
const block = at == null ? null : braceBlock(view.state, at);
if (!block) return null;
const selection = EditorSelection.single(block.from, block.to);
return { get: () => selection, update: () => false };
}),
);
+58 -10
View File
@@ -5,7 +5,7 @@ import { indentUnit } from "@codemirror/language";
import { codeLenses } from "@/editor/api";
import type { EditorSettings } from "@/bindings";
import { codeLensExtension } from "./codeLens";
import { guideVars, indentGuides, rainbowIndentGuides } from "./indentGuides";
import { braceGuide, caretGuide, guideVars, indentGuides, rainbowIndentGuides } from "./indentGuides";
import { heavyExtension } from "./setup";
const SOURCE = [
@@ -46,23 +46,23 @@ describe("indent guides", () => {
expect(guideVars(1, 4)).toBeNull();
expect(guideVars(3, 4)).toEqual({
images: "var(--openidea-guide), var(--openidea-guide)",
positions: "calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
positions: "calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
});
});
it("positions each guide the same way on lines of different depth", () => {
const v = mount(SOURCE);
expect(line(v, 1).classList.contains("cm-openidea-guides")).toBe(false);
expect(line(v, 2).style.getPropertyValue("--openidea-guide-positions")).toBe("calc(2.5px + 4ch) 0");
expect(line(v, 2).style.getPropertyValue("--openidea-guide-positions")).toBe("calc(0.5px + 4ch) 0");
expect(line(v, 3).style.getPropertyValue("--openidea-guide-positions")).toBe(
"calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
"calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
);
});
it("keeps guides through blank lines inside a block", () => {
const v = mount(SOURCE);
expect(line(v, 4).style.getPropertyValue("--openidea-guide-positions")).toBe(
"calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
"calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
);
});
@@ -77,18 +77,66 @@ describe("indent guides", () => {
]);
const [implLens, fnLens] = [...v.contentDOM.querySelectorAll<HTMLElement>(".cm-openidea-lens")];
expect(v.contentDOM.classList.contains("cm-openidea-guidesOn")).toBe(true);
expect(fnLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("calc(2.5px + 4ch) 0");
expect(fnLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("calc(0.5px + 4ch) 0");
expect(implLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("");
});
});
describe("the caret's indent guide", () => {
const at = (lineNo: number, col: number) => {
const state = EditorState.create({ doc: SOURCE });
return state.update({ selection: { anchor: state.doc.line(lineNo).from + col } }).state;
};
it("is the guide in the caret's column whose block holds the caret line (IDEA)", () => {
// `for` opens the guide at column 4, drawn on lines 3-7 (`if` … its `}`).
expect(caretGuide(at(2, 4), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(caretGuide(at(4, 4), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(caretGuide(at(4, 8), 4)).toEqual({ level: 2, from: 4, to: 6 });
});
it("is none in column 0, between indent units, in text, or on the closing line", () => {
expect(caretGuide(at(4, 0), 4)).toBeNull();
expect(caretGuide(at(4, 6), 4)).toBeNull();
expect(caretGuide(at(2, 12), 4)).toBeNull();
expect(caretGuide(at(8, 4), 4)).toBeNull();
});
it("is the matched brace colour's guide while the caret is at a brace of a multi-line block", () => {
const state = (pos: number) => EditorState.create({ doc: SOURCE, selection: { anchor: pos } });
const forOpen = SOURCE.indexOf("{", SOURCE.indexOf("for"));
// `for x in y {`: its guide is at column 4, on lines 3-7.
expect(braceGuide(state(forOpen + 1), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(braceGuide(state(forOpen), 4)).toEqual({ level: 1, from: 3, to: 7 });
const forClose = SOURCE.lastIndexOf(" }") + 4;
expect(braceGuide(state(forClose + 1), 4)).toEqual({ level: 1, from: 3, to: 7 });
expect(braceGuide(state(SOURCE.indexOf("push")), 4)).toBeNull();
expect(braceGuide(EditorState.create({ doc: "a {\n b { c }\n}", selection: { anchor: 9 } }), 4)).toBeNull();
});
it("draws the brace guide over the caret's", () => {
const v = mount(SOURCE);
v.dispatch({ selection: { anchor: SOURCE.indexOf("{", SOURCE.indexOf("for")) + 1 } });
expect(images(line(v, 3))).toBe("var(--openidea-guide-brace), var(--openidea-guide)");
});
it("draws that guide on its lines in the selected colour", () => {
const v = mount(SOURCE);
v.dispatch({ selection: { anchor: v.state.doc.line(4).from + 8 } });
expect(images(line(v, 3))).toBe("var(--openidea-guide), var(--openidea-guide-active)");
expect(images(line(v, 6))).toBe("var(--openidea-guide)");
v.dispatch({ selection: { anchor: v.state.doc.line(4).from + 12 } });
expect(images(line(v, 3))).toBe("var(--openidea-guide), var(--openidea-guide)");
});
});
describe("rainbow indent guides", () => {
it("gives each depth its own image, cycling through the five colours", () => {
expect(guideVars(0, 4, true)).toBeNull();
expect(guideVars(1, 4, true)).toBeNull();
expect(guideVars(3, 4, true)).toEqual({
images: "var(--openidea-guide-1), var(--openidea-guide-2)",
positions: "calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0",
positions: "calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0",
});
const deep = guideVars(8, 2, true)!;
expect(deep.images.split(", ")).toEqual([1, 2, 3, 4, 5, 1, 2].map((n) => `var(--openidea-guide-${n})`));
@@ -108,7 +156,7 @@ describe("rainbow indent guides", () => {
expect(images(line(v, 2))).toBe("var(--openidea-guide-1)");
expect(images(line(v, 3))).toBe("var(--openidea-guide-1), var(--openidea-guide-2)");
expect(images(line(v, 4))).toBe("var(--openidea-guide-1), var(--openidea-guide-2)");
expect(positions(line(v, 4))).toBe("calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0");
expect(positions(line(v, 4))).toBe("calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0");
expect(images(line(v, 6))).toBe("var(--openidea-guide-1)");
});
@@ -122,7 +170,7 @@ describe("rainbow indent guides", () => {
spaces.destroy();
const tabs = mount(body("\t"), [EditorState.tabSize.of(4)], rainbowIndentGuides, "\t");
expect(images(deepest(tabs))).toBe(expected);
expect(positions(deepest(tabs)).split(", ")[1]).toBe("calc(2.5px + 8ch) 0");
expect(positions(deepest(tabs)).split(", ")[1]).toBe("calc(0.5px + 8ch) 0");
});
it("does not count a partial indent unit as a level", () => {
@@ -163,7 +211,7 @@ describe("rainbow indent guides", () => {
});
const lens = () => view!.contentDOM.querySelector<HTMLElement>(".cm-openidea-lens")!;
expect(images(lens())).toBe("var(--openidea-guide-1), var(--openidea-guide-2)");
expect(positions(lens())).toBe("calc(2.5px + 4ch) 0, calc(2.5px + 8ch) 0");
expect(positions(lens())).toBe("calc(0.5px + 4ch) 0, calc(0.5px + 8ch) 0");
view.dispatch({ effects: guides.reconfigure(indentGuides) });
expect(images(lens())).toBe("var(--openidea-guide), var(--openidea-guide)");
});
+99 -12
View File
@@ -6,13 +6,17 @@
* so a guide stays straight across lines of different depth. Blank lines take
* the smaller level of their neighbours and code lens rows the level of the
* line they annotate (`guideVars`), so guides stay continuous. With rainbow
* guides each depth gets its own image, coloured from `--syn-rainbow-*`.
* guides each depth gets its own image, coloured from `--syn-rainbow-*`. The
* guide the caret is on takes the selected colour (`caretGuide`).
*/
import { Facet, RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
import { countColumn, Facet, RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { getIndentUnit } from "@codemirror/language";
import { getIndentUnit, matchBrackets } from "@codemirror/language";
import { LINE_INSET } from "./lineInset";
/** How far (px) a guide sits left of its column, so a caret in that column is right of it (IDEA's indent guide shift). */
const GUIDE_SHIFT = 2;
/** Number of `--openidea-guide-<n>` rainbow colours; deeper guides repeat them. */
export const GUIDE_COLORS = 5;
@@ -22,26 +26,41 @@ export const rainbowGuides = Facet.define<boolean, boolean>({ combine: (values)
/**
* Values of `--openidea-guide-images` / `--openidea-guide-positions` for an
* indentation `levels` deep (in units of `step` columns), or null when it has
* no guides. `.cm-openidea-guides` lines and code lens rows turn them into
* backgrounds (theme.ts).
* no guides; the guide at level `active` takes the selected colour, the one at
* level `brace` the matched brace one. `.cm-openidea-guides` lines and code lens
* rows turn them into backgrounds (theme.ts).
*/
export function guideVars(levels: number, step: number, rainbow = false): { images: string; positions: string } | null {
export function guideVars(
levels: number,
step: number,
rainbow = false,
active = 0,
brace = 0,
): { images: string; positions: string } | null {
if (levels < 2) return null;
const images: string[] = [];
const positions: string[] = [];
for (let level = 1; level < levels; level++) {
images.push(rainbow ? `var(--openidea-guide-${((level - 1) % GUIDE_COLORS) + 1})` : "var(--openidea-guide)");
positions.push(`calc(${LINE_INSET}px + ${level * step}ch) 0`);
images.push(
level === brace
? "var(--openidea-guide-brace)"
: level === active
? "var(--openidea-guide-active)"
: rainbow
? `var(--openidea-guide-${((level - 1) % GUIDE_COLORS) + 1})`
: "var(--openidea-guide)",
);
positions.push(`calc(${LINE_INSET - GUIDE_SHIFT}px + ${level * step}ch) 0`);
}
return { images: images.join(", "), positions: positions.join(", ") };
}
const cache = new Map<string, Decoration | null>();
function guideDeco(levels: number, step: number, rainbow: boolean) {
const key = `${levels}:${step}:${rainbow ? 1 : 0}`;
function guideDeco(levels: number, step: number, rainbow: boolean, active: number, brace: number) {
const key = `${levels}:${step}:${rainbow ? 1 : 0}:${active}:${brace}`;
let d = cache.get(key);
if (d === undefined) {
const vars = guideVars(levels, step, rainbow);
const vars = guideVars(levels, step, rainbow, active, brace);
const style = vars && `--openidea-guide-images: ${vars.images}; --openidea-guide-positions: ${vars.positions}`;
d = style ? Decoration.line({ attributes: { class: "cm-openidea-guides", style } }) : null;
cache.set(key, d);
@@ -71,10 +90,77 @@ function levelOf(state: EditorState, lineNo: number, step: number, dir: 1 | -1):
return 0;
}
const BLOCK_SCAN = 10_000;
/** A guide: its level (in indent units) and the lines (`from`..`to`) it is drawn on. */
export interface Guide {
level: number;
from: number;
to: number;
}
/** The guide line `start` opens at column `col` (its indentation): down to the last deeper line. */
function blockGuide(state: EditorState, start: number, col: number, step: number): Guide | null {
if (col === 0 || col % step) return null;
let end = start;
for (let n = start + 1; n <= state.doc.lines && n - start < BLOCK_SCAN; n++) {
const cols = indentColumns(state.doc.line(n).text, state.tabSize);
if (cols === null) continue;
if (cols <= col) break;
end = n;
}
return end === start ? null : { level: col / step, from: start + 1, to: end };
}
/**
* The guide the caret is on, as IDEA's `IndentsModel.getCaretIndentGuide`: the caret's
* column is the guide's and its line lies in the guide's block, from the line opening it
* to the last deeper one.
*/
export function caretGuide(state: EditorState, step: number): Guide | null {
const head = state.selection.main.head;
const caretLine = state.doc.lineAt(head);
const col = countColumn(caretLine.text.slice(0, head - caretLine.from), state.tabSize);
if (col === 0 || col % step) return null;
let start = caretLine.number;
for (let i = 0; ; start--, i++) {
if (start < 1 || i > BLOCK_SCAN) return null;
const cols = indentColumns(state.doc.line(start).text, state.tabSize);
if (cols === null || cols > col) continue;
if (cols !== col) return null;
break;
}
const guide = blockGuide(state, start, col, step);
return guide && caretLine.number <= guide.to ? guide : null;
}
/**
* The guide of the block whose braces the caret is at, while they match on different lines:
* IDEA draws it in the matched brace colour (`MATCHED_BRACES_INDENT_GUIDE_COLOR`).
*/
export function braceGuide(state: EditorState, step: number): Guide | null {
const head = state.selection.main.head;
const match =
matchBrackets(state, head, -1) ??
(head > 0 ? matchBrackets(state, head - 1, 1) : null) ??
matchBrackets(state, head, 1) ??
(head < state.doc.length ? matchBrackets(state, head + 1, -1) : null);
if (!match?.matched || !match.end) return null;
const open = Math.min(match.start.from, match.end.from);
if (state.sliceDoc(open, open + 1) !== "{") return null;
const openLine = state.doc.lineAt(open);
if (openLine.number === state.doc.lineAt(Math.max(match.start.from, match.end.from)).number) return null;
const col = indentColumns(openLine.text, state.tabSize) ?? 0;
return blockGuide(state, openLine.number, col, step);
}
function build(view: EditorView): DecorationSet {
const state = view.state;
const step = Math.max(1, getIndentUnit(state));
const rainbow = state.facet(rainbowGuides);
const caret = caretGuide(state, step);
const brace = braceGuide(state, step);
const on = (g: Guide | null, n: number) => (g && n >= g.from && n <= g.to ? g.level : 0);
const b = new RangeSetBuilder<Decoration>();
for (const { from, to } of view.visibleRanges) {
let pos = from;
@@ -88,7 +174,7 @@ function build(view: EditorView): DecorationSet {
} else {
levels = Math.floor(cols / step);
}
const deco = guideDeco(levels, step, rainbow);
const deco = guideDeco(levels, step, rainbow, on(caret, line.number), on(brace, line.number));
if (deco) b.add(line.from, line.from, deco);
}
pos = line.to + 1;
@@ -107,6 +193,7 @@ export const indentGuides: Extension = [
update(u: ViewUpdate) {
if (
u.docChanged ||
u.selectionSet ||
u.viewportChanged ||
u.startState.tabSize !== u.state.tabSize ||
u.startState.facet(rainbowGuides) !== u.state.facet(rainbowGuides)
+2
View File
@@ -47,6 +47,7 @@ import { caretExtension, fontZoom, methodLinesField, prefsExtension, softWrapAct
import { commentTokens } from "./languages";
import { multiCaretMouse } from "./multiCaret";
import { columnModeExtension } from "./columnMode";
import { bracketBlockSelect } from "./bracketBlockSelect";
import { visibleGoalColumns } from "./verticalMotion";
import { tabKeymap } from "./smartKeys";
import { editorBaseKeymap } from "./baseKeymap";
@@ -376,6 +377,7 @@ export function baseExtensions(o: SetupOptions): Extension {
EditorState.allowMultipleSelections.of(true),
multiCaretMouse,
columnModeExtension,
bracketBlockSelect,
visibleGoalColumns,
occurrenceSearch,
overwriteField,
+7
View File
@@ -137,6 +137,13 @@ export const openideaTheme = EditorView.theme({
"var(--bg-editor-selection-inactive, color-mix(in srgb, var(--accent) 14%, transparent))",
"--editor-indent-guide": "var(--border-indent-guide, var(--border))",
"--openidea-guide": "linear-gradient(var(--editor-indent-guide), var(--editor-indent-guide))",
// The guide the caret is on (IDEA's Selected indent guide).
"--editor-indent-guide-active": "var(--border-indent-guide-active, var(--editor-indent-guide))",
"--openidea-guide-active": "linear-gradient(var(--editor-indent-guide-active), var(--editor-indent-guide-active))",
// The guide of the block whose braces match at the caret (IDEA's Matched braces indent guide).
"--editor-indent-guide-brace":
"var(--border-indent-guide-brace, var(--bg-matched-brace, var(--editor-indent-guide-active)))",
"--openidea-guide-brace": "linear-gradient(var(--editor-indent-guide-brace), var(--editor-indent-guide-brace))",
// Rainbow indent guides: one image per depth, so they follow the colour scheme.
...Object.fromEntries(
Array.from({ length: GUIDE_COLORS }, (_, i) => {
+20 -1
View File
@@ -9,9 +9,10 @@ import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { useRemote } from "@/platform/remote";
import { hideContextMenu, resolveMenuItems, useContextMenuStore } from "@/ui";
import { getContext, setContext } from "@/platform/context";
import { evaluateWhen, getContext, setContext } from "@/platform/context";
import { installKeyboardDispatcher } from "@/platform/keymap";
import { TEXT_WHEN } from "./actions";
import { EDITOR_WHEN } from "@/editor/lsp/actions";
let ctrl: EditorController | null = null;
@@ -464,3 +465,21 @@ describe("EditorController", () => {
}
});
});
describe("editor action when clauses", () => {
const editorBehind = { textEditorActive: true, editorTextFocus: false, editorFocus: false, popupOpen: true };
it.each([
["TEXT_WHEN", TEXT_WHEN],
["EDITOR_WHEN", EDITOR_WHEN],
])("%s runs on the editor from a popup such as Find Action", (_, when) => {
expect(evaluateWhen(when, { ...editorBehind, toolWindowFocus: false })).toBe(true);
});
it.each([
["TEXT_WHEN", TEXT_WHEN],
["EDITOR_WHEN", EDITOR_WHEN],
])("%s leaves keys to a tool window's speed search, e.g. ⌘⌫ to Delete Line", (_, when) => {
expect(evaluateWhen(when, { ...editorBehind, toolWindowFocus: true })).toBe(false);
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ import {
import { evaluateWhen } from "@/platform/context";
/** Editor actions apply to the active text editor unless a tool window or popup owns the keyboard. */
export const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
export const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
/**
* Languages whose servers offer Generate (constructors, accessors, overrides…).
+76 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { OutlineItem, References } from "@/platform/ipc";
import { _addEditor, _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
View File
@@ -2,15 +2,21 @@
* "N usages" code lenses above declarations (IDEA's usages inlay). Cheap by
* construction: only declarations in (and near) the viewport are counted, one
* debounced pass after edits / scrolling, results cached per document text
* and symbol, and at most a few reference requests in flight. Beside each
* and symbol until the project's documents change, and at most a few reference
* requests in flight. Counts run the same references query the Find Usages
* panel runs (including the plugin indexers' anchors), scoped to what the
* server analyses: opening candidate files or the libraries for every count
* would trigger expensive server analysis, so only an explicit Find Usages
* widens the server's scope, and a widened search can find more. Beside each
* count, the declaration's last author from VCS blame (`authorLens`).
*/
import { Channel } from "@tauri-apps/api/core";
import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { codeLenses, shownAgain, type CodeLens } from "@/editor/api";
import { codeLenses, onEditorEvent, shownAgain, type CodeLens } from "@/editor/api";
import { onNavigationProvidersChanged } from "@/editor/navigationProviders";
import { executeAction, getAction } from "@/platform/actions";
import { commands, nextRequestId, type OutlineItem, type RequestId, type SymbolKind } from "@/platform/ipc";
import { commands, nextRequestId, onIdeEvent, type OutlineItem, type RequestId, type SymbolKind } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
import { posToOffset } from "./positions";
@@ -134,6 +140,7 @@ class UsagesLensPlugin {
private offReady: () => void;
private offSettled: () => void;
private offBlame: () => void;
private offChanges: () => void;
private skipped = false;
constructor(private view: EditorView) {
@@ -142,12 +149,7 @@ class UsagesLensPlugin {
if (s.settings !== prev.settings) this.refresh();
});
// Counts computed while the server was indexing may be incomplete.
this.offReady = onLspRefresh("codeLens", () => {
this.cache.clear();
this.incomplete.clear();
this.retries.clear();
this.refresh();
});
this.offReady = onLspRefresh("codeLens", () => this.invalidate());
this.offSettled = onServerSettled(() => {
if (!this.incomplete.size) return;
this.incomplete.clear();
@@ -155,10 +157,43 @@ class UsagesLensPlugin {
this.refresh();
});
this.offBlame = onBlameInvalidated(() => this.refresh());
// Counts cover the whole project, so a change anywhere can outdate them
// while this view is hidden and skips its passes: another document's edit
// (the call sites) or a closed unsaved buffer, a backend patch (a
// refactoring), a file changed on disk, or a plugin index batch (an XML
// statement is indexed after its Java declaration). This view's own edits
// already refresh through `update`.
const offDocs = onEditorEvent((e) => {
if (e.type === "docChanged" && e.doc !== docOf(this.view)?.doc) this.invalidate();
else if (e.type === "docClosed") this.invalidate();
});
const offPatches = onIdeEvent("docPatched", (e) => {
if (e.doc !== docOf(this.view)?.doc) this.invalidate();
});
const offBackend = [
onIdeEvent("workspaceEditApplied", () => this.invalidate()),
onIdeEvent("fsChanged", () => this.invalidate()),
onNavigationProvidersChanged(() => this.invalidate()),
];
this.offChanges = () => {
offDocs();
offPatches();
offBackend.forEach((off) => off());
};
}
/** Counts are cached until anything in the project may have changed them. */
private invalidate() {
this.cache.clear();
this.incomplete.clear();
this.retries.clear();
this.refresh();
}
update(u: ViewUpdate) {
if (u.docChanged || u.viewportChanged || (this.skipped && shownAgain(u))) this.refresh();
// A re-shown tab ran nothing while hidden, and its counts may have been
// invalidated meanwhile; when they are cached the pass only redraws.
if (u.docChanged || u.viewportChanged || shownAgain(u)) this.refresh();
}
private cancel() {
@@ -289,6 +324,7 @@ class UsagesLensPlugin {
this.offReady();
this.offSettled();
this.offBlame();
this.offChanges();
this.cancel();
}
}
+120 -22
View File
@@ -10,7 +10,16 @@ import {
* click), the multiple-targets chooser, and document highlights (occurrences
* of the symbol at the caret, plus the sticky find.highlightUsages variant).
*/
import { EditorSelection, Prec, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import {
countColumn,
EditorSelection,
Prec,
StateEffect,
StateField,
type Extension,
type Text,
} from "@codemirror/state";
import { LINE_INSET } from "@/editor/core/cm/lineInset";
import {
Decoration,
EditorView,
@@ -54,6 +63,7 @@ import {
SYNTAX_NOTE,
declarationFallback,
indexDefinitions,
isDeclarationName,
isSelfTarget,
localHighlights,
type DeclarationTarget,
@@ -351,6 +361,15 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
}
}
/** Whether the caret is on the name of a declaration, by the syntax tree (no language server wait). */
async function onDeclarationName(view: EditorView, offset: number): Promise<boolean> {
const doc = docOf(view);
if (!doc) return false;
const pos = offsetToPos(view.state.doc, offset);
const at = await structure.nodeAt(doc.doc, { start: pos, end: pos }).catch(() => null);
return isDeclarationName(at);
}
async function gotoResolved(
view: EditorView,
kind: GotoKind,
@@ -365,7 +384,10 @@ async function gotoResolved(
const before = view.state.doc;
const plugin = await contributedNavigation(view, offset).catch(() => null);
if (view.state.doc !== before || superseded()) return;
if (plugin) {
// ⌘B on a declaration shows its usages, also when a plugin links the name elsewhere (a
// MyBatis mapper method to its XML statement).
if (plugin && !(kind === "definition" && (await onDeclarationName(view, offset)))) {
if (superseded()) return;
if (plugin.targets.length === 1) await navigateTo(plugin.targets[0]!);
else {
const items = await locationItems(plugin.targets);
@@ -1031,6 +1053,8 @@ function navigateFromPointer(view: EditorView, e: MouseEvent): boolean {
interface HighlightState {
deco: DecorationSet;
/** The highlighted usages (the decorations also hold whole-line backgrounds). */
ranges: readonly { from: number; to: number }[];
sticky: boolean;
}
@@ -1038,31 +1062,94 @@ type HighlightItem = { from: number; to: number; kind: Highlight["kind"] };
const setHighlights = StateEffect.define<{ items: HighlightItem[]; sticky: boolean }>();
const OCCURRENCE = "cm-lsp-occurrence";
const KIND_CLASS = {
text: OCCURRENCE,
read: `${OCCURRENCE} ${OCCURRENCE}-read`,
write: `${OCCURRENCE} ${OCCURRENCE}-write`,
};
const marks = {
text: Decoration.mark({ class: "cm-lsp-occurrence" }),
read: Decoration.mark({ class: "cm-lsp-occurrence cm-lsp-occurrence-read" }),
write: Decoration.mark({ class: "cm-lsp-occurrence cm-lsp-occurrence-write" }),
text: Decoration.mark({ class: KIND_CLASS.text }),
read: Decoration.mark({ class: KIND_CLASS.read }),
write: Decoration.mark({ class: KIND_CLASS.write }),
};
const fullLines = {
text: Decoration.line({ class: KIND_CLASS.text }),
read: Decoration.line({ class: KIND_CLASS.read }),
write: Decoration.line({ class: KIND_CLASS.write }),
};
/**
* Highlights as IDEA paints a text range: one on a single line marks its text; one over
* several lines (a `return` statement's exit point) is a block, from its start to the right
* edge, whole lines between, and its last line up to its end.
*/
function highlightDeco(doc: Text, tabSize: number, items: readonly HighlightItem[]): DecorationSet {
const out = [];
for (const i of items) {
if (i.from >= i.to) continue;
const first = doc.lineAt(i.from);
const last = doc.lineAt(i.to);
if (first.number === last.number) {
out.push(marks[i.kind].range(i.from, i.to));
continue;
}
const col = countColumn(first.text.slice(0, i.from - first.from), tabSize);
const tail = Decoration.line({
class: i.kind === "write" ? "cm-lsp-occurrence-tail cm-lsp-occurrence-tail-write" : "cm-lsp-occurrence-tail",
attributes: { style: `--lsp-occurrence-from: calc(${LINE_INSET}px + ${col}ch)` },
});
out.push(tail.range(first.from));
for (let n = first.number + 1; n < last.number; n++) out.push(fullLines[i.kind].range(doc.line(n).from));
if (i.to > last.from) out.push(marks[i.kind].range(last.from, i.to));
}
return Decoration.set(out, true);
}
const highlightField = StateField.define<HighlightState>({
create: () => ({ deco: Decoration.none, sticky: false }),
create: () => ({ deco: Decoration.none, ranges: [], sticky: false }),
update(value, tr) {
for (const e of tr.effects)
if (e.is(setHighlights))
if (e.is(setHighlights)) {
const items = e.value.items.filter((i) => i.from < i.to);
return {
deco: Decoration.set(
e.value.items.filter((i) => i.from < i.to).map((i) => marks[i.kind].range(i.from, i.to)),
true,
),
deco: highlightDeco(tr.state.doc, tr.state.tabSize, items),
ranges: items.map(({ from, to }) => ({ from, to })),
sticky: e.value.sticky,
};
}
// Kept (mapped) until recomputed; ⌘⇧F7 highlights survive edits until Escape.
if (tr.docChanged) return { deco: value.deco.map(tr.changes), sticky: value.sticky };
if (tr.docChanged)
return {
deco: value.deco.map(tr.changes),
ranges: value.ranges.map((r) => ({ from: tr.changes.mapPos(r.from, 1), to: tr.changes.mapPos(r.to, -1) })),
sticky: value.sticky,
};
return value;
},
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
});
/**
* The latest usages found per editor, for its current document: a caret landing in one shows
* them at once while the server is asked again, as IDEA's highlighting follows the caret.
*/
const recentHighlights = new WeakMap<EditorView, { doc: Text; sets: HighlightItem[][] }>();
const RECENT_SETS = 8;
function rememberHighlights(view: EditorView, doc: Text, items: HighlightItem[]) {
if (!items.length) return;
let recent = recentHighlights.get(view);
if (recent?.doc !== doc) recentHighlights.set(view, (recent = { doc, sets: [] }));
recent.sets = [items, ...recent.sets].slice(0, RECENT_SETS);
}
function recentHighlightsAt(view: EditorView, doc: Text, head: number): HighlightItem[] | null {
const recent = recentHighlights.get(view);
if (recent?.doc !== doc) return null;
return recent.sets.find((set) => set.some((i) => i.from <= head && head <= i.to)) ?? null;
}
/** IDEA's status bar text while ⌘⇧F7 highlights are shown. */
export const useUsagesStatus = create<{ text: string | null; owner: EditorView | null }>(() => ({
text: null,
@@ -1092,6 +1179,7 @@ async function fetchHighlights(view: EditorView, sticky: boolean) {
const stale = () => view.state.doc !== state.doc || view.state.selection.main.head !== head;
const apply = (items: HighlightItem[]) => {
view.dispatch({ effects: setHighlights.of({ items, sticky: sticky && items.length > 0 }) });
rememberHighlights(view, state.doc, items);
if (!sticky) return;
if (!items.length) {
if (useUsagesStatus.getState().owner === view) setUsagesStatus(null, null);
@@ -1129,7 +1217,7 @@ async function fetchHighlights(view: EditorView, sticky: boolean) {
if (lspError) reportLspError(view, lspError, sticky);
else if (sticky) showHint(view, NO_SERVER);
// Drop stale marks from an earlier caret position.
if (view.state.field(highlightField).deco.size) apply([]);
if (view.state.field(highlightField).ranges.length) apply([]);
}
async function syntaxHighlights(view: EditorView, doc: EditorDocContext, head: number) {
@@ -1148,10 +1236,7 @@ async function syntaxHighlights(view: EditorView, doc: EditorDocContext, head: n
export function highlightUsages(view: EditorView) {
const st = view.state.field(highlightField, false);
const head = view.state.selection.main.head;
let onUsage = false;
st?.deco.between(head, head, (from, to) => {
if (from <= head && head <= to) onUsage = true;
});
const onUsage = !!st?.ranges.some(({ from, to }) => from <= head && head <= to);
if (st?.sticky && onUsage) {
clearHighlights(view);
return;
@@ -1165,8 +1250,7 @@ export function highlightUsages(view: EditorView) {
*/
export function gotoHighlightedUsage(view: EditorView, dir: 1 | -1): boolean {
const st = view.state.field(highlightField, false);
const ranges: { from: number; to: number }[] = [];
st?.deco.between(0, view.state.doc.length, (from, to) => void ranges.push({ from, to }));
const ranges = [...(st?.ranges ?? [])].sort((a, b) => a.from - b.from);
const head = view.state.selection.main.head;
const current = ranges.findIndex((r) => r.from <= head && head <= r.to);
if (ranges.length < 2 || current < 0) return false;
@@ -1178,7 +1262,8 @@ export function gotoHighlightedUsage(view: EditorView, dir: 1 | -1): boolean {
const highlightPlugin = ViewPlugin.fromClass(
class {
private request = debounce(() => void fetchHighlights(this.view, false), 300);
// Keyboard moves wait a little, so holding an arrow key does not ask for every line.
private request = debounce(() => void fetchHighlights(this.view, false), 100);
constructor(private view: EditorView) {}
update(u: ViewUpdate) {
// Recomputed after caret moves and, like IDEA's daemon, after edits.
@@ -1190,7 +1275,7 @@ const highlightPlugin = ViewPlugin.fromClass(
}
const clear = () => {
this.request.cancel();
if (st.deco.size)
if (st.ranges.length)
queueMicrotask(() => this.view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) }));
};
// Code Editing › "Usages of element at caret".
@@ -1198,7 +1283,20 @@ const highlightPlugin = ViewPlugin.fromClass(
clear();
return;
}
this.request();
const doc = u.state.doc;
const head = u.state.selection.main.head;
const known = u.docChanged ? null : recentHighlightsAt(this.view, doc, head);
if (known)
queueMicrotask(() => {
const now = this.view.state;
if (now.doc === doc && now.selection.main.head === head)
this.view.dispatch({ effects: setHighlights.of({ items: known, sticky: false }) });
});
// A click asks at once (IDEA highlights as the caret lands).
if (u.transactions.some((tr) => tr.isUserEvent("select.pointer"))) {
this.request.cancel();
void fetchHighlights(this.view, false);
} else this.request();
}
destroy() {
this.request.cancel();
+43
View File
@@ -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
View File
@@ -63,6 +63,14 @@ export function usageRole(at: NodeAt | null): UsageRole {
return "other";
}
const DECLARATION = /(declaration|definition|declarator)$/;
/** Whether the syntax tree puts the identifier at `at` as the name of a declaration (`int f()`'s `f`). */
export function isDeclarationName(at: NodeAt | null): boolean {
const parent = at?.ancestors[0];
return !!parent && at.node.field === "name" && DECLARATION.test(parent.kind);
}
/** Index symbols that may be the declaration of `name` used in `role`. */
export function filterSymbols(matches: readonly SymbolMatch[], name: string, role: UsageRole): SymbolMatch[] {
return matches.filter((m) => {
+11 -4
View File
@@ -288,11 +288,18 @@ export const lspTheme = EditorView.theme({
textUnderlineOffset: "2px",
cursor: "pointer",
},
".cm-lsp-occurrence": {
backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--accent) 14%, transparent))",
".cm-lsp-occurrence, .cm-lsp-occurrence-tail": {
"--lsp-occurrence-bg": "var(--bg-identifier-under-caret, color-mix(in srgb, var(--accent) 14%, transparent))",
},
".cm-lsp-occurrence-write": {
backgroundColor: "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, transparent))",
".cm-lsp-occurrence-write, .cm-lsp-occurrence-tail-write": {
"--lsp-occurrence-bg": "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, transparent))",
},
".cm-lsp-occurrence": { backgroundColor: "var(--lsp-occurrence-bg)" },
// First line of a highlight over several lines: from its start column to the right edge,
// over the line's own background (the caret row's).
".cm-lsp-occurrence-tail": {
backgroundImage:
"linear-gradient(to right, transparent var(--lsp-occurrence-from), var(--lsp-occurrence-bg) var(--lsp-occurrence-from))",
},
".cm-lsp-nav-info": { padding: "4px 8px" },
// Chooser highlight (tempHighlight.ts): the expression / occurrences a choice would take.
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { Highlight, IdeError, Range } from "@/bindings";
type R<T> = Promise<{ status: "ok"; data: T } | { status: "error"; error: IdeError }>;
const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
const cmd = {
lspHover: vi.fn((): R<unknown> => ok(null)),
lspDocumentLinks: vi.fn(() => ok([])),
cancelRequest: vi.fn(() => ok(null)),
lspDocumentHighlights: vi.fn((): R<Highlight[]> => ok([])),
};
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } };
});
const { navigationExtension, highlightUsages } = await import("./navigation");
const { lspDoc } = await import("./lsp");
const range = (line: number, col: number, endLine: number, endCol: number): Range => ({
start: { line, col },
end: { line: endLine, col: endCol },
});
// 0: int f() { 4: }
// 1: return a 5: int g(String a) {
// 2: .b() 6: return a;
// 3: .c(); 7: }
const DOC = "int f() {\n return a\n .b()\n .c();\n}\nint g(String a) {\n return a;\n}";
const views: EditorView[] = [];
function editor(anchor = 0) {
const view = new EditorView({
state: EditorState.create({
doc: DOC,
selection: { anchor },
extensions: [
lspDoc.of({ doc: 7, path: "/p/A.java", language: "java", uiMode: null, readOnly: false }),
navigationExtension(),
],
}),
parent: document.body,
});
views.push(view);
return view;
}
const lines = (v: EditorView) => [...v.contentDOM.querySelectorAll<HTMLElement>(".cm-line")];
const at = (line: number, col: number) => DOC.split("\n").slice(0, line).join("\n").length + (line ? 1 : 0) + col;
const click = (v: EditorView, pos: number) => v.dispatch({ selection: { anchor: pos }, userEvent: "select.pointer" });
beforeEach(() => {
cmd.lspDocumentHighlights.mockReset().mockImplementation(() => ok([]));
});
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
describe("highlights of the element at the caret", () => {
it("paints a highlight over several lines as one block, as IDEA paints a text range", async () => {
cmd.lspDocumentHighlights.mockImplementation(() => ok([{ range: range(1, 4, 3, 13), kind: "text" }]));
const v = editor(at(1, 4));
highlightUsages(v);
await vi.waitFor(() => expect(lines(v)[1]!.classList.contains("cm-lsp-occurrence-tail")).toBe(true));
// From `return` (column 4) to the right edge, the whole middle line, the last line up to `;`.
expect(lines(v)[1]!.style.getPropertyValue("--lsp-occurrence-from")).toBe("calc(2.5px + 4ch)");
expect(lines(v)[2]!.classList.contains("cm-lsp-occurrence")).toBe(true);
const last = lines(v)[3]!.querySelector(".cm-lsp-occurrence")!;
expect(last.textContent).toBe(" .c();");
expect(lines(v)[0]!.className).not.toContain("cm-lsp-occurrence");
expect(lines(v)[4]!.className).not.toContain("cm-lsp-occurrence");
});
it("keeps a single-line highlight on its text", async () => {
cmd.lspDocumentHighlights.mockImplementation(() => ok([{ range: range(6, 11, 6, 12), kind: "read" }]));
const v = editor(at(6, 11));
highlightUsages(v);
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence-read")).toHaveLength(1));
expect(lines(v)[6]!.classList.contains("cm-lsp-occurrence")).toBe(false);
});
it("asks at once after a click, and after a pause for keyboard moves", async () => {
const v = editor(0);
click(v, at(6, 11));
await vi.waitFor(() => expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(1));
v.dispatch({ selection: { anchor: at(6, 4) }, userEvent: "select" });
await new Promise((r) => setTimeout(r, 20));
expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(2));
});
it("shows the usages found before at once when the caret comes back to one", async () => {
const a = [
{ range: range(5, 13, 5, 14), kind: "write" as const },
{ range: range(6, 11, 6, 12), kind: "read" as const },
];
cmd.lspDocumentHighlights.mockImplementationOnce(() => ok(a));
const v = editor(0);
click(v, at(6, 11));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(2));
click(v, at(1, 4));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0));
// The server is slow now: the remembered usages of `a` show without waiting for it.
cmd.lspDocumentHighlights.mockImplementation(() => new Promise(() => {}));
click(v, at(5, 13));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(2));
});
it("forgets them after an edit", async () => {
cmd.lspDocumentHighlights.mockImplementationOnce(() => ok([{ range: range(6, 11, 6, 12), kind: "read" }]));
const v = editor(0);
click(v, at(6, 11));
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(1));
v.dispatch({ changes: { from: 0, insert: "\n" } });
click(v, 0);
await vi.waitFor(() => expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0));
// Back on `a` with a slow server: the results of the document before the edit are not reused.
cmd.lspDocumentHighlights.mockImplementation(() => new Promise(() => {}));
click(v, at(6, 11) + 1);
await vi.waitFor(() => expect(cmd.lspDocumentHighlights).toHaveBeenCalledTimes(3));
await new Promise((r) => setTimeout(r, 20));
expect(v.contentDOM.querySelectorAll(".cm-lsp-occurrence")).toHaveLength(0);
});
});
+1 -1
View File
@@ -22,7 +22,7 @@ import { gotoTestOrSubject, type TestNavDeps } from "./gotoTest";
import { baseName, memberAt, methodStop, superMethods } from "./structureNav";
import { tx } from "@/platform/i18n";
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
/** A backend document id for `path` while `fn` runs (open editors are reused). */
async function withDoc<T>(path: string, fn: (doc: number) => Promise<T>): Promise<T> {
+1 -1
View File
@@ -26,7 +26,7 @@ import { ServerStatus } from "./ServerStatus";
import { reportProblems } from "./problemsReport";
import { installWorkspaceEditUndo } from "./workspaceUndo";
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
const EDITOR_WHEN = "textEditorActive && (editorFocus || !toolWindowFocus)";
function useErrorBadge(): number | null {
return useProblems((s) => {
+21 -5
View File
@@ -268,13 +268,29 @@
gap: 6px;
min-width: 0;
}
.tinted {
flex: 1;
margin: 0 -4px;
padding: 0 4px;
border-radius: var(--radius-control, 4px);
/* The whole tree row, indent included, so a coloured source and its children form one band. */
.tintedRow:not([aria-selected="true"]):not(:hover) {
border-radius: 0;
background: color-mix(in srgb, var(--db-tint) var(--tint-strength), transparent);
}
.tintRed {
--db-tint: var(--db-color-red);
}
.tintOrange {
--db-tint: var(--db-color-orange);
}
.tintYellow {
--db-tint: var(--db-color-yellow);
}
.tintGreen {
--db-tint: var(--db-color-green);
}
.tintBlue {
--db-tint: var(--db-color-blue);
}
.tintViolet {
--db-tint: var(--db-color-violet);
}
.nodeLabel {
overflow: hidden;
text-overflow: ellipsis;
@@ -5,6 +5,10 @@ import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { registerDatabaseTreeActions } from "./treeActions";
import { tablesKey, useDatabase } from "./store";
import { tableInput } from "./tableInput";
import { useEditorRegistry } from "@/editor/api";
import { provideEditorService, provideToolWindowService, type EditorInput } from "@/workbench/contributions";
import styles from "./Database.module.css";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
@@ -108,6 +112,61 @@ describe("routine grouping", () => {
});
});
describe("Select Opened File", () => {
const nextFrame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
let active: EditorInput | null = null;
const show = vi.fn();
beforeEach(() => {
provideEditorService({ activeInput: () => active } as never);
provideToolWindowService({ show } as never);
useDatabase.setState({
tables: {
[tablesKey("pg", "public")]: {
status: "ok",
value: [
{ name: "people", kind: "table" },
{ name: "adults", kind: "view" },
],
},
},
schemas: { pg: { status: "ok", value: ["public", "audit"] } },
treeExpanded: new Set(),
});
});
afterEach(() => {
active = null;
useEditorRegistry.setState({ active: null });
});
it("selects the active table editor's view in the views folder", async () => {
render(<DatabaseView />);
active = tableInput("pg", "public", "adults");
await act(() => executeAction("db.selectOpened"));
await nextFrame();
expect(useDatabase.getState().selectedNode?.key).toBe("pg/public/views/adults");
expect(show).toHaveBeenCalledWith("database");
expect(document.activeElement).toBe(screen.getByRole("tree", { name: "Data sources" }));
});
it("selects the nearest shown ancestor of a dropped table, and a console's schema", async () => {
render(<DatabaseView />);
active = tableInput("pg", "public", "dropped");
await act(() => executeAction("db.selectOpened"));
await nextFrame();
expect(useDatabase.getState().selectedNode?.key).toBe("pg/public/tables");
const path = "/project/audit.sql";
useDatabase.setState({ consoles: { [path]: "pg" }, consoleSchemas: { [path]: "audit" } });
active = { kind: "file", key: path, path };
useEditorRegistry.setState({ active: { ctx: { path, language: "sql" } } as never });
await act(() => executeAction("db.selectOpened"));
await nextFrame();
expect(useDatabase.getState().selectedNode?.key).toBe("pg/audit");
});
});
describe("Expand All", () => {
it("opens every loaded node and leaves unloaded ones closed", async () => {
useDatabase.setState({
@@ -133,3 +192,20 @@ describe("Expand All", () => {
expect(tree.getByText("SQLite").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
});
});
describe("data source colour", () => {
it("tints the whole tree row of the source and of every node under it", () => {
useDatabase.setState({
sources: [
{ id: "pg", name: "PostgreSQL", driver: "postgres", color: "red", options: { singleDatabase: true } },
{ id: "lite", name: "SQLite", driver: "sqlite" },
],
});
render(<DatabaseView />);
const tree = within(screen.getByRole("tree", { name: "Data sources" }));
for (const name of ["PostgreSQL", "public", "routines", "refresh_data"]) {
expect(tree.getByText(name).closest('[role="treeitem"]')).toHaveClass(styles.tintedRow!, styles.tintRed!);
}
expect(tree.getByText("SQLite").closest('[role="treeitem"]')).not.toHaveClass(styles.tintedRow!);
});
});
+32 -14
View File
@@ -5,8 +5,8 @@
* with counts and column types, loaded lazily. Double-click a table to open its data, a data
* source to open its query console.
*/
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
import type { DbDatabases, DbTableDetails } from "@/platform/ipc";
import { useEffect, useMemo, useRef } from "react";
import type { DbColor, DbDatabases, DbTableDetails } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import {
@@ -23,7 +23,6 @@ import {
T,
} from "@/ui";
import {
colorVar,
databaseKey,
passesObjectFilter,
passesTreeFilter,
@@ -62,6 +61,16 @@ import { DB_VIEW_FOCUS, databaseTree, openTableData } from "./treeActions";
import { newMenu, nodeMenu } from "./treeMenu";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
import { databaseRequest } from "./requests";
const ROW_TINTS: Record<DbColor, string | undefined> = {
red: styles.tintRed,
orange: styles.tintOrange,
yellow: styles.tintYellow,
green: styles.tintGreen,
blue: styles.tintBlue,
violet: styles.tintViolet,
};
const GROUP_LABELS: Record<Group, string> = {
columns: "columns",
@@ -143,12 +152,12 @@ export function DatabaseView() {
useEffect(() => {
if (!st.loaded) void loadSources();
}, [st.loaded]);
// Go to Declaration from a console: the rows exist once the expanded nodes render.
// Go to Declaration from a console, Select Opened File: the rows exist once the expanded nodes render.
useEffect(() => {
if (!st.treeReveal) return;
const key = st.treeReveal.key;
const path = st.treeReveal.path;
const frame = requestAnimationFrame(() => {
tree.current?.reveal(key);
path.toReversed().some((key) => tree.current?.reveal(key));
tree.current?.focus();
});
return () => cancelAnimationFrame(frame);
@@ -346,7 +355,10 @@ export function DatabaseView() {
const load = async (n: Node) => {
if (n.kind === "source") {
const id = n.source.id ?? "";
if (!(await connect(id))) {
const current = databaseRequest(id);
const connected = await connect(id);
if (!current()) return;
if (!connected) {
const err = { status: "error" as const, message: tx("Cannot connect") };
if (listsDatabases(n.source)) useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: err } }));
else useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: err } }));
@@ -355,7 +367,10 @@ export function DatabaseView() {
if (listsDatabases(n.source)) await loadDatabases(id);
else await loadSchemas(id);
} else if (n.kind === "database") {
if (!(await connect(n.id))) {
const current = databaseRequest(n.id);
const connected = await connect(n.id);
if (!current()) return;
if (!connected) {
useDatabase.setState((s) => ({
schemas: { ...s.schemas, [n.id]: { status: "error", message: tx("Cannot connect") } },
}));
@@ -542,6 +557,12 @@ export function DatabaseView() {
getIcon={(n) => icon(n)}
getDetail={detail}
getColor={(n) => (n.kind === "message" && n.error ? "var(--danger)" : undefined)}
rowClassName={(n) => {
// IDEA tints every row of a coloured data source across its full width and height.
const target = nodeTarget(n);
const color = target ? sourceOf(target)?.color : null;
return color ? `${styles.tintedRow} ${ROW_TINTS[color]}` : undefined;
}}
renderNode={(n: Node) => {
const i = icon(n);
const d = detail(n);
@@ -553,13 +574,9 @@ export function DatabaseView() {
? styles.keyLabel
: styles.nodeLabel;
const live = n.kind === "source" && [...st.connected].some((t) => splitTarget(t).id === n.source.id);
const target = nodeTarget(n);
// IDEA tints every row of a coloured data source.
const tint = colorVar(target ? sourceOf(target)?.color : null);
return (
<span
className={tint ? `${styles.nodeRow} ${styles.tinted}` : styles.nodeRow}
style={tint ? ({ "--db-tint": tint } as CSSProperties) : undefined}
className={styles.nodeRow}
title={n.kind === "source" && n.source.comment ? n.source.comment : undefined}
>
{i && (
@@ -683,10 +700,11 @@ function DatabaseToolbar() {
);
}
/** Header controls (shown on hover, like IDEA's): Expand All, Collapse All. */
/** Header controls (shown on hover, like IDEA's): Select Opened File, Expand All, Collapse All. */
export function DatabaseHeaderActions() {
return (
<>
<IconButton icon="locate" action="db.selectOpened" size="small" iconSize={16} />
<IconButton icon="expand-all" action="db.expandAll" size="small" iconSize={16} />
<IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />
</>
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -9,11 +9,12 @@ import { tx } from "@/platform/i18n";
import { notifications } from "@/workbench/contributions";
import { shownNames, splitTarget } from "./model";
import { loadDatabases, sourceById, useDatabase } from "./store";
import { databaseRequest } from "./requests";
const ACCEPTED_KEY = "openidea.database.introspect";
/** Data sources asked (or started) in this run, so a connect asks only once. */
const offered = new Set<string>();
const cancelled = new Set<string>();
const offered = new Map<string, () => boolean>();
const cancelled = new Map<string, () => boolean>();
function accepted(): string[] {
try {
@@ -36,7 +37,7 @@ const notice = (id: string) => `db-introspect-${id}`;
/** A saved catalog older than this is read again on the next connect. */
const STALE_MS = 24 * 60 * 60 * 1000;
const loading = new Map<string, Promise<void>>();
const loading = new Map<string, { current: () => boolean; promise: Promise<void> }>();
/**
* Brings what earlier introspection of data source `id` saved into the store (once per run).
@@ -46,9 +47,11 @@ export function loadSavedCatalog(id: string): Promise<void> {
const sourceId = splitTarget(id).id;
if (sourceById(sourceId)?.driver !== "snowflake") return Promise.resolve();
let pending = loading.get(sourceId);
if (!pending) {
pending = call(commands.dbSavedCatalog(sourceId))
if (!pending?.current()) {
const current = databaseRequest(sourceId);
const promise = call(commands.dbSavedCatalog(sourceId))
.then((saved) => {
if (!current()) return;
const databases = saved?.databases ?? [];
// An introspection started meanwhile knows better.
if (!databases.length || useDatabase.getState().catalogs[sourceId]) return;
@@ -66,14 +69,16 @@ export function loadSavedCatalog(id: string): Promise<void> {
}));
})
.catch(() => {});
pending = { current, promise };
loading.set(sourceId, pending);
}
return pending;
return pending.promise;
}
/** Stops the running introspection of data source `id` after the database it is reading. */
export function cancelIntrospection(id: string) {
cancelled.add(splitTarget(id).id);
const sourceId = splitTarget(id).id;
cancelled.set(sourceId, databaseRequest(sourceId));
}
/** Reads the schemas and tables of every database data source `id` shows (see the Schemas tab). */
@@ -81,18 +86,22 @@ export async function introspectSource(id: string): Promise<void> {
const sourceId = splitTarget(id).id;
const source = sourceById(sourceId);
if (source?.driver !== "snowflake" || useDatabase.getState().catalogs[sourceId]?.status === "running") return;
offered.add(sourceId);
const current = databaseRequest(sourceId);
offered.set(sourceId, current);
remember(sourceId);
cancelled.delete(sourceId);
const name = source.name ?? "";
const update = (patch: Partial<{ status: "running" | "done" | "cancelled"; done: number; total: number }>) =>
useDatabase.setState((s) => {
if (!current()) return s;
const old = s.catalogs[sourceId] ?? { status: "running" as const, done: 0, total: 0, databases: {} };
return { catalogs: { ...s.catalogs, [sourceId]: { ...old, ...patch } } };
});
await loadSavedCatalog(sourceId);
if (!current()) return;
update({ status: "running", done: 0, total: 0 });
if (!useDatabase.getState().databases[sourceId]) await loadDatabases(sourceId);
if (!current()) return;
const listed = useDatabase.getState().databases[sourceId];
if (listed?.status !== "ok") {
update({ status: "cancelled" });
@@ -106,7 +115,8 @@ export async function introspectSource(id: string): Promise<void> {
const failed: string[] = [];
let reason = "";
for (const database of databases) {
if (cancelled.has(sourceId)) break;
if (!current()) return;
if (cancelled.get(sourceId)?.()) break;
const position = done + 1;
const count = databases.length;
notifications().notify({
@@ -119,6 +129,7 @@ export async function introspectSource(id: string): Promise<void> {
});
try {
const schemas = await call(commands.dbDatabaseCatalog(sourceId, database));
if (!current()) return;
tables += schemas.reduce((n, schema) => n + schema.tables.length, 0);
useDatabase.setState((s) => {
const old = s.catalogs[sourceId];
@@ -128,6 +139,7 @@ export async function introspectSource(id: string): Promise<void> {
};
});
} catch (error) {
if (!current()) return;
// A database the role may not read: completion goes without it, and the summary says so.
failed.push(database);
reason ||= error instanceof Error ? error.message : String(error);
@@ -135,7 +147,9 @@ export async function introspectSource(id: string): Promise<void> {
done += 1;
update({ done });
}
const stopped = cancelled.delete(sourceId);
if (!current()) return;
const stopped = cancelled.get(sourceId)?.() ?? false;
cancelled.delete(sourceId);
const total = tables.toLocaleString("en");
const read = done - failed.length;
const unread = failed.length;
@@ -150,6 +164,7 @@ export async function introspectSource(id: string): Promise<void> {
return { catalogs: { ...s.catalogs, [sourceId]: { ...old, databases: kept, readAt } } };
});
await call(commands.dbRetainCatalog(sourceId, databases)).catch(() => {});
if (!current()) return;
}
update({ status: stopped ? "cancelled" : "done" });
const summary = tx`${total} tables and views in ${read} databases are ready for completion.`;
@@ -170,9 +185,11 @@ export async function introspectSource(id: string): Promise<void> {
export async function offerIntrospection(id: string): Promise<void> {
const sourceId = splitTarget(id).id;
const source = sourceById(sourceId);
if (source?.driver !== "snowflake" || offered.has(sourceId)) return;
offered.add(sourceId);
if (source?.driver !== "snowflake" || offered.get(sourceId)?.()) return;
const current = databaseRequest(sourceId);
offered.set(sourceId, current);
await loadSavedCatalog(sourceId);
if (!current()) return;
const known = useDatabase.getState().catalogs[sourceId];
if (known?.status === "running") return;
if (known?.readAt !== undefined && Date.now() - known.readAt < STALE_MS) return;
@@ -187,7 +204,12 @@ export async function offerIntrospection(id: string): Promise<void> {
title: tx`Introspect ${name}?`,
body: tx("Reads the names of schemas, tables and views of every database, so completion works for all of them."),
actions: [
{ label: "Introspect", run: () => void introspectSource(sourceId) },
{
label: "Introspect",
run: () => {
if (current()) void introspectSource(sourceId);
},
},
{ label: "Not Now", run: () => {} },
],
sticky: true,
@@ -0,0 +1,721 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DataSource, ProjectInfo } from "@/platform/ipc";
import { useProject } from "@/platform/project";
const backend = vi.hoisted(() => ({
databases: vi.fn(),
schemas: vi.fn(),
tables: vi.fn(),
objects: vi.fn(),
details: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
completion: vi.fn(),
sources: vi.fn(),
connected: vi.fn(),
save: vi.fn(),
password: vi.fn(),
sshSecret: vi.fn(),
execute: vi.fn(),
statements: vi.fn(),
}));
const ui = vi.hoisted(() => ({ show: vi.fn(), notify: vi.fn() }));
vi.mock("@/workbench/contributions", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/workbench/contributions")>();
return { ...actual, toolWindowService: () => ({ show: ui.show }), notifications: () => ({ notify: ui.notify }) };
});
vi.mock("@/platform/ipc", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/platform/ipc")>();
return {
...actual,
commands: {
...actual.commands,
dbDatabases: backend.databases,
dbSchemas: backend.schemas,
dbTables: backend.tables,
dbSchemaObjects: backend.objects,
dbTableDetails: backend.details,
dbConnect: backend.connect,
dbDisconnect: backend.disconnect,
dbCompletion: backend.completion,
dbSources: backend.sources,
dbConnected: backend.connected,
dbSaveSources: backend.save,
dbSetPassword: backend.password,
dbSetSshSecret: backend.sshSecret,
dbExecute: backend.execute,
dbStatements: backend.statements,
},
};
});
import {
connect,
deleteSource,
disconnect,
loadCompletion,
loadDatabases,
loadDetails,
loadSchemas,
loadSources,
loadTables,
refresh,
renameSource,
revealInTree,
saveSource,
runSql,
executeConsole,
useDatabase,
withConnection,
} from "./store";
const initial = useDatabase.getState();
const initialProject = useProject.getState();
const pg: DataSource = { id: "pg", name: "Database", driver: "postgres" };
const other: DataSource = { id: "pg2", name: "Other", driver: "postgres" };
const projectA = { root: "/project-a" } as ProjectInfo;
const projectB = { root: "/project-b" } as ProjectInfo;
const ok = <T>(data: T) => ({ status: "ok" as const, data });
function deferred<T = unknown>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const details = { columns: [], indexes: [], foreignKeys: [], triggers: [] };
const completion = { defaultSchema: "public", tables: [], foreignKeys: [] };
const cases = [
{
name: "databases",
mock: backend.databases,
load: (id: string) => loadDatabases(id),
value: { current: "app", names: ["app"] },
stale: { current: "old", names: ["old"] },
read: (id: string) => useDatabase.getState().databases[id],
},
{
name: "schemas",
mock: backend.schemas,
load: (id: string) => loadSchemas(id),
value: ["public"],
stale: ["old"],
read: (id: string) => useDatabase.getState().schemas[id],
},
{
name: "tables",
mock: backend.tables,
load: (id: string) => loadTables(id, "public"),
value: [{ name: "users", kind: "table" }],
stale: [{ name: "old", kind: "table" }],
read: (id: string) => useDatabase.getState().tables[`${id}\u0000public`],
},
{
name: "details",
mock: backend.details,
load: (id: string) => loadDetails(id, "public", "users"),
value: details,
stale: { ...details, columns: [{ name: "old" }] },
read: (id: string) => useDatabase.getState().details[`${id}\u0000public\u0000users`],
},
];
let savedSources: DataSource[];
function switchProject(project: ProjectInfo) {
useProject.setState({ project });
// The database feature's project subscription clears its visible state.
useDatabase.setState({ ...initial, sources: [pg, other], loaded: true }, true);
}
beforeEach(() => {
vi.resetAllMocks();
useProject.setState({ project: projectA });
savedSources = [pg, other];
useDatabase.setState({ ...initial, sources: savedSources, loaded: true, connected: new Set(["pg"]) }, true);
for (const spec of cases) spec.mock.mockResolvedValue(ok(spec.value));
backend.connect.mockResolvedValue(ok(null));
backend.disconnect.mockResolvedValue(ok(null));
backend.objects.mockResolvedValue(ok({ routines: [], packages: [], sequences: [] }));
backend.completion.mockResolvedValue(ok(completion));
backend.sources.mockImplementation(async () => ok(savedSources));
backend.connected.mockImplementation(async () => ok([...useDatabase.getState().connected]));
backend.save.mockImplementation(async (sources: DataSource[]) => {
savedSources = sources;
return ok(null);
});
backend.password.mockResolvedValue(ok(null));
backend.sshSecret.mockResolvedValue(ok(null));
backend.statements.mockImplementation(async (_id: string, text: string) => ok([{ text, from: 0, to: text.length }]));
});
afterEach(() => {
useProject.setState(initialProject, true);
useDatabase.setState(initial, true);
});
describe("database metadata request lifetimes", () => {
it.each(cases)("loads $name normally", async (spec) => {
await spec.load("pg");
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
const boundaries = [
{ name: "disconnect", run: () => disconnect("pg") },
{ name: "delete", run: () => deleteSource("pg") },
{ name: "edit", run: () => saveSource({ ...pg, host: "new-host" }, undefined) },
{ name: "project switch with the same source ID", run: async () => switchProject(projectB) },
{
name: "project A to B to A",
run: async () => {
switchProject(projectB);
switchProject(projectA);
},
},
];
for (const boundary of boundaries) {
for (const outcome of ["success", "failure"] as const) {
it.each(cases)(`ignores late ${outcome} for $name after ${boundary.name}`, async (spec) => {
const response = deferred();
spec.mock.mockReturnValueOnce(response.promise);
const pending = spec.load("pg");
await boundary.run();
const cache = spec.read("pg");
const connected = [...useDatabase.getState().connected];
if (outcome === "success") response.resolve(ok(spec.stale));
else response.reject(new Error("old connection failed"));
await pending;
expect.soft(spec.read("pg")).toEqual(cache);
expect([...useDatabase.getState().connected]).toEqual(connected);
});
}
}
it.each(cases)("keeps the latest $name when responses arrive out of order", async (spec) => {
const old = deferred();
spec.mock.mockReturnValueOnce(old.promise);
const pending = spec.load("pg");
await spec.load("pg");
old.resolve(ok(spec.stale));
await pending;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
it.each(cases)("does not replace fresh $name with an older failure", async (spec) => {
const old = deferred();
spec.mock.mockReturnValueOnce(old.promise);
const pending = spec.load("pg");
await spec.load("pg");
old.reject(new Error("old failure"));
await pending;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
it.each(cases)("keeps fresh $name after disconnect and reconnect", async (spec) => {
const old = deferred();
spec.mock.mockReturnValueOnce(old.promise);
const pending = spec.load("pg");
await disconnect("pg");
expect(await connect("pg")).toBe(true);
await spec.load("pg");
old.resolve(ok(spec.stale));
await pending;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
expect(useDatabase.getState().connected.has("pg")).toBe(true);
});
it.each(cases)("keeps a pending fresh $name when the old reply arrives first", async (spec) => {
const old = deferred();
const fresh = deferred();
spec.mock.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
const previous = spec.load("pg");
await disconnect("pg");
await connect("pg");
const next = spec.load("pg");
old.resolve(ok(spec.stale));
await previous;
expect(spec.read("pg")).toEqual({ status: "loading" });
fresh.resolve(ok(spec.value));
await next;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
it.each(cases)("does not restore deleted $name after the source ID is reused", async (spec) => {
const old = deferred();
spec.mock.mockReturnValueOnce(old.promise);
const previous = spec.load("pg");
await deleteSource("pg");
savedSources = [pg, other];
await loadSources();
await spec.load("pg");
old.resolve(ok(spec.stale));
await previous;
expect(spec.read("pg")).toEqual({ status: "ok", value: spec.value });
});
it.each(cases)("does not invalidate another source's pending $name", async (spec) => {
const response = deferred();
spec.mock.mockReturnValueOnce(response.promise);
const pending = spec.load("pg2");
await disconnect("pg");
response.resolve(ok(spec.value));
await pending;
expect(spec.read("pg2")).toEqual({ status: "ok", value: spec.value });
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("invalidates every database of a source", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg:app");
await disconnect("pg");
response.resolve(ok(["old"]));
await pending;
expect(useDatabase.getState().schemas["pg:app"]).toBeUndefined();
expect(useDatabase.getState().connected.has("pg:app")).toBe(false);
});
it("disconnects one database without invalidating its sibling", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg:other");
await disconnect("pg:app");
response.resolve(ok(["public"]));
await pending;
expect(useDatabase.getState().schemas["pg:other"]).toEqual({ status: "ok", value: ["public"] });
});
it("does not request extra schema objects after the table request becomes stale", async () => {
const response = deferred();
backend.tables.mockReturnValueOnce(response.promise);
const pending = loadTables("pg", "public");
await disconnect("pg");
response.resolve(ok([]));
await pending;
expect(backend.objects).not.toHaveBeenCalled();
});
it("ignores schema objects that were already in flight at disconnect", async () => {
const response = deferred();
backend.objects.mockReturnValueOnce(response.promise);
const pending = loadTables("pg", "public");
await vi.waitFor(() => expect(backend.objects).toHaveBeenCalledOnce());
await disconnect("pg");
response.resolve(ok({ routines: [], packages: [], sequences: [{ name: "old" }] }));
await pending;
expect(useDatabase.getState().tables["pg\u0000public"]).toBeUndefined();
expect(useDatabase.getState().objects["pg\u0000public"]).toBeUndefined();
});
it("still lists tables when optional objects cannot be read", async () => {
backend.objects.mockRejectedValueOnce(new Error("permission denied"));
await loadTables("pg", "public");
expect(useDatabase.getState().tables["pg\u0000public"]?.status).toBe("ok");
});
it("keeps metadata in flight when an unchanged source list is reloaded", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg");
await loadSources();
response.resolve(ok(["public"]));
await pending;
expect(useDatabase.getState().schemas.pg).toEqual({ status: "ok", value: ["public"] });
});
it("keeps metadata in flight when the current project is renamed", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg");
useProject.setState({ project: { ...projectA, name: "Renamed" } });
response.resolve(ok(["public"]));
await pending;
expect(useDatabase.getState().schemas.pg).toEqual({ status: "ok", value: ["public"] });
});
});
describe("connection and metadata continuations", () => {
it("reloads every level the tree had expanded after a data source is edited", async () => {
await loadSchemas("pg");
await loadTables("pg", "public");
await loadDetails("pg", "public", "users");
await saveSource({ ...pg, host: "new-host" }, undefined);
const st = useDatabase.getState();
expect(st.schemas.pg).toEqual({ status: "ok", value: ["public"] });
expect(st.tables["pg\u0000public"]?.status).toBe("ok");
expect(st.details["pg\u0000public\u0000users"]?.status).toBe("ok");
});
it("stores credentials when the save emits a source-change event before returning", async () => {
backend.save.mockImplementationOnce(async (sources: DataSource[]) => {
savedSources = sources;
await loadSources();
return ok(null);
});
await saveSource({ ...pg, host: "new-host" }, "password", "ssh-secret");
expect(backend.password).toHaveBeenCalledWith("pg", "password", false);
expect(backend.sshSecret).toHaveBeenCalledWith("pg", "ssh-secret", false);
expect(useDatabase.getState().sources.find((s) => s.id === "pg")?.host).toBe("new-host");
});
it("does not let a pending disconnect clear a newer explicit connection", async () => {
const response = deferred();
backend.disconnect.mockReturnValueOnce(response.promise);
const pending = disconnect("pg");
expect(await connect("pg")).toBe(true);
await loadSchemas("pg");
response.resolve(ok(null));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(true);
expect(useDatabase.getState().schemas.pg?.status).toBe("ok");
});
it("does not restore connected state from a listing started before disconnect", async () => {
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
const pending = loadSources();
await disconnect("pg");
response.resolve(ok(["pg"]));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("does not clear a new connection with an old empty listing", async () => {
useDatabase.setState({ connected: new Set() });
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
const pending = loadSources();
await connect("pg");
response.resolve(ok([]));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(true);
});
it("keeps listing source definitions when its connected snapshot is stale", async () => {
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
backend.sources.mockResolvedValueOnce(ok([...savedSources, { id: "new", name: "New", driver: "sqlite" }]));
const pending = loadSources();
await disconnect("pg");
response.resolve(ok(["pg"]));
await pending;
expect(useDatabase.getState().sources.map((s) => s.id)).toEqual(["pg", "pg2", "new"]);
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
for (const outcome of ["success", "failure"] as const) {
it(`ignores a source-list ${outcome} from project A after A to B to A`, async () => {
const response = deferred();
backend.sources.mockReturnValueOnce(response.promise);
const pending = loadSources();
switchProject(projectB);
switchProject(projectA);
if (outcome === "success") response.resolve(ok([{ ...pg, name: "Old" }]));
else response.reject(new Error("old sources failure"));
await pending;
expect(useDatabase.getState().sources).toEqual([pg, other]);
expect(ui.notify).not.toHaveBeenCalled();
});
}
it("can load metadata again after a disconnect fails", async () => {
backend.disconnect.mockRejectedValueOnce(new Error("disconnect failed"));
await expect(disconnect("pg")).rejects.toThrow("disconnect failed");
expect(useDatabase.getState().connected.has("pg")).toBe(true);
await loadSchemas("pg");
expect(useDatabase.getState().schemas.pg).toEqual({ status: "ok", value: ["public"] });
});
it("stops refresh between metadata stages", async () => {
const response = deferred();
backend.databases.mockReturnValueOnce(response.promise);
useDatabase.setState({ schemas: { pg: { status: "ok", value: ["public"] } } });
const pending = refresh("pg");
await disconnect("pg");
response.resolve(ok({ current: "app", names: ["app"] }));
await pending;
expect(backend.schemas).not.toHaveBeenCalled();
});
it.each(["table", undefined] as const)(
"stops tree reveal after its connection becomes stale (kind %s)",
async (kind) => {
const response = deferred();
backend.connect.mockReturnValueOnce(response.promise);
const pending = revealInTree("pg", "public", "users", kind);
await disconnect("pg");
response.resolve(ok(null));
await pending;
expect(backend.databases).not.toHaveBeenCalled();
expect(backend.schemas).not.toHaveBeenCalled();
expect(useDatabase.getState().treeReveal).toBeNull();
},
);
it.each(["table", undefined] as const)(
"does not select a tree node after its pending details become stale (kind %s)",
async (kind) => {
useDatabase.setState({
databases: { pg: { status: "ok", value: { current: "app", names: ["app"] } } },
schemas: { pg: { status: "ok", value: ["public"] } },
tables: { "pg\u0000public": { status: "ok", value: [{ name: "users", kind: "view" }] } },
});
const response = deferred();
backend.details.mockReturnValueOnce(response.promise);
const pending = revealInTree("pg", "public", "users", kind, "id");
await vi.waitFor(() => expect(backend.details).toHaveBeenCalledOnce());
await disconnect("pg");
response.resolve(ok(details));
await pending;
expect(useDatabase.getState().treeReveal).toBeNull();
expect(useDatabase.getState().treeExpanded.size).toBe(0);
expect(ui.show).not.toHaveBeenCalled();
},
);
it("infers a view's folder while revealing a current table editor", async () => {
useDatabase.setState({
databases: { pg: { status: "ok", value: { current: "app", names: ["app"] } } },
schemas: { pg: { status: "ok", value: ["public"] } },
tables: { "pg\u0000public": { status: "ok", value: [{ name: "users", kind: "view" }] } },
});
await revealInTree("pg", "public", "users", undefined);
expect(useDatabase.getState().treeReveal?.path.at(-1)).toBe("pg/public/views/users");
expect(ui.show).toHaveBeenCalledWith("database");
expect(backend.connect).not.toHaveBeenCalled();
});
it("preserves successful write results without restoring a stale connection", async () => {
const response = deferred<number>();
const pending = withConnection("pg", () => response.promise);
await disconnect("pg");
response.resolve(7);
expect(await pending).toBe(7);
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("does not prompt for credentials after the request becomes stale", async () => {
const response = deferred();
backend.schemas.mockReturnValueOnce(response.promise);
const pending = loadSchemas("pg");
await disconnect("pg");
response.resolve({ status: "error", error: { kind: "permissionDenied", message: "authentication failed" } });
let settled = false;
void pending.then(() => {
settled = true;
});
await vi.waitFor(() => expect(settled || useDatabase.getState().dialog?.kind === "password").toBe(true));
const dialog = useDatabase.getState().dialog;
expect.soft(dialog).toBeNull();
if (dialog?.kind === "password") dialog.resolve(false);
await pending;
expect(backend.schemas).toHaveBeenCalledOnce();
});
it("does not retry if disconnected while waiting for a password", async () => {
backend.schemas.mockResolvedValueOnce({
status: "error",
error: { kind: "permissionDenied", message: "authentication failed" },
});
const pending = loadSchemas("pg");
await vi.waitFor(() => expect(useDatabase.getState().dialog?.kind).toBe("password"));
const dialog = useDatabase.getState().dialog;
if (dialog?.kind !== "password") throw new Error("Expected password prompt");
await disconnect("pg");
useDatabase.setState({ dialog: null });
dialog.resolve(true);
await pending;
expect(backend.schemas).toHaveBeenCalledOnce();
expect(useDatabase.getState().schemas.pg).toBeUndefined();
});
it("still retries a current request after a password is supplied", async () => {
backend.schemas.mockResolvedValueOnce({
status: "error",
error: { kind: "permissionDenied", message: "authentication failed" },
});
const pending = loadSchemas("pg");
await vi.waitFor(() => expect(useDatabase.getState().dialog?.kind).toBe("password"));
const dialog = useDatabase.getState().dialog;
if (dialog?.kind !== "password") throw new Error("Expected password prompt");
useDatabase.setState({ dialog: null });
dialog.resolve(true);
await pending;
expect(backend.schemas).toHaveBeenCalledTimes(2);
expect(useDatabase.getState().schemas.pg?.status).toBe("ok");
});
for (const [name, mutate] of [
["save", () => saveSource({ ...pg, host: "new" }, "secret", "ssh")],
["delete", () => deleteSource("pg")],
["rename", () => renameSource("pg", "Renamed")],
] as const) {
it(`does not continue ${name} in a different project`, async () => {
const response = deferred();
backend.save.mockReturnValueOnce(response.promise);
const pending = mutate();
switchProject(projectB);
response.resolve(ok(null));
await pending;
expect(backend.sources).not.toHaveBeenCalled();
expect(backend.password).not.toHaveBeenCalled();
expect(backend.sshSecret).not.toHaveBeenCalled();
});
}
});
describe("completion requests", () => {
it("deduplicates requests in the same lifetime", async () => {
const response = deferred();
backend.completion.mockReturnValueOnce(response.promise);
const first = loadCompletion("pg");
const second = loadCompletion("pg");
expect(first).toBe(second);
response.resolve(ok(completion));
expect(await first).toEqual(completion);
expect(backend.completion).toHaveBeenCalledOnce();
});
it("ignores completion resolved after disconnect", async () => {
const response = deferred();
backend.completion.mockReturnValueOnce(response.promise);
const pending = loadCompletion("pg");
await disconnect("pg");
response.resolve(ok(completion));
expect(await pending).toBeNull();
expect(useDatabase.getState().completion.pg).toBeUndefined();
});
it("does not reuse old completion or let its cleanup remove a newer request", async () => {
const old = deferred();
const fresh = deferred();
backend.completion.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
const previous = loadCompletion("pg");
await disconnect("pg");
const next = loadCompletion("pg");
expect(next).not.toBe(previous);
old.resolve(ok({ ...completion, defaultSchema: "old" }));
expect(await previous).toBeNull();
expect(loadCompletion("pg")).toBe(next);
fresh.resolve(ok(completion));
expect(await next).toEqual(completion);
expect(backend.completion).toHaveBeenCalledTimes(2);
});
it("lets completion recover after failure", async () => {
backend.completion.mockRejectedValueOnce(new Error("not available"));
expect(await loadCompletion("pg")).toBeNull();
expect(await loadCompletion("pg")).toEqual(completion);
expect(backend.completion).toHaveBeenCalledTimes(2);
});
});
describe("overlapping database operations", () => {
it("failed reconnect does not preserve the old disconnected flag", async () => {
const response = deferred();
backend.disconnect.mockReturnValueOnce(response.promise);
const pending = disconnect("pg");
backend.connect.mockRejectedValueOnce(new Error("new connection failed"));
expect(await connect("pg")).toBe(false);
response.resolve(ok(null));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("successful implicit reconnect survives the old disconnect acknowledgement", async () => {
const response = deferred();
backend.disconnect.mockReturnValueOnce(response.promise);
const pending = disconnect("pg");
await loadSchemas("pg");
response.resolve(ok(null));
await pending;
expect(useDatabase.getState().schemas.pg?.status).toBe("ok");
expect(useDatabase.getState().connected.has("pg")).toBe(true);
});
it("unrelated local connect does not keep an edited source marked connected", async () => {
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
backend.sources.mockResolvedValueOnce(ok([{ ...pg, host: "changed" }, other]));
const pending = loadSources();
await connect("pg2");
response.resolve(ok([]));
await pending;
expect.soft(useDatabase.getState().connected.has("pg2")).toBe(true);
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
});
it("saving a source must not reconnect after a later disconnect", async () => {
const response = deferred();
backend.save.mockReturnValueOnce(response.promise);
useDatabase.setState({ databases: { pg: { status: "ok", value: { current: "app", names: ["app"] } } } });
const pending = saveSource({ ...pg, host: "changed" }, undefined);
await disconnect("pg");
savedSources = [{ ...pg, host: "changed" }, other];
response.resolve(ok(null));
await pending;
expect(backend.databases).not.toHaveBeenCalled();
expect(useDatabase.getState().connected.has("pg")).toBe(false);
});
it("whole-source refresh does not restart a disconnected child database", async () => {
useDatabase.setState({
connected: new Set(["pg", "pg:app", "pg:other"]),
schemas: {
"pg:app": { status: "ok", value: ["public"] },
"pg:other": { status: "ok", value: ["public"] },
},
tables: {
"pg:app\u0000public": { status: "ok", value: [{ name: "users", kind: "table" }] },
"pg:other\u0000public": { status: "ok", value: [{ name: "users", kind: "table" }] },
},
});
const response = deferred();
backend.schemas.mockImplementation((id: string) =>
id === "pg:app" ? response.promise : Promise.resolve(ok(["public"])),
);
const pending = refresh("pg");
await vi.waitFor(() => expect(backend.schemas).toHaveBeenCalledTimes(2));
await disconnect("pg:app");
response.resolve(ok(["public"]));
await pending;
expect(backend.tables.mock.calls.map((c) => c[0])).toEqual(["pg:other"]);
expect(useDatabase.getState().schemas["pg:app"]).toBeUndefined();
});
it("late successful DDL does not refresh metadata after disconnect", async () => {
const response = deferred();
backend.execute.mockReturnValueOnce(response.promise);
const text = "CREATE TABLE test (id int)";
const pending = runSql("pg", [text]);
await disconnect("pg");
response.resolve(ok([{ statement: { text, from: 0, to: text.length }, result: null, error: null }]));
expect(await pending).toBe(true);
expect.soft(backend.databases).not.toHaveBeenCalled();
expect(useDatabase.getState().output).toEqual([]);
});
it("old SQL is not executed in a new project after completion finishes", async () => {
useDatabase.setState({ consoles: { "console.sql": "pg" } });
const response = deferred();
backend.completion.mockReturnValueOnce(response.promise);
const text = "DELETE FROM users";
const pending = executeConsole("console.sql", text, 0, text.length);
await vi.waitFor(() => expect(backend.completion).toHaveBeenCalledOnce());
switchProject(projectB);
response.resolve(ok(completion));
await pending;
expect(backend.execute).not.toHaveBeenCalled();
});
it("querying a source's default database does not freeze a sibling connection snapshot", async () => {
useDatabase.setState({ connected: new Set(["pg", "pg:app"]) });
const response = deferred();
backend.connected.mockReturnValueOnce(response.promise);
const pending = loadSources();
await loadSchemas("pg");
response.resolve(ok(["pg"]));
await pending;
expect(useDatabase.getState().connected.has("pg")).toBe(true);
expect(useDatabase.getState().connected.has("pg:app")).toBe(false);
});
+35
View File
@@ -0,0 +1,35 @@
import { useProject } from "@/platform/project";
import { splitTarget } from "./model";
let projectGeneration = 0;
const generations = new Map<string, object>();
useProject.subscribe((state, previous) => {
if (state.project?.root === previous.project?.root) return;
projectGeneration++;
generations.clear();
});
function generation(id: string): object {
let value = generations.get(id);
if (!value) generations.set(id, (value = {}));
return value;
}
/** A return to the same project or target is a new lifetime, too. */
export function projectRequest(): () => boolean {
const project = projectGeneration;
return () => project === projectGeneration;
}
export function databaseRequest(id: string): () => boolean {
const project = projectRequest();
const sourceId = splitTarget(id).id;
const source = generation(sourceId);
const target = generation(id);
return () => project() && generations.get(sourceId) === source && generations.get(id) === target;
}
export function invalidateDatabaseRequests(id: string) {
generations.set(id, {});
}
+232 -52
View File
@@ -25,9 +25,10 @@ import {
type RequestId,
type SqlStatement,
} from "@/platform/ipc";
import { editorsFor } from "@/editor/api";
import { activeEditor, editorsFor } from "@/editor/api";
import {
editorService,
maybeEditorService,
notifications,
showServiceView,
toolWindowService,
@@ -44,14 +45,18 @@ import {
idleTargets,
newSourceId,
splitTarget,
targetOf,
} from "./model";
import { tableFolder, type DbNode } from "./nodes";
import { SQL_LANGUAGE } from "./sqlLanguage";
import { TABLE_KIND, type TableRef } from "./tableInput";
import type { ExportData, ExportFormat } from "./export";
import type { DbObjectRef } from "./objects";
import { formatted } from "./sql/format";
import { bindParams, findParams } from "./sql/params";
import { isReadOnlyQuery } from "./sql/readOnly";
import { pageFetch } from "./resultView";
import { databaseRequest, invalidateDatabaseRequests, projectRequest } from "./requests";
import { tx } from "@/platform/i18n";
export const DB_TOOL_WINDOW = "database";
@@ -180,7 +185,7 @@ interface DatabaseState {
/** Expanded nodes of the Database tree. */
treeExpanded: Set<string>;
/** A tree node to select and scroll to (Go to Declaration from a console). */
treeReveal: { key: string; seq: number } | null;
treeReveal: { path: string[]; seq: number } | null;
/** Last value entered per query parameter name. */
paramValues: Record<string, string>;
/** Data sources in manual-commit mode. */
@@ -284,19 +289,43 @@ export function connectedTargets(id: string): string[] {
}
let sourcesRequest = 0;
const connectionIntents = new Map<string, object>();
const sourceConnectionIntents = new Map<string, object>();
const sourceMutations = new Map<string, object>();
function sourceMutation(id: string): () => boolean {
const project = projectRequest();
const intent = {};
sourceMutations.set(id, intent);
return () => project() && sourceMutations.get(id) === intent;
}
export async function loadSources(): Promise<void> {
const request = ++sourcesRequest;
const root = useProject.getState().project?.root;
const current = projectRequest();
const intents = new Map(connectionIntents);
const sourceIntents = new Map(sourceConnectionIntents);
try {
const [sources, connected] = await Promise.all([call(commands.dbSources()), call(commands.dbConnected())]);
if (request !== sourcesRequest || useProject.getState().project?.root !== root) return;
if (request !== sourcesRequest || !current()) return;
for (const old of useDatabase.getState().sources) {
if (JSON.stringify(old) !== JSON.stringify(sources.find((s) => s.id === old.id))) forget(old.id ?? "");
}
useDatabase.setState({ sources, connected: new Set(connected), loaded: true });
useDatabase.setState((s) => {
const server = new Set(connected);
const targets = new Set([...server, ...s.connected]);
const live = [...targets].filter((target) => {
const sourceId = splitTarget(target).id;
if (!sources.some((source) => source.id === sourceId)) return false;
const changed =
intents.get(target) !== connectionIntents.get(target) ||
sourceIntents.get(sourceId) !== sourceConnectionIntents.get(sourceId);
return changed ? s.connected.has(target) : server.has(target);
});
return { sources, connected: new Set(live), loaded: true };
});
} catch (e) {
if (request !== sourcesRequest || useProject.getState().project?.root !== root) return;
if (request !== sourcesRequest || !current()) return;
useDatabase.setState({ loaded: true });
notifications().notify({ level: "error", title: "Cannot read data sources", body: message(e), key: "db-sources" });
}
@@ -304,15 +333,19 @@ export async function loadSources(): Promise<void> {
/** Drops cached metadata of target `id` (refresh, disconnect); a data source id drops all its databases. */
function forget(id: string) {
invalidateDatabaseRequests(id);
const whole = !splitTarget(id).database;
const ofTarget = (t: string) => t === id || (whole && splitTarget(t).id === id);
const dropKeys = <T>(m: Record<string, T>) =>
Object.fromEntries(Object.entries(m).filter(([k]) => !ofTarget(k.split("\u0000")[0]!)));
useDatabase.setState((s) => {
const databases = { ...s.databases };
const catalogs = { ...s.catalogs };
if (whole) delete databases[id];
if (whole) delete catalogs[id];
return {
databases,
catalogs,
schemas: dropKeys(s.schemas),
completion: dropKeys(s.completion),
tables: dropKeys(s.tables),
@@ -327,22 +360,30 @@ export async function saveSource(
password: string | null | undefined,
sshSecret: string | null | undefined = undefined,
): Promise<void> {
const id = source.id ?? "";
const st = useDatabase.getState();
const loaded = id in st.databases || Object.keys(st.schemas).some((t) => splitTarget(t).id === id);
// Taken before anything is forgotten: what the tree had expanded under this source.
const levels = loadedLevels(id);
const connection = connectionIntents.get(id);
forget(id);
const current = sourceMutation(id);
const sources = useDatabase.getState().sources;
const next = sources.some((s) => s.id === source.id)
? sources.map((s) => (s.id === source.id ? source : s))
: [...sources, source];
await call(commands.dbSaveSources(next));
if (!current()) return;
// `undefined`: keep; `null`: forget; string: store.
const id = source.id ?? "";
if (password !== undefined) await call(commands.dbSetPassword(id, password, !!source.savePassword));
if (!current()) return;
if (sshSecret !== undefined) await call(commands.dbSetSshSecret(id, sshSecret, !!source.ssh?.saveSecret));
if (!current()) return;
// What the tree had loaded for this source is reloaded with the new settings, so its
// expanded nodes don't stay on Loading.
const st = useDatabase.getState();
const loaded = id in st.databases || Object.keys(st.schemas).some((t) => splitTarget(t).id === id);
await loadSources();
if (loaded) await refresh(id);
else forget(id);
if (!current() || !sourceById(id)) return;
if (loaded && connectionIntents.get(id) === connection) await refresh(id, levels);
}
/** Copy/Paste › Duplicate: a copy of data source `id` named `<name> [2]` (its password is not copied). */
@@ -366,7 +407,10 @@ export async function renameSource(id: string, name: string): Promise<void> {
const src = sourceById(id);
if (!src || !name.trim() || name.trim() === src.name) return;
const sources = useDatabase.getState().sources.map((s) => (s.id === src.id ? { ...s, name: name.trim() } : s));
forget(id);
const current = sourceMutation(id);
await call(commands.dbSaveSources(sources));
if (!current()) return;
await loadSources();
}
@@ -384,8 +428,10 @@ export async function removeSource(source: DataSource): Promise<void> {
}
export async function deleteSource(id: string): Promise<void> {
await call(commands.dbSaveSources(useDatabase.getState().sources.filter((s) => s.id !== id)));
forget(id);
const current = sourceMutation(id);
await call(commands.dbSaveSources(useDatabase.getState().sources.filter((s) => s.id !== id)));
if (!current()) return;
await loadSources();
}
@@ -454,9 +500,13 @@ function applyConnectDefaults(id: string) {
/** Runs `op`, asking for passwords / host key trust and retrying when the connection needs them. */
export async function withConnection<T>(id: string, op: () => Promise<T>): Promise<T> {
const current = databaseRequest(id);
for (;;) {
try {
const r = await op();
// A completed write keeps its result, but must not revive an old connection.
if (!current()) return r;
connectionIntents.set(id, {});
lastUsed.set(id, Date.now());
if (!useDatabase.getState().connected.has(id)) {
useDatabase.setState((s) => ({ connected: new Set(s.connected).add(id) }));
@@ -464,16 +514,19 @@ export async function withConnection<T>(id: string, op: () => Promise<T>): Promi
}
return r;
} catch (e) {
if (!current()) throw new IpcError({ kind: "cancelled", message: "Cancelled", data: null });
const source = sourceById(id);
if (!source || !(await resolveConnectError(e, source))) throw e;
if (!current()) throw new IpcError({ kind: "cancelled", message: "Cancelled", data: null });
}
}
}
export async function connect(id: string): Promise<boolean> {
const current = databaseRequest(id);
try {
await withConnection(id, () => call(commands.dbConnect(id)));
return true;
return current();
} catch (e) {
if (!isCancelled(e))
notifications().notify({ level: "error", title: `Cannot connect to ${targetLabel(id)}`, body: message(e) });
@@ -485,11 +538,21 @@ export async function connect(id: string): Promise<boolean> {
export async function disconnect(id: string): Promise<void> {
const targets = splitTarget(id).database ? [id] : connectedTargets(id);
if (!targets.includes(id)) targets.push(id);
await Promise.all(targets.map((t) => call(commands.dbDisconnect(t))));
const current = projectRequest();
forget(id);
const intent = {};
for (const target of targets) connectionIntents.set(target, intent);
if (!splitTarget(id).database) sourceConnectionIntents.set(id, {});
await Promise.all(targets.map((t) => call(commands.dbDisconnect(t))));
if (!current()) return;
if (!splitTarget(id).database) sourceConnectionIntents.set(id, {});
useDatabase.setState((s) => {
const connected = new Set(s.connected);
for (const t of targets) connected.delete(t);
for (const t of targets) {
if (connectionIntents.get(t) !== intent) continue;
connected.delete(t);
connectionIntents.set(t, {});
}
return { connected };
});
}
@@ -527,11 +590,14 @@ export function listsDatabases(source: DataSource | undefined): boolean {
);
}
/**
* Re-reads the metadata of target `id` (a data source id: all its databases),
* reloading the levels that were loaded so the tree keeps its expanded nodes.
*/
export async function refresh(id: string): Promise<void> {
interface LoadedLevels {
schemas: string[];
tables: [string, string][];
details: [string, string, string][];
}
/** The metadata of target `id` (a data source id: all its databases) the tree has loaded. */
function loadedLevels(id: string): LoadedLevels {
const whole = !splitTarget(id).database;
const ofTarget = (k: string) => {
const t = k.split("\u0000")[0]!;
@@ -540,86 +606,131 @@ export async function refresh(id: string): Promise<void> {
const st = useDatabase.getState();
const loaded = (m: Record<string, Loaded<unknown>>) =>
Object.keys(m).filter((k) => ofTarget(k) && m[k]!.status === "ok");
const schemas = loaded(st.schemas);
const tables = loaded(st.tables).map((k) => k.split("\u0000") as [string, string]);
const details = loaded(st.details).map((k) => k.split("\u0000") as [string, string, string]);
return {
schemas: loaded(st.schemas),
tables: loaded(st.tables).map((k) => k.split("\u0000") as [string, string]),
details: loaded(st.details).map((k) => k.split("\u0000") as [string, string, string]),
};
}
/**
* Re-reads the metadata of target `id` (a data source id: all its databases),
* reloading the levels that were loaded (`levels`) so the tree keeps its expanded nodes.
*/
export async function refresh(id: string, levels = loadedLevels(id)): Promise<void> {
const whole = !splitTarget(id).database;
const { tables, details } = levels;
const schemas = [...levels.schemas];
forget(id);
const current = databaseRequest(id);
const targets = new Map(
[...new Set([...schemas, ...tables.map(([t]) => t), ...details.map(([t]) => t)])].map((target) => [
target,
databaseRequest(target),
]),
);
const valid = (target: string) => targets.get(target)?.() ?? current();
if (whole && listsDatabases(sourceById(id))) await loadDatabases(id);
else if (!schemas.includes(id)) schemas.push(id);
await Promise.all(schemas.map((t) => loadSchemas(t)));
await Promise.all(tables.map(([t, schema]) => loadTables(t, schema)));
if (!current()) return;
await Promise.all(schemas.filter(valid).map((t) => loadSchemas(t)));
if (!current()) return;
await Promise.all(tables.filter(([t]) => valid(t)).map(([t, schema]) => loadTables(t, schema)));
if (!current()) return;
const exists = (t: string, schema: string, table: string) => {
const l = useDatabase.getState().tables[tablesKey(t, schema)];
return l?.status === "ok" && l.value.some((x) => x.name === table);
};
await Promise.all(details.filter((d) => exists(...d)).map(([t, schema, table]) => loadDetails(t, schema, table)));
await Promise.all(
details.filter((d) => valid(d[0]) && exists(...d)).map(([t, schema, table]) => loadDetails(t, schema, table)),
);
}
function metadataRequest(id: string, cache: "databases" | "schemas" | "tables" | "details", key = id) {
const current = databaseRequest(id);
const loading = { status: "loading" as const };
useDatabase.setState((s) => ({ ...s, [cache]: { ...s[cache], [key]: loading } }));
return () => current() && useDatabase.getState()[cache][key] === loading;
}
export async function loadDatabases(id: string): Promise<void> {
useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: { status: "loading" } } }));
const current = metadataRequest(id, "databases");
try {
const value = await withConnection(id, () => call(commands.dbDatabases(id)));
if (!current()) return;
useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: { status: "ok", value } } }));
} catch (e) {
if (!current()) return;
useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: { status: "error", message: message(e) } } }));
}
}
export async function loadSchemas(id: string): Promise<void> {
useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: { status: "loading" } } }));
const current = metadataRequest(id, "schemas");
try {
const value = await withConnection(id, () => call(commands.dbSchemas(id)));
if (!current()) return;
useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: { status: "ok", value } } }));
} catch (e) {
if (!current()) return;
useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: { status: "error", message: message(e) } } }));
}
}
export async function loadTables(id: string, schema: string): Promise<void> {
const key = tablesKey(id, schema);
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "loading" } } }));
const current = metadataRequest(id, "tables", key);
try {
const value = await withConnection(id, () => call(commands.dbTables(id, schema)));
if (!current()) return;
// Routines / sequences are extras: a server refusing their catalog still lists tables.
const objects = await call(commands.dbSchemaObjects(id, schema)).catch(() => null);
if (!current()) return;
useDatabase.setState((s) => ({
tables: { ...s.tables, [key]: { status: "ok", value } },
objects: objects ? { ...s.objects, [key]: objects } : s.objects,
}));
} catch (e) {
if (!current()) return;
useDatabase.setState((s) => ({ tables: { ...s.tables, [key]: { status: "error", message: message(e) } } }));
}
}
export async function loadDetails(id: string, schema: string, table: string): Promise<void> {
const key = detailsKey(id, schema, table);
useDatabase.setState((s) => ({ details: { ...s.details, [key]: { status: "loading" } } }));
const current = metadataRequest(id, "details", key);
try {
const value = await withConnection(id, () => call(commands.dbTableDetails(id, schema, table)));
if (!current()) return;
useDatabase.setState((s) => ({ details: { ...s.details, [key]: { status: "ok", value } } }));
} catch (e) {
if (!current()) return;
useDatabase.setState((s) => ({ details: { ...s.details, [key]: { status: "error", message: message(e) } } }));
}
}
/**
* Selects a table (or one of its columns) in the Database tool window,
* loading and expanding the nodes that lead to it.
* loading and expanding the nodes that lead to it. Without `kind` the loaded
* tables tell whether it is a table or a view.
*/
export async function revealInTree(
id: string,
schema: string,
table: string,
kind: DbTableKind,
kind: DbTableKind | undefined,
column?: string,
): Promise<void> {
const current = databaseRequest(id);
const loaded = (l: Loaded<unknown> | undefined) => l?.status === "ok";
const { id: sourceId, database } = splitTarget(id);
let databaseNode: string[] = [];
if (listsDatabases(sourceById(id))) {
if (!loaded(useDatabase.getState().databases[sourceId])) {
if (!(await connect(sourceId))) return;
if (!current()) return;
await loadDatabases(sourceId);
if (!current()) return;
}
const dbs = useDatabase.getState().databases[sourceId];
const name = database ?? (dbs?.status === "ok" ? dbs.value.current : null);
@@ -627,47 +738,110 @@ export async function revealInTree(
}
if (!loaded(useDatabase.getState().schemas[id])) {
if (!(await connect(id))) return;
if (!current()) return;
await loadSchemas(id);
if (!current()) return;
}
if (!loaded(useDatabase.getState().tables[tablesKey(id, schema)])) await loadTables(id, schema);
if (!current()) return;
if (column && !loaded(useDatabase.getState().details[detailsKey(id, schema, table)]))
await loadDetails(id, schema, table);
if (!current()) return;
const tables = useDatabase.getState().tables[tablesKey(id, schema)];
kind ??= (tables?.status === "ok" ? tables.value.find((t) => t.name === table)?.kind : undefined) ?? "table";
const folder = `${id}/${schema}/${tableFolder(kind)}`;
const tableKey = `${folder}/${table}`;
const open = [
sourceId,
...databaseNode,
`${id}/${schema}`,
folder,
...(column ? [tableKey, `${tableKey}/columns`] : []),
];
useDatabase.setState((s) => ({
treeExpanded: new Set([...s.treeExpanded, ...open]),
treeReveal: { key: column ? `${tableKey}/columns/${column}` : tableKey, seq: (s.treeReveal?.seq ?? 0) + 1 },
}));
toolWindowService().show(DB_TOOL_WINDOW, true);
selectInTree(
[sourceId, ...databaseNode, `${id}/${schema}`, folder, ...(column ? [tableKey, `${tableKey}/columns`] : [])],
column ? `${tableKey}/columns/${column}` : tableKey,
);
}
const completionLoads = new Map<string, Promise<DbCompletion | null>>();
/**
* Expands `open` and selects node `key` in the Database tool window, showing it.
* A node the tree doesn't show (filtered out, dropped) selects its nearest shown ancestor.
*/
function selectInTree(open: string[], key: string) {
useDatabase.setState((s) => ({
treeExpanded: new Set([...s.treeExpanded, ...open]),
treeReveal: { path: [...open, key], seq: (s.treeReveal?.seq ?? 0) + 1 },
}));
// Not `show(…, true)`: activation focuses the filter field after the view focuses the tree.
toolWindowService().show(DB_TOOL_WINDOW);
}
/**
* Select Opened File: the active table editor's table, else the schema (or
* database / data source) a SQL console runs in. A console reveals only nodes
* already loaded: it never connects.
*/
export async function revealOpened(): Promise<void> {
const input = maybeEditorService()?.activeInput?.();
if (input?.kind === TABLE_KIND) {
const { id, schema, table } = input.data as TableRef;
return revealInTree(id, schema, table, undefined);
}
const editor = activeEditor();
if (editor?.ctx.language !== SQL_LANGUAGE || (input && input.path !== editor.ctx.path)) return;
const target = consoleSource(editor.ctx.path);
if (!target) return;
const s = useDatabase.getState();
const { id: sourceId, database } = splitTarget(target);
const open: string[] = [];
let key = sourceId;
let nodeTarget = target;
if (listsDatabases(sourceById(sourceId))) {
const dbs = s.databases[sourceId];
const name = database ?? (dbs?.status === "ok" ? dbs.value.current : null);
if (dbs?.status !== "ok" || !name) return selectInTree(open, key);
open.push(key);
key = databaseKey(sourceId, name);
nodeTarget = targetOf(sourceId, name, dbs.value.current);
}
const schema = consoleSchema(editor.ctx.path);
const schemas = s.schemas[nodeTarget];
if (schema && schemas?.status === "ok" && schemas.value.includes(schema)) {
open.push(key);
key = `${nodeTarget}/${schema}`;
}
selectInTree(open, key);
}
const completionLoads = new Map<string, { current: () => boolean; promise: Promise<DbCompletion | null> }>();
/** The schema model of `id` for completion (cached; one load at a time). */
export function loadCompletion(id: string): Promise<DbCompletion | null> {
const cached = useDatabase.getState().completion[id];
if (cached) return Promise.resolve(cached);
let p = completionLoads.get(id);
if (!p) {
p = call(commands.dbCompletion(id))
let pending = completionLoads.get(id);
if (!pending?.current()) {
const current = databaseRequest(id);
const promise = call(commands.dbCompletion(id))
.then((model) => {
if (!current()) return null;
useDatabase.setState((s) => ({ completion: { ...s.completion, [id]: model } }));
return model;
})
.catch(() => null)
.finally(() => completionLoads.delete(id));
completionLoads.set(id, p);
.finally(() => {
if (completionLoads.get(id)?.promise === promise) completionLoads.delete(id);
});
pending = { current, promise };
completionLoads.set(id, pending);
}
return p;
return pending.promise;
}
useProject.subscribe((state, previous) => {
if (state.project?.root === previous.project?.root) return;
sourcesRequest++;
connectionIntents.clear();
sourceConnectionIntents.clear();
sourceMutations.clear();
completionLoads.clear();
lastUsed.clear();
});
// ---- consoles ----------------------------------------------------------------
export function bindConsole(path: string, id: string | null) {
@@ -784,10 +958,12 @@ async function run(
skip = 0,
schema?: string,
): Promise<DbStatementOutcome[]> {
const current = databaseRequest(id);
const wasConnected = useDatabase.getState().connected.has(id);
const outcomes = await withConnection(id, () =>
call(commands.dbExecute(request, id, statements, skip, maxRows, schema || null)),
);
if (!current()) return outcomes;
if (!wasConnected) log("info", "Connected");
const prompt = schema || promptOf(id);
for (const o of outcomes) {
@@ -925,8 +1101,9 @@ export async function executeConsole(
});
return [];
}
const current = databaseRequest(id);
const statements = await consoleStatements(id, text, from, to);
if (!statements.length) return [];
if (!current() || !statements.length) return [];
const prev = useDatabase.getState().running[path];
if (prev) void commands.cancelRequest(prev);
const request = nextRequestId();
@@ -934,7 +1111,9 @@ export async function executeConsole(
try {
const pageSize = useDatabase.getState().maxRows;
const schema = consoleSchema(path) || (await loadCompletion(id))?.defaultSchema || "";
if (!current()) return [];
const outcomes = await run(id, statements, request, pageSize, 0, schema);
if (!current() || useDatabase.getState().running[path] !== request) return outcomes;
const sets = outcomes.filter((o) => o.result?.columns.length);
const n = sets.length ? nextRun++ : 0;
const tabs: ResultTab[] = sets.map((outcome, i) => {
@@ -950,6 +1129,7 @@ export async function executeConsole(
showServiceView(RESULTS_VIEW, false);
return outcomes;
} catch (e) {
if (!current()) return [];
if (!isCancelled(e)) {
log("error", message(e));
notifications().notify({ level: "error", title: "Cannot execute", body: message(e), key: "db-execute" });
+29 -4
View File
@@ -31,6 +31,7 @@ import {
readableDdl,
refresh,
revealInTree,
revealOpened,
runSql,
sourceById,
tablesKey,
@@ -41,6 +42,7 @@ import {
} from "./store";
import { tableInput } from "./tableInput";
import { tx } from "@/platform/i18n";
import { databaseRequest } from "./requests";
/** Context key: the Database tree has focus. */
export const DB_VIEW_FOCUS = "databaseViewFocus";
@@ -98,18 +100,25 @@ async function tablesIn(id: string, schema: string): Promise<DbTableInfo[]> {
async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
const id = nodeTarget(n);
if (!id) return null;
const current = databaseRequest(id);
const one = async (schema: string, table: string) =>
readableDdl(await withConnection(id, () => call(commands.dbDdl(id, schema, table))), driverOf(id));
try {
const o = schemaObjectOf(n);
if (o)
return { id, text: await withConnection(id, () => call(commands.dbObjectDdl(id, o.schema, o.kind, o.name))) };
if (o) {
const text = await withConnection(id, () => call(commands.dbObjectDdl(id, o.schema, o.kind, o.name)));
return current() ? { id, text } : null;
}
const t = tableOf(n);
if (t) return { id, text: await one(t.schema, t.table) };
if (t) {
const text = await one(t.schema, t.table);
return current() ? { id, text } : null;
}
let schemas: string[] = [];
if (n.kind === "schema" || n.kind === "folder") schemas = [n.schema];
else if (n.kind === "source" || n.kind === "database") {
if (useDatabase.getState().schemas[id]?.status !== "ok") await loadSchemas(id);
if (!current()) return null;
const l = useDatabase.getState().schemas[id];
schemas = l?.status === "ok" ? l.value : [];
}
@@ -118,12 +127,17 @@ async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
const tables = (await tablesIn(id, schema)).filter(
(t) => n.kind !== "folder" || tableFolder(t.kind) === n.folder,
);
if (!current()) return null;
// Tables before views: views select from them.
tables.sort((a, b) => Number(a.kind !== "table") - Number(b.kind !== "table"));
for (const t of tables) parts.push(await one(schema, t.name));
for (const t of tables) {
parts.push(await one(schema, t.name));
if (!current()) return null;
}
}
return { id, text: parts.join("\n") };
} catch (e) {
if (!current()) return null;
notifications().notify({
level: "error",
title: "Cannot generate the DDL",
@@ -137,8 +151,10 @@ async function ddlOf(n: DbNode): Promise<{ id: string; text: string } | null> {
export async function insertScript(n: DbNode, kind: ScriptKind) {
const t = tableOf(n);
if (!t) return;
const current = databaseRequest(t.id);
const key = detailsKey(t.id, t.schema, t.table);
if (useDatabase.getState().details[key]?.status !== "ok") await loadDetails(t.id, t.schema, t.table);
if (!current()) return;
const d = useDatabase.getState().details[key];
const columns = d?.status === "ok" ? d.value.columns : [];
try {
@@ -485,6 +501,15 @@ export function registerDatabaseTreeActions(): () => void {
title: "Separate Procedures and Functions",
run: toggleSeparateProceduresAndFunctions,
}),
tree({
id: "db.selectOpened",
title: "Select Opened File",
icon: "locate",
keywords: ["locate", "scroll from editor", "scroll from source", "select in database view"],
// From the editor: a user-assigned shortcut works outside the Database view too.
keyWhen: undefined,
run: () => void revealOpened(),
}),
tree({
id: "db.expandAll",
title: "Expand All",
+5
View File
@@ -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
View File
@@ -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
View File
@@ -220,6 +220,7 @@ export type ScriptLanguage =
| "cpp"
| "dart"
| "elixir"
| "escript"
| "scala"
| "shebang";
@@ -259,6 +260,7 @@ export function scriptLanguage(path: string, content?: string): ScriptLanguage |
if (/\.(cpp|cc|cxx|c\+\+)$/.test(name)) return "cpp";
if (/\.dart$/.test(name)) return "dart";
if (/\.exs$/.test(name)) return "elixir";
if (/\.escript$/.test(name)) return "escript";
if (/\.(scala|sc)$/.test(name)) return "scala";
if (!name.includes(".") && shebang(content)) return "shebang";
return null;
@@ -313,6 +315,8 @@ export function toolsFor(lang: ScriptLanguage, content?: string): string[] {
return ["dart"];
case "elixir":
return ["elixir"];
case "escript":
return ["escript"];
case "scala":
return ["scala-cli"];
}
@@ -338,6 +342,7 @@ const INSTALL: Record<string, string> = {
cpp: "Install a C++ compiler: `xcode-select --install` (clang++) or `brew install gcc`.",
dart: "Install the Dart SDK (`brew install dart-sdk`).",
elixir: "Install Elixir (`brew install elixir`).",
escript: "Install Erlang/OTP (`brew install erlang`).",
scala: "Install Scala CLI (`brew install Virtuslab/scala-cli/scala-cli`).",
shell: "Install the shell named in the script's #! line.",
shebang: "Install the interpreter named in the script's #! line.",
@@ -905,6 +910,7 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
case "lua":
case "swift":
case "elixir":
case "escript":
return simple(lang, s, env, lang, () => [s.arg]);
case "r":
return simple(lang, s, env, "r", () => [s.arg]);
+1 -1
View File
@@ -25,7 +25,7 @@ import { loadEmmet } from "./emmet/model";
import { promptAbbreviation, UpdateTagDialogHost } from "./emmet/UpdateTagDialog";
import { tx } from "@/platform/i18n";
const EDITOR_WHEN = "textEditorActive && !editorReadOnly && (editorFocus || popupOpen || !toolWindowFocus)";
const EDITOR_WHEN = "textEditorActive && !editorReadOnly && (editorFocus || !toolWindowFocus)";
const MARKUP_PATH = /\.(html?|xhtml|shtml|vue|svelte|xml)$/i;
async function updateTagWithEmmet() {
@@ -124,7 +124,8 @@
color: var(--fg-muted);
}
.rowName {
flex: none;
flex: 0 1 auto;
min-width: 0;
max-width: 60%;
overflow: hidden;
color: var(--fg-strong);
@@ -150,6 +151,13 @@
font-size: var(--fs-tiny);
line-height: 16px;
}
.rowBadge {
flex: none;
}
.updateTag {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
}
.listEmpty {
padding: 8px 12px;
color: var(--fg-muted);
+31 -7
View File
@@ -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
View File
@@ -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
View File
@@ -3,7 +3,7 @@
* simulation in plain-browser development and tests (no network, ever).
*/
import { Channel } from "@tauri-apps/api/core";
import type { InstallEvent, RequestId, ToolInfo } 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
View File
@@ -1,6 +1,6 @@
/** Tool list + running installs. Installs show in the status-bar task list too. */
import { create } from "zustand";
import type { RequestId, ToolInfo } 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(() => {});
+5 -3
View File
@@ -82,6 +82,7 @@ import {
DIFF_KIND,
changeNodeDiff,
changeNodeDiffData,
changeTreeDiffData,
diffInput,
openDiff,
openVcsDiff,
@@ -205,7 +206,8 @@ const bottomLeft = (el: HTMLElement) => {
/**
* Activates a change (Enter, double-click, Show Diff); F7 continues through the other
* files of its group, in tree order. Enter and double-click follow IDEA's Show on
* files of its group, in tree order (Enter and double-click: of the whole tree, on into the
* unversioned files, as IDEA's Commit diff preview). Enter and double-click follow IDEA's Show on
* Double-Click (opensDiffOnActivate): the source file, or the diff where the commit
* interface puts it (openVcsDiff: a preview editor tab, or under the modal interface the
* front diff window, behind the modal Commit dialog, #1863). `forceDiff` is Show Diff and
@@ -217,7 +219,7 @@ function openNode(n: ChangeNode, roots: readonly ChangeNode[], forceDiff = false
void editorService().openFile(n.path, { focus: true });
return;
}
void openVcsDiff(changeNodeDiffData(roots, n), { preview: true });
void openVcsDiff((forceDiff ? changeNodeDiffData : changeTreeDiffData)(roots, n), { preview: true });
}
/** The setting behind Show on Double-Click (`None` follows the commit interface). */
@@ -630,7 +632,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
const active = maybeEditorService()?.activeInput?.();
if (active?.kind !== DIFF_KIND || active.key !== followedDiff.current) return;
if (diffInput(changeNodeDiff(node)).key === active.key) return;
void openDiff(changeNodeDiffData(roots, node), { preview: true, focus: false });
void openDiff(changeTreeDiffData(roots, node), { preview: true, focus: false });
};
// Reveal only after the ancestors have expanded, without moving keyboard focus.
+37 -1
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { chainNeighbour, revisionChangeDiffData, withChain, type DiffData } from "../diffInput";
import type { FileStatus } from "@/bindings";
import { buildChangeTree, changeFileNodes } from "../changes";
import {
chainNeighbour,
changeNodeDiffData,
changeTreeDiffData,
revisionChangeDiffData,
withChain,
type DiffData,
} from "../diffInput";
const diff = (path: string): DiffData => ({ path, left: { type: "rev", rev: "HEAD" }, right: { type: "worktree" } });
@@ -24,6 +33,33 @@ describe("diff chain", () => {
});
});
describe("the changes tree's diff chain", () => {
const fs = (rel: string, staged: FileStatus["staged"], unstaged: FileStatus["unstaged"]): FileStatus => ({
path: `/r/${rel}`,
relPath: rel,
staged,
unstaged,
renamedFrom: null,
});
const status = [fs("a.ts", null, "modified"), fs("b.ts", "modified", null), fs("new.ts", null, "untracked")];
it("goes on past the last change into the unversioned files, as IDEA's Commit diff does", () => {
const tree = buildChangeTree(status, "/r", false);
const last = changeFileNodes(tree).find((n) => n.path === "/r/b.ts")!;
const data = changeTreeDiffData(tree, last);
expect(data.chain?.map((d) => d.path)).toEqual(["/r/a.ts", "/r/b.ts", "/r/new.ts"]);
expect(chainNeighbour(data, 1)).toMatchObject({ path: "/r/new.ts" });
// Show Diff keeps to the file's group.
expect(chainNeighbour(changeNodeDiffData(tree, last), 1)).toBeNull();
});
it("walks every staging group in tree order", () => {
const tree = buildChangeTree(status, "/r", false, undefined, [], true);
const staged = changeFileNodes(tree).find((n) => n.group === "staged")!;
expect(chainNeighbour(changeTreeDiffData(tree, staged), 1)).toMatchObject({ path: "/r/a.ts" });
});
});
describe("combined changes of several commits", () => {
const base = { oldPath: null, before: "b".repeat(40), after: "a".repeat(40) };
@@ -5,6 +5,12 @@ import type { RemoteOp } from "@/bindings";
const mocks = vi.hoisted(() => ({
ops: [] as Array<{ repo: string | null; op: RemoteOp }>,
failing: new Set<string>(),
ask: vi.fn(),
}));
vi.mock("@/workbench/messages", async (importOriginal) => ({
...(await importOriginal<typeof import("@/workbench/messages")>()),
ask: mocks.ask,
}));
vi.mock("@tauri-apps/api/core", async (importOriginal) => ({
@@ -50,6 +56,8 @@ let notes: Note[] = [];
beforeEach(() => {
mocks.ops = [];
mocks.failing = new Set();
mocks.ask.mockReset();
mocks.ask.mockResolvedValue({ button: "force", checked: false });
notes = [];
useVcs.setState({ root: "/p", roots: ["/p/a", "/p/b", "/p/c"], repo: "/p/a", remoteBusy: null, available: false });
provideNotificationService({ notify: (n) => void notes.push(n as Note), expire: () => {} });
@@ -110,3 +118,66 @@ it("pushes each repository to its own remote and target branch, reporting the on
await waitFor(() => expect(notes.map((n) => n.title)).toContain("Push partially failed"));
expect(notes.find((n) => n.title === "Push partially failed")?.body).toContain("Not pushed: b.");
});
const forceOf = (ops: typeof mocks.ops) =>
ops.map(({ repo, op }) => [repo, "force_with_lease" in op ? op.force_with_lease : null]);
const askedTargets = () => mocks.ask.mock.calls.map(([r]) => (r as { body: string }).body.match(/to '(.*)'/)?.[1]);
async function openPushDialog() {
render(<VcsDialogHost />);
await act(() => showPushDialog());
await screen.findByRole("textbox", { name: "Target branch of a" });
}
it("asks to confirm each forced target and pushes nothing when one is declined", async () => {
mocks.ask.mockResolvedValueOnce({ button: "force", checked: false }).mockResolvedValueOnce({ button: null });
await openPushDialog();
fireEvent.change(screen.getByRole("textbox", { name: "Target branch of a" }), { target: { value: "feat-a" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Force (with lease)" }));
fireEvent.click(screen.getByRole("button", { name: "Force Push" }));
await waitFor(() => expect(mocks.ask).toHaveBeenCalledTimes(2));
expect(askedTargets()).toEqual(["origin/feat-a", "origin/feat-x"]);
expect(mocks.ops).toEqual([]);
// Declining keeps the dialog open, as in the single-repository dialog.
expect(screen.getByRole("button", { name: "Force Push" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
});
it("force pushes with lease once every target is confirmed", async () => {
await openPushDialog();
fireEvent.click(screen.getByRole("checkbox", { name: "Force (with lease)" }));
fireEvent.click(screen.getByRole("button", { name: "Force Push" }));
await waitFor(() => expect(mocks.ops).toHaveLength(3));
// The three repositories push to the same target: one confirmation.
expect(askedTargets()).toEqual(["origin/feat-x"]);
expect(forceOf(mocks.ops)).toEqual([
["/p/a", true],
["/p/b", true],
["/p/c", true],
]);
});
it("does not ask about a protected target, which is never forced", async () => {
await openPushDialog();
fireEvent.change(screen.getByRole("textbox", { name: "Target branch of a" }), { target: { value: "main" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Force (with lease)" }));
fireEvent.click(screen.getByRole("button", { name: "Force Push" }));
await waitFor(() => expect(mocks.ops).toHaveLength(3));
expect(askedTargets()).toEqual(["origin/feat-x"]);
expect(forceOf(mocks.ops)).toEqual([
["/p/a", false],
["/p/b", true],
["/p/c", true],
]);
});
it("does not ask without Force", async () => {
await openPushDialog();
fireEvent.click(screen.getByRole("button", { name: "Push" }));
await waitFor(() => expect(mocks.ops).toHaveLength(3));
expect(mocks.ask).not.toHaveBeenCalled();
});
+15 -1
View File
@@ -5,7 +5,7 @@ import { workflowSettings } from "@/platform/workflowSettings";
import { editorService, type EditorInput } from "@/workbench/contributions";
import { baseName, effectiveKind } from "./model";
import { readWorkingText } from "./ops";
import { groupFileNodes, renamedFromPath, stagingSide, type ChangeNode } from "./changes";
import { changeFileNodes, groupFileNodes, renamedFromPath, stagingSide, type ChangeNode } from "./changes";
import { currentRepoRoot, statusOf } from "./store";
import { repositoryPath } from "./repositoryPaths";
@@ -188,6 +188,20 @@ export function changeNodeDiffData(roots: readonly ChangeNode[], node: ChangeNod
);
}
/**
* The Commit window's diff of a file: every file of the tree (in tree order) as the F7 chain,
* so past the last change it goes on into the unversioned files, as IDEA's Commit diff
* preview does (`ChangesViewDiffPreviewProcessor.iterateAllChanges`).
*/
export function changeTreeDiffData(roots: readonly ChangeNode[], node: ChangeNode): DiffData {
const all = changeFileNodes(roots);
const nodes = all.includes(node) ? all : [node];
return withChain(
nodes.map((n) => changeNodeDiff(n)),
nodes.indexOf(node),
);
}
/** Default local-changes diff for a working file: HEAD (or empty) vs the working copy. */
export function workingDiffData(path: string, line?: number, side?: "staged" | "unstaged" | null): DiffData {
if (side === "staged") {
+9
View File
@@ -342,6 +342,15 @@ function MultiPushDialog({ only, onClose }: MultiPushDialogProps) {
// Like the single-repository dialog: tags go only with plain pushes, so a retargeted repository hides them.
const retargeted = picked.some((r) => pushOperation(choiceOf(r, null)).op === "pushBranch");
const push = async () => {
// Like the single-repository dialog: each forced target is confirmed first (once, when several
// repositories push to the same one), and declining one pushes nothing.
const forced = new Set<string>();
for (const r of force ? picked : []) {
const target = targetNameOf(r);
const remote = shownRemoteOf(r);
if (!isProtectedBranch(target)) forced.add(remote ? `${remote}/${target}` : target);
}
for (const target of forced) if (!(await confirmForcePush(target))) return;
onClose();
// Every picked repository is pushed even when another fails; the outcome is summed up.
const session: PushSession = { method: null };
+8 -1
View File
@@ -269,8 +269,15 @@ const caret = "var(--caret, var(--fg-strong))";
/** Block caret and command line in theme tokens (the package's defaults are fixed colours). */
const vimTheme = EditorView.theme({
// A solid block with the character under it in the editor's background colour, as IdeaVim's
// block caret: a translucent fill over light text on a dark theme read as a brighter letter.
"& .cm-vimCursorLayer .cm-fat-cursor": {
background: `color-mix(in srgb, ${caret} 60%, transparent)`,
background: caret,
},
// The package sets the letter's own colour inline; a partial block (replace, operator-pending)
// draws no letter.
"&.cm-focused .cm-vimCursorLayer .cm-fat-cursor:not([style*='color: transparent'])": {
color: "var(--bg-editor, var(--bg-island, var(--bg-app))) !important",
},
"&:not(.cm-focused) .cm-vimCursorLayer .cm-fat-cursor": {
background: "none",
+28 -1
View File
@@ -1,5 +1,13 @@
import { expect, it, vi } from "vitest";
import { executeAction, executeActionOrThrow, registerAction, useRunningActions } from "./actions";
import {
executeAction,
executeActionOrThrow,
getAction,
registerAction,
registerActions,
useActions,
useRunningActions,
} from "./actions";
const running = (id: string) => useRunningActions.getState().running.get(id) ?? 0;
@@ -91,3 +99,22 @@ it("checks argument-specific enablement before dispatch", async () => {
dispose();
}
});
it("registers a batch with one store update and disposes every action", () => {
const updates: number[] = [];
const off = useActions.subscribe((s) => updates.push(s.actions.size));
const dispose = registerActions([
{ id: "test.batch.one", title: "One", run: () => {} },
{ id: "test.batch.two", title: "Two", run: () => {} },
]);
try {
expect(updates).toHaveLength(1);
expect(getAction("test.batch.one")?.title).toBe("One");
expect(getAction("test.batch.two")?.title).toBe("Two");
} finally {
dispose();
off();
}
expect(getAction("test.batch.one")).toBeUndefined();
expect(getAction("test.batch.two")).toBeUndefined();
});
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more