mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 02:39:49 -10:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44d871caee | ||
|
|
89f96edc07 | ||
|
|
fd79a5c55e | ||
|
|
bb8c9fa0b4 | ||
|
|
039ec09122 | ||
|
|
d02aae2280 | ||
|
|
e44a3af310 | ||
|
|
ff2eb95b01 | ||
|
|
b2b881e075 | ||
|
|
90d32f7883 | ||
|
|
f72e3a9a8a | ||
|
|
1ae8572021 | ||
|
|
023c3f36c2 | ||
|
|
88a1e4161e | ||
|
|
0b1f723b9a | ||
|
|
e2f22de824 | ||
|
|
24d3173a55 | ||
|
|
799391b301 | ||
|
|
8ddf979cfe | ||
|
|
f3f9fdb1ad | ||
|
|
687471c19e | ||
|
|
a46d873f6c | ||
|
|
5d3432a000 | ||
|
|
16ebb6fde8 | ||
|
|
0e673c206a | ||
|
|
2680ba8463 | ||
|
|
660587a6fe |
No files matched your search
Generated
+29
-20
@@ -1522,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4746,6 +4746,7 @@ dependencies = [
|
||||
"ide-run",
|
||||
"ide-vfs",
|
||||
"ignore",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -5581,9 +5582,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.37.0"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
||||
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@@ -6363,6 +6364,15 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-system-configuration"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.2.2"
|
||||
@@ -8629,8 +8639,7 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
@@ -8642,8 +8651,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -8665,7 +8673,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2 0.11.1",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
@@ -8678,8 +8686,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -8691,8 +8698,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dotenvy",
|
||||
@@ -8703,7 +8709,7 @@ dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2 0.11.1",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
@@ -8716,8 +8722,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-mysql"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"byteorder",
|
||||
@@ -8742,8 +8747,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-postgres"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
@@ -8777,8 +8781,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"flume",
|
||||
@@ -9543,7 +9546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -11309,6 +11312,12 @@ name = "whoami"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
"objc2-system-configuration",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "win_uds"
|
||||
|
||||
+2
-1
@@ -103,7 +103,8 @@ portable-pty = "0.9"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls-platform-verifier = "0.7"
|
||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
# 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 }
|
||||
bb8 = "0.9"
|
||||
|
||||
@@ -69,7 +69,7 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
### Contributors
|
||||
|
||||
<a href="https://github.com/l0ng-ai/openidea/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=100" alt="OpenIDEA contributors">
|
||||
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=500" alt="OpenIDEA contributors">
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
@@ -169,8 +169,10 @@ impl Shared {
|
||||
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
|
||||
let options = config::options_of(&json!({ "configOptions": options }));
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
// Notifies subscribers itself when the discovery
|
||||
// changed the cached config; unchanged results
|
||||
// stay silent.
|
||||
shared.remember_discovered(&spec.id, &options, &commands);
|
||||
shared.emit(AgentEvent::Providers);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -253,6 +255,13 @@ impl Shared {
|
||||
{
|
||||
tracing::warn!("saving agent settings: {e}");
|
||||
}
|
||||
// The cached provider config actually changed (a session report, discovery,
|
||||
// or a turn update): tell subscribers to re-list, so a changed model list
|
||||
// reaches the composer without waiting for the next task (#1490). Unchanged
|
||||
// reports stay silent, so routine session traffic emits nothing. The cache
|
||||
// lock is released first; the sink never runs under it.
|
||||
drop(cache);
|
||||
self.emit(AgentEvent::Providers);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2146,6 +2146,49 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
|
||||
assert!(probe.error.is_none());
|
||||
}
|
||||
|
||||
/// A session report that changes the cached provider config notifies subscribers,
|
||||
/// so the composer re-lists models without waiting for the next task (#1490).
|
||||
/// Reports that change nothing stay silent.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn remembered_config_changes_notify_subscribers_once() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = events.clone();
|
||||
mgr.subscribe(Arc::new(move |e| sink.lock().push(e)));
|
||||
events.lock().clear();
|
||||
|
||||
let reported = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
|
||||
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
|
||||
] }));
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
|
||||
assert!(
|
||||
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
|
||||
"a first report changing the cache notifies subscribers"
|
||||
);
|
||||
events.lock().clear();
|
||||
|
||||
// The same report again: the cache is unchanged, so subscribers hear nothing.
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
|
||||
assert!(events.lock().is_empty(), "an unchanged report must stay silent");
|
||||
|
||||
// A changed model list notifies again, and providers() serves the new list.
|
||||
let updated = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
|
||||
"options": [{ "value": "default" }, { "value": "custom-opus" }, { "value": "custom-sonnet" }] },
|
||||
] }));
|
||||
mgr.shared.remember("claude", Some(&updated), None, Some("default"));
|
||||
assert!(
|
||||
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
|
||||
"a changed model list notifies subscribers"
|
||||
);
|
||||
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
|
||||
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
|
||||
assert_eq!(values, ["default", "custom-opus", "custom-sonnet"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
|
||||
let new = || NewAgentTask {
|
||||
|
||||
@@ -148,6 +148,12 @@ pub enum IdeEvent {
|
||||
SettingsChanged {
|
||||
key: String,
|
||||
},
|
||||
UpdateStatusChanged {
|
||||
status: crate::updates::UpdateStatus,
|
||||
},
|
||||
UpdateAvailable {
|
||||
update: crate::updates::UpdateInfo,
|
||||
},
|
||||
/// The project's source / test / resource / excluded roots changed (build files
|
||||
/// changed or folders were marked); the UI re-reads them with `project_roots`.
|
||||
ProjectRootsChanged {
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod settings;
|
||||
pub mod task;
|
||||
pub mod text;
|
||||
pub mod tool_path;
|
||||
pub mod updates;
|
||||
pub mod version_managers;
|
||||
pub mod workflow_settings;
|
||||
pub mod wsl;
|
||||
|
||||
@@ -104,6 +104,9 @@ fn signal_group(child: &Child, signal: libc::c_int) {
|
||||
#[cfg(windows)]
|
||||
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[cfg(windows)]
|
||||
pub use windows_tree::ProcessTree;
|
||||
|
||||
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
|
||||
/// started is gone: termination is asynchronous, and until a process is gone the files it
|
||||
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
|
||||
@@ -136,13 +139,48 @@ mod windows_tree {
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
|
||||
TerminateProcess, WaitForSingleObject,
|
||||
};
|
||||
|
||||
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
|
||||
pub struct ProcessTree {
|
||||
processes: Vec<OwnedHandle>,
|
||||
}
|
||||
|
||||
impl ProcessTree {
|
||||
pub fn capture(pid: u32) -> Self {
|
||||
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
|
||||
let children = descendants_with(pid, PROCESS_TERMINATE);
|
||||
Self { processes: std::iter::once(root).chain(children).collect() }
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.processes.iter().any(|process| {
|
||||
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
|
||||
unsafe {
|
||||
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Requests termination children first; poll `is_running` until termination completes.
|
||||
pub fn terminate(&self) {
|
||||
for process in self.processes.iter().rev() {
|
||||
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
|
||||
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every process `root` started, directly or not, opened so it can be waited for. A
|
||||
/// parent's id is reused once it is gone, so a process counts only if it started after
|
||||
/// its parent.
|
||||
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
|
||||
descendants_with(root, 0)
|
||||
}
|
||||
|
||||
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
|
||||
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
|
||||
let processes = processes();
|
||||
let mut seen = HashSet::from([root]);
|
||||
@@ -153,7 +191,7 @@ mod windows_tree {
|
||||
if ppid != parent || !seen.insert(pid) {
|
||||
continue;
|
||||
}
|
||||
let Some(handle) = open(pid) else { continue };
|
||||
let Some(handle) = open_with(pid, access) else { continue };
|
||||
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
|
||||
parents.push((pid, at));
|
||||
found.push(handle);
|
||||
@@ -193,8 +231,12 @@ mod windows_tree {
|
||||
}
|
||||
|
||||
fn open(pid: u32) -> Option<OwnedHandle> {
|
||||
open_with(pid, 0)
|
||||
}
|
||||
|
||||
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
|
||||
// SAFETY: as above.
|
||||
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
|
||||
}
|
||||
|
||||
@@ -292,6 +292,7 @@ pub struct GeneralSettings {
|
||||
pub project_name: String,
|
||||
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
|
||||
pub update_source: String,
|
||||
pub auto_check_updates: bool,
|
||||
}
|
||||
|
||||
impl Default for GeneralSettings {
|
||||
@@ -307,6 +308,7 @@ impl Default for GeneralSettings {
|
||||
power_save_mode: false,
|
||||
project_name: String::new(),
|
||||
update_source: "auto".into(),
|
||||
auto_check_updates: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -896,6 +898,8 @@ pub struct JavaSettings {
|
||||
/// Extra JVM options for the Java language server (jdtls), space-separated
|
||||
/// (VS Code's `java.jdt.ls.vmargs`).
|
||||
pub jdtls_vm_args: String,
|
||||
/// Share one JDT LS across builds in an ordinary project window; other windows remain separate.
|
||||
pub jdtls_shared: bool,
|
||||
/// Build Tools › Maven.
|
||||
pub maven: MavenSettings,
|
||||
/// Build Tools › Gradle.
|
||||
@@ -1477,6 +1481,26 @@ mod maven_linkage_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod java_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_jdtls_defaults_off_and_round_trips() {
|
||||
for old in [r#"{}"#, r#"{"jdk":"/jdk"}"#] {
|
||||
let java: JavaSettings = serde_json::from_str(old).unwrap();
|
||||
assert!(!java.jdtls_shared);
|
||||
}
|
||||
for shared in [false, true] {
|
||||
let java = JavaSettings { jdtls_shared: shared, ..JavaSettings::default() };
|
||||
let json = serde_json::to_value(&java).unwrap();
|
||||
assert_eq!(json["jdtlsShared"], serde_json::json!(shared));
|
||||
assert_eq!(serde_json::from_value::<JavaSettings>(json).unwrap(), java);
|
||||
}
|
||||
assert!(serde_json::from_str::<JavaSettings>(r#"{"jdtlsShared":"true"}"#).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashSet;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UpdateSource {
|
||||
#[default]
|
||||
Github,
|
||||
Cnb,
|
||||
}
|
||||
|
||||
impl UpdateSource {
|
||||
pub fn resolve(preference: &str, locale: &str, system_locale: &str) -> Self {
|
||||
match preference {
|
||||
"github" => return Self::Github,
|
||||
"cnb" => return Self::Cnb,
|
||||
_ => {}
|
||||
}
|
||||
let locale = locale.trim();
|
||||
let locale = if matches!(locale, "" | "auto" | "system") { system_locale } else { locale };
|
||||
if locale.split(['-', '_']).next().is_some_and(|base| base.eq_ignore_ascii_case("zh")) {
|
||||
Self::Cnb
|
||||
} else {
|
||||
Self::Github
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateInfo {
|
||||
pub version: String,
|
||||
pub current_version: String,
|
||||
pub notes: Option<String>,
|
||||
pub date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateCheck {
|
||||
pub enabled: bool,
|
||||
pub update: Option<UpdateInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateStatus {
|
||||
/// Unix milliseconds of the last successful check in this process.
|
||||
pub last_checked: Option<f64>,
|
||||
pub checking: bool,
|
||||
}
|
||||
|
||||
/// One schedule and announcement history for the desktop process, independent of its windows.
|
||||
#[derive(Default)]
|
||||
pub struct UpdatePolicy {
|
||||
attempted: Option<Instant>,
|
||||
pub status: UpdateStatus,
|
||||
announced: HashSet<String>,
|
||||
}
|
||||
|
||||
impl UpdatePolicy {
|
||||
pub fn due_in(&self, now: Instant) -> Duration {
|
||||
self.attempted.map_or(Duration::ZERO, |at| CHECK_INTERVAL.saturating_sub(now.saturating_duration_since(at)))
|
||||
}
|
||||
|
||||
pub fn begin(&mut self, now: Instant) {
|
||||
self.attempted = Some(now);
|
||||
self.status.checking = true;
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, checked_at: Option<f64>) {
|
||||
self.status.checking = false;
|
||||
if let Some(at) = checked_at {
|
||||
self.status.last_checked = Some(at);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn announced(&self, version: &str) -> bool {
|
||||
self.announced.contains(version)
|
||||
}
|
||||
|
||||
/// Manual checks always show their result, but also count as an announcement.
|
||||
pub fn announce(&mut self, version: &str) -> bool {
|
||||
self.announced.insert(version.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn automatic_and_manual_checks_resolve_the_same_preferred_source() {
|
||||
for (preference, locale, system, expected) in [
|
||||
("auto", "zh-CN", "en-US", UpdateSource::Cnb),
|
||||
("auto", " ZH_CN ", "en-US", UpdateSource::Cnb),
|
||||
("auto", "system", "zh-CN", UpdateSource::Cnb),
|
||||
("auto", "auto", "zh-TW", UpdateSource::Cnb),
|
||||
("auto", "", "zh", UpdateSource::Cnb),
|
||||
("auto", "en", "zh-CN", UpdateSource::Github),
|
||||
("github", "zh", "zh-CN", UpdateSource::Github),
|
||||
("cnb", "en", "en-US", UpdateSource::Cnb),
|
||||
] {
|
||||
assert_eq!(UpdateSource::resolve(preference, locale, system), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_at_startup_and_every_four_hours_even_after_failure() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
let now = Instant::now();
|
||||
assert_eq!(policy.due_in(now), Duration::ZERO);
|
||||
policy.begin(now);
|
||||
assert!(policy.status.checking);
|
||||
policy.finish(None);
|
||||
assert_eq!(policy.status.last_checked, None);
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL - Duration::from_secs(1)), Duration::from_secs(1));
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::ZERO);
|
||||
policy.begin(now + CHECK_INTERVAL);
|
||||
policy.finish(Some(1234.0));
|
||||
assert_eq!(policy.status.last_checked, Some(1234.0));
|
||||
assert!(!policy.status.checking);
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_checks_reset_the_schedule_without_losing_last_success_on_failure() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
let now = Instant::now();
|
||||
policy.begin(now);
|
||||
policy.finish(Some(1000.0));
|
||||
policy.begin(now + Duration::from_secs(60));
|
||||
policy.finish(None);
|
||||
assert_eq!(policy.status.last_checked, Some(1000.0));
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announcements_are_per_version_across_windows_sources_and_manual_checks() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
assert!(policy.announce("0.1.40"));
|
||||
assert!(!policy.announce("0.1.40"));
|
||||
assert!(policy.announce("0.1.41"));
|
||||
assert!(!policy.announce("0.1.40"));
|
||||
assert!(UpdatePolicy::default().announce("0.1.40"));
|
||||
}
|
||||
}
|
||||
@@ -1130,6 +1130,9 @@ impl DebugManager {
|
||||
|
||||
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
|
||||
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
|
||||
if terminate {
|
||||
crate::session::remember_debuggee(s).await;
|
||||
}
|
||||
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
|
||||
// ends that DAP session, so every line the program writes while shutting down is lost.
|
||||
// Ask the program to exit ourselves and keep the adapter attached until it has drained
|
||||
|
||||
@@ -59,6 +59,8 @@ pub(crate) struct Session {
|
||||
/// adapter may report the launcher shell, whose child is the program).
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
|
||||
#[cfg(windows)]
|
||||
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
|
||||
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
|
||||
pub pending_restore: Mutex<PendingRestore>,
|
||||
/// Adapter breakpoint id → (file, index in the stored list).
|
||||
@@ -111,6 +113,8 @@ impl Session {
|
||||
stop_force: AtomicBool::new(false),
|
||||
debuggee_pid: AtomicI64::new(0),
|
||||
stop_pids: parking_lot::Mutex::new(Vec::new()),
|
||||
#[cfg(windows)]
|
||||
stop_tree: Mutex::new(None),
|
||||
pending_restore: Mutex::default(),
|
||||
breakpoint_ids: Mutex::new(HashMap::new()),
|
||||
resolved_paths: Mutex::new(HashMap::new()),
|
||||
@@ -298,6 +302,21 @@ impl Session {
|
||||
/// How long Stop waits for a launched program to exit before reporting the session ended.
|
||||
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
|
||||
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
|
||||
return;
|
||||
}
|
||||
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
|
||||
s.stop_tree.lock().get_or_insert(tree);
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = s;
|
||||
}
|
||||
|
||||
/// The program and everything it started: an adapter may report the launcher shell, whose
|
||||
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
|
||||
#[cfg(unix)]
|
||||
@@ -349,12 +368,10 @@ pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut cmd = std::process::Command::new("taskkill");
|
||||
cmd.args(["/PID", &pid.to_string(), "/T"]);
|
||||
if force {
|
||||
cmd.arg("/F");
|
||||
// Console programs have no external graceful signal; the adapter gets its chance first.
|
||||
if force && let Some(tree) = s.stop_tree.lock().as_ref() {
|
||||
tree.terminate();
|
||||
}
|
||||
let _ = cmd.status();
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = (pid, force);
|
||||
@@ -391,7 +408,18 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
{
|
||||
remember_debuggee(s).await;
|
||||
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
|
||||
while s.stop_tree.lock().as_ref().is_some_and(|tree| tree.is_running()) {
|
||||
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
|
||||
signal_debuggee(s, true);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = pid;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,93 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
type Log = Arc<Mutex<Vec<Value>>>;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[ignore = "child process for Windows stop tests"]
|
||||
fn windows_debuggee_process() {
|
||||
if std::env::var_os("OPENIDEA_TEST_DEBUGGEE").is_none() {
|
||||
return;
|
||||
}
|
||||
if std::env::var("OPENIDEA_TEST_DEBUGGEE").unwrap() == "wrapper" {
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
let mut child = ide_core::process::prepare(&mut command)
|
||||
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
|
||||
.env("OPENIDEA_TEST_DEBUGGEE", "leaf")
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let _ = child.wait();
|
||||
return;
|
||||
}
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
println!("LISTENING {}", listener.local_addr().unwrap().port());
|
||||
std::thread::sleep(Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn windows_stop_releases_the_debuggees_port_before_returning() {
|
||||
use std::io::BufRead;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
struct Child(std::process::Child, Option<ide_core::process::ProcessTree>);
|
||||
impl Drop for Child {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tree) = &self.1 {
|
||||
tree.terminate();
|
||||
}
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
for (second_stop, wrapper, supports_terminate) in
|
||||
[(false, false, false), (true, false, false), (true, true, false), (true, true, true)]
|
||||
{
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
ide_core::process::prepare(&mut command)
|
||||
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
|
||||
.env("OPENIDEA_TEST_DEBUGGEE", if wrapper { "wrapper" } else { "leaf" })
|
||||
.stdout(std::process::Stdio::piped());
|
||||
let mut child = Child(command.spawn().unwrap(), None);
|
||||
let port: u16 = std::io::BufReader::new(child.0.stdout.take().unwrap())
|
||||
.lines()
|
||||
.map(Result::unwrap)
|
||||
.find_map(|line| line.split("LISTENING ").nth(1).and_then(|p| p.parse().ok()))
|
||||
.unwrap();
|
||||
let pid = child.0.id();
|
||||
child.1 = Some(ide_core::process::ProcessTree::capture(pid));
|
||||
let h = harness(Script { debuggee_pid: Some(pid), supports_terminate, ..Default::default() });
|
||||
let id = h.start(config()).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
while session.debuggee_pid.load(Ordering::Acquire) != i64::from(pid) {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let stop = tokio::spawn({
|
||||
let manager = h.manager.clone();
|
||||
async move { manager.stop(id).await }
|
||||
});
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while session.stop_tree.lock().is_none() && !stop.is_finished() {
|
||||
assert!(std::time::Instant::now() < deadline, "Stop did not capture the program tree");
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(!stop.is_finished(), "Stop must wait for the live program");
|
||||
if wrapper {
|
||||
assert!(session.stop_tree.lock().is_some());
|
||||
child.0.kill().unwrap();
|
||||
child.0.wait().unwrap();
|
||||
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the child still owns its port");
|
||||
}
|
||||
if second_stop {
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
let limit = if second_stop { 10 } else { 25 };
|
||||
tokio::time::timeout(Duration::from_secs(limit), stop).await.unwrap().unwrap().unwrap();
|
||||
assert!(child.0.try_wait().unwrap().is_some(), "Stop returned with the program alive");
|
||||
let _listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("Stop returned with its port occupied");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Script {
|
||||
/// Send a `runInTerminal` reverse request when `launch` arrives.
|
||||
|
||||
@@ -92,7 +92,7 @@ pub struct SequenceInfo {
|
||||
pub data_type: String,
|
||||
}
|
||||
|
||||
/// A schema's routines and sequences (the tree's `routines` / `sequences` folders).
|
||||
/// A schema's routines (procedures and functions) and sequences.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSchemaObjects")]
|
||||
|
||||
@@ -552,7 +552,8 @@ pub async fn build_run(
|
||||
let mut combined: Option<compile::BuildPlan> = None;
|
||||
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
|
||||
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
|
||||
let member_cache = cache.join(crate::project::project_key(member));
|
||||
let member_cache =
|
||||
if member == &root { cache.clone() } else { cache.join(crate::project::project_key(member)) };
|
||||
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
|
||||
let plan = prepare_plan(
|
||||
compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
|
||||
|
||||
@@ -12,13 +12,18 @@ pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult<Vec<Scope>>
|
||||
}
|
||||
|
||||
fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult<Vec<Scope>> {
|
||||
let window_root = root;
|
||||
Ok(match ide_workspace::workspace::Workspace::load(root)? {
|
||||
Some(workspace) => workspace
|
||||
.projects
|
||||
.into_iter()
|
||||
.filter(|p| p.is_dir())
|
||||
.map(|root| Scope {
|
||||
cache: cache.join(crate::project::project_key(&root)),
|
||||
cache: if root == window_root {
|
||||
cache.to_owned()
|
||||
} else {
|
||||
cache.join(crate::project::project_key(&root))
|
||||
},
|
||||
settings: ide_workspace::SettingsStore::load(config, Some(&root)).effective(),
|
||||
root,
|
||||
})
|
||||
@@ -36,10 +41,12 @@ pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
|
||||
let mut combined = BuildModel::default();
|
||||
for scope in scopes {
|
||||
let mut model = load(&scope.root, &scope.cache, &scope.settings);
|
||||
if scope.root == root {
|
||||
if scopes.len() == 1 && scope.root == root {
|
||||
return model;
|
||||
}
|
||||
if let Some(maven) = &mut model.maven {
|
||||
if scope.root != root
|
||||
&& let Some(maven) = &mut model.maven
|
||||
{
|
||||
let owner = ide_core::ipc_path::to_ipc(&scope.root);
|
||||
for p in &mut maven.projects {
|
||||
p.project_root = Some(owner.clone());
|
||||
@@ -73,7 +80,7 @@ pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
for g in &mut model.gradle {
|
||||
for g in model.gradle.iter_mut().filter(|_| scope.root != root) {
|
||||
g.dir = absolute(&scope.root, &g.dir);
|
||||
for p in &mut g.projects {
|
||||
p.dir = absolute(&scope.root, &p.dir);
|
||||
@@ -145,6 +152,30 @@ pub(super) fn update_files(root: &Path, scopes: &[Scope], model: &BuildModel, to
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attached_primary_keeps_its_cache_and_combines_build_models() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let root = base.join("primary");
|
||||
let other = base.join("other");
|
||||
for path in [&root, &other] {
|
||||
std::fs::create_dir(path).unwrap();
|
||||
std::fs::write(path.join("pom.xml"), "<project><artifactId>app</artifactId></project>").unwrap();
|
||||
}
|
||||
let cache = base.join("cache");
|
||||
ide_build::save_maven_profiles(&cache, &["kept".into()]).unwrap();
|
||||
ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap();
|
||||
let scopes = scopes_at(&root, &cache, &base.join("config"), Settings::default()).unwrap();
|
||||
assert_eq!(scopes[0].cache, cache);
|
||||
assert_eq!(ide_build::maven_profiles(&scopes[0].cache), ["kept"]);
|
||||
assert_eq!(scopes[1].cache, cache.join(crate::project::project_key(&other)));
|
||||
let model = load_scopes(&root, &scopes);
|
||||
let projects = model.maven.unwrap().projects;
|
||||
assert_eq!(projects.len(), 2);
|
||||
assert_eq!(projects[0].pom, "pom.xml");
|
||||
assert_eq!(projects[1].pom, absolute(&other, "pom.xml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_models_use_member_settings_paths_and_caches() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -82,7 +82,7 @@ fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeRes
|
||||
workspace
|
||||
.projects
|
||||
.into_iter()
|
||||
.filter(|root| path.starts_with(root))
|
||||
.filter(|root| **root != project.root && path.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
});
|
||||
let settings = match &member {
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct ProjectInfo {
|
||||
pub trusted: bool,
|
||||
pub root: ide_core::IpcPath,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub display_name: String,
|
||||
pub is_git: bool,
|
||||
pub settings: Settings,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -24,17 +26,23 @@ pub struct ProjectInfo {
|
||||
pub fn info(project: &Project) -> ProjectInfo {
|
||||
let settings = project.settings.effective();
|
||||
let custom = settings.general.project_name.trim();
|
||||
let primary = if custom.is_empty() {
|
||||
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
} else {
|
||||
custom.to_owned()
|
||||
};
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&project.root).ok().flatten();
|
||||
ProjectInfo {
|
||||
trusted: project.is_trusted(),
|
||||
name: if custom.is_empty() {
|
||||
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
} else {
|
||||
custom.to_owned()
|
||||
},
|
||||
display_name: workspace
|
||||
.as_ref()
|
||||
.map(|w| w.display_name(&project.root, &primary))
|
||||
.unwrap_or_else(|| primary.clone()),
|
||||
name: workspace.as_ref().filter(|w| w.is_container(&project.root)).map(|w| w.name.clone()).unwrap_or(primary),
|
||||
is_git: !project.repos.read().is_empty(),
|
||||
settings,
|
||||
root: project.root.clone().into(),
|
||||
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
|
||||
workspace,
|
||||
unity: ide_workspace::unity::detect(&project.root),
|
||||
}
|
||||
}
|
||||
@@ -63,38 +71,71 @@ pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots
|
||||
|
||||
pub fn save_workspace(host: &Host, window: &str, name: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<ProjectInfo> {
|
||||
let project = host.project(window)?;
|
||||
ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
|
||||
let previous = ide_workspace::workspace::Workspace::load(&project.root)?
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("This folder is not a workspace"))?;
|
||||
for path in paths.iter().filter(|p| !previous.projects.contains(p)) {
|
||||
require_attachment_trust(host, path)?;
|
||||
}
|
||||
if !previous.is_container(&project.root) && paths.first() != Some(&project.root) {
|
||||
return Err(ide_core::IdeError::invalid("The primary project cannot be detached"));
|
||||
}
|
||||
if !previous.is_container(&project.root) && paths == [project.root.clone()] {
|
||||
std::fs::remove_file(project.root.join(ide_workspace::workspace::FILE))?;
|
||||
} else {
|
||||
let workspace = ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
|
||||
keep_attachment_private(&project.root, &workspace)?;
|
||||
}
|
||||
refresh_workspace(&project);
|
||||
Ok(info(&project))
|
||||
}
|
||||
|
||||
fn require_attachment_trust(host: &Host, path: &Path) -> IdeResult<()> {
|
||||
if !host.trust.trusted(path)? {
|
||||
return Err(ide_core::IdeError::invalid("Trust the project before attaching it"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn keep_attachment_private(root: &Path, workspace: &ide_workspace::workspace::Workspace) -> IdeResult<()> {
|
||||
if !workspace.is_container(root) {
|
||||
ide_vcs::changelists::ensure_ignored(&root.join(".openidea"), &["/workspace.json"])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_workspace(project: &Project) {
|
||||
project.settings.reload();
|
||||
project.refresh_index_scope();
|
||||
project.refresh_repositories();
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
Ok(info(&project))
|
||||
}
|
||||
|
||||
/// Links a folder, or prepares an automatic workspace for the caller to open in place.
|
||||
/// Attaches a trusted folder without replacing the current project.
|
||||
pub fn attach_workspace(host: &Host, window: &str, path: &Path) -> IdeResult<ide_core::IpcPath> {
|
||||
let project = host.project(window)?;
|
||||
let path = dunce::canonicalize(path)?;
|
||||
if project.light {
|
||||
return Err(ide_core::IdeError::invalid("Open a project before attaching a folder"));
|
||||
}
|
||||
if path == project.root {
|
||||
return Ok(project.root.clone().into());
|
||||
}
|
||||
require_attachment_trust(host, &path)?;
|
||||
if let Some(mut workspace) = ide_workspace::workspace::Workspace::load(&project.root)? {
|
||||
if !workspace.projects.contains(&path) {
|
||||
workspace.projects.push(path);
|
||||
save_workspace(host, window, &workspace.name, workspace.projects)?;
|
||||
}
|
||||
return Ok(project.root.clone().into());
|
||||
} else {
|
||||
let workspace = ide_workspace::workspace::Workspace::attach(
|
||||
&project.root,
|
||||
&info(&project).name,
|
||||
vec![project.root.clone(), path],
|
||||
)?;
|
||||
keep_attachment_private(&project.root, &workspace)?;
|
||||
refresh_workspace(&project);
|
||||
}
|
||||
if path == project.root {
|
||||
return Ok(project.root.clone().into());
|
||||
}
|
||||
Ok(ide_workspace::workspace::Workspace::create_for_project(
|
||||
&host.data_dir,
|
||||
&project.root,
|
||||
&info(&project).name,
|
||||
&path,
|
||||
)?
|
||||
.into())
|
||||
Ok(project.root.clone().into())
|
||||
}
|
||||
|
||||
/// What [`set_settings`] changed; the caller tells the windows.
|
||||
|
||||
@@ -24,7 +24,7 @@ pub(super) fn project_configs(
|
||||
let root_suppressed = ide_run::load_suppressed_config_ids(root)?;
|
||||
let mut all = detected_and_saved(root, settings, &user, &root_suppressed);
|
||||
if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)? {
|
||||
for member in workspace.projects.into_iter().filter(|p| p.is_dir()) {
|
||||
for member in workspace.projects.into_iter().filter(|p| p != root && p.is_dir()) {
|
||||
let settings = ide_workspace::SettingsStore::load(config_dir, Some(&member)).effective();
|
||||
let saved = ide_run::load_user_configs(&member)?;
|
||||
let suppressed = ide_run::load_suppressed_config_ids(&member)?;
|
||||
@@ -145,20 +145,21 @@ fn settings_for(
|
||||
config: &mut RunConfig,
|
||||
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
|
||||
if workspace.is_some() {
|
||||
let owner = ide_run::scope::owner(&project.root, config);
|
||||
let member = workspace.as_ref().and_then(|workspace| {
|
||||
workspace
|
||||
.projects
|
||||
.iter()
|
||||
.filter(|root| **root != project.root && owner.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
.cloned()
|
||||
});
|
||||
if workspace.as_ref().is_some_and(|w| w.is_container(&project.root)) || member.is_some() {
|
||||
if config.extras.module.is_none() && !matches!(config.kind.as_str(), "application" | "spring-boot") {
|
||||
config.extras.module = config.cwd.as_deref().map(ide_core::ipc_path::to_ipc);
|
||||
}
|
||||
ide_run::scope::rebase(config, &project.root, None);
|
||||
}
|
||||
let member = workspace.and_then(|workspace| {
|
||||
let owner = ide_run::scope::owner(&project.root, config);
|
||||
workspace
|
||||
.projects
|
||||
.into_iter()
|
||||
.filter(|root| owner.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
});
|
||||
Ok(match member {
|
||||
Some(root) => {
|
||||
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective();
|
||||
|
||||
@@ -61,7 +61,7 @@ pub(crate) fn search_project(
|
||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||
_ => None,
|
||||
};
|
||||
let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some();
|
||||
let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some_and(|w| w.is_container(root));
|
||||
let include_root = !workspace
|
||||
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
|
||||
let content_roots = match query.scope {
|
||||
|
||||
@@ -24,8 +24,10 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
|
||||
.collect();
|
||||
// Godot is usually an app bundle / Flatpak outside the search path.
|
||||
let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p));
|
||||
let runtime = ide_sdk::apply::tool_install_environment(&root, &settings);
|
||||
let work_dir = (!root.as_os_str().is_empty()).then_some(root);
|
||||
Ok(Tools::new(&data)
|
||||
.with_runtime(runtime.commands, runtime.env)
|
||||
.with_custom_paths(custom)
|
||||
.with_found_paths(found.into_iter().collect())
|
||||
.with_work_dir(work_dir))
|
||||
|
||||
@@ -83,7 +83,7 @@ impl Host {
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Reject a damaged manifest before replacing the currently open project.
|
||||
ide_workspace::workspace::Workspace::load(&root)?;
|
||||
let is_workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
// it, with its warm index, language servers and unsaved documents.
|
||||
if let Ok(current) = self.project(window)
|
||||
@@ -96,7 +96,7 @@ impl Host {
|
||||
self.wait_detached_closes();
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
// Before anything the project starts: its children get the project's environment.
|
||||
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
|
||||
let trusted = Arc::new(AtomicBool::new(self.trust.window_trusted(&root)?));
|
||||
let post_start =
|
||||
trusted.load(Ordering::Acquire).then(|| self.environment.project_opened(&root, &sink)).flatten();
|
||||
let settings = SettingsStore::load(&self.config_dir, Some(&root));
|
||||
@@ -137,6 +137,16 @@ impl Host {
|
||||
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
lsp.set_content_roots(extra_roots.clone());
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
let settings = settings.clone();
|
||||
// Workspace members have independent settings; target servers have their own path mappings.
|
||||
lsp.set_jdtls_shared_hook(Box::new(move || {
|
||||
let effective = settings.effective();
|
||||
!is_workspace
|
||||
&& effective.languages.java.jdtls_shared
|
||||
&& effective.language_server_targets.get("java").is_none_or(|target| target.target.is_empty())
|
||||
}));
|
||||
}
|
||||
{
|
||||
let settings = settings.clone();
|
||||
let root = root.clone();
|
||||
@@ -188,14 +198,15 @@ impl Host {
|
||||
.as_ref()
|
||||
.map(|p| SettingsStore::load(&config_dir, Some(p)).effective())
|
||||
.unwrap_or_else(|| settings.effective());
|
||||
let root = member.as_ref().unwrap_or(&root);
|
||||
let window_root = &root;
|
||||
let root = member.as_ref().unwrap_or(window_root);
|
||||
ide_sdk::apply::server_launch_overrides(cfg, &effective);
|
||||
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
|
||||
if cfg.name == "jdtls" {
|
||||
// A workspace member keeps its build model under its own key (Build Project).
|
||||
let cache = match &member {
|
||||
Some(member) => build_cache.join(crate::project::project_key(member)),
|
||||
None => build_cache.clone(),
|
||||
Some(member) if member != window_root => build_cache.join(crate::project::project_key(member)),
|
||||
_ => build_cache.clone(),
|
||||
};
|
||||
let profiles = ide_build::maven_profiles(&cache);
|
||||
ide_sdk::maven_profiles::configure_jdtls(cfg, root, &effective, &profiles, &cache);
|
||||
@@ -1214,17 +1225,32 @@ pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspa
|
||||
}
|
||||
}
|
||||
|
||||
/// A workspace uses its root only for state, never as indexed content.
|
||||
/// Only explicit workspace containers exclude their root from indexed content.
|
||||
fn index_roots(root: &Path, folders: Vec<PathBuf>) -> ide_index::IndexRoots {
|
||||
ide_index::IndexRoots { include_root: !root.join(ide_workspace::workspace::FILE).is_file(), folders }
|
||||
let container =
|
||||
ide_workspace::workspace::Workspace::load(root).ok().flatten().is_some_and(|w| w.is_container(root));
|
||||
ide_index::IndexRoots { include_root: !container, folders: folders.into_iter().filter(|p| p != root).collect() }
|
||||
}
|
||||
|
||||
/// Workspace members in manifest order, or extra Project Structure directories and the
|
||||
/// Gradle project folders outside the project as of the last sync (`build_cache`).
|
||||
fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
|
||||
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
|
||||
return workspace.projects;
|
||||
if workspace.is_container(root) {
|
||||
return workspace.projects;
|
||||
}
|
||||
let mut roots = workspace.projects.into_iter().filter(|p| !p.starts_with(root)).collect::<Vec<_>>();
|
||||
for path in extra_content_roots(settings, root, build_cache) {
|
||||
if !roots.contains(&path) {
|
||||
roots.push(path);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
extra_content_roots(settings, root, build_cache)
|
||||
}
|
||||
|
||||
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()));
|
||||
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const EOF_GRACE: Duration = Duration::from_secs(5);
|
||||
const EOF_GRACE: Duration = Duration::from_secs(10);
|
||||
|
||||
/// `<host> <port>` from the arguments after `--connect`.
|
||||
pub fn parse_args(args: &[String]) -> Result<(String, u16), String> {
|
||||
@@ -25,7 +25,7 @@ pub fn parse_args(args: &[String]) -> Result<(String, u16), String> {
|
||||
|
||||
/// Connects to `host:port`, copies `input` to it and its replies to `output`, and returns when
|
||||
/// the target is done (the copy of `input` may still be blocked: the caller exits). Fails only
|
||||
/// when the connection can't be opened. An input EOF allows five idle seconds for a final reply.
|
||||
/// when the connection can't be opened. An input EOF allows ten idle seconds for a final reply.
|
||||
pub fn relay(host: &str, port: u16, mut input: impl Read + Send + 'static, mut output: impl Write) -> io::Result<()> {
|
||||
let mut from_target = connect(host, port)?;
|
||||
from_target.set_read_timeout(Some(Duration::from_millis(100)))?;
|
||||
@@ -140,11 +140,11 @@ mod tests {
|
||||
let mut request = Vec::new();
|
||||
socket.read_to_end(&mut request).unwrap();
|
||||
// Remain open after seeing EOF, like an idle keep-alive server.
|
||||
wait.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
wait.recv_timeout(Duration::from_secs(15)).unwrap();
|
||||
});
|
||||
let start = Instant::now();
|
||||
relay("127.0.0.1", port, io::empty(), io::sink()).unwrap();
|
||||
assert!(start.elapsed() < Duration::from_secs(8));
|
||||
assert!(start.elapsed() < Duration::from_secs(13));
|
||||
done.send(()).unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
@@ -56,6 +56,22 @@ impl TrustStore {
|
||||
|| self.inherited.lock().contains(&root)))
|
||||
}
|
||||
|
||||
pub fn window_trusted(&self, root: &Path) -> IdeResult<bool> {
|
||||
if !self.trusted(root)? {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)?
|
||||
&& !workspace.is_container(root)
|
||||
{
|
||||
for member in workspace.projects.iter().filter(|p| *p != root && p.exists()) {
|
||||
if !self.trusted(member)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Trusts `path` for this process without recording a decision (see [`TRUSTED_ROOT_ENV`]).
|
||||
pub fn inherit(&self, path: &Path) -> IdeResult<()> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
@@ -134,7 +150,7 @@ impl crate::Host {
|
||||
|
||||
pub async fn refresh_project_trust(&self) -> IdeResult<()> {
|
||||
for project in self.projects().into_iter().filter(|p| !p.light) {
|
||||
let trusted = self.trust.trusted(&project.root).unwrap_or(false);
|
||||
let trusted = self.trust.window_trusted(&project.root).unwrap_or(false);
|
||||
let previous = project.trusted.swap(trusted, std::sync::atomic::Ordering::AcqRel);
|
||||
if previous == trusted {
|
||||
continue;
|
||||
@@ -221,12 +237,15 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "app_cli_uninstall"
|
||||
| "app_log_location"
|
||||
| "app_check_update"
|
||||
| "app_startup_update_check"
|
||||
| "app_update_window_ready"
|
||||
| "app_update_status"
|
||||
| "app_update_announcement"
|
||||
| "app_install_update"
|
||||
| "caches_schedule_invalidation"
|
||||
| "clear_caches"
|
||||
| "clipboard_file_list"
|
||||
| "clipboard_has_files"
|
||||
| "clipboard_image"
|
||||
| "clipboard_set_file_list"
|
||||
| "jdk_download_vendors"
|
||||
| "jdk_download_releases"
|
||||
|
||||
@@ -38,12 +38,15 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"app_cli_uninstall",
|
||||
"app_log_location",
|
||||
"app_check_update",
|
||||
"app_startup_update_check",
|
||||
"app_update_window_ready",
|
||||
"app_update_status",
|
||||
"app_update_announcement",
|
||||
"app_install_update",
|
||||
"caches_schedule_invalidation",
|
||||
"clear_caches",
|
||||
"clipboard_file_list",
|
||||
"clipboard_has_files",
|
||||
"clipboard_image",
|
||||
"clipboard_set_file_list",
|
||||
"jdk_download_vendors",
|
||||
"jdk_download_releases",
|
||||
@@ -448,3 +451,33 @@ async fn revocation_still_applies_when_a_project_folder_was_removed() {
|
||||
assert!(!project.is_trusted());
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_member_trust_is_required_after_restart_and_revocation() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (config, data) = (tmp.path().join("config"), tmp.path().join("data"));
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let root = base.join("primary");
|
||||
let other = base.join("other");
|
||||
for path in [&root, &other] {
|
||||
std::fs::create_dir(path).unwrap();
|
||||
}
|
||||
ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap();
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
trust.decide(&root, true, false).unwrap();
|
||||
trust.inherit(&other).unwrap();
|
||||
assert!(trust.window_trusted(&root).unwrap());
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
assert!(trust.status(&root).unwrap().trusted);
|
||||
assert!(!trust.window_trusted(&root).unwrap());
|
||||
assert!(!trust.status(&other).unwrap().decided);
|
||||
trust.decide(&root, true, false).unwrap();
|
||||
assert!(!trust.window_trusted(&root).unwrap(), "trusting only the root does not recover the window");
|
||||
trust.decide(&other, true, false).unwrap();
|
||||
assert!(trust.window_trusted(&root).unwrap());
|
||||
trust.decide(&other, false, false).unwrap();
|
||||
assert!(trust.status(&other).unwrap().decided);
|
||||
assert!(!trust.window_trusted(&root).unwrap());
|
||||
trust.decide(&other, true, false).unwrap();
|
||||
assert!(trust.window_trusted(&root).unwrap());
|
||||
}
|
||||
@@ -32,11 +32,15 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
|
||||
});
|
||||
std::fs::write(root.join("internal-needle.txt"), "needle").unwrap();
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
for member in &roots {
|
||||
host.trust.decide(member, true, false).unwrap();
|
||||
}
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert_eq!(ops::project::info(&project).name, "Full stack");
|
||||
assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), root.as_path());
|
||||
ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
|
||||
assert_eq!(project.content_roots(), roots);
|
||||
assert!(!root.join(".openidea/.gitignore").exists());
|
||||
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
||||
assert!(project.index.find_files("internal-needle", 100).is_empty());
|
||||
assert!(project.index.find_files("workspace.json", 100).is_empty());
|
||||
@@ -98,20 +102,6 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
|
||||
std::fs::write(invalid.join(ide_workspace::workspace::FILE), "broken").unwrap();
|
||||
assert!(host.open_project("main", &invalid).is_err());
|
||||
assert!(Arc::ptr_eq(&host.project("main").unwrap(), &project));
|
||||
host.open_project("main", &roots[0]).unwrap();
|
||||
let automatic = ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
|
||||
assert_eq!(host.project("main").unwrap().root, roots[0]);
|
||||
let automatic = host.open_project("main", &automatic).unwrap();
|
||||
assert_eq!(automatic.content_roots(), roots);
|
||||
assert_eq!(ops::project::info(&automatic).name, "frontend");
|
||||
assert!(automatic.root.starts_with(base.join("data/workspaces")));
|
||||
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
|
||||
assert!(automatic.index.find_files("workspace.json", 100).is_empty());
|
||||
let updated = ops::project::save_workspace(&host, "main", "frontend", vec![roots[1].clone()]).unwrap();
|
||||
assert_eq!(updated.workspace.unwrap().projects, vec![roots[1].clone()]);
|
||||
assert_eq!(automatic.root, updated.root.as_ref());
|
||||
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
|
||||
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
@@ -206,3 +196,133 @@ async fn build_tool_availability_follows_workspace_members_and_trust() {
|
||||
assert_eq!(model.gradle.len(), 1);
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn attach_and_detach_preserve_the_primary_project_session() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let roots = vec![base.join("primary"), base.join("other")];
|
||||
for root in &roots {
|
||||
std::fs::create_dir(root).unwrap();
|
||||
std::fs::write(root.join("same.txt"), "needle").unwrap();
|
||||
std::fs::write(root.join("package.json"), r#"{"scripts":{"test":"echo test"}}"#).unwrap();
|
||||
}
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&roots[0], true, false).unwrap();
|
||||
let project = host.open_project("main", &roots[0]).unwrap();
|
||||
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
||||
let configs = ops::run::configs(&host, "main").unwrap();
|
||||
assert!(!configs.is_empty());
|
||||
let cache = project.build_cache_dir();
|
||||
let doc = ops::doc::open(&host, "main", roots[0].join("same.txt")).await.unwrap();
|
||||
ops::doc::edit(
|
||||
&host,
|
||||
"main",
|
||||
doc.id,
|
||||
doc.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "unsaved ".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ops::project::attach_workspace(&host, "main", &roots[1]).is_err());
|
||||
host.trust.decide(&roots[1], false, false).unwrap();
|
||||
assert!(ops::project::attach_workspace(&host, "main", &roots[1]).is_err());
|
||||
assert!(!roots[0].join(ide_workspace::workspace::FILE).exists());
|
||||
let existing_index = &project.index as *const _;
|
||||
host.trust.decide(&roots[1], true, false).unwrap();
|
||||
assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), roots[0]);
|
||||
assert!(Arc::ptr_eq(&host.project("main").unwrap(), &project));
|
||||
assert_eq!(&host.project("main").unwrap().index as *const _, existing_index);
|
||||
assert_eq!(project.root, roots[0]);
|
||||
assert_eq!(project.build_cache_dir(), cache);
|
||||
assert!(!base.join("data/workspaces").exists());
|
||||
assert_eq!(Workspace::load(&roots[0]).unwrap().unwrap().projects, roots);
|
||||
assert!(
|
||||
std::fs::read_to_string(roots[0].join(".openidea/.gitignore")).unwrap().lines().any(|l| l == "/workspace.json")
|
||||
);
|
||||
assert_eq!(project.content_roots(), vec![roots[1].clone()]);
|
||||
let attached = ops::project::info(&project);
|
||||
assert_eq!(attached.name, "primary");
|
||||
assert_eq!(attached.display_name, "primary, other");
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
"main",
|
||||
ide_workspace::SettingsScope::Project,
|
||||
"general.projectName",
|
||||
&serde_json::to_string(&attached.name).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ops::project::info(&project).display_name, "primary, other");
|
||||
let restricted = base.join("restricted");
|
||||
std::fs::create_dir(&restricted).unwrap();
|
||||
host.trust.decide(&restricted, false, false).unwrap();
|
||||
assert!(
|
||||
ops::project::save_workspace(&host, "main", "Primary", vec![roots[0].clone(), roots[1].clone(), restricted])
|
||||
.is_err()
|
||||
);
|
||||
let all = ops::run::configs(&host, "main").unwrap();
|
||||
assert_eq!(all.len(), configs.len() * 2);
|
||||
for config in &configs {
|
||||
assert_eq!(all.iter().filter(|c| c.id == config.id).count(), 1);
|
||||
}
|
||||
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
||||
assert_eq!(project.index.find_files("same.txt", 100).len(), 2);
|
||||
let hits = Arc::new(Mutex::new(Vec::new()));
|
||||
let out = hits.clone();
|
||||
let query = serde_json::from_value(serde_json::json!({"pattern":"needle", "isRegex":false,"caseSensitive":false,"wholeWord":false,"include":[],"exclude":[],"scope":{"kind":"project"},"maxMatches":100})).unwrap();
|
||||
ops::search::start(
|
||||
&host,
|
||||
"main",
|
||||
RequestId::next(),
|
||||
query,
|
||||
Arc::new(move |event| {
|
||||
if let ops::search::SearchEvent::File { file } = event {
|
||||
out.lock().unwrap().push(file);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(hits.lock().unwrap().len(), 2);
|
||||
assert!(ops::project::save_workspace(&host, "main", "Primary", vec![roots[1].clone()]).is_err());
|
||||
let updated = ops::project::save_workspace(&host, "main", "Primary", vec![roots[0].clone()]).unwrap();
|
||||
assert!(updated.workspace.is_none());
|
||||
assert_eq!(updated.name, "primary");
|
||||
assert_eq!(updated.root.as_ref(), roots[0]);
|
||||
assert!(!roots[0].join(ide_workspace::workspace::FILE).exists());
|
||||
assert!(project.content_roots().is_empty());
|
||||
assert_eq!(
|
||||
ops::run::configs(&host, "main").unwrap().iter().map(|c| &c.id).collect::<Vec<_>>(),
|
||||
configs.iter().map(|c| &c.id).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(project.documents.text_of(&roots[0].join("same.txt")).unwrap().starts_with("unsaved"));
|
||||
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
||||
assert_eq!(project.index.find_files("same.txt", 100).len(), 1);
|
||||
let nested = roots[0].join("nested");
|
||||
std::fs::create_dir(&nested).unwrap();
|
||||
std::fs::write(nested.join("same.txt"), "needle").unwrap();
|
||||
host.trust.decide(&nested, true, false).unwrap();
|
||||
Workspace::attach(&nested, "Nested", vec![nested.clone()]).unwrap();
|
||||
ops::project::attach_workspace(&host, "main", &nested).unwrap();
|
||||
assert!(project.content_roots().is_empty());
|
||||
let container = base.join("container");
|
||||
Workspace::save(&container, "Container", vec![], true).unwrap();
|
||||
host.trust.decide(&container, true, false).unwrap();
|
||||
assert!(ops::project::attach_workspace(&host, "main", &container).is_err());
|
||||
host.close_project("main");
|
||||
let reopened = host.open_project("main", &roots[0]).unwrap();
|
||||
assert_eq!(ops::project::info(&reopened).name, "primary");
|
||||
assert_eq!(ops::project::info(&reopened).display_name, "primary, nested");
|
||||
host.set_project_trust(&nested, false, false).await.unwrap();
|
||||
assert!(!reopened.is_trusted());
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -268,6 +268,12 @@ impl Handler for ClientHandler {
|
||||
"window/showMessage" => self.show_message(&mgr, ¶ms),
|
||||
"window/logMessage" => {
|
||||
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
|
||||
if self.server.name == "jdtls"
|
||||
&& text.contains("Can't use Java ")
|
||||
&& text.contains(" to import Gradle project ")
|
||||
{
|
||||
self.show_message(&mgr, &json!({ "type": 1, "message": text.lines().next().unwrap_or(text) }));
|
||||
}
|
||||
match params.get("type").and_then(Value::as_u64) {
|
||||
Some(1) if self.server.name == "jdtls" && text.contains(crate::jdtls::reserved_names::IMPORT_FAILURE) => {
|
||||
tracing::warn!("[{}] {text}", self.server.name);
|
||||
|
||||
+48
-20
@@ -627,7 +627,11 @@ impl LspManager {
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root))
|
||||
.filter(|d| {
|
||||
d.language.as_str() == "java"
|
||||
&& d.path.is_absolute()
|
||||
&& d.servers.iter().any(|a| a.server.id == server.id)
|
||||
})
|
||||
.map(|d| crate::uri::path_to_uri_string(&d.path))
|
||||
.collect();
|
||||
if uris.is_empty() {
|
||||
@@ -652,26 +656,50 @@ impl LspManager {
|
||||
/// started if needed and waited for.
|
||||
async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc<Server>, Running)> {
|
||||
let java = LanguageId::new("java");
|
||||
let server = self
|
||||
.servers_for(&java, path)
|
||||
.into_iter()
|
||||
.find(|s| s.name == SERVER)
|
||||
.ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?;
|
||||
self.ensure_started(&server);
|
||||
match cancellable_wait(cancel, server.wait_ready(READY_TIMEOUT)).await? {
|
||||
Some(run) => Ok((server, run)),
|
||||
None => {
|
||||
let status = server.status();
|
||||
Err(IdeError::unavailable(match status {
|
||||
ide_core::event::ServerStatus::Missing => {
|
||||
"jdtls is not installed (Settings › Languages & Frameworks › Language Servers)".to_owned()
|
||||
}
|
||||
ide_core::event::ServerStatus::Misconfigured | ide_core::event::ServerStatus::Crashed => {
|
||||
server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned())
|
||||
}
|
||||
_ => format!("jdtls is not ready ({status:?})"),
|
||||
}))
|
||||
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let server = self
|
||||
.servers_for(&java, path)
|
||||
.into_iter()
|
||||
.find(|s| s.name == SERVER)
|
||||
.ok_or_else(|| IdeError::unavailable("no Java language server (jdtls) is configured"))?;
|
||||
self.ensure_started(&server);
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if let Some(run) = cancellable_wait(cancel, server.wait_ready(remaining)).await? {
|
||||
return Ok((server, run));
|
||||
}
|
||||
let status = server.status();
|
||||
if !matches!(
|
||||
status,
|
||||
ide_core::event::ServerStatus::Missing
|
||||
| ide_core::event::ServerStatus::Misconfigured
|
||||
| ide_core::event::ServerStatus::Crashed
|
||||
) && tokio::time::Instant::now() < deadline
|
||||
&& let Ok(_restart) =
|
||||
cancellable_wait(cancel, tokio::time::timeout_at(deadline, self.reconfigure_lock.lock())).await?
|
||||
{
|
||||
// A restart publishes Down while reaping its old JVM. Its successor may
|
||||
// also have a new identity after changing the shared workspace.
|
||||
if let Some(current) = self.servers_for(&java, path).into_iter().find(|s| s.name == SERVER)
|
||||
&& current.root.is_dir()
|
||||
&& (current.id != server.id || current.is_starting() || current.running().is_some())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let status = server.status();
|
||||
return Err(IdeError::unavailable(match status {
|
||||
ide_core::event::ServerStatus::Missing => {
|
||||
"jdtls is not installed (Settings › Languages & Frameworks › Language Servers)".to_owned()
|
||||
}
|
||||
ide_core::event::ServerStatus::Misconfigured | ide_core::event::ServerStatus::Crashed => {
|
||||
server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned())
|
||||
}
|
||||
_ => format!("jdtls is not ready ({status:?})"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+237
-45
@@ -175,6 +175,20 @@ impl DocSnap {
|
||||
/// Receives the folders outside the project that servers want watched.
|
||||
pub type WatchRootsFn = dyn Fn(Vec<PathBuf>) + Send + Sync;
|
||||
|
||||
pub type JdtlsSharedFn = dyn Fn() -> bool + Send + Sync;
|
||||
|
||||
#[derive(Default)]
|
||||
struct JdtlsWorkspace {
|
||||
initialized: bool,
|
||||
shared: bool,
|
||||
roots: Vec<PathBuf>,
|
||||
transitioning: bool,
|
||||
}
|
||||
|
||||
fn is_local_jdtls(config: &LanguageServerConfig) -> bool {
|
||||
config.name == "jdtls" && config.target_key.is_none() && config.target.is_none()
|
||||
}
|
||||
|
||||
pub struct LspManager {
|
||||
me: Weak<LspManager>,
|
||||
project_root: PathBuf,
|
||||
@@ -195,6 +209,9 @@ pub struct LspManager {
|
||||
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
|
||||
excludes_hook: RwLock<Option<Arc<external_project::ExcludesFn>>>,
|
||||
watch_roots_hook: RwLock<Option<Arc<WatchRootsFn>>>,
|
||||
jdtls_shared_hook: RwLock<Option<Arc<JdtlsSharedFn>>>,
|
||||
jdtls_workspace: Mutex<JdtlsWorkspace>,
|
||||
reconfigure_lock: tokio::sync::Mutex<()>,
|
||||
shutting_down: AtomicBool,
|
||||
execution_allowed: AtomicBool,
|
||||
java_usages_warned: AtomicBool,
|
||||
@@ -402,6 +419,9 @@ impl LspManager {
|
||||
candidates_hook: RwLock::new(None),
|
||||
excludes_hook: RwLock::new(None),
|
||||
watch_roots_hook: RwLock::new(None),
|
||||
jdtls_shared_hook: RwLock::new(None),
|
||||
jdtls_workspace: Mutex::new(Default::default()),
|
||||
reconfigure_lock: tokio::sync::Mutex::new(()),
|
||||
shutting_down: AtomicBool::new(false),
|
||||
execution_allowed: AtomicBool::new(true),
|
||||
java_usages_warned: AtomicBool::new(false),
|
||||
@@ -416,7 +436,56 @@ impl LspManager {
|
||||
// ---- server lookup & lifecycle ----
|
||||
|
||||
pub fn set_content_roots(&self, roots: Vec<PathBuf>) {
|
||||
*self.content_roots.write() = roots;
|
||||
let changed = {
|
||||
let mut current = self.content_roots.write();
|
||||
let changed = *current != roots;
|
||||
*current = roots;
|
||||
changed
|
||||
};
|
||||
if changed
|
||||
&& self.desired_jdtls_workspace().0
|
||||
&& let Some(me) = self.me.upgrade()
|
||||
{
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shares JDT LS within this manager; callers reconfigure after the hook's value changes.
|
||||
pub fn set_jdtls_shared_hook(&self, hook: Box<JdtlsSharedFn>) {
|
||||
*self.jdtls_shared_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
fn desired_jdtls_workspace(&self) -> (bool, Vec<PathBuf>) {
|
||||
let shared = self.jdtls_shared_hook.read().as_ref().is_some_and(|hook| hook());
|
||||
let mut roots = Vec::new();
|
||||
if shared {
|
||||
roots.push(self.project_root.clone());
|
||||
for root in self.content_roots.read().iter() {
|
||||
if root.is_absolute() && root.is_dir() && !roots.contains(root) {
|
||||
roots.push(root.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(shared, roots)
|
||||
}
|
||||
|
||||
fn jdtls_workspace_state(&self) -> parking_lot::MutexGuard<'_, JdtlsWorkspace> {
|
||||
let mut state = self.jdtls_workspace.lock();
|
||||
if !state.initialized {
|
||||
(state.shared, state.roots) = self.desired_jdtls_workspace();
|
||||
state.initialized = true;
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn server_workspace_roots(&self, server: &Server) -> Vec<PathBuf> {
|
||||
if is_local_jdtls(&server.config) {
|
||||
let state = self.jdtls_workspace_state();
|
||||
if state.shared {
|
||||
return state.roots.clone();
|
||||
}
|
||||
}
|
||||
vec![server.root.clone()]
|
||||
}
|
||||
|
||||
fn project_root_for(&self, path: &Path) -> PathBuf {
|
||||
@@ -431,16 +500,21 @@ impl LspManager {
|
||||
}
|
||||
|
||||
fn server_for(&self, cfg: &LanguageServerConfig, language: &LanguageId, path: &Path) -> Arc<Server> {
|
||||
let shared = is_local_jdtls(cfg).then(|| self.jdtls_workspace_state());
|
||||
let mut servers = self.servers.lock();
|
||||
let root = launch::find_root(&self.project_root_for(path), path, &cfg.root_markers).unwrap_or_else(|| {
|
||||
// Outside the project (library sources, …): reuse a running instance.
|
||||
servers
|
||||
.iter()
|
||||
.filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key)
|
||||
.min_by_key(|s| s.root != self.project_root)
|
||||
.map(|s| s.root.clone())
|
||||
.unwrap_or_else(|| self.project_root.clone())
|
||||
});
|
||||
let root = if shared.as_ref().is_some_and(|state| state.shared) {
|
||||
self.project_root.clone()
|
||||
} else {
|
||||
launch::find_root(&self.project_root_for(path), path, &cfg.root_markers).unwrap_or_else(|| {
|
||||
// Outside the project (library sources, …): reuse a running instance.
|
||||
servers
|
||||
.iter()
|
||||
.filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key)
|
||||
.min_by_key(|s| s.root != self.project_root)
|
||||
.map(|s| s.root.clone())
|
||||
.unwrap_or_else(|| self.project_root.clone())
|
||||
})
|
||||
};
|
||||
if let Some(s) =
|
||||
servers.iter().find(|s| s.name == cfg.name && s.root == root && s.config.target_key == cfg.target_key)
|
||||
{
|
||||
@@ -490,6 +564,7 @@ impl LspManager {
|
||||
/// to the open documents (and start them). Also attaches servers newly
|
||||
/// enabled in settings.
|
||||
fn attach_fallbacks(&self) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> = self
|
||||
.docs
|
||||
.lock()
|
||||
@@ -538,6 +613,27 @@ impl LspManager {
|
||||
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let workspace = is_local_jdtls(&server.config).then(|| self.jdtls_workspace_state());
|
||||
if let Some(state) = &workspace {
|
||||
if state.transitioning || !self.servers.lock().iter().any(|s| s.id == server.id) {
|
||||
return;
|
||||
}
|
||||
let (shared, roots) = self.desired_jdtls_workspace();
|
||||
if state.shared != shared || (shared && state.roots != roots) {
|
||||
if let Some(me) = self.me.upgrade() {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let startup = if is_local_jdtls(&server.config) {
|
||||
match server.startup_lock.clone().try_lock_owned() {
|
||||
Ok(guard) => Some(guard),
|
||||
Err(_) => return,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// `begin_start` only claims Idle servers, so a claimed server is never a
|
||||
// live one. Its root is the process's working directory: starting with
|
||||
// one deleted from disk fails (`os error 267` on Windows). Such a
|
||||
@@ -553,7 +649,10 @@ impl LspManager {
|
||||
return;
|
||||
}
|
||||
let server = server.clone();
|
||||
self.runtime.spawn(async move { me.run_instance(server, generation).await });
|
||||
self.runtime.spawn(async move {
|
||||
let _startup = startup;
|
||||
me.run_instance(server, generation).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_instance(self: Arc<Self>, server: Arc<Server>, generation: u64) {
|
||||
@@ -561,6 +660,7 @@ impl LspManager {
|
||||
let handler = Arc::new(handler::ClientHandler::new(self.me.clone(), server.clone(), generation));
|
||||
let me = self.clone();
|
||||
let tail = server.clone();
|
||||
let launch_cancel = cancel.clone();
|
||||
// The settings hook may touch the filesystem (e.g. scanning for JDKs); TCP
|
||||
// servers may take a while to start listening.
|
||||
let launched = tokio::task::spawn_blocking(move || {
|
||||
@@ -568,17 +668,17 @@ impl LspManager {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
let mut cfg = me.configured(&tail);
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || launch_cancel.is_cancelled() {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
tail.set_effective_config(generation, cfg.clone());
|
||||
let prepare = me.prepare_hook.read().clone();
|
||||
if cfg.target.is_none()
|
||||
&& !cancel.is_cancelled()
|
||||
&& !launch_cancel.is_cancelled()
|
||||
&& me.launcher.available(&cfg, &tail.root)
|
||||
&& let Some(prepare) = prepare
|
||||
{
|
||||
prepare(&cfg, &tail.root, &cancel);
|
||||
prepare(&cfg, &tail.root, &launch_cancel);
|
||||
cfg = me.configured(&tail);
|
||||
}
|
||||
tail.set_effective_config(generation, cfg.clone());
|
||||
@@ -588,7 +688,7 @@ impl LspManager {
|
||||
let missing = cfg.target.is_none() && !me.launcher.available(&cfg, &root);
|
||||
return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) });
|
||||
}
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || launch_cancel.is_cancelled() {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
me.launcher.launch_resolved(
|
||||
@@ -596,7 +696,7 @@ impl LspManager {
|
||||
&root,
|
||||
handler,
|
||||
Box::new(move |line| tail.push_stderr(generation, line)),
|
||||
&cancel,
|
||||
&launch_cancel,
|
||||
)
|
||||
})
|
||||
.await
|
||||
@@ -605,7 +705,7 @@ impl LspManager {
|
||||
let peer = match launched {
|
||||
Ok((peer, binary)) => {
|
||||
if !server.set_resolved_binary(generation, binary) {
|
||||
peer.kill();
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
return;
|
||||
}
|
||||
peer
|
||||
@@ -662,9 +762,14 @@ impl LspManager {
|
||||
init["initializationOptions"]["triggerFiles"] = json!([uri::path_to_uri_string(&path)]);
|
||||
}
|
||||
}
|
||||
let result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, None))
|
||||
.await
|
||||
.unwrap_or(Err(RpcError::Timeout));
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => {
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
return;
|
||||
}
|
||||
result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, Some(cancel.clone()))) => result.unwrap_or(Err(RpcError::Timeout)),
|
||||
};
|
||||
let caps = match result {
|
||||
Ok(result) => {
|
||||
if let Some(info) = result.get("serverInfo") {
|
||||
@@ -681,7 +786,7 @@ impl LspManager {
|
||||
// The server is exiting: let it, so the crash report has its exit code and last output.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), peer.closed()).await;
|
||||
}
|
||||
peer.kill();
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
// Treated like a crash so transient failures retry with backoff.
|
||||
self.spawn_supervisor(server, generation, peer);
|
||||
return;
|
||||
@@ -690,7 +795,7 @@ impl LspManager {
|
||||
let run =
|
||||
Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)), watchers: Default::default() };
|
||||
if !server.set_initializing(run.clone()) {
|
||||
peer.kill();
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
return;
|
||||
}
|
||||
if let Err(e) = peer.notify("initialized", json!({})) {
|
||||
@@ -709,24 +814,30 @@ impl LspManager {
|
||||
|
||||
// Publish readiness and (re)open attached documents atomically w.r.t. did_change.
|
||||
let mut reopened = Vec::new();
|
||||
{
|
||||
let ready = {
|
||||
let mut docs = self.docs.lock();
|
||||
if !server.mark_ready(generation, run.clone()) {
|
||||
drop(docs);
|
||||
peer.kill();
|
||||
return;
|
||||
}
|
||||
let open_close = run.caps.read().open_close;
|
||||
for (id, d) in docs.iter_mut() {
|
||||
let params = d.did_open_params();
|
||||
for a in d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation)) {
|
||||
if open_close && let Err(e) = peer.notify("textDocument/didOpen", params.clone()) {
|
||||
tracing::debug!("{}: didOpen: {e}", server.name);
|
||||
false
|
||||
} else {
|
||||
let open_close = run.caps.read().open_close;
|
||||
for (id, d) in docs.iter_mut() {
|
||||
let params = d.did_open_params();
|
||||
for a in
|
||||
d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation))
|
||||
{
|
||||
if open_close && let Err(e) = peer.notify("textDocument/didOpen", params.clone()) {
|
||||
tracing::debug!("{}: didOpen: {e}", server.name);
|
||||
}
|
||||
a.opened_gen = Some(generation);
|
||||
reopened.push(*id);
|
||||
}
|
||||
a.opened_gen = Some(generation);
|
||||
reopened.push(*id);
|
||||
}
|
||||
true
|
||||
}
|
||||
};
|
||||
if !ready {
|
||||
Self::kill_peer(&server, &peer).await;
|
||||
return;
|
||||
}
|
||||
for doc in reopened {
|
||||
self.schedule_pull(doc, Some(&server), Duration::ZERO);
|
||||
@@ -767,7 +878,6 @@ impl LspManager {
|
||||
|
||||
fn initialize_params(&self, server: &Server) -> Value {
|
||||
let root_uri = uri::path_to_uri(&server.root);
|
||||
let name = server.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into());
|
||||
let cfg = server.effective_config();
|
||||
let mut init_options =
|
||||
cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or(Value::Null);
|
||||
@@ -776,11 +886,24 @@ impl LspManager {
|
||||
init_options = json!({});
|
||||
}
|
||||
init_options["settings"] = handler::all_settings(&cfg);
|
||||
let shared = is_local_jdtls(&server.config) && self.jdtls_workspace_state().shared;
|
||||
if shared {
|
||||
init_options["workspaceFolders"] = json!(
|
||||
self.server_workspace_roots(server)
|
||||
.iter()
|
||||
.map(|root| uri::path_to_uri_string(root))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
let triggers: Vec<_> = self
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root))
|
||||
.filter(|d| {
|
||||
d.language.as_str() == "java"
|
||||
&& d.path.is_absolute()
|
||||
&& d.servers.iter().any(|a| a.server.id == server.id)
|
||||
})
|
||||
.map(|d| uri::path_to_uri_string(&d.path))
|
||||
.collect();
|
||||
if !triggers.is_empty() && init_options.get("triggerFiles").is_none() {
|
||||
@@ -794,7 +917,7 @@ impl LspManager {
|
||||
"locale": "en",
|
||||
"rootPath": server.root.to_string_lossy(),
|
||||
"rootUri": root_uri,
|
||||
"workspaceFolders": [{ "uri": root_uri, "name": name }],
|
||||
"workspaceFolders": self.workspace_folders(server),
|
||||
"capabilities": caps::client_capabilities(),
|
||||
"trace": "off",
|
||||
});
|
||||
@@ -811,8 +934,16 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_folders(&self, server: &Server) -> Value {
|
||||
let name = server.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into());
|
||||
json!([{ "uri": uri::path_to_uri(&server.root), "name": name }])
|
||||
json!(
|
||||
self.server_workspace_roots(server)
|
||||
.iter()
|
||||
.map(|root| {
|
||||
let name =
|
||||
root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "root".into());
|
||||
json!({"uri": uri::path_to_uri(root), "name": name})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn sink(&self) -> &SharedSink {
|
||||
@@ -862,6 +993,7 @@ impl LspManager {
|
||||
/// Opens a read-only virtual document (`jdt://…`) on the server that
|
||||
/// returned its URI (`didOpen` only: it is never changed or saved).
|
||||
pub fn did_open_virtual(&self, doc: DocId, uri: &str, language: &LanguageId, version: u32, text: &str) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
if self.docs.lock().contains_key(&doc) {
|
||||
self.close_injections(doc);
|
||||
self.close_document(doc);
|
||||
@@ -2450,15 +2582,22 @@ impl LspManager {
|
||||
.into_iter()
|
||||
.flat_map(|(paths, change)| paths.iter().map(move |p| (p, change)));
|
||||
let watchers = run.watchers.read();
|
||||
let roots = self.server_workspace_roots(&s);
|
||||
let changes: Vec<(&PathBuf, Change)> = if watchers.is_empty() {
|
||||
// Dart watches the filesystem itself and rejects this unregistered notification.
|
||||
if s.name == "dart" {
|
||||
continue;
|
||||
}
|
||||
let languages = s.languages();
|
||||
all.filter(|(p, change)| {
|
||||
p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)) && send(p, *change)
|
||||
roots.iter().any(|root| p.starts_with(root))
|
||||
&& languages.contains(&self.languages.detect(p, None))
|
||||
&& send(p, *change)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
all.chain(excluded_dirs.iter().map(|p| (p, Change::Created)))
|
||||
.filter(|(p, change)| watchers.wants(&s.root, p, *change) && send(p, *change))
|
||||
.filter(|(p, change)| roots.iter().any(|root| watchers.wants(root, p, *change)) && send(p, *change))
|
||||
.collect()
|
||||
};
|
||||
drop(watchers);
|
||||
@@ -3036,7 +3175,8 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
if allowed && !previous {
|
||||
for server in self.servers.lock().iter() {
|
||||
let servers = self.servers.lock().clone();
|
||||
for server in &servers {
|
||||
server.reset_for_restart();
|
||||
self.ensure_started(server);
|
||||
}
|
||||
@@ -3053,11 +3193,13 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub async fn reconfigure(&self) {
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
if !self.execution_allowed.load(Ordering::Acquire) {
|
||||
let servers = self.servers.lock().clone();
|
||||
join_all(servers.iter().map(|s| self.stop_instance(s, true))).await;
|
||||
return;
|
||||
}
|
||||
self.reconfigure_jdtls_workspace().await;
|
||||
self.stop_unlisted().await;
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
@@ -3120,6 +3262,39 @@ impl LspManager {
|
||||
self.injection_providers_changed();
|
||||
}
|
||||
|
||||
async fn reconfigure_jdtls_workspace(&self) {
|
||||
let (shared, roots) = self.desired_jdtls_workspace();
|
||||
let retired = {
|
||||
// Opens attach atomically with root selection; no document can acquire an old
|
||||
// instance between removing the registry entries and detaching its documents.
|
||||
let _gate = self.injections.gate.lock();
|
||||
let mut state = self.jdtls_workspace_state();
|
||||
if state.shared == shared && (!shared || state.roots == roots) {
|
||||
return;
|
||||
}
|
||||
state.transitioning = true;
|
||||
state.shared = shared;
|
||||
state.roots = roots;
|
||||
let mut servers = self.servers.lock();
|
||||
let retired: Vec<_> = servers.iter().filter(|s| is_local_jdtls(&s.config)).cloned().collect();
|
||||
servers.retain(|s| !is_local_jdtls(&s.config));
|
||||
for doc in self.docs.lock().values_mut() {
|
||||
doc.servers.retain(|a| !is_local_jdtls(&a.server.config));
|
||||
}
|
||||
retired
|
||||
};
|
||||
for server in &retired {
|
||||
self.stop_instance(server, true).await;
|
||||
}
|
||||
self.forget(&retired);
|
||||
self.jdtls_workspace.lock().transitioning = false;
|
||||
self.attach_fallbacks();
|
||||
let pending: Vec<_> = self.servers.lock().iter().filter(|s| is_local_jdtls(&s.config)).cloned().collect();
|
||||
for server in pending {
|
||||
self.ensure_started(&server);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detaches fallbacks shadowed by enabled servers and instances whose target
|
||||
/// no longer matches the document's language. Stop them once unused.
|
||||
async fn detach_shadowed_servers(&self) {
|
||||
@@ -3255,6 +3430,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub async fn restart(&self, server: ide_core::ServerId) -> IdeResult<()> {
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
let s = self
|
||||
.servers
|
||||
.lock()
|
||||
@@ -3282,7 +3458,12 @@ impl LspManager {
|
||||
|
||||
/// Gracefully stops the current instance (`shutdown` + `exit`, then kill).
|
||||
async fn stop_instance(&self, server: &Arc<Server>, final_stop: bool) {
|
||||
let Some(run) = server.begin_stop(&self.sink, final_stop) else { return };
|
||||
let Some(run) = server.begin_stop(&self.sink, final_stop) else {
|
||||
if is_local_jdtls(&server.config) {
|
||||
let _startup = server.startup_lock.lock().await;
|
||||
}
|
||||
return;
|
||||
};
|
||||
let peer = run.peer.clone();
|
||||
// A server we connected to without starting it (an open Godot editor) is not
|
||||
// ours to shut down: only the connection closes.
|
||||
@@ -3302,13 +3483,24 @@ impl LspManager {
|
||||
peer.kill();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), peer.closed()).await;
|
||||
}
|
||||
peer.kill();
|
||||
Self::kill_peer(server, &peer).await;
|
||||
if is_local_jdtls(&server.config) {
|
||||
let _startup = server.startup_lock.lock().await;
|
||||
}
|
||||
server.end_stop(run.generation, &self.sink);
|
||||
self.file_watch_roots_changed();
|
||||
}
|
||||
|
||||
async fn kill_peer(server: &Server, peer: &ide_jsonrpc::Peer) {
|
||||
peer.kill();
|
||||
if is_local_jdtls(&server.config) && peer.pid().is_some() {
|
||||
peer.exited().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown_all(&self) {
|
||||
self.shutting_down.store(true, Ordering::Release);
|
||||
let _reconfigure = self.reconfigure_lock.lock().await;
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
join_all(servers.iter().map(|s| self.stop_instance(s, true))).await;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ pub enum Phase {
|
||||
struct State {
|
||||
generation: u64,
|
||||
conn: Conn,
|
||||
restarting: bool,
|
||||
events: VecDeque<IdeEvent>,
|
||||
publishing: bool,
|
||||
}
|
||||
@@ -119,6 +120,8 @@ pub struct Server {
|
||||
phase_notify: Notify,
|
||||
transient: Mutex<HashMap<PathBuf, Transient>>,
|
||||
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
/// JDT retirement waits for launch and initialization cleanup, even before `Ready`.
|
||||
pub startup_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
|
||||
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
|
||||
pub external_diagnostics: std::sync::atomic::AtomicBool,
|
||||
@@ -138,7 +141,13 @@ impl Server {
|
||||
effective: Mutex::new(None),
|
||||
resolved_binary: RwLock::new(None),
|
||||
languages: Mutex::new(vec![language]),
|
||||
state: Mutex::new(State { generation: 0, conn: Conn::Idle, events: VecDeque::new(), publishing: false }),
|
||||
state: Mutex::new(State {
|
||||
generation: 0,
|
||||
conn: Conn::Idle,
|
||||
restarting: false,
|
||||
events: VecDeque::new(),
|
||||
publishing: false,
|
||||
}),
|
||||
initializing: Mutex::new(None),
|
||||
start_cancel: Mutex::new(CancellationToken::new()),
|
||||
phase: watch::channel(Phase::Down).0,
|
||||
@@ -158,6 +167,7 @@ impl Server {
|
||||
phase_notify: Notify::new(),
|
||||
transient: Mutex::new(HashMap::new()),
|
||||
java_build_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
startup_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false),
|
||||
external_project: tokio::sync::Mutex::new(Default::default()),
|
||||
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
|
||||
@@ -214,6 +224,7 @@ impl Server {
|
||||
}
|
||||
st.generation += 1;
|
||||
st.conn = Conn::Starting;
|
||||
st.restarting = false;
|
||||
*self.start_cancel.lock() = CancellationToken::new();
|
||||
*self.started.lock() = Some(Instant::now());
|
||||
self.phase.send_replace(Phase::Starting);
|
||||
@@ -269,6 +280,10 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_starting(&self) -> bool {
|
||||
matches!(self.state.lock().conn, Conn::Starting)
|
||||
}
|
||||
|
||||
/// The instance told `initialized`, ready or not.
|
||||
pub fn instance(&self) -> Option<Running> {
|
||||
self.running().or_else(|| self.initializing.lock().clone())
|
||||
@@ -293,21 +308,43 @@ impl Server {
|
||||
self.running().filter(|r| r.generation == generation)
|
||||
}
|
||||
|
||||
/// Waits while the server is starting; returns the instance once ready.
|
||||
/// Waits through startup or a planned restart; returns the instance once ready.
|
||||
pub async fn wait_ready(&self, timeout: Duration) -> Option<Running> {
|
||||
let mut rx = self.phase.subscribe();
|
||||
let _ = tokio::time::timeout(timeout, rx.wait_for(|p| *p != Phase::Starting)).await;
|
||||
self.running()
|
||||
tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
{
|
||||
let st = self.state.lock();
|
||||
// Read both under the writer's lock: a restart can begin just after a wakeup.
|
||||
if *rx.borrow_and_update() != Phase::Starting {
|
||||
return match &st.conn {
|
||||
Conn::Ready(run) => Some(run.clone()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
if rx.changed().await.is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Ready → Stopping, returning the instance to shut down. An in-flight
|
||||
/// start is abandoned instead.
|
||||
pub fn begin_stop(&self, sink: &SharedSink, final_stop: bool) -> Option<Running> {
|
||||
let mut st = self.state.lock();
|
||||
if final_stop {
|
||||
st.restarting = false;
|
||||
}
|
||||
match std::mem::replace(&mut st.conn, Conn::Idle) {
|
||||
Conn::Ready(run) => {
|
||||
st.conn = Conn::Stopping;
|
||||
self.phase.send_replace(Phase::Down);
|
||||
st.restarting = !final_stop;
|
||||
self.phase.send_replace(if st.restarting { Phase::Starting } else { Phase::Down });
|
||||
Some(run)
|
||||
}
|
||||
Conn::Starting => {
|
||||
@@ -315,7 +352,8 @@ impl Server {
|
||||
self.start_cancel.lock().cancel();
|
||||
self.queue_cleanup(&mut st);
|
||||
self.queue_status(&mut st, ServerStatus::Stopped, None);
|
||||
self.phase.send_replace(Phase::Down);
|
||||
st.restarting = !final_stop;
|
||||
self.phase.send_replace(if st.restarting { Phase::Starting } else { Phase::Down });
|
||||
drop(st);
|
||||
self.publish(sink);
|
||||
None
|
||||
@@ -324,6 +362,7 @@ impl Server {
|
||||
st.conn = other;
|
||||
if final_stop {
|
||||
self.queue_status(&mut st, ServerStatus::Stopped, None);
|
||||
self.phase.send_replace(Phase::Down);
|
||||
}
|
||||
drop(st);
|
||||
self.publish(sink);
|
||||
@@ -345,7 +384,7 @@ impl Server {
|
||||
self.queue_cleanup(&mut st);
|
||||
self.queue_status(&mut st, ServerStatus::Stopped, None);
|
||||
st.conn = Conn::Idle;
|
||||
self.phase.send_replace(Phase::Down);
|
||||
self.phase.send_replace(if st.restarting { Phase::Starting } else { Phase::Down });
|
||||
drop(st);
|
||||
self.publish(sink);
|
||||
}
|
||||
@@ -899,6 +938,37 @@ mod tests {
|
||||
assert_eq!(s.status(), ServerStatus::Stopped);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn final_stop_cancels_pending_restarts_in_every_phase() {
|
||||
for phase in ["starting", "stopping", "between"] {
|
||||
let s = server();
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
let generation = s.begin_start().unwrap();
|
||||
if phase == "starting" {
|
||||
s.begin_stop(&sink, false);
|
||||
} else {
|
||||
let mut st = s.state.lock();
|
||||
st.conn = Conn::Stopping;
|
||||
st.restarting = true;
|
||||
}
|
||||
if phase == "between" {
|
||||
s.end_stop(generation, &sink);
|
||||
}
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(10), s.wait_ready(Duration::from_secs(30))).await.is_err()
|
||||
);
|
||||
s.begin_stop(&sink, true);
|
||||
s.end_stop(generation, &sink);
|
||||
assert_eq!(*s.phase.borrow(), Phase::Down);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(1), s.wait_ready(Duration::from_secs(30)))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_publication_can_restart_without_overwriting_the_new_phase() {
|
||||
for conn in [Conn::Missing, Conn::Failed] {
|
||||
|
||||
@@ -35,6 +35,8 @@ struct MockState {
|
||||
responder: Mutex<Option<Responder>>,
|
||||
/// Holds the handling of notifications of this method until the gate opens.
|
||||
held: Mutex<Option<(String, Arc<Gate>)>>,
|
||||
initialize_gate: Mutex<Option<Arc<Gate>>>,
|
||||
launch_gate: Mutex<Option<Arc<Gate>>>,
|
||||
peer: Mutex<Option<Peer>>,
|
||||
}
|
||||
|
||||
@@ -73,7 +75,9 @@ fn utf16_to_byte(text: &str, line: u64, col: u64) -> usize {
|
||||
text.len()
|
||||
}
|
||||
|
||||
struct MockHandler(Arc<MockState>);
|
||||
type MockConnection = Mutex<Option<Peer>>;
|
||||
|
||||
struct MockHandler(Arc<MockState>, std::sync::Weak<MockConnection>);
|
||||
|
||||
impl Handler for MockHandler {
|
||||
fn on_notification(&self, method: &str, params: Value) {
|
||||
@@ -118,8 +122,14 @@ impl Handler for MockHandler {
|
||||
s.docs.lock().remove(&uri);
|
||||
}
|
||||
"exit" => {
|
||||
if let Some(p) = s.peer.lock().take() {
|
||||
p.kill();
|
||||
if let Some(connection) = self.1.upgrade()
|
||||
&& let Some(peer) = connection.lock().take()
|
||||
{
|
||||
peer.kill();
|
||||
}
|
||||
let mut latest = s.peer.lock();
|
||||
if latest.as_ref().is_some_and(Peer::is_closed) {
|
||||
*latest = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -131,7 +141,13 @@ impl Handler for MockHandler {
|
||||
let s = &self.0;
|
||||
s.requests.lock().push((method.to_owned(), params.clone()));
|
||||
match method {
|
||||
"initialize" => Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } })),
|
||||
"initialize" => {
|
||||
let gate = s.initialize_gate.lock().take();
|
||||
if let Some(gate) = gate {
|
||||
gate.hold();
|
||||
}
|
||||
Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } }))
|
||||
}
|
||||
"shutdown" => Ok(Value::Null),
|
||||
_ => {
|
||||
if let Some(error) = s.errors.lock().get(method).cloned() {
|
||||
@@ -150,6 +166,7 @@ impl Handler for MockHandler {
|
||||
#[derive(Default)]
|
||||
struct MockLauncher {
|
||||
servers: Mutex<HashMap<String, Arc<MockState>>>,
|
||||
connections: Mutex<Vec<Arc<MockConnection>>>,
|
||||
}
|
||||
|
||||
impl MockLauncher {
|
||||
@@ -186,10 +203,18 @@ impl Launcher for MockLauncher {
|
||||
state.docs.lock().clear();
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
let server = Peer::connect(sr, sw, Arc::new(MockHandler(state.clone())));
|
||||
let connection = Arc::new(Mutex::new(None));
|
||||
let server = Peer::connect(sr, sw, Arc::new(MockHandler(state.clone(), Arc::downgrade(&connection))));
|
||||
*connection.lock() = Some(server.clone());
|
||||
self.connections.lock().push(connection);
|
||||
*state.peer.lock() = Some(server);
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
Ok(Peer::connect(cr, cw, handler))
|
||||
let client = Peer::connect(cr, cw, handler);
|
||||
let gate = state.launch_gate.lock().take();
|
||||
if let Some(gate) = gate {
|
||||
gate.hold();
|
||||
}
|
||||
Ok(client)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +420,45 @@ fn change(range: Range, text: &str) -> AppliedChange {
|
||||
AppliedChange { range, text: text.into() }
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn dart_file_watching_requires_an_explicit_registration() {
|
||||
for (language, extension, name, fallback) in
|
||||
[("dart", "dart", "dart", false), ("dart", "dart", "alternative-dart", true), ("java", "java", "jdtls", true)]
|
||||
{
|
||||
let h = harness_for(language, extension, &[(name, incremental_caps())], &[]);
|
||||
let (doc, _) = h.open(&format!("open.{extension}"), "x");
|
||||
h.wait_status(name, ServerStatus::Ready).await;
|
||||
let s = h.launcher.state(name);
|
||||
let path = h.root.join(format!("closed.{extension}"));
|
||||
let notify = || h.mgr.watched_files_changed(&[], std::slice::from_ref(&path), &[], &[]);
|
||||
notify();
|
||||
h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.unwrap();
|
||||
assert_eq!(s.notified("workspace/didChangeWatchedFiles").len(), usize::from(fallback), "{name}");
|
||||
s.peer()
|
||||
.request::<_, Value>(
|
||||
"client/registerCapability",
|
||||
json!({ "registrations": [{ "id": "watch", "method": "workspace/didChangeWatchedFiles", "registerOptions": { "watchers": [{ "globPattern": "**/*" }] } }] }),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
notify();
|
||||
eventually(|| s.notified("workspace/didChangeWatchedFiles").len() == usize::from(fallback) + 1).await;
|
||||
s.peer()
|
||||
.request::<_, Value>(
|
||||
"client/unregisterCapability",
|
||||
json!({ "unregisterations": [{ "id": "watch", "method": "workspace/didChangeWatchedFiles" }] }),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
notify();
|
||||
h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.unwrap();
|
||||
assert_eq!(s.notified("workspace/didChangeWatchedFiles").len(), usize::from(fallback) * 2 + 1, "{name}");
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tests
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -420,6 +484,451 @@ async fn linked_projects_initialize_separate_language_server_roots() {
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
fn java_siblings(h: &Harness) -> Vec<PathBuf> {
|
||||
["first", "second"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let root = h.root.join(name);
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::write(root.join("mock.toml"), "").unwrap();
|
||||
let path = root.join("Main.java");
|
||||
std::fs::write(&path, "class Main {}").unwrap();
|
||||
path
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_is_opt_in_and_keeps_module_requests_on_one_instance() {
|
||||
for shared in [false, true] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(move || shared));
|
||||
let paths = java_siblings(&h);
|
||||
std::thread::scope(|scope| {
|
||||
for path in &paths {
|
||||
scope.spawn(|| h.mgr.did_open(DocId::next(), path, &h.lang, 1, "class Main {}"));
|
||||
}
|
||||
});
|
||||
let state = h.launcher.state("jdtls");
|
||||
let count = if shared { 1 } else { 2 };
|
||||
eventually(|| state.requested("initialize").len() == count).await;
|
||||
assert_eq!(state.launches.load(Ordering::SeqCst), count);
|
||||
assert_eq!(h.mgr.servers().len(), count);
|
||||
for path in &paths {
|
||||
h.mgr.java_classpath(path.parent().unwrap(), false, &Default::default()).await.unwrap();
|
||||
}
|
||||
let requests = state.requested("workspace/executeCommand");
|
||||
let classpaths: Vec<_> = requests.iter().filter(|r| r["command"] == "java.project.getClasspaths").collect();
|
||||
assert_eq!(classpaths.len(), 2);
|
||||
for path in &paths {
|
||||
assert!(classpaths.iter().any(|r| r["arguments"][0] == uri_of(path.parent().unwrap())));
|
||||
}
|
||||
if shared {
|
||||
let init = &state.requested("initialize")[0];
|
||||
assert_eq!(init["rootUri"], uri_of(&h.root));
|
||||
assert_eq!(init["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
|
||||
}
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_toggle_retires_old_instances_and_reopens_every_document() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let shared = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let shared = shared.clone();
|
||||
move || shared.load(Ordering::SeqCst)
|
||||
}));
|
||||
let paths = java_siblings(&h);
|
||||
for path in &paths {
|
||||
h.mgr.did_open(DocId::next(), path, &h.lang, 1, "class Main {}");
|
||||
}
|
||||
let state = h.launcher.state("jdtls");
|
||||
eventually(|| state.requested("initialize").len() == 2).await;
|
||||
eventually(|| {
|
||||
paths
|
||||
.iter()
|
||||
.all(|path| state.notified("textDocument/didOpen").iter().any(|v| v["textDocument"]["uri"] == uri_of(path)))
|
||||
})
|
||||
.await;
|
||||
let old: Vec<_> = h.mgr.servers().into_iter().map(|s| s.id).collect();
|
||||
shared.store(true, Ordering::SeqCst);
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| state.launches.load(Ordering::SeqCst) == 3 && state.docs.lock().len() == 2).await;
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
for id in old {
|
||||
assert!(
|
||||
h.events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|event| matches!(event, IdeEvent::LanguageServerRemoved {server} if *server == id))
|
||||
);
|
||||
}
|
||||
shared.store(false, Ordering::SeqCst);
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| state.launches.load(Ordering::SeqCst) == 5).await;
|
||||
assert_eq!(h.mgr.servers().len(), 2);
|
||||
eventually(|| {
|
||||
paths.iter().all(|path| {
|
||||
state.notified("textDocument/didOpen").iter().filter(|v| v["textDocument"]["uri"] == uri_of(path)).count()
|
||||
== 3
|
||||
})
|
||||
})
|
||||
.await;
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_cancels_a_retiring_initialize_before_starting_its_replacement() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let shared = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let shared = shared.clone();
|
||||
move || shared.load(Ordering::SeqCst)
|
||||
}));
|
||||
let state = h.launcher.state("jdtls");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.initialize_gate.lock() = Some(gate.clone());
|
||||
let path = java_siblings(&h).remove(0);
|
||||
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
|
||||
eventually(|| gate.entered()).await;
|
||||
let old = state.peer();
|
||||
shared.store(true, Ordering::SeqCst);
|
||||
let switched = tokio::time::timeout(Duration::from_secs(2), h.mgr.reconfigure()).await;
|
||||
let closed_before_response = old.is_closed();
|
||||
gate.open();
|
||||
switched.expect("initialize cancellation must not wait for its response");
|
||||
eventually(|| state.requested("initialize").len() == 2).await;
|
||||
assert!(closed_before_response, "the old transport must close before its initialize response is released");
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
assert_eq!(h.mgr.servers()[0].root.as_ref(), h.root.as_path());
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn shared_java_waits_for_an_abandoned_launcher_before_replacing_the_workspace() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(|| true));
|
||||
let state = h.launcher.state("jdtls");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.launch_gate.lock() = Some(gate.clone());
|
||||
h.open("Main.java", "class Main {}");
|
||||
eventually(|| gate.entered()).await;
|
||||
let old = state.peer();
|
||||
let id = h.mgr.servers()[0].id;
|
||||
let external = tempfile::tempdir().unwrap();
|
||||
h.mgr.set_content_roots(vec![dunce::canonicalize(external.path()).unwrap()]);
|
||||
eventually(|| h.mgr.servers().iter().all(|s| s.id != id)).await;
|
||||
h.open("Another.java", "class Another {}");
|
||||
let mut transition = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.reconfigure().await }
|
||||
});
|
||||
let pending = tokio::time::timeout(Duration::from_millis(100), &mut transition).await.is_err();
|
||||
let launches = state.launches.load(Ordering::SeqCst);
|
||||
let old_alive = !old.is_closed();
|
||||
gate.open();
|
||||
assert!(pending, "retirement must wait for the launcher to return");
|
||||
assert_eq!(launches, 1, "the retiring launcher still owns its peer");
|
||||
assert!(old_alive);
|
||||
transition.await.unwrap();
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| state.launches.load(Ordering::SeqCst) == 2 && state.docs.lock().len() == 2).await;
|
||||
assert!(old.is_closed());
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_classpath_waits_for_a_config_restart_that_is_reaping_startup() {
|
||||
for (shared, failed) in [(false, false), (true, false), (false, true)] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let changed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let changed = changed.clone();
|
||||
move || shared && changed.load(Ordering::SeqCst)
|
||||
}));
|
||||
h.mgr.set_config_hook(Box::new({
|
||||
let changed = changed.clone();
|
||||
move |cfg, _| {
|
||||
let changed = changed.load(Ordering::SeqCst);
|
||||
cfg.env.insert("MOCK_CONFIG_REVISION".into(), changed.to_string());
|
||||
if failed && changed {
|
||||
cfg.launch_error = Some("restart configuration error".into());
|
||||
}
|
||||
}
|
||||
}));
|
||||
let state = h.launcher.state("jdtls");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.launch_gate.lock() = Some(gate.clone());
|
||||
let path = java_siblings(&h).remove(0);
|
||||
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
|
||||
eventually(|| gate.entered()).await;
|
||||
let old = h.mgr.servers()[0].id;
|
||||
changed.store(true, Ordering::SeqCst);
|
||||
let restarting = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.reconfigure().await }
|
||||
});
|
||||
h.wait_status("jdtls", ServerStatus::Stopped).await;
|
||||
let mut query = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.java_classpath(path.parent().unwrap(), false, &Default::default()).await }
|
||||
});
|
||||
let early = tokio::time::timeout(Duration::from_millis(100), &mut query).await;
|
||||
gate.open();
|
||||
restarting.await.unwrap();
|
||||
let result = match early {
|
||||
Ok(result) => result.unwrap(),
|
||||
Err(_) => query.await.unwrap(),
|
||||
};
|
||||
if failed {
|
||||
assert!(result.unwrap_err().message.contains("restart configuration error"));
|
||||
} else {
|
||||
assert!(result.is_ok(), "a configuration restart is transient: {result:?}");
|
||||
assert_eq!(state.launches.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(h.mgr.servers()[0].id != old, shared);
|
||||
}
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_classpath_restart_barrier_wait_remains_cancellable() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let changed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_config_hook(Box::new({
|
||||
let changed = changed.clone();
|
||||
move |cfg, _| {
|
||||
cfg.env.insert("MOCK_CONFIG_REVISION".into(), changed.load(Ordering::SeqCst).to_string());
|
||||
}
|
||||
}));
|
||||
let state = h.launcher.state("jdtls");
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.launch_gate.lock() = Some(gate.clone());
|
||||
h.open("Main.java", "class Main {}");
|
||||
eventually(|| gate.entered()).await;
|
||||
changed.store(true, Ordering::SeqCst);
|
||||
let restarting = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.reconfigure().await }
|
||||
});
|
||||
h.wait_status("jdtls", ServerStatus::Stopped).await;
|
||||
let cancel = CancellationToken::new();
|
||||
let mut query = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
let root = h.root.clone();
|
||||
let cancel = cancel.clone();
|
||||
async move { manager.java_classpath(&root, false, &cancel).await }
|
||||
});
|
||||
let early = tokio::time::timeout(Duration::from_millis(100), &mut query).await;
|
||||
let waiting = early.is_err();
|
||||
cancel.cancel();
|
||||
let result = match early {
|
||||
Ok(result) => Ok(result),
|
||||
Err(_) => tokio::time::timeout(Duration::from_secs(1), query).await,
|
||||
};
|
||||
gate.open();
|
||||
restarting.await.unwrap();
|
||||
assert!(waiting, "the query must wait behind the restart barrier");
|
||||
assert_eq!(result.unwrap().unwrap().unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_classpath_does_not_retry_permanently_unavailable_servers() {
|
||||
for missing in [false, true] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
if missing {
|
||||
h.launcher.state("jdtls").missing.store(true, Ordering::SeqCst);
|
||||
} else {
|
||||
h.mgr.set_config_hook(Box::new(|cfg, _| {
|
||||
cfg.launch_error = Some("permanent mock configuration error".into())
|
||||
}));
|
||||
}
|
||||
let result =
|
||||
tokio::time::timeout(Duration::from_secs(1), h.mgr.java_classpath(&h.root, false, &Default::default()))
|
||||
.await
|
||||
.expect("permanent failures must not incur the Java readiness timeout");
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.message.contains(if missing { "not installed" } else { "permanent mock configuration error" }));
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn shared_java_external_roots_reach_initialization_queries_and_watches() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(|| true));
|
||||
let external = tempfile::tempdir().unwrap();
|
||||
let external = dunce::canonicalize(external.path()).unwrap();
|
||||
h.mgr.set_content_roots(vec![external.clone(), external.clone(), h.root.clone()]);
|
||||
let file = external.join("External.java");
|
||||
std::fs::write(&file, "class External {}").unwrap();
|
||||
h.mgr.did_open(DocId::next(), &file, &h.lang, 1, "class External {}");
|
||||
let state = h.launcher.state("jdtls");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let init = &state.requested("initialize")[0];
|
||||
assert_eq!(init["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root), uri_of(&external)]));
|
||||
assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
|
||||
let folders: Value = state.peer().request("workspace/workspaceFolders", Value::Null, None).await.unwrap();
|
||||
assert_eq!(folders, init["workspaceFolders"]);
|
||||
state.peer().request::<_, Value>("client/registerCapability", json!({"registrations":[{
|
||||
"id":"external-java", "method":"workspace/didChangeWatchedFiles", "registerOptions":{"watchers":[{"globPattern":"**/*.java"}]}
|
||||
}]}), None).await.unwrap();
|
||||
let other = external.join("Other.java");
|
||||
h.mgr.watched_files_changed(std::slice::from_ref(&other), &[], &[], &[]);
|
||||
eventually(|| {
|
||||
state.notified("workspace/didChangeWatchedFiles").iter().any(|v| v["changes"][0]["uri"] == uri_of(&other))
|
||||
})
|
||||
.await;
|
||||
h.mgr.set_content_roots(vec![]);
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| state.requested("initialize").len() == 2).await;
|
||||
assert_eq!(state.requested("initialize")[1]["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn trusting_an_existing_java_instance_restarts_it_without_locking_the_registry() {
|
||||
for shared in [false, true] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(move || shared));
|
||||
h.open("Main.java", "class Main {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
h.mgr.set_execution_allowed(false);
|
||||
h.mgr.reconfigure().await;
|
||||
h.mgr.set_execution_allowed(true);
|
||||
eventually(|| state.launches.load(Ordering::SeqCst) == 2 && state.docs.lock().len() == 1).await;
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_preserves_other_servers_and_marker_scoped_fallbacks() {
|
||||
for fallback in [false, true] {
|
||||
let h = harness_for(
|
||||
"java",
|
||||
"java",
|
||||
&[("jdtls", incremental_caps()), ("other", incremental_caps())],
|
||||
if fallback { &["other"] } else { &[] },
|
||||
);
|
||||
h.mgr.set_jdtls_shared_hook(Box::new(|| true));
|
||||
h.launcher.state("jdtls").missing.store(fallback, Ordering::SeqCst);
|
||||
for path in java_siblings(&h) {
|
||||
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
|
||||
}
|
||||
eventually(|| h.launcher.state("other").requested("initialize").len() == 2).await;
|
||||
let servers = h.mgr.servers();
|
||||
assert_eq!(servers.iter().filter(|s| s.name == "jdtls").count(), 1);
|
||||
assert_eq!(servers.iter().filter(|s| s.name == "other").count(), 2);
|
||||
assert_eq!(h.launcher.state("jdtls").launches.load(Ordering::SeqCst), usize::from(!fallback));
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_sharing_keeps_target_instances_separate_and_preserves_them_on_toggle() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let shared = Arc::new(std::sync::atomic::AtomicBool::new(true));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let shared = shared.clone();
|
||||
move || shared.load(Ordering::SeqCst)
|
||||
}));
|
||||
h.mgr.set_servers_hook(Box::new(|_, configs| {
|
||||
let mut target = configs[0].clone();
|
||||
target.target_key = Some("container".into());
|
||||
target
|
||||
.initialization_options
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.as_table_mut()
|
||||
.unwrap()
|
||||
.insert("selectedTarget".into(), toml::Value::Boolean(true));
|
||||
vec![configs[0].clone(), target]
|
||||
}));
|
||||
h.open("Main.java", "class Main {}");
|
||||
for path in java_siblings(&h) {
|
||||
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
|
||||
}
|
||||
let state = h.launcher.state("jdtls");
|
||||
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 4).await;
|
||||
let initial = state.requested("initialize");
|
||||
assert_eq!(initial.len(), 4);
|
||||
let targets: Vec<_> = initial.iter().filter(|v| v["initializationOptions"]["selectedTarget"] == true).collect();
|
||||
assert_eq!(targets.len(), 3, "target roots retain their marker-based identity");
|
||||
assert!(targets.iter().all(|v| v["initializationOptions"].get("workspaceFolders").is_none()));
|
||||
let local = initial.iter().find(|v| v["initializationOptions"]["selectedTarget"] != true).unwrap();
|
||||
assert_eq!(local["rootUri"], uri_of(&h.root));
|
||||
assert_eq!(local["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
|
||||
let old: HashSet<_> = h.mgr.servers().into_iter().map(|s| s.id).collect();
|
||||
shared.store(false, Ordering::SeqCst);
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 6).await;
|
||||
assert_eq!(
|
||||
h.mgr.servers().iter().filter(|s| old.contains(&s.id)).count(),
|
||||
3,
|
||||
"local sharing does not retire targets"
|
||||
);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn virtual_java_documents_opened_during_a_toggle_attach_to_the_successor() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let shared = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
h.mgr.set_jdtls_shared_hook(Box::new({
|
||||
let shared = shared.clone();
|
||||
move || shared.load(Ordering::SeqCst)
|
||||
}));
|
||||
let (doc, _) = h.open("Main.java", "class Main {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
let uris: Vec<_> = (0..16).map(|n| format!("jdt://contents/project/Virtual{n}.class")).collect();
|
||||
state.respond(
|
||||
"textDocument/definition",
|
||||
json!(
|
||||
uris.iter()
|
||||
.map(|uri| json!({"uri":uri,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}}}))
|
||||
.collect::<Vec<_>>()
|
||||
),
|
||||
);
|
||||
h.mgr.goto(doc, Pos::default(), GotoKind::Definition).await.unwrap();
|
||||
shared.store(true, Ordering::SeqCst);
|
||||
let toggle = tokio::spawn({
|
||||
let manager = h.mgr.clone();
|
||||
async move { manager.reconfigure().await }
|
||||
});
|
||||
let mut opened = Vec::new();
|
||||
for uri in uris {
|
||||
let manager = h.mgr.clone();
|
||||
let language = h.lang.clone();
|
||||
let doc = DocId::next();
|
||||
opened.push((
|
||||
doc,
|
||||
std::thread::spawn(move || manager.did_open_virtual(doc, &uri, &language, 0, "class Virtual {}")),
|
||||
));
|
||||
}
|
||||
let docs: Vec<_> = opened
|
||||
.into_iter()
|
||||
.map(|(doc, thread)| {
|
||||
thread.join().unwrap();
|
||||
doc
|
||||
})
|
||||
.collect();
|
||||
toggle.await.unwrap();
|
||||
eventually(|| state.docs.lock().len() == docs.len() + 1).await;
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
for doc in docs {
|
||||
h.mgr.hover(doc, Pos::default(), Default::default()).await.unwrap();
|
||||
}
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn files_changed_on_disk_reach_the_servers_of_their_language() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
@@ -1877,6 +2386,34 @@ async fn java_source_paths_reach_initialization_live_configuration_and_builds()
|
||||
assert_eq!(init["initializationOptions"]["triggerFiles"], json!([uri_of(&file)]));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_build_waits_for_a_reconfigured_server_to_restart() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("App.java", "class App {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
state.respond("java/buildWorkspace", json!(1));
|
||||
let gate = Arc::new(Gate::default());
|
||||
*state.held.lock() = Some(("exit".into(), gate.clone()));
|
||||
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.args.push("--new-configuration".into())));
|
||||
let reconfigure = tokio::spawn({
|
||||
let mgr = h.mgr.clone();
|
||||
async move { mgr.reconfigure().await }
|
||||
});
|
||||
eventually(|| gate.entered()).await;
|
||||
let mut build = tokio::spawn({
|
||||
let (mgr, root) = (h.mgr.clone(), h.root.clone());
|
||||
async move { mgr.java_build(&root, false, &Default::default()).await }
|
||||
});
|
||||
let early = tokio::time::timeout(Duration::from_millis(100), &mut build).await;
|
||||
gate.open();
|
||||
reconfigure.await.unwrap();
|
||||
assert!(early.is_err(), "a planned restart must not fail a pending build: {early:?}");
|
||||
assert_eq!(build.await.unwrap().unwrap(), ide_lsp::jdtls::JavaBuildStatus::Succeeded);
|
||||
assert_eq!(state.launches.load(Ordering::SeqCst), 2);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_build_asks_for_a_full_build_when_rebuilding() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
@@ -2730,7 +3267,7 @@ async fn noop_handler_is_usable_for_raw_peers() {
|
||||
let (a, b) = tokio::io::duplex(1024);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
let (br, bw) = tokio::io::split(b);
|
||||
let _server = Peer::connect(br, bw, Arc::new(MockHandler(Arc::default())));
|
||||
let _server = Peer::connect(br, bw, Arc::new(MockHandler(Arc::default(), std::sync::Weak::new())));
|
||||
let client = Peer::connect(ar, aw, Arc::new(NoopHandler));
|
||||
let v: Value = client.request("shutdown", (), None).await.unwrap();
|
||||
assert_eq!(v, Value::Null);
|
||||
@@ -5136,6 +5673,39 @@ async fn jdtls_import_failure_names_reserved_device_files_on_windows() {
|
||||
let _ = std::fs::remove_file(&nul);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn gradle_incompatible_jdk_log_is_surfaced_once_across_message_channels() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
h.open("A.java", "class A {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let failure = "Can't use Java 27.0.1.0.3 and Gradle 9.7.1 to import Gradle project kotlin-gradle.";
|
||||
for _ in 0..2 {
|
||||
s.peer()
|
||||
.notify(
|
||||
"window/logMessage",
|
||||
json!({ "type": 1, "message": format!("{failure}\njava.lang.Exception: details stay in the log") }),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { body: Some(b), .. } if b == failure)))
|
||||
.await;
|
||||
s.peer().notify("window/showMessage", json!({ "type": 1, "message": failure })).unwrap();
|
||||
s.peer().notify("window/logMessage", json!({ "type": 1, "message": "unrelated internal error" })).unwrap();
|
||||
s.peer().notify("window/showMessage", json!({ "type": 2, "message": "after" })).unwrap();
|
||||
eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { body: Some(b), .. } if b == "after")))
|
||||
.await;
|
||||
let events = h.events.lock();
|
||||
let messages: Vec<_> = events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Notify { body: Some(body), .. } => Some(body.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(messages, vec![failure, "after"]);
|
||||
}
|
||||
|
||||
/// Pull Members Up / Push Members Down: the extension's answers, refusals and edits.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_hierarchy_refactorings_use_the_extension_commands() {
|
||||
|
||||
@@ -63,6 +63,104 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires OPENIDEA_TEST_JDTLS and OPENIDEA_TEST_GRADLE_HOME, with JDK 21+"]
|
||||
async fn shared_jdtls_imports_independent_gradle_builds_and_external_root() {
|
||||
use serde_json::json;
|
||||
|
||||
let jdtls = std::env::var("OPENIDEA_TEST_JDTLS").expect("set OPENIDEA_TEST_JDTLS");
|
||||
let gradle_home = std::env::var("OPENIDEA_TEST_GRADLE_HOME").expect("set OPENIDEA_TEST_GRADLE_HOME");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let temp_root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let root = temp_root.join("workspace");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let external = temp_root.join("external");
|
||||
let mut files = Vec::new();
|
||||
for (name, build) in [("alpha", root.join("alpha")), ("beta", root.join("beta")), ("external", external.clone())] {
|
||||
let source_dir = build.join("app/src/main/java/demo");
|
||||
std::fs::create_dir_all(&source_dir).unwrap();
|
||||
std::fs::write(build.join("settings.gradle"), format!("rootProject.name = '{name}'\ninclude 'app'\n")).unwrap();
|
||||
std::fs::write(build.join("build.gradle"), "").unwrap();
|
||||
std::fs::write(build.join("app/build.gradle"), "plugins { id 'java' }\n").unwrap();
|
||||
let source = format!(
|
||||
"package demo; public class Main {{ public static void main(String[] args) {{ {name}Marker(); }} static void {name}Marker() {{}} }}\n"
|
||||
);
|
||||
let path = source_dir.join("Main.java");
|
||||
std::fs::write(&path, &source).unwrap();
|
||||
files.push((name, path, source));
|
||||
}
|
||||
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
|
||||
java.language_servers.retain(|s| s.name == "jdtls");
|
||||
let server = &mut java.language_servers[0];
|
||||
server.command = jdtls;
|
||||
server.env.insert("GRADLE_USER_HOME".into(), dir.path().join("gradle-user-home").to_string_lossy().into_owned());
|
||||
server.args.extend(["-data".into(), dir.path().join("jdtls-data").to_string_lossy().into_owned()]);
|
||||
let options = server.initialization_options.as_mut().unwrap().as_table_mut().unwrap();
|
||||
let settings = options.get_mut("settings").unwrap().as_table_mut().unwrap();
|
||||
let java_settings = settings.get_mut("java").unwrap().as_table_mut().unwrap();
|
||||
let imports = java_settings.get_mut("import").unwrap().as_table_mut().unwrap();
|
||||
let gradle = imports.get_mut("gradle").unwrap().as_table_mut().unwrap();
|
||||
gradle.insert("home".into(), toml::Value::String(gradle_home));
|
||||
gradle.insert("wrapper".into(), toml::Value::try_from(json!({"enabled": false})).unwrap());
|
||||
if let Ok(home) = std::env::var("OPENIDEA_TEST_GRADLE_JAVA_HOME") {
|
||||
gradle.insert("java".into(), toml::Value::try_from(json!({"home": home})).unwrap());
|
||||
}
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java]));
|
||||
mgr.set_jdtls_shared_hook(Box::new(|| true));
|
||||
mgr.set_content_roots(vec![external]);
|
||||
for (_, path, source) in &files {
|
||||
mgr.did_open(DocId::next(), path, &LanguageId::new("java"), 1, source);
|
||||
}
|
||||
retry("shared jdtls ready", Duration::from_secs(60), || {
|
||||
let status = last_status(&events, "jdtls");
|
||||
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, message)) = &status {
|
||||
panic!("shared jdtls failed: {message:?}");
|
||||
}
|
||||
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
|
||||
})
|
||||
.await;
|
||||
for (name, path, _) in &files {
|
||||
let cp = retry(&format!("{name} Gradle classpath"), Duration::from_secs(180), || async {
|
||||
let cp =
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.java_classpath(path, false, &Default::default()))
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
cp.class_paths.iter().any(|p| p.contains(&format!("/{name}/app/"))).then_some(cp)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
cp.class_paths
|
||||
.iter()
|
||||
.all(|p| !files.iter().any(|(other, _, _)| other != name && p.contains(&format!("/{other}/app/")))),
|
||||
"classpath leaked between builds: {cp:?}"
|
||||
);
|
||||
let launch = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
mgr.java_launch(path, false, false, false, false, &Default::default()),
|
||||
)
|
||||
.await
|
||||
.expect("launch resolution timed out")
|
||||
.expect("launch resolution failed");
|
||||
assert_eq!(launch.class_paths, cp.class_paths, "launch resolved a different build for {name}");
|
||||
}
|
||||
let servers = mgr.servers().into_iter().filter(|s| s.name == "jdtls").collect::<Vec<_>>();
|
||||
assert_eq!(servers.len(), 1, "expected one JDT LS: {servers:?}");
|
||||
assert!(servers[0].pid.is_some(), "JDT LS process is not running");
|
||||
for (_, path, _) in [&files[0], &files[2]] {
|
||||
let bad = path.parent().unwrap().join("Broken.java");
|
||||
let bad_source = "package demo; class Broken { MissingType x; }";
|
||||
std::fs::write(&bad, bad_source).unwrap();
|
||||
mgr.did_open(DocId::next(), &bad, &LanguageId::new("java"), 1, bad_source);
|
||||
retry("diagnostics from shared JDT LS", Duration::from_secs(120), || {
|
||||
let found = events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { path, items, .. } if path.0 == bad && items.iter().any(|d| d.message.contains("MissingType"))));
|
||||
async move { found.then_some(()) }
|
||||
}).await;
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires kmp-lsp on PATH (or OPENIDEA_TEST_KMP_LSP)"]
|
||||
async fn kmp_lsp_java_end_to_end() {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! than one place is taken from the first, in that order.
|
||||
//!
|
||||
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script,
|
||||
//! Application, Spring Boot and compound configurations become the matching kinds; other types
|
||||
//! are skipped with their type.
|
||||
//! Application, Spring Boot, Go Application / Go Test and compound configurations become the
|
||||
//! matching kinds; other types are skipped with their type.
|
||||
//! `$PROJECT_DIR$` (and `$MODULE_DIR$`, `$MODULE_WORKING_DIR$`, `$ContentRoot$`,
|
||||
//! `$ProjectFileDir$`, all the project directory here) and `$USER_HOME$` are replaced; another
|
||||
//! path variable skips the configuration.
|
||||
@@ -165,6 +165,8 @@ const SHELL: &str = "ShConfigurationType";
|
||||
const COMPOUND: &str = "CompoundRunConfigurationType";
|
||||
const APPLICATION: &str = "Application";
|
||||
const SPRING_BOOT: &str = "SpringBootApplicationConfigurationType";
|
||||
const GO_APPLICATION: &str = "GoApplicationRunConfiguration";
|
||||
const GO_TEST: &str = "GoTestRunConfiguration";
|
||||
|
||||
type Res<T> = Result<T, SkipReason>;
|
||||
|
||||
@@ -343,6 +345,8 @@ impl Cx<'_> {
|
||||
SHELL => self.shell(name, c)?,
|
||||
APPLICATION => self.java_app(name, c, false, &mut ignored)?,
|
||||
SPRING_BOOT => self.java_app(name, c, true, &mut ignored)?,
|
||||
GO_APPLICATION => self.go_config(name, c, false, &mut ignored)?,
|
||||
GO_TEST => self.go_config(name, c, true, &mut ignored)?,
|
||||
COMPOUND => {
|
||||
members = c.children_named("toRun").filter_map(|t| t.attr("name")).map(str::to_owned).collect();
|
||||
config(name, "compound", String::new(), Vec::new(), None)
|
||||
@@ -353,6 +357,84 @@ impl Cx<'_> {
|
||||
Ok(Found { config, members, ignored })
|
||||
}
|
||||
|
||||
/// A Go Application (`GoApplicationRunConfiguration`) or Go Test configuration. Both share
|
||||
/// the kind (package / file / directory), the target node, `go_parameters` (go tool
|
||||
/// arguments) and envs; a test adds a `pattern` and program arguments go after `-args`.
|
||||
fn go_config(&self, name: &str, c: &El, test: bool, _ignored: &mut Vec<String>) -> Res<RunConfig> {
|
||||
let (dir, cwd) = self.cwd(c.value("working_directory"))?;
|
||||
let scope = match c.value("kind").unwrap_or("PACKAGE") {
|
||||
"FILE" => "file",
|
||||
"DIRECTORY" => "directory",
|
||||
_ => "package",
|
||||
};
|
||||
let path_target = |value: Option<&str>| -> Res<Option<String>> {
|
||||
Ok(self.path(value)?.map(|p| path_from(self.root, p, &dir)).filter(|p| !p.is_empty()))
|
||||
};
|
||||
let target = match scope {
|
||||
"file" => path_target(c.value("filePath"))?
|
||||
.or(self.text(c.value("package"))?)
|
||||
.filter(|t| !t.is_empty())
|
||||
.unwrap_or_else(|| ".".to_owned()),
|
||||
"directory" => path_target(c.value("directory"))?.unwrap_or_else(|| ".".to_owned()),
|
||||
_ => self.text(c.value("package"))?.filter(|t| !t.is_empty()).unwrap_or_else(|| ".".to_owned()),
|
||||
};
|
||||
let raw_tool = self.text(c.value("go_parameters"))?.unwrap_or_default();
|
||||
let tool_args = parse_params(&raw_tool, Quotes::Double);
|
||||
let raw_program = self.text(c.value("parameters"))?.unwrap_or_default();
|
||||
let program_args = parse_params(&raw_program, Quotes::Double);
|
||||
let pattern = self.text(c.value("pattern"))?.unwrap_or_default();
|
||||
let output_dir =
|
||||
self.path(c.value("output_directory"))?.map(|p| path_from(self.root, p, self.root)).unwrap_or_default();
|
||||
// The form's module is the working directory relative to the project root.
|
||||
let module = cwd.as_ref().map(|d| d.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
||||
let run_after_build = c.option("run") != Some("false");
|
||||
let targets: Vec<String> = if scope == "file" {
|
||||
target.split('|').map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()).collect()
|
||||
} else {
|
||||
vec![target.clone()]
|
||||
};
|
||||
let mut args = vec![if test { "test".to_owned() } else { "build".to_owned() }];
|
||||
args.extend(tool_args.clone());
|
||||
if test {
|
||||
if !pattern.is_empty() {
|
||||
args.extend(["-run".to_owned(), pattern.clone()]);
|
||||
}
|
||||
args.extend(targets);
|
||||
if !program_args.is_empty() {
|
||||
args.push("-args".to_owned());
|
||||
args.extend(program_args.clone());
|
||||
}
|
||||
} else {
|
||||
args.extend(targets);
|
||||
}
|
||||
let mut rc = config(name, "go", "go".into(), args, Some("delve"));
|
||||
rc.cwd = cwd.map(Into::into);
|
||||
rc.is_test = test;
|
||||
let mut params = BTreeMap::from([
|
||||
("goKind".to_owned(), if test { "test" } else { "build" }.to_owned()),
|
||||
("scope".to_owned(), scope.to_owned()),
|
||||
("target".to_owned(), target),
|
||||
("toolArgs".to_owned(), raw_tool),
|
||||
("programArgs".to_owned(), raw_program),
|
||||
("module".to_owned(), module),
|
||||
]);
|
||||
if test {
|
||||
if !pattern.is_empty() {
|
||||
params.insert("pattern".to_owned(), pattern);
|
||||
}
|
||||
} else {
|
||||
params.insert("outputDir".to_owned(), output_dir);
|
||||
params.insert("runAfterBuild".to_owned(), run_after_build.to_string());
|
||||
}
|
||||
if !program_args.is_empty() {
|
||||
params.insert("programArgv".to_owned(), serde_json::to_string(&program_args).unwrap_or_default());
|
||||
}
|
||||
params.retain(|_, value| !value.is_empty());
|
||||
rc.extras.params = params;
|
||||
self.envs(c, &mut rc);
|
||||
Ok(rc)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Python
|
||||
|
||||
/// The interpreter (`SDK_HOME` when the configuration doesn't use the module's), its
|
||||
|
||||
@@ -1007,3 +1007,74 @@ fn file_urls_expand_module_and_home_macros() {
|
||||
assert_eq!(cwd(home), Some(PathBuf::from("jobs dir")));
|
||||
assert!(home.extras.local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_go_application_and_test_configurations() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
dir.path(),
|
||||
".run/build-linux-amd64.run.xml",
|
||||
&run_file(
|
||||
"build-linux-amd64",
|
||||
r#"<configuration default="false" name="NAME" type="GoApplicationRunConfiguration" factoryName="Go Application" folderName="go-build">
|
||||
<module name="goproject" />
|
||||
<working_directory value="$PROJECT_DIR$" />
|
||||
<go_parameters value="-ldflags "-s -w" -o bin/goproject.linux-amd64" />
|
||||
<envs>
|
||||
<env name="CGO_ENABLED" value="0" />
|
||||
<env name="GOARCH" value="amd64" />
|
||||
<env name="GOOS" value="linux" />
|
||||
</envs>
|
||||
<kind value="PACKAGE" />
|
||||
<package value="goproject" />
|
||||
<directory value="$PROJECT_DIR$" />
|
||||
<filePath value="$PROJECT_DIR$/main.go" />
|
||||
<output_directory value="$PROJECT_DIR$/bin" />
|
||||
<option name="run" value="false" />
|
||||
<method v="2" />
|
||||
</configuration>"#,
|
||||
),
|
||||
);
|
||||
write(
|
||||
dir.path(),
|
||||
".run/Test.run.xml",
|
||||
&run_file(
|
||||
"Run tests",
|
||||
r#"<configuration default="false" name="NAME" type="GoTestRunConfiguration" factoryName="Go Test">
|
||||
<module name="goproject" />
|
||||
<working_directory value="$PROJECT_DIR$/pkg" />
|
||||
<go_parameters value="-tags integration" />
|
||||
<kind value="PACKAGE" />
|
||||
<package value="example.com/pkg" />
|
||||
<pattern value="TestLogin" />
|
||||
<parameters value="-v -count=1" />
|
||||
<method v="2" />
|
||||
</configuration>"#,
|
||||
),
|
||||
);
|
||||
let r = import_from(dir.path(), &[]);
|
||||
|
||||
let app = get(&r, "build-linux-amd64");
|
||||
assert_eq!(app.kind, "go");
|
||||
assert_eq!(app.command, "go");
|
||||
assert_eq!(app.args, s(&["build", "-ldflags", "-s -w", "-o", "bin/goproject.linux-amd64", "goproject"]));
|
||||
assert_eq!(app.env["GOOS"], "linux");
|
||||
assert_eq!(app.extras.params["goKind"], "build");
|
||||
assert_eq!(app.extras.params["scope"], "package");
|
||||
assert_eq!(app.extras.params["target"], "goproject");
|
||||
assert_eq!(app.extras.params["outputDir"], "bin");
|
||||
assert_eq!(app.extras.params["runAfterBuild"], "false");
|
||||
assert_eq!(app.extras.folder.as_deref(), Some("go-build"));
|
||||
|
||||
let test = get(&r, "Run tests");
|
||||
assert!(test.is_test);
|
||||
assert_eq!(test.kind, "go");
|
||||
assert_eq!(
|
||||
test.args,
|
||||
s(&["test", "-tags", "integration", "-run", "TestLogin", "example.com/pkg", "-args", "-v", "-count=1"])
|
||||
);
|
||||
assert_eq!(cwd(test), Some(PathBuf::from("pkg")));
|
||||
assert_eq!(test.extras.params["goKind"], "test");
|
||||
assert_eq!(test.extras.params["pattern"], "TestLogin");
|
||||
assert_eq!(test.extras.params["module"], "pkg");
|
||||
}
|
||||
@@ -12,6 +12,7 @@ ide-core.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-run.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
quick-xml = "0.42"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
@@ -130,6 +130,34 @@ pub(crate) fn goroot(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
.or_else(|| pinned::pinned_install(root, Tool::Go).and_then(|(dir, _)| pinned::goroot_in_install(&dir)))
|
||||
}
|
||||
|
||||
/// Runtime choices for package-manager based IDE tool installs.
|
||||
#[derive(Default)]
|
||||
pub struct ToolInstallEnvironment {
|
||||
pub commands: std::collections::BTreeMap<String, PathBuf>,
|
||||
pub env: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Uses the same configured / project-pinned SDKs as Run and language servers.
|
||||
/// Build tags and target OS settings intentionally do not apply to host tools.
|
||||
pub fn tool_install_environment(root: &Path, s: &Settings) -> ToolInstallEnvironment {
|
||||
let mut runtime = ToolInstallEnvironment::default();
|
||||
if let Some(go) = goroot(root, s) {
|
||||
runtime.commands.insert("go".into(), go.join("bin").join(exe("go")));
|
||||
runtime.env.insert("GOROOT".into(), go.to_string_lossy().into_owned());
|
||||
}
|
||||
if let Some(node) = node_dir(root, s) {
|
||||
let node = setting_path(root, &s.languages.node.interpreter).unwrap_or_else(|| node.join(exe("node")));
|
||||
runtime.commands.insert("node".into(), node);
|
||||
}
|
||||
if let Some(ruby) = ruby_interpreter(root, s) {
|
||||
runtime.commands.insert("ruby".into(), ruby);
|
||||
}
|
||||
if let Some(python) = python_interpreter(root, s) {
|
||||
runtime.commands.insert("python".into(), python);
|
||||
}
|
||||
runtime
|
||||
}
|
||||
|
||||
/// A JDK setting: a JDK home, its `bin` or its `bin/java`.
|
||||
fn jdk_setting(root: &Path, value: &str) -> Option<Result<crate::java::Jdk, PathBuf>> {
|
||||
let p = setting_path(root, value)?;
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::exe;
|
||||
use crate::exec::{query_in, query_in_cancellable, which};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::settings::Settings;
|
||||
use quick_xml::{Reader, XmlVersion, events::Event};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
@@ -27,7 +28,7 @@ use std::time::{Duration, SystemTime};
|
||||
pub struct LibraryGroup {
|
||||
/// Display name, e.g. `Python 3.12 (.venv)`.
|
||||
pub name: String,
|
||||
/// `python` | `jdk` | `nodeModules` | `nodeCore` | `rust` | `cargo` | `go` | `goModules` | `maven` | `gradle`.
|
||||
/// `python` | `jdk` | `nodeModules` | `nodeCore` | `rust` | `cargo` | `go` | `goModules` | `maven` | `gradle` | `classpath`.
|
||||
pub kind: String,
|
||||
pub roots: Vec<LibraryRoot>,
|
||||
}
|
||||
@@ -148,6 +149,7 @@ pub fn project_libraries_stream(
|
||||
return Vec::new();
|
||||
}
|
||||
let mut groups = dependency_groups(root, &settings, dependencies);
|
||||
groups.extend(classpath_groups(&roots));
|
||||
groups.extend(project_structure_groups(root, &settings));
|
||||
if !cancel.is_cancelled() && !groups.is_empty() {
|
||||
on_groups(groups.clone());
|
||||
@@ -433,10 +435,20 @@ fn ruby_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<Lib
|
||||
// ------------------------------------------------------------------ jdk
|
||||
|
||||
fn jdk_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<LibraryGroup> {
|
||||
// Java modules without a build manifest carry `.classpath` / `.project` /
|
||||
// `.idea` instead; a configured JDK or declared source roots mark one too.
|
||||
let files = match manifests(root, JAVA_MANIFESTS) {
|
||||
Some(files) => files,
|
||||
None if !s.languages.java.jdk.trim().is_empty() || !s.project_structure.source_roots.is_empty() => Vec::new(),
|
||||
None => return Vec::new(),
|
||||
None => {
|
||||
let java = [".classpath", ".project"].iter().any(|f| root.join(f).is_file())
|
||||
|| root.join(".idea").is_dir()
|
||||
|| !s.languages.java.jdk.trim().is_empty()
|
||||
|| !s.project_structure.source_roots.is_empty();
|
||||
if !java {
|
||||
return Vec::new();
|
||||
}
|
||||
[".classpath", ".project"].iter().map(|f| root.join(f)).collect()
|
||||
}
|
||||
};
|
||||
cached_cancellable(root, "jdk", s.languages.java.jdk.clone(), &files, cancel, || {
|
||||
let jdk = crate::apply::project_jdk(root, s).or_else(|| {
|
||||
@@ -470,6 +482,84 @@ fn project_structure_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
|
||||
out
|
||||
}
|
||||
|
||||
/// JDT libraries of modules without a Maven / Gradle manifest: each `.classpath`
|
||||
/// in `roots` (the project root and its discovered modules) becomes a group.
|
||||
fn classpath_groups(roots: &[PathBuf]) -> Vec<LibraryGroup> {
|
||||
let mut out = Vec::new();
|
||||
for dir in roots {
|
||||
let file = dir.join(".classpath");
|
||||
let Ok(text) = std::fs::read_to_string(&file) else { continue };
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let entries: Vec<LibraryRoot> = classpath_entries(dir, &text)
|
||||
.into_iter()
|
||||
.filter(|path| seen.insert(dunce::canonicalize(path).unwrap_or_else(|_| path.clone())))
|
||||
.filter_map(|path| {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
if path.is_file() {
|
||||
Some(LibraryRoot { name, path: ide_vfs::archive_root(&path).into() })
|
||||
} else {
|
||||
path.is_dir().then(|| LibraryRoot { name, path: path.into() })
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !entries.is_empty() {
|
||||
let name = dir
|
||||
.file_name()
|
||||
.map(|n| format!(".classpath ({})", n.to_string_lossy()))
|
||||
.unwrap_or_else(|| ".classpath".into());
|
||||
out.push(LibraryGroup { name, kind: "classpath".into(), roots: entries });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `path` (or its `sourcepath`, when it exists) of `.classpath` `kind="lib"`
|
||||
/// entries resolved against the module dir; entries pointing nowhere are dropped.
|
||||
pub(crate) fn classpath_entries(dir: &Path, text: &str) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut reader = Reader::from_str(text);
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Empty(ref tag)) | Ok(Event::Start(ref tag)) => {
|
||||
if !tag.local_name().as_ref().eq_ignore_ascii_case("classpathentry") {
|
||||
continue;
|
||||
}
|
||||
let (mut kind, mut path, mut srcpath) = (String::new(), String::new(), String::new());
|
||||
for a in tag.attributes().flatten() {
|
||||
let Ok(value) = a.normalized_value(XmlVersion::Implicit1_0) else { continue };
|
||||
match a.key.as_ref() {
|
||||
"kind" => kind = value.into_owned(),
|
||||
"path" => path = value.into_owned(),
|
||||
"sourcepath" => srcpath = value.into_owned(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if kind != "lib" {
|
||||
continue;
|
||||
}
|
||||
for p in [&srcpath, &path] {
|
||||
let p = p.trim_matches('/');
|
||||
if p.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let candidate = dir.join(p);
|
||||
if candidate.exists() {
|
||||
out.push(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The JDK home and its sources (`lib/src.zip`, `src.zip` in JDK 8) as library roots.
|
||||
pub(crate) fn jdk_library(jdk: &crate::java::Jdk) -> Option<LibraryGroup> {
|
||||
let name = jdk_name(&jdk.home).unwrap_or_else(|| format!("JDK {}", jdk.major));
|
||||
@@ -1108,4 +1198,52 @@ mod go_cache_tests {
|
||||
cached(dir.path(), "go", "same".into(), &[], detect);
|
||||
assert_eq!(count.get(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classpath_entries_resolve_lib_paths_against_the_module() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::write(root.join("guava.jar"), "").unwrap();
|
||||
std::fs::create_dir_all(root.join("classes")).unwrap();
|
||||
std::fs::create_dir_all(root.join("lib")).unwrap();
|
||||
std::fs::write(root.join("lib/servlet.jar"), "").unwrap();
|
||||
let text = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<classpath>
|
||||
<classpathentry kind="src" path="src"/>
|
||||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/>
|
||||
<classpathentry kind="lib" path="guava.jar" sourcepath="sources.zip"/>
|
||||
<classpathentry kind="lib" path="classes"/>
|
||||
<classpathentry kind="lib" path="/lib/servlet.jar"/>
|
||||
<classpathentry kind="lib" path="missing.jar"/>
|
||||
<classpathentry kind="output" path="bin"/>
|
||||
</classpath>"#;
|
||||
std::fs::write(root.join("sources.zip"), "").unwrap();
|
||||
assert_eq!(
|
||||
classpath_entries(root, text),
|
||||
vec![root.join("sources.zip"), root.join("classes"), root.join("lib/servlet.jar")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classpath_groups_lists_jars_per_module() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join(".classpath"), r#"<classpath><classpathentry kind="lib" path="a.jar"/></classpath>"#)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("a.jar"), "").unwrap();
|
||||
let module = root.join("module-b");
|
||||
std::fs::create_dir_all(&module).unwrap();
|
||||
std::fs::write(
|
||||
module.join(".classpath"),
|
||||
r#"<classpath><classpathentry kind="lib" path="../shared.jar"/></classpath>"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("shared.jar"), "").unwrap();
|
||||
let groups = classpath_groups(&[root.to_path_buf(), module]);
|
||||
assert_eq!(groups.len(), 2);
|
||||
assert_eq!(groups[0].kind, "classpath");
|
||||
assert_eq!(groups[0].roots.len(), 1);
|
||||
assert!(groups[0].roots[0].path.to_string_lossy().ends_with("a.jar!"));
|
||||
assert_eq!(groups[1].roots.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::apply::{
|
||||
apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter,
|
||||
ruff_configuration, terminal_activation, tsdk, without_derived,
|
||||
ruff_configuration, terminal_activation, tool_install_environment, tsdk, without_derived,
|
||||
};
|
||||
use crate::python::parse_pip_list;
|
||||
use crate::{bin_dir, exe, venv_of};
|
||||
@@ -2246,3 +2246,25 @@ fn plugin_interpreter_targets_route_all_php_node_command_families() {
|
||||
assert_eq!(config.extras.target.as_deref(), Some(""));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_installs_resolve_project_relative_sdks_and_leave_path_fallback_unconfigured() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let mut s = Settings::default();
|
||||
let defaults = tool_install_environment(root, &s);
|
||||
assert!(!defaults.commands.contains_key("go"));
|
||||
assert!(!defaults.commands.contains_key("node"));
|
||||
assert!(!defaults.commands.contains_key("python"));
|
||||
assert!(!defaults.env.contains_key("GOROOT"));
|
||||
s.languages.go.goroot = "go-sdk".into();
|
||||
s.languages.node.interpreter = "node-sdk/bin/node".into();
|
||||
s.languages.python.interpreter = "python-sdk/bin/python3.14".into();
|
||||
s.languages.ruby.interpreter = "ruby-sdk/bin/ruby".into();
|
||||
let runtime = tool_install_environment(root, &s);
|
||||
assert_eq!(runtime.commands["go"], root.join("go-sdk/bin").join(if cfg!(windows) { "go.exe" } else { "go" }));
|
||||
assert_eq!(runtime.env["GOROOT"], root.join("go-sdk").to_string_lossy());
|
||||
assert_eq!(runtime.commands["node"], root.join("node-sdk/bin/node"));
|
||||
assert_eq!(runtime.commands["python"], root.join("python-sdk/bin/python3.14"));
|
||||
assert_eq!(runtime.commands["ruby"], root.join("ruby-sdk/bin/ruby"));
|
||||
}
|
||||
@@ -421,6 +421,7 @@ pub fn managed_bundle(app_data: &Path, name: &str) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// Installer and detector over one tools directory.
|
||||
#[derive(Clone)]
|
||||
pub struct Tools {
|
||||
root: PathBuf,
|
||||
/// Search path for system tools (excludes our own dirs).
|
||||
@@ -432,6 +433,8 @@ pub struct Tools {
|
||||
custom: BTreeMap<String, PathBuf>,
|
||||
/// Executables found outside the search path (app bundles), by command.
|
||||
found: BTreeMap<String, PathBuf>,
|
||||
runtime_commands: BTreeMap<String, PathBuf>,
|
||||
runtime_env: BTreeMap<String, String>,
|
||||
version_timeout: Duration,
|
||||
/// The project the tools are used for: tools that install into "the active runtime" (gems) run here,
|
||||
/// so a version manager picks the project's Ruby.
|
||||
@@ -456,11 +459,41 @@ impl Tools {
|
||||
platform,
|
||||
custom: BTreeMap::new(),
|
||||
found: BTreeMap::new(),
|
||||
runtime_commands: BTreeMap::new(),
|
||||
runtime_env: BTreeMap::new(),
|
||||
version_timeout: Duration::from_secs(4),
|
||||
work_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the project's SDKs to prerequisite lookup, probes and installer children.
|
||||
pub fn with_runtime(mut self, commands: BTreeMap<String, PathBuf>, env: BTreeMap<String, String>) -> Self {
|
||||
self.runtime_commands = commands;
|
||||
self.runtime_env = env;
|
||||
self
|
||||
}
|
||||
|
||||
fn for_method(&self, method: Method) -> Self {
|
||||
let commands: &[&str] = match method {
|
||||
Method::Go { .. } => &["go"],
|
||||
Method::Npm { .. } => &["node"],
|
||||
Method::Gem { .. } | Method::GemActive { .. } => &["ruby"],
|
||||
Method::PipVenv { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => &["python"],
|
||||
_ => &[],
|
||||
};
|
||||
let mut runtime = self.clone();
|
||||
runtime.runtime_commands.retain(|name, _| commands.contains(&name.as_str()));
|
||||
runtime.runtime_env.retain(|name, _| name == "GOROOT" && commands.contains(&"go"));
|
||||
let dirs: Vec<PathBuf> =
|
||||
runtime.runtime_commands.values().filter_map(|p| p.parent().map(Path::to_path_buf)).collect();
|
||||
runtime.system_path =
|
||||
std::env::join_paths(dirs.iter().cloned().chain(std::env::split_paths(&self.system_path)))
|
||||
.unwrap_or_else(|_| self.system_path.clone());
|
||||
runtime.child_path = std::env::join_paths(dirs.iter().cloned().chain(std::env::split_paths(&self.child_path)))
|
||||
.unwrap_or_else(|_| self.child_path.clone());
|
||||
runtime
|
||||
}
|
||||
|
||||
/// Reports the executables chosen in settings (command → path) as the tools' location.
|
||||
pub fn with_custom_paths(mut self, custom: BTreeMap<String, PathBuf>) -> Self {
|
||||
self.custom = custom;
|
||||
@@ -493,6 +526,9 @@ impl Tools {
|
||||
}
|
||||
|
||||
fn which(&self, cmd: &str) -> Option<PathBuf> {
|
||||
if let Some(path) = self.runtime_commands.get(cmd) {
|
||||
return path.is_file().then(|| path.clone());
|
||||
}
|
||||
ide_core::tool_path::find_command(cmd, &self.system_path, &self.root)
|
||||
}
|
||||
|
||||
@@ -502,7 +538,7 @@ impl Tools {
|
||||
|
||||
fn command(&self, program: impl AsRef<std::ffi::OsStr>) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
ide_core::process::prepare(&mut cmd).env("PATH", &self.child_path);
|
||||
ide_core::process::prepare(&mut cmd).envs(&self.runtime_env).env("PATH", &self.child_path);
|
||||
cmd
|
||||
}
|
||||
|
||||
@@ -524,6 +560,9 @@ impl Tools {
|
||||
/// Aliases that only open the Store unless the Store's Python is installed, so they count
|
||||
/// only when they run.
|
||||
fn python(&self) -> Option<PathBuf> {
|
||||
if self.runtime_commands.contains_key("python") {
|
||||
return self.which("python");
|
||||
}
|
||||
if self.platform.os != Os::Windows {
|
||||
return self.which_any(&["python3", "python"]);
|
||||
}
|
||||
@@ -664,6 +703,13 @@ impl Tools {
|
||||
}
|
||||
|
||||
fn info_for(&self, name: &str, usage: Option<&Usage>) -> Option<ToolInfo> {
|
||||
match registry::find(name) {
|
||||
Some(spec) => self.for_method(spec.method).info_for_runtime(name, usage),
|
||||
None => self.info_for_runtime(name, usage),
|
||||
}
|
||||
}
|
||||
|
||||
fn info_for_runtime(&self, name: &str, usage: Option<&Usage>) -> Option<ToolInfo> {
|
||||
let spec = registry::find(name);
|
||||
let languages = usage.map(|u| u.languages.clone()).unwrap_or_default();
|
||||
let install_hint = usage.and_then(|u| u.install_hint.clone());
|
||||
@@ -868,6 +914,19 @@ impl Tools {
|
||||
langs: &[LanguageConfig],
|
||||
cancel: &CancellationToken,
|
||||
emit: Emit,
|
||||
) -> IdeResult<ToolInfo> {
|
||||
match registry::find(name) {
|
||||
Some(spec) => self.for_method(spec.method).install_for_runtime(name, langs, cancel, emit),
|
||||
None => self.install_for_runtime(name, langs, cancel, emit),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_for_runtime(
|
||||
&self,
|
||||
name: &str,
|
||||
langs: &[LanguageConfig],
|
||||
cancel: &CancellationToken,
|
||||
emit: Emit,
|
||||
) -> IdeResult<ToolInfo> {
|
||||
let spec = registry::find(name).ok_or_else(|| {
|
||||
let hint = usages(langs).get(name).and_then(|u| u.install_hint.clone());
|
||||
@@ -1130,6 +1189,9 @@ impl Tools {
|
||||
if let Some(uv) = self.which("uv") {
|
||||
let mut c = self.command(&uv);
|
||||
c.arg("venv").arg(&venv);
|
||||
if let Some(python) = self.runtime_commands.get("python") {
|
||||
c.arg("--python").arg(python);
|
||||
}
|
||||
run_streamed(c, cancel, emit)?;
|
||||
let mut c = self.command(&uv);
|
||||
c.args(["pip", "install", "--python"]).arg(&python).arg(package);
|
||||
@@ -1371,6 +1433,13 @@ impl Tools {
|
||||
|
||||
/// Removes a tool the IDE installed.
|
||||
pub fn uninstall(&self, name: &str, langs: &[LanguageConfig]) -> IdeResult<ToolInfo> {
|
||||
match registry::find(name) {
|
||||
Some(spec) => self.for_method(spec.method).uninstall_for_runtime(name, langs),
|
||||
None => self.uninstall_for_runtime(name, langs),
|
||||
}
|
||||
}
|
||||
|
||||
fn uninstall_for_runtime(&self, name: &str, langs: &[LanguageConfig]) -> IdeResult<ToolInfo> {
|
||||
let spec = registry::find(name).ok_or_else(|| IdeError::not_found(format!("unknown tool `{name}`")))?;
|
||||
// Uninstalling mid-install would tear down the directory under the running installer.
|
||||
if INSTALLING.lock().as_ref().is_some_and(|m| m.contains_key(name)) {
|
||||
|
||||
@@ -438,12 +438,15 @@ esac"#,
|
||||
#[test]
|
||||
fn npm_install_uses_tools_prefix() {
|
||||
let env = Env::new();
|
||||
let sdk = env._tmp.path().join("selected-sdk/bin");
|
||||
fs::create_dir_all(&sdk).unwrap();
|
||||
let log = env.root.parent().unwrap().join("npm-args.txt");
|
||||
env.script(
|
||||
&env.sys,
|
||||
&sdk,
|
||||
"npm",
|
||||
&format!(
|
||||
r#"echo "$@" > '{log}'
|
||||
r#"node --version >/dev/null || exit 98
|
||||
echo "$@" > '{log}'
|
||||
prefix=""; while [ $# -gt 0 ]; do [ "$1" = "--prefix" ] && prefix="$2"; shift; done
|
||||
/bin/mkdir -p "$prefix/node_modules/.bin" "$prefix/node_modules/bash-language-server"
|
||||
printf '#!/bin/sh\necho 5.4.0\n' > "$prefix/node_modules/.bin/bash-language-server"
|
||||
@@ -453,7 +456,14 @@ echo "added 1 package""#,
|
||||
log = log.display()
|
||||
),
|
||||
);
|
||||
let info = env.tools().install("bash-language-server", &langs(), &CancellationToken::new(), &|_| {}).unwrap();
|
||||
env.script(&sdk, "node", "exit 0");
|
||||
env.script(&env.sys, "node", "exit 99");
|
||||
env.script(&env.sys, "npm", "exit 99");
|
||||
let tools = env.tools().with_runtime(
|
||||
BTreeMap::from([("node".into(), sdk.join("node")), ("ruby".into(), env.sys.join("ruby"))]),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
let info = tools.install("bash-language-server", &langs(), &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(info.status, ToolStatus::Installed);
|
||||
assert_eq!(info.version.as_deref(), Some("5.4.0"));
|
||||
let args = fs::read_to_string(&log).unwrap();
|
||||
@@ -581,14 +591,19 @@ fn elixir_ls_needs_elixir_and_lists_its_debugger() {
|
||||
#[test]
|
||||
fn gem_install_wraps_stubs_with_gem_home() {
|
||||
let env = Env::new();
|
||||
let sdk = env._tmp.path().join("selected-sdk/bin");
|
||||
fs::create_dir_all(&sdk).unwrap();
|
||||
// `gem install --no-document --install-dir <home> --bindir <bin> solargraph`
|
||||
env.script(
|
||||
&env.sys,
|
||||
&sdk,
|
||||
"gem",
|
||||
r#"home="$4"; bin="$6"; /bin/mkdir -p "$home/gems/solargraph-0.50.0" "$bin"
|
||||
printf '#!/bin/sh\necho "$GEM_HOME"\n' > "$bin/solargraph"; /bin/chmod +x "$bin/solargraph""#,
|
||||
);
|
||||
let tools = env.tools();
|
||||
env.script(&sdk, "ruby", "exit 0");
|
||||
env.script(&env.sys, "ruby", "exit 99");
|
||||
env.script(&env.sys, "gem", "exit 99");
|
||||
let tools = env.tools().with_runtime(BTreeMap::from([("ruby".into(), sdk.join("ruby"))]), BTreeMap::new());
|
||||
let info = tools.install("solargraph", &langs(), &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(info.status, ToolStatus::Installed);
|
||||
assert_eq!(info.version.as_deref(), Some("0.50.0"));
|
||||
@@ -602,16 +617,18 @@ printf '#!/bin/sh\necho "$GEM_HOME"\n' > "$bin/solargraph"; /bin/chmod +x "$bin/
|
||||
#[test]
|
||||
fn ruby_lsp_installs_into_the_project_ruby_not_a_private_gem_home() {
|
||||
let env = Env::new();
|
||||
let sdk = env._tmp.path().join("selected-sdk/bin");
|
||||
fs::create_dir_all(&sdk).unwrap();
|
||||
let project = env._tmp.path().join("project");
|
||||
fs::create_dir_all(&project).unwrap();
|
||||
let marker = env._tmp.path().join("installed");
|
||||
// Homebrew's Ruby: the gem bindir is not on the search path; only the Ruby knows it.
|
||||
let bindir = env._tmp.path().join("gems/bin");
|
||||
fs::create_dir_all(&bindir).unwrap();
|
||||
env.script(&env.sys, "ruby", &format!("echo '{}'", bindir.display()));
|
||||
env.script(&sdk, "ruby", &format!("echo '{}'", bindir.display()));
|
||||
// `gem list --exact ruby-lsp` knows the gem once `gem install ruby-lsp` ran, in the cwd it was given.
|
||||
env.script(
|
||||
&env.sys,
|
||||
&sdk,
|
||||
"gem",
|
||||
&format!(
|
||||
r#"case "$1" in
|
||||
@@ -623,7 +640,10 @@ esac"#,
|
||||
b = bindir.display()
|
||||
),
|
||||
);
|
||||
let tools = env.tools().with_work_dir(Some(project.clone()));
|
||||
env.script(&env.sys, "ruby", "exit 99");
|
||||
env.script(&env.sys, "gem", "exit 99");
|
||||
let tools = env.tools().with_runtime(BTreeMap::from([("ruby".into(), sdk.join("ruby"))]), BTreeMap::new());
|
||||
let tools = tools.with_work_dir(Some(project.clone()));
|
||||
assert_eq!(find(&tools.list(&langs()), "ruby-lsp").status, ToolStatus::Missing);
|
||||
let info = tools.install("ruby-lsp", &langs(), &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(info.status, ToolStatus::OnPath);
|
||||
@@ -800,3 +820,88 @@ fn python_probes_run_in_an_empty_private_directory() {
|
||||
let dir = env.root.join(".python-cwd");
|
||||
assert_eq!(out.trim(), dir.canonicalize().unwrap().to_string_lossy());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn go_installers_use_selected_sdk_instead_of_broken_mise_shim() {
|
||||
for spec in registry::TOOLS.iter().filter(|s| matches!(s.method, registry::Method::Go { .. })) {
|
||||
let env = Env::new();
|
||||
env.script(&env.sys, "go", "echo 'mise ERROR No version is set for shim: go' >&2; exit 1");
|
||||
let sdk = env._tmp.path().join("selected go");
|
||||
let log = env._tmp.path().join("go.log");
|
||||
env.script(
|
||||
&sdk.join("bin"),
|
||||
"go",
|
||||
&format!(
|
||||
r#"
|
||||
[ "$GOROOT" = '{sdk}' ] || exit 99
|
||||
echo "$@" >> '{log}'
|
||||
case "$1" in
|
||||
install) printf '#!/bin/sh\necho 1.2.3\n' > "$GOBIN/{command}"; /bin/chmod +x "$GOBIN/{command}" ;;
|
||||
version) echo 'mod example.com/tool v1.2.3' ;;
|
||||
esac
|
||||
"#,
|
||||
sdk = sdk.display(),
|
||||
log = log.display(),
|
||||
command = spec.commands[0]
|
||||
),
|
||||
);
|
||||
let tools = env.tools().with_runtime(
|
||||
BTreeMap::from([("go".into(), sdk.join("bin/go"))]),
|
||||
BTreeMap::from([("GOROOT".into(), sdk.to_string_lossy().into_owned())]),
|
||||
);
|
||||
let info = tools.install(spec.name, &langs(), &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(info.status, ToolStatus::Installed, "{}", spec.name);
|
||||
assert_eq!(info.version.as_deref(), Some("1.2.3"));
|
||||
let log = fs::read_to_string(log).unwrap();
|
||||
assert!(log.contains("install -v ") && log.contains("version -m"), "{log}");
|
||||
tools.uninstall(spec.name, &langs()).unwrap();
|
||||
assert!(
|
||||
env.tools()
|
||||
.install(spec.name, &langs(), &CancellationToken::new(), &|_| {})
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("failed")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn selected_python_is_used_for_pip_and_uv_even_with_a_versioned_filename() {
|
||||
let env = Env::new();
|
||||
env.script(&env.sys, "python3", "exit 99");
|
||||
let sdk = env._tmp.path().join("python-sdk");
|
||||
let pip_log = env._tmp.path().join("pip.log");
|
||||
env.script(
|
||||
&sdk,
|
||||
"python3.14",
|
||||
&format!(
|
||||
r#"
|
||||
echo "$@" >> '{log}'
|
||||
[ "$1" = "-c" ] && exit 1
|
||||
while [ $# -gt 0 ]; do
|
||||
if [ "$1" = "--target" ]; then
|
||||
/bin/mkdir -p "$2/debugpy" "$2/debugpy-1.2.3.dist-info"
|
||||
/usr/bin/touch "$2/debugpy/__init__.py"
|
||||
exit 0
|
||||
fi
|
||||
shift
|
||||
done
|
||||
exit 1
|
||||
"#,
|
||||
log = pip_log.display()
|
||||
),
|
||||
);
|
||||
let tools = env.tools().with_runtime(BTreeMap::from([("python".into(), sdk.join("python3.14"))]), BTreeMap::new());
|
||||
assert_eq!(tools.python(), Some(sdk.join("python3.14")));
|
||||
let info = tools.install("debugpy", &langs(), &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(info.status, ToolStatus::Installed);
|
||||
assert_eq!(info.version.as_deref(), Some("1.2.3"));
|
||||
assert!(fs::read_to_string(pip_log).unwrap().contains("-m pip install"));
|
||||
let log = env._tmp.path().join("uv.log");
|
||||
env.script(&env.sys, "uv", &format!("echo \"$@\" >> '{}'; exit 1", log.display()));
|
||||
let _ = tools.install("ruff", &langs(), &CancellationToken::new(), &|_| {});
|
||||
let args = fs::read_to_string(log).unwrap();
|
||||
assert!(args.contains(&format!("--python {}", sdk.join("python3.14").display())), "{args}");
|
||||
}
|
||||
@@ -201,8 +201,9 @@ impl SettingsStore {
|
||||
let mut settings: Settings = serde_json::from_value(merged).unwrap_or_default();
|
||||
if let Some(root) = self.project_file.as_deref().and_then(Path::parent).and_then(Path::parent)
|
||||
&& let Ok(Some(workspace)) = crate::workspace::Workspace::load(root)
|
||||
&& workspace.is_container(root)
|
||||
{
|
||||
settings.general.project_name = workspace.name;
|
||||
settings.general.project_name = workspace.name.clone();
|
||||
settings.project_structure.content_roots =
|
||||
workspace.projects.iter().map(|p| ide_core::ipc_path::to_ipc(p)).collect();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! A workspace owns its settings and references project folders without changing them.
|
||||
//! Explicit workspaces reference project folders without changing them.
|
||||
//! Attaching folders writes only the primary project's `.openidea/` directory.
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -22,31 +23,22 @@ fn serialize_projects<S: serde::Serializer>(projects: &[PathBuf], serializer: S)
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// Creates an automatically named workspace outside all linked projects.
|
||||
pub fn create_for_project(data_dir: &Path, current: &Path, name: &str, attached: &Path) -> IdeResult<PathBuf> {
|
||||
let projects = vec![current.to_path_buf(), attached.to_path_buf()];
|
||||
let base = destination(&data_dir.join("workspaces"))?;
|
||||
let folder = current.file_name().and_then(|n| n.to_str()).unwrap_or("workspace");
|
||||
// Validate before creating anything, including when app data is inside a project.
|
||||
let normalized = normalize_projects(&base, projects.clone(), false)?;
|
||||
if normalized.iter().any(|p| base.starts_with(p)) {
|
||||
return Err(IdeError::invalid("Store the workspace outside its linked project folders"));
|
||||
pub fn is_container(&self, root: &Path) -> bool {
|
||||
self.projects.first().is_none_or(|p| p != root)
|
||||
}
|
||||
|
||||
pub fn display_name(&self, root: &Path, primary: &str) -> String {
|
||||
if self.is_container(root) {
|
||||
return self.name.clone();
|
||||
}
|
||||
std::fs::create_dir_all(&base)?;
|
||||
for suffix in 0.. {
|
||||
let root = base.join(if suffix == 0 { folder.to_owned() } else { format!("{folder}-{suffix}") });
|
||||
match std::fs::create_dir(&root) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
if let Err(error) = Self::save(&root, name, projects.clone(), true) {
|
||||
let _ = std::fs::remove_dir(&root);
|
||||
return Err(error);
|
||||
}
|
||||
return Ok(root);
|
||||
match self.projects.get(1) {
|
||||
Some(other) => format!(
|
||||
"{primary}, {}{}",
|
||||
other.file_name().unwrap_or_default().to_string_lossy(),
|
||||
if self.projects.len() > 2 { "..." } else { "" }
|
||||
),
|
||||
None => primary.to_owned(),
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
pub fn load(root: &Path) -> IdeResult<Option<Self>> {
|
||||
@@ -61,11 +53,20 @@ impl Workspace {
|
||||
return Err(IdeError::invalid("Unsupported workspace version"));
|
||||
}
|
||||
workspace.name = valid_name(&workspace.name)?;
|
||||
workspace.projects = normalize_projects(root, workspace.projects, true)?;
|
||||
workspace.projects = normalize_projects(root, workspace.projects)?;
|
||||
Ok(Some(workspace))
|
||||
}
|
||||
|
||||
pub fn save(root: &Path, name: &str, projects: Vec<PathBuf>, create: bool) -> IdeResult<Self> {
|
||||
Self::write(root, name, projects, create, false)
|
||||
}
|
||||
|
||||
/// Creates private attachment metadata in an existing primary project.
|
||||
pub fn attach(root: &Path, name: &str, projects: Vec<PathBuf>) -> IdeResult<Self> {
|
||||
Self::write(root, name, projects, true, true)
|
||||
}
|
||||
|
||||
fn write(root: &Path, name: &str, projects: Vec<PathBuf>, create: bool, attach: bool) -> IdeResult<Self> {
|
||||
let name = valid_name(name)?;
|
||||
let root = destination(root)?;
|
||||
let previous = Self::load(&root)?;
|
||||
@@ -75,14 +76,23 @@ impl Workspace {
|
||||
if !create && previous.is_none() {
|
||||
return Err(IdeError::invalid("This folder is not a workspace"));
|
||||
}
|
||||
let projects = normalize_projects(&root, projects, true)?;
|
||||
let projects = normalize_projects(&root, projects)?;
|
||||
if !attach
|
||||
&& previous.as_ref().is_none_or(|w| w.is_container(&root))
|
||||
&& projects.iter().any(|p| root.starts_with(p))
|
||||
{
|
||||
return Err(IdeError::invalid("Store the workspace outside its linked project folders"));
|
||||
}
|
||||
if attach && projects.first() != Some(&root) {
|
||||
return Err(IdeError::invalid("The primary project must be the first attached folder"));
|
||||
}
|
||||
for path in &projects {
|
||||
if !path.is_dir() && !previous.as_ref().is_some_and(|w| w.projects.contains(path)) {
|
||||
return Err(IdeError::invalid(format!("Project folder does not exist: {}", path.display())));
|
||||
}
|
||||
}
|
||||
if projects.iter().any(|p| p == &root || root.starts_with(p)) {
|
||||
return Err(IdeError::invalid("Store the workspace outside its linked project folders"));
|
||||
if previous.as_ref().is_some_and(|w| !w.is_container(&root)) && projects.first() != Some(&root) {
|
||||
return Err(IdeError::invalid("The primary project cannot be detached"));
|
||||
}
|
||||
std::fs::create_dir_all(&root)?;
|
||||
let workspace = Self { version: 1, name, projects };
|
||||
@@ -118,22 +128,33 @@ fn valid_name(name: &str) -> IdeResult<String> {
|
||||
Ok(name.to_owned())
|
||||
}
|
||||
|
||||
fn normalize_projects(root: &Path, projects: Vec<PathBuf>, allow_missing: bool) -> IdeResult<Vec<PathBuf>> {
|
||||
fn normalize_projects(root: &Path, projects: Vec<PathBuf>) -> IdeResult<Vec<PathBuf>> {
|
||||
let mut result = Vec::new();
|
||||
for path in projects {
|
||||
let path = if path.is_absolute() { path } else { root.join(path) };
|
||||
let path = match dunce::canonicalize(&path) {
|
||||
Ok(path) if path.is_dir() => path,
|
||||
Err(e) if allow_missing && e.kind() == std::io::ErrorKind::NotFound => path,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => path,
|
||||
_ => return Err(IdeError::invalid(format!("Project folder does not exist: {}", path.display()))),
|
||||
};
|
||||
if path.join(FILE).exists() {
|
||||
return Err(IdeError::invalid("Add project folders instead of nesting workspaces"));
|
||||
if path != root && path.join(FILE).exists() {
|
||||
let mut nested: Workspace = serde_json::from_slice(&std::fs::read(path.join(FILE))?)
|
||||
.map_err(|e| IdeError::invalid(format!("Invalid workspace: {e}")))?;
|
||||
let primary = nested.projects.first().map(|p| if p.is_absolute() { p.clone() } else { path.join(p) });
|
||||
if let Some(primary) = primary.and_then(|p| dunce::canonicalize(p).ok()) {
|
||||
nested.projects[0] = primary;
|
||||
}
|
||||
if nested.is_container(&path) {
|
||||
return Err(IdeError::invalid("Add project folders instead of nesting workspaces"));
|
||||
}
|
||||
}
|
||||
if !result.contains(&path) {
|
||||
result.push(path);
|
||||
}
|
||||
}
|
||||
if result.iter().enumerate().any(|(i, p)| (p == root && i != 0) || (p != root && root.starts_with(p))) {
|
||||
return Err(IdeError::invalid("Store the workspace outside its linked project folders"));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -142,24 +163,51 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn automatic_workspace_is_unique_and_outside_projects() {
|
||||
fn update_cannot_turn_a_container_into_an_attachment() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let a = base.join("a");
|
||||
let b = base.join("b");
|
||||
std::fs::create_dir(&a).unwrap();
|
||||
std::fs::create_dir(&b).unwrap();
|
||||
let data = base.join("data");
|
||||
let root = Workspace::create_for_project(&data, &a, "A", &b).unwrap();
|
||||
let workspace = Workspace::load(&root).unwrap().unwrap();
|
||||
assert_eq!(workspace.name, "A");
|
||||
assert_eq!(workspace.projects, vec![a.clone(), b.clone()]);
|
||||
assert_eq!(root, data.join("workspaces/a"));
|
||||
let other = Workspace::create_for_project(&data, &a, "A", &b).unwrap();
|
||||
assert_eq!(other, data.join("workspaces/a-1"));
|
||||
assert!(Workspace::create_for_project(&a.join("data"), &a, "A", &b).is_err());
|
||||
assert!(!a.join("data").exists());
|
||||
assert!(!a.join(FILE).exists());
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
Workspace::save(&root, "Container", vec![], true).unwrap();
|
||||
let before = std::fs::read(root.join(FILE)).unwrap();
|
||||
assert!(Workspace::save(&root, "Attached", vec![root.clone()], false).is_err());
|
||||
assert_eq!(std::fs::read(root.join(FILE)).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_workspace_refuses_a_location_equal_to_any_member() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let other = root.join("other");
|
||||
std::fs::create_dir(&other).unwrap();
|
||||
for projects in [vec![root.clone(), other.clone()], vec![other, root.clone()]] {
|
||||
let error = Workspace::save(&root, "Invalid", projects, true).unwrap_err();
|
||||
assert_eq!(error.message, "Store the workspace outside its linked project folders");
|
||||
assert!(!root.join(FILE).exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachments_keep_the_primary_and_allow_attached_folders() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let a = root.join("a");
|
||||
let b = root.join("b");
|
||||
let c = root.join("c");
|
||||
for path in [&a, &b, &c] {
|
||||
std::fs::create_dir(path).unwrap();
|
||||
}
|
||||
let nested = Workspace::attach(&b, "B", vec![b.clone(), c.clone()]).unwrap();
|
||||
assert!(!nested.is_container(&b));
|
||||
let saved = Workspace::attach(&a, "A", vec![a.clone(), b.clone()]).unwrap();
|
||||
assert_eq!(Workspace::load(&a).unwrap(), Some(saved.clone()));
|
||||
assert_eq!(saved.display_name(&a, "Primary"), "Primary, b");
|
||||
assert!(Workspace::save(&a, "A", vec![b.clone()], false).is_err());
|
||||
let saved = Workspace::save(&a, "A", vec![a.clone(), b, c], false).unwrap();
|
||||
assert_eq!(saved.display_name(&a, "Primary"), "Primary, b...");
|
||||
let container = root.join("container");
|
||||
Workspace::save(&container, "Container", vec![], true).unwrap();
|
||||
assert!(Workspace::save(&a, "A", vec![a.clone(), container], false).is_err());
|
||||
assert!(Workspace::save(&a, "A", vec![root.clone()], false).is_err());
|
||||
assert!(Workspace::save(&root.join("other"), "Other", vec![a, root], true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,3 +24,6 @@ multiple-versions = "warn"
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
# SQLx's unreleased SQLite dependency ceiling fix, pinned in Cargo.toml.
|
||||
allow-git = ["https://github.com/transact-rs/sqlx"]
|
||||
required-git-spec = "rev"
|
||||
+7
-2
@@ -6,6 +6,11 @@ wire-compatible products listed in **New ▸ Data Source**. Connections are save
|
||||
per project in `.openidea/data-sources.toml`; passwords live outside the project
|
||||
(and never in that file).
|
||||
|
||||
The Database tree groups procedures and functions in one `routines` folder by
|
||||
default. Enable **Separate Procedures and Functions** in the Database window’s
|
||||
**Options** menu to show `procedures` and `functions` folders instead. This view
|
||||
option is remembered across sessions.
|
||||
|
||||
## Oracle: Oracle Instant Client
|
||||
|
||||
Oracle support uses the Oracle Call Interface, so the Oracle client libraries
|
||||
@@ -50,8 +55,8 @@ search path.
|
||||
|
||||
- Schemas = Oracle users (`ALL_USERS`); system accounts (`SYS`, `SYSTEM`,
|
||||
`XDB`, `MDSYS`…) appear only with **Schemas ▸ Show system**.
|
||||
- Tables, views, columns, indexes, foreign keys, triggers, routines and
|
||||
sequences are read from the `ALL_*` views.
|
||||
- Tables, views, columns, indexes, foreign keys, triggers, procedures, functions
|
||||
and sequences are read from the `ALL_*` views.
|
||||
- The query console runs SQL and PL/SQL blocks; `BEGIN … END;` blocks (nested
|
||||
`;` included) are sent as one statement, and a lone `/` on its own line ends
|
||||
one as in SQL*Plus.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
|
||||
|
||||
test.use({ video: "on" });
|
||||
|
||||
test("Attach preserves unsaved tabs and detaches back to the plain project", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "README.md");
|
||||
await editor.locator(".cm-content").click();
|
||||
await page.keyboard.press("Meta+End");
|
||||
await page.keyboard.type("unsaved attach text");
|
||||
await page.screenshot({ path: test.info().outputPath("plain-project.png"), animations: "disabled" });
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async () => {
|
||||
const module = "/src/platform/project.ts";
|
||||
const { useProject } = await import(/* @vite-ignore */ module);
|
||||
const project = useProject.getState().project;
|
||||
useProject.setState({
|
||||
settings: { ...project.settings, general: { ...project.settings.general, openProjectIn: "ask" } },
|
||||
});
|
||||
const attached = {
|
||||
...project,
|
||||
displayName: "acme-api, other",
|
||||
workspace: { version: 1, name: project.name, projects: [project.root, "/dev/other"] },
|
||||
};
|
||||
let current = project;
|
||||
window.__ipc.handlers.project_trust_status = ({ path }) => ({ root: path, trusted: true, decided: true });
|
||||
window.__ipc.handlers.workspace_attach = () => {
|
||||
current = attached;
|
||||
return project.root;
|
||||
};
|
||||
window.__ipc.handlers.project_current = () => current;
|
||||
window.__ipc.handlers.menu_refresh = () => null;
|
||||
window.__ipc.handlers.project_rename = ({ name }) => {
|
||||
current = { ...attached, name, displayName: `${name}, other` };
|
||||
return current;
|
||||
};
|
||||
window.__ipc.handlers.workspace_update = () => {
|
||||
current = project;
|
||||
return project;
|
||||
};
|
||||
window.__ipc.handlers.project_roots = () => ({ folders: [] });
|
||||
window.__ipc.handlers.fs_read_dir = () => [];
|
||||
window.__ipc.handlers.run_configs = () => [];
|
||||
window.__ipc.handlers.recent_projects = () => [
|
||||
{ path: current.root, name: current.displayName || current.name, branch: null, openedAtMs: Date.now() },
|
||||
];
|
||||
const windows = "/src/features/app/windows.ts";
|
||||
void (await import(/* @vite-ignore */ windows)).openProjectWithPolicy("/dev/other");
|
||||
});
|
||||
const dialog = page.getByRole("dialog", { name: "Open Project" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("attach-dialog.png"), animations: "disabled" });
|
||||
await dialog.getByRole("button", { name: "Attach", exact: true }).click();
|
||||
await expect(
|
||||
page.locator("header").first().getByRole("button", { name: "acme-api, other", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("treeitem", { name: /^other / })).toBeVisible();
|
||||
await expect(editor.locator(".cm-content")).toContainText("unsaved attach text");
|
||||
expect(await ipcCalls(page, "open_project")).toHaveLength(0);
|
||||
expect(await ipcCalls(page, "workspace_attach")).toHaveLength(1);
|
||||
await page.evaluate(async () => {
|
||||
const actions = "/src/platform/actions.ts";
|
||||
void (await import(/* @vite-ignore */ actions)).executeAction("file.renameProject");
|
||||
});
|
||||
const rename = page.getByRole("dialog", { name: "Rename Project", exact: true });
|
||||
await expect(rename.getByRole("textbox")).toHaveValue("acme-api");
|
||||
await rename.getByRole("button", { name: "Rename", exact: true }).click();
|
||||
await expect(
|
||||
page.locator("header").first().getByRole("button", { name: "acme-api, other", exact: true }),
|
||||
).toBeVisible();
|
||||
expect((await ipcCalls(page, "project_rename"))[0]!.name).toBe("acme-api");
|
||||
await page.screenshot({ path: test.info().outputPath("attached-project.png"), animations: "disabled" });
|
||||
await page.locator("header").first().getByRole("button", { name: "acme-api, other", exact: true }).click();
|
||||
await expect(page.getByText("/dev/acme-api", { exact: true }).last()).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("attached-recent.png"), animations: "disabled" });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("treeitem", { name: /^other / }).click({ button: "right" });
|
||||
await expect(page.getByRole("menuitem", { name: "Detach Project", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Remove from Workspace" })).toHaveCount(0);
|
||||
await page.screenshot({ path: test.info().outputPath("detach-menu.png"), animations: "disabled" });
|
||||
await page.getByRole("menuitem", { name: "Detach Project", exact: true }).click();
|
||||
const detach = page.getByRole("dialog", { name: "Detach Project" });
|
||||
await expect(detach).toBeVisible();
|
||||
await detach.getByRole("button", { name: "Detach", exact: true }).click();
|
||||
await expect(page.locator("header").first().getByRole("button", { name: "acme-api", exact: true })).toBeVisible();
|
||||
await expect(editor.locator(".cm-content")).toContainText("unsaved attach text");
|
||||
await expect(page.getByRole("treeitem", { name: /^other / })).toHaveCount(0);
|
||||
await page.screenshot({ path: test.info().outputPath("detached-project.png"), animations: "disabled" });
|
||||
});
|
||||
|
||||
test("restricting an attached folder leaves the current project untouched", async ({ workbench: page }) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async () => {
|
||||
const projectModule = "/src/platform/project.ts";
|
||||
const { useProject } = await import(/* @vite-ignore */ projectModule);
|
||||
useProject.setState({ settings: { ...useProject.getState().settings, general: { openProjectIn: "ask" } } });
|
||||
let decided = false;
|
||||
window.__ipc.handlers.project_trust_status = ({ path }) => ({ root: path, trusted: false, decided });
|
||||
window.__ipc.handlers.project_trust_set = () => {
|
||||
decided = true;
|
||||
};
|
||||
const module = "/src/features/app/windows.ts";
|
||||
void (await import(/* @vite-ignore */ module)).openProjectWithPolicy("/dev/untrusted");
|
||||
});
|
||||
await page.getByRole("dialog", { name: "Open Project" }).getByRole("button", { name: "Attach", exact: true }).click();
|
||||
const trust = page.getByRole("dialog", { name: "Trust and Open Project?" });
|
||||
await expect(trust).toBeVisible();
|
||||
await trust.getByRole("button", { name: "Preview in Restricted Mode" }).click();
|
||||
await expect(trust).toBeHidden();
|
||||
expect(await ipcCalls(page, "workspace_attach")).toHaveLength(0);
|
||||
expect(await ipcCalls(page, "open_project")).toHaveLength(0);
|
||||
await expect(page.locator("header").first().getByRole("button", { name: "acme-api", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("restricted attachments can be trusted individually from the banner", async ({ workbench: page }) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async () => {
|
||||
const m = "/src/platform/project.ts";
|
||||
const { useProject } = await import(/* @vite-ignore */ m);
|
||||
const primary = useProject.getState().project;
|
||||
let current = {
|
||||
...primary,
|
||||
trusted: false,
|
||||
displayName: "acme-api, other...",
|
||||
workspace: { version: 1, name: primary.name, projects: [primary.root, "/dev/other", "/dev/third"] },
|
||||
};
|
||||
const trusted = new Set([primary.root]);
|
||||
window.__ipc.handlers.project_trust_status = ({ path }) => ({
|
||||
root: path,
|
||||
trusted: trusted.has(path),
|
||||
decided: true,
|
||||
});
|
||||
window.__ipc.handlers.project_trust_set = ({ path, trusted: allow }) => {
|
||||
if (allow) trusted.add(path);
|
||||
current = { ...current, trusted: current.workspace.projects.every((path) => trusted.has(path)) };
|
||||
};
|
||||
window.__ipc.handlers.project_current = () => current;
|
||||
window.__ipc.handlers.fs_read_dir = () => [];
|
||||
window.__ipc.handlers.project_roots = () => ({ folders: [] });
|
||||
window.__ipc.handlers.run_configs = () => [];
|
||||
useProject.setState({ project: current });
|
||||
});
|
||||
const banner = page.getByRole("status").filter({ hasText: "Restricted Mode" });
|
||||
await expect(banner).toContainText("/dev/other");
|
||||
await page.screenshot({ path: test.info().outputPath("restricted-member.png"), animations: "disabled" });
|
||||
await banner.getByRole("button", { name: "Trust Project" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Trust and Open Project?" });
|
||||
await expect(dialog).toContainText("/dev/other");
|
||||
await dialog.getByRole("button", { name: "Don't Open" }).click();
|
||||
expect(await ipcCalls(page, "project_trust_set")).toHaveLength(0);
|
||||
await banner.getByRole("button", { name: "Trust Project" }).click();
|
||||
await dialog.getByRole("button", { name: "Trust Project", exact: true }).click();
|
||||
await expect(banner).toContainText("/dev/third");
|
||||
expect(await ipcCalls(page, "project_trust_set")).toEqual([{ path: "/dev/other", trusted: true, folder: false }]);
|
||||
await banner.getByRole("button", { name: "Trust Project" }).click();
|
||||
await expect(dialog).toContainText("/dev/third");
|
||||
await dialog.getByRole("button", { name: "Trust Project", exact: true }).click();
|
||||
await expect(banner).toBeHidden();
|
||||
await page.screenshot({ path: test.info().outputPath("trusted-members.png"), animations: "disabled" });
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
test("an open editor leaves nothing for the page itself to scroll", async ({ workbench: page }) => {
|
||||
await openFromTree(page, "README.md");
|
||||
const page_ = await page.evaluate(() => {
|
||||
window.scrollBy(0, 100);
|
||||
const root = document.documentElement;
|
||||
return { scrollY: window.scrollY, scrollHeight: root.scrollHeight, clientHeight: root.clientHeight };
|
||||
});
|
||||
expect(page_.scrollY).toBe(0);
|
||||
expect(page_.scrollHeight).toBe(page_.clientHeight);
|
||||
});
|
||||
|
||||
test("scrolling to where the editor already is keeps the page in place", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "README.md");
|
||||
await expect(editor.locator(".cm-content")).toBeVisible();
|
||||
const scrollY = await page.evaluate(async () => {
|
||||
const content = document.querySelector(".cm-content") as HTMLElement & {
|
||||
cmTile?: { view: never };
|
||||
cmView?: { view: never };
|
||||
};
|
||||
const view = (content.cmTile ?? content.cmView)!.view as {
|
||||
constructor: { scrollIntoView(pos: number, options: { y: string }): unknown };
|
||||
dispatch(spec: { effects: unknown }): void;
|
||||
requestMeasure(): void;
|
||||
};
|
||||
for (let i = 0; i < 2; i++) {
|
||||
view.dispatch({ effects: view.constructor.scrollIntoView(0, { y: "start" }) });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve)));
|
||||
}
|
||||
return window.scrollY;
|
||||
});
|
||||
expect(scrollY).toBe(0);
|
||||
});
|
||||
@@ -122,7 +122,8 @@ test("font picker supports manual families and CJK fallback in browser developme
|
||||
expect(interLoaded).toBe(true);
|
||||
await family.fill("Inter");
|
||||
await family.press("Enter");
|
||||
await expect(dialog.getByLabel("Font preview")).toHaveCSS("font-family", /^"Inter Variable",/);
|
||||
// Computed styles drop redundant quotes around single-token families ("Inter" -> Inter).
|
||||
await expect(dialog.getByLabel("Font preview")).toHaveCSS("font-family", /^Inter, "Inter Variable",/);
|
||||
const fallback = dialog.getByRole("combobox", { name: "Fallback font", exact: true });
|
||||
await family.fill("Maple Mono NF");
|
||||
await family.press("Enter");
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
test("Updates shows check status and keeps manual checks available when automatic checks are off", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await page.evaluate(async () => {
|
||||
const ipcPath = "/src/platform/ipc.ts";
|
||||
const actionsPath = "/src/platform/actions.ts";
|
||||
const { commands } = await import(/* @vite-ignore */ ipcPath);
|
||||
const { registerAction, executeAction } = await import(/* @vite-ignore */ actionsPath);
|
||||
Object.assign(commands, {
|
||||
appBuildInfo: async () => ({ version: "0.1.39", updates: true }),
|
||||
appUpdateStatus: async () => ({ status: "ok", data: { lastChecked: null, checking: false } }),
|
||||
});
|
||||
registerAction({
|
||||
id: "app.checkForUpdates",
|
||||
title: "Check for Updates…",
|
||||
run: () => document.body.setAttribute("data-update-checked", "true"),
|
||||
});
|
||||
await executeAction("file.settings", "general");
|
||||
});
|
||||
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("textbox", { name: "Search settings" }).fill("update");
|
||||
await dialog.getByRole("treeitem", { name: "Updates", exact: true }).click();
|
||||
await expect(dialog.getByText("0.1.39", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText("Never", { exact: true })).toBeVisible();
|
||||
const automatic = dialog.getByRole("checkbox", { name: "Check for updates automatically" });
|
||||
await expect(automatic).toBeChecked();
|
||||
await automatic.uncheck();
|
||||
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
|
||||
await expect(automatic).not.toBeChecked();
|
||||
await dialog.getByRole("button", { name: "Check Now", exact: true }).click();
|
||||
await expect(page.locator("body")).toHaveAttribute("data-update-checked", "true");
|
||||
});
|
||||
|
||||
test("builds without update endpoints hide Updates from settings and search", async ({ workbench: page }) => {
|
||||
await page.evaluate(async () => {
|
||||
const ipcPath = "/src/platform/ipc.ts";
|
||||
const actionsPath = "/src/platform/actions.ts";
|
||||
const { commands } = await import(/* @vite-ignore */ ipcPath);
|
||||
commands.appBuildInfo = async () => ({ version: "0.1.39", updates: false });
|
||||
const { executeAction } = await import(/* @vite-ignore */ actionsPath);
|
||||
await executeAction("file.settings", "general");
|
||||
});
|
||||
const dialog = page.getByRole("dialog", { name: "Settings", exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole("treeitem", { name: "Updates", exact: true })).toHaveCount(0);
|
||||
await expect(dialog.getByText("Update source", { exact: true })).toHaveCount(0);
|
||||
await dialog.getByRole("textbox", { name: "Search settings" }).fill("update");
|
||||
await expect(dialog.getByRole("treeitem", { name: "Updates", exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -85,7 +85,7 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
let info = project_info(&project);
|
||||
let _ = window.set_title(&info.name);
|
||||
let _ = window.set_title(&info.display_name);
|
||||
window.state::<AppState>().sink.emit(ide_core::IdeEvent::WindowGroupsChanged);
|
||||
// Every project window is part of the session reopened at the next start.
|
||||
crate::window_groups::persist(window.app_handle()).await?;
|
||||
@@ -121,14 +121,19 @@ fn project_info(project: &crate::state::Project) -> ProjectInfo {
|
||||
pub fn project_rename(window: Window, state: State<'_, AppState>, name: String) -> IdeResult<ProjectInfo> {
|
||||
let project = state.project(window.label())?;
|
||||
let name = name.trim();
|
||||
if let Some(workspace) = ide_workspace::workspace::Workspace::load(&project.root)? {
|
||||
return ide_host::ops::project::save_workspace(&state.host, window.label(), name, workspace.projects);
|
||||
if let Some(workspace) = ide_workspace::workspace::Workspace::load(&project.root)?
|
||||
&& workspace.is_container(&project.root)
|
||||
{
|
||||
ide_host::ops::project::save_workspace(&state.host, window.label(), name, workspace.projects)?;
|
||||
} else {
|
||||
let value = if name.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(name.to_owned()) };
|
||||
project.settings.set(SettingsScope::Project, "general.projectName", value)?;
|
||||
}
|
||||
let value = if name.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(name.to_owned()) };
|
||||
project.settings.set(SettingsScope::Project, "general.projectName", value)?;
|
||||
state.recent.rename(&project.root, (!name.is_empty()).then_some(name))?;
|
||||
crate::jump_list::refresh(&state.app, state.recent.list());
|
||||
Ok(project_info(&project))
|
||||
let info = project_info(&project);
|
||||
state.record_project(&project)?;
|
||||
let _ = window.set_title(&info.display_name);
|
||||
state.sink.emit(ide_core::IdeEvent::WindowGroupsChanged);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
// Not on the main thread: closing stops the project's language servers, runs and file watcher.
|
||||
@@ -184,6 +189,9 @@ pub fn settings_set(
|
||||
if key == "mcpServer" || key.starts_with("mcpServer.") {
|
||||
crate::commands::mcp::apply(&state);
|
||||
}
|
||||
if !applied.project_scope {
|
||||
state.updates.wake.notify_one();
|
||||
}
|
||||
// A project value concerns only that project's window (and its settings window).
|
||||
let event = ide_core::IdeEvent::SettingsChanged { key };
|
||||
if applied.project_scope {
|
||||
@@ -238,6 +246,7 @@ pub async fn settings_import(window: Window, state: State<'_, AppState>, path: P
|
||||
crate::commands::ssh::apply_ssh_settings(&state);
|
||||
crate::commands::mcp::apply(&state);
|
||||
state.host.apply_proxy();
|
||||
state.updates.wake.notify_one();
|
||||
state.sink.emit(ide_core::IdeEvent::SettingsChanged { key: String::new() });
|
||||
let settings = ide_host::ops::project::settings(&state.host, window.label());
|
||||
Ok(SettingsImport { settings, storage })
|
||||
|
||||
@@ -31,6 +31,39 @@ pub async fn clipboard_set_file_list(app: AppHandle, paths: Vec<PathBuf>) -> Ide
|
||||
with_clipboard(app, move || Ok(ide_clipboard::set_files(&paths))).await
|
||||
}
|
||||
|
||||
/// The clipboard's image as a PNG data URL, for a paste whose event carried no
|
||||
/// files: WebKitGTK leaves `clipboardData` empty for a raw image (a screenshot).
|
||||
/// None when the clipboard holds no image, and always on other platforms.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_image(app: AppHandle) -> IdeResult<Option<String>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use base64::Engine;
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.run_on_main_thread(move || {
|
||||
let sender = std::cell::Cell::new(Some(tx));
|
||||
gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD).request_image(move |_, pixbuf| {
|
||||
let png = pixbuf.and_then(|image| image.save_to_bufferv("png", &[]).ok());
|
||||
if let Some(sender) = sender.take() {
|
||||
let _ = sender.send(png);
|
||||
}
|
||||
});
|
||||
})
|
||||
.map_err(|error| {
|
||||
ide_core::IdeError::internal(format!("Cannot dispatch clipboard access to the main thread: {error}"))
|
||||
})?;
|
||||
let png = rx.await.ok().flatten();
|
||||
Ok(png
|
||||
.map(|bytes| format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes))))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = app;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// AppKit and WebKit share the general pasteboard, so a worker-side mutex cannot
|
||||
/// protect its type cache. Await the main thread without blocking a command thread.
|
||||
async fn with_clipboard<T: Send + 'static>(
|
||||
|
||||
@@ -166,6 +166,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
fs::fs_read_text,
|
||||
clipboard::clipboard_file_list,
|
||||
clipboard::clipboard_has_files,
|
||||
clipboard::clipboard_image,
|
||||
clipboard::clipboard_set_file_list,
|
||||
fonts::system_font_families,
|
||||
doc::doc_open,
|
||||
@@ -461,7 +462,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
window::app_cli_uninstall,
|
||||
window::app_build_info,
|
||||
window::app_check_update,
|
||||
window::app_startup_update_check,
|
||||
window::app_update_window_ready,
|
||||
window::app_update_status,
|
||||
window::app_update_announcement,
|
||||
window::app_install_update,
|
||||
debug::debug_exception_filters,
|
||||
debug::debug_set_exception_breakpoints,
|
||||
|
||||
@@ -384,24 +384,10 @@ pub fn app_build_info(app: AppHandle) -> BuildInfo {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateInfo {
|
||||
pub version: String,
|
||||
pub current_version: String,
|
||||
pub notes: Option<String>,
|
||||
pub date: Option<String>,
|
||||
}
|
||||
use ide_core::updates::UpdateInfo;
|
||||
pub use ide_core::updates::{UpdateCheck, UpdateSource, UpdateStatus};
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateCheck {
|
||||
/// False when no update endpoint is configured (no request was made).
|
||||
pub enabled: bool,
|
||||
pub update: Option<UpdateInfo>,
|
||||
}
|
||||
|
||||
fn update_endpoint_env() -> Option<String> {
|
||||
pub(crate) fn update_endpoint_env() -> Option<String> {
|
||||
std::env::var("OPENIDEA_UPDATE_ENDPOINT").ok().filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
@@ -412,17 +398,6 @@ pub fn updates_configured<R: tauri::Runtime>(app: &AppHandle<R>) -> bool {
|
||||
!config_endpoints(app).is_empty() || update_endpoint_env().is_some()
|
||||
}
|
||||
|
||||
/// Where updates are downloaded from (System Settings › Updates).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UpdateSource {
|
||||
/// The GitHub releases named in tauri.conf.json.
|
||||
#[default]
|
||||
Github,
|
||||
/// The CNB mirror of the same releases, for networks where GitHub is slow.
|
||||
Cnb,
|
||||
}
|
||||
|
||||
const CNB_ENDPOINT: &str = "https://cnb.cool/l0ng-ai/openidea/-/releases/latest/download/latest.json";
|
||||
|
||||
fn config_endpoints<R: tauri::Runtime>(app: &AppHandle<R>) -> Vec<String> {
|
||||
@@ -476,10 +451,17 @@ fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plug
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult<UpdateCheck> {
|
||||
let Some(updater) = updater(&app, source)? else {
|
||||
crate::updates::check(&app, Some(source)).await
|
||||
}
|
||||
|
||||
pub(crate) async fn check_update(app: &AppHandle, source: UpdateSource) -> IdeResult<UpdateCheck> {
|
||||
let Some(updater) = updater(app, source)? else {
|
||||
return Ok(UpdateCheck { enabled: false, update: None });
|
||||
};
|
||||
let update = updater.check().await.map_err(|e| IdeError::unavailable(e.to_string()))?;
|
||||
let update = tokio::time::timeout(std::time::Duration::from_secs(60), updater.check())
|
||||
.await
|
||||
.map_err(|e| IdeError::unavailable(e.to_string()))?
|
||||
.map_err(|e| IdeError::unavailable(e.to_string()))?;
|
||||
Ok(UpdateCheck {
|
||||
enabled: true,
|
||||
update: update.map(|u| UpdateInfo {
|
||||
@@ -491,23 +473,29 @@ pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult
|
||||
})
|
||||
}
|
||||
|
||||
/// The background check made once per process when the first window starts;
|
||||
/// later calls (other windows) and debug builds get `None`. Failures are logged,
|
||||
/// not reported: the user didn't ask for this check.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn app_startup_update_check(app: AppHandle, source: UpdateSource) -> Option<UpdateInfo> {
|
||||
static DONE: AtomicBool = AtomicBool::new(false);
|
||||
if cfg!(debug_assertions) || DONE.swap(true, Ordering::SeqCst) {
|
||||
return None;
|
||||
}
|
||||
match app_check_update(app, source).await {
|
||||
Ok(check) => check.update,
|
||||
Err(e) => {
|
||||
tracing::warn!("startup update check failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
pub fn app_update_window_ready(
|
||||
app: AppHandle,
|
||||
window: WebviewWindow,
|
||||
ready: bool,
|
||||
system_locale: String,
|
||||
) -> IdeResult<()> {
|
||||
crate::updates::window_ready(&app, window.label(), ready, system_locale);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn app_update_status(state: State<'_, AppState>) -> IdeResult<UpdateStatus> {
|
||||
Ok(state.updates.state.lock().policy.status.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn app_update_announcement(state: State<'_, AppState>, version: String, automatic: bool) -> IdeResult<bool> {
|
||||
let claimed = state.updates.state.lock().policy.announce(&version);
|
||||
Ok(!automatic || claimed)
|
||||
}
|
||||
|
||||
/// Progress of [`app_install_update`], streamed to the UI's background task.
|
||||
|
||||
@@ -42,7 +42,10 @@ pub async fn workspace_update(window: WebviewWindow, name: String, projects: Vec
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
let _ = window.set_title(&info.name);
|
||||
let state = window.state::<AppState>();
|
||||
let project = state.project(window.label())?;
|
||||
state.record_project(&project)?;
|
||||
let _ = window.set_title(&info.display_name);
|
||||
window.state::<AppState>().sink.emit(ide_core::IdeEvent::WindowGroupsChanged);
|
||||
Ok(info)
|
||||
}
|
||||
@@ -52,7 +55,14 @@ pub async fn workspace_update(window: WebviewWindow, name: String, projects: Vec
|
||||
pub async fn workspace_attach(window: WebviewWindow, path: PathBuf) -> IdeResult<ide_core::IpcPath> {
|
||||
let host = window.state::<AppState>().host.clone();
|
||||
let label = window.label().to_owned();
|
||||
tauri::async_runtime::spawn_blocking(move || ide_host::ops::project::attach_workspace(&host, &label, &path))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
let root =
|
||||
tauri::async_runtime::spawn_blocking(move || ide_host::ops::project::attach_workspace(&host, &label, &path))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
let state = window.state::<AppState>();
|
||||
let project = state.project(window.label())?;
|
||||
state.record_project(&project)?;
|
||||
let _ = window.set_title(&ide_host::ops::project::info(&project).display_name);
|
||||
state.sink.emit(ide_core::IdeEvent::WindowGroupsChanged);
|
||||
Ok(root)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ mod state;
|
||||
mod theme_frame;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod titlebar;
|
||||
mod updates;
|
||||
mod window_groups;
|
||||
#[cfg(any(windows, test))]
|
||||
mod window_work_area;
|
||||
@@ -240,6 +241,7 @@ pub fn run() {
|
||||
window_groups::destroyed(window.app_handle(), window.label());
|
||||
windows::close_auxiliary_windows(window.app_handle(), window.label());
|
||||
if let Some(state) = window.try_state::<AppState>() {
|
||||
state.updates.forget_window(window.label());
|
||||
state.close_project(window.label());
|
||||
state.plugins.forget_client(window.label());
|
||||
}
|
||||
|
||||
@@ -224,6 +224,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("file.new", "New…"),
|
||||
act("file.newScratchFile", "New Scratch File…"),
|
||||
act("file.openProject", "Open…"),
|
||||
act("project.attach", "Attach Project…"),
|
||||
act("file.openFile", "Open File…"),
|
||||
act("file.openInNewWindow", "Open in New Window…"),
|
||||
sub(
|
||||
|
||||
@@ -528,7 +528,7 @@ pub async fn remote_connect(
|
||||
peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?;
|
||||
// As the server resolved it (symlinks, a trailing `/`): the frontend compares it with the project's.
|
||||
let root = serde_json::to_value(&project.root).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or(root);
|
||||
let _ = window.set_title(&format!("{} [{title_tag}]", project.name));
|
||||
let _ = window.set_title(&format!("{} [{title_tag}]", project.display_name));
|
||||
let connection = Arc::new(Connection {
|
||||
peer,
|
||||
target: target.clone(),
|
||||
|
||||
+17
-7
@@ -15,6 +15,7 @@ pub struct AppState {
|
||||
pub app: AppHandle,
|
||||
pub host: Arc<Host>,
|
||||
pub startup: Startup,
|
||||
pub updates: crate::updates::Updates,
|
||||
pub recent: RecentProjects,
|
||||
/// Plugins (`ide_host::plugins`), shared by all windows.
|
||||
pub plugins: Arc<ide_host::plugins::PluginHost>,
|
||||
@@ -99,6 +100,7 @@ impl AppState {
|
||||
}));
|
||||
Ok(Self {
|
||||
recent: RecentProjects::load(&config_dir),
|
||||
updates: Default::default(),
|
||||
askpass: start_askpass(&app),
|
||||
sink: global_sink,
|
||||
plugins: crate::commands::plugin::create_host(&app, &data_dir, &host)?,
|
||||
@@ -142,16 +144,24 @@ impl AppState {
|
||||
// A local project replaces a remote one in this window.
|
||||
self.remote_dev.disconnect(window);
|
||||
let project = self.host.open_project(window, root)?;
|
||||
let branch = project.repos.read().primary().and_then(|r| r.branch_info().ok()).and_then(|b| b.head);
|
||||
self.recent.touch(&project.root, branch)?;
|
||||
let display_name = project.settings.effective().general.project_name;
|
||||
if !display_name.trim().is_empty() {
|
||||
self.recent.rename(&project.root, Some(display_name.trim()))?;
|
||||
}
|
||||
crate::jump_list::refresh(&self.app, self.recent.list());
|
||||
self.record_project(&project)?;
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
pub fn record_project(&self, project: &Project) -> IdeResult<()> {
|
||||
let info = ide_host::ops::project::info(project);
|
||||
let attached = info.workspace.as_ref().is_some_and(|w| !w.is_container(&project.root));
|
||||
let branch = if attached {
|
||||
None
|
||||
} else {
|
||||
project.repos.read().primary().and_then(|r| r.branch_info().ok()).and_then(|b| b.head)
|
||||
};
|
||||
self.recent.touch(&project.root, branch)?;
|
||||
self.recent.rename(&project.root, Some(&info.display_name))?;
|
||||
crate::jump_list::refresh(&self.app, self.recent.list());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens `window` for editing the loose file `file` (LightEdit); not a recent project.
|
||||
pub fn open_light(&self, window: &str, file: &Path) -> IdeResult<Arc<Project>> {
|
||||
self.remote_dev.disconnect(window);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Desktop updater coordination shared by all webviews.
|
||||
|
||||
use crate::commands::window::{UpdateSource, check_update, update_endpoint_env, updates_configured};
|
||||
use crate::state::AppState;
|
||||
use ide_core::updates::{UpdateCheck, UpdatePolicy};
|
||||
use ide_core::{IdeEvent, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Updates {
|
||||
started: AtomicBool,
|
||||
ready: Mutex<HashSet<String>>,
|
||||
system_locale: Mutex<String>,
|
||||
pub wake: Notify,
|
||||
gate: tokio::sync::Mutex<()>,
|
||||
pub state: Mutex<CheckState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CheckState {
|
||||
pub policy: UpdatePolicy,
|
||||
revision: u64,
|
||||
result: Option<IdeResult<UpdateCheck>>,
|
||||
}
|
||||
|
||||
impl Updates {
|
||||
pub fn forget_window(&self, window: &str) {
|
||||
self.ready.lock().remove(window);
|
||||
}
|
||||
}
|
||||
|
||||
fn automatic_enabled(app: &AppHandle) -> bool {
|
||||
updates_configured(app) && app.state::<AppState>().global_settings.effective().general.auto_check_updates
|
||||
}
|
||||
|
||||
/// Called after an ordinary window has installed its event handlers. The task survives window closes and HMR.
|
||||
pub fn window_ready(app: &AppHandle, window: &str, ready: bool, system_locale: String) {
|
||||
if !ready {
|
||||
app.state::<AppState>().updates.forget_window(window);
|
||||
return;
|
||||
}
|
||||
app.state::<AppState>().updates.ready.lock().insert(window.to_owned());
|
||||
*app.state::<AppState>().updates.system_locale.lock() = system_locale;
|
||||
if !updates_configured(app) || (cfg!(debug_assertions) && update_endpoint_env().is_none()) {
|
||||
return;
|
||||
}
|
||||
if app.state::<AppState>().updates.started.swap(true, Ordering::SeqCst) {
|
||||
announce_available(app);
|
||||
return;
|
||||
}
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
let state = app.state::<AppState>();
|
||||
let wake = state.updates.wake.notified();
|
||||
let delay = automatic_enabled(&app).then(|| state.updates.state.lock().policy.due_in(Instant::now()));
|
||||
if delay.is_some_and(|d| d.is_zero()) {
|
||||
if let Err(error) = check(&app, None).await {
|
||||
tracing::warn!(%error, "automatic update check failed");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match delay {
|
||||
Some(delay) => tokio::select! {
|
||||
_ = tokio::time::sleep(delay) => {},
|
||||
_ = wake => {},
|
||||
},
|
||||
None => wake.await,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `None` is an automatic check; explicit sources are manual checks and always return their result.
|
||||
pub async fn check(app: &AppHandle, source: Option<UpdateSource>) -> IdeResult<UpdateCheck> {
|
||||
let state = app.state::<AppState>();
|
||||
let revision = state.updates.state.lock().revision;
|
||||
let _gate = state.updates.gate.lock().await;
|
||||
let automatic = source.is_none();
|
||||
{
|
||||
let current = state.updates.state.lock();
|
||||
if !automatic
|
||||
&& current.revision != revision
|
||||
&& let Some(result) = ¤t.result
|
||||
{
|
||||
return result.clone();
|
||||
}
|
||||
if automatic && (!automatic_enabled(app) || !current.policy.due_in(Instant::now()).is_zero()) {
|
||||
return Ok(UpdateCheck { enabled: updates_configured(app), update: None });
|
||||
}
|
||||
}
|
||||
let settings = state.global_settings.effective();
|
||||
let source = source.unwrap_or_else(|| {
|
||||
UpdateSource::resolve(
|
||||
&settings.general.update_source,
|
||||
&settings.appearance.locale,
|
||||
&state.updates.system_locale.lock(),
|
||||
)
|
||||
});
|
||||
state.updates.state.lock().policy.begin(Instant::now());
|
||||
emit_status(&state);
|
||||
let result = check_update(app, source).await;
|
||||
let checked_at = result
|
||||
.as_ref()
|
||||
.ok()
|
||||
.filter(|check| check.enabled)
|
||||
.map(|_| SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64() * 1000.0);
|
||||
{
|
||||
let mut current = state.updates.state.lock();
|
||||
current.policy.finish(checked_at);
|
||||
current.revision += 1;
|
||||
current.result = Some(result.clone());
|
||||
}
|
||||
emit_status(&state);
|
||||
state.updates.wake.notify_one();
|
||||
if automatic {
|
||||
announce_available(app);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn emit_status(state: &AppState) {
|
||||
let status = state.updates.state.lock().policy.status.clone();
|
||||
state.sink.emit(IdeEvent::UpdateStatusChanged { status });
|
||||
}
|
||||
|
||||
fn announce_available(app: &AppHandle) {
|
||||
if !automatic_enabled(app) {
|
||||
return;
|
||||
}
|
||||
let state = app.state::<AppState>();
|
||||
let owner = {
|
||||
let mut ready = state.updates.ready.lock();
|
||||
ready.retain(|label| app.get_webview_window(label).is_some());
|
||||
let focused = crate::windows::target_window(app);
|
||||
ready_owner(focused.as_ref().map(|window| window.label()), &crate::windows::project_window_labels(app), &ready)
|
||||
};
|
||||
let Some(owner) = owner else { return };
|
||||
let update = {
|
||||
let current = state.updates.state.lock();
|
||||
let update = current.result.as_ref().and_then(|r| r.as_ref().ok()).and_then(|r| r.update.clone());
|
||||
update.filter(|update| !current.policy.announced(&update.version))
|
||||
};
|
||||
if let Some(update) = update {
|
||||
state.window_sink(&owner).emit(IdeEvent::UpdateAvailable { update });
|
||||
}
|
||||
}
|
||||
|
||||
fn ready_owner(preferred: Option<&str>, live: &[String], ready: &HashSet<String>) -> Option<String> {
|
||||
preferred
|
||||
.map(AppState::owner_window)
|
||||
.filter(|label| ready.contains(*label) && live.iter().any(|live| live.as_str() == *label))
|
||||
.map(str::to_owned)
|
||||
.or_else(|| live.iter().find(|label| ready.contains(*label)).cloned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn notifications_only_target_live_owners_that_have_subscribed() {
|
||||
let live = vec!["main".into(), "project-2".into()];
|
||||
let ready = HashSet::from(["main".into(), "closed-window".into()]);
|
||||
for focused in [Some("project-2"), Some("settings-main"), Some("closed-window"), None] {
|
||||
assert_eq!(ready_owner(focused, &live, &ready), Some("main".into()));
|
||||
}
|
||||
assert_eq!(ready_owner(Some("main"), &live, &HashSet::new()), None);
|
||||
assert_eq!(ready_owner(Some("closed-window"), &[], &ready), None);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ pub fn current(app: &AppHandle, caller: &str) -> Vec<WindowTab> {
|
||||
let project = host.project(label).ok();
|
||||
let name = project
|
||||
.as_ref()
|
||||
.map(|p| ide_host::ops::project::info(p).name)
|
||||
.map(|p| ide_host::ops::project::info(p).display_name)
|
||||
.unwrap_or_else(|| window.title().unwrap_or_default());
|
||||
Some(WindowTab {
|
||||
label: label.clone(),
|
||||
|
||||
+25
-21
@@ -66,6 +66,7 @@ export const commands = {
|
||||
trusted: boolean,
|
||||
root: IpcPath,
|
||||
name: string,
|
||||
displayName: string,
|
||||
isGit: boolean,
|
||||
settings: Settings_Serialize,
|
||||
workspace?: Workspace_Serialize | null,
|
||||
@@ -226,6 +227,12 @@ export const commands = {
|
||||
clipboardFileList: () => typedError<string[], IdeError>(__TAURI_INVOKE("clipboard_file_list")),
|
||||
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
|
||||
clipboardHasFiles: () => __TAURI_INVOKE<boolean>("clipboard_has_files"),
|
||||
/**
|
||||
* The clipboard's image as a PNG data URL, for a paste whose event carried no
|
||||
* files: WebKitGTK leaves `clipboardData` empty for a raw image (a screenshot).
|
||||
* None when the clipboard holds no image, and always on other platforms.
|
||||
*/
|
||||
clipboardImage: () => typedError<string | null, IdeError>(__TAURI_INVOKE("clipboard_image")),
|
||||
/**
|
||||
* Replaces the clipboard with `paths`, so Explorer can paste them. Best-effort:
|
||||
* false when the clipboard stayed busy; the caller keeps working either way.
|
||||
@@ -1004,17 +1011,9 @@ export const commands = {
|
||||
appCliUninstall: () => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("app_cli_uninstall")),
|
||||
appBuildInfo: () => __TAURI_INVOKE<BuildInfo>("app_build_info"),
|
||||
appCheckUpdate: (source: UpdateSource) => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update", { source })),
|
||||
/**
|
||||
* The background check made once per process when the first window starts;
|
||||
* later calls (other windows) and debug builds get `None`. Failures are logged,
|
||||
* not reported: the user didn't ask for this check.
|
||||
*/
|
||||
appStartupUpdateCheck: (source: UpdateSource) => __TAURI_INVOKE<{
|
||||
version: string,
|
||||
currentVersion: string,
|
||||
notes: string | null,
|
||||
date: string | null,
|
||||
} | null>("app_startup_update_check", { source }),
|
||||
appUpdateWindowReady: (ready: boolean, systemLocale: string) => typedError<null, IdeError>(__TAURI_INVOKE("app_update_window_ready", { ready, systemLocale })),
|
||||
appUpdateStatus: () => typedError<UpdateStatus, IdeError>(__TAURI_INVOKE("app_update_status")),
|
||||
appUpdateAnnouncement: (version: string, automatic: boolean) => typedError<boolean, IdeError>(__TAURI_INVOKE("app_update_announcement", { version, automatic })),
|
||||
/**
|
||||
* Downloads and installs the available update, streaming progress to `on_progress`.
|
||||
* Cancellable through `request` until the download completes; the UI then restarts
|
||||
@@ -3195,7 +3194,7 @@ export type DbRoutineInfo = {
|
||||
*/
|
||||
export type DbRowEdit = { kind: "update"; key: DbCellValue[]; values: DbCellValue[] } | { kind: "insert"; values: DbCellValue[] } | { kind: "delete"; key: DbCellValue[] };
|
||||
|
||||
/** A schema's routines and sequences (the tree's `routines` / `sequences` folders). */
|
||||
/** A schema's routines (procedures and functions) and sequences. */
|
||||
export type DbSchemaObjects = {
|
||||
routines: DbRoutineInfo[],
|
||||
sequences: DbSequenceInfo[],
|
||||
@@ -4396,6 +4395,7 @@ export type GeneralSettings = {
|
||||
projectName?: string,
|
||||
/** Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`. */
|
||||
updateSource?: string,
|
||||
autoCheckUpdates?: boolean,
|
||||
};
|
||||
|
||||
/** A repository of the signed-in account (owned, collaborator, or organization member). */
|
||||
@@ -4871,7 +4871,7 @@ cancellable: boolean } |
|
||||
* A setting was written (any window); the windows it concerns (all for a
|
||||
* global value, the project's for a project value) re-read their effective settings.
|
||||
*/
|
||||
{ type: "settingsChanged"; key: string } |
|
||||
{ type: "settingsChanged"; key: string } | { type: "updateStatusChanged"; status: UpdateStatus } | { type: "updateAvailable"; update: UpdateInfo } |
|
||||
/**
|
||||
* The project's source / test / resource / excluded roots changed (build files
|
||||
* changed or folders were marked); the UI re-reads them with `project_roots`.
|
||||
@@ -5271,6 +5271,8 @@ export type JavaSettings = {
|
||||
* (VS Code's `java.jdt.ls.vmargs`).
|
||||
*/
|
||||
jdtlsVmArgs?: string,
|
||||
/** Share one JDT LS across builds in an ordinary project window; other windows remain separate. */
|
||||
jdtlsShared?: boolean,
|
||||
/** Build Tools › Maven. */
|
||||
maven?: MavenSettings,
|
||||
/** Build Tools › Gradle. */
|
||||
@@ -5552,7 +5554,7 @@ export type LaunchConfig = {
|
||||
export type LibraryGroup = {
|
||||
/** Display name, e.g. `Python 3.12 (.venv)`. */
|
||||
name: string,
|
||||
/** `python` | `jdk` | `nodeModules` | `nodeCore` | `rust` | `cargo` | `go` | `goModules` | `maven` | `gradle`. */
|
||||
/** `python` | `jdk` | `nodeModules` | `nodeCore` | `rust` | `cargo` | `go` | `goModules` | `maven` | `gradle` | `classpath`. */
|
||||
kind: string,
|
||||
roots: LibraryRoot[],
|
||||
};
|
||||
@@ -6946,6 +6948,7 @@ export type ProjectInfo_Deserialize = {
|
||||
trusted: boolean,
|
||||
root: IpcPath,
|
||||
name: string,
|
||||
displayName?: string,
|
||||
isGit: boolean,
|
||||
settings: Settings_Deserialize,
|
||||
workspace?: Workspace_Deserialize | null,
|
||||
@@ -6956,6 +6959,7 @@ export type ProjectInfo_Serialize = {
|
||||
trusted: boolean,
|
||||
root: IpcPath,
|
||||
name: string,
|
||||
displayName: string,
|
||||
isGit: boolean,
|
||||
settings: Settings_Serialize,
|
||||
workspace?: Workspace_Serialize | null,
|
||||
@@ -8742,7 +8746,6 @@ export type UnityProjectInfo = {
|
||||
};
|
||||
|
||||
export type UpdateCheck = {
|
||||
/** False when no update endpoint is configured (no request was made). */
|
||||
enabled: boolean,
|
||||
update: UpdateInfo | null,
|
||||
};
|
||||
@@ -8761,12 +8764,13 @@ export type UpdateProgress =
|
||||
/** Download done; the new version is being written over this one. */
|
||||
{ type: "installing" };
|
||||
|
||||
/** Where updates are downloaded from (System Settings › Updates). */
|
||||
export type UpdateSource =
|
||||
/** The GitHub releases named in tauri.conf.json. */
|
||||
"github" |
|
||||
/** The CNB mirror of the same releases, for networks where GitHub is slow. */
|
||||
"cnb";
|
||||
export type UpdateSource = "github" | "cnb";
|
||||
|
||||
export type UpdateStatus = {
|
||||
/** Unix milliseconds of the last successful check in this process. */
|
||||
lastChecked: number | null,
|
||||
checking: boolean,
|
||||
};
|
||||
|
||||
export type Variable = {
|
||||
name: string,
|
||||
|
||||
@@ -324,6 +324,13 @@ export interface SetupOptions {
|
||||
* below its top edge. WebKit positions them `absolute` within `.cm-editor`, so
|
||||
* a window-wide right bound makes CodeMirror skip the shift left and the
|
||||
* browser squeezes the box into the space left of the editor's edge.
|
||||
*
|
||||
* They are parented to the body (#2310): tooltips are `position: fixed` but
|
||||
* appended inside the editor by default, whose `.content{isolation:isolate}`
|
||||
* stacking context paints as one unit below the later bottom toolwindow — so
|
||||
* completion popups got covered where they overflowed the editor's bottom
|
||||
* edge. At the body level their z-index (500) sits above panels
|
||||
* (`--z-panel: 10`) while staying below dialogs (`--z-dialog: 900`).
|
||||
*/
|
||||
export function tooltipBounds(view: { scrollDOM: Element; dom: Element }) {
|
||||
const { top } = view.scrollDOM.getBoundingClientRect();
|
||||
@@ -336,7 +343,10 @@ export function tooltipBounds(view: { scrollDOM: Element; dom: Element }) {
|
||||
};
|
||||
}
|
||||
|
||||
const tooltipSpace = tooltips({ tooltipSpace: tooltipBounds });
|
||||
const tooltipSpace = tooltips({
|
||||
parent: typeof document === "undefined" ? undefined : document.body,
|
||||
tooltipSpace: tooltipBounds,
|
||||
});
|
||||
|
||||
export function baseExtensions(o: SetupOptions): Extension {
|
||||
return [
|
||||
|
||||
@@ -121,8 +121,9 @@ export const overviewStyles = {
|
||||
};
|
||||
|
||||
export const openideaTheme = EditorView.theme({
|
||||
// Not on "&": the tooltip container in `body` carries the theme classes too.
|
||||
"&.cm-editor": { height: "100%" },
|
||||
"&": {
|
||||
height: "100%",
|
||||
backgroundColor: "var(--bg-editor, var(--bg-island, var(--bg-app)))",
|
||||
color: "var(--fg-editor, var(--fg))",
|
||||
"--editor-line-number": "var(--fg-line-number, var(--fg-gutter))",
|
||||
|
||||
@@ -216,7 +216,7 @@ describe("EditorController", () => {
|
||||
expect(pane().tabs.map((t) => t.input.path)).toEqual(["/p/a.ts", "/p/c.ts"]);
|
||||
});
|
||||
|
||||
it("carries the plain project's editor tabs into its automatic workspace", async () => {
|
||||
it("keeps editor tabs and unsaved documents through attach and detach", async () => {
|
||||
const settings = {};
|
||||
useProject.setState({
|
||||
project: { root: "/attach-a", name: "A", trusted: true, isGit: false, settings },
|
||||
@@ -227,19 +227,31 @@ describe("EditorController", () => {
|
||||
ctrl.start();
|
||||
await ctrl.openFile("/attach-a/one.txt", { focus: false, preview: false });
|
||||
await ctrl.openFile("/attach-a/two.txt", { focus: false, preview: false });
|
||||
const slot = ctrl.activeSlot!;
|
||||
const host = document.createElement("div");
|
||||
const view = slot.mount(host);
|
||||
ctrl.onMounted(slot);
|
||||
view.dispatch({ changes: { from: 0, insert: "unsaved " }, userEvent: "input.type" });
|
||||
const tabs = useTabs.getState();
|
||||
useProject.setState({
|
||||
project: {
|
||||
root: "/attach-workspace",
|
||||
name: "A",
|
||||
root: "/attach-a",
|
||||
name: "A, attach-b",
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
settings,
|
||||
workspace: { version: 1, name: "A", projects: ["/attach-a", "/attach-b"] },
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(openFilePaths(useTabs.getState())).toEqual(["/attach-a/one.txt", "/attach-a/two.txt"]),
|
||||
);
|
||||
expect(useTabs.getState()).toBe(tabs);
|
||||
expect(ctrl.activeSlot).toBe(slot);
|
||||
expect(view.state.doc.toString()).toContain("unsaved ");
|
||||
expect(openFilePaths(useTabs.getState())).toEqual(["/attach-a/one.txt", "/attach-a/two.txt"]);
|
||||
useProject.setState({ project: { root: "/attach-a", name: "A", trusted: true, isGit: false, settings } });
|
||||
expect(useTabs.getState()).toBe(tabs);
|
||||
expect(ctrl.activeSlot).toBe(slot);
|
||||
expect(view.state.doc.toString()).toContain("unsaved ");
|
||||
host.remove();
|
||||
useProject.setState({ project: null });
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ import { t } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { isRemoteWindow } from "@/platform/remote";
|
||||
import { fileIconName, showContextMenu, type MenuItem } from "@/ui";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { fileManagerName } from "@/platform/fileManager";
|
||||
import {
|
||||
activateTab,
|
||||
changeSplitOrientation,
|
||||
@@ -307,15 +307,6 @@ export class EditorController implements SlotHost {
|
||||
prevRoot = root;
|
||||
prevLight = s.lightEdit;
|
||||
this.saveSession();
|
||||
if (
|
||||
root &&
|
||||
previous.project &&
|
||||
!previous.project.workspace &&
|
||||
s.project?.workspace?.projects[0] === previous.project.root
|
||||
) {
|
||||
const session = loadSession(previous.project.root);
|
||||
if (session) storeSession(root, session);
|
||||
}
|
||||
this.sessionRoot = null;
|
||||
const generation = ++this.projectGeneration;
|
||||
const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds);
|
||||
@@ -338,8 +329,8 @@ export class EditorController implements SlotHost {
|
||||
.catch((e) => this.documentError(e))
|
||||
.then(() => {
|
||||
if (generation !== this.projectGeneration) return;
|
||||
if (root) this.restoreSession(root);
|
||||
else if (s.lightEdit) this.restoreSession(LIGHT_EDIT_SESSION);
|
||||
if (root) return this.restoreSession(root);
|
||||
else if (s.lightEdit) return this.restoreSession(LIGHT_EDIT_SESSION);
|
||||
});
|
||||
} else if (previous.opening && !s.opening && this.closeCommitted) {
|
||||
// A failed project open returns to the same project.
|
||||
@@ -354,7 +345,7 @@ export class EditorController implements SlotHost {
|
||||
this.upgradeWrappedImageTabs(wrappers);
|
||||
}),
|
||||
);
|
||||
if (prevRoot) this.restoreSession(prevRoot);
|
||||
if (prevRoot) this.projectTransition = this.restoreSession(prevRoot);
|
||||
if (this.manager.backend.onFsChanged)
|
||||
this.disposers.push(this.manager.backend.onFsChanged((e) => this.onFilesRemoved(e.removed)));
|
||||
const onWindowBlur = () => {
|
||||
@@ -520,7 +511,7 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
|
||||
/** Reopens the tabs, splits and carets saved for `root` (after the project opened). */
|
||||
restoreSession(root: string) {
|
||||
async restoreSession(root: string) {
|
||||
this.sessionRoot = root;
|
||||
const session = loadSession(root);
|
||||
if (!session) return;
|
||||
@@ -528,17 +519,29 @@ export class EditorController implements SlotHost {
|
||||
const kinds = useContributions.getState().editorKinds;
|
||||
for (const pane of Object.values(state.panes))
|
||||
pane.tabs = pane.tabs.map((tab) => (documentLifecycle(tab.input, kinds) ? { ...tab, preview: false } : tab));
|
||||
this.restoredViews = new Map(Object.entries(session.files));
|
||||
const current = useTabs.getState();
|
||||
const liveKeys = openKeys(current);
|
||||
this.restoredViews = new Map(Object.entries(session.files).filter(([path]) => !liveKeys.has(fileKey(path))));
|
||||
this.restoredTabs = new Set(Object.values(state.panes).flatMap((p) => p.tabs.map((t) => t.id)));
|
||||
this.restoring = true;
|
||||
try {
|
||||
// A restore landing after tabs were already opened (e.g. a launched file
|
||||
// admitted while the transition was still in flight) must not wipe them.
|
||||
useTabs.setState((current) => mergeSessionTabs(current, state), true);
|
||||
useTabs.setState(mergeSessionTabs(current, state), true);
|
||||
} finally {
|
||||
this.restoring = false;
|
||||
}
|
||||
for (const pane of Object.values(state.panes)) if (pane.active) void this.ensureSlot(pane.active);
|
||||
if (liveKeys.size)
|
||||
for (const paneId of paneIds(useTabs.getState())) await this.enforcePaneTabLimit(paneId, undefined, liveKeys);
|
||||
const next = useTabs.getState();
|
||||
for (const pane of Object.values(next.panes)) if (pane.active) void this.ensureSlot(pane.active);
|
||||
if (liveKeys.size) {
|
||||
const active = this.activeTabOf(next);
|
||||
if (active) {
|
||||
await this.ensureSlot(active.id);
|
||||
if (this.activeTabOf()?.id === active.id) this.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private activeTabOf(s: TabsState = useTabs.getState()): Tab | null {
|
||||
@@ -1131,24 +1134,7 @@ export class EditorController implements SlotHost {
|
||||
this.admitting = false;
|
||||
}
|
||||
const canFocus = () => this.isCurrentOpening(generation) && this.activeTabOf()?.id === tabId;
|
||||
const tabPrefs = editorPrefs().tabs;
|
||||
await this.removeGuarded(
|
||||
(current) =>
|
||||
current.panes[paneId]
|
||||
? enforceTabLimit(
|
||||
current,
|
||||
paneId,
|
||||
tabPrefs.tabLimit,
|
||||
tabPrefs.closingPolicy,
|
||||
(tab) =>
|
||||
inputDirty(tab.input, useContributions.getState().editorKinds) ??
|
||||
!!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty),
|
||||
activate ? tabId : (current.panes[paneId]?.active ?? tabId),
|
||||
(tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true,
|
||||
)
|
||||
: current,
|
||||
{ protect: (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true },
|
||||
);
|
||||
await this.enforcePaneTabLimit(paneId, activate ? tabId : (useTabs.getState().panes[paneId]?.active ?? tabId));
|
||||
if (!isTextInput(input)) {
|
||||
if (o.focus !== false)
|
||||
requestAnimationFrame(() => {
|
||||
@@ -1184,6 +1170,29 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
}
|
||||
|
||||
private enforcePaneTabLimit(paneId: string, keep?: string, liveKeys?: ReadonlySet<string>) {
|
||||
const prefs = editorPrefs().tabs;
|
||||
const protect = (tab: Tab) =>
|
||||
!!liveKeys?.has(tab.input.key) || inputDirty(tab.input, useContributions.getState().editorKinds) === true;
|
||||
return this.removeGuarded(
|
||||
(current) =>
|
||||
current.panes[paneId]
|
||||
? enforceTabLimit(
|
||||
current,
|
||||
paneId,
|
||||
prefs.tabLimit,
|
||||
prefs.closingPolicy,
|
||||
(tab) =>
|
||||
inputDirty(tab.input, useContributions.getState().editorKinds) ??
|
||||
!!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty),
|
||||
keep ?? current.panes[paneId]?.active ?? undefined,
|
||||
protect,
|
||||
)
|
||||
: current,
|
||||
{ protect },
|
||||
);
|
||||
}
|
||||
|
||||
activate(paneId: string, tabId: string, focus = true) {
|
||||
const restored = restoreMaximizedToolWindow();
|
||||
++this.activationGeneration;
|
||||
@@ -1901,7 +1910,7 @@ function openInMenu(path: string): MenuItem {
|
||||
label: "Open In",
|
||||
submenu: [
|
||||
{ kind: "action", action: "navigate.selectIn", arg: { path }, label: "Project View" },
|
||||
{ kind: "action", action: "project.revealInFinder", arg: path, label: revealLabel() },
|
||||
{ kind: "action", action: "project.revealInFinder", arg: path, label: fileManagerName() },
|
||||
{ kind: "action", action: "project.openInTerminal", arg: path, label: "Terminal" },
|
||||
{ kind: "separator" },
|
||||
...menuGroupItems("openIn", { paths: [path] }),
|
||||
@@ -1986,12 +1995,6 @@ export function editorController(): EditorController {
|
||||
return (controller ??= new EditorController());
|
||||
}
|
||||
|
||||
/** "Finder" / "Explorer" / "Files", like IDEA's Open In submenu. */
|
||||
function revealLabel(): string {
|
||||
const os = usePlatform.getState().os;
|
||||
return os === "macos" ? "Finder" : os === "windows" ? "Explorer" : "Files";
|
||||
}
|
||||
|
||||
function inPopup(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && !!target.closest("[data-popup]");
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorController, LIGHT_EDIT_SESSION, openFilePaths, useTabs } from "./controller";
|
||||
import { createMemoryBackend } from "./backend";
|
||||
import { initialTabsState } from "./model/tabs";
|
||||
import { storeSession } from "./model/session";
|
||||
import { initialTabsState, openTab, paneIds, togglePin } from "./model/tabs";
|
||||
import { restoreTabsState, storeSession, type EditorSession } from "./model/session";
|
||||
import { useProject } from "@/platform/project";
|
||||
|
||||
let ctrl: EditorController | null = null;
|
||||
@@ -15,6 +15,7 @@ afterEach(() => {
|
||||
useProject.setState({ project: null, lightEdit: false, settings: initialSettings } as never);
|
||||
try {
|
||||
localStorage.removeItem(`openidea.editorSession.v1:${LIGHT_EDIT_SESSION}`);
|
||||
storeSession("/p", null);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -55,9 +56,181 @@ describe("lightedit launch (#2229)", () => {
|
||||
useProject.setState({ project: null, lightEdit: true });
|
||||
await ctrl.openFile("/p/b.ts", { focus: true });
|
||||
// A stale/second transition restore landing after the admit (#2229).
|
||||
ctrl.restoreSession(LIGHT_EDIT_SESSION);
|
||||
await ctrl.restoreSession(LIGHT_EDIT_SESSION);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]);
|
||||
expect(activePath()).toBe("/p/b.ts");
|
||||
});
|
||||
});
|
||||
|
||||
function savedSplit(dir: "row" | "column" = "row"): EditorSession {
|
||||
return {
|
||||
version: 1,
|
||||
layout: {
|
||||
kind: "split",
|
||||
dir,
|
||||
children: [
|
||||
{
|
||||
kind: "pane",
|
||||
tabs: [
|
||||
{ path: "/p/a.ts", pinned: true },
|
||||
{ path: "/p/shared.ts", preview: true },
|
||||
],
|
||||
active: 1,
|
||||
},
|
||||
{ kind: "pane", tabs: [{ path: "/p/c.ts", preview: true }, { path: "/p/shared.ts" }], active: 1 },
|
||||
],
|
||||
},
|
||||
activePane: 1,
|
||||
files: {},
|
||||
};
|
||||
}
|
||||
|
||||
function setupRestore() {
|
||||
ctrl = new EditorController(
|
||||
createMemoryBackend({
|
||||
"/p/a.ts": "a",
|
||||
"/p/b.ts": "b\nsecond",
|
||||
"/p/c.ts": "c",
|
||||
"/p/shared.ts": "shared",
|
||||
}),
|
||||
);
|
||||
ctrl.start();
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
describe.each([LIGHT_EDIT_SESSION, "/p"])("late session restore: %s", (root) => {
|
||||
it.each(["row", "column"] as const)(
|
||||
"keeps the saved %s split and intentional copies, with the CLI file active in the saved active pane",
|
||||
async (dir) => {
|
||||
const controller = setupRestore();
|
||||
await controller.openFile("/p/b.ts", { selection: { line: 1, col: 2 } });
|
||||
const live = controller.activeSlot!;
|
||||
storeSession(root, savedSplit(dir));
|
||||
await controller.restoreSession(root);
|
||||
const state = useTabs.getState();
|
||||
expect(state.layout).toMatchObject({ kind: "split", dir });
|
||||
expect(paneIds(state)).toHaveLength(2);
|
||||
expect(state.activePane).toBe(paneIds(state)[1]);
|
||||
expect(
|
||||
Object.values(state.panes)
|
||||
.flatMap((p) => p.tabs)
|
||||
.filter((t) => t.input.path === "/p/shared.ts"),
|
||||
).toHaveLength(2);
|
||||
expect(activePath()).toBe("/p/b.ts");
|
||||
expect(controller.activeSlot).toBe(live);
|
||||
expect(live.caret()).toMatchObject({ line: 1, col: 2 });
|
||||
expect(live.wantsFocus).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps pins before regular tabs, including after a subsequent pin and open", async () => {
|
||||
const controller = setupRestore();
|
||||
await controller.openFile("/p/b.ts", { focus: false });
|
||||
storeSession(root, {
|
||||
version: 1,
|
||||
layout: { kind: "pane", tabs: [{ path: "/p/a.ts", pinned: true }], active: 0 },
|
||||
activePane: 0,
|
||||
files: {},
|
||||
});
|
||||
await controller.restoreSession(root);
|
||||
let state = useTabs.getState();
|
||||
expect(state.panes[state.activePane]!.tabs.map((t) => t.pinned)).toEqual([true, false]);
|
||||
const pinned = togglePin(state, state.activePane, state.panes[state.activePane]!.active!);
|
||||
state = openTab(pinned, pinned.activePane, { kind: "file", key: "file:/p/c.ts", path: "/p/c.ts" }).state;
|
||||
expect(state.panes[state.activePane]!.tabs.map((t) => t.pinned)).toEqual([true, true, false]);
|
||||
});
|
||||
|
||||
it("keeps at most one preview per pane when the live tab and both saved panes have previews", async () => {
|
||||
const controller = setupRestore();
|
||||
await controller.openFile("/p/b.ts", { preview: true, focus: false });
|
||||
storeSession(root, savedSplit());
|
||||
await controller.restoreSession(root);
|
||||
for (const pane of Object.values(useTabs.getState().panes))
|
||||
expect(pane.tabs.filter((t) => t.preview).length).toBeLessThanOrEqual(1);
|
||||
expect(openFilePaths(useTabs.getState())).toContain("/p/b.ts");
|
||||
});
|
||||
|
||||
it("enforces the tab limit without evicting the launched tab", async () => {
|
||||
useProject.setState({ settings: { editor: { tabs: { tabLimit: 1, closingPolicy: "lru" } } } as never });
|
||||
const controller = setupRestore();
|
||||
await controller.openFile("/p/b.ts", { focus: false });
|
||||
storeSession(root, {
|
||||
version: 1,
|
||||
layout: { kind: "pane", tabs: [{ path: "/p/a.ts" }], active: 0 },
|
||||
activePane: 0,
|
||||
files: {},
|
||||
});
|
||||
await controller.restoreSession(root);
|
||||
expect(openFilePaths(useTabs.getState())).toEqual(["/p/b.ts"]);
|
||||
expect(activePath()).toBe("/p/b.ts");
|
||||
});
|
||||
|
||||
it("keeps the live same-key slot and launch target instead of the saved caret", async () => {
|
||||
const controller = setupRestore();
|
||||
await controller.openFile("/p/b.ts", { selection: { line: 1, col: 2 } });
|
||||
const slot = controller.activeSlot!;
|
||||
storeSession(root, {
|
||||
version: 1,
|
||||
layout: { kind: "pane", tabs: [{ path: "/p/b.ts", pinned: true }], active: 0 },
|
||||
activePane: 0,
|
||||
files: { "/p/b.ts": { anchor: 0, head: 0 } },
|
||||
});
|
||||
await controller.restoreSession(root);
|
||||
expect(openFilePaths(useTabs.getState())).toEqual(["/p/b.ts"]);
|
||||
expect(controller.activeSlot).toBe(slot);
|
||||
expect(slot.caret()).toMatchObject({ line: 1, col: 2 });
|
||||
expect(useTabs.getState().panes[useTabs.getState().activePane]!.tabs[0]!.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it("moves a saved same-key launch target into the saved active pane without losing the split", async () => {
|
||||
const controller = setupRestore();
|
||||
await controller.openFile("/p/b.ts", { selection: { line: 1, col: 2 } });
|
||||
const slot = controller.activeSlot!;
|
||||
const session = savedSplit();
|
||||
if (session.layout.kind !== "split") throw new Error("expected split");
|
||||
session.layout.children[0] = { kind: "pane", tabs: [{ path: "/p/b.ts", pinned: true }], active: 0 };
|
||||
storeSession(root, session);
|
||||
await controller.restoreSession(root);
|
||||
const state = useTabs.getState();
|
||||
expect(state.layout).toMatchObject({ kind: "split", dir: "row" });
|
||||
expect(state.activePane).toBe(paneIds(state)[1]);
|
||||
expect(
|
||||
Object.values(state.panes)
|
||||
.flatMap((p) => p.tabs)
|
||||
.filter((t) => t.input.path === "/p/b.ts"),
|
||||
).toHaveLength(1);
|
||||
expect(controller.activeSlot).toBe(slot);
|
||||
expect(slot.caret()).toMatchObject({ line: 1, col: 2 });
|
||||
expect(state.panes[state.activePane]!.tabs[0]!.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it("protects every live tab when they already exceed the configured limit", async () => {
|
||||
const controller = setupRestore();
|
||||
await controller.openFile("/p/b.ts", { focus: false });
|
||||
await controller.openFile("/p/c.ts", { preview: true, focus: false });
|
||||
useProject.setState({ settings: { editor: { tabs: { tabLimit: 1 } } } as never });
|
||||
storeSession(root, {
|
||||
version: 1,
|
||||
layout: { kind: "pane", tabs: [{ path: "/p/a.ts" }], active: 0 },
|
||||
activePane: 0,
|
||||
files: {},
|
||||
});
|
||||
await controller.restoreSession(root);
|
||||
expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/b.ts", "/p/c.ts"]);
|
||||
expect(activePath()).toBe("/p/c.ts");
|
||||
});
|
||||
|
||||
it("takes the saved state unchanged when the window is empty", async () => {
|
||||
const controller = setupRestore();
|
||||
storeSession(root, savedSplit());
|
||||
await controller.restoreSession(root);
|
||||
const saved = restoreTabsState(savedSplit(), (p) => `file:${p}`);
|
||||
const state = useTabs.getState();
|
||||
expect(paneIds(state)).toHaveLength(2);
|
||||
expect(Object.values(state.panes).map((p) => p.tabs.map((t) => [t.input.path, t.pinned, t.preview]))).toEqual(
|
||||
Object.values(saved.panes).map((p) => p.tabs.map((t) => [t.input.path, t.pinned, t.preview])),
|
||||
);
|
||||
expect(activePath()).toBe("/p/shared.ts");
|
||||
});
|
||||
});
|
||||
@@ -93,27 +93,55 @@ export function openKeys(s: TabsState): Set<string> {
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds restored tabs into the current tabs without losing anything open: tabs
|
||||
* already open stay (with their active tab), restored tabs missing by key are
|
||||
* appended to the active pane. An empty window takes the restored state as-is,
|
||||
* keeping its saved layout and active tab.
|
||||
*/
|
||||
/** Replays live tabs into the saved layout, retaining their slots and the launch target. */
|
||||
export function mergeSessionTabs(current: TabsState, restored: TabsState): TabsState {
|
||||
const target = current.panes[current.activePane];
|
||||
if (!target || !Object.values(current.panes).some((p) => p.tabs.length)) return restored;
|
||||
const seen = openKeys(current);
|
||||
const additions: Tab[] = [];
|
||||
for (const pane of Object.values(restored.panes))
|
||||
for (const tab of pane.tabs) {
|
||||
if (seen.has(tab.input.key)) continue;
|
||||
seen.add(tab.input.key);
|
||||
additions.push(tab);
|
||||
const live = new Map<string, Tab>();
|
||||
for (const pane of Object.values(current.panes)) for (const tab of pane.tabs) live.set(tab.input.key, tab);
|
||||
if (!live.size) return restored;
|
||||
const active = current.panes[current.activePane]?.tabs.find(
|
||||
(t) => t.id === current.panes[current.activePane]?.active,
|
||||
);
|
||||
if (active) live.set(active.input.key, active);
|
||||
let next = restored;
|
||||
for (const tab of live.values()) {
|
||||
let found = findTab(next, tab.input.key, restored.activePane);
|
||||
let opening = tab;
|
||||
// Put the launch target in the saved active pane without collapsing its saved source pane.
|
||||
if (tab === active && found && found.paneId !== restored.activePane) {
|
||||
const source = paneOf(next, found.paneId);
|
||||
const savedId = found.tab.id;
|
||||
const tabs = source.tabs.filter((t) => t.id !== savedId);
|
||||
const mru = source.mru.filter((id) => id !== savedId);
|
||||
next = withPane(next, {
|
||||
...source,
|
||||
tabs,
|
||||
mru,
|
||||
active: source.active === savedId ? (mru[0] ?? tabs[0]?.id ?? null) : source.active,
|
||||
});
|
||||
opening = { ...found.tab, input: tab.input };
|
||||
found = null;
|
||||
}
|
||||
if (!additions.length) return current;
|
||||
const tabs = [...target.tabs, ...additions];
|
||||
const mru = [...target.mru, ...additions.map((t) => t.id).filter((id) => !target.mru.includes(id))];
|
||||
return withPane(current, { ...target, tabs, mru });
|
||||
if (!found) {
|
||||
const pane = paneOf(next, restored.activePane);
|
||||
const opened = openTab(next, pane.id, tab.input, {
|
||||
pinned: opening.pinned,
|
||||
preview: opening.preview && !pane.tabs.some((t) => t.preview),
|
||||
preservePreview: true,
|
||||
activate: false,
|
||||
});
|
||||
next = opened.state;
|
||||
found = { paneId: pane.id, tab: paneOf(next, pane.id).tabs.find((t) => t.id === opened.tabId)! };
|
||||
}
|
||||
const pane = paneOf(next, found.paneId);
|
||||
const savedId = found.tab.id;
|
||||
next = withPane(next, {
|
||||
...pane,
|
||||
tabs: pane.tabs.map((t) => (t.id === savedId ? { ...t, id: tab.id, input: tab.input } : t)),
|
||||
active: pane.active === savedId ? tab.id : pane.active,
|
||||
mru: pane.mru.map((id) => (id === savedId ? tab.id : id)),
|
||||
});
|
||||
}
|
||||
return active ? activateTab(next, restored.activePane, active.id) : next;
|
||||
}
|
||||
|
||||
function withPane(s: TabsState, pane: Pane): TabsState {
|
||||
|
||||
@@ -110,7 +110,8 @@ describe.each([
|
||||
view = editor();
|
||||
activateHover(view, 2, 1);
|
||||
await settle();
|
||||
const tooltip = view.dom.querySelector(".cm-tooltip")!;
|
||||
// Tooltips are parented to the body (#2310), not to the editor element.
|
||||
const tooltip = document.querySelector(".cm-tooltip")!;
|
||||
tooltip.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
|
||||
expect(pointerTarget()).toBe(tooltip);
|
||||
key("keydown", mod, { [flag]: true });
|
||||
|
||||
@@ -300,13 +300,15 @@ export function Composer(props: ComposerProps) {
|
||||
[id],
|
||||
);
|
||||
|
||||
const fitsLimit = (name: string, size: number) => {
|
||||
if (size <= MAX_IMAGE_BYTES) return true;
|
||||
notifications().notify({ level: "warning", title: "Image too large", body: tx`${name} is over 5 MB.` });
|
||||
return false;
|
||||
};
|
||||
|
||||
const readImageFiles = (picked: Iterable<File>) => {
|
||||
for (const file of picked) {
|
||||
if (!file.type.startsWith("image/")) continue;
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
notifications().notify({ level: "warning", title: "Image too large", body: tx`${file.name} is over 5 MB.` });
|
||||
continue;
|
||||
}
|
||||
if (!file.type.startsWith("image/") || !fitsLimit(file.name, file.size)) continue;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const image = typeof reader.result === "string" ? imageFromDataUrl(reader.result, file.name || "image") : null;
|
||||
@@ -316,6 +318,12 @@ export function Composer(props: ComposerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const pasteClipboardImage = async () => {
|
||||
const url = await call(commands.clipboardImage());
|
||||
const image = url ? imageFromDataUrl(url, "image") : null;
|
||||
if (image && fitsLimit("image", (image.data.length * 3) / 4)) addImages([image]);
|
||||
};
|
||||
|
||||
// Files dropped from the OS onto the box: images are attached, other files referenced.
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
@@ -571,6 +579,10 @@ export function Composer(props: ComposerProps) {
|
||||
if (pasted.length) {
|
||||
e.preventDefault();
|
||||
readImageFiles(pasted);
|
||||
} else if (isTauri() && !e.clipboardData.types.length) {
|
||||
pasteClipboardImage().catch((error: unknown) =>
|
||||
notifications().notify({ level: "error", title: tx("Cannot insert image"), body: String(error) }),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ const agentCommands = vi.fn();
|
||||
const agentCreate = vi.fn();
|
||||
const vcsBranches = vi.fn();
|
||||
const vcsBranchInfo = vi.fn();
|
||||
const clipboardImage = vi.fn();
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
@@ -17,6 +18,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
...real.commands,
|
||||
agentCommands: (provider: string, task: string | null) => agentCommands(provider, task),
|
||||
agentCreate: (request: unknown) => agentCreate(request),
|
||||
clipboardImage: () => clipboardImage(),
|
||||
vcsBranchInfo: (root: string | null) => vcsBranchInfo(root),
|
||||
vcsBranches: (root: string | null) => vcsBranches(root),
|
||||
},
|
||||
@@ -33,7 +35,7 @@ import { defaultTaskProject } from "./project";
|
||||
import { HomeEditor } from "./HomeEditor";
|
||||
import composerStyles from "./Composer.module.css";
|
||||
import tagStyles from "@/ui/InlineTag.module.css";
|
||||
import { HOME, updateComposer, useAgents } from "./store";
|
||||
import { composerDraft, HOME, updateComposer, useAgents } from "./store";
|
||||
import { emptyDraft } from "./composerModel";
|
||||
|
||||
const command = (name: string, description: string, source: "command" | "skill" = "command"): AgentCommand => ({
|
||||
@@ -72,6 +74,7 @@ beforeEach(() => {
|
||||
Promise.resolve({ status: "ok", data: { head: root === "/project-b" ? "b-main" : "a-main" } }),
|
||||
);
|
||||
agentCommands.mockReset();
|
||||
clipboardImage.mockReset().mockResolvedValue({ status: "ok", data: "data:image/png;base64,iVBORw0KGgo=" });
|
||||
agentCreate.mockReset().mockResolvedValue({ status: "error", error: { kind: "invalid", message: "stop here" } });
|
||||
useAgents.setState({
|
||||
providers: [claude],
|
||||
@@ -227,6 +230,22 @@ describe("New Task composer commands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("New Task composer paste", () => {
|
||||
it("attaches the clipboard's image when WebKitGTK pastes an empty clipboardData", async () => {
|
||||
render(<HomeEditor />);
|
||||
fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { types: [], files: [] } });
|
||||
await waitFor(() =>
|
||||
expect(composerDraft(HOME).images).toEqual([{ mimeType: "image/png", data: "iVBORw0KGgo=", name: "image" }]),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a text paste to the text box", () => {
|
||||
render(<HomeEditor />);
|
||||
fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { types: ["text/plain"], files: [] } });
|
||||
expect(clipboardImage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace task project", () => {
|
||||
const project = {
|
||||
root: "/workspace",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { call, commands, isCancelled, nextRequestId, type ProjectDiscovery, type RequestId } from "@/platform/ipc";
|
||||
import { invokeLocal } from "@/platform/tauriCore";
|
||||
import { confirmProjectTrust } from "@/platform/projectTrust";
|
||||
import { basename, dirname, useProject } from "@/platform/project";
|
||||
import { isRemoteWindow } from "@/platform/remote";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -101,12 +102,21 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
|
||||
const root = await call(commands.workspaceCreate(path.trim(), name.trim(), included));
|
||||
await openProjectWithPolicy(root);
|
||||
} else {
|
||||
for (const path of included.filter((p) => !workspace?.projects.includes(p))) {
|
||||
await confirmProjectTrust(path);
|
||||
if (!(await call(commands.projectTrustStatus(path))).trusted) {
|
||||
setError(
|
||||
tx`Cannot attach ${path} because it is restricted. Open it separately and choose Trust Project, then attach it again.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const updated = await call(commands.workspaceUpdate(name.trim(), included));
|
||||
useProject.setState({ project: updated, settings: updated.settings });
|
||||
}
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
if (!isCancelled(e)) setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { commands, type ProjectDiscovery } from "@/platform/ipc";
|
||||
import { provideProjectTrustPrompt } from "@/platform/projectTrust";
|
||||
import { invokeLocal } from "@/platform/tauriCore";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { WorkspaceDialogHost, showWorkspaceDialog } from "../WorkspaceDialog";
|
||||
@@ -19,6 +20,10 @@ beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(openProjectWithPolicy).mockClear();
|
||||
vi.mocked(open).mockReset();
|
||||
vi.spyOn(commands, "projectTrustStatus").mockResolvedValue({
|
||||
status: "ok",
|
||||
data: { root: "/work", trusted: true, decided: true },
|
||||
});
|
||||
vi.mocked(invokeLocal).mockClear();
|
||||
useProject.setState({ project: null, settings: null, lightEdit: false });
|
||||
});
|
||||
@@ -191,3 +196,39 @@ it("retains the form and linked folders when saving fails", async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Cancel$/ }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["restricted", "cancelled"])("retains Manage Workspace when project trust is %s", async (decision) => {
|
||||
useProject.setState({
|
||||
project: {
|
||||
root: "/work/primary",
|
||||
name: "primary",
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
settings: {},
|
||||
workspace: { version: 1, name: "primary", projects: ["/work/primary"] },
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(commands.projectTrustStatus).mockResolvedValue({
|
||||
status: "ok",
|
||||
data: { root: "/work/restricted", trusted: false, decided: true },
|
||||
});
|
||||
const update = vi.spyOn(commands, "workspaceUpdate");
|
||||
const dispose = provideProjectTrustPrompt(async () => decision !== "cancelled");
|
||||
try {
|
||||
showWorkspaceDialog("manage");
|
||||
render(<WorkspaceDialogHost />);
|
||||
fireEvent.change(screen.getByLabelText("Project folder path"), { target: { value: "/work/restricted" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Apply$/ }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /^Apply$/ })).toBeEnabled());
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
if (decision === "restricted") {
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"Cannot attach /work/restricted because it is restricted. Open it separately and choose Trust Project, then attach it again.",
|
||||
);
|
||||
} else expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Project folder path")).toHaveValue("/work/restricted");
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Cancel$/ }));
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { projectActions } from "../projectActions";
|
||||
import { installWindowTitle } from "../title";
|
||||
|
||||
const { rename, prompt } = vi.hoisted(() => ({ rename: vi.fn(), prompt: vi.fn() }));
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
isTauri: () => true,
|
||||
commands: { projectRename: rename, menuRefresh: vi.fn() },
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ setTitle: async () => {} }) }));
|
||||
vi.mock("@/workbench/inputDialogs", () => ({ promptInput: prompt }));
|
||||
|
||||
afterEach(() => {
|
||||
useProject.setState({ project: null, settings: null });
|
||||
});
|
||||
|
||||
it("renames from the primary name while updating the attached window label", async () => {
|
||||
const project = {
|
||||
root: "/primary",
|
||||
name: "primary",
|
||||
displayName: "primary, other",
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
settings: {},
|
||||
workspace: { version: 1, name: "primary", projects: ["/primary", "/other"] },
|
||||
};
|
||||
useProject.setState({ project });
|
||||
const dispose = installWindowTitle();
|
||||
try {
|
||||
expect(document.title).toBe("primary, other");
|
||||
prompt.mockImplementation(async ({ initial }) => initial);
|
||||
rename.mockImplementation(async (name) => ({
|
||||
status: "ok",
|
||||
data: { ...project, name, displayName: `${name}, other` },
|
||||
}));
|
||||
await projectActions()
|
||||
.find((a) => a.id === "file.renameProject")!
|
||||
.run({ ctx: {} });
|
||||
expect(rename).toHaveBeenCalledWith("primary");
|
||||
expect(document.title).toBe("primary, other");
|
||||
prompt.mockResolvedValueOnce("Renamed");
|
||||
await projectActions()
|
||||
.find((a) => a.id === "file.renameProject")!
|
||||
.run({ ctx: {} });
|
||||
expect(useProject.getState().project).toMatchObject({ name: "Renamed", displayName: "Renamed, other" });
|
||||
expect(document.title).toBe("Renamed, other");
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
@@ -25,7 +25,9 @@ const appInstallUpdate = vi.fn((_source: string, _request: number, ch: Channel)
|
||||
return install.promise;
|
||||
});
|
||||
const appCheckUpdate = vi.fn(() => check.promise);
|
||||
const appStartupUpdateCheck = vi.fn(async (): Promise<UpdateInfo | null> => null);
|
||||
const appUpdateWindowReady = vi.fn(async (_ready: boolean, _systemLocale: string) => ({ status: "ok", data: null }));
|
||||
const appUpdateAnnouncement = vi.fn(async () => ({ status: "ok", data: true }));
|
||||
const updateListeners = new Map<string, (event: { update: UpdateInfo }) => void>();
|
||||
const appRestart = vi.fn(async () => null);
|
||||
const cancelRequest = vi.fn(async () => null);
|
||||
const appBuildInfo = vi.fn(async () => ({ version: "0.1.21" }));
|
||||
@@ -36,11 +38,17 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
return {
|
||||
...actual,
|
||||
isTauri: () => true,
|
||||
ideEventsReady: async () => {},
|
||||
onIdeEvent: (type: string, handler: (event: { update: UpdateInfo }) => void) => {
|
||||
updateListeners.set(type, handler);
|
||||
return () => updateListeners.delete(type);
|
||||
},
|
||||
commands: {
|
||||
...actual.commands,
|
||||
appInstallUpdate,
|
||||
appCheckUpdate,
|
||||
appStartupUpdateCheck,
|
||||
appUpdateWindowReady,
|
||||
appUpdateAnnouncement,
|
||||
appRestart,
|
||||
cancelRequest,
|
||||
appBuildInfo,
|
||||
@@ -51,7 +59,7 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
|
||||
const {
|
||||
checkForUpdates,
|
||||
checkForUpdatesOnStartup,
|
||||
installUpdateChecks,
|
||||
installUpdate,
|
||||
notifyUpdate,
|
||||
progressText,
|
||||
@@ -115,7 +123,7 @@ describe("Install and Restart", () => {
|
||||
});
|
||||
|
||||
it("joins the running install on repeated clicks (balloon, log entry, a second check)", async () => {
|
||||
notifyUpdate({ version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null });
|
||||
await notifyUpdate({ version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null });
|
||||
const action = notes[0]!.actions![0]!;
|
||||
action.run();
|
||||
action.run();
|
||||
@@ -212,6 +220,30 @@ describe("Check for Updates", () => {
|
||||
expect(task(CHECK_TASK_ID)?.done).toBe(true);
|
||||
});
|
||||
|
||||
it("does not consume an announcement when a manual check is cancelled", async () => {
|
||||
const done = checkForUpdates();
|
||||
task(CHECK_TASK_ID)!.cancel!();
|
||||
await done;
|
||||
check.resolve(
|
||||
ok({ enabled: true, update: { version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null } }),
|
||||
);
|
||||
await tick();
|
||||
expect(appUpdateAnnouncement).not.toHaveBeenCalled();
|
||||
await notifyUpdate({ version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null });
|
||||
expect(appUpdateAnnouncement).toHaveBeenCalledWith("0.1.22", true);
|
||||
expect(notes.at(-1)?.title).toBe("OpenIDEA 0.1.22 is available");
|
||||
});
|
||||
|
||||
it("suppresses an already announced automatic version but still answers a manual check", async () => {
|
||||
const update = { version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null };
|
||||
appUpdateAnnouncement.mockResolvedValueOnce({ status: "ok", data: false });
|
||||
await notifyUpdate(update);
|
||||
expect(notes).toHaveLength(0);
|
||||
await notifyUpdate(update, true);
|
||||
expect(appUpdateAnnouncement).toHaveBeenLastCalledWith("0.1.22", false);
|
||||
expect(notes.at(-1)).toMatchObject({ title: "OpenIDEA 0.1.22 is available", userInitiated: true });
|
||||
});
|
||||
|
||||
it("shows the check in a balloon where there is no status bar (the welcome screen)", async () => {
|
||||
const done = checkForUpdates();
|
||||
expect(notes[0]).toMatchObject({ level: "info", title: "Checking for updates…", key: UPDATE_KEY });
|
||||
@@ -227,16 +259,25 @@ describe("Check for Updates", () => {
|
||||
await done;
|
||||
});
|
||||
|
||||
it("leaves the startup check to the IDE Updates notification settings", async () => {
|
||||
appStartupUpdateCheck.mockResolvedValueOnce({
|
||||
version: "0.1.22",
|
||||
currentVersion: "0.1.21",
|
||||
notes: null,
|
||||
date: null,
|
||||
it("starts the process scheduler after subscribing and leaves automatic results to notification settings", async () => {
|
||||
const dispose = installUpdateChecks();
|
||||
await tick();
|
||||
expect(appUpdateWindowReady).toHaveBeenCalledTimes(1);
|
||||
updateListeners.get("updateAvailable")!({
|
||||
update: { version: "0.1.22", currentVersion: "0.1.21", notes: null, date: null },
|
||||
});
|
||||
await checkForUpdatesOnStartup();
|
||||
expect(notes.at(-1)).toMatchObject({ title: "OpenIDEA 0.1.22 is available" });
|
||||
expect(notes.at(-1)?.userInitiated).toBe(false);
|
||||
await tick();
|
||||
expect(notes.at(-1)).toMatchObject({ title: "OpenIDEA 0.1.22 is available", userInitiated: false });
|
||||
dispose();
|
||||
expect(updateListeners.has("updateAvailable")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not start a schedule after its window has been disposed", async () => {
|
||||
const dispose = installUpdateChecks();
|
||||
dispose();
|
||||
await tick();
|
||||
expect(appUpdateWindowReady).not.toHaveBeenCalledWith(true, expect.anything());
|
||||
expect(appUpdateWindowReady).toHaveBeenCalledWith(false, navigator.language);
|
||||
});
|
||||
|
||||
it('still pops up when IDE Updates is set to "No popup" (no other surface on the welcome screen)', async () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const projectTrustStatus = vi.fn();
|
||||
vi.mock("@/platform/projectTrust", () => ({ confirmProjectTrust: vi.fn(async () => {}) }));
|
||||
vi.mock("@/workbench/messages", () => ({ ask: vi.fn() }));
|
||||
const workspaceAttach = vi.fn();
|
||||
const workspaceUpdate = vi.fn();
|
||||
const projectCurrent = vi.fn();
|
||||
@@ -35,6 +38,7 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
isTauri: () => true,
|
||||
commands: {
|
||||
...real.commands,
|
||||
projectTrustStatus: (...a: unknown[]) => projectTrustStatus(...a),
|
||||
workspaceAttach: (...a: unknown[]) => workspaceAttach(...a),
|
||||
workspaceUpdate: (...a: unknown[]) => workspaceUpdate(...a),
|
||||
projectCurrent: () => projectCurrent(),
|
||||
@@ -50,7 +54,7 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
});
|
||||
vi.mock("@/platform/project", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/project")>();
|
||||
return { ...real, openProject: (p: string) => openProject(p) };
|
||||
return { ...real, confirmLeaveProject: vi.fn(real.confirmLeaveProject), openProject: (p: string) => openProject(p) };
|
||||
});
|
||||
const diffs: unknown[] = [];
|
||||
const openDiff = vi.fn(async (data: unknown) => void diffs.push(data));
|
||||
@@ -66,9 +70,11 @@ const {
|
||||
openProjectWithPolicy,
|
||||
windowActions,
|
||||
} = await import("../windows");
|
||||
const { useProject, startupLaunchHandled } = await import("@/platform/project");
|
||||
const { useProject, startupLaunchHandled, confirmLeaveProject } = await import("@/platform/project");
|
||||
const { provideEditorService, provideNotificationService } = await import("@/workbench/contributions");
|
||||
const { isRemoteWindow, connectRemote } = await import("@/platform/remote");
|
||||
const { ask } = await import("@/workbench/messages");
|
||||
const { confirmProjectTrust } = await import("@/platform/projectTrust");
|
||||
const { IpcError } = await import("@/platform/ipc");
|
||||
provideNotificationService({ notify });
|
||||
|
||||
@@ -99,6 +105,12 @@ function deferred() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(ask).mockReset().mockResolvedValue({ button: "attach", checked: false });
|
||||
vi.mocked(confirmProjectTrust).mockReset().mockResolvedValue();
|
||||
projectTrustStatus.mockReset().mockResolvedValue(ok({ trusted: true }));
|
||||
workspaceAttach.mockReset();
|
||||
workspaceUpdate.mockReset();
|
||||
projectCurrent.mockReset();
|
||||
for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch, wslResolve]) f.mockReset();
|
||||
windowTakeLaunch.mockResolvedValue(null);
|
||||
windowCompleteLaunch.mockReset().mockResolvedValue(ok(null));
|
||||
@@ -106,6 +118,7 @@ beforeEach(() => {
|
||||
vi.mocked(connectRemote).mockClear();
|
||||
for (const f of [lightEditOpen, fsReadDir, openProject]) f.mockClear();
|
||||
cancelClose.mockClear();
|
||||
vi.mocked(confirmLeaveProject).mockClear();
|
||||
notify.mockClear();
|
||||
listen.mockReset();
|
||||
openEditorFile.mockClear();
|
||||
@@ -591,17 +604,54 @@ describe("launch", () => {
|
||||
});
|
||||
|
||||
describe("Attach", () => {
|
||||
it("switches a plain project to the automatic workspace in place", async () => {
|
||||
it("attaches in place without reopening or asking to leave", async () => {
|
||||
useProject.setState({
|
||||
...projectState("/p/a"),
|
||||
settings: { general: { openProjectIn: "attach" } },
|
||||
} as never);
|
||||
workspaceAttach.mockResolvedValue(ok("/data/workspaces/a"));
|
||||
await openProjectWithPolicy("/p/b");
|
||||
const project = {
|
||||
...projectState("/p/a").project,
|
||||
workspace: { version: 1, name: "a", projects: ["/p/a", "/p/b"] },
|
||||
};
|
||||
projectCurrent.mockResolvedValue(project);
|
||||
workspaceAttach.mockResolvedValue(ok("/p/a"));
|
||||
await openProjectWithPolicy("/p/b", ["/p/b/file.txt"]);
|
||||
expect(confirmProjectTrust).toHaveBeenCalledWith("/p/b", "local");
|
||||
expect(workspaceAttach).toHaveBeenCalledWith("/p/b");
|
||||
expect(openProject).toHaveBeenCalledWith("/data/workspaces/a");
|
||||
expect(openProject).not.toHaveBeenCalled();
|
||||
expect(cancelClose).not.toHaveBeenCalled();
|
||||
expect(confirmLeaveProject).not.toHaveBeenCalled();
|
||||
expect(useProject.getState().project).toEqual(project);
|
||||
expect(opened).toEqual(["/p/b/file.txt (focus)"]);
|
||||
expect(ask).toHaveBeenCalledTimes(1);
|
||||
expect(windowOpenProject).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not attach when trust is cancelled", async () => {
|
||||
useProject.setState(projectState("/p/a"));
|
||||
vi.mocked(confirmProjectTrust).mockRejectedValueOnce(new IpcError({ kind: "cancelled", message: "Cancelled" }));
|
||||
await openProjectWithPolicy("/p/b");
|
||||
expect(workspaceAttach).not.toHaveBeenCalled();
|
||||
expect(openProject).not.toHaveBeenCalled();
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not attach a folder previewed in restricted mode", async () => {
|
||||
useProject.setState(projectState("/p/a"));
|
||||
projectTrustStatus.mockResolvedValueOnce(ok({ trusted: false }));
|
||||
await openProjectWithPolicy("/p/b");
|
||||
expect(workspaceAttach).not.toHaveBeenCalled();
|
||||
});
|
||||
it("explains how to attach a folder already decided as restricted", async () => {
|
||||
useProject.setState(projectState("/p/a"));
|
||||
projectTrustStatus.mockResolvedValueOnce(ok({ root: "/p/b", trusted: false, decided: true }));
|
||||
await openProjectWithPolicy("/p/b");
|
||||
expect(workspaceAttach).not.toHaveBeenCalled();
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
level: "warning",
|
||||
body: "Cannot attach /p/b because it is restricted. Open it separately and choose Trust Project, then attach it again.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("refreshes an existing workspace without reopening its editor session", async () => {
|
||||
const before = {
|
||||
root: "/workspace",
|
||||
@@ -645,6 +695,25 @@ it("removes the first workspace member while retaining the workspace state root"
|
||||
expect(useProject.getState().project?.workspace?.projects).toEqual(["/b"]);
|
||||
});
|
||||
|
||||
it("confirms detaching an attachment and never removes the primary", async () => {
|
||||
const project = { ...projectState("/a").project, workspace: { version: 1, name: "A", projects: ["/a", "/b"] } };
|
||||
useProject.setState({ project });
|
||||
const action = windowActions().find((a) => a.id === "project.detach")!;
|
||||
expect(action.title).toBe("Detach Project");
|
||||
expect(action.when).toBe("projectAttached");
|
||||
await action.run({ arg: "/a" } as never);
|
||||
expect(ask).not.toHaveBeenCalled();
|
||||
expect(workspaceUpdate).not.toHaveBeenCalled();
|
||||
vi.mocked(ask).mockResolvedValueOnce({ button: "cancel", checked: false });
|
||||
await action.run({ arg: "/b" } as never);
|
||||
expect(workspaceUpdate).not.toHaveBeenCalled();
|
||||
vi.mocked(ask).mockResolvedValueOnce({ button: "detach", checked: false });
|
||||
workspaceUpdate.mockResolvedValueOnce(ok(projectState("/a").project));
|
||||
await action.run({ arg: "/b" } as never);
|
||||
expect(workspaceUpdate).toHaveBeenCalledWith("A", ["/a"]);
|
||||
expect(useProject.getState().project?.workspace).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("explicit project-window requests", () => {
|
||||
it("honours current-window requests even when a project is already open", async () => {
|
||||
useProject.setState(projectState("/p/old") as never);
|
||||
|
||||
@@ -201,7 +201,7 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
|
||||
confirmTerminate,
|
||||
processes: confirmTerminate ? await processNames(deps) : [],
|
||||
dirty: dirtyCount(),
|
||||
project: s.project?.name ?? null,
|
||||
project: s.project?.displayName || s.project?.name || null,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
@@ -223,7 +223,7 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
|
||||
*/
|
||||
export async function confirmLeave(
|
||||
deps: CloseDeps = defaultDeps,
|
||||
project = useProject.getState().project?.name ?? null,
|
||||
project = (useProject.getState().project?.displayName || useProject.getState().project?.name) ?? null,
|
||||
): Promise<boolean> {
|
||||
if (!(await closeDocumentWindows())) return false;
|
||||
const confirmTerminate = workflowSettings().run.confirmTerminate;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { registerOverlay, registerStatusItem } from "@/workbench/contributions";
|
||||
import { AboutOverlay } from "./AboutDialog";
|
||||
import { appActions, updateActions } from "./appActions";
|
||||
import { checkForUpdatesOnStartup } from "./updates";
|
||||
import { installUpdateChecks } from "./updates";
|
||||
import { cliActions } from "./cliCommand";
|
||||
import { installCloseGuard } from "./closeGuard";
|
||||
import { TerminationOverlay } from "./termination";
|
||||
@@ -60,7 +60,7 @@ export function activate(): () => void {
|
||||
void buildInfo().then((b) => {
|
||||
if (!b?.updates || disposed) return;
|
||||
disposers.push(registerLocalizedActions(updateActions));
|
||||
void checkForUpdatesOnStartup();
|
||||
disposers.push(installUpdateChecks());
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
|
||||
@@ -224,9 +224,9 @@ export function installMenuBridge(): () => void {
|
||||
useMenuSlotItems.subscribe(push),
|
||||
useContextKeys.subscribe(() => pushEnablement()),
|
||||
useActions.subscribe(() => pushEnablement()),
|
||||
// Opening / closing a project changes the Open Recent list.
|
||||
// Project membership and names also change the Open Recent list.
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) void commands.menuRefresh();
|
||||
if (s.project !== prev.project) void commands.menuRefresh();
|
||||
if (s.lightEdit !== prev.lightEdit) push();
|
||||
// View › Appearance check marks and Status Bar Widgets.
|
||||
if (s.settings?.appearance !== prev.settings?.appearance) push();
|
||||
|
||||
@@ -129,7 +129,7 @@ async function renameProject() {
|
||||
if (name === null) return;
|
||||
try {
|
||||
const info = await call(commands.projectRename(name));
|
||||
useProject.setState((s) => ({ project: s.project && { ...s.project, name: info.name }, settings: info.settings }));
|
||||
useProject.setState((s) => ({ project: s.project && info, settings: info.settings }));
|
||||
void commands.menuRefresh();
|
||||
} catch (e) {
|
||||
notifications().notify({ level: "error", title: "Cannot rename project", body: errorText(e) });
|
||||
|
||||
@@ -35,7 +35,12 @@ export function installWindowTitle(): () => void {
|
||||
const { project, settings } = useProject.getState();
|
||||
const fullPath = settings?.appearance?.fullPathInTitle === true;
|
||||
const host = useRemote.getState().session?.host ?? null;
|
||||
const title = windowTitle(project?.name, useEditorRegistry.getState().active?.ctx.path, fullPath, host);
|
||||
const title = windowTitle(
|
||||
project?.displayName || project?.name,
|
||||
useEditorRegistry.getState().active?.ctx.path,
|
||||
fullPath,
|
||||
host,
|
||||
);
|
||||
if (title === last) return;
|
||||
last = title;
|
||||
if (typeof document !== "undefined") document.title = title;
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
commands,
|
||||
isCancelled,
|
||||
isTauri,
|
||||
ideEventsReady,
|
||||
onIdeEvent,
|
||||
nextRequestId,
|
||||
type UpdateInfo,
|
||||
type UpdateProgress,
|
||||
@@ -110,7 +112,7 @@ async function runCheck() {
|
||||
userInitiated: true,
|
||||
});
|
||||
} else if (!installing) {
|
||||
notifyUpdate(r.update, true);
|
||||
await notifyUpdate(r.update, true);
|
||||
}
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
@@ -125,15 +127,33 @@ async function runCheck() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Silent check once per app start; only an available update is shown. */
|
||||
export async function checkForUpdatesOnStartup(): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
const update = await commands.appStartupUpdateCheck(updateSource()).catch(() => null);
|
||||
if (update) notifyUpdate(update);
|
||||
/** Start one backend schedule after this window can receive its notifications. */
|
||||
export function installUpdateChecks(): () => void {
|
||||
let disposed = false;
|
||||
const off = onIdeEvent("updateAvailable", ({ update }) => {
|
||||
if (!installing) void notifyUpdate(update);
|
||||
});
|
||||
void ideEventsReady().then(() => {
|
||||
if (!disposed && isTauri())
|
||||
void call(commands.appUpdateWindowReady(true, navigator.language)).catch((e) =>
|
||||
console.warn("cannot start update checks", e),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
off();
|
||||
if (isTauri()) void call(commands.appUpdateWindowReady(false, navigator.language)).catch(() => {});
|
||||
};
|
||||
}
|
||||
|
||||
/** `userInitiated`: the update came from a manual check, so it pops up regardless of the balloon settings. */
|
||||
export function notifyUpdate(update: UpdateInfo, userInitiated = false) {
|
||||
export async function notifyUpdate(update: UpdateInfo, userInitiated = false): Promise<void> {
|
||||
if (isTauri()) {
|
||||
const claimed = await call(commands.appUpdateAnnouncement(update.version, !userInitiated)).catch(
|
||||
() => userInitiated,
|
||||
);
|
||||
if (!claimed) return;
|
||||
}
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: t("app.update.available", { version: update.version }),
|
||||
|
||||
+72
-30
@@ -8,7 +8,7 @@
|
||||
* editor tabs only. One LightEdit window collects them all.
|
||||
*/
|
||||
import { executeAction, type Action } from "@/platform/actions";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { t, tx } from "@/platform/i18n";
|
||||
import { call, commands, isCancelled, isTauri } from "@/platform/ipc";
|
||||
import {
|
||||
basename,
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
provideStartupLaunch,
|
||||
useProject,
|
||||
} from "@/platform/project";
|
||||
import { confirmProjectTrust } from "@/platform/projectTrust";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { connectRemote, isRemoteWindow } from "@/platform/remote";
|
||||
import { holdSplash } from "@/platform/splash";
|
||||
import { editorService, maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
@@ -160,22 +162,7 @@ export async function openProjectWithPolicy(path: string, files: readonly string
|
||||
if (!mode) return;
|
||||
if (mode === "new") return openProjectInWindow(path, true, files);
|
||||
if (mode === "attach") {
|
||||
try {
|
||||
if (!current?.workspace && !(await confirmLeaveProject())) return;
|
||||
const root = await call(commands.workspaceAttach(path));
|
||||
if (root === current?.root) {
|
||||
if (!current.workspace) maybeEditorService()?.cancelClose?.();
|
||||
const project = await commands.projectCurrent();
|
||||
if (project) useProject.setState({ project, settings: project.settings });
|
||||
} else {
|
||||
await openProject(root);
|
||||
}
|
||||
await executeAction("project.reveal", path);
|
||||
await openInEditor(files);
|
||||
} catch (e) {
|
||||
maybeEditorService()?.cancelClose?.();
|
||||
openFailed(path, e);
|
||||
}
|
||||
await attachProject(path, files);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -187,6 +174,27 @@ export async function openProjectWithPolicy(path: string, files: readonly string
|
||||
}
|
||||
}
|
||||
|
||||
async function attachProject(path: string, files: readonly string[] = []): Promise<void> {
|
||||
try {
|
||||
await confirmProjectTrust(path, "local");
|
||||
if (!(await call(commands.projectTrustStatus(path))).trusted) {
|
||||
notifications().notify({
|
||||
level: "warning",
|
||||
title: tx("Project not attached"),
|
||||
body: tx`Cannot attach ${path} because it is restricted. Open it separately and choose Trust Project, then attach it again.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await call(commands.workspaceAttach(path));
|
||||
const project = await commands.projectCurrent();
|
||||
if (project) useProject.setState({ project, settings: project.settings });
|
||||
await executeAction("project.reveal", path);
|
||||
await openInEditor(files);
|
||||
} catch (e) {
|
||||
openFailed(path, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** LightEdit: opens the current file's folder as a project ("Open project in" applies) and the file there. */
|
||||
export async function openLightEditFileAsProject(): Promise<void> {
|
||||
if (!useProject.getState().lightEdit) return;
|
||||
@@ -291,24 +299,58 @@ function closeProjectTabs(side: "others" | "left" | "right"): Action["run"] {
|
||||
};
|
||||
}
|
||||
|
||||
const removeProject: Action["run"] = async ({ arg }) => {
|
||||
const current = useProject.getState().project;
|
||||
const workspace = current?.workspace;
|
||||
if (typeof arg !== "string" || !workspace || !workspace.projects.includes(arg)) return;
|
||||
if (workspace.projects[0] === current.root) {
|
||||
if (arg === current.root) return;
|
||||
const result = await ask({
|
||||
title: t("app.window.detachTitle"),
|
||||
body: t("app.window.detachBody", { name: basename(arg) }),
|
||||
buttons: [
|
||||
{ id: "cancel", label: t("common.cancel") },
|
||||
{ id: "detach", label: t("app.window.detach"), primary: true },
|
||||
],
|
||||
});
|
||||
if (result.button !== "detach") return;
|
||||
}
|
||||
const project = await call(
|
||||
commands.workspaceUpdate(
|
||||
workspace.name,
|
||||
workspace.projects.filter((p) => p !== arg),
|
||||
),
|
||||
);
|
||||
useProject.setState({ project, settings: project.settings });
|
||||
};
|
||||
|
||||
export function windowActions(): Action[] {
|
||||
return [
|
||||
{
|
||||
id: "project.attach",
|
||||
title: t("app.action.attachProject"),
|
||||
category: t("app.category.file"),
|
||||
icon: "folder-open",
|
||||
when: "projectLocal && !projectOpenAsk",
|
||||
run: async () => {
|
||||
if (desktopOnly()) return;
|
||||
const path = await pickFolder(t("app.action.attachProject"));
|
||||
if (path) await attachProject(path);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace.removeProject",
|
||||
title: "Remove from Workspace",
|
||||
category: t("app.category.file"),
|
||||
when: "workspaceOpen",
|
||||
run: async ({ arg }) => {
|
||||
const workspace = useProject.getState().project?.workspace;
|
||||
if (typeof arg !== "string" || !workspace || !workspace.projects.includes(arg)) return;
|
||||
const project = await call(
|
||||
commands.workspaceUpdate(
|
||||
workspace.name,
|
||||
workspace.projects.filter((p) => p !== arg),
|
||||
),
|
||||
);
|
||||
useProject.setState({ project, settings: project.settings });
|
||||
},
|
||||
when: "workspaceOpen && !projectAttached",
|
||||
run: removeProject,
|
||||
},
|
||||
{
|
||||
id: "project.detach",
|
||||
title: "Detach Project",
|
||||
category: t("app.category.file"),
|
||||
when: "projectAttached",
|
||||
run: removeProject,
|
||||
},
|
||||
{
|
||||
id: "workspace.create",
|
||||
@@ -334,7 +376,7 @@ export function windowActions(): Action[] {
|
||||
id: "workspace.manage",
|
||||
title: "Manage Workspace…",
|
||||
category: t("app.category.file"),
|
||||
when: "workspaceOpen",
|
||||
when: "workspaceContainer",
|
||||
run: () => showWorkspaceDialog("manage"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type RunConfig,
|
||||
type SyncProgress,
|
||||
} from "@/platform/ipc";
|
||||
import { downloadJdkActions } from "@/platform/jdk";
|
||||
import { buildToolRecovery } from "@/platform/buildToolRecovery";
|
||||
import { useBuildModel } from "@/platform/buildModel";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
@@ -454,9 +454,8 @@ export async function syncTool(tool: BuildTool, buildDir: string | null = null):
|
||||
group: "run",
|
||||
level: "warning",
|
||||
title: tool === "maven" ? "Maven sync finished with errors" : "Gradle sync failed",
|
||||
body: error,
|
||||
...buildToolRecovery(error),
|
||||
key: `build-sync-${tool}`,
|
||||
actions: downloadJdkActions(error),
|
||||
});
|
||||
} catch (e) {
|
||||
if (!isCancelled(e))
|
||||
@@ -464,7 +463,7 @@ export async function syncTool(tool: BuildTool, buildDir: string | null = null):
|
||||
group: "run",
|
||||
level: "error",
|
||||
title: tool === "maven" ? "Cannot reload Maven projects" : "Cannot reload Gradle projects",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
...buildToolRecovery(e instanceof Error ? e.message : String(e)),
|
||||
key: `build-sync-${tool}`,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DatabaseView } from "./DatabaseView";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { registerDatabaseTreeActions } from "./treeActions";
|
||||
import { tablesKey, useDatabase } from "./store";
|
||||
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: ({ count }: { count: number }) => ({
|
||||
getTotalSize: () => count * 24,
|
||||
getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, start: index * 24, key: index })),
|
||||
scrollToIndex: () => {},
|
||||
measure: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
let dispose: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem("openidea.database.viewOptions");
|
||||
setContext("projectOpen", true);
|
||||
dispose = registerDatabaseTreeActions();
|
||||
useDatabase.setState({
|
||||
loaded: true,
|
||||
sources: [{ id: "pg", name: "PostgreSQL", driver: "postgres", options: { singleDatabase: true } }],
|
||||
schemas: { pg: { status: "ok", value: ["public"] } },
|
||||
tables: { [tablesKey("pg", "public")]: { status: "ok", value: [] } },
|
||||
objects: {
|
||||
[tablesKey("pg", "public")]: {
|
||||
routines: [
|
||||
{ name: "calculate", kind: "function", arguments: "integer", returns: "integer" },
|
||||
{ name: "calculate", kind: "function", arguments: "text", returns: "text" },
|
||||
{ name: "refresh_data", kind: "procedure", arguments: "", returns: null },
|
||||
],
|
||||
sequences: [],
|
||||
},
|
||||
},
|
||||
treeFilter: "",
|
||||
separateProceduresAndFunctions: false,
|
||||
treeExpanded: new Set(["pg", "pg/public", "pg/public/routines"]),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => dispose());
|
||||
|
||||
describe("routine grouping", () => {
|
||||
it("keeps an existing expanded routines folder and both overloads by default", () => {
|
||||
render(<DatabaseView />);
|
||||
const tree = within(screen.getByRole("tree", { name: "Data sources" }));
|
||||
expect(tree.getByText("routines").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "true");
|
||||
expect(tree.getAllByText("calculate")).toHaveLength(2);
|
||||
expect(tree.getByText("refresh_data")).toBeInTheDocument();
|
||||
expect(tree.queryByText("procedures")).toBeNull();
|
||||
expect(tree.queryByText("functions")).toBeNull();
|
||||
fireEvent.mouseDown(tree.getAllByText("calculate")[1]!, { button: 0 });
|
||||
expect(useDatabase.getState().selectedNode?.key).toBe("pg/public/routines/calculate(text)");
|
||||
act(() => useDatabase.setState({ treeFilter: "refresh" }));
|
||||
expect(tree.queryAllByText("calculate")).toHaveLength(0);
|
||||
expect(tree.getByText("refresh_data")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles through the action, persists the option, and restores the old expansion", async () => {
|
||||
expect(useDatabase.getInitialState().separateProceduresAndFunctions).toBe(false);
|
||||
render(<DatabaseView />);
|
||||
const tree = within(screen.getByRole("tree", { name: "Data sources" }));
|
||||
await act(() => executeAction("db.separateProceduresAndFunctions"));
|
||||
expect(JSON.parse(localStorage.getItem("openidea.database.viewOptions")!)).toEqual({
|
||||
separateProceduresAndFunctions: true,
|
||||
});
|
||||
expect(tree.queryByText("routines")).toBeNull();
|
||||
const procedures = tree.getByText("procedures").closest('[role="treeitem"]')!;
|
||||
const functions = tree.getByText("functions").closest('[role="treeitem"]')!;
|
||||
expect(within(procedures as HTMLElement).getByText("1")).toBeInTheDocument();
|
||||
expect(within(functions as HTMLElement).getByText("2")).toBeInTheDocument();
|
||||
fireEvent.mouseDown(functions.querySelector("[data-tree-toggle]")!, { button: 0 });
|
||||
expect(tree.getAllByText("calculate")).toHaveLength(2);
|
||||
fireEvent.mouseDown(tree.getAllByText("calculate")[1]!, { button: 0 });
|
||||
expect(useDatabase.getState().selectedNode?.key).toBe("pg/public/functions/calculate(text)");
|
||||
await act(() => executeAction("db.separateProceduresAndFunctions"));
|
||||
expect(tree.getByText("routines").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "true");
|
||||
expect(tree.getAllByText("calculate")).toHaveLength(2);
|
||||
expect(tree.getByText("refresh_data")).toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem("openidea.database.viewOptions")!)).toEqual({
|
||||
separateProceduresAndFunctions: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])("applies object and quick filters before grouping (separate: %s)", (separate) => {
|
||||
const source = useDatabase.getState().sources[0]!;
|
||||
useDatabase.setState({
|
||||
separateProceduresAndFunctions: separate,
|
||||
sources: [{ ...source, introspection: { objectFilter: "calculate" } }],
|
||||
treeExpanded: new Set(["pg", "pg/public", "pg/public/routines", "pg/public/functions"]),
|
||||
});
|
||||
render(<DatabaseView />);
|
||||
const tree = within(screen.getByRole("tree", { name: "Data sources" }));
|
||||
expect(tree.queryByText("procedures")).toBeNull();
|
||||
expect(tree.queryByText("refresh_data")).toBeNull();
|
||||
expect(tree.getAllByText("calculate")).toHaveLength(2);
|
||||
const folder = tree.getByText(separate ? "functions" : "routines").closest('[role="treeitem"]')!;
|
||||
expect(within(folder as HTMLElement).getByText("2")).toBeInTheDocument();
|
||||
act(() => useDatabase.setState({ treeFilter: "refresh" }));
|
||||
expect(tree.queryByText("public")).toBeNull();
|
||||
expect(tree.queryByText("routines")).toBeNull();
|
||||
expect(tree.queryByText("functions")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Database tool window (IDEA's layout): data source → database (PostgreSQL) → schema → `tables` /
|
||||
* `views` / `materialized views` / `routines` / `sequences` folders → table → `columns` / `keys` /
|
||||
* `foreign keys` / `indexes` / `triggers`,
|
||||
* `views` / `materialized views` / `routines` (optionally `procedures` / `functions`) / `sequences`
|
||||
* folders → table → `columns` / `keys` / `foreign keys` / `indexes` / `triggers`,
|
||||
* with counts and column types, loaded lazily. Double-click a table to open its data, a data
|
||||
* source to open its query console.
|
||||
*/
|
||||
@@ -32,7 +32,15 @@ import {
|
||||
splitTarget,
|
||||
targetOf,
|
||||
} from "./model";
|
||||
import { nodeTarget, schemaObjectOf, tableFolder, type DbNode as Node, type Folder, type Group } from "./nodes";
|
||||
import {
|
||||
nodeTarget,
|
||||
routineFolder,
|
||||
schemaObjectOf,
|
||||
tableFolder,
|
||||
type DbNode as Node,
|
||||
type Folder,
|
||||
type Group,
|
||||
} from "./nodes";
|
||||
import {
|
||||
connect,
|
||||
detailsKey,
|
||||
@@ -66,10 +74,20 @@ const FOLDER_LABELS: Record<Folder, string> = {
|
||||
views: "views",
|
||||
materializedViews: "materialized views",
|
||||
routines: "routines",
|
||||
procedures: "procedures",
|
||||
functions: "functions",
|
||||
sequences: "sequences",
|
||||
};
|
||||
|
||||
const FOLDERS: readonly Folder[] = ["tables", "views", "materializedViews", "routines", "sequences"];
|
||||
const SEPARATE_FOLDERS: readonly Folder[] = [
|
||||
"tables",
|
||||
"views",
|
||||
"materializedViews",
|
||||
"procedures",
|
||||
"functions",
|
||||
"sequences",
|
||||
];
|
||||
|
||||
function status<T>(key: string, l: Loaded<T> | undefined, map: (v: T) => Node[]): Node[] | undefined {
|
||||
if (!l) return undefined;
|
||||
@@ -101,6 +119,7 @@ function groupSize(d: DbTableDetails, g: Group): number {
|
||||
|
||||
export function DatabaseView() {
|
||||
const st = useDatabase();
|
||||
const folders = st.separateProceduresAndFunctions ? SEPARATE_FOLDERS : FOLDERS;
|
||||
const tree = useRef<TreeHandle>(null);
|
||||
useEffect(() => {
|
||||
if (!st.loaded) void loadSources();
|
||||
@@ -149,9 +168,12 @@ export function DatabaseView() {
|
||||
(st.objects[tablesKey(id, schema)]?.routines ?? []).filter(shown(id));
|
||||
const sequencesOf = (id: string, schema: string) =>
|
||||
(st.objects[tablesKey(id, schema)]?.sequences ?? []).filter(shown(id));
|
||||
/** Members of a schema folder: tables / views / materialized views, routines or sequences. */
|
||||
/** Members of a schema folder, after object and quick filtering. */
|
||||
const folderSize = (id: string, schema: string, folder: Folder): number => {
|
||||
if (folder === "routines") return routinesOf(id, schema).length;
|
||||
if (folder === "routines" || folder === "procedures" || folder === "functions")
|
||||
return routinesOf(id, schema).filter(
|
||||
(routine) => routineFolder(routine.kind, st.separateProceduresAndFunctions) === folder,
|
||||
).length;
|
||||
if (folder === "sequences") return sequencesOf(id, schema).length;
|
||||
return (tablesOf(id, schema) ?? []).filter((t) => tableFolder(t.kind) === folder).length;
|
||||
};
|
||||
@@ -205,24 +227,28 @@ export function DatabaseView() {
|
||||
return schemasOf(n.key, n.id);
|
||||
case "schema":
|
||||
return status(n.key, st.tables[tablesKey(n.id, n.schema)], () =>
|
||||
FOLDERS.filter((f) => folderSize(n.id, n.schema, f) > 0).map((folder): Node => ({
|
||||
kind: "folder",
|
||||
key: `${n.key}/${folder}`,
|
||||
id: n.id,
|
||||
schema: n.schema,
|
||||
folder,
|
||||
})),
|
||||
folders
|
||||
.filter((f) => folderSize(n.id, n.schema, f) > 0)
|
||||
.map((folder): Node => ({
|
||||
kind: "folder",
|
||||
key: `${n.key}/${folder}`,
|
||||
id: n.id,
|
||||
schema: n.schema,
|
||||
folder,
|
||||
})),
|
||||
);
|
||||
case "folder": {
|
||||
const at = { id: n.id, schema: n.schema };
|
||||
if (n.folder === "routines")
|
||||
if (n.folder === "routines" || n.folder === "procedures" || n.folder === "functions")
|
||||
// Overloads share a name: the key tells them apart by their arguments.
|
||||
return routinesOf(n.id, n.schema).map((routine) => ({
|
||||
kind: "routine",
|
||||
key: `${n.key}/${routine.name}(${routine.arguments})`,
|
||||
routine,
|
||||
...at,
|
||||
}));
|
||||
return routinesOf(n.id, n.schema)
|
||||
.filter((routine) => routineFolder(routine.kind, st.separateProceduresAndFunctions) === n.folder)
|
||||
.map((routine) => ({
|
||||
kind: "routine",
|
||||
key: `${n.key}/${routine.name}(${routine.arguments})`,
|
||||
routine,
|
||||
...at,
|
||||
}));
|
||||
if (n.folder === "sequences")
|
||||
return sequencesOf(n.id, n.schema).map((sequence) => ({
|
||||
kind: "sequence",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* results grids, and a table data editor. Connections, introspection and
|
||||
* statements run in Rust (`ide-db`).
|
||||
*/
|
||||
import { lazyComponent, lazyWhen } from "@/ui";
|
||||
import { lazyComponent, lazyWhen, type MenuItem } from "@/ui";
|
||||
import { activeEditor, lazyEditorExtension, registerEditorContribution } from "@/editor/api";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { onIdeEvent } from "@/platform/ipc";
|
||||
@@ -41,6 +41,11 @@ import {
|
||||
watchIdleConnections,
|
||||
} from "./store";
|
||||
|
||||
function useDatabaseHeaderMenu(): MenuItem[] {
|
||||
const separate = useDatabase((s) => s.separateProceduresAndFunctions);
|
||||
return [{ kind: "action", action: "db.separateProceduresAndFunctions", checked: separate }];
|
||||
}
|
||||
|
||||
const useDialogOpen = () => useDatabase((s) => !!s.dialog);
|
||||
|
||||
export function activate(): () => void {
|
||||
@@ -55,6 +60,7 @@ export function activate(): () => void {
|
||||
anchor: "right",
|
||||
order: 20,
|
||||
component: lazyComponent(() => import("./DatabaseView").then((m) => m.DatabaseView)),
|
||||
headerMenu: useDatabaseHeaderMenu,
|
||||
headerActions: lazyComponent(() => import("./DatabaseView").then((m) => m.DatabaseHeaderActions)),
|
||||
}),
|
||||
registerServiceView({
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routineFolder, tableFolder } from "./nodes";
|
||||
|
||||
describe("schema folders", () => {
|
||||
it("combines routines by default and separates them on request", () => {
|
||||
expect(routineFolder("procedure")).toBe("routines");
|
||||
expect(routineFolder("function")).toBe("routines");
|
||||
expect(routineFolder("procedure", true)).toBe("procedures");
|
||||
expect(routineFolder("function", true)).toBe("functions");
|
||||
});
|
||||
|
||||
it("keeps tables, views and materialized views apart", () => {
|
||||
expect(tableFolder("table")).toBe("tables");
|
||||
expect(tableFolder("view")).toBe("views");
|
||||
expect(tableFolder("materializedView")).toBe("materializedViews");
|
||||
});
|
||||
});
|
||||
@@ -15,14 +15,20 @@ import type { DbObjectRef } from "./objects";
|
||||
|
||||
export type Group = "columns" | "keys" | "foreignKeys" | "indexes" | "triggers";
|
||||
|
||||
/** Folders under a schema: IDEA's `tables`, `views`, `materialized views`, `routines`, `sequences`. */
|
||||
export type Folder = "tables" | "views" | "materializedViews" | "routines" | "sequences";
|
||||
/** Schema folders; routines can optionally be separated into procedures and functions. */
|
||||
export type Folder = "tables" | "views" | "materializedViews" | "routines" | "procedures" | "functions" | "sequences";
|
||||
|
||||
/** The folder a table, view or materialized view is listed in. */
|
||||
export function tableFolder(kind: DbTableKind): Folder {
|
||||
return kind === "view" ? "views" : kind === "materializedView" ? "materializedViews" : "tables";
|
||||
}
|
||||
|
||||
/** IDEA's *Separate Procedures and Functions* option: the folder a routine is listed in. */
|
||||
export function routineFolder(kind: DbRoutineInfo["kind"], separate = false): "routines" | "procedures" | "functions" {
|
||||
if (!separate) return "routines";
|
||||
return kind === "procedure" ? "procedures" : "functions";
|
||||
}
|
||||
|
||||
/** Where a table-level node lives: connection target, schema, table. */
|
||||
interface InTable {
|
||||
id: string;
|
||||
|
||||
@@ -57,6 +57,7 @@ export const DB_TOOL_WINDOW = "database";
|
||||
export const RESULTS_VIEW = "dbResults";
|
||||
const BINDINGS_KEY = "openidea.database.consoles";
|
||||
const CONSOLE_SCHEMAS_KEY = "openidea.database.consoleSchemas";
|
||||
const VIEW_OPTIONS_KEY = "openidea.database.viewOptions";
|
||||
const MAX_TABS = 12;
|
||||
|
||||
export interface ResultTab {
|
||||
@@ -144,6 +145,7 @@ interface DatabaseState {
|
||||
consoleSchemas: Record<string, string>;
|
||||
/** The Database tree's quick object filter (empty: everything shows). */
|
||||
treeFilter: string;
|
||||
separateProceduresAndFunctions: boolean;
|
||||
completion: Record<string, DbCompletion>;
|
||||
results: ResultTab[];
|
||||
/** Result tab id, or `OUTPUT_TAB`. */
|
||||
@@ -189,6 +191,15 @@ function readConsoleSchemas(): Record<string, string> {
|
||||
}
|
||||
}
|
||||
|
||||
function readSeparateProceduresAndFunctions(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(VIEW_OPTIONS_KEY);
|
||||
return raw ? JSON.parse(raw)?.separateProceduresAndFunctions === true : false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const useDatabase = create<DatabaseState>(() => ({
|
||||
sources: [],
|
||||
loaded: false,
|
||||
@@ -201,6 +212,7 @@ export const useDatabase = create<DatabaseState>(() => ({
|
||||
consoles: readBindings(),
|
||||
consoleSchemas: readConsoleSchemas(),
|
||||
treeFilter: "",
|
||||
separateProceduresAndFunctions: readSeparateProceduresAndFunctions(),
|
||||
completion: {},
|
||||
results: [],
|
||||
activeResult: null,
|
||||
@@ -218,6 +230,16 @@ export const useDatabase = create<DatabaseState>(() => ({
|
||||
pendingTx: new Set(),
|
||||
}));
|
||||
|
||||
export function toggleSeparateProceduresAndFunctions() {
|
||||
const separateProceduresAndFunctions = !useDatabase.getState().separateProceduresAndFunctions;
|
||||
useDatabase.setState({ separateProceduresAndFunctions });
|
||||
try {
|
||||
localStorage.setItem(VIEW_OPTIONS_KEY, JSON.stringify({ separateProceduresAndFunctions }));
|
||||
} catch {
|
||||
// Per-viewer convenience only.
|
||||
}
|
||||
}
|
||||
|
||||
export const tablesKey = (id: string, schema: string) => `${id}\u0000${schema}`;
|
||||
export const detailsKey = (id: string, schema: string, table: string) => `${id}\u0000${schema}\u0000${table}`;
|
||||
|
||||
|
||||
@@ -125,4 +125,29 @@ describe("table editor capabilities", () => {
|
||||
uninstall();
|
||||
}
|
||||
});
|
||||
|
||||
it("counts the editor-area host focusing the tab as table-editor focus (#2307)", async () => {
|
||||
backend.driver = "postgres";
|
||||
const uninstall = installTableEditorFocus();
|
||||
try {
|
||||
let root!: HTMLElement;
|
||||
await act(async () => {
|
||||
const r = render(<TableEditor input={tableInput("source", "public", "users")} />);
|
||||
root = r.container;
|
||||
});
|
||||
// Mimic EditorArea wrapping the tab in its focusable host, then focusing the host on open.
|
||||
const host = root.closest("div")!;
|
||||
host.setAttribute("data-editor-focus", "");
|
||||
host.tabIndex = -1;
|
||||
act(() => host.focus());
|
||||
expect(getContext()).toMatchObject({ dbTableEditorFocus: true, dbTableEditorChanges: false });
|
||||
// A later focus inside a text field still turns it off.
|
||||
const input = document.createElement("input");
|
||||
root.querySelector("[data-db-table-editor]")!.appendChild(input);
|
||||
act(() => input.focus());
|
||||
expect(getContext().dbTableEditorFocus).toBe(false);
|
||||
} finally {
|
||||
uninstall();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,11 @@ function update() {
|
||||
|
||||
function onFocusIn(event: FocusEvent) {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const root = target?.closest("[data-db-table-editor]");
|
||||
// The editor area focuses its host `[data-editor-focus]` when the tab opens —
|
||||
// treat that as focus inside a table editor so ⌘F12 / ⌘↩ work right away.
|
||||
const root =
|
||||
target?.closest("[data-db-table-editor]") ??
|
||||
(target?.hasAttribute("data-editor-focus") ? target.querySelector("[data-db-table-editor]") : null);
|
||||
active = (root && !isTextField(target!) && handles.get(root)) || null;
|
||||
update();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
runSql,
|
||||
sourceById,
|
||||
tablesKey,
|
||||
toggleSeparateProceduresAndFunctions,
|
||||
useDatabase,
|
||||
withConnection,
|
||||
removeSource,
|
||||
@@ -462,6 +463,11 @@ export function registerDatabaseTreeActions(): () => void {
|
||||
useDatabase.setState({ newMenuOpen: true });
|
||||
},
|
||||
}),
|
||||
tree({
|
||||
id: "db.separateProceduresAndFunctions",
|
||||
title: "Separate Procedures and Functions",
|
||||
run: toggleSeparateProceduresAndFunctions,
|
||||
}),
|
||||
tree({
|
||||
id: "db.collapseAll",
|
||||
title: "Collapse All",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const executeAction = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/platform/actions", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/platform/actions")>()),
|
||||
executeAction,
|
||||
}));
|
||||
|
||||
const ipc = vi.hoisted(() => {
|
||||
const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
|
||||
return {
|
||||
@@ -18,7 +24,9 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
return { ...real, isTauri: () => true, commands: { ...real.commands, ...cmds } };
|
||||
});
|
||||
|
||||
const notes = vi.hoisted(() => [] as Array<{ level: string; title: string; body?: unknown; actions?: unknown[] }>);
|
||||
const notes = vi.hoisted(
|
||||
() => [] as Array<{ level: string; title: string; body?: unknown; actions?: { label: string; run: () => void }[] }>,
|
||||
);
|
||||
vi.mock("@/workbench/contributions", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/workbench/contributions")>();
|
||||
return {
|
||||
@@ -74,6 +82,7 @@ function session(id: number, hotCodeReplace: boolean): DebugSession {
|
||||
describe("HotSwap", () => {
|
||||
beforeEach(() => {
|
||||
notes.length = 0;
|
||||
executeAction.mockClear();
|
||||
ipc.cancelRequest.mockClear();
|
||||
ipc.javaBuildWorkspace.mockClear();
|
||||
ipc.debugRedefineClasses.mockReset();
|
||||
@@ -226,6 +235,110 @@ describe("HotSwap", () => {
|
||||
expect(hotSwapMessage(3, ["boom"])).toMatchObject({ level: "error", body: "boom" });
|
||||
});
|
||||
|
||||
it.each([0, 1, 3])("reports obsolete methods as a reload warning even with %s returned classes", (changed) => {
|
||||
expect(hotSwapMessage(changed, ["JVM contains obsolete methods"])).toMatchObject({
|
||||
level: "warning",
|
||||
title: "Classes reloaded",
|
||||
body: expect.stringContaining("Active frames keep running the old code"),
|
||||
});
|
||||
expect(hotSwapMessage(changed, ["JVM contains obsolete methods"], true).body).toContain(
|
||||
"bottom frame cannot be reset",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Failed to redefine classes: add method not implemented",
|
||||
"Failed to redefine classes: schema change not implemented",
|
||||
"Failed to redefine classes: JVM contains obsolete methods",
|
||||
])("keeps a real failure even alongside obsolete methods: %s", (failure) => {
|
||||
for (const errors of [[failure], ["JVM contains obsolete methods", failure]]) {
|
||||
expect(hotSwapMessage(1, errors)).toMatchObject({ level: "error", title: "Hot Swap failed" });
|
||||
}
|
||||
});
|
||||
|
||||
function pausedFrames(names: string[], restartFrame = true): DebugSession {
|
||||
return {
|
||||
...session(1, true),
|
||||
status: "paused",
|
||||
caps: { hotCodeReplace: true, restartFrame } as DebugCapabilities,
|
||||
selectedThread: 1,
|
||||
selectedFrame: 11,
|
||||
frames: names.map((name, i) => ({
|
||||
id: 11 + i,
|
||||
name,
|
||||
path: "/p/App.java",
|
||||
line: 11,
|
||||
column: 0,
|
||||
presentationHint: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
it.each([
|
||||
[true, ["App.main(String[])"], false],
|
||||
[false, ["OrderBook.place()", "App.main(String[])"], false],
|
||||
[true, ["OrderBook.place()", "Native.call()[native method]"], false],
|
||||
[true, ["Native.call()[native method]", "App.main(String[])"], false],
|
||||
[true, ["OrderBook.place()", "App.main(String[])"], true],
|
||||
])("offers Reset Frame only when supported and droppable (%s, %j)", async (supported, names, offered) => {
|
||||
useDebug.setState({ sessions: { 1: pausedFrames(names, supported) } });
|
||||
ipc.debugRedefineClasses.mockImplementationOnce(() =>
|
||||
ipc.ok({ changedClasses: [], errorMessage: "JVM contains obsolete methods" }),
|
||||
);
|
||||
await reloadChangedClasses(false);
|
||||
const note = notes.at(-1)!;
|
||||
expect(note).toMatchObject({ level: "warning", title: "Classes reloaded" });
|
||||
expect(note.actions).toHaveLength(offered ? 1 : 0);
|
||||
if (offered) {
|
||||
useDebug.setState({ active: 2 });
|
||||
note.actions![0]!.run();
|
||||
expect(executeAction).toHaveBeenCalledWith("debug.resetFrame");
|
||||
expect(useDebug.getState().active).toBe(1);
|
||||
expect(useDebug.getState().sessions[1]?.selectedFrame).toBe(11);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not reset a stale frame from an earlier suspension", async () => {
|
||||
const s = pausedFrames(["OrderBook.place()", "App.main(String[])"]);
|
||||
useDebug.setState({ sessions: { 1: s } });
|
||||
ipc.debugRedefineClasses.mockImplementationOnce(() =>
|
||||
ipc.ok({ changedClasses: [], errorMessage: "JVM contains obsolete methods" }),
|
||||
);
|
||||
await reloadChangedClasses(false);
|
||||
useDebug.setState({ sessions: { 1: { ...s, frames: [...s.frames] } } });
|
||||
notes.at(-1)!.actions![0]!.run();
|
||||
expect(executeAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps java-debug's obsolete-method ERROR event to the same warning", () => {
|
||||
useDebug.setState({ sessions: { 1: pausedFrames(["App.main(String[])"]) } });
|
||||
onHotCodeReplace(1 as DebugSessionId, {
|
||||
kind: "hotCodeReplace",
|
||||
changeType: "ERROR",
|
||||
message: "JVM contains obsolete methods",
|
||||
});
|
||||
expect(notes.at(-1)).toMatchObject({
|
||||
level: "warning",
|
||||
title: "Classes reloaded",
|
||||
body: expect.stringContaining("bottom frame cannot be reset"),
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let the obsolete-method event replace the response's real failure", async () => {
|
||||
ipc.debugRedefineClasses.mockImplementationOnce(async () => {
|
||||
onHotCodeReplace(1 as DebugSessionId, {
|
||||
kind: "hotCodeReplace",
|
||||
changeType: "ERROR",
|
||||
message: "JVM contains obsolete methods",
|
||||
});
|
||||
return ipc.ok({ changedClasses: [], errorMessage: "Failed to redefine classes: add method not implemented" });
|
||||
});
|
||||
await reloadChangedClasses(false);
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toMatchObject({ level: "error", title: "Hot Swap failed" });
|
||||
});
|
||||
|
||||
it("translates the reload count with the sentence", async () => {
|
||||
await setLocale("zh");
|
||||
try {
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
type RequestId,
|
||||
} from "@/platform/ipc";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { isLive, useDebug, type DebugSession } from "./state";
|
||||
import { isLive, selectFrame, selectSession, useDebug, type DebugSession } from "./state";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { MenuItem } from "@/ui/Menu";
|
||||
|
||||
@@ -48,16 +49,61 @@ export function hotSwapMenuItems(paths: readonly string[]): MenuItem[] {
|
||||
/** "3 classes", translated per count (the sentence around it is translated on its own). */
|
||||
const classesText = (n: number) => (n === 1 ? tx`${n} class` : tx`${n} classes`);
|
||||
|
||||
// java-debug reports this after redefineClasses succeeds, with no changedClasses count.
|
||||
const obsoleteMethods = (message: string) => message.trim() === "JVM contains obsolete methods";
|
||||
|
||||
/** Message for the reload result (IDEA wording). */
|
||||
export function hotSwapMessage(
|
||||
changed: number,
|
||||
errors: string[],
|
||||
): { level: "info" | "error"; title: string; body?: string } {
|
||||
if (errors.length) return { level: "error", title: "Hot Swap failed", body: errors.join("\n") };
|
||||
bottomFrame = false,
|
||||
): { level: "info" | "warning" | "error"; title: string; body?: string } {
|
||||
if (errors.some((e) => !obsoleteMethods(e)))
|
||||
return { level: "error", title: "Hot Swap failed", body: errors.join("\n") };
|
||||
if (errors.length)
|
||||
return {
|
||||
level: "warning",
|
||||
title: "Classes reloaded",
|
||||
body:
|
||||
tx("Active frames keep running the old code until they return. New calls use the reloaded code.") +
|
||||
(bottomFrame
|
||||
? " " +
|
||||
tx(
|
||||
"The bottom frame cannot be reset. Re-enter the method or restart the debug session to run the new code.",
|
||||
)
|
||||
: ""),
|
||||
};
|
||||
if (changed === 0) return { level: "info", title: "Loaded classes are up to date. Nothing to reload." };
|
||||
return { level: "info", title: tx`${classesText(changed)} reloaded` };
|
||||
}
|
||||
|
||||
function obsoleteFrameActions(sessions: DebugSession[]) {
|
||||
return sessions.flatMap((s) => {
|
||||
const frame = s.frames[0];
|
||||
// java-debug cannot drop the bottom frame or frames involving a native caller.
|
||||
if (
|
||||
s.status !== "paused" ||
|
||||
!s.caps?.restartFrame ||
|
||||
!frame ||
|
||||
s.frames.length < 2 ||
|
||||
s.frames.slice(0, 2).some((f) => f.name.includes("[native method]"))
|
||||
)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
label: sessions.length > 1 ? tx`Reset Frame (${s.name})` : "Reset Frame",
|
||||
run: () => {
|
||||
const current = useDebug.getState().sessions[s.id];
|
||||
if (current?.status !== "paused" || current.frames !== s.frames) return;
|
||||
selectSession(s.id);
|
||||
selectFrame(s.id, frame.id, false);
|
||||
void executeAction("debug.resetFrame");
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** Compiles (unless `build` is false) and reloads changed classes into every Java debug session. */
|
||||
export async function reloadChangedClasses(build = true, configId?: string): Promise<void> {
|
||||
if (reloading) return;
|
||||
@@ -136,17 +182,31 @@ export async function reloadChangedClasses(build = true, configId?: string): Pro
|
||||
}
|
||||
const changed = new Set<string>();
|
||||
const errors: string[] = [];
|
||||
const obsolete: DebugSessionId[] = [];
|
||||
for (const s of sessions) {
|
||||
try {
|
||||
const r = await call(commands.debugRedefineClasses(s.id));
|
||||
r.changedClasses.forEach((c) => changed.add(c));
|
||||
if (r.errorMessage) errors.push(r.errorMessage);
|
||||
if (r.errorMessage) {
|
||||
errors.push(r.errorMessage);
|
||||
if (obsoleteMethods(r.errorMessage)) obsolete.push(s.id);
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
const m = hotSwapMessage(changed.size, errors);
|
||||
notifications().notify({ group: "run", key: KEY, ...m });
|
||||
const affected = obsolete.flatMap((id) => useDebug.getState().sessions[id] ?? []);
|
||||
const m = hotSwapMessage(
|
||||
changed.size,
|
||||
errors,
|
||||
affected.some((s) => s.status === "paused" && s.frames.length === 1),
|
||||
);
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
key: KEY,
|
||||
...m,
|
||||
...(m.level === "warning" ? { sticky: true, actions: obsoleteFrameActions(affected) } : {}),
|
||||
});
|
||||
} finally {
|
||||
reloading = false;
|
||||
quietUntil = Date.now() + 2_000;
|
||||
@@ -170,6 +230,18 @@ export function onHotCodeReplace(session: DebugSessionId, event: Extract<DebugEv
|
||||
break;
|
||||
case "ERROR":
|
||||
case "WARNING":
|
||||
if (obsoleteMethods(event.message)) {
|
||||
if (reloading) return;
|
||||
const s = useDebug.getState().sessions[session]!;
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
key: KEY,
|
||||
...hotSwapMessage(0, [event.message], s.status === "paused" && s.frames.length === 1),
|
||||
sticky: true,
|
||||
actions: obsoleteFrameActions([s]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: event.changeType === "ERROR" ? "error" : "warning",
|
||||
|
||||
@@ -29,6 +29,7 @@ const GROUP_ICONS: Record<string, string> = {
|
||||
goModules: "file-gomod",
|
||||
maven: "maven-project",
|
||||
gradle: "file-gradle",
|
||||
classpath: "file-java",
|
||||
ruby: "file-ruby",
|
||||
gems: "file-ruby",
|
||||
};
|
||||
@@ -58,6 +59,7 @@ const MANIFESTS = new Set([
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"go.work",
|
||||
".classpath",
|
||||
"pyproject.toml",
|
||||
"setup.py",
|
||||
"requirements.txt",
|
||||
|
||||
@@ -80,7 +80,7 @@ export function CellEditor({
|
||||
language.of([]),
|
||||
readonly.current.of(EditorState.readOnly.of(session.getState().frozen)),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.theme({ "&": { height: "auto" }, ".cm-scroller": { maxHeight: "640px", overflow: "auto" } }),
|
||||
EditorView.theme({ "&.cm-editor": { height: "auto" }, ".cm-scroller": { maxHeight: "640px", overflow: "auto" } }),
|
||||
EditorView.contentAttributes.of({
|
||||
"aria-label": tx(type === "code" ? "Python cell source" : "Markdown cell source"),
|
||||
}),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { activeEditor } from "@/editor/api";
|
||||
import { getAction, registerActions, type Action } from "@/platform/actions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { revealInFileManagerLabel } from "@/platform/fileManager";
|
||||
import { dirname, useProject } from "@/platform/project";
|
||||
import { showContextMenu, type MenuItem } from "@/ui";
|
||||
import { maybeEditorService, menuGroupItems, toolWindowService } from "@/workbench/contributions";
|
||||
@@ -388,7 +389,7 @@ export function registerProjectActions(): () => void {
|
||||
copyPath("source", "project.copySourceRootPath", "Copy Path From Source Root"),
|
||||
{
|
||||
id: "project.revealInFinder",
|
||||
title: tx`Reveal in ${revealLabel()}`,
|
||||
title: revealInFileManagerLabel(),
|
||||
category: "Project View",
|
||||
icon: "external-link",
|
||||
when: OPEN,
|
||||
|
||||
@@ -247,3 +247,23 @@ it("offers workspace removal for every member root", () => {
|
||||
expect.objectContaining({ action: "workspace.removeProject" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not offer removal on the attached primary", () => {
|
||||
useProject.setState({
|
||||
project: {
|
||||
root: "/a",
|
||||
name: "A",
|
||||
displayName: "A, b",
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
settings: {},
|
||||
workspace: { version: 1, name: "A", projects: ["/a", "/b"] },
|
||||
},
|
||||
});
|
||||
expect(contextMenuItems([node("/a", true, true)])).not.toContainEqual(
|
||||
expect.objectContaining({ action: "project.detach" }),
|
||||
);
|
||||
expect(contextMenuItems([node("/b", true, true)])).toContainEqual(
|
||||
expect.objectContaining({ action: "project.detach" }),
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Context menu and "New" popup of the project view. */
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { fileManagerName } from "@/platform/fileManager";
|
||||
import { formatBinding, hostBinding, isMacHost, shortcutLabel } from "@/platform/keymap";
|
||||
import { relativePath, useProject } from "@/platform/project";
|
||||
import { currentDetectedRoots, currentRoots } from "@/platform/projectRoots";
|
||||
@@ -152,8 +153,20 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
||||
const items: MenuItem[] = [];
|
||||
const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i));
|
||||
|
||||
if (single && project?.workspace && project.workspace.projects.includes(single.path)) {
|
||||
push({ kind: "action", action: "workspace.removeProject", arg: single.path }, { kind: "separator" });
|
||||
if (
|
||||
single &&
|
||||
project?.workspace &&
|
||||
single.path !== project.root &&
|
||||
project.workspace.projects.includes(single.path)
|
||||
) {
|
||||
push(
|
||||
{
|
||||
kind: "action",
|
||||
action: project.workspace.projects[0] === project.root ? "project.detach" : "workspace.removeProject",
|
||||
arg: single.path,
|
||||
},
|
||||
{ kind: "separator" },
|
||||
);
|
||||
}
|
||||
|
||||
push({ id: "new", label: "New", submenu: newMenuItems(folder) }, { kind: "separator" });
|
||||
@@ -458,10 +471,7 @@ function libraryMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
||||
}
|
||||
|
||||
export function revealLabel() {
|
||||
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
|
||||
if (/Win/i.test(ua)) return "Explorer";
|
||||
if (/Linux/i.test(ua)) return "Files";
|
||||
return "Finder";
|
||||
return fileManagerName();
|
||||
}
|
||||
|
||||
/** What contributed menu groups see: the selected (non-placeholder) paths. */
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/** Navigate › File Path (⌥⌘F12): the file and its folders, each revealed in the OS file manager. */
|
||||
import { openPopup } from "@/editor/lsp/popupStore";
|
||||
import { revealInFileManager } from "@/platform/fileManager";
|
||||
import { fileManagerName, revealInFileManager } from "@/platform/fileManager";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { nameOf, parentOf } from "./paths";
|
||||
import { revealLabel } from "./contextMenu";
|
||||
|
||||
const DRIVE_ROOT = /^[A-Za-z]:\/?$/;
|
||||
|
||||
@@ -24,7 +23,7 @@ async function reveal(path: string) {
|
||||
try {
|
||||
await revealInFileManager(path);
|
||||
} catch (e) {
|
||||
const fileManager = revealLabel();
|
||||
const fileManager = tx(fileManagerName());
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: tx`Cannot reveal in ${fileManager}`,
|
||||
|
||||
@@ -112,7 +112,7 @@ export function activate(): () => void {
|
||||
let shownRoots = "";
|
||||
const syncContentRoots = () => {
|
||||
const { project, settings } = useProject.getState();
|
||||
const own = project && !project.workspace;
|
||||
const own = project && (!project.workspace || project.workspace.projects[0] === project.root);
|
||||
const paths = own ? validateProjectStructure(settings?.projectStructure).contentRoots : [];
|
||||
// The Build tool window's model (one load per build change), not a load of our own.
|
||||
const build = useBuildModel.getState();
|
||||
|
||||
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user