From 376501a5fe574936e46775a89432df1aee13affd Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:42:28 +0800 Subject: [PATCH 01/21] fix(properties): keep existing Unicode escapes when saving with native-to-ascii (#650) With Transparent native-to-ascii conversion on, pasting or typing existing \uXXXX escapes into a .properties file and saving wrote them back as \u005CuXXXX, so the file on disk changed meaning. Root cause: the shared properties encoder escaped the backslash of any valid ASCII Unicode escape. It now writes ASCII escapes as they are and only converts non-ASCII characters; a literal backslash right before a non-ASCII character is still protected. The same encoder serves every write path, so normal/auto save, find and replace in open and closed files, workspace edits, Local History restore and partial Git stage/commit are all covered. Verified on macOS following the reporter's steps (paste escapes, save, reload externally); Windows and Linux not run. Fixes #519 --- crates/ide-host/tests/properties.rs | 7 ++-- crates/ide-vcs/src/text_encoding.rs | 2 +- crates/ide-vfs/src/encoding.rs | 28 +++++++++++--- crates/ide-workspace/tests/documents.rs | 51 +++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/crates/ide-host/tests/properties.rs b/crates/ide-host/tests/properties.rs index 097d92a63..dce783dd4 100644 --- a/crates/ide-host/tests/properties.rs +++ b/crates/ide-host/tests/properties.rs @@ -104,7 +104,7 @@ async fn properties_search_git_and_document_coordinates_agree() { "main", opened.id, opened.version, - ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: "世界".into() }] }, + ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: r"\u4E16\u754C".into() }] }, ) .unwrap(); let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap(); @@ -129,7 +129,7 @@ async fn properties_search_git_and_document_coordinates_agree() { assert!(project.documents.snapshot(opened.id).unwrap().version > snap.version); assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty()); // Partial commits identify hunks in displayed line coordinates as well. - let partial_text = displayed.replace("target", "世界").replace("logical", "changed"); + let partial_text = displayed.replace("target", r"\u4E16\u754C").replace("logical", "changed"); project.documents.replace_all(opened.id, &partial_text).unwrap(); doc::save(&host, "main", opened.id).await.unwrap(); let hunks = ide_vcs::diff_hunks(displayed, &partial_text); @@ -169,7 +169,7 @@ async fn properties_search_git_and_document_coordinates_agree() { "main", RequestId(520), query, - "世界".into(), + r"\u4E16\u754C".into(), false, vec![ide_search::ReplaceTarget { path: closed_path.clone().into(), @@ -181,5 +181,6 @@ async fn properties_search_git_and_document_coordinates_agree() { .unwrap(); assert_eq!(std::fs::read_to_string(&closed_path).unwrap(), r"value=\u4F60\u597D\uD83D\uDE00\u4E16\u754C"); assert!(project.documents.by_path(&closed_path).is_none()); + assert_eq!(project.text_of(&closed_path).unwrap(), "value=你好😀世界"); host.close_project("main"); } diff --git a/crates/ide-vcs/src/text_encoding.rs b/crates/ide-vcs/src/text_encoding.rs index 038256a95..768504b6d 100644 --- a/crates/ide-vcs/src/text_encoding.rs +++ b/crates/ide-vcs/src/text_encoding.rs @@ -3,7 +3,7 @@ use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE}; use ide_core::{IdeError, IdeResult}; use std::borrow::Cow; -/// File charset plus an optional reversible ASCII representation (for properties). +/// File charset plus an optional ASCII representation (for properties). /// The host supplies conversion functions so Git and documents share one codec. #[derive(Clone, Copy)] pub struct TextEncoding { diff --git a/crates/ide-vfs/src/encoding.rs b/crates/ide-vfs/src/encoding.rs index 05504a1b2..92e0d999b 100644 --- a/crates/ide-vfs/src/encoding.rs +++ b/crates/ide-vfs/src/encoding.rs @@ -62,20 +62,19 @@ pub fn decode_properties_escapes(text: &str) -> String { } /// Serializes displayed properties text as ASCII, using uppercase UTF-16 units. +/// Existing ASCII Unicode escapes retain their spelling and meaning on disk. pub fn encode_properties_ascii(text: &str) -> String { use std::fmt::Write; let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(c) = rest.chars().next() { - let current = rest; rest = &rest[c.len_utf8()..]; if c == '\\' { if rest.starts_with('\\') { out.push_str("\\\\"); rest = &rest[1..]; - } else if unicode_character(current).is_some() || rest.chars().next().is_some_and(|next| !next.is_ascii()) { - // Keep a literal backslash from swallowing a generated escape, - // or becoming a Unicode escape on the next load. + } else if rest.chars().next().is_some_and(|next| !next.is_ascii()) { + // Keep a literal backslash from swallowing a generated escape. out.push_str("\\u005C"); } else { out.push(c); @@ -387,13 +386,32 @@ mod tests { for text in ["plain=ASCII\r\n", r"escaped=\\u0041", r"bad=\u12xz", r"bad=\uD800"] { assert_eq!(encode_properties_ascii(text), text); } - for text in ["你好😀", "\0", "\\中", "\\\\中", "\\\\\\😀", r"\u0041", r"\\u0041", r"\uD83D\uDE00"] { + for text in ["你好😀", "\0", "\\中", "\\\\中", "\\\\\\😀", r"\\u0041"] { let ascii = encode_properties_ascii(text); assert!(ascii.is_ascii()); assert_eq!(decode_properties_escapes(&ascii), text, "{ascii}"); } } + #[test] + fn properties_ascii_escapes_are_not_escaped_again() { + for text in [ + r"test.key2=\u4F60\u597D", + r"test.key3=\u4f60\u597d", + r"ascii=\u0041\u005C\u000A", + r"emoji=\uD83D\uDE00", + r"slashes=\\u4F60 \\\u4F60 \\\\u4F60", + r"bad=\u123 \u12xz \uu0041 \uD800 \uDC00", + ] { + assert_eq!(encode_properties_ascii(text), text, "{text}"); + } + let mixed = "raw=\\u4f60\\u597d; native=你好😀"; + let expected = r"raw=\u4f60\u597d; native=\u4F60\u597D\uD83D\uDE00"; + assert_eq!(encode_properties_ascii(mixed), expected); + assert_eq!(encode_properties_ascii(expected), expected); + assert_eq!(decode_properties_escapes(expected), "raw=你好; native=你好😀"); + } + #[test] fn properties_line_terminator_escapes_survive_an_edit() { let source = r"key=one\u000Atwo\u000Dthree"; diff --git a/crates/ide-workspace/tests/documents.rs b/crates/ide-workspace/tests/documents.rs index 028617eb8..02370eed5 100644 --- a/crates/ide-workspace/tests/documents.rs +++ b/crates/ide-workspace/tests/documents.rs @@ -641,6 +641,57 @@ fn properties_conversion_uses_displayed_utf16_and_survives_reload() { assert!(!f.docs.is_dirty(doc.id).unwrap()); } +#[test] +fn properties_pasted_escapes_survive_save_and_decode_on_reload() { + let pasted = "test.key1=Hello\ntest.key2=\\u4F60\\u597D\ntest.key3=\\u4f60\\u597d\n"; + let displayed = "test.key1=Hello\ntest.key2=你好\ntest.key3=你好\n"; + for encoding in ["utf-8", "iso-8859-1", "utf-16le"] { + for eol in ["\n", "\r\n"] { + let f = fixture(); + let path = f.root.join("test_zh_CN.properties"); + let source = pasted.replace('\n', eol); + fs::write(&path, ide_vfs::encode(&source, encoding, encoding == "utf-16le").unwrap()).unwrap(); + let mut defaults = f.docs.file_defaults(); + defaults.native_to_ascii = true; + defaults.properties_fallback = encoding.into(); + f.docs.set_file_defaults(defaults.clone()); + let doc = f.docs.open(&path).unwrap(); + assert_eq!(doc.text, displayed); + + f.docs.replace_all(doc.id, pasted).unwrap(); + f.docs.save(doc.id).unwrap(); + assert_eq!(fs::read(&path).unwrap(), source.as_bytes(), "{encoding} {eol:?}"); + assert_eq!(f.docs.snapshot(doc.id).unwrap().text, pasted); + assert!(!f.docs.is_dirty(doc.id).unwrap()); + f.docs.on_disk_changed(std::slice::from_ref(&path)); + assert_eq!(f.docs.snapshot(doc.id).unwrap().text, pasted, "own saves do not reload"); + + f.docs.close(doc.id); + let reopened = f.docs.open(&path).unwrap(); + assert_eq!(reopened.text, displayed); + // An edit followed by its inverse preserves the original escape casing. + f.docs.replace_all(reopened.id, pasted).unwrap(); + f.docs.replace_all(reopened.id, displayed).unwrap(); + f.docs.save(reopened.id).unwrap(); + assert_eq!(fs::read(&path).unwrap(), source.as_bytes()); + + let external = format!("{source}test.key4=\\uD83D\\uDE00{eol}"); + fs::write(&path, &external).unwrap(); + f.docs.on_disk_changed(std::slice::from_ref(&path)); + assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, format!("{displayed}test.key4=😀\n")); + defaults.native_to_ascii = false; + f.docs.set_file_defaults(defaults.clone()); + f.docs.reload_properties(); + assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, external.replace("\r\n", "\n")); + defaults.native_to_ascii = true; + f.docs.set_file_defaults(defaults); + f.docs.reload_properties(); + assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, format!("{displayed}test.key4=😀\n")); + assert_eq!(fs::read_to_string(&path).unwrap(), external); + } + } +} + #[test] fn properties_toggle_reloads_only_clean_properties_and_pins_dirty_codec() { let f = fixture(); From 7c72411dd73b217ad31a0c3bd92bcaeb1bb5a97d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:45:25 +0800 Subject: [PATCH 02/21] fix(trust): allow application-level commands in restricted mode (#648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Help › About showed no version, build or runtime in untrusted project windows and on the welcome screen. Root cause: the restricted-mode allowlist added with project trust missed app_build_info and other application-level commands, so they were rejected wherever the window had no trusted project; the frontend then cached the failed build info for the whole session. Fix: classify all registered commands and allow the 24 that only read application information or change the user's own application state (build info, log location, update checks, cache invalidation, file clipboard, JDK download, Docker settings, database export paths, patch export, system environment); JDK download now uses the application tool directory instead of project tool settings. Execution commands (run, build, debug, language servers, tool install from project settings) stay blocked. Build info is retried after a failure instead of being cached. Verified on macOS in an untrusted project and on the welcome screen; Windows not run. Related to #519 --- crates/ide-host/src/ops/tools.rs | 8 +-- crates/ide-host/src/trust.rs | 24 +++++++ crates/ide-host/tests/trust.rs | 88 +++++++++++++++++++++++++ src/features/app/__tests__/host.test.ts | 51 ++++++++++++++ src/features/app/host.ts | 7 +- 5 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 src/features/app/__tests__/host.test.ts diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index f87b5799c..f8f05b1ff 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -100,11 +100,11 @@ pub fn jdk_download_vendors(_host: &Host, _window: &str) -> Vec { /// Java versions `vendor` publishes; cancellable with `cancel_request(request)`. pub async fn jdk_download_releases( host: &Host, - window: &str, + _window: &str, vendor: String, request: RequestId, ) -> IdeResult { - let tools = tools(host, window)?; + let tools = Tools::new(&host.data_dir); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err); host.end_request(request); @@ -123,14 +123,14 @@ pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult, request: RequestId, on_event: Channel, ) -> IdeResult { - let tools = tools(host, window)?; + let tools = Tools::new(&host.data_dir); let location = location.filter(|p| !p.as_os_str().is_empty()); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || { diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 2f883b7b9..96c7c9740 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -175,6 +175,30 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { matches!( cmd, "app_info" + | "app_build_info" + | "app_log_location" + | "app_check_update" + | "app_startup_update_check" + | "app_install_update" + | "caches_schedule_invalidation" + | "clear_caches" + | "clipboard_file_list" + | "clipboard_has_files" + | "clipboard_set_file_list" + | "jdk_download_vendors" + | "jdk_download_releases" + | "jdk_download_location" + | "jdk_download" + | "run_system_env" + | "run_gradle_jvm_args_script" + | "proxy_check_connection" + | "docker_settings" + | "docker_save_settings" + | "docker_set_registry_password" + | "docker_run_env" + | "db_console_path" + | "db_export_file" + | "vcs_write_patch" | "app_metrics" | "app_quit" | "app_cancel_quit" diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index bf6a0a057..4a1d0250a 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -20,6 +20,94 @@ fn host(base: &Path, environment: bool) -> Arc { })) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn application_commands_work_in_restricted_projects_and_without_a_project() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + std::fs::create_dir(&root).unwrap(); + let host = host(&tmp.path().join("app"), false); + assert!(host.project("welcome").is_err()); + assert!(ide_host::ops::project::roots(&host, "welcome").is_err()); + assert!(!host.open_project("main", &root).unwrap().is_trusted()); + for command in [ + "app_info", + "app_build_info", + "app_log_location", + "app_check_update", + "app_startup_update_check", + "app_install_update", + "caches_schedule_invalidation", + "clear_caches", + "clipboard_file_list", + "clipboard_has_files", + "clipboard_set_file_list", + "jdk_download_vendors", + "jdk_download_releases", + "jdk_download_location", + "jdk_download", + "run_system_env", + "run_gradle_jvm_args_script", + "proxy_check_connection", + "docker_settings", + "docker_save_settings", + "docker_set_registry_password", + "docker_run_env", + "db_console_path", + "db_export_file", + "vcs_write_patch", + ] { + assert!(ide_host::trust::restricted_command_allowed(command), "{command}"); + for window in ["main", "welcome"] { + assert!(host.authorize_command(window, command).is_ok(), "{window}: {command}"); + } + } + for command in [ + "run_start", + "build_sync", + "debug_start", + "sdk_detect", + "tools_list", + "tools_install", + "tools_uninstall", + "plugin_install_dir", + "plugin_reload", + "vcs_git_version", + "docker_compose_op", + "db_execute", + "unknown_command", + ] { + assert!(!ide_host::trust::restricted_command_allowed(command), "{command}"); + for window in ["main", "welcome"] { + assert!(host.authorize_command(window, command).is_err(), "{window}: {command}"); + } + } + // These requests validate the vendor before any network access, independently of project trust. + for window in ["main", "welcome"] { + let error = ide_host::ops::tools::jdk_download_releases( + &host, + window, + "unknown-vendor".into(), + ide_core::RequestId(901), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + let error = ide_host::ops::tools::jdk_download( + &host, + window, + "unknown-vendor".into(), + 25, + None, + ide_core::RequestId(902), + ide_host::ops::Channel::new(|_| {}), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + } + host.shutdown().await; +} + #[test] fn persists_user_decisions_locations_revocation_and_one_time_migration() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/features/app/__tests__/host.test.ts b/src/features/app/__tests__/host.test.ts new file mode 100644 index 000000000..27f7d38ee --- /dev/null +++ b/src/features/app/__tests__/host.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import type { BuildInfo } from "@/platform/ipc"; + +const { appBuildInfo, isTauri } = vi.hoisted(() => ({ appBuildInfo: vi.fn(), isTauri: vi.fn() })); +vi.mock("@/platform/ipc", () => ({ commands: { appBuildInfo }, isTauri })); + +const build: BuildInfo = { + version: "0.1.32", + build: "dev", + profile: "debug", + os: "windows", + arch: "x86_64", + tauri: "2.11.3", + updates: false, +}; + +beforeEach(() => { + vi.resetModules(); + appBuildInfo.mockReset(); + isTauri.mockReturnValue(true); +}); + +it("retries after a rejected request and caches the successful build info", async () => { + appBuildInfo.mockRejectedValueOnce(new Error("Project is not trusted")).mockResolvedValueOnce(build); + const { buildInfo, hostOs } = await import("../host"); + + const first = buildInfo(); + expect(buildInfo()).toBe(first); + await expect(first).resolves.toBeNull(); + await expect(hostOs()).resolves.toBe("windows"); + await expect(buildInfo()).resolves.toBe(build); + expect(appBuildInfo).toHaveBeenCalledTimes(2); +}); + +it("shares an in-flight successful request", async () => { + let resolve!: (value: BuildInfo) => void; + appBuildInfo.mockReturnValue(new Promise((done) => (resolve = done))); + const { buildInfo } = await import("../host"); + const first = buildInfo(); + expect(buildInfo()).toBe(first); + expect(appBuildInfo).toHaveBeenCalledTimes(1); + resolve(build); + await expect(first).resolves.toBe(build); +}); + +it("does not invoke the backend outside Tauri", async () => { + isTauri.mockReturnValue(false); + const { buildInfo } = await import("../host"); + await expect(buildInfo()).resolves.toBeNull(); + expect(appBuildInfo).not.toHaveBeenCalled(); +}); diff --git a/src/features/app/host.ts b/src/features/app/host.ts index 649365e98..4e1fdacdb 100644 --- a/src/features/app/host.ts +++ b/src/features/app/host.ts @@ -13,7 +13,12 @@ function normalize(os: string): HostOs { let info: Promise | null = null; export function buildInfo(): Promise { - info ??= isTauri() ? commands.appBuildInfo().catch(() => null) : Promise.resolve(null); + info ??= isTauri() + ? commands.appBuildInfo().catch(() => { + info = null; + return null; + }) + : Promise.resolve(null); return info; } From 482241ebb9a91e2ae1b6bb849f1315af65274479 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:04:28 +0800 Subject: [PATCH 03/21] feat(java): open class files as decompiled sources with breakpoints (#657) Opening a .class file showed binary content. Class files from library jars, project output directories, navigation, Go to Class and debugger frames now all go through one route to JDT class documents: - Library classes open the attached source, or the FernFlower decompilation when there is none, read-only and marked as decompiled. java-debug's decompiled-source support is enabled, so breakpoints set in decompiled code map to the original lines and hit, and stepping into such classes lands on the right line. - Output-directory classes (target/classes, build/classes) open their project source when it exists; otherwise the decompiled text is shown read-only with breakpoints disabled, because jdt.ls returns no line mapping for classes outside the Java model. - The preferred content provider id is corrected to fernflowerContentProvider (attached sources still win), and the decompiled marker recognises the current FernFlower header. Verified on macOS with a Maven project: library class from Libraries and Go to Class, breakpoint hit and step into decompiled code, output class with and without source. Not run on Windows, Linux or Gradle projects. Fixes #394 --- crates/ide-host/src/ops/lsp.rs | 14 ++- crates/ide-lsp/src/jdtls.rs | 56 ++++++++++- crates/ide-lsp/src/lib.rs | 5 +- crates/ide-lsp/src/types.rs | 2 +- crates/ide-lsp/src/virtual_doc.rs | 92 ++++++++++++++++--- crates/ide-lsp/tests/mock_server.rs | 52 +++++++++++ crates/ide-sdk/src/libraries.rs | 8 +- crates/ide-sdk/src/tests.rs | 16 ++-- languages/java.toml | 2 +- src-tauri/src/commands/lsp.rs | 14 ++- src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 7 +- src/editor/api.ts | 2 +- src/editor/core/backend.ts | 2 +- src/editor/core/controller.ts | 16 +++- src/editor/core/view/Banner.tsx | 15 ++- src/editor/core/view/EditorArea.module.css | 4 + src/editor/core/view/EditorArea.tsx | 2 +- src/editor/core/virtual.test.ts | 21 ++++- .../debugger/__tests__/actions.test.ts | 20 ++++ .../debugger/__tests__/editor.test.ts | 26 ++++++ src/features/debugger/actions.ts | 24 +++-- .../debugger/breakpoints/languages.ts | 4 +- .../debugger/editor/breakpointGutter.ts | 28 ++++++ src/features/debugger/editor/runToCursor.ts | 3 +- src/platform/classFiles.test.ts | 34 +++++++ src/platform/classFiles.ts | 8 ++ src/platform/locales/text/zh.json | 1 + src/platform/virtualDocs.ts | 5 +- 29 files changed, 427 insertions(+), 57 deletions(-) create mode 100644 src/platform/classFiles.test.ts create mode 100644 src/platform/classFiles.ts diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 953f878a7..0c85d1065 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -83,12 +83,18 @@ pub async fn lsp_implementations( result } -/// Contents of a virtual document (library class, decompiled code) that a -/// language server returned a non-`file:` URI for. Cached per project. +/// Contents of a read-only language-server document, including JDT library +/// classes and disk class files. Cached per project. pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult { host.project(window)?.lsp.virtual_document(&uri).await } +/// Opens compiled classes through JDT, with project source taking priority on disk. +pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::PathBuf) -> IdeResult { + let archive = ide_vfs::split_archive_path(&path); + host.project(window)?.lsp.java_class_file(&path, archive).await +} + /// References of the symbol at `pos`. `project_wide` also searches project files /// outside the language server's analysis scope; bulk scans over many symbols /// leave it off. Cancel with `cancel_request(request)`. @@ -367,6 +373,10 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("lsp_implementations", |c| async move { lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await }); + d.add( + "lsp_resolve_class_file", + |c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await }, + ); d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await }); d.add("lsp_references", |c| async move { lsp_references( diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 6b47e143c..e66cafe63 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -532,6 +532,59 @@ impl LspManager { collect() } + /// Resolve a disk/archive class to project source or the server's binary document. + /// Archive URIs must come from JDT's Java model: fabricated handles cannot map debug lines. + pub async fn java_class_file(&self, path: &Path, archive: Option<(PathBuf, String)>) -> IdeResult { + let cancel = CancellationToken::new(); + self.jdtls(&self.project_root, &cancel).await?; + if let Some((jar, entry)) = archive { + let entry = entry + .strip_prefix("META-INF/versions/") + .and_then(|s| s.split_once('/').map(|(_, s)| s)) + .unwrap_or(&entry); + let class = entry.strip_suffix(".class").ok_or_else(|| IdeError::not_found("expected a class file"))?; + let outer = class.split('$').next().unwrap_or(class); + let (package, name) = outer.rsplit_once('/').unwrap_or(("", outer)); + let query = outer.replace('/', "."); + let symbols = self.workspace_symbols(&query, cancel).await?; + let package = package.replace('/', "."); + let name = name.to_owned(); + let outer = outer.to_owned(); + return tokio::task::spawn_blocking(move || { + symbols + .into_iter() + .filter_map(|s| s.location.uri) + .find(|uri| crate::virtual_doc::matches_class(uri, &jar, &package, &name)) + .ok_or_else(|| { + IdeError::not_found(format!("jdtls could not resolve {} in {}", outer, jar.display())) + }) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))?; + } + let uri = crate::uri::path_to_uri_string(path); + // classFileContents accepts file URIs, but java-debug's line mapping only accepts JDT handles. + let doc = self.virtual_document(&uri).await?; + let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").split('$').next().unwrap_or(""); + let package = doc + .text + .lines() + .find_map(|l| l.trim().strip_prefix("package ").and_then(|p| p.strip_suffix(';'))) + .unwrap_or(""); + let relative = PathBuf::from(package.replace('.', "/")).join(format!("{name}.java")); + let key = "org.eclipse.jdt.ls.core.sourcePaths"; + let settings = self + .jdtls_project_command(path, "java.project.getSettings", vec![json!(uri), json!([key])], &cancel) + .await?; + let roots: Vec = + settings[key].as_array().into_iter().flatten().filter_map(Value::as_str).map(PathBuf::from).collect(); + let source = + tokio::task::spawn_blocking(move || roots.into_iter().map(|r| r.join(&relative)).find(|p| p.is_file())) + .await + .map_err(|e| IdeError::internal(e.to_string()))?; + Ok(source.map(|s| s.to_string_lossy().into_owned()).unwrap_or(uri)) + } + /// Starts (or reuses) java-debug's DAP server inside jdtls and returns its port. pub async fn java_debug_server(&self, path: &Path) -> IdeResult { let v = self.jdtls_command(path, "vscode.java.startDebugSession", Vec::new()).await.map_err(|e| { @@ -542,7 +595,7 @@ impl LspManager { } })?; // Hot code replace after each (auto-)build; errors only cost the feature. - let settings = json!({ "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false, + let settings = json!({ "debugSupportOnDecompiledSource": "on", "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false, "showLogicalStructure": true, "showToString": true, "maxStringLength": 0, // java-debug NPEs on a missing level (java.util.logging names). "logLevel": "WARNING" }); @@ -574,6 +627,7 @@ mod tests { assert_eq!(settings["java"]["project"]["sourcePaths"], json!(["src/main/java", "tests"])); assert_eq!(settings["java"]["project"]["referencedLibraries"], json!(["lib/**/*.jar"])); assert_eq!(settings["java"]["autobuild"]["enabled"], true); + assert_eq!(settings["java"]["contentProvider"]["preferred"], "fernflowerContentProvider"); assert!(settings.get("bundles").is_none()); assert!(settings.get("settings").is_none(), "didChangeConfiguration takes java settings directly"); } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 6d44afeb9..7091c69a3 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -2166,6 +2166,7 @@ impl LspManager { return Ok(d.clone()); } let scheme = virtual_doc::scheme(uri).to_owned(); + let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class")); let origin = self.origin_server(uri); let mut servers: Vec> = origin.iter().cloned().collect(); servers.extend(self.servers.lock().iter().filter(|s| Some(s.id) != origin.as_ref().map(|o| o.id)).cloned()); @@ -2174,7 +2175,7 @@ impl LspManager { for t in &targets { let languages = t.server.languages(); let serves_scheme = t.caps().content_schemes().contains(&scheme); - let result: IdeResult> = if serves_scheme { + let result: IdeResult> = if serves_scheme && !class_file { self.call::<_, Option>( t, "workspace/textDocumentContent", @@ -2184,7 +2185,7 @@ impl LspManager { ) .await .map(|v| v.and_then(|v| v.get("text").and_then(Value::as_str).map(str::to_owned))) - } else if scheme == "jdt" && languages.iter().any(|l| l.as_str() == "java") { + } else if class_file && languages.iter().any(|l| l.as_str() == "java") { self.call::<_, Option>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT) .await } else { diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 45be5d0c3..beaee53d1 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -335,7 +335,7 @@ pub struct VirtualDocument { pub title: String, /// Origin shown in the tooltip / banner, e.g. the jar path. pub description: Option, - /// Generated by a decompiler / disassembler rather than real sources. + /// Generated Java from a decompiler, rather than real source or a disassembler stub. pub decompiled: bool, } diff --git a/crates/ide-lsp/src/virtual_doc.rs b/crates/ide-lsp/src/virtual_doc.rs index 71394d7a1..110b206fd 100644 --- a/crates/ide-lsp/src/virtual_doc.rs +++ b/crates/ide-lsp/src/virtual_doc.rs @@ -1,21 +1,29 @@ //! Virtual documents: read-only contents a language server provides for a -//! non-`file:` URI (library classes from `jdt://`, `workspace/textDocumentContent`). +//! URI (library classes from `jdt://`, disk classes from `file://`, or +//! `workspace/textDocumentContent`). use percent_encoding::percent_decode_str; -/// Markers decompilers / disassemblers put at the top of generated code. -const GENERATED_MARKERS: [&str; 5] = [ - "generated by the fernflower decompiler", - "failed to get sources", - "decompiled with cfr", - "decompiled with procyon", - "generated by the disassembler", -]; - -/// Whether `text` is decompiled / disassembled code rather than real source. +/// Recognize decompiler headers, without treating disassembler stubs as Java source. pub fn is_decompiled(text: &str) -> bool { - let head: String = text.chars().take(600).collect::().to_ascii_lowercase(); - GENERATED_MARKERS.iter().any(|m| head.contains(m)) + let head: String = text.chars().take(600).collect(); + head.lines() + .take(12) + .take_while(|line| { + let line = line.trim(); + line.is_empty() || line.starts_with("//") || line.starts_with("/*") || line.starts_with('*') + }) + .any(|line| { + let line = line.trim().trim_start_matches('/').trim_start_matches('*').trim().to_ascii_lowercase(); + [ + "source code is unavailable, and was generated by the fernflower decompiler.", + "source code is decompiled from a .class file using fernflower decompiler", + "decompiled with cfr", + "decompiled with procyon", + ] + .iter() + .any(|marker| line.starts_with(marker)) + }) } /// `scheme` of a URI (`jdt` for `jdt://…`). @@ -34,7 +42,16 @@ fn segments(uri: &str) -> Vec { /// Editor tab title: the last path segment; a class file whose real source /// was found is shown as `.java` (IDEA: `String.java` vs `String.class`). pub fn title(uri: &str, decompiled: bool) -> String { - let name = segments(uri).pop().unwrap_or_else(|| uri.to_owned()); + let mut name = segments(uri).pop().unwrap_or_else(|| uri.to_owned()); + // Recent jdtls versions use a .java path even for decompiled class handles. + if decompiled + && scheme(uri) == "jdt" + && name.ends_with(".java") + && uri.split_once('?').is_some_and(|(_, q)| percent_decode_str(q).decode_utf8_lossy().contains(".class")) + { + name.truncate(name.len() - 5); + name.push_str(".class"); + } match name.strip_suffix(".class") { Some(stem) if !decompiled && scheme(uri) == "jdt" => format!("{stem}.java"), _ => name, @@ -68,6 +85,19 @@ pub fn description(uri: &str) -> Option { } } +/// Check both the package and actual archive, so duplicate type names never pick another jar. +pub(crate) fn matches_class(uri: &str, jar: &std::path::Path, package: &str, name: &str) -> bool { + let segs = segments(uri); + scheme(uri) == "jdt" + && (package.is_empty() && segs.len() == 3 || segs.get(2).is_some_and(|s| s == package)) + && segs.last().is_some_and(|s| s == &format!("{name}.class") || s == &format!("{name}.java")) + && description(uri).is_some_and(|p| { + let path = std::path::Path::new(&p); + path == jar + || dunce::canonicalize(path).ok().zip(dunce::canonicalize(jar).ok()).is_some_and(|(a, b)| a == b) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -84,6 +114,30 @@ mod tests { #[test] fn jdt_description() { + assert!(matches_class( + JDT, + std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"), + "org.springframework.boot", + "SpringApplication" + )); + assert!(!matches_class( + JDT, + std::path::Path::new("/elsewhere/spring-boot-4.1.1.jar"), + "org.springframework.boot", + "SpringApplication" + )); + assert!(!matches_class( + JDT, + std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"), + "other.package", + "SpringApplication" + )); + assert!(matches_class( + "jdt://contents/a.jar//C.java?=p/%5C/m2%5C/a.jar%3C%28C.class", + std::path::Path::new("/m2/a.jar"), + "", + "C" + )); assert_eq!(description(JDT).as_deref(), Some("/Users/me/.m2/repository/spring-boot-4.1.1.jar")); assert_eq!( description( @@ -98,10 +152,18 @@ mod tests { #[test] fn detects_generated_code() { + assert!(is_decompiled( + "// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage a;" + )); + assert!(!is_decompiled("package a;\n// generated by the fernflower decompiler")); + assert!(!is_decompiled("// Documentation about code generated by the Fernflower decompiler.\npackage a;")); + let recent = "jdt://contents/a.jar/p/C.java?=p/a.jar%3Cp%28C.class"; + assert_eq!(title(recent, true), "C.class"); + assert_eq!(title(recent, false), "C.java"); assert!(is_decompiled( "// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage a;" )); - assert!(is_decompiled( + assert!(!is_decompiled( "// Failed to get sources. Instead, stub sources have been generated by the disassembler." )); assert!(!is_decompiled("/*\n * Copyright 2012-2024 the original author or authors.\n */\npackage a;")); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index e909af815..2cd2b4f9a 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1617,6 +1617,58 @@ async fn jdt_class_file_contents() { assert_eq!(vd.title, "Lib.class"); assert_eq!(vd.language_id.as_str(), "java"); assert_eq!(vd.description.as_deref(), Some("/m2/lib-1.0.jar")); + let source_uri = jdt.replace("Lib.class", "WithSources.class"); + s.respond( + "java/classFileContents", + json!("/* Copyright library author */\npackage org.example;\npublic class WithSources {}"), + ); + let source = h.mgr.virtual_document(&source_uri).await.unwrap(); + assert!(!source.decompiled); + assert_eq!(source.title, "WithSources.java"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn class_file_opening_routes_sources_and_binary_documents() { + let mut caps = incremental_caps(); + caps["workspaceSymbolProvider"] = json!(true); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + h.open("App.java", "class App {}\n"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let s = h.launcher.state("jdtls"); + let jdt = "jdt://contents/lib.jar/p/Outer.java?=demo/%5C/m2%5C/lib.jar%3Cp%28Outer.class"; + s.respond("workspace/symbol", json!([ + {"name":"Outer", "kind":5, "location":{"uri":jdt,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}}}} + ])); + let archive = Some((PathBuf::from("/m2/lib.jar"), "p/Outer$1.class".into())); + assert_eq!(h.mgr.java_class_file(Path::new("/m2/lib.jar!/p/Outer$1.class"), archive).await.unwrap(), jdt); + assert!( + h.mgr + .java_class_file( + Path::new("/other/lib.jar!/p/Outer.class"), + Some(("/other/lib.jar".into(), "p/Outer.class".into())) + ) + .await + .is_err() + ); + + let sources = h.root.join("src/main/java"); + std::fs::create_dir_all(sources.join("p")).unwrap(); + let source = sources.join("p/Outer.java"); + std::fs::write(&source, "package p; class Outer {} ").unwrap(); + s.respond("java/classFileContents", json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Outer {}")); + s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]})); + for class in ["Outer", "Outer$Nested", "Outer$1"] { + let path = h.root.join(format!("target/classes/p/{class}.class")); + assert_eq!(h.mgr.java_class_file(&path, None).await.unwrap(), source.to_string_lossy()); + } + let path = h.root.join("build/classes/java/main/p/Missing.class"); + let uri = uri_of(&path); + assert_eq!(h.mgr.java_class_file(&path, None).await.unwrap(), uri); + let vd = h.mgr.virtual_document(&uri).await.unwrap(); + assert!(vd.decompiled); + assert_eq!(vd.language_id.as_str(), "java"); + assert_eq!(vd.title, "Missing.class"); + assert!(s.requested("java/classFileContents").iter().any(|p| p["uri"] == uri)); } #[tokio::test(flavor = "multi_thread")] diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index 4c50b2f55..bdc450b7e 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -8,7 +8,7 @@ //! result is cached until the manifests (or the relevant settings) change. //! //! Archives are listed as browsable roots (`!`, see `ide_vfs`): the -//! JDK's `src.zip` and the Maven / Gradle dependency jars (sources jars preferred). +//! JDK's `src.zip` and the Maven / Gradle dependency jars (source attachments resolved by JDT). use crate::exe; use crate::exec::{query, query_in, which}; @@ -248,7 +248,7 @@ fn gradle_user_home() -> Option { } /// One `Maven: g:a:v` / `Gradle: g:a:v` group per dependency whose jar is in a -/// local repository, its root being the jar (the `-sources.jar` when downloaded). +/// local repository, its root being the binary jar so Java classes open through JDT's source provider. pub(crate) fn dependency_libraries( deps: &[BuildDependency], maven_repo: Option<&Path>, @@ -286,7 +286,7 @@ pub(crate) fn maven_jar(repo: &Path, d: &BuildDependency) -> Option { return None; } let dir = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version); - ["-sources.jar", ".jar"] + [".jar", "-sources.jar"] .iter() .map(|suffix| dir.join(format!("{}-{}{suffix}", d.artifact, d.version))) .find(|p| p.is_file()) @@ -300,7 +300,7 @@ pub(crate) fn gradle_jar(home: &Path, d: &BuildDependency) -> Option { let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version); let mut hashes: Vec = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).collect(); hashes.sort(); - ["-sources.jar", ".jar"].iter().find_map(|suffix| { + [".jar", "-sources.jar"].iter().find_map(|suffix| { let file = format!("{}-{}{suffix}", d.artifact, d.version); hashes.iter().map(|h| h.join(&file)).find(|p| p.is_file()) }) diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 8ce5a774b..37adca8be 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -540,12 +540,12 @@ fn build_dependencies_resolve_to_local_jars() { std::fs::write(&p, "").unwrap(); p }; - touch(m2.join("com/google/guava/guava/33.0/guava-33.0.jar")); - let guava_src = touch(m2.join("com/google/guava/guava/33.0/guava-33.0-sources.jar")); + let guava = touch(m2.join("com/google/guava/guava/33.0/guava-33.0.jar")); + touch(m2.join("com/google/guava/guava/33.0/guava-33.0-sources.jar")); let junit = touch(m2.join("junit/junit/4.13/junit-4.13.jar")); let files = gradle.join("caches/modules-2/files-2.1/org.slf4j/slf4j-api/2.0.9"); - touch(files.join("a1b2/slf4j-api-2.0.9.jar")); - let slf4j_src = touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar")); + let slf4j = touch(files.join("a1b2/slf4j-api-2.0.9.jar")); + touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar")); let dep = |gradle: bool, g: &str, a: &str, v: &str| BuildDependency { gradle, group: g.into(), @@ -566,14 +566,14 @@ fn build_dependencies_resolve_to_local_jars() { assert_eq!( shown, [ - ("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0-sources.jar"), + ("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0.jar"), ("Maven: junit:junit:4.13", "maven", "junit-4.13.jar"), - ("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9-sources.jar"), + ("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9.jar"), ] ); - assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava_src))); + assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava))); assert_eq!(groups[1].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&junit))); - assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j_src))); + assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j))); // A Gradle build also finds jars installed in the Maven local repository (`mavenLocal()`). let groups = dependency_libraries(&[dep(true, "junit", "junit", "4.13")], Some(&m2), Some(&gradle)); assert_eq!(groups[0].name, "Gradle: junit:junit:4.13"); diff --git a/languages/java.toml b/languages/java.toml index 7e2e87c7f..7e7837b08 100644 --- a/languages/java.toml +++ b/languages/java.toml @@ -32,7 +32,7 @@ resolveAdditionalTextEditsSupport = true [language-servers.initialization-options.settings.java] autobuild = { enabled = true } -contentProvider = { preferred = "fernflower" } +contentProvider = { preferred = "fernflowerContentProvider" } maven = { downloadSources = true, updateSnapshots = false } eclipse = { downloadSources = true } references = { includeDecompiledSources = true } diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index c311cb8eb..765d7e755 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -95,8 +95,8 @@ pub async fn lsp_implementations( ide_host::ops::lsp::lsp_implementations(&state.host, window.label(), request, doc, positions).await } -/// Contents of a virtual document (library class, decompiled code) that a -/// language server returned a non-`file:` URI for. Cached per project. +/// Contents of a read-only language-server document, including JDT library +/// classes and disk class files. Cached per project. #[tauri::command] #[specta::specta] pub async fn lsp_virtual_document( @@ -107,6 +107,16 @@ pub async fn lsp_virtual_document( ide_host::ops::lsp::lsp_virtual_document(&state.host, window.label(), uri).await } +#[tauri::command] +#[specta::specta] +pub async fn lsp_resolve_class_file( + window: Window, + state: State<'_, AppState>, + path: std::path::PathBuf, +) -> IdeResult { + ide_host::ops::lsp::lsp_resolve_class_file(&state.host, window.label(), path).await +} + /// References of the symbol at `pos`. `project_wide` also searches project files /// outside the language server's analysis scope; bulk scans over many symbols /// leave it off. Cancel with `cancel_request(request)`. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5111d421b..ab121db65 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -201,6 +201,7 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_goto, lsp::lsp_implementations, lsp::lsp_virtual_document, + lsp::lsp_resolve_class_file, lsp::lsp_references, lsp::lsp_document_highlights, lsp::lsp_workspace_symbols, diff --git a/src/bindings.ts b/src/bindings.ts index 20ee0eba2..79eba7f80 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -372,10 +372,11 @@ export const commands = { */ lspImplementations: (request: RequestId, doc: DocId, positions: Pos[]) => typedError(__TAURI_INVOKE("lsp_implementations", { request, doc, positions })), /** - * Contents of a virtual document (library class, decompiled code) that a - * language server returned a non-`file:` URI for. Cached per project. + * Contents of a read-only language-server document, including JDT library + * classes and disk class files. Cached per project. */ lspVirtualDocument: (uri: string) => typedError(__TAURI_INVOKE("lsp_virtual_document", { uri })), + lspResolveClassFile: (path: string) => typedError(__TAURI_INVOKE("lsp_resolve_class_file", { path })), /** * References of the symbol at `pos`. `project_wide` also searches project files * outside the language server's analysis scope; bulk scans over many symbols @@ -6310,7 +6311,7 @@ export type VirtualDocument = { title: string, /** Origin shown in the tooltip / banner, e.g. the jar path. */ description: string | null, - /** Generated by a decompiler / disassembler rather than real sources. */ + /** Generated Java from a decompiler, rather than real source or a disassembler stub. */ decompiled: boolean, }; diff --git a/src/editor/api.ts b/src/editor/api.ts index 8ce6db51b..9f2ab9a0f 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -42,7 +42,7 @@ export interface EditorDocContext { /** * A read-only virtual document (library class contents from a language * server); `path` is then its URI, not a file: skip file-based features - * (VCS, local history, TODO scanning, breakpoints…). + * (VCS, local history, TODO scanning…). JDT class documents support breakpoints. */ virtual?: boolean; } diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 5c2250168..8e4b020cf 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -36,7 +36,7 @@ export interface DocPatchedEvent { export interface DocBackend { open(path: string): Promise; - /** Opens the read-only virtual document behind a non-`file:` URI (library class contents). */ + /** Opens a read-only language-server document (library or disk class contents). */ openVirtual(uri: string): Promise; close(doc: DocId): Promise; edit(doc: DocId, baseVersion: number, changes: TextChange[]): Promise; diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index be343fbf4..90e12af6d 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -89,6 +89,7 @@ import { NavigationHistory } from "./model/navHistory"; import { EditHistory } from "./model/editHistory"; import { loadSession, restoreTabsState, serializeSession, storeSession, type FileViewState } from "./model/session"; import { docBackend, type DocBackend } from "./backend"; +import { isClassFile } from "@/platform/classFiles"; import { isVirtualUri, virtualDocument } from "@/platform/virtualDocs"; import type { VirtualDocument } from "@/platform/ipc"; import type { Scheduler } from "./sync/DocSync"; @@ -860,9 +861,21 @@ export class EditorController implements SlotHost { throw new Error("The project is closing"); } - async openFile(path: string, o: OpenOptions = {}) { + async openFile(path: string, o: OpenOptions = {}): Promise { const generation = this.openingGeneration(); if (isVirtualUri(path)) return this.openVirtual(path, o); + if (isClassFile(path)) { + await this.projectTransition; + this.checkOpening(generation); + try { + const target = await call(commands.lspResolveClassFile(path)); + this.checkOpening(generation); + return isVirtualUri(target) ? this.openVirtual(target, o) : this.openFile(target, o); + } catch (e) { + notifications().notify({ level: "warning", title: "Cannot open library class", body: String(e), group: "lsp" }); + return; + } + } await this.projectTransition; this.checkOpening(generation); // Images, binary and huge files open in a contributed viewer unless already open as text. @@ -901,6 +914,7 @@ export class EditorController implements SlotHost { } async openInput(input: EditorInput, o: OpenOptions = {}) { + if (input.path && isClassFile(input.path)) return this.openFile(input.path, o); return this.admitInput(input, o, this.openingGeneration()); } diff --git a/src/editor/core/view/Banner.tsx b/src/editor/core/view/Banner.tsx index 0023ee02a..963665f22 100644 --- a/src/editor/core/view/Banner.tsx +++ b/src/editor/core/view/Banner.tsx @@ -3,6 +3,7 @@ import { Icon, T } from "@/ui"; import { useEditorBanners, type EditorBannerContribution } from "@/workbench/contributions"; import { useDocMeta } from "../model/document"; import styles from "./EditorArea.module.css"; +import { classFileBreakpointReason } from "@/platform/classFiles"; import { tx } from "@/platform/i18n"; const TEXT = { @@ -28,12 +29,22 @@ export const documentBanner: EditorBannerContribution = { * Notice above a library class opened from a language server (IDEA's * "Decompiled .class file" / library source bar): read-only, with its origin. */ -export function VirtualBanner({ decompiled, description }: { decompiled: boolean; description: string | null }) { +export function VirtualBanner({ + decompiled, + description, + path, +}: { + decompiled: boolean; + description: string | null; + path: string; +}) { + const reason = classFileBreakpointReason(path); return (
- + {decompiled ? tx("Decompiled .class file: sources are not available.") : tx("Library source (read-only).")} + {reason && ` ${tx(reason)}`} {description && ( diff --git a/src/editor/core/view/EditorArea.module.css b/src/editor/core/view/EditorArea.module.css index 40fbc0dd1..593cd1ac4 100644 --- a/src/editor/core/view/EditorArea.module.css +++ b/src/editor/core/view/EditorArea.module.css @@ -90,6 +90,10 @@ background: var(--bg-feedback-warning); border-bottom-color: var(--border-feedback-warning); } +.virtualBannerText { + min-width: 0; + padding: 8px 0; +} .bannerText { flex: none; white-space: nowrap; diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index bd4b709a3..cc172aa56 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -398,7 +398,7 @@ function PaneContent({ paneId, tab, tabsVisible }: { paneId: string; tab: Tab; t return ( <> {virtual ? ( - + ) : ( )} diff --git a/src/editor/core/virtual.test.ts b/src/editor/core/virtual.test.ts index e4417eb4d..aaa464afa 100644 --- a/src/editor/core/virtual.test.ts +++ b/src/editor/core/virtual.test.ts @@ -10,7 +10,7 @@ vi.mock("@/platform/virtualDocs", async (importOriginal) => { return { ...actual, virtualDocument: vi.fn(async (uri: string): Promise => { - if (uri !== JDT) throw new Error(`no contents for ${uri}`); + if (uri !== JDT && uri !== "file:///p/target/classes/Lib.class") throw new Error(`no contents for ${uri}`); return { uri, text: TEXT, @@ -30,6 +30,7 @@ const { serializeSession } = await import("./model/session"); const { useDocMeta } = await import("./model/document"); const { registerMenuGroup } = await import("@/workbench/contributions"); const { registerActions } = await import("@/platform/actions"); +const { commands } = await import("@/platform/ipc"); const { resolveMenuItems } = await import("@/ui"); let ctrl: InstanceType | null = null; @@ -55,6 +56,24 @@ describe("virtual editor input", () => { expect(isTextInput({ kind: "diff", key: "d" })).toBe(false); }); + it.each([ + ["/m2/lib.jar!/org/example/Lib.class", JDT, "virtual"], + ["/p/target/classes/Lib.class", "/p/Lib.java", "file"], + ["/p/build/classes/Lib.class", "file:///p/target/classes/Lib.class", "virtual"], + ])("routes %s before choosing a binary viewer", async (path, target, kind) => { + const resolve = vi.spyOn(commands, "lspResolveClassFile").mockResolvedValue({ status: "ok", data: target }); + ctrl = new EditorController(createMemoryBackend({ [target]: TEXT })); + ctrl.start(); + try { + await ctrl.openFile(path!, { focus: false }); + expect(resolve).toHaveBeenCalledWith(path); + expect(activeTab().input).toMatchObject({ path: target, kind }); + expect(ctrl.slotFor(activeTab().id)!.model.ctx().readOnly).toBe(kind === "virtual"); + } finally { + resolve.mockRestore(); + } + }); + it("opens a URI read-only at the target and keeps it out of the saved session", async () => { ctrl = new EditorController(createMemoryBackend({ "/p/App.java": "class App {}\n", [JDT]: TEXT })); ctrl.start(); diff --git a/src/features/debugger/__tests__/actions.test.ts b/src/features/debugger/__tests__/actions.test.ts index 0fe45b753..295b6944e 100644 --- a/src/features/debugger/__tests__/actions.test.ts +++ b/src/features/debugger/__tests__/actions.test.ts @@ -268,3 +268,23 @@ describe("debugger actions (IDEA parity)", () => { expect(useOverlays.getState().evaluate?.expression).toBe("total = order.items\nfor i in x:"); }); }); + +describe("output class breakpoint restrictions", () => { + it.each([ + "debug.toggleBreakpoint", + "debug.toggleTemporaryBreakpoint", + "debug.toggleMethodBreakpoint", + "debug.toggleFieldWatchpoint", + "debug.runToCursor", + "debug.forceRunToCursor", + ])("blocks %s for a source-less output class", async (id) => { + paused(); + const path = "file:///p/target/classes/p/C.class"; + caret(path, "java", "class C {\n int n;\n void run() {}\n}", 2); + await executeAction(id); + expect(breakpointsOf(path)).toHaveLength(0); + expect(useSpecialBreakpoints.getState().methods).toHaveLength(0); + expect(useSpecialBreakpoints.getState().fields).toHaveLength(0); + expect(ipc.debugRunTo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/debugger/__tests__/editor.test.ts b/src/features/debugger/__tests__/editor.test.ts index 57d96067d..b975cca61 100644 --- a/src/features/debugger/__tests__/editor.test.ts +++ b/src/features/debugger/__tests__/editor.test.ts @@ -53,6 +53,32 @@ describe("breakpoint gutter", () => { }); }); +describe("output class gutter", () => { + it("disables ordinary, temporary and logging breakpoints and explains why", () => { + loadBreakpoints("/proj"); + const path = "file:///proj/target/classes/C.class"; + const view = new EditorView({ + state: EditorState.create({ + doc: "class C {}", + extensions: [ + lineNumbers(), + breakpointGutter({ ...ctx, path, language: "java", readOnly: true, virtual: true }), + ], + }), + parent: document.body, + }); + views.push(view); + const cell = view.dom.querySelector(".cm-lineNumbers .cm-gutterElement")!; + cell.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + expect(cell.closest(".cm-gutters")?.getAttribute("title")).toMatch(/output classes without source code/); + for (const modifier of [{}, { altKey: true }, { shiftKey: true }]) { + cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ...modifier })); + cell.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0, ...modifier })); + } + expect(breakpointsOf(path)).toHaveLength(0); + }); +}); + describe("breakpoint look", () => { it("shows the verified icon only while a session has bound it", () => { const b = { id: "1", line: 0, enabled: true, condition: null, logMessage: null, hitCondition: null }; diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 78a08fe4e..3112affc7 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -5,6 +5,7 @@ import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { activeEditor } from "@/editor/api"; import { maybeEditorService, notifications, showStatusInfo, toolWindowService } from "@/workbench/contributions"; +import { classFileBreakpointReason } from "@/platform/classFiles"; import { canPlaceBreakpoint } from "./breakpoints/languages"; import { removeAllBreakpoints, @@ -75,10 +76,18 @@ function pausedTarget(): { s: DebugSession; thread: number } | null { return thread == null ? null : { s, thread }; } +function breakpointPathAllowed(path: string): boolean { + const reason = classFileBreakpointReason(path); + if (!reason) return true; + showStatusInfo(tx(reason)); + return false; +} + /** Run to Cursor at `line` (0-based) of `path` in the paused session. */ export function runToLine(path: string, line: number) { const t = pausedTarget(); - if (t) void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false))); + if (t && breakpointPathAllowed(path)) + void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false))); } function notSupported(what: string, s: DebugSession) { @@ -518,7 +527,7 @@ export function registerDebugActions(): () => void { run: () => { const t = pausedTarget(); const c = cursor(); - if (t && c) + if (t && c && breakpointPathAllowed(c.path)) void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false))); }, }, @@ -532,7 +541,7 @@ export function registerDebugActions(): () => void { run: () => { const t = pausedTarget(); const c = cursor(); - if (t && c) + if (t && c && breakpointPathAllowed(c.path)) void report("Force Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true))); }, }, @@ -543,7 +552,7 @@ export function registerDebugActions(): () => void { icon: "breakpoint", run: () => { const c = cursor(); - if (!c) return; + if (!c || !breakpointPathAllowed(c.path)) return; const exists = useBreakpoints.getState().byFile[c.path]?.some((b) => b.line === c.line); const language = activeEditor()?.ctx.language ?? ""; if (!exists && !canPlaceBreakpoint(language)) { @@ -568,7 +577,8 @@ export function registerDebugActions(): () => void { description: "A line breakpoint that is removed after it is hit", run: () => { const c = cursor(); - if (c && canPlaceBreakpoint(activeEditor()?.ctx.language ?? "")) toggleTemporaryBreakpoint(c.path, c.line); + if (c && breakpointPathAllowed(c.path) && canPlaceBreakpoint(activeEditor()?.ctx.language ?? "", c.path)) + toggleTemporaryBreakpoint(c.path, c.line); }, }, { @@ -579,7 +589,7 @@ export function registerDebugActions(): () => void { run: () => { const ed = activeEditor(); const c = cursor(); - if (!ed || !c) return; + if (!ed || !c || !breakpointPathAllowed(c.path)) return; const existing = specialAt(c.path).find((b) => b.kind === "method" && b.line === c.line); const method = methodAt(ed.ctx.language, ed.view.state.doc.toString(), c.line); if (!existing && !method) { @@ -602,7 +612,7 @@ export function registerDebugActions(): () => void { run: () => { const ed = activeEditor(); const c = cursor(); - if (!ed || !c) return; + if (!ed || !c || !breakpointPathAllowed(c.path)) return; const existing = specialAt(c.path).find((b) => b.kind === "field" && b.line === c.line); const field = fieldAt(ed.ctx.language, ed.view.state.doc.toString(), c.line); if (!existing && !field) { diff --git a/src/features/debugger/breakpoints/languages.ts b/src/features/debugger/breakpoints/languages.ts index 1496645db..e532b40d3 100644 --- a/src/features/debugger/breakpoints/languages.ts +++ b/src/features/debugger/breakpoints/languages.ts @@ -3,6 +3,7 @@ * when off, new line breakpoints go only into files of languages that have a * debug adapter (`languages/*.toml` `[debugger]`). */ +import { classFileBreakpointReason } from "@/platform/classFiles"; import { commands, isTauri } from "@/platform/ipc"; import { workflowSettings } from "@/platform/workflowSettings"; @@ -22,7 +23,8 @@ function load() { } /** Whether a new breakpoint may be placed in a file of `language`. */ -export function canPlaceBreakpoint(language: string): boolean { +export function canPlaceBreakpoint(language: string, path = ""): boolean { + if (classFileBreakpointReason(path)) return false; if (workflowSettings().debugger.breakpointsAnyFile) return true; load(); // Until the language list is known, don't block anything. diff --git a/src/features/debugger/editor/breakpointGutter.ts b/src/features/debugger/editor/breakpointGutter.ts index 075dc954b..ada2140ce 100644 --- a/src/features/debugger/editor/breakpointGutter.ts +++ b/src/features/debugger/editor/breakpointGutter.ts @@ -15,6 +15,9 @@ import { updateBreakpoint, useBreakpoints, } from "../breakpoints/store"; +import { classFileBreakpointReason } from "@/platform/classFiles"; +import { tx } from "@/platform/i18n"; +import { showStatusInfo } from "@/workbench/contributions"; import { canPlaceBreakpoint } from "../breakpoints/languages"; import { removeSpecial, @@ -129,6 +132,31 @@ function inBreakpointArea(e: MouseEvent): boolean { } export function breakpointGutter(ctx: EditorDocContext): Extension { + const reason = classFileBreakpointReason(ctx.path); + if (reason) { + const message = tx(reason); + return ViewPlugin.fromClass( + class { + constructor(readonly view: EditorView) { + view.dom.addEventListener("mouseover", this.hover); + view.dom.addEventListener("mousedown", this.down, true); + } + hover = (e: MouseEvent) => { + if (inBreakpointArea(e)) (e.target as HTMLElement).closest(".cm-gutters")?.setAttribute("title", message); + }; + down = (e: MouseEvent) => { + if (!inBreakpointArea(e)) return; + showStatusInfo(message); + }; + destroy() { + this.view.dom.removeEventListener("mouseover", this.hover); + this.view.dom.removeEventListener("mousedown", this.down, true); + const gutters = this.view.dom.querySelector(".cm-gutters"); + if (gutters?.getAttribute("title") === message) gutters.removeAttribute("title"); + } + }, + ); + } const path = ctx.path; const field = StateField.define>({ create: (state) => buildMarkers(state, path), diff --git a/src/features/debugger/editor/runToCursor.ts b/src/features/debugger/editor/runToCursor.ts index f1f3ca7d2..1db51c42c 100644 --- a/src/features/debugger/editor/runToCursor.ts +++ b/src/features/debugger/editor/runToCursor.ts @@ -3,6 +3,7 @@ * line shows a small button left of its text that runs to that line (Resume on * the execution line itself). Settings › Debugger › "Show inlay Run to Cursor popup". */ +import { classFileBreakpointReason } from "@/platform/classFiles"; import type { Extension } from "@codemirror/state"; import { EditorView, ViewPlugin } from "@codemirror/view"; import type { EditorDocContext } from "@/editor/api"; @@ -30,7 +31,7 @@ export function hoveredLine( } export function runToCursorInlay(ctx: EditorDocContext): Extension { - if (ctx.virtual) return []; + if (classFileBreakpointReason(ctx.path) || (ctx.virtual && !ctx.path.startsWith("jdt:"))) return []; const path = ctx.path; return ViewPlugin.fromClass( class { diff --git a/src/platform/classFiles.test.ts b/src/platform/classFiles.test.ts new file mode 100644 index 000000000..a7c869099 --- /dev/null +++ b/src/platform/classFiles.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { classFileBreakpointReason, isClassFile } from "./classFiles"; +import { isVirtualUri } from "./virtualDocs"; +import { canPlaceBreakpoint } from "@/features/debugger/breakpoints/languages"; +vi.mock("@/platform/workflowSettings", () => ({ + workflowSettings: () => ({ debugger: { breakpointsAnyFile: true } }), +})); + +describe("compiled class routing and breakpoint policy", () => { + it.each([ + "/p/target/classes/p/C.class", + "/p/build/classes/java/main/p/C$1.class", + "C:\\p\\out\\C.class", + "file:///p/out/C.class", + ])("disables output breakpoints even with any-file enabled: %s", (path) => { + expect(isClassFile(path)).toBe(true); + expect(classFileBreakpointReason(path)).toBeTruthy(); + expect(canPlaceBreakpoint("java", path)).toBe(false); + }); + it.each([ + "jdt://contents/a.jar/p/C.class?=p/a.jar%3Cp%28C.class", + "jdt://contents/a.jar/p/C.java?=p/a.jar%3Cp%28C.class", + "/p/src/main/java/p/C.java", + ])("keeps source and JDT breakpoints enabled: %s", (path) => { + expect(isClassFile(path)).toBe(false); + expect(classFileBreakpointReason(path)).toBeNull(); + expect(canPlaceBreakpoint("java", path)).toBe(true); + }); + it("treats disk class URIs as read-only server documents", () => { + expect(isVirtualUri("file:///p/out/C.class")).toBe(true); + expect(isVirtualUri("file:///p/src/C.java")).toBe(false); + expect(isClassFile("/m2/a.jar!/p/C.class")).toBe(true); + }); +}); diff --git a/src/platform/classFiles.ts b/src/platform/classFiles.ts new file mode 100644 index 000000000..8af7e7351 --- /dev/null +++ b/src/platform/classFiles.ts @@ -0,0 +1,8 @@ +/** Compiled Java files are resolved before the binary viewer, at every opening entry. */ +export function isClassFile(path: string): boolean { + return !path.startsWith("jdt:") && /\.class$/i.test(path); +} + +export function classFileBreakpointReason(path: string): string | null { + return isClassFile(path) ? "Breakpoints are not supported in output classes without source code." : null; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index f66b845de..0f69670d7 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4467,6 +4467,7 @@ "Plan node properties": "计划节点属性", "Values": "值", "Vim rc file": "Vim rc 文件", + "Breakpoints are not supported in output classes without source code.": "输出目录中无源码的类不支持断点。", "Decompiled .class file: sources are not available.": "已反编译的 .class 文件:没有可用的源代码。", "No column of the file matches the table": "文件中没有与该表匹配的列", "Not a URL of PostgreSQL, MySQL, SQLite or a compatible database": "不是 PostgreSQL、MySQL、SQLite 或兼容数据库的 URL", diff --git a/src/platform/virtualDocs.ts b/src/platform/virtualDocs.ts index 8a31ef0ef..f0ced78e8 100644 --- a/src/platform/virtualDocs.ts +++ b/src/platform/virtualDocs.ts @@ -4,12 +4,13 @@ * code). Locations into them carry the URI as their `path`; the editor opens * them as `virtual` editor inputs instead of files. */ +import { isClassFile } from "./classFiles"; import { call, commands, type VirtualDocument } from "./ipc"; -/** True for `scheme:…` URIs other than `file:` (scheme of 2+ chars, so `C:\…` is a path). */ +/** Server document URIs, including decompiled disk classes; drive letters remain paths. */ export function isVirtualUri(s: string | null | undefined): boolean { const m = /^([a-zA-Z][a-zA-Z0-9+.-]+):/.exec(s ?? ""); - return !!m && m[1]!.toLowerCase() !== "file"; + return !!m && (m[1]!.toLowerCase() !== "file" || isClassFile(s!)); } /** Last path segment of a URI (without query / fragment), e.g. `SpringApplication.class`. */ From 1716f3e062fbadab5b7e5dc7408077d8dedba2f5 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:14:13 +0800 Subject: [PATCH 04/21] feat(diff): compare directories and synchronize entries (#661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two folders can now be compared from the project view (Compare Directories with two folders selected, or Compare With… on one folder, also in Find Action). The result tree marks entries as only on left, only on right, contents differ or identical, with a name filter and a show-identical toggle; double-click opens the existing diff editor, and files or whole subdirectories can be copied to either side (overwrite and delete ask first, extra files in the target are kept). Comparison runs in the background with progress and cancel, honours the project's exclusions, and works for remote projects through the same file commands. The comparison tree and diff preview are shared with the Git folder/revision comparison. Verified on macOS (subdirectories, Chinese names, binary files, copy both ways, delete, cancel on large files); Windows and Linux not run. Fixes #520 --- crates/ide-host/src/ops/fs.rs | 67 ++- crates/ide-host/src/rpc.rs | 23 + crates/ide-host/tests/rpc.rs | 33 ++ crates/ide-vfs/src/compare.rs | 505 ++++++++++++++++++ crates/ide-vfs/src/lib.rs | 4 + src-tauri/src/commands/fs.rs | 32 ++ src-tauri/src/commands/mod.rs | 3 + src/bindings.ts | 22 + src/features/project-tree/contextMenu.test.ts | 11 +- src/features/project-tree/contextMenu.ts | 16 +- src/features/vcs/ComparisonTree.tsx | 78 +++ src/features/vcs/DiffEditor.tsx | 23 +- src/features/vcs/DirectoryCompare.tsx | 291 ++++++++++ .../vcs/__tests__/diffToolbar.test.tsx | 15 + .../vcs/__tests__/directoryCompare.test.tsx | 179 +++++++ .../__tests__/directoryCompareActions.test.ts | 95 ++++ src/features/vcs/__tests__/fileTree.test.ts | 17 + src/features/vcs/common.module.css | 11 + src/features/vcs/compare.tsx | 72 ++- src/features/vcs/compareData.ts | 16 + src/features/vcs/fileTree.ts | 18 +- src/features/vcs/operations.tsx | 75 +-- src/platform/locales/text/zh.json | 33 +- 23 files changed, 1552 insertions(+), 87 deletions(-) create mode 100644 crates/ide-vfs/src/compare.rs create mode 100644 src/features/vcs/ComparisonTree.tsx create mode 100644 src/features/vcs/DirectoryCompare.tsx create mode 100644 src/features/vcs/__tests__/directoryCompare.test.tsx create mode 100644 src/features/vcs/__tests__/directoryCompareActions.test.ts create mode 100644 src/features/vcs/compareData.ts diff --git a/crates/ide-host/src/ops/fs.rs b/crates/ide-host/src/ops/fs.rs index 10681ca31..fb55f6f0f 100644 --- a/crates/ide-host/src/ops/fs.rs +++ b/crates/ide-host/src/ops/fs.rs @@ -1,8 +1,73 @@ +use super::Channel; use crate::Host; -use ide_core::IdeResult; +use ide_core::{IdeResult, RequestId}; use ide_vfs::FileEntry; use std::path::PathBuf; +pub async fn entry_kind(host: &Host, path: PathBuf) -> IdeResult { + host.blocking(move || { + let meta = std::fs::metadata(path)?; + Ok(if meta.is_dir() { ide_vfs::EntryKind::Dir } else { ide_vfs::EntryKind::File }) + }) + .await +} + +pub async fn compare_directories( + host: &Host, + window: &str, + left: PathBuf, + right: PathBuf, + request: RequestId, + on_progress: Channel, +) -> IdeResult> { + let project = host.project(window)?; + let cancel = host.begin_request(request); + let result = host + .blocking(move || { + let last = std::cell::Cell::new(std::time::Instant::now() - std::time::Duration::from_secs(1)); + ide_vfs::compare_directories(&left, &right, Some(&project.root), &project.exclude_globs(), &cancel, |p| { + // Bound IPC traffic even when hashing multi-gigabyte files. + if last.get().elapsed() >= std::time::Duration::from_millis(80) || p.total == Some(p.compared) { + let _ = on_progress.send(p); + last.set(std::time::Instant::now()); + } + }) + }) + .await; + host.end_request(request); + result +} + +pub async fn copy_merge( + host: &Host, + window: &str, + source_root: PathBuf, + destination_root: PathBuf, + path: PathBuf, + overwrite: bool, +) -> IdeResult<()> { + if path.as_os_str().is_empty() || path.components().any(|c| !matches!(c, std::path::Component::Normal(_))) { + return Err(ide_core::IdeError::invalid("expected a relative comparison entry path")); + } + let project = host.project(window)?; + host.blocking(move || { + let from = source_root.join(&path); + let to = destination_root.join(&path); + if overwrite { + project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT); + } + ide_vfs::copy_merge( + &from, + &to, + overwrite, + Some(&project.root), + &project.exclude_globs(), + (&source_root, &destination_root), + ) + }) + .await +} + /// Files of a deleted folder snapshotted in Local History. const DELETE_SNAPSHOT_LIMIT: usize = 2000; diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index e34db1e4a..78605b1e0 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -179,6 +179,7 @@ impl Dispatcher { }) .add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::("id")?)) }) .add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) }) + .add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await }) .add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) }) .add("fs_create_file_bytes", |c| async move { ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await @@ -190,6 +191,28 @@ impl Dispatcher { }) .add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await }) .add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await }) + .add("fs_copy_merge", |c| async move { + ops::fs::copy_merge( + &c.host, + &c.window, + c.arg("sourceRoot")?, + c.arg("destinationRoot")?, + c.arg("path")?, + c.arg("overwrite")?, + ) + .await + }) + .add("fs_compare_directories", |c| async move { + ops::fs::compare_directories( + &c.host, + &c.window, + c.arg("left")?, + c.arg("right")?, + c.arg("request")?, + c.sender("onProgress")?, + ) + .await + }) .add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await }) .add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await }) .add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) }) diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index d16f408fc..58e4ede39 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -94,6 +94,39 @@ async fn serves_projects_files_documents_search_and_runs() { let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap(); assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}"); + // Directory comparisons, streamed progress and confirmed copies share the remote filesystem. + let left = project.join("compare-left"); + let right = project.join("compare-right"); + std::fs::create_dir_all(&left).unwrap(); + std::fs::create_dir_all(&right).unwrap(); + std::fs::write(left.join("中文.bin"), [0, 255, 1]).unwrap(); + std::fs::write(right.join("中文.bin"), [0, 254, 1]).unwrap(); + assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left})).await.unwrap(), "dir"); + assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left.join("中文.bin")})).await.unwrap(), "file"); + let compare_args = + |request| json!({"left": left, "right": right, "request": request, "onProgress": {"$channel": 520}}); + let rows = invoke(&peer, "fs_compare_directories", compare_args(520)).await.unwrap(); + assert_eq!(rows[0]["status"], "different"); + assert!(client.channels.lock().iter().any(|c| c.id == 520 && c.data["compared"] == 1)); + let copy_args = + |overwrite| json!({"sourceRoot": left, "destinationRoot": right, "path": "中文.bin", "overwrite": overwrite}); + let error = invoke(&peer, "fs_copy_merge", copy_args(false)).await.unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::AlreadyExists); + invoke(&peer, "fs_copy_merge", copy_args(true)).await.unwrap(); + let rows = invoke(&peer, "fs_compare_directories", compare_args(521)).await.unwrap(); + assert_eq!(rows[0]["status"], "equal"); + invoke(&peer, "cancel_request", json!({"id": 522})).await.unwrap(); + let error = invoke(&peer, "fs_compare_directories", compare_args(522)).await.unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::Cancelled); + let error = invoke( + &peer, + "fs_copy_merge", + json!({"sourceRoot": left, "destinationRoot": right, "path": "../escape", "overwrite": true}), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + // JVM icons use the same read-only protocol as local windows. let java = project.join("src/Foo.java"); let kotlin = project.join("src/Singleton.kt"); diff --git a/crates/ide-vfs/src/compare.rs b/crates/ide-vfs/src/compare.rs new file mode 100644 index 000000000..8f29b1008 --- /dev/null +++ b/crates/ide-vfs/src/compare.rs @@ -0,0 +1,505 @@ +//! Disk comparisons use the same excludes and layered ignore files as the project tree. +use crate::{EntryKind, ExcludeMatcher, rules::GitignoreStack}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum DirectoryCompareStatus { + LeftOnly, + RightOnly, + Different, + Equal, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryCompareEntry { + pub path: String, + pub status: DirectoryCompareStatus, + pub left_kind: Option, + pub right_kind: Option, + #[specta(optional)] + pub left_link: Option, + #[specta(optional)] + pub right_link: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryCompareProgress { + pub scanned: u32, + pub compared: u32, + pub total: Option, + pub bytes_read: f64, + pub path: String, +} + +struct Entry { + kind: EntryKind, + size: u64, + link: Option, +} + +fn check(cancel: &CancellationToken) -> IdeResult<()> { + if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(()) } +} + +struct Rules { + base: PathBuf, + excludes: ExcludeMatcher, + ignore: GitignoreStack, +} + +impl Rules { + fn new(root: &Path, project: Option<&Path>, excludes: &[String]) -> Self { + Self { + base: project.filter(|p| root.starts_with(p)).unwrap_or(root).to_path_buf(), + excludes: ExcludeMatcher::new(excludes), + ignore: GitignoreStack::new(root), + } + } + + fn excluded(&self, path: &Path, dir: bool) -> bool { + path.file_name().is_some_and(|n| n == ".git") + || self.excludes.is_excluded(path.strip_prefix(&self.base).unwrap_or(path)) + || self.ignore.is_ignored(path, dir) + } +} + +fn scan( + root: &Path, + rules: &Rules, + cancel: &CancellationToken, + progress: &mut DirectoryCompareProgress, + emit: &impl Fn(DirectoryCompareProgress), +) -> IdeResult> { + if !fs::metadata(root)?.is_dir() { + return Err(IdeError::invalid(format!("{} is not a directory", root.display()))); + } + let mut result = BTreeMap::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + check(cancel)?; + for item in fs::read_dir(dir)? { + check(cancel)?; + let path = item?.path(); + let meta = fs::symlink_metadata(&path)?; + if rules.excluded(&path, meta.is_dir()) { + continue; + } + let kind = if meta.file_type().is_symlink() { + EntryKind::Symlink + } else if meta.is_dir() { + pending.push(path.clone()); + EntryKind::Dir + } else if meta.is_file() { + EntryKind::File + } else { + continue; + }; + let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?; + let rel = ide_core::ipc_path::to_ipc(rel); + let link = if kind == EntryKind::Symlink { Some(fs::read_link(&path)?) } else { None }; + result.insert(rel.clone(), Entry { kind, size: meta.len(), link }); + progress.scanned += 1; + progress.path = rel; + emit(progress.clone()); + } + } + Ok(result) +} + +fn hash( + path: &Path, + cancel: &CancellationToken, + progress: &mut DirectoryCompareProgress, + emit: &impl Fn(DirectoryCompareProgress), +) -> IdeResult { + let mut file = fs::File::open(path)?; + let mut hash = gix::hash::hasher(gix::hash::Kind::Sha256); + let mut buffer = [0; 64 * 1024]; + loop { + check(cancel)?; + let len = file.read(&mut buffer)?; + if len == 0 { + break; + } + hash.update(&buffer[..len]); + progress.bytes_read += len as f64; + emit(progress.clone()); + } + hash.try_finalize().map_err(|e| IdeError::internal(e.to_string())) +} + +/// Size checks avoid reading unequal files. Equal-sized files are hashed in bounded, +/// cancellable chunks; symlinks compare their link text and are never traversed. +pub fn compare_directories( + left: &Path, + right: &Path, + project: Option<&Path>, + excludes: &[String], + cancel: &CancellationToken, + emit: impl Fn(DirectoryCompareProgress), +) -> IdeResult> { + check(cancel)?; + let mut progress = DirectoryCompareProgress::default(); + emit(progress.clone()); + let l = scan(left, &Rules::new(left, project, excludes), cancel, &mut progress, &emit)?; + let r = scan(right, &Rules::new(right, project, excludes), cancel, &mut progress, &emit)?; + let mut paths: Vec<_> = l.keys().chain(r.keys()).cloned().collect(); + paths.sort(); + paths.dedup(); + progress.total = Some(paths.len() as u32); + let mut rows = BTreeMap::new(); + for path in paths { + check(cancel)?; + progress.path.clone_from(&path); + let status = match (l.get(&path), r.get(&path)) { + (Some(_), None) => DirectoryCompareStatus::LeftOnly, + (None, Some(_)) => DirectoryCompareStatus::RightOnly, + (Some(a), Some(b)) => { + let equal = a.kind == b.kind + && match a.kind { + EntryKind::Dir => true, + EntryKind::Symlink => a.link == b.link, + EntryKind::File => { + a.size == b.size + && hash(&left.join(&path), cancel, &mut progress, &emit)? + == hash(&right.join(&path), cancel, &mut progress, &emit)? + } + }; + if equal { DirectoryCompareStatus::Equal } else { DirectoryCompareStatus::Different } + } + _ => unreachable!(), + }; + rows.insert( + path.clone(), + DirectoryCompareEntry { + path: path.clone(), + status, + left_kind: l.get(&path).map(|e| e.kind), + right_kind: r.get(&path).map(|e| e.kind), + left_link: l.get(&path).and_then(|e| e.link.as_ref()).map(|p| p.to_string_lossy().into_owned()), + right_link: r.get(&path).and_then(|e| e.link.as_ref()).map(|p| p.to_string_lossy().into_owned()), + }, + ); + progress.compared += 1; + emit(progress.clone()); + } + let changed: Vec<_> = + rows.values().filter(|r| r.status != DirectoryCompareStatus::Equal).map(|r| r.path.clone()).collect(); + for path in changed { + for parent in Path::new(&path).ancestors().skip(1) { + if let Some(row) = rows.get_mut(&ide_core::ipc_path::to_ipc(parent)) + && row.status == DirectoryCompareStatus::Equal + { + row.status = DirectoryCompareStatus::Different; + } + } + } + check(cancel)?; + Ok(rows.into_values().collect()) +} + +/// Merges a subtree without following links inside the comparison roots. Existing files +/// require explicit overwrite; directory merges preserve destination-only entries. +pub fn copy_merge( + from: &Path, + to: &Path, + overwrite: bool, + project: Option<&Path>, + excludes: &[String], + roots: (&Path, &Path), +) -> IdeResult<()> { + fn resolve(path: &Path) -> IdeResult { + if path.exists() { + return Ok(dunce::canonicalize(path)?); + } + let parent = path.parent().ok_or_else(|| IdeError::invalid("copy requires an absolute path"))?; + Ok(resolve(parent)?.join(path.file_name().ok_or_else(|| IdeError::invalid("missing file name"))?)) + } + if !from.is_absolute() || !to.is_absolute() { + return Err(IdeError::invalid("copy requires absolute paths")); + } + if !from.starts_with(roots.0) || !to.starts_with(roots.1) { + return Err(IdeError::invalid("copy paths must belong to the comparison roots")); + } + let source = resolve(from)?; + let target = resolve(to)?; + if source.starts_with(&target) || target.starts_with(&source) { + return Err(IdeError::invalid("cannot copy overlapping paths")); + } + for parent in to.ancestors().skip(1) { + // The user explicitly selected this root; aliases above it (e.g. /tmp + // on macOS) are valid. Links inside the copied subtree are not followed. + if to == roots.1 || parent == roots.1 { + break; + } + if fs::symlink_metadata(parent).is_ok_and(|m| m.file_type().is_symlink()) { + return Err(IdeError::invalid("cannot copy through a destination symlink")); + } + } + let source_base = project.filter(|p| from.starts_with(p)).or(Some(roots.0)); + let target_base = project.filter(|p| to.starts_with(p)).or(Some(roots.1)); + let rules = Rules::new(from, source_base, excludes); + let target_rules = Rules::new(to, target_base, excludes); + let mut pending = vec![(from.to_path_buf(), to.to_path_buf())]; + while let Some((src, dst)) = pending.pop() { + let meta = fs::symlink_metadata(&src)?; + if rules.excluded(&src, meta.is_dir()) || target_rules.excluded(&dst, meta.is_dir()) { + continue; + } + let existing = fs::symlink_metadata(&dst).ok(); + let merge = meta.is_dir() && existing.as_ref().is_some_and(|m| m.is_dir()); + if existing.is_some() && !merge && !overwrite { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, dst.display().to_string()).into()); + } + if meta.is_dir() { + if existing.is_some() && !merge { + crate::delete(&dst)?; + } + fs::create_dir_all(&dst)?; + for entry in fs::read_dir(&src)? { + let entry = entry?; + pending.push((entry.path(), dst.join(entry.file_name()))); + } + } else { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + // Stage the complete file before touching its destination. + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let temp = dst.with_file_name(format!(".openidea-copy-{}-{id}", std::process::id())); + let mut staged = false; + let result: IdeResult<()> = (|| { + #[cfg(windows)] + if meta.file_type().is_symlink() { + use std::os::windows::fs::{FileTypeExt, symlink_dir, symlink_file}; + let target = fs::read_link(&src)?; + if meta.file_type().is_symlink_dir() { + symlink_dir(target, &temp)?; + } else { + symlink_file(target, &temp)?; + } + } else { + crate::copy(&src, &temp)?; + } + #[cfg(not(windows))] + crate::copy(&src, &temp)?; + staged = true; + // Recheck after staging: another writer may have created the target. + if fs::symlink_metadata(&dst).is_ok() { + if !overwrite { + return Err( + std::io::Error::new(std::io::ErrorKind::AlreadyExists, dst.display().to_string()).into() + ); + } + crate::delete(&dst)?; + } + fs::rename(&temp, &dst)?; + Ok(()) + })(); + if let Err(error) = &result + && (staged || error.kind != ide_core::error::ErrorKind::AlreadyExists) + { + let _ = crate::delete_permanently(&temp); + } + result?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_core::error::ErrorKind; + + fn put(root: &Path, path: &str, content: &[u8]) { + let path = root.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + #[test] + fn reports_add_delete_modify_equal_binary_unicode_empty_and_type_conflicts() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "子目录/仅左.txt", b"left"); + put(&right, "only-right", b"right"); + for (path, a, b) in [ + ("same", b"same".as_slice(), b"same".as_slice()), + ("binary", &[0, 255, 10], &[0, 254, 10]), + ("size", b"a", b"bb"), + ("conflict", b"file", b""), + ] { + put(&left, path, a); + if path == "conflict" { + put(&right, "conflict/child", b"child"); + } else { + put(&right, path, b); + } + } + fs::create_dir(left.join("empty")).unwrap(); + fs::create_dir(right.join("empty")).unwrap(); + let rows = compare_directories(&left, &right, None, &[], &CancellationToken::new(), |_| {}).unwrap(); + let status = |path| rows.iter().find(|r| r.path == path).unwrap().status; + assert_eq!(status("same"), DirectoryCompareStatus::Equal); + assert_eq!(status("empty"), DirectoryCompareStatus::Equal); + for path in ["binary", "size", "conflict"] { + assert_eq!(status(path), DirectoryCompareStatus::Different); + } + assert_eq!(status("子目录/仅左.txt"), DirectoryCompareStatus::LeftOnly); + assert_eq!(status("only-right"), DirectoryCompareStatus::RightOnly); + assert_eq!(status("conflict/child"), DirectoryCompareStatus::RightOnly); + } + + #[test] + fn applies_project_anchored_excludes_and_layered_ignores() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + for root in [&left, &right] { + put(root, "nested/node_modules/x", b"ignored"); + put(root, "target/x", b"ignored"); + put(root, ".git/config", b"ignored"); + put(root, "generated/x", b"ignored"); + put(root, ".gitignore", b"*.log\n"); + put(root, "nested/debug.log", b"ignored"); + put(root, "kept", b"kept"); + } + let excludes = ["**/node_modules", "**/target", "/left/generated", "/right/generated"].map(String::from); + let rows = + compare_directories(&left, &right, Some(tmp.path()), &excludes, &CancellationToken::new(), |_| {}).unwrap(); + assert!( + rows.iter() + .all(|r| !["node_modules", "target", ".git/", "generated", ".log"].iter().any(|p| r.path.contains(p))) + ); + assert!(rows.iter().any(|r| r.path == "kept")); + } + + #[test] + fn cancellation_stops_before_scan_during_scan_and_during_hashing() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "big", &vec![1; 1024 * 1024]); + put(&right, "big", &vec![1; 1024 * 1024]); + for phase in 0..3 { + let cancel = CancellationToken::new(); + if phase == 0 { + cancel.cancel(); + } + let result = compare_directories(&left, &right, None, &[], &cancel, |p| { + if phase == 1 && p.scanned > 0 || phase == 2 && p.bytes_read > 0.0 { + cancel.cancel(); + } + }); + assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled); + } + } + + #[test] + fn size_mismatches_are_not_hashed_and_directory_status_tracks_children() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "nested/file", b"a"); + put(&right, "nested/file", b"ab"); + let bytes = std::cell::Cell::new(0.0); + let rows = + compare_directories(&left, &right, None, &[], &CancellationToken::new(), |p| bytes.set(p.bytes_read)) + .unwrap(); + assert_eq!(bytes.get(), 0.0); + assert!(rows.iter().all(|r| r.status == DirectoryCompareStatus::Different)); + } + + #[test] + fn merges_copies_with_explicit_overwrite_and_keeps_destination_only_files() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "file", b"new"); + put(&left, "target/ignored", b"ignored"); + put(&right, "file", b"old"); + put(&right, "keep", b"keep"); + assert_eq!( + copy_merge(&left, &right, false, None, &["target".into()], (&left, &right)).unwrap_err().kind, + ErrorKind::AlreadyExists + ); + assert_eq!(fs::read(right.join("file")).unwrap(), b"old"); + copy_merge(&left, &right, true, None, &["target".into()], (&left, &right)).unwrap(); + assert_eq!(fs::read(right.join("file")).unwrap(), b"new"); + assert_eq!(fs::read(right.join("keep")).unwrap(), b"keep"); + assert!(!right.join("target").exists()); + for (from, to) in [(&left, left.clone()), (&left, left.join("child")), (&right, tmp.path().to_path_buf())] { + assert_eq!( + copy_merge(from, &to, true, None, &[], (tmp.path(), tmp.path())).unwrap_err().kind, + ErrorKind::InvalidInput + ); + } + } + + #[test] + fn subtree_copy_keeps_excludes_anchored_to_each_comparison_root() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "sub/build/keep", b"included below sub"); + put(&left, "sub/protected", b"do not overwrite"); + put(&right, "sub/protected", b"keep destination"); + put(&right, ".gitignore", b"sub/protected\n"); + copy_merge(&left.join("sub"), &right.join("sub"), true, None, &["/build".into()], (&left, &right)).unwrap(); + assert_eq!(fs::read(right.join("sub/build/keep")).unwrap(), b"included below sub"); + assert_eq!(fs::read(right.join("sub/protected")).unwrap(), b"keep destination"); + } + + #[test] + fn confirmed_copy_replaces_file_directory_type_conflicts() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "file", b"file"); + put(&right, "file/child", b"old directory"); + put(&left, "tree/child", b"new directory"); + put(&right, "tree", b"old file"); + for path in ["file", "tree"] { + copy_merge(&left.join(path), &right.join(path), true, None, &[], (&left, &right)).unwrap(); + } + assert_eq!(fs::read(right.join("file")).unwrap(), b"file"); + assert_eq!(fs::read(right.join("tree/child")).unwrap(), b"new directory"); + } + + #[cfg(unix)] + #[test] + fn symlinks_compare_link_text_and_are_not_followed_when_copying() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + fs::create_dir(&left).unwrap(); + fs::create_dir(&right).unwrap(); + symlink(".", left.join("loop")).unwrap(); + symlink("missing", right.join("loop")).unwrap(); + let rows = compare_directories(&left, &right, None, &[], &CancellationToken::new(), |_| {}).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, DirectoryCompareStatus::Different); + copy_merge(&left.join("loop"), &right.join("copied"), false, None, &[], (&left, &right)).unwrap(); + assert_eq!(fs::read_link(right.join("copied")).unwrap(), PathBuf::from(".")); + symlink(&right, tmp.path().join("alias")).unwrap(); + assert_eq!( + copy_merge(&left.join("loop"), &tmp.path().join("alias/new"), true, None, &[], (tmp.path(), tmp.path())) + .unwrap_err() + .kind, + ErrorKind::InvalidInput + ); + } +} diff --git a/crates/ide-vfs/src/lib.rs b/crates/ide-vfs/src/lib.rs index 55c28b71b..6ac24e21b 100644 --- a/crates/ide-vfs/src/lib.rs +++ b/crates/ide-vfs/src/lib.rs @@ -2,6 +2,7 @@ mod archive; mod bytes; +mod compare; mod encoding; mod ops; mod rules; @@ -20,6 +21,9 @@ pub use archive::{ read_archive_entry, split_archive_path, }; pub use bytes::{ByteRange, FileProbe, MAX_RANGE_LEN, base64, image_mime, probe, read_data_url, read_range}; +pub use compare::{ + DirectoryCompareEntry, DirectoryCompareProgress, DirectoryCompareStatus, compare_directories, copy_merge, +}; use encoding::SNIFF_LEN; pub use encoding::{ LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, decode_properties_escapes, encode, diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 5ff8d0f10..efa29a9b5 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -5,6 +5,38 @@ use ide_vfs::FileEntry; use std::path::PathBuf; use tauri::{State, Window}; +#[tauri::command] +#[specta::specta] +pub async fn fs_entry_kind(state: State<'_, AppState>, path: PathBuf) -> IdeResult { + fs::entry_kind(&state.host, path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn fs_compare_directories( + window: Window, + state: State<'_, AppState>, + left: PathBuf, + right: PathBuf, + request: ide_core::RequestId, + on_progress: tauri::ipc::Channel, +) -> IdeResult> { + fs::compare_directories(&state.host, window.label(), left, right, request, super::sender(on_progress)).await +} + +#[tauri::command] +#[specta::specta] +pub async fn fs_copy_merge( + window: Window, + state: State<'_, AppState>, + source_root: PathBuf, + destination_root: PathBuf, + path: PathBuf, + overwrite: bool, +) -> IdeResult<()> { + fs::copy_merge(&state.host, window.label(), source_root, destination_root, path, overwrite).await +} + #[tauri::command] #[specta::specta] pub fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ab121db65..c71ce8405 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -126,6 +126,7 @@ pub fn builder() -> tauri_specta::Builder { notebook::notebook_interrupt, notebook::notebook_restart, fs::fs_read_dir, + fs::fs_entry_kind, bytes::fs_probe, bytes::fs_read_range, bytes::fs_read_image, @@ -136,6 +137,8 @@ pub fn builder() -> tauri_specta::Builder { fs::fs_set_writable, fs::fs_delete, fs::fs_copy, + fs::fs_copy_merge, + fs::fs_compare_directories, fs::fs_read_text, clipboard::clipboard_file_list, clipboard::clipboard_has_files, diff --git a/src/bindings.ts b/src/bindings.ts index 79eba7f80..97ce05285 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -145,6 +145,7 @@ export const commands = { notebookInterrupt: (id: string) => typedError(__TAURI_INVOKE("notebook_interrupt", { id })), notebookRestart: (id: string) => typedError(__TAURI_INVOKE("notebook_restart", { id })), fsReadDir: (path: string) => typedError(__TAURI_INVOKE("fs_read_dir", { path })), + fsEntryKind: (path: string) => typedError(__TAURI_INVOKE("fs_entry_kind", { path })), /** Size and text/binary classification, from the file's head only. */ fsProbe: (path: string) => typedError(__TAURI_INVOKE("fs_probe", { path })), /** Up to `len` bytes from `offset` (capped per call), base64-encoded. */ @@ -167,6 +168,8 @@ export const commands = { /** Moving to the trash can take a while (large trees): kept off the main thread. */ fsDelete: (path: string) => typedError(__TAURI_INVOKE("fs_delete", { path })), fsCopy: (from: string, to: string) => typedError(__TAURI_INVOKE("fs_copy", { from, to })), + fsCopyMerge: (sourceRoot: string, destinationRoot: string, path: string, overwrite: boolean) => typedError(__TAURI_INVOKE("fs_copy_merge", { sourceRoot, destinationRoot, path, overwrite })), + fsCompareDirectories: (left: string, right: string, request: RequestId, onProgress: Channel) => typedError(__TAURI_INVOKE("fs_compare_directories", { left, right, request, onProgress })), /** * Reads a text file without opening it as a document (previews, quick * definition). Prefers the unsaved buffer when the file is open. @@ -2513,6 +2516,25 @@ export type Diagnostic = { server?: string | null, }; +export type DirectoryCompareEntry = { + path: string, + status: DirectoryCompareStatus, + leftKind: EntryKind | null, + rightKind: EntryKind | null, + leftLink?: string | null, + rightLink?: string | null, +}; + +export type DirectoryCompareProgress = { + scanned: number, + compared: number, + total: number | null, + bytesRead: number | null, + path: string, +}; + +export type DirectoryCompareStatus = "leftOnly" | "rightOnly" | "different" | "equal"; + /** Changes under a folder (Show History on a directory). */ export type DirectoryHistory = { /** Snapshots of files under the folder and labels, newest first. */ diff --git a/src/features/project-tree/contextMenu.test.ts b/src/features/project-tree/contextMenu.test.ts index 622ddf3b0..d3f18a6d5 100644 --- a/src/features/project-tree/contextMenu.test.ts +++ b/src/features/project-tree/contextMenu.test.ts @@ -40,6 +40,7 @@ const OTHER_ACTIONS = [ "localHistory.putLabel", "diff.compareWith", "diff.compareFiles", + "diff.compareDirectories", "vcs.commit", "vcs.addToVcs", "vcs.annotate", @@ -75,6 +76,14 @@ afterEach(() => { }); describe("project view context menu", () => { + it("offers directory comparison only for two folders and Compare With for a single folder", () => { + expect(labels(contextMenuItems([node("/p/left", true), node("/p/right", true)]))).toContain("Compare Directories"); + expect(labels(contextMenuItems([node("/p/left", true)]))).toContain("Compare With…"); + const mixed = labels(contextMenuItems([node("/p/left", true), node("/p/a.txt")])); + for (const label of ["Compare Directories", "Compare Files", "Compare With…"]) expect(mixed).not.toContain(label); + expect(labels(contextMenuItems([node("/p/a.txt"), node("/p/b.txt")]))).toContain("Compare Files"); + }); + it("follows IDEA's order for a file", () => { const items = contextMenuItems([node(`${ROOT}/src/a.py`)]); expect(labels(items)).toEqual([ @@ -161,7 +170,7 @@ describe("project view context menu", () => { const l = labels(contextMenuItems([node(`${ROOT}/src`, true)])); expect(l).toContain("Mark Directory as"); expect(sub(contextMenuItems([node(`${ROOT}/src`, true)]), "Refactor")).toContain("Move Directory…"); - for (const fileOnly of ["Find Usages", "Reformat Code", "Optimize Imports", "Compare With…", "Run 'a'"]) + for (const fileOnly of ["Find Usages", "Reformat Code", "Optimize Imports", "Run 'a'"]) expect(l).not.toContain(fileOnly); }); diff --git a/src/features/project-tree/contextMenu.ts b/src/features/project-tree/contextMenu.ts index 1b35cf483..9775c9b4c 100644 --- a/src/features/project-tree/contextMenu.ts +++ b/src/features/project-tree/contextMenu.ts @@ -361,7 +361,17 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] { ); } - // IDEA: Compare With… (one file) / Compare Files (two files). + // Compare selections of the same kind; mixed file/folder selections cannot apply. + const compareArg = single?.dir ? { path: single.path, directory: true } : single?.path; + if (nodes.length === 2 && nodes.every((n) => n.dir && !n.placeholder)) + push( + delegated( + "diff.compareDirectories", + nodes.map((n) => n.path), + "Compare Directories", + { icon: "diff" }, + ), + ); if (files.length === 2 && nodes.length === 2) push( delegated( @@ -371,9 +381,9 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] { { icon: "diff" }, ), ); - else if (singleFile) + else if (single && !single.placeholder) push( - delegated("diff.compareWith", singleFile.path, "Compare With…", { + delegated("diff.compareWith", compareArg, "Compare With…", { icon: "diff", shortcut: keyLabel("project.compareWith"), }), diff --git a/src/features/vcs/ComparisonTree.tsx b/src/features/vcs/ComparisonTree.tsx new file mode 100644 index 000000000..e2fd952c6 --- /dev/null +++ b/src/features/vcs/ComparisonTree.tsx @@ -0,0 +1,78 @@ +import { useState, type ReactNode } from "react"; +import { Icon, Tree, fileIconName } from "@/ui"; +import { DiffEditor } from "./DiffEditor"; +import { diffInput, openDiff, type DiffData } from "./diffInput"; +import { dirKeys, type FileTreeNode } from "./fileTree"; +import styles from "./common.module.css"; + +/** Shared changes tree and on-demand diff surface for revisions and disk folders. */ +export function ComparisonTree({ + tree, + current, + onSelect, + diffFor, + renderDetails, + ariaLabel, + openOnActivate = false, + getColor, + onOpen, +}: { + tree: FileTreeNode[]; + current: F | null; + onSelect: (entry: F | null) => void; + diffFor: (entry: F) => DiffData | null; + renderDetails: (node: FileTreeNode) => ReactNode; + ariaLabel: string; + openOnActivate?: boolean; + getColor?: (entry: F) => string | undefined; + onOpen?: () => void; +}) { + const [selected, setSelected] = useState>(new Set()); + const [expanded, setExpanded] = useState | null>(null); + const data = current && diffFor(current); + const input = data ? diffInput(data) : null; + return ( +
+ > + roots={tree} + getKey={(n) => n.key} + getLabel={(n) => n.name} + getChildren={(n) => (n.file ? undefined : n.children)} + isExpandable={(n) => !n.file} + expanded={expanded ?? new Set(dirKeys(tree))} + onExpandedChange={setExpanded} + selected={selected} + onSelectionChange={(sel, lead) => { + setSelected(sel); + onSelect(lead?.file ?? lead?.directory ?? null); + }} + onActivate={(n) => { + const entry = n.file ?? n.directory; + if (!entry) return; + onSelect(entry); + const diff = diffFor(entry); + if (openOnActivate && diff) { + void openDiff(diff); + onOpen?.(); + } + }} + aria-label={ariaLabel} + className={styles.comparisonTree} + renderNode={(n) => ( + + + + {n.name} + + {renderDetails(n)} + + )} + /> + {input && ( +
+ +
+ )} +
+ ); +} diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index 49a2dc674..f44a47d02 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -200,6 +200,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { const data = input.data as DiffData; const { path, left, right } = data; const [attached, setAttached] = useState(null); + const [binary, setBinary] = useState(null); /** Text of the sides that are not live documents. */ const [statics, setStatics] = useState<{ a: string | null; b: string | null } | null>(null); const [error, setError] = useState(null); @@ -263,15 +264,18 @@ export function DiffEditor({ input }: { input: EditorInput }) { const reload = useCallback(async () => { if (!attached) return; try { - const [a, b] = await Promise.all([ + const paths = [sideFile(left, path), sideFile(right, path)].filter((p): p is string => !!p); + const [a, b, probes] = await Promise.all([ attached.a ? null : loadSide(path, left), attached.b ? null : loadSide(path, right), + Promise.all(paths.map((p) => call(commands.fsProbe(p)).catch(() => null))), ]); const prev = staticsRef.current; if (!prev || prev.a !== a || prev.b !== b) { staticsRef.current = { a, b }; setStatics({ a, b }); } + setBinary(probes.some((p) => p?.binary)); setError(null); } catch (e) { setError(errorMessage(e)); @@ -311,9 +315,9 @@ export function DiffEditor({ input }: { input: EditorInput }) { left.rev === "HEAD" && !left.path && isWorktree(right); - const editableA = !!attached?.a && !attached.a.readOnly; - const editableB = !!attached?.b && !attached.b.readOnly; - const ready = !!attached && !!statics && lang !== null; + const editableA = !binary && !!attached?.a && !attached.a.readOnly; + const editableB = !binary && !!attached?.b && !attached.b.readOnly; + const ready = !!attached && !!statics && lang !== null && binary !== null; // Read when the views are built; toggling it later goes through `setSync`. const syncScroll = useRef(opts.syncScroll); @@ -983,7 +987,8 @@ export function DiffEditor({ input }: { input: EditorInput }) { void reload()} /> - {!ready + {binary && tx("Binary content")} + {!ready || binary ? "" : chunkInfo.count === 0 ? identical @@ -1047,7 +1052,11 @@ export function DiffEditor({ input }: { input: EditorInput }) { />
)} - {error ? ( + {binary ? ( +
+ Binary files cannot be shown as text +
+ ) : error ? (
{error}
) : !ready ? (
@@ -1061,7 +1070,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { opts.highlight === "none" && styles.noHighlight, opts.mode === "split" && (opts.alignChanges ? !opts.syncScroll && styles.unsynced : styles.unaligned), )} - hidden={!!error || !ready} + hidden={!!binary || !!error || !ready} aria-label={diffTitle(data)} role="region" onContextMenu={onContextMenu} diff --git a/src/features/vcs/DirectoryCompare.tsx b/src/features/vcs/DirectoryCompare.tsx new file mode 100644 index 000000000..b70e2acad --- /dev/null +++ b/src/features/vcs/DirectoryCompare.tsx @@ -0,0 +1,291 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Channel } from "@tauri-apps/api/core"; +import type { DirectoryCompareEntry, DirectoryCompareProgress, DirectoryCompareStatus } from "@/bindings"; +import { call, commands, emitLocalIdeEvent, IpcError, isCancelled, nextRequestId } from "@/platform/ipc"; +import { notifyFileOperation } from "@/platform/fileOps"; +import { tx } from "@/platform/i18n"; +import { Button, Checkbox, Dialog, Input, Spinner, T } from "@/ui"; +import { upsertTask } from "@/workbench/tasks"; +import { ComparisonTree } from "./ComparisonTree"; +import { buildFileTree } from "./fileTree"; +import { confirmDialog, openDialog } from "./dialogs"; +import { openDiff, type DiffData, type DiffSide } from "./diffInput"; +import { compareFilesData } from "./compareData"; +import styles from "./common.module.css"; + +const STATUS: Record = { + leftOnly: "Only on left", + rightOnly: "Only on right", + different: "Contents differ", + equal: "Identical", +}; +const join = (root: string, path: string) => `${root.replace(/\/$/, "")}/${path}`; +const directory = (e: DirectoryCompareEntry) => e.leftKind === "dir" || e.rightKind === "dir"; + +export function directoryDiff(left: string, right: string, entry: DirectoryCompareEntry): DiffData | null { + if (directory(entry)) return null; + const data = compareFilesData(join(left, entry.path), join(right, entry.path)); + const missing = (): DiffSide => ({ type: "text", text: "", label: tx("(does not exist)") }); + return { + ...data, + title: entry.path, + left: + entry.leftKind === "symlink" + ? { type: "text", text: entry.leftLink ?? "", label: data.left.label ?? join(left, entry.path) } + : entry.leftKind + ? data.left + : missing(), + right: + entry.rightKind === "symlink" + ? { type: "text", text: entry.rightLink ?? "", label: data.right.label ?? join(right, entry.path) } + : entry.rightKind + ? data.right + : missing(), + }; +} + +export function showDirectoryComparison(left: string, right: string) { + if (left === right) return; + openDialog((close) => ); +} + +export function DirectoryCompareDialog({ left, right, onClose }: { left: string; right: string; onClose: () => void }) { + const [entries, setEntries] = useState([]); + const [current, setCurrent] = useState(null); + const [showEqual, setShowEqual] = useState(false); + const [filter, setFilter] = useState(""); + const [generation, setGeneration] = useState(0); + const [busy, setBusy] = useState(true); + const [operating, setOperating] = useState(false); + const [error, setError] = useState(null); + const [operationError, setOperationError] = useState(null); + const [progress, setProgress] = useState(null); + const [cancelled, setCancelled] = useState(false); + const cancel = useRef<() => void>(() => {}); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + useEffect(() => { + let alive = true; + let pending = true; + const request = nextRequestId(); + const task = { + id: `directory-compare-${request}`, + title: tx("Comparing directories"), + message: null as string | null, + fraction: null as number | null, + cancel: () => { + void commands.cancelRequest(request); + }, + }; + cancel.current = task.cancel; + upsertTask({ ...task, done: false }); + const channel = new Channel(); + channel.onmessage = (p) => { + if (!alive) return; + setProgress(p); + task.message = p.path; + task.fraction = p.total ? p.compared / p.total : null; + upsertTask({ ...task, done: false }); + }; + void call(commands.fsCompareDirectories(left, right, request, channel)) + .then((rows) => { + if (!alive) return; + setEntries(rows); + setCurrent((prev) => rows.find((e) => e.path === prev?.path) ?? null); + }) + .catch((e) => { + if (!alive) return; + if (isCancelled(e)) setCancelled(true); + else setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + pending = false; + upsertTask({ ...task, done: true }); + if (alive) setBusy(false); + }); + return () => { + alive = false; + if (pending) task.cancel(); + upsertTask({ ...task, done: true }); + }; + }, [left, right, generation]); + + const visible = useMemo(() => { + const paths = new Set(); + const query = filter.trim().toLocaleLowerCase(); + for (const e of entries) { + if ((!showEqual && e.status === "equal") || !e.path.toLocaleLowerCase().includes(query)) continue; + let path = e.path; + while (path) { + paths.add(path); + const slash = path.lastIndexOf("/"); + path = slash < 0 ? "" : path.slice(0, slash); + } + } + return entries.filter((e) => paths.has(e.path)); + }, [entries, showEqual, filter]); + const tree = useMemo(() => buildFileTree(visible, (e) => e.path, directory), [visible]); + const selected = cancelled || error ? null : (visible.find((e) => e.path === current?.path) ?? null); + const diff = selected && directoryDiff(left, right, selected); + const refresh = () => { + setBusy(true); + setCancelled(false); + setError(null); + setProgress(null); + setGeneration((g) => g + 1); + }; + + const operate = async (side: "left" | "right" | "delete") => { + if (!selected || busy || operating) return; + setOperating(true); + setOperationError(null); + let attempted = false; + const targetRoot = side === "left" ? left : right; + try { + if (side === "delete") { + if (!!selected.leftKind === !!selected.rightKind) return; + const path = join(selected.leftKind ? left : right, selected.path); + if (!(await confirmDialog({ title: "Delete", message: tx`Delete ${path}?`, okLabel: "Delete", danger: true }))) + return; + attempted = true; + await call(commands.fsDelete(path)); + notifyFileOperation({ kind: "deleted", paths: [path] }); + emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [], removed: [path] }); + } else { + const sourceRoot = side === "left" ? right : left; + const to = join(targetRoot, selected.path); + try { + attempted = true; + await call(commands.fsCopyMerge(sourceRoot, targetRoot, selected.path, false)); + } catch (e) { + if (!(e instanceof IpcError) || e.kind !== "alreadyExists") throw e; + if ( + !(await confirmDialog({ + title: "Overwrite files?", + message: tx`Copy to ${to} and overwrite existing files?`, + okLabel: "Overwrite", + danger: true, + })) + ) + return; + await call(commands.fsCopyMerge(sourceRoot, targetRoot, selected.path, true)); + } + notifyFileOperation({ kind: "created", paths: [to] }); + emitLocalIdeEvent({ type: "fsChanged", created: [to], modified: [to], removed: [] }); + } + } catch (e) { + if (mounted.current) setOperationError(e instanceof Error ? e.message : String(e)); + } finally { + if (mounted.current) { + setOperating(false); + if (attempted) refresh(); + } + } + }; + + return ( + { + if (diff) { + void openDiff(diff); + onClose(); + } + }, + }, + { label: "Close", role: "cancel" }, + ]} + > +
{tx`Left: ${left}`}
+
{tx`Right: ${right}`}
+
+ setFilter(e.target.value)} + /> + + +
+
+ + + + {operating && } +
+ {error && ( +
+ {tx(error)} +
+ )} + {operationError && ( +
+ {tx(operationError)} +
+ )} + {busy && ( +
+ + + {progress?.total != null + ? tx`Compared ${progress.compared} of ${progress.total} entries` + : tx`Scanned ${progress?.scanned ?? 0} entries`} + + +
+ )} + {cancelled && ( +
+ Comparison cancelled +
+ )} + {!busy && !cancelled && !error && !visible.length && ( +
+ No matching differences +
+ )} + {!!tree.length && ( + directoryDiff(left, right, e)} + openOnActivate={!busy && !operating} + onOpen={onClose} + ariaLabel="Compared files" + renderDetails={(n) => { + const entry = n.file ?? n.directory; + return {entry ? tx(STATUS[entry.status]) : n.count}; + }} + /> + )} +
+ ); +} diff --git a/src/features/vcs/__tests__/diffToolbar.test.tsx b/src/features/vcs/__tests__/diffToolbar.test.tsx index 9bd19206f..dbf74852f 100644 --- a/src/features/vcs/__tests__/diffToolbar.test.tsx +++ b/src/features/vcs/__tests__/diffToolbar.test.tsx @@ -66,6 +66,21 @@ beforeEach(() => { }); describe("Diff toolbar", () => { + it("shows a binary notice for disk comparisons instead of identical empty text", async () => { + probe.mockResolvedValue({ status: "ok", data: { size: 3, binary: true, tooLarge: false } }); + const binaryInput = { + ...input, + data: { path: "/p/right.bin", left: { type: "file", path: "/p/left.bin" }, right: { type: "worktree" } }, + } as EditorInput; + render(); + expect(await screen.findByText("Binary files cannot be shown as text")).toBeInTheDocument(); + expect(screen.queryByText("Contents are identical")).toBeNull(); + expect(screen.queryByRole("region", { name: /left.bin/ })).toBeNull(); + probe.mockResolvedValue({ status: "ok", data: { size: 3, binary: false, tooLarge: false } }); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(screen.queryByText("Binary files cannot be shown as text")).toBeNull()); + }); + it("puts Jump to Source right after Next Difference, in the navigation group", async () => { render(); await screen.findByRole("button", { name: "Jump to Source" }); diff --git a/src/features/vcs/__tests__/directoryCompare.test.tsx b/src/features/vcs/__tests__/directoryCompare.test.tsx new file mode 100644 index 000000000..896fc57e1 --- /dev/null +++ b/src/features/vcs/__tests__/directoryCompare.test.tsx @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { DirectoryCompareEntry } from "@/bindings"; + +const mocks = vi.hoisted(() => ({ + compare: vi.fn(), + cancel: vi.fn(), + copy: vi.fn(), + remove: vi.fn(), + confirm: vi.fn(), + open: vi.fn(), +})); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + commands: { + ...real.commands, + fsCompareDirectories: mocks.compare, + fsCopyMerge: mocks.copy, + fsDelete: mocks.remove, + cancelRequest: mocks.cancel, + }, + }; +}); +vi.mock("@tauri-apps/api/core", async (original) => ({ + ...(await original()), + Channel: class { + onmessage = () => {}; + }, +})); +vi.mock("../DiffEditor", () => ({ + DiffEditor: ({ input }: { input: unknown }) =>
{JSON.stringify(input)}
, +})); +vi.mock("../dialogs", () => ({ confirmDialog: mocks.confirm, openDialog: vi.fn(), promptText: vi.fn() })); +vi.mock("../diffInput", async (original) => ({ + ...(await original()), + openDiff: mocks.open, +})); +import { DirectoryCompareDialog, directoryDiff } from "../DirectoryCompare"; + +const row = ( + path: string, + status: DirectoryCompareEntry["status"], + leftKind: DirectoryCompareEntry["leftKind"] = "file", + rightKind: DirectoryCompareEntry["rightKind"] = "file", +): DirectoryCompareEntry => ({ path, status, leftKind, rightKind }); +const rows = [ + row("nested", "different", "dir", "dir"), + row("nested/中文.txt", "different"), + row("only-left.txt", "leftOnly", "file", null), + row("only-right.txt", "rightOnly", null, "file"), + row("same.txt", "equal"), + row("empty", "leftOnly", "dir", null), +]; +const ok = (data: unknown = null) => ({ status: "ok", data }); +const setup = () => render( {}} />); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.compare.mockResolvedValue(ok(rows)); + mocks.copy.mockResolvedValue(ok()); + mocks.remove.mockResolvedValue(ok()); + mocks.confirm.mockResolvedValue(true); +}); +afterEach(cleanup); + +describe("disk directory comparison", () => { + it("reuses the changes tree, filters names and toggles identical files", async () => { + setup(); + expect(await screen.findByText("中文.txt")).toBeInTheDocument(); + expect(screen.getAllByText("Only on left")).toHaveLength(2); + expect(screen.getByText("Only on right")).toBeInTheDocument(); + expect(screen.queryByText("same.txt")).toBeNull(); + fireEvent.click(screen.getByRole("checkbox", { name: "Show identical files" })); + expect(screen.getByText("same.txt")).toBeInTheDocument(); + fireEvent.change(screen.getByRole("textbox", { name: "Filter by name" }), { target: { value: "中文" } }); + expect(screen.queryByText("same.txt")).toBeNull(); + expect(screen.getByText("nested")).toBeInTheDocument(); + fireEvent.mouseDown(screen.getByText("nested"), { button: 0 }); + expect(screen.getByRole("button", { name: "Copy to Right" })).toBeEnabled(); + }); + + it("previews on selection and opens the existing diff on double click, including missing sides", async () => { + setup(); + fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 }); + expect(screen.getByTestId("diff")).toHaveTextContent("/p/left/nested/中文.txt"); + fireEvent.doubleClick(screen.getByText("中文.txt")); + expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ path: "/p/right/nested/中文.txt" })); + expect(directoryDiff("/l", "/r", rows[2]!)?.right.type).toBe("text"); + expect(directoryDiff("/l", "/r", rows[3]!)?.left.type).toBe("text"); + expect(directoryDiff("/l", "/r", row("type", "different", "file", "dir"))).toBeNull(); + expect( + directoryDiff("/l", "/r", { + ...row("link", "different", "symlink", "symlink"), + leftLink: "one", + rightLink: "two", + }), + ).toMatchObject({ + left: { type: "text", text: "one" }, + right: { type: "text", text: "two" }, + }); + }); + + it.each(["Copy to Left", "Copy to Right"])( + "confirms before overwriting, refreshes and restores selection after %s", + async (label) => { + mocks.copy + .mockResolvedValueOnce({ status: "error", error: { kind: "alreadyExists", message: "exists" } }) + .mockResolvedValueOnce(ok()); + setup(); + fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 }); + fireEvent.click(screen.getByRole("button", { name: label })); + await waitFor(() => + expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Overwrite files?" })), + ); + await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2)); + expect(mocks.copy.mock.calls[0]![3]).toBe(false); + expect(mocks.copy.mock.calls[1]![3]).toBe(true); + expect(mocks.copy.mock.calls[0]!.slice(0, 3)).toEqual( + label === "Copy to Left" + ? ["/p/right", "/p/left", "nested/中文.txt"] + : ["/p/left", "/p/right", "nested/中文.txt"], + ); + expect(screen.getByTestId("diff")).toBeInTheDocument(); + }, + ); + + it("does not overwrite when confirmation is declined and reports operation errors", async () => { + mocks.copy.mockResolvedValue({ status: "error", error: { kind: "alreadyExists", message: "exists" } }); + mocks.confirm.mockResolvedValue(false); + setup(); + fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 }); + fireEvent.click(screen.getByRole("button", { name: "Copy to Right" })); + await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2)); + expect(mocks.copy).toHaveBeenCalledTimes(1); + mocks.copy.mockResolvedValue({ status: "error", error: { kind: "permissionDenied", message: "Cannot write" } }); + fireEvent.click(screen.getByRole("button", { name: "Copy to Right" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Cannot write"); + }); + + it("copies empty and nonempty subdirectories and confirms deletion only for one-sided entries", async () => { + setup(); + fireEvent.mouseDown(await screen.findByText("nested"), { button: 0 }); + expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Copy to Right" })); + await waitFor(() => expect(mocks.copy).toHaveBeenCalledWith("/p/left", "/p/right", "nested", false)); + await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2)); + fireEvent.mouseDown(screen.getByText("empty"), { button: 0 }); + fireEvent.click(screen.getByRole("button", { name: "Copy to Right" })); + await waitFor(() => expect(mocks.copy).toHaveBeenCalledWith("/p/left", "/p/right", "empty", false)); + await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(3)); + fireEvent.mouseDown(screen.getByText("only-left.txt"), { button: 0 }); + mocks.confirm.mockResolvedValueOnce(false); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + await waitFor(() => expect(mocks.confirm).toHaveBeenCalled()); + expect(mocks.remove).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + await waitFor(() => expect(mocks.remove).toHaveBeenCalledWith("/p/left/only-left.txt")); + }); + + it("streams progress, cancels on demand and on close, and ignores stale results after refresh", async () => { + let resolve!: (value: unknown) => void; + mocks.compare.mockImplementationOnce((_l, _r, _request, channel) => { + channel.onmessage({ scanned: 20, compared: 5, total: 10, bytesRead: 100, path: "big" }); + return new Promise((r) => { + resolve = r; + }); + }); + const view = setup(); + expect(await screen.findByText("Compared 5 of 10 entries")).toBeInTheDocument(); + const request = mocks.compare.mock.calls[0]![2]; + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(mocks.cancel).toHaveBeenCalledWith(request); + view.unmount(); + expect(mocks.cancel).toHaveBeenCalledTimes(2); + resolve(ok(rows)); + }); +}); diff --git a/src/features/vcs/__tests__/directoryCompareActions.test.ts b/src/features/vcs/__tests__/directoryCompareActions.test.ts new file mode 100644 index 000000000..4a0d5cbae --- /dev/null +++ b/src/features/vcs/__tests__/directoryCompareActions.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const mocks = vi.hoisted(() => ({ + show: vi.fn(), + read: vi.fn(), + notify: vi.fn(), + pick: vi.fn(), + remote: vi.fn(), + prompt: vi.fn(), + openDiff: vi.fn(), +})); +vi.mock("../DirectoryCompare", () => ({ showDirectoryComparison: mocks.show })); +vi.mock("../dialogs", () => ({ promptText: mocks.prompt })); +vi.mock("../diffInput", () => ({ openDiff: mocks.openDiff })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.pick })); +vi.mock("@/platform/remote", () => ({ isRemoteWindow: mocks.remote })); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + isTauri: () => true, + commands: { fsEntryKind: mocks.read }, +})); +vi.mock("@/workbench/contributions", async (original) => ({ + ...(await original()), + notifications: () => ({ notify: mocks.notify }), +})); +import { getAction, isActionEnabled } from "@/platform/actions"; +import { provideProjectViewSelection } from "@/workbench/contributions"; +import { registerCompareActions } from "../compare"; + +let dispose: () => void; +beforeEach(() => { + vi.clearAllMocks(); + dispose = registerCompareActions(); + mocks.remote.mockReturnValue(false); + mocks.read.mockImplementation((path: string) => + Promise.resolve({ status: "ok", data: path.endsWith(".txt") ? "file" : "dir" }), + ); +}); +afterEach(() => dispose()); + +describe("directory compare actions", () => { + it("has a stable searchable action and resolves the selection for Find Action", async () => { + const unselect = provideProjectViewSelection(() => ["/p/left", "/p/right"]); + try { + const action = getAction("diff.compareDirectories")!; + expect(action.title).toBe("Compare Directories"); + expect(action.hidden).not.toBe(true); + expect(isActionEnabled(action, { projectOpen: true })).toBe(true); + await action.run({ ctx: { projectOpen: true } }); + expect(mocks.show).toHaveBeenCalledWith("/p/left", "/p/right"); + await action.run({ ctx: { projectOpen: true }, arg: { paths: ["/x/left", "/x/right"] } }); + expect(mocks.show).toHaveBeenLastCalledWith("/x/left", "/x/right"); + } finally { + unselect(); + } + }); + + it("rejects mixed selections and does not start a directory comparison", async () => { + await getAction("diff.compareDirectories")!.run({ ctx: {}, arg: ["/p/left", "/p/file.txt"] }); + expect(mocks.show).not.toHaveBeenCalled(); + expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Select two directories to compare" })); + }); + + it("uses the native folder picker for Compare With on a local directory", async () => { + mocks.pick.mockResolvedValue("/p/right"); + await getAction("diff.compareWith")!.run({ ctx: {}, arg: { path: "/p/left", directory: true } }); + expect(mocks.pick).toHaveBeenCalledWith(expect.objectContaining({ directory: true })); + expect(mocks.show).toHaveBeenCalledWith("/p/left", "/p/right"); + expect(mocks.openDiff).not.toHaveBeenCalled(); + }); + + it("uses remote paths and validates the selected directory via the routed filesystem", async () => { + mocks.remote.mockReturnValue(true); + mocks.prompt.mockResolvedValue({ value: "/remote/right" }); + const unselect = provideProjectViewSelection(() => ["/remote/left"]); + try { + await getAction("diff.compareWith")!.run({ ctx: {} }); + expect(mocks.prompt).toHaveBeenCalledWith( + expect.objectContaining({ label: "Directory path", initial: "/remote/left" }), + ); + expect(mocks.pick).not.toHaveBeenCalled(); + expect(mocks.read).toHaveBeenCalledWith("/remote/right"); + expect(mocks.show).toHaveBeenCalledWith("/remote/left", "/remote/right"); + } finally { + unselect(); + } + }); + + it("leaves file Compare With on the existing diff flow", async () => { + mocks.pick.mockResolvedValue("/p/b.txt"); + await getAction("diff.compareWith")!.run({ ctx: {}, arg: "/p/a.txt" }); + expect(mocks.pick).toHaveBeenCalledWith(expect.objectContaining({ directory: false })); + expect(mocks.openDiff).toHaveBeenCalledWith(expect.objectContaining({ path: "/p/b.txt" })); + expect(mocks.show).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/vcs/__tests__/fileTree.test.ts b/src/features/vcs/__tests__/fileTree.test.ts index c0327edca..158f32829 100644 --- a/src/features/vcs/__tests__/fileTree.test.ts +++ b/src/features/vcs/__tests__/fileTree.test.ts @@ -9,4 +9,21 @@ describe("buildFileTree", () => { expect(tree[1]!.children.map((c) => c.name)).toEqual(["a.ts", "b.ts"]); expect(dirKeys(tree)).toEqual(["d:docs/x/y", "d:src/app"]); }); + + it("retains explicit empty directories and their entries for subtree operations", () => { + const entries = [ + { path: "empty", dir: true }, + { path: "src", dir: true }, + { path: "src/nested", dir: true }, + { path: "src/nested/a", dir: false }, + ]; + const tree = buildFileTree( + entries, + (e) => e.path, + (e) => e.dir, + ); + expect(tree[0]).toMatchObject({ name: "empty", directory: entries[0], children: [], count: 0 }); + expect(tree[1]).toMatchObject({ name: "src", directory: entries[1] }); + expect(tree[1]!.children[0]).toMatchObject({ name: "nested", directory: entries[2] }); + }); }); diff --git a/src/features/vcs/common.module.css b/src/features/vcs/common.module.css index c18a071e9..9e9acc8df 100644 --- a/src/features/vcs/common.module.css +++ b/src/features/vcs/common.module.css @@ -310,6 +310,17 @@ color: var(--fg-subtle); overflow-wrap: anywhere; } +.compareToolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 6px 14px; +} +.compareToolbar > input { + flex: 1; + min-width: 140px; +} .compareAdded { color: var(--vcs-added); font-variant-numeric: tabular-nums; diff --git a/src/features/vcs/compare.tsx b/src/features/vcs/compare.tsx index a2799e617..1437abdbe 100644 --- a/src/features/vcs/compare.tsx +++ b/src/features/vcs/compare.tsx @@ -4,10 +4,13 @@ */ import { activeEditor } from "@/editor/api"; import { registerActions } from "@/platform/actions"; -import { isTauri } from "@/platform/ipc"; -import { useProject } from "@/platform/project"; -import { maybeEditorService, notifications } from "@/workbench/contributions"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { isRemoteWindow } from "@/platform/remote"; +import { maybeEditorService, notifications, projectViewSelection } from "@/workbench/contributions"; +import { promptText } from "./dialogs"; import { focusedDiffNavigator } from "./diffNavigator"; +import { compareFilesData, displayPath } from "./compareData"; +export { compareFilesData, displayPath } from "./compareData"; import { openDiff, type DiffData } from "./diffInput"; import { baseName } from "./model"; import { tx } from "@/platform/i18n"; @@ -27,20 +30,6 @@ function argPaths(arg: unknown): string[] { return p ? [p] : []; } -/** Project-relative path for labels. */ -export function displayPath(path: string, root = useProject.getState().project?.root ?? null): string { - return root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path; -} - -/** Diff of two files on disk; both sides are live, editable documents. */ -export function compareFilesData(leftPath: string, rightPath: string): DiffData { - return { - path: rightPath, - left: { type: "file", path: leftPath, label: displayPath(leftPath) }, - right: { type: "worktree", label: displayPath(rightPath) }, - }; -} - /** Clipboard ↔ the file (editable) or ↔ the editor selection. */ export function clipboardDiffData(path: string, clipboard: string, selection: string | null): DiffData { return { @@ -122,6 +111,29 @@ async function pickComparison( const contextPath = (arg: unknown) => argPath(arg) ?? maybeEditorService()?.activePath() ?? undefined; +async function showDirectories(left: string, right: string) { + const { showDirectoryComparison } = await import("./DirectoryCompare"); + showDirectoryComparison(left, right); +} + +async function isDirectory(path: string): Promise { + try { + return (await call(commands.fsEntryKind(path))) === "dir"; + } catch { + return false; + } +} + +async function pickDirectory(near: string): Promise { + if (isRemoteWindow() || !isTauri()) { + const picked = await promptText({ title: "Compare With", label: "Directory path", initial: near, mono: true }); + return picked?.value ?? null; + } + const { open } = await import("@tauri-apps/plugin-dialog"); + const picked = await open({ multiple: false, directory: true, title: tx("Compare With"), defaultPath: near }); + return (Array.isArray(picked) ? picked[0] : picked) ?? null; +} + export function registerCompareActions(): () => void { return registerActions([ { @@ -210,10 +222,18 @@ export function registerCompareActions(): () => void { category: "Diff", icon: "diff", when: "projectOpen", - keywords: ["diff", "compare files"], + keywords: ["diff", "compare files", "compare folders"], run: async ({ arg }) => { - const path = contextPath(arg); + const path = argPath(arg) ?? projectViewSelection()[0] ?? contextPath(arg); if (!path) return info("Open or select a file to compare"); + const directoryArg = !!arg && typeof arg === "object" && "directory" in arg && arg.directory === true; + if (directoryArg || (isTauri() && (await isDirectory(path)))) { + const other = await pickDirectory(path); + if (!other || other === path) return; + if (!(await isDirectory(other))) return info("Select a directory to compare"); + await showDirectories(path, other); + return; + } const other = await pickComparison(path); if (!other) return; if (other.type === "file") { @@ -228,6 +248,20 @@ export function registerCompareActions(): () => void { } }, }, + { + id: "diff.compareDirectories", + title: "Compare Directories", + category: "Diff", + icon: "diff", + when: "projectOpen", + keywords: ["diff", "folders", "compare folders"], + run: async ({ arg }) => { + const paths = arg === undefined ? projectViewSelection() : argPaths(arg); + if (paths.length !== 2 || !(await isDirectory(paths[0]!)) || !(await isDirectory(paths[1]!))) + return info("Select two directories to compare"); + await showDirectories(paths[0]!, paths[1]!); + }, + }, { id: "diff.compareFiles", title: "Compare Files", diff --git a/src/features/vcs/compareData.ts b/src/features/vcs/compareData.ts new file mode 100644 index 000000000..fc693fce1 --- /dev/null +++ b/src/features/vcs/compareData.ts @@ -0,0 +1,16 @@ +import { useProject } from "@/platform/project"; +import type { DiffData } from "./diffInput"; + +/** Project-relative path for labels. */ +export function displayPath(path: string, root = useProject.getState().project?.root ?? null): string { + return root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path; +} + +/** Diff of two files on disk; both sides are live, editable documents. */ +export function compareFilesData(leftPath: string, rightPath: string): DiffData { + return { + path: rightPath, + left: { type: "file", path: leftPath, label: displayPath(leftPath) }, + right: { type: "worktree", label: displayPath(rightPath) }, + }; +} diff --git a/src/features/vcs/fileTree.ts b/src/features/vcs/fileTree.ts index a31265634..72bd576fb 100644 --- a/src/features/vcs/fileTree.ts +++ b/src/features/vcs/fileTree.ts @@ -5,15 +5,22 @@ export interface FileTreeNode { /** Directory label (`src/app`) or file name. */ name: string; file: F | null; + /** An explicit directory entry, including empty directories. */ + directory?: F; children: FileTreeNode[]; /** Number of files below (1 for a file). */ count: number; } -export function buildFileTree(files: readonly F[], relPath: (f: F) => string): FileTreeNode[] { +export function buildFileTree( + files: readonly F[], + relPath: (f: F) => string, + isDirectory?: (f: F) => boolean, +): FileTreeNode[] { interface Dir { dirs: Map; files: { name: string; file: F; rel: string }[]; + entry?: F; } const root: Dir = { dirs: new Map(), files: [] }; for (const f of files) { @@ -26,7 +33,11 @@ export function buildFileTree(files: readonly F[], relPath: (f: F) => string) if (!next) d.dirs.set(p, (next = { dirs: new Map(), files: [] })); d = next; } - d.files.push({ name, file: f, rel }); + if (isDirectory?.(f)) { + let sub = d.dirs.get(name); + if (!sub) d.dirs.set(name, (sub = { dirs: new Map(), files: [] })); + sub.entry = f; + } else d.files.push({ name, file: f, rel }); } const convert = (d: Dir, prefix: string): FileTreeNode[] => { @@ -35,7 +46,7 @@ export function buildFileTree(files: readonly F[], relPath: (f: F) => string) let label = name; let path = prefix ? `${prefix}/${name}` : name; let cur = sub; - while (cur.files.length === 0 && cur.dirs.size === 1) { + while (!cur.entry && cur.files.length === 0 && cur.dirs.size === 1) { const [n, only] = [...cur.dirs][0]!; label += `/${n}`; path += `/${n}`; @@ -46,6 +57,7 @@ export function buildFileTree(files: readonly F[], relPath: (f: F) => string) key: `d:${path}`, name: label, file: null, + directory: cur.entry, children, count: children.reduce((n, c) => n + c.count, 0), }); diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index e0099acd0..b18a7ae1d 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from "react"; import type { ChangedFile, Commit, CompareResult, ResetMode, WorkingTreeChange } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { call, commands, nextRequestId } from "@/platform/ipc"; -import { Checkbox, Dialog, Icon, Input, Tree, Spinner, fileIconName, T } from "@/ui"; +import { Checkbox, Dialog, Icon, Input, Spinner, T } from "@/ui"; import { notifications } from "@/workbench/contributions"; import { diffInput, openDiff, type DiffData, type DiffSide } from "./diffInput"; import { openDialog, promptText } from "./dialogs"; @@ -17,10 +17,10 @@ import { runGitOperation, runOp } from "./ops"; import { reloadLog, useGitLog } from "./logStore"; import { conflictedFiles, currentRepo, currentRepoRoot, refreshNow, repoName, repoOf, useVcs } from "./store"; import styles from "./common.module.css"; -import { DiffEditor } from "./DiffEditor"; -import { buildFileTree, dirKeys, type FileTreeNode } from "./fileTree"; +import { ComparisonTree } from "./ComparisonTree"; +import { buildFileTree, type FileTreeNode } from "./fileTree"; import { tx } from "@/platform/i18n"; -import { displayPath } from "./compare"; +import { displayPath } from "./compareData"; // --------------------------------------------------------------------------- // Operation in progress @@ -528,8 +528,6 @@ export function ChangesBetweenDialog({ const [files, setFiles] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(null); - const [selected, setSelected] = useState>(new Set()); - const [expanded, setExpanded] = useState | null>(null); const [current, setCurrent] = useState(null); const fromRev = from.rev; const toRev = to?.rev ?? null; @@ -665,52 +663,25 @@ export function ChangesBetweenDialog({
) : null} {files && files.length > 0 ? ( -
- > - roots={tree} - getKey={(n) => n.key} - getLabel={(n) => n.name} - getChildren={(n) => (n.file ? undefined : n.children)} - isExpandable={(n) => !n.file} - expanded={expanded ?? new Set(dirKeys(tree))} - onExpandedChange={setExpanded} - selected={selected} - onSelectionChange={(sel, lead) => { - setSelected(sel); - if (lead?.file) setCurrent(lead.file); - }} - onActivate={(n) => n.file && setCurrent(n.file)} - aria-label="Changed files" - className={styles.comparisonTree} - renderNode={(n) => ( - - - - {n.name} - - {n.file ? ( - <> - {tx(n.file.kind)} - {n.file.added !== undefined && +{n.file.added}} - {n.file.removed !== undefined && −{n.file.removed}} - - ) : ( - {n.count} - )} - - )} - /> - {input && ( -
- -
- )} -
+ KIND_COLOR[f.kind === "untracked" ? "added" : f.kind]} + ariaLabel="Changed files" + renderDetails={(n) => + n.file ? ( + <> + {tx(n.file.kind)} + {n.file.added !== undefined && +{n.file.added}} + {n.file.removed !== undefined && −{n.file.removed}} + + ) : ( + {n.count} + ) + } + /> ) : null} ); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 0f69670d7..a7637c16d 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5382,5 +5382,36 @@ "in {length} repositories": "在 {length} 个仓库中", "No repository in the selection has revision {label}": "所选内容中的仓库均无版本 {label}", "{repoName} has no revision {label}": "{repoName} 没有版本 {label}", - "Waiting for language server…": "正在等待语言服务器…" + "Waiting for language server…": "正在等待语言服务器…", + "Compare Directories": "比较目录", + "Select two directories to compare": "选择两个要比较的目录", + "Select a directory to compare": "选择要比较的目录", + "Directory path": "目录路径", + "Only on left": "仅左侧", + "Only on right": "仅右侧", + "Contents differ": "内容不同", + "Comparing directories": "正在比较目录", + "Filter by name": "按名称过滤", + "Show identical files": "显示相同文件", + "Copy to Left": "复制到左侧", + "Copy to Right": "复制到右侧", + "Compared files": "比较的文件", + "Comparison cancelled": "已取消比较", + "No matching differences": "没有匹配的差异", + "Overwrite files?": "覆盖文件?", + "Copy to {to} and overwrite existing files?": "复制到 {to} 并覆盖已有文件?", + "Left: {left}": "左侧:{left}", + "Right: {right}": "右侧:{right}", + "Compared {compared} of {total} entries": "已比较 {compared} / {total} 个条目", + "Scanned {scanned} entries": "已扫描 {scanned} 个条目", + "Binary content": "二进制内容", + "Binary files cannot be shown as text": "二进制文件无法以文本形式显示", + "Delete {path}?": "删除 {path}?", + "copy requires an absolute path": "复制需要绝对路径", + "missing file name": "缺少文件名", + "copy requires absolute paths": "复制需要绝对路径", + "copy paths must belong to the comparison roots": "复制路径必须位于比较的根目录内", + "cannot copy overlapping paths": "无法在相互包含的路径之间复制", + "cannot copy through a destination symlink": "无法通过目标目录内的符号链接复制", + "expected a relative comparison entry path": "需要比较条目的相对路径" } From 0c54266d03988be2c98cd370442ffce68d86647c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:18:53 +0800 Subject: [PATCH 05/21] fix(java): build mixed-language Maven dependencies before Java launch (#659) Run/Debug of a Java-only module that depends on a Kotlin/Groovy/Scala module in the same reactor launched with stale classes or failed with Unresolved compilation problem, and a successful Maven build was still reported with the old JDT compile errors. Root cause: the pre-launch external build only checked the launch module's own sources, and Maven shared target/classes with JDT autobuild. Fix: walk the launch module's reactor dependency closure (transitive, test scope aware) for non-Java sources; when found, run Maven from the reactor root (-pl -am clean compile, test-compile for test launches) and block the launch on failure or cancel with the Maven errors in the console. JDT autobuild is paused while Maven runs and restored afterwards, and the launch uses a per-launch snapshot of the closure's output so JDT cannot overwrite it. A successful Maven build no longer shows the stale JDT warning. Pure Java projects and Gradle keep their previous path; the debugger adapter choice still follows the launch module. Verified on macOS with single- and multi-module Maven projects (new Kotlin values in Run and Debug, Kotlin error blocks the launch, cancel, pure Java does not invoke Maven). Editor type resolution for Kotlin in Java is handled separately. Part of #428 --- crates/ide-build/src/compile/mod.rs | 237 +++++++++++++++++++++++++++- crates/ide-host/Cargo.toml | 2 +- crates/ide-host/src/ops/java.rs | 110 +++++++++++-- crates/ide-host/src/project.rs | 4 + crates/ide-lsp/src/jdtls.rs | 37 +++++ crates/ide-lsp/src/server.rs | 25 ++- 6 files changed, 395 insertions(+), 20 deletions(-) diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 857912ada..b674e1adf 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -237,12 +237,7 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option< if !m.projects.iter().any(|p| p.dir == rel) { return Err(IdeError::not_found("no linked Maven project found for module")); } - let top = tops - .iter() - .filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir))) - .max_by_key(|t| t.dir.len()) - .copied() - .unwrap_or(tops[0]); + let top = maven_reactor(m, &rel).ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?; let top_dir = subdir(root, &top.dir); if module == top_dir { return Ok(vec![step(top, &[])]); @@ -611,6 +606,90 @@ pub fn has_non_java_sources(module: &Path) -> bool { walk(&module.join("src"), 12) } +fn reactor_projects<'a>(m: &'a maven::MavenModel, top: &'a maven::MavenProject) -> Vec<&'a maven::MavenProject> { + let mut projects = Vec::new(); + let mut pending = vec![top]; + let mut seen = HashSet::new(); + while let Some(p) = pending.pop() { + if !seen.insert(&p.pom) { + continue; + } + projects.push(p); + pending.extend(m.projects.iter().filter(|child| p.modules.contains(&child.pom))); + } + projects +} + +fn maven_reactor<'a>(m: &'a maven::MavenModel, module: &str) -> Option<&'a maven::MavenProject> { + m.projects.iter().filter(|p| !p.aggregated).find(|top| reactor_projects(m, top).iter().any(|p| p.dir == module)) +} + +/// Launch module first, followed by its upstream modules in the same Maven reactor. +/// External artifacts and unrelated linked reactors are never rebuilt for this launch. +fn maven_launch_projects<'a>( + root: &Path, + m: &'a maven::MavenModel, + module: &Path, + test: bool, +) -> Vec<&'a maven::MavenProject> { + let rel = rel_str(root, module); + let Some(top) = maven_reactor(m, &rel) else { return Vec::new() }; + let reactor = reactor_projects(m, top); + let Some(start) = reactor.iter().find(|p| p.dir == rel) else { return Vec::new() }; + let mut pending = vec![*start]; + let mut seen = HashSet::new(); + let mut projects = Vec::new(); + while let Some(p) = pending.pop() { + if !seen.insert(&p.pom) { + continue; + } + projects.push(p); + for dep in &p.dependencies { + if !dep.resolved() || (dep.scope == "test" && (!test || p.pom != start.pom)) { + continue; + } + if let Some(upstream) = reactor.iter().find(|u| { + u.group_id == dep.group_id + && u.artifact_id == dep.artifact_id + && dep.version.as_ref().is_none_or(|v| *v == u.version) + }) { + pending.push(*upstream); + } + } + } + projects +} + +/// Whether a launch requires the external JVM compiler. Maven includes upstream +/// reactor dependencies; adapter selection remains a separate, module-local decision. +pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Path, test: bool) -> bool { + if let Some(m) = &model.maven { + let projects = maven_launch_projects(root, m, module, test); + if !projects.is_empty() { + return projects.iter().any(|p| has_non_java_sources(&subdir(root, &p.dir))); + } + } + has_non_java_sources(module) +} + +fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult { + let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) { + // JDT may have left newer error stubs in Maven's output directories. + BuildKind::Rebuild + } else { + BuildKind::Build + }; + let mut plan = plan(root, model, kind, Some(module))?; + if plan.system == BuildSystem::Maven && !test { + for step in &mut plan.steps { + if let Some(goal) = step.args.last_mut() { + *goal = "compile".into(); + } + } + } + Ok(plan) +} + /// Compiles the module at `module` with its build system before a JVM launch and /// returns the class directories it wrote, to put ahead of the jdtls classpath. pub fn build_for_launch( @@ -620,7 +699,7 @@ pub fn build_for_launch( test: bool, cancel: &CancellationToken, ) -> IdeResult> { - let plan = plan(root, model, BuildKind::Build, Some(module))?; + let plan = launch_plan(root, model, module, test)?; let result = run(root, &plan, cancel, &|_| {})?; if !result.success { let errors: Vec = result @@ -635,6 +714,14 @@ pub fn build_for_launch( .collect(); return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n")))); } + if plan.system == BuildSystem::Maven + && let Some(m) = &model.maven + { + return Ok(maven_launch_projects(root, m, module, test) + .iter() + .flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module)) + .collect()); + } Ok(launch_class_dirs(plan.system, module, test)) } @@ -821,6 +908,142 @@ mod tests { } } + fn reactor_model() -> BuildModel { + let project = |dir: &str, deps: &[&str]| maven::MavenProject { + pom: format!("{dir}/pom.xml"), + dir: dir.into(), + group_id: "probe".into(), + artifact_id: dir.into(), + version: "1".into(), + aggregated: true, + dependencies: deps + .iter() + .map(|d| maven::MavenDependency { + group_id: "probe".into(), + artifact_id: (*d).into(), + version: Some("1".into()), + scope: "compile".into(), + ..Default::default() + }) + .collect(), + ..Default::default() + }; + let mut projects = vec![ + project("app", &["middle"]), + project("middle", &["lib"]), + project("lib", &["app"]), + project("unrelated", &[]), + ]; + projects.insert( + 0, + maven::MavenProject { + pom: "pom.xml".into(), + artifact_id: "reactor".into(), + command: "mvnw".into(), + modules: projects.iter().map(|p| p.pom.clone()).collect(), + options: vec!["-s".into(), "settings.xml".into()], + env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]), + ..Default::default() + }, + ); + BuildModel { maven: Some(maven::MavenModel { projects, ..Default::default() }), ..Default::default() } + } + + #[test] + fn mixed_launch_detection_follows_only_reactor_dependencies() { + for extension in ["kt", "groovy", "scala"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut model = reactor_model(); + write(root, "app/src/main/java/Application.java", "class Application {}"); + write(root, &format!("unrelated/src/main/java/Other.{extension}"), ""); + assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false)); + write(root, &format!("lib/src/main/java/Channel.{extension}"), ""); + assert!(launch_has_non_java_sources(root, &model, &root.join("app"), false)); + assert_eq!( + maven_launch_projects(root, model.maven.as_ref().unwrap(), &root.join("app"), false).len(), + 3, + "cycles terminate" + ); + let m = model.maven.as_mut().unwrap(); + m.projects[2].dependencies[0].scope = "test".into(); + assert!( + !launch_has_non_java_sources(root, &model, &root.join("app"), true), + "upstream test dependencies are not transitive" + ); + let m = model.maven.as_mut().unwrap(); + m.projects[1].dependencies[0].scope = "test".into(); + m.projects[2].dependencies[0].scope = "compile".into(); + assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false)); + assert!(launch_has_non_java_sources(root, &model, &root.join("app"), true)); + model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("external".into()); + assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), true)); + model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("1".into()); + model.maven.as_mut().unwrap().projects[3].aggregated = false; + model.maven.as_mut().unwrap().projects[0].modules.retain(|p| p != "lib/pom.xml"); + assert!( + !launch_has_non_java_sources(root, &model, &root.join("app"), true), + "another linked reactor is external" + ); + } + } + + #[test] + fn maven_launch_steps_use_reactor_settings_and_launch_scope() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "app/pom.xml", ""); + let model = reactor_model(); + for (test, goal) in [(false, "compile"), (true, "test-compile")] { + let plan = launch_plan(root, &model, &root.join("app"), test).unwrap(); + let step = &plan.steps[0]; + assert_eq!(step.cwd, root); + assert_eq!(step.program, "mvnw"); + assert_eq!(step.env["JAVA_HOME"], "/jdk"); + assert_eq!( + step.args, + ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal] + ); + } + } + + #[cfg(unix)] + #[test] + fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "app/pom.xml", ""); + write( + root, + "mvnw", + "#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n", + ); + let wrapper = root.join("mvnw"); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut model = reactor_model(); + model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned(); + let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap(); + assert_eq!( + dirs, + ["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"] + .map(|p| root.join(p)) + ); + write( + root, + "mvnw", + "#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n", + ); + let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err(); + assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert_eq!( + build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind, + ide_core::error::ErrorKind::Cancelled + ); + } + #[test] fn non_java_launch_class_dirs() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index 4518fa0ff..c63304344 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -48,7 +48,7 @@ parking_lot.workspace = true dunce.workspace = true dirs.workspace = true uuid.workspace = true +tempfile.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } -tempfile.workspace = true diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index 4eccfb01f..6e334ca73 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -5,7 +5,7 @@ use crate::rpc::Dispatcher; use super::build::load; use ide_build::compile; use ide_core::{CancellationToken, IdeError, IdeResult, RequestId}; -use ide_lsp::jdtls::JavaLaunchInfo; +use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo}; use std::path::PathBuf; /// Cancellable with `cancel_request(request)`. @@ -39,23 +39,42 @@ async fn resolve_launch( let project = host.project(window)?; let module = project.root.join(module); let settings = project.settings.effective(); - // Let jdtls finish its build before the external compiler writes Kotlin/Scala classes. - let mut info = project.lsp.java_launch(&module, test, build, cancel).await?; + let (model, external, delegated) = { + let project = project.clone(); + let settings = settings.clone(); + let module = module.clone(); + tokio::task::spawn_blocking(move || { + let model = load(&project.root, &project.build_cache_dir(), &settings); + let external = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test); + let delegated = external + && model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module)); + (model, external, delegated) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? + }; + let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None }; + let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?; let lsp_ms = started.elapsed().as_millis(); let data_dir = host.data_dir.clone(); let cancel = cancel.clone(); tokio::task::spawn_blocking(move || { - let built = if build && compile::has_non_java_sources(&module) { - compile::build_for_launch( - &project.root, - &load(&project.root, &project.build_cache_dir(), &settings), - &module, - test, - &cancel, - )? + let _external_build = external_build; + let built = if external { + compile::build_for_launch(&project.root, &model, &module, test, &cancel)? } else { Vec::new() }; + let built = if delegated { + let (snapshot, copies) = snapshot_outputs(&mut info, &built, &cancel)?; + project.java_launch_outputs.lock().push(snapshot); + info.build = Some(JavaBuildStatus::Succeeded); + info.errors.clear(); + info.error_count = 0; + copies + } else { + built + }; let mut class_paths: Vec = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); for p in std::mem::take(&mut info.class_paths) { if !class_paths.contains(&p) { @@ -103,6 +122,35 @@ async fn resolve_launch( .map_err(|e| IdeError::internal(e.to_string()))? } +fn snapshot_outputs( + info: &mut JavaLaunchInfo, + built: &[PathBuf], + cancel: &CancellationToken, +) -> IdeResult<(tempfile::TempDir, Vec)> { + let snapshot = tempfile::Builder::new().prefix("openidea-java-launch-").tempdir()?; + let mut copies = Vec::new(); + for (index, source) in built.iter().enumerate() { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let dest = snapshot.path().join(index.to_string()); + ide_vfs::copy(source, &dest)?; + let original = source.to_string_lossy(); + info.class_paths.retain(|p| p != original.as_ref()); + let mut modular = false; + for path in &mut info.module_paths { + if path == original.as_ref() { + *path = dest.to_string_lossy().into_owned(); + modular = true; + } + } + if !modular { + copies.push(dest); + } + } + Ok((snapshot, copies)) +} + /// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult { host.require_trust(window)?; @@ -132,3 +180,43 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await }); d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() { + let root = tempfile::tempdir().unwrap(); + let app = root.path().join("app/target/classes"); + let lib = root.path().join("lib/target/classes"); + for dir in [&app, &lib] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("Channel.class"), "Maven bytecode").unwrap(); + } + let mut info = JavaLaunchInfo { + class_paths: vec![app.to_string_lossy().into_owned(), "stdlib.jar".into()], + module_paths: vec![lib.to_string_lossy().into_owned()], + ..Default::default() + }; + let (snapshot, paths) = + snapshot_outputs(&mut info, &[app.clone(), lib.clone()], &CancellationToken::new()).unwrap(); + assert_eq!(paths, [snapshot.path().join("0")]); + assert_eq!(info.class_paths, ["stdlib.jar"]); + assert_eq!(info.module_paths, [snapshot.path().join("1").to_string_lossy().into_owned()]); + std::fs::write(app.join("Channel.class"), "JDT error stub").unwrap(); + std::fs::remove_dir_all(lib).unwrap(); + for index in ["0", "1"] { + assert_eq!( + std::fs::read_to_string(snapshot.path().join(index).join("Channel.class")).unwrap(), + "Maven bytecode" + ); + } + let cancel = CancellationToken::new(); + cancel.cancel(); + assert_eq!( + snapshot_outputs(&mut info, &[app], &cancel).unwrap_err().kind, + ide_core::error::ErrorKind::Cancelled + ); + } +} diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 24cc4aa34..caf5afea0 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -42,6 +42,8 @@ pub struct Project { pub detected_roots: Arc>>, /// Per-project cache directory (build tool sync results…). pub cache_dir: PathBuf, + /// Launch snapshots stay alive while any JVM of this project may load classes. + pub(crate) java_launch_outputs: Mutex>, /// Database tool window: data sources and their connections. pub db: Arc, /// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir. @@ -366,6 +368,7 @@ impl Host { syntax, detected_roots, cache_dir: self.data_dir.join("cache").join(&project_key), + java_launch_outputs: Mutex::new(Vec::new()), db, http_history, agents, @@ -481,6 +484,7 @@ impl Host { syntax, detected_roots: Arc::default(), cache_dir: self.data_dir.join("cache").join(&project_key), + java_launch_outputs: Mutex::new(Vec::new()), db, http_history, agents, diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index e66cafe63..09d6ed610 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -189,6 +189,24 @@ pub struct JavaCompileError { pub message: String, } +/// Holds off JDT writers while an external compiler builds and snapshots launch outputs. +/// Restores current settings even when preparation fails or is cancelled. +pub struct JavaExternalBuild { + server: Arc, + run: Running, + _lock: tokio::sync::OwnedMutexGuard<()>, +} + +impl Drop for JavaExternalBuild { + fn drop(&mut self) { + self.server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst); + let settings = crate::handler::all_settings(&self.server.effective_config()); + if let Err(e) = self.run.peer.notify("workspace/didChangeConfiguration", json!({ "settings": settings })) { + tracing::debug!("jdtls: restoring autobuild: {e}"); + } + } +} + /// Error diagnostics sorted by file and position, capped at `limit`, and their total. pub fn compile_errors( diagnostics: Vec<(PathBuf, lsp_types::Diagnostic)>, @@ -433,9 +451,28 @@ impl LspManager { .map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e)) } + /// Pause autobuild before resolving the launch classpath. That query takes the + /// Java project's workspace scheduling rule, draining the interrupted autobuild. + /// Keep this guard until Maven outputs have been copied out of JDT's reach. + pub async fn java_external_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult { + let (server, run) = self.jdtls(path, cancel).await?; + let lock = cancellable_wait(cancel, server.java_build_lock.clone().lock_owned()).await?; + server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst); + let guard = JavaExternalBuild { server, run, _lock: lock }; + let settings = crate::handler::all_settings(&guard.server.effective_config()); + guard + .run + .peer + .notify("workspace/didChangeConfiguration", json!({ "settings": settings })) + .map_err(|e| crate::rpc_err(SERVER, "workspace/didChangeConfiguration", e))?; + Ok(guard) + } + /// Incremental build of the jdtls workspace (`java/buildWorkspace`), so /// class files are up to date before a launch. pub async fn java_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult { + let (server, _) = self.jdtls(path, cancel).await?; + let _lock = cancellable_wait(cancel, server.java_build_lock.lock()).await?; let mut v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?; // The first build can finish the Maven import before ServiceReady is handled. let (server, run) = self.jdtls(path, cancel).await?; diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 018c81ee0..81c21b021 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -99,6 +99,8 @@ pub struct Server { /// Woken when the last `$/progress` task ends. idle_notify: Notify, transient: Mutex>, + pub java_build_lock: Arc>, + pub java_autobuild_suspended: std::sync::atomic::AtomicBool, pub external_project: tokio::sync::Mutex, pub external_diagnostics: std::sync::atomic::AtomicBool, pub external_refresh_queued: std::sync::atomic::AtomicBool, @@ -126,6 +128,8 @@ impl Server { published_notify: Notify::new(), idle_notify: Notify::new(), transient: Mutex::new(HashMap::new()), + java_build_lock: Arc::new(tokio::sync::Mutex::new(())), + java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false), external_project: tokio::sync::Mutex::new(Default::default()), external_diagnostics: std::sync::atomic::AtomicBool::new(false), external_refresh_queued: std::sync::atomic::AtomicBool::new(false), @@ -134,7 +138,13 @@ impl Server { /// Configuration of the current (or last) instance; the base config before the first launch. pub fn effective_config(&self) -> LanguageServerConfig { - self.effective.lock().clone().unwrap_or_else(|| self.config.clone()) + let mut config = self.effective.lock().clone().unwrap_or_else(|| self.config.clone()); + if self.java_autobuild_suspended.load(std::sync::atomic::Ordering::SeqCst) { + let mut settings = crate::handler::all_settings(&config); + settings["java"]["autobuild"]["enabled"] = serde_json::json!(false); + config.settings = Some(settings); + } + config } pub fn set_effective_config(&self, config: LanguageServerConfig) { @@ -553,6 +563,19 @@ mod tests { assert_eq!(s.record_crash(), Some(Duration::from_millis(500))); } + #[test] + fn external_build_suspends_only_the_effective_autobuild_setting() { + let mut config: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap(); + config.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": true}, "other": "kept"}})); + let server = Server::new(config.clone(), PathBuf::from("/p"), LanguageId::new("java")); + server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst); + let settings = crate::handler::all_settings(&server.effective_config()); + assert_eq!(settings["java"]["autobuild"]["enabled"], false); + assert_eq!(settings["java"]["other"], "kept"); + server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst); + assert_eq!(server.effective_config(), config); + } + #[tokio::test(flavor = "multi_thread")] async fn a_failed_start_reports_its_status_before_waiters_wake() { // The race behind "jdtls is not ready (Starting)": a request woken by the From f8d6f9df7757180a67ea3b2d78504766a8f6f67e Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 15:45:35 +0800 Subject: [PATCH 06/21] fix(editor): Shift+F6 renames the file when no language server covers it (#653) In files no language server covers (Markdown, .properties, plain text), Shift+F6 only showed a transient "no running language server supports rename" hint at the caret, so the default rename shortcut looked broken. Root cause: when prepare_rename reported no server and the syntax fallback declined, startRename fell through to the error hint. It now hands off to project.rename, which opens the file rename dialog for the active file; a running server that lacks rename keeps its hint. Verified on macOS: Shift+F6 in .md and .properties editors renames the file, symbol rename in TypeScript is unchanged, and Shift+F6 in the project view still renames the selected file. Fixes #651 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/editor/lsp/rename.ts | 7 +++++- src/editor/lsp/syntaxFallback.test.ts | 35 +++++++++++++++++++++++++++ src/platform/ipc.ts | 5 ++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/editor/lsp/rename.ts b/src/editor/lsp/rename.ts index 48fb7ec45..aaeed7547 100644 --- a/src/editor/lsp/rename.ts +++ b/src/editor/lsp/rename.ts @@ -15,12 +15,14 @@ import type { EditorView } from "@codemirror/view"; import { commands, isCancelled, + isNoServer, nextRequestId, unavailableText, type Pos, type RequestId, type WorkspaceEdit, } from "@/platform/ipc"; +import { executeAction } from "@/platform/actions"; import { whenSynced, type EditorDocContext } from "@/editor/api"; import { structure } from "@/editor/structure"; import { docOf, incompleteNote, isUnavailable, lspRequest, reportLspError } from "./lsp"; @@ -78,7 +80,10 @@ export async function startRename(view: EditorView) { range = await lspRequest(() => commands.lspPrepareRename(doc.doc, pos)); } catch (e) { if (isCancelled(e)) return; - if (!(await startSyntaxRename(view, doc, pos, e))) reportLspError(view, e, true); + if (await startSyntaxRename(view, doc, pos, e)) return; + // No server covers this file (a plain text file): Shift+F6 renames the file, like IDEA. + if (isNoServer(e) && (await executeAction("project.rename"))) return; + reportLspError(view, e, true); return; } if (!range) { diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 2b811ad5d..366cbb6da 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -30,6 +30,13 @@ vi.mock("@/platform/ipc", async (orig) => { return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } }; }); +const executeAction = vi.hoisted(() => vi.fn()); +vi.mock("@/platform/actions", async (orig) => { + const actual = await orig(); + executeAction.mockImplementation(actual.executeAction); + return { ...actual, executeAction }; +}); + const openFile = vi.fn(async () => {}); const { provideEditorService } = await import("@/workbench/contributions"); provideEditorService({ openFile } as never); @@ -488,6 +495,34 @@ describe("rename wiring", () => { }); }); + it("renames the file itself when no server covers it (a plain text file)", async () => { + cmd.lspPrepareRename.mockReturnValueOnce( + Promise.resolve({ + status: "error", + error: { kind: "unavailable", message: "no running language server supports rename" } as IdeError, + }), + ); + cmd.syntaxSupport.mockReturnValueOnce(ok({ tree: false, locals: false })); + executeAction.mockResolvedValueOnce(true); + const view = editor("# notes", "markdown", 2); + await startRename(view); + expect(executeAction).toHaveBeenCalledWith("project.rename"); + expect(document.querySelector(".cm-lsp-hint")).toBeNull(); + }); + + it("keeps the hint when a server runs but lacks rename (no file fallback)", async () => { + cmd.lspPrepareRename.mockReturnValueOnce( + Promise.resolve({ + status: "error", + error: { kind: "unavailable", message: "jdtls doesn't support rename" } as IdeError, + }), + ); + const view = editor("class A {}", "java", 6); + await startRename(view); + expect(executeAction).not.toHaveBeenCalledWith("project.rename"); + expect(document.querySelector(".cm-lsp-hint")?.textContent).toContain("jdtls doesn't support rename"); + }); + it("respects a server refusal over valid code, but falls back over broken code", async () => { cmd.lspPrepareRename.mockReturnValueOnce(ok(null)); const view = editor("const x = 1;", "typescript", 6); diff --git a/src/platform/ipc.ts b/src/platform/ipc.ts index 9936911de..b9f8c1080 100644 --- a/src/platform/ipc.ts +++ b/src/platform/ipc.ts @@ -46,6 +46,11 @@ export function unavailableText(e: unknown, fallback = "No language server avail return e instanceof IpcError && e.kind === "unavailable" && LACKS_FEATURE.test(e.message) ? e.message : fallback; } +/** True when `unavailable` means no server is running for the file, rather than a running server lacking the feature. */ +export function isNoServer(e: unknown): boolean { + return e instanceof IpcError && e.kind === "unavailable" && !LACKS_FEATURE.test(e.message); +} + // Ids are shared by every window of the backend and outlive a reload there (a cancel that // arrives before its request began is remembered), so each page starts at a random point. let nextId = 1 + Math.floor(Math.random() * 0x7000_0000); From 5c0a5239faefa279474acff1f1740c14b2c4819c Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 15:53:24 +0800 Subject: [PATCH 07/21] feat(appearance): fallback font for the custom UI font (#660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a UI fallback font under Settings › Appearance › Use custom font, so glyphs missing from the chosen UI font (e.g. CJK in Menlo) render in a font the user picks instead of the system default. The fallback joins the UI font stack used by menus, the project tree and dialogs, and resolves macOS user fonts through the shared font loader. Also keeps the custom font controls wrapping together at narrow widths and covers save/clear/disable, stack composition and settings round-trip in tests. Fixes #654 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-core/src/settings.rs | 17 +++++ src/bindings.ts | 2 + .../settings/__tests__/behavior.test.ts | 24 ++++++- .../settings/__tests__/dialog.test.tsx | 18 +++++ src/features/settings/appearanceRuntime.ts | 2 +- src/features/settings/model/behaviorSchema.ts | 3 + .../settings/pages/Behavior.module.css | 15 +++- src/features/settings/pages/behavior.tsx | 68 ++++++++++++------- src/platform/fonts.test.ts | 22 ++++-- src/platform/fonts.ts | 7 +- src/platform/locales/text/zh.json | 2 + 11 files changed, 142 insertions(+), 38 deletions(-) diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index f9390f0da..1b49cd813 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -276,6 +276,8 @@ pub struct AppearanceSettings { /// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's). pub use_custom_font: bool, pub ui_font_family: String, + /// Font tried when `ui_font_family` lacks a glyph; empty = none. + pub ui_font_fallback: String, pub contrast_scrollbars: bool, /// Adjust colours for red-green vision deficiency. pub red_green_vision: bool, @@ -326,6 +328,7 @@ impl Default for AppearanceSettings { zoom: 100, use_custom_font: false, ui_font_family: "Inter".into(), + ui_font_fallback: String::new(), contrast_scrollbars: false, red_green_vision: false, different_tool_window_background: false, @@ -721,6 +724,20 @@ impl Settings { mod tests { use super::*; + #[test] + fn ui_font_fallback_defaults_empty_and_round_trips() { + let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap(); + assert!(old.appearance.ui_font_fallback.is_empty()); + for family in ["", "Songti SC", "Maple Mono NF CN"] { + let mut settings = Settings::default(); + settings.appearance.ui_font_fallback = family.into(); + let json = serde_json::to_value(&settings).unwrap(); + assert_eq!(json["appearance"]["uiFontFallback"], family); + let restored: Settings = serde_json::from_value(json).unwrap(); + assert_eq!(restored.appearance.ui_font_fallback, family); + } + } + #[test] fn tool_window_badges_default_on_and_round_trip() { let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap(); diff --git a/src/bindings.ts b/src/bindings.ts index 97ce05285..5c4e5a3bf 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1422,6 +1422,8 @@ export type AppearanceSettings = { /** Apply `ui_font_family` / `ui_font_size` (otherwise the theme's). */ useCustomFont?: boolean, uiFontFamily?: string, + /** Font tried when `ui_font_family` lacks a glyph; empty = none. */ + uiFontFallback?: string, contrastScrollbars?: boolean, /** Adjust colours for red-green vision deficiency. */ redGreenVision?: boolean, diff --git a/src/features/settings/__tests__/behavior.test.ts b/src/features/settings/__tests__/behavior.test.ts index 3f2cff405..e17a9eb13 100644 --- a/src/features/settings/__tests__/behavior.test.ts +++ b/src/features/settings/__tests__/behavior.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import { formatPattern, formatTimestamp } from "@/platform/dateFormat"; import { resolveThemeId } from "@/workbench/theme"; -import { nextZoom } from "../appearanceRuntime"; +import { applyAppearanceOptions, nextZoom } from "../appearanceRuntime"; +import { validateSettings } from "../model/schema"; import { validateAppearanceExtras, validateNotifications } from "../model/behaviorSchema"; describe("appearance & behavior settings", () => { @@ -27,6 +28,27 @@ describe("appearance & behavior settings", () => { expect(validateAppearanceExtras({ showToolWindowBadges: 1 }).showToolWindowBadges).toBe(true); }); + it("keeps the UI fallback font empty by default and trims a stored one", () => { + expect(validateAppearanceExtras({}).uiFontFallback).toBe(""); + expect(validateAppearanceExtras({ uiFontFallback: " 思源黑体 " }).uiFontFallback).toBe("思源黑体"); + expect(validateAppearanceExtras({ uiFontFallback: 5 }).uiFontFallback).toBe(""); + }); + + it("applies and clears the UI fallback, and removes custom fonts when disabled", () => { + const root = document.createElement("div"); + const settings = validateSettings({ + appearance: { useCustomFont: true, uiFontFamily: "Menlo", uiFontFallback: "Songti SC" }, + }); + applyAppearanceOptions(settings, root); + expect(root.style.getPropertyValue("--font-ui")).toBe('"Menlo", "Songti SC", system-ui, sans-serif'); + settings.appearance.uiFontFallback = ""; + applyAppearanceOptions(settings, root); + expect(root.style.getPropertyValue("--font-ui")).toBe('"Menlo", system-ui, sans-serif'); + settings.appearance.useCustomFont = false; + applyAppearanceOptions(settings, root); + expect(root.style.getPropertyValue("--font-ui")).toBe(""); + }); + it("formats dates with an override pattern and pretty dates", () => { const d = new Date(2026, 0, 5, 14, 7); expect(formatPattern(d, "dd.MM.yyyy")).toBe("05.01.2026"); diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index 4a11b98c0..dbdb5f024 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -222,6 +222,24 @@ describe("Settings dialog", () => { ]); }); + it("enables, saves and clears the custom UI fallback font", async () => { + const dialog = await openDialog("appearance"); + const fallback = within(dialog).getByRole("combobox", { name: "UI fallback font" }); + const custom = within(dialog).getByRole("checkbox", { name: "Use custom font:" }); + expect(fallback).toBeDisabled(); + fireEvent.click(custom); + expect(fallback).toBeEnabled(); + fireEvent.change(fallback, { target: { value: "Songti SC" } }); + expect(settingsSet).not.toHaveBeenCalled(); + await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }))); + expect(settingsSet).toHaveBeenCalledWith("global", "appearance.uiFontFallback", JSON.stringify("Songti SC")); + fireEvent.change(fallback, { target: { value: "" } }); + await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }))); + expect(settingsSet).toHaveBeenCalledWith("global", "appearance.uiFontFallback", JSON.stringify("")); + fireEvent.click(custom); + expect(fallback).toBeDisabled(); + }); + it("edits and saves independent custom font weights", async () => { const dialog = await openDialog("editor.font"); for (const [label, value] of [ diff --git a/src/features/settings/appearanceRuntime.ts b/src/features/settings/appearanceRuntime.ts index 3052bf659..c60c5744b 100644 --- a/src/features/settings/appearanceRuntime.ts +++ b/src/features/settings/appearanceRuntime.ts @@ -33,7 +33,7 @@ export function applyAppearanceOptions(s: ValidSettings, root: HTMLElement = doc if (size === DEFAULT_SETTINGS.appearance.uiFontSize) root.style.removeProperty(name); else root.style.setProperty(name, `${size + delta}px`); } - if (a.useCustomFont) root.style.setProperty("--font-ui", uiFontFamily(a.uiFontFamily)); + if (a.useCustomFont) root.style.setProperty("--font-ui", uiFontFamily(a.uiFontFamily, a.uiFontFallback)); else root.style.removeProperty("--font-ui"); flag(root, "compact", a.compactMode); diff --git a/src/features/settings/model/behaviorSchema.ts b/src/features/settings/model/behaviorSchema.ts index 37fadb745..d1dacd1ee 100644 --- a/src/features/settings/model/behaviorSchema.ts +++ b/src/features/settings/model/behaviorSchema.ts @@ -20,6 +20,7 @@ export interface AppearanceExtras { zoom: number; useCustomFont: boolean; uiFontFamily: string; + uiFontFallback: string; contrastScrollbars: boolean; redGreenVision: boolean; differentToolWindowBackground: boolean; @@ -72,6 +73,7 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = { zoom: 100, useCustomFont: false, uiFontFamily: "Inter", + uiFontFallback: "", contrastScrollbars: false, redGreenVision: false, differentToolWindowBackground: false, @@ -123,6 +125,7 @@ export function validateAppearanceExtras(raw: unknown): AppearanceExtras { zoom: int(a.zoom, d.zoom, ZOOM_LIMITS), useCustomFont: b("useCustomFont"), uiFontFamily: typeof a.uiFontFamily === "string" && a.uiFontFamily.trim() ? a.uiFontFamily.trim() : d.uiFontFamily, + uiFontFallback: typeof a.uiFontFallback === "string" ? a.uiFontFallback.trim() : d.uiFontFallback, contrastScrollbars: b("contrastScrollbars"), redGreenVision: b("redGreenVision"), differentToolWindowBackground: b("differentToolWindowBackground"), diff --git a/src/features/settings/pages/Behavior.module.css b/src/features/settings/pages/Behavior.module.css index a1c511bbc..0a36ad272 100644 --- a/src/features/settings/pages/Behavior.module.css +++ b/src/features/settings/pages/Behavior.module.css @@ -24,7 +24,7 @@ padding: 8px 14px; } -/* "[x] Use custom font: [Inter] Size: [13]" */ +/* Inline option groups, with custom font controls wrapping together. */ .fontRow { display: flex; align-items: center; @@ -37,8 +37,17 @@ .fontInput { width: 220px; } -.sizeLabel { - margin-left: 8px; +.customFontRow { + flex-wrap: wrap; +} +.fontOption { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 100%; +} +.fontOption > label { + flex-shrink: 0; } .sizeInput { width: 64px; diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index f2a1732a0..6143d3abb 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -181,35 +181,53 @@ function ZoomRow() { function CustomFontRow() { const [on, setOn] = useSetting("appearance.useCustomFont"); const [family, setFamily] = useSetting("appearance.uiFontFamily"); + const [fallback, setFallback] = useSetting("appearance.uiFontFallback"); const [size, setSize] = useSetting("appearance.uiFontSize"); const id = useId(); const matched = useLabelMatch("Use custom font"); return ( -
- } /> - - - {on ? ( - + + } /> + + + + + - ) : ( - - )} + + + + {on ? ( + + ) : ( + + )} +
); } @@ -531,7 +549,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [ { kind: "custom", label: "Use custom font", - keywords: ["UI font size", "Size", "font family"], + keywords: ["UI font size", "Size", "font family", "fallback font"], component: CustomFontRow, }, { kind: "toggle", key: "appearance.contrastScrollbars", label: "Use contrast scrollbars" }, diff --git a/src/platform/fonts.test.ts b/src/platform/fonts.test.ts index f8060c04c..65cf7520f 100644 --- a/src/platform/fonts.test.ts +++ b/src/platform/fonts.test.ts @@ -36,11 +36,13 @@ it("escapes CSS special characters without turning a family into a list", () => describe("uiFontFamily", () => { it.each([ - ["Inter", '"Inter Variable", "Inter", system-ui, sans-serif'], - ["JetBrains Mono", '"JetBrains Mono Variable", "JetBrains Mono", system-ui, sans-serif'], - [' "PingFang SC" ', '"PingFang SC", system-ui, sans-serif'], - ])("%j", (family, expected) => { - expect(uiFontFamily(family)).toBe(expected); + ["Inter", "", '"Inter Variable", "Inter", system-ui, sans-serif'], + ["JetBrains Mono", "", '"JetBrains Mono Variable", "JetBrains Mono", system-ui, sans-serif'], + [' "PingFang SC" ', "", '"PingFang SC", system-ui, sans-serif'], + ["Inter", "思源黑体", '"Inter Variable", "Inter", "思源黑体", system-ui, sans-serif'], + ["Inter", " ", '"Inter Variable", "Inter", system-ui, sans-serif'], + ])("%j + %j", (family, fallback, expected) => { + expect(uiFontFamily(family, fallback)).toBe(expected); }); }); @@ -126,6 +128,16 @@ describe("macOS user font resolver", () => { expect(invoke).not.toHaveBeenCalled(); }); + it("loads a UI fallback through the shared macOS user font resolver", async () => { + const { fonts, invoke, registered } = await resolver(false); + const family = fonts.uiFontFamily("Inter", "Maple Mono NF CN"); + expect(family).toBe('"Inter Variable", "Inter", "OpenIDEA User Font 0", "Maple Mono NF CN", system-ui, sans-serif'); + expect(fonts.monoFontFamily("Inter", "Maple Mono NF CN")).toContain('"OpenIDEA User Font 0"'); + await fonts.fontsLoaded(family, 13); + expect(invoke).toHaveBeenCalledExactlyOnceWith("user_font_data", { family: "Maple Mono NF CN" }); + expect(registered).toHaveLength(3); + }); + it("detects user fonts that WKWebView exposes only to connected canvases without loading bytes", async () => { const { fonts, invoke, canvases } = await resolver("connected"); expect(fonts.fontAvailable("Maple Mono NF CN")).toBe(true); diff --git a/src/platform/fonts.ts b/src/platform/fonts.ts index 161a98739..9acf9eb31 100644 --- a/src/platform/fonts.ts +++ b/src/platform/fonts.ts @@ -140,7 +140,8 @@ export async function fontsLoaded(family: string, size: number): Promise { } } -/** Bundled aliases also apply when selected for the interface. */ -export function uiFontFamily(family: string): string { - return [...familyFaces(family), "system-ui", "sans-serif"].join(", "); +/** Bundled aliases also apply when selected for the interface; `fallback` covers glyphs `family` lacks (empty = none). */ +export function uiFontFamily(family: string, fallback = ""): string { + const fb = familyName(fallback); + return [...familyFaces(family), ...(fb ? familyFaces(fb) : []), "system-ui", "sans-serif"].join(", "); } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index a7637c16d..8204ccd51 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -760,6 +760,7 @@ "Failed": "失败", "Failed to restart language server": "重启语言服务器失败", "Fallback font": "备用字体", + "Fallback font:": "备用字体:", "Fetch": "获取", "Fetches remotes in the background so incoming commits show in the Branches popup. Auto stops after an authentication failure.": "在后台获取远程,以便传入的提交显示在“分支”弹出窗口中。“自动”在身份验证失败后停止。", "Field access": "字段访问", @@ -2223,6 +2224,7 @@ "Merged with main toolbar": "与主工具栏合并", "UI Options": "UI 选项", "UI elements take up less screen space": "UI 元素占用更少的屏幕空间", + "UI fallback font": "UI 备用字体", "UI font": "UI 字体", "UI font size": "UI 字体大小", "Uninstalled {name}": "已卸载 {name}", From d3e63d8ff0febf8a8f82a58efa20aa9e846553e5 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:05:16 +0800 Subject: [PATCH 08/21] feat(search): select and open multiple search results (#670) Search Everywhere and Recent Files lists now support multi-selection: Shift+Up/Down and Shift+click extend a range from the anchor, Cmd/Ctrl+click toggles a row, and Enter opens every selected file, symbol or text result in list order, leaving focus on the lead row. Actions in a mixed selection are skipped; a single selection behaves as before. Selection resets when the query or tab changes. The row-selection model used by Find in Files moves to src/ui so the shared List can opt into modifier selection. Fixes #647 --- .../search/__tests__/dialogs.test.tsx | 280 +++++++++++++++++- src/features/search/common/open.ts | 14 + .../search/everywhere/SearchEverywhere.tsx | 100 +++++-- src/features/search/find/rowSelection.ts | 53 +--- src/features/search/recent/RecentPopups.tsx | 69 ++++- src/ui/List.tsx | 16 +- src/ui/index.ts | 1 + src/ui/rowSelection.test.ts | 22 ++ src/ui/rowSelection.ts | 74 +++++ 9 files changed, 535 insertions(+), 94 deletions(-) create mode 100644 src/ui/rowSelection.test.ts create mode 100644 src/ui/rowSelection.ts diff --git a/src/features/search/__tests__/dialogs.test.tsx b/src/features/search/__tests__/dialogs.test.tsx index 3e10bacd2..202c6dffe 100644 --- a/src/features/search/__tests__/dialogs.test.tsx +++ b/src/features/search/__tests__/dialogs.test.tsx @@ -1,12 +1,17 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { registerAction } from "@/platform/actions"; import type { SeDeps } from "../everywhere/controller"; +const defaultFindFiles: SeDeps["findFiles"] = async (q) => + q ? [{ path: "/p/src/OrderController.kt", relPath: "src/OrderController.kt", score: 1, indices: [4, 5, 6] }] : []; +let findFiles = defaultFindFiles; +let findSymbols: SeDeps["findSymbols"] = async () => []; +let settings: NonNullable = async () => []; const fakeDeps = (): SeDeps => ({ - findFiles: async (q) => - q ? [{ path: "/p/src/OrderController.kt", relPath: "src/OrderController.kt", score: 1, indices: [4, 5, 6] }] : [], - findSymbols: async () => [], + findFiles: (...args) => findFiles(...args), + findSymbols: (...args) => findSymbols(...args), + settings: () => settings(), searchText: async () => {}, cancel: () => {}, nextRequestId: () => 1, @@ -18,6 +23,11 @@ const fakeDeps = (): SeDeps => ({ now: () => 0, }); +vi.mock("../common/open", async (orig) => ({ + ...(await orig()), + openLocation: vi.fn(async () => {}), +})); + vi.mock("../everywhere/deps", () => ({ ipcDeps: () => fakeDeps(), loadLanguages: async () => [ @@ -42,6 +52,12 @@ vi.mock("../find/windowStore", async (orig) => ({ }), })); +const contributions = await import("@/workbench/contributions"); +const { commands } = await import("@/platform/ipc"); +const { RecentPopupsOverlay } = await import("../recent/RecentPopups"); +const { showRecentPopup, hideRecentPopup, useRecentPopup } = await import("../recent/store"); +const { openLocation } = await import("../common/open"); +const { usePlatform } = await import("@/platform/platformInfo"); const { allActions } = await import("@/platform/actions"); (globalThis as unknown as { __actions: typeof allActions }).__actions = allActions; const { SearchEverywhereOverlay } = await import("../everywhere/SearchEverywhere"); @@ -52,10 +68,18 @@ const { showFindPopup, hideFindPopup } = await import("../find/store"); const tick = () => act(() => new Promise((r) => setTimeout(r, 100))); +beforeEach(() => { + findFiles = defaultFindFiles; + findSymbols = async () => []; + settings = async () => []; + vi.mocked(openLocation).mockClear(); +}); + afterEach(() => { act(() => { hideSearchEverywhere(); hideFindPopup(); + hideRecentPopup(); }); }); @@ -203,6 +227,254 @@ describe("Search Everywhere dialog", () => { }); }); +const duplicateFiles: Awaited> = ["one", "two", "three"].map((dir) => ({ + path: `/p/${dir}/index.ts`, + relPath: `${dir}/index.ts`, + score: 1, + indices: [0, 1, 2, 3, 4], +})); +const resultOptions = () => within(document.getElementById("se-results")!).getAllByRole("option"); +const selectedOptions = () => resultOptions().filter((r) => r.getAttribute("aria-selected") === "true"); +async function showDuplicateFiles() { + findFiles = async () => duplicateFiles; + render(); + act(() => showSearchEverywhere("files", { query: "index" })); + await tick(); + return screen.getByLabelText("Search query"); +} +function clickOption(index: number, modifiers = {}) { + const row = resultOptions()[index]!; + fireEvent.mouseDown(row, modifiers); + fireEvent.click(row, modifiers); +} + +describe("Search Everywhere multi-selection", () => { + it("extends, shrinks, crosses the anchor and clamps Shift selection; plain arrows select one", async () => { + const input = await showDuplicateFiles(); + const key = (key: string, shiftKey = true) => fireEvent.keyDown(input, { key, shiftKey }); + key("ArrowDown", false); + key("ArrowDown"); + expect(selectedOptions()).toEqual(resultOptions().slice(1)); + key("ArrowDown"); + expect(selectedOptions()).toHaveLength(2); + key("ArrowUp"); + expect(selectedOptions()).toEqual([resultOptions()[1]]); + key("ArrowUp"); + expect(selectedOptions()).toEqual(resultOptions().slice(0, 2)); + key("ArrowDown", false); + expect(selectedOptions()).toEqual([resultOptions()[1]]); + expect(screen.getByRole("listbox", { name: "Results" })).toHaveAttribute("aria-multiselectable", "true"); + }); + + it("Shift-click selects a range without opening; an ordinary click opens just that file", async () => { + await showDuplicateFiles(); + clickOption(2, { shiftKey: true }); + expect(selectedOptions()).toHaveLength(3); + expect(openLocation).not.toHaveBeenCalled(); + expect(useSearchEverywhere.getState().open).toBe(true); + clickOption(1); + await tick(); + expect(openLocation).toHaveBeenCalledExactlyOnceWith("/p/two/index.ts", null, { side: false }); + expect(useSearchEverywhere.getState().open).toBe(false); + }); + + it.each(["macos", "windows", "linux"] as const)("uses the primary modifier to toggle a row on %s", async (os) => { + const previous = usePlatform.getState().os; + usePlatform.setState({ os }); + try { + const input = await showDuplicateFiles(); + const modifier = os === "macos" ? { metaKey: true } : { ctrlKey: true }; + clickOption(2, modifier); + expect(selectedOptions()).toEqual([resultOptions()[0], resultOptions()[2]]); + clickOption(0, modifier); + expect(selectedOptions()).toEqual([resultOptions()[2]]); + expect(openLocation).not.toHaveBeenCalled(); + fireEvent.keyDown(input, { key: "Enter" }); + await tick(); + expect(openLocation).toHaveBeenCalledExactlyOnceWith("/p/three/index.ts", null, { side: false }); + } finally { + usePlatform.setState({ os: previous }); + } + }); + + it("awaits each open in list order, keeps tabs and restores an upward range's lead", async () => { + const input = await showDuplicateFiles(); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "ArrowUp", shiftKey: true }); + let finishFirst!: () => void; + vi.mocked(openLocation).mockImplementationOnce( + () => + new Promise((r) => { + finishFirst = r; + }), + ); + fireEvent.keyDown(input, { key: "Enter" }); + await tick(); + expect(openLocation).toHaveBeenCalledTimes(1); + expect(useSearchEverywhere.getState().open).toBe(true); + fireEvent.keyDown(input, { key: "Enter" }); + expect(openLocation).toHaveBeenCalledTimes(1); + expect(openLocation).toHaveBeenNthCalledWith(1, "/p/two/index.ts", null, { + preview: false, + focus: false, + side: false, + }); + await act(async () => finishFirst()); + await tick(); + expect(vi.mocked(openLocation).mock.calls).toEqual([ + ["/p/two/index.ts", null, { preview: false, focus: false, side: false }], + ["/p/three/index.ts", null, { preview: false, focus: false, side: false }], + ["/p/two/index.ts", null, { preview: false, focus: true, side: false }], + ]); + expect(useSearchEverywhere.getState().open).toBe(false); + }); + + it("resets selection immediately on query changes and tab switches", async () => { + const input = await showDuplicateFiles(); + clickOption(2, { shiftKey: true }); + fireEvent.change(input, { target: { value: "index.t" } }); + expect(selectedOptions()).toHaveLength(1); + await tick(); + clickOption(2, { shiftKey: true }); + fireEvent.click(screen.getByRole("tab", { name: "All" })); + expect(selectedOptions()).toHaveLength(1); + await tick(); + expect(selectedOptions()).toHaveLength(1); + }); + + it.each(["classes", "symbols"] as const)("opens selected %s at their positions", async (tab) => { + findSymbols = async () => + ["First", "Second"].map((name, line) => ({ + name, + kind: tab === "classes" ? "class" : "function", + container: null, + path: `/p/${name}.ts`, + relPath: `${name}.ts`, + score: 1, + indices: [0], + range: { start: { line, col: 2 }, end: { line, col: 5 } }, + })); + render(); + act(() => showSearchEverywhere(tab, { query: "s" })); + await tick(); + const input = screen.getByLabelText("Search query"); + fireEvent.keyDown(input, { key: "ArrowDown", shiftKey: true }); + fireEvent.keyDown(input, { key: "Enter" }); + await tick(); + expect(openLocation).toHaveBeenNthCalledWith( + 1, + "/p/First.ts", + { line: 0, col: 2 }, + expect.objectContaining({ preview: false }), + ); + expect(openLocation).toHaveBeenNthCalledWith( + 2, + "/p/Second.ts", + { line: 1, col: 2 }, + expect.objectContaining({ preview: false }), + ); + }); + + it("ignores actions and settings in a mixed selection even when the lead is an action", async () => { + const run = vi.fn(); + const dispose = registerAction({ id: "test.index", title: "Index", category: "Test", run }); + findFiles = async () => duplicateFiles.slice(0, 2); + settings = async () => [{ pageId: "editor", title: "Index", path: ["Editor"] }]; + render(); + act(() => showSearchEverywhere("all", { query: "index" })); + await tick(); + const input = screen.getByLabelText("Search query"); + const count = resultOptions().length; + for (let i = 1; i < count; i++) fireEvent.keyDown(input, { key: "ArrowDown" }); + for (let i = 1; i < count; i++) fireEvent.keyDown(input, { key: "ArrowUp", shiftKey: true }); + expect(selectedOptions()).toHaveLength(count); + fireEvent.keyDown(input, { key: "Enter" }); + await tick(); + expect(run).not.toHaveBeenCalled(); + expect(vi.mocked(openLocation).mock.calls.map((c) => c[0])).toEqual([ + "/p/one/index.ts", + "/p/two/index.ts", + "/p/two/index.ts", + ]); + dispose(); + }); + + it("does not execute a multi-selection containing only actions", async () => { + const run = vi.fn(); + const disposers = ["One", "Two"].map((name) => + registerAction({ id: `test.index${name}`, title: `Index ${name}`, run }), + ); + render(); + act(() => showSearchEverywhere("actions", { query: "index" })); + await tick(); + const input = screen.getByLabelText("Search query"); + fireEvent.keyDown(input, { key: "ArrowDown", shiftKey: true }); + fireEvent.keyDown(input, { key: "Enter" }); + await tick(); + expect(run).not.toHaveBeenCalled(); + expect(openLocation).not.toHaveBeenCalled(); + disposers.forEach((d) => d()); + }); +}); + +describe("Recent Files multi-selection", () => { + async function showFiles() { + vi.spyOn(commands, "recentFiles").mockResolvedValue({ status: "ok", data: duplicateFiles.map((f) => f.path) }); + render(); + act(() => showRecentPopup("files")); + await tick(); + return screen.getByLabelText("Filter recent files"); + } + const options = () => within(screen.getByRole("listbox", { name: "Recent files" })).getAllByRole("option"); + afterEach(() => vi.restoreAllMocks()); + + it("extends and shrinks a keyboard range, then opens its files in order", async () => { + const input = await showFiles(); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "ArrowUp", shiftKey: true }); + expect(options().filter((r) => r.getAttribute("aria-selected") === "true")).toHaveLength(2); + fireEvent.keyDown(input, { key: "Enter" }); + await tick(); + expect(vi.mocked(openLocation).mock.calls.map((c) => c[0])).toEqual([ + "/p/one/index.ts", + "/p/two/index.ts", + "/p/one/index.ts", + ]); + expect(useRecentPopup.getState().open).toBeNull(); + }); + + it("starts a range at the initially selected previous file", async () => { + vi.spyOn(contributions, "maybeEditorService").mockReturnValue({ + activePath: () => "/p/one/index.ts", + } as import("@/workbench/contributions").EditorService); + const input = await showFiles(); + expect(options()[1]).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(input, { key: "ArrowDown", shiftKey: true }); + expect(options()[0]).toHaveAttribute("aria-selected", "false"); + expect(options()[1]).toHaveAttribute("aria-selected", "true"); + expect(options()[2]).toHaveAttribute("aria-selected", "true"); + }); + + it("handles modifier clicks and resets on filter text changes", async () => { + const input = await showFiles(); + const click = (i: number, modifiers: object) => { + fireEvent.mouseDown(options()[i]!, modifiers); + fireEvent.click(options()[i]!, modifiers); + }; + click(2, { shiftKey: true }); + expect(options().filter((r) => r.getAttribute("aria-selected") === "true")).toHaveLength(3); + click(1, { metaKey: true }); + expect(options().filter((r) => r.getAttribute("aria-selected") === "true")).toEqual([options()[0], options()[2]]); + expect(openLocation).not.toHaveBeenCalled(); + fireEvent.change(input, { target: { value: "index" } }); + expect(options().filter((r) => r.getAttribute("aria-selected") === "true")).toHaveLength(1); + click(1, {}); + await tick(); + expect(openLocation).toHaveBeenCalledExactlyOnceWith("/p/two/index.ts", null, { side: false }); + }); +}); + describe("Find in Files dialog", () => { it("streams results and previews the replacement", async () => { render(); diff --git a/src/features/search/common/open.ts b/src/features/search/common/open.ts index c5f52601f..a11da3857 100644 --- a/src/features/search/common/open.ts +++ b/src/features/search/common/open.ts @@ -82,3 +82,17 @@ export function saveJson(key: string, value: unknown) { /* storage unavailable */ } } + +/** Open persistent tabs in list order, then restore the lead's focus and position. */ +export async function openInListOrder( + items: readonly T[], + lead: T | undefined, + open: (item: T, options: Omit) => Promise, + side = false, + afterOpen?: () => Promise, +): Promise { + for (const item of items) await open(item, { preview: false, focus: false, side }); + await afterOpen?.(); + const target = lead && items.includes(lead) ? lead : items.at(-1); + if (target) await open(target, { preview: false, focus: true, side }); +} diff --git a/src/features/search/everywhere/SearchEverywhere.tsx b/src/features/search/everywhere/SearchEverywhere.tsx index f5bd6c410..c5c50460f 100644 --- a/src/features/search/everywhere/SearchEverywhere.tsx +++ b/src/features/search/everywhere/SearchEverywhere.tsx @@ -18,12 +18,16 @@ import { Select, Toggle, listNavigate, + selectRow, + selectedRows, + rowSelectionMode, + type RowSelection, type MenuItem, T, } from "@/ui"; import { useFileDecoration } from "@/workbench/contributions"; import { highlightTokens, languageForPath, type CodeToken } from "@/editor/lsp/markdown"; -import { badgeKind, basename, openLocation, relDir } from "../common/open"; +import { badgeKind, basename, openInListOrder, openLocation, relDir } from "../common/open"; import { parseLocationQuery } from "../common/query"; import { CodePreview, type PreviewHighlight } from "../find/parts"; import { SeController, type SeSnapshot } from "./controller"; @@ -81,7 +85,8 @@ export function SearchEverywhereOverlay() { return open ? : null; } -type Selection = { key: string | null; index: number; moved: boolean }; +type Selection = RowSelection & { key: string | null; anchorKey: string | null; moved: boolean }; +const INITIAL_SELECTION: Selection = { key: null, anchorKey: null, lead: -1, anchor: -1, keys: null, moved: false }; function SearchEverywhereDialog() { const tx = useTx(); @@ -100,7 +105,7 @@ function SearchEverywhereDialog() { const [extra, setExtra] = useState>>({}); const [snap, setSnap] = useState(null); const [controller] = useState(() => new SeController(ipcDeps(ctx), setSnap)); - const [sel, setSel] = useState({ key: null, index: -1, moved: false }); + const [sel, setSel] = useState(INITIAL_SELECTION); const [menu, setMenu] = useState<{ anchor: Element; items: MenuItem[]; @@ -134,12 +139,19 @@ function SearchEverywhereDialog() { return () => clearTimeout(t); }, [query, debounced]); + // Reset selection immediately, including while the new query is debouncing. + const [selectionInputs, setSelectionInputs] = useState({ query, tab }); + if (selectionInputs.query !== query || selectionInputs.tab !== tab) { + setSelectionInputs({ query, tab }); + setSel(INITIAL_SELECTION); + } + // New query / scope: forget "Show more" and the user's selection. const [prevInputs, setPrevInputs] = useState({ debounced, tab }); if (prevInputs.debounced !== debounced || prevInputs.tab !== tab) { setPrevInputs({ debounced, tab }); setExtra({}); - setSel({ key: null, index: -1, moved: false }); + setSel(INITIAL_SELECTION); } // "/fi" lists scope commands instead of searching. @@ -177,7 +189,7 @@ function SearchEverywhereDialog() { () => listing ?? (snap ? buildRows(snap.sections, { headers: !parsed.text.trim(), mix }) : []), [listing, snap, parsed.text, mix], ); - const active = stableIndex(rows, sel.key, sel.index, sel.moved); + const active = stableIndex(rows, sel.key, sel.lead, sel.moved); const activeRow = active >= 0 ? rows[active] : undefined; // Rows still answer an older query (debounce / in-flight search): Enter must wait. const settled = @@ -187,8 +199,20 @@ function SearchEverywhereDialog() { snap.tab === tab && topResultSettled(snap.sections, mix); const pendingEnter = useRef<{ side: boolean } | null>(null); + const opening = useRef(false); - const select = (index: number) => setSel({ key: rows[index]?.key ?? null, index, moved: true }); + const chosen = selectedRows(rows, { ...sel, lead: active }); + const select = (index: number, e?: KeyboardEvent | MouseEvent) => { + const anchor = rows.findIndex((r) => r.key === sel.anchorKey); + const next = selectRow( + rows, + { ...sel, lead: active, anchor: anchor < 0 ? active : anchor }, + index, + rowSelectionMode(e), + isSectionRow, + ); + setSel({ ...next, key: rows[next.lead]?.key ?? null, anchorKey: rows[next.anchor]?.key ?? null, moved: true }); + }; /** Typed or picked query; "/files foo" switches to Files with "foo". */ const setQuery = (value: string) => { history.current.pos = -1; @@ -246,24 +270,13 @@ function SearchEverywhereDialog() { case "more": { const step = tab === "all" ? MORE_STEP_ALL : MORE_STEP_TAB; setExtra((e) => ({ ...e, [row.section]: (e[row.section] ?? 0) + step })); - setSel({ key: null, index: rows.indexOf(row), moved: true }); + setSel({ ...INITIAL_SELECTION, lead: rows.indexOf(row), moved: true }); return; } case "file": - return closeThen( - () => void openLocation(row.path, row.line !== null ? { line: row.line, col: row.col ?? 0 } : null, { side }), - ); case "symbol": - return closeThen(() => void openLocation(row.path, { line: row.line, col: row.col }, { side })); case "text": - return closeThen( - () => - void openLocation( - row.path, - { line: row.line, col: row.col, endLine: row.line, endCol: row.endCol }, - { side }, - ), - ); + return closeThen(() => void openResult(row, { side })); case "action": if (!row.enabled) return; pushRecentAction(row.id); @@ -277,11 +290,27 @@ function SearchEverywhereDialog() { } }; + const activateSelection = (side = false) => { + if (opening.current) return; + if (chosen.length <= 1) return activate(chosen[0], side); + const navigable = chosen.filter(isNavigationRow); + if (!navigable.length) return close(); + opening.current = true; + pushSearchHistory(tab, query); + void openInListOrder( + navigable, + activeRow && isNavigationRow(activeRow) ? activeRow : undefined, + openResult, + side, + () => new Promise((resolve) => closeThen(resolve)), + ); + }; + useEffect(() => { const pending = pendingEnter.current; if (!pending || !settled) return; pendingEnter.current = null; - activate(activeRow, pending.side); + activateSelection(pending.side); }); /** IDEA's ⌥↵ on an action: record a shortcut for it (written to the active keymap). */ @@ -291,7 +320,7 @@ function SearchEverywhereDialog() { const row = activeRow; if (!row || row.kind === "section" || row.kind === "more" || row.kind === "command") return; if (row.kind === "action") return assignShortcut(row.id); - const anchor = listRef.current?.querySelector('[aria-selected="true"]') ?? inputRef.current; + const anchor = listRef.current?.querySelector('[data-lead="true"]') ?? inputRef.current; if (!anchor) return; const copy = (text: string) => void navigator.clipboard?.writeText(text).catch(() => {}); const items: MenuItem[] = @@ -331,7 +360,7 @@ function SearchEverywhereDialog() { closeThen(() => void executeAction("find.inPath", { query: parsed.text })); else if (listing) activate(activeRow); else if (!settled && !sel.moved) pendingEnter.current = { side: e.shiftKey }; - else activate(activeRow, e.shiftKey); + else activateSelection(e.shiftKey); return; } if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown")) { @@ -347,15 +376,15 @@ function SearchEverywhereDialog() { const jump = groupJump(rows, active, e.key === "PageDown" || e.key === "ArrowDown" ? 1 : -1); if (jump !== null) { e.preventDefault(); - select(jump); + select(jump, e); return; } } if (!["ArrowDown", "ArrowUp", "PageDown", "PageUp"].includes(e.key)) return; - const next = listNavigate(e.key, { items: rows, active, isSection: isSectionRow, wrap: true, pageSize: 10 }); + const next = listNavigate(e.key, { items: rows, active, isSection: isSectionRow, wrap: !e.shiftKey, pageSize: 10 }); if (next === null) return; e.preventDefault(); - select(next); + select(next, e); }; const maxList = preview ? MAX_LIST_PREVIEW : MAX_LIST; @@ -522,7 +551,8 @@ function SearchEverywhereDialog() { {r.kind === "section" ? {r.title} : ""} )} activeIndex={active} - onActiveChange={select} + onSelect={select} + isSelected={(r) => !!sel.keys?.has(r.key)} onActivate={(r, _i, e) => activate(r, (e as MouseEvent).shiftKey)} focusable={false} rowHeight={ROW} @@ -844,3 +874,21 @@ function FileRow({ ); } + +type NavigationRow = Extract; +function isNavigationRow(row: SeRow): row is NavigationRow { + return row.kind === "file" || row.kind === "symbol" || row.kind === "text"; +} +function openResult(row: NavigationRow, options: Parameters[2]): Promise { + if (row.kind === "file") + return openLocation(row.path, row.line !== null ? { line: row.line, col: row.col ?? 0 } : null, options); + return openLocation( + row.path, + { + line: row.line, + col: row.col, + ...(row.kind === "text" ? { endLine: row.line, endCol: row.endCol } : {}), + }, + options, + ); +} diff --git a/src/features/search/find/rowSelection.ts b/src/features/search/find/rowSelection.ts index 19d35b58a..6930c0073 100644 --- a/src/features/search/find/rowSelection.ts +++ b/src/features/search/find/rowSelection.ts @@ -1,52 +1 @@ -/** - * Multi-selection of the Find in Files results list: click / ↑↓ select one row, - * ⇧ extends from the anchor, ⌘/Ctrl toggles a row (IDEA's results table). - */ - -export interface RowSelection { - /** Active row (keyboard focus, preview). */ - lead: number; - /** Start of ⇧ ranges. */ - anchor: number; - /** Keys of the selected rows; `null` when only the lead is selected. */ - keys: ReadonlySet | null; -} - -export const SINGLE_FIRST: RowSelection = { lead: 0, anchor: 0, keys: null }; - -export function selectRow( - rows: ReadonlyArray<{ key: string }>, - sel: RowSelection, - index: number, - mode: "single" | "range" | "toggle" = "single", -): RowSelection { - if (index < 0 || index >= rows.length) return sel; - if (mode === "range") { - const anchor = Math.min(Math.max(0, sel.anchor), rows.length - 1); - const [a, b] = anchor <= index ? [anchor, index] : [index, anchor]; - return { lead: index, anchor, keys: new Set(rows.slice(a, b + 1).map((r) => r.key)) }; - } - if (mode === "toggle") { - const keys = new Set(sel.keys ?? (rows[sel.lead] ? [rows[sel.lead]!.key] : [])); - const key = rows[index]!.key; - if (!keys.has(key)) return { lead: index, anchor: index, keys: keys.add(key) }; - keys.delete(key); - const rest = rows.findLastIndex((r) => keys.has(r.key)); - if (rest < 0) return { lead: index, anchor: index, keys: null }; - return { lead: rest, anchor: rest, keys }; - } - return { lead: index, anchor: index, keys: null }; -} - -/** The selected rows in list order (the lead when nothing else is selected). */ -export function selectedRows(rows: readonly T[], sel: RowSelection): T[] { - if (!rows.length) return []; - const lead = Math.min(sel.lead, rows.length - 1); - if (!sel.keys) return [rows[lead]!]; - const out = rows.filter((r) => sel.keys!.has(r.key)); - return out.length ? out : [rows[lead]!]; -} - -export function isRowSelected(sel: RowSelection, key: string): boolean { - return !!sel.keys?.has(key); -} +export { SINGLE_FIRST, isRowSelected, selectRow, selectedRows, type RowSelection } from "@/ui/rowSelection"; diff --git a/src/features/search/recent/RecentPopups.tsx b/src/features/search/recent/RecentPopups.tsx index efab57b66..a6b79e08b 100644 --- a/src/features/search/recent/RecentPopups.tsx +++ b/src/features/search/recent/RecentPopups.tsx @@ -5,7 +5,21 @@ import { editorsFor, getNavigationHistory } from "@/editor/api"; import { call, commands, type OutlineItem } from "@/platform/ipc"; import { shortcutLabel } from "@/platform/keymap"; import { useProject } from "@/platform/project"; -import { Checkbox, Dialog, HighlightedText, Icon, List, fileIconName, listNavigate, T } from "@/ui"; +import { + Checkbox, + Dialog, + HighlightedText, + Icon, + List, + fileIconName, + listNavigate, + selectRow, + selectedRows, + rowSelectionMode, + SINGLE_FIRST, + type RowSelection, + T, +} from "@/ui"; import { maybeEditorService, toolWindowService, @@ -15,7 +29,7 @@ import { type ToolWindowContribution, } from "@/workbench/contributions"; import { highlightTokens, languageForPath, type CodeToken } from "@/editor/lsp/markdown"; -import { basename, openLocation } from "../common/open"; +import { basename, openInListOrder, openLocation } from "../common/open"; import { outlinePathAt } from "../structure/outline"; import { fromNavigationHistory, isDismissed, mergeLocations, type RecentLocation } from "../common/locations"; import { dismissLocation, useTracking } from "../common/tracking"; @@ -73,6 +87,14 @@ function RecentFilesDialog() { const [column, setColumn] = useState<"files" | "tools">("files"); const [fileIndex, setFileIndex] = useState(null); const [toolIndex, setToolIndex] = useState(0); + const opening = useRef(false); + const [previousEditedOnly, setPreviousEditedOnly] = useState(editedOnly); + const [fileSelection, setFileSelection] = useState(SINGLE_FIRST); + if (previousEditedOnly !== editedOnly) { + setPreviousEditedOnly(editedOnly); + setFileSelection(SINGLE_FIRST); + setFileIndex(null); + } const inputRef = useRef(null); // The current editor: a file path, or the key of a non-file editor (Switcher lists those too). const activePath = useMemo(() => { @@ -106,6 +128,7 @@ function RecentFilesDialog() { ), [paths, tabs, removed, query, root, edited, editedOnly], ); + const fileRows = useMemo(() => files.map((f) => ({ ...f, key: f.path })), [files]); const tools = useMemo(() => switcherToolWindows(toolWindows.values(), !!root, query), [toolWindows, root, query]); const fIndex = files.length ? Math.min(fileIndex ?? initialFileIndex(files, activePath, query), files.length - 1) @@ -123,6 +146,31 @@ function RecentFilesDialog() { ); }; + const selectFile = (index: number, e?: KeyboardEvent | ReactMouseEvent) => { + const next = selectRow( + fileRows, + { ...fileSelection, lead: fIndex, anchor: fileSelection.keys ? fileSelection.anchor : fIndex }, + index, + switcher ? "single" : rowSelectionMode(e), + ); + setFileSelection(next); + setFileIndex(next.lead); + setColumn("files"); + }; + const openFiles = (side = false) => { + if (opening.current) return; + const chosen = selectedRows(fileRows, { ...fileSelection, lead: fIndex }); + if (chosen.length <= 1) return openFile(chosen[0], side); + opening.current = true; + void openInListOrder( + chosen, + fileRows[fIndex], + (f, options) => openLocation(f.path, null, options), + side, + () => new Promise((resolve) => afterClose(resolve)), + ); + }; + const openTool = (tw: ToolWindowContribution | undefined) => { if (tw) afterClose(() => toolWindowService().show(tw.id, true)); }; @@ -143,6 +191,7 @@ function RecentFilesDialog() { if (!switcher) void call(commands.removeRecent(item.path)).catch(() => {}); setRemoved((r) => new Set(r).add(item.path)); setFileIndex(fIndex); + setFileSelection({ lead: fIndex, anchor: fIndex, keys: null }); }; // Switcher: each ⌃⇥ moves one row, past the end into the other column; releasing Ctrl opens the selection. @@ -182,7 +231,7 @@ function RecentFilesDialog() { // Nothing recent matches: carry the text over to Go to File. const text = query.trim(); afterClose(() => showSearchEverywhere("files", { query: text })); - } else if (effectiveColumn === "files") openFile(files[fIndex], e.shiftKey); + } else if (effectiveColumn === "files") openFiles(e.shiftKey); else openTool(tools[tIndex]?.tw); return; } @@ -201,6 +250,7 @@ function RecentFilesDialog() { if (e.key === "Tab" || ((e.key === "ArrowLeft" || e.key === "ArrowRight") && !query)) { e.preventDefault(); e.stopPropagation(); + setFileSelection(SINGLE_FIRST); setColumn( e.key === "ArrowLeft" ? "tools" @@ -215,8 +265,8 @@ function RecentFilesDialog() { if (!NAV_KEYS.includes(e.key)) return; e.preventDefault(); if (effectiveColumn === "files") { - const next = listNavigate(e.key, { items: files, active: fIndex, wrap: true }); - if (next !== null) setFileIndex(next); + const next = listNavigate(e.key, { items: files, active: fIndex, wrap: switcher || !e.shiftKey }); + if (next !== null) selectFile(next, e); } else { const next = listNavigate(e.key, { items: tools, active: tIndex, wrap: true }); if (next !== null) setToolIndex(next); @@ -248,6 +298,7 @@ function RecentFilesDialog() { onChange={(e) => { setQuery(e.target.value); setFileIndex(null); + setFileSelection(SINGLE_FIRST); setToolIndex(0); }} onKeyDown={onKeyDown} @@ -280,6 +331,7 @@ function RecentFilesDialog() { getKey={(t) => t.tw.id} activeIndex={effectiveColumn === "tools" ? tIndex : -1} onActiveChange={(i) => { + setFileSelection(SINGLE_FIRST); setToolIndex(i); setColumn("tools"); }} @@ -307,10 +359,9 @@ function RecentFilesDialog() { items={files} getKey={(f) => f.path} activeIndex={effectiveColumn === "files" ? fIndex : -1} - onActiveChange={(i) => { - setFileIndex(i); - setColumn("files"); - }} + onActiveChange={switcher ? (i) => selectFile(i) : undefined} + onSelect={switcher ? undefined : selectFile} + isSelected={(f) => effectiveColumn === "files" && !!fileSelection.keys?.has(f.path)} onActivate={(f, _i, e) => openFile(f, e.shiftKey)} focusable={false} rowHeight={24} diff --git a/src/ui/List.tsx b/src/ui/List.tsx index 5a361ddcd..71417a26d 100644 --- a/src/ui/List.tsx +++ b/src/ui/List.tsx @@ -16,6 +16,7 @@ import { } from "react"; import clsx from "clsx"; import { useVirtualizer } from "@tanstack/react-virtual"; +import { rowSelectionMode } from "./rowSelection"; import styles from "./List.module.css"; export interface ListRowState { @@ -32,6 +33,8 @@ export interface ListProps { activeIndex?: number; defaultActiveIndex?: number; onActiveChange?: (index: number) => void; + /** Opt into modifier selection; modified clicks select without activating. */ + onSelect?: (index: number, e: KeyboardEvent | MouseEvent) => void; /** Rows selected besides the active one (multi-selection); drawn like the active row. */ isSelected?: (item: T, index: number) => boolean; onActivate?: (item: T, index: number, e: KeyboardEvent | MouseEvent) => void; @@ -115,6 +118,7 @@ export function List({ activeIndex, defaultActiveIndex, onActiveChange, + onSelect, isSelected, onActivate, onContextMenu, @@ -169,7 +173,8 @@ export function List({ const next = listNavigate(e.key, { items, active, isSection, pageSize }); if (next === null) return; e.preventDefault(); - if (next !== active) setActive(next); + if (onSelect) onSelect(next, e); + else if (next !== active) setActive(next); }; if (!items.length && empty) return
{empty}
; @@ -178,6 +183,7 @@ export function List({
({ id={`${baseId}-${v.index}`} role="option" aria-selected={selected} + data-lead={isActive || undefined} aria-posinset={optionIndex.idx[v.index]} aria-setsize={optionIndex.count} className={clsx(styles.row, selected && styles.active)} @@ -217,9 +224,12 @@ export function List({ onMouseDown={(e) => { if (e.button !== 0) return; if (!focusable) e.preventDefault(); - setActive(v.index); + if (!onSelect) setActive(v.index); + }} + onClick={(e) => { + onSelect?.(v.index, e); + if (!onSelect || rowSelectionMode(e) === "single") onActivate?.(item, v.index, e); }} - onClick={(e) => onActivate?.(item, v.index, e)} onContextMenu={onContextMenu ? (e) => onContextMenu(item, v.index, e) : undefined} > {renderItem(item, { active: isActive, index: v.index })} diff --git a/src/ui/index.ts b/src/ui/index.ts index 21427a32f..669975184 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -33,3 +33,4 @@ export { lazyComponent, prefetchLazyComponents } from "./lazy"; export { T } from "./T"; export { selectableText, copyMenuFor, copyText } from "./selectableText"; export { useSelectionBoundary } from "./useSelectionBoundary"; +export * from "./rowSelection"; diff --git a/src/ui/rowSelection.test.ts b/src/ui/rowSelection.test.ts new file mode 100644 index 000000000..c06936aec --- /dev/null +++ b/src/ui/rowSelection.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { selectRow, selectedRows } from "./rowSelection"; + +describe("list selection across sections", () => { + it("selects only options across headers in either direction", () => { + const rows = [ + { key: "files", section: true }, + { key: "a", section: false }, + { key: "symbols", section: true }, + { key: "b", section: false }, + { key: "actions", section: true }, + { key: "c", section: false }, + ]; + const single = { lead: 3, anchor: 3, keys: null }; + const section = (row: (typeof rows)[number]) => row.section; + const down = selectRow(rows, single, 5, "range", section); + expect(selectedRows(rows, down).map((r) => r.key)).toEqual(["b", "c"]); + const up = selectRow(rows, down, 1, "range", section); + expect(selectedRows(rows, up).map((r) => r.key)).toEqual(["a", "b"]); + expect(selectRow(rows, up, 2, "toggle", section)).toBe(up); + }); +}); diff --git a/src/ui/rowSelection.ts b/src/ui/rowSelection.ts new file mode 100644 index 000000000..05c8261dc --- /dev/null +++ b/src/ui/rowSelection.ts @@ -0,0 +1,74 @@ +import { hasPrimaryModifier } from "@/platform/keymap"; + +/** + * Multi-selection of list rows: click / ↑↓ select one row, + * ⇧ extends from the anchor, ⌘/Ctrl toggles a row. + */ + +export interface RowSelection { + /** Active row (keyboard focus, preview). */ + lead: number; + /** Start of ⇧ ranges. */ + anchor: number; + /** Keys of the selected rows; `null` when only the lead is selected. */ + keys: ReadonlySet | null; +} + +export const SINGLE_FIRST: RowSelection = { lead: 0, anchor: 0, keys: null }; + +export function selectRow( + rows: readonly T[], + sel: RowSelection, + index: number, + mode: "single" | "range" | "toggle" = "single", + isSection?: (row: T) => boolean, +): RowSelection { + if (index < 0 || index >= rows.length || isSection?.(rows[index]!)) return sel; + if (mode === "range") { + const anchor = Math.min(Math.max(0, sel.anchor), rows.length - 1); + const [a, b] = anchor <= index ? [anchor, index] : [index, anchor]; + return { + lead: index, + anchor, + keys: new Set( + rows + .slice(a, b + 1) + .filter((r) => !isSection?.(r)) + .map((r) => r.key), + ), + }; + } + if (mode === "toggle") { + const keys = new Set(sel.keys ?? (rows[sel.lead] ? [rows[sel.lead]!.key] : [])); + const key = rows[index]!.key; + if (!keys.has(key)) return { lead: index, anchor: index, keys: keys.add(key) }; + keys.delete(key); + const rest = rows.findLastIndex((r) => keys.has(r.key)); + if (rest < 0) return { lead: index, anchor: index, keys: null }; + return { lead: rest, anchor: rest, keys }; + } + return { lead: index, anchor: index, keys: null }; +} + +/** The selected rows in list order (the lead when nothing else is selected). */ +export function selectedRows(rows: readonly T[], sel: RowSelection): T[] { + if (!rows.length || sel.lead < 0) return []; + const lead = Math.min(sel.lead, rows.length - 1); + if (!sel.keys) return [rows[lead]!]; + const out = rows.filter((r) => sel.keys!.has(r.key)); + return out.length ? out : [rows[lead]!]; +} + +export function isRowSelected(sel: RowSelection, key: string): boolean { + return !!sel.keys?.has(key); +} + +export function rowSelectionMode(e?: { + shiftKey: boolean; + metaKey: boolean; + ctrlKey: boolean; + type: string; +}): "single" | "range" | "toggle" { + if (e?.shiftKey) return "range"; + return e?.type === "click" && hasPrimaryModifier(e) ? "toggle" : "single"; +} From f19eacc33647406533bd48802567776d3e175e89 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:08:16 +0800 Subject: [PATCH 09/21] fix(java): skip unchanged Maven launch builds (#667) Run/Debug of a Maven module with Kotlin/Groovy/Scala sources rebuilt the module and its reactor dependencies with Maven before every launch, even when nothing had changed. Each successful launch build now records a fingerprint of its inputs (sources and resource roots of the dependency closure, module and parent POMs, imported BOMs, wrapper/.mvn/settings/toolchains, JDK, command line and environment) together with an immutable snapshot of its outputs in the project cache. A later launch with the same fingerprint reuses the snapshot without running Maven; Run and Debug share it, application and test launches are kept apart. Inputs that cannot be resolved reliably (dynamic roots overridden by profiles or -D) always rebuild. A failed, cancelled or concurrently edited build never publishes a reusable record. Superseded snapshots stay available while JVMs of the current session may still load classes and are removed when the project closes; snapshots left by earlier sessions and no longer referenced are cleaned on the first launch. Fixes #530 --- crates/ide-build/src/compile/fingerprint.rs | 321 +++++++++++++++++ crates/ide-build/src/compile/mod.rs | 3 + crates/ide-build/src/maven.rs | 66 ++++ crates/ide-build/src/process.rs | 2 +- crates/ide-host/src/ops/java.rs | 59 ++-- crates/ide-host/src/ops/java/cache.rs | 371 ++++++++++++++++++++ crates/ide-host/src/project.rs | 12 +- 7 files changed, 798 insertions(+), 36 deletions(-) create mode 100644 crates/ide-build/src/compile/fingerprint.rs create mode 100644 crates/ide-host/src/ops/java/cache.rs diff --git a/crates/ide-build/src/compile/fingerprint.rs b/crates/ide-build/src/compile/fingerprint.rs new file mode 100644 index 000000000..8274003d6 --- /dev/null +++ b/crates/ide-build/src/compile/fingerprint.rs @@ -0,0 +1,321 @@ +//! Metadata fingerprints for successful Maven launch builds. +use super::*; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::time::SystemTime; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LaunchFingerprint { + version: u32, + parameters: String, + files: BTreeMap>, +} + +fn scan( + path: &Path, + files: &mut BTreeMap>, + ancestors: &mut HashSet, + cancel: &CancellationToken, +) -> IdeResult<()> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let meta = match fs::metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + files.insert(path.to_path_buf(), None); + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + if meta.is_dir() { + let canonical = dunce::canonicalize(path)?; + if !ancestors.insert(canonical.clone()) { + return Err(IdeError::internal("cyclic launch input directory")); + } + // Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions. + files.insert(path.to_path_buf(), None); + for entry in fs::read_dir(path)? { + scan(&entry?.path(), files, ancestors, cancel)?; + } + ancestors.remove(&canonical); + } else if meta.is_file() { + files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?))); + } else { + return Err(IdeError::internal("unsupported launch input file")); + } + Ok(()) +} + +/// `None` means an input cannot be resolved safely: build normally without reuse. +/// All filesystem work belongs on the caller's blocking thread. +pub fn launch_fingerprint( + root: &Path, + model: &BuildModel, + module: &Path, + test: bool, + local_repo: Option, + cancel: &CancellationToken, +) -> IdeResult> { + let Some(m) = &model.maven else { return Ok(None) }; + let projects = maven_launch_projects(root, m, module, test); + if projects.is_empty() { + return Ok(None); + } + let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) }; + let plan = launch_plan(root, model, module, test)?; + let mut environment: BTreeMap = std::env::vars() + .map(|(key, value)| (key.to_ascii_uppercase(), value)) + .filter(|(key, _)| { + key.starts_with("MAVEN_") + || key.starts_with("JAVA_") + || key.starts_with("JDK_") + || matches!( + key.as_str(), + "PATH" | "HOME" | "LANG" | "LC_ALL" | "_JAVA_OPTIONS" | "M2_HOME" | "MAVEN_HOME" + ) + }) + .collect(); + // Explicit step values override the inherited environment just as they do in Command. + for step in &plan.steps { + environment.extend(step.env.iter().map(|(key, value)| (key.to_ascii_uppercase(), value.clone()))); + for dir in step.cwd.ancestors() { + inputs.extend([dir.join(".mvn"), dir.join("mvnw"), dir.join("mvnw.cmd")]); + if dir == root { + break; + } + } + let program = process::program_path(&step.program, &step.env); + inputs.push(program.clone()); + // Maven installed via a symlink still reads conf from its distribution. + if let Ok(program) = dunce::canonicalize(program) + && let Some(home) = program.parent().and_then(Path::parent) + { + for conf in ["conf", "libexec/conf"] { + inputs.extend([home.join(conf).join("settings.xml"), home.join(conf).join("toolchains.xml")]); + } + } + if !environment.contains_key("JAVA_HOME") { + let java = process::program_path(if cfg!(windows) { "java.exe" } else { "java" }, &step.env); + inputs.push(java.clone()); + if let Ok(java) = dunce::canonicalize(java) + && let Some(home) = java.parent().and_then(Path::parent) + { + inputs.push(home.join("release")); + } + } + for (i, arg) in step.args.iter().enumerate() { + if matches!(arg.as_str(), "-s" | "--settings" | "-gs" | "--global-settings" | "-t" | "--toolchains") + && let Some(file) = step.args.get(i + 1) + { + inputs.push(step.cwd.join(file)); + } + for prefix in ["--settings=", "--global-settings=", "--toolchains="] { + if let Some(file) = arg.strip_prefix(prefix) { + inputs.push(step.cwd.join(file)); + } + } + } + } + if let Some(home) = dirs::home_dir() { + inputs.extend([home.join(".m2/settings.xml"), home.join(".m2/toolchains.xml")]); + } + for key in ["MAVEN_HOME", "M2_HOME"] { + if let Some(home) = environment.get(key) { + inputs.extend([Path::new(home).join("conf/settings.xml"), Path::new(home).join("conf/toolchains.xml")]); + } + } + if let Some(home) = environment.get("JAVA_HOME") { + inputs.extend([ + Path::new(home).join("release"), + Path::new(home).join("lib/modules"), + Path::new(home).join("lib/tools.jar"), + Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }), + ]); + } + let steps: Vec<_> = + plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect(); + // Profiles and plugins can read arbitrary environment variables. Keep a signature + // of the inherited environment without persisting unrelated credentials. + let inherited: BTreeMap<_, _> = std::env::vars_os().collect(); + let mut inherited_hash = std::hash::DefaultHasher::new(); + inherited.hash(&mut inherited_hash); + let parameters = + serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects)) + .map_err(|e| IdeError::internal(e.to_string()))?; + let mut files = BTreeMap::new(); + for path in inputs { + scan(&path, &mut files, &mut HashSet::new(), cancel)?; + } + // Store project inputs relative to the project, including each file's relative path. + let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect(); + Ok(Some(LaunchFingerprint { version: 1, parameters, files })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(root: &Path, rel: &str, value: &str) { + let path = root.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, value).unwrap(); + } + + fn fixture(root: &Path) -> BuildModel { + write( + root, + "pom.xml", + r#"demoparent1 + pomlibapp"#, + ); + write( + root, + "lib/pom.xml", + r#"demoparent1 + libcodeconf"#, + ); + write( + root, + "app/pom.xml", + r#"demoparent1 + appdemolib1"#, + ); + write(root, "lib/src/main/kotlin/Channel.kt", "value"); + write(root, "app/src/main/resources/app.properties", "key=value"); + write(root, "lib/conf/custom.properties", "custom=value"); + write(root, "lib/code/Custom.java", "class Custom {}"); + BuildModel { maven: Some(maven::load(root)), ..Default::default() } + } + + fn fingerprint(root: &Path, model: &BuildModel) -> Option { + launch_fingerprint(root, model, &root.join("app"), false, None, &CancellationToken::new()).unwrap() + } + + #[test] + fn unchanged_inputs_match_but_source_resource_pom_add_delete_and_mtime_invalidate() { + for rel in [ + "lib/src/main/kotlin/Channel.kt", + "app/src/main/resources/app.properties", + "lib/conf/custom.properties", + "lib/code/Custom.java", + "app/pom.xml", + "lib/pom.xml", + "pom.xml", + ".mvn/maven.config", + "lib/src/main/groovy/New.groovy", + "app/src/test/scala/Test.scala", + ] { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let before = fingerprint(root, &model); + assert!(before.is_some()); + assert_eq!(before, fingerprint(root, &model), "unchanged {rel}"); + let serialized = serde_json::to_vec(&before).unwrap(); + assert_eq!(before, serde_json::from_slice(&serialized).unwrap()); + let value = if rel.ends_with("pom.xml") { + format!("{}", fs::read_to_string(root.join(rel)).unwrap()) + } else { + "changed input".to_owned() + }; + write(root, rel, &value); + let changed = fingerprint(root, &model); + assert_ne!(before, changed, "changed/added {rel}"); + let file = fs::OpenOptions::new().write(true).open(root.join(rel)).unwrap(); + file.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42)).unwrap(); + let touched = fingerprint(root, &model); + assert_ne!(changed, touched, "mtime-only {rel}"); + fs::remove_file(root.join(rel)).unwrap(); + assert_ne!(touched, fingerprint(root, &model), "deleted {rel}"); + } + } + + #[test] + fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let mut model = fixture(root); + let before = fingerprint(root, &model); + write(root, "lib/target/classes/Channel.class", "JDT stub"); + write(root, "app/target/classes/Main.class", "JDT stub"); + assert_eq!(before, fingerprint(root, &model)); + let mut previous = before.clone(); + for option in ["-Pdev", "-Pprod", "-DskipTests", "-o"] { + model.maven.as_mut().unwrap().projects[0].options = vec![option.into()]; + let current = fingerprint(root, &model); + assert_ne!(previous, current, "{option}"); + previous = current; + } + model.maven.as_mut().unwrap().projects[0].options.clear(); + model.maven.as_mut().unwrap().projects[0] + .env + .insert("JAVA_HOME".into(), root.join("jdk").to_string_lossy().into_owned()); + let jdk = fingerprint(root, &model); + assert_ne!(before, jdk); + write(root, "jdk/release", "JAVA_VERSION=21"); + assert_ne!(jdk, fingerprint(root, &model)); + model.maven.as_mut().unwrap().projects[0].env.clear(); + let test = launch_fingerprint(root, &model, &root.join("app"), true, None, &CancellationToken::new()) + .unwrap() + .unwrap(); + assert_ne!(before, Some(test)); + write(root, "mvnw", "wrapper changed"); + assert_ne!(before, fingerprint(root, &model)); + } + + #[test] + fn unresolved_roots_and_cancel_never_claim_up_to_date() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + write( + root, + "lib/pom.xml", + "lib${unknown}/src", + ); + assert!( + launch_fingerprint(root, &model, &root.join("app"), false, None, &CancellationToken::new()) + .unwrap() + .is_none() + ); + let model = fixture(root); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert!(launch_fingerprint(root, &model, &root.join("app"), false, None, &cancel).is_err()); + } + #[test] + fn external_parent_and_selected_settings_files_invalidate() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + let repo = dir.path().join("repository"); + write( + &repo, + "demo/parent/1/parent-1.pom", + "demoparent1", + ); + write( + &root, + "pom.xml", + "demoparent1app", + ); + let mut model = crate::load(&root, &root.join("cache"), Some(repo.clone())); + let settings = dir.path().join("custom-settings.xml"); + fs::write(&settings, "").unwrap(); + model.maven.as_mut().unwrap().projects[0].options = vec!["-s".into(), settings.to_string_lossy().into_owned()]; + let get = + || launch_fingerprint(&root, &model, &root, false, Some(repo.clone()), &CancellationToken::new()).unwrap(); + let before = get(); + assert!(before.is_some()); + write( + &repo, + "demo/parent/1/parent-1.pom", + "demoparent1", + ); + let parent = get(); + assert_ne!(before, parent); + fs::write(settings, "dev") + .unwrap(); + assert_ne!(parent, get()); + } +} diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index b674e1adf..9cd0ea26f 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -3,8 +3,11 @@ //! background with cancellation and parses the compiler output into messages //! for the Build tool window, the Problems view and the editor. +mod fingerprint; mod parse; +pub use fingerprint::{LaunchFingerprint, launch_fingerprint}; + use crate::process::{self, Stream}; use crate::{BuildModel, maven}; use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity}; diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 2c0e3f8fb..7f2561003 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -601,6 +601,72 @@ impl Resolver { } } +/// Files and source/resource trees consumed by a launch build. Unresolved custom +/// roots disable reuse rather than silently omitting an input. +pub(crate) fn launch_inputs( + root: &Path, + projects: &[&MavenProject], + local_repo: Option, +) -> Option> { + fn roots(el: &Element, parent: &str, dir: &Path, out: &mut Vec) -> Option<()> { + let input = + matches!(el.name.as_str(), "sourceDirectory" | "testSourceDirectory" | "sourceDir" | "testSourceDir") + || (matches!(parent, "sources" | "sourceDirs" | "testSources" | "testSourceDirs") + && matches!(el.name.as_str(), "source" | "sourceDir" | "testSourceDir")) + || (matches!(parent, "resource" | "testResource" | "source" | "testSource") && el.name == "directory") + || (parent == "filters" && el.name == "filter"); + // Profile activation may depend on the presence of an otherwise unrelated file. + let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing"); + if input || activation { + // Other properties can be overridden by -D, profiles or settings. + // Without Maven's effective roots, conservatively disable reuse for them. + let value = el + .text + .trim() + .replace("${project.basedir}", &dir.to_string_lossy()) + .replace("${basedir}", &dir.to_string_lossy()); + if value.contains("${") { + return None; + } + if !value.is_empty() { + out.push(dir.join(value)); + } + } + for child in &el.children { + roots(child, &el.name, dir, out)?; + } + Some(()) + } + let mut resolver = Resolver::new(local_repo.or_else(default_local_repo)); + let mut out = Vec::new(); + for project in projects { + let dir = root.join(&project.dir); + // Include all conventional language/resource roots, including nonstandard files under src. + out.push(dir.join("src")); + let pom = root.join(&project.pom); + resolver.context(&pom, 0); + let mut path = pom; + let mut seen = HashSet::new(); + loop { + let canonical = dunce::canonicalize(&path).ok()?; + if !seen.insert(canonical) || seen.len() > MAX_PARENT_DEPTH { + return None; + } + out.push(path.clone()); + let text = std::fs::read_to_string(&path).ok()?; + roots(&xml::parse(&text)?, "", &dir, &mut out)?; + let raw = resolver.pom(&path)?; + let Some(parent) = raw.parent else { break }; + path = resolver.parent_path(path.parent()?, &parent)?; + } + } + // Also track imported BOMs read while resolving inheritance. + out.extend(resolver.poms.into_keys()); + out.sort(); + out.dedup(); + Some(out) +} + // ---------------------------------------------------------------- plugins /// `maven-clean-plugin` → `clean`, `spring-boot-maven-plugin` → `spring-boot`. diff --git a/crates/ide-build/src/process.rs b/crates/ide-build/src/process.rs index b598eb791..8177089f0 100644 --- a/crates/ide-build/src/process.rs +++ b/crates/ide-build/src/process.rs @@ -37,7 +37,7 @@ fn kill_tree(child: &mut Child) { /// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's /// search path — the login shell's `PATH` plus version-manager shims — since the /// app's own `PATH` can be minimal and `Command` would not see `mvn` there. -fn program_path(program: &str, env: &BTreeMap) -> std::path::PathBuf { +pub(crate) fn program_path(program: &str, env: &BTreeMap) -> std::path::PathBuf { let path = std::path::Path::new(program); if !path.is_relative() || path.components().count() > 1 { return path.to_path_buf(); diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index 6e334ca73..47beb38d5 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -1,4 +1,6 @@ //! Java launch support: classpath / JDK resolution and the java-debug server (jdtls). +pub(crate) mod cache; + use crate::Host; use crate::rpc::Dispatcher; @@ -44,10 +46,13 @@ async fn resolve_launch( let settings = settings.clone(); let module = module.clone(); tokio::task::spawn_blocking(move || { + project.java_launch_snapshots.lock().initialize(); let model = load(&project.root, &project.build_cache_dir(), &settings); - let external = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test); - let delegated = external - && model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module)); + let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test); + let delegated = build + && model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module)) + && (non_java || cache::has_record(&project.root, &project.build_cache_dir(), &module)); + let external = non_java || delegated; (model, external, delegated) }) .await @@ -60,20 +65,24 @@ async fn resolve_launch( let cancel = cancel.clone(); tokio::task::spawn_blocking(move || { let _external_build = external_build; - let built = if external { - compile::build_for_launch(&project.root, &model, &module, test, &cancel)? - } else { - Vec::new() - }; let built = if delegated { - let (snapshot, copies) = snapshot_outputs(&mut info, &built, &cancel)?; - project.java_launch_outputs.lock().push(snapshot); + let outputs = project.java_launch_snapshots.lock().build( + &project.root, + &model, + &module, + test, + ide_sdk::apply::maven_local_repository(&project.root, &settings), + &cancel, + )?; + let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot); info.build = Some(JavaBuildStatus::Succeeded); info.errors.clear(); info.error_count = 0; copies + } else if external { + compile::build_for_launch(&project.root, &model, &module, test, &cancel)? } else { - built + Vec::new() }; let mut class_paths: Vec = built.iter().map(|p| p.to_string_lossy().into_owned()).collect(); for p in std::mem::take(&mut info.class_paths) { @@ -122,19 +131,10 @@ async fn resolve_launch( .map_err(|e| IdeError::internal(e.to_string()))? } -fn snapshot_outputs( - info: &mut JavaLaunchInfo, - built: &[PathBuf], - cancel: &CancellationToken, -) -> IdeResult<(tempfile::TempDir, Vec)> { - let snapshot = tempfile::Builder::new().prefix("openidea-java-launch-").tempdir()?; +fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec { let mut copies = Vec::new(); for (index, source) in built.iter().enumerate() { - if cancel.is_cancelled() { - return Err(IdeError::cancelled()); - } - let dest = snapshot.path().join(index.to_string()); - ide_vfs::copy(source, &dest)?; + let dest = snapshot.join(index.to_string()); let original = source.to_string_lossy(); info.class_paths.retain(|p| p != original.as_ref()); let mut modular = false; @@ -148,7 +148,7 @@ fn snapshot_outputs( copies.push(dest); } } - Ok((snapshot, copies)) + copies } /// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). @@ -199,8 +199,11 @@ mod tests { module_paths: vec![lib.to_string_lossy().into_owned()], ..Default::default() }; - let (snapshot, paths) = - snapshot_outputs(&mut info, &[app.clone(), lib.clone()], &CancellationToken::new()).unwrap(); + let snapshot = tempfile::tempdir().unwrap(); + for (i, source) in [&app, &lib].iter().enumerate() { + ide_vfs::copy(source, &snapshot.path().join(i.to_string())).unwrap(); + } + let paths = use_snapshot(&mut info, &[app.clone(), lib.clone()], snapshot.path()); assert_eq!(paths, [snapshot.path().join("0")]); assert_eq!(info.class_paths, ["stdlib.jar"]); assert_eq!(info.module_paths, [snapshot.path().join("1").to_string_lossy().into_owned()]); @@ -212,11 +215,5 @@ mod tests { "Maven bytecode" ); } - let cancel = CancellationToken::new(); - cancel.cancel(); - assert_eq!( - snapshot_outputs(&mut info, &[app], &cancel).unwrap_err().kind, - ide_core::error::ErrorKind::Cancelled - ); } } diff --git a/crates/ide-host/src/ops/java/cache.rs b/crates/ide-host/src/ops/java/cache.rs new file mode 100644 index 000000000..422ade156 --- /dev/null +++ b/crates/ide-host/src/ops/java/cache.rs @@ -0,0 +1,371 @@ +//! Immutable Maven outputs retained in the project cache, including across close/reopen. +use ide_build::compile::{self, LaunchFingerprint}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct Outputs { + fingerprint: Option, + pub snapshot: PathBuf, + pub originals: Vec, +} + +impl Outputs { + fn reusable(&self, fingerprint: &Option, snapshots: &Path) -> bool { + fingerprint.is_some() + && self.fingerprint == *fingerprint + && self.snapshot.parent() == Some(snapshots) + && !self.originals.is_empty() + && (0..self.originals.len()).all(|i| self.snapshot.join(i.to_string()).is_dir()) + } +} + +fn record_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option { + Some(cache.join("java-launch/records").join(module.strip_prefix(root).ok()?).join(if test { + "test.json" + } else { + "main.json" + })) +} + +pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool { + [false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file())) +} + +/// Owned by Project: running JVMs may still load superseded outputs until it drops. +pub(crate) struct Session { + cache: PathBuf, + initialized: bool, + unreferenced: Vec, +} + +impl Session { + pub(crate) fn new(cache: PathBuf) -> Self { + Self { cache, initialized: false, unreferenced: Vec::new() } + } + + pub(super) fn initialize(&mut self) { + if !self.initialized { + self.initialized = true; + if let Err(error) = sweep(&self.cache) { + tracing::warn!(%error, "Cannot clean previous Java launch snapshots"); + } + } + } + + pub(super) fn build( + &mut self, + root: &Path, + model: &ide_build::BuildModel, + module: &Path, + test: bool, + local_repo: Option, + cancel: &CancellationToken, + ) -> IdeResult { + self.initialize(); + let cache = &self.cache; + let record = + record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?; + let snapshots = cache.join("java-launch/snapshots"); + let fingerprint = || match compile::launch_fingerprint(root, model, module, test, local_repo.clone(), cancel) { + Ok(value) => Ok(value), + Err(e) if cancel.is_cancelled() => Err(e), + Err(e) => { + tracing::debug!(error = %e, "Cannot fingerprint Maven launch; rebuilding"); + Ok(None) + } + }; + let before = fingerprint()?; + let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if let Some(outputs) = previous.as_ref() + && outputs.reusable(&before, &snapshots) + { + tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot"); + return Ok(previous.unwrap()); + } + // Retain a non-reusable record even on failure, so removing the final mixed + // source still delegates to Maven to clean its old classes on the next launch. + fs::create_dir_all(record.parent().unwrap())?; + fs::write(&record, b"null")?; + if let Some(previous) = previous { + self.unreferenced.push(previous.snapshot); + } + let originals = compile::build_for_launch(root, model, module, test, cancel)?; + fs::create_dir_all(&snapshots)?; + let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep(); + // Retain every new snapshot even if copying or publishing its record fails. + self.unreferenced.push(snapshot.clone()); + for (i, source) in originals.iter().enumerate() { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + ide_vfs::copy(source, &snapshot.join(i.to_string()))?; + } + let after = fingerprint()?; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let mut outputs = Outputs { fingerprint: None, snapshot, originals }; + if before.is_some() && before == after { + outputs.fingerprint = before; + fs::create_dir_all(record.parent().unwrap())?; + let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?; + serde_json::to_writer(file.as_file_mut(), &outputs).map_err(|e| IdeError::internal(e.to_string()))?; + file.persist(&record).map_err(|e| IdeError::from(e.error))?; + self.unreferenced.retain(|path| path != &outputs.snapshot); + } + Ok(outputs) + } +} + +impl Drop for Session { + fn drop(&mut self) { + // Recheck all records: another module or launch mode may reference a path. + if let Ok(referenced) = referenced_snapshots(&self.cache) { + let snapshots = self.cache.join("java-launch/snapshots"); + for path in &self.unreferenced { + remove_unreferenced(path, &snapshots, &referenced); + } + } + } +} + +fn referenced_snapshots(cache: &Path) -> std::io::Result> { + let mut referenced = HashSet::new(); + let records = cache.join("java-launch/records"); + if !records.try_exists()? { + return Ok(referenced); + } + let mut pending = vec![records]; + while let Some(dir) = pending.pop() { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let kind = entry.file_type()?; + if kind.is_dir() { + pending.push(entry.path()); + } else if kind.is_file() && entry.path().extension().is_some_and(|ext| ext == "json") { + let outputs: Option = serde_json::from_slice(&fs::read(entry.path())?)?; + if let Some(outputs) = outputs + && let Ok(path) = fs::canonicalize(outputs.snapshot) + { + referenced.insert(path); + } + } + } + } + Ok(referenced) +} + +fn remove_unreferenced(path: &Path, snapshots: &Path, referenced: &HashSet) { + // Only real launch-* directories directly inside snapshots, never symlinks. + if path.parent() != Some(snapshots) + || !path.file_name().is_some_and(|name| name.to_string_lossy().starts_with("launch-")) + || !fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_dir()) + { + return; + } + if let (Ok(path), Ok(snapshots)) = (fs::canonicalize(path), fs::canonicalize(snapshots)) + && path.parent() == Some(snapshots.as_path()) + && !referenced.contains(&path) + && let Err(error) = fs::remove_dir_all(&path) + { + tracing::warn!(%error, path = %path.display(), "Cannot remove Java launch snapshot"); + } +} + +fn sweep(cache: &Path) -> std::io::Result<()> { + let referenced = referenced_snapshots(cache)?; + let snapshots = cache.join("java-launch/snapshots"); + if snapshots.try_exists()? { + for entry in fs::read_dir(&snapshots)? { + remove_unreferenced(&entry?.path(), &snapshots, &referenced); + } + } + Ok(()) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + fn fixture(root: &Path) -> ide_build::BuildModel { + fs::write( + root.join("pom.xml"), + r#"demoapp1"#, + ) + .unwrap(); + let script = root.join("mvnw"); + fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + fs::create_dir_all(root.join("src/main/kotlin")).unwrap(); + fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap(); + ide_build::load(root, &root.join("cache"), None) + } + + #[test] + fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let cache = root.join("cache"); + let cancel = CancellationToken::new(); + let mut session = Session::new(cache.clone()); + let mut build = || session.build(root, &model, root, false, None, &cancel); + let first = build().unwrap(); + let snapshot = first.snapshot.clone(); + drop(first); + // Dropping an Outputs value leaves the persisted snapshot reusable. + let second = build().unwrap(); + assert_eq!(snapshot, second.snapshot); + assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1); + fs::write(root.join("target/classes/Main.class"), "JDT stub").unwrap(); + assert_eq!(snapshot, build().unwrap().snapshot); + assert_eq!(fs::read_to_string(snapshot.join("0/Main.class")).unwrap(), "first"); + fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap(); + let updated = build().unwrap(); + assert_ne!(snapshot, updated.snapshot); + assert_eq!(fs::read_to_string(updated.snapshot.join("0/Main.class")).unwrap(), "updated"); + assert!(snapshot.join("0/Main.class").is_file(), "previous JVMs may still load it"); + fs::remove_dir_all(updated.snapshot.join("0")).unwrap(); + let rebuilt = build().unwrap(); + assert_ne!(rebuilt.snapshot, updated.snapshot); + fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap(); + assert!(build().is_err()); + assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null"); + fs::remove_file(root.join("src/main/kotlin/broken")).unwrap(); + assert_ne!(rebuilt.snapshot, build().unwrap().snapshot, "a failed build invalidates success"); + cancel.cancel(); + assert!(build().is_err()); + assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5); + } + #[test] + fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let cache = root.join("cache"); + let cancel = CancellationToken::new(); + let mut session = Session::new(cache.clone()); + let first = session.build(root, &model, root, false, None, &cancel).unwrap(); + fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap(); + let second = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert!(first.snapshot.is_dir()); + assert!(second.snapshot.is_dir()); + // Another mode can keep a superseded snapshot alive. + let test_record = cache.join("java-launch/records/nested/test.json"); + fs::create_dir_all(test_record.parent().unwrap()).unwrap(); + fs::write(&test_record, serde_json::to_vec(&first).unwrap()).unwrap(); + drop(session); + assert!(first.snapshot.is_dir()); + assert!(second.snapshot.is_dir()); + let mut session = Session::new(cache.clone()); + let reused = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert_eq!(reused.snapshot, second.snapshot); + fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap(); + let third = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert!(second.snapshot.is_dir()); + drop(session); + assert!(!second.snapshot.exists()); + assert!(first.snapshot.is_dir()); + assert!(third.snapshot.is_dir()); + assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 3); + } + + #[test] + fn initial_sweep_only_removes_unreferenced_direct_snapshot_directories() { + let dir = tempfile::tempdir().unwrap(); + let cache = dir.path().join("cache"); + let snapshots = cache.join("java-launch/snapshots"); + let retained = snapshots.join("launch-retained"); + let orphan = snapshots.join("launch-orphan"); + let other = snapshots.join("other"); + let nested = other.join("launch-nested"); + let outside = dir.path().join("launch-outside"); + for path in [&retained, &orphan, &nested, &outside] { + fs::create_dir_all(path).unwrap(); + } + fs::write(outside.join("classes"), "keep").unwrap(); + std::os::unix::fs::symlink(&outside, snapshots.join("launch-link")).unwrap(); + fs::write(snapshots.join("launch-file"), "keep").unwrap(); + let record = cache.join("java-launch/records/deep/module/test.json"); + fs::create_dir_all(record.parent().unwrap()).unwrap(); + let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() }; + fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap(); + let mut session = Session::new(cache); + session.initialize(); + assert!(!orphan.exists()); + assert!(retained.is_dir()); + assert!(nested.is_dir()); + assert!(outside.join("classes").is_file()); + assert!(snapshots.join("launch-link").is_symlink()); + assert!(snapshots.join("launch-file").is_file()); + // Initialization must not sweep snapshots still in use by this session. + fs::create_dir_all(&orphan).unwrap(); + session.initialize(); + assert!(orphan.is_dir()); + session.unreferenced.extend([outside.clone(), snapshots.join("../../launch-outside"), nested]); + drop(session); + assert!(outside.join("classes").is_file()); + assert!(other.join("launch-nested").is_dir()); + } + + #[test] + fn failed_record_publish_retains_snapshot_until_session_drop() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let script = root.join("mvnw"); + let mut text = fs::read_to_string(&script).unwrap(); + text.push_str("rm cache/java-launch/records/main.json\nmkdir cache/java-launch/records/main.json\n"); + fs::write(script, text).unwrap(); + let cache = root.join("cache"); + let mut session = Session::new(cache.clone()); + assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err()); + assert_eq!(session.unreferenced.len(), 1); + let snapshot = session.unreferenced[0].clone(); + assert!(snapshot.join("0/Main.class").is_file()); + drop(session); + assert!(!snapshot.exists()); + } + + #[test] + fn edits_during_build_do_not_publish_a_success_record() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let script = root.join("mvnw"); + let mut text = fs::read_to_string(&script).unwrap(); + text.push_str("echo edited-during-build >> src/main/kotlin/Main.kt\n"); + fs::write(script, text).unwrap(); + let cache = root.join("cache"); + let mut session = Session::new(cache.clone()); + let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap(); + assert!(outputs.fingerprint.is_none()); + assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null"); + assert!(outputs.snapshot.join("0/Main.class").is_file()); + drop(session); + assert!(!outputs.snapshot.exists()); + } + #[test] + fn deleting_the_last_mixed_source_keeps_maven_delegation_after_failure() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let model = fixture(root); + let cache = root.join("cache"); + let cancel = CancellationToken::new(); + let mut session = Session::new(cache.clone()); + let first = session.build(root, &model, root, false, None, &cancel).unwrap(); + fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap(); + assert!(!compile::launch_has_non_java_sources(root, &model, root, false)); + assert!(has_record(root, &cache, root)); + assert!(session.build(root, &model, root, false, None, &cancel).is_err()); + assert!(has_record(root, &cache, root)); + fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap(); + let restored = session.build(root, &model, root, false, None, &cancel).unwrap(); + assert_ne!(first.snapshot, restored.snapshot); + } +} diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index caf5afea0..e3d868e21 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -42,8 +42,8 @@ pub struct Project { pub detected_roots: Arc>>, /// Per-project cache directory (build tool sync results…). pub cache_dir: PathBuf, - /// Launch snapshots stay alive while any JVM of this project may load classes. - pub(crate) java_launch_outputs: Mutex>, + /// Superseded launch snapshots retained until this project session ends. + pub(crate) java_launch_snapshots: Mutex, /// Database tool window: data sources and their connections. pub db: Arc, /// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir. @@ -368,7 +368,9 @@ impl Host { syntax, detected_roots, cache_dir: self.data_dir.join("cache").join(&project_key), - java_launch_outputs: Mutex::new(Vec::new()), + java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( + self.data_dir.join("cache").join(&project_key).join("build"), + )), db, http_history, agents, @@ -484,7 +486,9 @@ impl Host { syntax, detected_roots: Arc::default(), cache_dir: self.data_dir.join("cache").join(&project_key), - java_launch_outputs: Mutex::new(Vec::new()), + java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new( + self.data_dir.join("cache").join(&project_key).join("build"), + )), db, http_history, agents, From 683f8d8826afc1e2df6bf9d7c1deeba1a299f0b1 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 16:16:14 +0800 Subject: [PATCH 10/21] test(jdtls): compare resolved source paths as paths, not strings (#665) java_class_file joins the package path with std::path, so on Windows the result uses backslashes while the test's expectation kept the literal forward slash from "p/Outer.java". Compare as Path so separator style does not matter. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-lsp/tests/mock_server.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 2cd2b4f9a..aa15d3ede 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1659,7 +1659,10 @@ async fn class_file_opening_routes_sources_and_binary_documents() { s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]})); for class in ["Outer", "Outer$Nested", "Outer$1"] { let path = h.root.join(format!("target/classes/p/{class}.class")); - assert_eq!(h.mgr.java_class_file(&path, None).await.unwrap(), source.to_string_lossy()); + let resolved = h.mgr.java_class_file(&path, None).await.unwrap(); + // Compare as paths: the manager joins the relative source path with std::path, so + // separators can differ from the literal "p/Outer.java" used to build `source` on Windows. + assert_eq!(Path::new(&resolved), source.as_path()); } let path = h.root.join("build/classes/java/main/p/Missing.class"); let uri = uri_of(&path); From 8d38aa4a9ce90a709a3337b05d6aa142db047fd3 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 16:17:18 +0800 Subject: [PATCH 11/21] feat(editor): navigate to definition on a plain middle-button click (#666) The editor's mousedown handler ignored every button except the left one, so a middle click fell through to the webview's autoscroll. A plain middle click now moves the caret to the clicked symbol and runs go-to-definition, suppressing autoscroll; a keymap shortcut bound to Button2 still takes precedence, and modifier + middle combinations stay unbound. Fixes #663 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/editor/lsp/navigation.ts | 13 +++++++------ src/editor/lsp/navigationMouse.test.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 9dc2d5cdf..7afdda099 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -1,8 +1,8 @@ /** - * Go to declaration / implementation / type declaration (⌘B, ⌥⌘B, ⇧⌘B and - * ⌘-click with a link underline while ⌘ is held), the multiple-targets - * chooser, and document highlights (occurrences of the symbol at the caret, - * plus the sticky find.highlightUsages variant). + * Go to declaration / implementation / type declaration (⌘B, ⌥⌘B, ⇧⌘B, + * ⌘-click with a link underline while ⌘ is held, and a plain middle-button + * click), the multiple-targets chooser, and document highlights (occurrences + * of the symbol at the caret, plus the sticky find.highlightUsages variant). */ import { EditorSelection, Prec, StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; import { @@ -489,7 +489,7 @@ const linkPlugin = ViewPlugin.fromClass( this.show(false); }, mousedown(e, view) { - if (e.button !== 0) return false; + if (e.button !== 0 && e.button !== 1) return false; if ( dispatchMouseShortcut(e, () => { const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false); @@ -502,7 +502,8 @@ const linkPlugin = ViewPlugin.fromClass( e.preventDefault(); return true; } - if (!isNavigationGesture(e)) return false; + // IDEA parity: middle button navigates on a plain click, without the modifier. + if (e.button === 1 ? e.metaKey || e.ctrlKey || e.altKey || e.shiftKey : !isNavigationGesture(e)) return false; const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false); if (pos == null) return false; e.preventDefault(); diff --git a/src/editor/lsp/navigationMouse.test.ts b/src/editor/lsp/navigationMouse.test.ts index 3940c61df..5ec3b9d53 100644 --- a/src/editor/lsp/navigationMouse.test.ts +++ b/src/editor/lsp/navigationMouse.test.ts @@ -94,6 +94,14 @@ describe("mouse navigation", () => { expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "definition"); }); + it("opens the definition from a plain middle-button click", async () => { + const event = click({ button: 1 }); + await vi.waitFor(() => expect(openFile).toHaveBeenCalledOnce()); + expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "definition"); + expect(view.state.selection.main.head).toBe(9); + expect(event.defaultPrevented).toBe(true); + }); + it.each([ { button: 1, metaKey: true, altKey: true }, { button: 2, metaKey: true, altKey: true }, From 9bb44cbeb35d1d183314f376abda0de69a6ce0b5 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:33:44 +0800 Subject: [PATCH 12/21] feat(java): resolve compiled Kotlin APIs in Maven Java editors (#669) Java files in mixed Maven projects could not resolve Kotlin types from the same module or upstream reactor modules, because Kotlin compiler output never reached JDT's Java model. A per-project bridge compiles saved Kotlin sources with the project's own Maven setup into private staging output (a bundled Maven extension redirects outputs and records each module's Kotlin compile result) and mounts the Kotlin classes as marked libraries through a bundled JDT command, leaving m2e/JRE containers and other entries untouched. The last successful snapshot stays mounted while rebuilding; a new one is published when every Kotlin target compiles, even if javac fails, so Java callers of a renamed or removed Kotlin API show their errors in the editor. Kotlin compile failures keep the previous API and are reported under the kotlin-bridge Problems source. Removing the last Kotlin file withdraws the libraries. Changes under build outputs and excluded directories never trigger a rebuild. Part of #428 --- crates/ide-build/src/kotlin_bridge.rs | 475 ++++++++++++++++++ crates/ide-build/src/lib.rs | 1 + crates/ide-host/src/kotlin_bridge.rs | 429 ++++++++++++++++ crates/ide-host/src/lib.rs | 33 +- crates/ide-host/src/ops/build.rs | 4 + crates/ide-host/src/ops/lsp.rs | 8 +- crates/ide-host/src/ops/project.rs | 3 + crates/ide-host/src/project.rs | 38 +- crates/ide-host/src/trust.rs | 3 + crates/ide-lsp/src/jdtls.rs | 3 +- crates/ide-sdk/src/java.rs | 43 ++ plugins/kotlin-bridge/MANIFEST.MF | 8 + plugins/kotlin-bridge/README.md | 73 +++ .../kotlin-bridge/build-dependencies.sha256 | 161 ++++++ plugins/kotlin-bridge/build.py | 32 ++ plugins/kotlin-bridge/components.xml | 5 + .../ai/openidea/kotlin/IsolatedOutput.java | 177 +++++++ .../kotlin-bridge/openidea-kotlin-bridge.jar | Bin 0 -> 25814 bytes plugins/kotlin-bridge/plugin.xml | 8 + .../ai/openidea/kotlin/KotlinLibraries.java | 94 ++++ plugins/kotlin-bridge/tests/verify.py | 275 ++++++++++ src/platform/locales/text/zh.json | 5 + src/workbench/notificationStore.ts | 6 +- 23 files changed, 1874 insertions(+), 10 deletions(-) create mode 100644 crates/ide-build/src/kotlin_bridge.rs create mode 100644 crates/ide-host/src/kotlin_bridge.rs create mode 100644 plugins/kotlin-bridge/MANIFEST.MF create mode 100644 plugins/kotlin-bridge/README.md create mode 100644 plugins/kotlin-bridge/build-dependencies.sha256 create mode 100644 plugins/kotlin-bridge/build.py create mode 100644 plugins/kotlin-bridge/components.xml create mode 100644 plugins/kotlin-bridge/maven/ai/openidea/kotlin/IsolatedOutput.java create mode 100644 plugins/kotlin-bridge/openidea-kotlin-bridge.jar create mode 100644 plugins/kotlin-bridge/plugin.xml create mode 100644 plugins/kotlin-bridge/src/ai/openidea/kotlin/KotlinLibraries.java create mode 100644 plugins/kotlin-bridge/tests/verify.py diff --git a/crates/ide-build/src/kotlin_bridge.rs b/crates/ide-build/src/kotlin_bridge.rs new file mode 100644 index 000000000..150170efc --- /dev/null +++ b/crates/ide-build/src/kotlin_bridge.rs @@ -0,0 +1,475 @@ +//! Private Maven builds for JDT's Kotlin binary libraries. Never use launch outputs. + +use crate::BuildModel; +use crate::compile::{self, BuildEvent, BuildKind, BuildMessage}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Module { + key: String, + project_uri: String, + output: PathBuf, + test_output: Option, + dependencies: Vec, + has_kotlin: bool, + kotlin_goals: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct KotlinGoal { + execution: String, + status: String, +} + +#[derive(Clone, Debug, Deserialize)] +struct Dependency { + key: String, + scope: String, + #[serde(rename = "type")] + kind: Option, + classifier: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct Library { + pub path: PathBuf, + pub test: bool, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProjectLibraries { + pub project_uri: String, + pub libraries: Vec, +} + +pub struct Outcome { + pub projects: Vec, + pub messages: Vec, + pub success: bool, +} + +/// Inspect sources, excluding generated/build/VCS trees and never following symlinks. +pub fn has_kotlin(root: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(root) else { return false }; + entries.flatten().any(|entry| { + let Ok(kind) = entry.file_type() else { return false }; + if kind.is_file() { + entry.path().extension().is_some_and(|e| e == "kt") + } else { + kind.is_dir() + && !matches!( + entry.file_name().to_str(), + Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea") + ) + && has_kotlin(&entry.path()) + } + }) +} + +/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output +/// redirector loaded before Maven calculates its lifecycle execution plans. +pub fn build( + root: &Path, + model: &BuildModel, + bundle: &Path, + generation: &Path, + cancel: &CancellationToken, +) -> IdeResult { + std::fs::create_dir_all(generation)?; + let mut plan = compile::plan(root, model, BuildKind::Build, None)?; + if plan.system != compile::BuildSystem::Maven { + return Err(IdeError::invalid("Kotlin bridge requires a Maven build root")); + } + let mut manifests = Vec::new(); + for (index, step) in plan.steps.iter_mut().enumerate() { + step.args.push("--fail-at-end".into()); + let directory = generation.join(format!("reactor-{index}")); + std::fs::create_dir_all(&directory)?; + manifests.push(directory.join("modules.json")); + let mut extensions = vec![bundle.to_path_buf()]; + step.args.retain(|arg| { + if let Some(value) = arg.strip_prefix("-Dmaven.ext.class.path=") { + extensions.extend(std::env::split_paths(value)); + false + } else { + true + } + }); + let extension_path = std::env::join_paths(extensions).map_err(|e| IdeError::invalid(e.to_string()))?; + step.args.push(format!("-Dmaven.ext.class.path={}", extension_path.to_string_lossy())); + step.args.push(format!("-Dopenidea.kotlin.staging={}", directory.join("staging").display())); + } + let mut messages = Vec::new(); + // Independent linked reactors must still run after another reactor's javac fails. + for step in &plan.steps { + let mut reactor = plan.clone(); + reactor.steps = vec![step.clone()]; + let result = compile::run(root, &reactor, cancel, &|event| { + if let BuildEvent::Output { text, .. } = event { + tracing::debug!("kotlin bridge: {}", text.trim_end()); + } + })?; + messages.extend(result.messages); + } + // javac diagnostics belong to JDT, including when a Kotlin target was skipped. + messages.retain(|message| { + message.path.as_ref().is_some_and(|path| PathBuf::from(path.clone()).extension().is_some_and(|e| e == "kt")) + }); + let mut modules = Vec::new(); + for manifest in manifests { + let parsed: Vec = serde_json::from_slice(&std::fs::read(manifest)?) + .map_err(|e| IdeError::internal(format!("Kotlin module manifest: {e}")))?; + modules.extend(parsed); + } + messages.extend(kotlin_failures(&modules)); + if messages.iter().any(|message| message.severity == ide_core::Severity::Error) { + return Ok(Outcome { projects: Vec::new(), messages, success: false }); + } + let pending = generation.join("libraries.pending"); + let published = generation.join("libraries"); + std::fs::create_dir_all(&pending)?; + let mut libraries = BTreeMap::new(); + for (index, module) in modules.iter().enumerate() { + let mut outputs = ModuleLibraries::default(); + for (source, target, name) in [ + (Some(&module.output), &mut outputs.main, format!("main-{index}")), + (module.test_output.as_ref(), &mut outputs.test, format!("test-{index}")), + ] { + let Some(source) = source else { continue }; + if !source.starts_with(generation) { + return Err(IdeError::internal("Kotlin build output escaped staging")); + } + if copy_kotlin(source, &pending.join(&name), cancel)? { + *target = Some(published.join(name)); + } + } + libraries.insert(module.key.clone(), outputs); + } + publish(&pending, &published, cancel)?; + Ok(Outcome { projects: consumers(&modules, &libraries), messages, success: true }) +} + +fn kotlin_failures(modules: &[Module]) -> Vec { + let mut messages = Vec::new(); + for module in modules { + if !module.has_kotlin { + continue; + } + if module.kotlin_goals.is_empty() { + messages.push(kotlin_failure(module, "compile target was not executed")); + } + for goal in &module.kotlin_goals { + if goal.status != "success" { + messages.push(kotlin_failure(module, &format!("{} {}", goal.execution, goal.status))); + } + } + } + messages +} + +fn kotlin_failure(module: &Module, reason: &str) -> BuildMessage { + BuildMessage { + severity: ide_core::Severity::Error, + text: format!("Kotlin compilation for {}: {reason}", module.key), + path: None, + range: None, + code: None, + } +} + +fn publish(pending: &Path, published: &Path, cancel: &CancellationToken) -> IdeResult<()> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + std::fs::rename(pending, published)?; + Ok(()) +} + +#[derive(Default)] +struct ModuleLibraries { + main: Option, + test: Option, +} + +fn consumers(modules: &[Module], libraries: &BTreeMap) -> Vec { + modules + .iter() + .map(|consumer| { + let mut visible = Vec::::new(); + let mut add = |path: &Option, test: bool| { + if let Some(path) = path { + if let Some(previous) = visible.iter_mut().find(|entry| &entry.path == path) { + previous.test &= test; + } else { + visible.push(Library { path: path.clone(), test }); + } + } + }; + if let Some(own) = libraries.get(&consumer.key) { + add(&own.test, true); + add(&own.main, false); + } + // Maven's resolved artifacts already apply exclusions, mediation and optional + // propagation. Preserve their classpath order, after the module's own output. + for dependency in &consumer.dependencies { + let Some(output) = libraries.get(&dependency.key) else { continue }; + let test = match dependency.scope.as_str() { + "compile" | "provided" | "system" | "" => false, + "test" | "runtime" => true, + _ => continue, + }; + match (dependency.kind.as_deref(), dependency.classifier.as_deref()) { + (Some("test-jar"), None | Some("tests")) | (Some("jar"), Some("tests")) => add(&output.test, test), + (None | Some("jar"), None | Some("")) => add(&output.main, test), + _ => {} + } + } + ProjectLibraries { project_uri: consumer.project_uri.clone(), libraries: visible } + }) + .collect() +} + +fn copy_kotlin(source: &Path, destination: &Path, cancel: &CancellationToken) -> IdeResult { + let Ok(entries) = std::fs::read_dir(source) else { return Ok(false) }; + let mut found = false; + for entry in entries { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let entry = entry?; + let kind = entry.file_type()?; + let path = entry.path(); + let output = destination.join(entry.file_name()); + if kind.is_dir() { + found |= copy_kotlin(&path, &output, cancel)?; + } else if kind.is_file() { + let bytes = std::fs::read(&path)?; + let class = path.extension().is_some_and(|e| e == "class"); + let metadata = path.extension().is_some_and(|e| e == "kotlin_module"); + if metadata || (class && kotlin_class(&bytes).unwrap_or(false)) { + std::fs::create_dir_all(destination)?; + std::fs::write(output, bytes)?; + found |= class; + } + } + } + Ok(found) +} + +/// Read the actual class-level SourceFile attribute, rather than searching strings +/// (a Java constant containing "Something.kt" must never become a Kotlin library). +fn kotlin_class(bytes: &[u8]) -> Option { + struct Reader<'a> { + bytes: &'a [u8], + pos: usize, + } + impl<'a> Reader<'a> { + fn take(&mut self, count: usize) -> Option<&'a [u8]> { + let value = self.bytes.get(self.pos..self.pos.checked_add(count)?)?; + self.pos += count; + Some(value) + } + fn u2(&mut self) -> Option { + Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?) as usize) + } + fn u4(&mut self) -> Option { + Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?) as usize) + } + fn attributes(&mut self) -> Option<()> { + for _ in 0..self.u2()? { + self.u2()?; + let n = self.u4()?; + self.take(n)?; + } + Some(()) + } + } + let mut reader = Reader { bytes, pos: 0 }; + if reader.take(4)? != [0xca, 0xfe, 0xba, 0xbe] { + return None; + } + reader.take(4)?; + let count = reader.u2()?; + let mut utf8 = BTreeMap::new(); + let mut index = 1; + while index < count { + match reader.take(1)?[0] { + 1 => { + let n = reader.u2()?; + utf8.insert(index, reader.take(n)?); + } + 3 | 4 | 9..=12 | 17 | 18 => { + reader.take(4)?; + } + 5 | 6 => { + reader.take(8)?; + index += 1; + } + 7 | 8 | 16 | 19 | 20 => { + reader.take(2)?; + } + 15 => { + reader.take(3)?; + } + _ => return None, + } + index += 1; + } + reader.take(6)?; + let interfaces = reader.u2()?; + reader.take(interfaces.checked_mul(2)?)?; + for _ in 0..2 { + for _ in 0..reader.u2()? { + reader.take(6)?; + reader.attributes()?; + } + } + for _ in 0..reader.u2()? { + let name = reader.u2()?; + let length = reader.u4()?; + if utf8.get(&name).copied() == Some(b"SourceFile".as_slice()) && length == 2 { + let source = reader.u2()?; + return Some(utf8.get(&source)?.ends_with(b".kt")); + } + reader.take(length)?; + } + Some(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_kotlin_target_must_succeed_even_when_maven_fails_elsewhere() { + let mut modules: Vec = serde_json::from_str(r#"[ + {"key":"lib","projectUri":"lib","output":"lib","dependencies":[],"hasKotlin":true, + "kotlinGoals":[{"execution":"main:compile","status":"success"},{"execution":"test:test-compile","status":"success"}]}, + {"key":"java","projectUri":"java","output":"java","dependencies":[],"hasKotlin":false,"kotlinGoals":[]} + ]"#).unwrap(); + assert!(kotlin_failures(&modules).is_empty()); + for status in ["failed", "skipped"] { + modules[0].kotlin_goals[1].status = status.into(); + let failures = kotlin_failures(&modules); + assert_eq!(failures.len(), 1); + assert!(failures[0].text.contains(&format!("test:test-compile {status}"))); + } + modules[0].kotlin_goals.clear(); + assert_eq!(kotlin_failures(&modules).len(), 1); + modules[0].has_kotlin = false; + assert!(kotlin_failures(&modules).is_empty()); + assert!( + serde_json::from_str::>( + r#"[{"key":"old","projectUri":"old","output":"old","dependencies":[]}]"# + ) + .is_err() + ); + } + + #[test] + fn respects_mavens_resolved_closure_and_scopes() { + let json = r#"[ + {"key":"zapp","projectUri":"app","output":"app", "hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"mid","scope":"compile","optional":false},{"key":"lib","scope":"compile","optional":false}]}, + {"key":"mid","projectUri":"mid","output":"mid", "hasKotlin":false,"kotlinGoals":[],"dependencies":[ + {"key":"lib","scope":"compile","optional":false}, + {"key":"optional","scope":"compile","optional":true}, + {"key":"test","scope":"test","optional":false}, + {"key":"provided","scope":"provided","optional":false}]}, + {"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]} + ]"#; + let modules: Vec = serde_json::from_str(json).unwrap(); + let libraries = ["zapp", "lib", "optional", "test", "provided"] + .into_iter() + .map(|key| (key.to_owned(), ModuleLibraries { main: Some(PathBuf::from(key)), test: None })) + .collect(); + let result = consumers(&modules, &libraries); + assert_eq!( + result[0].libraries, + vec![Library { path: "zapp".into(), test: false }, Library { path: "lib".into(), test: false },] + ); + assert_eq!(result[1].libraries.len(), 4); + assert!(result[1].libraries.iter().find(|l| l.path == Path::new("test")).unwrap().test); + } + + #[test] + fn test_outputs_are_private_unless_maven_resolves_a_test_jar() { + let modules: Vec = serde_json::from_str(r#"[ + {"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]}, + {"key":"app","projectUri":"app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"compile","type":"jar","classifier":null}]}, + {"key":"test-app","projectUri":"test-app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"test","type":"test-jar","classifier":"tests"}]} + ]"#).unwrap(); + let libraries = + BTreeMap::from([("lib".into(), ModuleLibraries { main: Some("main".into()), test: Some("test".into()) })]); + let result = consumers(&modules, &libraries); + assert_eq!( + result[0].libraries, + vec![Library { path: "test".into(), test: true }, Library { path: "main".into(), test: false }] + ); + assert_eq!(result[1].libraries, vec![Library { path: "main".into(), test: false }]); + assert_eq!(result[2].libraries, vec![Library { path: "test".into(), test: true }]); + } + + #[test] + fn publishes_complete_directories_and_cancellation_keeps_them_private() { + let directory = tempfile::tempdir().unwrap(); + let pending = directory.path().join("pending"); + let final_path = directory.path().join("published"); + std::fs::create_dir(&pending).unwrap(); + std::fs::write(pending.join("Channel.class"), "complete").unwrap(); + let cancelled = CancellationToken::new(); + cancelled.cancel(); + assert!(publish(&pending, &final_path, &cancelled).is_err()); + assert!(!final_path.exists()); + assert!(pending.exists()); + publish(&pending, &final_path, &CancellationToken::new()).unwrap(); + assert!(!pending.exists()); + assert_eq!(std::fs::read(final_path.join("Channel.class")).unwrap(), b"complete"); + } + + #[test] + fn never_runs_another_build_system_with_maven_output_properties() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("Cargo.toml"), "").unwrap(); + let error = build( + directory.path(), + &BuildModel::default(), + Path::new("unused.jar"), + &directory.path().join("private"), + &CancellationToken::new(), + ) + .err() + .unwrap(); + assert_eq!(error.message, "Kotlin bridge requires a Maven build root"); + } + + #[test] + fn does_not_find_deleted_sources_in_build_outputs() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("target/generated")).unwrap(); + std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap(); + assert!(!has_kotlin(dir.path())); + std::fs::write(dir.path().join("New.kt"), "").unwrap(); + assert!(has_kotlin(dir.path())); + } + + #[test] + fn parses_sourcefile_and_rejects_truncated_classes() { + let mut class = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52, 0, 3, 1, 0, 10]; + class.extend_from_slice(b"SourceFile"); + class.extend_from_slice(&[1, 0, 10]); + class.extend_from_slice(b"Channel.kt"); + class.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 2, 0, 2]); + assert_eq!(kotlin_class(&class), Some(true)); + for end in 0..class.len() { + assert!(kotlin_class(&class[..end]).is_none()); + } + let offset = 26; + class[offset..offset + 10].copy_from_slice(b"Other.java"); + assert_eq!(kotlin_class(&class), Some(false)); + } +} diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index 10dcd9b1c..96358419f 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -8,6 +8,7 @@ pub mod application_update; pub mod compile; pub mod gradle; +pub mod kotlin_bridge; pub mod maven; pub mod pom_edit; mod process; diff --git a/crates/ide-host/src/kotlin_bridge.rs b/crates/ide-host/src/kotlin_bridge.rs new file mode 100644 index 000000000..c29be5a29 --- /dev/null +++ b/crates/ide-host/src/kotlin_bridge.rs @@ -0,0 +1,429 @@ +//! Saved Kotlin -> isolated Maven output -> marked JDT libraries. + +use ide_build::kotlin_bridge::{self, Outcome, ProjectLibraries}; +use ide_core::event::NotifyLevel; +use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink}; +use ide_lsp::LspManager; +use ide_workspace::SettingsStore; +use ide_workspace::documents::DocumentObserver; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::time::Duration; +use tokio::sync::Notify; + +const DELAY: Duration = Duration::from_millis(750); +const SOURCE: &str = "kotlin-bridge"; +const COMMAND: &str = "openidea.kotlin.replace"; + +#[derive(Default)] +struct Revision { + generation: u64, + cancel: CancellationToken, +} + +impl Revision { + fn change(&mut self) { + self.cancel.cancel(); + self.generation += 1; + self.cancel = CancellationToken::new(); + } + fn current(&self, generation: u64) -> bool { + self.generation == generation && !self.cancel.is_cancelled() + } +} + +#[derive(Clone, Default, Serialize, Deserialize)] +struct MavenOptions { + offline: bool, + profiles: Vec, +} + +pub(crate) struct KotlinBridge { + root: PathBuf, + cache: PathBuf, + bundle: Option, + settings: Arc, + lsp: Arc, + sink: SharedSink, + trusted: Arc, + lifetime: CancellationToken, + revision: Mutex, + changed: Notify, + options: Mutex, + worker: Mutex>>, +} + +impl KotlinBridge { + pub(crate) fn new( + root: PathBuf, + cache: PathBuf, + data: &Path, + settings: Arc, + lsp: Arc, + sink: SharedSink, + trusted: Arc, + ) -> Arc { + let options = std::fs::read(cache.join("kotlin-bridge-options.json")) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default(); + Arc::new(Self { + root, + cache, + bundle: ide_sdk::java::kotlin_bridge_bundle(data), + settings, + lsp, + sink, + trusted, + lifetime: CancellationToken::new(), + revision: Mutex::new(Revision::default()), + changed: Notify::new(), + options: Mutex::new(options), + worker: Mutex::new(None), + }) + } + + pub(crate) fn start(self: &Arc, runtime: &tokio::runtime::Handle) { + let bridge = self.clone(); + *self.worker.lock() = Some(runtime.spawn(async move { bridge.run().await })); + self.schedule(); + } + + pub(crate) fn configure_maven(&self, offline: bool, profiles: Vec) { + let options = MavenOptions { offline, profiles }; + if let Ok(bytes) = serde_json::to_vec(&options) { + let _ = std::fs::create_dir_all(&self.cache); + let _ = std::fs::write(self.cache.join("kotlin-bridge-options.json"), bytes); + } + *self.options.lock() = options; + self.schedule(); + } + + pub(crate) async fn shutdown(&self) { + self.stop(); + let worker = self.worker.lock().take(); + if let Some(worker) = worker { + let _ = worker.await; + } + } + + pub(crate) fn schedule(&self) { + if self.lifetime.is_cancelled() { + return; + } + self.revision.lock().change(); + self.changed.notify_one(); + } + + pub(crate) fn files_changed<'a>(&self, mut paths: impl Iterator) { + let settings = self.settings.effective(); + if paths.any(|path| relevant_change(&self.root, path, &settings)) { + self.schedule(); + } + } + + pub(crate) fn stop(&self) { + self.lifetime.cancel(); + self.revision.lock().cancel.cancel(); + } + + async fn replace(&self, projects: &[ProjectLibraries], cancel: &CancellationToken) -> ide_core::IdeResult<()> { + let snapshot = serde_json::to_value(projects).map_err(|e| ide_core::IdeError::internal(e.to_string()))?; + let response = self.lsp.jdtls_command_cancellable(&self.root, COMMAND, vec![snapshot], cancel).await?; + if response.as_u64().is_none() { + return Err(ide_core::IdeError::unavailable( + "Kotlin bridge command did not acknowledge its library snapshot", + )); + } + Ok(()) + } + + async fn run(self: Arc) { + let mut diagnostics = BTreeSet::new(); + let mut last_failure = String::new(); + let mut initialized = false; + // Also retain directories from unacknowledged replacements: JDT may have applied them. + let mut published = Vec::new(); + loop { + tokio::select! { + _ = self.lifetime.cancelled() => return, + _ = self.changed.notified() => {}, + } + let (generation, cancel) = { + let state = self.revision.lock(); + (state.generation, state.cancel.clone()) + }; + if !self.trusted.load(Ordering::Acquire) { + continue; + } + if !debounce(&cancel, &self.lifetime).await { + continue; + } + let root = self.root.clone(); + let cache = self.cache.clone(); + let settings = self.settings.effective(); + let has_sources = tokio::task::spawn_blocking(move || { + let model = crate::ops::build::load(&root, &cache, &settings); + let enabled = + model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root); + (model, enabled) + }) + .await; + let Ok((mut model, enabled)) = has_sources else { continue }; + if !self.revision.lock().current(generation) { + continue; + } + if !enabled { + if self.cache.join("kotlin-bridge").exists() + && let Err(error) = self.replace(&[], &cancel).await + { + if !cancel.is_cancelled() { + tracing::warn!("Kotlin library withdrawal: {error}"); + } + continue; + } + for old in published.drain(..) { + remove_directory(old).await; + } + self.clear_diagnostics(&mut diagnostics); + last_failure.clear(); + continue; + } + if let Some(maven) = &mut model.maven { + let options = self.options.lock(); + for module in &mut maven.projects { + if options.offline { + module.options.push("-o".into()); + } + if !options.profiles.is_empty() { + module.options.push(format!("-P{}", options.profiles.join(","))); + } + } + } + let Some(bundle) = self.bundle.clone() else { + self.failure("Kotlin bridge bundle is unavailable".into(), &mut diagnostics, &mut last_failure); + continue; + }; + // Clear entries persisted by an earlier incarnation only before our first build. + if !initialized { + if let Err(error) = self.replace(&[], &cancel).await { + if !cancel.is_cancelled() { + self.failure(error.message, &mut diagnostics, &mut last_failure); + } + continue; + } + initialized = true; + } + let directory = self.cache.join(format!("kotlin-bridge/{}", uuid::Uuid::new_v4())); + let staging = directory.clone(); + let root = self.root.clone(); + let token = cancel.clone(); + let result = + tokio::task::spawn_blocking(move || kotlin_bridge::build(&root, &model, &bundle, &staging, &token)) + .await; + if !self.revision.lock().current(generation) || self.lifetime.is_cancelled() { + remove_directory(directory).await; + continue; + } + match result { + Ok(Ok(Outcome { success: true, projects, .. })) => { + match self.replace(&projects, &cancel).await { + Ok(()) => { + for old in published.drain(..) { + remove_directory(old).await; + } + published.push(directory); + self.clear_diagnostics(&mut diagnostics); + last_failure.clear(); + } + Err(error) => { + // Keep both snapshots until an acknowledged replacement. + published.push(directory); + if !cancel.is_cancelled() { + self.failure(error.message, &mut diagnostics, &mut last_failure); + } + } + } + } + other => { + let text = match other { + Ok(Ok(outcome)) => outcome + .messages + .iter() + .filter(|m| m.severity == Severity::Error) + .map(|m| m.text.as_str()) + .collect::>() + .join("\n"), + Ok(Err(error)) => error.message, + Err(error) => error.to_string(), + }; + self.failure(text, &mut diagnostics, &mut last_failure); + remove_directory(directory).await; + } + } + } + } + + fn clear_diagnostics(&self, paths: &mut BTreeSet) { + for path in std::mem::take(paths) { + self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items: Vec::new() }); + } + } + + fn failure(&self, text: String, paths: &mut BTreeSet, previous: &mut String) { + let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text }; + let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API."); + self.clear_diagnostics(paths); + let path = self.root.join("pom.xml"); + paths.insert(path.clone()); + self.sink.emit(IdeEvent::Diagnostics { + path: path.into(), + source: SOURCE.into(), + items: vec![Diagnostic { + range: Range { start: Pos { line: 0, col: 0 }, end: Pos { line: 0, col: 0 } }, + severity: Severity::Error, + message: text.clone(), + source: Some(SOURCE.into()), + server: Some(SOURCE.into()), + code: None, + unnecessary: false, + deprecated: false, + }], + }); + if previous != &text { + self.sink.emit(IdeEvent::Notify { + level: NotifyLevel::Error, + title: "Kotlin editor compilation failed".into(), + body: Some(text.clone()), + }); + *previous = text; + } + } +} + +fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool { + let input = path.extension().is_some_and(|e| e == "kt") + || path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd") + || path.components().any(|c| c.as_os_str() == ".mvn"); + let Ok(relative) = path.strip_prefix(root) else { + // Content roots outside the project only contribute Kotlin sources. + return path.extension().is_some_and(|e| e == "kt"); + }; + // Build outputs and generated sources (e.g. KSP's `.kt` under target/) never trigger, + // so a build cannot schedule itself. + if relative.components().any(|c| { + matches!( + c.as_os_str().to_str(), + Some("target" | "build" | "out" | "bin" | ".git" | "node_modules" | ".openidea") + ) + }) || ide_vfs::ExcludeMatcher::new(&settings.exclude_globs()).is_excluded(relative) + { + return false; + } + if input { + return true; + } + if path.extension().is_some() { + return false; + } + // Works for deleted directories too; the module POM remains on disk. + path.ancestors().take_while(|ancestor| ancestor.starts_with(root)).any(|ancestor| { + ancestor.file_name().is_some_and(|name| name == "src") + && ancestor.parent().is_some_and(|module| module.join("pom.xml").is_file()) + }) +} + +async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool { + tokio::select! { + _ = lifetime.cancelled() => false, + _ = cancel.cancelled() => false, + _ = tokio::time::sleep(DELAY) => true, + } +} + +async fn remove_directory(path: PathBuf) { + let _ = tokio::task::spawn_blocking(move || std::fs::remove_dir_all(path)).await; +} + +impl DocumentObserver for KotlinBridge { + fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) { + self.files_changed(std::iter::once(&path.to_path_buf())); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_package_directory_does_not_trigger() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("pom.xml"), "").unwrap(); + assert!(!relevant_change(dir.path(), &dir.path().join("target/classes/probe"), &Default::default())); + let generated = dir.path().join("target/generated-sources/ksp/main/kotlin/Gen.kt"); + assert!(!relevant_change(dir.path(), &generated, &Default::default())); + assert!(relevant_change(dir.path(), &dir.path().join("src/main/kotlin/A.kt"), &Default::default())); + } + + #[test] + fn deleted_source_package_triggers_unless_excluded() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("module")).unwrap(); + std::fs::write(dir.path().join("module/pom.xml"), "").unwrap(); + let path = dir.path().join("module/src/main/kotlin/pkg"); + let mut settings = ide_core::settings::Settings::default(); + assert!(relevant_change(dir.path(), &path, &settings)); + settings.project_structure.excluded.push("module/src/main/kotlin".into()); + assert!(!relevant_change(dir.path(), &path, &settings)); + assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default())); + } + + #[test] + fn extensionless_root_file_does_not_trigger() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("pom.xml"), "").unwrap(); + assert!(!relevant_change(dir.path(), &dir.path().join("LICENSE"), &Default::default())); + } + + #[tokio::test(start_paused = true)] + async fn burst_saves_cancel_the_pending_debounce() { + let mut state = Revision::default(); + state.change(); + let token = state.cancel.clone(); + let lifetime = CancellationToken::new(); + let pending_lifetime = lifetime.clone(); + let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await }); + tokio::task::yield_now().await; + tokio::time::advance(DELAY - Duration::from_millis(1)).await; + assert!(!waiting.is_finished()); + for _ in 0..20 { + state.change(); + } + assert!(!waiting.await.unwrap()); + let current = state.generation; + assert!(debounce(&state.cancel, &lifetime).await); + assert!(state.current(current)); + lifetime.cancel(); + assert!(!debounce(&state.cancel, &lifetime).await); + } + + #[test] + fn superseded_and_cancelled_builds_cannot_publish() { + let mut state = Revision::default(); + state.change(); + let first = state.generation; + let token = state.cancel.clone(); + state.change(); + assert!(token.is_cancelled()); + assert!(!state.current(first)); + assert!(state.current(state.generation)); + state.cancel.cancel(); + assert!(!state.current(state.generation)); + } +} diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index ccb01db4a..0d310d471 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -5,6 +5,7 @@ pub mod environment; mod java_runtime; +mod kotlin_bridge; pub mod notebook; pub mod ops; mod project; @@ -148,9 +149,15 @@ impl Host { if !project.light { self.environment.project_closed(); } + if let Some(bridge) = &project.kotlin_bridge { + bridge.stop(); + } project.agents.shutdown(); project.notebooks.stop_admission(); - self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]); + self.shutdown_project_workers_blocking( + vec![project.notebooks.clone()], + project.kotlin_bridge.iter().cloned().collect(), + ); let lsp = project.lsp.clone(); self.runtime.spawn(async move { lsp.shutdown_all().await }); } @@ -158,7 +165,14 @@ impl Host { /// Synchronous project/window destruction must finish owned kernel cleanup, /// not enqueue work which can be lost as the desktop runtime exits. - fn shutdown_notebooks_blocking(&self, notebooks: Vec>) { + fn shutdown_project_workers_blocking( + &self, + notebooks: Vec>, + bridges: Vec>, + ) { + for bridge in &bridges { + bridge.stop(); + } for service in ¬ebooks { service.stop_admission(); } @@ -166,6 +180,9 @@ impl Host { let wait = move || { let worker = std::thread::spawn(move || { runtime.block_on(async move { + for bridge in bridges { + bridge.shutdown().await; + } for service in notebooks { service.shutdown().await; } @@ -186,7 +203,11 @@ impl Host { /// Called before the desktop runtime exits, including update/restart exits. pub fn shutdown_notebooks_on_exit(&self) { - self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.clone()).collect()); + let projects = self.projects(); + self.shutdown_project_workers_blocking( + projects.iter().map(|project| project.notebooks.clone()).collect(), + projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(), + ); } /// Stops what this host started: notebook kernels, language servers, agents, runs and terminals. @@ -197,12 +218,18 @@ impl Host { self.terminals.kill_all(); let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect(); for project in &projects { + if let Some(bridge) = &project.kotlin_bridge { + bridge.stop(); + } project.notebooks.stop_admission(); project.agents.shutdown(); } // Kernel cleanup owns its interrupt/termination/reaping deadlines. An // outer LSP timeout must never abort it before owned children are reaped. for project in &projects { + if let Some(bridge) = &project.kotlin_bridge { + bridge.shutdown().await; + } project.notebooks.shutdown().await; } let stop_language_servers = async { diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 650db06d4..ad3983302 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -97,6 +97,7 @@ pub async fn build_sync( host.require_trust(window)?; let project = host.project(window)?; let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); + let bridge_profiles = profiles.clone(); let token = host.begin_project_request(window, request); let result = tokio::task::spawn_blocking(move || { let current = load(&root, &cache, &settings); @@ -137,6 +138,9 @@ pub async fn build_sync( // jdtls re-imports on pom changes but misses artifacts added to ~/.m2 // meanwhile ("missing artifact" stays until Invalidate Caches); asking it // to update the project configurations re-resolves them (#436). + if let Some(bridge) = &project.kotlin_bridge { + bridge.configure_maven(offline, bridge_profiles); + } let lsp = project.lsp.clone(); let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect(); tokio::spawn(async move { diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 0c85d1065..f729f7989 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -269,7 +269,13 @@ pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> { } pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> { - host.project(window)?.lsp.restart(server).await + let project = host.project(window)?; + let java = project.lsp.servers().iter().any(|s| s.id == server && s.name == "jdtls"); + project.lsp.restart(server).await?; + if java && let Some(bridge) = &project.kotlin_bridge { + bridge.schedule(); + } + Ok(()) } /// Applies a (previewed) workspace edit, e.g. after rename confirmation. diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index ec1d44026..4c17cdde1 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -177,6 +177,9 @@ pub fn set_settings( .any(|k| key == *k || key.starts_with(&format!("{k}."))) { for project in affected() { + if let Some(bridge) = &project.kotlin_bridge { + bridge.schedule(); + } let lsp = project.lsp.clone(); host.runtime.spawn(async move { lsp.reconfigure().await }); } diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index e3d868e21..f28c7cffe 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -52,6 +52,7 @@ pub struct Project { pub agents: Arc, /// Native notebooks and owned kernels, scoped to this project incarnation. pub notebooks: Arc, + pub(crate) kotlin_bridge: Option>, pub(crate) watcher: Watcher, /// Watchers of the Project Structure content roots outside the project folder. pub(crate) content_watchers: Mutex>, @@ -152,6 +153,7 @@ impl Host { let bundles: Vec = ide_tools::managed_bundle(&data_dir, "java-debug") .into_iter() .chain(ide_sdk::java::usages_bundle(&data_dir)) + .chain(ide_sdk::java::kotlin_bridge_bundle(&data_dir)) .collect(); let min_java = ide_sdk::java::jdtls_min_java(cfg); let jdks = ide_sdk::java::jdtls_jdks(min_java, &data_dir); @@ -228,11 +230,22 @@ impl Host { trusted: trusted.clone(), })); + let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new( + root.clone(), + self.data_dir.join("cache").join(&project_key).join("build"), + &self.data_dir, + settings.clone(), + lsp.clone(), + sink.clone(), + trusted.clone(), + ); + documents.add_observer(kotlin_bridge.clone()); let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?)); for repo in repos.read().all() { repo.set_execution_allowed(trusted.load(Ordering::Acquire)); } let watcher = { + let kotlin_bridge = kotlin_bridge.clone(); let repos = repos.clone(); let settings = settings.clone(); let index = index.clone(); @@ -253,6 +266,7 @@ impl Host { Box::new(move |batch| { let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); index.update(&changed, &batch.removed); + kotlin_bridge.files_changed(changed.iter().chain(&batch.removed)); lsp.project_files_changed(batch.created.iter().chain(&batch.removed)); // Atomic saves by external tools surface as `created`. documents.on_disk_changed(&changed); @@ -332,7 +346,7 @@ impl Host { let content_watchers = extra_roots .iter() .filter_map(|r| { - let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?; + let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history, Some(&kotlin_bridge))?; Some((r.clone(), w)) }) .collect(); @@ -375,6 +389,7 @@ impl Host { http_history, agents, notebooks, + kotlin_bridge: Some(kotlin_bridge), watcher, content_watchers: Mutex::new(content_watchers), _scratch_watcher: scratch_watcher, @@ -393,6 +408,9 @@ impl Host { })); } self.insert_project(window, project.clone()); + if let Some(bridge) = &project.kotlin_bridge { + bridge.start(&self.runtime); + } if let Some(cmd) = post_start { self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone()); } @@ -493,6 +511,7 @@ impl Host { http_history, agents, notebooks, + kotlin_bridge: None, watcher, content_watchers: Mutex::new(Vec::new()), _scratch_watcher: None, @@ -786,9 +805,15 @@ impl Project { if watchers.iter().any(|(p, _)| *p == r) { continue; } - if let Some(w) = - content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history) - { + if let Some(w) = content_root_watcher( + &r, + &excludes, + &self.sink, + &self.index, + &self.documents, + &self.history, + self.kotlin_bridge.as_ref(), + ) { watchers.push((r, w)); } } @@ -920,7 +945,9 @@ fn content_root_watcher( index: &Arc, documents: &Arc, history: &Arc, + kotlin_bridge: Option<&Arc>, ) -> Option { + let kotlin_bridge = kotlin_bridge.cloned(); let (index, documents, history) = (index.clone(), documents.clone(), history.clone()); Watcher::start( dir, @@ -929,6 +956,9 @@ fn content_root_watcher( Box::new(move |batch| { let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); index.update(&changed, &batch.removed); + if let Some(bridge) = &kotlin_bridge { + bridge.files_changed(changed.iter().chain(&batch.removed)); + } documents.on_disk_changed(&changed); record_external_changes(&history, &changed, &batch.removed); }), diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 96c7c9740..b829bd636 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -106,6 +106,9 @@ impl crate::Host { if previous == trusted { continue; } + if let Some(bridge) = &project.kotlin_bridge { + bridge.schedule(); + } if !trusted { project.lsp.set_execution_allowed(false); } diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 09d6ed610..f2b79e0c1 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -396,7 +396,8 @@ impl LspManager { self.jdtls_command_cancellable(path, command, arguments, &CancellationToken::new()).await } - async fn jdtls_command_cancellable( + /// Cancellable delegate commands for project-owned background services. + pub async fn jdtls_command_cancellable( &self, path: &Path, command: &str, diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index bf86f5815..9d104583a 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -948,6 +948,49 @@ mod tests { } } +/// The same jar supplies the JDT command and the private Maven output redirector. +pub fn kotlin_bridge_bundle(data_dir: &Path) -> Option { + let extension = data_dir.join("kotlin-bridge-1.0.0.jar"); + let bytes = include_bytes!("../../../plugins/kotlin-bridge/openidea-kotlin-bridge.jar"); + if std::fs::read(&extension).is_ok_and(|old| old == bytes) { + return Some(extension); + } + let temporary = data_dir.join(format!( + "kotlin-bridge-{}-{}.tmp", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos() + )); + let result = std::fs::create_dir_all(data_dir) + .and_then(|_| std::fs::write(&temporary, bytes)) + .and_then(|_| std::fs::rename(&temporary, &extension)); + if let Err(error) = result { + let _ = std::fs::remove_file(temporary); + tracing::warn!("Kotlin bridge extension unavailable: {error}"); + return std::fs::read(&extension).is_ok_and(|old| old == bytes).then_some(extension); + } + Some(extension) +} + +#[cfg(test)] +mod kotlin_bridge_bundle_tests { + #[test] + fn embeds_both_runtime_targets_and_repairs_a_damaged_installation() { + let directory = tempfile::tempdir().unwrap(); + let path = super::kotlin_bridge_bundle(directory.path()).unwrap(); + let expected = std::fs::read(&path).unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&expected)).unwrap(); + for (class, major) in [("KotlinLibraries", 65), ("IsolatedOutput", 52)] { + use std::io::Read; + let mut bytes = Vec::new(); + archive.by_name(&format!("ai/openidea/kotlin/{class}.class")).unwrap().read_to_end(&mut bytes).unwrap(); + assert_eq!(u16::from_be_bytes([bytes[6], bytes[7]]), major); + } + std::fs::write(&path, "incomplete").unwrap(); + assert_eq!(super::kotlin_bridge_bundle(directory.path()), Some(path.clone())); + assert_eq!(std::fs::read(path).unwrap(), expected); + } +} + /// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure. pub fn usages_bundle(data_dir: &Path) -> Option { let extension = data_dir.join("java-usages-1.1.1.jar"); diff --git a/plugins/kotlin-bridge/MANIFEST.MF b/plugins/kotlin-bridge/MANIFEST.MF new file mode 100644 index 000000000..f0d3eebaa --- /dev/null +++ b/plugins/kotlin-bridge/MANIFEST.MF @@ -0,0 +1,8 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: OpenIDEA Kotlin binary bridge +Bundle-SymbolicName: ai.openidea.kotlin;singleton:=true +Bundle-Version: 1.0.0 +Bundle-RequiredExecutionEnvironment: JavaSE-21 +Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core, + org.eclipse.core.runtime,org.eclipse.core.resources,com.google.gson diff --git a/plugins/kotlin-bridge/README.md b/plugins/kotlin-bridge/README.md new file mode 100644 index 000000000..a1eb4bf21 --- /dev/null +++ b/plugins/kotlin-bridge/README.md @@ -0,0 +1,73 @@ +# Kotlin binary bridge + +App-owned source, licensed under this repository's Apache-2.0 license. The jar +contains no third-party implementation classes. JDT LS and Maven supply their +respective APIs at runtime. + +`KotlinLibraries` is a JDT LS delegate command. `openidea.kotlin.replace` takes +one complete array of `{projectUri, libraries: [{path, test}]}` objects and returns +the number of changed projects. Paths name fully published, immutable directories. +An empty array withdraws all owned libraries. Only library entries carrying +`ai.openidea.kotlin=true` are removed; all other `IClasspathEntry` objects are +retained, including containers, access rules, attributes and project references. +A Java model listener restores the desired snapshot after Maven reimport. + +`IsolatedOutput` is a Java 8 Maven lifecycle participant, activated only in the +private editor build via `-Dmaven.ext.class.path=` and +`-Dopenidea.kotlin.staging=`. It redirects reactor output +before lifecycle planning and records Maven's resolved dependency closure after +each session, including failed builds. An execution listener records each module's +Kotlin compile/test-compile targets as successful, failed or skipped. The host uses +the project's Maven lifecycle/configuration, +then publishes only classes whose actual `SourceFile` attribute ends in `.kt`, +plus Kotlin module metadata. Java output is not bridged. Main/test Kotlin APIs and +same-reactor consumers are covered; Precise Kotlin +source navigation, unsaved analysis and refactoring are outside this bridge. + +The host debounces saved/source/configuration changes, cancels superseded process +groups, and retains the last successful snapshot while rebuilding. Maven runs +with `--fail-at-end`. A new snapshot is attached only when all Kotlin targets in +modules containing Kotlin sources succeed, even if javac fails. Java diagnostics +belong to JDT. Failed or skipped Kotlin targets retain the previous snapshot and +report errors under `kotlin-bridge` Problems and a notification explaining the +older API. Old directories are deleted only after JDT acknowledges replacement. +With no Kotlin sources left, all managed libraries and bridge diagnostics are +withdrawn, even when the POM still declares the Kotlin plugin. Extensionless +watcher paths trigger only below a Maven module's `src/`, outside generated and +user-excluded directories. Run/Debug preparation is independent of this editor +build. + +## Build + +Built against JDT LS 1.63.0 and Maven 3.9.11, using their installed API jars. +`build-dependencies.sha256` pins all build inputs. The build refuses different +inputs; `--record-dependencies` is an explicit maintainer action when updating +those dependencies. Compilation uses the JDT batch compiler with Java 21 target +for the JDT command and Java 8 target for the Maven participant. Use Java 25 to +run the build compiler. Archive order and timestamps are deterministic. + +```sh +python3 plugins/kotlin-bridge/build.py /path/to/jdtls /path/to/maven +shasum -a 256 plugins/kotlin-bridge/openidea-kotlin-bridge.jar +``` + +Committed jar SHA-256: +`5c6407a85bd3cfcde0ee68b2f223dd3d1ca0e5165ff76a2249191617496b1fcd`. + +The SDK embeds this jar with `include_bytes!`, writes it atomically into the app +data directory, and includes it in JDT LS initialization bundles. + +## Integration regression + +```sh +# `java` on PATH must satisfy JDT LS; JAVA_HOME selects Maven's project JDK. +JAVA_HOME=/path/to/project/jdk python3 plugins/kotlin-bridge/tests/verify.py /path/to/jdtls +``` + +Requires Maven on PATH and access to Kotlin 2.0.10 and the fixture's Maven +plugins (or their existing local cache). The test creates its own projects, +workspace, bundle copy and JDT process. It checks baseline errors, single/reactor +resolution, SMS/member completion, binary definition locations, preservation of +unowned entries, reimport, API changes, publication despite javac failure, +failed/skipped Kotlin target retention, deletion withdrawal and repair. Evidence +and protocol transcripts are written to a printed temporary directory. Only the spawned JDT process is terminated. diff --git a/plugins/kotlin-bridge/build-dependencies.sha256 b/plugins/kotlin-bridge/build-dependencies.sha256 new file mode 100644 index 000000000..23592d907 --- /dev/null +++ b/plugins/kotlin-bridge/build-dependencies.sha256 @@ -0,0 +1,161 @@ +beebede8db065fe1b72909ecc66bb49acda618901635d86c25fce14a5915e37e jdt/ch.qos.logback.classic_1.6.3.jar +a6967a28c8b086dee75a694d2f6fb8830c71153539866d7d8af065c9b6df91e0 jdt/ch.qos.logback.core_1.6.3.jar +2cbd119bf1961c28788310963dc80ba65f58cdeec1dd139c8bdb1240faa2c36f jdt/com.google.gson_2.14.0.jar +cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb jdt/com.google.guava.failureaccess_1.0.3.jar +796d8e28ac64e83a47c4c5935a8fecc4682650a04bbdead738ef0f5a3a0e6c46 jdt/com.google.guava_33.7.1.jre.jar +3b3864f5b449e9c3c24b16861524b622b086563f44e0cd8384c8efc5a6052f82 jdt/com.sun.jna.platform_5.19.1.jar +467b2990d4f406e7d41f7a989f89df257767ca11d566f844560e2ab8d09f6433 jdt/com.sun.jna_5.19.1.v20260612-1000.jar +85fb03fc054cdf4efca8efd9b6712bbb418e1ab98241c4539c8585bbc23e1b8a jdt/jakarta.annotation-api_1.3.5.jar +a852b0971c589491e24334bd77e92760c048d2c102fc4c68e582e1cd53130f84 jdt/jakarta.inject.jakarta.inject-api_1.0.5.jar +2506fb25a748d4a5e5ec549d8bb063889d0f213e9cfbd6fd6fcbdc93b4f35bbb jdt/jakarta.servlet-api_6.2.0.M2.jar +cbf2aa09c14441d2ddea7096f60b738b3aee7f08fbf98f5f91e2ca48b08fff4a jdt/org.apache.ant_1.10.18.v20260906-1000.jar +8f7f8605d68e15bf32db61ec94eac6fdafc51b1bdbe1e0e0802b57d23f387792 jdt/org.apache.commons.cli_1.11.0.jar +78a5d732fbd715e2d10bd7150d2f8030bae57267f8aacc5c88f642cb6c2e5d3f jdt/org.apache.commons.commons-codec_1.22.1.jar +69e5c9fa35da7a51a5fd2099dfe56a2d8d32cf233e2f6d770e796146440263f4 jdt/org.apache.commons.lang3_3.20.0.jar +c5bd887c8fd5ec4cd563249e7552bb1a1a1ca57e05a1f1ef8425a7b7b7bd2fbe jdt/org.apache.felix.scr_2.2.18.jar +c375b6d5b71efe179e5763a6d83fcf10e303c0bf82031310a46769727ac90da0 jdt/org.commonmark.ext-gfm-tables_0.30.1.jar +9b0d20064975a17e876fcb4b599c6b3667f46a7739ed61e2f06c96f44b50629f jdt/org.commonmark_0.30.1.jar +a6a16f8f2b6977a10f951ab4ff46b155e4e20dd8c2fcd2cb835b74d90f30b456 jdt/org.eclipse.ant.core_3.7.1100.v20260831-0701.jar +61c479b6544db7eeebb5ba796051c092032d03a5136295baabd72a233baef29c jdt/org.eclipse.buildship.compat_3.1.13.v20260923-0232-s.jar +213baab808816e0661acd61a5382a8f120923288212defbd189b4ee6540ecce1 jdt/org.eclipse.buildship.core_3.1.13.v20260923-0232-s.jar +e75b07863128a076989d15e815d63a49cab772a338f1f0444d007bef3fa94601 jdt/org.eclipse.compare.core_3.8.900.v20260731-0809.jar +01425989976dccd56d348b207e11239b3d8bf572f7b7ef12dbd29eb39c40205e jdt/org.eclipse.core.commands_3.13.100.v20260902-1129.jar +a63b488fb9d5382fbb3b8bae0951f1da5fd3a20211aa6814c7e8c1e21d2ffc78 jdt/org.eclipse.core.contenttype_3.9.1000.v20260921-1824.jar +06a18236ae181659e1e5499d912d68de527720686f1f51dab632d3e298e32b78 jdt/org.eclipse.core.expressions_3.9.700.v20260710-0925.jar +422a33041ea01954f48ff3969e2c6d96e64831b3e372202a4d2a914282e3ffd9 jdt/org.eclipse.core.filebuffers_3.9.0.v20261010-1449.jar +d0f5e3fb7cd17bb8daebc684311198138e380e089292477f12d2dfbb4c8a30a8 jdt/org.eclipse.core.filesystem_1.12.0.v20260927-0940.jar +941b3c5feeba4ec0a949a8a94ff10283b2505813fa736c9e6345f146ae1b7e8a jdt/org.eclipse.core.jobs_3.15.900.v20260604-0622.jar +6e7a8db7d87a44bb35924bb345ddf0d9372413e3fee2edd842b50ed630ec254f jdt/org.eclipse.core.net_1.5.900.v20260507-0401.jar +e22d47e2e68713a74bfacd134dc01b1aa904a1b429769241483e058de166acea jdt/org.eclipse.core.resources_3.25.0.v20261010-1447.jar +0947f29f5c5b7d234e3b735de2d00d33ae6e3ee428a61bb818f9c0fe2b809735 jdt/org.eclipse.core.runtime_3.35.100.v20260910-1515.jar +553f09a59f2662fa4a3bbc94e84bd83221c1c6fb532d81b57010162fb47460a6 jdt/org.eclipse.core.variables_3.6.900.v20260602-0702.jar +a4e87f5e883e6c864783dc8148e9f915c3e7a9e3e0e26acf6b82210d1134ed79 jdt/org.eclipse.debug.core_3.24.100.v20261009-1915.jar +fd353398835107608f554779a6e91891725efb94e0f57559d0ab3cea4b6156c7 jdt/org.eclipse.equinox.app_1.7.600.v20251211-1038.jar +28749ec64dac9d0e20e00becc3d5f9daccb5637455c9c546ab07be324011ef84 jdt/org.eclipse.equinox.common_3.21.100.v20260829-1451.jar +9beeb1098a3da89a470c547aa840dea0d240d144138a5ba7b9ceedb84ed301ab jdt/org.eclipse.equinox.frameworkadmin.equinox_1.3.500.v20260928-0506.jar +f2b956930b8383d708f1fe9df92938bffbe2e129082d4fd0b3d5f59c20a5c9f0 jdt/org.eclipse.equinox.frameworkadmin_2.3.600.v20260306-0554.jar +8e889f92718fb8c612dd358265da2c94738bbc08b46473b81cad36f1177a8235 jdt/org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.1700.v20260913-1737.jar +347674eb3d3d4000c184573618241141297fb9586c8f65ebdbacace59b8c81a2 jdt/org.eclipse.equinox.launcher.cocoa.macosx.x86_64_1.2.1700.v20260913-1737.jar +57e8a4b46906751e214dfa8fab1e02462c40ad4e51f2226d9ef70af5f0941ccb jdt/org.eclipse.equinox.launcher.gtk.linux.aarch64_1.2.1700.v20260913-1737.jar +d0e7418232627c53288e566728d24d1578650514152992b87f82a5d982429606 jdt/org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.1700.v20260913-1737.jar +aa8c31a082b25b87f75008548f6544379174c98091934fb8399e3f917a0445f1 jdt/org.eclipse.equinox.launcher.win32.win32.x86_64_1.3.200.v20260913-1737.jar +9b3b1da60813ac5fa09dd5c198f1884be7076d0a5daec54faa3f351a787e34b7 jdt/org.eclipse.equinox.launcher_1.8.0.v20260804-1928.jar +fd04e81a28ab32b6bb073286eb293c44b760176fcf1e35ef15614d62df820f86 jdt/org.eclipse.equinox.preferences_3.12.100.v20251111-0704.jar +9b149d3367d33fc1f9a19d3f87c425ecf9fc235ee748b3cf200ec387169b042b jdt/org.eclipse.equinox.registry_3.12.700.v20260830-1519.jar +60388d163f70329c39a9cf4e32389ec4fba8dc80ae5518d97cd0be2ebb971510 jdt/org.eclipse.equinox.security.linux_1.2.0.v20260914-1851.jar +a8408d4bcdc70e019d2d89ed8b1b0413eb74ea73e74fa3d064b7401643539797 jdt/org.eclipse.equinox.security.macosx_1.102.500.v20250521-0414.jar +cf9668c1d1e481059366abc2719468aa52e1276475cf9c32d4d10f57b34d7182 jdt/org.eclipse.equinox.security.win32_1.3.0.v20240419-2334.jar +91b74719f9c1a130193407808fe5daab3b92431efbf30b8f886f1ff5e97f468f jdt/org.eclipse.equinox.security_1.4.700.v20250622-1644.jar +1a24a3c0fb01ad71f97432d5c2b364d9868b5a5ebd99a1308aacb1a2c03c47da jdt/org.eclipse.equinox.simpleconfigurator.manipulator_2.3.700.v20260306-0556.jar +d250938d3715529a9822fcdf46ad2f5de39720558d7195b5c1a8713af00bcd13 jdt/org.eclipse.equinox.simpleconfigurator_1.5.700.v20251111-1031.jar +db9a4e856bb4728a2719df544b0f2ccef52b54ebf4e643a417fdb471f85703c1 jdt/org.eclipse.equinox.spi_1.0.0.v20261009-1945.jar +7615ee7abdd14695957a52864c57869018dc10ddaac80d3cd24b6a97e05d0d9f jdt/org.eclipse.jdt.apt.core_3.8.1000.v20261012-1009.jar +ea365808514a22c2e5a1a3fe0b9c07b88a371094d5d5716d31906a8818d56b2a jdt/org.eclipse.jdt.apt.pluggable.core_1.4.800.v20260930-1106.jar +f667bb3666d9875a2309fa2447a19e6cdd960281473bd7125a22c5a435246d21 jdt/org.eclipse.jdt.core.compiler.batch_3.46.200.v20261008-1115.jar +a6d0f8fabd64c248c37c463789a1a13f801252b68b0a4f4808c207677b5cae17 jdt/org.eclipse.jdt.core.javac_1.0.0.z20260430-1959.jar +8ea98fcbecbd89f311849e448f4c4274a2aba5e15cfcdcad29951330c40e0ee8 jdt/org.eclipse.jdt.core.manipulation_1.26.0.v20261009-1526.jar +70b4bb168aab096ee7f33da7b72f470f4172fe3e18e80289dd89cf344937ed22 jdt/org.eclipse.jdt.core_3.48.0.v20261007-1246.jar +12113df750bb22f7ef0dd3f044f7c334feecd55ddb56b9340529899c55f5893b jdt/org.eclipse.jdt.debug_3.27.0.v20261012-1250.jar +f140fad97dc5c452af0cb0f87ed3c6c83ff518f1dda49a43b8b9bec07f17d0fd jdt/org.eclipse.jdt.junit.core_3.16.0.v20261008-2002.jar +4ea3fe4a12a0e1e8b10b7ce3f577a0ff4f5115c6a5e40364c53b8b329a8505b0 jdt/org.eclipse.jdt.junit.runtime_3.8.200.v20260911-1643.jar +8b928263817f5dd0a9210a8842682903f779dc83253fea501591e150e5303095 jdt/org.eclipse.jdt.launching_3.25.0.v20261010-0421.jar +6e5e5e39d82e9ac04925e8b3f432ef6fe81284441e99952d2f720779c9e2831b jdt/org.eclipse.jdt.ls.core_1.63.0.202610191322.jar +666cc544f8a63e41d2dc8e6f3626071214027e4f698dea5bf36137f85477bba4 jdt/org.eclipse.jdt.ls.filesystem_1.63.0.202610191322.jar +792f7df214d4ab5e4915ac1805922d44de61ebb880f1fc4881ecd4ecfdca6aca jdt/org.eclipse.jdt.ls.logback.appender_1.63.0.202610191322.jar +9647feb0524bf763c878e12ab878a684102b81cccb3f77feecbec709d54f9bbb jdt/org.eclipse.lsp4j.jsonrpc_1.0.0.v20260209-1721.jar +ccd78893facc6bfcc359d56cba05d3d5b85eb41e4c40d4b4215ca45db5f416d9 jdt/org.eclipse.lsp4j_1.0.0.v20260209-1721.jar +a5d6bdc3e6f5017f19375697c7a5929fbc83f5734f35d5ed4540c7b2c843d4bb jdt/org.eclipse.ltk.core.refactoring_3.17.0.v20261010-1449.jar +69d1b41401686fd99337e1ddd8aeec309255ae464393ca19a8025c85bfae6601 jdt/org.eclipse.m2e.apt.core_2.3.200.20260815-0704.jar +34872f7712dffca0b39804708c0c1b58f2f2576e27cb7176b92a1ea437d384e6 jdt/org.eclipse.m2e.core_2.8.0.20260815-0704.jar +8c3fa36fe9f62ace897b0196df8e6bba6a50610c186f6689ada5f386a8b223b1 jdt/org.eclipse.m2e.jdt_2.5.300.20260813-1359.jar +5c5b419a101210127f21fa7d37bd2605b90f96efe9d3e91ea31099fb243dec8e jdt/org.eclipse.m2e.maven.runtime_3.9.1601.20260722-2149.jar +53376fa2d7bfd2d5166806e74af9576ee3f8f1c1ac916e2cb4d2bb0c3e72c02f jdt/org.eclipse.m2e.workspace.cli_0.4.0.jar +20e09b8f88845480b6ad0b89ac001679b6713592cd0ee4bd78cdb326172eee62 jdt/org.eclipse.osgi.compatibility.state_1.3.100.v20260909-1123.jar +fe66fd0b071bebc7e1a122be571ed8ecde373d2966fa6b1e161a899b3c7a054d jdt/org.eclipse.osgi_3.25.0.v20261008-2211.jar +2714e0c7548a0c23ab99d4e84b68d3e67419bbd6b295efda15d881e7cb173a1c jdt/org.eclipse.search.core_3.17.0.v20261010-1449.jar +0ca3b552c867619285b8ae36c7defdd6c0f013e692643d433fd111796da55f19 jdt/org.eclipse.text_3.14.900.v20261012-1310.jar +c6c2b8593cd39c279e74e7055cbd146d39dc6f16011961b4928b92728d32d00d jdt/org.eclipse.xtext.xbase.lib_2.44.0.v20260824-1228.jar +ea7a53f8b7966039c6f7d54f462cbbaaad38a5e313a4e7d9125fa39640988725 jdt/org.gradle.toolingapi_9.7.1.v20260923-0232-s.jar +5d66b6a4a680755cb6ed7cb104fa7835ef644667586ff0737adeb977c39ecdbc jdt/org.hamcrest_3.0.0.jar +e9d8c856856680427f096d156f02a414289985c930998fcb519ce5be39b42003 jdt/org.jsoup_1.23.2.jar +3efac657139b0b8024748e937e3e1dba71fbdf69fb526c60f504a3fb5490381b jdt/org.junit_4.13.2.v20240929-1000.jar +ed825d10ab1399c8c0cb669e688cf0c8c82629b4c8399b58352b68e92ca10fcb jdt/org.objectweb.asm_9.10.1.jar +0653852deab5c6f6e9c84e3aa8f72e72b9ce7dfaf7a6431f3d80f6f9d7a433ad jdt/org.osgi.service.component_1.5.1.202212101352.jar +43c7c870710e363405d422da653cce0d798a4537f76e4930f79bceadd3a55345 jdt/org.osgi.service.prefs_1.1.2.202109301733.jar +208819c7c71690c15a6bb8b187474e7f9d0147946b680182a62b9f222ae014ec jdt/org.osgi.util.function_1.2.0.202109301733.jar +7053c57e7d7d88fec6b90979a3af125e1d2bb847268a328a2f1ed65ad0a4c185 jdt/org.osgi.util.promise_1.3.0.202212101352.jar +7e1446499b359675d8aabe6af2de86b85e72ae8a2a932c0b40d0e5ba57097439 jdt/slf4j.api_2.0.20.jar +3970dab20b4deb8380ed9656392f289f3f3c2ee882d8c3942f1b5b486284620d jdt/wrapped.com.jetbrains.intellij.java.java-decompiler-engine_253.29346.240.jar +65d87386b7e926c0b6fb330a8d2eb6a987f5949634f7574731c0a3c2329d5629 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-emoji_0.64.8.jar +7a350f43f858da8f4a230552607ced119aeaf97484ca11ce4cd792e1e41e5bf7 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-gfm-strikethrough_0.64.8.jar +7a424dae7df0ee86200a08e163d9d1d8f63cd7a1a0c980b84ccbec029571fb4e jdt/wrapped.com.vladsch.flexmark.flexmark-ext-ins_0.64.8.jar +75a2e7168145f799a039832708aa0bc277a954c4a367f58711ccd6b9b7e7d04e jdt/wrapped.com.vladsch.flexmark.flexmark-ext-superscript_0.64.8.jar +dbe6355ea9e04731d534d3d4151870f83684c7a44c87d541a2c53ef5ee2c4fa4 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-tables_0.64.8.jar +91ae2f77e832d20607fad5b7f407d438ca3f16ac032b4f14956e4e7eb3a60931 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-wikilink_0.64.8.jar +0b650a4b47d29ffe0a08802c5d58e2270f733ce5443420c0e8904cfa1605964f jdt/wrapped.com.vladsch.flexmark.flexmark-html2md-converter_0.64.8.jar +74bab2d767f48f930471c9a72a0c3b89e9073549ad3754e6b7f83f09585463bc jdt/wrapped.com.vladsch.flexmark.flexmark-jira-converter_0.64.8.jar +1c7d205afeef618760a5fa907d579835df625ee258cbebd49813f14c2254a535 jdt/wrapped.com.vladsch.flexmark.flexmark-util-ast_0.64.8.jar +9179310212700753a4ecdc7aa057a04591b9cfdb089787a248e8269c34740310 jdt/wrapped.com.vladsch.flexmark.flexmark-util-builder_0.64.8.jar +796b9acec9af0fa290725a14be8a3fc1d6fe7a9cb518a13f67124b5172a3485b jdt/wrapped.com.vladsch.flexmark.flexmark-util-collection_0.64.8.jar +20458c20bc72808e1718ff8f1652272bbb32f62ddaecb2e9a0ddc84d77fd7283 jdt/wrapped.com.vladsch.flexmark.flexmark-util-data_0.64.8.jar +b59fbc922e3604442ff6aae1c6b83220245ba4c3f89d38870bc28c982b69d01d jdt/wrapped.com.vladsch.flexmark.flexmark-util-dependency_0.64.8.jar +b6073ee177aa2476d64787f9ec813fdb881943d402c195df0631ba672bfb1311 jdt/wrapped.com.vladsch.flexmark.flexmark-util-format_0.64.8.jar +c892e6cdbba715c55462decbe1c83cec432548cf2877cfd0715975c44dfab55c jdt/wrapped.com.vladsch.flexmark.flexmark-util-html_0.64.8.jar +1726c33ee95f77cfadc58c8ad27949ed72653f4045fbc399aff62827b44c7fcd jdt/wrapped.com.vladsch.flexmark.flexmark-util-misc_0.64.8.jar +cd0f9212530aa73f67de94d080018b2c3a3a55d41050d3abb4be8afb69989be0 jdt/wrapped.com.vladsch.flexmark.flexmark-util-options_0.64.8.jar +340167af15da00e3ae5fb89d6b44751abfd648951bb6d2e05c48287d9f9e70c9 jdt/wrapped.com.vladsch.flexmark.flexmark-util-sequence_0.64.8.jar +a8fad4a006411d020624c7539e6145e97920067844acddc10b019a178a680948 jdt/wrapped.com.vladsch.flexmark.flexmark-util-visitor_0.64.8.jar +792c13bc5536704fc0bfba9120e5bac712843a7ae6da6104b1392b382feaf63d jdt/wrapped.com.vladsch.flexmark.flexmark-util_0.64.8.jar +a2ec63056bcfe8c4babc62f13f5f49b502fccccf7c011b5d4ba7dd93f4d09393 jdt/wrapped.com.vladsch.flexmark.flexmark_0.64.8.jar +482b6aade7b0320e28db530a22f1382b3d30addf26a5ffd55a347f5c32f6dfc2 jdt/wrapped.org.jetbrains.annotations_24.0.1.jar +0addec670fedcd3f113c5c8091d783280d23f75e3acb841b61a9cdb079376a08 maven/aopalliance-1.0.jar +876eab6a83daecad5ca67eb9fcabb063c97b5aeb8cf1fca7a989ecde17522051 maven/asm-9.8.jar +d3d530d0f28fd0fbbffe2b0b338f70e8cb96f1605579e2e3abd4db29cac24e69 maven/commons-cli-1.9.0.jar +ba005f304cef92a3dede24a38ad5ac9b8afccf0d8f75839d6c1338634cf7f6e4 maven/commons-codec-1.18.0.jar +6661d5335090a5fc61dd869d2095bc6c1e2156e3aa47a6e4ababdf64c99a7889 maven/error_prone_annotations-2.38.0.jar +cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb maven/failureaccess-1.0.3.jar +94855942d4992f112946d3de1c334e709237b8126d8130bf07807c018a4a2120 maven/gson-2.13.1.jar +f3d7f57f67fd622f4d468dfdd692b3a5e3909246c28017ac3263405f0fe617ed maven/guava-33.4.8-jre.jar +142ad4475e19524d2fe3ac995b3f7cbc962fc726f2edb9dbdccc61feab9b2bf9 maven/guice-5.1.0-classes.jar +c8bc7e1c51a6d4ce72f40d2ebbabf1c4b68bfe76e732104b04381b493478e9d6 maven/httpclient-4.5.14.jar +6c9b3dd142a09dc468e23ad39aad6f75a0f2b85125104469f026e52a474e464f maven/httpcore-4.4.16.jar +0b7b8b003a90ea491579b62f5118828e45112914c65589b00faa49d6ec785839 maven/jansi-2.4.2.jar +e04ba5195bcd555dc95650f7cc614d151e4bcd52d29a10b8aa2197f3ab89ab9b maven/javax.annotation-api-1.3.2.jar +91c77044a50c481636c32d916fd89c9118a72195390452c81065080f957de7ff maven/javax.inject-1.jar +ab57ca8fd223772c17365d121f59e94ecbf0ae59d08c03a3cb5b81071c019195 maven/jcl-over-slf4j-1.7.36.jar +1fad6e6be7557781e4d33729d49ae1cdc8fdda6fe477bb0cc68ce351eafdfbab maven/jspecify-1.0.0.jar +0bfd1c121f907642344311d81e4dfd93eba6c24dc712cc0aeae273eb22833022 maven/maven-artifact-3.9.11.jar +5b1de1560ec19c4c69a655f986455888f3a0bcc3c160cac1f1edb87de3825f77 maven/maven-builder-support-3.9.11.jar +b0e2865fc808fd9fb3bee95bf37c0609eeb20c59bf1e6b958b37b365ee11f52e maven/maven-compat-3.9.11.jar +93d3c0523dda6c86c6620d9e692e7df39d0e2e4e4cd860061a8c0d274c2ad185 maven/maven-core-3.9.11.jar +7e15e05035331d0a7e0fe56d7354b2b30e15c0a8c60863cbf28df25190e197db maven/maven-embedder-3.9.11.jar +163aa596f976264384efeb50b4cb8a806086fd64a9b755ad1f69195fed3998ad maven/maven-model-3.9.11.jar +c4c72accdcd848332c1a37995610a6a369d49fdaabc43e85f0022a5b77de3ba7 maven/maven-model-builder-3.9.11.jar +8e985a56412564cf687040efed8bcd3a63eac2202a824bb01e1d5ece2b87458f maven/maven-plugin-api-3.9.11.jar +f8655f5f0060e0032944f2a23688d186e140d2184749bcfa1927f83cd6a8b1a2 maven/maven-repository-metadata-3.9.11.jar +c0ce14849298720dcf3b09fe9e263b3c576d2d6cd43c61f8aa9d6117e04e5e47 maven/maven-resolver-api-1.9.24.jar +18b7d557c41379db8b157ba95263c676f52c293f5416b67bd1db083e99b30383 maven/maven-resolver-connector-basic-1.9.24.jar +3cd88edda00c46242cdbe57aefb47703519d176d9e8a500333de15faf41b0e1d maven/maven-resolver-impl-1.9.24.jar +a222b0a5ca4c145e8e0f45207a23bcce18bdbe1f1c980e4e9fb332591ee8c91a maven/maven-resolver-named-locks-1.9.24.jar +b5d6e52ae1ecf1cb62ede2234234ba3093149524066488e84415187ce478c582 maven/maven-resolver-provider-3.9.11.jar +e3153906e93333c106ee18babb72ba7984ccb15e9116ea08f88c71b8d9387e01 maven/maven-resolver-spi-1.9.24.jar +b09268d41e1dfcae15a829d45a512ca29d62b727421d3bd6383ac6252e5e960f maven/maven-resolver-transport-file-1.9.24.jar +a0de339a8dd3473d9abaa1cf974507f38783e26af31d36a90c1e59cc8714ded0 maven/maven-resolver-transport-http-1.9.24.jar +78ed57fa2123a2d84fad7a0b15ca73b89df47c10294d02d6f8121c4d0e5050cb maven/maven-resolver-transport-wagon-1.9.24.jar +aeb7e776a8c47b1c317a2581f299e2525b044cb8abde31164e970bbbefab12b1 maven/maven-resolver-util-1.9.24.jar +026718d47d19e360d978857bc63d3dc065884fc2c801563d8a2a77b5ab74ccf8 maven/maven-settings-3.9.11.jar +33c96e4ca5a15b7c93180f6b2f8deb15f511dfc15dda0d2b671c968b22108972 maven/maven-settings-builder-3.9.11.jar +b613357e1bad4dfc1dead801691c9460f9585fe7c6b466bc25186212d7d18487 maven/maven-shared-utils-3.4.2.jar +76d9fa894fa61d1492877ca599cb5ab28ca4573d5f389d6c12a8233b995335f9 maven/maven-slf4j-provider-3.9.11.jar +1cbd7a965a5e2a9ea823bab311962a4e5aa5c240705bdbad5a52b40ffdfa1004 maven/org.eclipse.sisu.inject-0.9.0.M4.jar +b90579bc652eac7331436e0a25533fce14130b9c6e015f2dd3a3d4bb07e942b7 maven/org.eclipse.sisu.plexus-0.9.0.M4.jar +9a7f1b5c5a9effd61eadfd8731452a2f76a8e79111fac391ef75ea801bea203a maven/plexus-cipher-2.0.jar +50edb93c73786e62822b4fe1336e22880fdf147191373cf5c911370e16748fcf maven/plexus-component-annotations-2.2.0.jar +ab2a8715570438a2e4164d85ad3e8d489eabc38ea5093c2eb8ab7f58403535b5 maven/plexus-interpolation-1.28.jar +873139960c4c780176dda580b003a2c4bf82188bdce5bb99234e224ef7acfceb maven/plexus-sec-dispatcher-2.0.jar +27ef130e32c236090e408fb5498d94cb9ea26d14070fb1c8985d607b62d098d1 maven/plexus-utils-3.6.0.jar +d3ef575e3e4979678dc01bf1dcce51021493b4d11fb7f1be8ad982877c16a1c0 maven/slf4j-api-1.7.36.jar +afc9216fa97b78dad227b4a8d4d67b9897bf113a57f80598d62993841113e103 maven/wagon-file-3.5.3.jar +d2b6e48c9fcbe579e1858c622d14464011ff265fa6e28e794004a6882154a509 maven/wagon-http-3.5.3.jar +8e7da766f55164fde8779aaaa125832506c2848cab4876b5305138873e28037f maven/wagon-http-shared-3.5.3.jar +5e72000338945ed3e96f8e4f578d1d0672e1af7e19c0e9014197ae5b31af3ef4 maven/wagon-provider-api-3.5.3.jar diff --git a/plugins/kotlin-bridge/build.py b/plugins/kotlin-bridge/build.py new file mode 100644 index 000000000..538b749c3 --- /dev/null +++ b/plugins/kotlin-bridge/build.py @@ -0,0 +1,32 @@ +"""Build with JDT LS 1.63.0 + Maven 3.9.11. Checksums pin every compiler dependency.""" +import hashlib +import os +import pathlib +import subprocess +import sys +import tempfile +import zipfile + +root = pathlib.Path(__file__).resolve().parent +jdt, maven = map(lambda p: pathlib.Path(p).resolve(), sys.argv[1:3]) +jars = sorted((jdt / 'plugins').glob('*.jar')) + sorted((maven / 'lib').glob('*.jar')) +actual = ''.join(f'{hashlib.sha256(p.read_bytes()).hexdigest()} {"jdt/" if p.parent.name == "plugins" else "maven/"}{p.name}\n' for p in jars) +if '--record-dependencies' in sys.argv: + (root / 'build-dependencies.sha256').write_text(actual) +if (root / 'build-dependencies.sha256').read_text() != actual: + sys.exit('Build dependencies differ from build-dependencies.sha256') +with tempfile.TemporaryDirectory() as output: + for source, target, classpath in [('src', '-21', jars[:len(list((jdt / 'plugins').glob('*.jar')))]), ('maven', '-8', jars)]: + subprocess.run(['java', '-cp', str(jdt / 'plugins/*'), 'org.eclipse.jdt.internal.compiler.batch.Main', target, '-proc:none', '-classpath', os.pathsep.join(map(str, classpath)), '-d', output, *map(str, sorted((root/source).rglob('*.java')))], check=True) + files = {'META-INF/MANIFEST.MF': root/'MANIFEST.MF', 'plugin.xml': root/'plugin.xml', 'META-INF/plexus/components.xml': root/'components.xml'} + files.update({p.relative_to(output).as_posix(): p for p in pathlib.Path(output).rglob('*.class')}) + archive = pathlib.Path(output)/'bundle.jar' + with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as jar: + for name, path in sorted(files.items()): + data = path.read_bytes() + if name == 'META-INF/MANIFEST.MF': + data = data.rstrip(b'\r\n') + b'\n\n' + jar.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), data) + staged = root/'openidea-kotlin-bridge.jar.pending' + staged.write_bytes(archive.read_bytes()) + staged.replace(root/'openidea-kotlin-bridge.jar') diff --git a/plugins/kotlin-bridge/components.xml b/plugins/kotlin-bridge/components.xml new file mode 100644 index 000000000..156cd7d36 --- /dev/null +++ b/plugins/kotlin-bridge/components.xml @@ -0,0 +1,5 @@ + + org.apache.maven.AbstractMavenLifecycleParticipant + openidea-kotlin-output + ai.openidea.kotlin.IsolatedOutput + diff --git a/plugins/kotlin-bridge/maven/ai/openidea/kotlin/IsolatedOutput.java b/plugins/kotlin-bridge/maven/ai/openidea/kotlin/IsolatedOutput.java new file mode 100644 index 000000000..8da379947 --- /dev/null +++ b/plugins/kotlin-bridge/maven/ai/openidea/kotlin/IsolatedOutput.java @@ -0,0 +1,177 @@ +package ai.openidea.kotlin; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import org.apache.maven.AbstractMavenLifecycleParticipant; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.MavenExecutionException; +import org.apache.maven.execution.*; +import java.lang.reflect.*; +import org.apache.maven.model.*; +import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.util.xml.Xpp3Dom; + +/** Loaded only by the private bridge build, before lifecycle plans are calculated. */ +public final class IsolatedOutput extends AbstractMavenLifecycleParticipant { + private final Map outputs = new LinkedHashMap<>(); + private File staging; + private final Map> kotlin = new LinkedHashMap<>(); + private final Set sources = new HashSet<>(); + + @Override public void afterProjectsRead(MavenSession session) throws MavenExecutionException { + String destination = session.getUserProperties().getProperty("openidea.kotlin.staging"); + if (destination == null) throw new MavenExecutionException("Missing Kotlin staging directory", (File) null); + staging = new File(destination); + int index = 0; + for (MavenProject project : session.getProjects()) { + File build = new File(staging, Integer.toString(index++)); + File main = new File(build, "classes"); + File test = new File(build, "test-classes"); + project.getBuild().setDirectory(build.getAbsolutePath()); + project.getBuild().setOutputDirectory(main.getAbsolutePath()); + project.getBuild().setTestOutputDirectory(test.getAbsolutePath()); + for (Plugin plugin : project.getBuildPlugins()) { + if ("kotlin-maven-plugin".equals(plugin.getArtifactId())) { + plugin.setConfiguration(redirect(plugin.getConfiguration(), build, main, test)); + for (PluginExecution execution : plugin.getExecutions()) + execution.setConfiguration(redirect(execution.getConfiguration(), build, main, test)); + } else if ("maven-compiler-plugin".equals(plugin.getArtifactId())) { + plugin.setConfiguration(compiler(plugin.getConfiguration())); + for (PluginExecution execution : plugin.getExecutions()) + execution.setConfiguration(compiler(execution.getConfiguration())); + } + } + outputs.put(project, main); + Map goals = new LinkedHashMap<>(); + for (Plugin plugin : project.getBuildPlugins()) { + if (!isKotlin(plugin)) continue; + for (PluginExecution execution : plugin.getExecutions()) { + if ("none".equals(execution.getPhase())) continue; + for (String goal : execution.getGoals()) + if ("compile".equals(goal) || "test-compile".equals(goal)) + goals.put(execution.getId() + ":" + goal, "skipped"); + } + } + kotlin.put(project, goals); + if (hasKotlin(project.getBasedir().toPath(), project.getBasedir().toPath(), session.getProjects())) + sources.add(project); + } + final ExecutionListener previous = session.getRequest().getExecutionListener(); + // Preserve Maven's logger and any listener installed by another extension. + session.getRequest().setExecutionListener((ExecutionListener) Proxy.newProxyInstance( + ExecutionListener.class.getClassLoader(), new Class[] { ExecutionListener.class }, + (proxy, method, args) -> { + if (args != null && args.length == 1 && args[0] instanceof ExecutionEvent) { + ExecutionEvent event = (ExecutionEvent) args[0]; + if (Arrays.asList("mojoStarted", "mojoSucceeded", "mojoFailed", "mojoSkipped").contains(method.getName()) + && event.getMojoExecution() != null && isKotlin(event.getMojoExecution().getPlugin())) { + String goal = event.getMojoExecution().getGoal(); + if ("compile".equals(goal) || "test-compile".equals(goal)) { + String status = method.getName().equals("mojoSucceeded") ? "success" : + method.getName().equals("mojoFailed") ? "failed" : "skipped"; + Map goals = kotlin.get(event.getProject()); + if (goals != null) goals.put(event.getMojoExecution().getExecutionId() + ":" + goal, status); + } + } + } + if (previous == null) return null; + try { return method.invoke(previous, args); } + catch (InvocationTargetException e) { throw e.getCause(); } + })); + } + + private static boolean isKotlin(Plugin plugin) { + return "org.jetbrains.kotlin".equals(plugin.getGroupId()) && "kotlin-maven-plugin".equals(plugin.getArtifactId()); + } + private static boolean hasKotlin(Path root, Path directory, List projects) throws MavenExecutionException { + if (!directory.equals(root)) { + String name = directory.getFileName().toString(); + if (Arrays.asList("target", "build", "out", "bin", ".git", "node_modules", ".openidea").contains(name)) return false; + for (MavenProject project : projects) if (project.getBasedir().toPath().equals(directory)) return false; + } + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) return false; + try (DirectoryStream entries = Files.newDirectoryStream(directory)) { + for (Path path : entries) { + if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && path.toString().endsWith(".kt")) return true; + if (hasKotlin(root, path, projects)) return true; + } + return false; + } catch (IOException e) { throw new MavenExecutionException("Cannot inspect Kotlin sources", e); } + } + + @Override public void afterSessionEnd(MavenSession session) throws MavenExecutionException { + if (staging == null) return; + List modules = new ArrayList<>(); + for (Map.Entry entry : outputs.entrySet()) { + MavenProject project = entry.getKey(); + File main = entry.getValue(); + List dependencies = new ArrayList<>(); + // Maven has applied dependency management, mediation, scopes, optional + // propagation and exclusions. Do not reconstruct that graph from POMs. + for (Artifact dependency : project.getArtifacts()) { + dependencies.add("{\"key\":" + quote(dependency.getGroupId() + ":" + dependency.getArtifactId() + ":" + dependency.getVersion()) + + ",\"scope\":" + quote(dependency.getScope() == null ? "compile" : dependency.getScope()) + + ",\"optional\":" + dependency.isOptional() + ",\"type\":" + quote(dependency.getType()) + + ",\"classifier\":" + quote(dependency.getClassifier()) + "}"); + } + List goals = new ArrayList<>(); + for (Map.Entry goal : kotlin.get(project).entrySet()) + goals.add("{\"execution\":" + quote(goal.getKey()) + ",\"status\":" + quote(goal.getValue()) + "}"); + modules.add("{\"key\":" + quote(project.getGroupId() + ":" + project.getArtifactId() + ":" + project.getVersion()) + + ",\"projectUri\":" + quote(project.getBasedir().toURI().toString()) + + ",\"output\":" + quote(main.getAbsolutePath()) + + ",\"testOutput\":" + quote(project.getBuild().getTestOutputDirectory()) + + ",\"hasKotlin\":" + sources.contains(project) + + ",\"kotlinGoals\":[" + String.join(",", goals) + "]" + + ",\"dependencies\":[" + String.join(",", dependencies) + "]}"); + } + try { + Files.createDirectories(staging.toPath()); + Files.write(new File(staging.getParentFile(), "modules.json").toPath(), + ("[" + String.join(",", modules) + "]").getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new MavenExecutionException("Cannot write Kotlin module manifest", e); + } + } + + private static Xpp3Dom config(Object value) { + return value instanceof Xpp3Dom ? new Xpp3Dom((Xpp3Dom) value) : new Xpp3Dom("configuration"); + } + private static void set(Xpp3Dom config, String name, File path) { + Xpp3Dom child = config.getChild(name); + if (child == null) { child = new Xpp3Dom(name); config.addChild(child); } + child.setValue(path.getAbsolutePath()); + } + private static Xpp3Dom redirect(Object value, File build, File main, File test) { + Xpp3Dom config = config(value); + set(config, "output", main); + set(config, "testOutput", test); + set(config, "incrementalCachesRoot", new File(build, "kotlin-ic")); + Xpp3Dom incremental = config.getChild("myIncremental"); + if (incremental == null) { incremental = new Xpp3Dom("myIncremental"); config.addChild(incremental); } + incremental.setValue("false"); + return config; + } + private static Xpp3Dom compiler(Object value) { + Xpp3Dom config = config(value); + // compile and testCompile share the parameter name outputDirectory but + // have different defaults. Let each mojo read the redirected build model. + for (int i = config.getChildCount() - 1; i >= 0; i--) { + String name = config.getChild(i).getName(); + if ("outputDirectory".equals(name) || "testOutputDirectory".equals(name)) config.removeChild(i); + } + return config; + } + private static String quote(String text) { + if (text == null) return "null"; + StringBuilder out = new StringBuilder("\""); + for (char c : text.toCharArray()) { + if (c == '\\' || c == '"') out.append('\\').append(c); + else if (c < 32) out.append(String.format("\\u%04x", (int) c)); + else out.append(c); + } + return out.append('"').toString(); + } +} diff --git a/plugins/kotlin-bridge/openidea-kotlin-bridge.jar b/plugins/kotlin-bridge/openidea-kotlin-bridge.jar new file mode 100644 index 0000000000000000000000000000000000000000..8250780f3a2be6c84102a3270bf06aeff984a182 GIT binary patch literal 25814 zcmdsgd0K1#}^+&qG!CL!e^4${qO-rCR=r3>W3q~3@Y+PHu zC7K9_BIO;SNFa8kydxIs>IvFa+m7^hM8ly@vq>OS7e%X3S1?d_P_?R$hax@UU;=%w zO2qn;?UJ2hK&c^Z3trbBiUqqi9u9W)CotfRkwc+aG}0T4B>d%@1BU`_8*7(b;<4)0 znltg2M`Jy8!On1~FCMHr(3PkQ$Ll(yv0zPRxhnLeRhOc=SbroD>P@e2z6i#n{jttq zyrwhSTh|kfVlH((@o2>3*_yT*8@nGaaAPIpXO?p{`oh7({c#_f_eCR^M!fEDZ+K<$ zu{It|tWG_~9p6`b%F9>AqT%3bnMR;5(78WY*Bdw#jMS~|h$mu!&P203HDQT6k93BE zTLZB~s58_Th$L3}R6`p=?fy_CvD#jmT61Y?qy34#eygfkL+XcmF=Rn_Ko(&2=q0Ud zj7P(PM6j#XYM;@;eF~|*agoZTn<$Gj6*V0IHTpoLVPA8g zubwHtEz}bUB>H1PriS#lE1O1j))$K&2zDmW7Av^be5f~Dg`&QOP&in>8bI}UBG3a4 zVw#lxk}1zb2~+R?OK0{3R+>PvU49i1hX8m%53Tcu|Boqm@^!IiIW7`8AVXWZfKzAY- zvlb@4Eg0xxs;L~kjzL>enJd{AjEkJDml=3A9_|eG2@}P2TEJ8}iU`!=r(~U7-9wdB zrPCs&g`=8e@6VuWs==Upf{7h*HSE41WI+&wI=ym{V@)t2>rK6z>VQ$3!7OoIXVII7 z7Sj@)E@7II!d|MeK}+dUrULY56&_()P&t~0tiGK@R%Xs1pfAw!MTD)f+_RtW@<)YL2x7VQ)|x!j<9@(9M) z8MK}@Bqt@@3G|hup|2?vPt+Gs16`rhCh%kmH}b}yM%v8e4uNt4m;}?bwAPJQIUqDq zi%!h|pMqe&HK>)gg8n1H!wI0@hM-W`+9AWL*J&Hm{86;gmqn&{XgdKpJ8U*kU>YNd zU{5e+&@S4I@g|}s#|xesv#gk4a*tqgZ)z=U%x78hoo>1sl1s_vVBAgnurLWRLaY)G zT}c6hI;azUgM!xehr-~la?EtplzJfq!ah}_UWO4gsGA@Si_i}8IvVazK=XClfH zVIB(6L7fgTm0bw5L1Cd11G;!aiU30tF(@i8O7Ox|c4@D#Gbkpnr{VQ>OmI|{D+x5w zFGMBdRkPR{?uR^wK)9Z1<``o#>$nL#RcfhfrJ-v3RAUbvq8kl*qu`-X@BmKlhB46C z<)NGD%{sk_saVmKgY67@3%wOW76w)z47yI^2#c&1?GeyN4SKr}U@<_ik4CyfJ^i3& zNHGuC);?zDl8F`J#z=)DHLPZmHBm6WtV{~eH57es%uvWIS`qw?!r!nPkY z=q~yY$a{Yv-l8}Z>S~Xh?#3X@MH15v>N}QPEvAncbPs(LShL?T`Lkecg19K4`{)6k z?#HB3(gRx1ppVhVp*~^LK@n$x@8DH?#YY#(Du2SD0s17y8;WmHRy9M4!FiUhAw6(s2 zmK7rfW75lzLBrBZE&!VJf7YPS(c=JmC=l)swsvRer7ROq%8FC8%1uu)vEM_d=<^0W zO0+vNqSR_MT3T9z1I3=AXGFsGxamt6PW)g9aKNHh(z6DAg}w>{0fSx# z%z%?X)wZCwC98!(~{Ypi2hjwbxq_w&PWO2{tpoGwAp9 z2P}LjawvKb_UPftrg8T*^`rG?3U8x^T<@gaSg<=R+mS|e*Quoa6#;P)#6VhqqQB_$ z=QKXFtHby^e8ix?(%(S9k>K^}xiJE3F4BoXcQ`r5ImNvWxueRRn++TI=&?9QY%#JB z&EF0B2fd8thhi8tO*M+wvy{Ng1*)f;>^A9wN7@pB&V!J`YX9J-S1=rasXGu%bi@MC z_?Ce!I_WioMnq@{|8I&#`}-OpU2HX)jV-g4C0TXido1uYYcLQv$2L6E0OKX+8k{Er zM=X2Ugo;~78a?CYd=W!|7+$!!01~%D%r-X{LKI+E?B*hTfs4q^lc5FbdO|2Fg*cBu zweN$b=numd7WH`82%!z0v1E@q`?S}LsnsI%`3~$pi-rm8iQ*YTeTAK zR}vc3PcYESbXiuI7M~0J5HM~~m%WX6& zVYq#NEPB0c!wp`?>(Mn-O-K_W6$m8jhNEpi;3pv$cV0f+EoJcV&&RadW_ee3`c~f<^_rop%|$ zTMYF)u;iAYD%xZ4UMUj$YNyygFgN&WgRhb93>4aX1#rc+FzQALI`JUNUczTrbaMxc zmm4b%29H$u3wR&BCckdPFYY$D#};FjI~R5chYUU-&Vk(P`lAUDN*F9(QxWfkr$IsB zh`~|YGHjD#H(!U*)l@_kM+U-XH84>^ENxFHZnA1144aA%!7O8Jp2{lsIMsok_s5QQUX^AU0-wL=lRwQ+Dh2PEZ#sq;KI9vPU6|g(6q}Sxv zjk0jvZoXX&(%RVXh=rtzpWkoro%{iyI1$~kt1U4NahqzRNS+{Bew> zro!B_D*L{|zt_#50Pbo;P<+zhPw_)g4+o;52z)P2{#(C45Ni|r5tf6~8c=Q#$bvL; z#bQ=B9|!2G-TWDFP)$WwPz-edfa6Pbe1b{4`X-%ELV=_Ux|36u z(d-shO&EGN^A`<%hR>VwW8D#u1=B3Ylrx(w+PhBYFOSl$tyl(=Wd)yKG5D+eH4NeU z7#w3vn;ZbUlRKEb>oT#Jf|4C^ZvF<`QI@b*ux(LqqO82vI{bo!ge?F+hR)A3%@dh7 zp1;Av&+)fKOMHiEk-767uZdDWvRN|wvVA7}9Ib+!z)~BxduO<4r;P8% z2LC7j#9XWO`;`kV&8c8DouE@eAiiMm&t%4K=vxI*3jfRC7sbit#uO}-!u(5vzeyi; zGxj@qrZ5r7|67B9z&~{JOAscjx!yX7lz)tE^DZE||vf|sKQCNt7gmSQ4T%k@k z{~7q}J<^z}>*l|La=PIufT{2t|Bu0c=YN=tqDHtrijzCww{lUVy#J@*RA}Z{sOa#=bT(oyRbevWU9jC!YDyv+70Uf8o zyw1C-fBxc259``g+lW>cl&Nvx`_(RjJ4Bl$Xs>rIPYqKD7_rhj#hXcJGU4eOvnckAcb(GYBzEK-Cr|`Elr-LEv z&0%veu|L{XKhChM_PSO9niys5WEsRpoW>>G8a4w3z0m{ki^CuacDXei-xR8X{?5)| zuuF<6LFH1^5P%TFbCuo3a`b35{Hm_i+R{;>DSh+>+8oViXp6N=uy~-oWYwcZn>3Xir~YtpqXOw~$_rmaxQ`LJE>d6Ndep*3qQpl^(5Jv`P>XcyVG zy8@8fHDKm#YSSUg9I_+h>{|N7y03#AjN>rB&!p(MM{Cn|=-PHz=c!SdULaqS;we-N zh>ea@GDF*`?Gn066)}C)$-Y)g6S(QqiO*|_Mi2J)IT_93Uk9V5H57~4S$*O!%`Pw8 z)+ysG$m;{)a2w2p`l37yTbDNHl>U!|Lb28krKMeEXtTBQB3;{y0@z!P5%>(1Y1E3P;T_yzVje0@?c(SXD4HC&`&?8a2iJJg@uZO(KRA}e z_YyhAPL>;Z#({P6xd?ATLo^w`#ckLEXb3=|1y!f1wCXHPZ9h#HoucWfuQTwq45hP9 z(da1dPWmH(82I*EQxXo;83{ZvFu$tcP zFYw~OL3+QV_MP$?yRf@QemFp_CC5^r*IA&4=)ZQQFAVXGw8-SeYUYA!N zrh9W}m$jh&e8&_E2Iw>1g5(rEUe6FccsJz@&|O|nayEqnbhEcGIUA$oQ(j|`KCKXe zsvYw58FS)A1Jvj(vZ~(fpH%XQcM|H|O@$?os&B_=kvIP=oobgxr-$gwX?mjM+#o$A zZ=V{ZFP5A)hbvIjG#-_%)wi2vsOnn_rZxjp%eU%c(Y`!LUz3^1r_jt0J$H!i~ykEzP7I2;Sf_vM`hZLv`V{$)@ZlV<=Tg6t#%*2AE!3@ zCnj(^mA*n=oo*h%pb_4}I#uFH)igogv|J!x^R5)fGoel{k1#Q*{ zA&6$Ys_3+Gf&izFK&Xuk&>o+Uj0<#npIvR*g{xUJ!SE*0qzl1wPFb)zfyz4FZsB_p z6^{?ws%|xzJ@F=3S*y|fi8r$GlRuG}q$*Dc=c`839M}kF#g_hA1r`(iQ&#e?szG{H ziI>SGhwbzO4;4k?kZ(ZTYGLP8*S}*r97#vT`%S0wsvN^xvQXHusRZUw4jr_3M^*GON&pF9+0Bru<0)|@{ z0%?e={kf>eKFBA10CEcgTy!35{ycA8#W>CtP4e}d~(PVG#Gvtxx_1F_0 zukz;AC`p)6E-;lOeb8Ay=Xi_rvG$zbRePSGFj@wvsMhOxkn-_+kXP59YY~NA${T7= zbHh0p5}%+KaCAaAc>ITaavm3}=lkd-7K!;f5gQs)*$?r4d@1FF z`0d3D97>%hcgZ6IAYt>-uF+@Jh@mO>m-1oj`3Cf50lCR4LIjgt^i~wXrWod{$ zMf2-0bY5#h8f@Izz=M{t+wsQ+hH@g&1t?9(*zt7TQ}R?yi0mUsX|<`G`(}% zDmz}2fiU3qJx+At`8L2ak%fo%nsc!GdY|-_D|QawF~}dZi;qdMShscoj7L@P+Cjd@ zF1rt9$|Ay)AJ9xhehg)${2+{29#CIDt$sgj73+5KXHk$sB}hk^Qcnn-!Q#q!oF5tD zM`2`|Hu)f*E@jwu?RaO-_jAA;`l+$uNjHFZlAjplFQECChWOcC15|7Ymai+p@(A`= z`?W!BI4KEkQC3(8vitzH5eKOb(RVATmo`v@n&~>)kEpui6sNCK0twAJ)xZ8zPZT|)=8{q#2NdOE7T zi{7rC#OEO0qMf04XwSg1gCv3ZRNx+g4VY_d@djHYEGJL0OlJri7*a5&U`r*dbd6m_ z=Y68NO{sKh1RJI?p;=XPlmI)Wgxcn@Yum%~r3|TtjNv6m_%CW2p|53`+a8D_%XDnO zCpQ}85r-~hlds;3wh_~f(vTzl1%{M>2&)&LdCQz?JYBpc)a370 z!WlvMQyX>oy*=U$wm`N4qzl#OR8ULHEwT{XnN@VmUQ0Dc8R{f&J2?^^52?}WCi zOZg9=YOCl^7>?-b^EAzz*q<;Y6u3lS{?rQ-;x8DL*Oi)Ug%}}pCwlt;_R1f`*zQ8~ z^@r#(7!u7eN_Ie{iIT$3{8ehkIL)CV0MHuhn}9UIg)wODXDyz!|`Q<@= zrKOZdu&I_ESEyPbDvn|%2_=? zo4vU+FI`nueAT^_UshakA5E?MlYO=}y{ zybuz2oi7t8+J$}o%&D&XM+U2lEAR~Ik(!H%*iQnzpQ2KFh!)bPu_rqY+u&gY{XT@f z&1cn|){qXK^@=G9^Y|3hsIJYF%}=pi#8y!u3<0=R((7r(ns|J$2$q@2YlP}?JnVwi zKnz>y8Tymg^@Qs_`mNWMv*aml&M|sHzCEeU@#k7*rZ!j1Ol`iHnc6}zGqpv^rX18@ zl`6CHIBYnrj_DbC!1^|*Ey3>m7~PS4UhdDs1{+e}+F;AKV-%|e6aZHb`K4O@08KZG z7TQIinB0={e%;c4+8VJ7OeF}F?tX^qOof;1X}wcJp&Jq1wqv3CJmvC<+G<#aTEhw9 zhe56J97yD4oLI27*YuAtB0&C|4n z{(?~Uml2P^IMEiC@RI&29o4ltSdo&FwfOxqHRNv6(Ls*ZiVB*xRl5?O29(Bbl3 z)~?a^p`--wuGKoUF7onrx=#z{<){H1uP6o7&ZtzYHr!k{F4yluoOIN6}owCW8w4IABhDmOsf=qIo4WqIMPZ|hkGCs}qEW!DOBm_!% zN&0#QUwY3cHlj=US%C@@SP$#qGO08&7WQJbuuK09Kx3`2r_VeLMX*=8piQoGe%RJv z5D}&%PKCfiR5|icS7*FlnJtJsLs-}(ta3e*>MMhgiX^?#_&*|~sR+fK00u2G(&?ht zO?btb%Qz8Q1S%B~30Y~DTq{J0lO`vbG+B&dlP1eSlR}H2J%~ePw}2j}L0}1(X%JH$ z2rYR(EuA)Rx$?o{ZeT_n=`!jicT!v^W& z!B`{^_BCz@hJ!tbi(4P2(ggDLrjsek;DICJ7H=&Q{fn+e%)X#rAJ zdp9BOW>nI3EZ7$gbRt-L1tIsypJw@)NHS=p#1W2@zA1FG#-Ph-Em$cYx&ijLM8@f~ z9uhN!sm8FdaCmL3r(YuSl1OpO8U4JChb3%2*j0{1zH*B$OoBL49*+e2;`^hB`d>%Y zIwAaK3Uc>+=Dip`xfjE?NiN60esG2iW{UwQ7{cMrIO&Y+Vhbfwxz(Vp2zZt}x6Noj zen_=NIy%AZU$%E5T5S}AOTRk|96up|#@!u@_L@DvVNAMECbxK3*}Xw_t^x*a+$Y4Lk0WWYI#tzpX6kmU>;aMNOSCX(UaM=>2v+w2+cae|?G;SBdf zugfUNN1F9vgN_Kwl#T4c6szUe8x6Wi4#<&4U?+?#uGk|l-z;g-cY29E9+SASF)nT5K&A&md5X8l7=WR#5?bBkz!$X>?+z3>VO?^lqKr^?LS$2EE6c z1OlErp@O?~dM^mesjt&K5Qv^Ov$pN}NiO%$Z62q&=?cStrFex0f|2ku^HL%8wX*KQaOvt+rTUTR@YLebUg=Tt?NPVv^ zxzs9oepHjq2&$9)yMY|(`;lacNal1XcE2he%Za^l2Z~r;G2uRD(8uXPa7NM=c->A8 zIWhl~PM-wxIfSx(C&NSZY0yeCdwGn&-O>Jq&!lO(WEdhw+8IDncBnHDc0}`AlE5Na zJF;AO;-o>3N+Pz(|F>J3A&gmaly}phL8qm@^GXJ(uEQT`>E85|DV@$v*!|+6$LX9- zPe2$r7t17d`)(%RHv5|y&1&Ez@F_@1glO)H#tz1Da}qS69ZqZ0Oa_d`WL2bIJxyQK z=?h?u3v`-lV8FwB9&3jFB&uC9#k2X}>_PfcEu}hQu+~(e6FPC0 zwX#9qLAp?ST=2qiP?qZF`}9Mdet^^uXFr*XX;nAqM^#O5#Lt1HU!Ev2>kq=Ue*z5 z;=I%;4XlA9gMY!+2tqbvFvDw*v#SRTd-QHOK!EgJ?a8vlpKni{_5cpo}x?ob()qmnZ&&6PTd<}ARin4 zdPA(=);4p9a(Tme<4yzAsM6R}8n;}mmpl+jk-Euy+mHcXLODFcKxQ{Q_qn)NBYZ^c z)vTm~43!3pY(Oltd6t1p?Q%%jMAS-`v2&l($-&tU?2%Hi)^k=NUX7 zscck)#kbs0icOimmrY<>C^j*ZCOorXZoKzn4K;;TWWi)dd={!g((E{OP5Fh>cb=gm zjhz;b3*Br1`NHrOtYr8K7G)D4Sr$V$_Ia)|*oVw;Dng1`y1d;1I1?IfTCEHuXC961 z)OiW^xnn_gs6X-Er1F?>|4Y`fq#KyZOhq!F1k}sFFL`)5`*mKCp&8Q#X}gOIg#00+ z#vMn7oZOfI`BS-B?vk^Iaf!NyChIU7v#hq<_%MktH+U^F>67_w*tOd{=afxSb%TKv zdQ?p&kf*u^&gzgvHWu2l^qy+F6!zOh+M1LE)4-WHGsbDe=*$@eUvAYUKyF@&>#dXx zQ?{8qxdtnDhK?c)pFZT2b{9zSaHgKxd z-u`%^ydzj1DDUg%@H&yc%U4#k{(hnx$;N!413#Wj)2b$tvqLaAiaz%EHBn zZB5S;0&7`X0mhYOdYT0YGte13f(a%OF}xlVPa-Oi`AQ+TXHiW%a+MOi*h;HKex6*-G|T8pC$`8V?{c|c9$%x9;D^RaQ(orWF@=qML-&@YcJVwni|fXrA-)U zqlABXT{Stwv>6eS)Kao-khVWb+a#`~-Jk!&GI!20eSi*m^QY=dB;jbcH}5gpGerAN zQ&7I{AEtwdutjRkM#)!+N-%A|--C+nUe9B6{b>njzwsDNO)q)V-BPm7taFCmO4t}T zoD&GQNS4s)qdHI3r@HSSdFM&wVEOYtUpp*!=^dkjvveB<{=ReG+#$Lh7(PjN-c7}4 z5M4&DA^M2xQM$8sh#nZCPk8g5K7pTeSii4v4kDxpQQ5kDqC}RVMIAfM#qW7IshST5 z{sLM}3z0ukiOA|j$ZDyAZ={+|;``@NjzA&A2>R$J!2d6?0xw|&{s!#7j7$|5-buLr zx5-nQKY|A{h;ao1D0I3NS772IO0bLkM)=iSDGmG@muXQ2n8Jl&)lzGI`w}^8)ig74RQ)ESP)*M7!9zAkm7k;0T zI{DzANeGEHaXd+Jh99N%C|#*=1h1H+nrJ#*t7G?oIS-)+xQfumrs*?6%Q@c!cj6G6 zkLRo$qR$P}=OxzqOT+XPMv|KqZO6AF+HOfRi2OOkb;%`clG4T3ouwaxP=4ai!<(N< za)DQG_ZK{|%#*VWVcnqCsrgGJ{A*aQLv!6nv*hcqrGuBw(4PsQkPCH;bk_j6a+VoW z4Fnk?r45x{L3s7ie4d&=)pP&I8N1^V^x-~9^Gt?7NcF=KQmq(buY^=TLm!c_;~a0k z$w2rz$hj*0>}^uE-)x9vx&xB;W^oee-JC|Dx3I)h%6UUAXNc#TbC7NnyWJrpRo_~@d1rWGvT`K`u6y%` zxaw{yugYY>8gD+*OKNjq9k3Ox_}Zb1g$vBJzX4*6aKxfPK33ouWGQ0dRw5Q|17hLY z5ewIaV}%>>`Buci-Hx&kBNpzHh=qF!Cm}yYfapJ|g>$HtOA!}WgENoixXZN(pF40) zvJWx&A=<$=&`v&zbBPb&de)EPT;YEB1|CAb1V+JkQ1J*D8$pe?N^Bl(aT2zY6a*CL zu%_*zS;9i8BH>xCR4s74Jc3Y|EU$HXQQD)z<)G%`Y*lOsh_)%0805?zK>wg`#p{d4 zcn_Ii)tDriV_6_rHLO~zB$H{(TBgvz^kh~l6>ZF__P|sYT9#V&`^!YIWO=VJGlEcn zH0*1|9x*E{Nm2pXfN&x5oTS<*Dx)V;*DXPPm>gF922hgJpIhPq-K~+d3aGbfpnW{z z+)h23{F)*{Q)zBef+v@>IhQkF!ep^vCROc>$9d8T0EZEY-)1YR3QJ~mReEt$Rcrz` zqoKxIK%)`^mCGXV+i=TN4qt^kpsrQl0>};ts&CyK;sfg2L5^?~d5{DyaX|-}|N7DT zI>^Pn_>3VCInMojJ;MDCZ)%rUtXb_@X|_<;>Ep)MU_@@{F7Lw?M2S^$16H=b z98d@EDb7eU!yGCE2=2(w{eZLMTQxCXa%Pw?uF;iShF9Z$eb%PrjQuwwtW}{m3k7EV3lfMFWiPrrWT>OFHD~e*w=*R@(po literal 0 HcmV?d00001 diff --git a/plugins/kotlin-bridge/plugin.xml b/plugins/kotlin-bridge/plugin.xml new file mode 100644 index 000000000..a0ac23ef7 --- /dev/null +++ b/plugins/kotlin-bridge/plugin.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/plugins/kotlin-bridge/src/ai/openidea/kotlin/KotlinLibraries.java b/plugins/kotlin-bridge/src/ai/openidea/kotlin/KotlinLibraries.java new file mode 100644 index 000000000..8f92e7efd --- /dev/null +++ b/plugins/kotlin-bridge/src/ai/openidea/kotlin/KotlinLibraries.java @@ -0,0 +1,94 @@ +package ai.openidea.kotlin; + +import com.google.gson.Gson; +import java.io.File; +import java.net.URI; +import java.util.*; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.*; +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.jdt.core.*; +import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler; + +/** Owns only marked binary entries; Maven and user entries retain their identities. */ +public final class KotlinLibraries implements IDelegateCommandHandler { + private static final String OWNER = "ai.openidea.kotlin"; + private static final Object LOCK = new Object(); + private static List desired = List.of(); + private static boolean listening; + private static final Job reconcileJob = new Job("Kotlin binary libraries") { + @Override protected IStatus run(IProgressMonitor monitor) { + synchronized (LOCK) { + try { + reconcile(monitor); + return Status.OK_STATUS; + } catch (CoreException e) { + return e.getStatus(); + } + } + } + }; + private static class Library { String path; boolean test; } + private static class ProjectLibraries { String projectUri; List libraries; } + + @Override public Object executeCommand(String command, List arguments, IProgressMonitor monitor) throws Exception { + if (!"openidea.kotlin.replace".equals(command) || arguments.size() != 1) + throw new IllegalArgumentException("Expected one Kotlin library snapshot"); + Gson gson = new Gson(); + ProjectLibraries[] snapshot = gson.fromJson(gson.toJson(arguments.get(0)), ProjectLibraries[].class); + for (ProjectLibraries project : snapshot) { + new File(URI.create(project.projectUri)); + for (Library library : project.libraries) { + if (!new File(library.path).isAbsolute() || !new File(library.path).isDirectory()) + throw new IllegalArgumentException("Kotlin library must be a published directory: " + library.path); + } + } + synchronized (LOCK) { + if (monitor.isCanceled()) throw new OperationCanceledException(); + desired = List.of(snapshot); + if (!listening) { + JavaCore.addElementChangedListener(event -> reconcileJob.schedule(200), ElementChangedEvent.POST_CHANGE); + listening = true; + } + return reconcile(monitor); + } + } + + private static File canonical(URI uri) { + File file = new File(uri); + try { return file.getCanonicalFile(); } + catch (java.io.IOException e) { return file.getAbsoluteFile(); } + } + + private static int reconcile(IProgressMonitor monitor) throws CoreException { + int changed = 0; + for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) { + if (!project.getProject().isAccessible()) continue; + List entries = new ArrayList<>(); + IClasspathEntry[] original = project.getRawClasspath(); + for (IClasspathEntry entry : original) { + boolean owned = entry.getEntryKind() == IClasspathEntry.CPE_LIBRARY && + Arrays.stream(entry.getExtraAttributes()).anyMatch(a -> OWNER.equals(a.getName()) && "true".equals(a.getValue())); + if (!owned) entries.add(entry); + } + URI location = project.getProject().getLocationURI(); + for (ProjectLibraries item : desired) { + if (location == null || !canonical(location).equals(canonical(URI.create(item.projectUri)))) continue; + for (Library library : item.libraries) { + List attributes = new ArrayList<>(); + attributes.add(JavaCore.newClasspathAttribute(OWNER, "true")); + if (library.test) attributes.add(JavaCore.newClasspathAttribute(IClasspathAttribute.TEST, "true")); + entries.add(JavaCore.newLibraryEntry(Path.fromOSString(library.path), null, null, + new IAccessRule[0], attributes.toArray(IClasspathAttribute[]::new), false)); + } + } + IClasspathEntry[] updated = entries.toArray(IClasspathEntry[]::new); + if (!Arrays.equals(original, updated)) { + if (monitor.isCanceled()) throw new OperationCanceledException(); + project.setRawClasspath(updated, monitor); + changed++; + } + } + return changed; + } +} diff --git a/plugins/kotlin-bridge/tests/verify.py b/plugins/kotlin-bridge/tests/verify.py new file mode 100644 index 000000000..3d6c89862 --- /dev/null +++ b/plugins/kotlin-bridge/tests/verify.py @@ -0,0 +1,275 @@ +"""Real Maven + JDT LS regression: python3 verify.py JDT_HOME (Java 25; Maven uses JAVA_HOME).""" +import json +import os +import pathlib +import platform +import queue +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import xml.etree.ElementTree as ET + +home = pathlib.Path(sys.argv[1]).resolve() +bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-kotlin-bridge.jar' +work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-kotlin-bridge-', dir=os.environ.get('TMPDIR'))).resolve() +copied_bundle = work/'openidea-kotlin-bridge.jar' +shutil.copyfile(bundle, copied_bundle) +bundle = copied_bundle +projects = work / 'projects' +single = projects / 'single' +multi = projects / 'multi' +lib = multi / 'channel-lib' +app = multi / 'app' +for directory in [single, lib, app]: + (directory / 'src/main/java/probe').mkdir(parents=True) +plugin = '''org.jetbrains.kotlinkotlin-maven-plugin2.0.10kotlin-compileprocess-sourcescompile1.8src/main/javaorg.apache.maven.pluginsmaven-compiler-plugin3.13.0''' +plugin = plugin.replace('', 'kotlin-testprocess-test-sourcestest-compile1.8src/test/java') +stdlib = 'org.jetbrains.kotlinkotlin-stdlib2.0.10' +properties = '88' +prefix = '4.0.0' +(single / 'pom.xml').write_text(prefix + 'probesingle1' + properties + stdlib + plugin + '') +(multi / 'pom.xml').write_text(prefix + 'probereactor1pom' + properties + 'channel-libapp') +parent = 'probereactor1' +(lib / 'pom.xml').write_text(prefix + parent + 'channel-lib' + stdlib + plugin + '') +(app / 'pom.xml').write_text(prefix + parent + 'appprobechannel-lib1') +source = 'package probe;\npublic class Caller {\n public Channel channel() {\n return Channel.SMS;\n }\n}\n' +for directory in [single, app]: + (directory / 'src/main/java/probe/Caller.java').write_text(source) +for directory in [single, lib]: + (directory / 'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n') + +(single/'src/test/java/probe').mkdir(parents=True) +test_source = source.replace('Caller', 'TestCaller').replace('Channel', 'TestChannel') +(single/'src/test/java/probe/TestCaller.java').write_text(test_source) +(single/'src/test/java/probe/TestChannel.kt').write_text('package probe\nenum class TestChannel { SMS }\n') + +config = work / 'config' +config.mkdir() +base = {'Darwin': 'config_mac', 'Windows': 'config_win'}.get(platform.system(), 'config_linux') +names = [base + '_arm', base] if platform.machine().lower() in ('arm64', 'aarch64') else [base] +shutil.copyfile(next(home / n / 'config.ini' for n in names if (home/n/'config.ini').exists()), config/'config.ini') +proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home/'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work/'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=(work/'stderr.log').open('w')) +messages = queue.Queue() +lock = threading.Lock() +transcript = [] +seq = 0 +def send(message): + data = json.dumps({'jsonrpc': '2.0', **message}).encode() + with lock: + proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data) + proc.stdin.flush() +def read(): + while True: + headers = {} + while True: + line = proc.stdout.readline() + if not line: return + if line == b'\r\n': break + key, value = line.decode().split(':', 1) + headers[key.lower()] = value.strip() + message = json.loads(proc.stdout.read(int(headers['content-length']))) + if 'method' in message and 'id' in message: + send({'id':message['id'], 'result': [] if message['method']=='workspace/configuration' else None}) + else: + messages.put(message) +threading.Thread(target=read, daemon=True).start() +def request(method, params): + global seq + seq += 1 + send({'id':seq, 'method':method, 'params':params}) + deadline = time.monotonic() + 240 + while time.monotonic() < deadline: + message = messages.get(timeout=max(1, deadline-time.monotonic())) + transcript.append(message) + if message.get('id') == seq: + if 'error' in message: raise RuntimeError(message['error']) + return message.get('result') + raise TimeoutError(method) +def command(snapshot): + response = request('workspace/executeCommand', {'command':'openidea.kotlin.replace', 'arguments':[snapshot]}) + assert isinstance(response, (int, float)), response + return response +def build(directory, revision, expected=True): + staging = work / revision + result = subprocess.run(['mvn', '-B', '-fae', '-Dstyle.color=never', f'-Dmaven.ext.class.path={bundle}', f'-Dopenidea.kotlin.staging={staging}/staging', 'test-compile'], cwd=directory, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + (work/f'{revision}.log').write_bytes(result.stdout) + modules = json.loads((staging/'modules.json').read_text()) + eligible = all(m['kotlinGoals'] and all(g['status'] == 'success' for g in m['kotlinGoals']) for m in modules if m['hasKotlin']) + assert eligible == expected, (modules, result.stdout.decode()) + evidence[revision + '-build'] = {'exitCode':result.returncode, 'modules':modules, 'eligible':eligible} + return modules +def library(module, revision, test=False, name="Channel"): + directory = work/'published'/revision + directory.mkdir(parents=True) + output = module['testOutput' if test else 'output'] + for file in pathlib.Path(output).rglob('TestChannel*.class' if test else name+'*.class'): + destination = directory/file.relative_to(output) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(file, destination) + assert list(directory.rglob('*.class')), module + return {'path':str(directory), 'test':test} +def state(directory, label, expected, test=False): + file = directory/('src/test/java/probe/TestCaller.java' if test else 'src/main/java/probe/Caller.java') + deadline = time.monotonic() + 30 + while True: + time.sleep(0.25) + request('workspace/symbol', {'query':'Caller'}) + reports = [m['params'] for m in transcript if m.get('method')=='textDocument/publishDiagnostics' and m['params']['uri']==file.as_uri()] + errors = [d for d in reports[-1]['diagnostics'] if d.get('severity')==1] if reports else None + if errors is not None and ((errors == []) == expected): break + if time.monotonic() > deadline: raise AssertionError((label, errors)) + completion = request('textDocument/completion', {'textDocument':{'uri':file.as_uri()}, 'position':{'line':3,'character':27 if test else 23}}) + items = completion.get('items', []) if isinstance(completion, dict) else completion or [] + labels = [item['label'] for item in items] + definition = request('textDocument/definition', {'textDocument':{'uri':file.as_uri()}, 'position':{'line':2,'character':12}}) + result = {'errors':errors, 'completion':labels, 'definition':definition} + evidence[label] = result + assert errors is not None, (label, "no published diagnostics") + assert (errors == []) == expected, (label, errors) + assert any(label.startswith('SMS') for label in labels) == expected, (label, labels) + assert bool(definition) == expected, (label, definition) + if expected: + location = definition[0] + assert location.get('uri', location.get('targetUri', '')).startswith('jdt:'), definition + return result + +evidence = {'work':str(work)} +try: + init = request('initialize', {'processId':None, 'rootUri':projects.as_uri(), 'workspaceFolders':[{'uri':projects.as_uri(), 'name':'projects'}], 'capabilities':{'textDocument':{'completion':{'completionItem':{'snippetSupport':True}}, 'definition':{'linkSupport':True}}}, 'initializationOptions':{'bundles':[str(bundle)], 'extendedClientCapabilities':{'classFileContentsSupport':True}, 'settings':{'java':{'import':{'gradle':{'enabled':False}, 'maven':{'enabled':True}}, 'configuration':{'updateBuildConfiguration':'automatic'}}}}}) + send({'method':'initialized', 'params':{}}) + request('java/buildWorkspace', True) + for directory, test in [(single,False), (app,False), (single,True)]: + file = directory/('src/test/java/probe/TestCaller.java' if test else 'src/main/java/probe/Caller.java') + send({'method':'textDocument/didOpen','params':{'textDocument':{'uri':file.as_uri(),'languageId':'java','version':1,'text':test_source if test else source}}}) + # Allow reconcile jobs to publish their first marker batch. + time.sleep(2) + state(single, 'single-before', False) + state(app, 'multi-before', False) + state(single, 'test-before', False, True) + def raw(directory): + entries = ET.parse(directory/'.classpath').getroot() + return [ET.tostring(e).decode().strip() for e in entries if not any(a.get('name')=='ai.openidea.kotlin' for a in e.findall('./attributes/attribute'))] + before = {str(d):raw(d) for d in [single, lib, app]} + sm = build(single, 'single-build')[0] + assert not (pathlib.Path(sm['output'])/'probe/TestCaller.class').exists() + assert (pathlib.Path(sm['testOutput'])/'probe/TestCaller.class').is_file() + mm = build(multi, 'multi-build') + lm = next(m for m in mm if 'channel-lib' in m['key']) + sl, ml = library(sm, 'single'), library(lm, 'multi') + snapshot = [{'projectUri':single.as_uri(),'libraries':[sl, library(sm, 'single-test', True)]}, {'projectUri':lib.as_uri(),'libraries':[ml]}, {'projectUri':app.as_uri(),'libraries':[ml]}] + command(snapshot) + time.sleep(2) + state(single, 'single-after', True) + state(app, 'multi-after', True) + state(single, 'test-after', True, True) + assert all(raw(d) == before[str(d)] for d in [single,lib,app]), 'changed unowned entries' + # Simulate m2e removing the extra entries during a configuration update. + send({'method':'java/projectConfigurationsUpdate','params':{'identifiers':[{'uri':(d/'pom.xml').as_uri()} for d in [single,lib,app]]}}) + time.sleep(3) + state(single, 'single-reimport', True) + state(app, 'multi-reimport', True) + assert all(raw(d) == before[str(d)] for d in [single,lib,app]), 'reimport changed unowned entries' + # A saved API change must replace the immutable binary directory. + (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS, EMAIL }\n') + state(single, 'single-during-rebuild', True) + changed = build(single, 'single-changed')[0] + snapshot[0]['libraries'] = [library(changed, 'single-changed'), library(changed, 'single-test-changed', True)] + command(snapshot) + time.sleep(2) + changed_state = state(single, 'single-new-enum-member', True) + assert any(label.startswith('EMAIL') for label in changed_state['completion']) + # Failed Kotlin compilation keeps both main and test APIs attached. + (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { ??? }\n') + broken = build(single, 'single-broken', expected=False)[0] + assert any(g['status'] == 'failed' for g in broken['kotlinGoals']) + state(single, 'single-broken-retains-api', True) + state(single, 'single-broken-retains-test-api', True, True) + # A javac failure skips test Kotlin targets, so this whole snapshot is retained. + (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Delivery { SMS }\n') + skipped = build(single, 'single-skipped-test', expected=False)[0] + assert any(g['execution'].endswith(':test-compile') and g['status'] == 'skipped' for g in skipped['kotlinGoals']) + assert list(pathlib.Path(skipped['output']).rglob('Delivery.class')) + state(single, 'single-skipped-test-retains-api', True) + # The reactor fixture has no test Kotlin goal. Publish despite downstream javac failure. + kt = lib/'src/main/java/probe/Channel.kt' + kt.write_text('package probe\nenum class Delivery { SMS }\n') + renamed = build(multi, 'multi-renamed') + renamed_lib = next(m for m in renamed if 'channel-lib' in m['key']) + new_library = library(renamed_lib, 'multi-renamed', name='Delivery') + assert not list(pathlib.Path(renamed_lib['output']).rglob('Channel.class')) + snapshot[1]['libraries'] = snapshot[2]['libraries'] = [new_library] + command(snapshot) + state(app, 'multi-renamed', False) + kt.unlink() + snapshot[1]['libraries'] = snapshot[2]['libraries'] = [] + command(snapshot) + state(app, 'multi-deleted', False) + kt.write_text('package probe\nenum class Channel { SMS }\n') + restored = next(m for m in build(multi, 'multi-restored') if 'channel-lib' in m['key']) + restored_library = library(restored, 'multi-restored') + snapshot[1]['libraries'] = snapshot[2]['libraries'] = [restored_library] + command(snapshot) + state(app, 'multi-restored', True) + (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n') + repaired = build(single, 'single-repaired')[0] + snapshot[0]['libraries'] = [library(repaired, 'single-repaired'), library(repaired, 'single-test-repaired', True)] + command(snapshot) + time.sleep(2) + state(single, 'single-repaired', True) + # No old API after explicit invalidation, and recovery is repeatable. + command([]) + time.sleep(2) + state(single, 'single-withdrawn', False) + state(app, 'multi-withdrawn', False) + state(single, 'test-withdrawn', False, True) + command(snapshot) + time.sleep(2) + state(single, 'single-recovered', True) + state(app, 'multi-recovered', True) + # Main sources must not gain visibility of test-only Kotlin libraries. + leak = single/'src/main/java/probe/MainLeak.java' + leak_source = test_source.replace('TestCaller', 'MainLeak') + leak.write_text(leak_source) + send({'method':'textDocument/didOpen','params':{'textDocument':{'uri':leak.as_uri(),'languageId':'java','version':1,'text':leak_source}}}) + deadline = time.monotonic() + 30 + while True: + time.sleep(0.25) + request('workspace/symbol', {'query':'MainLeak'}) + reports = [m['params'] for m in transcript if m.get('method')=='textDocument/publishDiagnostics' and m['params']['uri']==leak.as_uri()] + if reports: break + assert time.monotonic() < deadline, 'no diagnostics for main/test isolation' + evidence['main-test-isolation'] = reports[-1]['diagnostics'] + assert any(d.get('severity') == 1 for d in reports[-1]['diagnostics']), reports[-1] + # Compiler and JDT outputs must not share Kotlin classes. + assert not list(single.glob('target/classes/**/Channel.class')) + assert not list(lib.glob('target/classes/**/Channel.class')) + # -fae runs independent Kotlin modules and records upstream-skipped targets. + fail_reactor = work/'fail-at-end' + fail_reactor.mkdir() + (fail_reactor/'pom.xml').write_text(prefix + 'probefailure-reactor1pom' + properties + 'broken-javaindependentdownstream') + failure_parent = 'probefailure-reactor1' + for name in ['broken-java', 'independent', 'downstream']: + module = fail_reactor/name + (module/'src/main/java/probe').mkdir(parents=True) + dependency = 'probebroken-java1org.jetbrains.kotlinkotlin-stdlib2.0.10' if name == 'downstream' else stdlib + (module/'pom.xml').write_text(prefix + failure_parent + '' + name + '' + dependency + plugin + '') + if name == 'broken-java': + (module/'src/main/java/probe/Broken.java').write_text('package probe; public class Broken { Missing value; }') + else: + (module/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n') + statuses = build(fail_reactor, 'fail-at-end-statuses', expected=False) + independent = next(m for m in statuses if ':independent:' in m['key']) + downstream = next(m for m in statuses if ':downstream:' in m['key']) + assert all(g['status'] == 'success' for g in independent['kotlinGoals']), independent + assert all(g['status'] == 'skipped' for g in downstream['kotlinGoals']), downstream + assert list(pathlib.Path(independent['output']).rglob('Channel.class')) + print(json.dumps(evidence, indent=2)) +finally: + (work/'evidence.json').write_text(json.dumps(evidence, indent=2)) + (work/'transcript.json').write_text(json.dumps(transcript, indent=2)) + proc.terminate() + proc.wait(timeout=20) + print('Evidence:', work, file=sys.stderr) diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 8204ccd51..7380fa336 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,9 @@ { + "Kotlin bridge requires a Maven build root": "Kotlin 桥接需要 Maven 构建根目录", + "Kotlin bridge bundle is unavailable": "Kotlin 桥接扩展不可用", + "Kotlin bridge command did not acknowledge its library snapshot": "Kotlin 桥接命令未确认类库更新", + "Maven compilation failed": "Maven 编译失败", + "Kotlin editor compilation failed": "Kotlin 编辑器编译失败", "Updating results…": "正在更新结果…", "Uploading files": "正在上传文件", "No files on the clipboard": "剪贴板中没有文件", diff --git a/src/workbench/notificationStore.ts b/src/workbench/notificationStore.ts index f67a384f5..622079e90 100644 --- a/src/workbench/notificationStore.ts +++ b/src/workbench/notificationStore.ts @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { create } from "zustand"; import { call, commands, onIdeEvent } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; import { useProject } from "@/platform/project"; import type { NotificationService } from "./contributions"; @@ -189,5 +190,8 @@ export const notificationServiceImpl: NotificationService = { }; export function installNotifyEvents(): () => void { - return onIdeEvent("notify", (e) => void notify({ level: e.level, title: e.title, body: e.body ?? undefined })); + return onIdeEvent( + "notify", + (e) => void notify({ level: e.level, title: tx(e.title), body: e.body ? tx(e.body) : undefined }), + ); } From ec5193f827d5a91ff78daf54f6a142aa19c8b499 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:54:31 +0800 Subject: [PATCH 13/21] feat(format): external formatting with managed Prettier (#671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reformat Code only asked the language server for formatting, so files whose server has no formatting capability (Markdown with marksman, and others depending on the server) could not be formatted. Languages can now declare an external formatter. Prettier is the first: a managed tool installed on demand, or the project's own node_modules/prettier, an explicit path, or PATH. It runs on the backend copy of the document with the file's path so Prettier resolves the project's config, overrides and ignore files, and its result is applied as one undoable edit. Language servers that format a file keep doing so unless Prettier is preferred in Settings › Languages › Prettier; formatting on save is opt-in. Parsers without range support format the whole file and say so. Errors carry structured tool data, so a missing Prettier reuses the shared install prompt and Prettier diagnostics or timeouts show as non-sticky notifications. Fixes #639 --- crates/ide-core/src/error.rs | 53 +++++- crates/ide-core/src/settings.rs | 41 +++++ crates/ide-dap/src/tests.rs | 1 + crates/ide-host/src/formatter.rs | 107 +++++++++++ crates/ide-host/src/lib.rs | 1 + crates/ide-host/src/ops/lsp.rs | 2 +- crates/ide-language/src/lib.rs | 13 ++ crates/ide-lsp/src/lib.rs | 6 + crates/ide-lsp/src/types.rs | 3 + crates/ide-lsp/tests/mock_server.rs | 39 +++- crates/ide-lsp/tests/real_servers.rs | 1 + crates/ide-tools/src/formatter.rs | 174 ++++++++++++++++++ crates/ide-tools/src/lib.rs | 4 + crates/ide-tools/src/process.rs | 117 ++++++++++++ crates/ide-tools/src/registry.rs | 19 +- crates/ide-tools/tests/prettier.rs | 91 +++++++++ languages/css.toml | 1 + languages/graphql.toml | 1 + languages/html.toml | 1 + languages/javascript.toml | 1 + languages/javascriptreact.toml | 1 + languages/json.toml | 1 + languages/jsonc.toml | 1 + languages/less.toml | 1 + languages/markdown.toml | 1 + languages/scss.toml | 1 + languages/typescript.toml | 1 + languages/typescriptreact.toml | 1 + languages/vue.toml | 1 + languages/yaml.toml | 1 + src/bindings.ts | 19 +- src/editor/lsp/actions.ts | 12 +- src/editor/lsp/format.module.css | 3 + src/editor/lsp/format.test.ts | 35 +++- src/editor/lsp/format.ts | 39 +++- src/editor/lsp/formatErrors.test.tsx | 124 +++++++++++++ .../settings/__tests__/languages.test.ts | 11 ++ .../settings/languages/PrettierPage.tsx | 72 ++++++++ src/features/settings/pages/languages.tsx | 11 ++ src/features/tools/LanguageServersPage.tsx | 1 + src/features/tools/__tests__/tools.test.tsx | 30 ++- src/features/tools/index.ts | 12 +- src/features/tools/missing.ts | 13 +- src/platform/ipc.ts | 2 + src/platform/languageSettings.ts | 31 ++++ src/platform/locales/text/zh.json | 22 ++- src/platform/locales/textCatalog.test.ts | 2 + src/workbench/Workbench.test.tsx | 11 ++ src/workbench/notificationStore.ts | 4 +- 49 files changed, 1111 insertions(+), 29 deletions(-) create mode 100644 crates/ide-host/src/formatter.rs create mode 100644 crates/ide-tools/src/formatter.rs create mode 100644 crates/ide-tools/tests/prettier.rs create mode 100644 src/editor/lsp/format.module.css create mode 100644 src/editor/lsp/formatErrors.test.tsx create mode 100644 src/features/settings/languages/PrettierPage.tsx diff --git a/crates/ide-core/src/error.rs b/crates/ide-core/src/error.rs index 7df81c9a5..f78cf37f7 100644 --- a/crates/ide-core/src/error.rs +++ b/crates/ide-core/src/error.rs @@ -12,6 +12,15 @@ pub type IdeResult = Result; pub struct IdeError { pub kind: ErrorKind, pub message: String, + /// Structured detail for errors the UI acts on; `null` for plain errors. + #[specta(optional)] + pub data: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ErrorData { + Tool { tool: String, missing: bool }, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -24,6 +33,7 @@ pub enum ErrorKind { /// Document version mismatch; the UI must resync the full text. OutOfSync, Cancelled, + TimedOut, /// A required external tool (language server, git, debugger) is unavailable. Unavailable, Io, @@ -33,7 +43,21 @@ pub enum ErrorKind { impl IdeError { pub fn new(kind: ErrorKind, message: impl Into) -> Self { - Self { kind, message: message.into() } + Self { kind, message: message.into(), data: None } + } + pub fn with_tool(mut self, tool: impl Into) -> Self { + if self.data.is_none() { + self.data = Some(ErrorData::Tool { tool: tool.into(), missing: false }); + } + self + } + pub fn missing_tool(tool: impl Into) -> Self { + let tool = tool.into(); + Self { + kind: ErrorKind::NotFound, + message: format!("{tool} is not installed"), + data: Some(ErrorData::Tool { tool, missing: true }), + } } pub fn not_found(m: impl Into) -> Self { Self::new(ErrorKind::NotFound, m) @@ -65,3 +89,30 @@ impl From for IdeError { Self::new(kind, e.to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_errors_round_trip_without_changing_diagnostics() { + let message = "[error] file.css: Unexpected token\n 1 | }\n"; + let error = IdeError::invalid(message).with_tool("prettier"); + let decoded: IdeError = serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap(); + assert_eq!(decoded.message, message); + assert_eq!(decoded.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false })); + let missing = IdeError::missing_tool("prettier").with_tool("prettier"); + assert_eq!(missing.kind, ErrorKind::NotFound); + assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true })); + } + + #[test] + fn legacy_errors_need_no_data() { + let error: IdeError = serde_json::from_str(r#"{"kind":"unavailable","message":"no server"}"#).unwrap(); + assert!(error.data.is_none()); + assert_eq!( + serde_json::to_value(error).unwrap(), + serde_json::json!({"kind":"unavailable","message":"no server","data":null}) + ); + } +} diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 1b49cd813..7f360b1f1 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -508,9 +508,50 @@ pub struct LanguagesSettings { pub rust: RustSettings, pub go: GoSettings, pub typescript: TypeScriptSettings, + pub prettier: PrettierSettings, pub java: JavaSettings, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct PrettierSettings { + /// Empty path: project node_modules, managed installation, then PATH. + pub path: String, + pub languages: Vec, + /// Prefer Prettier even when a language server offers formatting. + pub prefer: bool, + pub run_on_save: bool, +} + +impl Default for PrettierSettings { + fn default() -> Self { + Self { + path: String::new(), + languages: [ + "markdown", + "yaml", + "css", + "scss", + "less", + "html", + "graphql", + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "json", + "jsonc", + "vue", + ] + .into_iter() + .map(String::from) + .collect(), + prefer: false, + run_on_save: false, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct PythonSettings { diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 6bcd57b65..e1fb0ecae 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -905,6 +905,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc, language: &str, settings: &PrettierSettings, lsp: bool) -> bool { + config.is_some_and(|c| c.tool == "prettier") + && settings.languages.iter().any(|l| l == language) + && (settings.prefer || !lsp) +} + +pub async fn format( + host: &Host, + window: &str, + doc: DocId, + range: Option, + options: FormatOptions, +) -> IdeResult> { + let project = host.project(window)?; + let snap = project.documents.snapshot(doc)?; + let settings = project.settings.effective(); + let prettier = &settings.languages.prettier; + let language = project.languages.get(&snap.language); + let config = language.as_ref().and_then(|l| l.formatter.as_ref()); + let candidate = + config.is_some_and(|c| c.tool == "prettier") && prettier.languages.iter().any(|l| l == snap.language.as_str()); + let lsp = if candidate && !prettier.prefer { project.lsp.can_format(doc).await? } else { false }; + if !external_selected(config, snap.language.as_str(), prettier, lsp) { + if options.on_save && !settings.editor.format_on_save { + return Ok(Vec::new()); + } + return project.lsp.format(doc, range, options).await; + } + if options.on_save && !prettier.run_on_save { + return Ok(Vec::new()); + } + host.require_trust(window)?; + if snap.read_only { + 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 tools = Tools::new(&host.data_dir); + let whole_selection = range.is_some() && !config.range; + 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())?; + Ok(text + .diff(&formatted) + .changes + .into_iter() + .map(|c| TextEdit { + range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)), + new_text: c.insert, + }) + .collect()) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))?; + 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(), + ), + }); + } + edits.map_err(|e| e.with_tool("prettier")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn language_enablement_and_preference_preserve_lsp_formatters() { + let languages = ide_language::LanguageRegistry::load(None).unwrap(); + let mut settings = PrettierSettings::default(); + for lang in languages.all() { + for lsp in [false, true] { + let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier"); + assert_eq!( + external_selected(lang.formatter.as_ref(), lang.id.as_str(), &settings, lsp), + configured && !lsp, + "{}", + lang.id + ); + } + } + let ts = languages.get(&ide_core::LanguageId::new("typescript")).unwrap(); + settings.prefer = true; + assert!(external_selected(ts.formatter.as_ref(), "typescript", &settings, true)); + settings.languages.clear(); + assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false)); + assert!(!external_selected(None, "rust", &settings, false)); + } +} diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 0d310d471..5937b9725 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -4,6 +4,7 @@ //! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]). pub mod environment; +mod formatter; mod java_runtime; mod kotlin_bridge; pub mod notebook; diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index f729f7989..b57f54efd 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -214,7 +214,7 @@ pub async fn lsp_format( range: Option, options: FormatOptions, ) -> IdeResult> { - host.project(window)?.lsp.format(doc, range, options).await + crate::formatter::format(host, window, doc, range, options).await } pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult> { diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index fc935338f..a42587cc8 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -53,6 +53,19 @@ pub struct LanguageConfig { pub language_servers: Vec, #[serde(default)] pub debugger: Option, + /// External formatter used when no language server can format, or selected in settings. + #[serde(default)] + pub formatter: Option, +} + +/// An external formatting tool and its language-specific parser. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct FormatterConfig { + pub tool: String, + pub parser: String, + #[serde(default)] + pub range: bool, } /// A CodeMirror mode for the files of a framework's projects. diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 7091c69a3..521451845 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -1886,6 +1886,12 @@ impl LspManager { } } + /// Checks initialized capabilities, including dynamic registrations, before choosing a formatter. + pub async fn can_format(&self, doc: DocId) -> IdeResult { + let snap = self.snapshot(doc)?; + Ok(!self.ready_targets(&snap.servers, |c| c.has("documentFormattingProvider")).await.is_empty()) + } + pub async fn format(&self, doc: DocId, range: Option, options: FormatOptions) -> IdeResult> { let (method, cap, feature) = match range { None => ("textDocument/formatting", "documentFormattingProvider", "formatting"), diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index beaee53d1..050d493d1 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -219,6 +219,9 @@ pub struct CodeActionQuery { pub struct FormatOptions { pub tab_size: u32, pub insert_spaces: bool, + #[serde(default)] + #[specta(optional)] + pub on_save: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index aa15d3ede..a636e5e4d 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -252,6 +252,7 @@ fn harness_configured( indent: None, ui_mode: None, framework_ui_modes: Vec::new(), + formatter: None, tree_sitter: None, language_servers: servers .iter() @@ -1248,12 +1249,17 @@ async fn navigation_rename_format_and_decorations() { assert!(matches!(&edit.files[0], FileEdit::Edit { edits, .. } if edits.len() == 2)); assert_eq!(s.requested("textDocument/rename")[0]["newName"], "bar"); + assert!(h.mgr.can_format(doc).await.unwrap()); s.respond("textDocument/formatting", json!([{ "range": r(0,10,0,11), "newText": "\n}" }])); - let edits = h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true }).await.unwrap(); + let edits = + h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }).await.unwrap(); assert_eq!(edits[0].range, rng(0, 10, 0, 11)); assert_eq!(s.requested("textDocument/formatting")[0]["options"], json!({ "tabSize": 2, "insertSpaces": true })); assert!( - h.mgr.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true }).await.is_err() + h.mgr + .format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }) + .await + .is_err() ); s.respond("textDocument/semanticTokens/full", json!({ "data": [0, 3, 3, 0, 1, 1, 0, 3, 0, 0] })); @@ -2737,3 +2743,32 @@ async fn interactive_capability_is_scoped_to_gopls() { assert!(form.is_none()); assert!(s.requested("command/resolve").is_empty()); } + +#[tokio::test(flavor = "multi_thread")] +async fn formatting_provider_selection_tracks_dynamic_registration() { + let h = harness(&[("mock", json!({ "textDocumentSync": 1, "documentRangeFormattingProvider": true }))]); + let (doc, _) = h.open("a.mk", "hello\n"); + h.wait_status("mock", ServerStatus::Ready).await; + assert!(!h.mgr.can_format(doc).await.unwrap(), "a range provider alone cannot format the whole document"); + let peer = h.launcher.state("mock").peer(); + peer.request::<_, Value>( + "client/registerCapability", + json!({"registrations": [ + {"id": "formatter", "method": "textDocument/formatting"} + ]}), + None, + ) + .await + .unwrap(); + assert!(h.mgr.can_format(doc).await.unwrap()); + peer.request::<_, Value>( + "client/unregisterCapability", + json!({"unregisterations": [ + {"id": "formatter", "method": "textDocument/formatting"} + ]}), + None, + ) + .await + .unwrap(); + assert!(!h.mgr.can_format(doc).await.unwrap()); +} diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 13ab9667f..43c250a20 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -55,6 +55,7 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig { indent: None, ui_mode: None, framework_ui_modes: Vec::new(), + formatter: None, tree_sitter: None, language_servers: vec![server], debugger: None, diff --git a/crates/ide-tools/src/formatter.rs b/crates/ide-tools/src/formatter.rs new file mode 100644 index 000000000..9b599f0bb --- /dev/null +++ b/crates/ide-tools/src/formatter.rs @@ -0,0 +1,174 @@ +//! External formatting: resolve a tool, pass unsaved text on stdin, return text without writing files. + +use crate::Tools; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use ide_language::FormatterConfig; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +/// Walk from the file's directory upward, including workspace parents (monorepos). +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 { + let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?) + .map_err(|e| IdeError::invalid(format!("Prettier: {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"))?; + let path = package.join(bin); + if !path.is_file() { + return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display()))); + } + Ok(path) +} + +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 { + self.resolve_formatter_command(tool, file, custom).map_err(|e| e.with_tool(tool)) + } + + fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult { + if tool != "prettier" { + return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}"))); + } + let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) { + (package_bin(custom)?, 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 { + (self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false) + }; + if node { + let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?; + let mut cmd = self.command(node); + cmd.arg(path); + Ok(cmd) + } else { + Ok(self.command(path)) + } + } +} + +/// Project configuration overrides these defaults; Prettier resolves config and plugins itself. +pub fn arguments( + 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(); + args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]); + if config.range + && let Some((start, end)) = range + { + args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]); + } + // CLI defaults only find ignores in cwd; explicitly pass the nearest ancestor's files. + for name in [".prettierignore", ".gitignore"] { + if let Some(path) = find_up(file, Path::new(name)) { + args.extend(["--ignore-path".into(), path.into_os_string()]); + } + } + 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")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_formatter_is_structured_but_invalid_custom_path_is_not_an_install_prompt() { + use ide_core::error::{ErrorData, ErrorKind}; + let dir = tempfile::tempdir().unwrap(); + let tools = Tools::with_paths(dir.path().join("tools"), OsString::new(), crate::platform::Platform::current()); + let file = dir.path().join("file.md"); + let missing = tools.formatter_command("prettier", &file, None).unwrap_err(); + 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(); + assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false })); + } + + #[cfg(unix)] + #[test] + 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(); + assert_eq!(failure.message, "error\n".repeat(1000)); + assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false })); + } + + #[test] + fn discovers_nearest_package_and_ignore_above_nested_file() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("packages/web/src")).unwrap(); + std::fs::write(root.join(".prettierignore"), "ignored.md").unwrap(); + std::fs::create_dir_all(root.join("node_modules/prettier")).unwrap(); + std::fs::write(root.join("node_modules/prettier/package.json"), "{}").unwrap(); + let file = root.join("packages/web/src/readme.md"); + assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join(".prettierignore"))); + std::fs::write(root.join("packages/web/.prettierignore"), "src/ignored.md").unwrap(); + assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join("packages/web/.prettierignore"))); + assert_eq!( + find_up(&file, Path::new("node_modules/prettier/package.json")), + Some(root.join("node_modules/prettier/package.json")) + ); + } + + #[test] + fn arguments_use_stdin_config_precedence_and_only_supported_ranges() { + for (parser, supports_range) in [ + ("markdown", false), + ("css", false), + ("yaml", false), + ("typescript", true), + ("graphql", true), + ("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: 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"])); + assert!(args.windows(2).any(|a| a == ["--tab-width", "4"])); + assert!(args.windows(2).any(|a| a == ["--use-tabs", "true"])); + assert_eq!(args.contains(&"--range-start".into()), supports_range); + assert!(!args.contains(&"--write".into())); + } + } +} diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 51272bf6c..1822367fe 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -11,6 +11,7 @@ pub mod archive; mod bundled; +pub mod formatter; pub mod jdk; pub mod platform; mod process; @@ -323,6 +324,9 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap { } }; for l in langs { + if let Some(f) = &l.formatter { + add(&f.tool, &f.tool, ToolKind::Formatter, None, &l.name); + } for s in &l.language_servers { add(&s.name, &s.command, ToolKind::LanguageServer, s.install_hint.as_ref(), &l.name); } diff --git a/crates/ide-tools/src/process.rs b/crates/ide-tools/src/process.rs index cd3e15788..fad1f755d 100644 --- a/crates/ide-tools/src/process.rs +++ b/crates/ide-tools/src/process.rs @@ -464,3 +464,120 @@ mod tests { assert_eq!(run_quiet(cmd, Duration::from_secs(5)), Some((true, "v1.2.3\n".into()))); } } + +/// Runs a formatter with separate pipes so diagnostics can never become document text. +pub(crate) fn run_input( + cmd: &mut Command, + input: &str, + timeout: Duration, + cancel: &CancellationToken, +) -> IdeResult { + use std::io::Write; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; + let mut stdin = child.stdin.take().unwrap(); + let input = input.to_owned(); + // All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout. + enum Pipe { + Written(std::io::Result<()>), + Read(bool, std::io::Result>), + } + let (tx, rx) = mpsc::channel(); + let writer = tx.clone(); + std::thread::spawn(move || { + let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes()))); + }); + for (is_stderr, mut pipe) in [ + (false, Box::new(child.stdout.take().unwrap()) as Box), + (true, Box::new(child.stderr.take().unwrap()) as Box), + ] { + let tx = tx.clone(); + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes))); + }); + } + drop(tx); + let started = Instant::now(); + let (mut status, mut output, mut errors, mut written) = (None, None, None, None); + let result = loop { + if cancel.is_cancelled() { + break Err(IdeError::cancelled()); + } + if started.elapsed() >= timeout { + break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out")); + } + if status.is_none() { + match child.try_wait() { + Ok(s) => status = s, + Err(e) => break Err(e.into()), + } + } + if status.is_some() && output.is_some() && errors.is_some() && written.is_some() { + break Ok(()); + } + match rx.recv_timeout(Duration::from_millis(10)) { + Ok(Pipe::Written(result)) => written = Some(result), + Ok(Pipe::Read(true, result)) => errors = Some(result), + Ok(Pipe::Read(false, result)) => output = Some(result), + Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")), + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + }; + if result.is_err() { + kill(&mut child); + } + result?; + let output = output.unwrap()?; + let errors = errors.unwrap()?; + let status = status.unwrap(); + if !status.success() { + let diagnostics: String = String::from_utf8_lossy(&errors).into_owned(); + return Err(IdeError::invalid(if diagnostics.trim().is_empty() { + format!("Formatter exited with {status}") + } else { + diagnostics + })); + } + written.unwrap()?; + String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string())) +} + +#[cfg(all(test, unix))] +mod input_tests { + use super::*; + + #[test] + fn separate_diagnostics_and_never_return_failed_output() { + let mut command = Command::new("sh"); + command.args(["-c", "cat; printf warning >&2"]); + assert_eq!(run_input(&mut command, "a😀\n", Duration::from_secs(2), &Default::default()).unwrap(), "a😀\n"); + let mut command = Command::new("sh"); + command.args(["-c", "printf broken; printf 'invalid syntax' >&2; exit 1"]); + let e = run_input(&mut command, "", Duration::from_secs(2), &Default::default()).unwrap_err(); + assert_eq!(e.message, "invalid syntax"); + } + + #[test] + fn timeout_kills_formatter_and_cancellation_prevents_start() { + let mut command = Command::new("sh"); + command.args(["-c", "sleep 10 &"]); + let start = Instant::now(); + assert_eq!( + run_input(&mut command, "", Duration::from_millis(50), &Default::default()).unwrap_err().kind, + ErrorKind::TimedOut + ); + assert!(start.elapsed() < Duration::from_secs(2)); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert_eq!( + run_input(&mut Command::new("does-not-exist"), "", Duration::from_secs(2), &cancel).unwrap_err().kind, + ErrorKind::Cancelled + ); + } +} diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index d23f6db3b..5c05cbe75 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -12,6 +12,7 @@ pub enum ToolKind { Debugger, /// Loaded into another tool's process, e.g. a jdtls agent. Plugin, + Formatter, } /// Where release archives come from. @@ -105,7 +106,7 @@ impl ToolSpec { use Arch::{Arm64, X64}; use Os::{Linux, Mac, Windows}; -use ToolKind::{Debugger, LanguageServer, Plugin}; +use ToolKind::{Debugger, Formatter, LanguageServer, Plugin}; const V: Option<(&str, &[&str])> = None; const JAVA: &[(&str, &str)] = &[("java", "Java is required (https://adoptium.net)")]; @@ -122,6 +123,17 @@ const fn ver(cmd: &'static str) -> Option<(&'static str, &'static [&'static str] pub const TYPESCRIPT: &str = "typescript@6"; pub static TOOLS: &[ToolSpec] = &[ + ToolSpec { + name: "prettier", + display: "Prettier", + kind: Formatter, + commands: &["prettier"], + method: Method::Npm { packages: &["prettier"] }, + fallback: None, + version: ver("prettier"), + needs: NODE, + homepage: "https://prettier.io", + }, ToolSpec { name: "rust-analyzer", display: "rust-analyzer", @@ -943,6 +955,11 @@ 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) { assert!( known.contains(&(t.name.to_string(), t.commands[0].to_string())), diff --git a/crates/ide-tools/tests/prettier.rs b/crates/ide-tools/tests/prettier.rs new file mode 100644 index 000000000..3d622caac --- /dev/null +++ b/crates/ide-tools/tests/prettier.rs @@ -0,0 +1,91 @@ +//! `cargo test -p ide-tools --test prettier -- --ignored --nocapture` +//! Installs into a temporary managed-tools directory; no repository dependency is added. +use ide_core::CancellationToken; +use ide_language::FormatterConfig; +use ide_tools::{Tools, formatter}; +use std::path::Path; + +fn format( + tools: &Tools, + path: &Path, + parser: &str, + text: &str, + range: Option<(u32, u32)>, +) -> ide_core::IdeResult { + let config = FormatterConfig { + tool: "prettier".into(), + parser: parser.into(), + range: matches!(parser, "typescript" | "json" | "graphql"), + }; + 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()) +} + +#[test] +#[ignore = "requires Node.js/npm and network; installs real Prettier into a temporary managed directory"] +fn real_prettier_config_ignore_languages_ranges_and_errors() { + if std::env::var_os("OPENIDEA_TEST_OFFLINE").is_some() { + eprintln!("SKIP real Prettier: OPENIDEA_TEST_OFFLINE is set; npm network access is disabled"); + return; + } + let temp = tempfile::tempdir().unwrap(); + let tools = Tools::new(&temp.path().join("app-data")); + let info = tools.info("prettier", &[]).unwrap(); + if !info.can_install { + eprintln!("SKIP real Prettier: {}", info.unavailable_reason.unwrap_or_default()); + return; + } + tools.install("prettier", &[], &Default::default(), &|_| {}).expect( + "Prettier managed install failed; offline environments may set OPENIDEA_TEST_OFFLINE=1 (reported as SKIP)", + ); + let project = temp.path().join("project with spaces"); + std::fs::create_dir_all(project.join("docs/nested")).unwrap(); + let md = project.join("docs/nested/readme.md"); + let input = "# Title\ntext\n\n| a | b |\n|---|---|\n|1|2|\n\n- a\n - b\n"; + let output = format(&tools, &md, "markdown", input, None).unwrap(); + assert!(output.starts_with("# Title\n\ntext\n"), "{output}"); + assert!(output.contains("| 1 | 2 |"), "{output}"); + assert!(!md.exists(), "formatting must never write the file"); + std::fs::write(project.join(".prettierrc"), r#"{"proseWrap":"always","printWidth":24,"tabWidth":2}"#).unwrap(); + let prose = + "This is a long paragraph with several words that should wrap according to the project configuration.\n"; + assert!(format(&tools, &md, "markdown", prose, None).unwrap().lines().count() > 2); + std::fs::write(project.join(".prettierignore"), "docs/nested/readme.md\n").unwrap(); + assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), input); + std::fs::remove_file(project.join(".prettierignore")).unwrap(); + std::fs::remove_file(project.join(".prettierrc")).unwrap(); + assert_eq!( + format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(), + "a {\n color: red;\n}\n" + ); + std::fs::write(project.join("package.json"), r#"{"prettier":{"tabWidth":3,"proseWrap":"never"}}"#).unwrap(); + assert_eq!( + format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(), + "a {\n color: red;\n}\n" + ); + assert_eq!(format(&tools, &project.join("a.yaml"), "yaml", "a: [1,2]\n", None).unwrap(), "a: [1, 2]\n"); + let js = "const a={x:1};\nconst b={y:2};\n"; + let formatted = format(&tools, &project.join("a.ts"), "typescript", js, Some((0, 14))).unwrap(); + assert!(formatted.starts_with("const a = { x: 1 };"), "{formatted}"); + assert!(formatted.contains("const b={y:2};"), "{formatted}"); + assert!(format(&tools, &project.join("bad.css"), "css", "a {", None).is_err()); + // Nearest project version must win over the managed install, including npm's object-style bin. + let package = project.join("node_modules/prettier"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("package.json"), r#"{"bin":{"prettier":"cli.cjs"}}"#).unwrap(); + std::fs::write( + package.join("cli.cjs"), + "process.stdin.resume(); process.stdin.on('end', () => process.stdout.write('project version\\n')); ", + ) + .unwrap(); + assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), "project version\n"); + std::fs::write(package.join("cli.cjs"), "setInterval(() => {}, 1000)").unwrap(); + let start = std::time::Instant::now(); + assert!(format(&tools, &md, "markdown", input, None).unwrap_err().message.contains("timed out")); + assert!(start.elapsed() < std::time::Duration::from_secs(20)); + eprintln!( + "PASS: real managed Prettier, config/ignore discovery, editor defaults, YAML/CSS/Markdown, ranges, errors, project precedence, timeout; no disk writes" + ); +} diff --git a/languages/css.toml b/languages/css.toml index 81379d5b8..4f8ba1e11 100644 --- a/languages/css.toml +++ b/languages/css.toml @@ -1,5 +1,6 @@ id = "css" name = "CSS" +formatter = { tool = "prettier", parser = "css", range = false } extensions = ["css", "pcss", "postcss"] block-comment = ["/*", "*/"] indent = { tab-size = 2, insert-spaces = true } diff --git a/languages/graphql.toml b/languages/graphql.toml index d3a7a9263..a26bb6acd 100644 --- a/languages/graphql.toml +++ b/languages/graphql.toml @@ -1,5 +1,6 @@ id = "graphql" name = "GraphQL" +formatter = { tool = "prettier", parser = "graphql", range = true } extensions = ["graphql", "graphqls", "gql"] line-comment = "#" indent = { tab-size = 2, insert-spaces = true } diff --git a/languages/html.toml b/languages/html.toml index 9da5321dd..908771a2d 100644 --- a/languages/html.toml +++ b/languages/html.toml @@ -1,5 +1,6 @@ id = "html" name = "HTML" +formatter = { tool = "prettier", parser = "html", range = false } extensions = ["html", "htm", "xhtml", "shtml", "html.tmpl"] first-line = "^\\s*"] diff --git a/languages/javascript.toml b/languages/javascript.toml index edbd83d89..b5676537e 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -1,5 +1,6 @@ id = "javascript" name = "JavaScript" +formatter = { tool = "prettier", parser = "babel", range = true } extensions = ["js", "mjs", "cjs"] first-line = "^#!.*\\b(node|nodejs|deno|bun)\\b" line-comment = "//" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index f2864cdf3..3c5a17760 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -1,5 +1,6 @@ id = "javascriptreact" name = "JavaScript JSX" +formatter = { tool = "prettier", parser = "babel", range = true } extensions = ["jsx"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/json.toml b/languages/json.toml index bad607263..f7010dffa 100644 --- a/languages/json.toml +++ b/languages/json.toml @@ -1,5 +1,6 @@ id = "json" name = "JSON" +formatter = { tool = "prettier", parser = "json", range = true } extensions = ["json", "geojson", "topojson", "webmanifest", "har", "avsc", "jsonl", "ndjson", "json.map"] filenames = [".prettierrc", ".watchmanconfig", "composer.lock", ".arcconfig"] indent = { tab-size = 2, insert-spaces = true } diff --git a/languages/jsonc.toml b/languages/jsonc.toml index 272f5c956..d90e5a655 100644 --- a/languages/jsonc.toml +++ b/languages/jsonc.toml @@ -1,5 +1,6 @@ id = "jsonc" name = "JSON with Comments" +formatter = { tool = "prettier", parser = "jsonc", range = true } extensions = ["jsonc", "code-workspace", "code-snippets"] filenames = ["tsconfig.json", "jsconfig.json", "tsconfig.*.json", "jsconfig.*.json", ".eslintrc", ".eslintrc.json", ".jshintrc", ".babelrc", ".swcrc", "devcontainer.json", ".devcontainer.json", "launch.json", "tasks.json", "settings.json", "extensions.json", "keybindings.json", "tslint.json", "api-extractor.json", "typedoc.json", "deno.json", "biome.json", "turbo.json", "language-configuration.json"] line-comment = "//" diff --git a/languages/less.toml b/languages/less.toml index 227590f9f..cfc834ee8 100644 --- a/languages/less.toml +++ b/languages/less.toml @@ -1,5 +1,6 @@ id = "less" name = "Less" +formatter = { tool = "prettier", parser = "less", range = false } extensions = ["less"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/markdown.toml b/languages/markdown.toml index 9fec38354..675ac34f3 100644 --- a/languages/markdown.toml +++ b/languages/markdown.toml @@ -1,5 +1,6 @@ id = "markdown" name = "Markdown" +formatter = { tool = "prettier", parser = "markdown", range = false } extensions = ["md", "markdown", "mdown", "mkd", "mkdn", "mdwn", "mdx", "ronn", "workbook"] block-comment = [""] indent = { tab-size = 2, insert-spaces = true } diff --git a/languages/scss.toml b/languages/scss.toml index 1fd1d0d5a..dcdaadd4c 100644 --- a/languages/scss.toml +++ b/languages/scss.toml @@ -1,5 +1,6 @@ id = "scss" name = "SCSS" +formatter = { tool = "prettier", parser = "scss", range = false } extensions = ["scss"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/typescript.toml b/languages/typescript.toml index e5f231bc5..41817c5c3 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -1,5 +1,6 @@ id = "typescript" name = "TypeScript" +formatter = { tool = "prettier", parser = "typescript", range = true } extensions = ["ts", "mts", "cts"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index 0a5069c7a..ad238c6c9 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -1,5 +1,6 @@ id = "typescriptreact" name = "TypeScript JSX" +formatter = { tool = "prettier", parser = "typescript", range = true } extensions = ["tsx"] line-comment = "//" block-comment = ["/*", "*/"] diff --git a/languages/vue.toml b/languages/vue.toml index a7cdade09..e260bce72 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -1,5 +1,6 @@ id = "vue" name = "Vue" +formatter = { tool = "prettier", parser = "vue", range = true } extensions = ["vue"] block-comment = [""] indent = { tab-size = 2, insert-spaces = true } diff --git a/languages/yaml.toml b/languages/yaml.toml index 76d550671..17e64e1b2 100644 --- a/languages/yaml.toml +++ b/languages/yaml.toml @@ -1,5 +1,6 @@ id = "yaml" name = "YAML" +formatter = { tool = "prettier", parser = "yaml", range = false } extensions = ["yaml", "yml"] filenames = [".clang-format", ".clang-tidy", ".clangd", ".gemrc", "pnpm-lock.yaml", "CITATION.cff"] first-line = "^%YAML" diff --git a/src/bindings.ts b/src/bindings.ts index 5c4e5a3bf..c3d5811cd 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2975,9 +2975,11 @@ export type EnvReloaded = { shellRead: boolean, }; +export type ErrorData = { type: "tool"; tool: string; missing: boolean }; + export type ErrorKind = "notFound" | "alreadyExists" | "permissionDenied" | "invalidInput" | /** Document version mismatch; the UI must resync the full text. */ -"outOfSync" | "cancelled" | +"outOfSync" | "cancelled" | "timedOut" | /** A required external tool (language server, git, debugger) is unavailable. */ "unavailable" | "io" | "protocol" | "internal"; @@ -3207,6 +3209,7 @@ export type FormField = { export type FormatOptions = { tabSize: number, insertSpaces: boolean, + onSave?: boolean, }; /** One occurrence of a duplicated code fragment. */ @@ -3543,6 +3546,8 @@ export type HunkRange = { export type IdeError = { kind: ErrorKind, message: string, + /** Structured detail for errors the UI acts on; `null` for plain errors. */ + data?: ErrorData | null, }; export type IdeEvent = @@ -4028,6 +4033,7 @@ export type LanguagesSettings = { rust?: RustSettings, go?: GoSettings, typescript?: TypeScriptSettings, + prettier?: PrettierSettings, java?: JavaSettings, }; @@ -4771,6 +4777,15 @@ export type PreparedRequest = { body: string | null, }; +export type PrettierSettings = { + /** Empty path: project node_modules, managed installation, then PATH. */ + path?: string, + languages?: string[], + /** Prefer Prettier even when a language server offers formatting. */ + prefer?: boolean, + runOnSave?: boolean, +}; + /** Memory of one row of the breakdown. */ export type ProcessMemory = { category: MemoryCategory, @@ -6186,7 +6201,7 @@ export type ToolInfo = { export type ToolKind = "languageServer" | "debugger" | /** Loaded into another tool's process, e.g. a jdtls agent. */ -"plugin"; +"plugin" | "formatter"; export type ToolSettings = { /** Executable to launch instead of the managed / `PATH` one; empty = auto-detect. */ diff --git a/src/editor/lsp/actions.ts b/src/editor/lsp/actions.ts index 44ca77656..f774f6779 100644 --- a/src/editor/lsp/actions.ts +++ b/src/editor/lsp/actions.ts @@ -5,6 +5,7 @@ import { executeAction, registerActions, type Action } from "@/platform/actions" import { activeEditor, editorsFor, registerSaveParticipant, type TextEditorHandle } from "@/editor/api"; import { maybeEditorService } from "@/workbench/contributions"; import { useProject } from "@/platform/project"; +import { validateLanguages } from "@/platform/languageSettings"; import { explicitCompletion } from "./completion"; import { triggerSignatureHelp } from "./signature"; import { quickDefinition, quickDocumentation } from "./hover"; @@ -122,8 +123,15 @@ export function registerFormatOnSave(): () => void { id: "lsp.formatOnSave", order: 50, beforeSave: async (ctx, handle) => { - if (!handle || ctx.readOnly || !useProject.getState().settings?.editor?.formatOnSave) return; - await formatDocument(handle.view, { explicit: false, selectionOnly: false }); + const settings = useProject.getState().settings; + const prettier = validateLanguages(settings?.languages).prettier; + if ( + !handle || + ctx.readOnly || + (!settings?.editor?.formatOnSave && !(prettier.runOnSave && prettier.languages.includes(ctx.language))) + ) + return; + await formatDocument(handle.view, { explicit: false, selectionOnly: false, onSave: true }); }, }); } diff --git a/src/editor/lsp/format.module.css b/src/editor/lsp/format.module.css new file mode 100644 index 000000000..54460af1f --- /dev/null +++ b/src/editor/lsp/format.module.css @@ -0,0 +1,3 @@ +.diagnostics { + white-space: pre-wrap; +} diff --git a/src/editor/lsp/format.test.ts b/src/editor/lsp/format.test.ts index 75c31b429..8f21dd7c7 100644 --- a/src/editor/lsp/format.test.ts +++ b/src/editor/lsp/format.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { Text } from "@codemirror/state"; +import { describe, expect, it, vi } from "vitest"; +import { EditorState, Text } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { CONTRIBUTED_FORMAT } from "@/editor/api"; +import { useProject } from "@/platform/project"; +import type { Settings } from "@/bindings"; import { formatExcluded, guardFormatChanges, @@ -7,7 +11,7 @@ import { protectedRegions, type FormatGuardOptions, } from "@/platform/formatGuard"; -import { dedentsToColumnZero, formattedLines } from "./format"; +import { dedentsToColumnZero, formatDocument, formattedLines } from "./format"; describe("formattedLines", () => { const doc = Text.of(["fn main(){", "let x=1;", "}", "", "fn other() {}"]); @@ -105,3 +109,28 @@ describe("dedentsToColumnZero", () => { expect(dedentsToColumnZero("fun f() {}\n", "fun f() { }\n")).toBe(false); }); }); + +describe("format on save provider isolation", () => { + it("does not enable contributed formatters when only Prettier save is enabled", async () => { + const previous = useProject.getState(); + const formatter = vi.fn(() => [{ from: 0, to: 6, insert: "SELECT" }]); + const view = new EditorView({ + state: EditorState.create({ + doc: "select", + extensions: [EditorState.languageData.of(() => [{ [CONTRIBUTED_FORMAT]: formatter }])], + }), + }); + try { + const settings = { editor: { formatOnSave: false }, languages: { prettier: { runOnSave: true } } } as Settings; + useProject.setState({ settings }); + expect((await formatDocument(view, { explicit: false, onSave: true })).applied).toBe(false); + expect(formatter).not.toHaveBeenCalled(); + useProject.setState({ settings: { ...settings, editor: { ...settings.editor, formatOnSave: true } } }); + expect((await formatDocument(view, { explicit: false, onSave: true })).applied).toBe(true); + expect(view.state.doc.toString()).toBe("SELECT"); + } finally { + view.destroy(); + useProject.setState(previous); + } + }); +}); diff --git a/src/editor/lsp/format.ts b/src/editor/lsp/format.ts index 1e37e1554..061e377c1 100644 --- a/src/editor/lsp/format.ts +++ b/src/editor/lsp/format.ts @@ -5,11 +5,14 @@ * reports "Formatted N lines" with a link to the Reformat File dialog * (⌥⇧⌘L: scope and Optimize Imports). */ +import { createElement } from "react"; +import styles from "./format.module.css"; import { indentUnit } from "@codemirror/language"; import { isolateHistory } from "@codemirror/commands"; import type { EditorState, Text } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; -import { commands } from "@/platform/ipc"; +import { commands, IpcError } from "@/platform/ipc"; +import { notifications } from "@/workbench/contributions"; import { shortcutLabel } from "@/platform/keymap"; import { executeAction } from "@/platform/actions"; import { @@ -78,14 +81,17 @@ export function formatOptionsOf(state: EditorState): { tabSize: number; insertSp export async function formatDocument( view: EditorView, - opts: { explicit: boolean; selectionOnly?: boolean } = { explicit: true }, + opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean } = { explicit: true }, ): Promise { const result = await format(view, opts); if (opts.explicit && result.applied) formattedHint(view, result.lines); return result; } -async function format(view: EditorView, opts: { explicit: boolean; selectionOnly?: boolean }): Promise { +async function format( + view: EditorView, + opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean }, +): Promise { const { project, settings } = useProject.getState(); const guard = formatGuardOptions(settings); const path = docOf(view)?.path; @@ -95,7 +101,10 @@ async function format(view: EditorView, opts: { explicit: boolean; selectionOnly return NOTHING; } const own = contributedAt(view.state, CONTRIBUTED_FORMAT, 0); - if (own) return applyContributedFormat(view, own, opts, guard); + if (own) { + if (opts.onSave && !settings?.editor?.formatOnSave) return NOTHING; + return applyContributedFormat(view, own, opts, guard); + } const doc = docOf(view); if (!doc || doc.readOnly) return NOTHING; // The server formats the backend's copy: let pending keystrokes reach it first. @@ -107,9 +116,25 @@ async function format(view: EditorView, opts: { explicit: boolean; selectionOnly const options = formatOptionsOf(state); let edits; try { - edits = await lspRequest(() => commands.lspFormat(doc.doc, range, options)); + edits = await lspRequest(() => commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave })); } catch (e) { - reportLspError(view, e, opts.explicit); + if (e instanceof IpcError && e.data?.type === "tool" && e.data.tool === "prettier") { + if (e.kind === "notFound" && e.data.missing) { + await executeAction("tools.promptInstall", { name: "prettier", sticky: !opts.onSave }); + } else if (e.kind !== "cancelled") { + notifications().notify({ + group: "tools", + level: "error", + title: tx("Prettier formatting failed"), + body: + e.kind === "timedOut" + ? tx("Prettier formatting timed out") + : createElement("span", { className: styles.diagnostics }, e.message), + key: `prettier-format-error:${doc.path}`, + sticky: false, + }); + } + } else reportLspError(view, e, opts.explicit); return NOTHING; } if (view.state.doc !== state.doc) { @@ -179,7 +204,7 @@ export async function formatLines(view: EditorView, from: number, to: number): P function applyContributedFormat( view: EditorView, format: ContributedFormatter, - opts: { explicit: boolean; selectionOnly?: boolean }, + opts: { explicit: boolean; selectionOnly?: boolean; onSave?: boolean }, guard: FormatGuardOptions, ): FormatResult { const state = view.state; diff --git a/src/editor/lsp/formatErrors.test.tsx b/src/editor/lsp/formatErrors.test.tsx new file mode 100644 index 000000000..aa21c997f --- /dev/null +++ b/src/editor/lsp/formatErrors.test.tsx @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen } from "@testing-library/react"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { IdeError } from "@/bindings"; +import { registerAction } from "@/platform/actions"; +import { _resetI18nForTests, setLocale } from "@/platform/i18n"; +import { IpcError } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { provideNotificationService } from "@/workbench/contributions"; +import { Balloons } from "@/workbench/Notifications"; +import { + balloonTimeout, + clearNotifications, + notificationServiceImpl, + useNotifications, +} from "@/workbench/notificationStore"; +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({ + state: EditorState.create({ + doc: ".card {", + extensions: [lsp.lspDoc.of({ doc: 1, path, language: "css", uiMode: null, readOnly: false })], + }), + }); + views.push(view); + return view; +}; +const failWith = (error: IdeError) => vi.spyOn(lsp, "lspRequest").mockRejectedValue(new IpcError(error)); + +beforeEach(() => { + clearNotifications(); + provideNotificationService(notificationServiceImpl); +}); +afterEach(() => { + views.splice(0).forEach((view) => view.destroy()); + clearNotifications(); + vi.restoreAllMocks(); + _resetI18nForTests(); +}); + +describe("external formatter errors", () => { + it("uses structured missing-tool information to request the shared install prompt", async () => { + const prompt = vi.fn(); + const dispose = registerAction({ + id: "tools.promptInstall", + title: "Install Missing Tool", + run: ({ arg }) => prompt(arg), + }); + try { + failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } }); + await formatDocument(viewFor()); + expect(prompt).toHaveBeenCalledWith({ name: "prettier", sticky: true }); + await formatDocument(viewFor(), { explicit: false, onSave: true }); + expect(prompt).toHaveBeenLastCalledWith({ name: "prettier", sticky: false }); + expect(useNotifications.getState().items).toHaveLength(0); + } finally { + dispose(); + } + }); + + it("does not treat text alone or a broken custom executable as a missing installation", async () => { + const prompt = vi.fn(); + const dispose = registerAction({ + id: "tools.promptInstall", + title: "Install Missing Tool", + run: ({ arg }) => prompt(arg), + }); + try { + const request = failWith({ kind: "unavailable", message: "Prettier is not installed" }); + const view = viewFor(); + await formatDocument(view, { explicit: false }); + expect(useNotifications.getState().items).toHaveLength(0); + request.mockRejectedValue(new IpcError({ kind: "notFound", message: "custom executable missing", data: tool })); + await formatDocument(view); + expect(prompt).not.toHaveBeenCalled(); + expect(useNotifications.getState().items).toHaveLength(1); + } finally { + dispose(); + } + }); + + it("keeps stderr untranslated in a Chinese notification and replaces failures per file, including on save", async () => { + await setLocale("zh"); + const previous = useProject.getState().settings; + useProject.setState({ settings: null }); + try { + const stderr = "Formatting timed out"; + failWith({ kind: "invalidInput", message: stderr, data: tool }); + const view = viewFor(); + 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(balloonTimeout(notes[0]!)).toBe(10000); + render(); + expect(screen.getByRole("alert")).toHaveTextContent(stderr); + expect(screen.queryByText("格式化超时")).toBeNull(); + expect(view.state.doc.toString()).toBe(".card {"); + await act(async () => { + await formatDocument(viewFor("/project/other.css")); + }); + expect(useNotifications.getState().items).toHaveLength(2); + } finally { + useProject.setState({ settings: previous }); + } + }); + + it("localizes the timeout explanation by kind and ignores cancellation", async () => { + await setLocale("zh"); + 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 }); + clearNotifications(); + request.mockRejectedValue(new IpcError({ kind: "cancelled", message: "cancelled", data: tool })); + await formatDocument(view); + expect(useNotifications.getState().items).toHaveLength(0); + }); +}); diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 0fd9ac555..5864b0586 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -35,6 +35,16 @@ describe("languages settings", () => { expect(validateSettings({}).languages).toEqual(DEFAULT_LANGUAGES); }); + it("keeps Prettier opt-in on save and normalizes its enabled languages", () => { + expect(validateLanguages({}).prettier).toMatchObject({ prefer: false, runOnSave: false, path: "" }); + expect( + validateLanguages({ + prettier: { path: " ./bin/prettier ", languages: ["markdown", 4, "markdown"], prefer: true, runOnSave: true }, + }).prettier, + ).toEqual({ path: "./bin/prettier", languages: ["markdown"], prefer: true, runOnSave: true }); + expect(validateLanguages({ prettier: { languages: [] } }).prettier.languages).toEqual([]); + }); + it("marks folders with one role each", () => { expect(normalizeFolder("./src/main/")).toBe("src/main"); let ps = validateProjectStructure({ sourceRoots: ["src/", "src", 3], excluded: ["/dist"] }); @@ -58,6 +68,7 @@ describe("language pages", () => { "Java", "Node.js", "Python Interpreter", + "Prettier", "Rust", "TypeScript", ]); diff --git a/src/features/settings/languages/PrettierPage.tsx b/src/features/settings/languages/PrettierPage.tsx new file mode 100644 index 000000000..ea20c7b01 --- /dev/null +++ b/src/features/settings/languages/PrettierPage.tsx @@ -0,0 +1,72 @@ +import { useId } from "react"; +import { Input } from "@/ui"; +import { Row, Section, Select, Toggle } from "../components/Form"; +import { useSetting } from "../components/context"; +import { DEFAULT_LANGUAGES } from "@/platform/languageSettings"; + +const names: Record = { + markdown: "Markdown", + yaml: "YAML", + css: "CSS", + scss: "SCSS", + less: "Less", + html: "HTML", + graphql: "GraphQL", + javascript: "JavaScript", + javascriptreact: "JavaScript JSX", + typescript: "TypeScript", + typescriptreact: "TypeScript JSX", + json: "JSON", + jsonc: "JSON with Comments", + vue: "Vue", +}; + +export function PrettierPage() { + const id = useId(); + const [path, setPath] = useSetting("languages.prettier.path"); + const [languages, setLanguages] = useSetting("languages.prettier.languages"); + const [prefer, setPrefer] = useSetting("languages.prettier.prefer"); + const [save, setSave] = useSetting("languages.prettier.runOnSave"); + return ( + <> +
+ + setPath(e.target.value)} /> + + + { - readImageFiles(e.target.files ?? []); - e.target.value = ""; - }} - /> - {props.actions} +
{props.toolbar}
+
+ { + const glue = text && !/\s$/.test(text) ? " " : ""; + setText(`${text}${glue}@`, text.length + glue.length + 1); + }} + /> + imageInput.current?.click()} + /> + { + readImageFiles(e.target.files ?? []); + e.target.value = ""; + }} + /> + + {props.actions} +
); diff --git a/src/features/agents/ConfigPickers.test.tsx b/src/features/agents/ConfigPickers.test.tsx index 2c7c737de..e0c0a828c 100644 --- a/src/features/agents/ConfigPickers.test.tsx +++ b/src/features/agents/ConfigPickers.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { AgentConfigOption } from "@/bindings"; import { ConfigPickers, configPickerOptions } from "./ConfigPickers"; @@ -85,3 +85,28 @@ it("sends the complete nested model id through ConfigPickers", () => { fireEvent.click(screen.getByRole("option", { name: "Beamcmc/Beam" })); expect(onChange).toHaveBeenCalledExactlyOnceWith("model", "9router/cmc/Beam"); }); + +it("edits secondary settings from the compact popup and restores focus when dismissed", () => { + const approval: AgentConfigOption = { + id: "approvals", + name: "Approvals", + category: "approval", + description: null, + current: "ask", + via: "option", + values: [value("ask", "Ask first"), value("bypass", "Bypass approvals")], + }; + const onChange = vi.fn(); + render(); + const trigger = screen.getByRole("button", { name: "Agent settings" }); + trigger.focus(); + fireEvent.click(trigger); + const dialog = screen.getByRole("dialog", { name: "Agent settings" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Approvals" })); + fireEvent.click(screen.getByRole("option", { name: "Ask first" })); + expect(onChange).toHaveBeenCalledExactlyOnceWith("approvals", "ask"); + expect(dialog).toBeInTheDocument(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("dialog", { name: "Agent settings" })).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); +}); diff --git a/src/features/agents/ConfigPickers.tsx b/src/features/agents/ConfigPickers.tsx index 08a0e8af8..dbb6f2331 100644 --- a/src/features/agents/ConfigPickers.tsx +++ b/src/features/agents/ConfigPickers.tsx @@ -2,9 +2,11 @@ * Model, mode, approvals and thinking-effort dropdowns for a composer's bottom row. * Modes and approvals use icons; levels are plain text. */ +import { useRef, useState } from "react"; +import clsx from "clsx"; import type { AgentConfigOption } from "@/bindings"; import { useTx } from "@/platform/i18n"; -import { Select } from "@/ui"; +import { IconButton, Popup, Select, T } from "@/ui"; import type { SelectOption } from "@/ui/Select"; import { mainOptions, modeIcon } from "./composerModel"; import styles from "./Composer.module.css"; @@ -43,47 +45,100 @@ export function ConfigPickers({ onChange: (option: string, value: string) => void; }) { const tx = useTx(); + const [open, setOpen] = useState(false); + const anchor = useRef(null); + const options = mainOptions(config); + const secondary = options.filter((o) => o.category !== "model"); + const approval = secondary.find((o) => o.category === "approval" || o.category === "mode"); + const summary = secondary + .map((o) => { + const value = values?.[o.id] ?? o.current; + return `${tx(o.name)}: ${o.values.find((v) => v.value === value)?.name ?? tx("Default")}`; + }) + .join(" · "); return ( <> - {mainOptions(config).map((o) => { - const isMode = o.category === "mode"; - const value = values?.[o.id] ?? o.current ?? null; - const options = configPickerOptions(o); - - return ( - ({ + ...opt, + icon: + option.category === "approval" + ? opt.value === "bypass" + ? "unlock" + : "lock" + : option.category === "mode" + ? modeIcon(opt.value) + : undefined, + }))} + searchable={option.category === "model"} + onChange={(v) => onChange(option.id, v)} + renderValue={ + option.category === "thought_level" && !framed + ? (current) => ( + + {option.name} + {current?.label ?? tx("Default")} + + ) + : undefined + } + /> + ); +} diff --git a/src/features/agents/Home.module.css b/src/features/agents/Home.module.css index f86221152..67cad0ade 100644 --- a/src/features/agents/Home.module.css +++ b/src/features/agents/Home.module.css @@ -60,3 +60,47 @@ .warning { color: var(--danger); } + +.docked { + justify-content: flex-end; + padding: 16px 12px 12px; +} +.docked .inner { + gap: 8px; +} +.docked .where { + order: -1; + flex-wrap: nowrap; + min-width: 0; + gap: 6px; +} +.docked .project, +.docked .projectPicker { + flex: 0 1 auto; + min-width: 0; + max-width: 30%; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.docked .branch, +.docked .basePicker { + order: 1; + flex: 1 1 0; + min-width: 0; + padding: 0; +} +.docked .isolation { + order: 2; + flex: 0 1 auto; + min-width: 0; + max-width: 45%; + margin-left: auto; +} +.branchName { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index d7dfa29e9..3ff76b005 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -1,4 +1,5 @@ /** New Task page: one prompt box with the agent and its settings; where it works under it. */ +import clsx from "clsx"; import { useEffect, useState } from "react"; import type { AgentProbe, Branch } from "@/bindings"; import { executeAction } from "@/platform/actions"; @@ -7,7 +8,7 @@ import { useTx, useTxTemplate } from "@/platform/i18n"; import { basename, useProject } from "@/platform/project"; import { Icon, Select, T, type SelectOption } from "@/ui"; import { defaultTaskProject } from "./project"; -import { maybeEditorService } from "@/workbench/contributions"; +import { maybeEditorService, type EditorInput } from "@/workbench/contributions"; import { Composer } from "./Composer"; import { SubmitButton } from "./SubmitButton"; import { canSend, emptyDraft } from "./composerModel"; @@ -17,7 +18,7 @@ import { ADD_AGENT, providerOptions as agentOptions } from "./providerOptions"; import { HOME, NO_COMMANDS, refreshProviders, startTask, updateDraft, useAgentCommands, useAgents } from "./store"; import styles from "./Home.module.css"; -export function HomeEditor() { +export function HomeEditor({ docked = false }: { docked?: boolean; input?: EditorInput }) { const tx = useTx(); const txTemplate = useTxTemplate(); const draft = useAgents((s) => s.draft); @@ -86,15 +87,19 @@ export function HomeEditor() { const setOption = (id: string, value: string) => updateDraft({ config: { ...draft.config, [id]: value } }); return ( -
+
-

- What should the agent do? -

+ {!docked && ( +

+ What should the agent do? +

+ )} ({ value: path, label: basename(path), detail: path }))} onChange={(path) => { @@ -139,12 +145,15 @@ export function HomeEditor() { }} /> ) : ( - {members.length ? basename(root) : project?.name} + + {members.length ? basename(root) : project?.name} + )} {isGit && ( ({ value: b.name, label: b.name }))} onChange={(b) => updateDraft({ base: b === current ? null : b })} @@ -184,7 +198,7 @@ export function HomeEditor() { )}
- {probe && ( + {probe && (!docked || probe.error) && (

{probe.error ? txTemplate(`${provider?.name ?? probe.provider} didn't start: ${probe.error}`) diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index 5b104f185..a28570ef3 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -16,6 +16,20 @@ border-right: 1px solid var(--border); } +.conversation[hidden] { + display: none; +} +.docked { + grid-template-columns: minmax(0, 1fr); + overflow: hidden; +} +.docked .conversation { + border-right: 0; +} +.docked .changes { + width: auto; +} + /* The conversation hugs its content; spare space stays below the composer instead of inside the chat. */ .streamArea, @@ -568,6 +582,7 @@ width: 280px; } .changesHeader { + min-width: 0; display: flex; align-items: center; gap: 6px; @@ -577,11 +592,17 @@ border-bottom: 1px solid var(--border); } .changesTitle { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-weight: 600; color: var(--fg-strong); } .changesSummary { flex: none; + overflow: hidden; + text-overflow: ellipsis; padding: 4px 10px; color: var(--fg-subtle); white-space: pre; @@ -608,8 +629,11 @@ gap: 4px; } .changeName { - flex: none; + flex: 0 1 auto; + min-width: 0; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .changeDir { flex: 1 1 0; diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 00d2ae510..deddc4c4c 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -68,15 +68,13 @@ import { setFocusedSession, focusedSession, type FocusedSession } from "./sessio import { taskMenu } from "./taskActions"; import styles from "./Session.module.css"; -export function SessionEditor({ input }: { input: EditorInput }) { +export function SessionEditor({ input, docked = false }: { input: EditorInput; docked?: boolean }) { const id = (input.data as { task: string }).task; - return ; + return ; } -function TaskSession({ id }: { id: string }) { +function TaskSession({ id, docked }: { id: string; docked: boolean }) { const task = useAgents((s) => s.tasks.get(id)); - // The tool window bar above already shows the title of a docked conversation. - const docked = useAgents((s) => s.docked === id); const entries = useAgents((s) => s.entries.get(id)); const changes = useAgents((s) => s.changes.get(id)); const project = useProject((s) => s.project); @@ -84,7 +82,7 @@ function TaskSession({ id }: { id: string }) { task?.isolation === "local" ? task.status : null, project?.workspace ? (task?.projectRoot ?? project.root) : null, ); - const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null); + const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null, docked); // The subagent conversation shown, with the subagents that lead to it (empty: the task's own). const [path, setPath] = useState([]); const view = path.at(-1) ?? null; @@ -96,8 +94,10 @@ function TaskSession({ id }: { id: string }) { useEffect(() => { void loadEntries(id).catch(() => undefined); void refreshChanges(id); - return markVisible(id); }, [id]); + useEffect(() => { + if (!docked || !changesOpen) return markVisible(id); + }, [id, docked, changesOpen]); useEffect(() => { if (view) void loadEntries(id, view).catch(() => undefined); }, [id, view]); @@ -127,7 +127,7 @@ function TaskSession({ id }: { id: string }) { } }} > -

+ ); @@ -270,6 +276,7 @@ function Header({ className={styles.changesToggle} aria-pressed={changesOpen} disabled={count === 0 && !changesOpen} + aria-label={changesOpen ? tx("Hide changes") : tx("Show changes")} title={changesOpen ? tx("Hide changes") : tx("Show changes")} onClick={onToggleChanges} > @@ -1292,16 +1299,22 @@ function loadChangesOpen(): Record { /** * Whether the Changes pane is shown for `task`: the user's choice once made, else - * only while the task has changes (so it opens when the agent first edits a file). + * only while the task has changes. Docked sessions stay in the conversation until + * the user explicitly opens changes; navigation there does not change editor preferences. */ -function useChangesOpen(task: string, changes: AgentChange[] | null): [boolean, (open: boolean) => void] { +function useChangesOpen( + task: string, + changes: AgentChange[] | null, + docked: boolean, +): [boolean, (open: boolean) => void] { const [state, setState] = useState<{ task: string; open: boolean | undefined }>(() => ({ task, - open: loadChangesOpen()[task], + open: docked ? false : loadChangesOpen()[task], })); - const chosen = state.task === task ? state.open : loadChangesOpen()[task]; + const chosen = state.task === task ? state.open : docked ? false : loadChangesOpen()[task]; const set = (open: boolean) => { setState({ task, open }); + if (docked) return; try { globalThis.localStorage?.setItem(CHANGES_OPEN_KEY, JSON.stringify({ ...loadChangesOpen(), [task]: open })); } catch { @@ -1315,11 +1328,13 @@ function ChangesPane({ task, changes, head, + docked, onHide, }: { task: AgentTask; changes: AgentChange[] | null; head: string | null; + docked: boolean; onHide: () => void; }) { const [selected, setSelected] = useState>(() => new Set()); @@ -1328,6 +1343,7 @@ function ChangesPane({ return (