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
129 changed files with 1854 additions and 273 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());
+30 -2
View File
@@ -4125,6 +4125,11 @@ impl PluginApi {
if items.len() > 10_000 || file.as_ref().and_then(|f| f.badge.as_ref()).is_some_and(|b| b.chars().count() > 2) {
return Err(HostError::Invalid("decoration limit exceeded".into()));
}
if let Some(note) = file.as_ref().and_then(|f| f.note.as_ref())
&& (note.trim().is_empty() || note.chars().count() > 200 || note.contains(['\n', '\r']))
{
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into()));
@@ -4143,7 +4148,7 @@ impl PluginApi {
set_id: set.into(),
tree,
items,
file: file.filter(|f| decoration_style(&f.style)),
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
})
} else {
None
@@ -4315,10 +4320,33 @@ mod workbench_tests {
api.file_decorations(
Some(&path),
"tree",
Some(ide_plugin::FileDecoration { badge: Some("abc".into()), style: "info".into(), tooltip: None })
Some(ide_plugin::FileDecoration {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None
})
)
.is_err()
);
let note = |text: &str| ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: Some("why".into()),
note: Some(text.into()),
};
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
let notes = |api: &PluginApi| {
api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "notes")
.map(|s| s.file.as_ref().and_then(|f| f.note.clone()))
.collect::<Vec<_>>()
};
assert_eq!(notes(&api), [Some("entry point".to_string())], "kept without a style");
for bad in ["", "two\nlines", &"x".repeat(201)] {
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
}
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
+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")]
+3
View File
@@ -922,6 +922,9 @@ pub struct FileDecoration {
pub badge: Option<String>,
pub style: String,
pub tooltip: Option<String>,
/// Text shown after the name (API 1.7 `set-file-note`).
#[serde(default)]
pub note: Option<String>,
}
/// Commit metadata, date in Unix seconds.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
+12 -1
View File
@@ -2497,7 +2497,7 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
.file_decorations(
Some(&real),
&set_id,
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip }),
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
)
.map_err(to_wit))
}
@@ -2507,6 +2507,17 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
}
Ok(self.host.file_decorations(None, &set_id, None).map_err(to_wit))
}
fn set_file_note(&mut self, path: String, set_id: String, note: String, tooltip: Option<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let real = match self.decoration_path(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
}
pub(crate) mod language_features_bindings {
@@ -1 +1 @@
3c7f35facd15ee8380ccb6475b7c051d500e38346b711e1805077becd0c75f75
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
@@ -1 +1 @@
3879822d975695c6258e95286b0e64c5ee1e3265876501efaf0e577b38f8222b
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
@@ -1 +1 @@
228ae888bf556ce5cedc7843eb20e92f221d9e1f640f3c6c595bed3fd6be6051
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
@@ -1 +1 @@
41de239b6f723020b38eb2b67f642b7c88cc88c79117c3072ba122633afc418c
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
@@ -1 +1 @@
78c5ed3c756498c7619b46c3622ae7a59461c61c747b45caecd79b341ee654de
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
@@ -1 +1 @@
ee848321e53cb181536e6158570dbb4ab02a6fd2a1a064b068803ac7da7f3448
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
@@ -1 +1 @@
b587de945875a98f1d4ca20fab7cf94fb4dc6d848136aa19ab0e0e5319155a80
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
@@ -1 +1 @@
1c8776dcae35d2d49dd6c1e32b0cdd354de1eb0f72a473245a2c47368b89ec30
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
@@ -1 +1 @@
2a517cbffd6d3cdda37117d8ca8815978a09995923fc3d5aa5af7020afdac56a
17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352
@@ -1 +1 @@
dbd912b1f8488ecd446af07b5dcaef55d44bce57c1a55be5c0b0fb33e2697903
2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba
@@ -1 +1 @@
efdc84cd2bedb3989b03d19b569d09958f26e502db65db4dd786eb3438a7c407
54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7
@@ -1 +1 @@
be6bc15ee42a5e21781ba54f548007351a46b669882ee4491da20ff556927a06
4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68
@@ -1 +1 @@
e5c3a3beda750dff27d7b823676d53f15b7c76b8ad539594cd913bd178d750da
2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b
@@ -1 +1 @@
322de3299f0413101c02358836e7df7e06e58cbb22c11897664564abbb8af090
233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646
@@ -1 +1 @@
d768ff6a4a466cc6f1d72499df3ccb4ae76872436b8eddff27f62aa156331a37
bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e
@@ -1 +1 @@
57aea107b2c10e4d30fa3785ac4c6566f1b40908a18a3859aca8be047fac9518
c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7
@@ -1 +1 @@
c98bcc89750e26e738d7115a7c0454fde25367a0a23bdc2ff95fb5a5dacc9f98
7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5
@@ -1 +1 @@
a4c23b93a14eda705cb18c88bbc2373d1ef3f96031ff2df21b8b092b50633996
3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72
@@ -1 +1 @@
aec002b3dd310068e69a6ae67a49d30655bf8baea9153a110a407e9d408e6880
d349977fd3cbfba92d1d953ac09f65dae5b83b7070723b59553d791c638acf81
@@ -1 +1 @@
08ece563c3b55c2b86e68fbb0815ddcbb63beb6f1f6c32b8dbdbee558a4a66c1
e796039ae2f8e534ffc8c2ed2e47e910aaf593c464525287699cf1daf3e195ef
@@ -1 +1 @@
450845edf234a1cc78cd6cfc0b812a89f3a2e41484689cc1e1b05be66873d4eb
5e084fa409bb0641409110d91f203a6f99b451b95cd8067bd1ee68140bf8a892
@@ -1 +1 @@
61b735de5ef61dcd0ea8e640c2ad004e84a63fe4dfd7872ffb5e04d6d81dc9a9
521557c1a062a9b82afa14547bc54b57255cdbed3b2ab4b1b86424dfb8accbdc
@@ -1 +1 @@
36776367782eaa4b8b8121f4a665dd8e68525a4836bff1c92bc16770cce14929
5280990269e944aef81ec91b356fadf4aa2fc7e7bb10e4b6842e1dc1c669704a
@@ -1 +1 @@
f6311d232a99356b02a398b2ce3db0865c425f435a8d201157719178ba2b97c7
ff1391d3c9338b6270fef8af7d7c4a5bc595c391997c220a3a827b4b8a068de8
@@ -1 +1 @@
fe1d26665e3c816ed43fc720acabad03a37b7924bccd4dd8c357fd156947b3c7
b78ac0b6fa14401a6379f3e6d07d1398a63d70e882930b3747ec843c0fdb5de7
@@ -1 +1 @@
76bab8e48235f9f066f5b9bbfdd9398916b6c3ed73c072c543d80595130519f7
55331477c1d5322b0dc35d13fc7f8223bf697f5f21328d340f75519dd1098727
@@ -1 +1 @@
f9ed2eac306229f76b05d0c4a4fb2f0ed8d81a1dfc5eb50d044bbe10c9206a5f
0ae6b7edd0d7cd5025ead4a4afacf0fbe3ebc9548d847f94be0e63585a19747e
@@ -1 +1 @@
f2d83d9f56da072d38a1552195af8a9d4edc11888c645627d5dd1e4a46afc4df
5d909d6ab2e73fabba2d213a944546ef7e425552852cf7f74e00eb94c9d167ac
+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": {
+5 -2
View File
@@ -598,6 +598,7 @@ then run `openidea-plugin check`.
| `TextEditor::set_selections(ranges)`, `reveal(range)` | `editor.text-editor` | API 1.4: selections or scrolling in this window's already open editor; no capability. Commands/events only, UTF-16 coordinates. |
| `host::set_decorations`, `clear_decorations` | `decorations.{set,clear}` | API 1.4: project-scoped editor annotations cleared on plugin stop. Requires `project.read`. |
| `host::set_file_decoration`, `clear_file_decorations` | `decorations.{set-file,clear-file}` | API 1.4: project-tree badges/styles. Requires `project.read`. |
| `host::set_file_note` | `decorations.set-file-note` | API 1.7: a note after the name in the project tree (one line, at most 200 characters). Requires `project.read`. |
| `Project::search_text(query, limit)` | `workspace.project.search-text` | API 1.4: cancellable Find in Files with unsaved documents and project excludes, at most 10,000 matches. Requires `project.read`. |
| `host::problems(path)` | `diagnostics.problems` | API 1.4: project Problems reports grouped by file/source, optional file filter. Requires `project.read` and an invoking window. Empty for remote projects. |
| `Project::vcs_log(path, limit)` | `vcs.log` | API 1.4: newest first, at most 1000 commits, optional file history. Requires `vcs.read` and trust. |
@@ -851,8 +852,10 @@ Decorations are plain text, with fixed styles mapped to theme tokens:
`highlight`, `search`, `added`, `modified`, `removed`, `warning`, `error`, `info`,
`underline`, `strikethrough`, `muted`. Unknown styles are ignored. Editor items
have `{ range, style, hover, after }`; tree items have `{ badge, style, tooltip }`,
where a badge has at most two Unicode characters. Paths must resolve inside an
open project; absolute paths select that project, relative paths use the invoking
where a badge has at most two Unicode characters. A tree item can instead be a note
(`set-file-note`, API 1.7): muted text after the file or folder name, one line of at
most 200 characters, with an optional tooltip; it replaces that set's item for the file.
Paths must resolve inside an open project; absolute paths select that project, relative paths use the invoking
project. Sets are isolated by plugin and project and shared by all windows of
that project. Setting replaces a set's items for one file; clearing removes that
set in all projects. Each plugin has at most 50 editor sets and 50 tree sets,
+1 -1
View File
@@ -1 +1 @@
e8e6dbe6dba09de2fd71a9d84eed8c01b16253f9247bf882361c6f49b8a4d51d
b064bd91f44d088c778a0b7e207f9682a56156724588ff500c0c864d22085205
@@ -1 +1 @@
784a4e01725063fc2949a88bd209e7fcc8ecb0f721acd5f03d92db39ece091a9
4145b677141c592dbd9130000eae707615ec36069b78ea3e25fe598dc642bf14
+1 -1
View File
@@ -1 +1 @@
73670a049d8c108511af35b5030d08f1e04a9198cba0da54268e75a06747d953
52f7ccad8966c991505e7a377c59dbf24d0660df66eca97e7a2a8c5d58e3ce68
+1 -1
View File
@@ -1 +1 @@
a17332b7d5becc4ddccffdba1f9b009e07f75f171bc2519b6c1038fdda2bfd71
a438e04a1e179469eec1ee16522b031fea9bbb669d34c77973ee6fd85885a088
+1 -1
View File
@@ -1 +1 @@
3db5b81c6562e2532c9408d749cf65164247c0ef6d51e4195a44ebdc9a697924
7ff4e304120c9c7bd316b95ce7526fda74adf00867642fcc66311effa1567d76
+1 -1
View File
@@ -1 +1 @@
2c31b614b8c942fdfc075aa58da2000c85598f0c5031d565ffafeb60c75eeac5
63847067fc7b9931a3d697d8e6649cea04a0af7ad02d57e26a38f9acb2e6fcdd
+1 -1
View File
@@ -1 +1 @@
08ab43e65c6904d48a969cb0c4247ece1257b51600962fbc64e67d9a1a78e96a
78b71cba21352bfc91cd631523e4b69a6a2df2d5f6bfbf8164e63698a4625594
+1 -1
View File
@@ -1 +1 @@
c7aa8a41a0b142fac374470597316500e2f1b0dfb2c2e58c8e4bcbe05dd87c1f
6d5ea800a0bcbf26e4770a985b1bc8ef09ca71e7e5c94d59f510dbd108c79ece
+1 -1
View File
@@ -1 +1 @@
e8469311f7ccf2ed4ced5bfea4987cd3ff13aede56ee4b18652d3ab466316392
21061c0875e8213efe809e95b0a4d4b0dd73962398fd793d176a443163e73e0e
+1 -1
View File
@@ -1 +1 @@
79d714b6dcbd34cfe24f9b643c65e91c695d6ed3bd68d752a16c0b253ba27384
6a95d38a5f41dbed72dca23584f715db9a12192d19222460cee8669b4a352770
+1 -1
View File
@@ -1 +1 @@
e13b635a78df5d7de5bcc8bf2a75ab5699c6f06b97bd36cbfcfb110c9b332ff5
bfab2d2fa19bdaac6890a117aa055c4caf1393065f87b6c104ff46114d9189c8
+1 -1
View File
@@ -1 +1 @@
7debd0f77bdcd1eddaf84450f8070142e3622d1b826770826232f3211c008fc1
1e860aa8277c6cc5ffd887e1bc5c3107feb8c7f856d2c4df7294497879308ce7
+1 -1
View File
@@ -1 +1 @@
de2139c370c23f70bf2778b78967eea7617bc6c63f400bf7de5ad26a57f6da66
cbe64480e49895004af403c165735d58cfe657910b45cea2d86d7eadcd93b93b
+1 -1
View File
@@ -1 +1 @@
2d14371febb308fe8d92ab3497dbba8df297912566754fcc1723980824ea3cd7
8186e9cfb58fedf1add5288eb30010b8a376dd3dbd2aee39da5ddf067f5b0aae
+1 -1
View File
@@ -1 +1 @@
7a7cf4af3a6fe381ae4e3046116a0d5a5f3996d4df7f4cab1c35852c52a489e6
75d6e447d1d54ad254845b423c38d3c395f07f2e31ee2dd376edc1ed7079ae06
+8
View File
@@ -416,6 +416,9 @@ mod imp {
pub fn clear_file_decorations(set: &str) -> Result<(), Error> {
wit::decorations::clear_file(set).map_err(error)
}
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
wit::decorations::set_file_note(path, set, note, tooltip).map_err(error)
}
pub fn vcs_log(p: &ProjectRes, path: Option<&str>, limit: u32) -> Result<Vec<CommitInfo>, Error> {
wit::vcs::log(p, path, limit)
.map(|v| {
@@ -1842,6 +1845,11 @@ pub fn set_file_decoration(path: &str, set: &str, item: &FileDecoration) -> Resu
pub fn clear_file_decorations(set: &str) -> Result<(), Error> {
imp::clear_file_decorations(set)
}
/// Set a note shown after a file's name in the project tree, replacing this tree set's
/// annotation for the file (API 1.7). One line of at most 200 characters. Requires project.read.
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
imp::set_file_note(path, set, note, tooltip)
}
/// Starts an owned run configuration in the invoking window. Its id receives run-exited.
pub fn start_run(config: &str, args: &[String], debug: bool) -> Result<String, Error> {
+30 -2
View File
@@ -156,6 +156,7 @@ struct State {
restricted: bool,
decorations: BTreeMap<(String, String), Vec<Decoration>>,
file_decorations: BTreeMap<(String, String), FileDecoration>,
file_notes: BTreeMap<(String, String), (String, Option<String>)>,
revealed_ranges: Vec<Range>,
loopback_queries: VecDeque<Option<String>>,
net: Option<NetHandler>,
@@ -300,6 +301,10 @@ impl MockHost {
pub fn file_decorations(&self) -> BTreeMap<(String, String), FileDecoration> {
self.state.borrow().file_decorations.clone()
}
/// `(set, path)` → `(note, tooltip)` set with `set_file_note`.
pub fn file_notes(&self) -> BTreeMap<(String, String), (String, Option<String>)> {
self.state.borrow().file_notes.clone()
}
/// Ranges requested to scroll into view.
pub fn revealed_ranges(&self) -> Vec<Range> {
self.state.borrow().revealed_ranges.clone()
@@ -1655,9 +1660,9 @@ pub mod current {
let mut next = s.file_decorations.clone();
next.remove(&(set.into(), path.clone()));
if style(&item.style) {
next.insert((set.into(), path), item.clone());
next.insert((set.into(), path.clone()), item.clone());
}
let names: BTreeSet<_> = next.keys().map(|(set, _)| set).collect();
let names: BTreeSet<_> = next.keys().chain(s.file_notes.keys()).map(|(set, _)| set).collect();
if set.is_empty()
|| set.len() > 128
|| names.len() > 50
@@ -1666,6 +1671,7 @@ pub mod current {
return Err(Error::Invalid("file decoration limit".into()));
}
s.file_decorations = next;
s.file_notes.remove(&(set.into(), path));
Ok(())
})
}
@@ -1673,6 +1679,28 @@ pub mod current {
with(|s| {
require(s, "project.read")?;
s.file_decorations.retain(|(id, _), _| id != set);
s.file_notes.retain(|(id, _), _| id != set);
Ok(())
})
}
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
with(|s| {
require(s, "project.read")?;
let path = decoration_path(s, path)?;
let key = (set.to_string(), path);
let names: BTreeSet<_> =
s.file_decorations.keys().chain(s.file_notes.keys()).chain([&key]).map(|(set, _)| set).collect();
if set.is_empty()
|| set.len() > 128
|| names.len() > 50
|| note.trim().is_empty()
|| note.chars().count() > 200
|| note.contains(['\n', '\r'])
{
return Err(Error::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
s.file_decorations.remove(&key);
s.file_notes.insert(key, (note.into(), tooltip.map(Into::into)));
Ok(())
})
}
@@ -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};
@@ -67,6 +67,14 @@ fn decorations_replace_clear_validate_styles_badges_paths_and_limits() {
host::set_file_decoration("a.txt", "test", &file("😀a")).unwrap();
host::clear_file_decorations("test").unwrap();
assert!(mock.file_decorations().is_empty());
host::set_file_decoration("a.txt", "notes", &file("A")).unwrap();
host::set_file_note("a.txt", "notes", "entry point", Some("why")).unwrap();
assert!(mock.file_decorations().is_empty(), "a note replaces the set's annotation");
assert_eq!(mock.file_notes().values().next(), Some(&("entry point".to_string(), Some("why".to_string()))));
assert!(host::set_file_note("a.txt", "notes", "two\nlines", None).is_err());
assert!(host::set_file_note("a.txt", "notes", &"x".repeat(201), None).is_err());
host::clear_file_decorations("notes").unwrap();
assert!(mock.file_notes().is_empty());
for i in 0..50 {
host::set_decorations("a.txt", &format!("set{i}"), &[]).unwrap();
}
@@ -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>;
}
+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",
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more