diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 625757a5f..554812551 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2620,6 +2620,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index c0b46ba43..9ebac1fb5 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ keyring = { version = "3.6.3", features = ["windows-native"] } serde = { version = "1", features = ["derive"] } serde_json = "1" regex = "1" +sha2 = "0.10" tauri = { version = "2", features = ["common-controls-v6", "protocol-asset"] } tauri-plugin-clipboard-manager = "2" tauri-plugin-deep-link = "2" diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index a75d921c4..41a43c60d 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -5,6 +5,7 @@ mod file_events; mod host; mod logging; mod lsp; +mod maven; mod memory; mod platform; mod run; @@ -118,6 +119,8 @@ fn main() { host::create_app_window, lsp::lsp_resolve_java_launch, lsp::lsp_rebuild_java_index, + maven::maven_load_configuration, + maven::maven_write_configuration, run::run_list_java_sources, run::run_write_generated, run::run_write_documents, diff --git a/windows/tauri/src-tauri/src/maven.rs b/windows/tauri/src-tauri/src/maven.rs new file mode 100644 index 000000000..4187845fd --- /dev/null +++ b/windows/tauri/src-tauri/src/maven.rs @@ -0,0 +1,250 @@ +//! Windows persistence for Maven project and machine-local configuration. +//! +//! Portable selections stay below the workspace `.lithe` directory. Maven, +//! JDK, and settings paths are stored only in the application data directory. + +use crate::run::atomic_write; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +const MAVEN_CONFIGURATION_VERSION: u32 = 1; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MavenPortableConfiguration { + pub version: u32, + #[serde(default)] + pub selected_profiles: Vec, + #[serde(default)] + pub custom_profiles: Vec, + #[serde(default)] + pub skip_tests: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MavenLocalConfiguration { + pub version: u32, + #[serde(default)] + pub settings_path: Option, + #[serde(default)] + pub maven_executable_path: Option, + #[serde(default)] + pub java_home_path: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MavenStoredConfiguration { + pub portable: Option, + pub local: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteMavenConfigurationArgs { + pub root: PathBuf, + pub reactor_path: String, + pub configuration: MavenStoredConfiguration, +} + +#[tauri::command] +pub fn maven_load_configuration( + app: AppHandle, + root: PathBuf, + reactor_path: String, +) -> Result { + let root = existing_directory(&root)?; + let portable = read_optional::(&portable_path(&root))?; + let local = read_optional::(&local_path(&app, &root, &reactor_path)?)?; + validate_versions(portable.as_ref(), local.as_ref())?; + Ok(MavenStoredConfiguration { portable, local }) +} + +#[tauri::command] +pub fn maven_write_configuration( + app: AppHandle, + args: WriteMavenConfigurationArgs, +) -> Result<(), String> { + let root = existing_directory(&args.root)?; + validate_versions( + args.configuration.portable.as_ref(), + args.configuration.local.as_ref(), + )?; + write_optional(&portable_path(&root), args.configuration.portable.as_ref())?; + write_optional( + &local_path(&app, &root, &args.reactor_path)?, + args.configuration.local.as_ref(), + ) +} + +fn validate_versions( + portable: Option<&MavenPortableConfiguration>, + local: Option<&MavenLocalConfiguration>, +) -> Result<(), String> { + if portable.is_some_and(|value| value.version != MAVEN_CONFIGURATION_VERSION) + || local.is_some_and(|value| value.version != MAVEN_CONFIGURATION_VERSION) + { + return Err( + "The Maven configuration was created by an unsupported version of Lithe.".into(), + ); + } + Ok(()) +} + +fn portable_path(root: &Path) -> PathBuf { + root.join(".lithe").join("maven").join("config.json") +} + +fn local_path(app: &AppHandle, root: &Path, reactor_path: &str) -> Result { + let app_data = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())?; + let mut digest = Sha256::new(); + let identity = storage_identity(&root.to_string_lossy(), reactor_path); + digest.update(identity.as_bytes()); + Ok(app_data + .join("maven") + .join(format!("{:x}.json", digest.finalize()))) +} + +fn storage_identity(workspace_path: &str, reactor_path: &str) -> String { + format!( + "{}\0{}", + workspace_path.to_lowercase(), + reactor_path.replace('\\', "/") + ) +} + +fn existing_directory(path: &Path) -> Result { + let root = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if !root.is_dir() { + return Err("The project directory is unavailable.".into()); + } + Ok(root) +} + +fn read_optional(path: &Path) -> Result, String> { + if !path.is_file() { + return Ok(None); + } + let contents = fs::read(path).map_err(|_| { + format!( + "Unable to read Maven configuration {}.", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("file") + ) + })?; + serde_json::from_slice(&contents).map(Some).map_err(|_| { + format!( + "The Maven configuration in {} is invalid.", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("file") + ) + }) +} + +fn write_optional(path: &Path, value: Option<&T>) -> Result<(), String> { + let Some(value) = value else { + if path.is_file() { + fs::remove_file(path).map_err(|error| error.to_string())?; + } + return Ok(()); + }; + let parent = path + .parent() + .ok_or_else(|| "Maven configuration path has no parent directory.".to_string())?; + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let mut contents = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?; + contents.push('\n'); + atomic_write(path, contents.as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + fn temp_directory() -> PathBuf { + static NEXT_DIRECTORY_ID: AtomicU64 = AtomicU64::new(1); + let id = NEXT_DIRECTORY_ID.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("lithe-maven-config-{}-{id}", std::process::id())); + fs::create_dir_all(&path).expect("temp directory"); + path + } + + #[test] + fn portable_configuration_round_trips_without_local_paths() { + let root = temp_directory(); + let path = portable_path(&root); + let portable = MavenPortableConfiguration { + version: 1, + selected_profiles: vec!["dev".into(), "qa".into()], + custom_profiles: vec!["qa".into()], + skip_tests: true, + }; + write_optional(&path, Some(&portable)).expect("write portable configuration"); + let loaded = read_optional::(&path) + .expect("read portable configuration") + .expect("portable configuration"); + + assert_eq!(loaded.selected_profiles, ["dev", "qa"]); + assert!(loaded.skip_tests); + let text = fs::read_to_string(&path).expect("portable text"); + assert!(!text.contains("settingsPath")); + assert!(!text.contains("mavenExecutablePath")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn rejects_unsupported_configuration_versions() { + let portable = MavenPortableConfiguration { + version: 2, + selected_profiles: Vec::new(), + custom_profiles: Vec::new(), + skip_tests: false, + }; + assert!(validate_versions(Some(&portable), None) + .unwrap_err() + .contains("unsupported version")); + } + + #[test] + fn windows_storage_identity_matches_shared_contract() { + let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/fixtures/maven/platform-contract-v1.json" + ))) + .expect("Maven platform contract fixture"); + let cases = fixture["storageIdentityCases"] + .as_array() + .expect("storage identity cases"); + let windows_cases: Vec<_> = cases + .iter() + .filter(|item| item["platform"] == "windows") + .collect(); + + assert!( + !windows_cases.is_empty(), + "Windows fixture case is required" + ); + for item in windows_cases { + assert_eq!( + storage_identity( + item["workspacePath"].as_str().expect("workspace path"), + item["reactorPath"].as_str().expect("reactor path"), + ), + item["expectedIdentity"] + .as_str() + .expect("expected identity") + ); + } + } +} diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 323e0b205..98b053290 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -440,7 +440,7 @@ fn validate_write_target(root: &Path, target: &Path) -> Result<(), String> { Ok(()) } -fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), String> { +pub(crate) fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), String> { if path.exists() { if let Ok(existing) = fs::read(path) { if existing == contents { diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index fa2d91763..d8d4001f0 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -32,6 +32,7 @@ import { InlineEditPopover } from "@/features/editor/inline-edit/inline-edit-pop import { useInlineEdit } from "@/features/editor/inline-edit/use-inline-edit"; import { useInlineEditToolbarStore } from "@/features/editor/stores/inline-edit-toolbar.store"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; import { useGitBlame } from "@/features/git/hooks/use-git-blame"; import { keymapRegistry } from "@/features/keymaps/utils/registry"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; @@ -272,6 +273,7 @@ export function MonacoEditor({ javaMarkerRefreshRevision(state.lspStatus), ); const inlineGitBlameEnabled = useSettingsStore((state) => state.settings.enableInlineGitBlame); + const workspaceId = useActiveWorkspaceId(); const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); const workspaceFolders = useFileSystemStore((state) => state.workspaceFolders); const vimModeEnabled = useSettingsStore((state) => state.settings.vimMode); @@ -795,7 +797,7 @@ export function MonacoEditor({ editor, model, documentTarget, - workspaceRoot: rootFolderPath, + workspaceScope: rootFolderPath ? { workspaceId, root: rootFolderPath } : undefined, enabled: enableExpensiveServices, }); let definitionClickIntent = 0; @@ -1127,6 +1129,7 @@ export function MonacoEditor({ renderIndentGuides, renderWhitespace, rootFolderPath, + workspaceId, scrollable, scheduleInlineGitBlameRender, selectEntireModel, @@ -1371,7 +1374,7 @@ export function MonacoEditor({ const markers = await loadJavaNavigationMarkers({ client: lspClient, target: documentTarget, - workspaceRoot: rootFolderPath, + workspaceScope: { workspaceId, root: rootFolderPath }, content: model.getValue(), }); if (isDisposed()) { @@ -1450,6 +1453,7 @@ export function MonacoEditor({ javaMarkerRevision, monacoLanguageId, rootFolderPath, + workspaceId, ]); useEffect(() => { diff --git a/windows/tauri/src/features/editor/engines/monaco/definition-link.ts b/windows/tauri/src/features/editor/engines/monaco/definition-link.ts index e17ee4f0f..527da1f6c 100644 --- a/windows/tauri/src/features/editor/engines/monaco/definition-link.ts +++ b/windows/tauri/src/features/editor/engines/monaco/definition-link.ts @@ -1,6 +1,7 @@ import { editor as monacoEditor, Range as MonacoRange } from "monaco-editor"; import type * as Monaco from "monaco-editor"; import type { DefinitionNavigationHint } from "@/features/editor/lsp/definition-navigation-hint"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; import { isEditorLspTargetSupported, type LspDocumentTarget, @@ -25,7 +26,7 @@ interface MonacoDefinitionLinkOptions { editor: Monaco.editor.IStandaloneCodeEditor; model: Monaco.editor.ITextModel; documentTarget: LspDocumentTarget; - workspaceRoot?: string; + workspaceScope?: WorkspaceLaunchScope; enabled?: boolean; } @@ -54,7 +55,7 @@ export function registerMonacoDefinitionLinkGesture({ editor, model, documentTarget, - workspaceRoot, + workspaceScope, enabled = true, }: MonacoDefinitionLinkOptions): MonacoDefinitionLinkGesture { const decorations = editor.createDecorationsCollection(); @@ -114,7 +115,7 @@ export function registerMonacoDefinitionLinkGesture({ } const lspClient = LspClient.getInstance(); if ( - workspaceRoot && + workspaceScope && !isDocumentFeatureAvailable( lspClient.getDocumentAvailability(documentTarget, "definition"), ) @@ -122,7 +123,7 @@ export function registerMonacoDefinitionLinkGesture({ try { await lspClient.ensureDocumentReady( documentTarget, - workspaceRoot, + workspaceScope, model.getValue(), "definition", ); @@ -143,7 +144,7 @@ export function registerMonacoDefinitionLinkGesture({ model.isDisposed() || model.getLanguageId() !== "java" || documentTarget.documentUri || - !workspaceRoot + !workspaceScope ) { return { locations }; } @@ -152,7 +153,7 @@ export function registerMonacoDefinitionLinkGesture({ const lombokDefinition = await resolveLombokAccessorDefinition({ source: model.getValue(), sourceFilePath: documentTarget.filePath, - workspaceRoot, + workspaceRoot: workspaceScope.root, line, character: request.character, }); diff --git a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts index 609471fa2..f1cebfd1e 100644 --- a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts +++ b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts @@ -12,6 +12,8 @@ import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { getSourceEditorBufferByPath } from "@/features/editor/utils/buffer-index"; import { logger } from "@/features/editor/utils/logger"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { workspaceScopeMatchesRoot } from "@/features/workspace/types/workspace-launch-scope"; import { getDirName } from "@/utils/path-helpers"; interface UseLspIntegrationOptions { @@ -67,6 +69,7 @@ export const useLspIntegration = ({ contentRevision = 0, }: UseLspIntegrationOptions) => { const lspClient = useMemo(() => LspClient.getInstance(), []); + const workspaceId = useActiveWorkspaceId(); const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); const installedExtensions = useExtensionStore.use.installedExtensions(); const activeFilePath = enabled ? filePath : undefined; @@ -85,6 +88,17 @@ export const useLspIntegration = ({ logger.warn("LspIntegration", `Could not determine workspace path for ${filePath}`); return; } + const scope = { workspaceId, root: workspacePath }; + if ( + rootFolderPath && + !workspaceScopeMatchesRoot( + scope, + useFileSystemStore.getStore(workspaceId).getState().rootFolderPath, + ) + ) { + logger.warn("LspIntegration", `Ignoring stale workspace scope for ${filePath}`); + return; + } const existingOwner = documentOwnersRef.current.get(filePath); const owner: LspDocumentOwner = @@ -141,7 +155,7 @@ export const useLspIntegration = ({ const initializeLsp = async () => { try { logger.debug("LspIntegration", `Starting LSP for ${filePath} in ${workspacePath}`); - const attachment = await lspClient.startForFile(filePath, workspacePath); + const attachment = await lspClient.startForFile(filePath, scope); if (attachment.kind !== "attached") { owner.state = { phase: "stopped" }; if (documentOwnersRef.current.get(filePath) === owner) { @@ -183,7 +197,7 @@ export const useLspIntegration = ({ cancelInitialization(); cleanupDocument(); }; - }, [enabled, filePath, isLspSupported, lspClient, rootFolderPath]); + }, [enabled, filePath, isLspSupported, lspClient, rootFolderPath, workspaceId]); useEffect(() => { if (!enabled || !filePath || !isLspSupported) return; diff --git a/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.test.ts b/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.test.ts index 3515bf564..b99161d78 100644 --- a/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.test.ts +++ b/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.test.ts @@ -29,14 +29,14 @@ test("attaches the Java document before requesting gutter markers", async () => const markers = await loadJavaNavigationMarkers({ client: { ensureDocumentReady, getJavaNavigationMarkers }, target, - workspaceRoot: "C:/work", + workspaceScope: { workspaceId: "workspace-a", root: "C:/work" }, content: "interface Service {}", }); expect(calls).toEqual(["ensureDocumentReady", "getJavaNavigationMarkers"]); expect(ensureDocumentReady).toHaveBeenCalledWith( target, - "C:/work", + { workspaceId: "workspace-a", root: "C:/work" }, "interface Service {}", "codeLens", ); diff --git a/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.ts b/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.ts index c296d16e7..9114dde29 100644 --- a/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.ts +++ b/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.ts @@ -1,11 +1,12 @@ import type { JavaImplementationMarker } from "./java-navigation-models"; import type { LspDocumentAvailability } from "./lsp-client"; import type { LspDocumentTarget } from "./lsp-document-target"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; export interface JavaNavigationMarkerClient { ensureDocumentReady( target: LspDocumentTarget, - workspaceRoot: string, + scope: WorkspaceLaunchScope, content: string, feature?: string, ): Promise; @@ -15,7 +16,7 @@ export interface JavaNavigationMarkerClient { interface LoadJavaNavigationMarkersOptions { client: JavaNavigationMarkerClient; target: LspDocumentTarget; - workspaceRoot: string; + workspaceScope: WorkspaceLaunchScope; content: string; } @@ -27,9 +28,9 @@ interface LoadJavaNavigationMarkersOptions { export async function loadJavaNavigationMarkers({ client, target, - workspaceRoot, + workspaceScope, content, }: LoadJavaNavigationMarkersOptions): Promise { - await client.ensureDocumentReady(target, workspaceRoot, content, "codeLens"); + await client.ensureDocumentReady(target, workspaceScope, content, "codeLens"); return client.getJavaNavigationMarkers(target); } diff --git a/windows/tauri/src/features/editor/lsp/java-workspace-language-server.test.ts b/windows/tauri/src/features/editor/lsp/java-workspace-language-server.test.ts index a2634a881..49d4b4702 100644 --- a/windows/tauri/src/features/editor/lsp/java-workspace-language-server.test.ts +++ b/windows/tauri/src/features/editor/lsp/java-workspace-language-server.test.ts @@ -1,6 +1,11 @@ -import { expect, mock, test } from "bun:test"; +import { afterEach, expect, mock, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { JavaWorkspaceLanguageServerOwner } from "./java-workspace-language-server"; +const workspaceA = { workspaceId: "workspace-a", root: "C:/work" }; + +afterEach(() => workspaceRuntimeRegistry.resetForTests()); + function operationRecorder() { const outcomes: string[] = []; const operationIds: string[] = []; @@ -43,14 +48,14 @@ test("shares one Java workspace prewarm and reports readiness once", async () => () => undefined, ); - const first = owner.prewarm("C:/work", "C:/work/src/Main.java"); - const second = owner.prewarm("C:\\work", "C:/work/src/Other.java"); + const first = owner.prewarm(workspaceA, "C:/work/src/Main.java"); + const second = owner.prewarm({ ...workspaceA, root: "C:\\work" }, "C:/work/src/Other.java"); expect(start).toHaveBeenCalledTimes(1); releaseStart?.({ kind: "ready" }); expect(await first).toEqual({ kind: "ready" }); expect(await second).toEqual({ kind: "ready" }); - expect(await owner.prewarm("C:/work", "C:/work/src/Third.java")).toEqual({ kind: "ready" }); + expect(await owner.prewarm(workspaceA, "C:/work/src/Third.java")).toEqual({ kind: "ready" }); expect(operations.outcomes).toEqual(["succeeded"]); expect(operations.names).toEqual(["workspacePrewarm"]); expect(notifyReady).toHaveBeenCalledTimes(1); @@ -78,8 +83,8 @@ test("closing a workspace cancels an in-flight prewarm and stops its server", as () => undefined, ); - const prewarm = owner.prewarm("C:/work", "C:/work/src/Main.java"); - const close = owner.stop("C:\\work"); + const prewarm = owner.prewarm(workspaceA, "C:/work/src/Main.java"); + const close = owner.stop({ ...workspaceA, root: "C:\\work" }); releaseStart?.({ kind: "ready" }); expect(await prewarm).toEqual({ @@ -113,7 +118,7 @@ test("records a timeout without converting it to a generic failure", async () => notifyFailure, ); - expect(await owner.prewarm("C:/work", "C:/work/src/Main.java")).toEqual({ + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "timedOut", error: timeout, }); @@ -152,15 +157,15 @@ test("waits for a stopping owner before starting a replacement workspace session () => undefined, ); - const first = owner.prewarm("C:/work", "C:/work/src/First.java"); - const stopping = owner.stop("C:/work"); + const first = owner.prewarm(workspaceA, "C:/work/src/First.java"); + const stopping = owner.stop(workspaceA); startResolvers[0]?.({ kind: "ready" }); expect(await first).toEqual({ kind: "cancelled", reason: "workspace-closed-before-ready", }); - const replacement = owner.prewarm("C:/work", "C:/work/src/Second.java"); + const replacement = owner.prewarm(workspaceA, "C:/work/src/Second.java"); await Promise.resolve(); expect(stop).toHaveBeenCalledTimes(1); releaseStop?.(); @@ -194,11 +199,11 @@ test("creates a new operation after a failed start is retried", async () => { () => undefined, ); - expect(await owner.prewarm("C:/work", "C:/work/src/Main.java")).toEqual({ + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "failed", error: failure, }); - expect(await owner.prewarm("C:/work", "C:/work/src/Main.java")).toEqual({ kind: "ready" }); + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "ready" }); expect(operations.outcomes).toEqual(["failed", "succeeded"]); expect(operations.operationIds).toHaveLength(2); @@ -220,7 +225,7 @@ test("reports a configured workspace without a usable runtime as unavailable", a notifyFailure, ); - expect(await owner.prewarm("C:/work", "C:/work/src/Main.java")).toEqual({ + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "unavailable", reason: "notConfigured", }); @@ -232,3 +237,31 @@ test("reports a configured workspace without a usable runtime as unavailable", a expect.any(Function), ); }); + +test("keeps workspace A scope when retrying while workspace B is active", async () => { + const retryCallbacks: Array<() => void> = []; + let startAttempt = 0; + const start = mock(async (_scope: typeof workspaceA) => { + startAttempt += 1; + if (startAttempt === 1) throw new Error("first start failed"); + return { kind: "ready" } as const; + }); + const owner = new JavaWorkspaceLanguageServerOwner( + { start, stop: async () => undefined }, + operationRecorder().factory, + () => undefined, + () => undefined, + () => undefined, + (_workspacePath, _languageId, _failure, retry) => retryCallbacks.push(retry), + ); + + workspaceRuntimeRegistry.activateWorkspace({ id: "workspace-b", name: "B" }, "ready"); + await owner.prewarm(workspaceA, "C:/work/src/Main.java"); + expect(retryCallbacks).toHaveLength(1); + retryCallbacks[0]!(); + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "ready" }); + + expect(workspaceRuntimeRegistry.getActiveWorkspaceId()).toBe("workspace-b"); + expect(start).toHaveBeenCalledTimes(2); + expect(start.mock.calls.map((call) => call[0])).toEqual([workspaceA, workspaceA]); +}); diff --git a/windows/tauri/src/features/editor/lsp/java-workspace-language-server.ts b/windows/tauri/src/features/editor/lsp/java-workspace-language-server.ts index 91c2df416..47ec4e150 100644 --- a/windows/tauri/src/features/editor/lsp/java-workspace-language-server.ts +++ b/windows/tauri/src/features/editor/lsp/java-workspace-language-server.ts @@ -1,4 +1,5 @@ import { LspOperationLog } from "@/platform/lsp-session-lifecycle"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; import { JAVA_LANGUAGE_ID } from "./built-in-language-support"; import { clearLanguageServerReadyFeedback, @@ -14,7 +15,7 @@ import { interface WorkspaceLanguageServerClient { start( - workspacePath: string, + scope: WorkspaceLaunchScope, representativeFilePath?: string, ): Promise; stop(workspacePath: string): Promise; @@ -50,13 +51,13 @@ type WorkspaceOwnerState = interface WorkspaceOwner { operationId: string; operation: OperationLog; - workspacePath: string; + scope: WorkspaceLaunchScope; representativeJavaFile: string; state: WorkspaceOwnerState; } -function workspaceKey(workspacePath: string): string { - return workspacePath.replace(/\\/g, "/").toLowerCase(); +function workspaceKey(scope: WorkspaceLaunchScope): string { + return `${scope.workspaceId}\0${scope.root.replace(/\\/g, "/").toLowerCase()}`; } function isTimeout(error: unknown): boolean { @@ -92,21 +93,22 @@ export class JavaWorkspaceLanguageServerOwner { ) {} prewarm( - workspacePath: string, + scope: WorkspaceLaunchScope, representativeJavaFile: string, ): Promise { - const key = workspaceKey(workspacePath); + const workspacePath = scope.root; + const key = workspaceKey(scope); const existing = this.owners.get(key); if (existing) { if (existing.state.phase === "starting" || existing.state.phase === "ready") { return existing.state.task; } if (existing.state.phase === "stopping") { - return existing.state.task.then(() => this.prewarm(workspacePath, representativeJavaFile)); + return existing.state.task.then(() => this.prewarm(scope, representativeJavaFile)); } if (existing.state.phase === "stopFailed") { - return this.stop(workspacePath).then(() => - this.prewarm(workspacePath, representativeJavaFile), + return this.stop(scope).then(() => + this.prewarm(scope, representativeJavaFile), ); } this.owners.delete(key); @@ -114,6 +116,7 @@ export class JavaWorkspaceLanguageServerOwner { const operationId = crypto.randomUUID(); const operation = this.createOperationLog("workspacePrewarm", operationId, { + workspaceId: scope.workspaceId, workspacePath, languageId: JAVA_LANGUAGE_ID, }); @@ -121,7 +124,7 @@ export class JavaWorkspaceLanguageServerOwner { const owner: WorkspaceOwner = { operationId, operation, - workspacePath, + scope, representativeJavaFile, state: { phase: "created" }, }; @@ -135,9 +138,10 @@ export class JavaWorkspaceLanguageServerOwner { owner: WorkspaceOwner, key: string, ): Promise { - const { workspacePath, representativeJavaFile, operation } = owner; + const { scope, representativeJavaFile, operation } = owner; + const workspacePath = scope.root; try { - const startOutcome = await this.client.start(workspacePath, representativeJavaFile); + const startOutcome = await this.client.start(scope, representativeJavaFile); if (this.owners.get(key) !== owner) { operation.cancelled("superseded-owner"); return { kind: "cancelled", reason: "superseded-owner" }; @@ -152,7 +156,7 @@ export class JavaWorkspaceLanguageServerOwner { workspacePath, JAVA_LANGUAGE_ID, { kind: "unavailable" }, - () => void this.prewarm(workspacePath, representativeJavaFile), + () => void this.prewarm(scope, representativeJavaFile), ); if (this.owners.get(key) === owner) this.owners.delete(key); return { kind: "unavailable", reason: startOutcome.kind }; @@ -184,20 +188,22 @@ export class JavaWorkspaceLanguageServerOwner { kind: timedOut ? "timedOut" : "failed", detail: error instanceof Error ? error.message : String(error), }, - () => void this.prewarm(workspacePath, representativeJavaFile), + () => void this.prewarm(scope, representativeJavaFile), ); if (this.owners.get(key) === owner) this.owners.delete(key); return timedOut ? { kind: "timedOut", error } : { kind: "failed", error }; } } - async stop(workspacePath: string): Promise { - const key = workspaceKey(workspacePath); + async stop(scope: WorkspaceLaunchScope): Promise { + const workspacePath = scope.root; + const key = workspaceKey(scope); const owner = this.owners.get(key); if (owner?.state.phase === "stopping") return owner.state.task; const operationId = crypto.randomUUID(); const operation = this.createOperationLog("workspaceStop", operationId, { + workspaceId: scope.workspaceId, workspacePath, languageId: JAVA_LANGUAGE_ID, }); diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 89f07cbe3..ad1d588aa 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -58,6 +58,10 @@ import { type WorkspaceEdit, } from "./workspace-edit"; import type { LspAdapterSessionPhase } from "@/platform/lsp-session-lifecycle"; +import { + workspaceScopesMatch, + type WorkspaceLaunchScope, +} from "@/features/workspace/types/workspace-launch-scope"; export type LspWorkspaceStartOutcome = | { kind: "ready" } @@ -128,10 +132,15 @@ type TrackedLspDocument = { }; type PendingFileStart = { - workspacePath: string; + scope: WorkspaceLaunchScope; task: Promise; }; +type PendingWorkspaceStart = { + scope: WorkspaceLaunchScope; + task: Promise; +}; + type LspFileStartIntent = "attach" | "manualRestart"; type LspFileStartAttempt = @@ -201,12 +210,13 @@ export class LspClient { private activeLanguages = new Set(); // Track active language IDs for status private activeServerFiles = new Map>(); // workspace:language -> tracked files private workspaceRepresentativeFiles = new Map(); + private serverScopes = new Map(); /** workspace:language -> failure timestamp (ms); expired after a short cooldown. */ private failedLanguageServers = new Map(); private repairLanguageServerPromises = new Map>(); private fileAttachmentIds = new Map(); private fileStartTasks = new Map(); - private workspaceStartTasks = new Map>(); + private workspaceStartTasks = new Map(); private documentOpenTasks = new Map(); private documents = new Map(); @@ -347,6 +357,7 @@ export class LspClient { if (trackedFiles.size === 0) { this.activeServerFiles.delete(existingKey); this.activeLanguageServers.delete(existingKey); + this.serverScopes.delete(existingKey); } } const trackedFiles = this.activeServerFiles.get(serverKey) ?? new Set(); @@ -357,8 +368,14 @@ export class LspClient { this.activeServerFiles.set(serverKey, trackedFiles); } - private registerActiveServer(serverKey: string, languageId: string, filePath?: string) { + private registerActiveServer( + serverKey: string, + languageId: string, + scope: WorkspaceLaunchScope, + filePath?: string, + ) { this.activeLanguageServers.add(serverKey); + this.serverScopes.set(serverKey, scope); if (filePath) this.addTrackedFile(serverKey, filePath); this.activeLanguages.add(getLanguageDisplayName(languageId)); this.updateLspStatus(); @@ -551,9 +568,10 @@ export class LspClient { } async start( - workspacePath: string, + scope: WorkspaceLaunchScope, representativeFilePath?: string, ): Promise { + const workspacePath = scope.root; try { logger.debug("LSPClient", "Starting LSP with workspace:", workspacePath); @@ -563,7 +581,7 @@ export class LspClient { } const launch = representativeFilePath - ? await resolveEditorLspLaunch(representativeFilePath, workspacePath) + ? await resolveEditorLspLaunch(representativeFilePath, scope) : null; if (!launch) { logger.debug("LSPClient", `No LSP server configured for workspace ${workspacePath}`); @@ -579,12 +597,15 @@ export class LspClient { if (representativeFilePath) { this.workspaceRepresentativeFiles.set(serverKey, representativeFilePath); } - this.registerActiveServer(serverKey, launch.languageId); + this.registerActiveServer(serverKey, launch.languageId, scope); return { kind: "ready" } as const; } const existingTask = this.workspaceStartTasks.get(serverKey); - if (existingTask) return existingTask; + if (existingTask && workspaceScopesMatch(existingTask.scope, scope)) { + return existingTask.task; + } + if (existingTask) await existingTask.task; const task: Promise = (async (): Promise => { logger.debug( @@ -605,20 +626,21 @@ export class LspClient { cacheDirectory: launch.cacheDirectory || null, environment: launch.environment || null, workspaceFingerprint: launch.workspaceFingerprint || null, + mavenContext: launch.mavenContext || null, }); if (representativeFilePath) { this.workspaceRepresentativeFiles.set(serverKey, representativeFilePath); } - this.registerActiveServer(serverKey, launch.languageId); + this.registerActiveServer(serverKey, launch.languageId, scope); logger.debug("LSPClient", "LSP started successfully for workspace:", workspacePath); return { kind: "ready" }; })().finally(() => { - if (this.workspaceStartTasks.get(serverKey) === task) { + if (this.workspaceStartTasks.get(serverKey)?.task === task) { this.workspaceStartTasks.delete(serverKey); } }); - this.workspaceStartTasks.set(serverKey, task); + this.workspaceStartTasks.set(serverKey, { scope, task }); return await task; } catch (error) { logger.error("LSPClient", "Failed to start LSP:", error); @@ -633,7 +655,7 @@ export class LspClient { const workspaceKey = trackedFileKey(workspacePath); const pendingStarts = [...this.workspaceStartTasks.entries()] .filter(([key]) => trackedFileKey(this.parseServerKey(key).workspacePath) === workspaceKey) - .map(([, task]) => task); + .map(([, pending]) => pending.task); if (pendingStarts.length > 0) await Promise.allSettled(pendingStarts); await invoke("lsp_stop", { workspacePath }); @@ -652,6 +674,7 @@ export class LspClient { this.activeLanguageServers.delete(server); this.activeServerFiles.delete(server); this.workspaceRepresentativeFiles.delete(server); + this.serverScopes.delete(server); const { languageId: language } = this.parseServerKey(server); if (language) { const displayName = getLanguageDisplayName(language); @@ -683,33 +706,35 @@ export class LspClient { async startForFile( filePath: string, - workspacePath: string, + scope: WorkspaceLaunchScope, intent: LspFileStartIntent = "attach", ): Promise { const attachmentKey = trackedFileKey(filePath); const currentAttachmentId = this.fileAttachmentIds.get(attachmentKey); const currentSession = getLspSessionSnapshot({ filePath }); + const currentServerKey = currentSession + ? `${currentSession.workspacePath}:${currentSession.languageId}` + : null; + const currentScope = currentServerKey ? this.serverScopes.get(currentServerKey) : undefined; if ( currentAttachmentId && currentSession && - trackedFileKey(currentSession.workspacePath) === trackedFileKey(workspacePath) + currentServerKey && + currentScope && + workspaceScopesMatch(currentScope, scope) ) { - this.registerActiveServer( - `${currentSession.workspacePath}:${currentSession.languageId}`, - currentSession.languageId, - filePath, - ); + this.registerActiveServer(currentServerKey, currentSession.languageId, scope, filePath); return { kind: "attached", attachmentId: currentAttachmentId }; } const pending = this.fileStartTasks.get(attachmentKey); - if (pending && trackedFileKey(pending.workspacePath) === trackedFileKey(workspacePath)) { + if (pending && workspaceScopesMatch(pending.scope, scope)) { return pending.task; } const attachmentId = crypto.randomUUID(); this.fileAttachmentIds.set(attachmentKey, attachmentId); - const task = this.startFileAttachment(filePath, workspacePath, { + const task = this.startFileAttachment(filePath, scope, { kind: intent, attachmentId, }).finally(() => { @@ -717,15 +742,16 @@ export class LspClient { this.fileStartTasks.delete(attachmentKey); } }); - this.fileStartTasks.set(attachmentKey, { workspacePath, task }); + this.fileStartTasks.set(attachmentKey, { scope, task }); return task; } private async startFileAttachment( filePath: string, - workspacePath: string, + scope: WorkspaceLaunchScope, attempt: LspFileStartAttempt, ): Promise { + const workspacePath = scope.root; const attachmentKey = trackedFileKey(filePath); const attachmentId = attempt.attachmentId; if (this.fileAttachmentIds.get(attachmentKey) !== attachmentId) { @@ -744,14 +770,14 @@ export class LspClient { let launch: Awaited> = null; try { - launch = await resolveEditorLspLaunch(filePath, workspacePath); + launch = await resolveEditorLspLaunch(filePath, scope); } catch (error) { if (attempt.kind !== "repairRetry" && !isBuiltInLspPath(filePath)) { const languageId = languageIdForEditorFile(filePath); if (languageId) { const repaired = await this.repairLanguageServerForFile(filePath, languageId); if (repaired.kind === "repaired") { - return this.startFileAttachment(filePath, workspacePath, { + return this.startFileAttachment(filePath, scope, { kind: "repairRetry", attachmentId, }); @@ -766,7 +792,7 @@ export class LspClient { if (languageId && attempt.kind !== "repairRetry" && !isBuiltInLspPath(filePath)) { const repaired = await this.repairLanguageServerForFile(filePath, languageId); if (repaired.kind === "repaired") { - return this.startFileAttachment(filePath, workspacePath, { + return this.startFileAttachment(filePath, scope, { kind: "repairRetry", attachmentId, }); @@ -833,6 +859,7 @@ export class LspClient { cacheDirectory: launch.cacheDirectory || null, environment: launch.environment || null, workspaceFingerprint: launch.workspaceFingerprint || null, + mavenContext: launch.mavenContext || null, attachmentId, }); if (!isCurrentAttachment()) { @@ -840,13 +867,13 @@ export class LspClient { return { kind: "cancelled", reason: "superseded" }; } clearLanguageServerFailure(this.failedLanguageServers, serverKey); - this.registerActiveServer(serverKey, languageId, filePath); + this.registerActiveServer(serverKey, languageId, scope, filePath); } catch (error) { recordLanguageServerFailure(this.failedLanguageServers, serverKey, Date.now()); if (attempt.kind !== "repairRetry" && this.isRepairableStartupError(error)) { const repaired = await this.repairLanguageServerForFile(filePath, languageId); if (repaired.kind === "repaired") { - return this.startFileAttachment(filePath, workspacePath, { + return this.startFileAttachment(filePath, scope, { kind: "repairRetry", attachmentId, }); @@ -916,7 +943,7 @@ export class LspClient { async ensureDocumentReady( target: LspDocumentTargetInput, - workspacePath: string, + scope: WorkspaceLaunchScope, content: string, feature?: string, ): Promise { @@ -929,7 +956,7 @@ export class LspClient { // that source document from virtual class text would corrupt synchronization. if (sessionFilePath !== document.filePath) return initial; - const attachment = await this.startForFile(sessionFilePath, workspacePath); + const attachment = await this.startForFile(sessionFilePath, scope); if (attachment.kind !== "attached") return this.getDocumentAvailability(document, feature); const { attachmentId } = attachment; @@ -975,6 +1002,7 @@ export class LspClient { }); if (!stillActiveForServer && !(languageId === JAVA_LANGUAGE_ID && workspaceSession)) { this.activeLanguageServers.delete(activeKey); + this.serverScopes.delete(activeKey); } clearLanguageServerFailure(this.failedLanguageServers, activeKey); } @@ -1008,7 +1036,11 @@ export class LspClient { await this.stopForFile(trackedFilePath); } - async restartForFile(filePath: string, workspacePath: string, content: string): Promise { + async restartForFile( + filePath: string, + scope: WorkspaceLaunchScope, + content: string, + ): Promise { const { actions } = useLspStore.getState(); try { @@ -1017,7 +1049,7 @@ export class LspClient { await this.notifyDocumentClose(filePath); await this.stopForFile(filePath); - const attachment = await this.startForFile(filePath, workspacePath, "manualRestart"); + const attachment = await this.startForFile(filePath, scope, "manualRestart"); if (attachment.kind !== "attached") { throw new Error("Language server failed to start."); } @@ -1036,16 +1068,24 @@ export class LspClient { if (!representativeFilePath) { throw new Error("No representative file for this language server"); } - const { workspacePath } = this.parseServerKey(serverKey); + const scope = this.serverScopes.get(serverKey); + if (!scope) { + throw new Error("No workspace scope for this language server"); + } + const workspacePath = scope.root; await this.stop(workspacePath); - await this.start(workspacePath, representativeFilePath); + await this.start(scope, representativeFilePath); return; } const filePath = trackedFilePath; const buffer = useBufferStore.getState().buffers.find((entry) => entry.path === filePath); const content = buffer && hasTextContent(buffer) ? buffer.content : ""; - await this.restartForFile(filePath, this.parseServerKey(serverKey).workspacePath, content); + const scope = this.serverScopes.get(serverKey); + if (!scope) { + throw new Error("No workspace scope for this language server"); + } + await this.restartForFile(filePath, scope, content); } async restartAllTrackedServers(): Promise { diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts new file mode 100644 index 000000000..49ca9d6c9 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts @@ -0,0 +1,51 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { resolveEditorLspLaunch } from "./resolve-editor-lsp-launch"; + +afterEach(() => workspaceRuntimeRegistry.resetForTests()); + +const resolveJavaLspLaunch = mock(async () => ({ + providerId: "java", + languageId: "java", + executablePath: "C:/Lithe/jdtls/bin/jdtls.bat", + arguments: [], + runtimeExecutablePath: "C:/Lithe/jdk/bin/java.exe", + cacheDirectory: "C:/Users/example/AppData/Local/Lithe/jdtls", + environment: { JAVA_HOME: "C:/Lithe/jdk" }, + workspaceFingerprint: "workspace-fingerprint", +})); +const mavenLaunchContextForWorkspace = mock(async () => ({ + version: 1 as const, + reactorPath: ".", + profiles: ["dev"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", +})); + +test("resolves workspace A Maven context while workspace B is active", async () => { + workspaceRuntimeRegistry.activateWorkspace({ id: "workspace-b", name: "B" }, "ready"); + const launch = await resolveEditorLspLaunch( + "D:/work-a/src/App.java", + { + workspaceId: "workspace-a", + root: "D:/work-a", + }, + { resolveJavaLspLaunch, mavenLaunchContextForWorkspace }, + ); + + expect(workspaceRuntimeRegistry.getActiveWorkspaceId()).toBe("workspace-b"); + expect(mavenLaunchContextForWorkspace).toHaveBeenCalledWith( + "D:/work-a", + ["src/App.java"], + "workspace-a", + ); + expect(launch?.mavenContext).toEqual( + expect.objectContaining({ + profiles: ["dev"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + }), + ); +}); diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts index 99be53ae4..fe44b6aa2 100644 --- a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts @@ -1,9 +1,10 @@ import type { BackendLanguageToolConfigSet } from "@/extensions/registry/extension-store-runtime"; import { isJavaSourcePath, JAVA_LANGUAGE_ID, JAVA_PROVIDER_ID } from "./built-in-language-support"; -import { - resolveJavaLspLaunch, - type JdtlsLaunchResources, -} from "./java-lsp-host-api"; +import { resolveJavaLspLaunch, type JdtlsLaunchResources } from "./java-lsp-host-api"; +import type { MavenLaunchContext } from "@/features/maven/types/maven.types"; +import { mavenLaunchContextForWorkspace } from "@/features/maven/stores/maven.store"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; +import { getRelativePath } from "@/utils/path-helpers"; export interface EditorLspLaunch { providerId: string; @@ -18,14 +19,34 @@ export interface EditorLspLaunch { environment?: Record; /** Workspace structure digest forwarded to the Rust core. */ workspaceFingerprint?: string | null; + mavenContext?: MavenLaunchContext | null; +} + +export interface EditorLspLaunchDependencies { + resolveJavaLspLaunch: typeof resolveJavaLspLaunch; + mavenLaunchContextForWorkspace: typeof mavenLaunchContextForWorkspace; } +const defaultDependencies: EditorLspLaunchDependencies = { + resolveJavaLspLaunch, + mavenLaunchContextForWorkspace, +}; + export async function resolveEditorLspLaunch( filePath: string, - workspacePath: string, + scope: WorkspaceLaunchScope, + dependencies: EditorLspLaunchDependencies = defaultDependencies, ): Promise { + const workspacePath = scope.root; if (isJavaSourcePath(filePath)) { - const launch = await resolveJavaLspLaunch(workspacePath); + const [launch, mavenContext] = await Promise.all([ + dependencies.resolveJavaLspLaunch(workspacePath), + dependencies.mavenLaunchContextForWorkspace( + workspacePath, + [getRelativePath(filePath, workspacePath)], + scope.workspaceId, + ), + ]); const environment: Record = {}; if (launch.environment.JAVA_HOME) { environment.JAVA_HOME = launch.environment.JAVA_HOME; @@ -40,6 +61,7 @@ export async function resolveEditorLspLaunch( cacheDirectory: launch.cacheDirectory, environment, workspaceFingerprint: launch.workspaceFingerprint, + mavenContext, }; } diff --git a/windows/tauri/src/features/editor/services/save-workspace-before-launch.ts b/windows/tauri/src/features/editor/services/save-workspace-before-launch.ts new file mode 100644 index 000000000..c3608f6a6 --- /dev/null +++ b/windows/tauri/src/features/editor/services/save-workspace-before-launch.ts @@ -0,0 +1,56 @@ +import { isEditorContent } from "@/features/panes/types/pane-content.types"; +import { useBufferStore } from "../stores/buffer.store"; +import { useEditorAppStore } from "../stores/editor-app.store"; + +function hasActiveWritableSave(workspaceId: string): boolean { + return useBufferStore + .getStore(workspaceId) + .getState() + .buffers.some( + (buffer) => + isEditorContent(buffer) && + !buffer.readOnly && + buffer.documentLifecycle?.status === "saving", + ); +} + +async function waitForActiveWorkspaceSaves(workspaceId: string): Promise { + if (!hasActiveWritableSave(workspaceId)) return; + + const bufferStore = useBufferStore.getStore(workspaceId); + await new Promise((resolve) => { + let unsubscribe = () => {}; + const resolveWhenIdle = () => { + if (hasActiveWritableSave(workspaceId)) return; + unsubscribe(); + resolve(); + }; + unsubscribe = bufferStore.subscribe(resolveWhenIdle); + resolveWhenIdle(); + }); +} + +function unsavedWritableBufferNames(workspaceId: string): string[] { + const names = useBufferStore + .getStore(workspaceId) + .getState() + .buffers.filter( + (buffer) => isEditorContent(buffer) && buffer.isDirty && !buffer.readOnly, + ) + .map((buffer) => buffer.name); + return [...new Set(names)].sort(); +} + +export async function saveWorkspaceBeforeLaunch(workspaceId: string): Promise { + while (true) { + await waitForActiveWorkspaceSaves(workspaceId); + await useEditorAppStore.getStore(workspaceId).getState().actions.handleSaveAll(); + const unsavedNames = unsavedWritableBufferNames(workspaceId); + if (unsavedNames.length === 0) return; + if (hasActiveWritableSave(workspaceId)) continue; + + throw new Error( + `Unable to start because modified files could not be saved: ${unsavedNames.join(", ")}.`, + ); + } +} diff --git a/windows/tauri/src/features/editor/stores/editor-app.store.test.ts b/windows/tauri/src/features/editor/stores/editor-app.store.test.ts index 926c12b2b..b39d35430 100644 --- a/windows/tauri/src/features/editor/stores/editor-app.store.test.ts +++ b/windows/tauri/src/features/editor/stores/editor-app.store.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { toast } from "sonner"; import type { EditorContent } from "@/features/panes/types/pane-content.types"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { saveWorkspaceBeforeLaunch } from "../services/save-workspace-before-launch"; import { getBufferById } from "../utils/buffer-index"; import { useBufferStore } from "./buffer.store"; import { useEditorAppStore } from "./editor-app.store"; @@ -54,6 +55,11 @@ beforeEach(() => { workspaceRuntimeRegistry.ensureWorkspace({ id: WORKSPACE_B, name: "Workspace B" }, "ready"); }); +afterEach(() => { + useEditorAppStore.getStore(WORKSPACE_A).getState().actions.cleanup(); + useEditorAppStore.getStore(WORKSPACE_B).getState().actions.cleanup(); +}); + describe("workspace-scoped editor actions", () => { test("routes content changes to the source workspace", async () => { setWorkspaceBuffers( @@ -178,4 +184,71 @@ describe("workspace-scoped editor actions", () => { expectedParseError.mockRestore(); } }); + + test("saves only the target workspace before an external launch", async () => { + setWorkspaceBuffers(WORKSPACE_A, [editorBuffer("a", "A edited", { isDirty: true })], "a"); + setWorkspaceBuffers(WORKSPACE_B, [editorBuffer("b", "B edited", { isDirty: true })], "b"); + + await saveWorkspaceBeforeLaunch(WORKSPACE_A); + + expect(getEditorBuffer(WORKSPACE_A, "a").isDirty).toBe(false); + expect(getEditorBuffer(WORKSPACE_B, "b").isDirty).toBe(true); + }); + + test("waits for an active auto-save before checking external launch readiness", async () => { + setWorkspaceBuffers(WORKSPACE_A, [editorBuffer("a", "A edited", { isDirty: true })], "a"); + const bufferActions = useBufferStore.getStore(WORKSPACE_A).getState().actions; + bufferActions.applyDocumentLifecycle("a", { + status: "saving", + revision: 1, + savedRevision: 0, + saveRevision: 1, + operationId: "auto-save-a", + }); + + const launchSaveState: { value: "pending" | "resolved" | "rejected" } = { + value: "pending", + }; + const launchSave = saveWorkspaceBeforeLaunch(WORKSPACE_A).then( + () => { + launchSaveState.value = "resolved"; + }, + () => { + launchSaveState.value = "rejected"; + }, + ); + await Promise.resolve(); + expect(launchSaveState.value).toBe("pending"); + + bufferActions.recordSuccessfulBufferSave("a", "A edited", { + status: "clean", + revision: 1, + }); + await launchSave; + + expect(launchSaveState.value).toBe("resolved"); + }, 1_000); + + test("rejects an external launch when a workspace file remains unsaved", async () => { + const saveFailureToast = spyOn(toast, "error").mockImplementation(() => "test-toast"); + const expectedParseError = spyOn(console, "error").mockImplementation(() => undefined); + setWorkspaceBuffers( + WORKSPACE_A, + [ + editorBuffer("settings", "not valid json", { + isDirty: true, + path: "settings://user-settings.json", + }), + ], + "settings", + ); + + try { + await expect(saveWorkspaceBeforeLaunch(WORKSPACE_A)).rejects.toThrow("settings.txt"); + expect(getEditorBuffer(WORKSPACE_A, "settings").isDirty).toBe(true); + } finally { + saveFailureToast.mockRestore(); + expectedParseError.mockRestore(); + } + }); }); diff --git a/windows/tauri/src/features/file-system/stores/file-system.store.ts b/windows/tauri/src/features/file-system/stores/file-system.store.ts index 4d24dc741..c051d9418 100644 --- a/windows/tauri/src/features/file-system/stores/file-system.store.ts +++ b/windows/tauri/src/features/file-system/stores/file-system.store.ts @@ -524,10 +524,11 @@ const initializeLocalWorkspaceInBackground = ( } const [{ getRelativePath, pathStartsWithRoot }, { resolveJavaWorkspacePolicy }, - { getJavaWorkspaceLanguageServerOwner }] = await Promise.all([ + { getJavaWorkspaceLanguageServerOwner }, { loadMavenProjectForWorkspace }] = await Promise.all([ import("@/utils/path-helpers"), import("@/platform/java-workspace-policy"), import("@/features/editor/lsp/java-workspace-language-server"), + import("@/features/maven/stores/maven.store"), ]); const workspaceFiles = projectFiles.filter( (entry) => !entry.isDir && pathStartsWithRoot(entry.path, path), @@ -535,6 +536,15 @@ const initializeLocalWorkspaceInBackground = ( const relativeToAbsolute = new Map( workspaceFiles.map((entry) => [getRelativePath(entry.path, path), entry.path]), ); + await loadMavenProjectForWorkspace(path, [...relativeToAbsolute.keys()], workspaceId); + if ( + activationVersion !== workspaceServiceActivationVersion || + workspaceRuntimeRegistry.getActiveWorkspaceId() !== workspaceId || + get().rootFolderPath !== path + ) { + operation.cancelled("workspace-activation-superseded"); + return; + } const policy = await resolveJavaWorkspacePolicy([...relativeToAbsolute.keys()]); const javaFile = policy.representativeJavaPath ? relativeToAbsolute.get(policy.representativeJavaPath) @@ -545,7 +555,10 @@ const initializeLocalWorkspaceInBackground = ( } operation.succeeded({ representativeJavaPath: policy.representativeJavaPath }); - await getJavaWorkspaceLanguageServerOwner().prewarm(path, javaFile); + await getJavaWorkspaceLanguageServerOwner().prewarm( + { workspaceId, root: path }, + javaFile, + ); } catch (error) { operation.failed(error); } @@ -3068,7 +3081,10 @@ const createFileSystemStore = (workspaceId: string): StoreApi { 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 4ad88ae0a..ae154f831 100644 --- a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts @@ -35,6 +35,8 @@ import { } from "@/features/spring/utils/spring-navigation"; import { useUIState } from "@/features/window/stores/ui-state.store"; import { useProjectStore } from "@/features/window/stores/project.store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; import { createTranslator } from "@/i18n/locale"; import { logger } from "@/features/editor/utils/logger"; import { normalizePath } from "@/utils/path-helpers"; @@ -67,7 +69,7 @@ type LspNavigationClient = { ) => LspDocumentAvailability; ensureDocumentReady: ( target: LspDocumentTargetInput, - workspacePath: string, + scope: WorkspaceLaunchScope, content: string, feature?: string, ) => Promise; @@ -167,6 +169,10 @@ async function ensureNavigationLanguageServer( } const target = lspDocumentTargetForEditor(buffer); + const scope = { + workspaceId: workspaceRuntimeRegistry.getActiveWorkspaceId(), + root: workspacePath, + }; toast.info( navigationBlockMessage( { reason: "preparing", languageId: block.languageId }, @@ -179,7 +185,7 @@ async function ensureNavigationLanguageServer( // here. Continuing after readiness would move the editor long after the user // has switched context. void lspClient - .ensureDocumentReady(target, workspacePath, buffer.content, feature) + .ensureDocumentReady(target, scope, buffer.content, feature) .catch((error) => { logger.warn("LSPNavigation", "Background document attachment failed", error); }); diff --git a/windows/tauri/src/features/keymaps/commands/view-command-actions.ts b/windows/tauri/src/features/keymaps/commands/view-command-actions.ts index d3888564f..828797e01 100644 --- a/windows/tauri/src/features/keymaps/commands/view-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/view-command-actions.ts @@ -37,6 +37,16 @@ export function toggleRunPane(): void { } } +export function toggleMavenPane(): void { + const state = useUIState.getState(); + if (state.isBottomPaneVisible && state.bottomPaneActiveTab === "maven") { + state.setIsBottomPaneVisible(false); + } else { + state.setBottomPaneActiveTab("maven"); + state.setIsBottomPaneVisible(true); + } +} + export function toggleTerminalPane(): void { const state = useUIState.getState(); if (state.isBottomPaneVisible && state.bottomPaneActiveTab === "terminal") { diff --git a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx index 1a3dde8ca..765a3b7a5 100644 --- a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx +++ b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx @@ -7,6 +7,8 @@ import RunPane from "@/features/run/components/run-pane"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useTranslation } from "@/i18n/locale-provider"; import { GitLogToolWindow } from "@/features/git/components/log/git-log-tool-window"; +import MavenPane from "@/features/maven/components/maven-pane"; +import { useMavenStore } from "@/features/maven/stores/maven.store"; import { BOTTOM_PANE_ID } from "@/features/panes/constants/pane"; import { usePaneStore } from "@/features/panes/stores/pane.store"; import { activateBufferInPaneAndSync } from "@/features/panes/utils/pane-activation"; @@ -28,6 +30,8 @@ const BottomPane = () => { const { t } = useTranslation(); const isBottomPaneVisible = useUIState((state) => state.isBottomPaneVisible); const bottomPaneActiveTab = useUIState((state) => state.bottomPaneActiveTab); + const mavenProjectStatus = useMavenStore((state) => state.projectStatus); + const mavenProject = useMavenStore((state) => state.project); const rootFolderPath = useProjectStore((state) => state.rootFolderPath); const terminalEnabled = useSettingsStore((state) => state.settings.coreFeatures.terminal); const debuggerEnabled = useSettingsStore((state) => state.settings.coreFeatures.debugger); @@ -64,6 +68,17 @@ const BottomPane = () => { } }, [bottomPaneActiveTab, isBottomPaneVisible]); + useEffect(() => { + if ( + isBottomPaneVisible && + bottomPaneActiveTab === "maven" && + mavenProjectStatus === "ready" && + !mavenProject + ) { + useUIState.getState().setIsBottomPaneVisible(false); + } + }, [bottomPaneActiveTab, isBottomPaneVisible, mavenProject, mavenProjectStatus]); + useEffect(() => { if ( isBottomPaneVisible && @@ -276,6 +291,12 @@ const BottomPane = () => { )} + {bottomPaneActiveTab === "maven" && ( +
+ +
+ )} + {bottomPaneActiveTab === "diagnostics" && (
state.openSettingsDialog); const isBottomPaneVisible = useUIState((state) => state.isBottomPaneVisible); const bottomPaneActiveTab = useUIState((state) => state.bottomPaneActiveTab); + const mavenProject = useMavenStore((state) => state.project); + const mavenProjectStatus = useMavenStore((state) => state.projectStatus); const configuredActivityRailWidth = useSettingsStore((state) => state.settings.activityRailWidth); const askWhereToOpenProjects = useSettingsStore((state) => state.settings.askWhereToOpenProjects); const openFoldersInNewWindow = useSettingsStore((state) => state.settings.openFoldersInNewWindow); @@ -170,74 +180,34 @@ export const SidebarActivityRail = memo(({ expanded = false }: SidebarActivityRa openSidebarView(view); }; - const activityRailVisibilityItems = useMemo( - () => [ - { - id: "files", - label: t("workbench.project"), - icon: , - }, - ...(coreFeatures.search - ? [ - { - id: "search", - label: t("workbench.search"), - icon: , - }, - ] - : []), - ...(coreFeatures.git - ? [ - { - id: "git", - label: t("workbench.changes"), - icon: , - }, - { - id: "gitLog", - label: t("workbench.gitLog"), - icon: , - }, - ] - : []), - ...(coreFeatures.terminal - ? [ - { - id: "terminal", - label: t("workbench.terminal"), - icon: , - }, - ] - : []), - ...(coreFeatures.diagnostics - ? [ - { - id: "diagnostics", - label: t("workbench.diagnostics"), - icon: , - }, - ] - : []), - { - id: "run", - label: t("workbench.run"), - icon: , - }, - { - id: "settings", - label: t("workbench.settings"), - icon: , - }, - ], - [coreFeatures.diagnostics, coreFeatures.git, coreFeatures.search, coreFeatures.terminal, t], - ); + const activityRailVisibilityItems = useMemo(() => { + const items = new Map< + SidebarActivityItemId, + { id: SidebarActivityItemId; label: string; icon: ReactNode } + >([ + ["files", { id: "files", label: t("workbench.project"), icon: }], + ["git", { id: "git", label: t("workbench.changes"), icon: }], + ["search", { id: "search", label: t("workbench.search"), icon: }], + ["maven", { id: "maven", label: t("workbench.maven"), icon: }], + ["run", { id: "run", label: t("workbench.run"), icon: }], + [ + "terminal", + { id: "terminal", label: t("workbench.terminal"), icon: }, + ], + [ + "diagnostics", + { id: "diagnostics", label: t("workbench.diagnostics"), icon: }, + ], + ["gitLog", { id: "gitLog", label: t("workbench.gitLog"), icon: }], + ["settings", { id: "settings", label: t("workbench.settings"), icon: }], + ]); + return sidebarActivityVisibilityItemIds(coreFeatures).map((id) => items.get(id)!); + }, [coreFeatures.diagnostics, coreFeatures.git, coreFeatures.search, coreFeatures.terminal, t]); const setActivityRailItemVisible = useCallback( - (itemId: string, visible: boolean) => { + (itemId: SidebarActivityItemId, visible: boolean) => { const currentHiddenItems = useSettingsStore.getState().settings.hiddenSidebarActivityItems; - const nextHiddenItems = visible - ? currentHiddenItems.filter((hiddenItemId) => hiddenItemId !== itemId) - : Array.from(new Set([...currentHiddenItems, itemId])); + const nextHiddenItems = setSidebarActivityItemVisibility(currentHiddenItems, itemId, visible); void updateSetting("hiddenSidebarActivityItems", nextHiddenItems); }, @@ -666,6 +636,12 @@ export const SidebarActivityRail = memo(({ expanded = false }: SidebarActivityRa isDiagnosticsActive={isBottomPaneVisible && bottomPaneActiveTab === "diagnostics"} onRunClick={() => toggleRunPane()} isRunActive={isBottomPaneVisible && bottomPaneActiveTab === "run"} + onMavenClick={ + mavenProject || mavenProjectStatus === "failed" + ? () => toggleMavenPane() + : undefined + } + isMavenActive={isBottomPaneVisible && bottomPaneActiveTab === "maven"} compact={!expanded} showLabels={expanded} orientation="vertical" diff --git a/windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx b/windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx index 58188a02d..19af6b243 100644 --- a/windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx +++ b/windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx @@ -19,6 +19,7 @@ import { GitGraphIcon, FilesIcon, MagnifyingGlassIcon, + PackageIcon, TerminalWindowIcon, WarningIcon, } from "@/ui/icons"; @@ -70,6 +71,8 @@ interface SidebarPaneSelectorProps { isDiagnosticsActive?: boolean; onRunClick?: () => void; isRunActive?: boolean; + onMavenClick?: () => void; + isMavenActive?: boolean; compact?: boolean; showLabels?: boolean; orientation?: "horizontal" | "vertical"; @@ -92,6 +95,8 @@ export const SidebarPaneSelector = ({ isDiagnosticsActive = false, onRunClick, isRunActive = false, + onMavenClick, + isMavenActive = false, compact = false, showLabels = false, orientation = "horizontal", @@ -233,6 +238,22 @@ export const SidebarPaneSelector = ({ } satisfies SidebarPaneItem, ] : []), + ...(onMavenClick + ? [ + { + id: "maven", + label: showLabels ? t("workbench.maven") : undefined, + icon: , + isActive: isMavenActive, + onClick: onMavenClick, + ariaLabel: t("workbench.maven"), + tooltip: { + content: t("workbench.maven"), + side: tooltipSide, + }, + } satisfies SidebarPaneItem, + ] + : []), ...(onSettingsClick ? [ { @@ -267,8 +288,10 @@ export const SidebarPaneSelector = ({ onDiagnosticsClick, isDiagnosticsActive, onRunClick, + onMavenClick, onSettingsClick, isRunActive, + isMavenActive, onViewChange, showLabels, t, diff --git a/windows/tauri/src/features/layout/config/item-order.test.ts b/windows/tauri/src/features/layout/config/item-order.test.ts index cee9ec6c7..17d3ac3f7 100644 --- a/windows/tauri/src/features/layout/config/item-order.test.ts +++ b/windows/tauri/src/features/layout/config/item-order.test.ts @@ -4,6 +4,8 @@ import { SIDEBAR_ACTIVITY_ITEM_IDS, SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS, normalizeItemOrder, + setSidebarActivityItemVisibility, + sidebarActivityVisibilityItemIds, } from "./item-order"; describe("footer item order", () => { @@ -23,12 +25,41 @@ describe("footer item order", () => { }); describe("sidebar activity order", () => { + test("includes Maven in the default visibility order", () => { + expect( + sidebarActivityVisibilityItemIds({ + search: true, + git: true, + terminal: true, + diagnostics: true, + }), + ).toEqual([ + "files", + "git", + "search", + "maven", + "run", + "terminal", + "diagnostics", + "gitLog", + "settings", + ]); + }); + + test("hides and restores Maven independently", () => { + const hidden = setSidebarActivityItemVisibility([], "maven", false); + + expect(hidden).toEqual(["maven"]); + expect(setSidebarActivityItemVisibility(hidden, "maven", true)).toEqual([]); + }); + test("does not expose an unavailable Database placeholder", () => { expect([...SIDEBAR_ACTIVITY_ITEM_IDS]).not.toContain("database"); }); - test("places Run, Terminal, Diagnostics, Git Log, then Settings", () => { + test("places Maven, Run, Terminal, Diagnostics, Git Log, then Settings", () => { expect([...SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS]).toEqual([ + "maven", "run", "terminal", "diagnostics", diff --git a/windows/tauri/src/features/layout/config/item-order.ts b/windows/tauri/src/features/layout/config/item-order.ts index 98f1cf443..fa8edb7d4 100644 --- a/windows/tauri/src/features/layout/config/item-order.ts +++ b/windows/tauri/src/features/layout/config/item-order.ts @@ -2,6 +2,7 @@ export const SIDEBAR_ACTIVITY_ITEM_IDS = [ "files", "git", "search", + "maven", "run", "terminal", "diagnostics", @@ -9,6 +10,7 @@ export const SIDEBAR_ACTIVITY_ITEM_IDS = [ "settings", ] as const; export const SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS = [ + "maven", "run", "terminal", "diagnostics", @@ -29,6 +31,35 @@ export type SidebarActivityItemId = (typeof SIDEBAR_ACTIVITY_ITEM_IDS)[number]; export type FooterLeadingItemId = (typeof FOOTER_LEADING_ITEM_IDS)[number] | "debugger"; export type FooterTrailingItemId = (typeof FOOTER_TRAILING_ITEM_IDS)[number]; +interface SidebarActivityVisibilityFeatures { + search: boolean; + git: boolean; + terminal: boolean; + diagnostics: boolean; +} + +export function sidebarActivityVisibilityItemIds( + features: SidebarActivityVisibilityFeatures, +): SidebarActivityItemId[] { + return SIDEBAR_ACTIVITY_ITEM_IDS.filter((id) => { + if (id === "search") return features.search; + if (id === "git" || id === "gitLog") return features.git; + if (id === "terminal") return features.terminal; + if (id === "diagnostics") return features.diagnostics; + return true; + }); +} + +export function setSidebarActivityItemVisibility( + hiddenItemIds: readonly string[], + itemId: SidebarActivityItemId, + visible: boolean, +): string[] { + return visible + ? hiddenItemIds.filter((hiddenItemId) => hiddenItemId !== itemId) + : [...new Set([...hiddenItemIds, itemId])]; +} + export function normalizeItemOrder( persistedOrder: readonly T[] | undefined, defaultOrder: readonly T[], diff --git a/windows/tauri/src/features/maven/api/maven-core-api.test.ts b/windows/tauri/src/features/maven/api/maven-core-api.test.ts new file mode 100644 index 000000000..8c6774c1c --- /dev/null +++ b/windows/tauri/src/features/maven/api/maven-core-api.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const executeCore = mock(async () => ({ + id: "request", + ok: true as const, + data: null, +})); + +mock.module("@/core/lithe-core-client", () => ({ executeCore })); + +const { createMavenLaunchPlan, scanMavenProject } = await import("./maven-core-api"); + +beforeEach(() => { + executeCore.mockClear(); +}); + +describe("Maven Core API", () => { + test("scans with the visible workspace-relative paths", async () => { + await scanMavenProject("D:/work", ["reactor/pom.xml", "reactor/app/src/App.java"]); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + command: "maven.scan", + payload: { + root: "D:/work", + paths: ["reactor/pom.xml", "reactor/app/src/App.java"], + }, + }), + ); + }); + + test("forwards the complete context without assembling Maven arguments", async () => { + const context = { + version: 1 as const, + reactorPath: "reactor", + profiles: ["dev", "qa"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }; + + await createMavenLaunchPlan("D:/work", context, ["verify"], "app"); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + command: "maven.launchPlan", + payload: { + root: "D:/work", + context, + module: "app", + goals: ["verify"], + }, + }), + ); + }); +}); diff --git a/windows/tauri/src/features/maven/api/maven-core-api.ts b/windows/tauri/src/features/maven/api/maven-core-api.ts new file mode 100644 index 000000000..7b909fb0e --- /dev/null +++ b/windows/tauri/src/features/maven/api/maven-core-api.ts @@ -0,0 +1,61 @@ +import { executeCore } from "@/core/lithe-core-client"; +import type { + MavenDiagnostic, + MavenLaunchContext, + MavenLaunchPlan, + MavenProject, +} from "../types/maven.types"; + +let requestSequence = 0; + +function nextRequestId(prefix: string): string { + requestSequence += 1; + return `${prefix}-${Date.now()}-${requestSequence}`; +} + +async function mavenCore( + command: string, + payload: unknown, + timeoutMilliseconds = 30_000, +): Promise { + const response = await executeCore({ + id: nextRequestId(command), + operationId: nextRequestId(`${command}-op`), + timeoutMilliseconds, + command, + payload, + }); + if (!response.ok) { + const error = new Error(response.error.message) as Error & { code?: string; details?: string }; + error.code = response.error.code; + error.details = response.error.details; + throw error; + } + return response.data; +} + +export function scanMavenProject(root: string, paths: string[] = []) { + return mavenCore("maven.scan", { root, paths }, 60_000); +} + +export function createMavenLaunchPlan( + root: string, + context: MavenLaunchContext, + goals: string[], + module?: string | null, +) { + return mavenCore("maven.launchPlan", { + root, + context, + module: module ?? null, + goals, + }); +} + +export async function parseMavenDiagnostics(root: string, output: string) { + const result = await mavenCore<{ issues: MavenDiagnostic[] }>("maven.diagnostics", { + root, + output, + }); + return result.issues ?? []; +} diff --git a/windows/tauri/src/features/maven/api/maven-host-api.ts b/windows/tauri/src/features/maven/api/maven-host-api.ts new file mode 100644 index 000000000..e4eb900fd --- /dev/null +++ b/windows/tauri/src/features/maven/api/maven-host-api.ts @@ -0,0 +1,42 @@ +import { invoke } from "@/platform/tauri-core"; +import { resolveRunLaunch, startRunProcess, stopRunProcess } from "@/features/run/api/run-host-api"; +import type { + MavenLaunchContext, + MavenLaunchPlan, + MavenStoredConfiguration, +} from "../types/maven.types"; + +export function loadMavenConfiguration(root: string, reactorPath: string) { + return invoke("maven_load_configuration", { + root, + reactorPath, + }); +} + +export function writeMavenConfiguration( + root: string, + reactorPath: string, + configuration: MavenStoredConfiguration, +) { + return invoke("maven_write_configuration", { + args: { root, reactorPath, configuration }, + }); +} + +export async function resolveMavenLaunch( + root: string, + context: MavenLaunchContext, + plan: MavenLaunchPlan, +) { + return resolveRunLaunch({ + root, + executable: plan.executable, + workingDirectory: plan.workingDirectory, + javaHomePath: "", + mavenExecutablePath: context.mavenExecutablePath ?? "", + mavenJavaHomePath: context.javaHomePath ?? "", + environment: {}, + }); +} + +export { startRunProcess as startMavenProcess, stopRunProcess as stopMavenProcess }; diff --git a/windows/tauri/src/features/maven/components/maven-pane.tsx b/windows/tauri/src/features/maven/components/maven-pane.tsx new file mode 100644 index 000000000..02bcd39b6 --- /dev/null +++ b/windows/tauri/src/features/maven/components/maven-pane.tsx @@ -0,0 +1,752 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { workspaceScopeMatchesRoot } from "@/features/workspace/types/workspace-launch-scope"; +import { RunOutputText } from "@/features/run/components/run-output-text"; +import { useUIState } from "@/features/window/stores/ui-state.store"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import { Checkbox } from "@/ui/checkbox"; +import Dialog from "@/ui/dialog"; +import Input from "@/ui/input"; +import { + ArrowClockwiseIcon, + ArrowCounterClockwiseIcon, + ArrowsInIcon, + CaretDownIcon, + CaretRightIcon, + FolderIcon, + GearIcon, + MinusIcon, + PackageIcon, + PlayIcon, + PlusIcon, + SlidersHorizontalIcon, + StopIcon, + TerminalIcon, + TrashIcon, + WarningIcon, +} from "@/ui/icons"; +import { ScrollArea } from "@/ui/scroll-area"; +import { Spinner } from "@/ui/spinner"; +import Tooltip from "@/ui/tooltip"; +import { joinPath } from "@/utils/path-helpers"; +import { cn } from "@/utils/cn"; +import { ensureMavenProcessListeners } from "../hooks/use-maven-process-events"; +import { availableMavenProfiles, useMavenStore } from "../stores/maven.store"; +import { + reloadJavaForMavenWorkspace, + reloadMavenWorkspaceProjects, +} from "../services/reload-maven-workspace"; +import { + MAVEN_LIFECYCLE_PHASES, + type MavenLifecyclePhase, + type MavenModule, + type MavenSettings, +} from "../types/maven.types"; + +interface TreeNodeProps { + id: string; + title: string; + subtitle?: string; + icon?: ReactNode; + selected?: boolean; + expanded: boolean; + onToggle: (id: string) => void; + onSelect?: () => void; + children?: ReactNode; +} + +function TreeNode({ + id, + title, + subtitle, + icon, + selected, + expanded, + onToggle, + onSelect, + children, +}: TreeNodeProps) { + return ( +
+
+ + +
+ {expanded && children ? ( +
{children}
+ ) : null} +
+ ); +} + +function MavenSettingsDialog({ + initial, + error, + onClose, + onSave, +}: { + initial: MavenSettings; + error: string | null; + onClose: () => void; + onSave: (settings: MavenSettings) => void; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(initial); + + const choosePath = async (field: keyof MavenSettings, directory: boolean) => { + const selected = await open({ + directory, + multiple: false, + ...(field === "settingsPath" + ? { filters: [{ name: "Maven settings", extensions: ["xml"] }] } + : {}), + }); + if (typeof selected === "string") setDraft((current) => ({ ...current, [field]: selected })); + }; + + const fields: Array<{ + id: string; + field: keyof MavenSettings; + label: string; + directory: boolean; + }> = [ + { id: "maven-settings-xml", field: "settingsPath", label: "settings.xml", directory: false }, + { + id: "maven-executable", + field: "mavenExecutablePath", + label: t("maven.mavenExecutable"), + directory: true, + }, + { + id: "maven-jdk-home", + field: "javaHomePath", + label: t("maven.javaHome"), + directory: true, + }, + ]; + + return ( + + {error ? ( + {error} + ) : ( + + )} + + + + } + > +
+ {fields.map(({ id, field, label, directory }) => ( + + ))} +
+
+ ); +} + +export default function MavenPane() { + const { t } = useTranslation(); + const workspaceId = useActiveWorkspaceId(); + const root = useMavenStore((state) => state.root); + const visiblePaths = useMavenStore((state) => state.visiblePaths); + const projectStatus = useMavenStore((state) => state.projectStatus); + const projectError = useMavenStore((state) => state.projectError); + const project = useMavenStore((state) => state.project); + const selectedProfiles = useMavenStore((state) => state.selectedProfiles); + const customProfiles = useMavenStore((state) => state.customProfiles); + const skipTests = useMavenStore((state) => state.skipTests); + const settingsPath = useMavenStore((state) => state.settingsPath); + const mavenExecutablePath = useMavenStore((state) => state.mavenExecutablePath); + const javaHomePath = useMavenStore((state) => state.javaHomePath); + const configurationSaveError = useMavenStore((state) => state.configurationSaveError); + const reloadRequired = useMavenStore((state) => state.reloadRequired); + const taskStatus = useMavenStore((state) => state.taskStatus); + const taskError = useMavenStore((state) => state.taskError); + const runningTitle = useMavenStore((state) => state.runningTitle); + const output = useMavenStore((state) => state.output); + const issues = useMavenStore((state) => state.issues); + const lastExitCode = useMavenStore((state) => state.lastExitCode); + const actions = useMavenStore((state) => state.actions); + const handleFileSelect = useFileSystemStore((state) => state.handleFileSelect); + const setIsBottomPaneVisible = useUIState((state) => state.setIsBottomPaneVisible); + const [selectedModule, setSelectedModule] = useState(null); + const [selectedPhase, setSelectedPhase] = useState("compile"); + const [expanded, setExpanded] = useState>(new Set()); + const [goalDialogOpen, setGoalDialogOpen] = useState(false); + const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + const [profileDialogOpen, setProfileDialogOpen] = useState(false); + const [customGoal, setCustomGoal] = useState(""); + const [customProfile, setCustomProfile] = useState(""); + const [reloadError, setReloadError] = useState(null); + + const profiles = useMemo( + () => availableMavenProfiles({ project, customProfiles }), + [customProfiles, project], + ); + const isRunning = taskStatus === "running" || taskStatus === "stopping"; + + useEffect(() => { + void ensureMavenProcessListeners(); + }, []); + + useEffect(() => { + setReloadError(null); + }, [root, workspaceId]); + + useEffect(() => { + if (!project) return; + const initial = new Set([`project:${project.relativePath}`]); + if (profiles.length > 0) initial.add("profiles"); + setExpanded(initial); + setSelectedModule(null); + setSelectedPhase("compile"); + }, [project?.relativePath]); + + const toggleExpanded = (id: string) => { + setExpanded((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const runPhase = (phase: MavenLifecyclePhase, module: MavenModule | null) => { + setSelectedModule(module?.relativePath ?? null); + setSelectedPhase(phase); + const target = module?.artifactId ?? project?.artifactId ?? t("maven.project"); + void actions.runGoals([phase], module?.relativePath ?? null, `${phase} · ${target}`); + }; + + const runSelected = () => { + const module = findMavenModule(project?.modules ?? [], selectedModule); + runPhase(selectedPhase, module); + }; + + const runCustomGoal = () => { + const goals = customGoal.trim().split(/\s+/).filter(Boolean); + if (goals.length === 0) return; + const module = findMavenModule(project?.modules ?? [], selectedModule); + const target = module?.artifactId ?? project?.artifactId ?? t("maven.project"); + setGoalDialogOpen(false); + void actions.runGoals(goals, module?.relativePath ?? null, `${customGoal.trim()} · ${target}`); + }; + + const reloadJava = async () => { + if (!root) return; + const scope = { workspaceId, root }; + setReloadError(null); + try { + await reloadJavaForMavenWorkspace(scope); + } catch (error) { + if ( + workspaceRuntimeRegistry.getActiveWorkspaceId() === workspaceId && + workspaceScopeMatchesRoot(scope, useMavenStore.getStore(workspaceId).getState().root) + ) { + setReloadError(error instanceof Error ? error.message : t("maven.reloadFailed")); + } + } + }; + + const reloadProjects = async () => { + if (!root) return; + const scope = { workspaceId, root }; + setReloadError(null); + try { + await reloadMavenWorkspaceProjects(scope); + } catch (error) { + if ( + workspaceRuntimeRegistry.getActiveWorkspaceId() === workspaceId && + workspaceScopeMatchesRoot(scope, useMavenStore.getStore(workspaceId).getState().root) + ) { + setReloadError(error instanceof Error ? error.message : t("maven.reloadFailed")); + } + } + }; + + const openIssue = (path: string, line: number, column?: number | null) => { + if (!root || !path) return; + const target = /^(?:[A-Za-z]:[\\/]|[\\/]{2}|\/)/.test(path) ? path : joinPath(root, path); + void handleFileSelect(target, false, line, column ?? undefined, undefined, false); + }; + + const renderLifecycle = (ownerId: string, module: MavenModule | null) => { + const id = `${ownerId}:lifecycle`; + return ( + } + expanded={expanded.has(id)} + onToggle={toggleExpanded} + > + {MAVEN_LIFECYCLE_PHASES.map((phase) => { + const selected = + selectedModule === (module?.relativePath ?? null) && selectedPhase === phase; + return ( + + ); + })} + + ); + }; + + const renderModule = (module: MavenModule): ReactNode => { + const id = `module:${module.relativePath}`; + return ( + setSelectedModule(module.relativePath)} + > + {renderLifecycle(id, module)} + {module.modules.map(renderModule)} + + ); + }; + + return ( +
+
+ +
+ {t("maven.title")} + {project ? ` · ${project.artifactId}` : ""} +
+ {projectStatus === "loading" ? : null} + {runningTitle ? ( + + {runningTitle} + + ) : null} + {taskStatus === "cancelled" ? ( + {t("maven.cancelled")} + ) : null} + {!isRunning && lastExitCode != null ? ( + + {lastExitCode === 0 ? t("run.succeeded") : t("run.failed")} + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {reloadRequired || configurationSaveError || reloadError || taskError ? ( +
+ + + {configurationSaveError ?? reloadError ?? taskError ?? t("maven.configurationChanged")} + + {reloadRequired || reloadError ? ( + + ) : null} +
+ ) : null} + + {projectStatus === "failed" ? ( +
+ +
{t("maven.loadFailed")}
+
{projectError}
+ +
+ ) : project ? ( +
+ +
+ {profiles.length > 0 ? ( + } + expanded={expanded.has("profiles")} + onToggle={toggleExpanded} + > +
+ + + + + + +
+ {profiles.map((profile) => ( + + ))} +
+ ) : null} + setSelectedModule(null)} + > + {renderLifecycle(`project:${project.relativePath}`, null)} + {project.modules.map(renderModule)} + +
+
+
+
+ {t("maven.buildOutput")} + {issues.length > 0 ? {issues.length} : null} +
+ {issues.length > 0 ? ( + +
+ {issues.map((issue, index) => ( + + ))} +
+
+ ) : null} + +
+ +
+
+
+
+ ) : ( +
+ {projectStatus === "loading" ? t("maven.scanning") : t("maven.notDetected")} +
+ )} + + {goalDialogOpen ? ( + setGoalDialogOpen(false)} + footer={ + <> + + + + } + > + setCustomGoal(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") runCustomGoal(); + }} + /> + + ) : null} + {profileDialogOpen ? ( + setProfileDialogOpen(false)} + footer={ + <> + + + + } + > + setCustomProfile(event.target.value)} + /> + + ) : null} + {settingsDialogOpen ? ( + setSettingsDialogOpen(false)} + onSave={actions.updateLocalConfiguration} + /> + ) : null} +
+ ); +} + +function findMavenModule( + modules: readonly MavenModule[], + relativePath: string | null, +): MavenModule | null { + if (!relativePath) return null; + for (const module of modules) { + if (module.relativePath === relativePath) return module; + const nested = findMavenModule(module.modules, relativePath); + if (nested) return nested; + } + return null; +} diff --git a/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts new file mode 100644 index 000000000..252e3c3b1 --- /dev/null +++ b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts @@ -0,0 +1,36 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { mavenStoreForSession, releaseMavenSessionWorkspace } from "../stores/maven.store"; + +interface RunOutputEvent { + sessionId: string; + chunk: string; +} + +interface RunExitEvent { + sessionId: string; + exitCode: number; +} + +let outputUnlisten: UnlistenFn | undefined; +let exitUnlisten: UnlistenFn | undefined; + +export async function ensureMavenProcessListeners(): Promise { + if (!outputUnlisten) { + outputUnlisten = await listen("run-output", (event) => { + if (!event.payload.sessionId.startsWith("maven:")) return; + mavenStoreForSession(event.payload.sessionId) + .getState() + .actions.appendOutput(event.payload.sessionId, event.payload.chunk); + }); + } + if (!exitUnlisten) { + exitUnlisten = await listen("run-exit", (event) => { + const sessionId = event.payload.sessionId; + if (!sessionId.startsWith("maven:")) return; + mavenStoreForSession(sessionId) + .getState() + .actions.finishProcess(sessionId, event.payload.exitCode); + releaseMavenSessionWorkspace(sessionId); + }); + } +} diff --git a/windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts b/windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts new file mode 100644 index 000000000..37b15057d --- /dev/null +++ b/windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts @@ -0,0 +1,156 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import type { MavenProject } from "../types/maven.types"; +import { + reloadJavaForMavenWorkspace, + reloadMavenWorkspaceProjects, +} from "./reload-maven-workspace"; + +afterEach(() => workspaceRuntimeRegistry.resetForTests()); + +type Deferred = { + promise: Promise; + resolve(value: T): void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function mavenProject(artifactId: string): MavenProject { + return { + relativePath: ".", + artifactId, + packaging: "jar", + modules: [], + profiles: [], + hasWrapper: true, + }; +} + +test("finishes workspace A reload without reading or mutating active workspace B", async () => { + const scanStarted = deferred(); + const finishScan = deferred(); + const acknowledgeA = mock(() => undefined); + const getFilesA = mock(async () => [ + { name: "Main.java", path: "D:/work-a/src/Main.java", isDir: false }, + ]); + const getFilesB = mock(async () => [ + { name: "Wrong.java", path: "D:/work-b/src/Wrong.java", isDir: false }, + ]); + const mavenA = { + root: "D:/work-a" as string | null, + visiblePaths: ["pom.xml"], + project: mavenProject("old-a") as MavenProject | null, + activeSessionId: null as string | null, + output: "A output", + actions: { + loadProject: mock(async () => { + scanStarted.resolve(undefined); + await finishScan.promise; + mavenA.project = mavenProject("new-a"); + }), + acknowledgeReload: acknowledgeA, + }, + }; + const mavenB = { + root: "D:/work-b" as string | null, + visiblePaths: ["pom.xml"], + project: mavenProject("project-b") as MavenProject | null, + activeSessionId: "session-b", + output: "B output", + actions: { + loadProject: mock(async () => undefined), + acknowledgeReload: mock(() => undefined), + }, + }; + const fileSystemA = { rootFolderPath: "D:/work-a", getAllProjectFiles: getFilesA }; + const fileSystemB = { rootFolderPath: "D:/work-b", getAllProjectFiles: getFilesB }; + const stop = mock(async () => undefined); + const prewarm = mock(async () => ({ kind: "ready" })); + const mavenStates = new Map([ + ["workspace-a", mavenA], + ["workspace-b", mavenB], + ]); + const fileSystemStates = new Map([ + ["workspace-a", fileSystemA], + ["workspace-b", fileSystemB], + ]); + const scopeA = { workspaceId: "workspace-a", root: "D:/work-a" }; + workspaceRuntimeRegistry.ensureWorkspace({ id: "workspace-a", name: "A" }, "ready"); + const getMavenState = mock((workspaceId: string) => mavenStates.get(workspaceId)!); + const getFileSystemState = mock((workspaceId: string) => fileSystemStates.get(workspaceId)!); + const reload = reloadMavenWorkspaceProjects(scopeA, { + hasWorkspace: (workspaceId) => workspaceRuntimeRegistry.hasWorkspace(workspaceId), + getMavenState, + getFileSystemState, + getJavaOwner: () => ({ stop, prewarm }), + }); + + try { + await scanStarted.promise; + workspaceRuntimeRegistry.activateWorkspace({ id: "workspace-b", name: "B" }, "ready"); + const workspaceBBefore = { + project: mavenB.project, + activeSessionId: mavenB.activeSessionId, + output: mavenB.output, + rootFolderPath: fileSystemB.rootFolderPath, + }; + finishScan.resolve(undefined); + + expect(await reload).toBe("completed"); + expect(workspaceRuntimeRegistry.getActiveWorkspaceId()).toBe("workspace-b"); + expect({ + project: mavenB.project, + activeSessionId: mavenB.activeSessionId, + output: mavenB.output, + rootFolderPath: fileSystemB.rootFolderPath, + }).toEqual(workspaceBBefore); + expect(mavenB.actions.loadProject).not.toHaveBeenCalled(); + expect(mavenB.actions.acknowledgeReload).not.toHaveBeenCalled(); + expect(getFilesB).not.toHaveBeenCalled(); + expect(getMavenState.mock.calls.every(([workspaceId]) => workspaceId === "workspace-a")).toBe( + true, + ); + expect( + getFileSystemState.mock.calls.every(([workspaceId]) => workspaceId === "workspace-a"), + ).toBe(true); + expect(stop).toHaveBeenCalledWith(scopeA); + expect(prewarm).toHaveBeenCalledWith(scopeA, "D:/work-a/src/Main.java"); + expect(acknowledgeA).toHaveBeenCalledTimes(1); + } finally { + finishScan.resolve(undefined); + await reload; + } +}); + +test("does not recreate stores after the workspace is closed", async () => { + const getMavenState = mock(() => { + throw new Error("Maven store must not be recreated"); + }); + const getFileSystemState = mock(() => { + throw new Error("File-system store must not be recreated"); + }); + const getJavaOwner = mock(() => { + throw new Error("Java owner must not be resolved"); + }); + + const outcome = await reloadJavaForMavenWorkspace( + { workspaceId: "closed-workspace", root: "D:/closed" }, + { + hasWorkspace: () => false, + getMavenState, + getFileSystemState, + getJavaOwner, + }, + ); + + expect(outcome).toBe("stale"); + expect(getMavenState).not.toHaveBeenCalled(); + expect(getFileSystemState).not.toHaveBeenCalled(); + expect(getJavaOwner).not.toHaveBeenCalled(); +}); diff --git a/windows/tauri/src/features/maven/services/reload-maven-workspace.ts b/windows/tauri/src/features/maven/services/reload-maven-workspace.ts new file mode 100644 index 000000000..1c3a8d672 --- /dev/null +++ b/windows/tauri/src/features/maven/services/reload-maven-workspace.ts @@ -0,0 +1,102 @@ +import { getJavaWorkspaceLanguageServerOwner } from "@/features/editor/lsp/java-workspace-language-server"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import type { FileEntry } from "@/features/file-system/types/app.types"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { + workspaceScopeMatchesRoot, + type WorkspaceLaunchScope, +} from "@/features/workspace/types/workspace-launch-scope"; +import type { MavenProject } from "../types/maven.types"; +import { useMavenStore } from "../stores/maven.store"; + +interface MavenReloadState { + root: string | null; + visiblePaths: string[]; + project: MavenProject | null; + actions: { + loadProject(root: string, visiblePaths?: string[]): Promise; + acknowledgeReload(): void; + }; +} + +interface FileSystemReloadState { + rootFolderPath?: string; + getAllProjectFiles(): Promise; +} + +interface JavaWorkspaceReloadOwner { + stop(scope: WorkspaceLaunchScope): Promise; + prewarm(scope: WorkspaceLaunchScope, representativeJavaFile: string): Promise; +} + +export interface MavenWorkspaceReloadDependencies { + hasWorkspace(workspaceId: string): boolean; + getMavenState(workspaceId: string): MavenReloadState; + getFileSystemState(workspaceId: string): FileSystemReloadState; + getJavaOwner(): JavaWorkspaceReloadOwner; +} + +export type MavenWorkspaceReloadOutcome = "completed" | "noProject" | "stale"; + +const defaultDependencies: MavenWorkspaceReloadDependencies = { + hasWorkspace: (workspaceId) => workspaceRuntimeRegistry.hasWorkspace(workspaceId), + getMavenState: (workspaceId) => useMavenStore.getStore(workspaceId).getState(), + getFileSystemState: (workspaceId) => useFileSystemStore.getStore(workspaceId).getState(), + getJavaOwner: getJavaWorkspaceLanguageServerOwner, +}; + +function scopedStates( + scope: WorkspaceLaunchScope, + dependencies: MavenWorkspaceReloadDependencies, +): { maven: MavenReloadState; fileSystem: FileSystemReloadState } | null { + if (!dependencies.hasWorkspace(scope.workspaceId)) return null; + const maven = dependencies.getMavenState(scope.workspaceId); + const fileSystem = dependencies.getFileSystemState(scope.workspaceId); + return workspaceScopeMatchesRoot(scope, maven.root) && + workspaceScopeMatchesRoot(scope, fileSystem.rootFolderPath) + ? { maven, fileSystem } + : null; +} + +export async function reloadJavaForMavenWorkspace( + scope: WorkspaceLaunchScope, + dependencies: MavenWorkspaceReloadDependencies = defaultDependencies, +): Promise { + let states = scopedStates(scope, dependencies); + if (!states) return "stale"; + + const files = await states.fileSystem.getAllProjectFiles(); + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + + const javaFile = files + .filter((entry) => !entry.isDir && entry.path.toLowerCase().endsWith(".java")) + .map((entry) => entry.path) + .sort()[0]; + const owner = dependencies.getJavaOwner(); + await owner.stop(scope); + + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + if (javaFile) await owner.prewarm(scope, javaFile); + + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + states.maven.actions.acknowledgeReload(); + return "completed"; +} + +export async function reloadMavenWorkspaceProjects( + scope: WorkspaceLaunchScope, + dependencies: MavenWorkspaceReloadDependencies = defaultDependencies, +): Promise { + let states = scopedStates(scope, dependencies); + if (!states) return "stale"; + + await states.maven.actions.loadProject(scope.root, [...states.maven.visiblePaths]); + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + if (!states.maven.project) return "noProject"; + + return reloadJavaForMavenWorkspace(scope, dependencies); +} diff --git a/windows/tauri/src/features/maven/stores/maven.store.test.ts b/windows/tauri/src/features/maven/stores/maven.store.test.ts new file mode 100644 index 000000000..d56823c94 --- /dev/null +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -0,0 +1,404 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { + MavenDiagnostic, + MavenLaunchPlan, + MavenProject, + MavenStoredConfiguration, +} from "../types/maven.types"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { + createMavenStore, + mavenLaunchContext, + mavenLaunchContextForWorkspace, + useMavenStore, + type MavenStoreDependencies, +} from "./maven.store"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +const project: MavenProject = { + relativePath: "reactor", + groupId: "dev.lithe", + artifactId: "demo", + version: "1.0.0", + packaging: "pom", + hasWrapper: true, + profiles: [ + { id: "default", isActiveByDefault: true }, + { id: "dev", isActiveByDefault: false }, + ], + modules: [], +}; + +const launchPlan: MavenLaunchPlan = { + version: 1, + executable: { toolchain: "project-maven" }, + arguments: ["-B", "compile"], + workingDirectory: "reactor", + configurationFingerprint: "fixture-fingerprint", +}; + +const scanMavenProject = mock(async (_root: string, _paths?: string[]) => project); +const createMavenLaunchPlan = mock(async () => launchPlan); +const parseMavenDiagnostics = mock( + async (_root: string, _output: string): Promise => [], +); +const loadMavenConfiguration = mock(async () => ({})); +const writeMavenConfiguration = mock( + async ( + _root: string, + _reactorPath: string, + _configuration: MavenStoredConfiguration, + ): Promise => undefined, +); +const resolveMavenLaunch = mock(async () => ({ + executable: "D:/Tools/apache-maven/bin/mvn.cmd", + workingDirectory: "D:/work/reactor", + environment: {}, +})); +const saveWorkspaceBeforeLaunch = mock(async (_workspaceId: string): Promise => undefined); +const startMavenProcess = mock(async () => undefined); +const stopMavenProcess = mock(async () => undefined); + +const dependencies = { + createMavenLaunchPlan, + loadMavenConfiguration, + parseMavenDiagnostics, + resolveMavenLaunch, + saveWorkspaceBeforeLaunch, + scanMavenProject, + startMavenProcess, + stopMavenProcess, + writeMavenConfiguration, +} satisfies MavenStoreDependencies; + +beforeEach(() => { + scanMavenProject.mockReset(); + scanMavenProject.mockResolvedValue(project); + loadMavenConfiguration.mockReset(); + loadMavenConfiguration.mockResolvedValue({}); + writeMavenConfiguration.mockClear(); + createMavenLaunchPlan.mockReset(); + createMavenLaunchPlan.mockResolvedValue(launchPlan); + parseMavenDiagnostics.mockReset(); + parseMavenDiagnostics.mockResolvedValue([]); + resolveMavenLaunch.mockClear(); + saveWorkspaceBeforeLaunch.mockReset(); + saveWorkspaceBeforeLaunch.mockResolvedValue(undefined); + startMavenProcess.mockClear(); + stopMavenProcess.mockClear(); +}); + +afterEach(() => workspaceRuntimeRegistry.resetForTests()); + +describe("Maven workspace state", () => { + test("resolves workspace A without mutating active workspace B", async () => { + const workspaceA = useMavenStore.getStore("workspace-a"); + const workspaceB = useMavenStore.getStore("workspace-b"); + const loadWorkspaceB = mock(async () => undefined); + workspaceA.setState({ + root: "D:/work-a", + projectStatus: "ready", + project: { ...project, artifactId: "project-a" }, + }); + workspaceB.setState((state) => ({ + root: "D:/work-b", + projectStatus: "ready", + project: { ...project, artifactId: "project-b" }, + activeSessionId: "session-b", + output: "B output", + actions: { ...state.actions, loadProject: loadWorkspaceB }, + })); + workspaceRuntimeRegistry.activateWorkspace({ id: "workspace-b", name: "B" }, "ready"); + const workspaceBBefore = { + root: workspaceB.getState().root, + project: workspaceB.getState().project, + activeSessionId: workspaceB.getState().activeSessionId, + output: workspaceB.getState().output, + }; + + const context = await mavenLaunchContextForWorkspace( + "D:/work-a", + ["src/Main.java"], + "workspace-a", + ); + + expect(context?.reactorPath).toBe("reactor"); + expect(workspaceRuntimeRegistry.getActiveWorkspaceId()).toBe("workspace-b"); + expect({ + root: workspaceB.getState().root, + project: workspaceB.getState().project, + activeSessionId: workspaceB.getState().activeSessionId, + output: workspaceB.getState().output, + }).toEqual(workspaceBBefore); + expect(loadWorkspaceB).not.toHaveBeenCalled(); + }); + + test("restores portable selections and machine-local paths into one launch context", async () => { + loadMavenConfiguration.mockResolvedValue({ + portable: { + version: 1, + selectedProfiles: ["qa", "dev"], + customProfiles: ["qa"], + skipTests: true, + }, + local: { + version: 1, + settingsPath: "C:/Users/example/.m2/settings.xml", + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }, + }); + const store = createMavenStore("workspace", dependencies); + + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + expect(mavenLaunchContext(store.getState())).toEqual({ + version: 1, + reactorPath: "reactor", + profiles: ["dev", "qa"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }); + }); + + test("persists portable and local values in separate documents", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + const writeStarted = deferred(); + writeMavenConfiguration.mockImplementationOnce(async () => { + writeStarted.resolve(undefined); + }); + + store.getState().actions.updateLocalConfiguration({ + settingsPath: "C:/Users/example/.m2/settings.xml", + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }); + await writeStarted.promise; + + const calls = writeMavenConfiguration.mock.calls; + const configuration = calls[calls.length - 1]?.[2]; + expect(configuration?.portable).toEqual({ + version: 1, + selectedProfiles: ["default"], + customProfiles: [], + skipTests: false, + }); + expect(configuration?.portable).not.toHaveProperty("settingsPath"); + expect(configuration?.local).toEqual({ + version: 1, + settingsPath: "C:/Users/example/.m2/settings.xml", + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }); + }); + + test("serializes rapid configuration writes so the newest value wins", async () => { + const firstStarted = deferred(); + const firstWrite = deferred(); + const secondStarted = deferred(); + writeMavenConfiguration + .mockImplementationOnce(async () => { + firstStarted.resolve(undefined); + await firstWrite.promise; + }) + .mockImplementationOnce(async () => { + secondStarted.resolve(undefined); + }); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + store.getState().actions.setSkipTests(true); + store.getState().actions.setSkipTests(false); + try { + await firstStarted.promise; + expect(writeMavenConfiguration).toHaveBeenCalledTimes(1); + + firstWrite.resolve(undefined); + await secondStarted.promise; + + expect(writeMavenConfiguration).toHaveBeenCalledTimes(2); + expect(writeMavenConfiguration.mock.calls[1]?.[2].portable?.skipTests).toBe(false); + } finally { + firstWrite.resolve(undefined); + } + }); + + test("waits for a pending configuration write before reloading", async () => { + const firstStarted = deferred(); + const firstWrite = deferred(); + const reloadScanStarted = deferred(); + const reloadConfigurationStarted = deferred(); + writeMavenConfiguration.mockImplementationOnce(async () => { + firstStarted.resolve(undefined); + await firstWrite.promise; + }); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + scanMavenProject.mockImplementationOnce(async () => { + reloadScanStarted.resolve(undefined); + return project; + }); + loadMavenConfiguration.mockImplementationOnce(async () => { + reloadConfigurationStarted.resolve(undefined); + return {}; + }); + let reload: Promise | undefined; + + try { + store.getState().actions.setSkipTests(true); + await firstStarted.promise; + reload = store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + await reloadScanStarted.promise; + + expect(loadMavenConfiguration).toHaveBeenCalledTimes(1); + firstWrite.resolve(undefined); + await reloadConfigurationStarted.promise; + await reload; + + expect(loadMavenConfiguration).toHaveBeenCalledTimes(2); + } finally { + firstWrite.resolve(undefined); + await reload; + } + }); + + test("does not let an older scan replace a newer workspace", async () => { + const firstScan = deferred(); + const secondScan = deferred(); + scanMavenProject + .mockImplementationOnce(() => firstScan.promise) + .mockImplementationOnce(() => secondScan.promise); + const store = createMavenStore("workspace", dependencies); + + const first = store.getState().actions.loadProject("D:/first", ["pom.xml"]); + const second = store.getState().actions.loadProject("D:/second", ["pom.xml"]); + secondScan.resolve({ ...project, artifactId: "second" }); + await second; + firstScan.resolve({ ...project, artifactId: "first" }); + await first; + + expect(store.getState().root).toBe("D:/second"); + expect(store.getState().project?.artifactId).toBe("second"); + }); + + test("cancels a pending launch without starting a stale process", async () => { + const pendingPlan = deferred(); + createMavenLaunchPlan.mockImplementationOnce(() => pendingPlan.promise); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + const run = store.getState().actions.runGoals(["compile"], null, "compile"); + await Promise.resolve(); + await store.getState().actions.stop(); + pendingPlan.resolve(launchPlan); + await run; + + expect(startMavenProcess).not.toHaveBeenCalled(); + expect(store.getState().taskStatus).toBe("cancelled"); + expect(store.getState().activeSessionId).toBeNull(); + expect(store.getState().output).toBe("Maven task cancelled.\n"); + + store.getState().actions.clearOutput(); + + expect(store.getState().taskStatus).toBe("idle"); + expect(store.getState().output).toBe(""); + }); + + test("waits for workspace files to save before creating a launch plan", async () => { + const pendingSave = deferred(); + saveWorkspaceBeforeLaunch.mockImplementationOnce(() => pendingSave.promise); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + const run = store.getState().actions.runGoals(["compile"], null, "compile"); + try { + await Promise.resolve(); + expect(saveWorkspaceBeforeLaunch).toHaveBeenCalledWith("workspace"); + expect(createMavenLaunchPlan).not.toHaveBeenCalled(); + } finally { + pendingSave.resolve(undefined); + await run; + } + + expect(createMavenLaunchPlan).toHaveBeenCalledTimes(1); + expect(startMavenProcess).toHaveBeenCalledTimes(1); + }); + + test("does not launch Maven when workspace files cannot be saved", async () => { + saveWorkspaceBeforeLaunch.mockRejectedValueOnce( + new Error("Unable to start because modified files could not be saved: App.java."), + ); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + await store.getState().actions.runGoals(["compile"], null, "compile"); + + expect(createMavenLaunchPlan).not.toHaveBeenCalled(); + expect(startMavenProcess).not.toHaveBeenCalled(); + expect(store.getState().taskStatus).toBe("failed"); + expect(store.getState().taskError).toContain("App.java"); + }); + + test("keeps cancellation when process exit arrives before stop completes", async () => { + const stopFinished = deferred(); + stopMavenProcess.mockImplementationOnce(() => stopFinished.promise); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + await store.getState().actions.runGoals(["compile"], null, "compile"); + const sessionId = store.getState().activeSessionId; + expect(sessionId).not.toBeNull(); + + const stop = store.getState().actions.stop(); + try { + expect(store.getState().taskStatus).toBe("stopping"); + store.getState().actions.finishProcess(sessionId!, 143); + expect(store.getState().taskStatus).toBe("cancelled"); + + stopFinished.resolve(undefined); + await stop; + + expect(store.getState().output.match(/Maven task cancelled\./g)).toHaveLength(1); + expect(store.getState().lastExitCode).toBeNull(); + } finally { + stopFinished.resolve(undefined); + await stop; + } + }); + + test("does not let diagnostics from a completed task replace a newer run", async () => { + const pendingDiagnostics = + deferred>(); + parseMavenDiagnostics.mockImplementationOnce(() => pendingDiagnostics.promise); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + await store.getState().actions.runGoals(["compile"], null, "compile"); + const completedSession = store.getState().activeSessionId; + expect(completedSession).not.toBeNull(); + + store.getState().actions.finishProcess(completedSession!, 1); + await store.getState().actions.runGoals(["test"], null, "test"); + pendingDiagnostics.resolve([ + { path: "src/Old.java", line: 3, severity: "error", message: "old task" }, + ]); + await pendingDiagnostics.promise; + await Promise.resolve(); + + expect(store.getState().issues).toEqual([]); + expect(store.getState().runningTitle).toBe("test"); + }); +}); diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts new file mode 100644 index 000000000..1f4614012 --- /dev/null +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -0,0 +1,627 @@ +import { createStore } from "zustand/vanilla"; +import { saveWorkspaceBeforeLaunch } from "@/features/editor/services/save-workspace-before-launch"; +import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { + createMavenLaunchPlan, + parseMavenDiagnostics, + scanMavenProject, +} from "../api/maven-core-api"; +import { + loadMavenConfiguration, + resolveMavenLaunch, + startMavenProcess, + stopMavenProcess, + writeMavenConfiguration, +} from "../api/maven-host-api"; +import type { + MavenDiagnostic, + MavenLaunchContext, + MavenLocalConfiguration, + MavenPortableConfiguration, + MavenProfile, + MavenProject, + MavenProjectStatus, + MavenSettings, + MavenStoredConfiguration, + MavenTaskStatus, +} from "../types/maven.types"; + +const MAXIMUM_OUTPUT_CHARACTERS = 500_000; +const mavenSessionWorkspaces = new Map(); + +interface MavenProjectLoad { + task: Promise; + hasVisiblePaths: boolean; +} + +const mavenProjectLoads = new Map(); + +export interface MavenStoreDependencies { + createMavenLaunchPlan: typeof createMavenLaunchPlan; + loadMavenConfiguration: typeof loadMavenConfiguration; + parseMavenDiagnostics: typeof parseMavenDiagnostics; + resolveMavenLaunch: typeof resolveMavenLaunch; + saveWorkspaceBeforeLaunch: typeof saveWorkspaceBeforeLaunch; + scanMavenProject: typeof scanMavenProject; + startMavenProcess: typeof startMavenProcess; + stopMavenProcess: typeof stopMavenProcess; + writeMavenConfiguration: typeof writeMavenConfiguration; +} + +const defaultMavenStoreDependencies: MavenStoreDependencies = { + createMavenLaunchPlan, + loadMavenConfiguration, + parseMavenDiagnostics, + resolveMavenLaunch, + saveWorkspaceBeforeLaunch, + scanMavenProject, + startMavenProcess, + stopMavenProcess, + writeMavenConfiguration, +}; + +export interface MavenState { + root: string | null; + visiblePaths: string[]; + projectStatus: MavenProjectStatus; + projectError: string | null; + project: MavenProject | null; + selectedProfiles: string[]; + customProfiles: string[]; + skipTests: boolean; + settingsPath: string; + mavenExecutablePath: string; + javaHomePath: string; + configurationSaveError: string | null; + reloadRequired: boolean; + taskStatus: MavenTaskStatus; + taskError: string | null; + activeSessionId: string | null; + runningTitle: string | null; + output: string; + issues: MavenDiagnostic[]; + lastExitCode: number | null; + actions: { + loadProject: (root: string, visiblePaths?: string[]) => Promise; + setSelectedProfiles: (profiles: string[]) => void; + addCustomProfile: (profile: string) => boolean; + restoreDefaultProfiles: () => void; + setSkipTests: (enabled: boolean) => void; + updateLocalConfiguration: (settings: MavenSettings) => void; + acknowledgeReload: () => void; + runGoals: (goals: string[], module: string | null, title: string) => Promise; + stop: () => Promise; + clearOutput: () => void; + appendOutput: (sessionId: string, chunk: string) => void; + finishProcess: (sessionId: string, exitCode: number) => void; + }; +} + +function normalizedProfile(value: string): string | null { + const profile = value.trim(); + const hasControlCharacter = [...profile].some((character) => { + const code = character.charCodeAt(0); + return code <= 0x1f || code === 0x7f; + }); + if (!profile || profile.includes(",") || hasControlCharacter) return null; + return profile; +} + +function normalizedProfiles(values: readonly string[]): string[] { + return [ + ...new Set(values.map(normalizedProfile).filter((value): value is string => !!value)), + ].sort(); +} + +function normalizedPath(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +export function availableMavenProfiles(state: Pick) { + const profiles = new Map(); + for (const profile of state.project?.profiles ?? []) profiles.set(profile.id, profile); + for (const id of state.customProfiles) { + if (!profiles.has(id)) profiles.set(id, { id, isActiveByDefault: false }); + } + return [...profiles.values()]; +} + +export function mavenLaunchContext(state: MavenState): MavenLaunchContext | null { + if (!state.project) return null; + return { + version: 1, + reactorPath: state.project.relativePath, + profiles: normalizedProfiles(state.selectedProfiles), + settingsPath: state.settingsPath || null, + skipTests: state.skipTests, + mavenExecutablePath: state.mavenExecutablePath || null, + javaHomePath: state.javaHomePath || null, + }; +} + +function storedConfiguration(state: MavenState): MavenStoredConfiguration { + const portable: MavenPortableConfiguration = { + version: 1, + selectedProfiles: normalizedProfiles(state.selectedProfiles), + customProfiles: normalizedProfiles(state.customProfiles), + skipTests: state.skipTests, + }; + const local: MavenLocalConfiguration = { + version: 1, + settingsPath: state.settingsPath || null, + mavenExecutablePath: state.mavenExecutablePath || null, + javaHomePath: state.javaHomePath || null, + }; + return { portable, local }; +} + +function displayArguments(arguments_: readonly string[]): string { + return arguments_ + .map((argument, index) => + index > 0 && arguments_[index - 1] === "-s" ? "" : argument, + ) + .join(" "); +} + +function trimOutput(output: string): string { + const normalized = output.replace(/\r/g, ""); + return normalized.length > MAXIMUM_OUTPUT_CHARACTERS + ? normalized.slice(normalized.length - MAXIMUM_OUTPUT_CHARACTERS) + : normalized; +} + +function cancelledOutput(output: string): string { + const separator = output && !output.endsWith("\n") ? "\n" : ""; + return trimOutput(`${output}${separator}Maven task cancelled.\n`); +} + +export const createMavenStore = ( + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), + dependencies: MavenStoreDependencies = defaultMavenStoreDependencies, +) => { + let projectLoadRevision = 0; + let configurationRevision = 0; + let launchRevision = 0; + let diagnosticsRevision = 0; + let configurationWriteTask = Promise.resolve(); + + return createStore()((set, get) => { + const persistConfiguration = () => { + const state = get(); + if (!state.root || !state.project) return; + const revision = ++configurationRevision; + const configuration = storedConfiguration(state); + const root = state.root; + const reactorPath = state.project.relativePath; + configurationWriteTask = configurationWriteTask + .catch(() => undefined) + .then(() => dependencies.writeMavenConfiguration(root, reactorPath, configuration)); + void configurationWriteTask + .then(() => { + if (configurationRevision === revision) set({ configurationSaveError: null }); + }) + .catch((error) => { + if (configurationRevision !== revision) return; + set({ + configurationSaveError: + error instanceof Error ? error.message : "Unable to save Maven configuration.", + }); + }); + }; + + const configurationDidChange = () => { + set({ reloadRequired: true, configurationSaveError: null }); + persistConfiguration(); + }; + + return { + root: null, + visiblePaths: [], + projectStatus: "idle", + projectError: null, + project: null, + selectedProfiles: [], + customProfiles: [], + skipTests: false, + settingsPath: "", + mavenExecutablePath: "", + javaHomePath: "", + configurationSaveError: null, + reloadRequired: false, + taskStatus: "idle", + taskError: null, + activeSessionId: null, + runningTitle: null, + output: "", + issues: [], + lastExitCode: null, + actions: { + loadProject: async (root, visiblePaths = []) => { + const revision = ++projectLoadRevision; + configurationRevision += 1; + const previous = get(); + if (previous.root && previous.root !== root && previous.activeSessionId) { + launchRevision += 1; + diagnosticsRevision += 1; + await dependencies.stopMavenProcess(previous.activeSessionId).catch(() => undefined); + releaseMavenSessionWorkspace(previous.activeSessionId); + } + set({ + root, + visiblePaths: [...visiblePaths], + projectStatus: "loading", + projectError: null, + configurationSaveError: null, + ...(previous.root && previous.root !== root + ? { + project: null, + taskStatus: "idle" as const, + taskError: null, + activeSessionId: null, + runningTitle: null, + output: "", + issues: [], + lastExitCode: null, + } + : {}), + }); + try { + const project = await dependencies.scanMavenProject(root, visiblePaths); + if (projectLoadRevision !== revision || get().root !== root) return; + if (!project) { + set({ + projectStatus: "ready", + project: null, + selectedProfiles: [], + customProfiles: [], + skipTests: false, + settingsPath: "", + mavenExecutablePath: "", + javaHomePath: "", + reloadRequired: false, + }); + return; + } + await configurationWriteTask.catch(() => undefined); + if (projectLoadRevision !== revision || get().root !== root) return; + const stored = await dependencies.loadMavenConfiguration(root, project.relativePath); + if (projectLoadRevision !== revision || get().root !== root) return; + const customProfiles = normalizedProfiles(stored.portable?.customProfiles ?? []); + const knownProfiles = new Set([ + ...project.profiles.map((profile) => profile.id), + ...customProfiles, + ]); + const defaultProfiles = project.profiles + .filter((profile) => profile.isActiveByDefault) + .map((profile) => profile.id); + const selectedProfiles = normalizedProfiles( + stored.portable?.selectedProfiles ?? defaultProfiles, + ).filter((profile) => knownProfiles.has(profile)); + set({ + projectStatus: "ready", + projectError: null, + project, + selectedProfiles, + customProfiles, + skipTests: stored.portable?.skipTests ?? false, + settingsPath: normalizedPath(stored.local?.settingsPath), + mavenExecutablePath: normalizedPath(stored.local?.mavenExecutablePath), + javaHomePath: normalizedPath(stored.local?.javaHomePath), + reloadRequired: false, + }); + } catch (error) { + if (projectLoadRevision !== revision || get().root !== root) return; + set({ + projectStatus: "failed", + projectError: + error instanceof Error ? error.message : "Unable to scan the Maven project.", + project: null, + selectedProfiles: [], + customProfiles: [], + skipTests: false, + settingsPath: "", + mavenExecutablePath: "", + javaHomePath: "", + }); + } + }, + + setSelectedProfiles: (profiles) => { + const knownProfiles = new Set(availableMavenProfiles(get()).map((profile) => profile.id)); + const selectedProfiles = normalizedProfiles(profiles).filter((profile) => + knownProfiles.has(profile), + ); + if (selectedProfiles.join("\0") === get().selectedProfiles.join("\0")) return; + set({ selectedProfiles }); + configurationDidChange(); + }, + + addCustomProfile: (value) => { + const profile = normalizedProfile(value); + if (!profile) return false; + const state = get(); + set({ + customProfiles: normalizedProfiles([...state.customProfiles, profile]), + selectedProfiles: normalizedProfiles([...state.selectedProfiles, profile]), + }); + configurationDidChange(); + return true; + }, + + restoreDefaultProfiles: () => { + const defaults = normalizedProfiles( + get() + .project?.profiles.filter((profile) => profile.isActiveByDefault) + .map((profile) => profile.id) ?? [], + ); + if (defaults.join("\0") === get().selectedProfiles.join("\0")) return; + set({ selectedProfiles: defaults }); + configurationDidChange(); + }, + + setSkipTests: (enabled) => { + if (get().skipTests === enabled) return; + set({ skipTests: enabled }); + configurationDidChange(); + }, + + updateLocalConfiguration: (settings) => { + const next = { + settingsPath: normalizedPath(settings.settingsPath), + mavenExecutablePath: normalizedPath(settings.mavenExecutablePath), + javaHomePath: normalizedPath(settings.javaHomePath), + }; + const state = get(); + if ( + next.settingsPath === state.settingsPath && + next.mavenExecutablePath === state.mavenExecutablePath && + next.javaHomePath === state.javaHomePath + ) { + return; + } + set(next); + configurationDidChange(); + }, + + acknowledgeReload: () => set({ reloadRequired: false }), + + runGoals: async (goals, module, title) => { + const state = get(); + const context = mavenLaunchContext(state); + if (!state.root || !context || goals.length === 0) return; + const revision = ++launchRevision; + diagnosticsRevision += 1; + const previousSessionId = state.activeSessionId; + if (previousSessionId) { + await dependencies.stopMavenProcess(previousSessionId).catch(() => undefined); + releaseMavenSessionWorkspace(previousSessionId); + } + const sessionId = `maven:${crypto.randomUUID()}`; + bindMavenSessionWorkspace(sessionId, workspaceId); + set({ + taskStatus: "running", + taskError: null, + activeSessionId: sessionId, + runningTitle: title, + output: "", + issues: [], + lastExitCode: null, + }); + try { + await dependencies.saveWorkspaceBeforeLaunch(workspaceId); + const plan = await dependencies.createMavenLaunchPlan( + state.root, + context, + goals, + module, + ); + const resolved = await dependencies.resolveMavenLaunch(state.root, context, plan); + if (launchRevision !== revision || get().activeSessionId !== sessionId) { + releaseMavenSessionWorkspace(sessionId); + return; + } + const executableName = resolved.executable.split(/[\\/]/).pop() ?? "mvn"; + set({ output: `$ ${executableName} ${displayArguments(plan.arguments)}\n\n` }); + await dependencies.startMavenProcess({ + sessionId, + executable: resolved.executable, + arguments: plan.arguments, + workingDirectory: resolved.workingDirectory, + environment: resolved.environment, + }); + if (launchRevision !== revision || get().activeSessionId !== sessionId) { + await dependencies.stopMavenProcess(sessionId).catch(() => undefined); + releaseMavenSessionWorkspace(sessionId); + } + } catch (error) { + if (launchRevision !== revision || get().activeSessionId !== sessionId) { + releaseMavenSessionWorkspace(sessionId); + return; + } + const message = + error instanceof Error ? error.message : "Unable to start the Maven task."; + set({ + taskStatus: "failed", + taskError: message, + activeSessionId: null, + runningTitle: null, + lastExitCode: 1, + output: trimOutput(`${get().output}${message}\n`), + issues: [{ path: "", line: 1, column: null, severity: "error", message }], + }); + releaseMavenSessionWorkspace(sessionId); + } + }, + + stop: async () => { + launchRevision += 1; + diagnosticsRevision += 1; + const sessionId = get().activeSessionId; + if (!sessionId) return; + set({ taskStatus: "stopping" }); + try { + await dependencies.stopMavenProcess(sessionId); + if (get().activeSessionId === sessionId) { + set({ + taskStatus: "cancelled", + taskError: null, + activeSessionId: null, + runningTitle: null, + lastExitCode: null, + output: cancelledOutput(get().output), + }); + } + } catch (error) { + if (get().activeSessionId === sessionId) { + set({ + taskStatus: "running", + taskError: + error instanceof Error ? error.message : "Unable to stop the Maven task.", + }); + } + } finally { + releaseMavenSessionWorkspace(sessionId); + } + }, + + clearOutput: () => { + diagnosticsRevision += 1; + set((state) => ({ + output: "", + issues: [], + lastExitCode: null, + taskStatus: state.taskStatus === "cancelled" ? "idle" : state.taskStatus, + })); + }, + + appendOutput: (sessionId, chunk) => { + if (get().activeSessionId !== sessionId) return; + set({ output: trimOutput(get().output + chunk) }); + }, + + finishProcess: (sessionId, exitCode) => { + const state = get(); + if (state.activeSessionId !== sessionId || !state.root) return; + const root = state.root; + const output = state.output; + const revision = ++diagnosticsRevision; + if (state.taskStatus === "stopping") { + set({ + taskStatus: "cancelled", + taskError: null, + activeSessionId: null, + runningTitle: null, + lastExitCode: null, + output: cancelledOutput(output), + }); + releaseMavenSessionWorkspace(sessionId); + return; + } + set({ + taskStatus: exitCode === 0 ? "idle" : "failed", + taskError: exitCode === 0 ? null : `Maven exited with code ${exitCode}.`, + activeSessionId: null, + runningTitle: null, + lastExitCode: exitCode, + }); + void dependencies + .parseMavenDiagnostics(root, output) + .then((issues) => { + if (diagnosticsRevision === revision && get().root === root) set({ issues }); + }) + .catch((error) => { + if (diagnosticsRevision !== revision || get().root !== root) return; + set({ + taskError: + error instanceof Error + ? error.message + : "Unable to parse Maven build diagnostics.", + }); + }); + }, + }, + }; + }); +}; + +export const useMavenStore = createWorkspaceScopedStore("maven", createMavenStore); + +function workspaceRootKey(root: string): string { + const normalized = root.replace(/\\/g, "/").replace(/\/$/, ""); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalized) ? normalized.toLowerCase() : normalized; +} + +function mavenProjectLoadKey(root: string, workspaceId: string): string { + return `${workspaceId}\0${workspaceRootKey(root)}`; +} + +export function loadMavenProjectForWorkspace( + root: string, + visiblePaths: string[] = [], + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), +): Promise { + const key = mavenProjectLoadKey(root, workspaceId); + const existing = mavenProjectLoads.get(key); + if (existing) { + if (visiblePaths.length === 0 || existing.hasVisiblePaths) return existing.task; + return existing.task.then(() => loadMavenProjectForWorkspace(root, visiblePaths, workspaceId)); + } + const task = useMavenStore + .getStore(workspaceId) + .getState() + .actions.loadProject(root, visiblePaths) + .finally(() => { + if (mavenProjectLoads.get(key)?.task === task) mavenProjectLoads.delete(key); + }); + mavenProjectLoads.set(key, { task, hasVisiblePaths: visiblePaths.length > 0 }); + return task; +} + +export async function mavenLaunchContextForWorkspace( + root: string, + visiblePaths: string[] = [], + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), +): Promise { + const key = mavenProjectLoadKey(root, workspaceId); + const pending = mavenProjectLoads.get(key); + if (pending) await pending.task; + let state = useMavenStore.getStore(workspaceId).getState(); + const rootKey = workspaceRootKey(root); + if ( + state.root === null || + workspaceRootKey(state.root) !== rootKey || + state.projectStatus === "idle" || + (state.project === null && visiblePaths.length > 0) + ) { + await loadMavenProjectForWorkspace(root, visiblePaths, workspaceId); + state = useMavenStore.getStore(workspaceId).getState(); + } + return state.root && workspaceRootKey(state.root) === rootKey ? mavenLaunchContext(state) : null; +} + +export function currentMavenLaunchContext( + root: string, + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), +): MavenLaunchContext | null { + const state = useMavenStore.getStore(workspaceId).getState(); + return state.root && workspaceRootKey(state.root) === workspaceRootKey(root) + ? mavenLaunchContext(state) + : null; +} + +export function bindMavenSessionWorkspace(sessionId: string, workspaceId?: string): void { + mavenSessionWorkspaces.set( + sessionId, + workspaceId ?? workspaceRuntimeRegistry.getActiveWorkspaceId(), + ); +} + +export function mavenStoreForSession(sessionId: string) { + const workspaceId = mavenSessionWorkspaces.get(sessionId); + return workspaceId ? useMavenStore.getStore(workspaceId) : useMavenStore; +} + +export function releaseMavenSessionWorkspace(sessionId: string): void { + mavenSessionWorkspaces.delete(sessionId); +} diff --git a/windows/tauri/src/features/maven/types/maven.types.test.ts b/windows/tauri/src/features/maven/types/maven.types.test.ts new file mode 100644 index 000000000..ad882018d --- /dev/null +++ b/windows/tauri/src/features/maven/types/maven.types.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from "bun:test"; +import platformContract from "../../../../../../shared/fixtures/maven/platform-contract-v1.json"; +import { MAVEN_LIFECYCLE_PHASES } from "./maven.types"; + +describe("Maven platform contract", () => { + test("keeps the Windows lifecycle phases aligned with the shared fixture", () => { + expect(platformContract.lifecyclePhases).toEqual([...MAVEN_LIFECYCLE_PHASES]); + }); +}); diff --git a/windows/tauri/src/features/maven/types/maven.types.ts b/windows/tauri/src/features/maven/types/maven.types.ts new file mode 100644 index 000000000..b4506de5b --- /dev/null +++ b/windows/tauri/src/features/maven/types/maven.types.ts @@ -0,0 +1,92 @@ +export type MavenProjectStatus = "idle" | "loading" | "ready" | "failed"; +export type MavenTaskStatus = "idle" | "running" | "stopping" | "failed" | "cancelled"; + +export interface MavenProfile { + id: string; + isActiveByDefault: boolean; +} + +export interface MavenModule { + relativePath: string; + groupId?: string | null; + artifactId: string; + version?: string | null; + packaging: string; + modules: MavenModule[]; +} + +export interface MavenProject { + relativePath: string; + groupId?: string | null; + artifactId: string; + version?: string | null; + packaging: string; + modules: MavenModule[]; + profiles: MavenProfile[]; + hasWrapper: boolean; +} + +export interface MavenLaunchContext { + version: 1; + reactorPath: string; + profiles: string[]; + settingsPath?: string | null; + skipTests: boolean; + mavenExecutablePath?: string | null; + javaHomePath?: string | null; +} + +export interface MavenLaunchPlan { + version: 1; + executable: { toolchain: "project-maven" }; + arguments: string[]; + workingDirectory: string; + configurationFingerprint: string; +} + +export interface MavenDiagnostic { + path: string; + line: number; + column?: number | null; + severity: "error" | "warning"; + message: string; +} + +export interface MavenPortableConfiguration { + version: 1; + selectedProfiles: string[]; + customProfiles: string[]; + skipTests: boolean; +} + +export interface MavenLocalConfiguration { + version: 1; + settingsPath?: string | null; + mavenExecutablePath?: string | null; + javaHomePath?: string | null; +} + +export interface MavenStoredConfiguration { + portable?: MavenPortableConfiguration | null; + local?: MavenLocalConfiguration | null; +} + +export interface MavenSettings { + settingsPath: string; + mavenExecutablePath: string; + javaHomePath: string; +} + +export const MAVEN_LIFECYCLE_PHASES = [ + "clean", + "validate", + "compile", + "test", + "package", + "verify", + "install", + "site", + "deploy", +] as const; + +export type MavenLifecyclePhase = (typeof MAVEN_LIFECYCLE_PHASES)[number]; diff --git a/windows/tauri/src/features/run/api/run-core-api.test.ts b/windows/tauri/src/features/run/api/run-core-api.test.ts index dd5066e49..456e31395 100644 --- a/windows/tauri/src/features/run/api/run-core-api.test.ts +++ b/windows/tauri/src/features/run/api/run-core-api.test.ts @@ -8,7 +8,7 @@ const executeCore = mock(async () => ({ mock.module("@/core/lithe-core-client", () => ({ executeCore })); -const { saveRunConfigurationEditorChanges } = await import("./run-core-api"); +const { createLaunchPlan, saveRunConfigurationEditorChanges } = await import("./run-core-api"); const emptyToolchain = { javaHomePath: "", @@ -22,6 +22,32 @@ beforeEach(() => { }); describe("saveRunConfigurationEditorChanges", () => { + test("forwards the shared Maven context when creating a launch plan", async () => { + const mavenContext = { + version: 1 as const, + reactorPath: "reactor", + profiles: ["dev"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }; + + await createLaunchPlan("D:/fixture/project", "spring", undefined, mavenContext); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + command: "runConfig.createLaunchPlan", + payload: { + root: "D:/fixture/project", + configurationId: "spring", + currentFile: undefined, + mavenContext, + }, + }), + ); + }); + test("sends project-relative working directory and toolchain paths in project scope", async () => { await saveRunConfigurationEditorChanges( "D:/fixture/project", @@ -31,6 +57,7 @@ describe("saveRunConfigurationEditorChanges", () => { javaHomePath: "D:\\fixture\\project\\toolchains\\jdk", mavenExecutablePath: "D:/fixture/project/toolchains/maven/bin/mvn.cmd", mavenJavaHomePath: "D:/fixture/project/toolchains/maven-jdk", + mavenSkipTests: false, workingDirectoryPath: "D:/fixture/project/app", vmArguments: "-Xmx2g", programArguments: "--dev", @@ -51,6 +78,7 @@ describe("saveRunConfigurationEditorChanges", () => { arguments: "--dev", environment: { APP_ENV: "dev" }, mavenProfiles: [], + mavenSkipTests: false, javaHomePath: "toolchains/jdk", mavenExecutablePath: "toolchains/maven/bin/mvn.cmd", mavenJavaHomePath: "toolchains/maven-jdk", @@ -88,6 +116,34 @@ describe("saveRunConfigurationEditorChanges", () => { ); }); + test("uses empty working directory and null test override to inherit project defaults", async () => { + await saveRunConfigurationEditorChanges( + "D:/fixture/project", + "spring", + "project", + { + javaHomePath: "", + mavenExecutablePath: "", + mavenJavaHomePath: "", + mavenSkipTests: null, + workingDirectoryPath: "", + vmArguments: "", + programArguments: "", + environment: {}, + }, + emptyToolchain, + ); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + workingDirectory: "", + mavenSkipTests: null, + }), + }), + ); + }); + test("rejects a project path outside the workspace", () => { expect(() => saveRunConfigurationEditorChanges( diff --git a/windows/tauri/src/features/run/api/run-core-api.ts b/windows/tauri/src/features/run/api/run-core-api.ts index 5a6c9cf17..eb7d85ec8 100644 --- a/windows/tauri/src/features/run/api/run-core-api.ts +++ b/windows/tauri/src/features/run/api/run-core-api.ts @@ -8,6 +8,7 @@ import type { RunOptions, RunSaveScope, } from "../types/run.types"; +import type { MavenLaunchContext } from "@/features/maven/types/maven.types"; import { projectScopedPath } from "../utils/run-configuration"; let requestSequence = 0; @@ -56,11 +57,17 @@ export function resolveRunConfiguration( return runCore("runConfig.resolve", { root, toolchainCandidates }); } -export function createLaunchPlan(root: string, configurationId: string, currentFile?: string) { +export function createLaunchPlan( + root: string, + configurationId: string, + currentFile?: string, + mavenContext?: MavenLaunchContext | null, +) { return runCore("runConfig.createLaunchPlan", { root, configurationId, currentFile, + mavenContext: mavenContext ?? null, }); } @@ -88,8 +95,9 @@ export function saveRunConfigurationEditorChanges( } function scopedRunOptions(root: string, scope: RunSaveScope, options: RunOptions) { - const workingDirectory = - scope === "project" + const workingDirectory = !options.workingDirectoryPath.trim() + ? "" + : scope === "project" ? projectScopedPath(root, options.workingDirectoryPath) : options.workingDirectoryPath; const scopedToolchainPath = (value: string) => { @@ -113,6 +121,7 @@ function scopedRunOptions(root: string, scope: RunSaveScope, options: RunOptions arguments: options.programArguments, environment: options.environment, mavenProfiles: [], + mavenSkipTests: options.mavenSkipTests ?? null, javaHomePath, mavenExecutablePath, mavenJavaHomePath, diff --git a/windows/tauri/src/features/run/components/run-configuration-editor.tsx b/windows/tauri/src/features/run/components/run-configuration-editor.tsx index a2264c73f..c8b018592 100644 --- a/windows/tauri/src/features/run/components/run-configuration-editor.tsx +++ b/windows/tauri/src/features/run/components/run-configuration-editor.tsx @@ -353,6 +353,33 @@ export function RunConfigurationEditor({ onSelect={(value) => setDraft((current) => ({ ...current, mavenJavaHomePath: value }))} onPick={() => pickDirectory("mavenJavaHomePath")} /> + + {t("run.mavenTests")} + + setDraft((current) => ({ + ...current, + mavenSkipTests: + event.target.value === "inherit" ? null : event.target.value === "skip", + })) + } + > + + {t("run.mavenTestsProjectDefault")} + + {t("run.mavenTestsRun")} + {t("run.mavenTestsSkip")} + + {t("run.mavenTestsHint")} + ) : null} diff --git a/windows/tauri/src/features/run/stores/run-maven-context.test.ts b/windows/tauri/src/features/run/stores/run-maven-context.test.ts new file mode 100644 index 000000000..532645a6a --- /dev/null +++ b/windows/tauri/src/features/run/stores/run-maven-context.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { MavenLaunchContext } from "@/features/maven/types/maven.types"; +import type { RunConfiguration } from "../types/run.types"; +import { createRunStore, type RunStoreDependencies } from "./run.store"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +const mavenContext: MavenLaunchContext = { + version: 1, + reactorPath: "reactor", + profiles: ["dev"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", +}; + +const configuration: RunConfiguration = { + id: "spring", + name: "Spring Boot", + provider: "spring-boot.maven", + kindTitle: "Spring Boot", + execution: "service", + cwd: "", + args: [], + env: {}, + jvmArguments: [], + programArguments: [], + profiles: [], + mavenSkipTests: null, + javaHomePath: "", + mavenExecutablePath: "", + mavenJavaHomePath: "", + toolchains: { java: "project-jdk", maven: "project-maven" }, + source: "generated", + disabled: false, +}; + +describe("Maven-backed Run context", () => { + test("waits for the workspace Maven load before creating the launch plan", async () => { + const pendingContext = deferred(); + const events: string[] = []; + const createLaunchPlan = mock( + async (...args: Parameters) => { + events.push("plan-created"); + expect(args[3]).toEqual(mavenContext); + return { + executable: { toolchain: "project-maven" }, + arguments: ["-B", "spring-boot:run"], + workingDirectory: "reactor", + }; + }, + ); + const mavenLaunchContextForWorkspace = mock(async () => { + events.push("context-started"); + return pendingContext.promise; + }); + const resolveRunLaunch = mock(async () => ({ + executable: "D:/Tools/apache-maven/bin/mvn.cmd", + workingDirectory: "D:/work/reactor", + environment: {}, + })); + const saveWorkspaceBeforeLaunch = mock(async () => { + events.push("files-saved"); + }); + const startRunProcess = mock(async () => undefined); + const stopRunProcess = mock(async () => undefined); + const dependencies: RunStoreDependencies = { + createLaunchPlan, + mavenLaunchContextForWorkspace, + resolveRunLaunch, + saveWorkspaceBeforeLaunch, + startRunProcess, + stopRunProcess, + }; + const store = createRunStore("workspace", dependencies); + store.setState({ + root: "D:/work", + configurations: [configuration], + diagnostics: [], + effectiveRuntimeExecutablePaths: {}, + }); + + const run = store.getState().actions.runConfiguration(configuration.id); + try { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(events).toEqual(["files-saved", "context-started"]); + expect(createLaunchPlan).not.toHaveBeenCalled(); + } finally { + pendingContext.resolve(mavenContext); + await run; + } + + expect(saveWorkspaceBeforeLaunch).toHaveBeenCalledWith("workspace"); + expect(mavenLaunchContextForWorkspace).toHaveBeenCalledWith("D:/work", [], "workspace"); + expect(createLaunchPlan).toHaveBeenCalledWith("D:/work", "spring", undefined, mavenContext); + expect(resolveRunLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + mavenExecutablePath: "D:/Tools/apache-maven", + mavenJavaHomePath: "C:/Java/jdk-21", + }), + ); + }); + + test("does not create a launch plan when workspace files cannot be saved", async () => { + const createLaunchPlan = mock(async () => ({ + executable: { toolchain: "project-maven" as const }, + arguments: ["-B", "spring-boot:run"], + workingDirectory: "reactor", + })); + const startRunProcess = mock(async () => undefined); + const dependencies: RunStoreDependencies = { + createLaunchPlan, + mavenLaunchContextForWorkspace: mock(async () => mavenContext), + resolveRunLaunch: mock(async () => ({ + executable: "D:/Tools/apache-maven/bin/mvn.cmd", + workingDirectory: "D:/work/reactor", + environment: {}, + })), + saveWorkspaceBeforeLaunch: mock(async () => { + throw new Error("Unable to start because modified files could not be saved: App.java."); + }), + startRunProcess, + stopRunProcess: mock(async () => undefined), + }; + const store = createRunStore("workspace", dependencies); + store.setState({ + root: "D:/work", + configurations: [configuration], + diagnostics: [], + effectiveRuntimeExecutablePaths: {}, + }); + + await store.getState().actions.runConfiguration(configuration.id); + + expect(createLaunchPlan).not.toHaveBeenCalled(); + expect(startRunProcess).not.toHaveBeenCalled(); + expect(store.getState().sessions).toEqual([ + expect.objectContaining({ + id: configuration.id, + isRunning: false, + exitCode: 1, + output: expect.stringContaining("App.java"), + }), + ]); + }); +}); diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 9d6677f1f..d51dbf175 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -1,6 +1,8 @@ import { createStore } from "zustand/vanilla"; +import { saveWorkspaceBeforeLaunch } from "@/features/editor/services/save-workspace-before-launch"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { mavenLaunchContextForWorkspace } from "@/features/maven/stores/maven.store"; import { createLaunchPlan, generateRunConfiguration, @@ -46,6 +48,7 @@ import { recoveryActionForError, recoveryPathFromMessage, selectedToolchainCandidates, + configurationUsesMaven, } from "../utils/run-configuration"; import { editorSaveFailureMessage, runEditorSaveWorkflow } from "../services/run-editor-save"; import { createOutputStamper, trimRunOutput, type OutputStamper } from "../utils/output-timestamper"; @@ -99,6 +102,24 @@ interface RunState { }; } +export interface RunStoreDependencies { + createLaunchPlan: typeof createLaunchPlan; + mavenLaunchContextForWorkspace: typeof mavenLaunchContextForWorkspace; + resolveRunLaunch: typeof resolveRunLaunch; + saveWorkspaceBeforeLaunch: typeof saveWorkspaceBeforeLaunch; + startRunProcess: typeof startRunProcess; + stopRunProcess: typeof stopRunProcess; +} + +const defaultRunStoreDependencies: RunStoreDependencies = { + createLaunchPlan, + mavenLaunchContextForWorkspace, + resolveRunLaunch, + saveWorkspaceBeforeLaunch, + startRunProcess, + stopRunProcess, +}; + interface ResolvedRunProject { configurations: RunConfiguration[]; diagnostics: RunDiagnostic[]; @@ -162,6 +183,7 @@ function optionsFromConfiguration(configuration: RunConfiguration): RunOptions { javaHomePath: configuration.javaHomePath, mavenExecutablePath: configuration.mavenExecutablePath, mavenJavaHomePath: configuration.mavenJavaHomePath, + mavenSkipTests: configuration.mavenSkipTests, workingDirectoryPath: configuration.cwd, vmArguments: configuration.jvmArguments.join(" "), programArguments: configuration.programArguments.join(" "), @@ -257,7 +279,10 @@ function readyRunState( }; } -export const createRunStore = () => +export const createRunStore = ( + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), + dependencies: RunStoreDependencies = defaultRunStoreDependencies, +) => createStore()((set, get) => ({ root: null, status: "missing", @@ -400,18 +425,28 @@ export const createRunStore = () => } const sessionId = configuration.execution === "service" ? configuration.id : PRIMARY_SESSION_ID; - bindRunSessionWorkspace(sessionId); + bindRunSessionWorkspace(sessionId, workspaceId); resetOutputStamper(sessionId); - await stopRunProcess(sessionId).catch(() => undefined); + await dependencies.stopRunProcess(sessionId).catch(() => undefined); try { - const plan = await createLaunchPlan(root, configuration.id, currentFile); - const resolved = await resolveRunLaunch({ + await dependencies.saveWorkspaceBeforeLaunch(workspaceId); + const mavenContext = configurationUsesMaven(configuration) + ? await dependencies.mavenLaunchContextForWorkspace(root, [], workspaceId) + : null; + const plan = await dependencies.createLaunchPlan( + root, + configuration.id, + currentFile, + mavenContext, + ); + const resolved = await dependencies.resolveRunLaunch({ root, executable: plan.executable, workingDirectory: plan.workingDirectory, javaHomePath: configuration.javaHomePath, - mavenExecutablePath: configuration.mavenExecutablePath, - mavenJavaHomePath: configuration.mavenJavaHomePath, + mavenExecutablePath: + configuration.mavenExecutablePath || mavenContext?.mavenExecutablePath || "", + mavenJavaHomePath: configuration.mavenJavaHomePath || mavenContext?.javaHomePath || "", runtimeExecutablePaths: state.effectiveRuntimeExecutablePaths, environment: mergeLaunchEnvironment(configuration.env, plan), }); @@ -440,7 +475,7 @@ export const createRunStore = () => ], })); } - await startRunProcess({ + await dependencies.startRunProcess({ sessionId, executable: resolved.executable, arguments: plan.arguments, @@ -457,18 +492,27 @@ export const createRunStore = () => primaryOutput: trimOutput(`${get().primaryOutput}${message}\n`), }); } else { - set((current) => ({ - sessions: current.sessions.map((session) => - session.id === sessionId - ? { - ...session, - isRunning: false, - exitCode: 1, - output: trimOutput(`${session.output}${message}\n`), - } - : session, - ), - })); + set((current) => { + const existingSession = current.sessions.find( + (session) => session.id === sessionId, + ); + const failedSession: RunSession = { + id: sessionId, + configurationId: configuration.id, + title: configuration.name, + output: trimOutput(`${existingSession?.output ?? ""}${message}\n`), + isRunning: false, + exitCode: 1, + }; + return { + selectedSessionId: sessionId, + sessions: existingSession + ? current.sessions.map((session) => + session.id === sessionId ? failedSession : session, + ) + : [...current.sessions, failedSession], + }; + }); } } }, diff --git a/windows/tauri/src/features/run/types/run.types.ts b/windows/tauri/src/features/run/types/run.types.ts index f6bc4af4b..9f31ad7c4 100644 --- a/windows/tauri/src/features/run/types/run.types.ts +++ b/windows/tauri/src/features/run/types/run.types.ts @@ -30,6 +30,7 @@ export interface RunConfiguration { jvmArguments: string[]; programArguments: string[]; profiles: string[]; + mavenSkipTests: boolean | null; javaHomePath: string; mavenExecutablePath: string; mavenJavaHomePath: string; @@ -42,6 +43,7 @@ export interface RunOptions { javaHomePath: string; mavenExecutablePath: string; mavenJavaHomePath: string; + mavenSkipTests?: boolean | null; workingDirectoryPath: string; vmArguments: string; programArguments: string; @@ -142,6 +144,7 @@ export interface CoreResolvedConfiguration { jvmArguments?: string[]; programArguments?: string[]; profiles?: string[]; + skipTests?: boolean; }; java?: { homePath?: string; @@ -158,6 +161,7 @@ export const EMPTY_RUN_OPTIONS: RunOptions = { javaHomePath: "", mavenExecutablePath: "", mavenJavaHomePath: "", + mavenSkipTests: null, workingDirectoryPath: "", vmArguments: "", programArguments: "", diff --git a/windows/tauri/src/features/run/utils/run-configuration.test.ts b/windows/tauri/src/features/run/utils/run-configuration.test.ts index 86d2ce9bc..df002f175 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.test.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.test.ts @@ -23,7 +23,11 @@ describe("run configuration mapping", () => { execution: "service", source: "generated", extensions: { - maven: { module: ".", mainClass: "com.example.demo.DemoApplication" }, + maven: { + module: ".", + mainClass: "com.example.demo.DemoApplication", + skipTests: false, + }, }, }); @@ -31,6 +35,7 @@ describe("run configuration mapping", () => { expect(configuration.execution).toBe("service"); expect(configuration.mainClass).toBe("com.example.demo.DemoApplication"); expect(configuration.modulePath).toBeUndefined(); + expect(configuration.mavenSkipTests).toBe(false); }); test("groups runnable configurations and hides Current File", () => { diff --git a/windows/tauri/src/features/run/utils/run-configuration.ts b/windows/tauri/src/features/run/utils/run-configuration.ts index 6f4bc3f2c..db9bfc104 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.ts @@ -42,6 +42,7 @@ export function mapCoreConfiguration(value: CoreResolvedConfiguration): RunConfi jvmArguments: maven?.jvmArguments ?? [], programArguments: maven?.programArguments ?? value.args ?? [], profiles: maven?.profiles ?? [], + mavenSkipTests: maven?.skipTests ?? null, javaHomePath: java?.homePath ?? "", mavenExecutablePath: java?.mavenExecutablePath ?? "", mavenJavaHomePath: java?.mavenJavaHomePath ?? "", diff --git a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts index 22fa67496..d049a451e 100644 --- a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts +++ b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts @@ -18,6 +18,7 @@ export type BottomPaneTab = | "references" | "buffers" | "run" + | "maven" | "gitLog"; export interface QuickEditSelection { diff --git a/windows/tauri/src/features/workspace/types/workspace-launch-scope.ts b/windows/tauri/src/features/workspace/types/workspace-launch-scope.ts new file mode 100644 index 000000000..9fca49956 --- /dev/null +++ b/windows/tauri/src/features/workspace/types/workspace-launch-scope.ts @@ -0,0 +1,26 @@ +import { normalizePath, stripTrailingPathSeparators } from "@/utils/path-helpers"; + +export interface WorkspaceLaunchScope { + workspaceId: string; + root: string; +} + +function workspaceRootKey(root: string): string { + const normalized = normalizePath(stripTrailingPathSeparators(root)); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalized) ? normalized.toLowerCase() : normalized; +} + +export function workspaceScopeMatchesRoot( + scope: WorkspaceLaunchScope, + root: string | null | undefined, +): boolean { + if (!root) return false; + return workspaceRootKey(root) === workspaceRootKey(scope.root); +} + +export function workspaceScopesMatch( + left: WorkspaceLaunchScope, + right: WorkspaceLaunchScope, +): boolean { + return left.workspaceId === right.workspaceId && workspaceScopeMatchesRoot(left, right.root); +} diff --git a/windows/tauri/src/i18n/locale.test.ts b/windows/tauri/src/i18n/locale.test.ts index eaeb9e503..3e0fbcdb9 100644 --- a/windows/tauri/src/i18n/locale.test.ts +++ b/windows/tauri/src/i18n/locale.test.ts @@ -37,6 +37,8 @@ describe("Windows display language", () => { expect(translate("git.log.headCurrentBranch")).toBe("HEAD(当前分支)"); expect(translate("run.identifyAndGenerate")).toBe("识别并生成"); expect(createTranslator("en-US")("workbench.run")).toBe("Run"); + expect(createTranslator("en-US")("workbench.maven")).toBe("Maven"); + expect(translate("workbench.maven")).toBe("Maven"); expect(createTranslator("en-US")("titleProject.closeProject", { name: "Lithe" })).toBe( "Close project Lithe", ); diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index bf8788644..6c31db585 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1328,6 +1328,7 @@ const catalogs = { "workbench.database": "Database", "workbench.settings": "Settings", "workbench.run": "Run", + "workbench.maven": "Maven", "workbench.terminal": "Terminal", "workbench.diagnostics": "Diagnostics", "run.title": "Run", @@ -1379,6 +1380,12 @@ const catalogs = { "run.nodeExecutableHint": "Choose node.exe, or leave empty to use the detected Node.js runtime.", "run.mavenJdkHome": "Maven JDK Home", "run.mavenJdkHomeHint": "Leave empty to use the same JDK as the application.", + "run.mavenTests": "Maven tests", + "run.mavenTestsProjectDefault": "Use project default", + "run.mavenTestsRun": "Run tests", + "run.mavenTestsSkip": "Skip tests", + "run.mavenTestsHint": + "Override the Maven tool window's Skip Tests setting for this run configuration.", "run.toolchainAuto": "Auto-detect (leave empty)", "run.toolchainCurrent": "Current path", "run.runtimeSection": "Runtime (this PC)", @@ -1414,6 +1421,35 @@ const catalogs = { "run.newCustomAction": "New custom action", "run.runCell": "Run cell", "run.runChunk": "Run chunk", + "maven.title": "Maven", + "maven.project": "Project", + "maven.lifecycle": "Lifecycle", + "maven.profiles": "Profiles", + "maven.settings": "Maven Settings", + "maven.automatic": "Automatic", + "maven.mavenExecutable": "Maven home or executable", + "maven.javaHome": "Maven JDK Home", + "maven.stop": "Stop Maven task", + "maven.cancelled": "Cancelled", + "maven.runSelected": "Run selected lifecycle phase", + "maven.executeGoal": "Execute Maven goal", + "maven.reloadProjects": "Reload Maven projects", + "maven.skipTests": "Skip tests", + "maven.collapseAll": "Collapse all", + "maven.clearOutput": "Clear build output", + "maven.configurationChanged": "Maven configuration changed", + "maven.reloadJdt": "Reload JDT LS", + "maven.reloadFailed": "Unable to reload the Java language server.", + "maven.loadFailed": "Unable to load Maven project", + "maven.buildOutput": "Build Output", + "maven.processOutput": "Process output", + "maven.emptyOutput": "Run a Maven lifecycle phase to see output.", + "maven.scanning": "Scanning Maven project...", + "maven.notDetected": "No Maven project detected", + "maven.addProfile": "Add Maven profile", + "maven.restoreProfiles": "Restore default profiles", + "maven.add": "Add", + "maven.profileId": "Profile ID", "runActions.editRunAction": "Edit run action", "runActions.newRunAction": "New run action", "runActions.saveChanges": "Save changes", @@ -5113,6 +5149,7 @@ const catalogs = { "workbench.database": "数据库", "workbench.settings": "设置", "workbench.run": "运行", + "workbench.maven": "Maven", "workbench.terminal": "终端", "workbench.diagnostics": "诊断", "run.title": "运行", @@ -5164,6 +5201,11 @@ const catalogs = { "run.nodeExecutableHint": "可选择 node.exe;留空则使用自动检测到的 Node.js 运行时。", "run.mavenJdkHome": "Maven JDK 主目录", "run.mavenJdkHomeHint": "留空则与应用使用同一个 JDK。", + "run.mavenTests": "Maven 测试", + "run.mavenTestsProjectDefault": "使用项目默认值", + "run.mavenTestsRun": "运行测试", + "run.mavenTestsSkip": "跳过测试", + "run.mavenTestsHint": "为当前运行配置覆盖 Maven 工具窗口中的“跳过测试”设置。", "run.toolchainAuto": "自动检测(留空)", "run.toolchainCurrent": "当前路径", "run.runtimeSection": "运行环境(本机)", @@ -5198,6 +5240,35 @@ const catalogs = { "run.newCustomAction": "新建自定义操作", "run.runCell": "运行单元", "run.runChunk": "运行代码块", + "maven.title": "Maven", + "maven.project": "项目", + "maven.lifecycle": "生命周期", + "maven.profiles": "Profiles", + "maven.settings": "Maven 设置", + "maven.automatic": "自动检测", + "maven.mavenExecutable": "Maven 主目录 / 可执行文件", + "maven.javaHome": "Maven JDK 主目录", + "maven.stop": "停止 Maven 任务", + "maven.cancelled": "已取消", + "maven.runSelected": "运行选中的生命周期阶段", + "maven.executeGoal": "执行 Maven Goal", + "maven.reloadProjects": "重新加载 Maven 项目", + "maven.skipTests": "跳过测试", + "maven.collapseAll": "全部折叠", + "maven.clearOutput": "清除构建输出", + "maven.configurationChanged": "Maven 配置已更改", + "maven.reloadJdt": "重新加载 JDT LS", + "maven.reloadFailed": "无法重新加载 Java 语言服务器。", + "maven.loadFailed": "无法加载 Maven 项目", + "maven.buildOutput": "构建输出", + "maven.processOutput": "进程输出", + "maven.emptyOutput": "运行 Maven 生命周期阶段后将在这里显示输出。", + "maven.scanning": "正在扫描 Maven 项目...", + "maven.notDetected": "未检测到 Maven 项目", + "maven.addProfile": "添加 Maven Profile", + "maven.restoreProfiles": "恢复默认 Profiles", + "maven.add": "添加", + "maven.profileId": "Profile ID", "runActions.editRunAction": "编辑运行操作", "runActions.newRunAction": "新建运行操作", "runActions.saveChanges": "保存更改", diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index a1ae8a727..9e920f6ac 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -689,6 +689,7 @@ async function createSession(args: JsonRecord, key: string): Promise { jdtlsLaunchResources: args.jdtlsLaunchResources ?? null, cacheDirectory: args.cacheDirectory ?? null, workspaceFingerprint: args.workspaceFingerprint ?? null, + mavenContext: args.mavenContext ?? null, initializeTimeoutMilliseconds: INITIALIZE_TIMEOUT_MS, }, operationId, diff --git a/windows/tauri/src/platform/tauri-core.ts b/windows/tauri/src/platform/tauri-core.ts index 3e9291f23..e74d192e2 100644 --- a/windows/tauri/src/platform/tauri-core.ts +++ b/windows/tauri/src/platform/tauri-core.ts @@ -38,6 +38,8 @@ const nativeCommands = new Set([ "list_shells", "lsp_rebuild_java_index", "lsp_resolve_java_launch", + "maven_load_configuration", + "maven_write_configuration", "move_file", "open_log_directory", "open_file_external",