дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-10 19:55:40 +02:00
Порівняти коміти
45
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
d62e9855b1 | ||
|
|
9660a47958 | ||
|
|
2d404a8664 | ||
|
|
83cbc8ff5c | ||
|
|
eab165b4e0 | ||
|
|
5bdf4835b0 | ||
|
|
1acc007e47 | ||
|
|
727dabe579 | ||
|
|
c70203b640 | ||
|
|
c088fa8b26 | ||
|
|
481cf172b5 | ||
|
|
ad7f03090a | ||
|
|
0e3b35e1e5 | ||
|
|
025e3e9255 | ||
|
|
d78a73452b | ||
|
|
eed2de8d66 | ||
|
|
e3808fc7c2 | ||
|
|
33786c14ba | ||
|
|
51ffd093b7 | ||
|
|
379709f13f | ||
|
|
0311bd3bf8 | ||
|
|
df87c3ba1f | ||
|
|
e8ef07f918 | ||
|
|
6a612a832c | ||
|
|
e09cb2353f | ||
|
|
bf54006f05 | ||
|
|
ad39bda16f | ||
|
|
283b3bbeea | ||
|
|
51e4ec0ab9 | ||
|
|
5593260635 | ||
|
|
f4f648567f | ||
|
|
7e365a3e0f | ||
|
|
7594a98e8c | ||
|
|
839123caf2 | ||
|
|
9153c66849 | ||
|
|
27ee6fde61 | ||
|
|
0574691d7e | ||
|
|
5948f8a7a3 | ||
|
|
828db7bd13 | ||
|
|
ef1421012a | ||
|
|
96e12d8d9f | ||
|
|
dc99fc83d2 | ||
|
|
ce7eb155b0 | ||
|
|
6788447d3a | ||
|
|
2ad642cdc9 |
No files matched your search
@@ -3,3 +3,6 @@ contact_links:
|
||||
- name: Download the latest release
|
||||
url: https://github.com/l0ng-ai/openidea/releases/latest
|
||||
about: Many problems are already fixed. Please check the latest version before reporting.
|
||||
- name: Report a security vulnerability
|
||||
url: https://github.com/l0ng-ai/openidea/security/advisories/new
|
||||
about: Please report security problems privately, not in a public issue.
|
||||
Створено
+43
-79
@@ -1522,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1578,16 +1578,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"darling_macro 0.13.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1608,20 +1598,6 @@ dependencies = [
|
||||
"darling_macro 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.10.0",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
@@ -1631,7 +1607,7 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
@@ -1644,21 +1620,10 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
@@ -2685,7 +2650,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4333,7 +4298,7 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
"ide-ssh",
|
||||
"oracle",
|
||||
"oracledb",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
@@ -4370,6 +4335,7 @@ version = "0.1.40"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"dunce",
|
||||
"ed25519-dalek",
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -5751,7 +5717,7 @@ checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -6487,15 +6453,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "odpic-sys"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -6609,29 +6566,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "oracle"
|
||||
version = "0.6.3"
|
||||
name = "oracledb"
|
||||
version = "26.0.0-beta.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0"
|
||||
checksum = "27b98c7346f2bb5169ec4b2d4f066fb616529ac766b395440c5019fe88d1ef45"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"odpic-sys",
|
||||
"once_cell",
|
||||
"oracle_procmacro",
|
||||
"paste",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oracle_procmacro"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27"
|
||||
dependencies = [
|
||||
"darling 0.13.4",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"aes",
|
||||
"base16ct",
|
||||
"base64ct",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"pbkdf2",
|
||||
"pkcs8",
|
||||
"rand 0.10.3",
|
||||
"rustls",
|
||||
"sha2 0.11.1",
|
||||
"uuid",
|
||||
"webpki-roots",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8918,12 +8870,6 @@ dependencies = [
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -8960,7 +8906,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
@@ -9183,7 +9128,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_vcruntime",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"tauri-utils",
|
||||
"tauri-winres",
|
||||
"walkdir",
|
||||
@@ -10723,6 +10668,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.7+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||
dependencies = [
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
@@ -10732,6 +10686,15 @@ dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
|
||||
dependencies = [
|
||||
"wasi 0.14.7+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
@@ -11315,6 +11278,7 @@ dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
"objc2-system-configuration",
|
||||
"wasite",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
|
||||
+2
-1
@@ -106,7 +106,8 @@ rustls-platform-verifier = "0.7"
|
||||
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
|
||||
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
oracle = { version = "0.6", default-features = false }
|
||||
# Pure-Rust thin Oracle driver; pinned to the beta until it stabilizes.
|
||||
oracledb = "=26.0.0-beta.5"
|
||||
bb8 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# OS credential stores
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please do not report security problems in public issues, pull requests or discussions.
|
||||
|
||||
Report them privately through GitHub:
|
||||
[Report a vulnerability](https://github.com/l0ng-ai/openidea/security/advisories/new)
|
||||
(repository **Security** tab → **Report a vulnerability**). Only the maintainers can see
|
||||
the report.
|
||||
|
||||
Include what you can of:
|
||||
|
||||
- the affected version, platform, and whether remote development (`openidea-server`)
|
||||
or plugins are involved;
|
||||
- steps to reproduce, or a proof of concept;
|
||||
- the impact you expect, and any fix you suggest.
|
||||
|
||||
We will acknowledge the report, keep you updated in the advisory, and agree on a
|
||||
disclosure date with you. Fixes are released as a new version, and the advisory is
|
||||
published once users can update.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Security fixes go into the latest release. Please check that the problem still occurs
|
||||
there before reporting.
|
||||
@@ -1,5 +1,12 @@
|
||||
# Third-party licenses
|
||||
|
||||
## Flutter and Vite project icons
|
||||
|
||||
`src/ui/brands/flutter.svg` and `vite.svg` use the unmodified logo paths from
|
||||
[Simple Icons](https://github.com/simple-icons/simple-icons), retrieved through
|
||||
Iconify (`simple-icons:flutter`, `simple-icons:vite`) and filled with the project
|
||||
icon colors. Licensed under [CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md).
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
|
||||
@@ -1722,7 +1722,7 @@ impl Task {
|
||||
format!("The agent process exited:\n{}", tail.join("\n"))
|
||||
}
|
||||
}
|
||||
RpcError::Server { message, .. } => message,
|
||||
RpcError::Server { message, data, .. } => with_details(message, data.as_ref()),
|
||||
e => e.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -2180,6 +2180,24 @@ fn usage_limit(e: &RpcError) -> Option<Option<f64>> {
|
||||
.then_some(None)
|
||||
}
|
||||
|
||||
/// An error's message with the detail ACP agents put in `data` (`{"details": …}` or a string):
|
||||
/// generic codes such as "Internal error" say nothing without it.
|
||||
fn with_details(message: String, data: Option<&Value>) -> String {
|
||||
let details = match data {
|
||||
Some(Value::String(text)) => text.as_str(),
|
||||
Some(data) => data.get("details").and_then(Value::as_str).unwrap_or_default(),
|
||||
None => "",
|
||||
}
|
||||
.trim();
|
||||
if details.is_empty() || message.contains(details) {
|
||||
message
|
||||
} else if message.trim().is_empty() {
|
||||
details.to_owned()
|
||||
} else {
|
||||
format!("{message}: {details}")
|
||||
}
|
||||
}
|
||||
|
||||
fn client_info() -> Value {
|
||||
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
|
||||
}
|
||||
|
||||
@@ -2568,3 +2568,17 @@ fn streamed_text_is_sent_as_appends() {
|
||||
let rewritten = entry_event("t", &mut streamed, None, 5, &agent(&format!("{}x!??", "é".repeat(4999))));
|
||||
assert!(matches!(rewritten, AgentEvent::Entry { .. }), "a rewritten end is sent whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_messages_keep_the_agents_details() {
|
||||
let details = json!({ "details": "spawn claude ENOENT" });
|
||||
assert_eq!(with_details("Internal error".into(), Some(&details)), "Internal error: spawn claude ENOENT");
|
||||
assert_eq!(
|
||||
with_details("Internal error".into(), Some(&json!("model not found"))),
|
||||
"Internal error: model not found"
|
||||
);
|
||||
assert_eq!(with_details(String::new(), Some(&details)), "spawn claude ENOENT");
|
||||
assert_eq!(with_details("spawn claude ENOENT".into(), Some(&details)), "spawn claude ENOENT");
|
||||
assert_eq!(with_details("Internal error".into(), Some(&json!({ "other": 1 }))), "Internal error");
|
||||
assert_eq!(with_details("Internal error".into(), None), "Internal error");
|
||||
}
|
||||
@@ -73,6 +73,8 @@ pub struct EditorAppearanceSettings {
|
||||
pub whitespace_inner: bool,
|
||||
pub whitespace_trailing: bool,
|
||||
pub indent_guides: bool,
|
||||
/// Colour indent guides by depth (`--syn-rainbow-*`), while `indent_guides` is on.
|
||||
pub rainbow_indent_guides: bool,
|
||||
/// Right margin and visual guides from Code Style.
|
||||
pub hard_wrap_guides: bool,
|
||||
pub intention_bulb: bool,
|
||||
@@ -95,6 +97,7 @@ impl Default for EditorAppearanceSettings {
|
||||
whitespace_inner: true,
|
||||
whitespace_trailing: true,
|
||||
indent_guides: true,
|
||||
rainbow_indent_guides: false,
|
||||
hard_wrap_guides: true,
|
||||
intention_bulb: true,
|
||||
distraction_free_width: 80,
|
||||
|
||||
@@ -1273,6 +1273,11 @@ pub struct ProjectStructureSettings {
|
||||
pub unmarked: Vec<String>,
|
||||
/// Content roots besides the project folder (absolute paths), shown and searched with it.
|
||||
pub content_roots: Vec<String>,
|
||||
/// Other projects (absolute paths) this project depends on; indexed and navigable like content roots.
|
||||
pub dependencies: Vec<String>,
|
||||
/// Per-workspace-member dependencies: member root (IPC path) → the roots it depends on.
|
||||
/// A member not listed here (or with an empty list) navigates only into itself.
|
||||
pub member_dependencies: BTreeMap<String, Vec<String>>,
|
||||
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
|
||||
pub exclude_files: String,
|
||||
/// Project compiler output directory (relative to project root or absolute; empty = default).
|
||||
@@ -1446,6 +1451,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_opens_diff_is_unset_by_default_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"nonModal":true}}}"#).unwrap();
|
||||
assert_eq!(old.vcs.commit.double_click_opens_diff, None);
|
||||
// The unset value shows up as `null` in the defaults: `SettingsStore::set` only
|
||||
// accepts a key path it finds there.
|
||||
let json = serde_json::to_value(Settings::default()).unwrap();
|
||||
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], serde_json::Value::Null);
|
||||
for on in [true, false] {
|
||||
let mut settings = Settings::default();
|
||||
settings.vcs.commit.double_click_opens_diff = Some(on);
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], on);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.vcs.commit.double_click_opens_diff, Some(on));
|
||||
}
|
||||
// Explicit `null` reads back as unset.
|
||||
let cleared: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"doubleClickOpensDiff":null}}}"#).unwrap();
|
||||
assert_eq!(cleared.vcs.commit.double_click_opens_diff, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_meters_default_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
|
||||
|
||||
@@ -115,6 +115,13 @@ pub struct CommitSettings {
|
||||
pub wrap_at_margin: bool,
|
||||
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
|
||||
pub disabled_plugin_checks: Vec<String>,
|
||||
/// Double-click / Enter in the changes view: `None` follows the interface (the
|
||||
/// Commit tool window opens the diff, Local Changes the source file).
|
||||
///
|
||||
/// No `skip_serializing_if`: an unset value must still appear in
|
||||
/// `Settings::default()`, or `SettingsStore::set` rejects the key as unknown.
|
||||
#[specta(optional)]
|
||||
pub double_click_opens_diff: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for CommitSettings {
|
||||
@@ -135,6 +142,7 @@ impl Default for CommitSettings {
|
||||
show_right_margin: true,
|
||||
wrap_at_margin: false,
|
||||
disabled_plugin_checks: Vec::new(),
|
||||
double_click_opens_diff: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,13 +246,7 @@ impl Connection {
|
||||
|
||||
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("dap: unparsable message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(msg) = parse_message(body) else { return };
|
||||
match msg.get("type").and_then(Value::as_str) {
|
||||
Some("response") => {
|
||||
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
|
||||
@@ -285,6 +279,25 @@ fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u
|
||||
}
|
||||
}
|
||||
|
||||
/// A message body as JSON. Some adapters pass the debuggee's output through as it was
|
||||
/// written: netcoredbg on Windows embeds console output in the ANSI code page (GBK on a
|
||||
/// Chinese system), which isn't valid UTF-8 and made the whole `output` event unparsable,
|
||||
/// so every line with non-ASCII text went missing. Such a body is decoded like other tool
|
||||
/// output (UTF-8, else the system code page) before parsing.
|
||||
fn parse_message(body: &[u8]) -> Option<Value> {
|
||||
let error = match serde_json::from_slice(body) {
|
||||
Ok(v) => return Some(v),
|
||||
Err(e) => e,
|
||||
};
|
||||
if std::str::from_utf8(body).is_err()
|
||||
&& let Ok(v) = serde_json::from_str(&ide_core::output_encoding::decode_tool_output(body))
|
||||
{
|
||||
return Some(v);
|
||||
}
|
||||
tracing::warn!("dap: unparsable message: {error}");
|
||||
None
|
||||
}
|
||||
|
||||
/// `message`, or the formatted `body.error` (`{format, variables}`) when present.
|
||||
fn error_message(msg: &Value) -> String {
|
||||
if let Some(err) = msg.pointer("/body/error")
|
||||
@@ -306,6 +319,22 @@ fn error_message(msg: &Value) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_output_events_with_non_utf8_text() {
|
||||
// "中文" in GBK inside an output event, as netcoredbg sends it on a Chinese Windows.
|
||||
let mut body = br#"{"seq":5,"type":"event","event":"output","body":{"category":"stdout","output":""#.to_vec();
|
||||
body.extend_from_slice(&[0xd6, 0xd0, 0xce, 0xc4]);
|
||||
body.extend_from_slice(br#"\n"}}"#);
|
||||
let msg = parse_message(&body).expect("parsed");
|
||||
assert_eq!(msg["event"], "output");
|
||||
let output = msg["body"]["output"].as_str().unwrap();
|
||||
assert!(output.ends_with('\n'));
|
||||
// Off Windows (UTF-8 code page) the bytes decode lossily; the line still arrives.
|
||||
#[cfg(not(windows))]
|
||||
assert!(output.starts_with(char::REPLACEMENT_CHARACTER));
|
||||
assert!(parse_message(b"{not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_structured_errors() {
|
||||
let msg =
|
||||
|
||||
@@ -20,7 +20,7 @@ parking_lot.workspace = true
|
||||
futures.workspace = true
|
||||
sqlx.workspace = true
|
||||
tiberius.workspace = true
|
||||
oracle.workspace = true
|
||||
oracledb.workspace = true
|
||||
bb8.workspace = true
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
dirs.workspace = true
|
||||
|
||||
@@ -417,7 +417,7 @@ fn binary_type(name: &str, text: bool) -> bool {
|
||||
}
|
||||
|
||||
/// Bytes as text: UTF-8, else hex (grids) or with invalid sequences replaced (catalog names).
|
||||
fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
|
||||
pub(crate) fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
|
||||
match (String::from_utf8(bytes), mode) {
|
||||
(Ok(s), _) => s,
|
||||
(Err(e), Cells::Display) => hex(e.as_bytes()),
|
||||
@@ -1042,7 +1042,7 @@ pub enum Held {
|
||||
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(crate::oracle::OracleConnection),
|
||||
Oracle(Box<crate::oracle::OracleConnection>),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -1058,7 +1058,7 @@ impl Held {
|
||||
}
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
Pool::Oracle(p) => Held::Oracle(p.get().await?),
|
||||
Pool::Oracle(p) => Held::Oracle(Box::new(p.get().await?)),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ impl DbTableKind {
|
||||
pub enum DbObjectKind {
|
||||
Function,
|
||||
Procedure,
|
||||
Package,
|
||||
Trigger,
|
||||
Sequence,
|
||||
}
|
||||
@@ -92,12 +93,21 @@ pub struct SequenceInfo {
|
||||
pub data_type: String,
|
||||
}
|
||||
|
||||
/// A schema's routines (procedures and functions) and sequences.
|
||||
/// An Oracle package (its procedures and functions are not listed separately).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbPackageInfo")]
|
||||
pub struct PackageInfo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A schema's routines (procedures and functions), packages and sequences.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSchemaObjects")]
|
||||
pub struct SchemaObjects {
|
||||
pub routines: Vec<RoutineInfo>,
|
||||
pub packages: Vec<PackageInfo>,
|
||||
pub sequences: Vec<SequenceInfo>,
|
||||
}
|
||||
|
||||
@@ -296,7 +306,7 @@ fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeErr
|
||||
|
||||
/// Options › Run keep-alive query: pings the pool until it is closed or dropped
|
||||
/// (a dropped SSH tunnel or an idle-killing proxy stays alive meanwhile).
|
||||
fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
pub(crate) fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
let pool = Arc::downgrade(pool);
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(every);
|
||||
@@ -853,6 +863,14 @@ impl DbManager {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if let Some(q) = sql::packages(driver, schema) {
|
||||
out.packages = pool
|
||||
.rows(&q, MAX_META_ROWS)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|r| Some(PackageInfo { name: r.into_iter().next().flatten()? }))
|
||||
.collect();
|
||||
}
|
||||
if let Some(q) = sql::sequences(driver, schema) {
|
||||
out.sequences = pool
|
||||
.rows(&q, MAX_META_ROWS)
|
||||
@@ -870,9 +888,29 @@ impl DbManager {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The definition of a routine (every overload), trigger or sequence.
|
||||
/// The definition of a routine (every overload), package (spec and body), trigger or sequence.
|
||||
pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult<String> {
|
||||
let pool = self.pool(id).await?;
|
||||
if kind == DbObjectKind::Package {
|
||||
let mut defs: Vec<String> = Vec::new();
|
||||
for body in [false, true] {
|
||||
let Some(q) = sql::package_ddl(pool.driver(), schema, name, body) else { continue };
|
||||
match pool.rows(&q, MAX_META_ROWS).await {
|
||||
Ok(rows) => defs.extend(rows.into_iter().filter_map(|r| r.into_iter().next().flatten())),
|
||||
// A spec-only package has no body: the spec alone still opens.
|
||||
Err(e) if body => tracing::warn!("package body {schema}.{name}: {}", e.message),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
if defs.is_empty() {
|
||||
return Err(IdeError::not_found(format!("{schema}.{name}")));
|
||||
}
|
||||
return Ok(defs
|
||||
.iter()
|
||||
.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';')))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"));
|
||||
}
|
||||
let (q, column) = sql::object_ddl(pool.driver(), schema, kind, name)
|
||||
.ok_or_else(|| IdeError::invalid(format!("no {kind:?} objects in this database")))?;
|
||||
let defs: Vec<String> = pool
|
||||
|
||||
+605
-243
File diff suppressed because it is too large.
Load diff
@@ -20,7 +20,7 @@ pub enum DbDriver {
|
||||
/// Microsoft SQL Server.
|
||||
SqlServer,
|
||||
Clickhouse,
|
||||
/// Oracle Database (via Oracle Instant Client).
|
||||
/// Oracle Database (pure-Rust thin driver, no client library).
|
||||
Oracle,
|
||||
}
|
||||
|
||||
@@ -88,8 +88,6 @@ pub struct DbOptions {
|
||||
pub auto_disconnect_secs: Option<u32>,
|
||||
/// PostgreSQL: show only the configured database, its schemas right under the data source.
|
||||
pub single_database: bool,
|
||||
/// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path).
|
||||
pub instant_client: String,
|
||||
}
|
||||
|
||||
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
|
||||
|
||||
@@ -664,6 +664,32 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name` of a schema's packages (Oracle only; their procedures and functions are not
|
||||
/// listed separately, as in IDEA).
|
||||
pub fn packages(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
match driver {
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT object_name FROM all_objects WHERE owner = {} AND object_type = 'PACKAGE' \
|
||||
AND object_name NOT LIKE 'BIN$%' ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The statement reading a package's specification (`body` false) or body as DDL.
|
||||
pub fn package_ddl(driver: DbDriver, schema: &str, name: &str, body: bool) -> Option<String> {
|
||||
match driver {
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT DBMS_METADATA.GET_DDL('{}', {}, {}) FROM DUAL",
|
||||
if body { "PACKAGE_BODY" } else { "PACKAGE" },
|
||||
quote_literal(driver, name),
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle).
|
||||
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
match driver {
|
||||
@@ -963,6 +989,7 @@ mod tests {
|
||||
assert!(completion_columns(DbDriver::Oracle).contains("all_tab_columns"));
|
||||
assert!(triggers(DbDriver::Oracle, "hr", "t").contains("all_triggers"));
|
||||
assert!(routines(DbDriver::Oracle, "hr").unwrap().contains("all_objects"));
|
||||
assert!(packages(DbDriver::Oracle, "hr").unwrap().contains("object_type = 'PACKAGE'"));
|
||||
assert!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences"));
|
||||
}
|
||||
|
||||
@@ -984,6 +1011,17 @@ mod tests {
|
||||
let (q, col) = object_ddl(DbDriver::Oracle, "hr", K::Procedure, "p").unwrap();
|
||||
assert!(q.contains("DBMS_METADATA.GET_DDL('PROCEDURE'") && col == 0);
|
||||
assert!(object_ddl(DbDriver::Oracle, "hr", K::Sequence, "q").unwrap().0.contains("all_sequences"));
|
||||
assert!(
|
||||
package_ddl(DbDriver::Oracle, "hr", "pkg", false)
|
||||
.unwrap()
|
||||
.contains("DBMS_METADATA.GET_DDL('PACKAGE', 'pkg', 'hr')")
|
||||
);
|
||||
assert!(
|
||||
package_ddl(DbDriver::Oracle, "hr", "pkg", true)
|
||||
.unwrap()
|
||||
.contains("DBMS_METADATA.GET_DDL('PACKAGE_BODY', 'pkg', 'hr')")
|
||||
);
|
||||
assert!(package_ddl(DbDriver::Postgres, "public", "pkg", false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1001,6 +1039,7 @@ mod tests {
|
||||
fn schema_objects_per_dialect() {
|
||||
use crate::DbObjectKind as K;
|
||||
assert!(routines(DbDriver::Sqlite, "main").is_none());
|
||||
assert!(packages(DbDriver::Sqlite, "main").is_none());
|
||||
assert!(sequences(DbDriver::Mysql, "db").is_none());
|
||||
assert!(routines(DbDriver::Postgres, "public").unwrap().contains("n.nspname = 'public'"));
|
||||
assert_eq!(
|
||||
|
||||
@@ -18,6 +18,7 @@ semver = "1"
|
||||
blake3.workspace = true
|
||||
zip.workspace = true
|
||||
base64.workspace = true
|
||||
dunce.workspace = true
|
||||
ed25519-dalek = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -441,11 +441,13 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
|
||||
/// `p` with its parent canonicalized but the file name as found: PATH
|
||||
/// entries like `/var` → `/private/var` (macOS) or `~` links collapse to
|
||||
/// their real directory, while a linked program (`cargo` → rustup) keeps
|
||||
/// its link name so the shim still dispatches on argv[0].
|
||||
/// its link name so the shim still dispatches on argv[0]. `dunce` keeps the
|
||||
/// path in its usual form on Windows: a `\\?\`-prefixed pin would never
|
||||
/// match the path the program was actually started with.
|
||||
fn canonical_dir(p: &Path) -> PathBuf {
|
||||
let Some(dir) = p.parent() else { return p.to_path_buf() };
|
||||
let Some(name) = p.file_name() else { return p.to_path_buf() };
|
||||
std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
|
||||
dunce::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
|
||||
}
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
@@ -498,6 +500,20 @@ mod tests {
|
||||
list.iter().map(|s| s.parse().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn pinned_program_path_has_no_verbatim_prefix() {
|
||||
// std::fs::canonicalize returns \\?\-prefixed paths on Windows; a pin
|
||||
// stored in that form would never match the plain path the program is
|
||||
// started with. dunce keeps the usual form.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("cargo.exe"), b"").unwrap();
|
||||
let pinned = resolve_program("cargo.exe", Some(dir.path().as_os_str())).unwrap();
|
||||
let shown = pinned.to_string_lossy();
|
||||
assert!(!shown.starts_with(r"\\?\"), "verbatim prefix leaked into the pin: {shown}");
|
||||
assert!(pinned.is_absolute());
|
||||
}
|
||||
|
||||
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
|
||||
Request {
|
||||
subject: Subject::Installed("p".into()),
|
||||
|
||||
@@ -8,7 +8,7 @@ use ide_language::{FileReference, WebReference};
|
||||
use ide_workspace::file_refs::{self, PathCompletion};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A web reference no language server resolves and where it points: `class="card"` →
|
||||
/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
|
||||
@@ -112,6 +112,68 @@ pub async fn web_goto(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeRes
|
||||
.await
|
||||
}
|
||||
|
||||
/// The file a Python import path names: `a.b.c` is `a/b/c.py(i)` or `a/b/c/__init__.py(i)`
|
||||
/// in the project or one of its content roots (an attached dependency project included);
|
||||
/// `.x` names it relative to `from`'s directory. Neither the language server (it analyses a
|
||||
/// single root) nor the definition index resolves a package segment, so ⌘B on the `a.b` of
|
||||
/// `from a.b import x` needs this.
|
||||
pub async fn nav_resolve_module(host: &Host, window: &str, name: String, from: IpcPath) -> IdeResult<Option<IpcPath>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let dots = name.len() - name.trim_start_matches('.').len();
|
||||
let parts: Vec<&str> = name.split('.').filter(|s| !s.is_empty()).collect();
|
||||
if parts.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let rel = parts.iter().collect::<PathBuf>();
|
||||
let bases: Vec<PathBuf> = if dots > 0 {
|
||||
let dir = PathBuf::from(from);
|
||||
let dir = dir.parent().map(Path::to_path_buf).unwrap_or(dir);
|
||||
let base = (0..dots.saturating_sub(1)).fold(dir, |p, _| p.parent().map(Path::to_path_buf).unwrap_or(p));
|
||||
vec![base]
|
||||
} else {
|
||||
crate::project::navigation_roots(&project, &from)
|
||||
};
|
||||
for base in bases {
|
||||
let dir = base.join(&rel);
|
||||
for candidate in
|
||||
[dir.with_extension("py"), dir.with_extension("pyi"), dir.join("__init__.py"), dir.join("__init__.pyi")]
|
||||
{
|
||||
if candidate.is_file() {
|
||||
return Ok(Some(IpcPath::from(candidate)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Index definitions named `name` in the other roots `from` may navigate into (its configured
|
||||
/// dependencies and extra content roots — see `navigation_roots`): the cross-root answer when
|
||||
/// the language server cannot resolve an import into an attached dependency project. `from`'s
|
||||
/// own root is left out: the server sees it, so a name it could not resolve there is not
|
||||
/// answered by every declaration that happens to share it (any `save` for `repo.save()`).
|
||||
pub async fn nav_index_definitions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
name: String,
|
||||
from: IpcPath,
|
||||
) -> IdeResult<Vec<SymbolMatch>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let roots = crate::project::navigation_roots(&project, &from);
|
||||
let Some((_own, roots)) = roots.split_first() else { return Ok(Vec::new()) };
|
||||
if roots.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Filter inside the index: a cap applied before the root filter can fill with
|
||||
// own-root matches and evict the dependency roots entirely.
|
||||
Ok(project.index.symbols_named_within(&name, None, roots, 500))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
|
||||
pub async fn fs_resolve_reference(
|
||||
host: &Host,
|
||||
@@ -181,6 +243,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("remove_recent", |c| async move { remove_recent(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("recent_files", |c| async move { recent_files(&c.host, &c.window) });
|
||||
d.add("web_goto", |c| async move { web_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await });
|
||||
d.add("nav_resolve_module", |c| async move {
|
||||
nav_resolve_module(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
|
||||
});
|
||||
d.add("nav_index_definitions", |c| async move {
|
||||
nav_index_definitions(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
|
||||
});
|
||||
d.add("fs_resolve_reference", |c| async move {
|
||||
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
|
||||
});
|
||||
|
||||
@@ -221,7 +221,10 @@ pub fn set_settings(
|
||||
|| key == "editor.codeStyle"
|
||||
|| key.starts_with("editor.codeStyle.")
|
||||
|| key == "inspections"
|
||||
|| key.starts_with("inspections.");
|
||||
|| key.starts_with("inspections.")
|
||||
|| key == "files"
|
||||
|| key == "files.composeFileOverrides"
|
||||
|| key.starts_with("files.composeFileOverrides.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
|
||||
@@ -13,9 +13,11 @@ use ide_vfs::Watcher;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Everything bound to one open project folder.
|
||||
pub struct Project {
|
||||
@@ -1197,9 +1199,81 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
|
||||
extra_content_roots(settings, root, build_cache)
|
||||
}
|
||||
|
||||
/// The roots a file may navigate into: its own root (the workspace member containing it, or
|
||||
/// the project root) plus that root's configured dependencies. A workspace member depends
|
||||
/// only on the roots picked per member in Settings › Project Structure › Dependencies —
|
||||
/// being attached to the workspace never makes its code navigable by itself.
|
||||
/// Cached navigation roots per (project, file), with the instant they were computed.
|
||||
type NavigationRootsCache = HashMap<(PathBuf, PathBuf), (Instant, Vec<PathBuf>)>;
|
||||
|
||||
pub(crate) fn navigation_roots(project: &Project, from: &Path) -> Vec<PathBuf> {
|
||||
// Hover probes and import-segment clicks repeat these lookups for the same file,
|
||||
// and each uncached call re-reads the workspace file: memoize briefly. The short
|
||||
// TTL keeps settings and workspace edits near-immediate.
|
||||
static CACHE: Mutex<Option<NavigationRootsCache>> = Mutex::new(None);
|
||||
const TTL: Duration = Duration::from_secs(1);
|
||||
const CAP: usize = 256;
|
||||
let key = (project.root.clone(), from.to_path_buf());
|
||||
if let Some((at, roots)) = CACHE.lock().as_ref().and_then(|m| m.get(&key))
|
||||
&& at.elapsed() < TTL
|
||||
{
|
||||
return roots.clone();
|
||||
}
|
||||
let roots = navigation_roots_uncached(project, from);
|
||||
if let Some(map) = CACHE.lock().as_mut() {
|
||||
if map.len() >= CAP {
|
||||
map.retain(|_, (at, _)| at.elapsed() < TTL);
|
||||
}
|
||||
map.insert(key, (Instant::now(), roots.clone()));
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn navigation_roots_uncached(project: &Project, from: &Path) -> Vec<PathBuf> {
|
||||
let settings = project.settings.effective();
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&project.root).ok().flatten();
|
||||
let own = workspace
|
||||
.as_ref()
|
||||
.and_then(|w| w.projects.iter().filter(|p| from.starts_with(p)).max_by_key(|p| p.components().count()).cloned())
|
||||
.unwrap_or_else(|| project.root.clone());
|
||||
let mut roots = vec![own.clone()];
|
||||
let own_key = ide_core::ipc_path::to_ipc(&own);
|
||||
if let Some(deps) = member_dependencies_for(&settings.project_structure.member_dependencies, &own_key) {
|
||||
for dep in deps {
|
||||
let dep = PathBuf::from(dep.trim());
|
||||
if dep.is_absolute() && dep.is_dir() && !roots.contains(&dep) {
|
||||
roots.push(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
if workspace.is_none() {
|
||||
for root in content_roots(&settings, &project.root, &project.build_cache_dir()) {
|
||||
if !roots.contains(&root) {
|
||||
roots.push(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// The dependency roots configured for `own`, keyed by IPC path. The settings UI writes
|
||||
/// keys as it received the member paths (IPC form); a hand-edited settings.toml may not,
|
||||
/// so compare normalized rather than only by exact match.
|
||||
fn member_dependencies_for<'a>(map: &'a BTreeMap<String, Vec<String>>, own_key: &str) -> Option<&'a Vec<String>> {
|
||||
if let Some(deps) = map.get(own_key) {
|
||||
return Some(deps);
|
||||
}
|
||||
map.iter().find(|(key, _)| ide_core::ipc_path::to_ipc(&PathBuf::from(key)) == own_key).map(|(_, deps)| deps)
|
||||
}
|
||||
|
||||
fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim()));
|
||||
let user = settings
|
||||
.project_structure
|
||||
.content_roots
|
||||
.iter()
|
||||
.chain(settings.project_structure.dependencies.iter())
|
||||
.map(|r| PathBuf::from(r.trim()));
|
||||
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
|
||||
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
|
||||
out.push(p);
|
||||
@@ -1811,3 +1885,34 @@ mod composer_tests {
|
||||
assert!(!changed(&[]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod content_roots_tests {
|
||||
use super::content_roots;
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn member_dependencies_match_hand_written_keys() {
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
map.insert("B:\\proj\\member".to_string(), vec!["B:/dep".to_string()]);
|
||||
let deps = super::member_dependencies_for(&map, "B:/proj/member");
|
||||
assert_eq!(deps.map(Vec::as_slice), Some(["B:/dep".to_string()].as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_projects_join_the_content_roots() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("app");
|
||||
let shared = tmp.path().join("shared");
|
||||
let dep = tmp.path().join("lib");
|
||||
for dir in [&root, &shared, &dep] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let mut settings = ide_core::settings::Settings::default();
|
||||
settings.project_structure.content_roots = vec![shared.to_string_lossy().into_owned()];
|
||||
settings.project_structure.dependencies =
|
||||
vec![dep.to_string_lossy().into_owned(), root.to_string_lossy().into_owned()];
|
||||
let roots = content_roots(&settings, &root, &tmp.path().join("build"));
|
||||
assert_eq!(roots, vec![shared, dep]);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,44 @@ impl PluginWorkspace for TestWorkspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
|
||||
}
|
||||
/// Find in Files over the fixture's files on disk.
|
||||
fn search_text(
|
||||
&self,
|
||||
_client: Option<&str>,
|
||||
root: &Path,
|
||||
query: ide_plugin::TextQuery,
|
||||
limit: u32,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> Result<ide_plugin::TextSearch, HostError> {
|
||||
let query = ide_search::SearchQuery {
|
||||
pattern: query.pattern,
|
||||
is_regex: query.regex,
|
||||
case_sensitive: query.case_sensitive,
|
||||
whole_word: query.whole_word,
|
||||
include: query.include,
|
||||
exclude: query.exclude,
|
||||
scope: ide_search::SearchScope::Project,
|
||||
max_matches: limit,
|
||||
context: Default::default(),
|
||||
};
|
||||
let files = Mutex::new(Vec::new());
|
||||
let summary = ide_search::search(root, &query, &|_| None, cancel, &|file| files.lock().push(file))
|
||||
.map_err(|e| HostError::Failed(e.to_string()))?;
|
||||
let matches = files
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.flat_map(|file| {
|
||||
file.matches.into_iter().map(move |m| ide_plugin::TextMatch {
|
||||
location: ide_plugin::Location {
|
||||
path: PathBuf::from(&file.path.0),
|
||||
range: ide_core::Range::new(ide_core::Pos::new(m.line, 0), ide_core::Pos::new(m.line, 0)),
|
||||
},
|
||||
line: m.text,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(ide_plugin::TextSearch { matches, limit_hit: summary.limit_hit })
|
||||
}
|
||||
fn buffer_text(&self, path: &Path) -> Option<String> {
|
||||
if path.ends_with("event-barrier.txt") {
|
||||
let gate = self.event_read_gate.lock().take();
|
||||
@@ -451,6 +489,29 @@ fn read_file_is_confined_to_the_project() {
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
/// `project.read-bytes` returns the exact bytes on disk, where `read-file`
|
||||
/// wants UTF-8, and follows `read-file`'s confinement rules.
|
||||
#[test]
|
||||
fn read_bytes_reads_the_project_file_exactly_and_stays_inside() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&misbehave_pkg());
|
||||
let binary = [0x25u8, 0x50, 0x44, 0x46, 0x00, 0xff, 0x1b, 0x7f];
|
||||
std::fs::write(f.project.join("logo.bin"), binary).unwrap();
|
||||
let hex = binary.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ");
|
||||
assert_eq!(f.run("misbehave", "read-bytes", Some("logo.bin")).unwrap().unwrap(), format!("8 {hex}"));
|
||||
let abs = f.project.join("logo.bin");
|
||||
assert_eq!(f.run("misbehave", "read-bytes", Some(abs.to_str().unwrap())).unwrap().unwrap(), format!("8 {hex}"));
|
||||
// The same refusal and not-found behaviour as `read-file`; like every
|
||||
// refusal, it is the plugin's to handle: it keeps running.
|
||||
let err = f.run("misbehave", "read-bytes", Some("../secret.txt")).unwrap_err();
|
||||
assert!(err.contains("outside the project root"), "{err}");
|
||||
let err = f.run("misbehave", "read-bytes", Some("src/missing.bin")).unwrap_err();
|
||||
assert!(err.contains("not found"), "{err}");
|
||||
let err = f.run("misbehave", "read-bytes", Some("src")).unwrap_err();
|
||||
assert!(err.contains("not a file"), "{err}");
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
/// `fs.read` reaches the granted paths and nothing else, through the real
|
||||
/// component: the same checks the runtime makes, against a real file system.
|
||||
#[test]
|
||||
@@ -2740,9 +2801,12 @@ fn official_mybatis_navigates_between_interface_and_xml() {
|
||||
let xml = "<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\">select 1</select>\n</mapper>\n";
|
||||
std::fs::write(&java_path, java).unwrap();
|
||||
std::fs::write(&xml_path, xml).unwrap();
|
||||
// Noise the index has to skip.
|
||||
for i in 0..200 {
|
||||
std::fs::write(f.project.join(format!("src/f{i}.xml")), "<beans/>").unwrap();
|
||||
// Noise: other mappers, some large, that the lookup must not read one by one.
|
||||
let filler = " <select id=\"q\">select 1</select>\n".repeat(3000);
|
||||
for i in 0..600 {
|
||||
let body = if i % 50 == 0 { filler.as_str() } else { "" };
|
||||
let other = format!("<mapper namespace=\"com.acme.Other{i}\">\n{body}</mapper>\n");
|
||||
std::fs::write(f.project.join(format!("src/f{i}.xml")), other).unwrap();
|
||||
}
|
||||
let doc = |path: &Path, language: &str, text: &str| ide_plugin::ProviderDocument {
|
||||
path: path.to_path_buf(),
|
||||
@@ -2751,9 +2815,9 @@ fn official_mybatis_navigates_between_interface_and_xml() {
|
||||
text: text.into(),
|
||||
};
|
||||
let jdoc = doc(&java_path, "java", java);
|
||||
// The first call indexes the XML files; on a slow machine that runs past the
|
||||
// edit-path budget, so the answer is dropped (the index is kept) and calls
|
||||
// queued behind it are skipped. The editor asks again; so does this.
|
||||
// The first call searches the XML files; on a slow machine that can run past the
|
||||
// edit-path budget, so the answer is dropped and calls queued behind it are
|
||||
// skipped. The editor asks again; so does this.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let markers = loop {
|
||||
let markers = f.host.provide_line_markers(&jdoc);
|
||||
@@ -2782,7 +2846,7 @@ fn official_mybatis_navigates_between_interface_and_xml() {
|
||||
"not its language"
|
||||
);
|
||||
|
||||
// Warm (index cached): well inside the edit-path budget.
|
||||
// Warm (lookup cached): well inside the edit-path budget.
|
||||
let slow = f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls;
|
||||
let mut times = Vec::new();
|
||||
for _ in 0..20 {
|
||||
@@ -5723,6 +5787,7 @@ fn api14_denies_new_privileges_and_requires_an_invoking_window() {
|
||||
("open-editors", "", "editor.read"),
|
||||
("open-editor", "a.txt", "project.read"),
|
||||
("search-text", "needle", "project.read"),
|
||||
("read-bytes", "a.txt", "project.read"),
|
||||
("problems", "", "project.read"),
|
||||
("decorations", "a.txt", "project.read"),
|
||||
("clear-decorations", "", "project.read"),
|
||||
|
||||
@@ -52,6 +52,8 @@ fn search_dirs(file: Option<&Path>, root: Option<&Path>) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn parse_env(text: &str) -> IdeResult<EnvMap> {
|
||||
// Editors on Windows (Notepad) save UTF-8 with a byte order mark, which serde_json rejects.
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("invalid environment file: {e}")))?;
|
||||
let mut out = EnvMap::new();
|
||||
@@ -262,4 +264,12 @@ mod tests {
|
||||
let envs = Environments::load(Some(&other.path().join("s.http")), Some(root)).unwrap();
|
||||
assert_eq!(envs.names(), vec!["dev"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_env_files_saved_with_a_byte_order_mark() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join(PUBLIC_FILE), "\u{feff}{\"dev\":{\"host\":\"h\"}}").unwrap();
|
||||
let envs = Environments::load(Some(&tmp.path().join("a.http")), Some(tmp.path())).unwrap();
|
||||
assert_eq!(envs.names(), vec!["dev"]);
|
||||
}
|
||||
}
|
||||
@@ -319,6 +319,19 @@ impl ProjectIndex {
|
||||
matching::symbols_named(&self.shared.data.read(), name, kind, limit)
|
||||
}
|
||||
|
||||
/// Symbols named exactly `name` (of `kind`, if given) whose file is inside one of `roots`
|
||||
/// (the project root plus its configured dependency roots): answers bounded by the cap
|
||||
/// never evict matches from a root because another root filled it first.
|
||||
pub fn symbols_named_within(
|
||||
&self,
|
||||
name: &str,
|
||||
kind: Option<SymbolKind>,
|
||||
roots: &[PathBuf],
|
||||
limit: usize,
|
||||
) -> Vec<SymbolMatch> {
|
||||
matching::symbols_named_within(&self.shared.data.read(), name, kind, roots, limit)
|
||||
}
|
||||
|
||||
/// Incrementally re-indexes changed paths (called from the vfs watcher).
|
||||
/// Cheap: the work is queued for the indexing thread.
|
||||
pub fn update(&self, changed: &[PathBuf], removed: &[PathBuf]) {
|
||||
|
||||
@@ -526,11 +526,26 @@ pub(crate) fn find_symbols_until(
|
||||
|
||||
/// Symbols named exactly `name` (and of `kind`, when given), by path then position.
|
||||
pub(crate) fn symbols_named(data: &IndexData, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
|
||||
symbols_named_within(data, name, kind, &[], limit)
|
||||
}
|
||||
|
||||
/// Symbols named exactly `name` (and of `kind`, when given) whose file is inside one of
|
||||
/// `roots` (empty = every indexed file), by path then position. Filtering while walking
|
||||
/// entries keeps symbols outside the roots from materialising or filling the cap.
|
||||
pub(crate) fn symbols_named_within(
|
||||
data: &IndexData,
|
||||
name: &str,
|
||||
kind: Option<SymbolKind>,
|
||||
roots: &[PathBuf],
|
||||
limit: usize,
|
||||
) -> Vec<SymbolMatch> {
|
||||
let mask = char_mask(name);
|
||||
let within = |e: &FileEntry| roots.is_empty() || roots.iter().any(|root| e.path.starts_with(root));
|
||||
let mut out: Vec<SymbolMatch> = data
|
||||
.files
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|e| within(e))
|
||||
.flat_map(|e| {
|
||||
e.symbols.iter().filter(|s| s.mask == mask && &*s.name == name && kind.is_none_or(|k| s.kind == k)).map(
|
||||
move |s| SymbolMatch {
|
||||
|
||||
@@ -528,6 +528,27 @@ fn css_selectors_are_found_by_exact_name() {
|
||||
assert!(idx.symbols_named(".card", Some(SymbolKind::Field), 10).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_named_within_keeps_out_of_root_matches_from_evicting_a_root() {
|
||||
let f = Fixture::new(&[
|
||||
("a/w.rs", "struct Decoder;\nstruct Decoder;\n"),
|
||||
("b/x.rs", "struct Decoder;\nstruct Decoder;\nstruct Decoder;\n"),
|
||||
("dep/b.rs", "struct Decoder;\n"),
|
||||
]);
|
||||
let idx = f.open();
|
||||
let own = f.root.join("a");
|
||||
let dep = f.root.join("dep");
|
||||
// Unfiltered, the cap fills in rel-path order — own root, then the out-of-root
|
||||
// content root b/ — so dep/ is evicted before the caller's root filter runs.
|
||||
let unfiltered = idx.symbols_named("Decoder", None, 3);
|
||||
let unfiltered: Vec<&str> = unfiltered.iter().map(|m| m.rel_path.as_ref()).collect();
|
||||
assert_eq!(unfiltered, vec!["a/w.rs", "a/w.rs", "b/x.rs"]);
|
||||
let found: Vec<String> =
|
||||
idx.symbols_named_within("Decoder", None, &[own, dep], 3).into_iter().map(|m| m.rel_path).collect();
|
||||
assert_eq!(found, vec!["a/w.rs".to_string(), "a/w.rs".to_string(), "dep/b.rs".to_string()]);
|
||||
assert!(idx.symbols_named_within("Decoder", None, &[f.root.join("none")], 3).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_roots_and_excludes_change_without_reopening() {
|
||||
let fx = Fixture::new(&[("src/main.rs", "fn alpha() {}\n"), ("src/gen/out.rs", "fn generated() {}\n")]);
|
||||
|
||||
@@ -10,6 +10,10 @@ use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Pinned Compose Specification JSON Schema URL associated with `yaml-language-server`.
|
||||
pub const COMPOSE_SCHEMA_URL: &str =
|
||||
"https://raw.githubusercontent.com/compose-spec/compose-go/v2.16.2/schema/compose-spec.json";
|
||||
|
||||
mod markers;
|
||||
pub mod plugin_point;
|
||||
mod registry;
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Docker Compose content detection and `yaml-language-server` schema association
|
||||
//! for open YAML documents whose filename does not already match `*compose*.y(a)ml`.
|
||||
|
||||
use ide_language::{COMPOSE_SCHEMA_URL, LanguageServerConfig};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const SERVER: &str = "yaml-language-server";
|
||||
|
||||
const TOP_SECTIONS: &[&str] = &["version", "name", "networks", "volumes", "configs", "secrets", "include"];
|
||||
|
||||
const SERVICE_KEYS: &[&str] = &[
|
||||
"image",
|
||||
"build",
|
||||
"container_name",
|
||||
"hostname",
|
||||
"domainname",
|
||||
"ports",
|
||||
"expose",
|
||||
"environment",
|
||||
"env_file",
|
||||
"volumes",
|
||||
"volumes_from",
|
||||
"networks",
|
||||
"network_mode",
|
||||
"depends_on",
|
||||
"command",
|
||||
"entrypoint",
|
||||
"restart",
|
||||
"pull_policy",
|
||||
"healthcheck",
|
||||
"deploy",
|
||||
"develop",
|
||||
"labels",
|
||||
"annotations",
|
||||
"platform",
|
||||
"profiles",
|
||||
"user",
|
||||
"working_dir",
|
||||
"privileged",
|
||||
"read_only",
|
||||
"stdin_open",
|
||||
"tty",
|
||||
"init",
|
||||
"cap_add",
|
||||
"cap_drop",
|
||||
"cgroup",
|
||||
"cgroup_parent",
|
||||
"configs",
|
||||
"secrets",
|
||||
"cpus",
|
||||
"cpu_count",
|
||||
"cpu_percent",
|
||||
"cpu_shares",
|
||||
"cpu_period",
|
||||
"cpu_quota",
|
||||
"cpuset",
|
||||
"devices",
|
||||
"dns",
|
||||
"dns_opt",
|
||||
"dns_search",
|
||||
"extends",
|
||||
"external_links",
|
||||
"extra_hosts",
|
||||
"group_add",
|
||||
"ipc",
|
||||
"isolation",
|
||||
"links",
|
||||
"logging",
|
||||
"mac_address",
|
||||
"mem_limit",
|
||||
"mem_reservation",
|
||||
"oom_kill_disable",
|
||||
"oom_score_adj",
|
||||
"pid",
|
||||
"pids_limit",
|
||||
"post_start",
|
||||
"pre_stop",
|
||||
"runtime",
|
||||
"scale",
|
||||
"security_opt",
|
||||
"shm_size",
|
||||
"stop_grace_period",
|
||||
"stop_signal",
|
||||
"storage_opt",
|
||||
"sysctls",
|
||||
"tmpfs",
|
||||
"ulimits",
|
||||
"userns_mode",
|
||||
"uts",
|
||||
];
|
||||
|
||||
fn parse_key(trimmed: &str) -> Option<&str> {
|
||||
let colon = trimmed.find(':')?;
|
||||
let after = &trimmed[colon + 1..];
|
||||
if !after.is_empty() && !after.starts_with(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
let key = trimmed[..colon].trim_end();
|
||||
let key = key
|
||||
.strip_prefix('"')
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.or_else(|| key.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
|
||||
.unwrap_or(key);
|
||||
(!key.is_empty() && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')))
|
||||
.then_some(key)
|
||||
}
|
||||
|
||||
/// Whether `path`'s filename already matches the Compose schema globs in `languages/yaml.toml`.
|
||||
pub fn is_named_compose_file(path: &Path) -> bool {
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false };
|
||||
let Some(stem) = name.strip_suffix(".yaml").or_else(|| name.strip_suffix(".yml")) else {
|
||||
return false;
|
||||
};
|
||||
// `<head>` or `<head>.*`, where `<head>` is `compose`, `docker-compose`, `*-compose` or `*.compose`.
|
||||
let is_head = |head: &str| {
|
||||
head == "compose"
|
||||
|| head == "docker-compose"
|
||||
|| head.strip_suffix("-compose").is_some_and(|p| !p.is_empty())
|
||||
|| head.strip_suffix(".compose").is_some_and(|p| !p.is_empty())
|
||||
};
|
||||
is_head(stem) || stem.match_indices('.').any(|(i, _)| i + 1 < stem.len() && is_head(&stem[..i]))
|
||||
}
|
||||
|
||||
/// Whether `path` is a YAML file whose name does not match a Compose glob, but whose content is Compose.
|
||||
pub fn is_content_detected_compose_file(path: &Path, text: &str) -> bool {
|
||||
!is_named_compose_file(path) && is_compose_content(text)
|
||||
}
|
||||
|
||||
/// Whether `text` looks like a Docker Compose YAML configuration.
|
||||
pub fn is_compose_content(text: &str) -> bool {
|
||||
let mut has_services = false;
|
||||
let mut has_compose_top = false;
|
||||
let mut has_api_version = false;
|
||||
let mut has_kind = false;
|
||||
let mut has_on = false;
|
||||
let mut has_jobs = false;
|
||||
let mut in_services = false;
|
||||
let mut service_indent = None;
|
||||
let mut prop_indent = None;
|
||||
let mut has_service_prop = false;
|
||||
|
||||
for raw in text.lines().take(600) {
|
||||
let line = raw.trim_end_matches('\r');
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let lead = line.len() - trimmed.len();
|
||||
if lead == 0 {
|
||||
in_services = false;
|
||||
if let Some(key) = parse_key(trimmed) {
|
||||
if matches!(key, "openapi" | "swagger" | "AWSTemplateFormatVersion") {
|
||||
return false;
|
||||
}
|
||||
has_api_version |= key == "apiVersion";
|
||||
has_kind |= key == "kind";
|
||||
has_on |= key == "on";
|
||||
has_jobs |= key == "jobs";
|
||||
if (has_api_version && has_kind) || (has_on && has_jobs) {
|
||||
return false;
|
||||
}
|
||||
if key == "services" {
|
||||
has_services = true;
|
||||
in_services = true;
|
||||
service_indent = None;
|
||||
prop_indent = None;
|
||||
} else if TOP_SECTIONS.contains(&key) || key.starts_with("x-") {
|
||||
has_compose_top = true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if in_services && !has_service_prop {
|
||||
let svc_indent = *service_indent.get_or_insert(lead);
|
||||
if lead == svc_indent {
|
||||
prop_indent = None;
|
||||
} else if lead > svc_indent {
|
||||
let p_indent = *prop_indent.get_or_insert(lead);
|
||||
if lead == p_indent
|
||||
&& let Some(prop) = parse_key(trimmed)
|
||||
&& (SERVICE_KEYS.contains(&prop) || prop.starts_with("x-"))
|
||||
{
|
||||
has_service_prop = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
has_services && (has_compose_top || has_service_prop)
|
||||
}
|
||||
|
||||
/// Appends open content-detected Compose files to `yaml-language-server`'s
|
||||
/// `yaml.schemas[COMPOSE_SCHEMA_URL]` unless excluded by user settings.
|
||||
pub fn configure_yaml_compose_schemas(cfg: &mut LanguageServerConfig, compose_files: &[PathBuf]) {
|
||||
if cfg.name != SERVER || compose_files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut settings = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({}));
|
||||
let schemas = settings
|
||||
.as_object_mut()
|
||||
.expect("object")
|
||||
.entry("yaml")
|
||||
.or_insert_with(|| json!({}))
|
||||
.as_object_mut()
|
||||
.and_then(|yaml| yaml.entry("schemas").or_insert_with(|| json!({})).as_object_mut());
|
||||
if let Some(schemas) = schemas {
|
||||
let entry = schemas.entry(COMPOSE_SCHEMA_URL).or_insert_with(|| json!([]));
|
||||
if let Value::String(single) = entry {
|
||||
*entry = json!([single.clone()]);
|
||||
}
|
||||
if let Some(list) = entry.as_array_mut() {
|
||||
for path in compose_files {
|
||||
let p = path.to_string_lossy().replace('\\', "/");
|
||||
let negated = format!("!{p}");
|
||||
if list.iter().any(|v| v.as_str() == Some(&p) || v.as_str() == Some(&negated)) {
|
||||
continue;
|
||||
}
|
||||
list.push(Value::String(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg.settings = Some(settings);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_language::LanguageRegistry;
|
||||
|
||||
#[test]
|
||||
fn detects_compose_content_and_ignores_other_manifests() {
|
||||
assert!(is_compose_content("services:\n db:\n image: postgres:16\n"));
|
||||
assert!(is_compose_content("version: \"3.9\"\nservices:\n app:\n foo: bar\n"));
|
||||
assert!(!is_compose_content("apiVersion: v1\nkind: Service\nservices:\n db:\n image: x\n"));
|
||||
assert!(!is_compose_content("on: push\njobs:\n build:\n runs-on: ubuntu-latest\n"));
|
||||
assert!(is_named_compose_file(Path::new("/work/docker-compose.yml")));
|
||||
assert!(is_named_compose_file(Path::new("/work/playground-compose.yaml")));
|
||||
assert!(!is_named_compose_file(Path::new("/work/custom-stack.yml")));
|
||||
for named in
|
||||
["compose.yaml", "compose.override.yml", "docker-compose.prod.yaml", "a.compose.yml", "x-compose.ci.yml"]
|
||||
{
|
||||
assert!(is_named_compose_file(Path::new(named)), "{named}");
|
||||
}
|
||||
for unnamed in ["docker-compose-dev.yml", "compose-prod.yaml", "composer.yml", "decompose.yaml", "-compose.yml"]
|
||||
{
|
||||
assert!(!is_named_compose_file(Path::new(unnamed)), "{unnamed}");
|
||||
}
|
||||
assert!(is_content_detected_compose_file(
|
||||
Path::new("/work/docker-compose-dev.yml"),
|
||||
"services:\n db:\n image: postgres:16\n"
|
||||
));
|
||||
assert!(!is_content_detected_compose_file(
|
||||
Path::new("/work/docker-compose.yml"),
|
||||
"services:\n db:\n image: postgres:16\n"
|
||||
));
|
||||
assert!(is_content_detected_compose_file(
|
||||
Path::new("/work/custom-stack.yml"),
|
||||
"services:\n db:\n image: postgres:16\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_content_detected_files_into_yaml_schemas_unless_excluded() {
|
||||
let reg = LanguageRegistry::load(None).unwrap();
|
||||
let yaml = reg.get(&ide_core::LanguageId::new("yaml")).unwrap();
|
||||
let mut cfg = yaml.language_servers[0].clone();
|
||||
let a = PathBuf::from("/work/custom-stack.yml");
|
||||
let b = PathBuf::from("/work/excluded.yml");
|
||||
cfg.settings.as_mut().unwrap()["yaml"]["schemas"][COMPOSE_SCHEMA_URL]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!("!/work/excluded.yml"));
|
||||
|
||||
configure_yaml_compose_schemas(&mut cfg, &[a, b]);
|
||||
let list = cfg.settings.as_ref().unwrap()["yaml"]["schemas"][COMPOSE_SCHEMA_URL]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(list.contains(&"compose.yml"));
|
||||
assert!(list.contains(&"*-compose.yml"));
|
||||
assert!(list.contains(&"/work/custom-stack.yml"));
|
||||
assert!(!list.contains(&"/work/excluded.yml"));
|
||||
assert!(list.contains(&"!/work/excluded.yml"));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod angular;
|
||||
mod arduino;
|
||||
mod caps;
|
||||
mod clang_tidy;
|
||||
mod compose;
|
||||
mod convert;
|
||||
mod external_project;
|
||||
mod file_ops;
|
||||
@@ -107,6 +108,8 @@ struct DocState {
|
||||
version: i32,
|
||||
text: Arc<DocText>,
|
||||
servers: Vec<Attached>,
|
||||
/// Open YAML document whose filename does not match a Compose glob and whose content is Compose.
|
||||
compose_detected: bool,
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
@@ -1017,6 +1020,7 @@ impl LspManager {
|
||||
servers: Vec<Arc<Server>>,
|
||||
) {
|
||||
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||||
let compose_detected = language.as_str() == "yaml" && compose::is_content_detected_compose_file(&path, text);
|
||||
let mut state = DocState {
|
||||
path,
|
||||
canonical,
|
||||
@@ -1025,9 +1029,12 @@ impl LspManager {
|
||||
version: version as i32,
|
||||
text: Arc::new(DocText::new(text)),
|
||||
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
|
||||
compose_detected,
|
||||
};
|
||||
{
|
||||
let compose_added = {
|
||||
let mut docs = self.docs.lock();
|
||||
let already_detected =
|
||||
compose_detected && docs.values().any(|d| d.compose_detected && d.path == state.path);
|
||||
let params = state.did_open_params();
|
||||
for a in &mut state.servers {
|
||||
if let Some(run) = a.server.running() {
|
||||
@@ -1038,13 +1045,17 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
docs.insert(doc, state);
|
||||
}
|
||||
compose_detected && !already_detected
|
||||
};
|
||||
self.schedule_pull(doc, None, Duration::ZERO);
|
||||
for s in &servers {
|
||||
self.ensure_started(s);
|
||||
if s.running().is_some()
|
||||
&& let Some(me) = self.me.upgrade()
|
||||
{
|
||||
if s.name == compose::SERVER && compose_added {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
let server = s.clone();
|
||||
self.runtime.spawn(async move { me.check_clang_tidy(&server).await });
|
||||
}
|
||||
@@ -1115,7 +1126,18 @@ impl LspManager {
|
||||
tracing::debug!("{}: didChange: {e}", server.name);
|
||||
}
|
||||
}
|
||||
let compose_changed = if d.language.as_str() == "yaml" && !compose::is_named_compose_file(&d.path) {
|
||||
let now = compose::is_compose_content(text.as_str());
|
||||
let changed = std::mem::replace(&mut d.compose_detected, now) != now;
|
||||
let closed_path = d.path.clone();
|
||||
changed && !docs.iter().any(|(id, other)| *id != doc && other.compose_detected && other.path == closed_path)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
drop(docs);
|
||||
if compose_changed && let Some(me) = self.me.upgrade() {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
self.schedule_pull(doc, None, pull::AFTER_EDIT);
|
||||
self.invalidate_injections(doc);
|
||||
self.schedule_injections(doc);
|
||||
@@ -1175,7 +1197,16 @@ impl LspManager {
|
||||
}
|
||||
|
||||
fn close_document(&self, doc: DocId) {
|
||||
let Some(d) = self.docs.lock().remove(&doc) else { return };
|
||||
let (d, compose_removed) = {
|
||||
let mut docs = self.docs.lock();
|
||||
let Some(d) = docs.remove(&doc) else { return };
|
||||
let removed =
|
||||
d.compose_detected && !docs.values().any(|other| other.compose_detected && other.path == d.path);
|
||||
(d, removed)
|
||||
};
|
||||
if compose_removed && let Some(me) = self.me.upgrade() {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
for a in &d.servers {
|
||||
self.forget_pulled(&a.server, &d.path, a.server.running().as_ref());
|
||||
let Some(run) = a.server.running() else { continue };
|
||||
@@ -3169,9 +3200,20 @@ impl LspManager {
|
||||
if let Some(hook) = self.config_hook.read().clone() {
|
||||
hook(&mut cfg, &server.root);
|
||||
}
|
||||
if cfg.name == compose::SERVER {
|
||||
compose::configure_yaml_compose_schemas(&mut cfg, &self.open_compose_files());
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
fn open_compose_files(&self) -> Vec<PathBuf> {
|
||||
let mut files: Vec<PathBuf> =
|
||||
self.docs.lock().values().filter(|d| d.compose_detected).map(|d| d.path.clone()).collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files
|
||||
}
|
||||
|
||||
/// Restarts running servers whose settings-derived configuration changed
|
||||
/// (e.g. a new project interpreter); stopped ones pick it up on their next start.
|
||||
/// A change of `settings` alone reaches a running server without a restart.
|
||||
|
||||
@@ -169,6 +169,19 @@ pub fn read_text(real: &Path) -> Result<String, String> {
|
||||
String::from_utf8(bytes).map_err(|_| format!("{}: not valid UTF-8 text", real.display()))
|
||||
}
|
||||
|
||||
/// Reads the bytes of a file that `resolve_in_root` already approved: binary
|
||||
/// content is fine, the size cap is the same as [`read_text`]'s.
|
||||
pub fn read_bytes(real: &Path) -> Result<Vec<u8>, String> {
|
||||
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{}: not a file", real.display()));
|
||||
}
|
||||
if meta.len() > MAX_READ_BYTES {
|
||||
return Err(format!("{}: file is larger than {} MiB", real.display(), MAX_READ_BYTES >> 20));
|
||||
}
|
||||
std::fs::read(real).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Why a read outside the project was refused.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
@@ -528,6 +541,25 @@ mod tests {
|
||||
assert!(read_text(&p).unwrap_err().contains("UTF-8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_returns_the_exact_bytes_up_to_the_cap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// The bytes binary files carry survive, where read_text refuses them.
|
||||
let binary = [0x25, 0x50, 0x44, 0x46, 0x00, 0xff, 0xfe, 0x1b];
|
||||
std::fs::write(tmp.path().join("b.bin"), binary).unwrap();
|
||||
let p = resolve_in_root(tmp.path(), "b.bin").unwrap();
|
||||
assert_eq!(read_bytes(&p).unwrap(), binary);
|
||||
// A directory is not a file; the size cap is read_text's.
|
||||
assert!(read_bytes(&resolve_in_root(tmp.path(), ".").unwrap()).unwrap_err().contains("not a file"));
|
||||
let big = tmp.path().join("big.bin");
|
||||
std::fs::write(&big, vec![0u8; (MAX_READ_BYTES + 1) as usize]).unwrap();
|
||||
let err = read_bytes(&big).unwrap_err();
|
||||
assert!(err.contains("larger than"), "{err}");
|
||||
let at_cap = tmp.path().join("at-cap.bin");
|
||||
std::fs::write(&at_cap, vec![7u8; MAX_READ_BYTES as usize]).unwrap();
|
||||
assert_eq!(read_bytes(&at_cap).unwrap().len(), MAX_READ_BYTES as usize);
|
||||
}
|
||||
|
||||
/// The grant a manifest author would write for `dir`: the plain path, no
|
||||
/// `\\?\` prefix.
|
||||
fn grant_for(dir: &Path) -> Capability {
|
||||
|
||||
@@ -696,6 +696,22 @@ impl workspace::HostProject for StoreData {
|
||||
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
|
||||
}
|
||||
|
||||
fn read_bytes(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<Vec<u8>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => {
|
||||
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
|
||||
return Ok(Err(e));
|
||||
}
|
||||
};
|
||||
// The bytes on disk: an unsaved text buffer does not apply, binary files are the point.
|
||||
Ok(sandbox::read_bytes(&real).map_err(wit::Error::Failed))
|
||||
}
|
||||
|
||||
fn exists(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<bool> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
163429536090fe11286cce425fabad3e84c14e1ea4e4b678be88a0bad0a1285a
|
||||
45ac83943d767245797a22a108169867e66791ca6f0a7e2bc70ca4ebfda7974a
|
||||
@@ -104,6 +104,10 @@ title = "reveal"
|
||||
id = "search-text"
|
||||
title = "search-text"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "read-bytes"
|
||||
title = "read-bytes"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "problems"
|
||||
title = "problems"
|
||||
|
||||
@@ -25,6 +25,7 @@ impl Plugin for Api14 {
|
||||
.search_text(&openidea_plugin::TextQuery { pattern: arg.into(), ..Default::default() }, 10)?;
|
||||
Ok(Some(format!("{result:?}")))
|
||||
}
|
||||
"read-bytes" => Ok(Some(format!("{:?}", ctx.project().unwrap().read_bytes(arg)?))),
|
||||
"problems" => Ok(Some(format!("{:?}", host::problems(if arg.is_empty() { None } else { Some(arg) })?))),
|
||||
"decorations" => {
|
||||
host::set_decorations(
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
bfdd78a242472a1279e0e393f66f0d96a856338e103aad454239a451df22cd05
|
||||
3d32267d42eada262ac000a6d1eda9070bea833f7cd5a0ae5c09a82ee6719f76
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
37276095967174307db4c5113d85b777138f60ee82d26b4af8ef3f2acd90aafa
|
||||
31092e0b376f28f0910d0d502066fd3aeb2b6e071637aee11af83eded0a21e95
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
b9597de10e7182e1ed488f80b6c1deb9f810912797b2bf02c9cae0347ee38bc8
|
||||
beddd694c7b95317f03539d5096a0a249d25ed1c2444c483e2cbf4269bc172f9
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
3ee642e6d1f983ba5e186007e2115b0a104383510cc8acc13f3e0a83fe1ca5b6
|
||||
f98ea4207669eab6f5bef16d39e3f8bdc8a2d53a4ad7b46dd85c4546587cc437
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
43f7af5d824853a3fbaa61ef408127cd2aac0134e6ec02ca2fb35813db4b0bd9
|
||||
3e822039e39554e3923b967fa2afb03bd2f816b4eb61d143dc9b621c066089ff
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
d85df5d6fb5e9d746fe4b28e1f8546c30fe6dc53c1b9dfd9201bc6a9dacf2306
|
||||
3cce72ed6d50690874bb4a21ef8f74307873bc19dd46c9c0f4df4e679592f9a8
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
17fcb4251b6bdd055e4e856bd8d69d7eca52693cefb818b547787907022b7943
|
||||
3f02b9e8be539c5f73f19e4ee894f5c07c35f1780ceb7e30b85f4bb055a6eaab
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
50d8a7304714e0810da7f8a2f723155287c26807848049b00492cb9def6c7763
|
||||
4f918e2fc7e3467f2f83e9173906688d95831b25675be63eb3845e6d6096d09d
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
8442bac7628c5e3ddbf2af2337781016197028054c83d4589dbbcf479c77ff87
|
||||
82096b10bdf00ea13c70e3c122473e6e78d3194689d046fd3c293bb3d51fbe01
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
054bdb7b732f74b0266880bfea3699bb9994bbdba4054dc2935a6cc0bcdf57de
|
||||
a3d54e4f17f47a419cade142f963c3f2f29ecb322859df4a87453782d25379f4
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
fb3097d0429782d24ac647433bb7146a29f9b28a505bd2e7693c0244dac3c1e1
|
||||
e098b329617036ce9287b449cf173b49138eca6f2ea56f580c13eba95077160f
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
e6da5f8b4c30861c17c7da1b62992de6132ed6ab99f404292193e39a40a68637
|
||||
feb25270f4459eb03e2a6c43715fc05b1ae6c14177b1a35891f06ef1bd783cba
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
514f87032622056e8b89f1c4d7950bb0a466c9c857fe27d65a523ea7d3c35911
|
||||
10ee58ead746983df8a1b2df1290c0de056fc7846ec53c5bb65ff512b014c7ff
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
8908a092da5958e55fab506e081695fc7b48ea3bf287832aaac3e27e4c2adb21
|
||||
a331930b86df4ac86a13728582a49e61db68793b18e171fde8a043dca5fbbe49
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
2a4996cbd60e29f4c3c47748d9ac7a4ca05ff4d8c55519fb6e06cec88b868be5
|
||||
1bd66a940d516f3ab39284b5721cc3995e255016b9e987f69aef2481543c4e3f
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
4385c1553a4aaed9030d462a72677d07b02eaa0d08b8c1770f42116efca95d94
|
||||
29116dfa55042b4a5b0c34f0f2dbde11070ffa3c41b5a013cf6901e59bdcf6fe
|
||||
@@ -66,6 +66,13 @@ impl commands::Guest for Misbehave {
|
||||
let project = ctx.project().ok_or_else(|| failed("no project is open"))?;
|
||||
project.read_file(&arg.unwrap_or_default()).map(Some)
|
||||
}
|
||||
// The bytes `project.read-bytes` returns, as "len hex hex …" so binary survives.
|
||||
"read-bytes" => {
|
||||
let project = ctx.project().ok_or_else(|| failed("no project is open"))?;
|
||||
let bytes = project.read_bytes(&arg.unwrap_or_default())?;
|
||||
let hex: Vec<String> = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||
Ok(Some(format!("{} {}", bytes.len(), hex.join(" "))))
|
||||
}
|
||||
"root" => ctx.project().map(|p| p.root()).transpose(),
|
||||
// The `fs.read` grants: the text of `arg`, or the refusal naming what it needs.
|
||||
"fsread" => openidea::plugin::fs::read_file(&arg.unwrap_or_default()).map(Some),
|
||||
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
777d722fbe5673ca37165c2032389ea7c9518f7b0b3b51953e26ba01b50b3ec4
|
||||
4b8cd615a5302f73e1e285f7e1bb78698f642e2362caf8b5d0e9d2cb915d6e40
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
e9b3cc0b1bf82b06ace741ccb9f9e23d0dab2f61a5695d786dec08b7839f77aa
|
||||
25ecb8286427c19b3f3d6881d8d34b54541bfdb8c94c5bf8f85e731798f5991d
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
fac47c0d2e74cb236b063e8edc617a991aaf7eecf6cf71a75404310ed270f75e
|
||||
25ffd18ec3229df2ca8ba875f58dd494041ec3982610b3d0982dade3bfd832cb
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
ba6c1563853158291b0524146031363c45288757cf959cf143db60437beb4b93
|
||||
61160a74f1998423c48290f9d34d3d3db9ee7b6633db15077d1c06602cc44c3f
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
57e4765ec72da0bb4768b5c097fead43e9484449abfc80c925bccd7ecc91af7e
|
||||
638a63af9e8a52d7596fbf87cfc925f6f92359c2b7716e71531efbca20d38998
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
6877c01f91c1ec0dfee326b6825d0d3fbbb14a67837eb7fd0eba10a727535b5b
|
||||
96a3429aa8143429ab1fcc60793424103fc144cba923b9b97704694bc044150b
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
3b7535831c9f5b9d910fc7a73a44ca492c4751236fd4ac610844dd99b1467f85
|
||||
0fd230df9fa72ae3216155b10a1f6388f1634f524f54be6faaf5a86d01fcafd2
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
7b20e7413eed468a60675814e88f25d27b96b379817bedaf512fdaca7f9995e4
|
||||
a60bcd2ed532f6449fef095cb7ebd45f7a36b3c9e94c0336e670b584206e682a
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
c4a043bf1fbed43f3225d6453612302b3e8a55ff630cbb521d73df60ea43cb1a
|
||||
128deab3ef53cd6742f01c7475ef908aaab7aa78a437623bc1cb3f1b75b3ed39
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
0150cf5f2f05df5666e7fe26f0164e9913c1d931d7ad52221c683d3b93dbcbe3
|
||||
1108fb26bca68149a00aa34f9865307353edbd571c5448aab23a732166df0551
|
||||
@@ -785,6 +785,18 @@ pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path,
|
||||
configure_java_compiler_with_level(cfg, data_dir, inspections, None);
|
||||
}
|
||||
|
||||
/// A Java language level as (file-name tag, JDT compliance value): `6` / `1.6` → `("6", "1.6")`,
|
||||
/// `17` → `("17", "17")`. Levels 5–8 use JDT's `1.x` spelling; unknown values are ignored.
|
||||
fn java_compliance(level: &str) -> Option<(String, String)> {
|
||||
let level = level.trim();
|
||||
let major: u32 = level.strip_prefix("1.").unwrap_or(level).parse().ok()?;
|
||||
match major {
|
||||
5..=8 => Some((major.to_string(), format!("1.{major}"))),
|
||||
9..=99 if !level.starts_with("1.") => Some((major.to_string(), major.to_string())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`configure_java_compiler`], also applying the Project Structure Java language level
|
||||
/// (`source`, `compliance`, `codegen.targetPlatform`) when configured.
|
||||
pub fn configure_java_compiler_with_level(
|
||||
@@ -796,14 +808,7 @@ pub fn configure_java_compiler_with_level(
|
||||
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
|
||||
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
|
||||
let serial = missing_serial_version(inspections);
|
||||
let normalized_level = language_level.map(str::trim).and_then(|l| match l {
|
||||
"8" | "1.8" => Some(("8", "1.8")),
|
||||
"11" => Some(("11", "11")),
|
||||
"17" => Some(("17", "17")),
|
||||
"21" => Some(("21", "21")),
|
||||
"25" => Some(("25", "25")),
|
||||
_ => None,
|
||||
});
|
||||
let normalized_level = language_level.and_then(java_compliance);
|
||||
let mut stem = "compiler-defaults".to_owned();
|
||||
let mut content = DEFAULTS.to_owned();
|
||||
if let Some(level) = serial {
|
||||
@@ -904,6 +909,47 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
}
|
||||
}
|
||||
|
||||
if name == "yaml-language-server" && !s.files.compose_file_overrides.is_empty() {
|
||||
let mut settings = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({}));
|
||||
let mut kept = Vec::new();
|
||||
let mut excluded = Vec::new();
|
||||
for (path, status) in &s.files.compose_file_overrides {
|
||||
let p = Path::new(path);
|
||||
let abs = if p.is_absolute() { p.to_path_buf() } else { root.join(p) };
|
||||
let normalized = abs.to_string_lossy().replace('\\', "/");
|
||||
match status {
|
||||
ide_core::settings::ComposeFileOverride::Kept => kept.push(normalized),
|
||||
ide_core::settings::ComposeFileOverride::Excluded => excluded.push(normalized),
|
||||
}
|
||||
}
|
||||
let yaml = settings
|
||||
.as_object_mut()
|
||||
.expect("object")
|
||||
.entry("yaml")
|
||||
.or_insert_with(|| json!({}))
|
||||
.as_object_mut()
|
||||
.expect("object");
|
||||
let schemas = yaml.entry("schemas").or_insert_with(|| json!({})).as_object_mut().expect("object");
|
||||
let entry = schemas.entry(ide_language::COMPOSE_SCHEMA_URL).or_insert_with(|| json!([]));
|
||||
if let Value::String(single) = entry {
|
||||
*entry = json!([single.clone()]);
|
||||
}
|
||||
if let Some(list) = entry.as_array_mut() {
|
||||
for p in kept {
|
||||
if !list.iter().any(|v| v.as_str() == Some(&p)) {
|
||||
list.push(Value::String(p));
|
||||
}
|
||||
}
|
||||
for p in &excluded {
|
||||
let neg = format!("!{p}");
|
||||
if !list.iter().any(|v| v.as_str() == Some(&neg)) {
|
||||
list.push(Value::String(neg));
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg.settings = Some(settings);
|
||||
}
|
||||
|
||||
if name == "rust-analyzer" {
|
||||
let r = &s.languages.rust;
|
||||
let extra_args: Vec<&str> = r.rustfmt_args.split_whitespace().collect();
|
||||
@@ -1314,6 +1360,45 @@ pub fn run_target<'a>(config: &RunConfig, settings: &'a Settings) -> Option<&'a
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the configuration's `JAVA_HOME` is the one Settings choose (the Maven runner JRE,
|
||||
/// the Gradle JVM) rather than a choice of its own: an IDE-generated build-tool configuration —
|
||||
/// a Maven goal or Gradle task from the tool window — carries the model's `JAVA_HOME`, which
|
||||
/// Settings derived when the model was built (IDEA has no JRE field on such a task either).
|
||||
/// Such a value is derived: Settings win, and [`without_derived`] drops it, so a corrected JDK
|
||||
/// applies to the next run, also across a restart (#2433). Every other configuration keeps its
|
||||
/// own `JAVA_HOME`, even one this build cannot parse.
|
||||
fn java_home_is_derived(c: &RunConfig) -> bool {
|
||||
generated_maven_goal(c) || (c.id.starts_with("gradle:task:") && c.extras.params.is_empty())
|
||||
}
|
||||
|
||||
/// Applies the `JAVA_HOME` of `env` (the one Settings resolved) to `c`. A derived value is
|
||||
/// replaced, also when Settings resolve none (a dead path must not outlive the setting it came
|
||||
/// from); an explicit `JAVA_HOME` of the configuration still wins.
|
||||
fn apply_java_home(c: &mut RunConfig, env: &std::collections::BTreeMap<String, String>, derived: bool) {
|
||||
match env.get("JAVA_HOME") {
|
||||
Some(value) if derived => {
|
||||
c.env.insert("JAVA_HOME".into(), value.clone());
|
||||
}
|
||||
Some(value) => {
|
||||
c.env.entry("JAVA_HOME".into()).or_insert_with(|| value.clone());
|
||||
}
|
||||
None if derived => {
|
||||
c.env.remove("JAVA_HOME");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Maven environment from Settings (the runner JRE), applied to `c`: a derived `JAVA_HOME`
|
||||
/// is replaced, an explicit one stays (see [`apply_java_home`]).
|
||||
pub fn apply_maven_env(c: &mut RunConfig, root: &Path, s: &Settings) {
|
||||
let env = maven_build_env(root, s, None);
|
||||
apply_java_home(c, &env, java_home_is_derived(c));
|
||||
for (key, value) in env {
|
||||
c.env.entry(key).or_insert(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Points run configurations at the selected toolchains: Python configs use the
|
||||
/// project interpreter, npm scripts the chosen package manager and Node.js,
|
||||
/// cargo / go configs the selected toolchain, Maven / Gradle their runner JDK
|
||||
@@ -1445,7 +1530,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
if matches!(c.command.as_str(), "gradle" | "gradle.bat") {
|
||||
c.command = resolve_gradle_command(root, &build_dir, s, false).to_string_lossy().into_owned();
|
||||
}
|
||||
for (key, value) in gradle_build_env(root, &build_dir, &c.command, s, None) {
|
||||
let env = gradle_build_env(root, &build_dir, &c.command, s, None);
|
||||
apply_java_home(c, &env, java_home_is_derived(c));
|
||||
for (key, value) in env {
|
||||
c.env.entry(key).or_insert(value);
|
||||
}
|
||||
}
|
||||
@@ -1475,9 +1562,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
}
|
||||
}
|
||||
"maven" => {
|
||||
for (key, value) in maven_build_env(root, s, None) {
|
||||
c.env.entry(key).or_insert(value);
|
||||
}
|
||||
apply_maven_env(c, root, s);
|
||||
if let Some(mvn) = &mvn
|
||||
&& (c.detected
|
||||
|| generated_maven_goal(c)
|
||||
@@ -1524,6 +1609,11 @@ pub fn without_derived(config: &RunConfig, root: &Path, s: &Settings) -> RunConf
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, value)| {
|
||||
// Settings choose the `JAVA_HOME` of a generated build-tool configuration, also when
|
||||
// a corrected setting no longer reproduces the value the model froze there (#2433).
|
||||
if key.as_str() == "JAVA_HOME" && java_home_is_derived(&out) {
|
||||
return true;
|
||||
}
|
||||
let mut probe = out.clone();
|
||||
probe.env.remove(*key);
|
||||
let mut plain = probe.clone();
|
||||
@@ -1846,6 +1936,36 @@ mod maven_linkage_tests {
|
||||
configure_maven_import(&mut cfg, tmp.path(), &[], Some(&files));
|
||||
assert!(serde_json::to_value(cfg.initialization_options).unwrap().get("projectConfigurations").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_file_overrides_populate_yaml_schemas_and_typed_exclusions() {
|
||||
let root = Path::new("/work");
|
||||
let mut s = Settings::default();
|
||||
s.files.compose_file_overrides.insert("custom-stack.yml".into(), ide_core::settings::ComposeFileOverride::Kept);
|
||||
s.files
|
||||
.compose_file_overrides
|
||||
.insert("/work/docker/playground-compose.yml".into(), ide_core::settings::ComposeFileOverride::Excluded);
|
||||
let mut cfg = custom_server("yaml-language-server", &LanguageServerSettings::default());
|
||||
cfg.settings = Some(json!({
|
||||
"yaml": {
|
||||
"schemas": {
|
||||
ide_language::COMPOSE_SCHEMA_URL: ["compose.yml", "*-compose.yml"]
|
||||
}
|
||||
}
|
||||
}));
|
||||
configure_server(&mut cfg, root, root, &s);
|
||||
let yaml = &cfg.settings.as_ref().unwrap()["yaml"];
|
||||
assert!(yaml.get("disableSchemaDetection").is_none());
|
||||
let list = yaml["schemas"][ide_language::COMPOSE_SCHEMA_URL]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(list.contains(&"compose.yml"));
|
||||
assert!(list.contains(&"/work/custom-stack.yml"));
|
||||
assert!(list.contains(&"!/work/docker/playground-compose.yml"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
@@ -1873,3 +1993,21 @@ mod ruby_lsp_tests {
|
||||
assert!(!dirs.iter().any(|d| d.starts_with("/nonexistent")), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod java_compliance_tests {
|
||||
#[test]
|
||||
fn maps_every_java_language_level_to_jdt_compliance() {
|
||||
let c = |l: &str| super::java_compliance(l);
|
||||
assert_eq!(c("6"), Some(("6".into(), "1.6".into())));
|
||||
assert_eq!(c("1.7"), Some(("7".into(), "1.7".into())));
|
||||
assert_eq!(c("8"), Some(("8".into(), "1.8".into())));
|
||||
assert_eq!(c(" 1.8 "), Some(("8".into(), "1.8".into())));
|
||||
assert_eq!(c("14"), Some(("14".into(), "14".into())));
|
||||
assert_eq!(c("25"), Some(("25".into(), "25".into())));
|
||||
assert_eq!(c("1.14"), None);
|
||||
assert_eq!(c("4"), None);
|
||||
assert_eq!(c(""), None);
|
||||
assert_eq!(c("latest"), None);
|
||||
}
|
||||
}
|
||||
@@ -297,14 +297,7 @@ fn python_sdk_with_version(path: &Path, source: &str, root: Option<&Path>, versi
|
||||
_ => format!("Python {short} ({dir})"),
|
||||
}
|
||||
}
|
||||
(None, "conda") => {
|
||||
let env = path
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().into_owned());
|
||||
format!("Python {short} (conda: {})", env.unwrap_or_default())
|
||||
}
|
||||
(None, "conda") => format!("Python {short} (conda: {})", conda_env_name(path).unwrap_or_default()),
|
||||
(None, "pixi") => {
|
||||
let env = pixi_env_name(path).unwrap_or_default();
|
||||
format!("Python {short} (pixi: {env})")
|
||||
@@ -320,12 +313,30 @@ fn python_sdk_with_version(path: &Path, source: &str, root: Option<&Path>, versi
|
||||
source: source.to_owned(),
|
||||
home: venv
|
||||
.or_else(|| (source == "pixi").then(|| pixi_env_home(path)).flatten())
|
||||
.or_else(|| (source == "conda").then(|| conda_env_home(path)).flatten())
|
||||
.or_else(|| path.parent().and_then(Path::parent).map(Path::to_path_buf))
|
||||
.map(Into::into),
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix of a conda interpreter: `<env>/bin/python` on Unix, `<env>/python.exe` on Windows.
|
||||
fn conda_env_home(python: &Path) -> Option<PathBuf> {
|
||||
let dir = python.parent()?;
|
||||
let bin = dir.file_name().is_some_and(|n| n == "bin" || n.eq_ignore_ascii_case("Scripts"));
|
||||
Some(if bin { dir.parent()? } else { dir }.to_path_buf())
|
||||
}
|
||||
|
||||
/// The name conda gives an environment: `base` for the installation itself (it holds
|
||||
/// `condabin/`), else the prefix's folder (`envs/<name>`, or a `--prefix` env's directory).
|
||||
fn conda_env_name(python: &Path) -> Option<String> {
|
||||
let home = conda_env_home(python)?;
|
||||
if home.join("condabin").is_dir() {
|
||||
return Some("base".into());
|
||||
}
|
||||
home.file_name().map(|n| n.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// `.pixi/envs/<env>` of a pixi interpreter (`<env>/python`, `<env>/bin/python`).
|
||||
fn pixi_env_home(python: &Path) -> Option<PathBuf> {
|
||||
let dir = python.parent()?;
|
||||
@@ -934,6 +945,26 @@ mod pixi_tests {
|
||||
mod conda_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn conda_envs_are_named_after_their_prefix_on_every_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("anaconda3");
|
||||
std::fs::create_dir_all(root.join("condabin")).unwrap();
|
||||
let name = |python: PathBuf| super::conda_env_name(&python);
|
||||
// Windows: python.exe at the prefix root.
|
||||
assert_eq!(name(root.join("envs/agent-gateway/python.exe")).as_deref(), Some("agent-gateway"));
|
||||
assert_eq!(name(root.join("python.exe")).as_deref(), Some("base"));
|
||||
// Unix: <prefix>/bin/python.
|
||||
assert_eq!(name(root.join("envs/ml/bin/python")).as_deref(), Some("ml"));
|
||||
assert_eq!(name(root.join("bin/python")).as_deref(), Some("base"));
|
||||
// A `--prefix` env outside envs/.
|
||||
assert_eq!(name(tmp.path().join("work/.conda/python.exe")).as_deref(), Some(".conda"));
|
||||
assert_eq!(
|
||||
super::conda_env_home(&root.join("envs/agent-gateway/python.exe")),
|
||||
Some(root.join("envs/agent-gateway"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conda_env_list_names_named_prefix_and_bare_prefix_envs() {
|
||||
// `conda env list` on Windows with Anaconda on a second drive (#2240).
|
||||
|
||||
@@ -152,7 +152,11 @@ fn create_conda_env(
|
||||
}
|
||||
}
|
||||
stream(&conda, &args, None, &[], cancel, on_line)?;
|
||||
let python = location.join(bin_dir()).join(exe("python"));
|
||||
// Conda keeps `python.exe` at the prefix root on Windows, not under Scripts/.
|
||||
let python = [location.join(exe("python")), location.join(bin_dir()).join(exe("python"))]
|
||||
.into_iter()
|
||||
.find(|p| p.is_file())
|
||||
.unwrap_or_else(|| location.join(bin_dir()).join(exe("python")));
|
||||
crate::discover::python_sdk(&python, "conda", location.parent().and_then(Path::parent))
|
||||
.ok_or_else(|| IdeError::internal(format!("{} was not created", python.display())))
|
||||
}
|
||||
|
||||
@@ -476,6 +476,85 @@ fn maven_runner_jre_overrides_the_project_jdk() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2433: a `JAVA_HOME` an IDE-generated build-tool configuration carries came from Settings, so
|
||||
/// a corrected setting reaches the next run — also when an older version baked the value into a
|
||||
/// temporary configuration (and persisted it). A configuration of the user's own keeps its
|
||||
/// `JAVA_HOME`. Table-driven over the build-tool kinds and the state of the carried value.
|
||||
#[test]
|
||||
fn settings_win_over_a_generated_configurations_java_home() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
for name in ["old-jdk", "new-jdk", "user-jdk", "runner-jdk"] {
|
||||
fake_jdk(&root.join(name));
|
||||
}
|
||||
let old = root.join("old-jdk").to_string_lossy().into_owned();
|
||||
let new = root.join("new-jdk").to_string_lossy().into_owned();
|
||||
let user = root.join("user-jdk").to_string_lossy().into_owned();
|
||||
let runner = root.join("runner-jdk").to_string_lossy().into_owned();
|
||||
let gone = root.join("removed-jdk").to_string_lossy().into_owned(); // never created
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.jdk = "new-jdk".into();
|
||||
assert_eq!(crate::apply::maven_build_env(&root, &settings, None)["JAVA_HOME"], new);
|
||||
|
||||
let launch = |config: &RunConfig| {
|
||||
let mut c = config.clone();
|
||||
if c.kind == "maven" {
|
||||
// The Maven launcher's own path (`run_start`).
|
||||
crate::apply::prepare_maven_run(&mut c, &root, &settings);
|
||||
} else {
|
||||
apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings);
|
||||
}
|
||||
c
|
||||
};
|
||||
|
||||
// (kind, id, carried JAVA_HOME, what it runs with, whether a save keeps it)
|
||||
for (kind, id, carried, runs_with, kept) in [
|
||||
// A Maven goal from the tool window: Settings win, whether the carried JDK still exists.
|
||||
("maven", "maven:goal:pom.xml:compile", Some(old.as_str()), new.as_str(), false),
|
||||
("maven", "maven:goal:pom.xml:compile", Some(gone.as_str()), new.as_str(), false),
|
||||
("maven", "maven:goal:pom.xml:compile", None, new.as_str(), false),
|
||||
// A saved Maven configuration keeps the JDK it carries.
|
||||
("maven", "maven:test", Some(user.as_str()), user.as_str(), true),
|
||||
("maven", "my-goal", Some(user.as_str()), user.as_str(), true),
|
||||
// A Gradle task from the tool window: the Gradle JVM / Project JDK decides.
|
||||
("gradle", "gradle:task:[\":\",\":app\"]:test", Some(user.as_str()), new.as_str(), false),
|
||||
("gradle", "gradle:task:[\":\",\":app\"]:test", Some(gone.as_str()), new.as_str(), false),
|
||||
// A Gradle task of the user's own keeps its JDK.
|
||||
("gradle", "my-task", Some(user.as_str()), user.as_str(), true),
|
||||
// Tomcat keeps the JDK it carries: it is a field of that configuration.
|
||||
("tomcat", "tomcat:local", Some(user.as_str()), user.as_str(), true),
|
||||
] {
|
||||
let command = if kind == "tomcat" { "catalina" } else { "mvn" };
|
||||
let mut config = run(kind, command, &["compile"]);
|
||||
config.id = id.into();
|
||||
config.detected = false;
|
||||
if let Some(home) = carried {
|
||||
config.env.insert("JAVA_HOME".into(), home.to_owned());
|
||||
}
|
||||
let case = format!("{kind} {id} carried={carried:?}");
|
||||
assert_eq!(launch(&config).env.get("JAVA_HOME").map(String::as_str), Some(runs_with), "{case}");
|
||||
// Saved: a derived value is dropped so a later settings change still reaches it; the
|
||||
// user's own value survives the round trip.
|
||||
let saved = without_derived(&config, &root, &settings);
|
||||
assert_eq!(saved.env.contains_key("JAVA_HOME"), kept, "{case} (saved={saved:?})");
|
||||
assert_eq!(launch(&saved).env.get("JAVA_HOME").map(String::as_str), Some(runs_with), "{case} (after a save)");
|
||||
}
|
||||
// A corrected setting reaches a goal configuration that was saved with the old JDK baked in,
|
||||
// without reopening the project: the Maven runner JRE (the reporter's field, #2433) wins.
|
||||
let mut goal = run("maven", "mvn", &["compile"]);
|
||||
goal.id = "maven:goal:pom.xml:compile".into();
|
||||
goal.detected = false;
|
||||
goal.env.insert("JAVA_HOME".into(), old.clone());
|
||||
let saved = without_derived(&goal, &root, &settings);
|
||||
assert!(!saved.env.contains_key("JAVA_HOME"));
|
||||
let mut moved = settings.clone();
|
||||
moved.languages.java.maven.runner_jre = "runner-jdk".into();
|
||||
let mut next = saved;
|
||||
crate::apply::prepare_maven_run(&mut next, &root, &moved);
|
||||
assert_eq!(next.env["JAVA_HOME"], runner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_daemon_uses_the_runner_jre_version() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -101,6 +101,22 @@ fn new_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Runs a credential-store operation off the async runtime.
|
||||
///
|
||||
/// The Linux Secret Service client is blocking (`zbus`) and starts a runtime of
|
||||
/// its own; calling it while a runtime already drives the current thread panics
|
||||
/// with "Cannot start a runtime from within a runtime". The store may also wait
|
||||
/// on an OS prompt, so run it on a plain thread when called from async code.
|
||||
fn off_runtime<R: Send>(f: impl FnOnce() -> R + Send) -> R {
|
||||
if tokio::runtime::Handle::try_current().is_err() {
|
||||
return f();
|
||||
}
|
||||
std::thread::scope(|scope| match scope.spawn(f).join() {
|
||||
Ok(value) => value,
|
||||
Err(panic) => std::panic::resume_unwind(panic),
|
||||
})
|
||||
}
|
||||
|
||||
impl SecretFile {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self::with_legacy(path, |v| serde_json::from_value(v).ok())
|
||||
@@ -115,85 +131,95 @@ impl SecretFile {
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<String> {
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(mut index) if index.keys.contains(key) => match self.vault.get(&service(&index.id), key) {
|
||||
Ok(secret) => Some(secret),
|
||||
Err(StoreError::NoEntry) => {
|
||||
// Removed outside the app: stop listing it.
|
||||
index.keys.remove(key);
|
||||
self.write_logged(&index);
|
||||
None
|
||||
off_runtime(|| {
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(mut index) if index.keys.contains(key) => {
|
||||
match self.vault.get(&service(&index.id), key) {
|
||||
Ok(secret) => Some(secret),
|
||||
Err(StoreError::NoEntry) => {
|
||||
// Removed outside the app: stop listing it.
|
||||
index.keys.remove(key);
|
||||
self.write_logged(&index);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("saved passwords: {key}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("saved passwords: {key}: {e}");
|
||||
None
|
||||
}
|
||||
},
|
||||
Contents::Index(_) => None,
|
||||
Contents::Plaintext(mut old, _) => old.remove(key),
|
||||
}
|
||||
Contents::Index(_) => None,
|
||||
Contents::Plaintext(mut old, _) => old.remove(key),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
|
||||
let _g = LOCK.lock();
|
||||
let mut index = match self.load() {
|
||||
Contents::Index(index) => index,
|
||||
Contents::Plaintext(_, why) => return Err(unavailable(&why)),
|
||||
};
|
||||
let service = service(&index.id);
|
||||
self.vault.set(&service, key, secret).map_err(|e| unavailable(&e.to_string()))?;
|
||||
if index.keys.insert(key.to_owned())
|
||||
&& let Err(e) = self.write(&index)
|
||||
{
|
||||
let _ = self.vault.delete(&service, key);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
off_runtime(|| {
|
||||
let _g = LOCK.lock();
|
||||
let mut index = match self.load() {
|
||||
Contents::Index(index) => index,
|
||||
Contents::Plaintext(_, why) => return Err(unavailable(&why)),
|
||||
};
|
||||
let service = service(&index.id);
|
||||
self.vault.set(&service, key, secret).map_err(|e| unavailable(&e.to_string()))?;
|
||||
if index.keys.insert(key.to_owned())
|
||||
&& let Err(e) = self.write(&index)
|
||||
{
|
||||
let _ = self.vault.delete(&service, key);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) {
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(mut index) => {
|
||||
if !index.keys.contains(key) {
|
||||
return;
|
||||
off_runtime(|| {
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(mut index) => {
|
||||
if !index.keys.contains(key) {
|
||||
return;
|
||||
}
|
||||
match self.vault.delete(&service(&index.id), key) {
|
||||
Ok(()) | Err(StoreError::NoEntry) => {}
|
||||
Err(e) => {
|
||||
// Kept in the index so that the next delete tries again.
|
||||
tracing::warn!("saved passwords: could not forget {key}: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
index.keys.remove(key);
|
||||
self.write_logged(&index);
|
||||
}
|
||||
match self.vault.delete(&service(&index.id), key) {
|
||||
Ok(()) | Err(StoreError::NoEntry) => {}
|
||||
Err(e) => {
|
||||
// Kept in the index so that the next delete tries again.
|
||||
Contents::Plaintext(mut old, _) => {
|
||||
if old.remove(key).is_none() {
|
||||
return;
|
||||
}
|
||||
// A move halfway may have copied it already.
|
||||
if let Some(id) = self.pending_id()
|
||||
&& let Err(e) = self.vault.delete(&service(&id), key)
|
||||
&& !matches!(e, StoreError::NoEntry)
|
||||
{
|
||||
tracing::warn!("saved passwords: could not forget {key}: {e}");
|
||||
return;
|
||||
}
|
||||
self.write_logged(&old);
|
||||
}
|
||||
index.keys.remove(key);
|
||||
self.write_logged(&index);
|
||||
}
|
||||
Contents::Plaintext(mut old, _) => {
|
||||
if old.remove(key).is_none() {
|
||||
return;
|
||||
}
|
||||
// A move halfway may have copied it already.
|
||||
if let Some(id) = self.pending_id()
|
||||
&& let Err(e) = self.vault.delete(&service(&id), key)
|
||||
&& !matches!(e, StoreError::NoEntry)
|
||||
{
|
||||
tracing::warn!("saved passwords: could not forget {key}: {e}");
|
||||
return;
|
||||
}
|
||||
self.write_logged(&old);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Every stored key, sorted (the secrets themselves stay private).
|
||||
pub fn keys(&self) -> Vec<String> {
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(index) => index.keys.into_iter().collect(),
|
||||
Contents::Plaintext(old, _) => old.into_keys().collect(),
|
||||
}
|
||||
off_runtime(|| {
|
||||
let _g = LOCK.lock();
|
||||
match self.load() {
|
||||
Contents::Index(index) => index.keys.into_iter().collect(),
|
||||
Contents::Plaintext(old, _) => old.into_keys().collect(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn load(&self) -> Contents {
|
||||
@@ -558,4 +584,50 @@ mod tests {
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
/// A credential store that behaves like the Linux Secret Service client:
|
||||
/// its blocking API runs a tokio runtime.
|
||||
#[derive(Default)]
|
||||
struct NestedRuntimeVault {
|
||||
entries: Mutex<HashMap<(String, String), String>>,
|
||||
}
|
||||
|
||||
fn nested_runtime() {
|
||||
tokio::runtime::Builder::new_multi_thread().build().expect("a nested runtime").block_on(async {});
|
||||
}
|
||||
|
||||
impl Vault for NestedRuntimeVault {
|
||||
fn available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn get(&self, service: &str, key: &str) -> StoreResult<String> {
|
||||
nested_runtime();
|
||||
self.entries.lock().get(&(service.to_owned(), key.to_owned())).cloned().ok_or(StoreError::NoEntry)
|
||||
}
|
||||
fn set(&self, service: &str, key: &str, secret: &str) -> StoreResult<()> {
|
||||
nested_runtime();
|
||||
self.entries.lock().insert((service.to_owned(), key.to_owned()), secret.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&self, service: &str, key: &str) -> StoreResult<()> {
|
||||
nested_runtime();
|
||||
self.entries.lock().remove(&(service.to_owned(), key.to_owned())).map(|_| ()).ok_or(StoreError::NoEntry)
|
||||
}
|
||||
}
|
||||
|
||||
/// Saving a password from a Tauri command runs on a tokio runtime; the
|
||||
/// Secret Service's blocking client starts a runtime of its own and panics
|
||||
/// when both share a thread ("Cannot start a runtime from within a runtime").
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn credential_store_access_leaves_the_async_runtime_first() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let vault = Arc::new(NestedRuntimeVault::default());
|
||||
let file =
|
||||
SecretFile::with_vault(tmp.path().join("db-passwords.json"), |v| serde_json::from_value(v).ok(), vault);
|
||||
file.set("s1", "hunter2").unwrap();
|
||||
assert_eq!(file.get("s1").as_deref(), Some("hunter2"));
|
||||
assert_eq!(file.keys(), ["s1"]);
|
||||
file.delete("s1");
|
||||
assert_eq!(file.get("s1"), None);
|
||||
}
|
||||
}
|
||||
@@ -642,6 +642,77 @@ mod tests {
|
||||
assert_eq!(reloaded, store.effective());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_settings_keep_their_defaults_json_shape() {
|
||||
// `SettingsStore::set` validates a key path against this JSON: a new field that
|
||||
// serializes away when unset (`skip_serializing_if`) cannot be written.
|
||||
let commit = defaults_json()["vcs"]["commit"].clone();
|
||||
assert_eq!(
|
||||
commit,
|
||||
json!({
|
||||
"nonModal": true,
|
||||
"clearInitialMessage": false,
|
||||
"analyzeCode": true,
|
||||
"checkTodo": true,
|
||||
"reformat": false,
|
||||
"optimizeImports": false,
|
||||
"cleanup": false,
|
||||
"blankLineAfterSubject": true,
|
||||
"limitSubject": true,
|
||||
"subjectLimit": 72,
|
||||
"limitBody": false,
|
||||
"bodyLimit": 72,
|
||||
"showRightMargin": true,
|
||||
"wrapAtMargin": false,
|
||||
"disabledPluginChecks": [],
|
||||
"doubleClickOpensDiff": null,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tri_state_commit_setting_writes_and_clears() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cfg = tmp.path().join("cfg");
|
||||
let store = SettingsStore::load(&cfg, None);
|
||||
assert_eq!(store.effective().vcs.commit.double_click_opens_diff, None);
|
||||
// A layer without the key: the unset state.
|
||||
std::fs::create_dir_all(&cfg).unwrap();
|
||||
std::fs::write(cfg.join("settings.toml"), "[vcs.commit]\nnonModal = false\n").unwrap();
|
||||
assert_eq!(store.reload().vcs.commit.double_click_opens_diff, None);
|
||||
|
||||
for on in [true, false] {
|
||||
let s = store.set(SettingsScope::Global, "vcs.commit.doubleClickOpensDiff", json!(on)).unwrap();
|
||||
assert_eq!(s.vcs.commit.double_click_opens_diff, Some(on));
|
||||
assert!(!store.effective().vcs.commit.non_modal);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(cfg.join("settings.toml")).unwrap(),
|
||||
format!("[vcs.commit]\nnonModal = false\ndoubleClickOpensDiff = {on}\n")
|
||||
);
|
||||
assert_eq!(SettingsStore::load(&cfg, None).effective().vcs.commit.double_click_opens_diff, Some(on));
|
||||
}
|
||||
|
||||
// `null` removes the key from the layer: back to following the interface.
|
||||
let s = store.set(SettingsScope::Global, "vcs.commit.doubleClickOpensDiff", json!(null)).unwrap();
|
||||
assert_eq!(s.vcs.commit.double_click_opens_diff, None);
|
||||
assert_eq!(std::fs::read_to_string(cfg.join("settings.toml")).unwrap(), "[vcs.commit]\nnonModal = false\n");
|
||||
// A layer already lacking the table keeps its other keys when the key is set.
|
||||
let fresh = tmp.path().join("fresh");
|
||||
let store = SettingsStore::load(&fresh, None);
|
||||
let s = store.set(SettingsScope::Global, "vcs.commit.doubleClickOpensDiff", json!(true)).unwrap();
|
||||
assert_eq!(s.vcs.commit.double_click_opens_diff, Some(true));
|
||||
assert!(s.vcs.commit.non_modal);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(fresh.join("settings.toml")).unwrap(),
|
||||
"[vcs.commit]\ndoubleClickOpensDiff = true\n"
|
||||
);
|
||||
// Wrong types are still rejected.
|
||||
assert_eq!(
|
||||
store.set(SettingsScope::Global, "vcs.commit.doubleClickOpensDiff", json!("yes")).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspection_profiles_live_in_their_layers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -16,7 +16,7 @@ Open it from **Tools › Database** or **View › Tool Windows › Database**. T
|
||||
| PostgreSQL | PostgreSQL, Amazon Redshift, CockroachDB, Greenplum, Neon, Supabase, TimescaleDB, YugabyteDB |
|
||||
| MySQL | MySQL, MariaDB, Amazon Aurora MySQL, OceanBase (MySQL mode), SingleStore, StarRocks, TiDB |
|
||||
| SQL Server | Microsoft SQL Server |
|
||||
| Oracle | Oracle (needs Oracle Instant Client, see below) |
|
||||
| Oracle | Oracle 19 or later (built-in thin driver, no client library) |
|
||||
| ClickHouse | ClickHouse (over HTTP) |
|
||||
| SQLite | SQLite |
|
||||
|
||||
@@ -44,7 +44,6 @@ The data source dialog has a name, an optional **Color:** (tints the tree and co
|
||||
- **Read-only**: blocks writes at the session level where the database supports it (PostgreSQL, MySQL, SQLite, Oracle, ClickHouse) and disables grid editing.
|
||||
- **Transaction control:** **Auto** or **Manual** (ClickHouse is always Auto).
|
||||
- **Default schema:** (PostgreSQL `search_path`) or **Current schema:** (Oracle).
|
||||
- **Instant Client:** (Oracle).
|
||||
- **Time zone:**, a keep-alive query interval, and **Auto-disconnect after** a number of idle seconds.
|
||||
- **Single database mode** (PostgreSQL): show only the configured database.
|
||||
- **Startup script:** runs on every new connection.
|
||||
@@ -141,28 +140,20 @@ The export button offers **Export Data…** and quick copies: **Copy as CSV**, *
|
||||
|
||||
**Import Data from File…** loads CSV, TSV or TXT into a table: the separator is detected, the first row can be a header matched to columns by name, and empty values can become `NULL`. The import runs in one transaction. It is not available for ClickHouse.
|
||||
|
||||
## Oracle Instant Client
|
||||
## Oracle
|
||||
|
||||
Oracle support uses the Oracle client libraries, which OpenIDEA does not bundle.
|
||||
Oracle connections use a built-in pure-Rust thin driver, so no Oracle client library or `ORACLE_HOME` is needed. It
|
||||
supports Oracle Database 19 or later.
|
||||
|
||||
<Steps>
|
||||
<Step title="Install Instant Client">
|
||||
Download the Basic (or Basic Light) package of Oracle Instant Client 19 or later for your OS and architecture, and
|
||||
unpack it in a stable place.
|
||||
</Step>
|
||||
<Step title="Point the data source at it">
|
||||
In the data source's **Options** tab, set **Instant Client:** to that directory. If you leave it empty,
|
||||
`ORACLE_HOME` or the system library path is used.
|
||||
</Step>
|
||||
<Step title="Connect">
|
||||
On **General**, set host, port (1521) and the service name (for example `ORCLPDB1`) in **Database:**. A TNS
|
||||
descriptor or alias in **Database:** is passed through as is. URLs such as
|
||||
`oracle://[email protected]:1521/ORCLPDB1` and `jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1` also work.
|
||||
</Step>
|
||||
</Steps>
|
||||
On **General**, set host, port (1521) and the service name (for example `ORCLPDB1`) in **Database:**. A full TNS
|
||||
descriptor, or a TNS alias resolved from `tnsnames.ora` (`TNS_ADMIN` or `$ORACLE_HOME/network/admin`), also works there.
|
||||
URLs such as `oracle://[email protected]:1521/ORCLPDB1` and `jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1` are
|
||||
accepted too.
|
||||
|
||||
If the libraries can't be loaded, Test Connection fails with `DPI-1047` and a hint to set the Instant Client directory. On macOS, remove the quarantine flag if the libraries are blocked: `xattr -dr com.apple.quarantine /path/to/instantclient`.
|
||||
If login fails with `ORA-01017` although the password is right, the account may only have old 10G password verifiers;
|
||||
setting the same password again (`ALTER USER … IDENTIFIED BY …`) creates the newer ones.
|
||||
|
||||
The tree shows packages (their DDL is the specification and body) next to tables, views, routines and sequences.
|
||||
Oracle schemas are database users; system accounts appear only with **Show internal system schemas**.
|
||||
|
||||
## Not supported
|
||||
|
||||
@@ -16,7 +16,7 @@ description: "连接 SQL 数据库、浏览架构、在查询控制台中执行
|
||||
| PostgreSQL | PostgreSQL、Amazon Redshift、CockroachDB、Greenplum、Neon、Supabase、TimescaleDB、YugabyteDB |
|
||||
| MySQL | MySQL、MariaDB、Amazon Aurora MySQL、OceanBase (MySQL mode)、SingleStore、StarRocks、TiDB |
|
||||
| SQL Server | Microsoft SQL Server |
|
||||
| Oracle | Oracle(需要 Oracle Instant Client,见下文) |
|
||||
| Oracle | Oracle 19 及以上(内置 thin 驱动,无需客户端库) |
|
||||
| ClickHouse | ClickHouse(通过 HTTP) |
|
||||
| SQLite | SQLite |
|
||||
|
||||
@@ -44,7 +44,6 @@ description: "连接 SQL 数据库、浏览架构、在查询控制台中执行
|
||||
- **只读**:在数据库支持时于会话级别禁止写入(PostgreSQL、MySQL、SQLite、Oracle、ClickHouse),同时禁用表格编辑。
|
||||
- **事务控制:** **自动** 或 **手动**(ClickHouse 只能自动)。
|
||||
- **默认架构:**(PostgreSQL 的 `search_path`)或 **当前模式:**(Oracle)。
|
||||
- **Instant Client:**(Oracle)。
|
||||
- **时区:**、保持活动查询的间隔,以及空闲多少秒后 **自动断开连接**。
|
||||
- **单数据库模式**(PostgreSQL):只显示配置的数据库。
|
||||
- **启动脚本:**:每次新建连接时执行。
|
||||
@@ -141,25 +140,15 @@ SQL 补全基于已连接的架构:`FROM`/`JOIN` 后补全表(并根据外
|
||||
|
||||
**从文件导入数据…** 把 CSV、TSV 或 TXT 导入到表中:自动识别分隔符,第一行可作为标题并按名称匹配列,空值可以视为 `NULL`。导入在一个事务中完成。ClickHouse 不支持导入。
|
||||
|
||||
## Oracle Instant Client
|
||||
## Oracle
|
||||
|
||||
Oracle 支持依赖 Oracle 客户端库,OpenIDEA 不自带。
|
||||
Oracle 连接使用内置的纯 Rust thin 驱动,不需要 Oracle 客户端库或 `ORACLE_HOME`。支持 Oracle Database 19 及以上版本。
|
||||
|
||||
<Steps>
|
||||
<Step title="安装 Instant Client">
|
||||
下载与操作系统和架构匹配的 Oracle Instant Client 19 或更高版本的 Basic(或 Basic Light)包,解压到一个固定位置。
|
||||
</Step>
|
||||
<Step title="在数据源中指定目录">
|
||||
在数据源的 **选项** 标签页中,把 **Instant Client:** 设为该目录。留空时使用 `ORACLE_HOME` 或系统库路径。
|
||||
</Step>
|
||||
<Step title="连接">
|
||||
在 **常规** 中填写主机、端口(1521),并在 **数据库:** 中填写服务名(例如 `ORCLPDB1`)。在 **数据库:** 中填写 TNS
|
||||
描述符或别名时会原样传给客户端。也可以使用 `oracle://[email protected]:1521/ORCLPDB1` 或
|
||||
`jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1` 这样的 URL。
|
||||
</Step>
|
||||
</Steps>
|
||||
在 **常规** 中填写主机、端口(1521),并在 **数据库:** 中填写服务名(例如 `ORCLPDB1`)。也可以在这里填写完整的 TNS 描述符,或从 `tnsnames.ora`(`TNS_ADMIN` 或 `$ORACLE_HOME/network/admin`)解析的 TNS 别名。也支持 `oracle://[email protected]:1521/ORCLPDB1`、`jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1` 这样的 URL。
|
||||
|
||||
如果客户端库加载失败,测试连接会报 `DPI-1047` 并提示设置 Instant Client 目录。在 macOS 上如果库文件被系统拦截,可以去掉隔离标记:`xattr -dr com.apple.quarantine /path/to/instantclient`。
|
||||
如果密码正确但登录报 `ORA-01017`,可能是该账号只有旧的 10G 密码校验值;把密码重新设置为同一个值(`ALTER USER … IDENTIFIED BY …`)即可生成新的校验值。
|
||||
|
||||
树中在表、视图、例程和序列旁边显示包(其 DDL 为包规范和包体)。
|
||||
|
||||
Oracle 的架构就是数据库用户;系统账户只在勾选 **显示内部系统架构** 后显示。
|
||||
|
||||
|
||||
@@ -66,6 +66,79 @@ async function openHistory(page: Page) {
|
||||
return stream;
|
||||
}
|
||||
|
||||
for (const placement of ["editor", "toolWindow"] as const) {
|
||||
test(`lets small upward wheel steps leave the end while replies stream in the ${placement}`, async ({ page }) => {
|
||||
const stream = await openHistory(page);
|
||||
await page.evaluate(
|
||||
async ({ id, placement }) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const state = store.useAgents.getState();
|
||||
store.useAgents.setState({
|
||||
tasks: new Map(state.tasks).set(id, { ...state.tasks.get(id)!, status: "running" }),
|
||||
});
|
||||
if (placement === "toolWindow") store.dockTask(id);
|
||||
},
|
||||
{ id: task.id, placement },
|
||||
);
|
||||
// The working indicator sits in the tray below the conversation.
|
||||
await expect(
|
||||
page.getByRole("status").getByText("Working…", { exact: true }).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(stream.locator(":scope > *")).toHaveCount(80 * 3);
|
||||
await expect.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThan(2);
|
||||
const timer = await page.evaluate(async (id) => {
|
||||
const path = "/src/features/agents/store.ts";
|
||||
const store = await import(path);
|
||||
const el = [...document.querySelectorAll<HTMLElement>("[class*='_stream_']")].find((el) => el.offsetHeight)!;
|
||||
el.addEventListener(
|
||||
"wheel",
|
||||
() => {
|
||||
el.dataset.streamWheelStart = String(el.scrollTop);
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
);
|
||||
let chunks = 0;
|
||||
return window.setInterval(() => {
|
||||
const state = store.useAgents.getState();
|
||||
const entries = state.entries.get(id)!.slice();
|
||||
const last = entries.at(-1)!;
|
||||
if (last.type !== "agent") throw new Error("Expected a streaming reply");
|
||||
entries[entries.length - 1] = { ...last, text: `${last.text}\n\nStreaming chunk ${++chunks}` };
|
||||
store.useAgents.setState({ entries: new Map(state.entries).set(id, entries) });
|
||||
el.dataset.streamChunks = String(chunks);
|
||||
}, 40);
|
||||
}, task.id);
|
||||
const chunks = async () => Number(await stream.getAttribute("data-stream-chunks"));
|
||||
try {
|
||||
await stream.hover();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const previous = await chunks();
|
||||
await page.mouse.wheel(0, -12);
|
||||
await expect.poll(chunks).toBeGreaterThan(previous);
|
||||
}
|
||||
const top = await stream.evaluate((el) => el.scrollTop);
|
||||
expect(Number(await stream.getAttribute("data-stream-wheel-start")) - top).toBeGreaterThan(80);
|
||||
const previous = await chunks();
|
||||
await expect.poll(chunks).toBeGreaterThan(previous + 3);
|
||||
expect(Math.abs((await stream.evaluate((el) => el.scrollTop)) - top)).toBeLessThanOrEqual(1);
|
||||
const jump = page.getByRole("button", { name: "Jump to Latest" });
|
||||
await expect(jump).toBeVisible();
|
||||
await jump.click();
|
||||
await expect
|
||||
.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
|
||||
.toBeLessThan(2);
|
||||
const resumed = await chunks();
|
||||
await expect.poll(chunks).toBeGreaterThan(resumed + 3);
|
||||
await expect
|
||||
.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop))
|
||||
.toBeLessThan(2);
|
||||
} finally {
|
||||
await page.evaluate((timer) => window.clearInterval(timer), timer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const selected of [false, true]) {
|
||||
test(`keeps long-chat scroll geometry stable while jumping through history (selected: ${selected})`, async ({
|
||||
page,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Double-click in the changes tree: the diff or the source file, from the Show on Double-Click
|
||||
* menu, the modal Commit dialog and the non-modal Commit window defaults. Git is stubbed at the IPC layer.
|
||||
*/
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, openFromTree, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
const FILE = "build.gradle.kts";
|
||||
/** Opened before stubbing so the file under test has no editor tab until a double-click. */
|
||||
const PRELOADED = "settings.gradle.kts";
|
||||
|
||||
/** A repository with one modified tracked file; the diff's HEAD side is a stubbed text. */
|
||||
async function stubRepository(page: Page) {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async (root) => {
|
||||
const changes = [
|
||||
{
|
||||
path: `${root}/build.gradle.kts`,
|
||||
relPath: "build.gradle.kts",
|
||||
staged: null,
|
||||
unstaged: "modified",
|
||||
renamedFrom: null,
|
||||
},
|
||||
];
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
|
||||
h.vcs_branch_info = () => ({
|
||||
head: "main",
|
||||
headCommit: "1111111",
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_status = () => changes;
|
||||
h.vcs_file_at = () => "plugins {}\n";
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
|
||||
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
|
||||
await refreshNow();
|
||||
}, ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings writes land in the project store the way the backend answers `settings_set`: the dotted
|
||||
* key is set, `null` removes it, and the store takes the returned settings.
|
||||
*/
|
||||
async function stubSettingsWrites(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
const storeModule = "/src/platform/project.ts";
|
||||
const { useProject } = await import(/* @vite-ignore */ storeModule);
|
||||
window.__ipc.handlers.settings_set = (args) => {
|
||||
const { key, value } = args as { key: string; value: string };
|
||||
const next = structuredClone(useProject.getState().settings ?? {}) as Record<string, unknown>;
|
||||
const path = key.split(".");
|
||||
let node = next;
|
||||
for (const seg of path.slice(0, -1)) {
|
||||
node[seg] ??= {};
|
||||
node = node[seg] as Record<string, unknown>;
|
||||
}
|
||||
const last = path[path.length - 1]!;
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (parsed === null) delete node[last];
|
||||
else node[last] = parsed;
|
||||
useProject.setState({ settings: next as never });
|
||||
return next;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Sets a commit setting in the store directly, before the window that reads it opens. */
|
||||
async function setCommitSetting(page: Page, key: "nonModal" | "doubleClickOpensDiff", value: boolean | null) {
|
||||
await page.evaluate(
|
||||
async ({ key, value }) => {
|
||||
const storeModule = "/src/platform/project.ts";
|
||||
const { useProject } = await import(/* @vite-ignore */ storeModule);
|
||||
const next = structuredClone(useProject.getState().settings ?? {}) as Record<string, unknown>;
|
||||
const vcs = (next.vcs ??= {}) as Record<string, Record<string, unknown>>;
|
||||
const commit = (vcs.commit ??= {});
|
||||
if (value === null) delete commit[key];
|
||||
else commit[key] = value;
|
||||
useProject.setState({ settings: next as never });
|
||||
},
|
||||
{ key, value },
|
||||
);
|
||||
}
|
||||
|
||||
const diffTab = (page: Page) => page.getByRole("tab", { name: `${FILE} (HEAD ↔ Your version)` });
|
||||
const sourceTab = (page: Page) =>
|
||||
page.getByRole("tablist", { name: "Editor tabs" }).getByRole("tab", { name: FILE, exact: true });
|
||||
|
||||
async function openCommitWindow(page: Page) {
|
||||
await openFromTree(page, PRELOADED);
|
||||
await stubRepository(page);
|
||||
await stubSettingsWrites(page);
|
||||
await page.keyboard.press("Meta+k");
|
||||
return page.getByRole("region", { name: "Commit", exact: true });
|
||||
}
|
||||
|
||||
async function doubleClickChange(panel: Locator) {
|
||||
await panel.getByRole("tree", { name: "Changes" }).getByText(FILE, { exact: true }).dblclick();
|
||||
}
|
||||
|
||||
async function pickDoubleClickMode(page: Page, panel: Locator, mode: string) {
|
||||
await panel.getByRole("button", { name: "View Options" }).click();
|
||||
await page.getByRole("menuitem", { name: "Show on Double-Click" }).hover();
|
||||
const item = page.getByRole("menuitemcheckbox", { name: mode, exact: true });
|
||||
await item.click();
|
||||
await expect(item).toBeHidden();
|
||||
}
|
||||
|
||||
test("non-modal Commit window: double-click opens the diff by default", async ({ workbench: page }) => {
|
||||
const panel = await openCommitWindow(page);
|
||||
await expect(panel.getByRole("textbox", { name: "Commit message" })).toBeFocused();
|
||||
await doubleClickChange(panel);
|
||||
await expect(diffTab(page)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(sourceTab(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Show on Double-Click → Source opens the file itself, and Follow the Interface restores the diff", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
const panel = await openCommitWindow(page);
|
||||
await pickDoubleClickMode(page, panel, "Source");
|
||||
await doubleClickChange(panel);
|
||||
await expect(sourceTab(page)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(diffTab(page)).toHaveCount(0);
|
||||
|
||||
await pickDoubleClickMode(page, panel, "Follow the Interface");
|
||||
await doubleClickChange(panel);
|
||||
await expect(diffTab(page)).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
test("modal Commit dialog: double-click opens the diff in a front window, not an editor tab", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await openFromTree(page, PRELOADED);
|
||||
await stubRepository(page);
|
||||
await stubSettingsWrites(page);
|
||||
await setCommitSetting(page, "nonModal", false);
|
||||
await page.keyboard.press("Meta+k");
|
||||
const dialog = page.getByRole("dialog", { name: "Commit Changes" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await doubleClickChange(dialog);
|
||||
await expect(page.getByRole("dialog", { name: /\(HEAD ↔ Your version\)/ })).toBeVisible();
|
||||
await expect(sourceTab(page)).toHaveCount(0);
|
||||
await expect(diffTab(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Dragging an unversioned file onto a changelist in the Commit tool window: it is
|
||||
* added to Git (IDEA's Unversioned Files behaviour) and joins that list.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
|
||||
/** A repository with a modified tracked file and an untracked one; staging tracks the latter. */
|
||||
async function stubRepository(page: Page) {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async (root) => {
|
||||
let staged = false;
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
|
||||
h.vcs_branch_info = () => ({
|
||||
head: "main",
|
||||
headCommit: "1111111",
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_status = () => [
|
||||
{
|
||||
path: `${root}/build.gradle.kts`,
|
||||
relPath: "build.gradle.kts",
|
||||
staged: null,
|
||||
unstaged: "modified",
|
||||
renamedFrom: null,
|
||||
},
|
||||
{
|
||||
path: `${root}/notes.txt`,
|
||||
relPath: "notes.txt",
|
||||
staged: staged ? "added" : null,
|
||||
unstaged: staged ? null : "untracked",
|
||||
renamedFrom: null,
|
||||
},
|
||||
];
|
||||
h.vcs_stage = () => {
|
||||
staged = true;
|
||||
return null;
|
||||
};
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
|
||||
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
|
||||
await refreshNow();
|
||||
}, ROOT);
|
||||
}
|
||||
|
||||
test("dragging an unversioned file onto a changelist adds it to Git", async ({ workbench: page }) => {
|
||||
await openFromTree(page, "build.gradle.kts");
|
||||
await stubRepository(page);
|
||||
await page.keyboard.press("Meta+k");
|
||||
const panel = page.getByRole("region", { name: "Commit", exact: true });
|
||||
const changes = panel.getByRole("tree", { name: "Changes" });
|
||||
|
||||
// Unversioned Files starts collapsed.
|
||||
await changes
|
||||
.getByRole("treeitem", { name: /Unversioned Files/ })
|
||||
.locator("[data-tree-toggle]")
|
||||
.click();
|
||||
const source = changes.getByText("notes.txt", { exact: true });
|
||||
await expect(source).toBeVisible();
|
||||
const target = changes.getByText("Changes", { exact: true }).first();
|
||||
const from = (await source.boundingBox())!;
|
||||
const to = (await target.boundingBox())!;
|
||||
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
// The drop adds the file to Git instead of only moving a changelist assignment.
|
||||
await expect.poll(async () => (await ipcCalls(page, "vcs_stage")).length).toBe(1);
|
||||
expect((await ipcCalls(page, "vcs_stage"))[0]).toMatchObject({ paths: [`${ROOT}/notes.txt`] });
|
||||
|
||||
// It is versioned now: the Unversioned Files group is gone and the file stays under Changes.
|
||||
await expect(changes.getByRole("treeitem", { name: /Unversioned Files/ })).toHaveCount(0);
|
||||
await expect(changes.getByRole("treeitem", { name: /notes\.txt/ })).toBeVisible();
|
||||
});
|
||||
Loaded 100 of 321 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача