From b839df6f6adde2cfcf19ffac49a7263e5c9660b6 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sat, 29 Aug 2026 17:13:50 -0700 Subject: [PATCH 1/5] feat(windows): add unified Maven tool window --- windows/tauri/src-tauri/Cargo.lock | 1 + windows/tauri/src-tauri/Cargo.toml | 1 + windows/tauri/src-tauri/src/main.rs | 3 + windows/tauri/src-tauri/src/maven.rs | 211 +++++ windows/tauri/src-tauri/src/run.rs | 2 +- .../src/features/editor/lsp/lsp-client.ts | 2 + .../lsp/resolve-editor-lsp-launch.test.ts | 41 + .../editor/lsp/resolve-editor-lsp-launch.ts | 15 +- .../file-system/stores/file-system.store.ts | 12 +- .../keymaps/commands/view-command-actions.ts | 10 + .../components/bottom-pane/bottom-pane.tsx | 21 + .../components/sidebar/main-sidebar.tsx | 10 + .../sidebar/sidebar-pane-selector.tsx | 23 + .../features/layout/config/item-order.test.ts | 3 +- .../src/features/layout/config/item-order.ts | 2 + .../features/maven/api/maven-core-api.test.ts | 57 ++ .../src/features/maven/api/maven-core-api.ts | 61 ++ .../src/features/maven/api/maven-host-api.ts | 42 + .../features/maven/components/maven-pane.tsx | 730 ++++++++++++++++++ .../maven/hooks/use-maven-process-events.ts | 36 + .../features/maven/stores/maven.store.test.ts | 282 +++++++ .../src/features/maven/stores/maven.store.ts | 600 ++++++++++++++ .../src/features/maven/types/maven.types.ts | 92 +++ .../src/features/run/api/run-core-api.test.ts | 28 +- .../src/features/run/api/run-core-api.ts | 9 +- .../src/features/run/stores/run.store.ts | 28 +- .../stores/ui-state/types/ui-state.types.ts | 1 + windows/tauri/src/i18n/locale.ts | 56 ++ .../tauri/src/platform/lsp-core-adapter.ts | 1 + windows/tauri/src/platform/tauri-core.ts | 2 + 30 files changed, 2360 insertions(+), 22 deletions(-) create mode 100644 windows/tauri/src-tauri/src/maven.rs create mode 100644 windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts create mode 100644 windows/tauri/src/features/maven/api/maven-core-api.test.ts create mode 100644 windows/tauri/src/features/maven/api/maven-core-api.ts create mode 100644 windows/tauri/src/features/maven/api/maven-host-api.ts create mode 100644 windows/tauri/src/features/maven/components/maven-pane.tsx create mode 100644 windows/tauri/src/features/maven/hooks/use-maven-process-events.ts create mode 100644 windows/tauri/src/features/maven/stores/maven.store.test.ts create mode 100644 windows/tauri/src/features/maven/stores/maven.store.ts create mode 100644 windows/tauri/src/features/maven/types/maven.types.ts 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..1ec630063 --- /dev/null +++ b/windows/tauri/src-tauri/src/maven.rs @@ -0,0 +1,211 @@ +//! 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(); + digest.update(root.to_string_lossy().to_lowercase().as_bytes()); + digest.update([0]); + digest.update(reactor_path.replace('\\', "/").as_bytes()); + Ok(app_data + .join("maven") + .join(format!("{:x}.json", digest.finalize()))) +} + +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")); + } +} 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/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 89f07cbe3..91d95cc67 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -605,6 +605,7 @@ export class LspClient { cacheDirectory: launch.cacheDirectory || null, environment: launch.environment || null, workspaceFingerprint: launch.workspaceFingerprint || null, + mavenContext: launch.mavenContext || null, }); if (representativeFilePath) { @@ -833,6 +834,7 @@ export class LspClient { cacheDirectory: launch.cacheDirectory || null, environment: launch.environment || null, workspaceFingerprint: launch.workspaceFingerprint || null, + mavenContext: launch.mavenContext || null, attachmentId, }); if (!isCurrentAttachment()) { 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..df24672f5 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts @@ -0,0 +1,41 @@ +import { expect, mock, test } from "bun:test"; + +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", +})); + +mock.module("./java-lsp-host-api", () => ({ resolveJavaLspLaunch })); +mock.module("@/features/maven/stores/maven.store", () => ({ + mavenLaunchContextForWorkspace, +})); + +const { resolveEditorLspLaunch } = await import("./resolve-editor-lsp-launch"); + +test("forwards the current Maven context to the Java language server", async () => { + const launch = await resolveEditorLspLaunch("D:/work/src/App.java", "D:/work"); + + expect(mavenLaunchContextForWorkspace).toHaveBeenCalledWith("D:/work", ["src/App.java"]); + 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..0c150c23e 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,9 @@ 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 { getRelativePath } from "@/utils/path-helpers"; export interface EditorLspLaunch { providerId: string; @@ -18,6 +18,7 @@ export interface EditorLspLaunch { environment?: Record; /** Workspace structure digest forwarded to the Rust core. */ workspaceFingerprint?: string | null; + mavenContext?: MavenLaunchContext | null; } export async function resolveEditorLspLaunch( @@ -25,7 +26,10 @@ export async function resolveEditorLspLaunch( workspacePath: string, ): Promise { if (isJavaSourcePath(filePath)) { - const launch = await resolveJavaLspLaunch(workspacePath); + const [launch, mavenContext] = await Promise.all([ + resolveJavaLspLaunch(workspacePath), + mavenLaunchContextForWorkspace(workspacePath, [getRelativePath(filePath, workspacePath)]), + ]); const environment: Record = {}; if (launch.environment.JAVA_HOME) { environment.JAVA_HOME = launch.environment.JAVA_HOME; @@ -40,6 +44,7 @@ export async function resolveEditorLspLaunch( cacheDirectory: launch.cacheDirectory, environment, workspaceFingerprint: launch.workspaceFingerprint, + mavenContext, }; } 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..f95213eb9 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) 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); @@ -666,6 +670,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..bb4b26747 100644 --- a/windows/tauri/src/features/layout/config/item-order.test.ts +++ b/windows/tauri/src/features/layout/config/item-order.test.ts @@ -27,8 +27,9 @@ describe("sidebar activity order", () => { 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..6afde5cca 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", 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..5e1aa3a8e --- /dev/null +++ b/windows/tauri/src/features/maven/components/maven-pane.tsx @@ -0,0 +1,730 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { getJavaWorkspaceLanguageServerOwner } from "@/features/editor/lsp/java-workspace-language-server"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +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 { + 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 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(() => { + 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; + setReloadError(null); + try { + const files = await useFileSystemStore.getState().getAllProjectFiles(); + const javaFile = files + .filter((entry) => !entry.isDir && entry.path.toLowerCase().endsWith(".java")) + .map((entry) => entry.path) + .sort()[0]; + const owner = getJavaWorkspaceLanguageServerOwner(); + await owner.stop(root); + if (javaFile) await owner.prewarm(root, javaFile); + actions.acknowledgeReload(); + } catch (error) { + setReloadError(error instanceof Error ? error.message : t("maven.reloadFailed")); + } + }; + + const reloadProjects = async () => { + if (!root) return; + await actions.loadProject(root, visiblePaths); + if (useMavenStore.getState().project) await reloadJava(); + }; + + 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} + {!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/stores/maven.store.test.ts b/windows/tauri/src/features/maven/stores/maven.store.test.ts new file mode 100644 index 000000000..bb17d0fb8 --- /dev/null +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -0,0 +1,282 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type { + MavenDiagnostic, + MavenLaunchPlan, + MavenProject, + MavenStoredConfiguration, +} from "../types/maven.types"; +import { createMavenStore, mavenLaunchContext, 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 startMavenProcess = mock(async () => undefined); +const stopMavenProcess = mock(async () => undefined); + +const dependencies = { + createMavenLaunchPlan, + loadMavenConfiguration, + parseMavenDiagnostics, + resolveMavenLaunch, + 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(); + startMavenProcess.mockClear(); + stopMavenProcess.mockClear(); +}); + +describe("Maven workspace state", () => { + 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("idle"); + expect(store.getState().activeSessionId).toBeNull(); + }); + + 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..39387650f --- /dev/null +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -0,0 +1,600 @@ +import { createStore } from "zustand/vanilla"; +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; + scanMavenProject: typeof scanMavenProject; + startMavenProcess: typeof startMavenProcess; + stopMavenProcess: typeof stopMavenProcess; + writeMavenConfiguration: typeof writeMavenConfiguration; +} + +const defaultMavenStoreDependencies: MavenStoreDependencies = { + createMavenLaunchPlan, + loadMavenConfiguration, + parseMavenDiagnostics, + resolveMavenLaunch, + 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; +} + +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 { + 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: "idle", + taskError: null, + activeSessionId: null, + runningTitle: null, + lastExitCode: null, + }); + } + } 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({ output: "", issues: [], lastExitCode: null }); + }, + + 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; + 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.ts b/windows/tauri/src/features/maven/types/maven.types.ts new file mode 100644 index 000000000..f2f6076b0 --- /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"; + +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..aa4fb8487 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", 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..c38a9e8d9 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, }); } diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 9d6677f1f..3f3cbeec3 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -1,6 +1,7 @@ import { createStore } from "zustand/vanilla"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { currentMavenLaunchContext } from "@/features/maven/stores/maven.store"; import { createLaunchPlan, generateRunConfiguration, @@ -48,7 +49,11 @@ import { selectedToolchainCandidates, } from "../utils/run-configuration"; import { editorSaveFailureMessage, runEditorSaveWorkflow } from "../services/run-editor-save"; -import { createOutputStamper, trimRunOutput, type OutputStamper } from "../utils/output-timestamper"; +import { + createOutputStamper, + trimRunOutput, + type OutputStamper, +} from "../utils/output-timestamper"; const MAXIMUM_OUTPUT_CHARACTERS = 500_000; const sessionWorkspaces = new Map(); @@ -179,14 +184,11 @@ async function resolveConfigurations(root: string): Promise runtimeExecutablePaths: automaticRuntimePaths, }), ); - const globalToolchain = mapCoreToolchain( - preliminary.toolchain, - preliminary.localToolchains, - ); + const globalToolchain = mapCoreToolchain(preliminary.toolchain, preliminary.localToolchains); const hasSelectedToolchain = Boolean( globalToolchain.javaHomePath || - globalToolchain.mavenExecutablePath || - Object.values(globalToolchain.runtimeExecutablePaths).some(Boolean), + globalToolchain.mavenExecutablePath || + Object.values(globalToolchain.runtimeExecutablePaths).some(Boolean), ); const discovered = hasSelectedToolchain ? await discoverRunToolchains(root, globalToolchain) @@ -257,7 +259,7 @@ function readyRunState( }; } -export const createRunStore = () => +export const createRunStore = (workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId()) => createStore()((set, get) => ({ root: null, status: "missing", @@ -400,18 +402,20 @@ 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); try { - const plan = await createLaunchPlan(root, configuration.id, currentFile); + const mavenContext = currentMavenLaunchContext(root, workspaceId); + const plan = await createLaunchPlan(root, configuration.id, currentFile, mavenContext); const resolved = await 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), }); 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/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index bf8788644..56da5f9d7 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1414,6 +1414,34 @@ 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.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", @@ -5198,6 +5226,34 @@ 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.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", From 9f0a3cbcd17498382a12970560e7240ee65c26d7 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sat, 29 Aug 2026 21:31:21 -0700 Subject: [PATCH 2/5] fix(windows): align Maven behavior across platforms --- windows/tauri/src-tauri/src/maven.rs | 45 +++++++++++++++++-- .../features/maven/components/maven-pane.tsx | 3 ++ .../features/maven/stores/maven.store.test.ts | 34 +++++++++++++- .../src/features/maven/stores/maven.store.ts | 27 ++++++++++- .../features/maven/types/maven.types.test.ts | 9 ++++ .../src/features/maven/types/maven.types.ts | 2 +- .../src/features/run/api/run-core-api.test.ts | 30 +++++++++++++ .../src/features/run/api/run-core-api.ts | 6 ++- .../components/run-configuration-editor.tsx | 27 +++++++++++ .../src/features/run/stores/run.store.ts | 16 +++---- .../tauri/src/features/run/types/run.types.ts | 4 ++ .../run/utils/run-configuration.test.ts | 7 ++- .../features/run/utils/run-configuration.ts | 1 + windows/tauri/src/i18n/locale.ts | 13 ++++++ 14 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 windows/tauri/src/features/maven/types/maven.types.test.ts diff --git a/windows/tauri/src-tauri/src/maven.rs b/windows/tauri/src-tauri/src/maven.rs index 1ec630063..4187845fd 100644 --- a/windows/tauri/src-tauri/src/maven.rs +++ b/windows/tauri/src-tauri/src/maven.rs @@ -105,14 +105,21 @@ fn local_path(app: &AppHandle, root: &Path, reactor_path: &str) -> Result 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() { @@ -208,4 +215,36 @@ mod tests { .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/features/maven/components/maven-pane.tsx b/windows/tauri/src/features/maven/components/maven-pane.tsx index 5e1aa3a8e..be2242ea3 100644 --- a/windows/tauri/src/features/maven/components/maven-pane.tsx +++ b/windows/tauri/src/features/maven/components/maven-pane.tsx @@ -396,6 +396,9 @@ export default function MavenPane() { {runningTitle} ) : null} + {taskStatus === "cancelled" ? ( + {t("maven.cancelled")} + ) : null} {!isRunning && lastExitCode != null ? ( { await run; expect(startMavenProcess).not.toHaveBeenCalled(); - expect(store.getState().taskStatus).toBe("idle"); + 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("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 () => { diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts index 39387650f..efb81b9c6 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -168,6 +168,11 @@ function trimOutput(output: string): string { : 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, @@ -455,11 +460,12 @@ export const createMavenStore = ( await dependencies.stopMavenProcess(sessionId); if (get().activeSessionId === sessionId) { set({ - taskStatus: "idle", + taskStatus: "cancelled", taskError: null, activeSessionId: null, runningTitle: null, lastExitCode: null, + output: cancelledOutput(get().output), }); } } catch (error) { @@ -477,7 +483,12 @@ export const createMavenStore = ( clearOutput: () => { diagnosticsRevision += 1; - set({ output: "", issues: [], lastExitCode: null }); + set((state) => ({ + output: "", + issues: [], + lastExitCode: null, + taskStatus: state.taskStatus === "cancelled" ? "idle" : state.taskStatus, + })); }, appendOutput: (sessionId, chunk) => { @@ -491,6 +502,18 @@ export const createMavenStore = ( 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}.`, 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 index f2f6076b0..b4506de5b 100644 --- a/windows/tauri/src/features/maven/types/maven.types.ts +++ b/windows/tauri/src/features/maven/types/maven.types.ts @@ -1,5 +1,5 @@ export type MavenProjectStatus = "idle" | "loading" | "ready" | "failed"; -export type MavenTaskStatus = "idle" | "running" | "stopping" | "failed"; +export type MavenTaskStatus = "idle" | "running" | "stopping" | "failed" | "cancelled"; export interface MavenProfile { id: string; 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 aa4fb8487..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 @@ -57,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", @@ -77,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", @@ -114,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 c38a9e8d9..eb7d85ec8 100644 --- a/windows/tauri/src/features/run/api/run-core-api.ts +++ b/windows/tauri/src/features/run/api/run-core-api.ts @@ -95,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) => { @@ -120,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.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 3f3cbeec3..4125d165e 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -49,11 +49,7 @@ import { selectedToolchainCandidates, } from "../utils/run-configuration"; import { editorSaveFailureMessage, runEditorSaveWorkflow } from "../services/run-editor-save"; -import { - createOutputStamper, - trimRunOutput, - type OutputStamper, -} from "../utils/output-timestamper"; +import { createOutputStamper, trimRunOutput, type OutputStamper } from "../utils/output-timestamper"; const MAXIMUM_OUTPUT_CHARACTERS = 500_000; const sessionWorkspaces = new Map(); @@ -167,6 +163,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(" "), @@ -184,11 +181,14 @@ async function resolveConfigurations(root: string): Promise runtimeExecutablePaths: automaticRuntimePaths, }), ); - const globalToolchain = mapCoreToolchain(preliminary.toolchain, preliminary.localToolchains); + const globalToolchain = mapCoreToolchain( + preliminary.toolchain, + preliminary.localToolchains, + ); const hasSelectedToolchain = Boolean( globalToolchain.javaHomePath || - globalToolchain.mavenExecutablePath || - Object.values(globalToolchain.runtimeExecutablePaths).some(Boolean), + globalToolchain.mavenExecutablePath || + Object.values(globalToolchain.runtimeExecutablePaths).some(Boolean), ); const discovered = hasSelectedToolchain ? await discoverRunToolchains(root, globalToolchain) 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/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 56da5f9d7..5964e6c7b 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1379,6 +1379,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)", @@ -1423,6 +1429,7 @@ const catalogs = { "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", @@ -5192,6 +5199,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": "运行环境(本机)", @@ -5235,6 +5247,7 @@ const catalogs = { "maven.mavenExecutable": "Maven 主目录 / 可执行文件", "maven.javaHome": "Maven JDK 主目录", "maven.stop": "停止 Maven 任务", + "maven.cancelled": "已取消", "maven.runSelected": "运行选中的生命周期阶段", "maven.executeGoal": "执行 Maven Goal", "maven.reloadProjects": "重新加载 Maven 项目", From 14d76ce01b681bf8580c4dd04f37212b2fe0583d Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sun, 30 Aug 2026 11:19:39 +0800 Subject: [PATCH 3/5] fix(windows): await Maven context before Run launch Wait for the workspace Maven load before planning Maven-backed Run configurations so project profiles, settings, skip-tests, and toolchain paths are applied consistently. Add a deterministic regression test for the pending-load race. Refs #291 --- .../run/stores/run-maven-context.test.ts | 111 ++++++++++++++++++ .../src/features/run/stores/run.store.ts | 41 +++++-- 2 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 windows/tauri/src/features/run/stores/run-maven-context.test.ts 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..c300887e0 --- /dev/null +++ b/windows/tauri/src/features/run/stores/run-maven-context.test.ts @@ -0,0 +1,111 @@ +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 startRunProcess = mock(async () => undefined); + const stopRunProcess = mock(async () => undefined); + const dependencies: RunStoreDependencies = { + createLaunchPlan, + mavenLaunchContextForWorkspace, + resolveRunLaunch, + 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(); + expect(events).toEqual(["context-started"]); + expect(createLaunchPlan).not.toHaveBeenCalled(); + } finally { + pendingContext.resolve(mavenContext); + await run; + } + + 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", + }), + ); + }); +}); diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 4125d165e..f352c2d80 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -1,7 +1,7 @@ import { createStore } from "zustand/vanilla"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; -import { currentMavenLaunchContext } from "@/features/maven/stores/maven.store"; +import { mavenLaunchContextForWorkspace } from "@/features/maven/stores/maven.store"; import { createLaunchPlan, generateRunConfiguration, @@ -47,6 +47,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"; @@ -100,6 +101,22 @@ interface RunState { }; } +export interface RunStoreDependencies { + createLaunchPlan: typeof createLaunchPlan; + mavenLaunchContextForWorkspace: typeof mavenLaunchContextForWorkspace; + resolveRunLaunch: typeof resolveRunLaunch; + startRunProcess: typeof startRunProcess; + stopRunProcess: typeof stopRunProcess; +} + +const defaultRunStoreDependencies: RunStoreDependencies = { + createLaunchPlan, + mavenLaunchContextForWorkspace, + resolveRunLaunch, + startRunProcess, + stopRunProcess, +}; + interface ResolvedRunProject { configurations: RunConfiguration[]; diagnostics: RunDiagnostic[]; @@ -259,7 +276,10 @@ function readyRunState( }; } -export const createRunStore = (workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId()) => +export const createRunStore = ( + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), + dependencies: RunStoreDependencies = defaultRunStoreDependencies, +) => createStore()((set, get) => ({ root: null, status: "missing", @@ -404,11 +424,18 @@ export const createRunStore = (workspaceId = workspaceRuntimeRegistry.getActiveW configuration.execution === "service" ? configuration.id : PRIMARY_SESSION_ID; bindRunSessionWorkspace(sessionId, workspaceId); resetOutputStamper(sessionId); - await stopRunProcess(sessionId).catch(() => undefined); + await dependencies.stopRunProcess(sessionId).catch(() => undefined); try { - const mavenContext = currentMavenLaunchContext(root, workspaceId); - const plan = await createLaunchPlan(root, configuration.id, currentFile, mavenContext); - const resolved = await resolveRunLaunch({ + 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, @@ -444,7 +471,7 @@ export const createRunStore = (workspaceId = workspaceRuntimeRegistry.getActiveW ], })); } - await startRunProcess({ + await dependencies.startRunProcess({ sessionId, executable: resolved.executable, arguments: plan.arguments, From 459e7c1db0175868e6148e20b6fec89d841da54c Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sun, 30 Aug 2026 12:32:02 +0800 Subject: [PATCH 4/5] fix(windows): save workspace before build launches Await active and pending editor saves before Maven goals or Run configurations create their launch plans. Surface actionable failures and cover save ordering, failures, and the in-flight auto-save race. --- .../services/save-workspace-before-launch.ts | 56 ++++++++++++++ .../editor/stores/editor-app.store.test.ts | 75 ++++++++++++++++++- .../features/maven/stores/maven.store.test.ts | 39 ++++++++++ .../src/features/maven/stores/maven.store.ts | 4 + .../run/stores/run-maven-context.test.ts | 51 ++++++++++++- .../src/features/run/stores/run.store.ts | 37 ++++++--- 6 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 windows/tauri/src/features/editor/services/save-workspace-before-launch.ts 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/maven/stores/maven.store.test.ts b/windows/tauri/src/features/maven/stores/maven.store.test.ts index 740b173fe..ab008831e 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.test.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -60,6 +60,7 @@ const resolveMavenLaunch = mock(async () => ({ workingDirectory: "D:/work/reactor", environment: {}, })); +const saveWorkspaceBeforeLaunch = mock(async (_workspaceId: string): Promise => undefined); const startMavenProcess = mock(async () => undefined); const stopMavenProcess = mock(async () => undefined); @@ -68,6 +69,7 @@ const dependencies = { loadMavenConfiguration, parseMavenDiagnostics, resolveMavenLaunch, + saveWorkspaceBeforeLaunch, scanMavenProject, startMavenProcess, stopMavenProcess, @@ -85,6 +87,8 @@ beforeEach(() => { parseMavenDiagnostics.mockReset(); parseMavenDiagnostics.mockResolvedValue([]); resolveMavenLaunch.mockClear(); + saveWorkspaceBeforeLaunch.mockReset(); + saveWorkspaceBeforeLaunch.mockResolvedValue(undefined); startMavenProcess.mockClear(); stopMavenProcess.mockClear(); }); @@ -264,6 +268,41 @@ describe("Maven workspace state", () => { 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); diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts index efb81b9c6..1f4614012 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -1,4 +1,5 @@ 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 { @@ -41,6 +42,7 @@ export interface MavenStoreDependencies { loadMavenConfiguration: typeof loadMavenConfiguration; parseMavenDiagnostics: typeof parseMavenDiagnostics; resolveMavenLaunch: typeof resolveMavenLaunch; + saveWorkspaceBeforeLaunch: typeof saveWorkspaceBeforeLaunch; scanMavenProject: typeof scanMavenProject; startMavenProcess: typeof startMavenProcess; stopMavenProcess: typeof stopMavenProcess; @@ -52,6 +54,7 @@ const defaultMavenStoreDependencies: MavenStoreDependencies = { loadMavenConfiguration, parseMavenDiagnostics, resolveMavenLaunch, + saveWorkspaceBeforeLaunch, scanMavenProject, startMavenProcess, stopMavenProcess, @@ -406,6 +409,7 @@ export const createMavenStore = ( lastExitCode: null, }); try { + await dependencies.saveWorkspaceBeforeLaunch(workspaceId); const plan = await dependencies.createMavenLaunchPlan( state.root, context, 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 index c300887e0..532645a6a 100644 --- a/windows/tauri/src/features/run/stores/run-maven-context.test.ts +++ b/windows/tauri/src/features/run/stores/run-maven-context.test.ts @@ -71,12 +71,16 @@ describe("Maven-backed Run context", () => { 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, }; @@ -92,13 +96,15 @@ describe("Maven-backed Run context", () => { try { await Promise.resolve(); await Promise.resolve(); - expect(events).toEqual(["context-started"]); + 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( @@ -108,4 +114,47 @@ describe("Maven-backed Run context", () => { }), ); }); + + 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 f352c2d80..d51dbf175 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -1,4 +1,5 @@ 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"; @@ -105,6 +106,7 @@ export interface RunStoreDependencies { createLaunchPlan: typeof createLaunchPlan; mavenLaunchContextForWorkspace: typeof mavenLaunchContextForWorkspace; resolveRunLaunch: typeof resolveRunLaunch; + saveWorkspaceBeforeLaunch: typeof saveWorkspaceBeforeLaunch; startRunProcess: typeof startRunProcess; stopRunProcess: typeof stopRunProcess; } @@ -113,6 +115,7 @@ const defaultRunStoreDependencies: RunStoreDependencies = { createLaunchPlan, mavenLaunchContextForWorkspace, resolveRunLaunch, + saveWorkspaceBeforeLaunch, startRunProcess, stopRunProcess, }; @@ -426,6 +429,7 @@ export const createRunStore = ( resetOutputStamper(sessionId); await dependencies.stopRunProcess(sessionId).catch(() => undefined); try { + await dependencies.saveWorkspaceBeforeLaunch(workspaceId); const mavenContext = configurationUsesMaven(configuration) ? await dependencies.mavenLaunchContextForWorkspace(root, [], workspaceId) : null; @@ -488,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], + }; + }); } } }, From b29da6757a0142c714eb398d2e677f07128b5933 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sun, 30 Aug 2026 21:54:40 +0800 Subject: [PATCH 5/5] fix(windows): isolate Maven reload workspace scope Preserve explicit workspace ownership across Java LSP launch and Maven reload paths, and expose Maven in activity visibility and localization. --- .../editor/components/monaco-editor.tsx | 8 +- .../editor/engines/monaco/definition-link.ts | 13 +- .../editor/hooks/use-lsp-integration.ts | 18 +- .../lsp/java-navigation-marker-loader.test.ts | 4 +- .../lsp/java-navigation-marker-loader.ts | 9 +- .../java-workspace-language-server.test.ts | 59 +++++-- .../lsp/java-workspace-language-server.ts | 38 +++-- .../src/features/editor/lsp/lsp-client.ts | 106 ++++++++---- .../lsp/resolve-editor-lsp-launch.test.ts | 32 ++-- .../editor/lsp/resolve-editor-lsp-launch.ts | 23 ++- .../file-system/stores/file-system.store.ts | 10 +- .../commands/navigation-command-actions.ts | 10 +- .../components/sidebar/main-sidebar.tsx | 96 ++++------- .../features/layout/config/item-order.test.ts | 30 ++++ .../src/features/layout/config/item-order.ts | 29 ++++ .../features/maven/components/maven-pane.tsx | 45 +++-- .../services/reload-maven-workspace.test.ts | 156 ++++++++++++++++++ .../maven/services/reload-maven-workspace.ts | 102 ++++++++++++ .../features/maven/stores/maven.store.test.ts | 55 +++++- .../workspace/types/workspace-launch-scope.ts | 26 +++ windows/tauri/src/i18n/locale.test.ts | 2 + windows/tauri/src/i18n/locale.ts | 2 + 22 files changed, 696 insertions(+), 177 deletions(-) create mode 100644 windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts create mode 100644 windows/tauri/src/features/maven/services/reload-maven-workspace.ts create mode 100644 windows/tauri/src/features/workspace/types/workspace-launch-scope.ts 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 91d95cc67..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( @@ -611,15 +632,15 @@ export class LspClient { 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); @@ -634,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 }); @@ -653,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); @@ -684,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(() => { @@ -718,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) { @@ -745,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, }); @@ -767,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, }); @@ -842,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, }); @@ -918,7 +943,7 @@ export class LspClient { async ensureDocumentReady( target: LspDocumentTargetInput, - workspacePath: string, + scope: WorkspaceLaunchScope, content: string, feature?: string, ): Promise { @@ -931,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; @@ -977,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); } @@ -1010,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 { @@ -1019,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."); } @@ -1038,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 index df24672f5..49ca9d6c9 100644 --- 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 @@ -1,4 +1,8 @@ -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 { resolveEditorLspLaunch } from "./resolve-editor-lsp-launch"; + +afterEach(() => workspaceRuntimeRegistry.resetForTests()); const resolveJavaLspLaunch = mock(async () => ({ providerId: "java", @@ -20,17 +24,23 @@ const mavenLaunchContextForWorkspace = mock(async () => ({ javaHomePath: "C:/Java/jdk-21", })); -mock.module("./java-lsp-host-api", () => ({ resolveJavaLspLaunch })); -mock.module("@/features/maven/stores/maven.store", () => ({ - mavenLaunchContextForWorkspace, -})); - -const { resolveEditorLspLaunch } = await import("./resolve-editor-lsp-launch"); - -test("forwards the current Maven context to the Java language server", async () => { - const launch = await resolveEditorLspLaunch("D:/work/src/App.java", "D:/work"); +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(mavenLaunchContextForWorkspace).toHaveBeenCalledWith("D:/work", ["src/App.java"]); + 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"], 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 0c150c23e..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 @@ -3,6 +3,7 @@ import { isJavaSourcePath, JAVA_LANGUAGE_ID, JAVA_PROVIDER_ID } from "./built-in 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 { @@ -21,14 +22,30 @@ export interface EditorLspLaunch { 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, mavenContext] = await Promise.all([ - resolveJavaLspLaunch(workspacePath), - mavenLaunchContextForWorkspace(workspacePath, [getRelativePath(filePath, workspacePath)]), + dependencies.resolveJavaLspLaunch(workspacePath), + dependencies.mavenLaunchContextForWorkspace( + workspacePath, + [getRelativePath(filePath, workspacePath)], + scope.workspaceId, + ), ]); const environment: Record = {}; if (launch.environment.JAVA_HOME) { 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 f95213eb9..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 @@ -555,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); } @@ -3078,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/layout/components/sidebar/main-sidebar.tsx b/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx index 6fd5e6b4f..395b39e2e 100644 --- a/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx +++ b/windows/tauri/src/features/layout/components/sidebar/main-sidebar.tsx @@ -25,6 +25,11 @@ import { getProjectSwipeBounds, } from "@/features/layout/utils/project-carousel"; import type { SidebarView } from "@/features/layout/utils/sidebar-pane-utils"; +import { + setSidebarActivityItemVisibility, + sidebarActivityVisibilityItemIds, + type SidebarActivityItemId, +} from "@/features/layout/config/item-order"; import { openGlobalSearchSidebar, toggleDiagnosticsPane, @@ -70,6 +75,7 @@ import { GitBranchIcon, GitGraphIcon, MagnifyingGlassIcon, + PackageIcon, TerminalWindowIcon, WarningIcon, } from "@/ui/icons"; @@ -174,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); }, 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 bb4b26747..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,6 +25,34 @@ 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"); }); diff --git a/windows/tauri/src/features/layout/config/item-order.ts b/windows/tauri/src/features/layout/config/item-order.ts index 6afde5cca..fa8edb7d4 100644 --- a/windows/tauri/src/features/layout/config/item-order.ts +++ b/windows/tauri/src/features/layout/config/item-order.ts @@ -31,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/components/maven-pane.tsx b/windows/tauri/src/features/maven/components/maven-pane.tsx index be2242ea3..02bcd39b6 100644 --- a/windows/tauri/src/features/maven/components/maven-pane.tsx +++ b/windows/tauri/src/features/maven/components/maven-pane.tsx @@ -1,7 +1,9 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { open } from "@tauri-apps/plugin-dialog"; -import { getJavaWorkspaceLanguageServerOwner } from "@/features/editor/lsp/java-workspace-language-server"; 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"; @@ -34,6 +36,10 @@ 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, @@ -216,6 +222,7 @@ function MavenSettingsDialog({ 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); @@ -258,6 +265,10 @@ export default function MavenPane() { void ensureMavenProcessListeners(); }, []); + useEffect(() => { + setReloadError(null); + }, [root, workspaceId]); + useEffect(() => { if (!project) return; const initial = new Set([`project:${project.relativePath}`]); @@ -299,26 +310,34 @@ export default function MavenPane() { const reloadJava = async () => { if (!root) return; + const scope = { workspaceId, root }; setReloadError(null); try { - const files = await useFileSystemStore.getState().getAllProjectFiles(); - const javaFile = files - .filter((entry) => !entry.isDir && entry.path.toLowerCase().endsWith(".java")) - .map((entry) => entry.path) - .sort()[0]; - const owner = getJavaWorkspaceLanguageServerOwner(); - await owner.stop(root); - if (javaFile) await owner.prewarm(root, javaFile); - actions.acknowledgeReload(); + await reloadJavaForMavenWorkspace(scope); } catch (error) { - setReloadError(error instanceof Error ? error.message : t("maven.reloadFailed")); + 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; - await actions.loadProject(root, visiblePaths); - if (useMavenStore.getState().project) await reloadJava(); + 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) => { 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 index ab008831e..d56823c94 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.test.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -1,11 +1,18 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import type { MavenDiagnostic, MavenLaunchPlan, MavenProject, MavenStoredConfiguration, } from "../types/maven.types"; -import { createMavenStore, mavenLaunchContext, type MavenStoreDependencies } from "./maven.store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { + createMavenStore, + mavenLaunchContext, + mavenLaunchContextForWorkspace, + useMavenStore, + type MavenStoreDependencies, +} from "./maven.store"; type Deferred = { promise: Promise; @@ -93,7 +100,51 @@ beforeEach(() => { 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: { 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 5964e6c7b..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", @@ -5148,6 +5149,7 @@ const catalogs = { "workbench.database": "数据库", "workbench.settings": "设置", "workbench.run": "运行", + "workbench.maven": "Maven", "workbench.terminal": "终端", "workbench.diagnostics": "诊断", "run.title": "运行",