From dd00a7f1df2b9b43cfc5c5178d21fbb97a7ee3db Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Wed, 19 Aug 2026 16:46:13 +0800 Subject: [PATCH 1/6] fix(windows): restore bundled JDTLS startup Normalize Windows resource and definition paths, preserve Core startup diagnostics, and clean up failed sessions.\n\nFixes #177 --- windows/tauri/src-tauri/src/lsp.rs | 29 +++- .../editor/lsp/workspace-edit.test.ts | 30 +++++ .../src/features/editor/lsp/workspace-edit.ts | 18 ++- .../src/platform/lsp-core-adapter.test.ts | 124 ++++++++++++++++++ .../tauri/src/platform/lsp-core-adapter.ts | 103 +++++++++++++-- 5 files changed, 291 insertions(+), 13 deletions(-) create mode 100644 windows/tauri/src/features/editor/lsp/workspace-edit.test.ts create mode 100644 windows/tauri/src/platform/lsp-core-adapter.test.ts diff --git a/windows/tauri/src-tauri/src/lsp.rs b/windows/tauri/src-tauri/src/lsp.rs index 3ed6a9840..9c32da240 100644 --- a/windows/tauri/src-tauri/src/lsp.rs +++ b/windows/tauri/src-tauri/src/lsp.rs @@ -264,7 +264,16 @@ fn language_server_cache_directory(app: &AppHandle) -> PathBuf { } fn normalize_path(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") + let path = path.to_string_lossy(); + if let Some(network_path) = path.strip_prefix(r"\\?\UNC\") { + return format!("//{}", network_path.replace('\\', "/")); + } + + // Tauri can return verbatim resource paths, but cmd.exe cannot execute + // their `//?/` form after slash normalization. + path.strip_prefix(r"\\?\") + .unwrap_or(path.as_ref()) + .replace('\\', "/") } #[cfg(test)] @@ -375,6 +384,24 @@ mod tests { assert!(error.contains("11.0.26"), "{error}"); } + #[test] + fn strips_verbatim_prefix_from_windows_launch_paths() { + assert_eq!( + normalize_path(Path::new( + r"\\?\D:\Lithe\LanguageServers\jdtls\bin\jdtls.bat" + )), + "D:/Lithe/LanguageServers/jdtls/bin/jdtls.bat" + ); + } + + #[test] + fn preserves_unc_root_when_stripping_verbatim_prefix() { + assert_eq!( + normalize_path(Path::new(r"\\?\UNC\server\share\jdtls\bin\jdtls.bat")), + "//server/share/jdtls/bin/jdtls.bat" + ); + } + fn java_runtime(home_path: &str, version: &str) -> run::JavaRuntime { run::JavaRuntime { home_path: home_path.to_string(), diff --git a/windows/tauri/src/features/editor/lsp/workspace-edit.test.ts b/windows/tauri/src/features/editor/lsp/workspace-edit.test.ts new file mode 100644 index 000000000..be1b7036c --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/workspace-edit.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { filePathFromUri } from "./workspace-edit"; + +describe("LSP file URI paths", () => { + test("removes the URI slash before a Windows drive", () => { + expect(filePathFromUri("file:///C:/work/src/Main.java")).toBe("C:/work/src/Main.java"); + }); + + test("preserves UNC hosts", () => { + expect(filePathFromUri("file://server/share/src/Main.java")).toBe( + "//server/share/src/Main.java", + ); + }); + + test("keeps POSIX absolute paths absolute", () => { + expect(filePathFromUri("file:///Users/dev/src/Main.java")).toBe("/Users/dev/src/Main.java"); + }); + + test("decodes escaped path segments", () => { + expect(filePathFromUri("file:///C:/work/My%20Project/Main.java")).toBe( + "C:/work/My Project/Main.java", + ); + }); + + test("keeps malformed escape sequences readable", () => { + expect(filePathFromUri("file:///C:/work/%invalid/Main.java")).toBe( + "C:/work/%invalid/Main.java", + ); + }); +}); diff --git a/windows/tauri/src/features/editor/lsp/workspace-edit.ts b/windows/tauri/src/features/editor/lsp/workspace-edit.ts index 2d9b35501..a6d1c7ad8 100644 --- a/windows/tauri/src/features/editor/lsp/workspace-edit.ts +++ b/windows/tauri/src/features/editor/lsp/workspace-edit.ts @@ -67,14 +67,28 @@ export function isWorkspaceEdit(value: unknown): value is WorkspaceEdit { return hasChanges || hasDocumentChanges; } +function decodeUriPath(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + export function filePathFromUri(uri: string): string { if (!uri.startsWith("file://")) return uri; try { const url = new URL(uri); - return decodeURIComponent(url.pathname); + const pathname = decodeUriPath(url.pathname); + // URL.pathname keeps the leading slash in `file:///C:/...`; remove it so + // Windows hosts receive a drive-qualified path instead of `/C:/...`. + if (/^\/[A-Za-z]:\//.test(pathname)) return pathname.slice(1); + if (url.hostname) return `//${url.hostname}${pathname}`; + return pathname; } catch { - return decodeURIComponent(uri.replace(/^file:\/\//, "")); + const path = decodeUriPath(uri.replace(/^file:\/\//, "")); + return path.replace(/^\/([A-Za-z]:[\\/])/, "$1"); } } diff --git a/windows/tauri/src/platform/lsp-core-adapter.test.ts b/windows/tauri/src/platform/lsp-core-adapter.test.ts new file mode 100644 index 000000000..c6dd4162f --- /dev/null +++ b/windows/tauri/src/platform/lsp-core-adapter.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, mock, test } from "bun:test"; + +const emit = mock(async () => undefined); +const emitTo = mock(async () => undefined); +const listen = mock(async () => () => undefined); +const once = mock(async () => () => undefined); +const TauriEvent = { + WINDOW_RESIZED: "tauri://resize", + WINDOW_MOVED: "tauri://move", + WINDOW_CLOSE_REQUESTED: "tauri://close-requested", + WINDOW_DESTROYED: "tauri://destroyed", + WINDOW_FOCUS: "tauri://focus", + WINDOW_BLUR: "tauri://blur", + WINDOW_SCALE_FACTOR_CHANGED: "tauri://scale-change", + WINDOW_THEME_CHANGED: "tauri://theme-changed", + WINDOW_CREATED: "tauri://window-created", + WINDOW_SUSPENDED: "tauri://suspended", + WINDOW_RESUMED: "tauri://resumed", + WEBVIEW_CREATED: "tauri://webview-created", + DRAG_ENTER: "tauri://drag-enter", + DRAG_OVER: "tauri://drag-over", + DRAG_DROP: "tauri://drag-drop", + DRAG_LEAVE: "tauri://drag-leave", +} as const; +const frontendTrace = mock(() => undefined); +const commands: string[] = []; +let startPayload: Record | undefined; +let pollCount = 0; + +const executeCore = mock(async (request: { + id: string; + command: string; + payload?: Record; +}) => { + commands.push(request.command); + if (request.command === "lsp.startServer") { + startPayload = request.payload; + return { + id: request.id, + ok: true as const, + data: { sessionId: "failed-java-session" }, + }; + } + if (request.command === "lsp.pollEvents") { + pollCount += 1; + return { + id: request.id, + ok: true as const, + data: { + events: + pollCount === 1 + ? [ + { + type: "log", + level: "warning", + message: "Language-server stderr", + detail: "JDTLS failed before initialization", + providerId: "java", + sessionId: "failed-java-session", + }, + { + type: "stateChanged", + state: "failed", + providerId: "java", + sessionId: "failed-java-session", + error: { + code: "serverExited", + stage: "process", + message: "Language-server process exited.", + underlyingMessage: "JVM startup failed", + processExitCode: 13, + }, + }, + ] + : [], + }, + }; + } + return { id: request.id, ok: true as const, data: null }; +}); + +mock.module("@tauri-apps/api/event", () => ({ emit, emitTo, listen, once, TauriEvent })); +mock.module("@/core/lithe-core-client", () => ({ executeCore })); +mock.module("@/utils/frontend-trace", () => ({ frontendTrace })); + +const { invokeLsp } = await import("./lsp-core-adapter"); + +describe("Rust Core LSP adapter failures", () => { + test("logs Core output, preserves failure details, and destroys the session", async () => { + let failure: (Error & { code?: string; details?: string }) | null = null; + try { + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath: "C:/work/Main.java", + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + } catch (error) { + failure = error as Error & { code?: string; details?: string }; + } + + expect(failure).not.toBeNull(); + expect(failure?.message).toBe( + "Language-server process exited. JVM startup failed; exit code 13", + ); + expect(failure?.code).toBe("serverExited"); + expect(failure?.details).toBe("JVM startup failed; exit code 13"); + expect(startPayload?.initializeTimeoutMilliseconds).toBe(30_000); + expect(frontendTrace).toHaveBeenCalledWith( + "warn", + "lsp.runtime", + "Language-server stderr", + expect.objectContaining({ detail: "JDTLS failed before initialization" }), + ); + expect(emit).toHaveBeenCalledWith("lsp://server-crashed", {}); + expect(commands).toEqual([ + "lsp.startServer", + "lsp.pollEvents", + "lsp.stopServer", + "lsp.destroyServer", + ]); + }); +}); diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index a90a0bfdf..356209bb2 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -1,8 +1,13 @@ import { emit } from "@tauri-apps/api/event"; import { executeCore, type CoreResponse } from "@/core/lithe-core-client"; +import { frontendTrace } from "@/utils/frontend-trace"; type JsonRecord = Record; +const INITIALIZE_TIMEOUT_MS = 30_000; +const SESSION_CLEANUP_TIMEOUT_MS = 5_000; +const POLL_INTERVAL_MS = 20; + interface Session { id: string; workspacePath: string; @@ -16,15 +21,32 @@ interface Session { completed: Map; } +interface RuntimeError { + code?: string; + providerId?: string; + sessionId?: string; + stage?: string; + method?: string; + documentUri?: string; + message?: string; + underlyingMessage?: string; + processExitCode?: number; +} + interface RuntimeEvent { type: string; + providerId?: string; + sessionId?: string; state?: string; operationId?: string; uri?: string; version?: number; diagnostics?: unknown[]; result?: unknown; - error?: { code?: string; message?: string }; + error?: RuntimeError; + level?: string; + message?: string; + detail?: string; } const sessions = new Map(); @@ -65,6 +87,14 @@ function normalizeCoreValue(value: unknown): unknown { } async function dispatchRuntimeEvent(event: RuntimeEvent): Promise { + if (event.type === "log") { + const level = event.level === "error" ? "error" : event.level === "warning" ? "warn" : "info"; + frontendTrace(level, "lsp.runtime", event.message ?? "Language-server log", { + detail: event.detail ?? null, + providerId: event.providerId, + sessionId: event.sessionId, + }); + } if (event.type === "diagnostics" && event.uri) { await emit("lsp://diagnostics", { uri: event.uri, @@ -118,22 +148,71 @@ async function runEventPump(session: Session): Promise { await emit("lsp://server-crashed", {}); return; } - await new Promise((resolve) => setTimeout(resolve, 20)); + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } } async function waitUntilReady(session: Session): Promise { - const deadline = Date.now() + 12_000; + const deadline = Date.now() + INITIALIZE_TIMEOUT_MS; while (Date.now() < deadline) { const events = await poll(session); const state = [...events].reverse().find((event) => event.type === "stateChanged")?.state; if (state === "ready") return; if (state === "failed" || state === "stopped") { - throw new Error(`Language server entered ${state} state`); + const failure = [...events] + .reverse() + .find((event) => event.type === "stateChanged" && event.error)?.error; + const detail = [ + failure?.underlyingMessage, + failure?.processExitCode != null ? `exit code ${failure.processExitCode}` : null, + ] + .filter(Boolean) + .join("; "); + const error = new Error( + [failure?.message ?? `Language server entered ${state} state`, detail] + .filter(Boolean) + .join(" "), + ) as Error & { code?: string; details?: string }; + error.code = failure?.code; + error.details = detail || failure?.stage; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + const error = new Error("Language server initialization timed out") as Error & { code?: string }; + error.code = "timed_out"; + throw error; +} + +async function stopAndDestroySession(session: Session): Promise { + session.running = false; + await core("lsp.stopServer", { sessionId: session.id }); + + const deadline = Date.now() + SESSION_CLEANUP_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + await core("lsp.destroyServer", { sessionId: session.id }); + return; + } catch { + // A graceful stop is asynchronous; keep draining events until Core is terminal. } - await new Promise((resolve) => setTimeout(resolve, 20)); + await poll(session); + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + await core("lsp.destroyServer", { sessionId: session.id }); +} + +async function cleanupFailedStart(key: string, session: Session): Promise { + try { + await stopAndDestroySession(session); + } catch (reason) { + frontendTrace("warn", "lsp.runtime", "Language-server cleanup failed", { + sessionId: session.id, + error: reason instanceof Error ? reason.message : String(reason), + }); } - throw new Error("Language server initialization timed out"); + sessions.delete(key); } function sessionForFile(filePath: string): Session { @@ -163,6 +242,7 @@ async function start(args: JsonRecord): Promise { initializationOptions: args.initializationOptions ?? null, runtimeExecutablePath: args.runtimeExecutablePath ?? null, cacheDirectory: args.cacheDirectory ?? null, + initializeTimeoutMilliseconds: INITIALIZE_TIMEOUT_MS, }); session = { id: started.sessionId, @@ -174,7 +254,12 @@ async function start(args: JsonRecord): Promise { completed: new Map(), }; sessions.set(key, session); - await waitUntilReady(session); + try { + await waitUntilReady(session); + } catch (error) { + await cleanupFailedStart(key, session); + throw error; + } session.running = true; void runEventPump(session); } @@ -185,9 +270,7 @@ async function start(args: JsonRecord): Promise { } async function stopSession(session: Session): Promise { - session.running = false; - await core("lsp.stopServer", { sessionId: session.id }); - await core("lsp.destroyServer", { sessionId: session.id }); + await stopAndDestroySession(session); sessions.delete(`${session.workspacePath}:${session.languageId}`); for (const file of session.files) fileSessions.delete(file); } From af6c6de247f66012844ed254c1d191ad345056b7 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 20 Aug 2026 12:31:50 +0800 Subject: [PATCH 2/6] feat(windows): support Java dependency navigation --- rust/lithe-core/src/lsp/interface/engine.rs | 118 ++++++- rust/lithe-core/src/lsp/languages/jdt.rs | 61 +++- shared/contracts/rust-core-api.md | 4 + .../editor/components/monaco-editor.tsx | 9 + .../editor/engines/monaco/definition-link.ts | 130 ++++++++ .../editor/engines/monaco/lsp-providers.ts | 14 +- .../features/editor/engines/monaco/theme.ts | 1 + .../lsp/lombok-accessor-navigation.test.ts | 297 +++++++++++++++++ .../editor/lsp/lombok-accessor-navigation.ts | 311 ++++++++++++++++++ .../src/features/editor/lsp/lsp-client.ts | 25 +- .../editor/lsp/navigation-target.test.ts | 113 +++++++ .../features/editor/lsp/navigation-target.ts | 84 +++++ .../utils/go-to-definition-gesture.test.ts | 19 +- .../editor/utils/go-to-definition-gesture.ts | 22 +- .../commands/navigation-command-actions.ts | 94 ++++-- .../src/platform/lsp-core-adapter.test.ts | 183 ++++++++--- .../tauri/src/platform/lsp-core-adapter.ts | 66 ++-- 17 files changed, 1423 insertions(+), 128 deletions(-) create mode 100644 windows/tauri/src/features/editor/engines/monaco/definition-link.ts create mode 100644 windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.test.ts create mode 100644 windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.ts create mode 100644 windows/tauri/src/features/editor/lsp/navigation-target.test.ts create mode 100644 windows/tauri/src/features/editor/lsp/navigation-target.ts diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index aa3aa96be..a87792876 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -11,8 +11,9 @@ use super::{ ParseServerMessagesRequest, }; use crate::lsp::languages::jdt::{ - adapt_start, initialized_notification, virtual_source_content, virtual_source_resolve_params, - workspace_configuration, JdtStartContext, WorkspaceConfigurationItem, + adapt_initialization_options, adapt_start, initialized_notification, normalize_location, + virtual_source_content, virtual_source_resolve_params, workspace_configuration, + JdtStartContext, ProviderLocation, WorkspaceConfigurationItem, }; use crate::protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; @@ -483,7 +484,10 @@ impl LspEngine { state: LspClientState::default(), root_uri: request.root_uri.clone(), process_id: Some(std::process::id() as i64), - initialization_options: request.initialization_options, + initialization_options: adapt_initialization_options( + &request.provider_id, + request.initialization_options, + ), })?; let request_id = (initialize.state.next_request_id - 1).to_string(); let now = Instant::now(); @@ -1136,12 +1140,16 @@ impl RuntimeSession { None, ) }); + let result = normalize_provider_navigation_result( + &self.provider_id, + event.and_then(|event| event.result.clone()), + ); push_request_event( self, &mut state, operation_id, &pending.method, - event.and_then(|event| event.result.clone()), + result, error, ); } @@ -1807,6 +1815,47 @@ fn push_request_event( }); } +fn normalize_provider_navigation_result( + provider_id: &str, + mut result: Option, +) -> Option { + let locations = result + .as_mut() + .and_then(|result| result.get_mut("locations")) + .and_then(Value::as_array_mut); + let Some(locations) = locations else { + return result; + }; + + for location in locations { + let Some(uri) = location + .get("uri") + .and_then(Value::as_str) + .map(ToString::to_string) + else { + continue; + }; + let normalized = normalize_location( + provider_id, + ProviderLocation { + uri, + is_read_only: location + .get("isReadOnly") + .and_then(Value::as_bool) + .unwrap_or(false), + display_path: location + .get("displayPath") + .and_then(Value::as_str) + .map(ToString::to_string), + }, + ); + location["isReadOnly"] = Value::Bool(normalized.is_read_only); + location["displayPath"] = normalized.display_path.map_or(Value::Null, Value::String); + } + + result +} + fn push_diagnostics_event( session: &RuntimeSession, state: &mut SessionState, @@ -3046,6 +3095,67 @@ mod tests { assert!(request["params"].get("textDocument").is_none()); } + #[test] + fn java_start_enables_and_normalizes_class_file_navigation() { + let cache = std::env::temp_dir().join("lithe-core-java-navigation-tests"); + let mut harness = Harness::start(|request| { + request.provider_id = "java".to_string(); + request.cache_directory = Some(cache.to_string_lossy().into_owned()); + request.initialization_options = Some(json!({ + "extendedClientCapabilities": { "customCapability": true } + })); + }); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("the initialize request should reach JDT LS"); + assert_eq!( + initialize["params"]["initializationOptions"]["extendedClientCapabilities"] + ["classFileContentsSupport"], + true + ); + assert_eq!( + initialize["params"]["initializationOptions"]["extendedClientCapabilities"] + ["customCapability"], + true + ); + + harness + .server + .complete_initialize(json!({ "definitionProvider": true })); + harness.await_state(LspLifecycleState::Ready); + let uri = "file:///workspace/Main.java"; + harness.sync(uri, "class Main { String value; }"); + let operation_id = harness.request(LspSemanticOperation::Definition, uri); + let request_id = harness + .server + .await_request("textDocument/definition") + .expect("the definition request should reach JDT LS"); + let virtual_uri = "jdt://contents/java.base/java/lang/String.class?=demo"; + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": [{ + "uri": virtual_uri, + "range": { + "start": { "line": 10, "character": 4 }, + "end": { "line": 10, "character": 10 } + } + }] + })); + let event = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation_id.as_str())) + .clone(); + let location = &event.result.as_ref().unwrap()["locations"][0]; + assert_eq!(location["uri"], virtual_uri); + assert_eq!(location["isReadOnly"], true); + assert_eq!(location["displayPath"], "java.base/java/lang/String.java"); + + let _ = std::fs::remove_dir_all(cache); + } + #[test] fn java_virtual_document_returns_decompiled_text_without_an_open_document() { let mut harness = Harness::start(|request| { diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs index 3642592df..4f24ae085 100644 --- a/rust/lithe-core/src/lsp/languages/jdt.rs +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -8,7 +8,7 @@ #![allow(dead_code)] // This module is an engine adapter seam; integration is intentionally separate. use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; @@ -107,6 +107,36 @@ pub(crate) fn adapt_start(context: &JdtStartContext) -> JdtStartAdaptation { } } +/// Adds the JDT LS client extensions required for class-file navigation. +/// +/// Catalog-provided options are preserved, while the provider-owned capability +/// is authoritative because virtual class files cannot be opened without it. +pub(crate) fn adapt_initialization_options( + provider_id: &str, + initialization_options: Option, +) -> Option { + if !is_java_provider(provider_id) { + return initialization_options; + } + + let mut options = match initialization_options { + Some(Value::Object(options)) => options, + _ => Map::new(), + }; + let extended = options + .entry("extendedClientCapabilities") + .or_insert_with(|| json!({})); + if !extended.is_object() { + *extended = json!({}); + } + extended + .as_object_mut() + .expect("the extended capabilities were normalized to an object") + .insert("classFileContentsSupport".to_string(), Value::Bool(true)); + + Some(Value::Object(options)) +} + /// Returns JDT LS configuration values in the same order as the requested /// `workspace/configuration` items. `None` delegates non-Java providers to the /// generic engine behavior. @@ -471,6 +501,10 @@ mod tests { assert!(workspace_configuration("rust", &[]).is_none()); assert!(initialized_notification("rust").is_none()); assert!(virtual_source_resolve_params("rust", "jdt://contents/A.class").is_none()); + assert_eq!( + adapt_initialization_options("rust", Some(json!({ "custom": true }))), + Some(json!({ "custom": true })) + ); let location = ProviderLocation { uri: "jdt://contents/A.class".to_string(), is_read_only: false, @@ -479,6 +513,31 @@ mod tests { assert_eq!(normalize_location("rust", location.clone()), location); } + #[test] + fn java_initialization_enables_class_file_content_without_losing_catalog_options() { + let options = adapt_initialization_options( + "JAVA", + Some(json!({ + "workspace": { "custom": true }, + "extendedClientCapabilities": { + "customCapability": true, + "classFileContentsSupport": false + } + })), + ) + .unwrap(); + + assert_eq!(options["workspace"]["custom"], true); + assert_eq!( + options["extendedClientCapabilities"]["customCapability"], + true + ); + assert_eq!( + options["extendedClientCapabilities"]["classFileContentsSupport"], + true + ); + } + #[test] fn java_workspace_configuration_matches_each_section_shape() { let items = [ diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 1cc6fd1ab..39ca2240a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -338,6 +338,10 @@ command fields, and returns `{ operationId }`. Supported operations include completion, hover, definition/declaration/type-definition, references, implementation, rename, formatting, code actions and resolve, execute command, inlay hints, folding ranges, code lens, and provider virtual documents. +The `virtualDocument` operation accepts `{ sessionId, operation, +virtualUri }` without a document `uri`. Its terminal `requestCompleted` event +returns `{ text }`, where `text` is the provider-resolved UTF-8 source for the +opaque virtual URI. `lsp.pollEvents` drains events ordered by per-session `sequence`. Event types include `stateChanged`, `featuresChanged`, `diagnostics`, diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index bee2eca18..721820fe5 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -53,6 +53,7 @@ import { toggleCaseText } from "../utils/text-operations"; import { editorAPI } from "../extensions/api"; import type { EditorModelPositionResolver } from "../view-model/view-layout"; import { syncContainedEditorFontOptions } from "../engines/monaco/contained-editors"; +import { registerMonacoDefinitionLinkGesture } from "../engines/monaco/definition-link"; import { consumeLocalContentSnapshot, rememberLocalContentSnapshot, @@ -699,6 +700,12 @@ export function MonacoEditor({ requestAnimationFrame(syncNestedEditorFonts); editor.addCommand(KeyMod.CtrlCmd | KeyCode.KeyA, selectEntireModel); + const definitionLinkDisposable = registerMonacoDefinitionLinkGesture({ + editor, + model, + filePath, + workspaceRoot: rootFolderPath, + }); const handleWindowSelectAllShortcut = (event: KeyboardEvent) => { const isSelectAllShortcut = @@ -834,6 +841,7 @@ export function MonacoEditor({ syncBottomScrollPadding(info.height); scheduleMonacoHoverClamp(); }), + definitionLinkDisposable, ]; const handleWindowMouseUp = () => { @@ -945,6 +953,7 @@ export function MonacoEditor({ readOnly, renderIndentGuides, renderWhitespace, + rootFolderPath, scrollable, scheduleInlineGitBlameRender, selectEntireModel, diff --git a/windows/tauri/src/features/editor/engines/monaco/definition-link.ts b/windows/tauri/src/features/editor/engines/monaco/definition-link.ts new file mode 100644 index 000000000..d2769a042 --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/definition-link.ts @@ -0,0 +1,130 @@ +import { editor as monacoEditor, Range as MonacoRange } from "monaco-editor"; +import type * as Monaco from "monaco-editor"; +import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; +import { LspClient } from "@/features/editor/lsp/lsp-client"; +import { resolveLombokAccessorDefinition } from "@/features/editor/lsp/lombok-accessor-navigation"; +import { logger } from "@/features/editor/utils/logger"; +import { isEditorGoToDefinitionModifierActive } from "@/features/editor/utils/go-to-definition-gesture"; + +interface MonacoDefinitionLinkOptions { + editor: Monaco.editor.IStandaloneCodeEditor; + model: Monaco.editor.ITextModel; + filePath: string; + workspaceRoot?: string; +} + +export function registerMonacoDefinitionLinkGesture({ + editor, + model, + filePath, + workspaceRoot, +}: MonacoDefinitionLinkOptions): Monaco.IDisposable { + const decorations = editor.createDecorationsCollection(); + let requestVersion = 0; + let hoveredPosition: Monaco.Position | null = null; + let resolvedWordKey = ""; + + const clearLink = () => { + if (!resolvedWordKey && decorations.length === 0) return; + requestVersion += 1; + resolvedWordKey = ""; + decorations.clear(); + }; + + const showLink = async (position: Monaco.Position) => { + if (!filePath || !isEditorLspSupported(filePath)) { + clearLink(); + return; + } + + const word = model.getWordAtPosition(position); + if (!word) { + clearLink(); + return; + } + + const wordKey = `${position.lineNumber}:${word.startColumn}:${word.endColumn}`; + if (resolvedWordKey === wordKey) return; + resolvedWordKey = wordKey; + decorations.clear(); + const request = ++requestVersion; + + try { + const line = position.lineNumber - 1; + const character = position.column - 1; + const locations = await LspClient.getInstance().getDefinition(filePath, line, character); + const lombokDefinition = + (!locations || locations.length === 0) && model.getLanguageId() === "java" && workspaceRoot + ? await resolveLombokAccessorDefinition({ + source: model.getValue(), + sourceFilePath: filePath, + workspaceRoot, + line, + character, + }) + : null; + if (request !== requestVersion || model.isDisposed()) return; + if ((!locations || locations.length === 0) && !lombokDefinition) return; + + decorations.set([ + { + range: new MonacoRange( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn, + ), + options: { inlineClassName: "goto-definition-link" }, + }, + ]); + } catch (error) { + if (request !== requestVersion) return; + logger.error("DefinitionLink", "Could not resolve definition link:", error); + } + }; + + const syncLinkForModifier = (event: { + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + }) => { + if (hoveredPosition && isEditorGoToDefinitionModifierActive(event)) { + void showLink(hoveredPosition); + } else { + clearLink(); + } + }; + + const disposables = [ + editor.onMouseMove((event) => { + if ( + event.target.type !== monacoEditor.MouseTargetType.CONTENT_TEXT || + !event.target.position + ) { + hoveredPosition = null; + clearLink(); + return; + } + + hoveredPosition = event.target.position; + syncLinkForModifier(event.event); + }), + editor.onMouseLeave(() => { + hoveredPosition = null; + clearLink(); + }), + editor.onKeyDown((event) => syncLinkForModifier(event.browserEvent)), + editor.onKeyUp((event) => syncLinkForModifier(event.browserEvent)), + editor.onDidChangeModelContent(clearLink), + editor.onDidBlurEditorWidget(clearLink), + ]; + + return { + dispose() { + requestVersion += 1; + decorations.clear(); + for (const disposable of disposables) disposable.dispose(); + }, + }; +} diff --git a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts index 7dbd54448..acd46e340 100644 --- a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts +++ b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts @@ -41,6 +41,12 @@ function toMonacoRange(range: { ); } +function toMonacoLocationUri(uri: string): Monaco.Uri { + return uri.startsWith("file://") || !uri.includes("://") + ? Uri.file(filePathFromUri(uri)) + : Uri.parse(uri); +} + function toMonacoTextEdit(edit: LspTextEdit): Monaco.languages.TextEdit { return { range: toMonacoRange(edit.range), @@ -261,7 +267,7 @@ export function registerMonacoLspProviders() { position.column - 1, ); return (locations ?? []).map((location) => ({ - uri: Uri.file(filePathFromUri(location.uri)), + uri: toMonacoLocationUri(location.uri), range: toMonacoRange(location.range), })); }, @@ -277,7 +283,7 @@ export function registerMonacoLspProviders() { position.column - 1, ); return (locations ?? []).map((location) => ({ - uri: Uri.file(filePathFromUri(location.uri)), + uri: toMonacoLocationUri(location.uri), range: toMonacoRange(location.range), })); }, @@ -293,7 +299,7 @@ export function registerMonacoLspProviders() { position.column - 1, ); return (locations ?? []).map((location) => ({ - uri: Uri.file(filePathFromUri(location.uri)), + uri: toMonacoLocationUri(location.uri), range: toMonacoRange(location.range), })); }, @@ -309,7 +315,7 @@ export function registerMonacoLspProviders() { position.column - 1, ); return (locations ?? []).map((location) => ({ - uri: Uri.file(filePathFromUri(location.uri)), + uri: toMonacoLocationUri(location.uri), range: toMonacoRange(location.range), })); }, diff --git a/windows/tauri/src/features/editor/engines/monaco/theme.ts b/windows/tauri/src/features/editor/engines/monaco/theme.ts index 0e5daf283..e23bf3791 100644 --- a/windows/tauri/src/features/editor/engines/monaco/theme.ts +++ b/windows/tauri/src/features/editor/engines/monaco/theme.ts @@ -74,6 +74,7 @@ function createMonacoThemeData( colors: { "editor.background": background, "editor.foreground": foreground, + "editorLink.activeForeground": accent, "editorCursor.foreground": cursor, "editor.selectionBackground": selection, "editor.inactiveSelectionBackground": selected, diff --git a/windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.test.ts b/windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.test.ts new file mode 100644 index 000000000..3d427117d --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + lombokAccessorTargetAtPosition, + resolveLombokAccessorDefinition, +} from "./lombok-accessor-navigation"; + +const source = `package com.score.controller; +import com.score.entity.SysUser; +class AuthController { + void find() { query.eq(SysUser::getUsername, "admin"); } +}`; + +function dependencies( + targetSource: string, + files = ["src/main/java/com/score/entity/SysUser.java"], +) { + const findSourceDefinition = mock( + async (_source: string, declarationName: string, memberName?: string) => { + const lines = targetSource.split(/\r?\n/); + const expression = memberName + ? new RegExp(`\\b${memberName}\\s*(?:=|;)`) + : new RegExp(`\\b(?:class|interface|enum|record)\\s+${declarationName}\\b`); + const line = lines.findIndex((value) => expression.test(value)); + if (line < 0) return null; + const token = memberName ?? declarationName; + return { line, utf16Column: lines[line].indexOf(token) }; + }, + ); + return { + listWorkspaceFiles: mock(async () => files), + readSource: mock(async () => targetSource), + findSourceDefinition, + }; +} + +describe("Lombok accessor navigation", () => { + test("recognizes JavaBeans method references only at the accessor token", () => { + const line = source.split("\n")[3]; + const character = line.indexOf("getUsername") + 2; + expect(lombokAccessorTargetAtPosition(source, 3, character)).toEqual({ + declarationName: "SysUser", + fieldName: "username", + kind: "getter", + }); + expect(lombokAccessorTargetAtPosition(source, 3, line.indexOf("query"))).toBeNull(); + }); + + test("infers the declared type of a simple instance receiver", () => { + const instanceSource = `class AuthController { + SysUser currentUser; + void authenticate(SysUser user) { + SysUser shadowed = user; + if (user.getStatus() == null || shadowed.getUsername() == null) {} + } +}`; + const lineText = instanceSource.split("\n")[4]; + + expect( + lombokAccessorTargetAtPosition(instanceSource, 4, lineText.indexOf("getStatus") + 2), + ).toEqual({ + declarationName: "SysUser", + fieldName: "status", + kind: "getter", + }); + expect( + lombokAccessorTargetAtPosition(instanceSource, 4, lineText.indexOf("getUsername") + 2), + ).toEqual({ + declarationName: "SysUser", + fieldName: "username", + kind: "getter", + }); + }); + + test("ignores same-name declarations from a closed method scope", () => { + const instanceSource = `class AuthController { + SysUser user; + void unrelated(OtherUser user) { + OtherUser local = user; + } + void authenticate() { + user.getStatus(); + } +}`; + const lineText = instanceSource.split("\n")[6]; + + expect( + lombokAccessorTargetAtPosition(instanceSource, 6, lineText.indexOf("getStatus") + 2), + ).toEqual({ + declarationName: "SysUser", + fieldName: "status", + kind: "getter", + }); + expect( + lombokAccessorTargetAtPosition(instanceSource, 6, lineText.indexOf("user") + 2), + ).toBeNull(); + }); + + test("does not guess instance receiver types or match non-code text", () => { + const instanceSource = `class AuthController { + void authenticate() { + unknown.getStatus(); + this.user.getStatus(); + user.refresh(); + // SysUser fake; fake.getStatus(); + String message = "SysUser fake; fake.getStatus()"; + } +}`; + + expect(lombokAccessorTargetAtPosition(instanceSource, 2, 17)).toBeNull(); + expect(lombokAccessorTargetAtPosition(instanceSource, 3, 19)).toBeNull(); + expect(lombokAccessorTargetAtPosition(instanceSource, 4, 10)).toBeNull(); + expect(lombokAccessorTargetAtPosition(instanceSource, 5, 30)).toBeNull(); + expect(lombokAccessorTargetAtPosition(instanceSource, 6, 42)).toBeNull(); + }); + + test("preserves JavaBeans acronym field names", () => { + expect(lombokAccessorTargetAtPosition("class A { Fn f = User::getURL; }", 0, 29)).toEqual({ + declarationName: "User", + fieldName: "URL", + kind: "getter", + }); + }); + + test("resolves a uniquely imported Lombok field declaration", async () => { + const targetSource = `package com.score.entity; +import lombok.Data; +@Data +public class SysUser { + private Long id; + private String username; +}`; + const deps = dependencies(targetSource, [ + "other/src/SysUser.java", + "score-management-backend/src/main/java/com/score/entity/SysUser.java", + ]); + const lineText = source.split("\n")[3]; + + const result = await resolveLombokAccessorDefinition( + { + source, + sourceFilePath: "F:/workspace/score-management-backend/src/AuthController.java", + workspaceRoot: "F:/workspace", + line: 3, + character: lineText.indexOf("getUsername") + 2, + }, + deps, + ); + + expect(result).toEqual({ + uri: "F:/workspace/score-management-backend/src/main/java/com/score/entity/SysUser.java", + filePath: "F:/workspace/score-management-backend/src/main/java/com/score/entity/SysUser.java", + range: { + start: { line: 5, character: 17 }, + end: { line: 5, character: 17 }, + }, + }); + expect(deps.findSourceDefinition).toHaveBeenCalledWith(targetSource, "SysUser", "username"); + }); + + test("resolves a Lombok field from an instance accessor", async () => { + const instanceSource = `package com.score.controller; +import com.score.entity.SysUser; +class AuthController { + void authenticate() { + SysUser user = findUser(); + if (user.getStatus() != 1) {} + } +}`; + const targetSource = `package com.score.entity; +import lombok.Data; +@Data +public class SysUser { + private Integer status; +}`; + const deps = dependencies(targetSource); + const lineText = instanceSource.split("\n")[5]; + + const result = await resolveLombokAccessorDefinition( + { + source: instanceSource, + sourceFilePath: "F:/workspace/src/AuthController.java", + workspaceRoot: "F:/workspace", + line: 5, + character: lineText.indexOf("getStatus") + 2, + }, + deps, + ); + + expect(result).toEqual({ + uri: "F:/workspace/src/main/java/com/score/entity/SysUser.java", + filePath: "F:/workspace/src/main/java/com/score/entity/SysUser.java", + range: { + start: { line: 4, character: 18 }, + end: { line: 4, character: 18 }, + }, + }); + }); + + test("does not guess for non-Lombok or ambiguous target types", async () => { + const lineText = source.split("\n")[3]; + const options = { + source, + sourceFilePath: "F:/workspace/src/AuthController.java", + workspaceRoot: "F:/workspace", + line: 3, + character: lineText.indexOf("getUsername") + 2, + }; + const plainJava = dependencies("public class SysUser { private String username; }"); + expect(await resolveLombokAccessorDefinition(options, plainJava)).toBeNull(); + expect(plainJava.findSourceDefinition).not.toHaveBeenCalled(); + + const ambiguous = dependencies("@lombok.Data class SysUser {}", [ + "one/SysUser.java", + "two/SysUser.java", + ]); + expect(await resolveLombokAccessorDefinition(options, ambiguous)).toBeNull(); + expect(ambiguous.readSource).not.toHaveBeenCalled(); + }); + + test("does not apply a field annotation to a different field", async () => { + const targetSource = `package com.score.entity; +import lombok.Getter; +public class SysUser { + @Getter private String displayName; + private String username; +}`; + const deps = dependencies(targetSource); + const lineText = source.split("\n")[3]; + + const result = await resolveLombokAccessorDefinition( + { + source, + sourceFilePath: "F:/workspace/src/AuthController.java", + workspaceRoot: "F:/workspace", + line: 3, + character: lineText.indexOf("getUsername") + 2, + }, + deps, + ); + + expect(result).toBeNull(); + }); + + test("does not select a lone same-name type from a different package", async () => { + const externalSource = source.replace( + "import com.score.entity.SysUser;", + "import vendor.model.SysUser;", + ); + const targetSource = `package com.score.entity; +import lombok.Data; +@Data +public class SysUser { + private String username; +}`; + const deps = dependencies(targetSource); + const lineText = externalSource.split("\n")[3]; + + const result = await resolveLombokAccessorDefinition( + { + source: externalSource, + sourceFilePath: "F:/workspace/src/AuthController.java", + workspaceRoot: "F:/workspace", + line: 3, + character: lineText.indexOf("getUsername") + 2, + }, + deps, + ); + + expect(result).toBeNull(); + expect(deps.readSource).not.toHaveBeenCalled(); + }); + + test("requires a primitive boolean field for an is-accessor", async () => { + const booleanReference = source.replace("getUsername", "isUsername"); + const targetSource = `package com.score.entity; +import lombok.Data; +@Data +public class SysUser { + private Boolean username; +}`; + const deps = dependencies(targetSource); + const lineText = booleanReference.split("\n")[3]; + + const result = await resolveLombokAccessorDefinition( + { + source: booleanReference, + sourceFilePath: "F:/workspace/src/AuthController.java", + workspaceRoot: "F:/workspace", + line: 3, + character: lineText.indexOf("isUsername") + 2, + }, + deps, + ); + + expect(result).toBeNull(); + }); +}); diff --git a/windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.ts b/windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.ts new file mode 100644 index 000000000..48ef51094 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.ts @@ -0,0 +1,311 @@ +import { executeCore } from "@/core/lithe-core-client"; +import { readFileContent } from "@/features/file-system/controllers/file-operations"; +import { joinPath, normalizePath, pathStartsWithRoot } from "@/utils/path-helpers"; +import type { LspLocation } from "./lsp-client"; + +interface JavaSourceDefinition { + line: number; + utf16Column: number; +} + +interface LombokAccessorTarget { + declarationName: string; + fieldName: string; + kind: "getter" | "boolean-getter" | "setter"; +} + +interface LombokNavigationDependencies { + listWorkspaceFiles: (root: string) => Promise; + readSource: (filePath: string) => Promise; + findSourceDefinition: ( + source: string, + declarationName: string, + memberName?: string, + ) => Promise; +} + +interface ResolveLombokAccessorDefinitionOptions { + source: string; + sourceFilePath: string; + workspaceRoot: string; + line: number; + character: number; +} + +function coreData(response: Awaited>>): T { + if (response.ok) return response.data; + throw new Error(response.error.message); +} + +function javaBeansFieldName(propertyName: string): string { + if (propertyName.length > 1 && /[A-Z]/.test(propertyName[0]) && /[A-Z]/.test(propertyName[1])) { + return propertyName; + } + return `${propertyName[0].toLowerCase()}${propertyName.slice(1)}`; +} + +function maskJavaCommentsAndStrings(source: string): string { + let masked = ""; + let state: "code" | "line-comment" | "block-comment" | "string" | "character" = "code"; + let escaped = false; + + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + const nextCharacter = source[index + 1]; + if (character === "\r" || character === "\n") { + masked += character; + if (state === "line-comment") state = "code"; + continue; + } + + if (state === "code") { + if (character === "/" && nextCharacter === "/") { + masked += " "; + index += 1; + state = "line-comment"; + } else if (character === "/" && nextCharacter === "*") { + masked += " "; + index += 1; + state = "block-comment"; + } else if (character === '"') { + masked += " "; + state = "string"; + escaped = false; + } else if (character === "'") { + masked += " "; + state = "character"; + escaped = false; + } else { + masked += character; + } + continue; + } + + if (state === "block-comment" && character === "*" && nextCharacter === "/") { + masked += " "; + index += 1; + state = "code"; + continue; + } + + if (state === "string" || state === "character") { + const delimiter = state === "string" ? '"' : "'"; + if (!escaped && character === delimiter) state = "code"; + escaped = !escaped && character === "\\"; + } + masked += " "; + } + + return masked; +} + +function openBraceStackAt(source: string, endOffset: number): number[] { + const stack: number[] = []; + for (let index = 0; index < endOffset; index += 1) { + if (source[index] === "{") stack.push(index); + if (source[index] === "}") stack.pop(); + } + return stack; +} + +function isScopePrefix(candidateScope: number[], cursorScope: number[]): boolean { + return candidateScope.every((braceOffset, index) => cursorScope[index] === braceOffset); +} + +function declaredReceiverType(sourcePrefix: string, receiverName: string): string | null { + const escapedReceiver = receiverName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const declaration = new RegExp( + `(?= 0 && + cursorScope.includes(nextBrace) && + (nextSemicolon < 0 || nextBrace < nextSemicolon); + } + + if (visible) declarationName = match[1]; + } + return declarationName; +} + +export function lombokAccessorTargetAtPosition( + source: string, + line: number, + character: number, +): LombokAccessorTarget | null { + const maskedLines = maskJavaCommentsAndStrings(source).split(/\r?\n/); + const lineText = maskedLines[line]; + if (!lineText || character < 0) return null; + + const methodReference = /\b([A-Z][A-Za-z0-9_$]*)\s*::\s*((get|set|is)([A-Z][A-Za-z0-9_$]*))/g; + let match: RegExpExecArray | null; + while ((match = methodReference.exec(lineText))) { + const accessorStart = match.index + match[0].lastIndexOf(match[2]); + const accessorEnd = accessorStart + match[2].length; + if (character < accessorStart || character > accessorEnd) continue; + const prefix = match[3]; + return { + declarationName: match[1], + fieldName: javaBeansFieldName(match[4]), + kind: prefix === "set" ? "setter" : prefix === "is" ? "boolean-getter" : "getter", + }; + } + + const instanceAccessor = + /(? accessorEnd) continue; + + const sourcePrefix = [...maskedLines.slice(0, line), lineText.slice(0, match.index)].join("\n"); + const declarationName = declaredReceiverType(sourcePrefix, match[1]); + if (!declarationName) return null; + + const prefix = match[3]; + return { + declarationName, + fieldName: javaBeansFieldName(match[4]), + kind: prefix === "set" ? "setter" : prefix === "is" ? "boolean-getter" : "getter", + }; + } + + return null; +} + +function importedTypeName(source: string, declarationName: string): string | null { + const escapedName = declarationName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const explicitImport = new RegExp( + `^\\s*import\\s+([A-Za-z_$][A-Za-z0-9_$.]*\\.${escapedName})\\s*;`, + "m", + ).exec(source)?.[1]; + if (explicitImport) return explicitImport; + + const packageName = /^\s*package\s+([A-Za-z_$][A-Za-z0-9_$.]*)\s*;/m.exec(source)?.[1]; + return packageName ? `${packageName}.${declarationName}` : declarationName; +} + +function selectTypeSourcePath( + source: string, + declarationName: string, + workspaceFiles: string[], +): string | null { + const expectedName = `${declarationName}.java`; + const candidates = workspaceFiles + .map(normalizePath) + .filter((path) => path.split("/").pop() === expectedName) + .sort((left, right) => left.localeCompare(right)); + if (candidates.length === 0) return null; + + const qualifiedName = importedTypeName(source, declarationName); + if (!qualifiedName) return null; + const expectedSuffix = `${qualifiedName.replace(/\./g, "/")}.java`; + const exactMatches = candidates.filter((path) => path.endsWith(expectedSuffix)); + return exactMatches.length === 1 ? exactMatches[0] : null; +} + +function annotationPrefix(source: string, definition: JavaSourceDefinition): string { + const lines = source.split(/\r?\n/); + const declarationLine = lines[definition.line] ?? ""; + const precedingAnnotations: string[] = []; + for (let index = definition.line - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (!/^(?:@[A-Za-z_$][A-Za-z0-9_$.]*(?:\s*\([^)]*\))?\s*)+$/.test(line.trim())) break; + precedingAnnotations.unshift(line); + } + return [...precedingAnnotations, declarationLine.slice(0, definition.utf16Column)].join("\n"); +} + +function hasLombokAnnotation(source: string, prefix: string, annotation: string): boolean { + if (new RegExp(`@lombok\\.${annotation}\\b`).test(prefix)) return true; + if (!new RegExp(`@${annotation}\\b`).test(prefix)) return false; + return new RegExp(`^\\s*import\\s+lombok\\.(?:${annotation}|\\*)\\s*;`, "m").test(source); +} + +function hasLombokAccessor( + source: string, + target: LombokAccessorTarget, + typeDefinition: JavaSourceDefinition, + fieldDefinition: JavaSourceDefinition, +): boolean { + const fieldPrefix = annotationPrefix(source, fieldDefinition); + if (target.kind === "setter" && /\bfinal\b/.test(fieldPrefix)) return false; + if (target.kind === "boolean-getter" && !/\bboolean\s*$/.test(fieldPrefix)) return false; + + const annotation = target.kind === "setter" ? "Setter" : "Getter"; + const typePrefix = annotationPrefix(source, typeDefinition); + return ( + hasLombokAnnotation(source, typePrefix, "Data") || + hasLombokAnnotation(source, typePrefix, annotation) || + hasLombokAnnotation(source, fieldPrefix, annotation) + ); +} + +const defaultDependencies: LombokNavigationDependencies = { + async listWorkspaceFiles(root) { + const response = await executeCore<{ files: string[] }>({ + id: crypto.randomUUID(), + command: "workspace.snapshot", + payload: { root }, + }); + return coreData(response).files; + }, + readSource: readFileContent, + async findSourceDefinition(source, declarationName, memberName) { + const response = await executeCore({ + id: crypto.randomUUID(), + command: "java.sourceDefinition", + payload: { source, declarationName, memberName }, + }); + return coreData(response); + }, +}; + +export async function resolveLombokAccessorDefinition( + options: ResolveLombokAccessorDefinitionOptions, + dependencies: LombokNavigationDependencies = defaultDependencies, +): Promise { + if (!pathStartsWithRoot(options.sourceFilePath, options.workspaceRoot)) return null; + const target = lombokAccessorTargetAtPosition(options.source, options.line, options.character); + if (!target) return null; + + const workspaceFiles = await dependencies.listWorkspaceFiles(options.workspaceRoot); + const relativePath = selectTypeSourcePath(options.source, target.declarationName, workspaceFiles); + if (!relativePath) return null; + + const filePath = normalizePath(joinPath(options.workspaceRoot, relativePath)); + const targetSource = await dependencies.readSource(filePath); + if (!/@(?:lombok\.)?(?:Data|Getter|Setter)\b/.test(targetSource)) return null; + const definition = await dependencies.findSourceDefinition( + targetSource, + target.declarationName, + target.fieldName, + ); + if (!definition) return null; + const typeDefinition = await dependencies.findSourceDefinition( + targetSource, + target.declarationName, + ); + if (!typeDefinition || !hasLombokAccessor(targetSource, target, typeDefinition, definition)) { + return null; + } + + const position = { line: definition.line, character: definition.utf16Column }; + return { + uri: filePath, + filePath, + range: { start: position, end: position }, + }; +} diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 4df22d423..a16758f4b 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -38,6 +38,9 @@ export interface LspError { export interface LspLocation { uri: string; + filePath?: string | null; + displayPath?: string | null; + isReadOnly?: boolean; range: { start: { line: number; character: number }; end: { line: number; character: number }; @@ -463,7 +466,10 @@ export class LspClient { return; } - logger.debug("LSPClient", `Using LSP server: ${launch.serverPath} for language: ${launch.languageId}`); + logger.debug( + "LSPClient", + `Using LSP server: ${launch.serverPath} for language: ${launch.languageId}`, + ); const serverKey = `${workspacePath}:${launch.languageId}`; if (this.activeLanguageServers.has(serverKey)) { logger.debug("LSPClient", `LSP for ${launch.languageId} already running in workspace`); @@ -590,7 +596,10 @@ export class LspClient { } const languageId = launch.languageId; - logger.debug("LSPClient", `Using LSP server: ${launch.serverPath} for language: ${languageId}`); + logger.debug( + "LSPClient", + `Using LSP server: ${launch.serverPath} for language: ${languageId}`, + ); const serverKey = `${workspacePath}:${languageId}`; if (options.forceRetry) { @@ -907,6 +916,18 @@ export class LspClient { ); } + async getVirtualDocument(filePath: string, virtualUri: string): Promise { + try { + return await invoke("lsp_get_virtual_document", { + filePath, + virtualUri, + }); + } catch (error) { + logger.error("LSPClient", `LSP virtual document error for ${virtualUri}:`, error); + return null; + } + } + async getSemanticTokens(filePath: string): Promise { try { return await invoke("lsp_get_semantic_tokens", { filePath }); diff --git a/windows/tauri/src/features/editor/lsp/navigation-target.test.ts b/windows/tauri/src/features/editor/lsp/navigation-target.test.ts new file mode 100644 index 000000000..908f074ad --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/navigation-target.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { LspLocation } from "./lsp-client"; +import { openLspNavigationLocation } from "./navigation-target"; +import type { OpenContentSpec, PaneContent } from "@/features/panes/types/pane-content.types"; + +const range = { + start: { line: 1, character: 2 }, + end: { line: 1, character: 3 }, +}; + +function createOptions(location: LspLocation, buffers: PaneContent[] = []) { + const openContent = mock((_spec: OpenContentSpec) => "opened-buffer"); + const setActiveBuffer = mock((_bufferId: string) => undefined); + const getVirtualDocument = mock( + async (): Promise => "public final class String {}", + ); + const readFileContent = mock(async () => "class Main {}"); + + return { + options: { + location, + sourceFilePath: "C:/work/src/Main.java", + buffers, + actions: { openContent, setActiveBuffer }, + getVirtualDocument, + readFileContent, + }, + openContent, + setActiveBuffer, + getVirtualDocument, + readFileContent, + }; +} + +describe("LSP navigation targets", () => { + test("opens physical file locations with their Core-projected path", async () => { + const context = createOptions({ + uri: "file:///C:/work/src/Service.java", + filePath: "C:/work/src/Service.java", + range, + }); + + expect(await openLspNavigationLocation(context.options)).toBe("opened-buffer"); + expect(context.readFileContent).toHaveBeenCalledWith("C:/work/src/Service.java"); + expect(context.openContent).toHaveBeenCalledWith({ + type: "editor", + path: "C:/work/src/Service.java", + name: "Service.java", + content: "class Main {}", + }); + expect(context.getVirtualDocument).not.toHaveBeenCalled(); + }); + + test("normalizes a leading slash from Windows file URIs", async () => { + const context = createOptions({ + uri: "file:///C:/work/src/Service.java", + filePath: "/C:/work/src/Service.java", + range, + }); + + await openLspNavigationLocation(context.options); + + expect(context.readFileContent).toHaveBeenCalledWith("C:/work/src/Service.java"); + }); + + test("opens JDT class files as read-only Java buffers", async () => { + const location: LspLocation = { + uri: "jdt://contents/java.base/java/lang/String.class?=demo", + filePath: null, + displayPath: "java.base/java/lang/String.java", + isReadOnly: true, + range, + }; + const context = createOptions(location); + + expect(await openLspNavigationLocation(context.options)).toBe("opened-buffer"); + expect(context.getVirtualDocument).toHaveBeenCalledWith("C:/work/src/Main.java", location.uri); + expect(context.openContent).toHaveBeenCalledWith({ + type: "editor", + path: location.uri, + name: "String.java", + content: "public final class String {}", + isVirtual: true, + readOnly: true, + language: "java", + }); + expect(context.readFileContent).not.toHaveBeenCalled(); + }); + + test("reuses an open virtual buffer without resolving it again", async () => { + const uri = "jdt://contents/java.base/java/lang/String.class?=demo"; + const existing = { id: "existing-buffer", path: uri } as PaneContent; + const context = createOptions({ uri, filePath: null, range }, [existing]); + + expect(await openLspNavigationLocation(context.options)).toBe("existing-buffer"); + expect(context.setActiveBuffer).toHaveBeenCalledWith("existing-buffer"); + expect(context.getVirtualDocument).not.toHaveBeenCalled(); + expect(context.openContent).not.toHaveBeenCalled(); + }); + + test("does not open a buffer when virtual source is unavailable", async () => { + const context = createOptions({ + uri: "jdt://contents/java.base/java/lang/String.class?=demo", + filePath: null, + range, + }); + context.getVirtualDocument.mockImplementation(async () => null); + + expect(await openLspNavigationLocation(context.options)).toBeNull(); + expect(context.openContent).not.toHaveBeenCalled(); + expect(context.setActiveBuffer).not.toHaveBeenCalled(); + }); +}); diff --git a/windows/tauri/src/features/editor/lsp/navigation-target.ts b/windows/tauri/src/features/editor/lsp/navigation-target.ts new file mode 100644 index 000000000..bfafbe193 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/navigation-target.ts @@ -0,0 +1,84 @@ +import type { LspLocation } from "./lsp-client"; +import { filePathFromUri } from "./workspace-edit"; +import type { OpenContentSpec, PaneContent } from "@/features/panes/types/pane-content.types"; +import { getBaseName, normalizePath } from "@/utils/path-helpers"; + +interface NavigationBufferActions { + openContent: (spec: OpenContentSpec) => string; + setActiveBuffer: (bufferId: string) => void; +} + +interface OpenLspNavigationLocationOptions { + location: LspLocation; + sourceFilePath: string; + buffers: readonly PaneContent[]; + actions: NavigationBufferActions; + getVirtualDocument: (sourceFilePath: string, virtualUri: string) => Promise; + readFileContent: (filePath: string) => Promise; +} + +function physicalPath(location: LspLocation): string | null { + const explicitPath = location.filePath?.trim(); + const uriPath = + location.uri.startsWith("file://") || !location.uri.includes("://") + ? filePathFromUri(location.uri) + : null; + const path = explicitPath || uriPath; + if (!path) return null; + + const normalized = normalizePath(path); + return /^\/[A-Za-z]:\//.test(normalized) ? normalized.slice(1) : normalized; +} + +function virtualDocumentName(location: LspLocation): string { + const displayPath = location.displayPath?.trim(); + const identityWithoutQuery = location.uri.split(/[?#]/, 1)[0]; + return getBaseName(displayPath || identityWithoutQuery, "Decompiled.java").replace( + /\.class$/i, + ".java", + ); +} + +export async function openLspNavigationLocation({ + location, + sourceFilePath, + buffers, + actions, + getVirtualDocument, + readFileContent, +}: OpenLspNavigationLocationOptions): Promise { + const filePath = physicalPath(location); + const targetPath = filePath ?? location.uri; + const existingBuffer = buffers.find((buffer) => buffer.path === targetPath); + if (existingBuffer) { + actions.setActiveBuffer(existingBuffer.id); + return existingBuffer.id; + } + + if (filePath) { + const content = await readFileContent(filePath); + const bufferId = actions.openContent({ + type: "editor", + path: filePath, + name: getBaseName(filePath), + content, + }); + actions.setActiveBuffer(bufferId); + return bufferId; + } + + const content = await getVirtualDocument(sourceFilePath, location.uri); + if (content == null) return null; + + const bufferId = actions.openContent({ + type: "editor", + path: location.uri, + name: virtualDocumentName(location), + content, + isVirtual: true, + readOnly: true, + language: "java", + }); + actions.setActiveBuffer(bufferId); + return bufferId; +} diff --git a/windows/tauri/src/features/editor/utils/go-to-definition-gesture.test.ts b/windows/tauri/src/features/editor/utils/go-to-definition-gesture.test.ts index f58cf8ff7..ec2e72cfc 100644 --- a/windows/tauri/src/features/editor/utils/go-to-definition-gesture.test.ts +++ b/windows/tauri/src/features/editor/utils/go-to-definition-gesture.test.ts @@ -1,14 +1,19 @@ import { describe, expect, test } from "bun:test"; -import { isEditorGoToDefinitionModifierClick } from "./go-to-definition-gesture"; +import { + isEditorGoToDefinitionModifierActive, + isEditorGoToDefinitionModifierClick, +} from "./go-to-definition-gesture"; describe("IDEA-style go to definition click", () => { + test("recognizes the unmodified Ctrl or Cmd hover modifier", () => { + expect(isEditorGoToDefinitionModifierActive({ ctrlKey: true })).toBe(true); + expect(isEditorGoToDefinitionModifierActive({ metaKey: true })).toBe(true); + expect(isEditorGoToDefinitionModifierActive({ ctrlKey: true, shiftKey: true })).toBe(false); + }); + test("accepts unmodified Ctrl or Cmd left clicks", () => { - expect( - isEditorGoToDefinitionModifierClick({ leftButton: true, ctrlKey: true }), - ).toBe(true); - expect( - isEditorGoToDefinitionModifierClick({ leftButton: true, metaKey: true }), - ).toBe(true); + expect(isEditorGoToDefinitionModifierClick({ leftButton: true, ctrlKey: true })).toBe(true); + expect(isEditorGoToDefinitionModifierClick({ leftButton: true, metaKey: true })).toBe(true); }); test("ignores right clicks and extra modifiers", () => { diff --git a/windows/tauri/src/features/editor/utils/go-to-definition-gesture.ts b/windows/tauri/src/features/editor/utils/go-to-definition-gesture.ts index 8e32fa117..d4acad574 100644 --- a/windows/tauri/src/features/editor/utils/go-to-definition-gesture.ts +++ b/windows/tauri/src/features/editor/utils/go-to-definition-gesture.ts @@ -1,14 +1,18 @@ -export function isEditorGoToDefinitionModifierClick(event: { - leftButton?: boolean; +interface EditorGoToDefinitionModifierEvent { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean; -}): boolean { - return Boolean( - event.leftButton && - (event.ctrlKey || event.metaKey) && - !event.altKey && - !event.shiftKey, - ); +} + +export function isEditorGoToDefinitionModifierActive( + event: EditorGoToDefinitionModifierEvent, +): boolean { + return Boolean((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey); +} + +export function isEditorGoToDefinitionModifierClick( + event: EditorGoToDefinitionModifierEvent & { leftButton?: boolean }, +): boolean { + return Boolean(event.leftButton && isEditorGoToDefinitionModifierActive(event)); } diff --git a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts index ad4a3f1ca..1ed50c478 100644 --- a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts @@ -12,6 +12,9 @@ import { useReferencesStore } from "@/features/references/stores/references.stor import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { languageIdForEditorFile } from "@/features/editor/lsp/built-in-language-support"; import { languageServerUnavailableMessage } from "@/features/editor/lsp/language-server-navigation"; +import { resolveLombokAccessorDefinition } from "@/features/editor/lsp/lombok-accessor-navigation"; +import { openLspNavigationLocation } from "@/features/editor/lsp/navigation-target"; +import type { LspLocation } from "@/features/editor/lsp/lsp-client"; import { useLspStore } from "@/features/editor/lsp/stores/lsp.store"; import { useSpringStore } from "@/features/spring/stores/spring.store"; import type { SpringNavigationLocation } from "@/features/spring/types/spring.types"; @@ -20,35 +23,30 @@ import { resolveSpringReferences, } from "@/features/spring/utils/spring-navigation"; import { useUIState } from "@/features/window/stores/ui-state.store"; +import { useProjectStore } from "@/features/window/stores/project.store"; import { createTranslator } from "@/i18n/locale"; -import { getBaseName, normalizePath } from "@/utils/path-helpers"; +import { logger } from "@/features/editor/utils/logger"; +import { normalizePath } from "@/utils/path-helpers"; import { showPromptDialog } from "@/ui/dialog"; import { toast } from "sonner"; -type LspNavigationLocation = { - uri: string; - range: { - start: { line: number; character: number }; - end: { line: number; character: number }; - }; -}; - type LspNavigationClient = { getDefinition: ( filePath: string, line: number, character: number, - ) => Promise; + ) => Promise; getImplementation: ( filePath: string, line: number, character: number, - ) => Promise; + ) => Promise; getTypeDefinition: ( filePath: string, line: number, character: number, - ) => Promise; + ) => Promise; + getVirtualDocument: (filePath: string, virtualUri: string) => Promise; }; const getCurrentTranslator = () => @@ -65,7 +63,9 @@ function translateNavigationLabel(label: string): string { function activeEditorNavigationContext() { const bufferStore = useBufferStore.getState(); - const activeBuffer = bufferStore.buffers.find((buffer) => buffer.id === bufferStore.activeBufferId); + const activeBuffer = bufferStore.buffers.find( + (buffer) => buffer.id === bufferStore.activeBufferId, + ); const editorState = useEditorStateStore.getState(); if (!activeBuffer || activeBuffer.type !== "editor" || !activeBuffer.path) return null; return { bufferStore, activeBuffer, editorState }; @@ -81,10 +81,15 @@ function isCurrentNavigationTarget( targetPath: string, targetLine: number, ): boolean { - return canonicalizeEditorPath(filePath) === canonicalizeEditorPath(targetPath) && line === targetLine; + return ( + canonicalizeEditorPath(filePath) === canonicalizeEditorPath(targetPath) && line === targetLine + ); } -function unavailableLanguageServerToast(filePath: string, lspClient: { hasSessionForFile(path: string): boolean }): string | null { +function unavailableLanguageServerToast( + filePath: string, + lspClient: { hasSessionForFile(path: string): boolean }, +): string | null { const status = useLspStore.getState().lspStatus; return languageServerUnavailableMessage({ languageId: languageIdForEditorFile(filePath), @@ -94,7 +99,9 @@ function unavailableLanguageServerToast(filePath: string, lspClient: { hasSessio }); } -function springLocationsForActiveFile(kind: "definition" | "references"): SpringNavigationLocation[] { +function springLocationsForActiveFile( + kind: "definition" | "references", +): SpringNavigationLocation[] { const context = activeEditorNavigationContext(); const springState = useSpringStore.getState(); if (!context || !springState.root) return []; @@ -196,13 +203,12 @@ async function goToActiveLspLocation( filePath: string, line: number, character: number, - ) => Promise, + ) => Promise, options: { requireLanguageServer?: boolean } = {}, ): Promise { - const [{ LspClient }, { readFileContent }, { filePathFromUri }] = await Promise.all([ + const [{ LspClient }, { readFileContent }] = await Promise.all([ import("@/features/editor/lsp/lsp-client"), import("@/features/file-system/controllers/file-operations"), - import("@/features/editor/lsp/workspace-edit"), ]); const lspClient = LspClient.getInstance(); @@ -221,13 +227,35 @@ async function goToActiveLspLocation( } } - const locations = await resolveLocations( + let locations = await resolveLocations( lspClient, activeBuffer.path, cursorPosition.line, cursorPosition.column, ); + if ( + (!locations || locations.length === 0) && + label === "definition" && + languageIdForEditorFile(activeBuffer.path) === "java" + ) { + const workspaceRoot = useProjectStore.getState().rootFolderPath; + if (workspaceRoot) { + try { + const fallback = await resolveLombokAccessorDefinition({ + source: activeBuffer.content, + sourceFilePath: activeBuffer.path, + workspaceRoot, + line: cursorPosition.line, + character: cursorPosition.column, + }); + if (fallback) locations = [fallback]; + } catch (error) { + logger.error("LombokNavigation", "Could not resolve generated accessor:", error); + } + } + } + if (!locations || locations.length === 0) { toast.info( getCurrentTranslator()("navigation.noTargetFound", { @@ -248,16 +276,22 @@ async function goToActiveLspLocation( }); const target = locations[0]; - const filePath = target.uri.includes("://") ? filePathFromUri(target.uri) : target.uri; - const existingBuffer = bufferStore.buffers.find((b) => b.path === filePath); - - if (existingBuffer) { - bufferStore.actions.setActiveBuffer(existingBuffer.id); - } else { - const content = await readFileContent(filePath); - const fileName = getBaseName(filePath); - const bufferId = bufferStore.actions.openBuffer(filePath, fileName, content); - bufferStore.actions.setActiveBuffer(bufferId); + const openedBufferId = await openLspNavigationLocation({ + location: target, + sourceFilePath: activeBuffer.path, + buffers: bufferStore.buffers, + actions: bufferStore.actions, + getVirtualDocument: (filePath, virtualUri) => + lspClient.getVirtualDocument(filePath, virtualUri), + readFileContent, + }); + if (!openedBufferId) { + toast.info( + getCurrentTranslator()("navigation.noTargetFound", { + target: translateNavigationLabel(label), + }), + ); + return; } setTimeout(() => { diff --git a/windows/tauri/src/platform/lsp-core-adapter.test.ts b/windows/tauri/src/platform/lsp-core-adapter.test.ts index c6dd4162f..6d11a8ca7 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.test.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, mock, test } from "bun:test"; +import { beforeEach, describe, expect, mock, test } from "bun:test"; const emit = mock(async () => undefined); const emitTo = mock(async () => undefined); @@ -24,60 +24,109 @@ const TauriEvent = { } as const; const frontendTrace = mock(() => undefined); const commands: string[] = []; +let scenario: "failure" | "virtual-document" = "failure"; let startPayload: Record | undefined; +let requestPayload: Record | undefined; let pollCount = 0; +let virtualDocumentPending = false; -const executeCore = mock(async (request: { - id: string; - command: string; - payload?: Record; -}) => { - commands.push(request.command); - if (request.command === "lsp.startServer") { - startPayload = request.payload; - return { - id: request.id, - ok: true as const, - data: { sessionId: "failed-java-session" }, - }; - } - if (request.command === "lsp.pollEvents") { - pollCount += 1; - return { - id: request.id, - ok: true as const, - data: { - events: - pollCount === 1 - ? [ +const executeCore = mock( + async (request: { id: string; command: string; payload?: Record }) => { + commands.push(request.command); + if (request.command === "lsp.startServer") { + startPayload = request.payload; + return { + id: request.id, + ok: true as const, + data: { + sessionId: scenario === "failure" ? "failed-java-session" : "java-session", + }, + }; + } + if (request.command === "lsp.pollEvents") { + pollCount += 1; + if (scenario === "virtual-document") { + if (pollCount === 1) { + return { + id: request.id, + ok: true as const, + data: { + events: [ { - type: "log", - level: "warning", - message: "Language-server stderr", - detail: "JDTLS failed before initialization", + type: "stateChanged", + state: "ready", providerId: "java", - sessionId: "failed-java-session", + sessionId: "java-session", }, + ], + }, + }; + } + if (virtualDocumentPending) { + virtualDocumentPending = false; + return { + id: request.id, + ok: true as const, + data: { + events: [ { - type: "stateChanged", - state: "failed", + type: "requestCompleted", providerId: "java", - sessionId: "failed-java-session", - error: { - code: "serverExited", - stage: "process", - message: "Language-server process exited.", - underlyingMessage: "JVM startup failed", - processExitCode: 13, - }, + sessionId: "java-session", + operationId: "virtual-document-operation", + result: { text: "public final class String {}" }, }, - ] - : [], - }, - }; - } - return { id: request.id, ok: true as const, data: null }; -}); + ], + }, + }; + } + return { id: request.id, ok: true as const, data: { events: [] } }; + } + return { + id: request.id, + ok: true as const, + data: { + events: + pollCount === 1 + ? [ + { + type: "log", + level: "warning", + message: "Language-server stderr", + detail: "JDTLS failed before initialization", + providerId: "java", + sessionId: "failed-java-session", + }, + { + type: "stateChanged", + state: "failed", + providerId: "java", + sessionId: "failed-java-session", + error: { + code: "serverExited", + stage: "process", + message: "Language-server process exited.", + underlyingMessage: "JVM startup failed", + processExitCode: 13, + }, + }, + ] + : [], + }, + }; + } + if (request.command === "lsp.request") { + requestPayload = request.payload; + virtualDocumentPending = true; + return { + id: request.id, + ok: true as const, + data: { operationId: "virtual-document-operation" }, + }; + } + return { id: request.id, ok: true as const, data: null }; + }, +); mock.module("@tauri-apps/api/event", () => ({ emit, emitTo, listen, once, TauriEvent })); mock.module("@/core/lithe-core-client", () => ({ executeCore })); @@ -86,6 +135,18 @@ mock.module("@/utils/frontend-trace", () => ({ frontendTrace })); const { invokeLsp } = await import("./lsp-core-adapter"); describe("Rust Core LSP adapter failures", () => { + beforeEach(() => { + scenario = "failure"; + commands.length = 0; + startPayload = undefined; + requestPayload = undefined; + pollCount = 0; + virtualDocumentPending = false; + emit.mockClear(); + frontendTrace.mockClear(); + executeCore.mockClear(); + }); + test("logs Core output, preserves failure details, and destroys the session", async () => { let failure: (Error & { code?: string; details?: string }) | null = null; try { @@ -121,4 +182,34 @@ describe("Rust Core LSP adapter failures", () => { "lsp.destroyServer", ]); }); + + test("resolves a provider virtual document without fabricating a file URI", async () => { + scenario = "virtual-document"; + const filePath = "C:/work/Main.java"; + const virtualUri = "jdt://contents/java.base/java/lang/String.class?=demo"; + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath, + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + + const text = await invokeLsp("lsp_get_virtual_document", { + filePath, + virtualUri, + }); + + expect(text).toBe("public final class String {}"); + expect(requestPayload).toEqual({ + sessionId: "java-session", + operation: "virtualDocument", + virtualUri, + }); + expect(requestPayload).not.toHaveProperty("uri"); + + await invokeLsp("lsp_stop_for_file", { filePath }); + expect(commands).toContain("lsp.stopServer"); + expect(commands).toContain("lsp.destroyServer"); + }); }); diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index 356209bb2..82e049227 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -14,10 +14,7 @@ interface Session { languageId: string; files: Set; running: boolean; - pending: Map< - string, - { resolve: (value: unknown) => void; reject: (reason: Error) => void } - >; + pending: Map void; reject: (reason: Error) => void }>; completed: Map; } @@ -229,8 +226,8 @@ async function start(args: JsonRecord): Promise { let session = sessions.get(key); if (!session) { const environment = { - ...(args.tools?.lsp?.env ?? {}), - ...(args.environment ?? {}), + ...args.tools?.lsp?.env, + ...args.environment, }; const started = await core<{ sessionId: string }>("lsp.startServer", { providerId, @@ -316,14 +313,19 @@ const operations: Record = { lsp_get_code_actions: "codeActions", lsp_get_inlay_hints: "inlayHints", lsp_get_code_lens: "codeLens", + lsp_get_virtual_document: "virtualDocument", }; function semanticPayload(command: string, args: JsonRecord, session: Session): JsonRecord { const payload: JsonRecord = { sessionId: session.id, operation: operations[command], - uri: fileUri(args.filePath), }; + if (command === "lsp_get_virtual_document") { + payload.virtualUri = args.virtualUri; + } else { + payload.uri = fileUri(args.filePath); + } if (typeof args.line === "number") { payload.position = { line: args.line, utf16Column: args.character ?? 0 }; } @@ -343,13 +345,15 @@ function semanticPayload(command: string, args: JsonRecord, session: Session): J utf16Column: diagnostic.endColumn ?? diagnostic.column ?? 0, }, }; - payload.diagnostics = [{ - range: payload.range, - message: diagnostic.message ?? "", - severity: diagnostic.severity ?? null, - source: diagnostic.source ?? null, - code: diagnostic.code == null ? null : String(diagnostic.code), - }]; + payload.diagnostics = [ + { + range: payload.range, + message: diagnostic.message ?? "", + severity: diagnostic.severity ?? null, + source: diagnostic.source ?? null, + code: diagnostic.code == null ? null : String(diagnostic.code), + }, + ]; } return payload; } @@ -357,24 +361,29 @@ function semanticPayload(command: string, args: JsonRecord, session: Session): J function unwrapResult(command: string, result: any): unknown { const normalized = normalizeCoreValue(result) as JsonRecord; switch (command) { - case "lsp_get_completions": return normalized.items ?? []; + case "lsp_get_completions": + return normalized.items ?? []; case "lsp_get_hover": { const hover = normalized.hover; if (!hover) return null; return { contents: hover.isMarkdown ? { kind: "markdown", value: hover.contents ?? "" } - : hover.contents ?? "", + : (hover.contents ?? ""), range: hover.range, }; } case "lsp_get_definition": case "lsp_get_implementation": case "lsp_get_type_definition": - case "lsp_get_references": return normalized.locations ?? []; - case "lsp_rename": return normalized.changes ? { changes: normalized.changes } : null; - case "lsp_format_document": return normalized.edits ?? []; - case "lsp_get_code_actions": return normalized.actions ?? []; + case "lsp_get_references": + return normalized.locations ?? []; + case "lsp_rename": + return normalized.changes ? { changes: normalized.changes } : null; + case "lsp_format_document": + return normalized.edits ?? []; + case "lsp_get_code_actions": + return normalized.actions ?? []; case "lsp_get_inlay_hints": return (normalized.hints ?? []).map((hint: JsonRecord) => ({ ...hint, @@ -388,7 +397,10 @@ function unwrapResult(command: string, result: any): unknown { command: lens.command?.command, arguments: lens.command?.arguments, })); - default: return normalized; + case "lsp_get_virtual_document": + return typeof normalized.text === "string" ? normalized.text : null; + default: + return normalized; } } @@ -417,7 +429,11 @@ export async function invokeLsp(command: string, args: JsonRecord = {}): Prom if (session.files.size === 0) await stopSession(session); return undefined as T; } - if (command === "lsp_document_open" || command === "lsp_document_change" || command === "lsp_document_save") { + if ( + command === "lsp_document_open" || + command === "lsp_document_change" || + command === "lsp_document_save" + ) { const session = sessionForFile(args.filePath); await core("lsp.syncDocument", { sessionId: session.id, @@ -438,9 +454,9 @@ export async function invokeLsp(command: string, args: JsonRecord = {}): Prom if (!commandPayload?.command) return { applied: true } as T; try { await requestOperation(session, { - sessionId: session.id, - operation: "executeCommand", - command: commandPayload, + sessionId: session.id, + operation: "executeCommand", + command: commandPayload, }); return { applied: true } as T; } catch (reason) { From 88074e025217bf9ef34ecb88d5af77c77258137e Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 20 Aug 2026 15:30:52 +0800 Subject: [PATCH 3/6] fix(windows): stabilize Java definition navigation Synchronize pane and buffer ownership, route definitions through Lithe buffers, and serialize or recover Core LSP sessions across concurrent starts and WebView reloads. Add regression coverage for Windows path normalization and startup-stop races. Refs #177 --- .../editor/engines/monaco/lsp-providers.ts | 17 +- .../panes/components/pane-container.tsx | 29 +- .../src/platform/lsp-core-adapter.test.ts | 119 +++++- .../tauri/src/platform/lsp-core-adapter.ts | 395 +++++++++++++++--- 4 files changed, 478 insertions(+), 82 deletions(-) diff --git a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts index acd46e340..6033f2660 100644 --- a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts +++ b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts @@ -257,21 +257,8 @@ export function registerMonacoLspProviders() { }, }); - languages.registerDefinitionProvider(selector, { - async provideDefinition(model, position) { - if (!isLspModel(model)) return []; - - const locations = await lspClient.getDefinition( - filePathFromModel(model), - position.lineNumber - 1, - position.column - 1, - ); - return (locations ?? []).map((location) => ({ - uri: toMonacoLocationUri(location.uri), - range: toMonacoRange(location.range), - })); - }, - }); + // Lithe owns definition navigation so target files and virtual documents are + // opened as buffers. Monaco's standalone navigation cannot load those models. languages.registerImplementationProvider(selector, { async provideImplementation(model, position) { diff --git a/windows/tauri/src/features/panes/components/pane-container.tsx b/windows/tauri/src/features/panes/components/pane-container.tsx index 2da8e6750..d92cd3387 100644 --- a/windows/tauri/src/features/panes/components/pane-container.tsx +++ b/windows/tauri/src/features/panes/components/pane-container.tsx @@ -287,9 +287,7 @@ function PullRequestPreviewCard({ buffer }: { buffer: PullRequestContent }) {
- {details?.body?.trim() - ? details.body - : t("panes.pullRequestPreviewFallback")} + {details?.body?.trim() ? details.body : t("panes.pullRequestPreviewFallback")}
@@ -307,9 +305,7 @@ function WebViewerDisabledState() { {t("panes.webViewerDisabled")} - - {t("panes.webViewerDisabledDescription")} - + {t("panes.webViewerDisabledDescription")} ); @@ -372,6 +368,15 @@ export function PaneContainer({ pane }: PaneContainerProps) { }), ); + useEffect(() => { + if (!isActivePane || !pane.activeBufferId) return; + + const bufferStore = useBufferStore.getState(); + if (bufferStore.activeBufferId !== pane.activeBufferId) { + bufferStore.actions.setActiveBuffer(pane.activeBufferId); + } + }, [isActivePane, pane.activeBufferId]); + useEffect(() => { const openEditorBufferIds = paneBuffers .filter(isStandardEditorBuffer) @@ -399,10 +404,8 @@ export function PaneContainer({ pane }: PaneContainerProps) { }, [activeBuffer, paneBuffers]); const handlePaneClick = useCallback(() => { - if (!isActivePane) { - activatePaneAndSyncBuffer(pane.id); - } - }, [isActivePane, pane.id]); + activatePaneAndSyncBuffer(pane.id); + }, [pane.id]); const handlePaneMouseDownCapture = useCallback( (e: React.MouseEvent) => { @@ -417,11 +420,9 @@ export function PaneContainer({ pane }: PaneContainerProps) { return; } - if (!isActivePane) { - activatePaneAndSyncBuffer(pane.id); - } + activatePaneAndSyncBuffer(pane.id); }, - [isActivePane, pane.id], + [pane.id], ); const handleTabClick = useCallback( diff --git a/windows/tauri/src/platform/lsp-core-adapter.test.ts b/windows/tauri/src/platform/lsp-core-adapter.test.ts index 6d11a8ca7..01644881e 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.test.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.test.ts @@ -24,11 +24,12 @@ const TauriEvent = { } as const; const frontendTrace = mock(() => undefined); const commands: string[] = []; -let scenario: "failure" | "virtual-document" = "failure"; +let scenario: "delayed-start" | "failure" | "virtual-document" = "failure"; let startPayload: Record | undefined; let requestPayload: Record | undefined; let pollCount = 0; let virtualDocumentPending = false; +let releaseInitialization: (() => void) | undefined; const executeCore = mock( async (request: { id: string; command: string; payload?: Record }) => { @@ -45,6 +46,28 @@ const executeCore = mock( } if (request.command === "lsp.pollEvents") { pollCount += 1; + if (scenario === "delayed-start") { + if (pollCount === 1) { + await new Promise((resolve) => { + releaseInitialization = resolve; + }); + return { + id: request.id, + ok: true as const, + data: { + events: [ + { + type: "stateChanged", + state: "ready", + providerId: "java", + sessionId: "java-session", + }, + ], + }, + }; + } + return { id: request.id, ok: true as const, data: { events: [] } }; + } if (scenario === "virtual-document") { if (pollCount === 1) { return { @@ -142,6 +165,7 @@ describe("Rust Core LSP adapter failures", () => { requestPayload = undefined; pollCount = 0; virtualDocumentPending = false; + releaseInitialization = undefined; emit.mockClear(); frontendTrace.mockClear(); executeCore.mockClear(); @@ -212,4 +236,97 @@ describe("Rust Core LSP adapter failures", () => { expect(commands).toContain("lsp.stopServer"); expect(commands).toContain("lsp.destroyServer"); }); + + test("shares an in-flight server start across files and normalizes Windows paths", async () => { + scenario = "virtual-document"; + + await Promise.all([ + invokeLsp("lsp_start_for_file", { + workspacePath: "C:\\work", + filePath: "C:\\work\\Main.java", + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }), + invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath: "C:/work/Other.java", + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }), + ]); + + expect(commands.filter((command) => command === "lsp.startServer")).toHaveLength(1); + + await invokeLsp("lsp_stop_for_file", { filePath: "C:/work/Main.java" }); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(0); + + await invokeLsp("lsp_stop_for_file", { filePath: "C:\\work\\Other.java" }); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(1); + expect(commands.filter((command) => command === "lsp.destroyServer")).toHaveLength(1); + }); + + test("keeps initializing sessions recoverable and makes an in-flight file stop deterministic", async () => { + scenario = "delayed-start"; + const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); + const values = new Map(); + const storage: Storage = { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: storage }); + + try { + const firstStart = invokeLsp("lsp_start_for_file", { + workspacePath: "C:\\work", + filePath: "C:\\work\\Main.java", + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + for (let attempt = 0; attempt < 10 && !releaseInitialization; attempt += 1) { + await Promise.resolve(); + } + + expect(releaseInitialization).toBeDefined(); + expect(JSON.parse(values.get("lithe:lsp-core-sessions:v1") ?? "[]")).toEqual([ + expect.objectContaining({ id: "java-session", ready: false }), + ]); + + const secondStart = invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath: "C:/work/Other.java", + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + const stopSecond = invokeLsp("lsp_stop_for_file", { filePath: "C:\\work\\Other.java" }); + + releaseInitialization?.(); + await Promise.all([firstStart, secondStart, stopSecond]); + + expect(commands.filter((command) => command === "lsp.startServer")).toHaveLength(1); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(0); + expect(JSON.parse(values.get("lithe:lsp-core-sessions:v1") ?? "[]")).toEqual([ + expect.objectContaining({ files: ["C:\\work\\Main.java"], ready: true }), + ]); + + await invokeLsp("lsp_stop_for_file", { filePath: "C:/work/Main.java" }); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(1); + expect(values.has("lithe:lsp-core-sessions:v1")).toBe(false); + } finally { + if (previousStorage) { + Object.defineProperty(globalThis, "sessionStorage", previousStorage); + } else { + delete (globalThis as { sessionStorage?: Storage }).sessionStorage; + } + } + }); }); diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index 82e049227..0853f8d8c 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -14,10 +14,20 @@ interface Session { languageId: string; files: Set; running: boolean; + ready: boolean; + recovered: boolean; pending: Map void; reject: (reason: Error) => void }>; completed: Map; } +interface StoredSession { + id: string; + workspacePath: string; + languageId: string; + files: string[]; + ready?: boolean; +} + interface RuntimeError { code?: string; providerId?: string; @@ -48,6 +58,145 @@ interface RuntimeEvent { const sessions = new Map(); const fileSessions = new Map(); +const sessionStarts = new Map>(); +const sessionStops = new Map>(); +const pendingFileSessions = new Map(); +const SESSION_STORAGE_KEY = "lithe:lsp-core-sessions:v1"; + +function normalizedPathKey(path: string): string { + const normalized = path.replace(/\\/g, "/"); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalized) ? normalized.toLowerCase() : normalized; +} + +function sessionKey(workspacePath: string, languageId: string): string { + return `${normalizedPathKey(workspacePath)}:${languageId}`; +} + +function fileKey(filePath: string): string { + return normalizedPathKey(filePath); +} + +function availableSessionStorage(): Storage | null { + try { + return typeof sessionStorage === "undefined" ? null : sessionStorage; + } catch { + return null; + } +} + +function persistSessions(): void { + const storage = availableSessionStorage(); + if (!storage) return; + + const stored: StoredSession[] = [...sessions.values()] + .map((session) => ({ + id: session.id, + workspacePath: session.workspacePath, + languageId: session.languageId, + files: [...session.files].sort(), + ready: session.ready, + })) + .sort((left, right) => + sessionKey(left.workspacePath, left.languageId).localeCompare( + sessionKey(right.workspacePath, right.languageId), + ), + ); + + try { + if (stored.length === 0) { + storage.removeItem(SESSION_STORAGE_KEY); + } else { + storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(stored)); + } + } catch (reason) { + frontendTrace("warn", "lsp.runtime", "Could not persist language-server sessions", { + error: reason instanceof Error ? reason.message : String(reason), + }); + } +} + +function attachFile(session: Session, filePath: string): void { + const key = fileKey(filePath); + for (const existing of session.files) { + if (fileKey(existing) === key) session.files.delete(existing); + } + session.files.add(filePath); + fileSessions.set(key, session); +} + +function detachFile(session: Session, filePath: string): void { + const key = fileKey(filePath); + for (const existing of session.files) { + if (fileKey(existing) === key) session.files.delete(existing); + } + fileSessions.delete(key); +} + +function removeSessionMappings(session: Session): void { + const key = sessionKey(session.workspacePath, session.languageId); + if (sessions.get(key) === session) sessions.delete(key); + for (const file of session.files) { + if (fileSessions.get(fileKey(file)) === session) fileSessions.delete(fileKey(file)); + } +} + +function restorePersistedSessions(): void { + const storage = availableSessionStorage(); + if (!storage) return; + + try { + const value: unknown = JSON.parse(storage.getItem(SESSION_STORAGE_KEY) ?? "[]"); + if (!Array.isArray(value)) throw new Error("Stored sessions are not an array"); + + for (const candidate of value) { + const stored = candidate as Partial | null; + if ( + !stored || + typeof stored !== "object" || + typeof stored.id !== "string" || + typeof stored.workspacePath !== "string" || + typeof stored.languageId !== "string" || + !Array.isArray(stored.files) || + !stored.files.every((file: unknown) => typeof file === "string") || + (stored.ready !== undefined && typeof stored.ready !== "boolean") + ) { + continue; + } + + const session: Session = { + id: stored.id, + workspacePath: stored.workspacePath, + languageId: stored.languageId, + files: new Set(), + running: false, + // Version-one entries created before this field existed were all ready. + ready: stored.ready ?? true, + recovered: true, + pending: new Map(), + completed: new Map(), + }; + const key = sessionKey(session.workspacePath, session.languageId); + const previous = sessions.get(key); + if (previous) removeSessionMappings(previous); + sessions.set(key, session); + for (const file of stored.files) attachFile(session, file); + } + } catch (reason) { + storage.removeItem(SESSION_STORAGE_KEY); + frontendTrace("warn", "lsp.runtime", "Could not restore language-server sessions", { + error: reason instanceof Error ? reason.message : String(reason), + }); + } +} + +restorePersistedSessions(); + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + persistSessions(); + for (const session of sessions.values()) session.running = false; + }); +} function coreData(response: CoreResponse): T { if (response.ok) return response.data; @@ -135,13 +284,33 @@ async function poll(session: Session): Promise { async function runEventPump(session: Session): Promise { while (session.running) { try { - await poll(session); + const events = await poll(session); + if (!session.running) return; + const terminalState = [...events] + .reverse() + .find( + (event) => + event.type === "stateChanged" && + (event.state === "failed" || event.state === "stopped"), + )?.state; + if (terminalState) { + const error = new Error(`Language server entered ${terminalState} state`); + for (const pending of session.pending.values()) pending.reject(error); + session.pending.clear(); + session.running = false; + session.ready = false; + removeSessionMappings(session); + persistSessions(); + return; + } } catch (reason) { if (!session.running) return; const error = reason instanceof Error ? reason : new Error(String(reason)); for (const pending of session.pending.values()) pending.reject(error); session.pending.clear(); session.running = false; + removeSessionMappings(session); + persistSessions(); await emit("lsp://server-crashed", {}); return; } @@ -154,7 +323,11 @@ async function waitUntilReady(session: Session): Promise { while (Date.now() < deadline) { const events = await poll(session); const state = [...events].reverse().find((event) => event.type === "stateChanged")?.state; - if (state === "ready") return; + if (state === "ready") { + session.ready = true; + persistSessions(); + return; + } if (state === "failed" || state === "stopped") { const failure = [...events] .reverse() @@ -183,6 +356,7 @@ async function waitUntilReady(session: Session): Promise { async function stopAndDestroySession(session: Session): Promise { session.running = false; + session.ready = false; await core("lsp.stopServer", { sessionId: session.id }); const deadline = Date.now() + SESSION_CLEANUP_TIMEOUT_MS; @@ -201,6 +375,9 @@ async function stopAndDestroySession(session: Session): Promise { } async function cleanupFailedStart(key: string, session: Session): Promise { + if (sessions.get(key) === session) sessions.delete(key); + removeSessionMappings(session); + persistSessions(); try { await stopAndDestroySession(session); } catch (reason) { @@ -209,67 +386,176 @@ async function cleanupFailedStart(key: string, session: Session): Promise error: reason instanceof Error ? reason.message : String(reason), }); } - sessions.delete(key); } function sessionForFile(filePath: string): Session { - const session = fileSessions.get(filePath); + const session = fileSessions.get(fileKey(filePath)); if (!session) throw new Error(`No LSP client for this file: ${filePath}`); return session; } -async function start(args: JsonRecord): Promise { +async function recoverSession(session: Session): Promise { + try { + const events = await poll(session); + const state = [...events].reverse().find((event) => event.type === "stateChanged")?.state; + if (state === "failed" || state === "stopped" || state === "stopping") { + try { + if (state === "stopping") { + await stopAndDestroySession(session); + } else { + await core("lsp.destroyServer", { sessionId: session.id }); + } + } catch (reason) { + frontendTrace("warn", "lsp.runtime", "Could not destroy stale language-server session", { + sessionId: session.id, + error: reason instanceof Error ? reason.message : String(reason), + }); + } + removeSessionMappings(session); + persistSessions(); + return null; + } + + if (state === "ready") { + session.ready = true; + } else if (state) { + session.ready = false; + } + if (!session.ready) await waitUntilReady(session); + + session.recovered = false; + session.running = true; + persistSessions(); + void runEventPump(session); + return session; + } catch (reason) { + removeSessionMappings(session); + persistSessions(); + frontendTrace("warn", "lsp.runtime", "Could not recover language-server session", { + sessionId: session.id, + error: reason instanceof Error ? reason.message : String(reason), + }); + return null; + } +} + +async function createSession(args: JsonRecord, key: string): Promise { const workspacePath = String(args.workspacePath ?? ""); const languageId = String(args.languageId ?? "plaintext"); const providerId = String(args.providerId ?? languageId); - const key = `${workspacePath}:${languageId}`; + const environment = { + ...args.tools?.lsp?.env, + ...args.environment, + }; + const started = await core<{ sessionId: string }>("lsp.startServer", { + providerId, + executablePath: args.serverPath, + arguments: args.serverArgs ?? [], + environment, + rootUri: fileUri(workspacePath), + workingDirectory: workspacePath, + initializationOptions: args.initializationOptions ?? null, + runtimeExecutablePath: args.runtimeExecutablePath ?? null, + cacheDirectory: args.cacheDirectory ?? null, + initializeTimeoutMilliseconds: INITIALIZE_TIMEOUT_MS, + }); + const session: Session = { + id: started.sessionId, + workspacePath, + languageId, + files: new Set(), + running: false, + ready: false, + recovered: false, + pending: new Map(), + completed: new Map(), + }; + sessions.set(key, session); + persistSessions(); + try { + await waitUntilReady(session); + } catch (error) { + await cleanupFailedStart(key, session); + throw error; + } + session.running = true; + persistSessions(); + void runEventPump(session); + return session; +} + +async function resolveSession(args: JsonRecord, key: string): Promise { + const stopping = sessionStops.get(key); + if (stopping) await stopping; + + const existing = sessions.get(key); + if (existing && !existing.recovered) return existing; + + if (existing) { + const recovered = await recoverSession(existing); + if (recovered) return recovered; + } + + return createSession(args, key); +} + +async function start(args: JsonRecord): Promise { + const workspacePath = String(args.workspacePath ?? ""); + const languageId = String(args.languageId ?? "plaintext"); + const key = sessionKey(workspacePath, languageId); + const filePath = args.filePath ? String(args.filePath) : null; + const pendingFileKey = filePath ? fileKey(filePath) : null; + if (pendingFileKey) pendingFileSessions.set(pendingFileKey, key); let session = sessions.get(key); - if (!session) { - const environment = { - ...args.tools?.lsp?.env, - ...args.environment, - }; - const started = await core<{ sessionId: string }>("lsp.startServer", { - providerId, - executablePath: args.serverPath, - arguments: args.serverArgs ?? [], - environment, - rootUri: fileUri(workspacePath), - workingDirectory: workspacePath, - initializationOptions: args.initializationOptions ?? null, - runtimeExecutablePath: args.runtimeExecutablePath ?? null, - cacheDirectory: args.cacheDirectory ?? null, - initializeTimeoutMilliseconds: INITIALIZE_TIMEOUT_MS, - }); - session = { - id: started.sessionId, - workspacePath, - languageId, - files: new Set(), - running: false, - pending: new Map(), - completed: new Map(), - }; - sessions.set(key, session); - try { - await waitUntilReady(session); - } catch (error) { - await cleanupFailedStart(key, session); - throw error; + + try { + if (!session || session.recovered || !session.ready) { + let startPromise = sessionStarts.get(key); + if (!startPromise) { + startPromise = resolveSession(args, key).finally(() => sessionStarts.delete(key)); + sessionStarts.set(key, startPromise); + } + session = await startPromise; + } + + if (filePath) { + attachFile(session, filePath); + persistSessions(); + } + } finally { + if (pendingFileKey && pendingFileSessions.get(pendingFileKey) === key) { + pendingFileSessions.delete(pendingFileKey); } - session.running = true; - void runEventPump(session); - } - if (args.filePath) { - session.files.add(args.filePath); - fileSessions.set(args.filePath, session); } } async function stopSession(session: Session): Promise { - await stopAndDestroySession(session); - sessions.delete(`${session.workspacePath}:${session.languageId}`); - for (const file of session.files) fileSessions.delete(file); + const key = sessionKey(session.workspacePath, session.languageId); + removeSessionMappings(session); + persistSessions(); + + let stopPromise = sessionStops.get(key); + if (!stopPromise) { + stopPromise = stopAndDestroySession(session).finally(() => { + if (sessionStops.get(key) === stopPromise) sessionStops.delete(key); + }); + sessionStops.set(key, stopPromise); + } + await stopPromise; +} + +async function sessionForStoppingFile(filePath: string): Promise { + const key = fileKey(filePath); + const pendingSessionKey = pendingFileSessions.get(key); + const pendingStart = pendingSessionKey ? sessionStarts.get(pendingSessionKey) : null; + if (pendingStart) { + try { + await pendingStart; + } catch { + return null; + } + } + return fileSessions.get(key) ?? null; } async function requestOperation(session: Session, payload: JsonRecord): Promise { @@ -417,16 +703,21 @@ export async function invokeLsp(command: string, args: JsonRecord = {}): Prom } if (command === "lsp_stop") { const matches = [...sessions.values()].filter( - (session) => session.workspacePath === args.workspacePath, + (session) => + normalizedPathKey(session.workspacePath) === normalizedPathKey(args.workspacePath), ); await Promise.all(matches.map(stopSession)); return undefined as T; } if (command === "lsp_stop_for_file") { - const session = sessionForFile(args.filePath); - session.files.delete(args.filePath); - fileSessions.delete(args.filePath); - if (session.files.size === 0) await stopSession(session); + const session = await sessionForStoppingFile(args.filePath); + if (!session) return undefined as T; + detachFile(session, args.filePath); + if (session.files.size === 0) { + await stopSession(session); + } else { + persistSessions(); + } return undefined as T; } if ( From bf94b18e2b2dfa3f2f9d26e67cac190c06d9ab3e Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 20 Aug 2026 16:20:38 +0800 Subject: [PATCH 4/6] Fix Windows LSP diagnostic path matching Resolve published diagnostic URIs to concrete open buffer paths so Windows separator and case differences do not drop Monaco markers. --- .../editor/lsp/diagnostics-file-path.test.ts | 39 +++++++++++++++++++ .../editor/lsp/diagnostics-file-path.ts | 30 ++++++++++++++ .../src/features/editor/lsp/lsp-client.ts | 26 ++++++++----- 3 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 windows/tauri/src/features/editor/lsp/diagnostics-file-path.test.ts create mode 100644 windows/tauri/src/features/editor/lsp/diagnostics-file-path.ts diff --git a/windows/tauri/src/features/editor/lsp/diagnostics-file-path.test.ts b/windows/tauri/src/features/editor/lsp/diagnostics-file-path.test.ts new file mode 100644 index 000000000..bee06e522 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/diagnostics-file-path.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { resolvePublishedDiagnosticsFilePath } from "./diagnostics-file-path"; + +describe("published diagnostics file paths", () => { + test("returns the concrete Windows buffer path across separator and case differences", () => { + const bufferPath = "C:\\Work\\src\\Main.java"; + + expect( + resolvePublishedDiagnosticsFilePath( + "c:/work/src/main.java", + [bufferPath], + ["c:/work/src/main.java"], + ), + ).toBe(bufferPath); + }); + + test("uses a tracked document path when the source buffer is not mounted", () => { + const trackedPath = "C:\\work\\src\\Main.java"; + + expect(resolvePublishedDiagnosticsFilePath("C:/work/src/Main.java", [], [trackedPath])).toBe( + trackedPath, + ); + }); + + test("compares UNC paths case-insensitively", () => { + const bufferPath = "\\\\SERVER\\Share\\Main.java"; + + expect(resolvePublishedDiagnosticsFilePath("//server/share/main.java", [bufferPath], [])).toBe( + bufferPath, + ); + }); + + test("keeps POSIX paths case-sensitive and rejects closed documents", () => { + expect( + resolvePublishedDiagnosticsFilePath("/workspace/Main.java", ["/workspace/main.java"], []), + ).toBeNull(); + expect(resolvePublishedDiagnosticsFilePath("C:/work/Main.java", [], [])).toBeNull(); + }); +}); diff --git a/windows/tauri/src/features/editor/lsp/diagnostics-file-path.ts b/windows/tauri/src/features/editor/lsp/diagnostics-file-path.ts new file mode 100644 index 000000000..9492b1d7b --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/diagnostics-file-path.ts @@ -0,0 +1,30 @@ +import { normalizePath } from "@/utils/path-helpers"; + +function diagnosticPathKey(filePath: string): string { + const normalizedPath = normalizePath(filePath); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalizedPath) + ? normalizedPath.toLowerCase() + : normalizedPath; +} + +function findEquivalentPath( + publishedPathKey: string, + candidatePaths: Iterable, +): string | null { + for (const candidatePath of candidatePaths) { + if (diagnosticPathKey(candidatePath) === publishedPathKey) return candidatePath; + } + return null; +} + +export function resolvePublishedDiagnosticsFilePath( + publishedFilePath: string, + sourceBufferPaths: Iterable, + openDocumentPaths: Iterable, +): string | null { + const publishedPathKey = diagnosticPathKey(publishedFilePath); + return ( + findEquivalentPath(publishedPathKey, sourceBufferPaths) ?? + findEquivalentPath(publishedPathKey, openDocumentPaths) + ); +} diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index a16758f4b..6c2f26379 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -14,11 +14,11 @@ import type { Diagnostic, DiagnosticCodeAction, } from "@/features/diagnostics/types/diagnostics.types"; -import { hasTextContent } from "@/features/panes/types/pane-content.types"; +import { hasTextContent, shouldStartLsp } from "@/features/panes/types/pane-content.types"; import { useBufferStore } from "../stores/buffer.store"; -import { getSourceEditorBufferByPath } from "../utils/buffer-index"; import { logger } from "../utils/logger"; import { isBuiltInLspPath, languageIdForEditorFile } from "./built-in-language-support"; +import { resolvePublishedDiagnosticsFilePath } from "./diagnostics-file-path"; import { resolveEditorLspLaunch } from "./resolve-editor-lsp-launch"; import type { LspSemanticTokensResponse } from "./semantic-token-types"; import { useLspStore } from "./stores/lsp.store"; @@ -378,14 +378,22 @@ export class LspClient { logger.debug("LSPClient", `Received diagnostics for ${uri}:`, diagnostics); - // Convert URI to file path - const filePath = filePathFromUri(uri); - const isOpenInEditor = - this.openDocuments.has(filePath) || - !!getSourceEditorBufferByPath(useBufferStore.getState().buffers, filePath); + const publishedFilePath = filePathFromUri(uri); + const sourceBufferPaths = useBufferStore + .getState() + .buffers.filter(shouldStartLsp) + .map((buffer) => buffer.path); + const filePath = resolvePublishedDiagnosticsFilePath( + publishedFilePath, + sourceBufferPaths, + this.openDocuments, + ); - if (!isOpenInEditor) { - logger.debug("LSPClient", `Ignoring diagnostics for closed document: ${filePath}`); + if (!filePath) { + logger.debug( + "LSPClient", + `Ignoring diagnostics for closed document: ${publishedFilePath}`, + ); return; } From 61f3e19cd529f4f72d24b4f2d2a2f3c5ce7cdab6 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 20 Aug 2026 17:29:23 +0800 Subject: [PATCH 5/6] Load Lombok in bundled JDTLS Pin and checksum the Lombok agent and MIT license during JDTLS preparation. Load the bundled agent from both generated platform launchers so JDTLS resolves annotation-generated Java members. --- docs/architecture/language-tooling.md | 2 +- scripts/prepare-jdtls.ps1 | 18 ++++++++++++++++++ scripts/prepare-jdtls.sh | 22 ++++++++++++++++++++++ third_party/jdtls/manifest.json | 5 +++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 6c7da10c0..8c5cbe520 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -125,7 +125,7 @@ Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和 macOS discovery 的查找顺序包括项目 `.lithe` 工具目录、`LITHE__PATH`/`LITHE_TOOL__PATH`、`PATH` 和常见系统目录;`gopls` 等 Go 工具还会检查 `GOBIN`、`GOPATH/bin`、`~/go/bin` 和 `~/.go/bin`。discovery 只查找,不自动安装软件。 -正式 macOS 与 Windows 安装包包含 JDTLS。发布构建根据 `third_party/jdtls/manifest.json` 下载固定版本,同时校验归档与 EPL-2.0 许可证的 SHA-256,再将产物放入应用资源目录的 `LanguageServers/jdtls`。平台 adapter 优先使用这个包内启动器;开发环境仍保留项目工具目录、显式覆盖和 `PATH` 等外部候选作为回退。下载只发生在构建阶段,应用运行时不会联网安装 JDTLS;Java 语义功能仍要求系统提供 JDK 17 或更高版本。 +正式 macOS 与 Windows 安装包包含 JDTLS。发布构建根据 `third_party/jdtls/manifest.json` 下载固定版本,同时校验 JDTLS 归档、EPL-2.0 许可证、Lombok agent 与 MIT 许可证的 SHA-256,再将产物放入应用资源目录的 `LanguageServers/jdtls`。包内启动器通过相对路径加载 Lombok `-javaagent`,确保 JDTLS 能解析注解生成的成员;agent 缺失时启动器会明确失败,而不会产生静默的错误诊断。平台 adapter 优先使用这个包内启动器;开发环境仍保留项目工具目录、显式覆盖和 `PATH` 等外部候选作为回退。下载只发生在构建阶段,应用运行时不会联网安装 JDTLS 或 Lombok;Java 语义功能仍要求系统提供 JDK 17 或更高版本。 JDTLS 使用独立于项目运行/调试配置的全局 JDK 偏好。空路径表示自动模式:平台探测本机 JDK,并只选择主版本 17 或更高的候选。用户也可以在语言服务器设置中选择单独的 JDK Home;平台会在保存时探测 `java -version`,并在每次启动 JDTLS 前再次校验路径和版本。这个偏好不会读取、覆盖或写回项目的 Java/Maven JDK 设置。 diff --git a/scripts/prepare-jdtls.ps1 b/scripts/prepare-jdtls.ps1 index 820382e69..00ed988ca 100644 --- a/scripts/prepare-jdtls.ps1 +++ b/scripts/prepare-jdtls.ps1 @@ -25,13 +25,18 @@ $cache = Join-Path $root ".artifacts/jdtls-downloads" $archiveUsesOverride = -not [string]::IsNullOrWhiteSpace($env:LITHE_JDTLS_ARCHIVE) $archiveHash = $manifest.archiveSHA256.ToLowerInvariant() $licenseHash = $manifest.licenseSHA256.ToLowerInvariant() +$lombokHash = $manifest.lombokSHA256.ToLowerInvariant() +$lombokLicenseHash = $manifest.lombokLicenseSHA256.ToLowerInvariant() $safeVersion = ([string]$manifest.version) -replace '[^A-Za-z0-9._-]', '_' +$safeLombokVersion = ([string]$manifest.lombokVersion) -replace '[^A-Za-z0-9._-]', '_' $archive = if ($archiveUsesOverride) { $env:LITHE_JDTLS_ARCHIVE } else { Join-Path $cache "jdtls-$safeVersion-$archiveHash.tar.gz" } $license = Join-Path $cache "EPL-2.0-$licenseHash.txt" +$lombok = Join-Path $cache "lombok-$safeLombokVersion-$lombokHash.jar" +$lombokLicense = Join-Path $cache "lombok-MIT-$safeLombokVersion-$lombokLicenseHash.txt" function Get-FileSHA256 { param([Parameter(Mandatory)][string]$Path) @@ -73,6 +78,10 @@ function Assert-JdtlsOutput { if (-not (Test-Path -LiteralPath (Join-Path $output "config_win") -PathType Container)) { throw "JDTLS Windows configuration is missing: $output" } if (-not (Test-Path -LiteralPath (Join-Path $output "bin/jdtls.ps1") -PathType Leaf)) { throw "JDTLS PowerShell launcher is missing: $output" } if (-not (Test-Path -LiteralPath (Join-Path $output "bin/jdtls.bat") -PathType Leaf)) { throw "JDTLS batch launcher is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "lombok/lombok.jar") -PathType Leaf)) { throw "JDTLS Lombok agent is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "lombok/LICENSE-MIT.txt") -PathType Leaf)) { throw "JDTLS Lombok license is missing: $output" } + $launcher = Get-Content -Raw -LiteralPath (Join-Path $output "bin/jdtls.ps1") + if (-not $launcher.Contains("-javaagent:")) { throw "JDTLS launcher does not load the Lombok agent: $output" } } if ($usesExistingRoot) { @@ -90,16 +99,25 @@ if ($archiveUsesOverride) { Get-VerifiedDownload -Uri $manifest.archiveURL -ExpectedSHA256 $archiveHash -Destination $archive -Description "JDTLS archive" } Get-VerifiedDownload -Uri $manifest.licenseURL -ExpectedSHA256 $licenseHash -Destination $license -Description "EPL-2.0 license" +Get-VerifiedDownload -Uri $manifest.lombokURL -ExpectedSHA256 $lombokHash -Destination $lombok -Description "Lombok agent" +Get-VerifiedDownload -Uri $manifest.lombokLicenseURL -ExpectedSHA256 $lombokLicenseHash -Destination $lombokLicense -Description "Lombok MIT license" if (Test-Path -LiteralPath $output) { Remove-Item -Recurse -Force -LiteralPath $output } New-Item -ItemType Directory -Force -Path $output | Out-Null tar.exe -xzf $archive -C $output Copy-Item -LiteralPath $license -Destination (Join-Path $output "LICENSE-EPL-2.0.txt") -Force +$lombokOutput = Join-Path $output "lombok" +New-Item -ItemType Directory -Force -Path $lombokOutput | Out-Null +Copy-Item -LiteralPath $lombok -Destination (Join-Path $lombokOutput "lombok.jar") -Force +Copy-Item -LiteralPath $lombokLicense -Destination (Join-Path $lombokOutput "LICENSE-MIT.txt") -Force $windowsLauncher = @' $ErrorActionPreference = "Stop" $javaExecutable = if ($env:JAVA_HOME) { Join-Path $env:JAVA_HOME "bin\java.exe" } else { "java" } +$lombokAgent = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\lombok\lombok.jar")) +if (-not (Test-Path -LiteralPath $lombokAgent -PathType Leaf)) { throw "JDTLS Lombok agent was not found: $lombokAgent" } $jvmArguments = [System.Collections.Generic.List[string]]::new() +$jvmArguments.Add("-javaagent:$lombokAgent") $jvmArguments.Add("--add-modules=ALL-SYSTEM") $jvmArguments.Add("--add-opens=java.base/java.util=ALL-UNNAMED") $jvmArguments.Add("--add-opens=java.base/java.lang=ALL-UNNAMED") diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh index b82133c60..2ae7bb211 100755 --- a/scripts/prepare-jdtls.sh +++ b/scripts/prepare-jdtls.sh @@ -15,9 +15,16 @@ archive_url="$(manifest_value archiveURL)" archive_sha256="$(manifest_value archiveSHA256)" license_url="$(manifest_value licenseURL)" license_sha256="$(manifest_value licenseSHA256)" +lombok_url="$(manifest_value lombokURL)" +lombok_sha256="$(manifest_value lombokSHA256)" +lombok_license_url="$(manifest_value lombokLicenseURL)" +lombok_license_sha256="$(manifest_value lombokLicenseSHA256)" jdtls_version="$(manifest_value version)" +lombok_version="$(manifest_value lombokVersion)" archive_path="${LITHE_JDTLS_ARCHIVE:-$CACHE_DIR/jdtls-$jdtls_version-$archive_sha256.tar.gz}" license_path="$CACHE_DIR/EPL-2.0-$license_sha256.txt" +lombok_path="$CACHE_DIR/lombok-$lombok_version-$lombok_sha256.jar" +lombok_license_path="$CACHE_DIR/lombok-MIT-$lombok_version-$lombok_license_sha256.txt" file_sha256() { shasum -a 256 "$1" | awk '{print tolower($1)}' @@ -60,6 +67,10 @@ validate_output() { [[ -d "$OUTPUT_DIR/config_win" ]] || { print -u2 -- "JDTLS Windows configuration is missing: $OUTPUT_DIR"; exit 1; } [[ -x "$OUTPUT_DIR/bin/jdtls" ]] || { print -u2 -- "JDTLS launcher is missing: $OUTPUT_DIR/bin/jdtls"; exit 1; } [[ -f "$OUTPUT_DIR/bin/jdtls.ps1" ]] || { print -u2 -- "JDTLS Windows launcher is missing: $OUTPUT_DIR"; exit 1; } + [[ -f "$OUTPUT_DIR/lombok/lombok.jar" ]] || { print -u2 -- "JDTLS Lombok agent is missing: $OUTPUT_DIR"; exit 1; } + [[ -f "$OUTPUT_DIR/lombok/LICENSE-MIT.txt" ]] || { print -u2 -- "JDTLS Lombok license is missing: $OUTPUT_DIR"; exit 1; } + grep -Fq -- '-javaagent:' "$OUTPUT_DIR/bin/jdtls" || { print -u2 -- "JDTLS launcher does not load the Lombok agent: $OUTPUT_DIR"; exit 1; } + grep -Fq -- '-javaagent:' "$OUTPUT_DIR/bin/jdtls.ps1" || { print -u2 -- "JDTLS Windows launcher does not load the Lombok agent: $OUTPUT_DIR"; exit 1; } } if [[ -n "${LITHE_JDTLS_ROOT:-}" ]]; then @@ -80,11 +91,16 @@ else download_verified_file "$archive_url" "$archive_sha256" "$archive_path" "JDTLS archive" fi download_verified_file "$license_url" "$license_sha256" "$license_path" "EPL-2.0 license" +download_verified_file "$lombok_url" "$lombok_sha256" "$lombok_path" "Lombok agent" +download_verified_file "$lombok_license_url" "$lombok_license_sha256" "$lombok_license_path" "Lombok MIT license" rm -rf "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR" tar -xzf "$archive_path" -C "$OUTPUT_DIR" cp "$license_path" "$OUTPUT_DIR/LICENSE-EPL-2.0.txt" +mkdir -p "$OUTPUT_DIR/lombok" +cp "$lombok_path" "$OUTPUT_DIR/lombok/lombok.jar" +cp "$lombok_license_path" "$OUTPUT_DIR/lombok/LICENSE-MIT.txt" cat > "$OUTPUT_DIR/bin/jdtls" <<'EOF' #!/bin/zsh @@ -97,7 +113,10 @@ if [[ ! -x "$JAVA_EXECUTABLE" ]]; then JAVA_EXECUTABLE="${JAVA:-java}" fi +LOMBOK_AGENT="$SCRIPT_DIR/../lombok/lombok.jar" +[[ -f "$LOMBOK_AGENT" ]] || { print -u2 -- "JDTLS Lombok agent was not found: $LOMBOK_AGENT"; exit 1; } JVM_ARGUMENTS=( + "-javaagent:$LOMBOK_AGENT" "--add-modules=ALL-SYSTEM" "--add-opens=java.base/java.util=ALL-UNNAMED" "--add-opens=java.base/java.lang=ALL-UNNAMED" @@ -150,7 +169,10 @@ cat > "$OUTPUT_DIR/bin/jdtls.ps1" <<'EOF' $ErrorActionPreference = "Stop" $javaExecutable = if ($env:JAVA_HOME) { Join-Path $env:JAVA_HOME "bin\java.exe" } else { "java" } +$lombokAgent = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\lombok\lombok.jar")) +if (-not (Test-Path -LiteralPath $lombokAgent -PathType Leaf)) { throw "JDTLS Lombok agent was not found: $lombokAgent" } $jvmArguments = [System.Collections.Generic.List[string]]::new() +$jvmArguments.Add("-javaagent:$lombokAgent") $jvmArguments.Add("--add-modules=ALL-SYSTEM") $jvmArguments.Add("--add-opens=java.base/java.util=ALL-UNNAMED") $jvmArguments.Add("--add-opens=java.base/java.lang=ALL-UNNAMED") diff --git a/third_party/jdtls/manifest.json b/third_party/jdtls/manifest.json index 2c526d0ed..ddb001889 100644 --- a/third_party/jdtls/manifest.json +++ b/third_party/jdtls/manifest.json @@ -4,5 +4,10 @@ "archiveSHA256": "ba697788a19f2ba57b16302aba6b343c649928c95f76b0d170494ac12d17ac78", "licenseURL": "https://www.eclipse.org/org/documents/epl-2.0/EPL-2.0.txt", "licenseSHA256": "0becf16567beb77fa252b7664631dd177c8f9a1889e48995b45379c7130e5303", + "lombokVersion": "1.18.46", + "lombokURL": "https://repo.maven.apache.org/maven2/org/projectlombok/lombok/1.18.46/lombok-1.18.46.jar", + "lombokSHA256": "01f7b1a015e33e2b62d5f5f37053306357ab1415fd181fcba7794f5d198c1126", + "lombokLicenseURL": "https://raw.githubusercontent.com/projectlombok/lombok/v1.18.46/LICENSE", + "lombokLicenseSHA256": "76479448741d7a7a3a97b6afd9ab9699d95621faafc65a1b8e9149342ea00feb", "minimumJavaVersion": 17 } From bf3e63bee22ffa50f95e48c3620dee158073ef81 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 20 Aug 2026 21:50:47 +0800 Subject: [PATCH 6/6] Stabilize JDTLS document and session routing Bind physical and virtual editor buffers to the correct language-server session, gate semantic features by negotiated capabilities, and route provider-owned virtual references without requiring didOpen. Add lifecycle, duplicate-workspace, operation-matrix, and real-JDTLS smoke coverage. --- rust/lithe-core/src/lsp/interface/client.rs | 16 +- rust/lithe-core/src/lsp/interface/engine.rs | 487 +++++++++++++++++- rust/lithe-core/src/lsp/languages/jdt.rs | 7 +- .../editor/components/monaco-editor.tsx | 3 +- .../engines/monaco/code-lens-provider.ts | 29 +- .../editor/engines/monaco/lsp-providers.ts | 133 ++--- .../lsp/language-server-navigation.test.ts | 17 + .../editor/lsp/language-server-navigation.ts | 9 +- .../src/features/editor/lsp/lsp-client.ts | 182 +++++-- .../editor/lsp/lsp-document-target.test.ts | 99 ++++ .../editor/lsp/lsp-document-target.ts | 51 ++ .../editor/lsp/navigation-target.test.ts | 51 +- .../features/editor/lsp/navigation-target.ts | 35 ++ .../src/features/editor/lsp/use-code-lens.ts | 14 +- .../src/features/editor/lsp/use-rename.ts | 37 +- .../editor/stores/buffer-content-factory.ts | 1 + .../commands/navigation-command-actions.ts | 82 ++- .../panes/types/pane-content.types.ts | 8 + .../src/platform/lsp-core-adapter.test.ts | 331 +++++++++++- .../tauri/src/platform/lsp-core-adapter.ts | 119 ++++- 20 files changed, 1476 insertions(+), 235 deletions(-) create mode 100644 windows/tauri/src/features/editor/lsp/lsp-document-target.test.ts create mode 100644 windows/tauri/src/features/editor/lsp/lsp-document-target.ts diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 80ef4b632..4ac77f09d 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -212,6 +212,20 @@ pub fn client_shutdown(request: ClientShutdownRequest) -> Result Result { + client_feature_request_with_document_ownership(request, true) +} + +/// Allocates a feature request for a provider-owned virtual document. +pub(crate) fn client_provider_document_feature_request_canonical( + request: ClientFeatureRequest, +) -> Result { + client_feature_request_with_document_ownership(request, false) +} + +fn client_feature_request_with_document_ownership( + request: ClientFeatureRequest, + require_open_document: bool, ) -> Result { validate_uri(&request.uri)?; validate_lsp_method(&request.method)?; @@ -219,7 +233,7 @@ pub(crate) fn client_feature_request_canonical( let uri = request.uri.clone(); let method = request.method.clone(); let mut state = request.state; - if !state.open_documents.contains_key(&uri) { + if require_open_document && !state.open_documents.contains_key(&uri) { return Err(CoreError::new( ErrorCode::InvalidRequest, "Cannot request LSP features for a document that is not open.", diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index a87792876..3af794d17 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -3,17 +3,17 @@ use super::process::{LspProcessHandle, LspProcessLauncher, LspProcessSpec, SystemProcessLauncher}; use super::{ client_apply_server_message, client_change_document, client_close_document, - client_feature_request_canonical, client_initialize, client_open_document, client_shutdown, - frame_message, parse_server_messages, ClientApplyServerMessageRequest, - ClientChangeDocumentRequest, ClientCloseDocumentRequest, ClientFeatureRequest, - ClientInitializeRequest, ClientOpenDocumentRequest, ClientShutdownRequest, FrameMessageRequest, - LspClientDiagnostic, LspClientDocument, LspClientState, LspPosition, LspRange, - ParseServerMessagesRequest, + client_feature_request_canonical, client_initialize, client_open_document, + client_provider_document_feature_request_canonical, client_shutdown, frame_message, + parse_server_messages, ClientApplyServerMessageRequest, ClientChangeDocumentRequest, + ClientCloseDocumentRequest, ClientFeatureRequest, ClientInitializeRequest, + ClientOpenDocumentRequest, ClientShutdownRequest, FrameMessageRequest, LspClientDiagnostic, + LspClientDocument, LspClientState, LspPosition, LspRange, ParseServerMessagesRequest, }; use crate::lsp::languages::jdt::{ - adapt_initialization_options, adapt_start, initialized_notification, normalize_location, - virtual_source_content, virtual_source_resolve_params, workspace_configuration, - JdtStartContext, ProviderLocation, WorkspaceConfigurationItem, + adapt_initialization_options, adapt_start, initialized_notification, is_virtual_source_uri, + normalize_location, virtual_source_content, virtual_source_resolve_params, + workspace_configuration, JdtStartContext, ProviderLocation, WorkspaceConfigurationItem, }; use crate::protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; @@ -744,18 +744,19 @@ impl RuntimeSession { "This language-server operation requires a document URI.", ) })?; - if !state + let document_is_open = state .client .open_documents .get(&uri) - .is_some_and(|document| document.version > 0) - { + .is_some_and(|document| document.version > 0); + let provider_owns_document = is_virtual_source_uri(&self.provider_id, &uri); + if !document_is_open && !provider_owns_document { return Err(CoreError::new( ErrorCode::InvalidRequest, "The document is not open in the language server.", )); } - client_feature_request_canonical(ClientFeatureRequest { + let feature_request = ClientFeatureRequest { state: state.client.clone(), uri, method: method.to_string(), @@ -766,7 +767,12 @@ impl RuntimeSession { completion_item: request.completion_item, code_action: request.code_action, command: request.command, - })? + }; + if provider_owns_document { + client_provider_document_feature_request_canonical(feature_request)? + } else { + client_feature_request_canonical(feature_request)? + } } }; let request_id = (response.state.next_request_id - 1).to_string(); @@ -2236,6 +2242,145 @@ mod tests { } } + /// Owns an opt-in real-process smoke session and removes every temporary + /// resource even when the smoke test unwinds after a failed assertion. + struct RealSmokeCleanup<'a> { + engine: &'a LspEngine, + session_id: String, + root: PathBuf, + } + + impl Drop for RealSmokeCleanup<'_> { + fn drop(&mut self) { + if let Ok(session) = self.engine.session(&self.session_id) { + let _ = session.stop(); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + let terminal = session.snapshot().is_ok_and(|snapshot| { + matches!( + snapshot.state, + LspLifecycleState::Stopped | LspLifecycleState::Failed + ) + }); + if terminal { + break; + } + let _ = session.poll_events(); + thread::sleep(Duration::from_millis(20)); + } + if session.snapshot().is_ok_and(|snapshot| { + !matches!( + snapshot.state, + LspLifecycleState::Stopped | LspLifecycleState::Failed + ) + }) { + session.kill_process(); + } + } + let _ = self.engine.destroy(&self.session_id); + let _ = std::fs::remove_dir_all(&self.root); + } + } + + fn await_real_smoke_ready( + session: &Arc, + ) -> Result, String> { + let deadline = Instant::now() + Duration::from_secs(90); + let mut events = Vec::new(); + while Instant::now() < deadline { + events.extend(session.poll_events().map_err(|error| error.message)?); + let snapshot = session.snapshot().map_err(|error| error.message)?; + match snapshot.state { + LspLifecycleState::Ready => { + events.extend(session.poll_events().map_err(|error| error.message)?); + return Ok(events); + } + LspLifecycleState::Failed => { + return Err(format!("JDTLS failed during initialization: {events:?}")); + } + _ => thread::sleep(Duration::from_millis(20)), + } + } + Err(format!( + "JDTLS did not become ready before the smoke timeout: {events:?}" + )) + } + + fn real_smoke_request( + engine: &LspEngine, + session: &Arc, + operation: LspSemanticOperation, + uri: Option<&str>, + virtual_uri: Option<&str>, + position: Option, + ) -> Result { + let operation_id = engine.next_operation_id(); + session + .request( + SemanticRequest { + session_id: session.id.clone(), + operation_id: Some(operation_id.clone()), + operation, + uri: uri.map(str::to_string), + virtual_uri: virtual_uri.map(str::to_string), + position, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }, + operation_id.clone(), + ) + .map_err(|error| error.message)?; + + let deadline = Instant::now() + Duration::from_secs(35); + while Instant::now() < deadline { + for event in session.poll_events().map_err(|error| error.message)? { + if event.operation_id.as_deref() != Some(operation_id.as_str()) { + continue; + } + if let Some(error) = event.error { + return Err(format!( + "JDTLS smoke request {} failed: {error:?}", + semantic_method(operation) + )); + } + return event.result.ok_or_else(|| { + format!( + "JDTLS smoke request {} returned no result", + semantic_method(operation) + ) + }); + } + thread::sleep(Duration::from_millis(20)); + } + Err(format!( + "JDTLS smoke request {} timed out", + semantic_method(operation) + )) + } + + fn real_smoke_locations(result: &Value) -> &[Value] { + result + .get("locations") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + } + + fn real_smoke_token_position(text: &str, marker: &str, token_offset: usize) -> LspPosition { + let marker_index = text.find(marker).expect("smoke marker should exist") + token_offset; + let prefix = &text[..marker_index]; + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() as i64; + let line_prefix = prefix.rsplit_once('\n').map_or(prefix, |(_, tail)| tail); + LspPosition { + line, + utf16_column: line_prefix.encode_utf16().count() as i64, + } + } + /// Criterion 1: a spawned process that never initializes cannot become ready. #[test] fn a_server_that_never_answers_initialize_fails_instead_of_becoming_ready() { @@ -3220,6 +3365,320 @@ mod tests { assert!(event.error.is_none()); } + #[test] + fn java_virtual_semantics_bypass_did_open_without_weakening_physical_ownership() { + let mut harness = Harness::start(|request| { + request.provider_id = "java".to_string(); + request.cache_directory = Some("/tmp/lithe-lsp-engine-tests".to_string()); + }); + harness.server.complete_initialize(json!({ + "referencesProvider": true + })); + harness.await_state(LspLifecycleState::Ready); + let virtual_uri = "jdt://contents/java.base/java/lang/String.class?=smoke"; + let operation_id = harness.request(LspSemanticOperation::References, virtual_uri); + let request_id = harness + .server + .await_request("textDocument/references") + .expect("virtual references should reach JDT LS without didOpen"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": [] + })); + let event = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation_id.as_str())); + assert!(event.error.is_none()); + + let physical_operation_id = harness.engine.next_operation_id(); + let error = harness + .session() + .request( + SemanticRequest { + session_id: harness.session_id.clone(), + operation_id: Some(physical_operation_id.clone()), + operation: LspSemanticOperation::References, + uri: Some("file:///workspace/Unopened.java".to_string()), + virtual_uri: None, + position: Some(LspPosition { + line: 0, + utf16_column: 0, + }), + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }, + physical_operation_id, + ) + .expect_err("an unopened physical document must remain rejected"); + assert_eq!( + error.message, + "The document is not open in the language server." + ); + } + + #[test] + fn real_jdtls_routes_physical_and_virtual_references() { + let Ok(executable_path) = std::env::var("LITHE_JDTLS_SMOKE_EXECUTABLE") else { + return; + }; + let java_path = std::env::var("LITHE_JDTLS_SMOKE_JAVA") + .expect("LITHE_JDTLS_SMOKE_JAVA must accompany the JDTLS smoke executable"); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should follow the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-real-jdtls-smoke-{}-{stamp}", + std::process::id() + )); + let workspace = root.join("workspace"); + let source_directory = workspace + .join("src") + .join("main") + .join("java") + .join("smoke"); + let dependency_source_directory = root.join("dependency-source").join("dependency"); + let dependency_classes = root.join("dependency-classes"); + let dependency_jar = workspace.join("lib").join("smoke-dependency.jar"); + std::fs::create_dir_all(&source_directory).expect("smoke source directory should exist"); + std::fs::create_dir_all(&dependency_source_directory) + .expect("smoke dependency source directory should exist"); + std::fs::create_dir_all(&dependency_classes) + .expect("smoke dependency classes directory should exist"); + std::fs::create_dir_all( + dependency_jar + .parent() + .expect("dependency JAR should have a parent"), + ) + .expect("smoke dependency library directory should exist"); + let java_home = PathBuf::from(&java_path) + .parent() + .and_then(Path::parent) + .expect("smoke Java executable should be inside a JDK bin directory") + .to_path_buf(); + let executable_suffix = if cfg!(windows) { ".exe" } else { "" }; + let dependency_source = dependency_source_directory.join("Widget.java"); + std::fs::write( + &dependency_source, + "package dependency; public class Widget { public String value() { return \"widget\"; } }\n", + ) + .expect("smoke dependency source should be written"); + let javac_status = std::process::Command::new( + java_home + .join("bin") + .join(format!("javac{executable_suffix}")), + ) + .arg("-d") + .arg(&dependency_classes) + .arg(&dependency_source) + .status() + .expect("smoke javac should start"); + assert!(javac_status.success(), "smoke dependency should compile"); + let jar_status = std::process::Command::new( + java_home + .join("bin") + .join(format!("jar{executable_suffix}")), + ) + .arg("--create") + .arg("--file") + .arg(&dependency_jar) + .arg("-C") + .arg(&dependency_classes) + .arg(".") + .status() + .expect("smoke jar should start"); + assert!( + jar_status.success(), + "smoke dependency JAR should be created" + ); + std::fs::write( + workspace.join("pom.xml"), + r#" + 4.0.0 + smoke + lithe-jdtls-smoke + 1.0.0 + 17 + + + smoke + dependency + 1.0.0 + system + ${project.basedir}/lib/smoke-dependency.jar + + + +"#, + ) + .expect("smoke pom should be written"); + let source = r#"package smoke; + +import dependency.Widget; + +public class Main { + private Widget widget; + public Widget read() { return widget; } + public void write(Widget replacement) { widget = replacement; } +} +"#; + let source_path = source_directory.join("Main.java"); + std::fs::write(&source_path, source).expect("smoke source should be written"); + + let root_uri = url::Url::from_directory_path(&workspace) + .expect("workspace should convert to a file URI") + .to_string(); + let source_uri = url::Url::from_file_path(&source_path) + .expect("source should convert to a file URI") + .to_string(); + let engine = LspEngine::new(); + let started = engine + .start_server(StartServerRequest { + provider_id: "java".to_string(), + executable_path, + arguments: Vec::new(), + environment: BTreeMap::from([( + "JAVA_HOME".to_string(), + java_home.to_string_lossy().into_owned(), + )]), + root_uri, + working_directory: workspace.to_string_lossy().into_owned(), + initialization_options: None, + runtime_executable_path: Some(java_path), + cache_directory: Some(root.join("cache").to_string_lossy().into_owned()), + initialize_timeout_milliseconds: 90_000, + request_timeout_milliseconds: 30_000, + shutdown_timeout_milliseconds: 10_000, + }) + .expect("real JDTLS should start"); + let _cleanup = RealSmokeCleanup { + engine: &engine, + session_id: started.session_id.clone(), + root, + }; + let session = engine + .session(&started.session_id) + .expect("real JDTLS session should be registered"); + let mut ready_events = + await_real_smoke_ready(&session).unwrap_or_else(|error| panic!("{error}")); + let capability_deadline = Instant::now() + Duration::from_secs(30); + let capabilities = loop { + if let Some(capabilities) = ready_events.iter().find_map(|event| { + event + .capabilities + .as_ref() + .filter(|capabilities| { + ["definition", "references", "executeCommand"] + .iter() + .all(|required| capabilities.iter().any(|feature| feature == required)) + }) + .cloned() + }) { + break capabilities; + } + assert!( + Instant::now() < capability_deadline, + "real JDTLS should dynamically publish capabilities: {ready_events:?}" + ); + ready_events.extend( + session + .poll_events() + .expect("real JDTLS capability events should poll"), + ); + thread::sleep(Duration::from_millis(20)); + }; + for required in ["definition", "references", "executeCommand"] { + assert!( + capabilities.iter().any(|feature| feature == required), + "real JDTLS did not negotiate {required}: {capabilities:?}" + ); + } + + session + .sync_document(SyncDocumentRequest { + session_id: started.session_id.clone(), + uri: source_uri.clone(), + language_id: "java".to_string(), + text: source.to_string(), + }) + .expect("smoke source should synchronize"); + + let field_position = real_smoke_token_position(source, "Widget widget", "Widget ".len()); + let physical_deadline = Instant::now() + Duration::from_secs(30); + let physical_references = loop { + let result = real_smoke_request( + &engine, + &session, + LspSemanticOperation::References, + Some(&source_uri), + None, + Some(field_position), + ) + .unwrap_or_else(|error| panic!("{error}")); + if real_smoke_locations(&result).len() >= 3 { + break result; + } + assert!( + Instant::now() < physical_deadline, + "real JDTLS did not index physical references: {result}" + ); + thread::sleep(Duration::from_millis(250)); + }; + assert!(real_smoke_locations(&physical_references).len() >= 3); + + let widget_position = real_smoke_token_position(source, "Widget widget", 1); + let definition = real_smoke_request( + &engine, + &session, + LspSemanticOperation::Definition, + Some(&source_uri), + None, + Some(widget_position), + ) + .unwrap_or_else(|error| panic!("{error}")); + let virtual_uri = real_smoke_locations(&definition) + .iter() + .find_map(|location| location.get("uri").and_then(Value::as_str)) + .filter(|uri| uri.starts_with("jdt://")) + .expect("Widget definition should resolve to a JDT virtual URI") + .to_string(); + let virtual_document = real_smoke_request( + &engine, + &session, + LspSemanticOperation::VirtualDocument, + None, + Some(&virtual_uri), + None, + ) + .unwrap_or_else(|error| panic!("{error}")); + let virtual_source = virtual_document + .get("text") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .expect("JDTLS should return decompiled Widget source"); + let virtual_position = + real_smoke_token_position(virtual_source, "class Widget", "class ".len()); + let virtual_references = real_smoke_request( + &engine, + &session, + LspSemanticOperation::References, + Some(&virtual_uri), + None, + Some(virtual_position), + ) + .unwrap_or_else(|error| panic!("{error}")); + assert!( + real_smoke_locations(&virtual_references).iter().any(|location| { + location.get("uri").and_then(Value::as_str) == Some(source_uri.as_str()) + }), + "virtual Widget references should include the synchronized project source: {virtual_references}" + ); + } + #[test] fn java_runtime_is_derived_from_the_start_environment() { let environment = BTreeMap::from([("JAVA_HOME".to_string(), "/jdk".to_string())]); diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs index 4f24ae085..7ec35d491 100644 --- a/rust/lithe-core/src/lsp/languages/jdt.rs +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -184,7 +184,7 @@ pub(crate) fn virtual_source_resolve_params( provider_id: &str, uri: &str, ) -> Option { - if !is_java_provider(provider_id) || !has_uri_scheme(uri, JDT_URI_SCHEME) { + if !is_virtual_source_uri(provider_id, uri) { return None; } Some(ExecuteCommandParams { @@ -193,6 +193,11 @@ pub(crate) fn virtual_source_resolve_params( }) } +/// Returns whether a URI names a virtual source document owned by JDT LS. +pub(crate) fn is_virtual_source_uri(provider_id: &str, uri: &str) -> bool { + is_java_provider(provider_id) && has_uri_scheme(uri, JDT_URI_SCHEME) +} + /// Extracts the text shape returned by JDT LS for `java.decompile`. /// Different JDT LS versions return either the string directly or wrap it in /// a `content` member. diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index 721820fe5..ad43e5aac 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -167,7 +167,8 @@ export function MonacoEditor({ const buffer = activeBuffer && activeBuffer.type === "editor" ? activeBuffer : null; const content = buffer?.content ?? ""; const filePath = buffer?.path ?? ""; - const languageId = buffer?.languageOverride ?? getLanguageIdFromPath(filePath); + const languageId = + buffer?.languageOverride ?? buffer?.language ?? getLanguageIdFromPath(filePath); const monacoLanguageId = toMonacoLanguageId(languageId); const { fontFamily, diff --git a/windows/tauri/src/features/editor/engines/monaco/code-lens-provider.ts b/windows/tauri/src/features/editor/engines/monaco/code-lens-provider.ts index 8cd8e6fb0..af70765e7 100644 --- a/windows/tauri/src/features/editor/engines/monaco/code-lens-provider.ts +++ b/windows/tauri/src/features/editor/engines/monaco/code-lens-provider.ts @@ -6,10 +6,15 @@ import { languages, } from "monaco-editor"; import type * as Monaco from "monaco-editor"; +import { listen } from "@tauri-apps/api/event"; import { toast } from "sonner"; -import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; +import { + lspDocumentTargetForEditorPath, + type LspDocumentTarget, +} from "@/features/editor/lsp/lsp-document-target"; import { LspClient } from "@/features/editor/lsp/lsp-client"; import { useLspStore } from "@/features/editor/lsp/stores/lsp.store"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { filePathFromUri } from "@/features/editor/lsp/workspace-edit"; import { MONACO_HIGHLIGHT_LANGUAGE_IDS } from "./language"; import { filePathFromLitheModelUri } from "./model-uri"; @@ -25,7 +30,7 @@ interface LspCodeLens { } interface ExecuteLspCodeLensPayload { - filePath: string; + target: LspDocumentTarget; lens: LspCodeLens; } @@ -114,6 +119,7 @@ function toShowReferencesArguments(argumentsValue: unknown[] | undefined): unkno export function toMonacoCodeLens( filePath: string, lens: LspCodeLens, + target: LspDocumentTarget = { filePath }, ): Monaco.languages.CodeLens | null { if (!lens.command) return null; @@ -137,7 +143,7 @@ export function toMonacoCodeLens( command: { id: EXECUTE_LSP_CODE_LENS_COMMAND, title: lens.title, - arguments: [{ filePath, lens } satisfies ExecuteLspCodeLensPayload], + arguments: [{ target, lens } satisfies ExecuteLspCodeLensPayload], }, }; } @@ -152,21 +158,21 @@ export function registerMonacoCodeLensProvider(): void { onDidChange: codeLensesChanged.event, async provideCodeLenses(model, token) { const filePath = filePathFromModel(model); + const target = lspDocumentTargetForEditorPath(useBufferStore.getState().buffers, filePath); if ( - !filePath || - !isEditorLspSupported(filePath) || - !lspClient.getActiveServerEntryForFile(filePath) || - !lspClient.isDocumentOpen(filePath) + !target || + !lspClient.getDocumentAvailability(target, "codeLens").available || + (!target.documentUri && !lspClient.isDocumentOpen(target.filePath)) ) { return { lenses: [] }; } - const lenses = await lspClient.getCodeLens(filePath); + const lenses = await lspClient.getCodeLens(target); if (token.isCancellationRequested) return { lenses: [] }; return { lenses: lenses - .map((lens) => toMonacoCodeLens(filePath, lens)) + .map((lens) => toMonacoCodeLens(filePath, lens, target)) .filter((lens): lens is Monaco.languages.CodeLens => lens !== null), }; }, @@ -181,14 +187,15 @@ export function registerMonacoCodeLensProvider(): void { codeLensesChanged.fire(provider); } }); + void listen("lsp://features-changed", () => codeLensesChanged.fire(provider)); monacoEditor.addCommand({ id: EXECUTE_LSP_CODE_LENS_COMMAND, run: (_accessor, payload: ExecuteLspCodeLensPayload | undefined) => { - if (!payload?.filePath || !payload.lens.command) return; + if (!payload?.target.filePath || !payload.lens.command) return; void lspClient - .applyCodeAction(payload.filePath, { + .applyCodeAction(payload.target, { title: payload.lens.title, command: payload.lens.command, arguments: payload.lens.arguments ?? [], diff --git a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts index 6033f2660..9301454a8 100644 --- a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts +++ b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts @@ -1,19 +1,18 @@ -import { Emitter, languages, Range as MonacoRange, Uri } from "monaco-editor"; +import { languages, Range as MonacoRange, Uri } from "monaco-editor"; import type * as Monaco from "monaco-editor"; import type { CompletionItem, Hover } from "vscode-languageserver-protocol"; import { LspClient } from "@/features/editor/lsp/lsp-client"; import { formatHoverContents } from "@/features/editor/lsp/hover-content"; -import { useLspStore } from "@/features/editor/lsp/stores/lsp.store"; +import { lspDocumentTargetForEditorPath } from "@/features/editor/lsp/lsp-document-target"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { collectWorkspaceTextEdits, filePathFromUri, isWorkspaceEdit, type LspTextEdit, } from "@/features/editor/lsp/workspace-edit"; -import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; import { MONACO_HIGHLIGHT_LANGUAGE_IDS } from "./language"; import { filePathFromLitheModelUri } from "./model-uri"; -import { createMonacoSemanticTokenProvider } from "./semantic-token-provider"; let providersRegistered = false; @@ -185,47 +184,28 @@ function toWorkspaceEdit(edit: unknown): Monaco.languages.WorkspaceEdit | undefi return edits.length > 0 ? { edits } : undefined; } -function isLspModel(model: Monaco.editor.ITextModel): boolean { - const filePath = filePathFromModel(model); - return isEditorLspSupported(filePath); -} - export function registerMonacoLspProviders() { if (providersRegistered) return; providersRegistered = true; const selector = Array.from(MONACO_HIGHLIGHT_LANGUAGE_IDS); const lspClient = LspClient.getInstance(); - const semanticTokensChanged = new Emitter(); - useLspStore.subscribe((state, previousState) => { - const currentStatus = state.lspStatus; - const previousStatus = previousState.lspStatus; - if ( - currentStatus.status !== previousStatus.status || - currentStatus.documentRevision !== previousStatus.documentRevision - ) { - semanticTokensChanged.fire(); - } - }); - - languages.registerDocumentSemanticTokensProvider( - selector, - createMonacoSemanticTokenProvider({ - client: lspClient, - filePathFromModel, - isLspModel, - onDidChange: semanticTokensChanged.event, - }), - ); + const availableTarget = (model: Monaco.editor.ITextModel, feature: string) => { + const target = lspDocumentTargetForEditorPath( + useBufferStore.getState().buffers, + filePathFromModel(model), + ); + return target && lspClient.getDocumentAvailability(target, feature).available ? target : null; + }; languages.registerCompletionItemProvider(selector, { triggerCharacters: [".", ":", "<", '"', "'", "/", "@", "#"], async provideCompletionItems(model, position) { - if (!isLspModel(model)) return { suggestions: [] }; + const target = availableTarget(model, "completion"); + if (!target) return { suggestions: [] }; - const filePath = filePathFromModel(model); const completions = await lspClient.getCompletions( - filePath, + target, position.lineNumber - 1, position.column - 1, ); @@ -245,13 +225,10 @@ export function registerMonacoLspProviders() { languages.registerHoverProvider(selector, { async provideHover(model, position) { - if (!isLspModel(model)) return null; + const target = availableTarget(model, "hover"); + if (!target) return null; - const hover = await lspClient.getHover( - filePathFromModel(model), - position.lineNumber - 1, - position.column - 1, - ); + const hover = await lspClient.getHover(target, position.lineNumber - 1, position.column - 1); const contents = hoverToMarkdown(hover); return contents.length > 0 ? { contents } : null; }, @@ -262,10 +239,11 @@ export function registerMonacoLspProviders() { languages.registerImplementationProvider(selector, { async provideImplementation(model, position) { - if (!isLspModel(model)) return []; + const target = availableTarget(model, "implementation"); + if (!target) return []; const locations = await lspClient.getImplementation( - filePathFromModel(model), + target, position.lineNumber - 1, position.column - 1, ); @@ -278,10 +256,11 @@ export function registerMonacoLspProviders() { languages.registerTypeDefinitionProvider(selector, { async provideTypeDefinition(model, position) { - if (!isLspModel(model)) return []; + const target = availableTarget(model, "typeDefinition"); + if (!target) return []; const locations = await lspClient.getTypeDefinition( - filePathFromModel(model), + target, position.lineNumber - 1, position.column - 1, ); @@ -294,10 +273,11 @@ export function registerMonacoLspProviders() { languages.registerReferenceProvider(selector, { async provideReferences(model, position) { - if (!isLspModel(model)) return []; + const target = availableTarget(model, "references"); + if (!target) return []; const locations = await lspClient.getReferences( - filePathFromModel(model), + target, position.lineNumber - 1, position.column - 1, ); @@ -310,7 +290,8 @@ export function registerMonacoLspProviders() { languages.registerRenameProvider(selector, { async resolveRenameLocation(model, position) { - if (!isLspModel(model)) { + const target = availableTarget(model, "rename"); + if (!target) { return { range: new MonacoRange( position.lineNumber, @@ -322,46 +303,30 @@ export function registerMonacoLspProviders() { }; } - const prepared = await lspClient.prepareRename( - filePathFromModel(model), - position.lineNumber - 1, - position.column - 1, - ); - const range = - prepared?.range ?? - (prepared?.start && prepared?.end ? { start: prepared.start, end: prepared.end } : null); - - if (!range) { - const word = model.getWordAtPosition(position); - return { - range: word - ? new MonacoRange( - position.lineNumber, - word.startColumn, - position.lineNumber, - word.endColumn, - ) - : new MonacoRange( - position.lineNumber, - position.column, - position.lineNumber, - position.column, - ), - text: prepared?.placeholder || word?.word || "", - }; - } - - const monacoRange = toMonacoRange(range); + const word = model.getWordAtPosition(position); return { - range: monacoRange, - text: prepared?.placeholder || model.getValueInRange(monacoRange), + range: word + ? new MonacoRange( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn, + ) + : new MonacoRange( + position.lineNumber, + position.column, + position.lineNumber, + position.column, + ), + text: word?.word || "", }; }, async provideRenameEdits(model, position, newName) { - if (!isLspModel(model)) return undefined; + const target = availableTarget(model, "rename"); + if (!target) return undefined; const edit = await lspClient.rename( - filePathFromModel(model), + target, position.lineNumber - 1, position.column - 1, newName, @@ -372,14 +337,14 @@ export function registerMonacoLspProviders() { languages.registerCodeActionProvider(selector, { async provideCodeActions(model, _range, context) { - if (!isLspModel(model)) return { actions: [], dispose: () => {} }; + const target = availableTarget(model, "codeActions"); + if (!target) return { actions: [], dispose: () => {} }; - const filePath = filePathFromModel(model); const actions: Monaco.languages.CodeAction[] = []; for (const marker of context.markers.slice(0, 3)) { const diagnostic = { severity: marker.severity === 8 ? "error" : marker.severity === 4 ? "warning" : "info", - filePath, + filePath: target.filePath, line: marker.startLineNumber - 1, column: marker.startColumn - 1, endLine: marker.endLineNumber - 1, @@ -388,7 +353,7 @@ export function registerMonacoLspProviders() { source: marker.source, code: typeof marker.code === "string" ? marker.code : undefined, } as const; - const lspActions = await lspClient.getCodeActions(filePath, diagnostic); + const lspActions = await lspClient.getCodeActions(target, diagnostic); for (const action of lspActions) { if (action.disabledReason) continue; const edit = toWorkspaceEdit(getPayloadEdit(action.payload)); diff --git a/windows/tauri/src/features/editor/lsp/language-server-navigation.test.ts b/windows/tauri/src/features/editor/lsp/language-server-navigation.test.ts index 7102023c4..aabe47ad5 100644 --- a/windows/tauri/src/features/editor/lsp/language-server-navigation.test.ts +++ b/windows/tauri/src/features/editor/lsp/language-server-navigation.test.ts @@ -8,10 +8,27 @@ describe("language server jump messages", () => { languageId: "java", status: "connected", hasSession: true, + ready: true, + featuresKnown: true, + supportsFeature: true, }), ).toBeNull(); }); + test("reports a negotiated unsupported capability", () => { + expect( + languageServerUnavailableMessage({ + languageId: "java", + status: "connected", + hasSession: true, + ready: true, + featuresKnown: true, + supportsFeature: false, + featureLabel: "references", + }), + ).toBe("Java language server does not support references."); + }); + test("reports startup, failure, and not-ready states", () => { expect( languageServerUnavailableMessage({ diff --git a/windows/tauri/src/features/editor/lsp/language-server-navigation.ts b/windows/tauri/src/features/editor/lsp/language-server-navigation.ts index bebf2a699..112cd8dcf 100644 --- a/windows/tauri/src/features/editor/lsp/language-server-navigation.ts +++ b/windows/tauri/src/features/editor/lsp/language-server-navigation.ts @@ -11,10 +11,17 @@ export function languageServerUnavailableMessage(args: { status: LspStatus; lastError?: string; hasSession: boolean; + ready?: boolean; + featuresKnown?: boolean; + supportsFeature?: boolean; + featureLabel?: string; }): string | null { - if (args.hasSession && args.status === "connected") return null; + if (args.hasSession && args.ready !== false && args.supportsFeature !== false) return null; const name = languageDisplayName(args.languageId); + if (args.hasSession && args.ready && args.featuresKnown && args.supportsFeature === false) { + return `${name} language server does not support ${args.featureLabel || "this feature"}.`; + } if (args.status === "connecting") { return `${name} language server is starting.`; } diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 6c2f26379..b743d6963 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -1,4 +1,8 @@ -import { invokeLsp as invoke } from "@/platform/lsp-core-adapter"; +import { + getLspSessionSnapshot, + invokeLsp as invoke, + isLspSemanticCommandSupported, +} from "@/platform/lsp-core-adapter"; import { listen } from "@tauri-apps/api/event"; import type { CompletionItem, @@ -17,11 +21,17 @@ import type { import { hasTextContent, shouldStartLsp } from "@/features/panes/types/pane-content.types"; import { useBufferStore } from "../stores/buffer.store"; import { logger } from "../utils/logger"; +import { normalizePath } from "@/utils/path-helpers"; import { isBuiltInLspPath, languageIdForEditorFile } from "./built-in-language-support"; import { resolvePublishedDiagnosticsFilePath } from "./diagnostics-file-path"; import { resolveEditorLspLaunch } from "./resolve-editor-lsp-launch"; import type { LspSemanticTokensResponse } from "./semantic-token-types"; -import { useLspStore } from "./stores/lsp.store"; +import { useLspStore, type LspStatus } from "./stores/lsp.store"; +import { + lspDocumentRequestArgs, + normalizeLspDocumentTarget, + type LspDocumentTargetInput, +} from "./lsp-document-target"; import { applyWorkspaceEdit, applyTextEditsToContent, @@ -36,6 +46,17 @@ export interface LspError { code?: string; } +export interface LspDocumentAvailability { + languageId?: string; + status: LspStatus; + hasSession: boolean; + ready: boolean; + featuresKnown: boolean; + supportsFeature: boolean; + available: boolean; + workspacePath?: string; +} + export interface LspLocation { uri: string; filePath?: string | null; @@ -127,6 +148,11 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function trackedFileKey(filePath: string): string { + const normalized = normalizePath(filePath); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalized) ? normalized.toLowerCase() : normalized; +} + function getCodeActionEdit(actionPayload: unknown): unknown { if (!isRecord(actionPayload)) return null; return actionPayload.edit; @@ -261,21 +287,38 @@ export class LspClient { } private findServerKeyForFile(filePath: string, languageId?: string): string | null { + const targetKey = trackedFileKey(filePath); + const tracksFile = (trackedFiles: Set) => + Array.from(trackedFiles).some((trackedFile) => trackedFileKey(trackedFile) === targetKey); if (languageId) { const directMatch = Array.from(this.activeServerFiles.entries()).find( - ([key, trackedFiles]) => trackedFiles.has(filePath) && key.endsWith(`:${languageId}`), + ([key, trackedFiles]) => tracksFile(trackedFiles) && key.endsWith(`:${languageId}`), ); if (directMatch) return directMatch[0]; } const fallbackMatch = Array.from(this.activeServerFiles.entries()).find(([, trackedFiles]) => - trackedFiles.has(filePath), + tracksFile(trackedFiles), ); return fallbackMatch?.[0] ?? null; } private addTrackedFile(serverKey: string, filePath: string) { + const targetKey = trackedFileKey(filePath); + for (const [existingKey, trackedFiles] of this.activeServerFiles) { + if (existingKey === serverKey) continue; + for (const trackedFile of trackedFiles) { + if (trackedFileKey(trackedFile) === targetKey) trackedFiles.delete(trackedFile); + } + if (trackedFiles.size === 0) { + this.activeServerFiles.delete(existingKey); + this.activeLanguageServers.delete(existingKey); + } + } const trackedFiles = this.activeServerFiles.get(serverKey) ?? new Set(); + for (const trackedFile of trackedFiles) { + if (trackedFileKey(trackedFile) === targetKey) trackedFiles.delete(trackedFile); + } trackedFiles.add(filePath); this.activeServerFiles.set(serverKey, trackedFiles); } @@ -284,7 +327,10 @@ export class LspClient { const trackedFiles = this.activeServerFiles.get(serverKey); if (!trackedFiles) return; - trackedFiles.delete(filePath); + const targetKey = trackedFileKey(filePath); + for (const trackedFile of trackedFiles) { + if (trackedFileKey(trackedFile) === targetKey) trackedFiles.delete(trackedFile); + } if (trackedFiles.size === 0) { this.activeServerFiles.delete(serverKey); return; @@ -676,7 +722,38 @@ export class LspClient { } hasSessionForFile(filePath: string): boolean { - return this.findServerKeyForFile(filePath, languageIdForEditorFile(filePath)) !== null; + return getLspSessionSnapshot({ filePath }) !== null; + } + + getDocumentAvailability( + target: LspDocumentTargetInput, + feature?: string, + ): LspDocumentAvailability { + const document = normalizeLspDocumentTarget(target); + const session = getLspSessionSnapshot({ + filePath: document.filePath, + sessionFilePath: document.sessionFilePath, + }); + const languageId = + document.languageId ?? session?.languageId ?? languageIdForEditorFile(document.filePath); + const supportsFeature = Boolean( + session && (!feature || (session.featuresKnown && session.features.includes(feature))), + ); + const status: LspStatus = session + ? session.ready + ? "connected" + : "connecting" + : "disconnected"; + return { + languageId, + status, + hasSession: session !== null, + ready: session?.ready ?? false, + featuresKnown: session?.featuresKnown ?? false, + supportsFeature, + available: Boolean(session?.ready && supportsFeature), + workspacePath: session?.workspacePath, + }; } /** @@ -816,18 +893,22 @@ export class LspClient { } async getCompletions( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ): Promise { + const document = normalizeLspDocumentTarget(target); try { - logger.debug("LSPClient", `Getting completions for ${filePath}:${line}:${character}`); + logger.debug( + "LSPClient", + `Getting completions for ${document.filePath}:${line}:${character}`, + ); logger.debug( "LSPClient", `Active language servers: ${Array.from(this.activeLanguageServers).join(", ")}`, ); const completions = await invoke("lsp_get_completions", { - filePath, + ...lspDocumentRequestArgs(document), line, character, }); @@ -843,10 +924,14 @@ export class LspClient { } } - async getHover(filePath: string, line: number, character: number): Promise { + async getHover( + target: LspDocumentTargetInput, + line: number, + character: number, + ): Promise { try { return await invoke("lsp_get_hover", { - filePath, + ...lspDocumentRequestArgs(target), line, character, }); @@ -861,14 +946,15 @@ export class LspClient { private async getNavigationLocations( command: "lsp_get_definition" | "lsp_get_implementation" | "lsp_get_type_definition", label: string, - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ): Promise { + const document = normalizeLspDocumentTarget(target); try { - logger.debug("LSPClient", `Getting ${label} for ${filePath}:${line}:${character}`); + logger.debug("LSPClient", `Getting ${label} for ${document.filePath}:${line}:${character}`); const locations = await invoke(command, { - filePath, + ...lspDocumentRequestArgs(document), line, character, }); @@ -883,42 +969,36 @@ export class LspClient { } async getDefinition( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ): Promise { - return this.getNavigationLocations( - "lsp_get_definition", - "definition", - filePath, - line, - character, - ); + return this.getNavigationLocations("lsp_get_definition", "definition", target, line, character); } async getImplementation( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ): Promise { return this.getNavigationLocations( "lsp_get_implementation", "implementation", - filePath, + target, line, character, ); } async getTypeDefinition( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ): Promise { return this.getNavigationLocations( "lsp_get_type_definition", "type definition", - filePath, + target, line, character, ); @@ -937,6 +1017,7 @@ export class LspClient { } async getSemanticTokens(filePath: string): Promise { + if (!isLspSemanticCommandSupported("lsp_get_semantic_tokens")) return null; try { return await invoke("lsp_get_semantic_tokens", { filePath }); } catch (error) { @@ -946,7 +1027,7 @@ export class LspClient { } } - async getCodeLens(filePath: string): Promise< + async getCodeLens(target: LspDocumentTargetInput): Promise< { line: number; title: string; @@ -955,7 +1036,7 @@ export class LspClient { }[] > { try { - return await invoke("lsp_get_code_lens", { filePath }); + return await invoke("lsp_get_code_lens", lspDocumentRequestArgs(target)); } catch (error) { if (isCanceledLspRequest(error)) return []; logger.error("LSPClient", "LSP code lens error:", error); @@ -964,7 +1045,7 @@ export class LspClient { } async getInlayHints( - filePath: string, + target: LspDocumentTargetInput, startLine: number, endLine: number, ): Promise< @@ -979,7 +1060,7 @@ export class LspClient { > { try { return await invoke("lsp_get_inlay_hints", { - filePath, + ...lspDocumentRequestArgs(target), startLine, endLine, }); @@ -1003,6 +1084,7 @@ export class LspClient { hierarchyPath?: number[]; }[] > { + if (!isLspSemanticCommandSupported("lsp_get_document_symbols")) return []; try { logger.debug("LSPClient", `Getting document symbols for ${filePath}`); const symbols = await invoke< @@ -1042,6 +1124,7 @@ export class LspClient { filePath: string; }[] > { + if (!isLspSemanticCommandSupported("lsp_get_workspace_symbols")) return []; try { logger.debug("LSPClient", `Getting workspace symbols for "${query}" in ${workspacePath}`); const symbols = await invoke< @@ -1083,6 +1166,7 @@ export class LspClient { activeSignature?: number; activeParameter?: number; } | null> { + if (!isLspSemanticCommandSupported("lsp_get_signature_help")) return null; try { return await invoke("lsp_get_signature_help", { filePath, @@ -1096,6 +1180,7 @@ export class LspClient { } async getSignatureTriggerCharacters(filePath: string): Promise { + if (!isLspSemanticCommandSupported("lsp_get_signature_trigger_characters")) return []; try { return await invoke("lsp_get_signature_trigger_characters", { filePath }); } catch (error) { @@ -1104,9 +1189,12 @@ export class LspClient { } } - async formatDocument(filePath: string, content: string): Promise { + async formatDocument(target: LspDocumentTargetInput, content: string): Promise { try { - const edits = await invoke("lsp_format_document", { filePath }); + const edits = await invoke( + "lsp_format_document", + lspDocumentRequestArgs(target), + ); if (!edits.length) return content; return applyTextEditsToContent(content, edits); } catch (error) { @@ -1123,6 +1211,7 @@ export class LspClient { end: { line: number; character: number }; }, ): Promise { + if (!isLspSemanticCommandSupported("lsp_format_range")) return null; try { const edits = await invoke("lsp_format_range", { filePath, @@ -1140,7 +1229,7 @@ export class LspClient { } async getReferences( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ): Promise< @@ -1153,8 +1242,9 @@ export class LspClient { }[] | null > { + const document = normalizeLspDocumentTarget(target); try { - logger.debug("LSPClient", `Getting references for ${filePath}:${line}:${character}`); + logger.debug("LSPClient", `Getting references for ${document.filePath}:${line}:${character}`); const references = await invoke< | { uri: string; @@ -1165,7 +1255,7 @@ export class LspClient { }[] | null >("lsp_get_references", { - filePath, + ...lspDocumentRequestArgs(document), line, character, }); @@ -1180,15 +1270,19 @@ export class LspClient { } async rename( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, newName: string, ): Promise { + const document = normalizeLspDocumentTarget(target); try { - logger.debug("LSPClient", `Renaming at ${filePath}:${line}:${character} to "${newName}"`); + logger.debug( + "LSPClient", + `Renaming at ${document.filePath}:${line}:${character} to "${newName}"`, + ); const result = await invoke("lsp_rename", { - filePath, + ...lspDocumentRequestArgs(document), line, character, newName, @@ -1208,6 +1302,7 @@ export class LspClient { line: number, character: number, ): Promise { + if (!isLspSemanticCommandSupported("lsp_prepare_rename")) return null; try { return await invoke("lsp_prepare_rename", { filePath, @@ -1220,10 +1315,13 @@ export class LspClient { } } - async getCodeActions(filePath: string, diagnostic: Diagnostic): Promise { + async getCodeActions( + target: LspDocumentTargetInput, + diagnostic: Diagnostic, + ): Promise { try { return await invoke("lsp_get_code_actions", { - filePath, + ...lspDocumentRequestArgs(target), diagnostic: { line: diagnostic.line, column: diagnostic.column, @@ -1242,7 +1340,7 @@ export class LspClient { } async applyCodeAction( - filePath: string, + target: LspDocumentTargetInput, actionPayload: unknown, ): Promise { try { @@ -1260,7 +1358,7 @@ export class LspClient { } const result = await invoke("lsp_apply_code_action", { - filePath, + ...lspDocumentRequestArgs(target), actionPayload, }); diff --git a/windows/tauri/src/features/editor/lsp/lsp-document-target.test.ts b/windows/tauri/src/features/editor/lsp/lsp-document-target.test.ts new file mode 100644 index 000000000..5524a82ce --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/lsp-document-target.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import type { EditorContent } from "@/features/panes/types/pane-content.types"; +import { + lspDocumentRequestArgs, + lspDocumentTargetForEditor, + lspDocumentTargetForEditorPath, + lspSessionFilePath, +} from "./lsp-document-target"; + +function editor(overrides: Partial): EditorContent { + return { + id: "buffer-1", + type: "editor", + path: "C:/work/Main.java", + name: "Main.java", + content: "class Main {}", + savedContent: "class Main {}", + isDirty: false, + isVirtual: false, + isPinned: false, + isPreview: false, + isActive: true, + tokens: [], + ...overrides, + }; +} + +describe("LSP document targets", () => { + test("uses a physical editor path as its session and document identity", () => { + const target = lspDocumentTargetForEditor(editor({})); + + expect(target).toEqual({ + filePath: "C:/work/Main.java", + documentUri: undefined, + sessionFilePath: undefined, + languageId: "java", + }); + expect(lspSessionFilePath(target)).toBe("C:/work/Main.java"); + }); + + test("preserves an opaque virtual URI and reuses its source-file session", () => { + const target = lspDocumentTargetForEditor( + editor({ + path: "jdt://contents/java.base/java/lang/String.class?=demo", + name: "String.java", + isVirtual: true, + readOnly: true, + language: "java", + lspDocument: { + documentUri: "jdt://contents/java.base/java/lang/String.class?=demo", + sessionFilePath: "C:/work/Main.java", + languageId: "java", + }, + }), + ); + + expect(lspDocumentRequestArgs(target)).toEqual({ + filePath: "jdt://contents/java.base/java/lang/String.class?=demo", + sessionFilePath: "C:/work/Main.java", + documentUri: "jdt://contents/java.base/java/lang/String.class?=demo", + }); + expect(target.languageId).toBe("java"); + }); + + test("prefers an explicit language override over the provider binding", () => { + const target = lspDocumentTargetForEditor( + editor({ + languageOverride: "kotlin", + lspDocument: { + documentUri: "provider://document", + sessionFilePath: "C:/work/Main.java", + languageId: "java", + }, + }), + ); + + expect(target.languageId).toBe("kotlin"); + }); + + test("resolves the provider target from the backing virtual editor buffer", () => { + const virtual = editor({ + path: "jdt://contents/java.base/java/lang/String.class?=demo", + isVirtual: true, + lspDocument: { + documentUri: "jdt://contents/java.base/java/lang/String.class?=demo", + sessionFilePath: "C:/work/Main.java", + languageId: "java", + }, + }); + + expect(lspDocumentTargetForEditorPath([virtual], virtual.path)).toEqual({ + filePath: virtual.path, + documentUri: virtual.path, + sessionFilePath: "C:/work/Main.java", + languageId: "java", + }); + expect(lspDocumentTargetForEditorPath([virtual], "C:/work/Missing.java")).toBeNull(); + }); +}); diff --git a/windows/tauri/src/features/editor/lsp/lsp-document-target.ts b/windows/tauri/src/features/editor/lsp/lsp-document-target.ts new file mode 100644 index 000000000..c6c5d997b --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/lsp-document-target.ts @@ -0,0 +1,51 @@ +import { getBufferByPath } from "@/features/editor/utils/buffer-index"; +import type { EditorContent, PaneContent } from "@/features/panes/types/pane-content.types"; +import { languageIdForEditorFile } from "./built-in-language-support"; + +export interface LspDocumentTarget { + filePath: string; + documentUri?: string; + sessionFilePath?: string; + languageId?: string; +} + +export type LspDocumentTargetInput = string | LspDocumentTarget; + +export function normalizeLspDocumentTarget(target: LspDocumentTargetInput): LspDocumentTarget { + return typeof target === "string" ? { filePath: target } : target; +} + +export function lspDocumentTargetForEditor(buffer: EditorContent): LspDocumentTarget { + return { + filePath: buffer.path, + documentUri: buffer.lspDocument?.documentUri, + sessionFilePath: buffer.lspDocument?.sessionFilePath, + languageId: + buffer.languageOverride ?? + buffer.lspDocument?.languageId ?? + buffer.language ?? + languageIdForEditorFile(buffer.path), + }; +} + +export function lspDocumentTargetForEditorPath( + buffers: readonly PaneContent[], + filePath: string, +): LspDocumentTarget | null { + const buffer = getBufferByPath(buffers, filePath); + return buffer?.type === "editor" ? lspDocumentTargetForEditor(buffer) : null; +} + +export function lspDocumentRequestArgs(target: LspDocumentTargetInput) { + const document = normalizeLspDocumentTarget(target); + return { + filePath: document.filePath, + sessionFilePath: document.sessionFilePath, + documentUri: document.documentUri, + }; +} + +export function lspSessionFilePath(target: LspDocumentTargetInput): string { + const document = normalizeLspDocumentTarget(target); + return document.sessionFilePath ?? document.filePath; +} diff --git a/windows/tauri/src/features/editor/lsp/navigation-target.test.ts b/windows/tauri/src/features/editor/lsp/navigation-target.test.ts index 908f074ad..9dbb2c4d9 100644 --- a/windows/tauri/src/features/editor/lsp/navigation-target.test.ts +++ b/windows/tauri/src/features/editor/lsp/navigation-target.test.ts @@ -11,6 +11,7 @@ const range = { function createOptions(location: LspLocation, buffers: PaneContent[] = []) { const openContent = mock((_spec: OpenContentSpec) => "opened-buffer"); const setActiveBuffer = mock((_bufferId: string) => undefined); + const updateBuffer = mock((_buffer: PaneContent) => undefined); const getVirtualDocument = mock( async (): Promise => "public final class String {}", ); @@ -21,12 +22,13 @@ function createOptions(location: LspLocation, buffers: PaneContent[] = []) { location, sourceFilePath: "C:/work/src/Main.java", buffers, - actions: { openContent, setActiveBuffer }, + actions: { openContent, setActiveBuffer, updateBuffer }, getVirtualDocument, readFileContent, }, openContent, setActiveBuffer, + updateBuffer, getVirtualDocument, readFileContent, }; @@ -83,6 +85,11 @@ describe("LSP navigation targets", () => { isVirtual: true, readOnly: true, language: "java", + lspDocument: { + documentUri: location.uri, + sessionFilePath: "C:/work/src/Main.java", + languageId: "java", + }, }); expect(context.readFileContent).not.toHaveBeenCalled(); }); @@ -98,6 +105,48 @@ describe("LSP navigation targets", () => { expect(context.openContent).not.toHaveBeenCalled(); }); + test("rebinds a reused virtual buffer to the current source session", async () => { + const uri = "jdt://contents/java.base/java/lang/String.class?=demo"; + const existing = { + id: "existing-buffer", + type: "editor", + path: uri, + name: "String.java", + content: "old source", + savedContent: "old source", + isDirty: false, + isVirtual: true, + isPinned: false, + isPreview: false, + isActive: false, + readOnly: true, + language: "java", + lspDocument: { + documentUri: uri, + sessionFilePath: "C:/work/parent/src/Old.java", + languageId: "java", + }, + tokens: [], + } satisfies PaneContent; + const context = createOptions({ uri, filePath: null, range }, [existing]); + + expect(await openLspNavigationLocation(context.options)).toBe("existing-buffer"); + expect(context.getVirtualDocument).toHaveBeenCalledWith("C:/work/src/Main.java", uri); + expect(context.updateBuffer).toHaveBeenCalledWith({ + ...existing, + content: "public final class String {}", + savedContent: "public final class String {}", + isActive: false, + lspDocument: { + documentUri: uri, + sessionFilePath: "C:/work/src/Main.java", + languageId: "java", + }, + }); + expect(context.setActiveBuffer).toHaveBeenCalledWith("existing-buffer"); + expect(context.openContent).not.toHaveBeenCalled(); + }); + test("does not open a buffer when virtual source is unavailable", async () => { const context = createOptions({ uri: "jdt://contents/java.base/java/lang/String.class?=demo", diff --git a/windows/tauri/src/features/editor/lsp/navigation-target.ts b/windows/tauri/src/features/editor/lsp/navigation-target.ts index bfafbe193..3182fd500 100644 --- a/windows/tauri/src/features/editor/lsp/navigation-target.ts +++ b/windows/tauri/src/features/editor/lsp/navigation-target.ts @@ -6,6 +6,7 @@ import { getBaseName, normalizePath } from "@/utils/path-helpers"; interface NavigationBufferActions { openContent: (spec: OpenContentSpec) => string; setActiveBuffer: (bufferId: string) => void; + updateBuffer: (buffer: PaneContent) => void; } interface OpenLspNavigationLocationOptions { @@ -39,6 +40,11 @@ function virtualDocumentName(location: LspLocation): string { ); } +function physicalPathKey(path: string): string { + const normalized = normalizePath(path); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalized) ? normalized.toLowerCase() : normalized; +} + export async function openLspNavigationLocation({ location, sourceFilePath, @@ -51,6 +57,30 @@ export async function openLspNavigationLocation({ const targetPath = filePath ?? location.uri; const existingBuffer = buffers.find((buffer) => buffer.path === targetPath); if (existingBuffer) { + if ( + !filePath && + existingBuffer.type === "editor" && + (existingBuffer.lspDocument?.documentUri !== location.uri || + physicalPathKey(existingBuffer.lspDocument.sessionFilePath) !== + physicalPathKey(sourceFilePath)) + ) { + const content = await getVirtualDocument(sourceFilePath, location.uri); + if (content == null) return null; + actions.updateBuffer({ + ...existingBuffer, + content, + savedContent: content, + isDirty: false, + isVirtual: true, + readOnly: true, + language: "java", + lspDocument: { + documentUri: location.uri, + sessionFilePath: sourceFilePath, + languageId: "java", + }, + }); + } actions.setActiveBuffer(existingBuffer.id); return existingBuffer.id; } @@ -78,6 +108,11 @@ export async function openLspNavigationLocation({ isVirtual: true, readOnly: true, language: "java", + lspDocument: { + documentUri: location.uri, + sessionFilePath: sourceFilePath, + languageId: "java", + }, }); actions.setActiveBuffer(bufferId); return bufferId; diff --git a/windows/tauri/src/features/editor/lsp/use-code-lens.ts b/windows/tauri/src/features/editor/lsp/use-code-lens.ts index 42aa8b05f..5f98f3330 100644 --- a/windows/tauri/src/features/editor/lsp/use-code-lens.ts +++ b/windows/tauri/src/features/editor/lsp/use-code-lens.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { isEditorLspSupported } from "./built-in-language-support"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { LspClient } from "./lsp-client"; +import { lspDocumentTargetForEditorPath } from "./lsp-document-target"; import { useLspStore } from "./stores/lsp.store"; export interface CodeLensItem { @@ -19,19 +20,24 @@ export const useCodeLens = (filePath: string | undefined, enabled: boolean) => { }); const fetchLenses = useCallback(async () => { - if (!filePath || !enabled || !isEditorLspSupported(filePath)) { + if (!filePath || !enabled) { setLenses([]); return; } const id = ++requestIdRef.current; const lspClient = LspClient.getInstance(); - if (!lspClient.getActiveServerEntryForFile(filePath) || !lspClient.isDocumentOpen(filePath)) { + const target = lspDocumentTargetForEditorPath(useBufferStore.getState().buffers, filePath); + if ( + !target || + !lspClient.getDocumentAvailability(target, "codeLens").available || + (!target.documentUri && !lspClient.isDocumentOpen(target.filePath)) + ) { setLenses([]); return; } - const result = await lspClient.getCodeLens(filePath); + const result = await lspClient.getCodeLens(target); if (id !== requestIdRef.current) return; setLenses(result); diff --git a/windows/tauri/src/features/editor/lsp/use-rename.ts b/windows/tauri/src/features/editor/lsp/use-rename.ts index f050e7830..5df80975f 100644 --- a/windows/tauri/src/features/editor/lsp/use-rename.ts +++ b/windows/tauri/src/features/editor/lsp/use-rename.ts @@ -1,9 +1,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { editorAPI } from "@/features/editor/extensions/api"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useEditorStateStore } from "@/features/editor/stores/state.store"; import { getLineTextFromContent } from "@/features/editor/utils/position"; +import { lspDocumentTargetForEditorPath } from "./lsp-document-target"; import { LspClient } from "./lsp-client"; -import { applyWorkspaceEdit, isWorkspaceEdit, offsetFromPosition } from "./workspace-edit"; +import { applyWorkspaceEdit, isWorkspaceEdit } from "./workspace-edit"; import { logger } from "../utils/logger"; interface RenameState { @@ -19,18 +21,6 @@ function getWordUnderCursor(line: string, column: number): string { return (before?.[0] || "") + (after?.[0]?.slice(1) || ""); } -function getTextForRange( - content: string, - range: { - start: { line: number; character: number }; - end: { line: number; character: number }; - }, -): string { - const start = offsetFromPosition(content, range.start); - const end = offsetFromPosition(content, range.end); - return content.slice(start, end); -} - export const useRename = (filePath: string | undefined) => { const [renameState, setRenameState] = useState(null); const inputRef = useRef(null); @@ -42,20 +32,9 @@ export const useRename = (filePath: string | undefined) => { const content = editorAPI.getContent(); const currentLine = getLineTextFromContent(content, cursorPosition.line); const lspClient = LspClient.getInstance(); - const prepared = await lspClient.prepareRename( - filePath, - cursorPosition.line, - cursorPosition.column, - ); - const preparedRange = prepared?.range - ? prepared.range - : prepared?.start && prepared?.end - ? { start: prepared.start, end: prepared.end } - : null; - const symbol = - prepared?.placeholder || - (preparedRange ? getTextForRange(content, preparedRange) : "") || - getWordUnderCursor(currentLine, cursorPosition.column); + const target = lspDocumentTargetForEditorPath(useBufferStore.getState().buffers, filePath); + if (!target || !lspClient.getDocumentAvailability(target, "rename").available) return; + const symbol = getWordUnderCursor(currentLine, cursorPosition.column); if (!symbol) return; @@ -91,8 +70,10 @@ export const useRename = (filePath: string | undefined) => { try { const lspClient = LspClient.getInstance(); + const target = lspDocumentTargetForEditorPath(useBufferStore.getState().buffers, filePath); + if (!target || !lspClient.getDocumentAvailability(target, "rename").available) return; const result = await lspClient.rename( - filePath, + target, renameState.line, renameState.column, trimmed, diff --git a/windows/tauri/src/features/editor/stores/buffer-content-factory.ts b/windows/tauri/src/features/editor/stores/buffer-content-factory.ts index ef5b49754..5c44abe87 100644 --- a/windows/tauri/src/features/editor/stores/buffer-content-factory.ts +++ b/windows/tauri/src/features/editor/stores/buffer-content-factory.ts @@ -23,6 +23,7 @@ export const createPaneContent = (id: string, spec: OpenContentSpec): PaneConten isPreview: spec.isPreview ?? false, readOnly: spec.readOnly, language: spec.language ?? detectLanguageFromFileName(spec.name), + lspDocument: spec.lspDocument, tokens: [], }; case "terminal": { diff --git a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts index 1ed50c478..e1222b30c 100644 --- a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts @@ -14,8 +14,14 @@ import { languageIdForEditorFile } from "@/features/editor/lsp/built-in-language import { languageServerUnavailableMessage } from "@/features/editor/lsp/language-server-navigation"; import { resolveLombokAccessorDefinition } from "@/features/editor/lsp/lombok-accessor-navigation"; import { openLspNavigationLocation } from "@/features/editor/lsp/navigation-target"; -import type { LspLocation } from "@/features/editor/lsp/lsp-client"; +import { + lspDocumentTargetForEditor, + type LspDocumentTarget, + type LspDocumentTargetInput, +} from "@/features/editor/lsp/lsp-document-target"; +import type { LspDocumentAvailability, LspLocation } from "@/features/editor/lsp/lsp-client"; import { useLspStore } from "@/features/editor/lsp/stores/lsp.store"; +import type { EditorContent } from "@/features/panes/types/pane-content.types"; import { useSpringStore } from "@/features/spring/stores/spring.store"; import type { SpringNavigationLocation } from "@/features/spring/types/spring.types"; import { @@ -32,20 +38,24 @@ import { toast } from "sonner"; type LspNavigationClient = { getDefinition: ( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ) => Promise; getImplementation: ( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ) => Promise; getTypeDefinition: ( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ) => Promise; + getDocumentAvailability: ( + target: LspDocumentTargetInput, + feature?: string, + ) => LspDocumentAvailability; getVirtualDocument: (filePath: string, virtualUri: string) => Promise; }; @@ -87,15 +97,24 @@ function isCurrentNavigationTarget( } function unavailableLanguageServerToast( - filePath: string, - lspClient: { hasSessionForFile(path: string): boolean }, + buffer: EditorContent, + feature: string, + lspClient: Pick, ): string | null { - const status = useLspStore.getState().lspStatus; + const availability = lspClient.getDocumentAvailability( + lspDocumentTargetForEditor(buffer), + feature, + ); + const globalStatus = useLspStore.getState().lspStatus; return languageServerUnavailableMessage({ - languageId: languageIdForEditorFile(filePath), - status: status.status, - lastError: status.lastError, - hasSession: lspClient.hasSessionForFile(filePath), + languageId: availability.languageId, + status: availability.hasSession ? availability.status : globalStatus.status, + lastError: availability.hasSession ? undefined : globalStatus.lastError, + hasSession: availability.hasSession, + ready: availability.ready, + featuresKnown: availability.featuresKnown, + supportsFeature: availability.supportsFeature, + featureLabel: feature, }); } @@ -200,11 +219,11 @@ async function goToActiveLspLocation( label: string, resolveLocations: ( lspClient: LspNavigationClient, - filePath: string, + target: LspDocumentTarget, line: number, character: number, ) => Promise, - options: { requireLanguageServer?: boolean } = {}, + options: { requireLanguageServer?: boolean; feature?: string } = {}, ): Promise { const [{ LspClient }, { readFileContent }] = await Promise.all([ import("@/features/editor/lsp/lsp-client"), @@ -218,9 +237,14 @@ async function goToActiveLspLocation( const cursorPosition = editorState.cursorPosition; if (!activeBuffer || activeBuffer.type !== "editor" || !activeBuffer.path) return; + const documentTarget = lspDocumentTargetForEditor(activeBuffer); if (options.requireLanguageServer !== false) { - const unavailable = unavailableLanguageServerToast(activeBuffer.path, lspClient); + const unavailable = unavailableLanguageServerToast( + activeBuffer, + options.feature ?? label, + lspClient, + ); if (unavailable) { toast.error(unavailable); return; @@ -229,7 +253,7 @@ async function goToActiveLspLocation( let locations = await resolveLocations( lspClient, - activeBuffer.path, + documentTarget, cursorPosition.line, cursorPosition.column, ); @@ -237,7 +261,7 @@ async function goToActiveLspLocation( if ( (!locations || locations.length === 0) && label === "definition" && - languageIdForEditorFile(activeBuffer.path) === "java" + documentTarget.languageId === "java" ) { const workspaceRoot = useProjectStore.getState().rootFolderPath; if (workspaceRoot) { @@ -278,7 +302,7 @@ async function goToActiveLspLocation( const target = locations[0]; const openedBufferId = await openLspNavigationLocation({ location: target, - sourceFilePath: activeBuffer.path, + sourceFilePath: documentTarget.sessionFilePath ?? documentTarget.filePath, buffers: bufferStore.buffers, actions: bufferStore.actions, getVirtualDocument: (filePath, virtualUri) => @@ -349,20 +373,26 @@ export async function goToDefinition(): Promise { await presentSpringReferences(springLocations, springLocations[0]?.symbol || "Spring"); return; } - await goToActiveLspLocation("definition", (lspClient, filePath, line, character) => - lspClient.getDefinition(filePath, line, character), + await goToActiveLspLocation( + "definition", + (lspClient, target, line, character) => lspClient.getDefinition(target, line, character), + { feature: "definition" }, ); } export async function goToImplementation(): Promise { - await goToActiveLspLocation("implementation", (lspClient, filePath, line, character) => - lspClient.getImplementation(filePath, line, character), + await goToActiveLspLocation( + "implementation", + (lspClient, target, line, character) => lspClient.getImplementation(target, line, character), + { feature: "implementation" }, ); } export async function goToTypeDefinition(): Promise { - await goToActiveLspLocation("type definition", (lspClient, filePath, line, character) => - lspClient.getTypeDefinition(filePath, line, character), + await goToActiveLspLocation( + "type definition", + (lspClient, target, line, character) => lspClient.getTypeDefinition(target, line, character), + { feature: "typeDefinition" }, ); } @@ -398,7 +428,9 @@ export async function goToReferences(): Promise { return; } - const unavailable = unavailableLanguageServerToast(activeBuffer.path, lspClient); + if (activeBuffer.type !== "editor") return; + const documentTarget = lspDocumentTargetForEditor(activeBuffer); + const unavailable = unavailableLanguageServerToast(activeBuffer, "references", lspClient); if (unavailable) { toast.error(unavailable); return; @@ -410,7 +442,7 @@ export async function goToReferences(): Promise { const symbol = (wordMatch?.[0] || "") + (wordEnd?.[0]?.slice(1) || ""); const references = await lspClient.getReferences( - activeBuffer.path, + documentTarget, cursorPosition.line, cursorPosition.column, ); diff --git a/windows/tauri/src/features/panes/types/pane-content.types.ts b/windows/tauri/src/features/panes/types/pane-content.types.ts index 2c281f45a..0efa5d902 100644 --- a/windows/tauri/src/features/panes/types/pane-content.types.ts +++ b/windows/tauri/src/features/panes/types/pane-content.types.ts @@ -12,6 +12,12 @@ export interface TokenEntry { class_name: string; } +export interface EditorLspDocumentBinding { + documentUri: string; + sessionFilePath: string; + languageId: string; +} + // ── Content type discriminant ─────────────────────────────────────── export type PaneContentType = @@ -63,6 +69,7 @@ export interface EditorContent extends PaneContentBase { readOnly?: boolean; language?: string; languageOverride?: string; + lspDocument?: EditorLspDocumentBinding; tokens: TokenEntry[]; } @@ -307,6 +314,7 @@ export type OpenContentSpec = isPreview?: boolean; readOnly?: boolean; language?: string; + lspDocument?: EditorLspDocumentBinding; } | { type: "terminal"; diff --git a/windows/tauri/src/platform/lsp-core-adapter.test.ts b/windows/tauri/src/platform/lsp-core-adapter.test.ts index 01644881e..9f7f8e1bd 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.test.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { readFileSync } from "node:fs"; const emit = mock(async () => undefined); const emitTo = mock(async () => undefined); @@ -24,28 +25,73 @@ const TauriEvent = { } as const; const frontendTrace = mock(() => undefined); const commands: string[] = []; -let scenario: "delayed-start" | "failure" | "virtual-document" = "failure"; +let scenario: + | "capabilities" + | "delayed-start" + | "failure" + | "multi-session" + | "semantic-request" + | "virtual-document" = "failure"; let startPayload: Record | undefined; let requestPayload: Record | undefined; let pollCount = 0; +let startCount = 0; +const sessionPollCounts = new Map(); let virtualDocumentPending = false; +let semanticRequestPending = false; +let semanticOperationId = ""; let releaseInitialization: (() => void) | undefined; +function readyEvents(sessionId: string) { + return [ + { + type: "featuresChanged", + providerId: "java", + sessionId, + capabilities: [ + "codeActions", + "completion", + "definition", + "executeCommand", + "hover", + "implementation", + "references", + "rename", + "typeDefinition", + ], + }, + { + type: "stateChanged", + state: "ready", + providerId: "java", + sessionId, + }, + ]; +} + const executeCore = mock( async (request: { id: string; command: string; payload?: Record }) => { commands.push(request.command); if (request.command === "lsp.startServer") { + startCount += 1; startPayload = request.payload; + const sessionId = + scenario === "failure" + ? "failed-java-session" + : scenario === "multi-session" + ? `java-session-${startCount}` + : "java-session"; return { id: request.id, ok: true as const, - data: { - sessionId: scenario === "failure" ? "failed-java-session" : "java-session", - }, + data: { sessionId }, }; } if (request.command === "lsp.pollEvents") { pollCount += 1; + const sessionId = String(request.payload?.sessionId ?? "java-session"); + const sessionPollCount = (sessionPollCounts.get(sessionId) ?? 0) + 1; + sessionPollCounts.set(sessionId, sessionPollCount); if (scenario === "delayed-start") { if (pollCount === 1) { await new Promise((resolve) => { @@ -60,7 +106,42 @@ const executeCore = mock( type: "stateChanged", state: "ready", providerId: "java", - sessionId: "java-session", + sessionId, + }, + ], + }, + }; + } + return { id: request.id, ok: true as const, data: { events: [] } }; + } + if (scenario === "capabilities" || scenario === "multi-session") { + return { + id: request.id, + ok: true as const, + data: { events: sessionPollCount === 1 ? readyEvents(sessionId) : [] }, + }; + } + if (scenario === "semantic-request") { + if (sessionPollCount === 1) { + return { + id: request.id, + ok: true as const, + data: { events: readyEvents(sessionId) }, + }; + } + if (semanticRequestPending) { + semanticRequestPending = false; + return { + id: request.id, + ok: true as const, + data: { + events: [ + { + type: "requestCompleted", + providerId: "java", + sessionId, + operationId: semanticOperationId, + result: { locations: [] }, }, ], }, @@ -79,7 +160,7 @@ const executeCore = mock( type: "stateChanged", state: "ready", providerId: "java", - sessionId: "java-session", + sessionId, }, ], }, @@ -95,8 +176,8 @@ const executeCore = mock( { type: "requestCompleted", providerId: "java", - sessionId: "java-session", - operationId: "virtual-document-operation", + sessionId, + operationId: "virtualDocument-operation", result: { text: "public final class String {}" }, }, ], @@ -140,11 +221,18 @@ const executeCore = mock( } if (request.command === "lsp.request") { requestPayload = request.payload; - virtualDocumentPending = true; + const operation = String(request.payload?.operation ?? "request"); + const operationId = `${operation}-operation`; + if (scenario === "semantic-request") { + semanticRequestPending = true; + semanticOperationId = operationId; + } else { + virtualDocumentPending = true; + } return { id: request.id, ok: true as const, - data: { operationId: "virtual-document-operation" }, + data: { operationId }, }; } return { id: request.id, ok: true as const, data: null }; @@ -155,7 +243,38 @@ mock.module("@tauri-apps/api/event", () => ({ emit, emitTo, listen, once, TauriE mock.module("@/core/lithe-core-client", () => ({ executeCore })); mock.module("@/utils/frontend-trace", () => ({ frontendTrace })); -const { invokeLsp } = await import("./lsp-core-adapter"); +const { + getLspSessionSnapshot, + invokeLsp, + LSP_EXPLICITLY_UNAVAILABLE_COMMANDS, + LSP_OPERATION_BY_COMMAND, +} = await import("./lsp-core-adapter"); + +function installSessionStorage() { + const previous = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); + const values = new Map(); + const storage: Storage = { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: storage }); + return { + values, + restore() { + if (previous) { + Object.defineProperty(globalThis, "sessionStorage", previous); + } else { + delete (globalThis as { sessionStorage?: Storage }).sessionStorage; + } + }, + }; +} describe("Rust Core LSP adapter failures", () => { beforeEach(() => { @@ -164,7 +283,11 @@ describe("Rust Core LSP adapter failures", () => { startPayload = undefined; requestPayload = undefined; pollCount = 0; + startCount = 0; + sessionPollCounts.clear(); virtualDocumentPending = false; + semanticRequestPending = false; + semanticOperationId = ""; releaseInitialization = undefined; emit.mockClear(); frontendTrace.mockClear(); @@ -207,6 +330,192 @@ describe("Rust Core LSP adapter failures", () => { ]); }); + test("stores negotiated features and exposes them in the owning session snapshot", async () => { + scenario = "capabilities"; + const testStorage = installSessionStorage(); + const filePath = "C:/work/Main.java"; + + try { + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath, + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + + expect(getLspSessionSnapshot({ filePath })).toEqual({ + id: "java-session", + workspacePath: "C:/work", + languageId: "java", + ready: true, + features: [ + "codeActions", + "completion", + "definition", + "executeCommand", + "hover", + "implementation", + "references", + "rename", + "typeDefinition", + ], + featuresKnown: true, + }); + expect(JSON.parse(testStorage.values.get("lithe:lsp-core-sessions:v1") ?? "[]")).toEqual([ + expect.objectContaining({ + ready: true, + features: expect.arrayContaining(["definition", "references", "executeCommand"]), + }), + ]); + expect(emit).toHaveBeenCalledWith( + "lsp://features-changed", + expect.objectContaining({ sessionId: "java-session", languageId: "java" }), + ); + + await invokeLsp("lsp_stop_for_file", { filePath }); + } finally { + testStorage.restore(); + } + }); + + test("routes virtual references through the physical source session without rewriting the URI", async () => { + scenario = "semantic-request"; + const filePath = "C:/work/Main.java"; + const virtualUri = "jdt://contents/java.base/java/lang/String.class?=demo"; + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath, + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + + const references = await invokeLsp("lsp_get_references", { + filePath: virtualUri, + sessionFilePath: filePath, + documentUri: virtualUri, + line: 12, + character: 7, + }); + + expect(references).toEqual([]); + expect(requestPayload).toEqual({ + sessionId: "java-session", + operation: "references", + uri: virtualUri, + position: { line: 12, utf16Column: 7 }, + }); + expect(requestPayload?.uri).not.toStartWith("file:"); + + await invokeLsp("lsp_stop_for_file", { filePath }); + }); + + test("moves one normalized file to the new workspace session and stops the empty owner", async () => { + scenario = "multi-session"; + const filePath = "C:/work/project/src/Main.java"; + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath, + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + expect(getLspSessionSnapshot({ filePath })?.id).toBe("java-session-1"); + + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:\\work\\project", + filePath: "C:\\work\\project\\src\\Main.java", + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + + expect(getLspSessionSnapshot({ filePath })).toEqual( + expect.objectContaining({ + id: "java-session-2", + workspacePath: "C:\\work\\project", + }), + ); + expect(commands.filter((command) => command === "lsp.startServer")).toHaveLength(2); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(1); + expect(commands.filter((command) => command === "lsp.destroyServer")).toHaveLength(1); + + const parentFilePath = "C:/work/src/Other.java"; + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath: parentFilePath, + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + expect(getLspSessionSnapshot({ filePath: parentFilePath })).toEqual( + expect.objectContaining({ + id: "java-session-3", + workspacePath: "C:/work", + }), + ); + expect(commands.filter((command) => command === "lsp.startServer")).toHaveLength(3); + + await invokeLsp("lsp_stop_for_file", { filePath }); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(2); + await invokeLsp("lsp_stop_for_file", { filePath: parentFilePath }); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(3); + }); + + test("returns a structured capability error for explicitly unavailable commands", async () => { + await expect( + invokeLsp("lsp_prepare_rename", { + filePath: "C:/work/Main.java", + line: 0, + character: 0, + }), + ).rejects.toMatchObject({ + code: "unsupported_capability", + message: "LSP operation is not available through the shared Core: lsp_prepare_rename", + }); + }); + + test("maps or explicitly rejects every LspClient adapter command", () => { + expect(LSP_OPERATION_BY_COMMAND).toEqual({ + lsp_get_completions: "completion", + lsp_get_hover: "hover", + lsp_get_definition: "definition", + lsp_get_implementation: "implementation", + lsp_get_type_definition: "typeDefinition", + lsp_get_references: "references", + lsp_rename: "rename", + lsp_format_document: "formatting", + lsp_get_code_actions: "codeActions", + lsp_get_inlay_hints: "inlayHints", + lsp_get_code_lens: "codeLens", + lsp_get_virtual_document: "virtualDocument", + }); + + const explicitlyHandled = new Set([ + ...Object.keys(LSP_OPERATION_BY_COMMAND), + ...LSP_EXPLICITLY_UNAVAILABLE_COMMANDS, + "lsp_apply_code_action", + "lsp_document_change", + "lsp_document_close", + "lsp_document_open", + "lsp_document_save", + "lsp_start", + "lsp_start_for_file", + "lsp_stop", + "lsp_stop_for_file", + ]); + const clientSource = readFileSync( + new URL("../features/editor/lsp/lsp-client.ts", import.meta.url), + "utf8", + ); + const clientCommands = new Set( + [...clientSource.matchAll(/["'](lsp_[a-z_]+)["']/g)].map((match) => match[1]), + ); + + expect([...clientCommands].filter((command) => !explicitlyHandled.has(command))).toEqual([]); + }); + test("resolves a provider virtual document without fabricating a file URI", async () => { scenario = "virtual-document"; const filePath = "C:/work/Main.java"; diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index 0853f8d8c..c02aff413 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -15,6 +15,8 @@ interface Session { files: Set; running: boolean; ready: boolean; + features: Set; + featuresKnown: boolean; recovered: boolean; pending: Map void; reject: (reason: Error) => void }>; completed: Map; @@ -26,6 +28,7 @@ interface StoredSession { languageId: string; files: string[]; ready?: boolean; + features?: string[]; } interface RuntimeError { @@ -54,6 +57,16 @@ interface RuntimeEvent { level?: string; message?: string; detail?: string; + capabilities?: string[]; +} + +export interface LspSessionSnapshot { + id: string; + workspacePath: string; + languageId: string; + ready: boolean; + features: string[]; + featuresKnown: boolean; } const sessions = new Map(); @@ -95,6 +108,7 @@ function persistSessions(): void { languageId: session.languageId, files: [...session.files].sort(), ready: session.ready, + features: [...session.features].sort(), })) .sort((left, right) => sessionKey(left.workspacePath, left.languageId).localeCompare( @@ -115,13 +129,20 @@ function persistSessions(): void { } } -function attachFile(session: Session, filePath: string): void { +function attachFile(session: Session, filePath: string): Session | null { const key = fileKey(filePath); + const previous = fileSessions.get(key); + if (previous && previous !== session) { + for (const existing of previous.files) { + if (fileKey(existing) === key) previous.files.delete(existing); + } + } for (const existing of session.files) { if (fileKey(existing) === key) session.files.delete(existing); } session.files.add(filePath); fileSessions.set(key, session); + return previous && previous !== session ? previous : null; } function detachFile(session: Session, filePath: string): void { @@ -129,7 +150,7 @@ function detachFile(session: Session, filePath: string): void { for (const existing of session.files) { if (fileKey(existing) === key) session.files.delete(existing); } - fileSessions.delete(key); + if (fileSessions.get(key) === session) fileSessions.delete(key); } function removeSessionMappings(session: Session): void { @@ -158,7 +179,10 @@ function restorePersistedSessions(): void { typeof stored.languageId !== "string" || !Array.isArray(stored.files) || !stored.files.every((file: unknown) => typeof file === "string") || - (stored.ready !== undefined && typeof stored.ready !== "boolean") + (stored.ready !== undefined && typeof stored.ready !== "boolean") || + (stored.features !== undefined && + (!Array.isArray(stored.features) || + !stored.features.every((feature: unknown) => typeof feature === "string"))) ) { continue; } @@ -171,6 +195,8 @@ function restorePersistedSessions(): void { running: false, // Version-one entries created before this field existed were all ready. ready: stored.ready ?? true, + features: new Set(stored.features ?? []), + featuresKnown: stored.features !== undefined, recovered: true, pending: new Map(), completed: new Map(), @@ -181,6 +207,9 @@ function restorePersistedSessions(): void { sessions.set(key, session); for (const file of stored.files) attachFile(session, file); } + for (const session of sessions.values()) { + if (session.files.size === 0) removeSessionMappings(session); + } } catch (reason) { storage.removeItem(SESSION_STORAGE_KEY); frontendTrace("warn", "lsp.runtime", "Could not restore language-server sessions", { @@ -205,6 +234,12 @@ function coreData(response: CoreResponse): T { throw error; } +function lspAdapterError(code: string, message: string): Error & { code: string } { + const error = new Error(message) as Error & { code: string }; + error.code = code; + return error; +} + async function core(command: string, payload: JsonRecord): Promise { return coreData( await executeCore({ @@ -255,6 +290,17 @@ async function dispatchRuntimeEvent(event: RuntimeEvent): Promise { async function dispatchSessionEvent(session: Session, event: RuntimeEvent): Promise { await dispatchRuntimeEvent(event); + if (event.type === "featuresChanged") { + session.features = new Set(event.capabilities ?? []); + session.featuresKnown = true; + persistSessions(); + await emit("lsp://features-changed", { + sessionId: session.id, + workspacePath: session.workspacePath, + languageId: session.languageId, + features: [...session.features].sort(), + }); + } if (event.type !== "requestCompleted" || !event.operationId) return; const pending = session.pending.get(event.operationId); if (!pending) { @@ -390,7 +436,9 @@ async function cleanupFailedStart(key: string, session: Session): Promise function sessionForFile(filePath: string): Session { const session = fileSessions.get(fileKey(filePath)); - if (!session) throw new Error(`No LSP client for this file: ${filePath}`); + if (!session) { + throw lspAdapterError("no_session", `No language-server session owns this file: ${filePath}`); + } return session; } @@ -423,6 +471,13 @@ async function recoverSession(session: Session): Promise { } if (!session.ready) await waitUntilReady(session); + if (!session.featuresKnown) { + await stopAndDestroySession(session); + removeSessionMappings(session); + persistSessions(); + return null; + } + session.recovered = false; session.running = true; persistSessions(); @@ -466,6 +521,8 @@ async function createSession(args: JsonRecord, key: string): Promise { files: new Set(), running: false, ready: false, + features: new Set(), + featuresKnown: false, recovered: false, pending: new Map(), completed: new Map(), @@ -519,8 +576,9 @@ async function start(args: JsonRecord): Promise { } if (filePath) { - attachFile(session, filePath); + const displaced = attachFile(session, filePath); persistSessions(); + if (displaced && displaced.files.size === 0) await stopSession(displaced); } } finally { if (pendingFileKey && pendingFileSessions.get(pendingFileKey) === key) { @@ -587,7 +645,7 @@ async function requestOperation(session: Session, payload: JsonRecord): Promise< }); } -const operations: Record = { +export const LSP_OPERATION_BY_COMMAND = { lsp_get_completions: "completion", lsp_get_hover: "hover", lsp_get_definition: "definition", @@ -600,7 +658,24 @@ const operations: Record = { lsp_get_inlay_hints: "inlayHints", lsp_get_code_lens: "codeLens", lsp_get_virtual_document: "virtualDocument", -}; +} as const; + +export const LSP_EXPLICITLY_UNAVAILABLE_COMMANDS = [ + "lsp_get_semantic_tokens", + "lsp_get_document_symbols", + "lsp_get_workspace_symbols", + "lsp_get_signature_help", + "lsp_get_signature_trigger_characters", + "lsp_format_range", + "lsp_prepare_rename", +] as const; + +const operations: Record = LSP_OPERATION_BY_COMMAND; +const explicitlyUnavailableCommands = new Set(LSP_EXPLICITLY_UNAVAILABLE_COMMANDS); + +export function isLspSemanticCommandSupported(command: string): boolean { + return command in operations; +} function semanticPayload(command: string, args: JsonRecord, session: Session): JsonRecord { const payload: JsonRecord = { @@ -610,7 +685,7 @@ function semanticPayload(command: string, args: JsonRecord, session: Session): J if (command === "lsp_get_virtual_document") { payload.virtualUri = args.virtualUri; } else { - payload.uri = fileUri(args.filePath); + payload.uri = typeof args.documentUri === "string" ? args.documentUri : fileUri(args.filePath); } if (typeof args.line === "number") { payload.position = { line: args.line, utf16Column: args.character ?? 0 }; @@ -691,11 +766,27 @@ function unwrapResult(command: string, result: any): unknown { } async function semanticRequest(command: string, args: JsonRecord): Promise { - const session = sessionForFile(args.filePath); + const session = sessionForFile(args.sessionFilePath ?? args.filePath); const result = await requestOperation(session, semanticPayload(command, args, session)); return unwrapResult(command, result); } +export function getLspSessionSnapshot(args: { + filePath: string; + sessionFilePath?: string; +}): LspSessionSnapshot | null { + const session = fileSessions.get(fileKey(args.sessionFilePath ?? args.filePath)); + if (!session) return null; + return { + id: session.id, + workspacePath: session.workspacePath, + languageId: session.languageId, + ready: session.ready, + features: [...session.features].sort(), + featuresKnown: session.featuresKnown, + }; +} + export async function invokeLsp(command: string, args: JsonRecord = {}): Promise { if (command === "lsp_start" || command === "lsp_start_for_file") { await start(args); @@ -740,7 +831,7 @@ export async function invokeLsp(command: string, args: JsonRecord = {}): Prom return undefined as T; } if (command === "lsp_apply_code_action") { - const session = sessionForFile(args.filePath); + const session = sessionForFile(args.sessionFilePath ?? args.filePath); const commandPayload = args.actionPayload?.command ?? args.actionPayload; if (!commandPayload?.command) return { applied: true } as T; try { @@ -758,5 +849,11 @@ export async function invokeLsp(command: string, args: JsonRecord = {}): Prom } } if (command in operations) return (await semanticRequest(command, args)) as T; - throw new Error(`LSP operation is not supported by the shared Core: ${command}`); + if (explicitlyUnavailableCommands.has(command)) { + throw lspAdapterError( + "unsupported_capability", + `LSP operation is not available through the shared Core: ${command}`, + ); + } + throw lspAdapterError("invalid_request", `Unknown LSP adapter command: ${command}`); }