Compare commits

..
Author SHA1 Message Date
l0ng-ai 2bb14c9704 fix(database): keep unsubmitted result edits while another result tab is shown
The result pane remounts when another result tab is selected, so its pending
cell edits, added and deleted rows were dropped. They are now kept by the rows
they were made on, so switching back restores them; a re-run or another page
still starts over.
2026-11-13 17:00:14 +08:00
LunarFellerandLunarFeller 3a64bba254 fix(database): preserve numeric precision in JSON exports (#3102)
The JSON extractor converted numeric cells through JavaScript Number before JSON.stringify, rounding BIGINTs and high-precision decimals in the preview, Copy as JSON, the dialog clipboard and file export. Numeric cells are now written as normalized decimal text (sign, leading zeros and an empty fraction normalized, digits and exponent kept); non-finite or non-decimal text stays a string as before.

Refs #3105

Co-authored-by: LunarFeller <[email protected]>
2026-11-13 16:58:16 +08:00
LunarFellerandLunarFeller f4b4a0fa72 fix(database): preserve precision when sorting numeric cells (#3075)
Result sorting, filter value order and selection Min/Max compared numeric cells through JavaScript Number, so BIGINTs beyond 2^53 and high-precision decimals that round to the same double kept the wrong order. The comparator still uses the float difference when it is non-zero (rounding is monotonic) and compares the decimal digits and exponent order for rounded ties.

Refs #3105

Co-authored-by: LunarFeller <[email protected]>
2026-11-13 16:57:45 +08:00
Bake Yam 50bd134b65 feat(templates): built-in Jupyter Notebook file template (#3084)
New ▸ Jupyter Notebook creates a valid nbformat 4 notebook with a Python 3 kernelspec, either empty or starting with a Markdown heading named after the file. It uses the .ipynb file icon and is listed under Settings › Editor › File and Code Templates like the other built-ins.

Fixes #3082

Co-authored-by: Renjiankang <[email protected]>
2026-11-13 16:56:21 +08:00
Bake Yam 8840a1e58a fix(templates): write the file template body before opening the file (#3083)
Creating a file from a template whose extension opens in a non-text editor (an .ipynb notebook) left the file empty: the body was only ever inserted into a CodeMirror view, which never appears for such editors, so the wait timed out after ~4 s with "The template content could not be inserted."

Now the editor kind is probed before the file is created (its acquisitions released); a file a provided editor opens gets the rendered body written on create. Text templates keep inserting into the view (caret at $END$, reformat, saved with the editor's encoding).

Fixes #3081

Co-authored-by: Renjiankang <[email protected]>
2026-11-13 16:55:16 +08:00
l0ng-ai bf36cc0801 chore: v0.1.44 2026-11-13 15:46:55 +08:00
l0ng-ai 78c98d289f fix: Snowflake transactions and lost sessions, accept never writes over unreadable files (#3098)
Snowflake: COMMIT/ROLLBACK end a transaction without switching to the console's schema, and a failed one stays held to be ended again; after a lost session is signed in to again, the target's USE runs before the statement is repeated (a table-editor transaction is not repeated); the session key includes the project. Agent accept: a checkout file that exists but can't be read is skipped as the checkout's own instead of written over.
2026-11-13 15:46:25 +08:00
l0ng-ai 30b0fcb8fc fix(mcp): a late diagnostics publication for an older text version isn't fresh (#3097)
A publishDiagnostics tagged with a version older than the document's is still shown but no longer counts as a fresh answer for check_files.
2026-11-13 15:43:44 +08:00
l0ng-ai 586399deda fix(database): Snowflake runs nothing in another database and keeps sessions per data source (#3096)
A refused USE for a statement's database or schema no longer lets the statement run in the session's previous context (possibly another database of the data source) and is retried with the next statement. Sessions are keyed by data source as well as connection settings, so two data sources with the same credentials no longer share a Snowflake session and its transactions.
2026-11-13 15:33:09 +08:00
l0ng-ai e6e89860e6 fix(agent): don't overwrite a file at a case-only rename's new name on accept (#3095)
On a case-sensitive disk, accepting an a.txt -> A.txt rename removed a.txt and wrote A.txt unchecked, overwriting a file the user had created there. Both names are now checked before either changes (old: the checkout's file; new: absent or already the task's), and the new name is written before the old one is removed.
2026-11-13 15:32:53 +08:00
l0ng-ai d890c41352 fix: trusted interpreters only, plugin diffs off the disk, fresh check_files diagnostics, dotted Maven artifact ids (#3093)
- Python: All Interpreters ran the interpreter of every recent project, including ones last opened in Restricted Mode; only projects trusted in their own right are probed.
- Plugins: views.open-diff's file name became a path the diff viewer probed on disk; plugin diffs get a virtual plugin-diff: path and virtual paths aren't probed.
- MCP: check_files took a publication from before an already-applied outside edit as fresh; the LSP document records when its servers last got new text.
- Maven: version completion split dotted artifact ids into directories; only the group id is split.
2026-11-13 15:29:24 +08:00
l0ng-ai c942b87abb fix(editor): translate the code vision usage counts (#3088)
The usages hint above declarations was always English; it now goes through the text catalog, with Chinese entries.
2026-11-13 14:54:48 +08:00
l0ng-ai cdecb5916c fix(plugins): run the scripts of plugin html views in release builds (#3087)
A view's html body was loaded as a srcdoc frame, which inherits the workbench CSP; its script-src 'self' blocks inline scripts, so in every build that serves the CSP html views rendered but ran no script. In the app the body is now written into a page of the openidea-plugin scheme (.frame) whose response carries only the frame's own policy, sandboxed as before. Web views already load from the scheme; notebook HTML output never runs scripts.
2026-11-13 14:44:26 +08:00
Clarence 835c44615b fix(vcs): Branches popup search matches words, not scattered letters (#3076)
The Branches popup filtered with fuzzyMatch, whose last fallback accepts any subsequence, so `test` listed names like `sprint-1.1-device-reset`. It now uses wordMatch, as Search Everywhere and the Keymap page do: a substring, or fragments at word / camel-hump starts. Repository names in a multi-repository project keep the loose match. The search field takes the bar's free width, so the clear, Fetch and Options buttons sit at the right edge.

Co-authored-by: Clarence404 <[email protected]>
2026-11-13 14:19:19 +08:00
l0ng-ai df79c80aa8 feat(plugins): notes after file names in the project tree (API 1.7) (#3085)
Tree decorations could carry only a two-character badge and a tooltip. decorations.set-file-note (API 1.7) shows one line of muted text (at most 200 characters, optional tooltip) after a file or folder name in the project tree, replacing that tree set's item for the file and sharing the tree-set limits. The SDK wraps it as host::set_file_note with a mock; checked-in components rebuilt.
2026-11-13 14:18:04 +08:00
l0ng-ai 73df48db7c fix(vue): resolve the @ alias in Vue projects without Vue CLI (#3080)
Only projects depending on @vue/cli-service had their webpack aliases passed to tsserver, so @/ imports resolved to nothing in Vue CLI 2, custom webpack or Vite projects without jsconfig.json. Those now get their own webpack configuration's aliases (function / list / ES module exports handled), else the @ -> src alias Vue templates set up.
2026-11-13 14:01:38 +08:00
74 changed files with 1690 additions and 221 deletions

No files matched your search

Generated
+44 -44
View File
@@ -4144,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4168,7 +4168,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4182,7 +4182,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"ide-core",
"serde",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"blake3",
"chardetng",
@@ -4211,7 +4211,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"gtk",
"objc2 0.6.5",
@@ -4235,7 +4235,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4252,7 +4252,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-run",
@@ -4266,7 +4266,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"futures",
"ide-core",
@@ -4292,7 +4292,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"bb8",
@@ -4328,7 +4328,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"ide-core",
"serde",
@@ -4341,7 +4341,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4362,7 +4362,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4374,7 +4374,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4387,7 +4387,7 @@ dependencies = [
[[package]]
name = "ide-heap"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"criterion",
"ide-core",
@@ -4405,7 +4405,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4476,7 +4476,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-vfs",
@@ -4495,7 +4495,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"blake3",
"criterion",
@@ -4517,7 +4517,7 @@ dependencies = [
[[package]]
name = "ide-jdwp"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"ide-jvm-inspect",
"proptest",
@@ -4529,7 +4529,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"encoding_rs",
"futures",
@@ -4546,7 +4546,7 @@ dependencies = [
[[package]]
name = "ide-jvm-attach"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"libc",
"tempfile",
@@ -4555,7 +4555,7 @@ dependencies = [
[[package]]
name = "ide-jvm-inspect"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"futures",
"serde",
@@ -4566,7 +4566,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"blake3",
"criterion",
@@ -4623,7 +4623,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4651,7 +4651,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"bytes",
"dunce",
@@ -4670,7 +4670,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"futures",
"ide-core",
@@ -4693,7 +4693,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"blake3",
"dunce",
@@ -4716,7 +4716,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-jvm-attach",
@@ -4730,7 +4730,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"dunce",
"futures",
@@ -4750,7 +4750,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"dunce",
"encoding_rs",
@@ -4774,7 +4774,7 @@ dependencies = [
[[package]]
name = "ide-scaffold"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-tools",
@@ -4790,7 +4790,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"dunce",
"ide-core",
@@ -4811,7 +4811,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"criterion",
"encoding_rs",
@@ -4832,7 +4832,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"ide-core",
"parking_lot",
@@ -4845,7 +4845,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4870,7 +4870,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"ide-core",
"ide-language",
@@ -4885,7 +4885,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"ide-core",
"libc",
@@ -4898,7 +4898,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"criterion",
"ide-core",
@@ -4911,7 +4911,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"flate2",
"hex",
@@ -4932,7 +4932,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"blake3",
"dunce",
@@ -4953,7 +4953,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"chardetng",
"dunce",
@@ -4977,7 +4977,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"dirs 6.0.0",
"ide-core",
@@ -4993,7 +4993,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"blake3",
"dunce",
@@ -6594,7 +6594,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.43"
version = "0.1.44"
dependencies = [
"base64 0.22.1",
"blake3",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.43"
version = "0.1.44"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+77 -22
View File
@@ -225,6 +225,15 @@ fn same_file(a: &Path, b: &Path) -> bool {
}
}
/// The bytes of `path` (`Some(None)`: no file there), or `None` when it is there but can't be read.
fn existing(path: &Path) -> Option<Option<Vec<u8>>> {
match std::fs::read(path) {
Ok(bytes) => Some(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(None),
Err(_) => None,
}
}
fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
for (i, &b) in bytes.iter().enumerate() {
@@ -411,25 +420,31 @@ pub(crate) fn accept(
let copyable = |rel: &str| task_side.allows(rel) && checkout_side.allows(rel);
let renamed_from: BTreeMap<String, String> =
repo.diff_with_worktree(base)?.into_iter().filter_map(|f| Some((f.path, f.old_path?))).collect();
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
let apply = |rel: &str| -> IdeResult<bool> {
let local = local_root.join(rel);
// The task's version of one path of the checkout (`Some(None)`: absent there), or `None` when
// the checkout changed it itself.
let target = |rel: &str| -> IdeResult<Option<Option<Vec<u8>>>> {
let in_task = repo.root().join(rel);
let base_bytes = repo.file_bytes_at(&in_task, Some(base))?;
let current = std::fs::read(&in_task).ok();
let local_bytes = std::fs::read(&local).ok();
// A file there that can't be read is the checkout's own: never written over.
let Some(local_bytes) = existing(&local_root.join(rel)) else { return Ok(None) };
if local_bytes == current {
return Ok(true);
return Ok(Some(current));
}
let base_bytes = repo.file_bytes_at(&in_task, Some(base))?;
// The checkout may hold the committed LF text with CRLF line ends (`core.autocrlf`).
let unchanged = match (&local_bytes, &base_bytes) {
(Some(now), Some(at_base)) => now == at_base || crlf_to_lf(now) == *at_base,
(now, at_base) => now == at_base,
};
if !unchanged {
return Ok(false);
Ok(unchanged.then_some(current))
};
// Puts `bytes` (`None`: nothing) at `rel` of the checkout.
let write = |rel: &str, bytes: Option<Vec<u8>>| -> IdeResult<()> {
let local = local_root.join(rel);
if std::fs::read(&local).ok() == bytes {
return Ok(());
}
match current {
match bytes {
Some(bytes) => {
if let Some(parent) = local.parent() {
std::fs::create_dir_all(parent)?;
@@ -441,6 +456,12 @@ pub(crate) fn accept(
_ => {}
},
}
Ok(())
};
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
let apply = |rel: &str| -> IdeResult<bool> {
let Some(bytes) = target(rel)? else { return Ok(false) };
write(rel, bytes)?;
Ok(true)
};
let mut accepted = Vec::with_capacity(changes.len());
@@ -466,8 +487,9 @@ pub(crate) fn accept(
// A case-only rename (`a.txt` → `A.txt`). Where the disk resolves both
// spellings to one file it is renamed in place and `apply` then refreshes
// the content. On a case-sensitive disk the two names are different files:
// the old one's unchanged-ness (it is the file the checkout had) decides —
// then the task's version lands at the new name and the old one goes.
// both are checked before either changes — the old one must be the file the
// checkout had, the new one absent or already the task's — then the task's
// version lands at the new name before the old one goes.
let case_only =
old.is_some_and(|o| o != &change.rel_path && o.to_lowercase() == change.rel_path.to_lowercase());
if case_only {
@@ -476,20 +498,18 @@ pub(crate) fn accept(
if same_file(&old_path, &new_path) {
std::fs::rename(&old_path, &new_path)?;
} else {
if !apply(old)? {
let Some(old_target) = target(old)? else {
skipped.push(skip(old));
continue;
};
let task_bytes = std::fs::read(repo.root().join(&change.rel_path)).ok();
let at_new = existing(&new_path);
if at_new.as_ref().is_none_or(|at_new| at_new.is_some() && *at_new != task_bytes) {
skipped.push(skip(&change.rel_path));
continue;
}
// `apply(old)` removed the old file (the task's tree has it only at
// the new name); now write that version under the new name. `apply`
// can't do it — it reads the new name's own base/absence — so the
// write is open-coded from the same task bytes `apply` would use.
if let Ok(bytes) = std::fs::read(repo.root().join(&change.rel_path)) {
if let Some(parent) = new_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&new_path, bytes)?;
}
write(&change.rel_path, task_bytes)?;
write(old, old_target)?;
accepted.push(change.rel_path.clone());
continue;
}
@@ -960,6 +980,41 @@ mod tests {
assert_eq!(names, ["A.txt"]);
}
#[test]
fn a_case_only_rename_keeps_a_file_the_checkout_has_at_the_new_name() {
let (_tmp, repo, created) = accept_repo();
std::fs::write(repo.join("A.txt"), "mine\n").unwrap();
if std::fs::read_to_string(repo.join("a.txt")).unwrap() == "mine\n" {
eprintln!("this disk doesn't tell a.txt from A.txt; nothing to check");
return;
}
git(&created.path, &["mv", "a.txt", "A.txt"]);
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["A.txt"]);
assert_eq!(std::fs::read_to_string(repo.join("A.txt")).unwrap(), "mine\n");
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\ntwo\n");
}
#[cfg(unix)]
#[test]
fn accept_leaves_a_checkout_file_it_cannot_read() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, repo, created) = accept_repo();
std::fs::write(created.path.join("new.txt"), "task\n").unwrap();
std::fs::write(repo.join("new.txt"), "mine\n").unwrap();
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o200)).unwrap();
if std::fs::read(repo.join("new.txt")).is_ok() {
eprintln!("running as a user that reads any file; nothing to check");
return;
}
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["new.txt"]);
assert_eq!(std::fs::read_to_string(repo.join("new.txt")).unwrap(), "mine\n");
}
#[cfg(windows)]
#[test]
fn accept_does_not_write_into_the_checkouts_git_folder_through_its_short_name() {
+24 -7
View File
@@ -393,11 +393,13 @@ fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletion
out
}
/// The repository directory of the `/`-separated `coordinates` (a group id, or
/// a group and an artifact id), each segment validated against the layout's
/// alphabet so POM text cannot walk outside `repo`.
/// The repository directory of `coordinates` (a group id, or a group and an artifact
/// id): the group's dots are directories, an artifact id is one directory whatever dots
/// it has (`org/eclipse/platform/org.eclipse.core.runtime`). Each segment is validated
/// against the layout's alphabet so POM text cannot walk outside `repo`.
fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> {
let segments: Vec<&str> = coordinates.iter().flat_map(|c| c.split('.')).collect();
let (group, artifact) = coordinates.split_first()?;
let segments: Vec<&str> = group.split('.').chain(artifact.iter().copied()).collect();
segments.iter().all(|s| segment(s)).then(|| segments.iter().fold(repo.to_path_buf(), |p, s| p.join(s)))
}
@@ -460,6 +462,7 @@ mod tests {
"org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar",
"org/example/lib/2.0/lib-2.0.pom",
"net/nu/netty/4.1.100/netty-4.1.100.jar",
"org/eclipse/platform/org.eclipse.core.runtime/3.31.0/org.eclipse.core.runtime-3.31.0.jar",
// Failed downloads (a version typed while editing): tracking files only, not installed.
"org/example/lib/2/lib-2.jar.lastUpdated",
"org/example/lib/3.0/lib-3.0.pom.lastUpdated",
@@ -570,9 +573,10 @@ mod tests {
};
// Only directories holding artifacts are group ids: `net` and `org`
// alone never receive an install, their deeper levels do.
assert_eq!(options(""), ["net.nu", "org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org"), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org."), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
let org = ["org.apache.maven", "org.apache.maven.plugins", "org.eclipse.platform", "org.example"];
assert_eq!(options(""), [&["net.nu"][..], &org].concat());
assert_eq!(options("org"), org);
assert_eq!(options("org."), org);
assert_eq!(options("org.spr"), Vec::<String>::new());
assert_eq!(options("net."), ["net.nu"]);
}
@@ -623,6 +627,19 @@ mod tests {
};
assert_eq!(options(""), ["2.0", "2.0-beta-1", "1.10", "1.9.1", "1.0"]);
assert_eq!(options("1."), ["1.10", "1.9.1", "1.0"]);
// An artifact id with dots is one directory.
let dotted = local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: "".into(),
kind: PomCompletionKind::Version,
group: Some("org.eclipse.platform".into()),
artifact: Some("org.eclipse.core.runtime".into()),
},
);
assert_eq!(dotted.into_iter().map(|o| o.label).collect::<Vec<_>>(), ["3.31.0"]);
}
#[test]
+1 -1
View File
@@ -753,7 +753,7 @@ impl Pool {
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
}
if source.driver == DbDriver::Snowflake {
return crate::snowflake::SnowflakePool::connect(source, password, setup).await.map(Pool::Snowflake);
return crate::snowflake::SnowflakePool::connect(source, password, setup, root).await.map(Pool::Snowflake);
}
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
let init = Arc::new(setup.statements);
+12 -9
View File
@@ -1491,18 +1491,21 @@ impl DbManager {
let Some(session) = session else { return Ok(()) };
let mut held = session.lock().await;
let Some(mut conn) = held.take() else { return Ok(()) };
let result = async {
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
conn.restore_default().await
}
.await;
match result {
Ok(()) => Ok(()),
Err(e) => {
if let Err(e) = conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
// Discarding a Snowflake connection doesn't end its session, which the data source's
// other targets share: the transaction stays open, so it stays held to be ended again.
if matches!(conn, conn::Held::Snowflake(..)) {
*held = Some(conn);
} else {
conn.discard().await;
Err(e)
}
return Err(e);
}
if let Err(e) = conn.restore_default().await {
conn.discard().await;
return Err(e);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
+94 -24
View File
@@ -78,7 +78,8 @@ pub(crate) fn sso_seed(key: &str, token: &str) {
}
}
/// Live sessions by their connection settings, so targets opened with the same settings share one.
/// Live sessions by data source and connection settings, so the targets of one data source (its
/// databases, the tree, consoles) opened with the same settings share one.
static SESSIONS: LazyLock<parking_lot::Mutex<HashMap<String, Weak<State>>>> =
LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
@@ -106,6 +107,15 @@ struct Lane {
context: parking_lot::Mutex<Option<String>>,
}
/// What [`SnowflakePool::statement`] does when the session is gone and it signs in again.
#[derive(Clone, Copy)]
enum Lost<'a> {
/// Runs the statement again on the new session, after this `USE` when there is one.
Enter(Option<&'a str>),
/// Fails: the statement was part of a transaction that ended with the session.
Fail,
}
const CONSOLE: usize = 0;
const METADATA: usize = 1;
@@ -251,7 +261,12 @@ fn gunzip(bytes: Vec<u8>) -> IdeResult<Vec<u8>> {
}
impl SnowflakePool {
pub(crate) async fn connect(source: &DataSource, secret: Option<&str>, setup: Setup) -> IdeResult<Self> {
pub(crate) async fn connect(
source: &DataSource,
secret: Option<&str>,
setup: Setup,
root: Option<&std::path::Path>,
) -> IdeResult<Self> {
let settings = source.snowflake();
let endpoint = account::endpoint(source)?;
let user = source.user.trim();
@@ -302,9 +317,15 @@ impl SnowflakePool {
lane: CONSOLE,
};
// Everything that makes a session what it is, the secret included (hashed: the key is only compared).
// The data source and its project too: its databases share a session, but another data source
// with the same settings, or the same one in another project (a plugin's source has the same id
// in every project), gets its own: its statements and transactions are not this one's. A browser
// sign-in is still shared through the cached ID token.
let key = blake3::hash(
format!(
"{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
"{:?}\0{}\0{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
root,
source.id,
api.address(),
settings.auth,
secret.unwrap_or_default(),
@@ -636,13 +657,18 @@ impl SnowflakePool {
() = self.closed.cancelled() => return Err(closed()),
turn = self.lane().turn.lock() => turn,
};
// A `USE` needs no schema to run in, and may be what makes the schema reachable.
if first_word(sql) != "USE" {
self.enter(schema, cancel).await?;
}
// A `USE` needs no schema to run in, and may be what makes the schema reachable. Ending a
// transaction needs none either, and must not fail because the schema is gone meanwhile.
let context = match first_word(sql).as_str() {
"USE" | "COMMIT" | "ROLLBACK" => None,
_ => {
self.enter(schema, cancel).await?;
self.context(schema)
}
};
// These can move the session to another database or schema.
let moves = matches!(first_word(sql).as_str(), "USE" | "CALL" | "EXECUTE");
let result = self.statement(sql, skip, max_rows, cancel).await;
let result = self.statement(sql, skip, max_rows, Lost::Enter(context.as_deref()), cancel).await;
if moves {
*self.lane().context.lock() = None;
}
@@ -655,16 +681,14 @@ impl SnowflakePool {
&& self.lane().context.lock().as_deref() != Some(wanted.as_str())
{
*self.lane().context.lock() = None;
match self.statement(&wanted, 0, 0, cancel).await {
Ok(_) => {}
// The session's role may not use this database or schema (after `USE ROLE`, or a
// schema that is gone). The statement still runs, in the session's own context:
// it may be the `USE` that leads back, or name its objects in full. Asked again
// once a statement moves the session.
Err(refused) if refused.kind == ErrorKind::InvalidInput => {
tracing::warn!("Snowflake: the session did not enter its database and schema");
}
Err(error) => return Err(error),
// Refused (the session's role may not use this database or schema): the statement
// does not run, since the session is still wherever it was, which may be another
// target's database. Tried again with the next statement.
if let Err(error) = self.statement(&wanted, 0, 0, Lost::Enter(None), cancel).await {
return Err(IdeError {
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
..error
});
}
*self.lane().context.lock() = Some(wanted);
}
@@ -680,11 +704,11 @@ impl SnowflakePool {
let _turn = self.lane().turn.lock().await;
let cancel = CancellationToken::new();
self.enter(None, &cancel).await?;
self.statement("BEGIN", 0, 0, &cancel).await?;
self.statement("BEGIN", 0, 0, Lost::Enter(self.context(None).as_deref()), &cancel).await?;
let changes = async {
let mut total = 0;
for (sql, expect_one) in statements {
let changed = self.statement(sql, 0, 0, &cancel).await?.affected.unwrap_or(0.0) as u64;
let changed = self.statement(sql, 0, 0, Lost::Fail, &cancel).await?.affected.unwrap_or(0.0) as u64;
if *expect_one && changed != 1 {
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {changed}; nothing was saved"
@@ -697,22 +721,24 @@ impl SnowflakePool {
.await;
match changes {
Ok(total) => {
self.statement("COMMIT", 0, 0, &cancel).await?;
self.statement("COMMIT", 0, 0, Lost::Fail, &cancel).await?;
Ok(total)
}
Err(error) => {
let _ = self.statement("ROLLBACK", 0, 0, &cancel).await;
let _ = self.statement("ROLLBACK", 0, 0, Lost::Fail, &cancel).await;
Err(error)
}
}
}
/// One statement on the session; the caller holds the session's turn.
/// One statement on the session; the caller holds the session's turn. `lost`: what to do when
/// the session is gone and a new one is signed in to.
async fn statement(
&self,
sql: &str,
skip: usize,
max_rows: usize,
lost: Lost<'_>,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let started = Instant::now();
@@ -743,7 +769,24 @@ impl SnowflakePool {
Ok(Outcome::Done(result)) => return Ok(*result),
Ok(Outcome::Expired { gone }) if !renewed => {
renewed = true;
tokens = if gone { self.login().await? } else { self.renew(&tokens).await? };
if !gone {
tokens = self.renew(&tokens).await?;
continue;
}
if let Lost::Fail = lost {
// The transaction ended with the session: nothing of it was saved.
let _ = self.login().await;
return Err(IdeError::new(
ErrorKind::Io,
"Snowflake: the session was lost during the transaction; nothing was saved",
));
}
// The new session is under the login's database and schema, which may be another
// target's: back to this one's before the statement runs again.
tokens = self.login().await?;
if let Lost::Enter(Some(wanted)) = lost {
self.reenter(wanted, &tokens, cancel).await?;
}
}
Ok(Outcome::Expired { .. }) => {
return Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"));
@@ -753,6 +796,33 @@ impl SnowflakePool {
}
}
/// [`enter`](Self::enter) on a session just signed in to (its `tokens`), within [`statement`](Self::statement).
async fn reenter(&self, wanted: &str, tokens: &Tokens, cancel: &CancellationToken) -> IdeResult<()> {
let request_id = uuid();
let outcome = tokio::select! {
biased;
() = cancel.cancelled() => return Err(IdeError::cancelled()),
() = self.closed.cancelled() => return Err(closed()),
outcome = self.run(wanted, 0, 0, tokens, &request_id, Instant::now()) => outcome,
};
match outcome {
Ok(Outcome::Done(_)) => {
*self.lane().context.lock() = Some(wanted.to_owned());
Ok(())
}
Ok(Outcome::Expired { .. }) => {
Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"))
}
Err(error) => {
let error = self.shared.api.scrub.clean(error);
Err(IdeError {
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
..error
})
}
}
}
async fn run(
&self,
sql: &str,
+106 -16
View File
@@ -407,13 +407,64 @@ async fn a_lost_session_signs_in_again() {
let (listener, port) = bind().await;
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(one()), ok(CLOSED_OK)]);
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string();
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(status), ok(one()), ok(CLOSED_OK)]);
let pool = Pool::connect(&source(port), None, None).await.unwrap();
pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap();
pool.close().await;
let seen = server.await.unwrap();
assert!(seen[2].line().starts_with("POST /session/v1/login-request?"), "{}", seen[2].line());
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
// The new session enters the target's schema before the statement runs again.
assert_eq!(seen[3].json()["sqlText"], "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"");
assert_eq!(seen[4].json()["sqlText"], "select 1");
assert_eq!(seen[4].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
}
#[tokio::test]
async fn a_lost_session_runs_the_statement_in_its_own_database_again() {
let (listener, port) = bind().await;
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
let status = || {
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string()
};
// Raw Data entered, the statement finds the session gone, a new login (under ANALYTICS),
// Raw Data entered again, the statement.
let replies = vec![ok(LOGIN_OK), ok(status()), ok(gone), ok(again), ok(status()), ok(one()), ok(CLOSED_OK)];
let server = serve(listener, replies);
let first = Pool::connect(&source(port), None, None).await.unwrap();
let mut other = source(port);
other.database = "Raw Data".into();
other.options.default_schema = String::new();
let second = Pool::connect(&other, None, None).await.unwrap();
second.execute("delete from t", 1, &CancellationToken::new()).await.unwrap();
first.close().await;
second.close().await;
let sent: Vec<String> =
server.await.unwrap()[1..6].iter().map(|r| r.json()["sqlText"].as_str().unwrap_or("").to_owned()).collect();
let raw = "USE DATABASE \"Raw Data\"";
assert_eq!(sent, [raw, "delete from t", "", raw, "delete from t"]);
}
#[tokio::test]
async fn a_transaction_ends_even_when_its_schema_is_gone() {
let (listener, port) = bind().await;
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string();
let server = serve(listener, vec![ok(LOGIN_OK), ok(status), ok(CLOSED_OK)]);
let pool = Pool::connect(&source(port), None, None).await.unwrap();
// The console's schema was dropped since it was picked: ending the transaction doesn't switch to it.
let mut console = crate::conn::Held::acquire(&pool).await.unwrap();
console.apply_context(Some("Dropped")).await.unwrap();
console.execute(&pool, "ROLLBACK", 0, &CancellationToken::new()).await.unwrap();
pool.close().await;
let seen = server.await.unwrap();
assert_eq!(seen[1].json()["sqlText"], "ROLLBACK");
}
#[tokio::test]
@@ -714,7 +765,7 @@ async fn a_console_schema_is_switched_with_its_statement_and_only_when_needed()
}
#[tokio::test]
async fn a_role_that_may_not_use_the_schema_still_runs_statements() {
async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
let status = || {
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
@@ -722,14 +773,12 @@ async fn a_role_that_may_not_use_the_schema_still_runs_statements() {
};
let refused = r#"{"success":false,"code":"002043","message":"SQL compilation error:\nObject does not exist, or operation cannot be performed."}"#;
let (listener, port) = bind().await;
// use role | the schema is refused once, the statement runs | the next one does not ask again |
// a role that is not granted | use role back, the schema is entered again
// use role | the schema is refused, the statement is not sent | asked again, refused again |
// use role back, the schema is entered, the statement runs
let replies = vec![
ok(LOGIN_OK),
ok(status()),
ok(refused),
ok(one()),
ok(one()),
ok(refused),
ok(status()),
ok(status()),
@@ -740,20 +789,22 @@ async fn a_role_that_may_not_use_the_schema_still_runs_statements() {
let pool = Pool::connect(&source(port), None, None).await.unwrap();
let cancel = CancellationToken::new();
pool.execute("use role auditor", 0, &cancel).await.unwrap();
pool.execute("select 1", 1, &cancel).await.unwrap();
pool.execute("select 2", 1, &cancel).await.unwrap();
let missing = pool.execute("use role nobody", 0, &cancel).await.unwrap_err();
assert!(missing.message.contains("002043") && missing.message.contains("granted"), "{}", missing.message);
let error = pool.execute("delete from t", 1, &cancel).await.unwrap_err();
assert!(
error.message.contains("USE SCHEMA")
&& error.message.contains("was not run")
&& error.message.contains("002043"),
"{}",
error.message
);
assert!(pool.execute("select 2", 1, &cancel).await.is_err());
pool.execute("use role analyst", 0, &cancel).await.unwrap();
pool.execute("select 3", 1, &cancel).await.unwrap();
pool.close().await;
let sent: Vec<String> =
server.await.unwrap()[1..9].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
server.await.unwrap()[1..7].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
let schema = "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"";
assert_eq!(
sent,
["use role auditor", schema, "select 1", "select 2", "use role nobody", "use role analyst", schema, "select 3"]
);
assert_eq!(sent, ["use role auditor", schema, schema, "use role analyst", schema, "select 3"]);
}
#[tokio::test]
@@ -785,6 +836,45 @@ async fn databases_of_one_data_source_share_a_login() {
assert!(seen[4].line().starts_with("POST /session?delete=true"), "{}", seen[4].line());
}
#[tokio::test]
async fn data_sources_with_the_same_settings_have_their_own_sessions() {
let (listener, port) = bind().await;
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
let server = serve(listener, vec![ok(LOGIN_OK), ok(second), ok(one()), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
let first = Pool::connect(&source(port), None, None).await.unwrap();
let mut copy = source(port);
copy.id = format!("{}-copy", copy.id);
let other = Pool::connect(&copy, None, None).await.unwrap();
let cancel = CancellationToken::new();
first.execute("select 1", 1, &cancel).await.unwrap();
other.execute("select 2", 1, &cancel).await.unwrap();
first.close().await;
other.close().await;
let seen = server.await.unwrap();
assert!(seen[1].line().starts_with("POST /session/v1/login-request?"), "{}", seen[1].line());
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"session-token-1\""));
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
}
#[tokio::test]
async fn one_data_source_in_two_projects_has_a_session_in_each() {
let (listener, port) = bind().await;
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
let server = serve(listener, vec![ok(LOGIN_OK), ok(second), ok(one()), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
// A plugin's data source has the same id in every project.
let first = Pool::connect(&source(port), None, Some(std::path::Path::new("/projects/a"))).await.unwrap();
let other = Pool::connect(&source(port), None, Some(std::path::Path::new("/projects/b"))).await.unwrap();
let cancel = CancellationToken::new();
first.execute("select 1", 1, &cancel).await.unwrap();
other.execute("select 2", 1, &cancel).await.unwrap();
first.close().await;
other.close().await;
let seen = server.await.unwrap();
assert!(seen[1].line().starts_with("POST /session/v1/login-request?"), "{}", seen[1].line());
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"session-token-1\""));
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
}
#[tokio::test]
async fn metadata_runs_on_its_own_session_beside_a_busy_console() {
let (listener, port) = bind().await;
+13 -6
View File
@@ -61,13 +61,16 @@ pub async fn sdk_python_project_interpreters(host: &Host, window: &str) -> IdeRe
host.require_trust(window)?;
let current = host.project(window).ok().map(|p| p.root.clone());
let config = host.config_dir.clone();
// Probing runs the project's interpreter (and its settings may name any program): only
// projects trusted in their own right, not ones last opened in Restricted Mode.
let roots: Vec<PathBuf> = ide_workspace::RecentProjects::load(&config)
.list()
.into_iter()
.map(|p| p.path.into_path_buf())
.filter(|root| current.as_deref() != Some(root.as_path()))
.filter(|root| host.trust.window_trusted(root).unwrap_or(false))
.collect();
tokio::task::spawn_blocking(move || {
let roots: Vec<PathBuf> = ide_workspace::RecentProjects::load(&config)
.list()
.into_iter()
.map(|p| p.path.into_path_buf())
.filter(|root| current.as_deref() != Some(root.as_path()))
.collect();
// Each project resolves its configured interpreter, else its own virtualenv. The
// probes run `python`, so keep them off the async runtime and bound how many run at
// once: the recent list can hold ~30 projects, and each probe starts a process.
@@ -521,6 +524,10 @@ mod tests {
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
// A project last opened in Restricted Mode: its interpreter is never run.
host.trust.decide(&other, false, false).unwrap();
assert!(sdk_python_project_interpreters(&host, "main").await.unwrap().is_empty());
host.trust.decide(&other, true, false).unwrap();
let list = sdk_python_project_interpreters(&host, "main").await.unwrap();
assert_eq!(list.len(), 1, "{list:?}");
assert_eq!(&*list[0].project, other.as_path());
+8 -1
View File
@@ -250,7 +250,14 @@ impl ClientHandler {
{
return;
}
let shown = self.server.set_diagnostics(path.clone(), params.diagnostics);
// A late answer for an older version of the text is shown, but isn't fresh (`check_files`).
let obsolete =
params.version.is_some_and(|v| mgr.docs.lock().values().any(|d| d.path == path && d.version > v));
let shown = if obsolete {
self.server.set_obsolete_diagnostics(path.clone(), params.diagnostics)
} else {
self.server.set_diagnostics(path.clone(), params.diagnostics)
};
mgr.emit_diagnostics(&self.server, path, shown, self.encoding());
}
+3
View File
@@ -239,6 +239,9 @@ impl LspManager {
cancel: &CancellationToken,
) -> IdeResult<DocumentCheck> {
let snap = self.snapshot(doc)?;
// The text may have changed before this call synced it (the file watcher reloaded it):
// only diagnostics published after the servers got the current text are fresh.
let changed = Some(changed.map_or(snap.changed, |c| c.max(snap.changed)));
let deadline = tokio::time::Instant::now() + timeout;
let left = || deadline.saturating_duration_since(tokio::time::Instant::now());
let ready = tokio::select! {
+7
View File
@@ -120,6 +120,8 @@ struct DocState {
compose_detected: bool,
/// The notebook a code cell belongs to: synced through `notebookDocument/*`, not as a file.
notebook: Option<PathBuf>,
/// When the servers last got new text (open or change): diagnostics published before are stale.
changed: std::time::Instant,
}
impl DocState {
@@ -158,6 +160,8 @@ struct DocSnap {
language: LanguageId,
text: Arc<DocText>,
servers: Vec<Arc<Server>>,
/// [`DocState::changed`].
changed: std::time::Instant,
}
impl DocSnap {
@@ -1170,6 +1174,7 @@ impl LspManager {
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected,
notebook: None,
changed: std::time::Instant::now(),
};
let compose_added = {
let mut docs = self.docs.lock();
@@ -1217,6 +1222,7 @@ impl LspManager {
let mut docs = self.docs.lock();
let Some(d) = docs.get_mut(&doc) else { return };
d.version = version as i32;
d.changed = std::time::Instant::now();
// Servers that have this document open on their current instance.
let targets: Vec<(Arc<Server>, Running, SyncKind, Encoding)> = d
@@ -1392,6 +1398,7 @@ impl LspManager {
language: d.language.clone(),
text: d.text.clone(),
servers,
changed: d.changed,
})
}
+3
View File
@@ -213,6 +213,7 @@ impl LspManager {
{
if d.text.as_str() != cell.source {
Arc::make_mut(&mut d.text).set(&cell.source);
d.changed = std::time::Instant::now();
}
d.version += 1;
state.cells.push((cell.id.clone(), doc));
@@ -234,6 +235,7 @@ impl LspManager {
servers: state.servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected: false,
notebook: Some(path.to_owned()),
changed: std::time::Instant::now(),
},
);
state.cells.push((cell.id.clone(), doc));
@@ -259,6 +261,7 @@ impl LspManager {
}
Arc::make_mut(&mut d.text).set(&cell.source);
d.version += 1;
d.changed = std::time::Instant::now();
content.push(json!({
"document": { "uri": d.uri, "version": d.version },
"changes": [{ "text": cell.source }],
+14
View File
@@ -770,6 +770,20 @@ impl Server {
self.combine(path)
}
/// [`Server::set_diagnostics`] for a publication tagged with a document version older than
/// the text the server has now: shown, but not an answer for the current text, so
/// [`Server::last_published`] keeps its time.
pub fn set_obsolete_diagnostics(&self, path: PathBuf, items: Vec<lsp::Diagnostic>) -> Vec<lsp::Diagnostic> {
let before = self.last_published(&path);
let shown = self.set_diagnostics(path.clone(), items);
let mut published = self.published.lock();
match before {
Some(at) => published.insert(path, at),
None => published.remove(&path),
};
shown
}
/// Records a pulled report for `path` (`None` items: unchanged since `result_id`'s
/// predecessor); returns what the file now shows.
pub fn set_pulled(
+19
View File
@@ -2204,6 +2204,25 @@ async fn checking_a_document_saves_it_and_waits_for_fresh_diagnostics() {
let stale = h.mgr.check_document(doc, changed, Duration::from_millis(800), &cancel).await.unwrap();
assert!(stale.servers[0].checked && stale.servers[0].incomplete, "{:?}", stale.servers);
assert_eq!(stale.diagnostics.len(), 1);
// The text changed before the call (the file watcher reloaded it), so the caller saw no
// change of its own: the old publication is still not fresh.
h.mgr.did_change(doc, 2, &[change(rng(0, 0, 0, 3), "good")], &|| "good\n".to_owned());
let reloaded = h.mgr.check_document(doc, None, Duration::from_millis(800), &cancel).await.unwrap();
assert!(reloaded.servers[0].incomplete, "{:?}", reloaded.servers);
// A late publication tagged with the old version is no answer for the current text.
s.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "version": 1, "diagnostics": [] }))
.unwrap();
let late = h.mgr.check_document(doc, None, Duration::from_millis(800), &cancel).await.unwrap();
assert!(late.servers[0].incomplete, "{:?}", late.servers);
// One for the current version is.
s.peer()
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "version": 2, "diagnostics": [] }))
.unwrap();
let fresh = h.mgr.check_document(doc, None, Duration::from_secs(5), &cancel).await.unwrap();
assert!(fresh.servers[0].checked && !fresh.servers[0].incomplete, "{:?}", fresh.servers);
}
#[tokio::test(flavor = "multi_thread")]
+69 -15
View File
@@ -17,11 +17,18 @@ pub(crate) const EXTENSION: &str = "vue";
const CLI_SERVICE: &str = "@vue/cli-service";
const CLI_CONFIG_TIMEOUT: Duration = Duration::from_secs(30);
const ALIASES_MARKER: &str = "openidea-webpack-aliases:";
/// Prints the `resolve.alias` of the webpack configuration module `argv[1]`, after whatever
/// the configuration itself prints.
const PRINT_ALIASES: &str = "const c = require(process.argv[1]); const a = (c.resolve && c.resolve.alias) || {}; \
/// Prints the `resolve.alias` of the webpack configuration module `argv[1]` (an object, an
/// ES module's default, a function of the environment, or a list of configurations), after
/// whatever the configuration itself prints.
const PRINT_ALIASES: &str = "let c = require(process.argv[1]); if (c && c.__esModule) c = c.default; \
if (typeof c === 'function') c = c({}, { mode: 'development' }); if (Array.isArray(c)) c = c[0]; \
const a = (c && c.resolve && c.resolve.alias) || {}; \
const e = Array.isArray(a) ? a.map((x) => [x.name, x.alias]) : Object.entries(a); \
process.stdout.write('\\n' + process.argv[2] + JSON.stringify(e));";
/// Webpack configurations of Vue projects without Vue CLI: the project's own, and the
/// `vue init webpack` (Vue CLI 2) template's.
const WEBPACK_CONFIGS: &[&str] =
&["webpack.config.js", "webpack.config.cjs", "build/webpack.base.conf.js", "build/webpack.dev.conf.js"];
type Paths = BTreeMap<String, Vec<String>>;
static CLI_PATHS: LazyLock<Mutex<HashMap<PathBuf, (Stamp, Paths)>>> = LazyLock::new(Default::default);
@@ -42,24 +49,33 @@ pub(crate) fn tsserver_plugin(package: &Path) -> serde_json::Value {
})
}
/// `compilerOptions.paths` for the webpack aliases of a Vue CLI project at `root` (`@/*` →
/// `compilerOptions.paths` for the webpack aliases of a Vue project at `root` (`@/*` →
/// `<root>/src/*`), for its tsserver project when it has no `jsconfig.json`: tsserver can't
/// read webpack configurations, so `@/` imports resolve to nothing, and a mixin imported
/// that way makes the component's whole `this` `any`. Like IntelliJ, evaluates the
/// configuration `@vue/cli-service` resolves with the project's `vue.config.js` (the project
/// is trusted: language servers only run then); else the alias Vue CLI always adds.
/// Empty for other projects.
/// that way makes the component's whole `this` `any`. Like IntelliJ, evaluates the webpack
/// configuration (the project is trusted: language servers only run then): the one
/// `@vue/cli-service` resolves with the project's `vue.config.js`, else the project's own
/// (`webpack.config.js`, Vue CLI 2's `build/webpack.base.conf.js`). Without one (Vite, or
/// no Node), the `@` → `src` alias every Vue template sets up. Empty for other projects.
pub(crate) fn cli_paths(root: &Path, node: Option<&Path>) -> Paths {
let Ok(manifest) = std::fs::read_to_string(root.join("package.json")) else { return Paths::new() };
if !package_dependencies(&manifest).iter().any(|d| d == CLI_SERVICE) {
let dependencies = package_dependencies(&manifest);
let cli = dependencies.iter().any(|d| d == CLI_SERVICE);
if !cli && !dependencies.iter().any(|d| d == "vue") {
return Paths::new();
}
let config = root
.ancestors()
.map(|dir| dir.join("node_modules").join(CLI_SERVICE).join("webpack.config.js"))
.find(|f| f.is_file());
let mut inputs: Vec<PathBuf> =
["package.json", "vue.config.js", "vue.config.cjs", "vue.config.mjs"].iter().map(|f| root.join(f)).collect();
let config = if cli {
root.ancestors()
.map(|dir| dir.join("node_modules").join(CLI_SERVICE).join("webpack.config.js"))
.find(|f| f.is_file())
} else {
WEBPACK_CONFIGS.iter().map(|f| root.join(f)).find(|f| f.is_file())
};
let mut inputs: Vec<PathBuf> = ["package.json", "vue.config.js", "vue.config.cjs", "vue.config.mjs"]
.iter()
.chain(WEBPACK_CONFIGS)
.map(|f| root.join(f))
.collect();
inputs.extend(config.iter().cloned());
inputs.extend(node.map(Path::to_path_buf));
let now = stamp(&inputs);
@@ -71,6 +87,7 @@ pub(crate) fn cli_paths(root: &Path, node: Option<&Path>) -> Paths {
let aliases = config
.zip(node)
.and_then(|(config, node)| webpack_aliases(root, &config, node))
.filter(|aliases| !aliases.is_empty())
.unwrap_or_else(|| vec![("@".into(), root.join("src").to_string_lossy().into_owned())]);
let paths = alias_paths(&aliases);
CLI_PATHS.lock().unwrap_or_else(|e| e.into_inner()).insert(root.to_path_buf(), (now, paths.clone()));
@@ -181,6 +198,43 @@ mod tests {
);
}
#[test]
fn other_vue_projects_get_their_webpack_aliases_else_the_at_alias() {
let tmp = tempfile::tempdir().unwrap();
// As `__dirname` sees it (macOS: /private/var).
let t = &dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(t.join("package.json"), r#"{"dependencies":{"react":"^18.0.0"}}"#).unwrap();
std::fs::create_dir_all(t.join("src/views")).unwrap();
assert!(cli_paths(t, None).is_empty(), "not a Vue project");
std::fs::write(t.join("package.json"), r#"{"dependencies":{"vue":"^2.6.14"}}"#).unwrap();
let slash = |p: PathBuf| p.to_string_lossy().replace('\\', "/");
let (src, views) = (slash(t.join("src")), slash(t.join("src/views")));
let at = Paths::from([("@".into(), vec![src.clone()]), ("@/*".into(), vec![format!("{src}/*")])]);
assert_eq!(cli_paths(t, None), at, "Vite or no Node: the template alias");
let Some(node) = crate::exec::which("node") else { return };
// `vue init webpack`: a function of the environment is called, its aliases taken.
std::fs::create_dir_all(t.join("build")).unwrap();
std::fs::write(
t.join("build/webpack.base.conf.js"),
r#"const path = require('path');
const resolve = (dir) => path.join(__dirname, '..', dir);
module.exports = (env) => ({ resolve: { alias: { vue$: 'vue/dist/vue.esm.js', '@': resolve('src'), views: resolve('src/views') } } });"#,
)
.unwrap();
assert_eq!(
cli_paths(t, Some(&node)),
Paths::from([
("@".into(), vec![src.clone()]),
("@/*".into(), vec![format!("{src}/*")]),
("views".into(), vec![views.clone()]),
("views/*".into(), vec![format!("{views}/*")]),
])
);
// A configuration that can't be loaded (dependencies not installed): the template alias.
std::fs::write(t.join("build/webpack.base.conf.js"), "require('not-installed-plugin');").unwrap();
assert_eq!(cli_paths(t, Some(&node)), at);
}
#[test]
fn vue_cli_aliases_come_from_the_resolved_webpack_configuration() {
let Some(node) = crate::exec::which("node") else { return };
@@ -61,8 +61,9 @@ pub fn artifacts_url(group: &str, prefix: &str) -> Option<String> {
/// The published version metadata of one artifact on Central.
pub fn metadata_url(group: &str, artifact: &str) -> Option<String> {
(part(group, 200) && part(artifact, 100)).then(|| {
let path = group.split('.').chain(artifact.split('.')).collect::<Vec<_>>().join("/");
// The group's dots are directories; an artifact id is one directory, dots and all.
(part(group, 200) && part(artifact, 100) && artifact != "." && artifact != "..").then(|| {
let path = group.split('.').chain([artifact]).collect::<Vec<_>>().join("/");
format!("https://repo1.maven.org/maven2/{path}/maven-metadata.xml")
})
}
@@ -146,6 +147,11 @@ mod tests {
metadata_url("org.apache.maven", "maven-core").as_deref(),
Some("https://repo1.maven.org/maven2/org/apache/maven/maven-core/maven-metadata.xml")
);
assert_eq!(
metadata_url("org.eclipse.platform", "org.eclipse.core.runtime").as_deref(),
Some("https://repo1.maven.org/maven2/org/eclipse/platform/org.eclipse.core.runtime/maven-metadata.xml")
);
assert_eq!(metadata_url("org.example", ".."), None);
}
#[test]
+91
View File
@@ -0,0 +1,91 @@
import type { Page } from "@playwright/test";
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
const expected =
'[\n {\n "id": 9223372036854775807,\n "amount": 1.0000000000000000002,\n "note": "9007199254740993",\n "empty": null\n }\n]';
async function seedResult(page: Page) {
await stubDesktop(page);
await page.evaluate(async () => {
window.__ipc.handlers.clipboard_write_text = () => null;
window.__ipc.handlers.db_export_file = () => null;
window.__ipc.handlers["plugin:path|resolve_directory"] = () => "/tmp";
const storePath = "/src/features/database/store.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const { useDatabase, RESULTS_VIEW } = await import(storePath);
const { showServiceView } = await import(contributionsPath);
const text = "SELECT id, amount, note, empty FROM numbers";
useDatabase.setState({
loaded: true,
sources: [{ id: "exact", name: "Exact numbers", driver: "postgres", readOnly: true }],
consoles: { "exact-console.sql": "exact" },
results: [
{
id: 1,
title: "Result 1",
path: "exact-console.sql",
sourceId: "exact",
outcome: {
statement: { text, from: 0, to: text.length },
error: null,
result: {
statement: text,
columns: [
{ name: "id", typeName: "BIGINT" },
{ name: "amount", typeName: "NUMERIC(38,19)" },
{ name: "note", typeName: "TEXT" },
{ name: "empty", typeName: "TEXT" },
],
rows: [["9223372036854775807", "1.0000000000000000002", "9007199254740993", null]],
affected: null,
hasMore: false,
elapsedMs: 1,
executionMs: 1,
},
},
page: 0,
pageSize: 500,
},
],
activeResult: 1,
});
showServiceView(RESULTS_VIEW);
});
await expect(page.getByRole("grid", { name: "Result 1", exact: true }).getByRole("gridcell")).toHaveCount(4);
}
async function openExport(page: Page) {
await page.getByRole("button", { name: "Export Data", exact: true }).click();
await page.getByRole("menuitem", { name: "Export Data…", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Export Data", exact: true });
await dialog.getByRole("button", { name: "Extractor", exact: true }).click();
await page.getByRole("option", { name: "JSON", exact: true }).click();
return dialog;
}
test("Copy as JSON preserves exact numeric values", async ({ workbench: page }) => {
await seedResult(page);
await page.getByRole("button", { name: "Export Data", exact: true }).click();
await page.getByRole("menuitem", { name: "Copy as JSON", exact: true }).click();
await expect.poll(() => ipcCalls(page, "clipboard_write_text")).toHaveLength(1);
expect((await ipcCalls(page, "clipboard_write_text"))[0]!.text).toBe(expected);
});
test("Export Data preserves exact preview and clipboard values", async ({ workbench: page }) => {
await seedResult(page);
const dialog = await openExport(page);
await expect(dialog.getByLabel("Export preview", { exact: true })).toHaveText(expected);
await dialog.getByRole("button", { name: "Copy to Clipboard", exact: true }).click();
await expect.poll(() => ipcCalls(page, "clipboard_write_text")).toHaveLength(1);
expect((await ipcCalls(page, "clipboard_write_text"))[0]!.text).toBe(expected);
});
test("Export Data preserves exact preview and exported file values", async ({ workbench: page }) => {
await seedResult(page);
const dialog = await openExport(page);
await expect(dialog.getByLabel("Export preview", { exact: true })).toHaveText(expected);
await dialog.getByRole("textbox", { name: "Output file", exact: true }).fill("/tmp/exact-numbers.json");
await dialog.getByRole("button", { name: "Export to File", exact: true }).click();
await expect.poll(() => ipcCalls(page, "db_export_file")).toHaveLength(1);
expect((await ipcCalls(page, "db_export_file"))[0]).toEqual({ path: "/tmp/exact-numbers.json", text: expected });
});
+103
View File
@@ -0,0 +1,103 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./fixtures";
const integers = ["9007199254740993", "9007199254740992", "-9007199254740992", "-9007199254740993"];
const decimals = ["1.0000000000000000002", "1.0000000000000000001", "-0.0000000000000000001", "0"];
async function seedResult(page: Page, values: string[], type: string) {
await page.evaluate(
async ({ values, type }) => {
const storePath = "/src/features/database/store.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const { useDatabase, RESULTS_VIEW } = await import(storePath);
const { showServiceView } = await import(contributionsPath);
const text = "SELECT value FROM exact_numbers";
useDatabase.setState({
loaded: true,
sources: [{ id: "exact", name: "Exact numbers", driver: "postgres", readOnly: true }],
consoles: { "exact-console.sql": "exact" },
results: [
{
id: 1,
title: "Result 1",
path: "exact-console.sql",
sourceId: "exact",
outcome: {
statement: { text, from: 0, to: text.length },
error: null,
result: {
statement: text,
columns: [{ name: "value", typeName: type }],
rows: values.map((value) => [value]),
affected: null,
hasMore: false,
elapsedMs: 1,
executionMs: 1,
},
},
page: 0,
pageSize: 500,
},
],
activeResult: 1,
});
showServiceView(RESULTS_VIEW);
},
{ values, type },
);
await expect(page.getByRole("grid", { name: "Result 1", exact: true }).getByRole("gridcell")).toHaveCount(
values.length,
);
}
for (const [name, values, type, ascending] of [
["BIGINT", integers, "BIGINT", [integers[3]!, integers[2]!, integers[1]!, integers[0]!]],
["DECIMAL", decimals, "NUMERIC(38,19)", [decimals[2]!, decimals[3]!, decimals[1]!, decimals[0]!]],
] as const) {
test(`${name}: result headers sort exact values ascending, descending and back to query order`, async ({
workbench: page,
}) => {
await seedResult(page, [...values], type);
const grid = page.getByRole("grid", { name: "Result 1", exact: true });
const cells = grid.getByRole("gridcell");
const header = grid.getByRole("columnheader", { name: /^value\b/ }).getByText("value", { exact: true });
await expect(cells).toHaveText([...values]);
await header.click();
await expect(cells).toHaveText([...ascending]);
await header.click();
await expect(cells).toHaveText([...ascending].reverse());
await header.click();
await expect(cells).toHaveText([...values]);
});
}
test("precise filter values are ordered and filtering composes with result sorting", async ({ workbench: page }) => {
await seedResult(page, [...integers], "BIGINT");
const grid = page.getByRole("grid", { name: "Result 1", exact: true });
await grid.getByRole("button", { name: "Filter value", exact: true }).click();
const values = page.getByRole("group", { name: "Values", exact: true });
const ascending = [integers[3]!, integers[2]!, integers[1]!, integers[0]!];
await expect(values.locator("label")).toHaveText(ascending);
await values.getByRole("checkbox", { name: integers[3], exact: true }).uncheck();
await page.keyboard.press("Escape");
await grid
.getByRole("columnheader", { name: /^value\b/ })
.getByText("value", { exact: true })
.click();
await expect(grid.getByRole("gridcell")).toHaveText(ascending.slice(1));
});
test("selection aggregates preserve exact minimum and maximum values", async ({ workbench: page }) => {
await seedResult(page, [...decimals], "NUMERIC(38,19)");
const grid = page.getByRole("grid", { name: "Result 1", exact: true });
await grid.getByRole("gridcell").first().click();
await grid
.getByRole("gridcell")
.last()
.click({ modifiers: ["Shift"] });
await page.getByRole("button", { name: "Show Record / Value / Aggregates", exact: true }).click();
const panel = page.getByRole("complementary", { name: "Value panel", exact: true });
await panel.getByRole("tab", { name: "Aggregates", exact: true }).click();
await expect(panel.getByRole("row", { name: `Min ${decimals[2]}`, exact: true })).toBeVisible();
await expect(panel.getByRole("row", { name: `Max ${decimals[0]}`, exact: true })).toBeVisible();
});
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "openidea",
"private": true,
"description": "An open-source IDE inspired by IntelliJ IDEA",
"version": "0.1.43",
"version": "0.1.44",
"license": "Apache-2.0",
"type": "module",
"scripts": {
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// The commit being prepared in the window the current command or event came
/// from (the Commit tool window, or the Commit dialog while it is open): the
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Plugin-owned project data sources. Requires database.sources and project trust.
/// Runs on the Database subsystem's host, including on remote project servers.
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Project-scoped decorations (API 1.4). Requires project.read; paths must be inside
/// an open project. Sets belong to the plugin and disappear when it stops.
@@ -19,4 +19,7 @@ interface decorations {
set-file: func(path: string, set-id: string, item: file-decoration) -> result<_, error>;
/// Remove this tree set from every file/project.
clear-file: func(set-id: string) -> result<_, error>;
/// Replace this set's tree annotation for a file with a note shown after its name (API 1.7):
/// one line of at most 200 characters, with an optional tooltip.
set-file-note: func(path: string, set-id: string, note: string, tooltip: option<string>) -> result<_, error>;
}
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// What the pane beside the Services tree shows for a plugin's node.
interface detail-types {
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Optional export (world `plugin-diagnostics`): problems in the current,
/// possibly unsaved document, for the languages declared with the
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// HTTP endpoints found in a project (`[[contributes.endpoints]]`).
interface endpoints-provider {
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
interface lifecycle {
use types.{context, error};
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// What other plugins contributed to the extension points this plugin declares
/// (`[[extension-points]]` in `plugin.toml`; the contributors list this plugin
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Optional export (world `plugin-file-actions`): quick fixes and
/// refactorings that create, rename or delete files, next to the text edits
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Files outside the open projects: the configuration of the program a
/// plugin integrates with, a tool installed elsewhere, a licence file.
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Optional export (world `plugin-hover`): Markdown added to the hover of
/// documents of the languages declared with the `hover` provider kind,
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Cross-file associations a plugin publishes for the files it declares under
/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Embedded languages. The host routes plugin providers and configured language
/// servers inside these ranges, preserving host-document UTF-16 coordinates.
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Optional export (world `plugin-inlay-hints`): inline hints in documents of
/// the languages declared with the `inlay-hints` provider kind, including
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Additional language features, called only for declared API 1.4 provider kinds.
/// Editor calls have a 50 ms soft / 500 ms hard budget; formatting has 5 seconds.
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Discovery of the IDE's built-in MCP server; never exposes its URL or token.
interface mcp {
@@ -8,7 +8,7 @@
/// `fs` and `workspace`, and everything else goes through the imports below,
/// gated by the capabilities granted in `plugin.toml` (a missing one returns
/// `error::denied`).
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
world plugin {
import log;
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Project-scoped targets owned by the registering plugin (API 1.4).
interface run-targets {
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Launches this plugin's configurations in the invoking window's Run tool window.
interface run {
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Optional export (world `plugin-semantic-tokens`): semantic colouring of
/// documents of the languages declared with the `semantic-tokens` provider
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
/// as in the editor and LSP.
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
@@ -149,6 +149,12 @@ interface views {
open-content-tab: func(tab: content-tab) -> result<_, error>;
/// Answers content-tab-action in its originating window.
reply-content-tab: func(request-id: string, reply: content-tab-reply) -> result<_, error>;
/// Opens two texts in the diff viewer of the window the call came from
/// (API 1.7): a review's file before and after, say. `file-name` (a name or
/// relative path, at most 256 characters) picks the syntax highlighting and
/// names the tab when `diff.title` is absent. Only from a command or event
/// of a window; each side at most 1 MiB.
open-diff: func(diff: content-diff, file-name: option<string>) -> result<_, error>;
/// The view (or every custom editor of that id) shows outdated content:
/// the IDE renders it again.
@@ -1,4 +1,4 @@
package openidea:plugin@1.6.0;
package openidea:plugin@1.7.0;
interface workspace {
use types.{error, location};
+43
View File
@@ -6,6 +6,11 @@
//! and is immutable: the revision in the URL changes with the installed
//! package. HTML gets a CSP of its own, in case a page navigates its frame to
//! one: still sandboxed, still no network.
//!
//! [`FRAME_HOST_PATH`] is the page that the `html` bodies plugin code renders are
//! written into. A `srcdoc` frame would inherit the workbench's CSP, whose
//! `script-src` blocks the inline scripts of those bodies; a page of this scheme
//! has only the policy of its own response.
use crate::state::AppState;
use tauri::http::{Request, Response, StatusCode, header};
@@ -22,6 +27,25 @@ const HTML_CSP: &str = "sandbox allow-scripts; default-src 'none'; \
media-src openidea-plugin: http://openidea-plugin.localhost data: blob:; \
connect-src openidea-plugin: http://openidea-plugin.localhost data: blob:";
/// The host page of `html` bodies: it tells the workbench it is ready and writes
/// the document it gets back (`frameDocument` in src/features/plugins/views/frame.ts).
pub const FRAME_HOST_PATH: &str = ".frame";
/// The policy of rendered `html` bodies (`FRAME_CSP` in frame.ts), sandboxed as their frame is.
const FRAME_HOST_CSP: &str = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; \
style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:";
const FRAME_HOST: &str = r#"<!doctype html><meta charset="utf-8"><script>
addEventListener("message", (e) => {
const d = e.data;
if (e.source !== parent || !d || d.tag !== "openidea-view" || typeof d.document !== "string") return;
document.open();
document.write(d.document);
document.close();
});
parent.postMessage({ tag: "openidea-view", ready: true }, "*");
</script>"#;
/// `window.openidea` (one function expression taking the initial theme), the same
/// source the workbench inlines into rendered `html` bodies.
const BRIDGE: &str = include_str!("../../src/features/plugins/views/frame-bridge.js");
@@ -57,6 +81,15 @@ pub fn handle<R: Runtime>(ctx: UriSchemeContext<'_, R>, request: Request<Vec<u8>
fn serve(plugins: &ide_host::plugins::PluginHost, path: &str) -> Response<Vec<u8>> {
let decoded = percent_encoding::percent_decode_str(path.trim_start_matches('/')).decode_utf8_lossy();
if decoded == FRAME_HOST_PATH {
return Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CONTENT_SECURITY_POLICY, FRAME_HOST_CSP)
.header(header::CACHE_CONTROL, "no-cache")
.header("X-Content-Type-Options", "nosniff")
.body(FRAME_HOST.as_bytes().to_vec())
.unwrap_or_else(|_| status(StatusCode::INTERNAL_SERVER_ERROR));
}
let Some((plugin, rest)) = decoded.split_once('/') else { return status(StatusCode::NOT_FOUND) };
match plugins.web_file(plugin, rest) {
Ok(file) => {
@@ -104,4 +137,14 @@ mod tests {
let bare = String::from_utf8(with_bridge(b"<p>x</p>")).unwrap();
assert!(bare.starts_with("<script>(") && bare.ends_with("<p>x</p>"));
}
#[test]
fn the_frame_host_follows_the_workbench_frame() {
let frame = include_str!("../../src/features/plugins/views/frame.ts");
let csp = frame.split("FRAME_CSP =").nth(1).and_then(|s| s.split('"').nth(1)).unwrap();
assert_eq!(FRAME_HOST_CSP.strip_prefix("sandbox allow-scripts; ").unwrap(), csp);
let tag = frame.split("FRAME_TAG =").nth(1).and_then(|s| s.split('"').nth(1)).unwrap();
assert!(FRAME_HOST.contains(&format!("d.tag !== \"{tag}\"")), "{FRAME_HOST}");
assert!(frame.contains(&format!("FRAME_HOST_PATH = \"{FRAME_HOST_PATH}\"")));
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenIDEA",
"version": "0.1.43",
"version": "0.1.44",
"identifier": "ai.l0ng.openidea",
"build": {
"beforeDevCommand": "pnpm dev",
+11
View File
@@ -79,6 +79,17 @@ const item = (name: string, kind: OutlineItem["kind"], line: number, children: O
return { name, kind, detail: null, range: r, selection_range: r, children };
};
describe("usages label", () => {
it("is translated", async () => {
const { usagesLabel } = await import("./codeLens");
const { setLocale } = await import("@/platform/i18n");
expect([0, 1, 3].map(usagesLabel)).toEqual(["no usages", "1 usage", "3 usages"]);
await setLocale("zh");
expect([0, 1, 3].map(usagesLabel)).toEqual(["无用法", "1 个用法", "3 个用法"]);
await setLocale("en");
});
});
describe("usages lens targets", () => {
it("skips callbacks and anonymous functions", () => {
const items = [
+2 -2
View File
@@ -81,8 +81,8 @@ export function lensTargets(items: readonly OutlineItem[]): LensTarget[] {
}
export function usagesLabel(n: number): string {
if (n === 0) return "no usages";
return n === 1 ? "1 usage" : `${n} usages`;
if (n === 0) return tx("no usages");
return n === 1 ? tx("1 usage") : tx`${n} usages`;
}
const setLenses = StateEffect.define<CodeLens[]>();
+71 -1
View File
@@ -1,9 +1,11 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { writeClipboardText } from "@/platform/clipboard";
import { commands } from "@/platform/ipc";
import { ExportDialog } from "./ExportDialog";
import { cellPreview } from "./cellPreview";
vi.mock("@/platform/clipboard", () => ({ writeClipboardText: vi.fn(async () => {}) }));
vi.mock("./store", () => ({ closeDialog: vi.fn() }));
vi.mock("@/workbench/contributions", async (importOriginal) => ({
...(await importOriginal<typeof import("@/workbench/contributions")>()),
@@ -11,6 +13,8 @@ vi.mock("@/workbench/contributions", async (importOriginal) => ({
}));
vi.mock("@tauri-apps/api/path", () => ({ homeDir: async () => "/home/test" }));
afterEach(() => vi.restoreAllMocks());
describe("large value export", () => {
it("bounds preview values while exporting every row in full", async () => {
const full = JSON.stringify({ text: "\u4e2d text ".repeat(200_000), tail: "complete" });
@@ -38,3 +42,69 @@ describe("large value export", () => {
dbExportFile.mockRestore();
});
});
describe("exact numeric export", () => {
it.each(["Copy to Clipboard", "Export to File"])("keeps preview and %s values exact", async (action) => {
const dbExportFile = vi.spyOn(commands, "dbExportFile").mockResolvedValue({ status: "ok", data: null });
vi.mocked(writeClipboardText).mockClear();
const expected =
'[\n {\n "id": 9223372036854775807,\n "amount": 1.0000000000000000002,\n "note": "9007199254740993",\n "empty": null\n }\n]';
render(
<ExportDialog
data={{
columns: [
{ name: "id", type: "BIGINT" },
{ name: "amount", type: "NUMERIC(38,19)" },
{ name: "note", type: "TEXT" },
{ name: "empty", type: "TEXT" },
],
rows: [["9223372036854775807", "1.0000000000000000002", "9007199254740993", null]],
table: "numbers",
driver: "postgres",
}}
source="public.numbers"
initialFormat="json"
/>,
);
expect(screen.getByLabelText("Export preview").textContent).toBe(expected);
await waitFor(() => expect(screen.getByLabelText("Output file")).toHaveValue("/home/test/numbers.json"));
fireEvent.click(screen.getByRole("button", { name: action }));
if (action === "Copy to Clipboard") {
await waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith(expected));
} else {
await waitFor(() => expect(dbExportFile).toHaveBeenCalledWith("/home/test/numbers.json", expected));
}
});
});
it.each(["Copy to Clipboard", "Export to File"])(
"%s includes numeric rows beyond the preview limit",
async (action) => {
const dbExportFile = vi.spyOn(commands, "dbExportFile").mockResolvedValue({ status: "ok", data: null });
vi.mocked(writeClipboardText).mockClear();
const rows = Array.from({ length: 101 }, (_, i) => [(9223372036854775807n - BigInt(i)).toString()]);
render(
<ExportDialog
data={{ columns: [{ name: "id", type: "BIGINT" }], rows, table: "numbers", driver: "postgres" }}
source="public.numbers"
initialFormat="json"
/>,
);
const preview = screen.getByLabelText("Export preview").textContent!;
expect(preview).toContain('"id": 9223372036854775807');
expect(preview).not.toContain(rows[100]![0]!);
expect(preview).toContain("… 1 more rows");
await waitFor(() => expect(screen.getByLabelText("Output file")).toHaveValue("/home/test/numbers.json"));
fireEvent.click(screen.getByRole("button", { name: action }));
let text: string;
if (action === "Copy to Clipboard") {
await waitFor(() => expect(writeClipboardText).toHaveBeenCalled());
text = vi.mocked(writeClipboardText).mock.calls[0]![0];
} else {
await waitFor(() => expect(dbExportFile).toHaveBeenCalled());
text = dbExportFile.mock.calls[0]![1];
}
expect(JSON.parse(text)).toHaveLength(101);
expect(text).toContain(`"id": ${rows[100]![0]}`);
},
);
+14 -6
View File
@@ -200,6 +200,9 @@ function Pager({ tab }: { tab: ResultTab }) {
);
}
/** Unsubmitted edits by the rows they were made on, kept while another result tab is shown. */
const keptEdits = new WeakMap<DbQueryResult, PendingEdits>();
function Result({ tab }: { tab: ResultTab }) {
const [view, setView] = useState<"table" | "chart">("table");
const [panel, setPanel] = useState<DataPanel | null>(null);
@@ -275,14 +278,19 @@ function Result({ tab }: { tab: ResultTab }) {
sources.some((s) => s !== null);
// The pending edits belong to the result they were made on: fresh rows (a
// re-run, another page) start over, without an effect.
const [pending, setPending] = useState<{ outcome: DbQueryResult | null; edits: PendingEdits }>({
outcome: null,
edits: emptyEdits(),
});
// re-run, another page) start over, without an effect. They outlive the pane,
// which remounts when another result tab is selected.
const [pending, setPending] = useState<{ outcome: DbQueryResult | null; edits: PendingEdits }>(() => ({
outcome: r ?? null,
edits: (r && keptEdits.get(r)) ?? emptyEdits(),
}));
const edits = pending.outcome === r ? pending.edits : emptyEdits();
const setEdits = (f: (e: PendingEdits) => PendingEdits) =>
setPending((previous) => ({ outcome: r, edits: f(previous.outcome === r ? previous.edits : emptyEdits()) }));
setPending((previous) => {
const next = f(previous.outcome === r ? previous.edits : emptyEdits());
if (r) keptEdits.set(r, next);
return { outcome: r ?? null, edits: next };
});
const loaded = r?.rows.length ?? 0;
const rowCount = loaded + edits.inserts.length;
const value = useCallback(
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import type { DbDriver } from "@/platform/ipc";
import { exportData, type ExportData, type ExportFormat } from "./export";
const data = (value: string, type = "NUMERIC", driver: DbDriver = "postgres"): ExportData => ({
columns: [{ name: "value", type }],
rows: [[value]],
table: "numbers",
driver,
});
const jsonNumber = (value: string) => `[\n {\n "value": ${value}\n }\n]`;
describe("exact JSON number export", () => {
it.each([
["9007199254740993", "BIGINT", "postgres"],
["9223372036854775807", "BIGINT", "postgres"],
["-9223372036854775808", "BIGINT", "sqlite"],
["18446744073709551615", "BIGINT UNSIGNED", "mysql"],
["-9007199254740993", "Int64", "clickhouse"],
["12345678901234567890123456789012345678", "NUMBER(38,0)", "snowflake"],
["1.0000000000000000002", "DECIMAL(38,19)", "sqlServer"],
["-1.0000000000000000002", "NUMERIC(38,19)", "postgres"],
["1234567890.12345678901234567890", "DECIMAL(38,20)", "mysql"],
["0.0000000000000000001", "NUMERIC", "postgres"],
["1e-400", "NUMERIC", "postgres"],
["1e-325", "NUMERIC", "postgres"],
["1e-9007199254740993", "NUMERIC", "postgres"],
["-2e-999999999999999999999", "NUMERIC", "postgres"],
["99999999999999999999.99999999999999999999", "NUMERIC", "postgres"],
["0.10000000000000000000000000001", "DECIMAL(38,29)", "mysql"],
["1.797693134862315708145274237317043567981e308", "DOUBLE", "mysql"],
["-2e-400", "NUMERIC", "postgres"],
["1.0000000000000000002E+100", "NUMERIC", "postgres"],
] as const)("preserves %s (%s, %s) as a JSON number", (value, type, driver) => {
const json = exportData("json", data(value, type, driver));
expect(json).toBe(jsonNumber(value));
expect(typeof JSON.parse(json)[0].value).toBe("number");
});
it.each([
["+9007199254740993", "9007199254740993"],
["09007199254740993", "9007199254740993"],
["-09223372036854775807", "-9223372036854775807"],
[" \t+001.0000000000000000002\n", "1.0000000000000000002"],
[".0000000000000000001", "0.0000000000000000001"],
["-.0000000000000000001", "-0.0000000000000000001"],
["0001.", "1"],
["+001.25e-003", "1.25e-003"],
["1.e-400", "1e-400"],
["-000.0000000000000000001", "-0.0000000000000000001"],
["+.0000000000000000001E-400", "0.0000000000000000001E-400"],
])("normalizes %s to valid JSON without rounding", (value, expected) => {
const json = exportData("json", data(value));
expect(json).toBe(jsonNumber(expected));
expect(typeof JSON.parse(json)[0].value).toBe("number");
});
it.each(["0", "-0", "-000.000e99999", "42", "-12.5", "+1", ".5", "1.", "", " \t ", "0x10", "0b10", "0o10"])(
"keeps ordinary numeric coercion for %s",
(value) => {
expect(JSON.parse(exportData("json", data(value)))).toEqual([
{ value: JSON.parse(JSON.stringify(Number(value))) },
]);
},
);
it.each(["NaN", "Infinity", "-Infinity", "1e400", "-1e999999999999999999999", "1e", '0,"injected":true', "1\n2"])(
"keeps non-finite or invalid numeric text %s quoted",
(value) => {
expect(JSON.parse(exportData("json", data(value)))).toEqual([{ value }]);
},
);
it.each(["TEXT", "VARCHAR(100)", "jsonb", "BOOLEAN", "POINT", "INTERVAL"])(
"keeps numeric-looking %s as text",
(type) => {
expect(JSON.parse(exportData("json", data("9007199254740993", type)))).toEqual([{ value: "9007199254740993" }]);
},
);
it.each(["", "unknown", "NULL"])("preserves exact numbers inferred from %s types", (type) => {
expect(exportData("json", data("9007199254740993", type))).toBe(jsonNumber("9007199254740993"));
});
it("keeps a mixed unknown column as text", () => {
const d = { ...data("9007199254740993", "unknown"), rows: [["9007199254740993"], ["text"]] };
expect(JSON.parse(exportData("json", d))).toEqual([{ value: "9007199254740993" }, { value: "text" }]);
});
it("escapes column names and text and keeps NULL and missing cells", () => {
const d: ExportData = {
columns: [
{ name: 'a"\n', type: "TEXT" },
{ name: "empty", type: "NUMERIC" },
{ name: "missing", type: "TEXT" },
],
rows: [['中文 \\ \t "quote"\n\u0000', null]],
table: "numbers",
driver: "postgres",
};
expect(exportData("json", d)).toBe(
JSON.stringify([{ ['a"\n']: '中文 \\ \t "quote"\n\u0000', empty: null, missing: null }], null, 2),
);
});
it("keeps duplicate columns last-wins, integer key order and literal __proto__ keys", () => {
const d: ExportData = {
columns: [
{ name: "2", type: "NUMERIC" },
{ name: "10", type: "TEXT" },
{ name: "1", type: "TEXT" },
{ name: "2", type: "TEXT" },
{ name: "__proto__", type: "TEXT" },
],
rows: [["9007199254740993", "ten", "one", "9007199254740993", "literal"]],
table: "numbers",
driver: "postgres",
};
const row = Object.fromEntries([
["2", "9007199254740993"],
["10", "ten"],
["1", "one"],
["__proto__", "literal"],
]);
const json = exportData("json", d);
expect(json).toBe(JSON.stringify([row], null, 2));
expect(Object.hasOwn(JSON.parse(json)[0], "__proto__")).toBe(true);
});
it("keeps empty results, zero-column rows and ignored extra cells", () => {
expect(exportData("json", { ...data("1"), rows: [] })).toBe("[]");
expect(exportData("json", { ...data("1"), columns: [], rows: [[], []] })).toBe("[\n {},\n {}\n]");
expect(exportData("json", { ...data("1"), rows: [["1", "ignored"]] })).toBe(jsonNumber("1"));
});
it("keeps all-NULL inference samples from changing later values to numbers", () => {
const rows = [...Array.from({ length: 50 }, () => [null]), ["9007199254740993"]];
const json = exportData("json", { ...data("", "unknown"), rows });
expect(JSON.parse(json)[50]).toEqual({ value: "9007199254740993" });
});
it("keeps numeric inference when later rows include text and NULL", () => {
const rows = [...Array.from({ length: 50 }, () => ["9007199254740993"]), ["text"], [null]];
const json = exportData("json", { ...data("", "unknown"), rows });
expect(json.match(/"value": 9007199254740993/g)).toHaveLength(50);
expect(JSON.parse(json).slice(50)).toEqual([{ value: "text" }, { value: null }]);
});
it("retains the exact numeric value when the last duplicate column is numeric", () => {
const d: ExportData = {
...data(""),
columns: [
{ name: "value", type: "TEXT" },
{ name: "value", type: "BIGINT" },
],
rows: [["discarded", "9223372036854775807"]],
};
expect(exportData("json", d)).toBe(jsonNumber("9223372036854775807"));
});
it("agrees with independent integer arithmetic over 250 decimal scales and exponents", () => {
let seed = 0x20261113;
const random = () => (seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0);
const exponents = [-450, -400, -40, -1, 0, 1, 40, 200];
for (let i = 0; i < 250; i++) {
const absolute = 10n ** 36n + BigInt(random()) * 10n ** 27n + BigInt(random());
const coefficient = i % 2 ? absolute : -absolute;
const scale = (random() >>> 16) % 45;
const exponent = exponents[(random() >>> 16) % exponents.length]!;
const digits = absolute.toString().padStart(scale + 1, "0");
const mantissa = scale ? `${digits.slice(0, -scale)}.${digits.slice(-scale)}` : digits;
const sign = coefficient < 0n ? "-" : i % 3 ? "" : "+";
const input = `\t${sign}${i % 5 ? "" : "00"}${mantissa}E${exponent >= 0 ? "+" : ""}${exponent} `;
const json = exportData("json", data(input));
expect(typeof JSON.parse(json)[0].value, input).toBe("number");
const token = /"value": ([^\n]+)\n/.exec(json)![1]!;
const [whole, exp = "0"] = token.toLowerCase().split("e");
const point = whole!.indexOf(".");
const actual = BigInt(whole!.replace(".", ""));
const power = Number(exp) - (point < 0 ? 0 : whole!.length - point - 1);
const expectedPower = exponent - scale;
const common = Math.min(power, expectedPower);
expect(actual * 10n ** BigInt(power - common), input).toBe(coefficient * 10n ** BigInt(expectedPower - common));
}
});
it("writes long numbers without losing trailing digits or expanding exponents", () => {
const value = `1.${"0".repeat(20_000)}1e-400`;
expect(exportData("json", data(value))).toBe(jsonNumber(value));
});
});
it.each<ExportFormat>(["csv", "tsv", "sql", "markdown", "onerow"])("%s retains exact numeric digits", (format) => {
const value = "9223372036854775807";
expect(exportData(format, data(value))).toContain(value);
});
+19 -7
View File
@@ -49,6 +49,19 @@ function laidOut(d: ExportData, o: ExportOptions): { head: string[] | null; rows
return { head: null, rows: head.map((h, c) => [...(o.columnHeader ? [h] : []), ...rows.map((r) => r[c] ?? null)]) };
}
/** Normalize decimal text to JSON syntax without rounding its digits. */
function jsonCell(v: Cell, numeric: boolean): string {
if (v === null) return "null";
const number = numeric ? Number(v) : NaN;
if (!Number.isFinite(number)) return JSON.stringify(v);
const parts = /^([+-]?)(\d*)(?:\.(\d*))?([eE][+-]?\d+)?$/.exec(v.trim());
if (!parts) return JSON.stringify(number);
const whole = parts[2]!.replace(/^0+/, "") || "0";
const fraction = parts[3] ?? "";
if (!/[1-9]/.test(whole + fraction)) return "0";
return `${parts[1] === "-" ? "-" : ""}${whole}${fraction ? `.${fraction}` : ""}${parts[4] ?? ""}`;
}
export function exportData(
format: ExportFormat,
d: ExportData,
@@ -71,13 +84,12 @@ export function exportData(
return toTsv(t.head, t.rows);
}
case "json": {
const value = (v: Cell, i: number) =>
v === null ? null : numeric[i] && Number.isFinite(Number(v)) ? Number(v) : v;
return JSON.stringify(
d.rows.map((r) => Object.fromEntries(names.map((n, i) => [n, value(r[i] ?? null, i)]))),
null,
2,
);
const rows = d.rows.map((r) => {
const cells = Object.fromEntries(names.map((n, i) => [n, jsonCell(r[i] ?? null, numeric[i]!)]));
const members = Object.entries(cells).map(([name, value]) => ` ${JSON.stringify(name)}: ${value}`);
return members.length ? ` {\n${members.join(",\n")}\n }` : " {}";
});
return rows.length ? `[\n${rows.join(",\n")}\n]` : "[]";
}
case "sql": {
const literal = (v: Cell, i: number) =>
+120
View File
@@ -33,6 +33,95 @@ const rows: Cell[][] = [
];
const value = (r: number, c: number) => rows[r]?.[c] ?? null;
describe("exact numeric cell comparison", () => {
it.each([
["9007199254740992", "9007199254740993"],
["9223372036854775806", "9223372036854775807"],
["-9007199254740993", "-9007199254740992"],
["-9223372036854775808", "-9223372036854775807"],
["1.0000000000000000001", "1.0000000000000000002"],
["-1.0000000000000000002", "-1.0000000000000000001"],
["0.99999999999999999999", "1.00000000000000000001"],
["12.00000000000000000001", "12.0000000000000000001"],
["1e400", "2e400"],
["-2e400", "-1e400"],
["1e-400", "2e-400"],
["-2e-400", "-1e-400"],
["0", "1e-400"],
["-1e-400", "-0"],
["1e9007199254740992", "1e9007199254740993"],
["1e-9007199254740993", "1e-9007199254740992"],
["9e999999999999999999999999", "1e1000000000000000000000000"],
["000.000000000000000000001", "0.000000000000000000002"],
["123456789012345678901234567890", "123456789012345678901234567891"],
["10", "9e1"],
])("orders %s before %s in both directions", (smaller, larger) => {
expect(compareCells(smaller, larger)).toBeLessThan(0);
expect(compareCells(larger, smaller)).toBeGreaterThan(0);
});
it.each([
["1", "01"],
["1", "1.000"],
["1", "10e-1"],
["1.25", "0012500E-4"],
["-1.25", "-125e-2"],
["0", "-0"],
["-0.000e999999999999999999999999", "00e-999999999999999999999999"],
["1e400", "10e399"],
["1e-400", "100e-402"],
["1e9007199254740993", "10e9007199254740992"],
])("keeps equivalent values equal: %s and %s", (a, b) => {
expect(compareCells(a, b)).toBe(0);
expect(compareCells(b, a)).toBe(0);
});
it("keeps NULL and non-numeric text ordering", () => {
for (const text of ["", "NaN", "Infinity", "+1", ".5", "1.", "1e", "abc"]) {
expect(compareCells(null, text)).toBeLessThan(0);
expect(compareCells(text, null)).toBeGreaterThan(0);
expect(compareCells(text, "2")).toBe(text < "2" ? -1 : 1);
expect(compareCells(text, text)).toBe(0);
}
});
it("compares long significant digits without expanding the exponent", () => {
const prefix = `1${"0".repeat(20_000)}`;
expect(compareCells(`${prefix}1e999999999999999999999999`, `${prefix}2e999999999999999999999999`)).toBeLessThan(0);
});
it("agrees with independent rational arithmetic across decimal scales and exponents", () => {
let seed = 0x12345678;
const random = () => (seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0);
const samples = Array.from({ length: 120 }, (_, i) => {
const coefficient = (2n ** 60n + BigInt(random() % 2048)) * (i % 3 === 0 ? -1n : 1n);
const scale = random() % 25;
const exponent = (random() % 41) - 20;
const digits = (coefficient < 0n ? -coefficient : coefficient).toString().padStart(scale + 1, "0");
const decimal = scale ? `${digits.slice(0, -scale)}.${digits.slice(-scale)}` : digits;
return {
text: `${coefficient < 0n ? "-" : ""}${decimal}e${exponent}`,
numerator: coefficient * 10n ** BigInt(Math.max(0, exponent)),
denominator: 10n ** BigInt(scale + Math.max(0, -exponent)),
};
});
for (const a of samples) {
for (const b of samples) {
const left = a.numerator * b.denominator;
const right = b.numerator * a.denominator;
const expected = left === right ? 0 : left < right ? -1 : 1;
expect(Math.sign(compareCells(a.text, b.text)), `${a.text} vs ${b.text}`).toBe(expected);
}
}
const ordered = samples.toSorted((a, b) => compareCells(a.text, b.text));
for (let i = 1; i < ordered.length; i++) {
const a = ordered[i - 1]!;
const b = ordered[i]!;
expect(a.numerator * b.denominator <= b.numerator * a.denominator).toBe(true);
}
});
});
describe("local filter and sort", () => {
it("compares numbers by value, NULL first", () => {
expect(["10", "9", null, "abc"].sort(compareCells)).toEqual([null, "9", "10", "abc"]);
@@ -63,6 +152,27 @@ describe("local filter and sort", () => {
f = toggleFilterValue(f, 1, all, "Beijing", true);
expect(f.has(1)).toBe(false);
});
it.each([false, true])("sorts exact values, preserving equal rows (descending: %s)", (descending) => {
const cells: Cell[] = ["9007199254740993", "1.0000000000000000002", "9007199254740992", "1", "1.000", null];
const order = visibleRows(cells.length, (r) => cells[r]!, new Map(), { column: 0, descending });
expect(order).toEqual(descending ? [0, 2, 1, 3, 4, 5] : [5, 3, 4, 1, 2, 0]);
});
it("sorts after filtering and keeps newly added rows last", () => {
const cells = ["9007199254740993", "9007199254740992", "1.0000000000000000002", "-999"];
const filter: LocalFilter = new Map([[0, new Set([cells[0]!, cells[1]!])]]);
expect(visibleRows(cells.length, (r) => cells[r]!, filter, { column: 0, descending: false }, 3)).toEqual([1, 0, 3]);
});
it("orders precise filter values before applying the limit and retains their counts", () => {
const cells = ["9007199254740993", "9007199254740992", "9007199254740992", "1.0000000000000000002", "1"];
expect(distinctValues(cells.length, (r) => cells[r]!, 0, 3)).toEqual([
{ value: "1", count: 1 },
{ value: "1.0000000000000000002", count: 1 },
{ value: "9007199254740992", count: 2 },
]);
});
});
describe("aggregates", () => {
@@ -79,6 +189,16 @@ describe("aggregates", () => {
});
expect(aggregates(["b", "a", "b"])).toMatchObject({ distinct: 2, sum: null, avg: null, min: "a", max: "b" });
});
it.each([
["9007199254740992", "9007199254740993"],
["-9007199254740993", "-9007199254740992"],
["1.0000000000000000001", "1.0000000000000000002"],
["1e-400", "2e-400"],
["1e400", "2e400"],
])("preserves exact minimum %s and maximum %s", (min, max) => {
expect(aggregates([max, null, min])).toMatchObject({ min, max });
});
});
describe("chart series", () => {
+32 -2
View File
@@ -13,14 +13,44 @@ export interface LocalSort {
descending: boolean;
}
const NUMBER = /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i;
const NUMBER = /^(-?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i;
function decimalKey(value: string) {
const parts = NUMBER.exec(value)!;
const fraction = parts[3] ?? "";
const digits = (parts[2]! + fraction).replace(/^0+/, "");
if (!digits) return null;
let end = digits.length;
while (digits[end - 1] === "0") end--;
return {
negative: parts[1] === "-",
digits: digits.slice(0, end),
order: BigInt(parts[4] ?? "0") + BigInt(digits.length) - BigInt(fraction.length),
};
}
function compareNumbers(a: string, b: string): number {
const difference = Number(a) - Number(b);
// Distinct floating-point values keep decimal order; rounded ties need the original digits.
if (difference) return difference;
const left = decimalKey(a);
const right = decimalKey(b);
if (!left) return !right ? 0 : right.negative ? 1 : -1;
if (!right) return left.negative ? -1 : 1;
if (left.negative !== right.negative) return left.negative ? -1 : 1;
const direction = left.negative ? -1 : 1;
// Compare decimal orders without expanding even enormous exponents into zeroes.
if (left.order !== right.order) return direction * (left.order < right.order ? -1 : 1);
if (left.digits === right.digits) return 0;
return direction * (left.digits < right.digits ? -1 : 1);
}
/** Numbers by value, then text; NULL first (as most databases sort ascending). */
export function compareCells(a: Cell, b: Cell): number {
if (a === b) return 0;
if (a === null) return -1;
if (b === null) return 1;
if (NUMBER.test(a) && NUMBER.test(b)) return Number(a) - Number(b);
if (NUMBER.test(a) && NUMBER.test(b)) return compareNumbers(a, b);
return a < b ? -1 : 1;
}
@@ -235,6 +235,29 @@ describe("editable result grid", () => {
expect(backend.applyEdits).not.toHaveBeenCalled();
});
it("keeps unsubmitted edits while another result tab is shown", async () => {
const first = show("SELECT * FROM users WHERE id < 10", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
const second: ResultTab = {
...first,
id: nextId++,
title: "Result 2",
outcome: { ...first.outcome, result: { ...first.outcome.result! } },
};
useDatabase.setState((s) => ({ results: [...s.results, second] }));
await act(async () => render(<ResultsView />));
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
const input = screen.getByRole("textbox", { name: "Edit name" });
fireEvent.change(input, { target: { value: "Bob" } });
fireEvent.keyDown(input, { key: "Enter" });
await act(async () => useDatabase.setState({ activeResult: second.id }));
expect(screen.getByRole("button", { name: "Submit" }).hasAttribute("disabled")).toBe(true);
await act(async () => useDatabase.setState({ activeResult: first.id }));
expect(screen.getByRole("gridcell", { name: "Bob" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Submit" }).hasAttribute("disabled")).toBe(false);
});
it("keeps an aliased column read-only while the plain ones stay editable", async () => {
show("SELECT id, name AS n FROM users", [{ name: "id" }, { name: "n" }], [["1", "Ann"]]);
await act(async () => render(<ResultsView />));
@@ -218,7 +218,7 @@ describe("plugin-backed content editor tabs", () => {
});
await waitFor(() => expect(diffs).toHaveLength(1));
expect(diffs[0]).toEqual({
path: "service.toml",
path: "plugin-diff:/service.toml",
title: "Diff: service.toml",
left: { type: "text", text: 'name = "demo"\nreplicas = 2\n', label: "Live" },
right: { type: "text", text: 'name = "demo"\nreplicas = 5\n', label: "Edited" },
@@ -239,18 +239,26 @@ describe("plugin-backed content editor tabs", () => {
openPluginDiffRequest({ left: "", right: "b\n", title: "MR !12" }, 10);
expect(diffs).toEqual([
{
path: "src/review.ts",
path: "plugin-diff:/review.ts",
title: "src/review.ts",
left: { type: "text", text: "a\n", label: "Target" },
right: { type: "text", text: "b\n", label: "After" },
},
{
path: "MR !12",
path: "plugin-diff:/MR !12",
title: "MR !12",
left: { type: "text", text: "", label: "Before" },
right: { type: "text", text: "b\n", label: "After" },
},
]);
// A file name only names the syntax: never a path on disk (a UNC share, say).
openPluginDiffRequest({ left: "a", right: "b", fileName: "\\\\attacker\\share\\x.rs" }, 10);
openPluginDiffRequest({ left: "a", right: "b", title: "C:\\Users\\x\\y.ts" }, 10);
expect(diffs.slice(2).map((d) => (d as { path: string }).path)).toEqual([
"plugin-diff:/x.rs",
"plugin-diff:/y.ts",
]);
diffs.splice(2);
expect(() => openPluginDiffRequest({ left: "a", right: 1 }, 10)).toThrow();
expect(() => openPluginDiffRequest({ left: "a", right: "b", fileName: "x\ny" }, 10)).toThrow();
expect(() => openPluginDiffRequest({ left: "a", right: "b" }, 4 << 20)).toThrow();
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
import type { View } from "@/bindings";
import { createMemoryPluginsBackend, setPluginsBackend } from "../backend";
import { FRAME_TAG } from "../views/frame";
import { PluginView } from "../views/PluginView";
vi.mock("@/platform/ipc", async (original) => ({ ...(await original()), isTauri: () => true }));
describe("html bodies in the app", () => {
it("writes the document into the plugin scheme's host page, not a srcdoc that inherits the workbench CSP", async () => {
const view: View = {
toolbar: [],
body: { kind: "html", value: "<p>chart</p><script>draw()</script>" },
status: null,
};
setPluginsBackend({ ...createMemoryPluginsBackend([]), renderView: vi.fn(async () => view) });
const { container } = render(<PluginView pluginId="charts" view="chart" />);
await waitFor(() => expect(container.querySelector("iframe")).not.toBeNull());
const frame = container.querySelector("iframe")!;
expect(frame.hasAttribute("srcdoc")).toBe(false);
expect(frame.getAttribute("src")).toMatch(
/^(openidea-plugin:\/\/localhost|http:\/\/openidea-plugin\.localhost)\/\.frame$/,
);
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
const post = vi.spyOn(frame.contentWindow!, "postMessage");
// Not from the frame: ignored.
window.dispatchEvent(new MessageEvent("message", { data: { tag: FRAME_TAG, ready: true }, source: window }));
expect(post).not.toHaveBeenCalled();
window.dispatchEvent(
new MessageEvent("message", { data: { tag: FRAME_TAG, ready: true }, source: frame.contentWindow }),
);
expect(post).toHaveBeenCalledWith(
{ tag: FRAME_TAG, document: expect.stringContaining("<p>chart</p><script>draw()</script>") },
"*",
);
const doc = (post.mock.calls[0]![0] as { document: string }).document;
expect(doc).toContain('<meta http-equiv="Content-Security-Policy"');
expect(doc).toContain("window.openidea");
});
});
+19 -9
View File
@@ -18,6 +18,7 @@ import {
} from "react";
import clsx from "clsx";
import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings";
import { isTauri } from "@/platform/ipc";
import { tx, VerbatimTextContext } from "@/platform/i18n";
import {
Checkbox,
@@ -34,9 +35,9 @@ import {
showContextMenu,
type MenuItem,
} from "@/ui";
import { pluginsBackend } from "../backend";
import { pluginsBackend, webSchemeUrl } from "../backend";
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
import { FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame";
import { FRAME_HOST_PATH, FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame";
import { localizeView, usePluginT } from "../pluginText";
import { currentViewLocation, onViewMessage, useViewLocation, useViewRevision } from "./viewStore";
import { ServicePane } from "./ServicePane";
@@ -674,7 +675,10 @@ function HtmlBody({
const frame = useRef<HTMLIFrameElement>(null);
// The theme at load time is in the document; later changes are posted, so a
// theme switch doesn't reload the frame and lose its state.
const srcDoc = useMemo(() => frameDocument(html, frameTheme()), [html]);
const doc = useMemo(() => frameDocument(html, frameTheme()), [html]);
// In the app the document is written into a page of the plugin scheme: a `srcdoc` frame
// would inherit the workbench's CSP, which blocks the document's inline scripts.
const host = isTauri() ? webSchemeUrl(FRAME_HOST_PATH) : null;
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
const postTheme = useCallback(
() => frame.current?.contentWindow?.postMessage(frameThemeMessage(frameTheme()), "*"),
@@ -685,8 +689,10 @@ function HtmlBody({
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (e.source !== frame.current?.contentWindow) return;
const data = e.data as { tag?: unknown; text?: unknown } | null;
if (data?.tag === FRAME_TAG && typeof data.text === "string") send({ kind: "message", text: data.text });
const data = e.data as { tag?: unknown; text?: unknown; ready?: unknown } | null;
if (data?.tag !== FRAME_TAG) return;
if (data.ready === true) frame.current?.contentWindow?.postMessage({ tag: FRAME_TAG, document: doc }, "*");
else if (typeof data.text === "string") send({ kind: "message", text: data.text });
};
window.addEventListener("message", onMessage);
const off = onViewMessage(pluginId, view, (text) =>
@@ -696,18 +702,22 @@ function HtmlBody({
window.removeEventListener("message", onMessage);
off();
};
}, [pluginId, view, send]);
}, [pluginId, view, send, doc]);
// No `allow-same-origin`: an opaque origin without access to the IDE.
// `onLoad` re-sends the theme in case it changed between building `srcDoc` and the frame loading.
return (
// `onLoad` re-sends the theme in case it changed between building the document and the frame loading.
// A new document loads a new frame (`key`), as a changed `srcdoc` does.
return host ? (
<iframe
key={doc}
ref={frame}
className={styles.frame}
title={view}
sandbox="allow-scripts"
srcDoc={srcDoc}
src={host}
onLoad={postTheme}
/>
) : (
<iframe ref={frame} className={styles.frame} title={view} sandbox="allow-scripts" srcDoc={doc} onLoad={postTheme} />
);
}
+15 -2
View File
@@ -176,9 +176,22 @@ function validDiff(v: unknown): ContentTabDiff | undefined {
}
/** Opens a plugin's two texts in the diff viewer; `path` picks the syntax highlighting. */
export function openPluginDiff(diff: ContentTabDiff, path: string, title: string) {
/**
* A plugin diff's path: the file name only picks the syntax highlighting. It is virtual, so the
* diff viewer never probes or opens a file of that name (a plugin may lack file system access).
*/
export function pluginDiffPath(name: string): string {
const base =
name
.split(/[\\/]/)
.pop()
?.replace(/[\u0000-\u001f:]/g, "") || "diff";
return `plugin-diff:/${base}`;
}
export function openPluginDiff(diff: ContentTabDiff, name: string, title: string) {
void executeAction("vcs.openDiff", {
path,
path: pluginDiffPath(name),
title: diff.title ?? title,
left: { type: "text", text: diff.left, label: diff.leftLabel ?? tx("Before") },
right: { type: "text", text: diff.right, label: diff.rightLabel ?? tx("After") },
+7
View File
@@ -19,6 +19,13 @@ export const FRAME_CSP =
/** Marks frame ↔ workbench messages. */
export const FRAME_TAG = "openidea-view";
/**
* The page of the `openidea-plugin` scheme that a rendered document is written into
* (src-tauri/src/plugin_web.rs). A `srcdoc` frame would inherit the workbench's CSP,
* which blocks inline scripts; this page has only {@link FRAME_CSP}.
*/
export const FRAME_HOST_PATH = ".frame";
/**
* The theme variables a frame gets: public name → IDE token. Part of the
* plugin API: names are only ever added, so the IDE's own tokens can change.
@@ -92,6 +92,40 @@ describe("file naming", () => {
expect(ids).toContain(id);
expect(new Set(ids).size).toBe(ids.length);
});
it("ships a Jupyter Notebook template that the notebook editor accepts", () => {
const jupyter = tpl("jupyter");
expect(jupyter).toMatchObject({ extension: "ipynb", icon: "file-jupyter", nameStyle: "asis" });
expect(jupyter.enabled).not.toBe(false);
for (const kind of jupyter.kinds) {
const { text } = renderFileTemplate(kind.body, { name: "analysis", fileName: "analysis.ipynb" });
const notebook = JSON.parse(text);
// The rules of the backend's validate_notebook.
expect(notebook.nbformat).toBe(4);
expect(Number.isInteger(notebook.nbformat_minor)).toBe(true);
expect(notebook.metadata).toEqual(expect.any(Object));
expect(Array.isArray(notebook.cells)).toBe(true);
for (const cell of notebook.cells as Array<Record<string, unknown>>) {
expect(typeof cell.cell_type).toBe("string");
expect(cell.metadata).toEqual(expect.any(Object));
expect(cell.source).toEqual(expect.any(Array));
}
}
});
it("names the titled notebook after the file", () => {
const titled = tpl("jupyter").kinds.find((k) => k.id === "titled")!;
const { text } = renderFileTemplate(titled.body, { name: "report", fileName: "report.ipynb" });
expect(JSON.parse(text).cells[0]).toMatchObject({
cell_type: "markdown",
source: ["# report"],
});
});
it("plans the notebook file name from the entered name", () => {
expect(planFile(tpl("jupyter"), "/p/notebooks", "analysis")?.path).toBe("/p/notebooks/analysis.ipynb");
expect(planFile(tpl("jupyter"), "/p", "analysis.ipynb")?.path).toBe("/p/analysis.ipynb");
});
});
describe("PHP namespaces", () => {
@@ -0,0 +1,189 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFromTemplate } from "../files/create";
import { renderFileTemplate, type FileTemplate } from "../files/model";
import { BUILTIN_FILE_TEMPLATES } from "../files/builtins";
import { activate as activateTemplates } from "../index";
import { menuGroupItems } from "@/workbench/contributions";
import { useProject } from "@/platform/project";
const files = vi.hoisted(() => new Map<string, string>());
const calls = vi.hoisted(() => ({
bytes: vi.fn(),
empty: vi.fn(),
openFile: vi.fn(),
notify: vi.fn(),
resolve: vi.fn(),
rollback: vi.fn(),
}));
const view = vi.hoisted(() => ({
state: { doc: { length: 0, toString: () => "" }, selection: { main: { head: 0 } } },
dispatch: vi.fn(),
focus: vi.fn(),
}));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
isTauri: () => true,
call: (result: unknown) => result,
commands: {
fsCreateFileBytes: (path: string, data: number[]) => {
calls.bytes(path);
files.set(path, new TextDecoder().decode(new Uint8Array(data)));
return Promise.resolve(null);
},
fsCreateFile: (path: string) => {
calls.empty(path);
files.set(path, "");
return Promise.resolve(null);
},
fsReadDir: () => Promise.resolve([]),
},
}));
vi.mock("@/editor/api", async (original) => ({
...(await original<typeof import("@/editor/api")>()),
editorsFor: (path: string) => (/\.py$/.test(path) ? [{ view }] : []),
activeEditor: () => null,
}));
vi.mock("@/platform/actions", async (original) => ({
...(await original<typeof import("@/platform/actions")>()),
executeAction: vi.fn(async () => true),
}));
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
editorService: () => ({
openFile: async (path: string) => void calls.openFile(path),
saveAll: async () => {},
}),
maybeEditorService: () => ({ activePath: () => null }),
notifications: () => ({ notify: calls.notify }),
resolveFileEditor: (path: string) => {
calls.resolve(path);
// A notebook: its own editor kind, no CodeMirror view.
return Promise.resolve(
/\.ipynb$/.test(path)
? { input: { kind: "notebook", key: `notebook:${path}` }, admit: () => {}, rollback: calls.rollback }
: null,
);
},
}));
const NOTEBOOK_BODY = `{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": ["# $NAME$"]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 5
}
`;
const notebook: FileTemplate = {
id: "user.jupyter",
name: "Jupyter Notebook",
icon: "file-jupyter",
extension: "ipynb",
nameStyle: "asis",
kinds: [{ id: "notebook", label: "Notebook", body: NOTEBOOK_BODY }],
};
afterEach(() => {
files.clear();
vi.clearAllMocks();
useProject.setState({ project: null });
});
describe("createFromTemplate", () => {
it("writes the rendered body before a notebook editor takes the file", async () => {
useProject.setState({ project: { root: "/p", name: "p" } as never });
const path = await createFromTemplate(notebook, notebook.kinds[0]!, "/p", "analysis");
expect(path).toBe("/p/analysis.ipynb");
expect(files.get(path)).toBe(NOTEBOOK_BODY.replace("$NAME$", "analysis"));
expect(JSON.parse(files.get(path)!)).toMatchObject({
nbformat: 4,
cells: [{ cell_type: "markdown", source: ["# analysis"] }],
});
expect(calls.empty).not.toHaveBeenCalled();
// The kind owns the document, so there is no "could not insert" warning.
expect(calls.notify).not.toHaveBeenCalled();
expect(calls.resolve).toHaveBeenCalledWith(path);
// The probe's acquisitions (the notebook backend session) are released.
expect(calls.rollback).toHaveBeenCalledTimes(1);
expect(calls.openFile).toHaveBeenCalledWith(path);
// The body is on disk before the editor reads it.
expect(calls.bytes.mock.invocationCallOrder[0]!).toBeLessThan(calls.openFile.mock.invocationCallOrder[0]!);
});
it("inserts a text template into the editor view, caret at $END$", async () => {
const py: FileTemplate = {
...notebook,
id: "user.py",
extension: "py",
kinds: [{ id: "script", label: "Script", body: "print(1)\n$END$" }],
};
const path = await createFromTemplate(py, py.kinds[0]!, "/p", "main");
expect(calls.bytes).not.toHaveBeenCalled();
expect(calls.empty).toHaveBeenCalledWith(path);
expect(view.dispatch).toHaveBeenCalledWith(
expect.objectContaining({ changes: { from: 0, insert: "print(1)\n" }, userEvent: "input" }),
);
expect(calls.notify).not.toHaveBeenCalled();
});
it("creates an empty file when the template renders nothing", async () => {
const empty: FileTemplate = { ...notebook, kinds: [{ id: "blank", label: "Empty", body: "" }] };
const path = await createFromTemplate(empty, empty.kinds[0]!, "/p", "blank");
expect(calls.empty).toHaveBeenCalledWith(path);
expect(files.get(path)).toBe("");
expect(calls.bytes).not.toHaveBeenCalled();
expect(calls.notify).not.toHaveBeenCalled();
});
it("renders the notebook body a text template would have got", () => {
const { text } = renderFileTemplate(NOTEBOOK_BODY, { name: "report", fileName: "report.ipynb" });
expect(JSON.parse(text)).toMatchObject({
nbformat: 4,
cells: [{ cell_type: "markdown", source: ["# report"] }],
});
});
});
describe("built-in Jupyter Notebook", () => {
const builtin = BUILTIN_FILE_TEMPLATES.find((t) => t.id === "jupyter")!;
it("writes the titled notebook body before the notebook editor takes the file", async () => {
const titled = builtin.kinds.find((k) => k.id === "titled")!;
const path = await createFromTemplate(builtin, titled, "/p", "analysis");
expect(path).toBe("/p/analysis.ipynb");
expect(JSON.parse(files.get(path)!)).toMatchObject({
nbformat: 4,
cells: [{ cell_type: "markdown", source: ["# analysis"] }],
});
expect(calls.notify).not.toHaveBeenCalled();
});
it("is listed in the New menu with its icon, without duplicating entries", () => {
const dispose = activateTemplates();
try {
const items = menuGroupItems("new", { paths: ["/p"] });
const actions = items.filter((i) => i.kind === "action");
const row = actions.find((i) => i.kind === "action" && i.action === "templates.new.jupyter");
expect(row).toMatchObject({ label: "Jupyter Notebook", icon: "file-jupyter" });
const ids = actions.map((i) => i.action);
expect(new Set(ids).size).toBe(ids.length);
} finally {
dispose();
}
});
});
+56
View File
@@ -4,6 +4,51 @@ import type { FileTemplate, TemplateFragment } from "./model";
/** Includes tab: fragments file templates pull in with `#parse("…")`. */
export const BUILTIN_INCLUDES: readonly TemplateFragment[] = [{ name: "File Header", body: "" }];
/** Opens empty: the notebook editor adds the first cell. */
const JUPYTER_NOTEBOOK_BODY = `{
"cells": [],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
`;
/** Starts with a Markdown heading named after the file. */
const JUPYTER_TITLED_NOTEBOOK_BODY = `{
"cells": [
{
"cell_type": "markdown",
"id": "cell-title",
"metadata": {},
"source": [
"# $NAME$"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
`;
/** Code tab: the members the built-in test templates generate. */
export const BUILTIN_CODE_TEMPLATES: readonly TemplateFragment[] = [
{
@@ -128,6 +173,17 @@ export const BUILTIN_FILE_TEMPLATES: readonly FileTemplate[] = [
{ id: "script", label: "Script", body: 'def main():\n $END$\n\n\nif __name__ == "__main__":\n main()\n' },
],
},
{
id: "jupyter",
name: "Jupyter Notebook",
icon: "file-jupyter",
extension: "ipynb",
nameStyle: "asis",
kinds: [
{ id: "notebook", label: "Notebook", body: JUPYTER_NOTEBOOK_BODY },
{ id: "titled", label: "Notebook with a title", body: JUPYTER_TITLED_NOTEBOOK_BODY },
],
},
{
id: "go",
name: "Go File",
+21 -3
View File
@@ -7,7 +7,7 @@ import { call, commands, isTauri } from "@/platform/ipc";
import { dirname, useProject } from "@/platform/project";
import { currentRoots } from "@/platform/projectRoots";
import { activeEditor, editorsFor, type TextEditorHandle } from "@/editor/api";
import { editorService, maybeEditorService, notifications } from "@/workbench/contributions";
import { editorService, maybeEditorService, notifications, resolveFileEditor } from "@/workbench/contributions";
import {
GO_PACKAGE_RE,
caretAfterReformat,
@@ -112,10 +112,23 @@ export function defaultDirectory(): string | null {
return path ? dirname(path) : (useProject.getState().project?.root ?? null);
}
/** Whether a non-text editor opens `path`; the probe's acquisitions are released. */
async function providedEditor(path: string): Promise<boolean> {
const resolution = await resolveFileEditor(path).catch(() => null);
if (!resolution) return false;
try {
await resolution.rollback();
} catch {
// Released on a best-effort basis; opening resolves the editor again.
}
return true;
}
/**
* Creates `input` (a name, possibly with sub-directories) in `directory`
* from `kind`, opens it and inserts the rendered body with the caret at
* `$END$`. Throws with a user-facing message on failure.
* `$END$` (a file a non-text editor opens, like a notebook, gets the body on
* disk instead). Throws with a user-facing message on failure.
*/
export async function createFromTemplate(
t: FileTemplate,
@@ -144,11 +157,16 @@ export async function createFromTemplate(
templateIncludes(),
);
if (cancelled?.()) return plan.path;
await call(commands.fsCreateFile(plan.path));
// A provided editor (a notebook shows cells, not text) reads the file from
// disk and has no view to insert into, so its body is written on create.
const provided = await providedEditor(plan.path);
if (provided && text) await call(commands.fsCreateFileBytes(plan.path, [...new TextEncoder().encode(text)]));
else await call(commands.fsCreateFile(plan.path));
notifyFileOperation({ kind: "created", paths: [plan.path] });
onCreated?.();
await new Promise((r) => setTimeout(r, 0));
await editorService().openFile(plan.path, { focus: true });
if (provided) return plan.path;
const handle = await waitForEditor(plan.path);
if (!handle) {
notifications().notify({
+1 -1
View File
@@ -471,7 +471,7 @@ function BranchesPanel({ onClose, actions: baseActions, root, fallback, onBack }
<>
<div className={styles.search}>
<SearchField
className={root ? styles.searchGrow : undefined}
className={styles.searchGrow}
data-autofocus
value={query}
onChange={(v) => {
+3 -1
View File
@@ -37,6 +37,7 @@ import { call, commands } from "@/platform/ipc";
import { bindingsFor, normalizeBinding, shortcutLabel, strokeOf } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { isVirtualUri } from "@/platform/virtualDocs";
import { IconButton, Select, Spinner, T, showContextMenu } from "@/ui";
import { registerSoftWrapSurface } from "@/workbench/softWrap";
import { editorService, type EditorInput } from "@/workbench/contributions";
@@ -819,7 +820,8 @@ export function DiffEditor({
const fileSource = sideFile(left, path) ?? sideFile(right, path);
const [onDisk, setOnDisk] = useState<string | null>(null);
useEffect(() => {
if (fileSource) return;
// A virtual path (a plugin's text diff) names no file on disk.
if (fileSource || isVirtualUri(path)) return;
let alive = true;
void call(commands.fsProbe(path)).then(
() => alive && setOnDisk(path),
@@ -138,6 +138,16 @@ describe("buildBranchRows", () => {
expect(summary(buildBranchRows(branches, [], "push", actions))).toEqual(["> Push…"]);
});
it("matches a query as a substring or at word starts, not as letters scattered through the name", () => {
const list = ["sprint-1.1-device-reset", "feature/test-paging", "fix/TestRunner", "bugfix/totals-reset"].map((n) =>
b(n),
);
const search = (q: string) => summary(buildBranchRows(list, [], q, [], null, [], { groupByPrefix: false }));
expect(search("test")).toEqual(["v Local", " local:feature/test-paging", " local:fix/TestRunner"]);
// Word starts still work: `ftp` finds feature/test-paging.
expect(search("ftp")).toEqual(["v Local", " local:feature/test-paging"]);
});
it("omits empty groups", () => {
expect(summary(buildBranchRows([b("main", { current: true })], [], "", []))).toEqual(["v Local", " local:main ★"]);
});
+2 -2
View File
@@ -1,7 +1,7 @@
/** Branches popup model: grouping, ordering and filtering (pure). */
import type { Branch } from "@/bindings";
import { tx } from "@/platform/i18n";
import { fuzzyMatch } from "@/ui/match";
import { wordMatch } from "@/ui/match";
export interface PopupAction {
id: string;
@@ -213,7 +213,7 @@ export function buildBranchRows(
const q = query.trim();
const match = (text: string): number[] | null | false => {
if (!q) return null;
return fuzzyMatch(text, q) ?? false;
return wordMatch(text, q) ?? false;
};
const open = (key: string, byDefault: boolean) => !!q || (expanded.get(key) ?? byDefault);
const rows: BranchRow[] = actionRows(actions, match, !q);
+4 -3
View File
@@ -1,7 +1,7 @@
/** Branches popup model for a project with several repositories (pure). */
import type { Branch } from "@/bindings";
import { tx } from "@/platform/i18n";
import { fuzzyMatch } from "@/ui/match";
import { fuzzyMatch, wordMatch } from "@/ui/match";
import { rootOf } from "./model";
import {
actionRows,
@@ -173,7 +173,7 @@ export function buildMultiRepoRows({
showTags = true,
}: MultiRepoInput): RepoRow[] {
const q = query.trim();
const match = (text: string): number[] | null | false => (q ? (fuzzyMatch(text, q) ?? false) : null);
const match = (text: string): number[] | null | false => (q ? (wordMatch(text, q) ?? false) : null);
const open = (key: string, byDefault: boolean) => !!q || (expanded.get(key) ?? byDefault);
const blocks: RepoRow[][] = [];
@@ -181,7 +181,8 @@ export function buildMultiRepoRows({
const repoRows: RepoRow[] = [];
repos.forEach((repo, i) => {
const m = match(repo.name);
// Repository names keep the loose match: the list is short and abbreviations like `deps` help.
const m = q ? (fuzzyMatch(repo.name, q) ?? false) : null;
if (m !== false)
repoRows.push({ type: "repo", key: `repo:${repo.root}`, repo, color: repoColorIndex(i), matches: m });
});
+4 -1
View File
@@ -1469,5 +1469,8 @@
"Commands agents run appear in the Run tool window as _agent_ tabs. Beyond this many finished ones, the oldest move to the archive at the end of the tab strip. Running commands are never archived.": "Commands agents run appear in the Run tool window as _agent_ tabs. Beyond this many finished ones, the oldest move to the archive at the end of the tab strip. Running commands are never archived.",
"Archived agent tabs to keep": "Archived agent tabs to keep",
"Beyond this many archived agent tabs, the oldest are closed.": "Beyond this many archived agent tabs, the oldest are closed.",
"Archived ({count})": "Archived ({count})"
"Archived ({count})": "Archived ({count})",
"no usages": "no usages",
"1 usage": "1 usage",
"{n} usages": "{n} usages"
}
+4 -1
View File
@@ -7867,5 +7867,8 @@
"Commands agents run appear in the Run tool window as _agent_ tabs. Beyond this many finished ones, the oldest move to the archive at the end of the tab strip. Running commands are never archived.": "智能体运行的命令以 _agent_ 标签显示在运行工具窗口中。已结束的标签超过这个数量时,最旧的会移入标签栏末尾的归档。正在运行的命令永远不会被归档。",
"Archived agent tabs to keep": "保留的已归档智能体标签数",
"Beyond this many archived agent tabs, the oldest are closed.": "已归档的智能体标签超过这个数量时,最旧的会被关闭。",
"Archived ({count})": "归档({count})"
"Archived ({count})": "归档({count})",
"no usages": "无用法",
"1 usage": "1 个用法",
"{n} usages": "{n} 个用法"
}