{
+ setFocusedDiffNavigator(nav);
+ setContext("diffEditorFocus", true);
+ }}
+ onBlurCapture={(e) => {
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedDiffNavigator() === nav) {
+ setFocusedDiffNavigator(null);
+ setContext("diffEditorFocus", false);
+ }
+ }}
+ >
-
goto(-1)} />
- goto(1)} />
+ goto(-1)}
+ />
+ goto(1)}
+ />
);
})}
diff --git a/src/features/run/useConsoleSelection.test.tsx b/src/features/run/useConsoleSelection.test.tsx
new file mode 100644
index 000000000..9e0058cf9
--- /dev/null
+++ b/src/features/run/useConsoleSelection.test.tsx
@@ -0,0 +1,94 @@
+import { act, fireEvent, renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useConsoleSelection } from "./useConsoleSelection";
+
+const viewport = { startIndex: 100, endIndex: 110, overscan: 2, count: 500 };
+
+afterEach(() => {
+ window.getSelection()?.removeAllRanges();
+ document.body.replaceChildren();
+});
+
+function setup() {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const rows = [5, 6, 7].map((index) => {
+ const row = document.createElement("div");
+ row.dataset.index = String(index);
+ row.textContent = `output ${index}`;
+ host.append(row);
+ return row;
+ });
+ const ref = { current: host };
+ const pause = vi.fn();
+ const hook = renderHook(({ pause }) => useConsoleSelection(ref, pause), { initialProps: { pause } });
+ const select = (backward = false) => {
+ act(() => {
+ const [anchor, focus] = backward ? [rows[2]!, rows[0]!] : [rows[0]!, rows[2]!];
+ window.getSelection()!.setBaseAndExtent(anchor.firstChild!, 1, focus.firstChild!, 3);
+ document.dispatchEvent(new Event("selectionchange"));
+ });
+ };
+ return { ...hook, host, rows, pause, select };
+}
+
+describe("console selection retention", () => {
+ it.each([false, true])("keeps the full selected interval mounted (backward=%s)", (backward) => {
+ const { result, select, pause } = setup();
+ select(backward);
+ const indices = result.current.rangeExtractor(viewport);
+ expect(indices.slice(0, 3)).toEqual([5, 6, 7]);
+ expect(indices).not.toContain(50);
+ expect(indices).toContain(100);
+ expect(pause).toHaveBeenCalled();
+ });
+
+ it("renders the intervening text when dragging beyond the viewport", () => {
+ const { result, rows, select } = setup();
+ fireEvent.mouseDown(rows[0]!, { button: 0 });
+ select();
+ const indices = result.current.rangeExtractor(viewport);
+ expect(indices[0]).toBe(5);
+ expect(indices).toHaveLength(108);
+ fireEvent.mouseUp(document);
+ expect(result.current.rangeExtractor(viewport)).not.toContain(50);
+ expect(result.current.rangeExtractor(viewport)).toContain(5);
+ });
+
+ it("releases pinned rows when the selection is cleared or moved outside the console", () => {
+ const { result, select } = setup();
+ select();
+ act(() => {
+ window.getSelection()!.removeAllRanges();
+ document.dispatchEvent(new Event("selectionchange"));
+ });
+ expect(result.current.selecting).toBe(false);
+ expect(result.current.rangeExtractor(viewport)[0]).toBe(98);
+ select();
+ const other = document.createTextNode("other output");
+ document.body.append(other);
+ act(() => {
+ window.getSelection()!.setBaseAndExtent(other, 0, other, 5);
+ document.dispatchEvent(new Event("selectionchange"));
+ });
+ expect(result.current.selecting).toBe(false);
+ expect(result.current.rangeExtractor(viewport)[0]).toBe(98);
+ });
+
+ it("keeps a drag active when incoming output rerenders the parent", () => {
+ const { result, rows, select, rerender } = setup();
+ fireEvent.mouseDown(rows[0]!, { button: 0 });
+ const pause = vi.fn();
+ rerender({ pause });
+ select();
+ expect(result.current.rangeExtractor(viewport)).toContain(50);
+ expect(pause).toHaveBeenCalled();
+ });
+
+ it("does not pin rows or pause output for a context click", () => {
+ const { result, rows, pause } = setup();
+ fireEvent.mouseDown(rows[0]!, { button: 2 });
+ expect(result.current.selecting).toBe(false);
+ expect(pause).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/features/run/useConsoleSelection.ts b/src/features/run/useConsoleSelection.ts
new file mode 100644
index 000000000..6fa897240
--- /dev/null
+++ b/src/features/run/useConsoleSelection.ts
@@ -0,0 +1,73 @@
+import { useCallback, useEffect, useEffectEvent, useState, type RefObject } from "react";
+import { defaultRangeExtractor, type Range } from "@tanstack/react-virtual";
+
+type SelectedRows = { start: number; end: number; dragging: boolean };
+
+/** Keep the browser's selection endpoints, and the text between them, mounted. */
+export function useConsoleSelection(host: RefObject
, pauseFollow: () => void) {
+ const pause = useEffectEvent(pauseFollow);
+ const [selected, setSelected] = useState(null);
+
+ useEffect(() => {
+ const el = host.current;
+ if (!el) return;
+ let dragging = false;
+ const indexOf = (node: Node | null) => {
+ const element = node instanceof Element ? node : node?.parentElement;
+ const row = element?.closest("[data-index]");
+ return row && el.contains(row) ? Number(row.dataset.index) : null;
+ };
+ const update = () => {
+ const selection = window.getSelection();
+ const anchor = indexOf(selection?.anchorNode ?? null);
+ const focus = indexOf(selection?.focusNode ?? null);
+ const next =
+ selection && (!selection.isCollapsed || dragging) && anchor !== null && focus !== null
+ ? { start: Math.min(anchor, focus), end: Math.max(anchor, focus), dragging }
+ : null;
+ setSelected((prev) =>
+ prev?.start === next?.start && prev?.end === next?.end && prev?.dragging === next?.dragging ? prev : next,
+ );
+ if (next && !selection!.isCollapsed) pause();
+ };
+ const down = (event: MouseEvent) => {
+ if (event.button !== 0) return;
+ const index = indexOf(event.target as Node);
+ if (index === null) return;
+ dragging = true;
+ // Pin the anchor before a scroll can recycle it, even before selectionchange fires.
+ setSelected({ start: index, end: index, dragging: true });
+ pause();
+ };
+ const up = () => {
+ dragging = false;
+ update();
+ };
+ el.addEventListener("mousedown", down);
+ document.addEventListener("mouseup", up);
+ document.addEventListener("selectionchange", update);
+ return () => {
+ el.removeEventListener("mousedown", down);
+ document.removeEventListener("mouseup", up);
+ document.removeEventListener("selectionchange", update);
+ };
+ }, [host]);
+
+ const rangeExtractor = useCallback(
+ (range: Range) => {
+ const visible = defaultRangeExtractor(range);
+ if (!selected || !visible.length) return visible;
+ // During a drag, scrolling must also render every line up to the pointer.
+ const start = Math.max(0, selected.dragging ? Math.min(selected.start, visible[0]!) : selected.start);
+ const end = Math.min(
+ range.count - 1,
+ selected.dragging ? Math.max(selected.end, visible[visible.length - 1]!) : selected.end,
+ );
+ const indices = new Set(visible);
+ for (let i = start; i <= end; i++) indices.add(i);
+ return [...indices].sort((a, b) => a - b);
+ },
+ [selected],
+ );
+ return { rangeExtractor, selecting: selected !== null };
+}
From 362ae3535cc0ee27cc5cc43e47e67689065620fb Mon Sep 17 00:00:00 2001
From: kkmihai
Date: Mon, 2 Nov 2026 14:15:42 +0200
Subject: [PATCH 17/22] fix(trust): keep LightEdit sessions restricted (#857)
LightEdit created its project as trusted and refresh_project_trust skips light projects, so opening a loose notebook detected Python environments in the file's folder and could run a repository-shipped .venv/bin/python without a trust prompt. LightEdit now starts restricted and stays restricted when folder trust changes, since one session can hold files from unrelated folders; notebooks still open and save, while interpreter discovery, kernel execution and external formatting are refused. Opening the folder as a trusted project enables execution as before.
Fixes #849
Co-authored-by: kkMihai <59262001+kkMihai@users.noreply.github.com>
---
crates/ide-host/src/project.rs | 2 +-
crates/ide-host/tests/light_edit.rs | 101 ++++++++++++++++++++++++++++
2 files changed, 102 insertions(+), 1 deletion(-)
diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs
index faf28db01..0e00ae8b9 100644
--- a/crates/ide-host/src/project.rs
+++ b/crates/ide-host/src/project.rs
@@ -513,7 +513,7 @@ impl Host {
sink.clone(),
);
let project = Arc::new(Project {
- trusted: Arc::new(AtomicBool::new(true)),
+ trusted: Arc::new(AtomicBool::new(false)),
sink,
languages,
settings,
diff --git a/crates/ide-host/tests/light_edit.rs b/crates/ide-host/tests/light_edit.rs
index 1eff9e388..99f2e96c2 100644
--- a/crates/ide-host/tests/light_edit.rs
+++ b/crates/ide-host/tests/light_edit.rs
@@ -78,3 +78,104 @@ async fn light_windows_ignore_project_scoped_settings() {
assert!(!applied.project_scope);
assert!(!dir.join(".openidea").exists(), "no project settings next to the file");
}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn light_windows_stay_restricted_when_folder_trust_changes() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = tmp.path().join("loose");
+ let other = tmp.path().join("other");
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::create_dir_all(&other).unwrap();
+ std::fs::write(root.join("one.py"), "print(1)\n").unwrap();
+ std::fs::write(other.join("two.js"), "const value = 1;\n").unwrap();
+ let host = host(&tmp.path().join("app"));
+ host.global_settings
+ .set(ide_workspace::SettingsScope::Global, "languages.prettier.prefer", serde_json::json!(true))
+ .unwrap();
+ let light = host.open_light("main", &root.join("one.py")).unwrap();
+ assert!(!light.is_trusted());
+ assert!(light.require_trust().is_err());
+
+ host.set_project_trust(&root, true, false).await.unwrap();
+ assert!(!light.is_trusted());
+ let trusted_folder = host.open_light("second", &root.join("one.py")).unwrap();
+ assert!(!trusted_folder.is_trusted());
+ let again = host.open_light("main", &other.join("two.js")).unwrap();
+ assert!(Arc::ptr_eq(&light, &again));
+ assert!(!again.is_trusted());
+ host.set_project_trust(&root, true, true).await.unwrap();
+ assert!(!light.is_trusted());
+ assert!(!trusted_folder.is_trusted());
+ for command in ["sdk_detect", "sdk_locate_tools", "notebook_execute", "notebook_restart", "run_start"] {
+ assert!(host.authorize_command("main", command).is_err(), "{command}");
+ }
+
+ let doc = ops::doc::open(&host, "main", other.join("two.js")).await.unwrap();
+ let error = ops::lsp::lsp_format(
+ &host,
+ "main",
+ doc.id,
+ None,
+ ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false },
+ )
+ .await
+ .unwrap_err();
+ assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
+ assert!(error.message.contains("Restricted Mode"));
+
+ host.set_project_trust(&root, false, false).await.unwrap();
+ assert!(!light.is_trusted());
+ assert!(!trusted_folder.is_trusted());
+ host.set_project_trust(&root, true, false).await.unwrap();
+ let project = host.open_project("main", &root).unwrap();
+ assert!(!project.light);
+ assert!(project.is_trusted());
+ assert!(project.require_trust().is_ok());
+ host.shutdown().await;
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn light_notebooks_open_without_interpreter_detection_or_execution() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = tmp.path().join("loose");
+ let bin = root.join(".venv").join(if cfg!(windows) { "Scripts" } else { "bin" });
+ std::fs::create_dir_all(&bin).unwrap();
+ std::fs::write(root.join(".venv/pyvenv.cfg"), "home = python\n").unwrap();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let python = bin.join("python");
+ std::fs::write(&python, "#!/bin/sh\nprintf probed > \"$0.probed\"\nprintf 'Python 3.12.0\\n'\n").unwrap();
+ std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
+ }
+ let file = root.join("analysis.ipynb");
+ std::fs::write(
+ &file,
+ r#"{"cells":[{"id":"cell","cell_type":"code","source":["print(1)"],"metadata":{},"outputs":[],"execution_count":null}],"metadata":{},"nbformat":4,"nbformat_minor":5}"#,
+ )
+ .unwrap();
+ let host = host(&tmp.path().join("app"));
+ host.open_light("main", &file).unwrap();
+ let notebook = ops::notebook::open(&host, "main", file.to_string_lossy().into()).await.unwrap();
+ assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok());
+ let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err();
+ assert!(error.message.contains("Restricted Mode"));
+ assert!(ops::sdk::sdk_python_effective(&host, "main").is_err());
+ assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err());
+ let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err();
+ assert!(error.message.contains("Restricted Mode"));
+ let error = ops::notebook::execute(
+ &host,
+ "main",
+ notebook.id,
+ vec![notebook.cells[0].id.clone()],
+ None,
+ ide_core::RequestId(849),
+ ops::Channel::new(|_| {}),
+ )
+ .await
+ .unwrap_err();
+ assert!(error.message.contains("Restricted Mode"));
+ assert!(!bin.join("python.probed").exists());
+ host.shutdown().await;
+}
From 78b23df7b5da24df6b707f11db4bb72cda9a3864 Mon Sep 17 00:00:00 2001
From: misswell
Date: Mon, 2 Nov 2026 20:15:51 +0800
Subject: [PATCH 18/22] fix(host): close a project's background processes with
it (#852)
Closing a project only cancelled its agents and notebooks; its window's requests, run processes, terminal shells and debug sessions kept running until app exit, and the language server shutdown was a detached task a closing app could drop (orphaned jdtls). Closing now cancels the window's requests, kills its run processes and shells, stops its debug sessions and waits for the language servers to shut down; other windows are untouched. Terminals are keyed by owner window so they can be closed per window.
Co-authored-by: misswell <28730815+misswell@users.noreply.github.com>
---
crates/ide-dap/src/lib.rs | 10 +++++
crates/ide-host/src/lib.rs | 23 +++++++++-
crates/ide-host/src/ops/term.rs | 3 +-
crates/ide-host/src/trust.rs | 4 +-
crates/ide-host/tests/close_project.rs | 54 ++++++++++++++++++++++++
crates/ide-host/tests/terminal_images.rs | 1 +
crates/ide-terminal/src/lib.rs | 16 ++++++-
7 files changed, 104 insertions(+), 7 deletions(-)
create mode 100644 crates/ide-host/tests/close_project.rs
diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs
index 638ec8d38..5b8786a14 100644
--- a/crates/ide-dap/src/lib.rs
+++ b/crates/ide-dap/src/lib.rs
@@ -1389,6 +1389,16 @@ impl DebugManager {
})
}
+ /// Stops every session `key` started: the window that opened the project is closing.
+ pub async fn stop_key(&self, key: &str) {
+ let ids: Vec = self.sessions(Some(key)).into_iter().map(|s| s.id).collect();
+ for id in ids {
+ if let Err(e) = self.stop(id).await {
+ tracing::debug!("debug session {id:?} did not stop: {e}");
+ }
+ }
+ }
+
/// Stops a session and its child sessions.
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
let session = self.session(s)?;
diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs
index 66e21c9b7..d5fef1e5f 100644
--- a/crates/ide-host/src/lib.rs
+++ b/crates/ide-host/src/lib.rs
@@ -176,12 +176,21 @@ impl Host {
}
project.agents.shutdown();
project.notebooks.stop_admission();
+ // What the closing window started goes with it — the builds and searches it
+ // asked for, its runs, its shells and its debug sessions — and the language
+ // servers finish shutting down before the window is gone, as IDEA closes a
+ // project's processes.
+ for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
+ self.cancel_request(request);
+ }
+ self.processes.kill_project(&project.root);
+ self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
+ Some(project.lsp.clone()),
+ Some((self.debugger.clone(), window.to_owned())),
);
- let lsp = project.lsp.clone();
- self.runtime.spawn(async move { lsp.shutdown_all().await });
}
}
@@ -191,6 +200,8 @@ impl Host {
&self,
notebooks: Vec>,
bridges: Vec>,
+ lsp: Option>,
+ debug: Option<(Arc, String)>,
) {
for bridge in &bridges {
bridge.stop();
@@ -208,6 +219,12 @@ impl Host {
for service in notebooks {
service.shutdown().await;
}
+ if let Some(lsp) = lsp {
+ lsp.shutdown_all().await;
+ }
+ if let Some((debug, key)) = debug {
+ debug.stop_key(&key).await;
+ }
});
});
if let Err(error) = worker.join() {
@@ -229,6 +246,8 @@ impl Host {
self.shutdown_project_workers_blocking(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
+ None,
+ None,
);
}
diff --git a/crates/ide-host/src/ops/term.rs b/crates/ide-host/src/ops/term.rs
index c7a284c9f..02a340266 100644
--- a/crates/ide-host/src/ops/term.rs
+++ b/crates/ide-host/src/ops/term.rs
@@ -40,7 +40,8 @@ pub fn spawn(
.ok()
.filter(|p| p.is_trusted())
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell));
- let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
+ let key = host.owner_window(window).to_owned();
+ let info = host.terminals.spawn(options, key, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
if let Some(line) = activation {
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
}
diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs
index 7e8522e10..5fd072bba 100644
--- a/crates/ide-host/src/trust.rs
+++ b/crates/ide-host/src/trust.rs
@@ -135,9 +135,7 @@ impl crate::Host {
for request in requests {
self.cancel_request(request);
}
- for session in self.debugger.sessions(Some(&window)) {
- let _ = self.debugger.stop(session.id).await;
- }
+ self.debugger.stop_key(&window).await;
}
}
project.lsp.reconfigure().await;
diff --git a/crates/ide-host/tests/close_project.rs b/crates/ide-host/tests/close_project.rs
new file mode 100644
index 000000000..e0b07cd11
--- /dev/null
+++ b/crates/ide-host/tests/close_project.rs
@@ -0,0 +1,54 @@
+//! Closing a project takes the background processes of its window with it, as IDEA does,
+//! and leaves the other windows' processes alone.
+#![cfg(unix)]
+
+use ide_core::NullSink;
+use ide_host::{Host, HostConfig, ops};
+use std::sync::Arc;
+
+fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
+ ide_terminal::TerminalOptions {
+ cwd: root.to_path_buf().into(),
+ // A fixed shell: the test must not depend on the login shell of this machine.
+ shell: Some("/bin/sh".into()),
+ args: vec![],
+ env: Default::default(),
+ cols: 80,
+ rows: 24,
+ shell_integration: false,
+ }
+}
+
+#[test]
+fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
+ std::fs::create_dir_all(&root).unwrap();
+ let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
+ runtime.block_on(async {
+ let host = Arc::new(Host::new(HostConfig {
+ config_dir: tmp.path().join("config"),
+ data_dir: tmp.path().join("data"),
+ local_data_dir: tmp.path().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(&root, true, false).unwrap();
+ host.open_project("main", &root).unwrap();
+ let project_shell =
+ ops::term::spawn(&host, "main", options(&root), Arc::new(|_| {})).expect("project terminal");
+ let other_shell = ops::term::spawn(&host, "other", options(&root), Arc::new(|_| {})).expect("other terminal");
+ assert_eq!(host.terminals.list().len(), 2);
+
+ host.close_project("main");
+
+ let left: Vec<_> = host.terminals.list().into_iter().map(|t| t.id).collect();
+ assert!(!left.contains(&project_shell.id), "the closing window's shell is gone");
+ assert!(left.contains(&other_shell.id), "another window keeps its shell");
+ host.terminals.kill_key("other");
+ });
+}
diff --git a/crates/ide-host/tests/terminal_images.rs b/crates/ide-host/tests/terminal_images.rs
index 13605357a..8bb4a3e11 100644
--- a/crates/ide-host/tests/terminal_images.rs
+++ b/crates/ide-host/tests/terminal_images.rs
@@ -35,6 +35,7 @@ async fn storage_requires_a_registered_terminal() {
rows: 24,
shell_integration: false,
},
+ "main".into(),
Arc::new(NullSink),
Box::new(|_| {}),
)
diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs
index 82df70cde..21ae833cf 100644
--- a/crates/ide-terminal/src/lib.rs
+++ b/crates/ide-terminal/src/lib.rs
@@ -53,6 +53,8 @@ pub struct TerminalInfo {
}
struct Session {
+ /// The window that opened it: closing that window's project closes its terminals.
+ key: String,
info: TerminalInfo,
master: Mutex>,
writer: Mutex>,
@@ -83,6 +85,7 @@ impl TerminalManager {
pub fn spawn(
&self,
options: TerminalOptions,
+ key: String,
sink: SharedSink,
on_output: Box,
) -> IdeResult {
@@ -121,6 +124,7 @@ impl TerminalManager {
let info = TerminalInfo { id, title: shell_title(&shell), pid: child.process_id() };
let exited = Arc::new(AtomicBool::new(false));
let session = Arc::new(Session {
+ key,
info: info.clone(),
master: Mutex::new(pair.master),
writer: Mutex::new(writer),
@@ -160,6 +164,15 @@ impl TerminalManager {
.map_err(|e| IdeError::internal(format!("resize failed: {e}")))
}
+ /// Closes the terminals `key` opened: the project of that window is closing.
+ pub fn kill_key(&self, key: &str) {
+ let ids: Vec =
+ self.sessions.lock().iter().filter(|(_, s)| s.key == key).map(|(id, _)| *id).collect();
+ for id in ids {
+ let _ = self.kill(id);
+ }
+ }
+
pub fn kill(&self, id: TerminalId) -> IdeResult<()> {
let session = self.sessions.lock().remove(&id).ok_or_else(|| not_found(id))?;
terminate(&session);
@@ -636,6 +649,7 @@ mod tests {
.manager
.spawn(
opts,
+ "test".into(),
h.sink.clone(),
Box::new(move |s| {
let _ = tx.send(s);
@@ -793,7 +807,7 @@ mod tests {
let h = harness();
let (other_sink, other_events) = channel_sink();
let (mine, _rx) = spawn_collect(&h, sh("exit 1"));
- let theirs = h.manager.spawn(sh("exit 2"), other_sink, Box::new(|_| {})).expect("spawn");
+ let theirs = h.manager.spawn(sh("exit 2"), "other".into(), other_sink, Box::new(|_| {})).expect("spawn");
match h.events.recv_timeout(Duration::from_secs(10)).expect("exit event") {
IdeEvent::TerminalExited { terminal, code } => assert_eq!((terminal, code), (mine.id, Some(1))),
e => panic!("unexpected {e:?}"),
From 502a9dc7dd7fe8db44cf0fee924aaaa0307bf54e Mon Sep 17 00:00:00 2001
From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com>
Date: Mon, 2 Nov 2026 19:15:59 +0700
Subject: [PATCH 19/22] fix(kotlin): compile a folder of sibling Maven projects
in the Kotlin bridge (#842)
compile::plan only fell back to Gradle when the folder had no build file, so a folder of sibling Maven projects failed with "no supported build system" in the Kotlin editor compilation and Build Project. It now falls back to Maven when the model has Maven projects, after the existing Gradle fallback.
Fixes #814
Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com>
---
crates/ide-build/src/compile/mod.rs | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs
index 9cd0ea26f..9cbd6e48e 100644
--- a/crates/ide-build/src/compile/mod.rs
+++ b/crates/ide-build/src/compile/mod.rs
@@ -185,8 +185,10 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path
None => None,
};
let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf());
+ // A folder of sibling projects has no build file itself; its Maven projects are built one by one.
let system = detect_dir(&dir, model, root)
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
+ .or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
.ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?;
let module = module_dir.as_deref().filter(|d| *d != root);
let title = match module {
@@ -814,6 +816,17 @@ mod tests {
("pnpm", &["run".to_owned(), "build".to_owned()][..])
);
+ let siblings = tmp.path().join("siblings");
+ write(&siblings, "core/pom.xml", "core");
+ write(&siblings, "system/pom.xml", "system");
+ let model = crate::load(&siblings, &tmp.path().join("cache3"), None);
+ let p = plan(&siblings, &model, BuildKind::Build, None).unwrap();
+ assert_eq!(p.system, BuildSystem::Maven);
+ assert_eq!(
+ p.steps.iter().map(|s| s.cwd.clone()).collect::>(),
+ [siblings.join("core"), siblings.join("system")]
+ );
+
let none = tmp.path().join("none");
std::fs::create_dir_all(&none).unwrap();
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err());
From 4ea7d32074a984a315c44d5b8df767e07e84507b Mon Sep 17 00:00:00 2001
From: kevinh <45650311+kevinh458@users.noreply.github.com>
Date: Mon, 2 Nov 2026 20:16:07 +0800
Subject: [PATCH 20/22] fix(settings): confirm before exiting the IDE by
default (#834)
Confirm before exiting the IDE now defaults to on in both the Rust settings and the frontend schema; the exit dialog's "Don't ask again" still turns it off.
Fixes #749
Co-authored-by: kevinh458 <45650311+kevinh458@users.noreply.github.com>
---
crates/ide-core/src/settings.rs | 2 +-
src/features/settings/model/schema.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs
index 7c2c17a37..453b1433f 100644
--- a/crates/ide-core/src/settings.rs
+++ b/crates/ide-core/src/settings.rs
@@ -235,7 +235,7 @@ impl Default for GeneralSettings {
fn default() -> Self {
Self {
reopen_last_project: true,
- confirm_exit: false,
+ confirm_exit: true,
open_project_in: "ask".into(),
default_project_directory: String::new(),
save_on_idle: false,
diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts
index d92540d3a..f9400f4c9 100644
--- a/src/features/settings/model/schema.ts
+++ b/src/features/settings/model/schema.ts
@@ -118,7 +118,7 @@ function validateSpelling(raw: unknown): SpellingValid {
export { ACCENT_PRESETS } from "@/workbench/theme";
export const DEFAULT_SETTINGS: ValidSettings = {
- general: { reopenLastProject: true, confirmExit: false, ...DEFAULT_GENERAL_EXTRAS },
+ general: { reopenLastProject: true, confirmExit: true, ...DEFAULT_GENERAL_EXTRAS },
appearance: { theme: "light", accent: "#3871e1", uiFontSize: 13, locale: "en", ...DEFAULT_APPEARANCE_EXTRAS },
editor: {
fontFamily: "JetBrains Mono",
From 53b5052bbbf3e0cce2fdf788e0c5c3861d708486 Mon Sep 17 00:00:00 2001
From: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com>
Date: Mon, 2 Nov 2026 21:17:29 +0900
Subject: [PATCH 21/22] fix(editor): let the scrollbar thumb drag where
overview marks cover it (#807)
The overview lane lies over the vertical scrollbar and its marks take the pointer, so a mark under the thumb swallowed the press; a new or fully changed file is one lane-high mark, so its thumb could not be dragged at all. While the pointer is over the thumb (computed from the scroller's metrics and the 32px minimum thumb) the marks now let the pointer through; elsewhere clicking a mark still jumps to its line.
Fixes #752
Co-authored-by: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com>
---
e2e/overviewLane.spec.ts | 30 +++++++++++++
src/editor/core/cm/ideaParity.test.ts | 12 +++++-
src/editor/core/cm/lanes.ts | 61 +++++++++++++++++++++++++++
src/editor/core/cm/theme.ts | 2 +
4 files changed, 104 insertions(+), 1 deletion(-)
create mode 100644 e2e/overviewLane.spec.ts
diff --git a/e2e/overviewLane.spec.ts b/e2e/overviewLane.spec.ts
new file mode 100644
index 000000000..6c51df7c8
--- /dev/null
+++ b/e2e/overviewLane.spec.ts
@@ -0,0 +1,30 @@
+import { expect, openFromTree, test } from "./fixtures";
+
+// Headless Chromium hides scrollbars by default; this test drags one.
+test.use({ launchOptions: { ignoreDefaultArgs: ["--hide-scrollbars"] } });
+
+test("the scrollbar thumb drags even where overview marks cover it (#752)", async ({ workbench: page }) => {
+ const editor = await openFromTree(page, "build.gradle.kts");
+ await editor.locator(".cm-content").click();
+ await page.keyboard.press("Meta+a");
+ await page.keyboard.insertText(Array.from({ length: 400 }, (_, i) => `val mark${i} = ${i}`).join("\n"));
+ // A match on every line covers the overview lane, as a new file's change mark does.
+ await page.keyboard.press("Meta+f");
+ await page.getByRole("textbox", { name: "Find" }).fill("mark");
+ await expect(editor.locator(".cm-openidea-overviewMark").first()).toBeVisible();
+ const scroller = editor.locator(".cm-scroller");
+ await scroller.evaluate((s) => (s.scrollTop = 0));
+ const box = (await scroller.boundingBox())!;
+ // Over a mark and inside the thumb, which is at the top of the track.
+ const x = box.x + box.width - 5;
+ const y = box.y + 12;
+ await page.mouse.move(x - 40, y);
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await page.mouse.move(x, y + 150, { steps: 10 });
+ await page.mouse.up();
+ expect(await scroller.evaluate((s) => s.scrollTop)).toBeGreaterThan(0);
+ // Away from the thumb the marks take the pointer again.
+ await page.mouse.move(x, box.y + box.height - 20);
+ await expect(editor.locator(".cm-openidea-overview")).not.toHaveClass(/cm-openidea-overview-onThumb/);
+});
diff --git a/src/editor/core/cm/ideaParity.test.ts b/src/editor/core/cm/ideaParity.test.ts
index 38640f8fd..bad82ca40 100644
--- a/src/editor/core/cm/ideaParity.test.ts
+++ b/src/editor/core/cm/ideaParity.test.ts
@@ -23,7 +23,7 @@ import {
foldSelectionEffects,
} from "./folding";
import { columnModeExtension, extendBlock, toggleColumnMode } from "./columnMode";
-import { nearestMark, unfoldAround } from "./lanes";
+import { nearestMark, scrollbarThumb, unfoldAround } from "./lanes";
import { lineRangeSelection } from "./gutterDrag";
import { matchBraceTarget } from "./caretCommands";
import { overwriteField, toggleOverwrite } from "./overwrite";
@@ -291,6 +291,16 @@ describe("error stripe", () => {
expect(nearestMark(marks, 21)).toBeNull();
});
+ it("finds the scrollbar thumb the marks must let through", () => {
+ // Nothing to scroll: no thumb.
+ expect(scrollbarThumb(0, 400, 400)).toBeNull();
+ // A quarter of the document visible: a 100px thumb, at the top, then at the bottom.
+ expect(scrollbarThumb(0, 1600, 400)).toEqual({ top: 0, bottom: 100 });
+ expect(scrollbarThumb(1200, 1600, 400)).toEqual({ top: 300, bottom: 400 });
+ // A long document keeps the 32px minimum thumb; halfway down it sits mid-track.
+ expect(scrollbarThumb(49800, 100000, 400)).toEqual({ top: 184, bottom: 216 });
+ });
+
it("unfolds what hides the target line", () => {
const s = EditorState.create({ doc: "f {\n x\n}", extensions: [codeFolding()] }).update({
effects: foldEffect.of({ from: 3, to: 8 }),
diff --git a/src/editor/core/cm/lanes.ts b/src/editor/core/cm/lanes.ts
index cb876831c..9e6ac7c62 100644
--- a/src/editor/core/cm/lanes.ts
+++ b/src/editor/core/cm/lanes.ts
@@ -27,6 +27,27 @@ export function nearestMark(marks: re
return best;
}
+/** The smallest scrollbar thumb (`::-webkit-scrollbar-thumb` min-height in global.css). */
+const MIN_THUMB = 32;
+
+/**
+ * Where the vertical scrollbar thumb of a scroller is, in px from the top of its track, or null
+ * when nothing scrolls. The overview lane lies over the scrollbar, so its marks must not take
+ * the pointer where the thumb is (#752).
+ */
+export function scrollbarThumb(
+ scrollTop: number,
+ scrollHeight: number,
+ clientHeight: number,
+ minThumb = MIN_THUMB,
+): { top: number; bottom: number } | null {
+ const max = scrollHeight - clientHeight;
+ if (max <= 0 || clientHeight <= 0) return null;
+ const length = Math.min(clientHeight, Math.max(minThumb, (clientHeight * clientHeight) / scrollHeight));
+ const top = (Math.min(Math.max(scrollTop, 0), max) / max) * (clientHeight - length);
+ return { top, bottom: top + length };
+}
+
/** Unfolds the folds hiding `pos` or the rest of its line (navigating to a mark shows it). */
export function unfoldAround(state: EditorState, pos: number): StateEffect[] {
const lineEnd = state.doc.lineAt(pos).to;
@@ -41,12 +62,17 @@ class OverviewLane {
dom: HTMLElement;
private scheduled = false;
private lastKey = "";
+ /** The last pointer position over the editor, to re-check the thumb when it scrolls. */
+ private pointer: { x: number; y: number } | null = null;
constructor(private view: EditorView) {
this.dom = document.createElement("div");
this.dom.className = "cm-openidea-overview";
this.dom.setAttribute("aria-hidden", "true");
this.dom.addEventListener("mousedown", this.onMouseDown);
+ view.dom.addEventListener("mousemove", this.onPointerMove);
+ view.dom.addEventListener("mouseleave", this.onPointerLeave);
+ view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true });
view.dom.appendChild(this.dom);
this.schedule();
}
@@ -132,6 +158,38 @@ class OverviewLane {
});
}
+ /**
+ * Over the scrollbar thumb the marks let the pointer through, so the thumb can be dragged
+ * even where a mark covers it (a new file is one mark the height of the lane, #752).
+ */
+ private updateThumbPassThrough() {
+ const p = this.pointer;
+ let over = false;
+ if (p) {
+ const lane = this.dom.getBoundingClientRect();
+ const s = this.view.scrollDOM;
+ const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
+ const y = p.y - s.getBoundingClientRect().top;
+ over = !!thumb && p.x >= lane.left && p.x <= lane.right && y >= thumb.top && y <= thumb.bottom;
+ }
+ this.dom.classList.toggle("cm-openidea-overview-onThumb", over);
+ }
+
+ private onPointerMove = (e: MouseEvent) => {
+ this.pointer = { x: e.clientX, y: e.clientY };
+ // A thumb drag in progress keeps the marks out of the way until the button is released.
+ if (e.buttons === 0) this.updateThumbPassThrough();
+ };
+
+ private onPointerLeave = () => {
+ this.pointer = null;
+ this.updateThumbPassThrough();
+ };
+
+ private onScroll = () => {
+ if (this.pointer) this.updateThumbPassThrough();
+ };
+
private onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return;
const marks = [...this.dom.querySelectorAll(".cm-openidea-overviewMark")].map((el) => {
@@ -153,6 +211,9 @@ class OverviewLane {
destroy() {
this.dom.removeEventListener("mousedown", this.onMouseDown);
+ this.view.dom.removeEventListener("mousemove", this.onPointerMove);
+ this.view.dom.removeEventListener("mouseleave", this.onPointerLeave);
+ this.view.scrollDOM.removeEventListener("scroll", this.onScroll);
this.dom.remove();
}
}
diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts
index 0a95f9177..0390d1f1f 100644
--- a/src/editor/core/cm/theme.ts
+++ b/src/editor/core/cm/theme.ts
@@ -283,6 +283,8 @@ export const openideaTheme = EditorView.theme({
cursor: "pointer",
},
".cm-openidea-overviewMark-thin": { left: "4px", width: "4px", minHeight: "2px" },
+ // The scrollbar thumb wins over the marks under it (cm/lanes.ts, #752).
+ ".cm-openidea-overview-onThumb .cm-openidea-overviewMark": { pointerEvents: "none" },
".cm-openidea-overviewCaret": {
position: "absolute",
left: "2px",
From f8105e5473111013f5818a20a9519874eb3a15c1 Mon Sep 17 00:00:00 2001
From: misswell
Date: Mon, 2 Nov 2026 20:17:37 +0800
Subject: [PATCH 22/22] fix(maven): list every goal a plugin declares, not just
the bound one (#841)
Plugin goals came from a built-in table plus the goals bound in , so a plugin outside the table (maven-checkstyle-plugin) showed only its bound goal. The Plugins section now also reads META-INF/maven/plugin.xml from the plugin jar in the local repository (honouring of the settings file in use), cached per jar, with the table as fallback when the jar is not downloaded yet.
Co-authored-by: misswell <28730815+misswell@users.noreply.github.com>
---
Cargo.lock | 1 +
crates/ide-build/Cargo.toml | 1 +
crates/ide-build/src/maven.rs | 229 ++++++++++++++++++++++++++++++++--
crates/ide-build/src/sync.rs | 78 +++++++++++-
4 files changed, 294 insertions(+), 15 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index b7590a3f9..423882bf7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4086,6 +4086,7 @@ dependencies = [
"specta",
"tempfile",
"tracing",
+ "zip",
]
[[package]]
diff --git a/crates/ide-build/Cargo.toml b/crates/ide-build/Cargo.toml
index eff2f2485..946863725 100644
--- a/crates/ide-build/Cargo.toml
+++ b/crates/ide-build/Cargo.toml
@@ -8,6 +8,7 @@ license.workspace = true
[dependencies]
dunce.workspace = true
ide-core.workspace = true
+zip.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs
index f9b926a9d..b467c95ac 100644
--- a/crates/ide-build/src/maven.rs
+++ b/crates/ide-build/src/maven.rs
@@ -11,7 +11,9 @@ use crate::xml::{self, Element};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, HashMap, HashSet};
+use std::io::Read;
use std::path::{Path, PathBuf};
+use std::sync::{Mutex, OnceLock};
/// Phases shown under "Lifecycle" (IDEA's default list).
pub const LIFECYCLE: &[&str] =
@@ -456,13 +458,20 @@ struct Resolver {
/// `~/.m2/repository`, or `` from `~/.m2/settings.xml`.
pub(crate) fn default_local_repo() -> Option {
let m2 = dirs::home_dir()?.join(".m2");
- if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t))
- && let Some(repo) = settings.text_of("localRepository")
- {
- let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
- return Some(PathBuf::from(repo.replace("${user.home}", &home)));
+ local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
+}
+
+/// `` of a settings file, when it names one: the repository a build
+/// with `-s ` resolves into, which is not necessarily the user's default.
+pub fn local_repository_of(settings: &Path) -> Option {
+ let text = std::fs::read_to_string(settings).ok()?;
+ let root = xml::parse(&text)?;
+ let repo = root.text_of("localRepository")?.trim().to_owned();
+ if repo.is_empty() {
+ return None;
}
- Some(m2.join("repository"))
+ let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
+ Some(PathBuf::from(repo.replace("${user.home}", &home)))
}
impl Resolver {
@@ -688,7 +697,76 @@ pub fn goal_prefix(artifact_id: &str) -> String {
artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned()
}
-/// Goals of well-known plugins (their plugin descriptors are not read).
+/// Goals of every plugin jar read so far, by the path of that jar.
+static DESCRIPTOR: OnceLock>>> = OnceLock::new();
+
+/// `` names of the plugin descriptor inside its jar (`META-INF/maven/plugin.xml`),
+/// which is where Maven itself takes the list `plugin:help` prints. Empty when the jar
+/// is not in the local repository yet.
+fn descriptor_goals(repo: &Path, group_id: &str, artifact_id: &str, version: &str) -> Vec {
+ // Keyed by the jar itself, not by its coordinates: a repository can be replaced
+ // between runs, and a test's empty repository must not answer for another's.
+ // One `push` per group segment: the layout is `//…` on
+ // every platform, Windows' separator included.
+ let mut dir = repo.to_path_buf();
+ dir.extend(group_id.split('.'));
+ dir.push(artifact_id);
+ dir.push(version);
+ let Some(jar) = plugin_jar(&dir, artifact_id, version) else { return Vec::new() };
+ let key = jar.to_string_lossy().into_owned();
+ let cache = DESCRIPTOR.get_or_init(|| Mutex::new(HashMap::new()));
+ if let Some(cached) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
+ return cached.clone();
+ }
+ // A failed read is not remembered: the jar may be half written by Maven right now.
+ let Some(goals) = read_descriptor(&jar) else { return Vec::new() };
+ cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, goals.clone());
+ goals
+}
+
+/// The plugin's jar in its version directory: `-.jar`, or — for a
+/// SNAPSHOT installed from a repository, whose file name carries a timestamp — the newest
+/// `-*.jar` that is neither sources nor javadoc.
+fn plugin_jar(dir: &Path, artifact_id: &str, version: &str) -> Option {
+ let exact = dir.join(format!("{artifact_id}-{version}.jar"));
+ if exact.is_file() {
+ return Some(exact);
+ }
+ let prefix = format!("{artifact_id}-");
+ let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
+ for entry in std::fs::read_dir(dir).ok()?.flatten() {
+ let path = entry.path();
+ let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
+ if !name.starts_with(&prefix) || !name.ends_with(".jar") || name.ends_with("-sources.jar") {
+ continue;
+ }
+ if name.ends_with("-javadoc.jar") {
+ continue;
+ }
+ let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { continue };
+ if best.as_ref().is_none_or(|(at, _)| modified > *at) {
+ best = Some((modified, path));
+ }
+ }
+ best.map(|(_, p)| p)
+}
+
+fn read_descriptor(jar: &Path) -> Option> {
+ let file = std::fs::File::open(jar).ok()?;
+ let mut archive = zip::ZipArchive::new(file).ok()?;
+ let mut entry = archive.by_name("META-INF/maven/plugin.xml").ok()?;
+ let mut text = String::new();
+ entry.read_to_string(&mut text).ok()?;
+ let root = xml::parse(&text)?;
+ let mut goals: Vec =
+ root.list("mojos", "mojo").iter().filter_map(|m| m.text_of("goal")).map(|g| g.trim().to_owned()).collect();
+ goals.retain(|g| !g.is_empty());
+ goals.sort();
+ goals.dedup();
+ Some(goals)
+}
+
+/// Goals of well-known plugins, for a jar that is not in the local repository yet.
fn known_goals(artifact_id: &str) -> &'static [&'static str] {
match artifact_id {
"maven-clean-plugin" => &["clean", "help"],
@@ -840,9 +918,27 @@ fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] {
}
}
-/// A plugin as shown in the tree: execution goals plus the plugin's known goals, sorted.
-pub(crate) fn plugin(group_id: &str, artifact_id: &str, version: Option, goals: &[String]) -> MavenPlugin {
+/// A plugin as shown in the tree: the goals its jar declares, the ones known by name for
+/// the common plugins, and the ones the project binds in ``, sorted. IDEA lists
+/// a plugin's goals the same way, which is why a plugin outside the table (checkstyle, say)
+/// still shows every goal it offers instead of only the bound one.
+pub(crate) fn plugin(
+ group_id: &str,
+ artifact_id: &str,
+ version: Option,
+ goals: &[String],
+ local_repo: Option<&Path>,
+) -> MavenPlugin {
let mut all: Vec = known_goals(artifact_id).iter().map(|g| (*g).to_owned()).collect();
+ let repo = local_repo.map(Path::to_path_buf).or_else(default_local_repo);
+ // An unresolved `${…}` version names no jar: the table and the bound goals stand in.
+ if let (Some(repo), Some(version)) = (repo.as_deref(), version.as_deref().filter(|v| !v.contains("${"))) {
+ for g in descriptor_goals(repo, group_id, artifact_id, version) {
+ if !all.contains(&g) {
+ all.push(g);
+ }
+ }
+ }
for g in goals {
if !all.contains(g) {
all.push(g.clone());
@@ -1007,7 +1103,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option) -> MavenM
.or_else(|| ctx.managed_plugins.iter().find(|m| m.key() == p.key()).and_then(|m| m.version.clone()))
.map(|v| i(&v))
.filter(|v| !v.contains("${"));
- plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals)
+ plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals, resolver.local_repo.as_deref())
})
.collect();
@@ -1119,6 +1215,16 @@ pub fn build_files(root: &Path, model: &MavenModel) -> Vec {
/// Projects and their plugins / dependencies from `mvn help:effective-pom` output
/// (`` or ``), keyed `groupId:artifactId`.
pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec, Vec)> {
+ parse_effective_pom_in(text, None)
+}
+
+/// The same, with the local repository the project actually builds against (a
+/// `-Dmaven.repo.local` of the build, say): the plugin goals come from jars there,
+/// rather than from the user's default one.
+pub fn parse_effective_pom_in(
+ text: &str,
+ local_repo: Option<&Path>,
+) -> Vec<(String, Vec, Vec)> {
let Some(root) = xml::parse(text) else { return Vec::new() };
let projects: Vec<&Element> =
if root.name == "projects" { root.children_named("project").collect() } else { vec![&root] };
@@ -1130,7 +1236,7 @@ pub fn parse_effective_pom(text: &str) -> Vec<(String, Vec, Vec Vec<(String, Vec)
mod tests {
use super::*;
+ /// A plugin jar in `repo` whose descriptor declares `goals`.
+ fn write_plugin_jar(repo: &Path, group: &str, artifact: &str, version: &str, goals: &[&str]) {
+ let dir = repo.join(group.replace('.', "/")).join(artifact).join(version);
+ std::fs::create_dir_all(&dir).unwrap();
+ let mojos: String = goals.iter().map(|g| format!("{g}")).collect();
+ let descriptor = format!("{mojos}");
+ let file = std::fs::File::create(dir.join(format!("{artifact}-{version}.jar"))).unwrap();
+ let mut zip = zip::ZipWriter::new(file);
+ zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
+ std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
+ zip.finish().unwrap();
+ }
+
+ #[test]
+ fn a_plugin_lists_the_goals_its_jar_declares() {
+ let tmp = tempfile::tempdir().unwrap();
+ let repo = tmp.path();
+ write_plugin_jar(
+ repo,
+ "org.apache.maven.plugins",
+ "maven-checkstyle-plugin",
+ "3.3.1",
+ &["checkstyle", "checkstyle-aggregate", "help", "check"],
+ );
+ let p = plugin(
+ "org.apache.maven.plugins",
+ "maven-checkstyle-plugin",
+ Some("3.3.1".into()),
+ &["check".to_owned()],
+ Some(repo),
+ );
+ assert_eq!(p.goals, ["check", "checkstyle", "checkstyle-aggregate", "help"], "every goal the plugin offers");
+ assert_eq!(p.prefix, "checkstyle");
+ }
+
+ #[test]
+ fn a_settings_file_names_the_repository_to_read_goals_from() {
+ let tmp = tempfile::tempdir().unwrap();
+ let settings = tmp.path().join("settings.xml");
+ std::fs::write(
+ &settings,
+ "${user.home}/repo-from-settings",
+ )
+ .unwrap();
+ let repo = local_repository_of(&settings).unwrap();
+ assert!(repo.ends_with("repo-from-settings"), "{}", repo.display());
+ assert!(!repo.to_string_lossy().contains("${user.home}"));
+ // A settings file that names none leaves Maven's default alone.
+ let plain = tmp.path().join("plain.xml");
+ std::fs::write(&plain, "true").unwrap();
+ assert!(local_repository_of(&plain).is_none());
+ assert!(local_repository_of(&tmp.path().join("missing.xml")).is_none());
+ }
+
+ #[test]
+ fn effective_pom_goals_come_from_the_local_repository_it_is_given() {
+ let tmp = tempfile::tempdir().unwrap();
+ write_plugin_jar(tmp.path(), "org.apache.maven.plugins", "maven-demo-plugin", "1.0", &["run", "help"]);
+ let text = "ga\
+ org.apache.maven.pluginsmaven-demo-plugin\
+ 1.0run\
+ ";
+ let with_repo = parse_effective_pom_in(text, Some(tmp.path()));
+ assert_eq!(with_repo[0].1[0].goals, ["help", "run"], "the jar's own goals come first");
+ // Without that repository the plugin is unknown: only what the project binds.
+ assert_eq!(parse_effective_pom(text)[0].1[0].goals, ["run"]);
+ }
+
+ #[test]
+ fn a_snapshot_plugin_is_found_by_its_timestamped_jar() {
+ let tmp = tempfile::tempdir().unwrap();
+ let dir = tmp.path().join("g/p/maven-demo-plugin/1.0-SNAPSHOT");
+ std::fs::create_dir_all(&dir).unwrap();
+ let descriptor = "run";
+ let file = std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3.jar")).unwrap();
+ let mut zip = zip::ZipWriter::new(file);
+ zip.start_file("META-INF/maven/plugin.xml", zip::write::SimpleFileOptions::default()).unwrap();
+ std::io::Write::write_all(&mut zip, descriptor.as_bytes()).unwrap();
+ zip.finish().unwrap();
+ // A sibling sources jar must not win.
+ std::fs::File::create(dir.join("maven-demo-plugin-1.0-20240101.120000-3-sources.jar")).unwrap();
+ let p = plugin("g.p", "maven-demo-plugin", Some("1.0-SNAPSHOT".into()), &[], Some(tmp.path()));
+ assert_eq!(p.goals, ["run"]);
+ }
+
+ #[test]
+ fn a_plugin_without_its_jar_keeps_the_table_and_the_bound_goals() {
+ let tmp = tempfile::tempdir().unwrap();
+ let p = plugin(
+ "org.apache.maven.plugins",
+ "maven-checkstyle-plugin",
+ Some("3.3.1".into()),
+ &["check".to_owned()],
+ Some(tmp.path()),
+ );
+ assert_eq!(p.goals, ["check"], "nothing beyond what the project binds");
+ let known =
+ plugin("org.apache.maven.plugins", "maven-compiler-plugin", Some("3.13.0".into()), &[], Some(tmp.path()));
+ assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
+ }
+
fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs
index cbeed656f..815c46313 100644
--- a/crates/ide-build/src/sync.rs
+++ b/crates/ide-build/src/sync.rs
@@ -48,6 +48,11 @@ struct CachedMavenProject {
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct MavenCache {
+ /// Bumped whenever what the cache holds stops matching what the model would be
+ /// built as now (plugin goals read from a descriptor, say): an older cache is
+ /// dropped instead of shadowing the model.
+ #[serde(default)]
+ version: u32,
hash: String,
at_ms: f64,
error: Option,
@@ -66,6 +71,7 @@ struct GradleCache {
}
const MAVEN_CACHE: &str = "maven.json";
+const MAVEN_CACHE_VERSION: u32 = 1;
/// `dependency:tree` of a plugin version whose `-Dverbose` lists the occurrences Maven
/// omitted (conflicts, duplicates): verbose output was dropped in 3.0 and is back since 3.2.
@@ -121,6 +127,9 @@ fn now_ms() -> f64 {
/// Merges the cached effective build into `model`.
pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) {
let Some(cache) = read_cache::(cache_dir, MAVEN_CACHE) else { return };
+ if cache.version != MAVEN_CACHE_VERSION {
+ return; // built by an older IDE: what it holds no longer matches the model
+ }
model.synced_at = Some(cache.at_ms);
model.sync_error = cache.error;
let changed = cache.hash != hash_files(&maven::build_files(root, model));
@@ -211,8 +220,31 @@ pub fn sync_maven(
let mut model = maven::load(root);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
std::fs::create_dir_all(cache_dir)?;
- let mut cache =
- MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
+ let mut cache = MavenCache {
+ version: MAVEN_CACHE_VERSION,
+ hash: hash_files(&maven::build_files(root, &model)),
+ verbose: true,
+ ..MavenCache::default()
+ };
+ // Where the build resolves its jars, and therefore where the plugin goals are read
+ // from: `-Dmaven.repo.local=` first, then `` of the `-s` settings,
+ // then the user's default repository (which `plugin()` resolves itself).
+ let local_repo: Option =
+ options.maven_options.iter().find_map(|a| a.strip_prefix("-Dmaven.repo.local=")).map(PathBuf::from).or_else(
+ || {
+ let mut args = options.maven_options.iter();
+ while let Some(arg) = args.next() {
+ let path = match arg.as_str() {
+ "-s" | "--settings" => args.next().map(String::as_str),
+ _ => arg.strip_prefix("-s=").or_else(|| arg.strip_prefix("--settings=")),
+ };
+ if let Some(path) = path {
+ return maven::local_repository_of(Path::new(path));
+ }
+ }
+ None
+ },
+ );
let mut errors: Vec = Vec::new();
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
@@ -241,8 +273,9 @@ pub fn sync_maven(
});
}
})?;
- let effective_projects =
- std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).unwrap_or_default();
+ let effective_projects = std::fs::read_to_string(&effective)
+ .map(|t| maven::parse_effective_pom_in(&t, local_repo.as_deref()))
+ .unwrap_or_default();
let trees = maven::parse_dependency_tree(&out.text);
for (key, plugins, deps) in effective_projects {
let dependencies = trees.iter().find(|(k, _)| *k == key).map(|(_, t)| t.clone()).unwrap_or(deps);
@@ -332,6 +365,7 @@ mod tests {
let mut model = maven::load_with_repo(&root, None);
assert!(model.synced_at.is_none());
let cache = MavenCache {
+ version: MAVEN_CACHE_VERSION,
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
@@ -343,6 +377,7 @@ mod tests {
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
+ None,
)],
dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }],
}],
@@ -368,6 +403,41 @@ mod tests {
assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh");
}
+ #[test]
+ fn a_cache_from_an_older_ide_is_ignored() {
+ let tmp = tempfile::tempdir().unwrap();
+ let root = tmp.path().join("p");
+ let cache_dir = tmp.path().join("cache");
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::write(root.join("pom.xml"), "ga")
+ .unwrap();
+ let mut model = maven::load_with_repo(&root, None);
+ let cache = MavenCache {
+ // What an older build wrote: no plugin descriptors were read back then, so
+ // its goal lists would shadow the ones the model now knows.
+ version: 0,
+ hash: hash_files(&maven::build_files(&root, &model)),
+ at_ms: 5.0,
+ error: None,
+ verbose: true,
+ projects: vec![CachedMavenProject {
+ key: "g:a".into(),
+ plugins: vec![maven::plugin(
+ "org.apache.maven.plugins",
+ "maven-clean-plugin",
+ Some("3.2.0".into()),
+ &[],
+ None,
+ )],
+ dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }],
+ }],
+ };
+ write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap();
+ apply_maven_cache(&root, &cache_dir, &mut model);
+ assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced");
+ assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached"));
+ }
+
#[test]
fn gradle_cache_replaces_tasks_per_project() {
let tmp = tempfile::tempdir().unwrap();