From ff97d7724c7cacf0db0ba8d4cfaee5170c67c0b6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:47:30 +0800 Subject: [PATCH 01/22] fix(ui): give AsciiDoc files their own icon (#763) AsciiDoc became a recognized language in #731 but .adoc/.asciidoc still used the generic text glyph, failing the icon catalog test that requires every detected language to have a specific icon. Add a dedicated AsciiDoc icon (light/dark) and map both extensions to it. --- src/ui/icons.tsx | 4 +++- src/ui/openidea/fileTypes/asciidoc.svg | 3 +++ src/ui/openidea/fileTypes/asciidoc_dark.svg | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 src/ui/openidea/fileTypes/asciidoc.svg create mode 100644 src/ui/openidea/fileTypes/asciidoc_dark.svg diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 341630142..bcaf888bf 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -130,6 +130,7 @@ const ICONS = { "file-gdscript": "openidea/fileTypes/gdscript", "file-godot": "openidea/fileTypes/godot", "file-gdshader": "openidea/fileTypes/gdshader", + "file-asciidoc": "openidea/fileTypes/asciidoc", "file-d": "openidea/fileTypes/d", "file-dart": "openidea/fileTypes/dart", "file-elixir": "openidea/fileTypes/elixir", @@ -826,7 +827,7 @@ const EXT_ICONS = table({ "file-rst": "rst", "file-yaml": "yaml yml", "file-toml": "toml", - "file-text": "txt text adoc", + "file-text": "txt text", "file-log": "log", "file-csv": "csv tsv", "file-html": "html htm shtml html.tmpl", @@ -879,6 +880,7 @@ const EXT_ICONS = table({ "file-gdscript": "gd", "file-godot": "tscn tres godot import", "file-gdshader": "gdshader gdshaderinc", + "file-asciidoc": "adoc asciidoc", "file-d": "d di", "file-dart": "dart", "file-elixir": "ex exs", diff --git a/src/ui/openidea/fileTypes/asciidoc.svg b/src/ui/openidea/fileTypes/asciidoc.svg new file mode 100644 index 000000000..81076e492 --- /dev/null +++ b/src/ui/openidea/fileTypes/asciidoc.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/openidea/fileTypes/asciidoc_dark.svg b/src/ui/openidea/fileTypes/asciidoc_dark.svg new file mode 100644 index 000000000..2f60166f6 --- /dev/null +++ b/src/ui/openidea/fileTypes/asciidoc_dark.svg @@ -0,0 +1,3 @@ + + + From e2f8e8d5bc4215507cb9e58df352fb24e50fc976 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:14:27 +0800 Subject: [PATCH 02/22] fix(cpp): explain missing clang-tidy checks with Apple's clangd (#770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple's clangd (/usr/bin/clangd, Xcode and CommandLineTools toolchains) is built without clang-tidy, so on a Mac without a managed clangd the checks from .clang-tidy never appear. When a clangd server for C, C++, Objective-C or Objective-C++ resolves to Apple's binary and the project has a .clang-tidy, show one warning per session with Install (managed LLVM clangd, which already takes precedence once installed) and Language Servers… actions. Fixes #692. --- crates/ide-core/src/event.rs | 6 + crates/ide-lsp/src/clang_tidy.rs | 126 ++++++++++++++++++++ crates/ide-lsp/src/launch.rs | 27 ++++- crates/ide-lsp/src/lib.rs | 15 ++- crates/ide-lsp/src/server.rs | 2 + crates/ide-lsp/tests/mock_server.rs | 78 ++++++++++++ src/bindings.ts | 2 + src/features/tools/__tests__/tools.test.tsx | 48 +++++++- src/features/tools/index.ts | 3 +- src/features/tools/missing.ts | 28 +++++ src/platform/locales/text/zh.json | 1 + 11 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 crates/ide-lsp/src/clang_tidy.rs diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 5de989786..e2d45fec6 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -82,6 +82,12 @@ pub enum IdeEvent { status: ServerStatus, message: Option, }, + /// A running server lacks a capability; the UI offers the managed tool as a replacement. + LanguageServerWarning { + name: String, + message: String, + install_tool: String, + }, /// A stopped language server left the project's list (disabled in settings, or a /// fallback that a preferred server took over from again). LanguageServerRemoved { diff --git a/crates/ide-lsp/src/clang_tidy.rs b/crates/ide-lsp/src/clang_tidy.rs new file mode 100644 index 000000000..351ae473b --- /dev/null +++ b/crates/ide-lsp/src/clang_tidy.rs @@ -0,0 +1,126 @@ +//! Apple clangd accepts `.clang-tidy` projects but ships without clang-tidy checks. + +use crate::{LspManager, Server}; +use ide_core::IdeEvent; +use std::path::Path; + +pub(crate) const MESSAGE: &str = "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them."; + +fn is_apple_clangd(path: &Path) -> bool { + if path == Path::new("/usr/bin/clangd") { + return true; + } + if !path.ends_with("usr/bin/clangd") { + return false; + } + path.starts_with("/Library/Developer/CommandLineTools") + || path.ancestors().any(|ancestor| { + ancestor.file_name().is_some_and(|name| name == "Toolchains") + && ancestor.parent().is_some_and(|developer| { + developer.ends_with("Contents/Developer") + && developer.parent().and_then(Path::parent).and_then(Path::file_name).is_some_and(|name| { + let name = name.to_string_lossy(); + name.starts_with("Xcode") && name.ends_with(".app") + }) + }) + }) +} + +fn has_config(server_root: &Path, project_root: &Path, file: &Path) -> bool { + server_root.join(".clang-tidy").is_file() + || file + .parent() + .into_iter() + .flat_map(Path::ancestors) + .take_while(|dir| dir.starts_with(project_root)) + .any(|dir| dir.join(".clang-tidy").is_file()) +} + +impl LspManager { + pub(crate) async fn check_clang_tidy(&self, server: &Server) { + if server.name != "clangd" + || !cfg!(target_os = "macos") + || self.shown_messages.lock().contains(&("clangd".into(), MESSAGE.into())) + { + return; + } + let binary = server.resolved_binary.read().clone(); + let Some(binary) = binary else { return }; + let files: Vec<_> = self + .docs + .lock() + .values() + .filter(|doc| { + matches!(doc.language.as_str(), "c" | "cpp" | "objective-c" | "objective-cpp") + && doc.servers.iter().any(|attached| attached.server.id == server.id) + }) + .map(|doc| doc.path.clone()) + .collect(); + let root = server.root.clone(); + let Some(me) = self.me.upgrade() else { return }; + let _ = tokio::task::spawn_blocking(move || { + if !is_apple_clangd(&binary) && !std::fs::canonicalize(&binary).is_ok_and(|path| is_apple_clangd(&path)) { + return; + } + if !me.shutting_down.load(std::sync::atomic::Ordering::Acquire) + && files.iter().any(|file| has_config(&root, &me.project_root_for(file), file)) + && me.first_message("clangd", MESSAGE) + { + me.sink.emit(IdeEvent::LanguageServerWarning { + name: "clangd".into(), + message: MESSAGE.into(), + install_tool: "clangd".into(), + }); + } + }) + .await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apple_paths() { + for path in [ + "/usr/bin/clangd", + "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd", + "/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd", + "/Library/Developer/CommandLineTools/usr/bin/clangd", + ] { + assert!(is_apple_clangd(Path::new(path)), "{path}"); + } + for path in [ + "/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", + "/opt/homebrew/opt/llvm/bin/clangd", + "/usr/local/bin/clangd", + "/usr/bin/clangd-custom", + "/Library/Developer/CommandLineTools-custom/usr/bin/clangd", + "/Applications/Other.app/Contents/Developer/Toolchains/LLVM.xctoolchain/usr/bin/clangd", + ] { + assert!(!is_apple_clangd(Path::new(path)), "{path}"); + } + } + + #[test] + fn config_in_server_root_or_file_ancestors_stays_inside_project() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("project"); + let server = project.join("build"); + let source = project.join("src/nested"); + let file = source.join("main.cpp"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&server).unwrap(); + std::fs::write(dir.path().join(".clang-tidy"), "").unwrap(); + assert!(!has_config(&server, &project, &file)); + for root in [&server, &source, &project.join("src"), &project] { + let config = root.join(".clang-tidy"); + std::fs::write(&config, "Checks: '-*,modernize-use-nullptr'").unwrap(); + assert!(has_config(&server, &project, &file), "{}", root.display()); + std::fs::remove_file(config).unwrap(); + } + std::fs::create_dir(source.join(".clang-tidy")).unwrap(); + assert!(!has_config(&server, &project, &file)); + } +} diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 36ae556cb..5c7d53c01 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -43,6 +43,18 @@ pub trait Launcher: Send + Sync + 'static { self.launch(config, root, handler, on_stderr) } + /// Starts a server and reports its executable when the launcher runs a local process. + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { + self.launch_cancellable(config, root, handler, on_stderr, cancel).map(|peer| (peer, None)) + } + /// Whether `config` could start without a `Missing` error. fn available(&self, _config: &LanguageServerConfig, _root: &Path) -> bool { true @@ -72,6 +84,17 @@ impl Launcher for ProcessLauncher { on_stderr: Box, cancel: &CancellationToken, ) -> Result { + self.launch_resolved(config, root, handler, on_stderr, cancel).map(|(peer, _)| peer) + } + + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { let search = server_search_path(config); let binary = ide_core::tool_path::find_command(&config.command, &search, root) .and_then(|b| resolve_rustup_proxy(&config.command, b)); @@ -87,10 +110,12 @@ impl Launcher for ProcessLauncher { handler, on_stderr, cancel, - ); + ) + .map(|peer| (peer, None)); } let binary = binary.ok_or(LaunchError::Missing)?; Peer::spawn(&binary.to_string_lossy(), &config.args, root, &env, handler, on_stderr) + .map(|peer| (peer, Some(binary.clone()))) .map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display()))) } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 521451845..d8ead994b 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -8,6 +8,7 @@ mod angular; mod caps; +mod clang_tidy; mod convert; mod external_project; mod file_ops; @@ -472,13 +473,16 @@ impl LspManager { if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() { return Err(LaunchError::Config("Project is in Restricted Mode".into())); } - me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) + me.launcher.launch_resolved(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) }) .await .unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string()))); let peer = match launched { - Ok(peer) => peer, + Ok((peer, binary)) => { + *server.resolved_binary.write() = binary; + peer + } Err(LaunchError::Missing) => { let cfg = server.effective_config(); let message = @@ -587,6 +591,7 @@ impl LspManager { } } } + self.check_clang_tidy(&server).await; server.refresh_busy_status(&self.sink); server.announce_ready(generation); self.spawn_supervisor(server, generation, peer); @@ -810,6 +815,12 @@ impl LspManager { } for s in &servers { self.ensure_started(s); + if s.running().is_some() + && let Some(me) = self.me.upgrade() + { + let server = s.clone(); + self.runtime.spawn(async move { me.check_clang_tidy(&server).await }); + } } } diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 81c21b021..f15a893e6 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -79,6 +79,7 @@ pub struct Server { pub config: LanguageServerConfig, /// `config` with the settings hook applied, as the current instance was launched. effective: Mutex>, + pub resolved_binary: RwLock>, languages: Mutex>, state: Mutex, /// Fired when the instance being started is abandoned (stop / restart while starting). @@ -114,6 +115,7 @@ impl Server { root, config, effective: Mutex::new(None), + resolved_binary: RwLock::new(None), languages: Mutex::new(vec![language]), state: Mutex::new(State { generation: 0, conn: Conn::Idle }), start_cancel: Mutex::new(CancellationToken::new()), diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 7bfb45ccd..d2136467e 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -21,6 +21,7 @@ use std::time::Duration; struct MockState { caps: Mutex, missing: std::sync::atomic::AtomicBool, + binary: Mutex>, launches: AtomicUsize, notifications: Mutex>, requests: Mutex>, @@ -148,6 +149,18 @@ impl MockLauncher { } impl Launcher for MockLauncher { + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &ide_core::CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { + self.launch_cancellable(config, root, handler, on_stderr, cancel) + .map(|peer| (peer, self.state(&config.name).binary.lock().clone())) + } + fn launch( &self, config: &LanguageServerConfig, @@ -2836,3 +2849,68 @@ async fn formatting_provider_selection_tracks_dynamic_registration() { .unwrap(); assert!(!h.mgr.can_format(doc).await.unwrap()); } + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn apple_clangd_warns_for_tidy_config_once_across_languages_and_restarts() { + for language in ["c", "cpp", "objective-c", "objective-cpp"] { + let h = harness_for(language, "src", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd")); + std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + h.open("main.src", "int *p = 0;"); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await; + let id = h.mgr.servers()[0].id; + h.mgr.restart(id).await.unwrap(); + h.wait_status("clangd", ServerStatus::Ready).await; + h.open("other.src", "int *p = 0;"); + h.mgr.shutdown_all().await; + let events = h.events.lock(); + let warnings: Vec<_> = events + .iter() + .filter_map(|e| match e { + IdeEvent::LanguageServerWarning { name, message, install_tool } => Some((name, message, install_tool)), + _ => None, + }) + .collect(); + assert_eq!(warnings.len(), 1, "{language}"); + assert_eq!(warnings[0].0, "clangd"); + assert!(warnings[0].1.contains(".clang-tidy")); + assert_eq!(warnings[0].2, "clangd"); + } +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn clang_tidy_warning_requires_apple_binary_and_a_config() { + for (binary, config) in [ + ("/usr/bin/clangd", false), + ("/opt/homebrew/opt/llvm/bin/clangd", true), + ("/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", true), + ] { + let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from(binary)); + if config { + std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + } + h.open("main.cpp", "int *p = 0;"); + h.wait_status("clangd", ServerStatus::Ready).await; + h.mgr.shutdown_all().await; + assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })), "{binary}"); + } +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() { + let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd")); + h.open("main.cpp", "int *p = 0;"); + h.wait_status("clangd", ServerStatus::Ready).await; + assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))); + std::fs::create_dir(h.root.join("nested")).unwrap(); + std::fs::write(h.root.join("nested/.clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + h.open("nested/other.cpp", "int *p = 0;"); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await; + assert_eq!(h.mgr.servers().len(), 1); + h.mgr.shutdown_all().await; +} diff --git a/src/bindings.ts b/src/bindings.ts index b3ea339c3..e2eb8a2ae 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3619,6 +3619,8 @@ cancellable: boolean } | { type: "indexState"; indexing: boolean; files: number; symbols: number } | /** Git status / branch information should be refreshed. */ { type: "vcsChanged"; root: IpcPath } | { type: "languageServer"; server: ServerId; name: string; status: ServerStatus; message: string | null } | +/** A running server lacks a capability; the UI offers the managed tool as a replacement. */ +{ type: "languageServerWarning"; name: string; message: string; installTool: string } | /** * A stopped language server left the project's list (disabled in settings, or a * fallback that a preferred server took over from again). diff --git a/src/features/tools/__tests__/tools.test.tsx b/src/features/tools/__tests__/tools.test.tsx index 0d4bd57cc..49c64365d 100644 --- a/src/features/tools/__tests__/tools.test.tsx +++ b/src/features/tools/__tests__/tools.test.tsx @@ -7,7 +7,7 @@ import { useTasks } from "@/workbench/tasks"; import { MemoryToolsBackend, sampleTools, setToolsBackend } from "../backend"; import { cancelInstall, installTool, refreshTools, uninstallTool, useTools } from "../store"; import { filterTools, LanguageServersPage } from "../LanguageServersPage"; -import { installMissingServerPrompts, missingKey, promptInstall } from "../missing"; +import { installMissingServerPrompts, installServerWarningPrompts, missingKey, promptInstall } from "../missing"; import { resetTestForm, stagedFor, TestSettingsForm } from "./settingsForm"; type Note = Parameters[0]; @@ -273,3 +273,49 @@ describe("tools.install action", () => { } }); }); + +describe("language server capability warning", () => { + const event = { + type: "languageServerWarning", + name: "clangd", + message: + "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.", + installTool: "clangd", + } as const; + + it("offers a managed replacement for system clangd once and restarts it after installation", async () => { + backend.tools.push({ + ...sampleTools()[0]!, + name: "clangd", + displayName: "clangd", + command: "clangd", + status: "onPath", + path: "/usr/bin/clangd", + languages: ["C", "C++", "Objective-C", "Objective-C++"], + }); + const off = installServerWarningPrompts(); + try { + emitLocalIdeEvent(event); + emitLocalIdeEvent(event); + await waitFor(() => expect(notes).toHaveLength(1)); + expect(notes[0]).toMatchObject({ title: "clangd", level: "warning", sticky: true, body: event.message }); + expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Install", "Language Servers…"]); + notes[0]!.actions![0]!.run(); + await waitFor(() => expect(backend.restarted).toEqual(["clangd"])); + expect(notes.at(-1)).toMatchObject({ title: "clangd installed", key: notes[0]!.key }); + } finally { + off(); + } + }); + + it("keeps the Settings action when the tool cannot be installed", async () => { + const off = installServerWarningPrompts(); + try { + emitLocalIdeEvent(event); + await waitFor(() => expect(notes).toHaveLength(1)); + expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Language Servers…"]); + } finally { + off(); + } + }); +}); diff --git a/src/features/tools/index.ts b/src/features/tools/index.ts index 76736a609..f9c79d9a0 100644 --- a/src/features/tools/index.ts +++ b/src/features/tools/index.ts @@ -9,7 +9,7 @@ import { lazyComponent } from "@/ui"; import { executeAction, registerActions } from "@/platform/actions"; import { registerSettingsPage } from "@/workbench/contributions"; import { installJdkPrompts, registerJdkActions } from "./jdk"; -import { installMissingServerPrompts, promptInstall } from "./missing"; +import { installMissingServerPrompts, installServerWarningPrompts, promptInstall } from "./missing"; import { installTool, refreshTools, SETTINGS_PAGE_ID, toolByName, useTools } from "./store"; /** The Language Servers page (also used by the Settings window). */ @@ -79,6 +79,7 @@ export function activate(): () => void { }, ]), installMissingServerPrompts(), + installServerWarningPrompts(), registerJdkActions(), installJdkPrompts(), ]; diff --git a/src/features/tools/missing.ts b/src/features/tools/missing.ts index c1c48d1a2..6bd53191e 100644 --- a/src/features/tools/missing.ts +++ b/src/features/tools/missing.ts @@ -56,3 +56,31 @@ export function installMissingServerPrompts(): () => void { void promptInstall(e.name).catch(() => {}); }); } + +/** A system server that lacks a capability can be replaced by the managed tool. */ +export function installServerWarningPrompts(): () => void { + const prompted = new Set(); + return onIdeEvent("languageServerWarning", (e) => { + const key = `lsp-warning-${e.name}-${e.installTool}`; + if (prompted.has(key)) return; + prompted.add(key); + void (async () => { + if (!useTools.getState().loaded) await refreshTools(); + const tool = toolByName(e.installTool); + notifications().notify({ + group: "lsp", + level: "warning", + title: e.name, + body: tx(e.message), + key, + sticky: true, + actions: [ + ...(tool?.canInstall + ? [{ label: "Install", run: () => void installTool(e.installTool, { notificationKey: key }) }] + : []), + { label: "Language Servers…", run: () => void executeAction("file.settings", SETTINGS_PAGE_ID) }, + ], + }); + })(); + }); +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index daeb595f9..fec979feb 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,5 @@ { + "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "Apple 的 clangd 不运行 clang-tidy,因此不会显示 .clang-tidy 中配置的检查。请安装 clangd(LLVM)以启用这些检查。", "Newer dependency version available": "有更新的依赖版本可用", "Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。", "Update to {latest}": "更新至 {latest}", From 44c603f7dba0a785feffb01dc64dd571bfcd633d Mon Sep 17 00:00:00 2001 From: d0whc3r <1378986+d0whc3r@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:22:49 +0100 Subject: [PATCH 03/22] feat: add oxfmt/oxlint support for JS and TS projects (#717) Below an oxfmt configuration (.oxfmtrc.json/.jsonc, oxfmt.config.ts/.mts) Reformat Code runs oxfmt instead of Prettier, with the same external-formatter settings; oxfmt formats whole files, so a selection formats the file with a notice. oxlint --lsp serves JS/TS/React/Vue/Svelte files below an oxlint config. Both are managed npm tools; a missing oxfmt gets the same Install prompt and error handling as Prettier, and formatter messages are translated for either tool. Co-authored-by: d0whc3r <1378986+d0whc3r@users.noreply.github.com> --- crates/ide-host/src/formatter.rs | 50 ++++++++--- crates/ide-language/src/registry.rs | 20 ++++- crates/ide-tools/src/formatter.rs | 129 +++++++++++++++++++-------- crates/ide-tools/src/registry.rs | 30 +++++-- crates/ide-tools/tests/prettier.rs | 4 +- languages/javascript.toml | 8 ++ languages/javascriptreact.toml | 8 ++ languages/svelte.toml | 8 ++ languages/typescript.toml | 8 ++ languages/typescriptreact.toml | 8 ++ languages/vue.toml | 8 ++ src/editor/lsp/format.ts | 13 +-- src/editor/lsp/formatErrors.test.tsx | 13 +-- src/platform/locales/text/zh.json | 14 +-- 14 files changed, 246 insertions(+), 75 deletions(-) diff --git a/crates/ide-host/src/formatter.rs b/crates/ide-host/src/formatter.rs index 7bc9af9d1..054497b37 100644 --- a/crates/ide-host/src/formatter.rs +++ b/crates/ide-host/src/formatter.rs @@ -7,6 +7,16 @@ use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit}; use ide_language::FormatterConfig; use ide_lsp::types::FormatOptions; use ide_tools::{Tools, formatter}; +use std::path::Path; + +const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"]; + +/// oxfmt below an oxfmt configuration file, else Prettier. The Prettier +/// settings (languages, preference, on save) apply to both. +fn external_tool(file: &Path) -> &'static str { + let oxfmt = OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some()); + if oxfmt { "oxfmt" } else { "prettier" } +} fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool { config.is_some_and(|c| c.tool == "prettier") @@ -44,16 +54,19 @@ pub async fn format( return Err(IdeError::invalid("The file is read-only")); } let config = config.unwrap().clone(); - let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path); + let tool = external_tool(&snap.path); + let name = formatter::display_name(tool); + let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path).filter(|_| tool == "prettier"); let tools = Tools::new(&host.local_data_dir); - let whole_selection = range.is_some() && !config.range; + let whole_selection = range.is_some() && !formatter::supports_range(tool, &config); let edits: IdeResult> = tokio::task::spawn_blocking(move || { let text = ide_text::Document::new(&snap.text); let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end))); - let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?; - command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?); - command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets)); - let formatted = formatter::run(command, &snap.text, &Default::default())?; + let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?; + let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?; + command.current_dir(dir); + command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets)); + let formatted = formatter::run(tool, command, &snap.text, &Default::default())?; Ok(text .diff(&formatted) .changes @@ -69,13 +82,13 @@ pub async fn format( if edits.is_ok() && whole_selection { project.sink.emit(IdeEvent::Notify { level: NotifyLevel::Info, - title: "Prettier".into(), - body: Some( - "Prettier does not support selection formatting for this language; formatted the whole file".into(), - ), + title: name.into(), + body: Some(format!( + "{name} does not support selection formatting for this language; formatted the whole file" + )), }); } - edits.map_err(|e| e.with_tool("prettier")) + edits.map_err(|e| e.with_tool(tool)) } #[cfg(test)] @@ -104,4 +117,19 @@ mod tests { assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false)); assert!(!external_selected(None, "rust", &settings, false)); } + + #[test] + fn oxfmt_config_selects_oxfmt_over_prettier() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("web/src")).unwrap(); + let (web, api) = (root.join("web/src/app.ts"), root.join("api/main.ts")); + assert_eq!(external_tool(&web), "prettier"); + for config in OXFMT_CONFIGS { + std::fs::write(root.join("web").join(config), "{}").unwrap(); + assert_eq!(external_tool(&web), "oxfmt", "{config}"); + assert_eq!(external_tool(&api), "prettier", "{config}"); + std::fs::remove_file(root.join("web").join(config)).unwrap(); + } + } } diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 5ceaec741..584943590 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -576,7 +576,7 @@ mod tests { }; // After the TypeScript fallback (an available server before it would hide // it); before the HTML server (hover, rename and symbols take the first). - assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]); + assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server", "oxlint"]); assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]); for lang in ["typescript", "html"] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); @@ -586,6 +586,24 @@ mod tests { } } + #[test] + fn oxlint_serves_js_files_only_below_its_config() { + let reg = reg(); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("apps/web/src")).unwrap(); + std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap(); + for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] { + let cfg = reg.get(&LanguageId::new(lang)).unwrap(); + let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap(); + assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice())); + assert!(oxlint.require_root_marker && !oxlint.fallback, "{lang}"); + let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &oxlint.root_markers); + assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}"); + assert_eq!(served("apps/api/main.ts"), None, "{lang}"); + } + } + #[test] fn html_of_angular_projects_is_an_angular_template() { let reg = reg(); diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs index 9b599f0bb..95dd011b3 100644 --- a/crates/ide-tools/src/formatter.rs +++ b/crates/ide-tools/src/formatter.rs @@ -13,20 +13,30 @@ pub fn find_up(file: &Path, relative: &Path) -> Option { file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file()) } -fn package_bin(package: &Path) -> IdeResult { +pub fn display_name(tool: &str) -> &str { + crate::registry::find(tool).map_or(tool, |t| t.display) +} + +fn package_bin(package: &Path, tool: &str) -> IdeResult { + let name = display_name(tool); let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?) - .map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?; + .map_err(|e| IdeError::invalid(format!("{name}: {e}")))?; let bin = json["bin"] .as_str() - .or_else(|| json["bin"]["prettier"].as_str()) - .ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?; + .or_else(|| json["bin"][tool].as_str()) + .ok_or_else(|| IdeError::invalid(format!("{name}: package has no CLI entry point")))?; let path = package.join(bin); if !path.is_file() { - return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display()))); + return Err(IdeError::unavailable(format!("{name}: CLI does not exist: {}", path.display()))); } Ok(path) } +/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only. +pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool { + tool == "prettier" && config.range +} + impl Tools { /// Explicit executable/script, then the nearest project package, then managed tools and PATH. pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { @@ -34,22 +44,26 @@ impl Tools { } fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { - if tool != "prettier" { + if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) { return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}"))); } + let package = Path::new("node_modules").join(tool); + let manifest = package.join("package.json"); let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) { - (package_bin(custom)?, true) + (package_bin(custom, tool)?, true) } else if let Some(custom) = custom { (custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs"))) - } else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) { - (package_bin(manifest.parent().unwrap())?, true) - } else if self.root.join("node_modules/prettier/package.json").is_file() { - (package_bin(&self.root.join("node_modules/prettier"))?, true) + } else if let Some(manifest) = find_up(file, &manifest) { + (package_bin(manifest.parent().unwrap(), tool)?, true) + } else if self.root.join(&manifest).is_file() { + (package_bin(&self.root.join(&package), tool)?, true) } else { - (self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false) + (self.which(tool).ok_or_else(|| IdeError::missing_tool(tool))?, false) }; if node { - let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?; + let name = display_name(tool); + let node = + self.which("node").ok_or_else(|| IdeError::unavailable(format!("{name}: Node.js is required")))?; let mut cmd = self.command(node); cmd.arg(path); Ok(cmd) @@ -59,34 +73,40 @@ impl Tools { } } -/// Project configuration overrides these defaults; Prettier resolves config and plugins itself. +/// Both tools resolve their configuration and plugins themselves. Prettier's +/// project configuration overrides the editor indentation passed here; oxfmt has +/// no such flags and reads its configuration and `.editorconfig` only. pub fn arguments( + tool: &str, config: &FormatterConfig, file: &Path, tab_size: u32, insert_spaces: bool, range: Option<(u32, u32)>, ) -> Vec { - let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { - "mdx" - } else { - &config.parser - }; - let mut args: Vec = [ - "--parser", - parser, - "--config-precedence", - "file-override", - "--tab-width", - &tab_size.max(1).to_string(), - "--use-tabs", - if insert_spaces { "false" } else { "true" }, - ] - .into_iter() - .map(Into::into) - .collect(); + let mut args: Vec = Vec::new(); + if tool == "prettier" { + let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") { + "mdx" + } else { + &config.parser + }; + args.extend( + [ + "--parser", + parser, + "--config-precedence", + "file-override", + "--tab-width", + &tab_size.max(1).to_string(), + "--use-tabs", + if insert_spaces { "false" } else { "true" }, + ] + .map(OsString::from), + ); + } args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]); - if config.range + if supports_range(tool, config) && let Some((start, end)) = range { args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]); @@ -100,8 +120,8 @@ pub fn arguments( args } -pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { - crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier")) +pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult { + crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool)) } #[cfg(test)] @@ -118,7 +138,7 @@ mod tests { assert_eq!(missing.kind, ErrorKind::NotFound); assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true })); let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap(); - let failure = run(custom, "text", &Default::default()).unwrap_err(); + let failure = run("prettier", custom, "text", &Default::default()).unwrap_err(); assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false })); } @@ -127,7 +147,7 @@ mod tests { fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() { let mut command = Command::new("sh"); command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]); - let failure = run(command, "", &Default::default()).unwrap_err(); + let failure = run("prettier", command, "", &Default::default()).unwrap_err(); assert_eq!(failure.message, "error\n".repeat(1000)); assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false })); } @@ -161,7 +181,8 @@ mod tests { ("vue", true), ] { let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range }; - let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); + let args = + arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9))); let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect(); assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"])); assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"])); @@ -171,4 +192,36 @@ mod tests { assert!(!args.contains(&"--write".into())); } } + + #[test] + fn oxfmt_runs_the_nearest_package_without_prettier_flags() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("node_modules/oxfmt/bin")).unwrap(); + std::fs::write(root.join("node_modules/oxfmt/package.json"), r#"{"bin":{"oxfmt":"bin/oxfmt"}}"#).unwrap(); + std::fs::write(root.join("node_modules/oxfmt/bin/oxfmt"), "").unwrap(); + std::fs::write(root.join(".prettierignore"), "dist").unwrap(); + let file = root.join("packages/web/src/app.ts"); + let path = std::env::var_os("PATH").unwrap_or_default(); + let tools = Tools::with_paths(root.join("tools"), path, crate::platform::Platform::current()); + + let command = tools.formatter_command("oxfmt", &file, None).unwrap(); + let program = Path::new(command.get_program()).file_stem().unwrap().to_string_lossy().into_owned(); + assert_eq!(program, "node"); + let script: Vec<_> = command.get_args().collect(); + assert_eq!(script, [root.join("node_modules/oxfmt/bin/oxfmt").as_os_str()]); + + let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true }; + let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9))); + let ignore = root.join(".prettierignore").into_os_string(); + assert_eq!( + args[..4], + ["--stdin-filepath".into(), file.clone().into_os_string(), "--ignore-path".into(), ignore] + ); + assert!(args[4..].chunks(2).all(|pair| pair[0] == "--ignore-path"), "{args:?}"); + assert!(!supports_range("oxfmt", &config)); + + let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err(); + assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message); + } } diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 6dfcd5df7..661a71705 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -134,6 +134,28 @@ pub static TOOLS: &[ToolSpec] = &[ needs: NODE, homepage: "https://prettier.io", }, + ToolSpec { + name: "oxfmt", + display: "oxfmt", + kind: Formatter, + commands: &["oxfmt"], + method: Method::Npm { packages: &["oxfmt"] }, + fallback: None, + version: ver("oxfmt"), + needs: NODE, + homepage: "https://oxc.rs/docs/guide/usage/formatter", + }, + ToolSpec { + name: "oxlint", + display: "oxlint", + kind: LanguageServer, + commands: &["oxlint"], + method: Method::Npm { packages: &["oxlint"] }, + fallback: None, + version: ver("oxlint"), + needs: NODE, + homepage: "https://oxc.rs/docs/guide/usage/linter", + }, ToolSpec { name: "rust-analyzer", display: "rust-analyzer", @@ -965,12 +987,8 @@ mod tests { known.insert((d.adapter.clone(), d.command.clone())); } } - for l in &langs { - if let Some(f) = &l.formatter { - known.insert((f.tool.clone(), f.tool.clone())); - } - } - for t in TOOLS.iter().filter(|t| t.kind != Plugin) { + // The host selects the formatter of each file (Prettier or oxfmt). + for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) { assert!( known.contains(&(t.name.to_string(), t.commands[0].to_string())), "{} / {} is not referenced by languages/*.toml", diff --git a/crates/ide-tools/tests/prettier.rs b/crates/ide-tools/tests/prettier.rs index 3d622caac..c09f6d59f 100644 --- a/crates/ide-tools/tests/prettier.rs +++ b/crates/ide-tools/tests/prettier.rs @@ -19,8 +19,8 @@ fn format( }; let mut cmd = tools.formatter_command("prettier", path, None)?; cmd.current_dir(path.parent().unwrap()); - cmd.args(formatter::arguments(&config, path, 4, true, range)); - formatter::run(cmd, text, &CancellationToken::new()) + cmd.args(formatter::arguments("prettier", &config, path, 4, true, range)); + formatter::run("prettier", cmd, text, &CancellationToken::new()) } #[test] diff --git a/languages/javascript.toml b/languages/javascript.toml index b5676537e..5f19df869 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -27,6 +27,14 @@ args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index 3c5a17760..be5b5ab90 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -26,6 +26,14 @@ args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/svelte.toml b/languages/svelte.toml index 41095ee15..b441d4107 100644 --- a/languages/svelte.toml +++ b/languages/svelte.toml @@ -13,3 +13,11 @@ command = "svelteserver" args = ["--stdio"] root-markers = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts", "package.json"] install-hint = "npm i -g svelte-language-server" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/languages/typescript.toml b/languages/typescript.toml index 41817c5c3..0dedc17ad 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -37,6 +37,14 @@ root-markers = ["angular.json", "node_modules/@angular/core"] require-root-marker = true install-hint = "npm i -g @angular/language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index ad238c6c9..d7ecf0b8c 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -26,6 +26,14 @@ args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] install-hint = "npm i -g typescript@6 typescript-language-server" +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" + [debugger] adapter = "js-debug" command = "js-debug-adapter" diff --git a/languages/vue.toml b/languages/vue.toml index e260bce72..247f09303 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -22,3 +22,11 @@ command = "vtsls" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] install-hint = "npm i -g @vtsls/language-server" + +[[language-servers]] +name = "oxlint" +command = "oxlint" +args = ["--lsp"] +root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"] +require-root-marker = true +install-hint = "npm i -D oxlint" diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 061e377c1..5d3031c3c 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -118,19 +118,22 @@ async function format( try { edits = await lspRequest(() => commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave })); } catch (e) { - if (e instanceof IpcError && e.data?.type === "tool" && e.data.tool === "prettier") { + if (e instanceof IpcError && e.data?.type === "tool" && (e.data.tool === "prettier" || e.data.tool === "oxfmt")) { + const tool = e.data.tool; if (e.kind === "notFound" && e.data.missing) { - await executeAction("tools.promptInstall", { name: "prettier", sticky: !opts.onSave }); + await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave }); } else if (e.kind !== "cancelled") { notifications().notify({ group: "tools", level: "error", - title: tx("Prettier formatting failed"), + title: tool === "oxfmt" ? tx("oxfmt formatting failed") : tx("Prettier formatting failed"), body: e.kind === "timedOut" - ? tx("Prettier formatting timed out") + ? tool === "oxfmt" + ? tx("oxfmt formatting timed out") + : tx("Prettier formatting timed out") : createElement("span", { className: styles.diagnostics }, e.message), - key: `prettier-format-error:${doc.path}`, + key: `${tool}-format-error:${doc.path}`, sticky: false, }); } diff --git a/src/editor/lsp/formatErrors.test.tsx b/src/editor/lsp/formatErrors.test.tsx index aa21c997f..3107316b9 100644 --- a/src/editor/lsp/formatErrors.test.tsx +++ b/src/editor/lsp/formatErrors.test.tsx @@ -18,7 +18,6 @@ import { import { formatDocument } from "./format"; import * as lsp from "./lsp"; -const tool = { type: "tool", tool: "prettier", missing: false } as const; const views: EditorView[] = []; const viewFor = (path = "/project/file.css") => { const view = new EditorView({ @@ -43,7 +42,9 @@ afterEach(() => { _resetI18nForTests(); }); -describe("external formatter errors", () => { +describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => { + const tool = { type: "tool", tool: name, missing: false } as const; + const display = name === "prettier" ? "Prettier" : name; it("uses structured missing-tool information to request the shared install prompt", async () => { const prompt = vi.fn(); const dispose = registerAction({ @@ -54,9 +55,9 @@ describe("external formatter errors", () => { try { failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } }); await formatDocument(viewFor()); - expect(prompt).toHaveBeenCalledWith({ name: "prettier", sticky: true }); + expect(prompt).toHaveBeenCalledWith({ name, sticky: true }); await formatDocument(viewFor(), { explicit: false, onSave: true }); - expect(prompt).toHaveBeenLastCalledWith({ name: "prettier", sticky: false }); + expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false }); expect(useNotifications.getState().items).toHaveLength(0); } finally { dispose(); @@ -95,7 +96,7 @@ describe("external formatter errors", () => { for (let i = 0; i < 3; i++) await formatDocument(view, { explicit: false, onSave: true }); const notes = useNotifications.getState().items; expect(notes).toHaveLength(1); - expect(notes[0]).toMatchObject({ sticky: false, title: "Prettier 格式化失败" }); + expect(notes[0]).toMatchObject({ sticky: false, title: `${display} 格式化失败` }); expect(balloonTimeout(notes[0]!)).toBe(10000); render(); expect(screen.getByRole("alert")).toHaveTextContent(stderr); @@ -115,7 +116,7 @@ describe("external formatter errors", () => { const request = failWith({ kind: "timedOut", message: "arbitrary backend text", data: tool }); const view = viewFor(); await formatDocument(view); - expect(useNotifications.getState().items[0]).toMatchObject({ body: "Prettier 格式化超时", sticky: false }); + expect(useNotifications.getState().items[0]).toMatchObject({ body: `${display} 格式化超时`, sticky: false }); clearNotifications(); request.mockRejectedValue(new IpcError({ kind: "cancelled", message: "cancelled", data: tool })); await formatDocument(view); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index fec979feb..811644d75 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1614,16 +1614,18 @@ "Install Missing Tool": "安装缺失的工具", "Prettier formatting failed": "Prettier 格式化失败", "Prettier formatting timed out": "Prettier 格式化超时", - "Prettier does not support selection formatting for this language; formatted the whole file": "Prettier 不支持此语言的选区格式化,已格式化整个文件", + "oxfmt formatting failed": "oxfmt 格式化失败", + "oxfmt formatting timed out": "oxfmt 格式化超时", + "{name} does not support selection formatting for this language; formatted the whole file": "{name} 不支持此语言的选区格式化,已格式化整个文件", "Formatting timed out": "格式化超时", - "Prettier: Node.js is required": "Prettier:需要 Node.js", + "{name}: Node.js is required": "{name}:需要 Node.js", "Formatter": "格式化工具", "JSON with Comments": "带注释的 JSON", "Unknown external formatter: {tool}": "未知的外部格式化工具:{tool}", - "Prettier: package has no CLI entry point": "Prettier:软件包未提供命令行入口", - "Prettier: CLI does not exist: {path}": "Prettier:命令行程序不存在:{path}", - "Prettier: file has no directory": "Prettier:文件路径不含目录", - "Prettier: {error}": "Prettier:{error}", + "{name}: package has no CLI entry point": "{name}:软件包未提供命令行入口", + "{name}: CLI does not exist: {path}": "{name}:命令行程序不存在:{path}", + "{name}: file has no directory": "{name}:文件路径不含目录", + "{name}: {error}": "{name}:{error}", "Reformat File": "重新格式化文件", "Reformat code": "重新格式化代码", "Reformat code on save": "保存时重新格式化代码", From 743612d4ec3adc6a16ac660f180e8c5643a276d3 Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 2 Nov 2026 12:05:25 +0800 Subject: [PATCH 04/22] fix(java): let jdtls read jars JDK 25 rejects (#771) jdtls runs on JDK 25, whose ZipFile validates the ZIP64 extra field and rejects jars written by older tools (aspectjweaver 1.8.10: "Invalid CEN header (invalid zip64 extra data field size)"). ECJ then reports the dependency as unreadable, the module gets a build path error and never compiles, and running its main class fails with a missing class. Pass -Djdk.util.zip.disableZip64ExtraFieldValidation=true to jdtls unless the user set the property; set_jvm_property now owns the set-once, user-value-wins rule shared with the proxy properties. Co-authored-by: misswell <28730815+misswell@users.noreply.github.com> --- crates/ide-sdk/src/java.rs | 59 ++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 21b217dc0..814ccf7db 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -313,21 +313,36 @@ fn text_key(text: &str) -> String { format!("{h:016x}") } +/// Adds `arg` as its own `--jvm-arg`, unless the user already set the property it +/// carries: a value of theirs wins, and a second call adds nothing. +fn set_jvm_property(args: &mut Vec, arg: &str) { + let key = arg.split('=').next().unwrap_or(arg); + let taken = args.iter().enumerate().any(|(i, a)| { + let value = + a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str())); + value.is_some_and(|a| a.split('=').next() == Some(key)) + }); + if !taken { + args.push(format!("--jvm-arg={arg}")); + } +} + fn add_jdtls_proxy_args(args: &mut Vec, proxy: &ide_core::proxy_settings::ProxySettings) { for arg in proxy.jvm_args() { - let key = arg.split('=').next().unwrap_or(arg.as_str()); - if !args.iter().enumerate().any(|(i, a)| { - let value = - a.strip_prefix("--jvm-arg=").or_else(|| (i > 0 && args[i - 1] == "--jvm-arg").then_some(a.as_str())); - value.is_some_and(|a| a.split('=').next() == Some(key)) - }) { - args.push(format!("--jvm-arg={arg}")); - } + set_jvm_property(args, &arg); } } const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot"; +/// JDK 25's `ZipFile` validates the ZIP64 extra field, and jars written by older tools +/// carry a size that check rejects: ECJ reports the dependency as "cannot be read or is +/// not a valid ZIP file", the project using it gets a build path error, and every project +/// depending on that one stops compiling — aspectjweaver 1.8.10 stalls a whole Maven +/// build that way, while the same jar loads fine on IDEA's JBR 21. This is how 25 is told +/// to behave the same. +const ZIP64_LENIENCY: &str = "-Djdk.util.zip.disableZip64ExtraFieldValidation=true"; + /// jdtls: its Eclipse workspace (`-data`) lives under the IDE's data /// directory, one per server root, and no Eclipse metadata is written into /// the project; it runs on a JDK `min_java`+ (via `JAVA_HOME`, which its @@ -384,6 +399,10 @@ pub fn configure_jdtls( // of the `HTTP_PROXY` / `HTTPS_PROXY` it inherits: pass the same proxy as system // properties. One the user set in their own JVM arguments wins. add_jdtls_proxy_args(&mut cfg.args, &ide_core::proxy_settings::proxy()); + // ECJ reads the project's jars with the server JVM's own zip reader, so a jar JDK 25 + // refuses to open takes the whole Maven build with it. IDEA never notices: its JBR 21 + // accepts the same file. + set_jvm_property(&mut cfg.args, ZIP64_LENIENCY); let managed = language_server_runtimes_dir(data_dir); let managed = dunce::canonicalize(&managed).unwrap_or(managed); let project_jdks: Vec<_> = project_jdk @@ -664,6 +683,11 @@ mod tests { assert_eq!(cfg.args[n], "-data"); assert!(cfg.args[n + 1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[n + 1]); assert_eq!(cfg.args[n + 2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false"); + assert!( + cfg.args.iter().any(|a| a == "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"), + "jars JDK 25 rejects must still load: {:?}", + cfg.args + ); let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap(); let rt = &opts["settings"]["java"]["configuration"]["runtimes"]; assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": "/jdk8" })); @@ -751,6 +775,25 @@ mod tests { } } + #[test] + fn a_jar_jdk_25_rejects_loads_and_the_users_value_wins() { + // aspectjweaver 1.8.10 carries a ZIP64 extra field size 25 refuses to read; on the + // JVM that runs jdtls that is a build path error in every project using it. + let mut args = Vec::new(); + set_jvm_property(&mut args, ZIP64_LENIENCY); + assert_eq!(args, ["--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=true"]); + set_jvm_property(&mut args, ZIP64_LENIENCY); + assert_eq!(args.len(), 1, "added once"); + for user in [ + "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation=false".to_owned(), + "--jvm-arg=-Djdk.util.zip.disableZip64ExtraFieldValidation".to_owned(), + ] { + let mut own = vec![user.clone()]; + set_jvm_property(&mut own, ZIP64_LENIENCY); + assert_eq!(own, [user], "the user decides"); + } + } + #[test] fn jdtls_lombok_agent() { let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; From 8e8a998abe37758a073923424239df7b49e89815 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:11:12 +0800 Subject: [PATCH 05/22] feat(vcs): inline blame for the current line (#778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the current line's author, relative date and commit summary as a dimmed hint after the line end, with the full message, date and hash on hover and the commit opened on click. Uncommitted lines read "You, now · Uncommitted changes". Reuses the blame command with a per-version cache and a debounced caret; skipped outside repositories, for untracked/ignored and very large files. Off by default: Settings › Version Control › Git › Show inline blame for the current line. Fixes #742. --- crates/ide-core/src/workflow_settings.rs | 3 + src/bindings.ts | 2 + src/editor/api.ts | 3 + src/editor/core/cm/setup.ts | 5 +- src/features/settings/pages/vcs.tsx | 1 + .../vcs/__tests__/inlineBlame.test.ts | 255 ++++++++++++++++ src/features/vcs/index.ts | 3 +- src/features/vcs/inlineBlame.ts | 281 ++++++++++++++++++ src/platform/clientSettings.ts | 1 + src/platform/locales/text/zh.json | 5 +- src/platform/workflowSettings.test.ts | 6 + src/platform/workflowSettings.ts | 3 + 12 files changed, 563 insertions(+), 5 deletions(-) create mode 100644 src/features/vcs/__tests__/inlineBlame.test.ts create mode 100644 src/features/vcs/inlineBlame.ts diff --git a/crates/ide-core/src/workflow_settings.rs b/crates/ide-core/src/workflow_settings.rs index 64fb58fbe..3b31a576f 100644 --- a/crates/ide-core/src/workflow_settings.rs +++ b/crates/ide-core/src/workflow_settings.rs @@ -41,6 +41,8 @@ impl Default for VcsSettings { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct GitSettings { + /// Show blame after the current editor line. + pub inline_blame: bool, /// Path to the git executable; empty = auto-detect. pub executable: String, /// Default of Update Project: `merge` | `rebase`. @@ -71,6 +73,7 @@ pub struct GitSettings { impl Default for GitSettings { fn default() -> Self { Self { + inline_blame: false, executable: String::new(), update_method: "merge".into(), auto_update_on_push_rejected: false, diff --git a/src/bindings.ts b/src/bindings.ts index e2eb8a2ae..7c4a2d955 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3350,6 +3350,8 @@ export type GitOperation = { op: "interactiveRebase"; base: string | null; entries: RebaseEntry[] }; export type GitSettings = { + /** Show blame after the current editor line. */ + inlineBlame?: boolean, /** Path to the git executable; empty = auto-detect. */ executable?: string, /** Default of Update Project: `merge` | `rebase`. */ diff --git a/src/editor/api.ts b/src/editor/api.ts index 9f0251cbf..9d1e81e01 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -32,6 +32,9 @@ import type { LanguageDescription } from "@codemirror/language"; import { create } from "zustand"; import { maybeEditorService } from "@/workbench/contributions"; +/** Documents above this size (UTF-16 units) skip per-line decorations. */ +export const HEAVY_LIMIT = 8_000_000; + export interface EditorDocContext { doc: number; path: string; diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index bcecc965e..059b6c139 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -28,7 +28,7 @@ import type { EditorConfigProps } from "@/bindings"; import type { EditorSettings } from "@/platform/ipc"; import { editorPrefs, lineCommentStyleFor, type EditorExtras } from "@/platform/editorSettings"; import { BUNDLED_MONO_FAMILY, monoFontFamily } from "@/platform/fonts"; -import { editorContributions, type EditorDocContext } from "@/editor/api"; +import { editorContributions, HEAVY_LIMIT, type EditorDocContext } from "@/editor/api"; import { distractionFreeWidth, isDistractionFree } from "@/workbench/distractionFree"; import { openideaHighlightStyle } from "./highlight"; import { openideaTheme } from "./theme"; @@ -78,8 +78,7 @@ export function prefsOf(settings: EditorSettings | undefined): EditorExtras { return editorPrefs({ editor: settings }); } -/** Documents above this size (UTF-16 units) skip per-line decorations. */ -export const HEAVY_LIMIT = 8_000_000; +export { HEAVY_LIMIT } from "@/editor/api"; export interface EffectiveIndent { tabSize: number; diff --git a/src/features/settings/pages/vcs.tsx b/src/features/settings/pages/vcs.tsx index 225095a0e..db0875711 100644 --- a/src/features/settings/pages/vcs.tsx +++ b/src/features/settings/pages/vcs.tsx @@ -492,6 +492,7 @@ export const VCS_PAGES: PageSpec[] = [ keywords: ["Test", "git version"], component: GitExecutableRow, }, + { kind: "toggle", key: "vcs.git.inlineBlame", label: "Show inline blame for the current line" }, ], }, { diff --git a/src/features/vcs/__tests__/inlineBlame.test.ts b/src/features/vcs/__tests__/inlineBlame.test.ts new file mode 100644 index 000000000..e49cb3de8 --- /dev/null +++ b/src/features/vcs/__tests__/inlineBlame.test.ts @@ -0,0 +1,255 @@ +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView, activateHover, closeHoverTooltips, hasHoverTooltips } from "@codemirror/view"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BlameLine, Settings } from "@/bindings"; +import { HEAVY_LIMIT, _emitEditorEvent } from "@/editor/api"; +import { useProject } from "@/platform/project"; +import { useI18n } from "@/platform/i18n"; +import { formatInlineBlame, inlineBlame, relativeBlameDate } from "../inlineBlame"; +import { useVcs } from "../store"; + +const mocks = vi.hoisted(() => ({ blame: vi.fn(), file: vi.fn(), log: vi.fn(), action: vi.fn() })); +vi.mock("@/platform/actions", async (original) => ({ + ...(await original()), + executeAction: mocks.action, +})); +vi.mock("@/platform/ipc", async (original) => { + const actual = await original(); + return { + ...actual, + isTauri: () => true, + commands: { ...actual.commands, vcsBlame: mocks.blame, vcsFileAt: mocks.file, vcsLog: mocks.log }, + }; +}); + +const now = Date.UTC(2026, 10, 2, 12); +const alice: BlameLine = { + commit: "a".repeat(40), + author: "Alice Smith", + timeMs: now - 86400_000, + summary: "First line", +}; +const bob: BlameLine = { commit: "b".repeat(40), author: "Bob Jones", timeMs: now - 3600_000, summary: "Second line" }; +const uncommitted: BlameLine = { commit: "0".repeat(40), author: "Not Committed Yet", timeMs: now, summary: "" }; +const ctx = { doc: 1, path: "/repo/file.ts", language: "typescript", uiMode: null, readOnly: false }; +let view: EditorView | undefined; + +function settings(enabled: boolean) { + useProject.setState({ settings: { vcs: { git: { inlineBlame: enabled } } } as Settings }); +} + +function editor(doc = "one\ntwo\nthree", context = ctx) { + view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc, extensions: [inlineBlame(context)] }), + }); + return view; +} + +const hint = () => view?.dom.querySelector(".cm-vcs-inline-blame"); +const tick = () => vi.advanceTimersByTimeAsync(300); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + vi.clearAllMocks(); + useI18n.setState({ locale: "en", texts: {} }); + settings(true); + useVcs.setState({ available: true, roots: ["/repo"], statusLoaded: true, byPath: new Map(), version: 0 }); + mocks.file.mockResolvedValue({ status: "ok", data: "one\ntwo\nthree" }); + mocks.blame.mockResolvedValue({ status: "ok", data: [alice, bob, uncommitted] }); +}); + +afterEach(() => { + view?.destroy(); + view = undefined; + useProject.setState({ settings: null }); + vi.useRealTimers(); +}); + +describe("inline blame formatting", () => { + it("formats authors, relative dates, summaries and missing dates", () => { + expect(formatInlineBlame(alice, now)).toBe("Alice Smith, 1 day ago · First line"); + expect(formatInlineBlame({ ...bob, timeMs: null, summary: "Long\nmessage" }, now)).toBe("Bob Jones · Long message"); + expect(formatInlineBlame(uncommitted, now)).toBe("You, now · Uncommitted changes"); + expect(relativeBlameDate(now + 1000, now)).toBe("now"); + expect(relativeBlameDate(now - 120_000, now)).toBe("2 minutes ago"); + expect(relativeBlameDate(now - 400 * 86400_000, now)).toBe("1 year ago"); + }); +}); + +describe("current line decoration", () => { + it("debounces caret moves and reuses blame for the same document version", async () => { + const v = editor(); + await vi.advanceTimersByTimeAsync(299); + expect(mocks.blame).not.toHaveBeenCalled(); + v.dispatch({ selection: { anchor: 4 } }); + await vi.advanceTimersByTimeAsync(299); + expect(mocks.blame).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(hint()?.textContent).toContain("Bob Jones"); + v.dispatch({ selection: { anchor: 0 } }); + expect(hint()).toBeNull(); + await tick(); + expect(hint()?.textContent).toContain("Alice Smith"); + expect(mocks.blame).toHaveBeenCalledTimes(1); + expect(mocks.file).toHaveBeenCalledTimes(1); + expect(hint()?.contentEditable).toBe("false"); + hint()?.click(); + expect(mocks.action).toHaveBeenCalledWith("vcs.showCommit", alice.commit); + }); + + it("uses only the primary selection and hides multiline selections", async () => { + const v = editor(); + v.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(4)], 1) }); + await tick(); + expect(hint()?.textContent).toContain("Bob Jones"); + v.dispatch({ selection: { anchor: 0, head: 5 } }); + await tick(); + expect(hint()).toBeNull(); + v.dispatch({ selection: { anchor: 4, head: 6 } }); + await tick(); + expect(hint()?.textContent).toContain("Bob Jones"); + }); + + it("loads and caches the full commit message on hover and closes it on caret movement", async () => { + const message = "First line\n\nThe complete commit body."; + mocks.log.mockResolvedValue({ status: "ok", data: [{ message }] }); + const v = editor(); + await tick(); + activateHover(v, 3, 1); + await vi.advanceTimersByTimeAsync(0); + expect(hasHoverTooltips(v.state)).toBe(true); + const tooltip = v.dom.querySelector(".cm-vcs-inline-blame-tooltip"); + expect(tooltip?.textContent).toContain(message); + expect(tooltip?.textContent).toContain(alice.author); + expect(tooltip?.textContent).toContain(alice.commit); + expect(tooltip?.textContent).toContain(new Date(alice.timeMs!).toLocaleString()); + v.dispatch({ effects: closeHoverTooltips }); + activateHover(v, 3, 1); + await vi.advanceTimersByTimeAsync(0); + expect(mocks.log).toHaveBeenCalledTimes(1); + v.dispatch({ selection: { anchor: 4 } }); + expect(hasHoverTooltips(v.state)).toBe(false); + }); + + it("closes an open tooltip when the setting is disabled", async () => { + mocks.log.mockResolvedValue({ status: "ok", data: [{ message: "Full message" }] }); + const v = editor(); + await tick(); + activateHover(v, 3, 1); + await vi.advanceTimersByTimeAsync(0); + expect(hasHoverTooltips(v.state)).toBe(true); + settings(false); + expect(hasHoverTooltips(v.state)).toBe(false); + }); + + it("refetches edits, saves and VCS refreshes", async () => { + const v = editor(); + await tick(); + mocks.blame.mockResolvedValue({ status: "ok", data: [uncommitted, bob, uncommitted] }); + v.dispatch({ changes: { from: 0, insert: "x" } }); + expect(hint()).toBeNull(); + await tick(); + expect(mocks.blame).toHaveBeenLastCalledWith(ctx.path, "xone\ntwo\nthree", null); + expect(hint()?.textContent).toBe("You, now · Uncommitted changes"); + _emitEditorEvent({ type: "docSaved", doc: 1, path: ctx.path }); + await tick(); + useVcs.setState({ version: 1 }); + await tick(); + expect(mocks.blame).toHaveBeenCalledTimes(4); + }); + + it("discards a response from an older document version", async () => { + let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void; + mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done))); + const v = editor(); + await tick(); + v.dispatch({ changes: { from: 0, insert: "x" } }); + resolve({ status: "ok", data: [alice] }); + await vi.advanceTimersByTimeAsync(0); + expect(hint()).toBeNull(); + mocks.blame.mockResolvedValue({ status: "ok", data: [uncommitted] }); + await tick(); + expect(hint()?.textContent).toBe("You, now · Uncommitted changes"); + }); + + it("coalesces an in-flight blame when the caret moves", async () => { + let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void; + mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done))); + const v = editor(); + await tick(); + v.dispatch({ selection: { anchor: 4 } }); + await tick(); + resolve({ status: "ok", data: [alice, bob] }); + await vi.advanceTimersByTimeAsync(0); + expect(hint()?.textContent).toContain("Bob Jones"); + expect(mocks.blame).toHaveBeenCalledTimes(1); + }); + + it("responds to the setting immediately", async () => { + settings(false); + editor(); + await tick(); + expect(mocks.blame).not.toHaveBeenCalled(); + settings(true); + await tick(); + expect(hint()).not.toBeNull(); + settings(false); + expect(hint()).toBeNull(); + await tick(); + expect(mocks.blame).toHaveBeenCalledTimes(1); + }); + + it("ignores a pending response after disabling", async () => { + let resolve!: (result: { status: "ok"; data: BlameLine[] }) => void; + mocks.blame.mockReturnValueOnce(new Promise((done) => (resolve = done))); + editor(); + await tick(); + settings(false); + resolve({ status: "ok", data: [alice] }); + await vi.advanceTimersByTimeAsync(0); + expect(hint()).toBeNull(); + await tick(); + expect(hint()).toBeNull(); + }); + + it.each(["untracked", "ignored"] as const)("skips %s paths", async (unstaged) => { + useVcs.setState({ + byPath: new Map([[ctx.path, { path: ctx.path, relPath: "file.ts", unstaged, staged: null, renamedFrom: null }]]), + }); + editor(); + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + expect(hint()).toBeNull(); + }); + + it("skips ignored paths missing from status and HEAD", async () => { + mocks.file.mockResolvedValue({ status: "ok", data: null }); + editor(); + await tick(); + expect(mocks.blame).not.toHaveBeenCalled(); + expect(hint()).toBeNull(); + }); + + it("skips paths outside repositories", async () => { + editor("one", { ...ctx, path: "/elsewhere/file.ts" }); + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + }); + + it("skips large, virtual and read-only documents", async () => { + editor("x".repeat(HEAVY_LIMIT + 1)); + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + expect(inlineBlame({ ...ctx, virtual: true })).toEqual([]); + expect(inlineBlame({ ...ctx, readOnly: true })).toEqual([]); + }); + + it("cancels pending work when the view is destroyed", async () => { + editor().destroy(); + view = undefined; + await tick(); + expect(mocks.file).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index 801985175..9e74b30c3 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -23,6 +23,7 @@ import { registerHistoryActions, trackOperationContext } from "./historyActions" import { registerLogPaneActions } from "./logPaneActions"; import { controlLabel, controlOperation, inProgress } from "./operations"; import { annotationsShown, blameSlot } from "./blame"; +import { inlineBlame } from "./inlineBlame"; import { BranchWidget, useBranchesPopup } from "./BranchWidget"; import { showConflictsDialog } from "./conflictsUi"; import { registerCompareActions } from "./compare"; @@ -212,7 +213,7 @@ export function activate(): () => void { registerEditorContribution({ id: "vcs.changeMarkers", order: 60, - extensions: (ctx) => [changeMarkers(ctx), blameSlot()], + extensions: (ctx) => [changeMarkers(ctx), blameSlot(), inlineBlame(ctx)], }), registerMenuGroup({ id: "vcs.gutter", diff --git a/src/features/vcs/inlineBlame.ts b/src/features/vcs/inlineBlame.ts new file mode 100644 index 000000000..047b3136c --- /dev/null +++ b/src/features/vcs/inlineBlame.ts @@ -0,0 +1,281 @@ +import { StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; +import { + Decoration, + EditorView, + ViewPlugin, + WidgetType, + hoverTooltip, + type DecorationSet, + type ViewUpdate, +} from "@codemirror/view"; +import type { BlameLine } from "@/bindings"; +import { HEAVY_LIMIT, onEditorEvent, type EditorDocContext } from "@/editor/api"; +import { executeAction } from "@/platform/actions"; +import { tx, useI18n } from "@/platform/i18n"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings"; +import { isUncommitted } from "./blame"; +import { repoOf, statusOf, useVcs } from "./store"; + +export function relativeBlameDate(timeMs: number | null, now = Date.now()): string { + if (timeMs === null) return ""; + const seconds = Math.max(0, (now - timeMs) / 1000); + const units = [ + ["year", 365 * 86400], + ["month", 30 * 86400], + ["day", 86400], + ["hour", 3600], + ["minute", 60], + ] as const; + const unit = units.find(([, size]) => seconds >= size); + return unit + ? new Intl.RelativeTimeFormat(useI18n.getState().locale, { numeric: "always" }).format( + -Math.floor(seconds / unit[1]), + unit[0], + ) + : tx("now"); +} + +export function formatInlineBlame(line: BlameLine, now = Date.now()): string { + if (isUncommitted(line)) return tx("You, now · Uncommitted changes"); + const date = relativeBlameDate(line.timeMs, now); + return `${line.author}${date ? `, ${date}` : ""} · ${line.summary.replace(/\s+/g, " ")}`; +} + +/** Only the current document version is retained, including its in-flight request. */ +class BlameCache { + private doc: Text | undefined; + private pending: Promise | undefined; + + clear() { + this.doc = undefined; + this.pending = undefined; + } + + get(doc: Text, load: () => Promise): Promise { + if (doc !== this.doc || !this.pending) { + this.doc = doc; + this.pending = load(); + } + return this.pending; + } +} + +class BlameWidget extends WidgetType { + constructor(readonly line: BlameLine) { + super(); + } + + eq(other: BlameWidget) { + return formatInlineBlame(other.line) === formatInlineBlame(this.line) && other.line.commit === this.line.commit; + } + + toDOM() { + const el = document.createElement("span"); + el.className = "cm-vcs-inline-blame"; + el.textContent = formatInlineBlame(this.line); + el.contentEditable = "false"; + if (!isUncommitted(this.line)) { + el.setAttribute("role", "button"); + el.tabIndex = 0; + const open = () => void executeAction("vcs.showCommit", this.line.commit); + el.addEventListener("mousedown", (e) => e.preventDefault()); + el.addEventListener("click", open); + el.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + open(); + } + }); + } + return el; + } +} + +const setInlineBlame = StateEffect.define(); +const inlineBlameField = StateField.define({ + create: () => Decoration.none, + update(value, tr) { + if (tr.docChanged || tr.selection) return Decoration.none; + for (const effect of tr.effects) if (effect.is(setInlineBlame)) return effect.value; + return value; + }, + provide: (field) => EditorView.decorations.from(field), +}); + +export function inlineBlame(ctx: EditorDocContext): Extension { + if (ctx.virtual || ctx.readOnly) return []; + const plugin = ViewPlugin.fromClass( + class { + private timer: ReturnType | undefined; + private readonly cache = new BlameCache(); + private readonly messages = new Map>(); + private readonly off: (() => void)[]; + private generation = 0; + private destroyed = false; + line: BlameLine | undefined; + + constructor(readonly view: EditorView) { + this.off = [ + useVcs.subscribe((s, prev) => { + if (s.version !== prev.version) this.invalidate(); + }), + onEditorEvent((e) => { + if (e.type === "docSaved" && e.path === ctx.path) this.invalidate(); + }), + onWorkflowSettingsChange( + (s) => s.vcs.git.inlineBlame, + () => this.invalidate(), + ), + useI18n.subscribe(() => this.invalidate()), + ]; + this.schedule(); + } + + private enabled() { + const vcs = useVcs.getState(); + const status = statusOf(ctx.path); + return ( + workflowSettings().vcs.git.inlineBlame && + isTauri() && + vcs.available && + vcs.statusLoaded && + !!repoOf(ctx.path) && + status?.unstaged !== "untracked" && + status?.unstaged !== "ignored" && + this.view.state.doc.length <= HEAVY_LIMIT + ); + } + + private clear() { + this.line = undefined; + if (this.view.state.field(inlineBlameField).size) + this.view.dispatch({ effects: setInlineBlame.of(Decoration.none) }); + } + + private invalidate() { + this.cache.clear(); + this.clear(); + this.schedule(); + } + + update(update: ViewUpdate) { + if (update.docChanged) this.cache.clear(); + if (update.docChanged || update.selectionSet) { + this.line = undefined; + this.schedule(); + } + } + + private schedule() { + clearTimeout(this.timer); + ++this.generation; + this.timer = setTimeout(() => void this.refresh(), 300); + } + + private async refresh() { + if (this.destroyed || !this.enabled()) return; + const { doc, selection } = this.view.state; + const caret = doc.lineAt(selection.main.head); + if (doc.lineAt(selection.main.from).number !== doc.lineAt(selection.main.to).number) return; + const generation = this.generation; + try { + const lines = await this.cache.get(doc, async () => { + // Blame also returns synthetic entries for paths absent from HEAD (ignored / new files). + if ((await call(commands.vcsFileAt(ctx.path, "HEAD"))) === null) return []; + return call(commands.vcsBlame(ctx.path, doc.toString(), null)); + }); + if (this.destroyed || generation !== this.generation || doc !== this.view.state.doc || !this.enabled()) + return; + this.line = lines[caret.number - 1]; + if (this.line) { + const widget = Decoration.widget({ widget: new BlameWidget(this.line), side: 1 }).range(caret.to); + this.view.dispatch({ effects: setInlineBlame.of(Decoration.set([widget])) }); + } + } catch { + // Repository removed, unborn HEAD, inaccessible file, etc.: leave the line undecorated. + } + } + + message(line: BlameLine): Promise { + if (isUncommitted(line)) return Promise.resolve(tx("You, now · Uncommitted changes")); + let message = this.messages.get(line.commit); + if (!message) { + message = call( + commands.vcsLog(repoOf(ctx.path), { + branch: line.commit, + path: null, + author: null, + text: null, + skip: 0, + limit: 1, + }), + ).then( + (commits) => commits[0]?.message ?? line.summary, + () => line.summary, + ); + if (this.messages.size >= 100) this.messages.delete(this.messages.keys().next().value!); + this.messages.set(line.commit, message); + } + return message; + } + + destroy() { + this.destroyed = true; + clearTimeout(this.timer); + this.cache.clear(); + this.off.forEach((off) => off()); + } + }, + ); + return [ + inlineBlameField, + plugin, + hoverTooltip( + async (view, pos, side) => { + const instance = view.plugin(plugin); + const line = instance?.line; + const end = view.state.doc.lineAt(view.state.selection.main.head).to; + if (!instance || !line || pos !== end || side < 0) return null; + const message = await instance.message(line); + if (view.plugin(plugin)?.line !== line) return null; + return { + pos: end, + above: true, + create() { + const dom = document.createElement("div"); + dom.className = "cm-vcs-inline-blame-tooltip"; + dom.textContent = isUncommitted(line) + ? message + : `${line.author}\n${line.timeMs === null ? "" : new Date(line.timeMs).toLocaleString()}\n${line.commit}\n\n${message}`; + return { dom }; + }, + }; + }, + { + hideOnChange: true, + hideOn: (tr) => tr.effects.some((effect) => effect.is(setInlineBlame)), + }, + ), + EditorView.baseTheme({ + ".cm-vcs-inline-blame": { + color: "var(--fg-inlay)", + marginLeft: "3ch", + display: "inline-block", + maxWidth: "min(60ch, 50vw)", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + verticalAlign: "bottom", + userSelect: "none", + }, + '.cm-vcs-inline-blame[role="button"]': { cursor: "pointer" }, + ".cm-vcs-inline-blame-tooltip": { + padding: "8px 12px", + maxWidth: "min(70ch, 80vw)", + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + }, + }), + ]; +} diff --git a/src/platform/clientSettings.ts b/src/platform/clientSettings.ts index b2c2c83b0..742e797d0 100644 --- a/src/platform/clientSettings.ts +++ b/src/platform/clientSettings.ts @@ -77,6 +77,7 @@ export const CLIENT_SETTINGS_KEYS = [ "run.confirmTerminate", "run.showOnStart", "vcs.showDirtyDirectories", + "vcs.git.inlineBlame", "vcs.git.showPushDialog", "vcs.git.pushDialogOnlyProtected", "vcs.git.combineStashAndShelf", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 811644d75..844d50811 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5497,5 +5497,8 @@ "Cannot paste image": "无法粘贴图片", "Remote server does not support image paste.": "远程服务器不支持粘贴图片。", "Clipboard image reading is unavailable.": "无法读取剪贴板图片。", - "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。" + "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。", + "Show inline blame for the current line": "为当前行显示内联 Git Blame", + "You, now · Uncommitted changes": "你,刚刚 · 未提交的更改", + "now": "刚刚" } diff --git a/src/platform/workflowSettings.test.ts b/src/platform/workflowSettings.test.ts index 1721e2ca7..d12da7d58 100644 --- a/src/platform/workflowSettings.test.ts +++ b/src/platform/workflowSettings.test.ts @@ -3,6 +3,12 @@ import { splitCommandLine } from "./commandLine"; import { DEFAULT_WORKFLOW, validateWorkflow } from "./workflowSettings"; describe("validateWorkflow", () => { + it("defaults inline blame off and accepts only boolean values", () => { + expect(DEFAULT_WORKFLOW.vcs.git.inlineBlame).toBe(false); + expect(validateWorkflow({ vcs: { git: { inlineBlame: true } } }).vcs.git.inlineBlame).toBe(true); + expect(validateWorkflow({ vcs: { git: { inlineBlame: "true" } } }).vcs.git.inlineBlame).toBe(false); + }); + it("falls back to defaults for missing or malformed values", () => { expect(validateWorkflow(undefined)).toEqual(DEFAULT_WORKFLOW); const v = validateWorkflow({ diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index 428714803..559d20d9b 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -18,6 +18,7 @@ export interface IssueLinkValid { } export interface GitSettingsValid { + inlineBlame: boolean; executable: string; updateMethod: UpdateMethod; autoUpdateOnPushRejected: boolean; @@ -183,6 +184,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = { limitHistory: true, historyLimit: 1000, git: { + inlineBlame: false, executable: "", updateMethod: "merge", autoUpdateOnPushRejected: false, @@ -309,6 +311,7 @@ export function validateVcs(raw: unknown): VcsSettingsValid { limitHistory: bool(v.limitHistory, d.limitHistory), historyLimit: num(v.historyLimit, d.historyLimit, L.historyLimit, true), git: { + inlineBlame: bool(g.inlineBlame, d.git.inlineBlame), executable: text(g.executable, d.git.executable).trim(), updateMethod: oneOf(g.updateMethod, ["merge", "rebase"] as const, d.git.updateMethod), autoUpdateOnPushRejected: bool(g.autoUpdateOnPushRejected, d.git.autoUpdateOnPushRejected), From 6184330876df74b9c3eade84fedbedaa11f847ec Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 14:33:11 +0800 Subject: [PATCH 06/22] test(tools): compare the oxfmt script path by components on every platform (#786) package_bin joins the manifest's bin entry (bin/oxfmt) onto a platform path, so on Windows the separators differ from the expected path and the oxfmt formatter test failed. Compare path components instead. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-tools/src/formatter.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs index 95dd011b3..b6a7a692b 100644 --- a/crates/ide-tools/src/formatter.rs +++ b/crates/ide-tools/src/formatter.rs @@ -209,7 +209,12 @@ mod tests { let program = Path::new(command.get_program()).file_stem().unwrap().to_string_lossy().into_owned(); assert_eq!(program, "node"); let script: Vec<_> = command.get_args().collect(); - assert_eq!(script, [root.join("node_modules/oxfmt/bin/oxfmt").as_os_str()]); + // Compare components: `package_bin` joins the manifest's `bin` entry + // (`bin/oxfmt`) onto a platform path, so separators differ on Windows. + assert_eq!( + Path::new(&script[0]).components().collect::>(), + root.join("node_modules/oxfmt/bin/oxfmt").components().collect::>() + ); let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true }; let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9))); From 95188d9f40598172c698e2b19a136ec1fb9462d6 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Mon, 2 Nov 2026 14:33:14 +0800 Subject: [PATCH 07/22] fix(vcs): fall back to plain blame when git rejects --contents with a revision (#787) Git before 2.41 refuses `blame --contents - ` ("cannot use --contents with final commit object name"), so blame failed outright on e.g. Ubuntu 22.04's git 2.34. On that error, blame the revision plainly and mark the edited lines as uncommitted from the diff against it, the same path restricted mode already uses. Fixes #755. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-vcs/src/write.rs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index e31701bea..4e4f92832 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -89,15 +89,28 @@ pub(crate) fn blame( return Ok(uncommitted_lines(line_count)); } // Restricted blame reads committed blobs only. Feeding working-tree bytes to - // Git's --contents path would run configured clean filters. - let out = if crate::execution_allowed() { - run( - r.root(), - &["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel], - Some(&contents), - )? + // Git's --contents path would run configured clean filters, so it blames the + // revision plain. Git learned to combine `--contents` with a revision in 2.41; + // older releases reject the pair (`fatal: cannot use --contents with final + // commit object name`) and take the same plain path — the edited lines are + // marked uncommitted below either way (issue #755). + let contents_args = ["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel]; + let plain_args: &[&str] = if crate::execution_allowed() { + &["--literal-pathspecs", "blame", "--porcelain", revision, "--", &rel] } else { - run(r.root(), &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel], None)? + &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel] + }; + let (out, contents_blame) = if crate::execution_allowed() { + let out = cli::run_raw(r.root(), &contents_args, Some(&contents))?; + if out.code == Some(0) { + (out, true) + } else if String::from_utf8_lossy(&out.stderr).contains("cannot use --contents") { + (run(r.root(), plain_args, None)?, false) + } else { + return Err(cli::failure(&contents_args, &out.stderr, &out.stdout, out.code)); + } + } else { + (run(r.root(), plain_args, None)?, false) }; let mut lines = crate::blame::parse_porcelain(&out.stdout_str()); for line in &mut lines { @@ -106,7 +119,7 @@ pub(crate) fn blame( line.summary = String::new(); } } - if !crate::execution_allowed() { + if !contents_blame { let base = r.file_at(path, Some(revision), encoding)?.unwrap_or_default(); for hunk in crate::diff::line_hunks(&base, &text).into_iter().rev() { let start = (hunk.old_start as usize).min(lines.len()); From 193e6a4840c928f6d90b3ba4ddba6afca7476c6a Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:57:07 +0700 Subject: [PATCH 08/22] feat(run): colour the console of a Spring Boot run (#781) Spring Boot configurations get -Dspring.output.ansi.enabled=always: the Run console renders ANSI colours, but Spring only enables them for a terminal and the process runs on pipes. A configuration that sets the property keeps its value. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src/features/run/java.test.ts | 15 +++++++++++++++ src/platform/javaLaunch.ts | 13 ++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 3aae4705c..586f3f8cb 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -252,6 +252,21 @@ describe("java command lines", () => { ).toEqual(["--module-path", "/m/x.jar"]); }); + it("colours a Spring Boot console unless the configuration sets the property", () => { + const boot = (args: string[]) => ({ + ...applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"), + kind: "spring-boot" as const, + args, + }); + expect(resolveJavaCommand(boot(["-Xmx1g", "a.Main"]), info).args[0]).toBe("-Dspring.output.ansi.enabled=always"); + expect(resolveJavaCommand(boot(["-Dspring.output.ansi.enabled=never", "a.Main"]), info).args).toEqual([ + "-Dspring.output.ansi.enabled=never", + "a.Main", + ]); + const plain = applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java"); + expect(resolveJavaCommand(plain, info).args).not.toContain("-Dspring.output.ansi.enabled=always"); + }); + it("joins arguments for java-debug and reads JDWP's port", () => { expect(joinJavaArgs(["a", "b c", 'q"'])).toBe('a "b c" "q\\""'); expect(jdwpListeningPort("Listening for transport dt_socket at address: 50123")).toBe(50123); diff --git a/src/platform/javaLaunch.ts b/src/platform/javaLaunch.ts index 91d9cf6d5..75c57eefb 100644 --- a/src/platform/javaLaunch.ts +++ b/src/platform/javaLaunch.ts @@ -71,10 +71,21 @@ export function parseJavaArgs(args: readonly string[]): JavaCommandLine { return { vmArgs, mainClass: null, programArgs: [], classpath, explicitClasspath }; } +/** + * VM options a launch adds for the console, as IDEA's Spring Boot configurations do: the + * console renders ANSI colours, but Spring only detects a terminal, and the process runs on pipes. + * Kept out when the configuration sets the property itself. + */ +export function consoleVmArgs(config: Pick): string[] { + if (config.kind !== "spring-boot") return []; + const own = parseJavaArgs(config.args).vmArgs.some((a) => a.startsWith("-Dspring.output.ansi.enabled=")); + return own ? [] : ["-Dspring.output.ansi.enabled=always"]; +} + /** The configuration with its placeholders and launcher resolved. */ export function resolveJavaCommand(config: RunConfig, info: JavaLaunchInfo): RunConfig { const cp = info.classPaths.join(info.pathListSeparator); - const args: string[] = []; + const args: string[] = [...consoleVmArgs(config)]; if (info.modulePaths.length && !config.args.some((a) => a === "-p" || a === "--module-path")) args.push("--module-path", info.modulePaths.join(info.pathListSeparator)); args.push(...config.args.map((a) => (a === CLASSPATH || a === TEST_CLASSPATH ? cp : a))); From 47f68862c8bd6086556c652dcbc0fa3c427989e1 Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:57:11 +0700 Subject: [PATCH 09/22] feat(project-view): restore the persisted expansion state as the user left it (#782) The Project view restored only expanded paths inside the project roots and always re-expanded the roots. Restore the persisted state as is (a collapsed root stays collapsed, nodes outside it such as content roots stay open); only a first open expands the roots. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src/features/project-tree/store.test.ts | 8 ++++++++ src/features/project-tree/store.ts | 11 ++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/features/project-tree/store.test.ts b/src/features/project-tree/store.test.ts index 032924c3e..8d60d4d89 100644 --- a/src/features/project-tree/store.test.ts +++ b/src/features/project-tree/store.test.ts @@ -68,6 +68,14 @@ describe("lazy loading", () => { expect([...useProjectTree.getState().expanded].sort()).toEqual(["/p", "/p/docs"]); }); + it("keeps a collapsed project root collapsed and an expanded node outside it open", async () => { + setExpanded(new Set(["/other/lib"])); + await new Promise((r) => setTimeout(r, 350)); + initProjectTree(null); + initProjectTree(ROOT, "proj"); + expect([...useProjectTree.getState().expanded]).toEqual(["/other/lib"]); + }); + it("drops a folder that vanished and collapses it", async () => { await loadDir(ROOT); setExpanded(new Set([ROOT, "/p/gone"])); diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts index 3d90bfcb2..cca259ea9 100644 --- a/src/features/project-tree/store.ts +++ b/src/features/project-tree/store.ts @@ -301,11 +301,12 @@ function initialExpanded(root: string | null, mode: ViewMode, members = get().me r.scope === "scratches" && r.path ? [r.path] : [], ), ); - const persisted = mode === "project" ? (readJson(expandedKey(root)) ?? []) : []; - const roots = members ?? [root]; - const expanded = new Set(persisted.filter((p) => roots.some((r) => isSameOrInside(p, r)))); - roots.forEach((r) => expanded.add(r)); - return expanded; + // The tree as the user left it, like IDEA: a collapsed project root stays collapsed, and + // nodes outside it (content roots, contributed roots) stay open. + // Keys of nodes that no longer exist are inert. Only a first open expands the roots. + const persisted = mode === "project" ? readJson(expandedKey(root)) : null; + if (persisted) return new Set(persisted); + return new Set(members ?? [root]); } function persistExpanded() { From a8e12ff7c42784d3b9d2cecf3916f72c8dafec34 Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:57:15 +0700 Subject: [PATCH 10/22] feat(run): reuse the configuration of a main class run from its context (#783) Running or debugging a main class from the editor, gutter or Project view now selects an existing saved Application or Spring Boot configuration of the same main class and module, keeping its VM options, environment and profiles, instead of creating a temporary one. Co-authored-by: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> --- src/features/run/java.test.ts | 19 +++++++++++++++++++ src/features/run/store.ts | 12 +++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 586f3f8cb..161e1017a 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -395,6 +395,25 @@ describe("temporary configurations", () => { expect(useRun.getState().temporary).toEqual([]); }); + it("reuse a configuration of the same main class and module, whatever its id", () => { + const own = (module: string) => + cfg({ + id: `user:${module}`, + kind: "spring-boot", + name: `Mine ${module}`, + args: ["-Xmx1g", "-cp", "${classpath}", "a.Main"], + module, + detected: false, + }); + useRun.setState({ configs: [own("other"), own("app")] }); + expect(adoptTemporary(applicationConfig({ className: "a.Main" }, "app/src/main/java/a/Main.java")).name).toBe( + "Mine app", + ); + expect(useRun.getState().temporary).toEqual([]); + const fresh = adoptTemporary(applicationConfig({ className: "a.Other" }, "app/src/main/java/a/Other.java")); + expect(useRun.getState().temporary).toEqual([fresh.id]); + }); + it("offer Save in the chooser while selected", async () => { const app = adoptTemporary(applicationConfig({ className: "a.Main" }, "src/main/java/a/Main.java")); const { configs, temporary } = useRun.getState(); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 38a3c1b3c..3d7f35529 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -256,6 +256,13 @@ function persistTemporary() { ); } +/** IDEA's `isConfigurationFromContext` for an Application: the same main class of the same module. */ +function sameApplication(a: RunConfig, b: RunConfig): boolean { + if (!isJvmApplication(a) || !isJvmApplication(b)) return false; + const main = parseJavaArgs(a.args).mainClass; + return main !== null && main === parseJavaArgs(b.args).mainClass && (a.module ?? a.cwd) === (b.module ?? b.cwd); +} + /** * Selects the configuration for a gutter / context run: a saved or detected * configuration with the same id is reused, otherwise `config` becomes a @@ -263,7 +270,10 @@ function persistTemporary() { */ export function adoptTemporary(config: RunConfig): RunConfig { const state = useRun.getState(); - const existing = state.configs.find((c) => c.id === config.id); + // IDEA's `findExistingConfiguration`: a configuration the user keeps for this context wins. + const existing = + state.configs.find((c) => c.id === config.id) ?? + state.configs.find((c) => !state.temporary.includes(c.id) && sameApplication(c, config)); if (existing && !state.temporary.includes(config.id)) { selectConfig(existing.id); return existing; From 7375f30422ab7367a09705b63904b85a17c06409 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:58:23 +0800 Subject: [PATCH 11/22] fix(vcs): keep merge editor panes scrollable while they scroll together (#758) Scrolling a pane set the other two panes' scrollTop; their scroll events arrived a frame later, after the sync guard was cleared, were taken as user scrolls and written back, so the pane being scrolled was pulled back. Sync now maps positions by content and ignores the echoes of its own writes; a pane that cannot scroll no longer drags the others. Next/Previous Difference (F7 / Shift+F7) go through the keymap instead of a fixed key handler. Fixes #744. --- src/features/vcs/MergeEditor.tsx | 92 ++++++++++------- .../vcs/__tests__/mergeScroll.test.ts | 28 ++++++ src/features/vcs/diffScroll.ts | 4 +- src/features/vcs/mergeScroll.ts | 98 +++++++++++++++++++ 4 files changed, 186 insertions(+), 36 deletions(-) create mode 100644 src/features/vcs/__tests__/mergeScroll.test.ts create mode 100644 src/features/vcs/mergeScroll.ts diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 4c899b98f..b04e51675 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -8,13 +8,17 @@ import { keymap } from "@codemirror/view"; import { unifiedMergeView } from "@codemirror/merge"; import type { MergeSides } from "@/bindings"; import { embeddedEditorExtensions } from "@/editor/api"; +import { setContext } from "@/platform/context"; import { tx, useTxTemplate } from "@/platform/i18n"; import { call, commands } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; import { Button, IconButton, showContextMenu, Spinner, T } from "@/ui"; import { editorService, type EditorInput } from "@/workbench/contributions"; import { blameSlot, toggleAnnotations } from "./blame"; import { languageFor, viewerBase } from "./cm"; import { confirmDialog } from "./dialogs"; +import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator"; +import { syncMergeScroll } from "./mergeScroll"; import { initialResult, mergeTitles, resolutionText, type Resolution, type ResultConflict } from "./conflicts"; import { baseName } from "./model"; import { errorMessage, readWorkingText, runOp, writeFileText } from "./ops"; @@ -220,28 +224,10 @@ export function MergeEditor({ input }: { input: EditorInput }) { setLeft(result.state.field(conflictField).conflicts.length); oursView.current = ours; theirsView.current = theirs; - // Scroll the three panes together (by scroll ratio), like IDEA's merge view. - const views = [ours, result, theirs]; - let syncing = false; - const onScroll = (from: EditorView) => () => { - if (syncing) return; - syncing = true; - const s = from.scrollDOM; - const ratio = s.scrollTop / Math.max(1, s.scrollHeight - s.clientHeight); - for (const other of views) { - if (other === from) continue; - const o = other.scrollDOM; - o.scrollTop = ratio * Math.max(0, o.scrollHeight - o.clientHeight); - } - syncing = false; - }; - const offs = views.map((v) => { - const h = onScroll(v); - v.scrollDOM.addEventListener("scroll", h, { passive: true }); - return () => v.scrollDOM.removeEventListener("scroll", h); - }); + // Scroll the three panes together, lined up through their changes. + const stopSync = syncMergeScroll({ ours, result, theirs }); return () => { - offs.forEach((off) => off()); + stopSync(); ours.destroy(); theirs.destroy(); result.destroy(); @@ -313,17 +299,31 @@ export function MergeEditor({ input }: { input: EditorInput }) { } }; - // IDEA's merge view: F7 / Shift+F7 jump between conflicts; each side pane can - // annotate its version with Git Blame from the context menu. - // Each side pane blames on the branch it belongs to, so lines that only exist on - // the other branch show their real history instead of "Not Committed Yet". + // Next / Previous Difference (F7 / Shift+F7 by default) jump between conflicts through + // the keymap, as in the diff viewer. + // `goto` reads only refs, so the first render's one stays valid. + const [nav] = useState(() => ({ + next: () => goto(1), + prev: () => goto(-1), + jumpToSource: () => void editorService().openFile(path, { focus: true }), + applyChange: () => false, + file: () => {}, + showSettings: () => {}, + })); + useEffect( + () => () => { + if (focusedDiffNavigator() === nav) { + setFocusedDiffNavigator(null); + setContext("diffEditorFocus", false); + } + }, + [nav], + ); + + // Each side pane can annotate its version with Git Blame from the context menu, on + // the branch it belongs to, so lines that only exist on the other branch show their + // real history instead of "Not Committed Yet". const otherRev = state?.sides.commit ?? "HEAD"; - const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "F7") { - e.preventDefault(); - goto(e.shiftKey ? -1 : 1); - } - }; const sideMenu = (e: React.MouseEvent, viewRef: { current: EditorView | null }, text: string, revision?: string) => { const view = viewRef.current; @@ -358,10 +358,34 @@ export function MergeEditor({ input }: { input: EditorInput }) { ); return ( -
+
{ + 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();