diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index e22eac633..49da3aab5 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -22,6 +22,9 @@ const SIDECAR_VERSION: u32 = 1; /// Request to validate the layered configuration documents for a workspace. pub struct InspectRequest { pub root: String, + /// Host-owned local layer. When present, Core validates it instead of `.lithe/run/local.json`. + #[serde(default)] + pub local_document: Option, } #[derive(Debug, Deserialize)] @@ -42,6 +45,9 @@ pub struct ResolveRequest { pub root: String, #[serde(default)] pub toolchain_candidates: Vec, + /// Host-owned local layer. When present, Core uses it instead of `.lithe/run/local.json`. + #[serde(default)] + pub local_document: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -71,6 +77,21 @@ pub struct LaunchPlanRequest { pub class_path: Option, #[serde(default)] pub debug_port: Option, + /// Host-owned local layer. When present, Core uses it instead of `.lithe/run/local.json`. + #[serde(default)] + pub local_document: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Global machine toolchain stored at document level in the local layer. +pub struct ToolchainPaths { + #[serde(default)] + pub java_home_path: String, + #[serde(default)] + pub maven_executable_path: String, + #[serde(default)] + pub maven_java_home_path: String, } #[derive(Debug, Deserialize)] @@ -99,6 +120,13 @@ pub struct UpdateOptionsRequest { pub maven_executable_path: String, #[serde(default)] pub maven_java_home_path: String, + /// When present, `updateOptions` writes the document-level global toolchain + /// into the local layer instead of patching one configuration. + #[serde(default)] + pub toolchain: Option, + /// Host-owned local layer used when `scope` is `local` and when resolving first. + #[serde(default)] + pub local_document: Option, } #[derive(Debug, Deserialize)] @@ -318,6 +346,15 @@ pub fn inspect(request: InspectRequest) -> Result { "toolchains/local.json", "project.json", ] { + if relative == "run/local.json" { + if let Some(document) = request.local_document.as_ref() { + let mut migrated = document.clone(); + migrate_document_value(&mut migrated); + validate_version_value(&migrated)?; + configuration_ids(&migrated)?; + continue; + } + } if let Some(document) = read_document_value(&root, relative)? { if relative.starts_with("run/") { validate_version_value(&document)?; @@ -414,6 +451,11 @@ pub fn generate(request: GenerateRequest) -> Result { .filter(|value| value.is_spring_boot) .map(|value| (value.path.clone(), value.qualified_name.clone())) .collect::>(); + let main_class_sources = scanned + .main_classes + .iter() + .map(|value| (value.qualified_name.clone(), value.path.clone())) + .collect::>(); let configurations = scanned .configurations .into_iter() @@ -431,9 +473,27 @@ pub fn generate(request: GenerateRequest) -> Result { .map(|path| maven_module_path(maven_relative_path, path)) .unwrap_or_else(|| ".".to_string()); maven.insert("module".to_string(), json!(module_path)); - if let Some(main_class) = value.main_class { + if let Some(main_class) = value.main_class.as_ref() { maven.insert("mainClass".to_string(), json!(main_class)); } + let source_path = value + .main_class + .as_ref() + .and_then(|name| main_class_sources.get(name)) + .cloned(); + // A workspace without Maven still produces java.main entries. Binding + // project-maven there would force every plain Java class through mvn. + let uses_maven_toolchain = has_maven_project && provider != "java.current-file"; + let mut toolchains = BTreeMap::new(); + toolchains.insert("java".to_string(), "project-jdk".to_string()); + if uses_maven_toolchain { + toolchains.insert("maven".to_string(), "project-maven".to_string()); + } + let mut extensions = BTreeMap::new(); + extensions.insert("maven".to_string(), Value::Object(maven)); + if let Some(path) = source_path.as_ref() { + extensions.insert("java".to_string(), json!({ "source": path })); + } RunConfiguration { id, name: value.name, @@ -451,27 +511,14 @@ pub fn generate(request: GenerateRequest) -> Result { }, env: BTreeMap::new(), confidence: Confidence::Native, - toolchains: if provider == "java.current-file" { - [("java".to_string(), "project-jdk".to_string())] - .into_iter() - .collect() - } else { - [ - ("java".to_string(), "project-jdk".to_string()), - ("maven".to_string(), "project-maven".to_string()), - ] - .into_iter() - .collect() - }, + toolchains, debug: (provider != "java.main").then(|| DebugCapability { adapter: "jdwp".to_string(), }), members: Vec::new(), - extensions: [("maven".to_string(), Value::Object(maven))] - .into_iter() - .collect(), + extensions, disabled: false, - source: None, + source: source_path, } }) .collect::>(); @@ -765,8 +812,7 @@ pub fn resolve(request: ResolveRequest) -> Result { })?; let team = read_document_value(&root, "run/configurations.json")? .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); - let local = read_document_value(&root, "run/local.json")? - .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); + let local = local_layer_document(&root, request.local_document)?; let manifest = read_document_value(&root, "project.json")?; validate_version_value(&generated)?; validate_version_value(&team)?; @@ -789,6 +835,10 @@ pub fn resolve(request: ResolveRequest) -> Result { } } let mut configurations = merge_values(&generated, &team, &local)?; + let global_toolchain = local.get("toolchain").cloned(); + if let Some(toolchain) = global_toolchain.as_ref() { + apply_global_toolchain(&mut configurations, toolchain); + } for configuration in &mut configurations { validate_configuration(configuration)?; if configuration.disabled { @@ -853,17 +903,61 @@ pub fn resolve(request: ResolveRequest) -> Result { "version": VERSION, "configurations": configurations, "diagnostics": diagnostics, - "defaultRunConfiguration": default_run_configuration + "defaultRunConfiguration": default_run_configuration, + "toolchain": global_toolchain })) } +fn apply_global_toolchain(configurations: &mut [RunConfiguration], toolchain: &Value) { + let java_home = toolchain["java"]["homePath"].as_str().unwrap_or(""); + let maven_executable = toolchain["maven"]["executablePath"].as_str().unwrap_or(""); + let maven_java_home = toolchain["maven"]["javaHomePath"].as_str().unwrap_or(""); + for configuration in configurations { + if !configuration.toolchains.contains_key("java") + && !configuration.toolchains.contains_key("maven") + { + continue; + } + let java = configuration + .extensions + .entry("java".to_string()) + .or_insert_with(|| json!({})); + if let Some(object) = java.as_object_mut() { + object.insert("homePath".to_string(), json!(java_home)); + object.insert("mavenExecutablePath".to_string(), json!(maven_executable)); + object.insert("mavenJavaHomePath".to_string(), json!(maven_java_home)); + } + } +} + /// Persists editable configuration options in the requested ownership layer. pub fn update_options(request: UpdateOptionsRequest) -> Result { let root = existing_root(&request.root)?; + if let Some(toolchain) = request.toolchain { + if request.scope != "local" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Toolchain paths can only be saved in the local layer", + )); + } + let mut document = local_layer_document(&root, request.local_document)?; + validate_version_value(&document)?; + document["toolchain"] = json!({ + "java": { "homePath": toolchain.java_home_path }, + "maven": { + "executablePath": toolchain.maven_executable_path, + "javaHomePath": toolchain.maven_java_home_path + } + }); + return Ok(json!({ + "document": serde_json::to_string_pretty(&document).expect("document should encode") + })); + } let relative = scope_document(&request.scope)?; let resolved = resolve(ResolveRequest { root: request.root.clone(), toolchain_candidates: Vec::new(), + local_document: request.local_document.clone(), })?; let provider = resolved["configurations"] .as_array() @@ -892,8 +986,12 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result }, false, )?; - let mut document = read_document_value(&root, relative)? - .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); + let mut document = if request.scope == "local" { + local_layer_document(&root, request.local_document)? + } else { + read_document_value(&root, relative)? + .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})) + }; validate_version_value(&document)?; let configurations = document["configurations"] .as_array_mut() @@ -903,6 +1001,19 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result "cwd": working_directory, "env": request.environment }); + let mut java_extension = serde_json::Map::new(); + if !java_home_path.is_empty() { + java_extension.insert("homePath".to_string(), json!(java_home_path)); + } + if !maven_executable_path.is_empty() { + java_extension.insert( + "mavenExecutablePath".to_string(), + json!(maven_executable_path), + ); + } + if !maven_java_home_path.is_empty() { + java_extension.insert("mavenJavaHomePath".to_string(), json!(maven_java_home_path)); + } if uses_maven_capability { patch["extensions"] = json!({ "maven": { @@ -910,16 +1021,10 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result "programArguments": split_arguments(&request.arguments), "profiles": request.maven_profiles.into_iter().collect::>() }, - "java": { - "homePath": java_home_path, - "mavenExecutablePath": maven_executable_path, - "mavenJavaHomePath": maven_java_home_path - } - }); - } else if !java_home_path.is_empty() { - patch["extensions"] = json!({ - "java": { "homePath": java_home_path } + "java": java_extension }); + } else if !java_extension.is_empty() { + patch["extensions"] = json!({ "java": java_extension }); } else { patch["args"] = json!(split_arguments(&request.arguments)); } @@ -1062,6 +1167,7 @@ pub fn create_launch_plan(request: LaunchPlanRequest) -> Result Result Result Result Result Result<(), CoreEr Ok(()) } +fn local_layer_document(root: &Path, provided: Option) -> Result { + if let Some(mut document) = provided { + // A host-owned local layer may still carry the v1 shape, mirroring the + // migration applied to the on-disk document below. + migrate_document_value(&mut document); + validate_version_value(&document)?; + configuration_ids(&document)?; + return Ok(document); + } + Ok(read_document_value(root, "run/local.json")? + .unwrap_or_else(|| json!({"version": VERSION, "configurations": []}))) +} + fn scope_document(scope: &str) -> Result<&'static str, CoreError> { match scope { "local" => Ok("run/local.json"), diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index 6b56d7668..0f0e6ba00 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -423,6 +423,247 @@ fn ordinary_java_main_uses_an_application_launch_plan() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn plain_java_main_uses_the_jdk_without_maven() { + let root = temporary_root("run-config-plain-java-main"); + let source = "src/com/example/WorkerMain.java"; + fs::create_dir_all(root.join("src/com/example")).unwrap(); + fs::write( + root.join(source), + "package com.example; class WorkerMain { public static void main(String[] args) {} }", + ) + .unwrap(); + + let generated_response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-plain-java-main", + "command": "runConfig.generate", + "payload": {"root": root, "paths": [source], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + let generated = &generated_response["data"]["generated"]; + let java_main = generated["configurations"] + .as_array() + .unwrap() + .iter() + .find(|value| value["provider"] == "java.main") + .unwrap(); + assert_eq!(java_main["toolchains"]["java"], "project-jdk"); + assert!(java_main["toolchains"]["maven"].is_null()); + assert_eq!(java_main["source"], source); + + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(generated).unwrap(), + ) + .unwrap(); + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan-plain-java-main", + "command": "runConfig.createLaunchPlan", + "payload": { + "root": root, + "configurationId": "java-main:com.example.WorkerMain" + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!(plan["data"]["executable"]["toolchain"], "project-jdk"); + assert_eq!(plan["data"]["arguments"], serde_json::json!([source])); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn resolve_prefers_a_host_provided_local_document() { + let root = temporary_root("run-config-host-local"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{"id":"current-file","name":"Current File","provider":"java.current-file","execution":"application","toolchains":{"java":"project-jdk"}}]}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/run/local.json"), + r#"{"version":2,"configurations":[{"id":"current-file","name":"Project Local","provider":"java.current-file","cwd":"."}]}"#, + ) + .unwrap(); + + let resolve: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-host-local", + "command": "runConfig.resolve", + "payload": { + "root": root, + "localDocument": { + "version": 2, + "configurations": [{ + "id": "current-file", + "name": "This PC", + "provider": "java.current-file", + "cwd": "." + }] + } + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolve["ok"], true, "{resolve}"); + let current = resolve["data"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|value| value["id"] == "current-file") + .unwrap(); + assert_eq!(current["name"], "This PC"); + + // A legacy v1 local layer supplied by the host migrates like the on-disk + // document, so an old `.lithe/run/local.json` read by the adapter still works. + let legacy: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-host-local-v1", + "command": "runConfig.resolve", + "payload": { + "root": root, + "localDocument": { + "version": 1, + "configurations": [{ + "id": "current-file", + "name": "Legacy This PC", + "type": "java.current-file", + "programArguments": ["--dev"] + }] + } + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(legacy["ok"], true, "{legacy}"); + let legacy_current = legacy["data"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|value| value["id"] == "current-file") + .unwrap(); + assert_eq!(legacy_current["name"], "Legacy This PC"); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn resolve_applies_a_global_toolchain_to_every_configuration() { + let root = temporary_root("run-config-global-toolchain"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[ + {"id":"spring","name":"Spring","provider":"spring-boot.maven","execution":"service","toolchains":{"java":"project-jdk","maven":"project-maven"},"extensions":{"maven":{"module":"."}}}, + {"id":"plain","name":"Plain","provider":"java.main","execution":"application","toolchains":{"java":"project-jdk"},"extensions":{"java":{"source":"src/App.java"}}} + ]}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/run/local.json"), + r#"{"version":2,"toolchain":{"java":{"homePath":"C:/custom-jdk"},"maven":{"executablePath":"C:/mvn.cmd","javaHomePath":"C:/maven-jdk"}},"configurations":[]}"#, + ) + .unwrap(); + + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-global-toolchain", + "command": "runConfig.resolve", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true, "{resolved}"); + assert_eq!( + resolved["data"]["toolchain"]["java"]["homePath"], + "C:/custom-jdk" + ); + let plain = resolved["data"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|value| value["id"] == "plain") + .unwrap(); + assert_eq!(plain["extensions"]["java"]["homePath"], "C:/custom-jdk"); + assert_eq!( + plain["extensions"]["java"]["mavenExecutablePath"], + "C:/mvn.cmd" + ); + // The global toolchain replaces runtime paths but never the source path. + assert_eq!(plain["extensions"]["java"]["source"], "src/App.java"); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn global_toolchain_updates_only_in_the_local_layer() { + let root = temporary_root("run-config-toolchain-update"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/local.json"), + r#"{"version":2,"configurations":[]}"#, + ) + .unwrap(); + + let updated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "update-global-toolchain", + "command": "runConfig.updateOptions", + "payload": { + "root": root, + "scope": "local", + "configurationId": "unused", + "toolchain": { + "javaHomePath": "C:/jdk-21", + "mavenExecutablePath": "C:/apache-maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/jdk-17" + } + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(updated["ok"], true, "{updated}"); + let document: Value = + serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!(document["toolchain"]["java"]["homePath"], "C:/jdk-21"); + assert_eq!( + document["toolchain"]["maven"]["executablePath"], + "C:/apache-maven/bin/mvn.cmd" + ); + assert_eq!(document["toolchain"]["maven"]["javaHomePath"], "C:/jdk-17"); + + // Project scope must never accept toolchain paths. + let rejected: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "update-global-toolchain-project", + "command": "runConfig.updateOptions", + "payload": { + "root": root, + "scope": "project", + "configurationId": "unused", + "toolchain": {"javaHomePath": "C:/jdk-21"} + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(rejected["ok"], false, "{rejected}"); + + fs::remove_dir_all(root).unwrap(); +} + #[test] fn run_configuration_inspect_reports_malformed_and_unsupported_documents() { let root = temporary_root("run-config-errors"); diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index b7c73e92e..cf2318ffb 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -159,6 +159,8 @@ Platform clients coordinate inspection, generation, resolution, typed document edits, and launch planning, but must not implement a second JSON merger, toolchain matcher, ID generator, argument parser, or Java/Maven argument builder. Opening a project inspects existing files without writing; generation -is an explicit user action. Local absolute paths belong only in -`.lithe/**/local.json` and are excluded from project visibility and Git by -default. +is an explicit user action. Shared project overrides stay in +`.lithe/run/configurations.json`. Machine-local overrides may live in +`.lithe/run/local.json` or in a host-owned document supplied as +`localDocument`; absolute toolchain paths belong only in that local layer and +are excluded from project visibility and Git by default. diff --git a/shared/contracts/run-configuration-v2.schema.json b/shared/contracts/run-configuration-v2.schema.json index 9bf361325..8178e7c28 100644 --- a/shared/contracts/run-configuration-v2.schema.json +++ b/shared/contracts/run-configuration-v2.schema.json @@ -19,6 +19,28 @@ }, "additionalProperties": false }, + "toolchain": { + "type": "object", + "description": "Machine-local global toolchain applied to every configuration. Stored only in the local layer.", + "properties": { + "java": { + "type": "object", + "properties": { + "homePath": { "type": "string" } + }, + "additionalProperties": false + }, + "maven": { + "type": "object", + "properties": { + "executablePath": { "type": "string" }, + "javaHomePath": { "type": "string" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "configurations": { "type": "array", "items": { "$ref": "#/$defs/configuration" } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 9d67fcb20..1cc6fd1ab 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -393,13 +393,19 @@ never writes files. `runConfig.generate` accepts `root`, relative Java `paths`, and relative `modulePaths`; it returns generated configuration and toolchain requirement documents for the platform adapter to write atomically. -`runConfig.resolve` accepts `root` and optional local `toolchainCandidates`. -It merges configurations by stable ID using this precedence: +`runConfig.resolve` accepts `root`, optional local `toolchainCandidates`, and +optional `localDocument`. When `localDocument` is present, Core uses that JSON +object as the local layer instead of reading `.lithe/run/local.json`. It merges +configurations by stable ID using this precedence: `local.json > configurations.json > generated.json`. Scalars and arrays are replaced by the higher layer, while toolchain maps merge by key. It returns -effective configurations, their source, the team default, and structured +effective configurations, their source, the team default, structured diagnostics for stale, orphaned, missing, disabled, and toolchain mismatch -states. +states, and the effective global `toolchain`. A document-level `toolchain` +object in the local layer (e.g. +`{ "java": { "homePath": ... }, "maven": { "executablePath": ..., "javaHomePath": ... } }`) +is applied to every configuration's `extensions.java.*` and is authoritative +over per-configuration toolchain paths. `runConfig.updateOptions` and `runConfig.createUserConfiguration` are pure document transformations. They validate scope, paths, supported types, stable @@ -408,14 +414,22 @@ IDs, main classes, modules, and argument parsing, then return UTF-8 JSON in the file and performs the atomic write. These commands never write files. For project-scoped option updates, selected toolchain paths must resolve inside `root` and are persisted with `/`-separated project-relative paths. Local-scoped -updates may carry host absolute paths. +updates may carry host absolute paths. `runConfig.updateOptions` and +`runConfig.inspect` accept the same optional `localDocument` override. +When `updateOptions` carries a `toolchain` object (`javaHomePath`, +`mavenExecutablePath`, `mavenJavaHomePath`), it writes the document-level +global toolchain into the local layer instead of patching a configuration; +project scope rejects this payload because toolchain paths are machine-local. `runConfig.createLaunchPlan` accepts `root`, `configurationId`, optional -`currentFile` and `classPath`, and optional `debugPort`. It returns a toolchain +`currentFile` and `classPath`, optional `debugPort`, and optional +`localDocument`. It returns a toolchain reference, argument array, project-relative working directory, and structured environment references. It does not return a shell command or platform executable path. All project paths use `/`, reject absolute paths and `..` -traversal, and remain relative to `root`. +traversal, and remain relative to `root`. A `java.main` configuration without a +Maven toolchain launches through `project-jdk` and the configuration's Java +source path. `java.codeVision` accepts a workspace root, a target Java path, and Java source paths. It returns declaration locations and usage counts; Git blame attribution diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 9819f9f54..f94a6e1d6 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -49,6 +49,7 @@ fn main() { )); app.manage(host::FileClipboard::default()); app.manage(run::RunProcessManager::default()); + run::cleanup_legacy_appdata(app.handle()); if let Some(window) = app.get_webview_window("main") { host::apply_window_taskbar_icon(&window); } @@ -97,6 +98,7 @@ fn main() { run::run_list_java_sources, run::run_write_generated, run::run_write_document, + run::run_write_stdin, run::run_discover_toolchains, run::run_resolve_launch, run::run_start_process, diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index d4cc07b0d..d877d6627 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -8,12 +8,12 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::collections::HashMap; use std::fs; -use std::io::Read; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::{Mutex, OnceLock}; use std::thread; -use tauri::{AppHandle, Emitter}; +use tauri::{AppHandle, Emitter, Manager}; const CREATE_NO_WINDOW: u32 = 0x0800_0000; const SKIPPED_DIRECTORIES: &[&str] = &[ @@ -41,6 +41,7 @@ pub struct RunProcessManager; struct RunningSession { pid: u32, + stdin: Option, } impl Default for RunProcessManager { @@ -173,9 +174,17 @@ pub fn run_write_document(args: WriteDocumentArgs) -> Result<(), String> { } #[tauri::command] -pub fn run_discover_toolchains(root: PathBuf) -> Result { +pub fn run_discover_toolchains( + root: PathBuf, + java_home_path: Option, + maven_executable_path: Option, +) -> Result { let project_root = existing_directory(&root).ok(); - Ok(discover_toolchains(project_root.as_deref())) + Ok(discover_toolchains_with_overrides( + project_root.as_deref(), + java_home_path.as_deref(), + maven_executable_path.as_deref(), + )) } #[tauri::command] @@ -225,7 +234,7 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S command .current_dir(&args.working_directory) .envs(&args.environment) - .stdin(Stdio::null()) + .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); apply_creation_flags(&mut command); @@ -233,12 +242,16 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S .spawn() .map_err(|error| format!("Unable to start process: {error}"))?; let pid = child.id(); + let stdin = child.stdin.take(); let stdout = child.stdout.take(); let stderr = child.stderr.take(); sessions() .lock() .map_err(|_| "Run process state is unavailable".to_string())? - .insert(args.session_id.clone(), RunningSession { pid }); + .insert( + args.session_id.clone(), + RunningSession { pid, stdin }, + ); let stdout_reader = spawn_output_reader(app.clone(), args.session_id.clone(), stdout); let stderr_reader = spawn_output_reader(app.clone(), args.session_id.clone(), stderr); @@ -252,6 +265,35 @@ pub fn run_stop_process(session_id: String) -> Result<(), String> { Ok(()) } +#[tauri::command] +pub fn run_write_stdin(session_id: String, input: String) -> Result<(), String> { + let mut current = sessions() + .lock() + .map_err(|_| "Run process state is unavailable".to_string())?; + let session = current + .get_mut(&session_id) + .ok_or_else(|| "The run process is no longer active.".to_string())?; + let stdin = session + .stdin + .as_mut() + .ok_or_else(|| "The run process does not accept input.".to_string())?; + stdin + .write_all(input.as_bytes()) + .map_err(|error| format!("Could not write to process input: {error}")) +} + +/// Removes the abandoned `run/` app-data directory written by an earlier +/// implementation. Other app-data content (window state, settings) is kept. +pub fn cleanup_legacy_appdata(app: &AppHandle) { + let Ok(app_dir) = app.path().app_data_dir() else { + return; + }; + let run_dir = app_dir.join("run"); + if run_dir.is_dir() { + let _ = fs::remove_dir_all(&run_dir); + } +} + fn write_generated_documents( root: &Path, generated: &Value, @@ -381,9 +423,21 @@ fn pretty_json(value: &Value) -> Result { } pub(crate) fn discover_toolchains(project_root: Option<&Path>) -> DiscoveredToolchains { + discover_toolchains_with_overrides(project_root, None, None) +} + +fn discover_toolchains_with_overrides( + project_root: Option<&Path>, + java_home_path: Option<&str>, + maven_executable_path: Option<&str>, +) -> DiscoveredToolchains { let mut java = Vec::new(); let mut seen_homes = std::collections::HashSet::new(); - for home in java_home_candidates(project_root) { + let mut homes = java_home_candidates(project_root); + if let Some(path) = java_home_path.filter(|value| !value.trim().is_empty()) { + homes.insert(0, PathBuf::from(path)); + } + for home in homes { if !seen_homes.insert(home.clone()) { continue; } @@ -395,7 +449,11 @@ pub(crate) fn discover_toolchains(project_root: Option<&Path>) -> DiscoveredTool let mut maven = Vec::new(); let mut seen_executables = std::collections::HashSet::new(); - for executable in maven_executable_candidates(project_root) { + let mut executables = maven_executable_candidates(project_root); + if let Some(path) = maven_executable_path.filter(|value| !value.trim().is_empty()) { + executables.insert(0, PathBuf::from(path)); + } + for executable in executables { if !seen_executables.insert(executable.clone()) { continue; } @@ -1153,6 +1211,13 @@ mod tests { assert!(looks_like_real_utf8(b"[INFO] BUILD SUCCESS")); } + #[test] + fn stdin_write_rejects_an_inactive_session() { + let session_id = format!("missing-{}", std::process::id()); + let error = run_write_stdin(session_id, "input\n".to_string()).unwrap_err(); + assert_eq!(error, "The run process is no longer active."); + } + #[cfg(windows)] #[test] fn windows_gbk_bytes_decode_to_chinese() { diff --git a/windows/tauri/src/features/git/api/git-integration-api.test.ts b/windows/tauri/src/features/git/api/git-integration-api.test.ts index 6731ea69b..2fc94359c 100644 --- a/windows/tauri/src/features/git/api/git-integration-api.test.ts +++ b/windows/tauri/src/features/git/api/git-integration-api.test.ts @@ -1,13 +1,11 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import * as gitEvents from "../events/git-events"; import type { GitOperationState } from "../types/git.types"; const invoke = mock(async (_command: string, _args?: unknown): Promise => null); -const emitGitChanged = mock((_change: unknown) => {}); -const resolveRepositoryPathOrThrow = mock(async (repoPath: string) => repoPath); +const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); mock.module("@/platform/tauri-core", () => ({ invoke })); -mock.module("../events/git-events", () => ({ emitGitChanged })); -mock.module("./git-repo-api", () => ({ resolveRepositoryPathOrThrow })); const { getConflictMarkerPaths, getOperationState, mergeBranch, rebaseOntoBranch } = await import( "./git-integration-api" @@ -26,14 +24,13 @@ const operationState = ( beforeEach(() => { invoke.mockReset(); - emitGitChanged.mockReset(); - resolveRepositoryPathOrThrow.mockReset(); - resolveRepositoryPathOrThrow.mockImplementation(async (repoPath: string) => repoPath); + emitGitChanged.mockClear(); }); describe("Git integration state", () => { test("reports a stopped rebase even when no conflicted paths remain", async () => { invoke.mockImplementation(async (command: string) => { + if (command === "git_discover_repo") return "C:/repo"; if (command === "git_integration_preflight") { return { blockingPaths: [], blocksEntirely: false }; } @@ -52,6 +49,7 @@ describe("Git integration state", () => { test("reports conflicted paths when a merge stops on conflicts", async () => { invoke.mockImplementation(async (command: string) => { + if (command === "git_discover_repo") return "C:/repo"; if (command === "git_integration_preflight") { return { blockingPaths: [], blocksEntirely: false }; } @@ -69,13 +67,19 @@ describe("Git integration state", () => { }); test("propagates operation state query failures", async () => { - invoke.mockRejectedValue(new Error("Core unavailable")); + invoke.mockImplementation(async (command: string) => { + if (command === "git_discover_repo") return "C:/repo"; + throw new Error("Core unavailable"); + }); await expect(getOperationState("C:/repo")).rejects.toThrow("Core unavailable"); }); test("propagates conflict marker query failures so commits fail closed", async () => { - invoke.mockRejectedValue(new Error("Core unavailable")); + invoke.mockImplementation(async (command: string) => { + if (command === "git_discover_repo") return "C:/repo"; + throw new Error("Core unavailable"); + }); await expect(getConflictMarkerPaths("C:/repo")).rejects.toThrow("Core unavailable"); }); diff --git a/windows/tauri/src/features/git/api/git-remotes-api.test.ts b/windows/tauri/src/features/git/api/git-remotes-api.test.ts index 59fcee244..0402cae63 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.test.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.test.ts @@ -1,22 +1,15 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import * as gitEvents from "../events/git-events"; import type { GitPullPreflight } from "../types/git.types"; const invoke = mock(async (_command: string, _args?: unknown): Promise => null); -const emitGitChanged = mock((_change: unknown) => {}); -const resolveRepositoryPath = mock(async (repoPath: string) => repoPath); -const resolveRepositoryPathOrThrow = mock(async (repoPath: string) => repoPath); +const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); const getOperationState = mock(async () => null); const getBranches = mock(async () => []); const getGitHistory = mock(async () => null); const getGitStatus = mock(async () => null); mock.module("@/platform/tauri-core", () => ({ invoke })); -mock.module("../events/git-events", () => ({ emitGitChanged })); -mock.module("./git-repo-api", () => ({ - isNotGitRepositoryError: () => false, - resolveRepositoryPath, - resolveRepositoryPathOrThrow, -})); mock.module("./git-integration-api", () => ({ getOperationState })); mock.module("./git-branches-api", () => ({ getBranches })); mock.module("./git-commits-api", () => ({ getGitHistory })); @@ -27,14 +20,10 @@ const { executePullChanges, fetchChanges, getGitPullWorkflow, getPullPreflight, beforeEach(() => { invoke.mockReset(); - emitGitChanged.mockReset(); - resolveRepositoryPath.mockReset(); - resolveRepositoryPathOrThrow.mockReset(); + emitGitChanged.mockClear(); getBranches.mockClear(); getGitHistory.mockClear(); getGitStatus.mockClear(); - resolveRepositoryPath.mockImplementation(async (repoPath: string) => repoPath); - resolveRepositoryPathOrThrow.mockImplementation(async (repoPath: string) => repoPath); }); describe("Git remote Pull API", () => { @@ -51,14 +40,18 @@ describe("Git remote Pull API", () => { diverged: true, hasLocalChanges: false, }; - invoke.mockResolvedValue(preflight); + invoke.mockImplementation(async (command: string) => + command === "git_discover_repo" ? "C:/repo" : preflight, + ); await expect(getPullPreflight("C:/repo")).resolves.toEqual(preflight); expect(invoke).toHaveBeenCalledWith("git.pullPreflight", { repoPath: "C:/repo" }); }); test("executes Pull with only the selected Core mode", async () => { - invoke.mockResolvedValue(null); + invoke.mockImplementation(async (command: string) => + command === "git_discover_repo" ? "C:/repo" : null, + ); await expect(executePullChanges("C:/repo", "rebase")).resolves.toEqual({ success: true }); expect(invoke).toHaveBeenCalledWith("git_pull", { @@ -68,7 +61,9 @@ describe("Git remote Pull API", () => { }); test("fetches the repository without an ignored remote parameter", async () => { - invoke.mockResolvedValue(null); + invoke.mockImplementation(async (command: string) => + command === "git_discover_repo" ? "C:/repo" : null, + ); await expect(fetchChanges("C:/repo")).resolves.toEqual({ success: true }); expect(invoke).toHaveBeenCalledWith("git_fetch", { repoPath: "C:/repo" }); @@ -76,6 +71,7 @@ describe("Git remote Pull API", () => { test("keeps a headless divergent Pull on the safe Cancel default", async () => { invoke.mockImplementation(async (command: string) => { + if (command === "git_discover_repo") return "C:/repo"; if (command === "git.pullPreflight") { return { upstream: "origin/main", 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 new file mode 100644 index 000000000..e99e5da0c --- /dev/null +++ b/windows/tauri/src/features/run/api/run-core-api.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const executeCore = mock(async () => ({ + id: "request", + ok: true as const, + data: { document: "{}" }, +})); + +mock.module("@/core/lithe-core-client", () => ({ executeCore })); + +const { updateGlobalToolchain, updateRunOptions } = await import("./run-core-api"); + +beforeEach(() => { + executeCore.mockClear(); +}); + +describe("updateRunOptions", () => { + test("sends a project-relative working directory and empty toolchain paths in project scope", async () => { + await updateRunOptions("D:/fixture/project", "plain-java", "project", { + javaHomePath: "D:\\fixture\\project\\toolchains\\jdk", + mavenExecutablePath: "D:/fixture/project/toolchains/maven/bin/mvn.cmd", + mavenJavaHomePath: "D:/fixture/project/toolchains/maven-jdk", + workingDirectoryPath: "D:/fixture/project/app", + vmArguments: "-Xmx2g", + programArguments: "--dev", + environment: { APP_ENV: "dev" }, + }); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + command: "runConfig.updateOptions", + payload: expect.objectContaining({ + root: "D:/fixture/project", + scope: "project", + configurationId: "plain-java", + workingDirectory: "app", + jvmArguments: "-Xmx2g", + arguments: "--dev", + environment: { APP_ENV: "dev" }, + mavenProfiles: [], + javaHomePath: "", + mavenExecutablePath: "", + mavenJavaHomePath: "", + }), + }), + ); + }); + + test("keeps the working directory absolute when saving to local scope", async () => { + await updateRunOptions("D:/fixture/project", "plain-java", "local", { + javaHomePath: "C:/Program Files/Java/jdk-21", + mavenExecutablePath: "", + mavenJavaHomePath: "", + workingDirectoryPath: "D:/fixture/project/app", + vmArguments: "", + programArguments: "", + environment: {}, + }); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + workingDirectory: "D:/fixture/project/app", + javaHomePath: "", + mavenExecutablePath: "", + mavenJavaHomePath: "", + }), + }), + ); + }); + + test("rejects a project working directory outside the workspace", () => { + expect(() => + updateRunOptions("D:/fixture/project", "plain-java", "project", { + javaHomePath: "", + mavenExecutablePath: "", + mavenJavaHomePath: "", + workingDirectoryPath: "E:/outside", + vmArguments: "", + programArguments: "", + environment: {}, + }), + ).toThrow("Project working directory must stay inside the workspace."); + expect(executeCore).not.toHaveBeenCalled(); + }); +}); + +describe("updateGlobalToolchain", () => { + test("writes the global toolchain into the local layer", async () => { + await updateGlobalToolchain("D:/fixture/project", { + javaHomePath: "C:/Program Files/Java/jdk-21", + mavenExecutablePath: "D:/Tools/apache-maven/bin/mvn.cmd", + mavenJavaHomePath: "C:/Program Files/Java/jdk-17", + }); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + command: "runConfig.updateOptions", + payload: { + root: "D:/fixture/project", + scope: "local", + configurationId: "toolchain", + toolchain: { + javaHomePath: "C:/Program Files/Java/jdk-21", + mavenExecutablePath: "D:/Tools/apache-maven/bin/mvn.cmd", + mavenJavaHomePath: "C:/Program Files/Java/jdk-17", + }, + }, + }), + ); + }); +}); 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 fd2405c6e..7bd149e92 100644 --- a/windows/tauri/src/features/run/api/run-core-api.ts +++ b/windows/tauri/src/features/run/api/run-core-api.ts @@ -3,10 +3,12 @@ import type { CoreGenerateResult, CoreInspectResult, CoreResolveResult, + GlobalToolchain, LaunchPlan, RunOptions, RunSaveScope, } from "../types/run.types"; +import { projectScopedPath } from "../utils/run-configuration"; let requestSequence = 0; @@ -70,17 +72,32 @@ export function updateRunOptions( scope: RunSaveScope, options: RunOptions, ) { + const workingDirectory = scope === "project" + ? projectScopedPath(root, options.workingDirectoryPath) + : options.workingDirectoryPath; + if (workingDirectory === undefined) { + throw new Error("Project working directory must stay inside the workspace."); + } return runCore<{ document: string }>("runConfig.updateOptions", { root, scope, configurationId, - workingDirectory: options.workingDirectoryPath, + workingDirectory, jvmArguments: options.vmArguments, arguments: options.programArguments, environment: options.environment, mavenProfiles: [], - javaHomePath: scope === "local" ? options.javaHomePath : "", - mavenExecutablePath: scope === "local" ? options.mavenExecutablePath : "", - mavenJavaHomePath: scope === "local" ? options.mavenJavaHomePath : "", + javaHomePath: "", + mavenExecutablePath: "", + mavenJavaHomePath: "", + }); +} + +export function updateGlobalToolchain(root: string, toolchain: GlobalToolchain) { + return runCore<{ document: string }>("runConfig.updateOptions", { + root, + scope: "local", + configurationId: "toolchain", + toolchain, }); } diff --git a/windows/tauri/src/features/run/api/run-host-api.ts b/windows/tauri/src/features/run/api/run-host-api.ts index d486e3dc1..1646ec636 100644 --- a/windows/tauri/src/features/run/api/run-host-api.ts +++ b/windows/tauri/src/features/run/api/run-host-api.ts @@ -1,5 +1,5 @@ import { invoke } from "@/platform/tauri-core"; -import type { JavaRuntime, MavenRuntime } from "../types/run.types"; +import type { GlobalToolchain, JavaRuntime, MavenRuntime } from "../types/run.types"; export function listJavaSources(root: string) { return invoke("run_list_java_sources", { root }); @@ -20,8 +20,16 @@ export function writeRunDocument(root: string, relativePath: string, contents: s }); } -export function discoverRunToolchains(root: string) { - return invoke<{ java: JavaRuntime[]; maven: MavenRuntime[] }>("run_discover_toolchains", { root }); +export function writeRunStdin(sessionId: string, input: string) { + return invoke("run_write_stdin", { sessionId, input }); +} + +export function discoverRunToolchains(root: string, selected?: GlobalToolchain) { + return invoke<{ java: JavaRuntime[]; maven: MavenRuntime[] }>("run_discover_toolchains", { + root, + javaHomePath: selected?.javaHomePath, + mavenExecutablePath: selected?.mavenExecutablePath, + }); } export function resolveRunLaunch(args: { 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 141ef3562..3f4080980 100644 --- a/windows/tauri/src/features/run/components/run-configuration-editor.tsx +++ b/windows/tauri/src/features/run/components/run-configuration-editor.tsx @@ -4,43 +4,156 @@ import { Button } from "@/ui/button"; import Dialog from "@/ui/dialog"; import { Field, FieldDescription, FieldLabel } from "@/ui/field"; import Input from "@/ui/input"; +import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; import { FolderIcon, PlayIcon } from "@/ui/icons"; import { useTranslation } from "@/i18n/locale-provider"; -import type { RunConfiguration, RunOptions, RunSaveScope } from "../types/run.types"; -import { environmentFromText, environmentText } from "../utils/run-configuration"; +import type { + GlobalToolchain, + JavaRuntime, + MavenRuntime, + RunConfiguration, + RunOptions, + RunSaveScope, +} from "../types/run.types"; +import { + environmentFromText, + environmentText, + saveRunConfigurationChanges, +} from "../utils/run-configuration"; interface RunConfigurationEditorProps { configuration: RunConfiguration; options: RunOptions; saveError: string | null; + discoveredJava: JavaRuntime[]; + discoveredMaven: MavenRuntime[]; + globalToolchain: GlobalToolchain; onClose: () => void; onSave: (options: RunOptions, scope: RunSaveScope) => Promise; + onSaveToolchain: (toolchain: GlobalToolchain) => Promise; +} + +interface ToolchainFieldProps { + id: string; + label: string; + hint: string; + value: string; + autoLabel: string; + customLabel: string; + candidates: Array<{ value: string; label: string }>; + onSelect: (value: string) => void; + onPick: () => void; +} + +function ToolchainField({ + id, + label, + hint, + value, + autoLabel, + customLabel, + candidates, + onSelect, + onPick, +}: ToolchainFieldProps) { + const options = [{ value: "", label: autoLabel }, ...candidates]; + const hasCustomValue = Boolean(value) && !options.some((option) => option.value === value); + if (hasCustomValue) { + options.push({ value, label: `${customLabel}: ${value}` }); + } + return ( + + {label} +
+ onSelect(event.target.value)} + > + {options.map((option) => ( + + {option.label} + + ))} + + +
+ {hint} +
+ ); } export function RunConfigurationEditor({ configuration, options, saveError, + discoveredJava, + discoveredMaven, + globalToolchain, onClose, onSave, + onSaveToolchain, }: RunConfigurationEditorProps) { const { t } = useTranslation(); const [draft, setDraft] = useState(options); + const [toolchainDraft, setToolchainDraft] = useState(globalToolchain); const [scope, setScope] = useState("local"); const [envText, setEnvText] = useState(environmentText(options.environment)); const [saving, setSaving] = useState(false); - const pickDirectory = async (field: "javaHomePath" | "mavenJavaHomePath" | "workingDirectoryPath") => { - const selected = await open({ directory: true, multiple: false }); - if (typeof selected === "string" && selected) { - setDraft((current) => ({ ...current, [field]: selected })); - } + const projectUsesMaven = discoveredMaven.length > 0; + const javaCandidates = discoveredJava.map((runtime) => ({ + value: runtime.homePath, + label: runtime.version ? `${runtime.homePath} (${runtime.version})` : runtime.homePath, + })); + const mavenCandidates = discoveredMaven.map((runtime) => ({ + value: runtime.executablePath, + label: runtime.version + ? `${runtime.executablePath} (${runtime.version})` + : runtime.executablePath, + })); + + const pickDirectory = (field: "javaHomePath" | "mavenJavaHomePath" | "workingDirectoryPath") => { + void open({ directory: true, multiple: false }).then((selected) => { + if (typeof selected === "string" && selected) { + setDraft((current) => ({ ...current, [field]: selected })); + } + }); }; - const pickFile = async () => { - const selected = await open({ multiple: false }); - if (typeof selected === "string" && selected) { - setDraft((current) => ({ ...current, mavenExecutablePath: selected })); + const pickToolchainDirectory = (field: "javaHomePath" | "mavenJavaHomePath") => { + void open({ directory: true, multiple: false }).then((selected) => { + if (typeof selected === "string" && selected) { + setToolchainDraft((current) => ({ ...current, [field]: selected })); + } + }); + }; + + const pickMavenExecutable = () => { + void open({ multiple: false }).then((selected) => { + if (typeof selected === "string" && selected) { + setToolchainDraft((current) => ({ ...current, mavenExecutablePath: selected })); + } + }); + }; + + const save = async () => { + setSaving(true); + const runOptions = { ...draft, environment: environmentFromText(envText) }; + try { + // Local options and the global toolchain share run/local.json. Save them + // in sequence so the option mutation reads the toolchain write instead + // of racing two complete-document replacements against each other. + const saved = await saveRunConfigurationChanges( + () => onSaveToolchain(toolchainDraft), + () => onSave(runOptions, scope), + ); + if (saved) onClose(); + } finally { + setSaving(false); } }; @@ -50,128 +163,145 @@ export function RunConfigurationEditor({ icon={PlayIcon} onClose={onClose} size="lg" + classNames={{ modal: "h-[min(82vh,40rem)]" }} footer={ <> {saveError ? {saveError} : } - } > -
-
-
{t("run.saveScope")}
-
- - -
-

- {scope === "local" ? t("run.saveScopeLocalHint") : t("run.saveScopeProjectHint")} -

-
- -
-
{t("run.configuration")}
-
- {t("run.type")} - {configuration.kindTitle} - {t("run.effectiveSource")} - {t(`run.source.${configuration.source}`)} - {configuration.mainClass ? ( - <> - {t("run.mainClass")} - {configuration.mainClass} - - ) : null} +
+
+
+ {t("run.runtimeSection")} · {t("run.saveScopeLocal")}
+

{t("run.saveScopeLocalHint")}

+ setToolchainDraft((current) => ({ ...current, javaHomePath: value }))} + onPick={() => pickToolchainDirectory("javaHomePath")} + /> + {projectUsesMaven ? ( + <> + setToolchainDraft((current) => ({ ...current, mavenExecutablePath: value }))} + onPick={pickMavenExecutable} + /> + setToolchainDraft((current) => ({ ...current, mavenJavaHomePath: value }))} + onPick={() => pickToolchainDirectory("mavenJavaHomePath")} + /> + + ) : null}
- - {t("run.jdkHome")} -
- setDraft({ ...draft, javaHomePath: event.target.value })} - className="font-mono" - /> - +
+
+
{t("run.configuration")}
+
+ {t("run.type")} + {configuration.kindTitle} + {t("run.effectiveSource")} + {t(`run.source.${configuration.source}`)} + {configuration.mainClass ? ( + <> + {t("run.mainClass")} + {configuration.mainClass} + + ) : null} +
- {t("run.jdkHomeHint")} - - - {t("run.mavenExecutable")} -
- setDraft({ ...draft, mavenExecutablePath: event.target.value })} - className="font-mono" - /> - +
+
{t("run.saveScope")}
+
+ + +
+

+ {scope === "local" ? t("run.saveScopeLocalHint") : t("run.saveScopeProjectHint")} +

- {t("run.mavenExecutableHint")} - - - {t("run.mavenJdkHome")} -
- setDraft({ ...draft, mavenJavaHomePath: event.target.value })} - className="font-mono" - /> - +
+ + {t("run.programArguments")} + setDraft({ ...draft, programArguments: event.target.value })} + className="font-mono" + /> + + + {t("run.vmArguments")} + setDraft({ ...draft, vmArguments: event.target.value })} + className="font-mono" + /> + + + {t("run.workingDirectory")} + setDraft({ ...draft, workingDirectoryPath: event.target.value })} + className="font-mono" + /> + {t("run.workingDirectoryHint")} + + + {t("run.environment")} + setEnvText(event.target.value)} + className="font-mono" + placeholder="KEY=VALUE" + /> +
- {t("run.mavenJdkHomeHint")} - - - - {t("run.workingDirectory")} - setDraft({ ...draft, workingDirectoryPath: event.target.value })} - className="font-mono" - /> - {t("run.workingDirectoryHint")} - +
); diff --git a/windows/tauri/src/features/run/components/run-pane.tsx b/windows/tauri/src/features/run/components/run-pane.tsx index 0f4257b43..d13b0aa54 100644 --- a/windows/tauri/src/features/run/components/run-pane.tsx +++ b/windows/tauri/src/features/run/components/run-pane.tsx @@ -20,7 +20,7 @@ import Tooltip from "@/ui/tooltip"; import { cn } from "@/utils/cn"; import { ensureRunProcessListeners } from "../hooks/use-run-process-events"; import { runOptionsFor, useRunStore } from "../stores/run.store"; -import type { RunConfiguration } from "../types/run.types"; +import { PRIMARY_SESSION_ID, type RunConfiguration } from "../types/run.types"; import { configurationsForExecution, isBlockingToolchainDiagnostic, @@ -53,6 +53,9 @@ export default function RunPane() { const invalidMessage = useRunStore((state) => state.invalidMessage); const saveError = useRunStore((state) => state.saveError); const generationNotice = useRunStore((state) => state.generationNotice); + const discoveredJava = useRunStore((state) => state.discoveredJava); + const discoveredMaven = useRunStore((state) => state.discoveredMaven); + const globalToolchain = useRunStore((state) => state.globalToolchain); const actions = useRunStore((state) => state.actions); const [editingId, setEditingId] = useState(null); @@ -231,6 +234,12 @@ export default function RunPane() { {output || t("run.emptyOutput")}
+ {isSelectedRunning ? ( + void actions.writeStdin(selectedSessionId ?? PRIMARY_SESSION_ID, input)} + /> + ) : null} {generationNotice?.startsWith("generated:") ? (
{t("run.generatedEntries", { count: generationNotice.slice("generated:".length) })} @@ -245,14 +254,52 @@ export default function RunPane() { configuration={editingConfiguration} options={runOptionsFor(editingConfiguration)} saveError={saveError} + discoveredJava={discoveredJava} + discoveredMaven={discoveredMaven} + globalToolchain={globalToolchain} onClose={() => setEditingId(null)} onSave={(options, scope) => actions.saveOptions(editingConfiguration, options, scope)} + onSaveToolchain={(toolchain) => actions.saveToolchain(toolchain)} /> ) : null}
); } +function RunStdinInput({ + sessionId, + onSend, +}: { + sessionId: string; + onSend: (input: string) => void; +}) { + const { t } = useTranslation(); + const [text, setText] = useState(""); + const submit = () => { + const value = text.trim(); + if (!value) return; + onSend(`${value}\n`); + setText(""); + }; + return ( +
+ setText(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") submit(); + }} + placeholder={t("run.stdinPlaceholder")} + className="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-[12px] text-foreground outline-none focus-visible:border-ring" + aria-label={t("run.stdinPlaceholder")} + /> + +
+ ); +} + function ConfigurationSection({ title, configurations, diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index f3a180d54..e965295ca 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -6,6 +6,7 @@ import { generateRunConfiguration, inspectRunConfiguration, resolveRunConfiguration, + updateGlobalToolchain, updateRunOptions, } from "../api/run-core-api"; import { @@ -16,11 +17,16 @@ import { stopRunProcess, writeGeneratedRunDocuments, writeRunDocument, + writeRunStdin, } from "../api/run-host-api"; import { CURRENT_FILE_ID, + EMPTY_GLOBAL_TOOLCHAIN, EMPTY_RUN_OPTIONS, PRIMARY_SESSION_ID, + type GlobalToolchain, + type JavaRuntime, + type MavenRuntime, type RunConfiguration, type RunConfigurationStatus, type RunDiagnostic, @@ -33,10 +39,12 @@ import { defaultGeneratedConfigurationId, isBlockingToolchainDiagnostic, mapCoreConfiguration, + mapCoreToolchain, mapDiagnostics, mergeLaunchEnvironment, recoveryActionForError, recoveryPathFromMessage, + selectedToolchainCandidates, } from "../utils/run-configuration"; const MAXIMUM_OUTPUT_CHARACTERS = 500_000; @@ -62,6 +70,9 @@ interface RunState { selectedSessionId: string | null; saveError: string | null; generationNotice: string | null; + discoveredJava: JavaRuntime[]; + discoveredMaven: MavenRuntime[]; + globalToolchain: GlobalToolchain; actions: { loadProject: (root: string) => Promise; generate: (root: string) => Promise; @@ -75,6 +86,8 @@ interface RunState { options: RunOptions, scope: RunSaveScope, ) => Promise; + saveToolchain: (toolchain: GlobalToolchain) => Promise; + writeStdin: (sessionId: string, input: string) => Promise; appendOutput: (sessionId: string, chunk: string) => void; finishProcess: (sessionId: string, exitCode: number) => void; }; @@ -101,27 +114,33 @@ async function resolveConfigurations(root: string): Promise<{ configurations: RunConfiguration[]; diagnostics: RunDiagnostic[]; defaultConfigurationId: string | null; + discoveredJava: JavaRuntime[]; + discoveredMaven: MavenRuntime[]; + globalToolchain: GlobalToolchain; }> { - const discovered = await discoverRunToolchains(root); - const toolchainCandidates = [ - ...discovered.java.slice(0, 1).map((runtime) => ({ - id: "project-jdk", - type: "java", - version: runtime.version, - vendor: runtime.vendor, - })), - ...discovered.maven.slice(0, 1).map((runtime) => ({ - id: "project-maven", - type: "maven", - version: runtime.version, - vendor: "", - })), - ]; - const resolved = await resolveRunConfiguration(root, toolchainCandidates); + const automatic = await discoverRunToolchains(root); + const preliminary = await resolveRunConfiguration( + root, + selectedToolchainCandidates(automatic, EMPTY_GLOBAL_TOOLCHAIN), + ); + const globalToolchain = mapCoreToolchain(preliminary.toolchain); + const hasSelectedToolchain = Boolean( + globalToolchain.javaHomePath || globalToolchain.mavenExecutablePath, + ); + const discovered = hasSelectedToolchain + ? await discoverRunToolchains(root, globalToolchain) + : automatic; + const candidates = selectedToolchainCandidates(discovered, globalToolchain); + const resolved = hasSelectedToolchain + ? await resolveRunConfiguration(root, candidates) + : preliminary; return { configurations: (resolved.configurations ?? []).map(mapCoreConfiguration), diagnostics: mapDiagnostics(resolved.diagnostics), defaultConfigurationId: resolved.defaultRunConfiguration ?? null, + discoveredJava: discovered.java, + discoveredMaven: discovered.maven, + globalToolchain, }; } @@ -144,6 +163,9 @@ export const createRunStore = () => selectedSessionId: null, saveError: null, generationNotice: null, + discoveredJava: [], + discoveredMaven: [], + globalToolchain: EMPTY_GLOBAL_TOOLCHAIN, actions: { loadProject: async (root) => { set({ @@ -181,6 +203,9 @@ export const createRunStore = () => configurations: resolved.configurations, selectedConfigurationId, defaultConfigurationId: resolved.defaultConfigurationId, + discoveredJava: resolved.discoveredJava, + discoveredMaven: resolved.discoveredMaven, + globalToolchain: resolved.globalToolchain, isLoading: false, }); } catch (error) { @@ -226,6 +251,9 @@ export const createRunStore = () => resolved.configurations.find((configuration) => configuration.id !== CURRENT_FILE_ID)?.id ?? null, defaultConfigurationId: resolved.defaultConfigurationId, + discoveredJava: resolved.discoveredJava, + discoveredMaven: resolved.discoveredMaven, + globalToolchain: resolved.globalToolchain, generationNotice: notice, isGenerating: false, isLoading: false, @@ -382,6 +410,32 @@ export const createRunStore = () => } }, + saveToolchain: async (toolchain) => { + const root = get().root; + if (!root) return false; + try { + const mutation = await updateGlobalToolchain(root, toolchain); + await writeRunDocument(root, "run/local.json", mutation.document); + await get().actions.loadProject(root); + set({ saveError: null }); + return true; + } catch (error) { + set({ + saveError: error instanceof Error ? error.message : "Could not save the toolchain.", + }); + return false; + } + }, + + writeStdin: async (sessionId, input) => { + try { + await writeRunStdin(sessionId, input); + } catch (error) { + const message = error instanceof Error ? error.message : "Could not write to process input."; + get().actions.appendOutput(sessionId, `${message}\n`); + } + }, + appendOutput: (sessionId, chunk) => { if (sessionId === PRIMARY_SESSION_ID) { set({ primaryOutput: trimOutput(get().primaryOutput + chunk) }); diff --git a/windows/tauri/src/features/run/types/run.types.ts b/windows/tauri/src/features/run/types/run.types.ts index 4454d5a74..46a40cc67 100644 --- a/windows/tauri/src/features/run/types/run.types.ts +++ b/windows/tauri/src/features/run/types/run.types.ts @@ -33,6 +33,7 @@ export interface RunConfiguration { javaHomePath: string; mavenExecutablePath: string; mavenJavaHomePath: string; + toolchains: Record; source: RunConfigurationSource; disabled: boolean; } @@ -93,6 +94,18 @@ export interface CoreResolveResult { configurations: CoreResolvedConfiguration[]; diagnostics?: Array>; defaultRunConfiguration?: string | null; + toolchain?: CoreGlobalToolchain | null; +} + +export interface CoreGlobalToolchain { + java?: { homePath?: string }; + maven?: { executablePath?: string; javaHomePath?: string }; +} + +export interface GlobalToolchain { + javaHomePath: string; + mavenExecutablePath: string; + mavenJavaHomePath: string; } export interface CoreResolvedConfiguration { @@ -103,6 +116,7 @@ export interface CoreResolvedConfiguration { args?: string[]; cwd?: string; env?: Record; + toolchains?: Record; source?: string; disabled?: boolean; extensions?: { @@ -133,3 +147,9 @@ export const EMPTY_RUN_OPTIONS: RunOptions = { programArguments: "", environment: {}, }; + +export const EMPTY_GLOBAL_TOOLCHAIN: GlobalToolchain = { + javaHomePath: "", + mavenExecutablePath: "", + mavenJavaHomePath: "", +}; 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 6c7222283..069db279d 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.test.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.test.ts @@ -5,6 +5,9 @@ import { isBlockingToolchainDiagnostic, mapCoreConfiguration, mergeLaunchEnvironment, + projectScopedPath, + saveRunConfigurationChanges, + selectedToolchainCandidates, workspaceRelativePath, } from "./run-configuration"; @@ -75,6 +78,48 @@ describe("run configuration mapping", () => { expect( workspaceRelativePath("D:\\work\\demo", "D:\\work\\demo\\src\\main\\java\\App.java"), ).toBe("src/main/java/App.java"); + expect(projectScopedPath("D:/work/demo", "D:/other/output")).toBeUndefined(); + }); + + test("selects a probed custom toolchain instead of an unrelated automatic runtime", () => { + const candidates = selectedToolchainCandidates( + { + java: [ + { homePath: "C:/Java/automatic", version: "17", vendor: "Auto" }, + { homePath: "D:\\SDKs\\custom", version: "21", vendor: "Custom" }, + ], + maven: [], + }, + { + javaHomePath: "d:/sdks/custom/", + mavenExecutablePath: "", + mavenJavaHomePath: "", + }, + ); + + expect(candidates).toEqual([ + { id: "project-jdk", type: "java", version: "21", vendor: "Custom" }, + ]); + }); + + test("serializes toolchain and option saves that share the local document", async () => { + const calls: string[] = []; + let toolchainWritten = false; + const saved = await saveRunConfigurationChanges( + async () => { + calls.push("toolchain"); + toolchainWritten = true; + return true; + }, + async () => { + calls.push("options"); + expect(toolchainWritten).toBe(true); + return true; + }, + ); + + expect(saved).toBe(true); + expect(calls).toEqual(["toolchain", "options"]); }); test("merges user env with toolchain-derived launch environment", () => { diff --git a/windows/tauri/src/features/run/utils/run-configuration.ts b/windows/tauri/src/features/run/utils/run-configuration.ts index 58cb3cc94..c8da4909d 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.ts @@ -1,6 +1,10 @@ import { CURRENT_FILE_ID, + type CoreGlobalToolchain, type CoreResolvedConfiguration, + type GlobalToolchain, + type JavaRuntime, + type MavenRuntime, type RunConfiguration, type RunDiagnostic, type RunExecution, @@ -26,6 +30,7 @@ export function mapCoreConfiguration(value: CoreResolvedConfiguration): RunConfi provider: value.provider, kindTitle: configurationTitle(value.provider), execution: normalizeExecution(value.execution, value.provider), + toolchains: value.toolchains ?? {}, modulePath: maven?.module && maven.module !== "." ? maven.module : undefined, mainClass: maven?.mainClass, cwd: value.cwd && value.cwd !== "." ? value.cwd : "", @@ -57,6 +62,14 @@ export function normalizeExecution(execution: string | undefined, provider: stri return "application"; } +export function mapCoreToolchain(toolchain: CoreGlobalToolchain | null | undefined): GlobalToolchain { + return { + javaHomePath: toolchain?.java?.homePath ?? "", + mavenExecutablePath: toolchain?.maven?.executablePath ?? "", + mavenJavaHomePath: toolchain?.maven?.javaHomePath ?? "", + }; +} + export function runnableConfigurations(configurations: RunConfiguration[]): RunConfiguration[] { return configurations.filter((configuration) => configuration.id !== CURRENT_FILE_ID); } @@ -110,12 +123,61 @@ export function defaultGeneratedConfigurationId(generated: unknown): string | un export function workspaceRelativePath(root: string, filePath: string): string | undefined { const normalizedRoot = root.replace(/[\\/]+$/, "").replace(/\\/g, "/"); const normalizedFile = filePath.replace(/\\/g, "/"); + if (normalizedFile.toLowerCase() === normalizedRoot.toLowerCase()) { + return "."; + } if (!normalizedFile.toLowerCase().startsWith(normalizedRoot.toLowerCase() + "/")) { return undefined; } return normalizedFile.slice(normalizedRoot.length + 1); } +export function projectScopedPath(root: string, value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed) return "."; + const relative = workspaceRelativePath(root, trimmed); + if (relative !== undefined) return relative; + if (isAbsolutePath(trimmed)) return undefined; + return trimmed.replace(/\\/g, "/"); +} + +function isAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("/") || value.startsWith("\\\\"); +} + +export function configurationUsesMaven(configuration: { toolchains?: Record }): boolean { + return Boolean(configuration.toolchains?.maven); +} + +export function selectedToolchainCandidates( + discovered: { java: JavaRuntime[]; maven: MavenRuntime[] }, + selected: GlobalToolchain, +): Array<{ id: string; type: string; version: string; vendor: string }> { + const java = selected.javaHomePath + ? discovered.java.find((runtime) => sameWindowsPath(runtime.homePath, selected.javaHomePath)) + : discovered.java[0]; + const maven = selected.mavenExecutablePath + ? discovered.maven.find((runtime) => sameWindowsPath(runtime.executablePath, selected.mavenExecutablePath)) + : discovered.maven[0]; + return [ + ...(java ? [{ id: "project-jdk", type: "java", version: java.version, vendor: java.vendor }] : []), + ...(maven ? [{ id: "project-maven", type: "maven", version: maven.version, vendor: "" }] : []), + ]; +} + +function sameWindowsPath(left: string, right: string): boolean { + const normalize = (value: string) => value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); + return normalize(left) === normalize(right); +} + +export async function saveRunConfigurationChanges( + saveToolchain: () => Promise, + saveOptions: () => Promise, +): Promise { + if (!(await saveToolchain())) return false; + return saveOptions(); +} + export function environmentText(environment: Record): string { return Object.entries(environment) .sort(([left], [right]) => left.localeCompare(right)) diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 69013613f..016de560a 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -83,6 +83,14 @@ const catalogs = { "run.mavenExecutableHint": "Leave empty to use the project wrapper or detected Maven.", "run.mavenJdkHome": "Maven JDK Home", "run.mavenJdkHomeHint": "Leave empty to use the same JDK as the application.", + "run.toolchainAuto": "Auto-detect (leave empty)", + "run.toolchainCurrent": "Current path", + "run.runtimeSection": "Runtime (this PC)", + "run.programArguments": "Program arguments", + "run.vmArguments": "JVM arguments", + "run.environment": "Environment", + "run.stdinPlaceholder": "Type program input…", + "run.stdinSend": "Send", "run.workingDirectory": "Working directory", "run.workingDirectoryHint": "Leave empty to use the project root.", "run.done": "Done", @@ -1150,6 +1158,14 @@ const catalogs = { "run.mavenExecutableHint": "留空则使用项目 Wrapper 或系统 Maven。", "run.mavenJdkHome": "Maven JDK 主目录", "run.mavenJdkHomeHint": "留空则与应用使用同一个 JDK。", + "run.toolchainAuto": "自动检测(留空)", + "run.toolchainCurrent": "当前路径", + "run.runtimeSection": "运行环境(本机)", + "run.programArguments": "程序参数", + "run.vmArguments": "JVM 参数", + "run.environment": "环境变量", + "run.stdinPlaceholder": "输入程序所需内容…", + "run.stdinSend": "发送", "run.workingDirectory": "工作目录", "run.workingDirectoryHint": "留空则使用项目根目录。", "run.done": "完成", diff --git a/windows/tauri/src/platform/tauri-core.ts b/windows/tauri/src/platform/tauri-core.ts index c4d2437a6..04a4d7df6 100644 --- a/windows/tauri/src/platform/tauri-core.ts +++ b/windows/tauri/src/platform/tauri-core.ts @@ -48,6 +48,7 @@ const nativeCommands = new Set([ "run_stop_process", "run_write_document", "run_write_generated", + "run_write_stdin", "set_native_window_appearance", "set_project_root", "start_watching", @@ -111,7 +112,8 @@ function capabilityForCommand(command: string): BackendCapability | null { command === "run_start_process" || command === "run_stop_process" || command === "run_write_document" || - command === "run_write_generated" + command === "run_write_generated" || + command === "run_write_stdin" ) { return "run"; } diff --git a/windows/tauri/src/ui/icons.test.tsx b/windows/tauri/src/ui/icons.test.tsx index f7a54a5e9..cef0d2092 100644 --- a/windows/tauri/src/ui/icons.test.tsx +++ b/windows/tauri/src/ui/icons.test.tsx @@ -15,7 +15,7 @@ function renderIcon(IconComponent: ElementType) { describe("application icon mappings", () => { test("exports the complete icon inventory", () => { - expect(iconEntries).toHaveLength(202); + expect(iconEntries).toHaveLength(203); }); test("avoids unintended help fallbacks", () => {