Compare commits

...
Author SHA1 Message Date
Shohruh Ismoilov 44d871caee fix(deps): bundle patched SQLite for RUSTSEC-2026-0459 (#2351)
Update libsqlite3-sys to 0.39.1 (SQLite 3.54.1), which needs SQLx at its unreleased rev 10462fa: SQLx 0.9.0 on crates.io caps libsqlite3-sys below 0.38. The rev also brings the 48 other commits on SQLx's main since 0.9.0. Cargo deny allows this one Git source and requires revision pins.
2026-11-09 20:30:07 +08:00
arookieofc 89f96edc07 fix(vim): let mapleader shadow built-in keys like <Space> and , (#2337) (#2355)
* fix(vim): let mapleader shadow built-in keys like <Space> and , (#2337)

codemirror-vim prefers a full match over a partial one, so a user mapping
that starts with <Space> or , never fires: the built-in <Space>->l and
,->repeatLastCharacterSearch commands run first and clear the key buffer.

While an rc mapping begins with a key a built-in uses, unmap the built-in;
restore it on the next applyRcCommands pass so mapclear()'s user/default
boundary stays honest (Vim.unmap does not update defaultKeymapLength).

* fix(vim): pass the context arg Vim.unmap's typings require (#2337)
2026-11-09 20:27:38 +08:00
l0ng-ai fd79a5c55e fix(editor): keep the page from scrolling with the editor (#2356)
CodeMirror's tooltip container in `body` carries the editor's theme classes,
so the themes' `height: 100%` gave it a full window of height. Scrolling to a
position the editor had already reached then moved the page itself, leaving
the title bar cut off and an empty band at the bottom.

Set the height on `.cm-editor` only, and clip the body so nothing outside
`#root` can make the page scrollable.
2026-11-09 20:22:12 +08:00
arookieofc bb8c9fa0b4 fix(vcs): remember the diff window's size between openings (#2328) (#2353)
The modal-commit diff dialog always reopened at its default 1100x720.
Pass the persisted size as the dialog's initialSize and save it when a
resize ends, like the Find in Files popup already does.
2026-11-09 20:14:28 +08:00
ntbang0901andNguyenBang 039ec09122 feat(vcs): pick each repository's remote and target branch in the multi-repository Push dialog (#2342)
Co-authored-by: NguyenBang <[email protected]>
2026-11-09 20:10:09 +08:00
ntbang0901andNguyenBang d02aae2280 feat(keymap): bind ⌘↓ to Jump to Source in the diff viewer on macOS (#2341)
IDEA's Diff.OpenInEditor copies EditSource's shortcuts, which the Mac OS X 10.5+ keymap binds to F4 and ⌘↓. The IntelliJ (macOS) preset only had F4 for diff.jumpToSource, which macOS often takes for itself. The binding is scoped by diffEditorFocus, so ⌘↓ still moves to the document end in other editors.

Closes #2292

Co-authored-by: NguyenBang <[email protected]>
2026-11-09 20:08:01 +08:00
NealimeKennaandNealimeKenna e44a3af310 fix(agents): attach pasted clipboard images on Linux (#2336)
WebKitGTK hands the composer an empty clipboardData when the clipboard
holds only an image, as after a GNOME screenshot, and refuses
navigator.clipboard.read(). Ctrl+V then attached nothing. A paste that
carries no data now reads the image through the GTK clipboard with the
new clipboard_image command.

Co-authored-by: NealimeKenna <[email protected]>
2026-11-09 19:59:30 +08:00
l0ng-ai ff2eb95b01 fix(lsp): respect Dart's own filesystem watcher (#2350) 2026-11-09 19:58:10 +08:00
l0ng-ai b2b881e075 fix(debug): wait for Windows debuggee trees to exit (#2349) 2026-11-09 19:58:00 +08:00
l0ng-ai 90d32f7883 fix(lsp): wait through planned server restarts (#2348) 2026-11-09 19:57:51 +08:00
arookieofc f72e3a9a8a fix(fonts): prefer installed JetBrains Mono/Inter over bundled variable webfonts (#2311)
* fix(fonts): prefer installed JetBrains Mono/Inter over bundled variable webfonts

When the user has JetBrains Mono or Inter installed, the system outline
fonts carry TrueType hinting bytecode that the bundled fontsource variable
webfonts lack, so the installed face renders crisper at editor sizes and
matches IntelliJ IDEA. Keep the variable faces as fallbacks so machines
without a system install keep rendering (#26).

Fixes #2305

* test(e2e): expect installed Inter before bundled Inter Variable (#2305)

* test(e2e): match computed-style serialization in the Inter font assertion (#2305)

* chore: trigger CI (no runs created for 950cb4b4)
2026-11-09 19:43:48 +08:00
l0ng-ai 1ae8572021 fix(project): keep attachments in the current project (#2320)
Attach adds a trusted folder to the current project without reopening it or moving its root. Attachments live in the primary project's private .openidea/workspace.json, and detaching the last one restores a plain project.

ProjectInfo.name stays the primary project's name; displayName supplies window titles, project tabs and Recent Projects. Attach is no longer a remembered opening policy, and File > Attach Project is available when the policy is not Ask. Attached folders must be trusted, and Restricted Mode names any attached folder that blocks trust. Explicit workspaces keep their behaviour and still cannot be created in or inside a linked project.
2026-11-09 19:23:05 +08:00
l0ng-ai 023c3f36c2 feat(app): check for updates while the IDE stays open (#2329)
* feat(app): check for updates periodically

* test(app): cover update commands in Restricted Mode
2026-11-09 19:13:47 +08:00
l0ng-ai 88a1e4161e Update README.md 2026-11-09 18:44:37 +08:00
l0ng-ai 0b1f723b9a fix(build): link missing tool and JDK errors to recovery (#2335) 2026-11-09 18:15:24 +08:00
l0ng-ai e2f22de824 fix(tools): install with the selected project SDK (#2334) 2026-11-09 18:13:57 +08:00
arookieofc 24d3173a55 fix(editor): parent CodeMirror tooltips to the body so panels can't cover them (#2310) (#2327) 2026-11-09 18:12:18 +08:00
l0ng-ai 799391b301 fix(debugger): report obsolete methods as a completed hot swap (#2330) 2026-11-09 18:07:12 +08:00
Budi Santosoandboenkkk 8ddf979cfe feat(db): separate procedures and functions into their own schema folders (#2270)
The database tree lists routines in one `routines` folder. A Separate Procedures and Functions option in the Database tool window's menu (off by default, remembered across sessions) lists `procedures` and `functions` folders instead, each holding only routines of that kind, with the same overload-aware keys and object filter. Expanded folders carry over when the option is switched either way.

Co-authored-by: boenkkk <[email protected]>
2026-11-09 18:05:59 +08:00
Qing f3f9fdb1ad fix(run): drop the leftover vue kind from the category table (#2302)
The vue run-configuration kind never shipped upstream — detection is gone
and no type registers it. Its KIND_CATEGORIES row (and the test table row)
are dead data; the plugin-era dev-server story is covered by plain npm
configurations with stable ids.
2026-11-09 18:01:46 +08:00
arookieofc 687471c19e fix(database): table editor focus when the tab opens targets the editor host (#2307) (#2318)
The editor area focuses its own [data-editor-focus] host when a table tab
opens; dbTableEditorFocus only set when focus landed inside
[data-db-table-editor], so Go to Column / Submit / Revert stayed disabled
until a cell was clicked. Treat host focus that wraps a table editor as
table-editor focus too.
2026-11-09 17:58:29 +08:00
yoo hyeong chanandarookieofc a46d873f6c feat(java): share JDT LS across builds in a project window (#2272)
* feat(java): share JDT LS across builds in a project window

* fix(java): wait for JDT restart before launch requests

* fix(host): give relay final replies ten seconds of EOF grace

relay::tests::final_responses_keep_flowing_after_input_eof flakes on
Windows CI: the target writes one byte per second for seven seconds
after input EOF, but the relay only waits five idle seconds, so a
slow first byte makes the relay return with nothing received.

Raise EOF_GRACE to ten seconds and update the companion test's
server wait and elapsed bound to match.

Refs #2272 (Windows CI failure)

---------

Co-authored-by: arookieofc <[email protected]>
2026-11-09 17:53:59 +08:00
Emanyzwwwandemanyzwww 5d3432a000 fix(welcome): name the platform's file manager, not Finder (#2265)
On Windows and Linux several places still named macOS's Finder: the recent-project row menu (Show in Finder), Settings › Version Control › Confirmation › When patch is created, the question after a patch is saved, and the reveal error. The wording was an English literal at each call site, one place sniffed the user agent, and the catalogs only had Finder's text. Shared helpers in src/platform/fileManager.ts now name Finder / Explorer / Files from the platform info, and the project view's Reveal action, the editor tab menu, the project tree menu, the welcome page and the patch flow use them, with whole-sentence catalog entries so each locale can translate them.

Co-authored-by: emanyzwww <[email protected]>
2026-11-09 17:51:48 +08:00
ppxb 16ebb6fde8 feat(run): import JetBrains Go Application and Go Test configurations (#2309) 2026-11-09 17:49:59 +08:00
l0ng-ai 0e673c206a fix(editor): keep the saved layout when a session restore meets open tabs (#2325)
A session restore that lands after tabs were already opened (a file launched into LightEdit while the window was still restoring, or a file opened from the CLI into a project window during startup) appended every restored tab to the current pane: saved splits collapsed into one pane, restored pinned tabs landed after unpinned ones, a pane could hold several preview tabs, and the tab limit was skipped. The restore now starts from the saved state and reopens the live tabs into it through the normal tab rules (pin order, one preview per pane, tab limit without evicting the live tabs), keeping their slots and line/column targets; the launched tab ends up active and focused in the saved active pane. An empty window still takes the saved layout as is.

Refs #2229
2026-11-09 17:42:23 +08:00
arookieofc 2680ba8463 fix(agents): notify subscribers when a session report changes the cached provider config (#2308)
* fix(java): pass m2e an effective settings file carrying the configured local repository (#1739)

m2e resolves the local repository from the Maven settings XML, never from
-Dmaven.repo.local, so JDTLS imports kept downloading into ~/.m2/repository
even when the IDE's own mvn runs honored the configured path. When a local
repository is configured but the user settings lack <localRepository>, derive
an effective settings file under <root>/.openidea/ and pass it as
java.configuration.maven.userSettings. Settings that already declare
<localRepository> are passed through untouched.

* fix(agents): notify subscribers when a session report changes the cached provider config (#1490)

Session reports (session/new, session/load, turn updates) and CLI discovery
funnel through Shared::remember_inner, which cached new model lists but never
emitted AgentEvent::Providers. The composer therefore kept showing a stale
model list until the next task's busy-to-idle refresh. Emit Providers when the
cache actually changes; unchanged reports stay silent, and the cache lock is
released before invoking the sink. fetch_settings' unconditional emit is now
covered by remember_discovered and removed.
2026-11-09 17:39:15 +08:00
arookieofc 660587a6fe fix(java): list JDT .classpath libraries and the JDK for manifest-less projects (#2300) (#2304)
* fix(java): list JDT .classpath libraries and the JDK for manifest-less projects (#2300)

External Libraries was empty for a pure-Java project (only .classpath /
.idea/libraries, no pom.xml or build.gradle):

- jdk_groups returned early without a build manifest, so even a
  configured JDK never appeared. It now also treats .classpath, .project,
  .idea, a configured JDK and declared source roots as Java markers.
- The JDT model was never a source of libraries. classpath_groups parses
  each module's .classpath kind="lib" entries (sourcepath preferred when
  it exists) into a per-module group, discovered through the same module
  scan the other ecosystems use.

Adds quick-xml to ide-sdk (already used by ide-run / ide-host) and the
.classpath file to the External Libraries refresh triggers.

* chore: regenerate bindings for classpath library kind
2026-11-09 17:32:07 +08:00
171 changed files with 5785 additions and 707 deletions

No files matched your search

Generated
+29 -20
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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
+10 -1
View File
@@ -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);
}
}
+43
View File
@@ -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 {
+6
View File
@@ -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 {
+1
View File
@@ -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;
+45 -3
View File
@@ -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) })
}
+24
View File
@@ -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::*;
+151
View File
@@ -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"));
}
}
+3
View File
@@ -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
+34 -6
View File
@@ -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;
}
+87
View File
@@ -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.
+1 -1
View File
@@ -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")]
+2 -1
View File
@@ -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())?,
+35 -4
View File
@@ -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();
+1 -1
View File
@@ -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 {
+61 -20
View File
@@ -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.
+11 -10
View File
@@ -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();
+1 -1
View File
@@ -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 {
+2
View File
@@ -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))
+34 -8
View File
@@ -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)) {
+4 -4
View File
@@ -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();
}
+21 -2
View File
@@ -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"
+34 -1
View File
@@ -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());
}
+134 -14
View File
@@ -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");
}
+6
View File
@@ -268,6 +268,12 @@ impl Handler for ClientHandler {
"window/showMessage" => self.show_message(&mgr, &params),
"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
View File
@@ -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
View File
@@ -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;
}
+77 -7
View File
@@ -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] {
+577 -7
View File
@@ -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() {
+98
View File
@@ -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() {
+84 -2
View File
@@ -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
+71
View File
@@ -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 &quot;-s -w&quot; -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");
}
+1
View File
@@ -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
+28
View File
@@ -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)?;
+141 -3
View File
@@ -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);
}
}
+23 -1
View File
@@ -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"));
}
+70 -1
View File
@@ -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)) {
+113 -8
View File
@@ -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}");
}
+2 -1
View File
@@ -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();
}
+97 -49
View File
@@ -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]
+3
View File
@@ -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
View File
@@ -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.
+159
View File
@@ -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" });
});
+34
View File
@@ -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);
});
+2 -1
View File
@@ -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");
+52
View File
@@ -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);
});
+17 -8
View File
@@ -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 })
+33
View File
@@ -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(&gtk::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>(
+4 -1
View File
@@ -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,
+33 -45
View File
@@ -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.
+14 -4
View File
@@ -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)
}
+2
View File
@@ -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());
}
+1
View File
@@ -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(
+1 -1
View File
@@ -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
View File
@@ -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);
+176
View File
@@ -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) = &current.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);
}
}
+1 -1
View File
@@ -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
View File
@@ -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,
+11 -1
View File
@@ -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 [
+2 -1
View File
@@ -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))",
+18 -6
View File
@@ -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 });
});
+45 -42
View File
@@ -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]");
}
+176 -3
View File
@@ -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");
});
});
+47 -19
View File
@@ -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 {
+2 -1
View File
@@ -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 });
+17 -5
View File
@@ -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) }),
);
}
}}
/>
+20 -1
View File
@@ -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",
+11 -1
View File
@@ -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();
}
});
+54 -13
View File
@@ -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 () => {
+75 -6
View File
@@ -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);
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -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) });
+6 -1
View File
@@ -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;
+27 -7
View File
@@ -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
View File
@@ -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"),
},
{
+3 -4
View File
@@ -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 {
+108
View File
@@ -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();
});
});
+45 -19
View File
@@ -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",
+7 -1
View File
@@ -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({
+17
View File
@@ -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");
});
});
+8 -2
View File
@@ -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;
+22
View File
@@ -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();
}
});
});
+5 -1
View File
@@ -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();
}
+6
View File
@@ -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",
+114 -1
View File
@@ -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 {
+78 -6
View File
@@ -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",
+2
View File
@@ -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",
+1 -1
View File
@@ -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 -1
View File
@@ -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" }),
);
});
+16 -6
View File
@@ -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. */
+2 -3
View File
@@ -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}`,
+1 -1
View File
@@ -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