mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87be8cfcf2 | ||
|
|
771575057e | ||
|
|
563d2a75d4 | ||
|
|
3a64bba254 | ||
|
|
f4b4a0fa72 | ||
|
|
50bd134b65 | ||
|
|
8840a1e58a | ||
|
|
bf36cc0801 | ||
|
|
78c98d289f | ||
|
|
30b0fcb8fc | ||
|
|
586399deda | ||
|
|
e6e89860e6 | ||
|
|
d890c41352 | ||
|
|
c942b87abb | ||
|
|
cdecb5916c | ||
|
|
835c44615b | ||
|
|
df79c80aa8 | ||
|
|
73df48db7c |
No files matched your search
Generated
+44
-44
@@ -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
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -270,6 +270,13 @@ fn run(
|
||||
|
||||
/// The first error lines of a failed build, for the tool window.
|
||||
pub(crate) fn error_summary(text: &str) -> String {
|
||||
// The JVM's "could not find or load main class" (localized) when `gradlew` has no jar.
|
||||
if text.contains("org.gradle.wrapper.GradleWrapperMain") {
|
||||
return "gradle/wrapper/gradle-wrapper.jar is missing (often excluded by a `*.jar` rule in .gitignore). \
|
||||
Restore it, run `gradle wrapper` with an installed Gradle, or choose Distribution: Local installation \
|
||||
in Settings › Build, Execution, Deployment › Gradle."
|
||||
.to_owned();
|
||||
}
|
||||
let lines: Vec<&str> = text
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
@@ -909,6 +916,8 @@ mod tests {
|
||||
let text = "[INFO] x\n[ERROR] Failed to execute goal\n[ERROR] \n[ERROR] Re-run Maven using -X\n";
|
||||
assert_eq!(error_summary(text), "Failed to execute goal");
|
||||
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
|
||||
let missing_jar = "错误: 找不到或无法加载主类 org.gradle.wrapper.GradleWrapperMain\n";
|
||||
assert!(error_summary(missing_jar).starts_with("gradle/wrapper/gradle-wrapper.jar is missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(©, 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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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! {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -323,6 +323,18 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp
|
||||
}
|
||||
let wrapper = build_dir.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
|
||||
if use_wrapper && wrapper.is_file() {
|
||||
// `gradlew` can't start without its jar, which `.gitignore`s with `*.jar` often leave
|
||||
// out: run the distribution the wrapper already unpacked instead.
|
||||
if build_dir.join("gradle/wrapper/gradle-wrapper.jar").is_file() {
|
||||
return wrapper;
|
||||
}
|
||||
let home = setting_path(root, &g.user_home)
|
||||
.or_else(|| std::env::var_os("GRADLE_USER_HOME").map(PathBuf::from))
|
||||
.or_else(|| Some(crate::home_dir()?.join(".gradle")));
|
||||
if let Some(gradle) = home.and_then(|home| unpacked_wrapper_distribution(build_dir, &home)) {
|
||||
return gradle;
|
||||
}
|
||||
// Nothing to run instead: the wrapper's failure explains what is missing.
|
||||
return wrapper;
|
||||
}
|
||||
pinned::pinned_install(build_dir, Tool::Gradle)
|
||||
@@ -330,6 +342,21 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp
|
||||
.unwrap_or_else(|| name.into())
|
||||
}
|
||||
|
||||
/// `bin/gradle` of the distribution `gradle/wrapper/gradle-wrapper.properties` names, once the
|
||||
/// wrapper has unpacked it in `<gradle_user_home>/wrapper/dists` (and marked it complete).
|
||||
fn unpacked_wrapper_distribution(build_dir: &Path, gradle_user_home: &Path) -> Option<PathBuf> {
|
||||
let properties = std::fs::read_to_string(build_dir.join("gradle/wrapper/gradle-wrapper.properties")).ok()?;
|
||||
let url = properties.lines().find_map(|line| line.trim().strip_prefix("distributionUrl="))?;
|
||||
let zip = url.trim().replace("\\:", ":").rsplit('/').next()?.to_owned();
|
||||
let name = zip.strip_suffix(".zip")?;
|
||||
let gradle = if cfg!(windows) { "gradle.bat" } else { "gradle" };
|
||||
// One directory per distribution URL hash, each holding the unpacked `gradle-<version>`.
|
||||
let hashes = std::fs::read_dir(gradle_user_home.join("wrapper/dists").join(name)).ok()?;
|
||||
hashes.flatten().map(|e| e.path()).filter(|dir| dir.join(format!("{zip}.ok")).is_file()).find_map(|dir| {
|
||||
std::fs::read_dir(&dir).ok()?.flatten().map(|e| e.path().join("bin").join(gradle)).find(|p| p.is_file())
|
||||
})
|
||||
}
|
||||
|
||||
fn gradle_run_jdk<'a>(
|
||||
release: Option<u32>,
|
||||
configured: Option<&'a crate::java::Jdk>,
|
||||
|
||||
@@ -1866,6 +1866,37 @@ fn max_java_picks_an_older_jdk() {
|
||||
assert_eq!(free.env["JAVA_HOME"], j25.home.to_string_lossy());
|
||||
}
|
||||
|
||||
/// #3086: `gradle-wrapper.jar` excluded by a `*.jar` rule in `.gitignore`: `gradlew` can't
|
||||
/// start, so the distribution the wrapper already unpacked runs instead.
|
||||
#[test]
|
||||
fn gradle_wrapper_without_its_jar_runs_the_unpacked_distribution() {
|
||||
use crate::apply::gradle_command;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let plain = if cfg!(windows) { "gradle.bat" } else { "gradle" };
|
||||
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
|
||||
std::fs::write(&wrapper, "").unwrap();
|
||||
std::fs::create_dir_all(root.join("gradle/wrapper")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("gradle/wrapper/gradle-wrapper.properties"),
|
||||
"distributionUrl=https\\://services.gradle.org/distributions/gradle-4.10-bin.zip\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.gradle.user_home = "user-home".into();
|
||||
let hash = root.join("user-home/wrapper/dists/gradle-4.10-bin/7ddd1b3f");
|
||||
let gradle = hash.join("gradle-4.10/bin").join(plain);
|
||||
std::fs::create_dir_all(gradle.parent().unwrap()).unwrap();
|
||||
std::fs::write(&gradle, "").unwrap();
|
||||
// Not marked complete: possibly half unpacked.
|
||||
assert_eq!(gradle_command(root, root, &settings), wrapper);
|
||||
std::fs::write(hash.join("gradle-4.10-bin.zip.ok"), "").unwrap();
|
||||
assert_eq!(gradle_command(root, root, &settings), gradle);
|
||||
// With its jar the wrapper runs as usual.
|
||||
std::fs::write(root.join("gradle/wrapper/gradle-wrapper.jar"), "").unwrap();
|
||||
assert_eq!(gradle_command(root, root, &settings), wrapper);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_settings_precedence() {
|
||||
use crate::apply::{gradle_command, gradle_env};
|
||||
|
||||
+69
-15
@@ -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]
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
@@ -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]}`);
|
||||
},
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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") },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 ★"]);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("../CommitChooser", () => ({ chooseLogCommit: (commits: Commit[]) => api
|
||||
import { commitKey } from "../multiRoot";
|
||||
import { useVcs } from "../store";
|
||||
import { DEFAULT_LOG_OPTIONS, useLogOptions } from "../logOptions";
|
||||
import * as logPresentation from "../logPresentation";
|
||||
import {
|
||||
displayedLogGraph,
|
||||
emptyFilters,
|
||||
@@ -70,6 +71,7 @@ beforeEach(() => {
|
||||
commits: [],
|
||||
selection: [],
|
||||
selected: null,
|
||||
seek: null,
|
||||
done: false,
|
||||
fragments: { expanded: new Set(), collapsed: new Set(), merges: new Set() },
|
||||
generation: useGitLog.getState().generation + 1,
|
||||
@@ -77,6 +79,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
api.log.mockResolvedValue(ok([]));
|
||||
await reloadLog();
|
||||
});
|
||||
@@ -295,4 +298,85 @@ describe("commit navigation", () => {
|
||||
expect(api.log).toHaveBeenCalledTimes(1);
|
||||
expect(api.log.mock.calls[0]?.[1]).toMatchObject({ seek: target.id, branches: ["HEAD"] });
|
||||
});
|
||||
|
||||
it.each([10, 300])("does not rebuild the displayed graph for each of %i loaded commits", async (count) => {
|
||||
const commits = Array.from({ length: count }, (_, i) => c(`commit${i}`, i + 1 < count ? [`commit${i + 1}`] : []));
|
||||
useGitLog.setState({ commits, done: true });
|
||||
const present = vi.spyOn(logPresentation, "presentLogGraph");
|
||||
|
||||
await goToParent(commits[0]!);
|
||||
|
||||
expect(useGitLog.getState().selected).toBe("commit1");
|
||||
expect(api.log).not.toHaveBeenCalled();
|
||||
expect(present.mock.calls.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("reveals a commit beyond thirty fallback pages when seek does not find it", async () => {
|
||||
const history = Array.from({ length: PAGE * 32 + 7 }, (_, i) => c(`commit${i}`));
|
||||
const target = history.at(-1)!;
|
||||
api.log.mockImplementation(async (_repo: string, q: LogQuery) => ok(history.slice(q.skip, q.skip + q.limit)));
|
||||
|
||||
await revealCommit(target.id, "/repo");
|
||||
|
||||
expect(api.log.mock.calls[0]?.[1].seek).toBe(target.id);
|
||||
expect(api.log.mock.calls.length).toBeGreaterThan(31);
|
||||
expect(useGitLog.getState().selected).toBe(commitKey({ ...target, repo: "/repo" }));
|
||||
});
|
||||
|
||||
it("finds an old parent beyond thirty pages with one graph snapshot per check", async () => {
|
||||
const tip = { ...c("tip", ["parent"]), repo: "/repo" };
|
||||
const history = [
|
||||
tip,
|
||||
...Array.from({ length: PAGE * 31 }, (_, i) =>
|
||||
c(`commit${i}`, i + 1 < PAGE * 31 ? [`commit${i + 1}`] : ["parent"]),
|
||||
),
|
||||
c("parent"),
|
||||
];
|
||||
useGitLog.setState({ commits: [tip] });
|
||||
api.log.mockImplementation(async (_repo: string, q: LogQuery) => ok(history.slice(q.skip, q.skip + q.limit)));
|
||||
const present = vi.spyOn(logPresentation, "presentLogGraph");
|
||||
|
||||
await goToParent(tip);
|
||||
|
||||
expect(api.log.mock.calls.length).toBeGreaterThan(30);
|
||||
expect(useGitLog.getState().selected).toBe(commitKey({ ...c("parent"), repo: "/repo" }));
|
||||
expect(present.mock.calls.length).toBeLessThanOrEqual(api.log.mock.calls.length + 3);
|
||||
});
|
||||
|
||||
it("recomputes collapsed parent adjacency after loading another page", async () => {
|
||||
const tip = { ...c("tip", ["middle"]), repo: "/repo" };
|
||||
useGitLog.setState({ commits: [tip] });
|
||||
useLogOptions.setState({ collapseLinear: true });
|
||||
api.log.mockResolvedValue(ok([c("middle", ["base"]), c("base")]));
|
||||
|
||||
await goToParent(tip);
|
||||
|
||||
expect(api.log).toHaveBeenCalledTimes(1);
|
||||
expect(useGitLog.getState().selected).toBe(commitKey({ ...c("base"), repo: "/repo" }));
|
||||
});
|
||||
|
||||
it("navigates the displayed parent of a linearized merge", async () => {
|
||||
const commits = [c("merge", ["main", "side"]), c("side", ["base"]), c("main", ["base"]), c("base")];
|
||||
useGitLog.setState({ commits, done: true });
|
||||
useLogOptions.setState({ linearizeMerges: true });
|
||||
|
||||
await goToParent(commits[0]!);
|
||||
|
||||
expect(useGitLog.getState().selected).toBe("side");
|
||||
expect(api.choose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not confuse parent hashes shared by different repositories", async () => {
|
||||
const tip = { ...c("tip", ["parent"]), repo: "/a" };
|
||||
useVcs.setState({ roots: ["/a", "/b"] });
|
||||
useGitLog.setState({
|
||||
commits: [tip, { ...c("parent"), repo: "/b" }, { ...c("parent"), repo: "/a" }],
|
||||
done: true,
|
||||
});
|
||||
|
||||
await goToParent(tip);
|
||||
|
||||
expect(useGitLog.getState().selected).toBe(commitKey({ ...c("parent"), repo: "/a" }));
|
||||
expect(api.choose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -1196,12 +1196,18 @@ export function displayedLogGraph() {
|
||||
|
||||
export async function goToParent(c: Commit) {
|
||||
const generation = useGitLog.getState().generation;
|
||||
const parents = () => displayedLogGraph().graph.find((x) => x.id === commitKey(c))?.parents ?? [];
|
||||
if (!parents().length) return;
|
||||
const find = () => displayedLogGraph().commits.filter((x) => parents().includes(commitKey(x)));
|
||||
await pageUntil(() => find().length === parents().length);
|
||||
const find = () => {
|
||||
const { graph, commits } = displayedLogGraph();
|
||||
const parents = graph.find((x) => x.id === commitKey(c))?.parents ?? [];
|
||||
return { parents, candidates: commits.filter((x) => parents.includes(commitKey(x))) };
|
||||
};
|
||||
if (!find().parents.length) return;
|
||||
await pageUntil(() => {
|
||||
const { parents, candidates } = find();
|
||||
return candidates.length === parents.length;
|
||||
});
|
||||
if (useGitLog.getState().generation !== generation) return;
|
||||
const candidates = find();
|
||||
const { candidates } = find();
|
||||
const chosen = candidates.length > 1 ? await chooseLogCommit(candidates, "Select Parent Commit") : candidates[0];
|
||||
if (chosen && useGitLog.getState().generation === generation) selectCommit(commitKey(chosen), true);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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} 个用法"
|
||||
}
|
||||
Reference in new issue
Block a user