From 75773dd889199157038f876d832f787132dd54d5 Mon Sep 17 00:00:00 2001 From: RuyimgByCN <45452808+RuyimgByCN@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:46:03 +0800 Subject: [PATCH] feat(project-structure): add IDEA-style Project Structure dialog (#1845) * feat(project-structure): add IDEA-style Project Structure dialog * test(sdk): use dunce::canonicalize for Windows path parity in project structure test * fix(project-structure): preserve drafts and enforce supported module settings --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- crates/ide-core/src/settings.rs | 171 ++ crates/ide-host/src/ops/java.rs | 70 +- crates/ide-host/src/project.rs | 11 +- crates/ide-lsp/src/jdtls.rs | 233 +- crates/ide-sdk/src/apply.rs | 59 +- crates/ide-sdk/src/java.rs | 6 + crates/ide-sdk/src/libraries.rs | 33 +- crates/ide-sdk/src/tests.rs | 58 + e2e/settings.spec.ts | 21 + src-tauri/src/lib.rs | 5 +- src-tauri/src/menu.rs | 3 + src/bindings.ts | 79 + src/features/libraries/index.ts | 1 + .../settings/__tests__/dialog.test.tsx | 72 +- .../settings/__tests__/languages.test.ts | 96 + .../settings/dialog/LazySettingsDialog.tsx | 8 + src/features/settings/index.ts | 19 +- .../settings/languages/LanguagePages.tsx | 18 +- .../languages/ProjectStructurePage.tsx | 1933 +++++++++++++++-- .../settings/languages/Sdk.module.css | 304 ++- src/features/settings/model/staging.ts | 65 +- src/features/settings/pages/languages.tsx | 17 +- src/platform/keymaps/ideaParity.test.ts | 1 + src/platform/keymaps/intellij-macos.ts | 1 + src/platform/keymaps/intellij-windows.ts | 1 + src/platform/keymaps/vscode-macos.ts | 1 + src/platform/languageSettings.ts | 164 ++ src/platform/locales/en.json | 1 + src/platform/locales/text/en.json | 113 +- src/platform/locales/text/zh.json | 113 +- src/platform/locales/textCatalog.test.ts | 10 + src/platform/locales/zh.json | 1 + 32 files changed, 3524 insertions(+), 164 deletions(-) diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 101178039..0135b185c 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -850,6 +850,8 @@ pub struct JavaSettings { /// Project JDK home; empty = per module, the installed JDK matching the /// Java version it targets. pub jdk: String, + /// Project Java language level (`8`, `11`, `17`, `21`, `25`); empty = SDK default. + pub language_level: String, /// JDK home the Java language server (jdtls) runs on, apart from the project /// JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough, /// else the first installed one that is. @@ -953,6 +955,98 @@ impl Default for TypeScriptSettings { } } +/// Dependency scope for a module dependency or project library (IDEA's Modules › Dependencies). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum DependencyScope { + #[default] + Compile, + Test, + Runtime, + Provided, +} + +/// A project-level library (Project Structure › Libraries). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct ProjectLibrary { + pub name: String, + /// Class archives (`.jar`) or class directories. + pub classes: Vec, + /// Source archives (`-sources.jar`, `.zip`) or source directories. + pub sources: Vec, + /// Documentation archives (`-javadoc.jar`) or directories. + pub javadocs: Vec, + pub scope: DependencyScope, +} + +/// Dependency entry of a module (Project Structure › Modules › Dependencies). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct ModuleDependency { + /// `"library"` (references a `ProjectLibrary` by name or path) or `"module"` (references another module). + pub kind: String, + pub target: String, + pub scope: DependencyScope, + pub exported: bool, +} + +/// Per-module Project Structure overrides (SDK, language level, output paths, dependencies). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct ModuleStructureSettings { + /// Module SDK home override; empty = inherit Project SDK. + pub jdk: String, + /// Module language level override; empty = inherit Project language level. + pub language_level: String, + /// Module compiler output directory override; empty = inherit Project compiler output. + pub compiler_output: String, + /// Module test compiler output directory override; empty = default. + pub test_compiler_output: String, + /// Additional module dependencies. + pub dependencies: Vec, +} + +/// Framework facet attached to a module (Project Structure › Facets). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct FacetConfig { + /// `"web"` | `"spring"` | `"jpa"`. + pub kind: String, + /// Module relative path (`"."` for root module). + pub module: String, + /// Deployment descriptor path (e.g. `src/main/webapp/WEB-INF/web.xml`, `src/main/resources/META-INF/persistence.xml`). + pub descriptor: String, + /// Web resource root directory for Web facets (e.g. `src/main/webapp`). + pub web_root: String, +} + +/// Packaging type of a Project Structure artifact. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum ArtifactKind { + #[default] + Jar, + War, + WarExploded, +} + +/// Packaging artifact definition (Project Structure › Artifacts). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct ArtifactConfig { + pub name: String, + pub kind: ArtifactKind, + /// Module relative path (`"."` for root module). + pub module: String, + /// Output directory (relative to project root or absolute). + pub output_directory: String, + /// Main class for executable JAR artifacts. + pub main_class: String, + /// Project library names included in the packaged artifact (`WEB-INF/lib` or JAR manifest). + pub include_libraries: Vec, +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct ProjectStructureSettings { @@ -969,9 +1063,28 @@ pub struct ProjectStructureSettings { pub content_roots: Vec, /// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth. pub exclude_files: String, + /// Project compiler output directory (relative to project root or absolute; empty = default). + pub compiler_output: String, + /// Per-module SDK, language level, output paths and dependency overrides, keyed by module relative path (`"."` for root). + pub modules: BTreeMap, + /// Project-level libraries (`JAR`s / class folders / source archives). + pub libraries: Vec, + /// Module framework facets (`web`, `spring`, `jpa`). + pub facets: Vec, + /// Packaging artifacts (`jar`, `war`, `warExploded`). + pub artifacts: Vec, } impl ProjectStructureSettings { + /// Overrides belong to the module's full path, never just its directory name. + pub fn module_settings( + &self, + root: &std::path::Path, + module: &std::path::Path, + ) -> Option<&ModuleStructureSettings> { + self.modules.iter().find_map(|(key, settings)| (root.join(key) == module).then_some(settings)) + } + /// The "Exclude files" name patterns. pub fn exclude_file_patterns(&self) -> Vec { self.exclude_files.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_owned).collect() @@ -1152,6 +1265,64 @@ mod tests { assert_eq!(s.file_excludes(), vec!["*.log", "tmp*"]); assert!(Settings::default().files.file_types.is_empty()); } + + #[test] + fn project_structure_modules_libraries_facets_and_artifacts_round_trip() { + let old: Settings = serde_json::from_str(r#"{"projectStructure":{"sourceRoots":["src"]}}"#).unwrap(); + assert!(old.project_structure.compiler_output.is_empty()); + assert!(old.project_structure.modules.is_empty()); + assert!(old.project_structure.libraries.is_empty()); + assert!(old.project_structure.facets.is_empty()); + assert!(old.project_structure.artifacts.is_empty()); + assert!(old.languages.java.language_level.is_empty()); + + let mut settings = Settings::default(); + settings.languages.java.language_level = "21".into(); + settings.project_structure.compiler_output = "out/production".into(); + settings.project_structure.modules.insert( + "backend".into(), + ModuleStructureSettings { + jdk: "/opt/jdk-21".into(), + language_level: "21".into(), + compiler_output: "backend/target/classes".into(), + test_compiler_output: "backend/target/test-classes".into(), + dependencies: vec![ModuleDependency { + kind: "library".into(), + target: "custom-lib".into(), + scope: DependencyScope::Compile, + exported: true, + }], + }, + ); + settings.project_structure.libraries.push(ProjectLibrary { + name: "custom-lib".into(), + classes: vec!["lib/custom.jar".into()], + sources: vec!["lib/custom-sources.jar".into()], + javadocs: Vec::new(), + scope: DependencyScope::Compile, + }); + settings.project_structure.facets.push(FacetConfig { + kind: "web".into(), + module: "backend".into(), + descriptor: "src/main/webapp/WEB-INF/web.xml".into(), + web_root: "src/main/webapp".into(), + }); + settings.project_structure.artifacts.push(ArtifactConfig { + name: "backend:war exploded".into(), + kind: ArtifactKind::WarExploded, + module: "backend".into(), + output_directory: "out/artifacts/backend_war_exploded".into(), + main_class: String::new(), + include_libraries: vec!["custom-lib".into()], + }); + + let value = serde_json::to_value(&settings).unwrap(); + assert_eq!(value["languages"]["java"]["languageLevel"], "21"); + assert_eq!(value["projectStructure"]["compilerOutput"], "out/production"); + assert_eq!(value["projectStructure"]["artifacts"][0]["kind"], "warExploded"); + let restored: Settings = serde_json::from_value(value).unwrap(); + assert_eq!(restored, settings); + } } #[cfg(test)] diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index 984e4881c..8f05763d4 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -91,9 +91,10 @@ async fn resolve_launch( } } info.class_paths = class_paths; + apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test); let compile_ms = started.elapsed().as_millis() - lsp_ms; - // The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version. - let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings); + // The module/project JDK, else the JDK jdtls compiles the module against, else one matching its Java version. + let project_jdk = ide_sdk::apply::module_jdk(&project.root, &module, &settings); let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file()); let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| { // The build model's level includes what the module inherits from its parent POMs. @@ -153,6 +154,41 @@ fn entry_key(path: &str, windows: bool) -> String { if windows { key.to_lowercase() } else { key } } +fn apply_library_classpath( + info: &mut JavaLaunchInfo, + root: &std::path::Path, + libraries: &[ide_core::settings::ProjectLibrary], + test: bool, +) { + use ide_core::settings::DependencyScope; + let entries: Vec<_> = libraries + .iter() + .flat_map(|lib| { + let allowed = match lib.scope { + DependencyScope::Compile | DependencyScope::Runtime => true, + DependencyScope::Test | DependencyScope::Provided => test, + }; + lib.classes.iter().filter_map(move |entry| { + ide_sdk::apply::setting_path(root, entry).map(|path| (path.to_string_lossy().into_owned(), allowed)) + }) + }) + .collect(); + // JDT may already have returned these archives on either classpath. + for paths in [&mut info.class_paths, &mut info.module_paths] { + paths.retain(|path| { + let matching: Vec<_> = entries.iter().filter(|(entry, _)| same_entry(path, entry, cfg!(windows))).collect(); + matching.is_empty() || matching.iter().any(|(_, allowed)| *allowed) + }); + } + for (entry, allowed) in entries { + if allowed + && !info.class_paths.iter().chain(&info.module_paths).any(|path| same_entry(path, &entry, cfg!(windows))) + { + info.class_paths.push(entry); + } + } +} + fn same_entry(a: &str, b: &str, windows: bool) -> bool { a == b || entry_key(a, windows) == entry_key(b, windows) } @@ -220,6 +256,36 @@ pub(crate) fn register(d: &mut Dispatcher) { mod tests { use super::*; + #[test] + fn library_scopes_filter_jdt_classpaths_and_module_paths() { + use ide_core::settings::{DependencyScope, ProjectLibrary}; + let root = tempfile::tempdir().unwrap(); + let scopes = + [DependencyScope::Compile, DependencyScope::Runtime, DependencyScope::Test, DependencyScope::Provided]; + let libraries: Vec<_> = scopes + .iter() + .enumerate() + .map(|(i, scope)| ProjectLibrary { + classes: vec![format!("lib/{i}.jar")], + scope: *scope, + ..Default::default() + }) + .collect(); + let paths: Vec<_> = + (0..4).map(|i| root.path().join(format!("lib/{i}.jar")).to_string_lossy().into_owned()).collect(); + for test in [false, true] { + let mut info = + JavaLaunchInfo { class_paths: paths.clone(), module_paths: paths.clone(), ..Default::default() }; + apply_library_classpath(&mut info, root.path(), &libraries, test); + let expected = if test { &paths[..] } else { &paths[..2] }; + assert_eq!(info.class_paths, expected); + assert_eq!(info.module_paths, expected); + } + let mut info = JavaLaunchInfo::default(); + apply_library_classpath(&mut info, root.path(), &libraries, false); + assert_eq!(info.class_paths, &paths[..2]); + } + /// JDT LS and our build model spell a module output differently on Windows; the /// snapshot must still replace it, or resources (MyBatis mapper XML) load twice. #[test] diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index a6451cb3b..8329111cb 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -193,6 +193,7 @@ impl Host { &effective.project_structure, ), ); + ide_lsp::jdtls::configure_project_structure(cfg, root, server_root, &effective.project_structure); let bundles: Vec = ide_tools::managed_bundle(&data_dir, "java-debug") .into_iter() .chain(ide_sdk::java::usages_bundle(&data_dir)) @@ -213,13 +214,19 @@ impl Host { cfg.launch_error = Some(e); None }); - ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections); + let lang_level = ide_sdk::apply::effective_java_language_level(root, server_root, &effective); + ide_sdk::apply::configure_java_compiler_with_level( + cfg, + &data_dir, + &effective.inspections, + lang_level, + ); ide_sdk::java::configure_jdtls( cfg, server_root, &data_dir, &jdks, - ide_sdk::apply::project_jdk(root, &effective).as_ref(), + ide_sdk::apply::module_jdk(root, server_root, &effective).as_ref(), server_jdk.as_ref(), min_java, &bundles, diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 94767ace5..f06c1d2a5 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -41,6 +41,12 @@ const ACCESS_OPTIONS: [&str; 2] = [ "org.eclipse.jdt.core.compiler.problem.discouragedReference", ]; +const LEVEL_OPTIONS: [&str; 3] = [ + "org.eclipse.jdt.core.compiler.source", + "org.eclipse.jdt.core.compiler.compliance", + "org.eclipse.jdt.core.compiler.codegen.targetPlatform", +]; + /// Keep the source model in protocol settings so jdtls's invisible-project /// initialization and configuration use the same roots as workspace builds. pub fn configure_source_paths(cfg: &mut ide_language::LanguageServerConfig, paths: Option>) { @@ -54,6 +60,115 @@ pub fn configure_source_paths(cfg: &mut ide_language::LanguageServerConfig, path } } +/// Applies Project Structure compiler output path, project libraries (and source attachments), +/// and Web facet `WEB-INF/lib` directories to jdtls `java.project.*` settings. +pub fn configure_project_structure( + cfg: &mut ide_language::LanguageServerConfig, + root: &Path, + server_root: &Path, + structure: &ide_core::settings::ProjectStructureSettings, +) { + if cfg.name != SERVER { + return; + } + let module_cfg = structure.module_settings(root, server_root); + let module_output = module_cfg.map(|m| m.compiler_output.trim()).filter(|s| !s.is_empty()); + let output_path = if let Some(output) = module_output { + // The dialog stores paths relative to the project, JDT expects the server root. + let path = root.join(output); + match path.strip_prefix(server_root) { + Ok(relative) => Some(relative.to_string_lossy().replace('\\', "/")), + Err(_) => { + cfg.launch_error = Some(format!("Java compiler output must be inside its module: {}", path.display())); + None + } + } + } else { + let output = structure.compiler_output.trim(); + if output.is_empty() { + None + } else if Path::new(output).is_absolute() { + match Path::new(output).strip_prefix(server_root) { + Ok(relative) => Some(relative.to_string_lossy().replace('\\', "/")), + Err(_) => { + cfg.launch_error = Some(format!("Java compiler output must be inside its module: {output}")); + None + } + } + } else { + Some(output.replace('\\', "/")) + } + }; + + let normalize = |entry: &str| -> Option { + let trimmed = entry.trim(); + if trimmed.is_empty() { + return None; + } + // JDT resolves relative library globs against its own root, which may be a submodule. + Some(root.join(trimmed).to_string_lossy().replace('\\', "/")) + }; + let mut include: Vec = Vec::new(); + let mut sources = serde_json::Map::new(); + for lib in &structure.libraries { + let class_entries: Vec = lib.classes.iter().filter_map(|c| normalize(c)).collect(); + let source_entries: Vec = lib.sources.iter().filter_map(|s| normalize(s)).collect(); + for c in &class_entries { + if !include.contains(c) { + include.push(c.clone()); + } + } + for (class, source) in class_entries.iter().zip(&source_entries) { + sources.insert(class.clone(), json!(source)); + } + } + for facet in &structure.facets { + if facet.kind == "web" && root.join(&facet.module) == server_root { + let web_root = facet.web_root.trim(); + let web_root = if web_root.is_empty() { "src/main/webapp" } else { web_root }; + if let Some(path) = normalize(web_root) { + let glob = format!("{}/WEB-INF/lib/**/*.jar", path.trim_end_matches('/')); + if !include.contains(&glob) { + include.push(glob); + } + } + } + } + + if output_path.is_none() && include.is_empty() { + return; + } + let mut settings = cfg.settings.take().unwrap_or_else(|| json!({})); + if let Some(output) = output_path { + settings["java"]["project"]["outputPath"] = json!(output); + } + if !include.is_empty() { + let existing = &settings["java"]["project"]["referencedLibraries"]; + let mut libraries = if existing.is_object() { + existing.clone() + } else if existing.is_array() { + json!({ "include": existing }) + } else { + json!({ "include": ["lib/**/*.jar"] }) + }; + let mut entries = libraries["include"].as_array().cloned().unwrap_or_default(); + for path in include { + let path = json!(path); + if !entries.contains(&path) { + entries.push(path); + } + } + libraries["include"] = json!(entries); + if !sources.is_empty() { + let mut attachments = libraries["sources"].as_object().cloned().unwrap_or_default(); + attachments.extend(sources); + libraries["sources"] = json!(attachments); + } + settings["java"]["project"]["referencedLibraries"] = libraries; + } + cfg.settings = Some(settings); +} + pub(crate) fn source_paths_removed( current: &ide_language::LanguageServerConfig, next: &ide_language::LanguageServerConfig, @@ -104,11 +219,13 @@ fn compiler_defaults(cfg: &ide_language::LanguageServerConfig) -> Value { let Ok(prefs) = std::fs::read_to_string(path) else { return json!({}) }; let mut options = serde_json::Map::new(); for line in prefs.lines() { - if let Some((key, value)) = line.split_once('=') - && ACCESS_OPTIONS.contains(&key.trim()) - && ["error", "warning", "info", "ignore"].contains(&value.trim()) - { - options.insert(key.trim().into(), json!(value.trim())); + if let Some((key, value)) = line.split_once('=') { + let (k, v) = (key.trim(), value.trim()); + if (ACCESS_OPTIONS.contains(&k) && ["error", "warning", "info", "ignore"].contains(&v)) + || (LEVEL_OPTIONS.contains(&k) && ["1.8", "11", "17", "21", "25"].contains(&v)) + { + options.insert(k.into(), json!(v)); + } } } Value::Object(options) @@ -978,4 +1095,110 @@ mod tests { assert!(missing_bundle(&IdeError::invalid("No delegateCommandHandler for vscode.java.startDebugSession"))); assert!(!missing_bundle(&e)); } + + #[test] + fn configure_project_structure_sets_output_referenced_libraries_and_web_facets() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let lib_dir = root.join("lib"); + std::fs::create_dir_all(&lib_dir).unwrap(); + let jar_path = lib_dir.join("helper.jar"); + let src_path = lib_dir.join("helper-sources.jar"); + std::fs::write(&jar_path, b"").unwrap(); + std::fs::write(&src_path, b"").unwrap(); + + let mut structure = ide_core::settings::ProjectStructureSettings { + compiler_output: "out/production".into(), + libraries: vec![ide_core::settings::ProjectLibrary { + name: "helper".into(), + classes: vec!["lib/helper.jar".into()], + sources: vec!["lib/helper-sources.jar".into()], + javadocs: vec![], + scope: ide_core::settings::DependencyScope::Compile, + }], + facets: vec![ide_core::settings::FacetConfig { + kind: "web".into(), + module: ".".into(), + descriptor: "src/main/webapp/WEB-INF/web.xml".into(), + web_root: "src/main/webapp".into(), + }], + ..Default::default() + }; + structure.modules.insert( + "submod".into(), + ide_core::settings::ModuleStructureSettings { + compiler_output: "submod/classes".into(), + ..Default::default() + }, + ); + + let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap(); + configure_project_structure(&mut cfg, root, root, &structure); + + let settings = cfg.settings.as_ref().unwrap(); + assert_eq!(settings.pointer("/java/project/outputPath").and_then(Value::as_str), Some("out/production")); + let include = settings + .pointer("/java/project/referencedLibraries/include") + .and_then(Value::as_array) + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect::>(); + assert!(include.iter().any(|p| p.ends_with("lib/helper.jar"))); + assert!(include.iter().any(|p| p.ends_with("src/main/webapp/WEB-INF/lib/**/*.jar"))); + let sources = settings.pointer("/java/project/referencedLibraries/sources").and_then(Value::as_object).unwrap(); + assert!(sources.values().any(|v| v.as_str().is_some_and(|s| s.ends_with("lib/helper-sources.jar")))); + + // Module-specific compiler output overrides project-wide compiler output. + let mut sub_cfg: ide_language::LanguageServerConfig = + toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap(); + configure_project_structure(&mut sub_cfg, root, &root.join("submod"), &structure); + assert_eq!( + sub_cfg.settings.as_ref().unwrap().pointer("/java/project/outputPath").and_then(Value::as_str), + Some("classes") + ); + let sub_settings = sub_cfg.settings.as_ref().unwrap(); + let libs = sub_settings.pointer("/java/project/referencedLibraries/include").unwrap().as_array().unwrap(); + assert!(libs.contains(&json!(root.join("lib/helper.jar").to_string_lossy().replace('\\', "/")))); + assert!(!libs.iter().any(|v| v.as_str().is_some_and(|p| p.contains("WEB-INF")))); + } + + #[test] + fn project_libraries_preserve_existing_globs_exclusions_and_source_attachments() { + let tmp = tempfile::tempdir().unwrap(); + let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap(); + cfg.settings = Some(json!({"java": {"project": {"referencedLibraries": { + "include": ["vendor/**/*.jar"], "exclude": ["vendor/old.jar"], + "sources": {"vendor/base.jar": "vendor/base-sources.jar"} + }}}})); + let structure = ide_core::settings::ProjectStructureSettings { + libraries: vec![ide_core::settings::ProjectLibrary { + classes: vec!["lib/new.jar".into()], + sources: vec!["lib/new-sources.jar".into()], + ..Default::default() + }], + ..Default::default() + }; + configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure); + let libraries = cfg.settings.as_ref().unwrap().pointer("/java/project/referencedLibraries").unwrap(); + assert_eq!(libraries["include"][0], "vendor/**/*.jar"); + assert_eq!(libraries["exclude"], json!(["vendor/old.jar"])); + assert_eq!(libraries["sources"]["vendor/base.jar"], "vendor/base-sources.jar"); + assert_eq!(libraries["sources"].as_object().unwrap().len(), 2); + } + + #[test] + fn absolute_output_inside_the_module_becomes_relative() { + let tmp = tempfile::tempdir().unwrap(); + let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap(); + let mut structure = ide_core::settings::ProjectStructureSettings { + compiler_output: tmp.path().join("out").to_string_lossy().into_owned(), + ..Default::default() + }; + configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure); + assert_eq!(cfg.settings.as_ref().unwrap().pointer("/java/project/outputPath"), Some(&json!("out"))); + structure.compiler_output = tmp.path().parent().unwrap().join("elsewhere").to_string_lossy().into_owned(); + configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure); + assert!(cfg.launch_error.as_deref().unwrap().contains("inside its module")); + } } diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 7d098d949..471dad4ab 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -144,6 +144,24 @@ pub fn project_jdk(root: &Path, s: &Settings) -> Option { } } +/// Module-specific JDK from `projectStructure.modules`, falling back to [`project_jdk`]. +pub fn module_jdk(root: &Path, module: &Path, s: &Settings) -> Option { + let module_cfg = s.project_structure.module_settings(root, module); + if let Some(cfg) = module_cfg + && let Some(jdk) = jdk_setting(root, &cfg.jdk) + { + return jdk.inspect_err(|p| tracing::warn!("module JDK {}", not_a_jdk(p))).ok(); + } + project_jdk(root, s) +} + +/// Effective Java language level (`8`, `11`, `17`, `21`, `25`) for `module`, checking +/// `projectStructure.modules` first, then `languages.java.languageLevel`. +pub fn effective_java_language_level<'a>(root: &Path, module: &Path, s: &'a Settings) -> Option<&'a str> { + let module_level = s.project_structure.module_settings(root, module).and_then(|m| non_empty(&m.language_level)); + module_level.or_else(|| non_empty(&s.languages.java.language_level)) +} + /// The JDK chosen to run jdtls (`languages.java.jdtlsJdk`): `Ok(None)` when not /// set, `Err` (the message) when it is not a JDK home. pub fn jdtls_jdk(root: &Path, s: &Settings) -> Result, String> { @@ -640,16 +658,43 @@ fn missing_serial_version(inspections: &InspectionSettings) -> Option<&'static s /// Workspace compiler defaults; Eclipse project preferences still take precedence. /// Each variant has its own file, so changing an inspection restarts the server with it. pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path, inspections: &InspectionSettings) { + configure_java_compiler_with_level(cfg, data_dir, inspections, None); +} + +/// Like [`configure_java_compiler`], also applying the Project Structure Java language level +/// (`source`, `compliance`, `codegen.targetPlatform`) when configured. +pub fn configure_java_compiler_with_level( + cfg: &mut LanguageServerConfig, + data_dir: &Path, + inspections: &InspectionSettings, + language_level: Option<&str>, +) { const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\ org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n"; let serial = missing_serial_version(inspections); - let (name, content) = match serial { - None => ("compiler-defaults.prefs".to_owned(), DEFAULTS.to_owned()), - Some(level) => ( - format!("compiler-defaults-serial-{level}.prefs"), - format!("{DEFAULTS}org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n"), - ), - }; + let normalized_level = language_level.map(str::trim).and_then(|l| match l { + "8" | "1.8" => Some(("8", "1.8")), + "11" => Some(("11", "11")), + "17" => Some(("17", "17")), + "21" => Some(("21", "21")), + "25" => Some(("25", "25")), + _ => None, + }); + let mut stem = "compiler-defaults".to_owned(); + let mut content = DEFAULTS.to_owned(); + if let Some(level) = serial { + stem.push_str(&format!("-serial-{level}")); + content.push_str(&format!("org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n")); + } + if let Some((tag, jdt_ver)) = normalized_level { + stem.push_str(&format!("-level-{tag}")); + content.push_str(&format!( + "org.eclipse.jdt.core.compiler.source={jdt_ver}\n\ +org.eclipse.jdt.core.compiler.compliance={jdt_ver}\n\ +org.eclipse.jdt.core.compiler.codegen.targetPlatform={jdt_ver}\n" + )); + } + let name = format!("{stem}.prefs"); let file = data_dir.join("lsp/jdtls").join(name); let write = || -> ide_core::IdeResult<()> { std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?; diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 93b15c7f4..34c70b099 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -384,6 +384,12 @@ pub fn configure_jdtls( // A different source model needs a clean import, including new folders. key.push_str(&format!("-sources-{}", text_key(&paths.to_string()))); } + if let Some(output) = cfg.settings.as_ref().and_then(|s| s.pointer("/java/project/outputPath")) { + key.push_str(&format!("-out-{}", text_key(&output.to_string()))); + } + if let Some(libs) = cfg.settings.as_ref().and_then(|s| s.pointer("/java/project/referencedLibraries")) { + key.push_str(&format!("-libs-{}", text_key(&libs.to_string()))); + } let dir = data_dir.join("lsp").join("jdtls").join(key); cfg.args.extend(["-data".to_owned(), dir.to_string_lossy().into_owned()]); } diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index 7640ed6f7..58d77c989 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -71,7 +71,8 @@ pub struct BuildDependency { /// `dependencies` (from the build model) are listed when their jars are in the local repositories. pub fn project_libraries(root: &Path, settings: &Settings, dependencies: &[BuildDependency]) -> Vec { type Detect = fn(&Path, &Settings) -> Vec; - let detectors: [Detect; 6] = [python_groups, jdk_groups, node_groups, rust_groups, go_groups, ruby_groups]; + let detectors: [Detect; 7] = + [python_groups, jdk_groups, node_groups, rust_groups, go_groups, ruby_groups, project_structure_groups]; std::thread::scope(|s| { let handles: Vec<_> = detectors.iter().map(|d| s.spawn(move || d(root, settings))).collect(); let deps = s.spawn(|| dependency_groups(root, settings, dependencies)); @@ -217,7 +218,11 @@ fn ruby_groups(root: &Path, s: &Settings) -> Vec { // ------------------------------------------------------------------ jdk fn jdk_groups(root: &Path, s: &Settings) -> Vec { - let Some(files) = manifests(root, JAVA_MANIFESTS) else { return Vec::new() }; + let files = match manifests(root, JAVA_MANIFESTS) { + Some(files) => files, + None if !s.languages.java.jdk.trim().is_empty() || !s.project_structure.source_roots.is_empty() => Vec::new(), + None => return Vec::new(), + }; cached(root, "jdk", s.languages.java.jdk.clone(), &files, || { let jdk = crate::apply::project_jdk(root, s).or_else(|| { crate::java::pick_jdk(&crate::java::installed_jdks(), crate::java::project_java_version(root)).cloned() @@ -226,6 +231,30 @@ fn jdk_groups(root: &Path, s: &Settings) -> Vec { }) } +fn project_structure_groups(root: &Path, s: &Settings) -> Vec { + let mut out = Vec::new(); + for lib in &s.project_structure.libraries { + let mut roots = Vec::new(); + for entry in lib.classes.iter().chain(&lib.sources) { + let Some(path) = crate::apply::setting_path(root, entry) else { continue }; + let label = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + if path.is_file() { + roots.push(LibraryRoot { name: label, path: ide_vfs::archive_root(&path).into() }); + } else if path.is_dir() { + roots.push(LibraryRoot { name: label, path: path.into() }); + } + } + if !roots.is_empty() { + let name = if lib.name.trim().is_empty() { roots[0].name.clone() } else { lib.name.trim().to_owned() }; + out.push(LibraryGroup { name, kind: "library".into(), roots }); + } + } + out +} + /// The JDK home and its sources (`lib/src.zip`, `src.zip` in JDK 8) as library roots. pub(crate) fn jdk_library(jdk: &crate::java::Jdk) -> Option { let name = jdk_name(&jdk.home).unwrap_or_else(|| format!("JDK {}", jdk.major)); diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 1442b61fc..4b2ddef99 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -1784,3 +1784,61 @@ fn file_runtime_uses_nearest_virtualenv_and_explicit_project_settings_win() { settings.languages.python.interpreter = "selected/python".into(); assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(root.join("selected/python"))); } + +#[test] +fn project_structure_module_jdk_language_level_and_external_libraries() { + let temp = tempfile::tempdir().unwrap(); + let root_buf = dunce::canonicalize(temp.path()).unwrap(); + let root = root_buf.as_path(); + let jdk17 = root.join("jdk17"); + let jdk21 = root.join("jdk21"); + fake_jdk(&jdk17); + fake_jdk(&jdk21); + + let mut settings = Settings::default(); + settings.languages.java.jdk = jdk17.to_string_lossy().into_owned(); + settings.languages.java.language_level = "8".into(); + settings.project_structure.modules.insert( + "service".into(), + ide_core::settings::ModuleStructureSettings { + jdk: jdk21.to_string_lossy().into_owned(), + language_level: "21".into(), + ..Default::default() + }, + ); + + assert_eq!(crate::apply::module_jdk(root, root, &settings).map(|j| j.home), Some(jdk17)); + assert_eq!(crate::apply::module_jdk(root, &root.join("service"), &settings).map(|j| j.home), Some(jdk21)); + assert_eq!( + crate::apply::module_jdk(root, &root.join("nested/service"), &settings).map(|j| j.home), + crate::apply::project_jdk(root, &settings).map(|j| j.home) + ); + assert_eq!(crate::apply::effective_java_language_level(root, root, &settings), Some("8")); + assert_eq!(crate::apply::effective_java_language_level(root, &root.join("service"), &settings), Some("21")); + + let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap(); + crate::apply::configure_java_compiler_with_level(&mut cfg, root, &settings.inspections, Some("8")); + let prefs_files: Vec<_> = std::fs::read_dir(root.join("lsp/jdtls")) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| std::fs::read_to_string(e.path()).unwrap()) + .collect(); + assert_eq!(prefs_files.len(), 1); + assert!(prefs_files[0].contains("org.eclipse.jdt.core.compiler.source=1.8")); + assert!(prefs_files[0].contains("org.eclipse.jdt.core.compiler.compliance=1.8")); + assert!(prefs_files[0].contains("org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8")); + + let lib_dir = root.join("lib"); + std::fs::create_dir_all(&lib_dir).unwrap(); + let jar_file = lib_dir.join("servlet-api.jar"); + std::fs::write(&jar_file, b"").unwrap(); + settings.project_structure.libraries.push(ide_core::settings::ProjectLibrary { + name: "servlet-api".into(), + classes: vec!["lib/servlet-api.jar".into()], + sources: vec![], + javadocs: vec![], + scope: ide_core::settings::DependencyScope::Provided, + }); + let groups = crate::libraries::project_libraries(root, &settings, &[]); + assert!(groups.iter().any(|g| g.name == "servlet-api" && g.kind == "library" && g.roots.len() == 1)); +} diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 7b69b037a..1c6ee1ac6 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -1,5 +1,26 @@ import { expect, test } from "./fixtures"; +test("Project Structure keeps edits across repeated shortcuts and applies them", async ({ workbench: page }) => { + await page.keyboard.press("Meta+;"); + const dialog = page.getByRole("dialog", { name: "Project Structure", exact: true }); + await expect(dialog).toBeVisible(); + await dialog.getByLabel("Compiler output", { exact: true }).fill("out/classes"); + await page.keyboard.press("Meta+;"); + await expect(dialog.getByLabel("Compiler output", { exact: true })).toHaveValue("out/classes"); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeDisabled(); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.keyboard.press("Meta+;"); + await expect(dialog.getByLabel("Compiler output", { exact: true })).toHaveValue("out/classes"); + await dialog.getByRole("button", { name: "Modules", exact: true }).click(); + await dialog.getByRole("tab", { name: "Dependencies", exact: true }).click(); + await expect(dialog.getByRole("button", { name: "Add Library", exact: true })).toBeDisabled(); + await dialog.getByRole("button", { name: "Artifacts", exact: true }).click(); + await expect(dialog.getByRole("button", { name: "JAR", exact: true })).toBeDisabled(); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); +}); + test("⌘, opens Settings with the page tree; Escape closes", async ({ workbench: page }) => { await page.keyboard.press("Meta+,"); const dialog = page.getByRole("dialog", { name: "Settings" }); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4ba2f58f9..d00f4402c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -290,7 +290,10 @@ pub(crate) fn dispatch_menu_action(app: &tauri::AppHandle, id: &str) { // nothing else gets through. Others handle their own items; Close Tab closes the window. let label = match windows::auxiliary(w.label()) { Some((_, parent)) - if matches!(route.action.as_str(), "app.quit" | "file.settings" | "file.newProjectsSettings") => + if matches!( + route.action.as_str(), + "app.quit" | "file.settings" | "file.projectStructure" | "file.newProjectsSettings" + ) => { parent } diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index efebf4d34..226b4c086 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -230,6 +230,8 @@ pub fn spec(os: Os) -> Vec { act("file.reloadAllFromDisk", "Reload All from Disk"), sub("fileProperties", "File Properties", vec![act("file.toggleReadOnly", "Make File Read-only / Writable")]), SEP, + act("file.projectStructure", "Project Structure…"), + SEP, act("file.invalidateCaches", "Invalidate Caches…"), SEP, sub( @@ -1334,6 +1336,7 @@ mod tests { "file.openInNewWindow", "file.closeProject", "file.save", + "file.projectStructure", "file.settings", "file.newProjectsSettings", "window.new", diff --git a/src/bindings.ts b/src/bindings.ts index f1a6c0de0..e9b4747e3 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1865,12 +1865,29 @@ export type ApplyOutcome = { conflicts: string[], }; +/** Packaging artifact definition (Project Structure › Artifacts). */ +export type ArtifactConfig = { + name?: string, + kind?: ArtifactKind, + /** Module relative path (`"."` for root module). */ + module?: string, + /** Output directory (relative to project root or absolute). */ + outputDirectory?: string, + /** Main class for executable JAR artifacts. */ + mainClass?: string, + /** Project library names included in the packaged artifact (`WEB-INF/lib` or JAR manifest). */ + includeLibraries?: string[], +}; + /** `groupId:artifactId` of a dependency. */ export type ArtifactKey = { groupId: string, artifactId: string, }; +/** Packaging type of a Project Structure artifact. */ +export type ArtifactKind = "jar" | "war" | "warExploded"; + /** What an askpass prompt asks for. */ export type AskpassKind = /** Password or passphrase: hidden input. */ @@ -3099,6 +3116,9 @@ export type DeletedTag = { target: string, }; +/** Dependency scope for a module dependency or project library (IDEA's Modules › Dependencies). */ +export type DependencyScope = "compile" | "test" | "runtime" | "provided"; + export type DependencyUpdate = { latest: string, replacement: string, @@ -3747,6 +3767,18 @@ export type ExternalTool = { openConsole?: boolean, }; +/** Framework facet attached to a module (Project Structure › Facets). */ +export type FacetConfig = { + /** `"web"` | `"spring"` | `"jpa"`. */ + kind?: string, + /** Module relative path (`"."` for root module). */ + module?: string, + /** Deployment descriptor path (e.g. `src/main/webapp/WEB-INF/web.xml`, `src/main/resources/META-INF/persistence.xml`). */ + descriptor?: string, + /** Web resource root directory for Web facets (e.g. `src/main/webapp`). */ + webRoot?: string, +}; + export type FieldValue = { kind: "text"; value: string } | { kind: "multiline"; value: string } | { kind: "checkbox"; value: boolean } | { kind: "choice"; value: { options: string[], selected: string, @@ -4852,6 +4884,8 @@ export type JavaSettings = { * Java version it targets. */ jdk?: string, + /** Project Java language level (`8`, `11`, `17`, `21`, `25`); empty = SDK default. */ + languageLevel?: string, /** * JDK home the Java language server (jdtls) runs on, apart from the project * JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough, @@ -5567,6 +5601,29 @@ export type MethodStat = { total: number | null, }; +/** Dependency entry of a module (Project Structure › Modules › Dependencies). */ +export type ModuleDependency = { + /** `"library"` (references a `ProjectLibrary` by name or path) or `"module"` (references another module). */ + kind?: string, + target?: string, + scope?: DependencyScope, + exported?: boolean, +}; + +/** Per-module Project Structure overrides (SDK, language level, output paths, dependencies). */ +export type ModuleStructureSettings = { + /** Module SDK home override; empty = inherit Project SDK. */ + jdk?: string, + /** Module language level override; empty = inherit Project language level. */ + languageLevel?: string, + /** Module compiler output directory override; empty = inherit Project compiler output. */ + compilerOutput?: string, + /** Module test compiler output directory override; empty = default. */ + testCompilerOutput?: string, + /** Additional module dependencies. */ + dependencies?: ModuleDependency[], +}; + export type NavigationFiles = { paths: IpcPath[], indexing: boolean, @@ -6261,6 +6318,18 @@ export type ProjectInfo_Serialize = { unity?: UnityProjectInfo | null, }; +/** A project-level library (Project Structure › Libraries). */ +export type ProjectLibrary = { + name?: string, + /** Class archives (`.jar`) or class directories. */ + classes?: string[], + /** Source archives (`-sources.jar`, `.zip`) or source directories. */ + sources?: string[], + /** Documentation archives (`-javadoc.jar`) or directories. */ + javadocs?: string[], + scope?: DependencyScope, +}; + export type ProjectRoots = { /** Effective roots, sorted by path. */ roots: SourceFolder[], @@ -6282,6 +6351,16 @@ export type ProjectStructureSettings = { contentRoots?: string[], /** "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth. */ excludeFiles?: string, + /** Project compiler output directory (relative to project root or absolute; empty = default). */ + compilerOutput?: string, + /** Per-module SDK, language level, output paths and dependency overrides, keyed by module relative path (`"."` for root). */ + modules?: { [key in string]: ModuleStructureSettings }, + /** Project-level libraries (`JAR`s / class folders / source archives). */ + libraries?: ProjectLibrary[], + /** Module framework facets (`web`, `spring`, `jpa`). */ + facets?: FacetConfig[], + /** Packaging artifacts (`jar`, `war`, `warExploded`). */ + artifacts?: ArtifactConfig[], }; /** diff --git a/src/features/libraries/index.ts b/src/features/libraries/index.ts index 350507ba9..b18c03099 100644 --- a/src/features/libraries/index.ts +++ b/src/features/libraries/index.ts @@ -101,6 +101,7 @@ const sdkKey = (s: ReturnType) => { l?.go?.goroot, l?.go?.gopath, l?.go?.modules, + s.settings?.projectStructure?.libraries, ]); }; diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index a187bf413..d4503c563 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -30,6 +30,7 @@ import { PageContext, useSetting } from "../components/context"; import { DEFAULT_SETTINGS, setAt } from "../model/schema"; import { openSettings, stage, useSettingsDialog } from "../model/staging"; import { SettingsDialog } from "../dialog/SettingsDialog"; +import { ProjectStructureDialog } from "../languages/ProjectStructurePage"; import { SettingsWindow } from "../window/SettingsWindow"; import dialogStyles from "../dialog/SettingsDialog.module.css"; import { activate } from ".."; @@ -51,7 +52,7 @@ beforeEach(() => { }); afterEach(() => { - act(() => useSettingsDialog.setState({ open: false })); + act(() => useSettingsDialog.setState({ open: false, projectStructureOpen: false })); dispose(); }); @@ -472,6 +473,75 @@ describe("Settings dialog", () => { expect(within(dialog).getByRole("button", { name: "Theme" })).toHaveTextContent("Islands Light"); expect(within(dialog).getByLabelText("UI font size")).toHaveValue("13"); }); + + it("opens Project Structure as its own standalone IDEA-style dialog instead of Settings", async () => { + useProject.setState({ project: { name: "demo", root: "/project" } as never }); + render( + <> + + + + , + ); + await act(async () => { + await executeAction("file.projectStructure"); + }); + expect(useSettingsDialog.getState().open).toBe(false); + expect(useSettingsDialog.getState().projectStructureOpen).toBe(true); + const psDialog = screen.getByRole("dialog", { name: "Project Structure" }); + const nav = within(psDialog).getByRole("navigation", { name: "Project Structure sections" }); + expect(within(nav).getByText("Project Settings")).toBeInTheDocument(); + expect(within(nav).getByText("Platform Settings")).toBeInTheDocument(); + expect(within(nav).getByRole("button", { name: "Project" })).toHaveAttribute("aria-current", "page"); + await act(async () => { + fireEvent.click(within(nav).getByRole("button", { name: "Modules" })); + }); + expect(within(nav).getByRole("button", { name: "Modules" })).toHaveAttribute("aria-current", "page"); + expect(within(psDialog).getByRole("tab", { name: "Sources" })).toBeInTheDocument(); + expect(within(psDialog).getByRole("tab", { name: "Paths" })).toBeInTheDocument(); + expect(within(psDialog).getByRole("tab", { name: "Dependencies" })).toBeInTheDocument(); + }); + + it("preserves pending Project Structure edits when its shortcut is invoked again", async () => { + useProject.setState({ project: { name: "demo", root: "/project" } as never }); + render(); + await act(async () => executeAction("file.projectStructure")); + fireEvent.change(screen.getByLabelText("Compiler output"), { target: { value: "out/classes" } }); + await act(async () => executeAction("file.projectStructure")); + expect(screen.getByLabelText("Compiler output")).toHaveValue("out/classes"); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Apply" }))); + expect(settingsSet).toHaveBeenCalledWith("project", "projectStructure.compilerOutput", '"out/classes"'); + }); + + it("disables Project Structure controls whose settings have no execution support", async () => { + useProject.setState({ project: { name: "demo", root: "/project" } as never }); + render(); + await act(async () => executeAction("file.projectStructure", "modules")); + fireEvent.click(screen.getByRole("tab", { name: "Paths" })); + expect(screen.getByLabelText("Test output path")).toBeDisabled(); + fireEvent.click(screen.getByRole("tab", { name: "Dependencies" })); + expect(screen.getByRole("button", { name: "Add Library" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Add Module Dependency" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Artifacts" })); + expect(screen.getByRole("button", { name: "JAR" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "WAR" })).toBeDisabled(); + }); + + it("shows the selected module's source tree", async () => { + current = setAt(current, "projectStructure.modules", { + service: { jdk: "", languageLevel: "", compilerOutput: "", testCompilerOutput: "", dependencies: [] }, + }); + useProject.setState({ settings: current, project: { name: "demo", root: "/project" } as never }); + render(); + await act(async () => executeAction("file.projectStructure", "modules")); + fireEvent.click(screen.getByRole("button", { name: /service\s*custom/ })); + const tree = screen.getByRole("tree", { name: "Content root" }); + expect(within(tree).getByRole("treeitem")).toHaveTextContent("service"); + expect(within(tree).queryByText("demo")).toBeNull(); + fireEvent.click(screen.getByRole("tab", { name: "Paths" })); + expect(screen.getByLabelText("Module language level")).toBeDisabled(); + expect(screen.getByLabelText("Output path")).toBeDisabled(); + }); }); describe("native Settings window root", () => { diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 9ce8d8cff..fa6ef156c 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_LANGUAGES, + JAVA_LANGUAGE_LEVELS, PHP_LANGUAGE_LEVELS, folderRole, markFolder, @@ -170,6 +171,101 @@ describe("languages settings", () => { ps = markFolder(ps, "src", null); expect(folderRole(ps, "src")).toBeNull(); }); + + it("defaults Java language level to SDK default and keeps only supported levels", () => { + expect(validateLanguages({}).java.languageLevel).toBe(""); + expect(validateLanguages({ java: { languageLevel: "8" } }).java.languageLevel).toBe("8"); + expect(validateLanguages({ java: { languageLevel: "21" } }).java.languageLevel).toBe("21"); + for (const invalid of ["6", "26", 17, null]) { + expect(validateLanguages({ java: { languageLevel: invalid } }).java.languageLevel).toBe(""); + } + expect(JAVA_LANGUAGE_LEVELS[0]).toBe(""); + expect(JAVA_LANGUAGE_LEVELS).toContain("8"); + expect(JAVA_LANGUAGE_LEVELS).toContain("25"); + }); + + it("normalizes project structure modules, libraries, facets, and artifacts", () => { + const ps = validateProjectStructure({ + compilerOutput: " out/production ", + modules: { + " ./modules/core/ ": { + jdk: " /opt/jdk-21 ", + languageLevel: "17", + compilerOutput: " target/classes ", + testCompilerOutput: " target/test-classes ", + dependencies: [ + { target: " common-lib ", kind: "library", scope: "provided", exported: true }, + { target: " ", kind: "module", scope: "invalid" }, + ], + }, + }, + libraries: [ + { + name: " servlet-api ", + classes: [" lib/servlet-api.jar ", "lib/servlet-api.jar", ""], + sources: [" lib/servlet-api-sources.jar "], + javadocs: [], + scope: "provided", + }, + { name: " ", classes: [], sources: [] }, + ], + facets: [ + { + kind: "web", + module: " ./modules/web/ ", + descriptor: " src/main/webapp/WEB-INF/web.xml ", + webRoot: " src/main/webapp ", + }, + ], + artifacts: [ + { + name: " app:war exploded ", + kind: "warExploded", + module: " ./modules/web/ ", + outputDirectory: " target/app-exploded ", + mainClass: " com.example.Main ", + includeLibraries: [" servlet-api ", "servlet-api", ""], + }, + ], + }); + expect(ps.compilerOutput).toBe("out/production"); + expect(ps.modules).toEqual({ + "modules/core": { + jdk: "/opt/jdk-21", + languageLevel: "17", + compilerOutput: "target/classes", + testCompilerOutput: "target/test-classes", + dependencies: [{ target: "common-lib", kind: "library", scope: "provided", exported: true }], + }, + }); + expect(ps.libraries).toEqual([ + { + name: "servlet-api", + classes: ["lib/servlet-api.jar"], + sources: ["lib/servlet-api-sources.jar"], + javadocs: [], + scope: "provided", + }, + ]); + expect(ps.facets).toEqual([ + { + kind: "web", + module: "modules/web", + descriptor: "src/main/webapp/WEB-INF/web.xml", + webRoot: "src/main/webapp", + }, + ]); + expect(ps.artifacts).toEqual([ + { + name: "app:war exploded", + kind: "warExploded", + module: "modules/web", + outputDirectory: "target/app-exploded", + mainClass: "com.example.Main", + includeLibraries: ["servlet-api"], + }, + ]); + }); }); describe("language pages", () => { diff --git a/src/features/settings/dialog/LazySettingsDialog.tsx b/src/features/settings/dialog/LazySettingsDialog.tsx index 3f0bf0c7c..36fa2aa23 100644 --- a/src/features/settings/dialog/LazySettingsDialog.tsx +++ b/src/features/settings/dialog/LazySettingsDialog.tsx @@ -21,8 +21,16 @@ export function unloadBuiltinPages() { } const Dialog = lazyComponent(() => import("./SettingsDialog").then((m) => m.SettingsDialog)); +const ProjectStructure = lazyComponent(() => + import("../languages/ProjectStructurePage").then((m) => m.ProjectStructureDialog), +); export function LazySettingsDialog() { const open = useSettingsDialog((s) => s.open); return open ? : null; } + +export function LazyProjectStructureDialog() { + const open = useSettingsDialog((s) => s.projectStructureOpen); + return open ? : null; +} diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index b6e00d8c9..b5d6ced14 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -20,9 +20,15 @@ import { logError } from "@/platform/errorLog"; import { restoreRemote } from "@/platform/remote"; import { provideSettingsSearchIndex, registerOverlay } from "@/workbench/contributions"; import { windowBackgroundHex } from "@/workbench/theme"; -import { LazySettingsDialog, loadBuiltinPages, unloadBuiltinPages } from "./dialog/LazySettingsDialog"; +import { + LazyProjectStructureDialog, + LazySettingsDialog, + loadBuiltinPages, + unloadBuiltinPages, +} from "./dialog/LazySettingsDialog"; import { closeSettings, + openProjectStructure, openSettings, rebaseSettings, selectPage, @@ -115,8 +121,19 @@ export function activate(): () => void { activatePhpStatus(), unloadBuiltinPages, registerOverlay({ id: "settings.dialog", component: LazySettingsDialog }), + registerOverlay({ id: "settings.projectStructure", component: LazyProjectStructureDialog }), registerActions(zoomActions()), registerActions(settingsActions((page, newProjects) => void showSettings(page, newProjects))), + registerActions([ + { + id: "file.projectStructure", + title: "Project Structure…", + category: "File", + icon: "folder", + keywords: ["project structure", "sdk", "jdk", "modules", "libraries", "facets", "artifacts", "sources"], + run: ({ arg }) => openProjectStructure(typeof arg === "string" ? arg : undefined), + }, + ]), activateFileTypes(), registerOverlay({ id: "settings.assignShortcut", diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index e72d7b0a6..5a20539ca 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -6,7 +6,7 @@ import { useEffect, useId, useMemo, useState } from "react"; import { call, commands, type Sdk } from "@/platform/ipc"; import { IconButton, Input, type SelectOption, T } from "@/ui"; -import { PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings"; +import { JAVA_LANGUAGE_LEVELS, PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings"; import { GlobListEditor, Row, Section, Select, Toggle } from "../components/Form"; import { choosePath } from "./api"; import { useSetting } from "../components/context"; @@ -358,8 +358,13 @@ export function GoPage() { // --------------------------------------------------------------------------- // Java +export const JAVA_LEVEL_OPTIONS: SelectOption[] = JAVA_LANGUAGE_LEVELS.map((level) => + level ? { value: level, label: level } : { value: "", label: "SDK default", detail: "from build / JDK" }, +); + export function JavaPage() { const [vmArgs, setVmArgs] = useSetting("languages.java.jdtlsVmArgs"); + const [languageLevel, setLanguageLevel] = useSetting("languages.java.languageLevel"); const id = useId(); return (
@@ -375,6 +380,17 @@ export function JavaPage() { aria-label="Project JDK" hint="Compiles and runs the project: the default jdtls runtime, Run/Debug, and JAVA_HOME for Maven and Gradle." /> + + + + + + setCompilerOutput(e.target.value)} + /> + { + const picked = await choosePath({ + directory: true, + title: "Select Compiler Output Directory", + defaultPath: compilerOutput || root || undefined, + }); + if (picked) setCompilerOutput(root && inside(root, picked) ? relativePath(root, picked) : picked); + }} + /> + + + +
+ + + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 2. Modules Section (Sources, Paths, Dependencies) + +function ModuleSourcesTab({ moduleKey }: { moduleKey: string }) { const root = useProject((s) => s.project?.root ?? null); const name = useProject((s) => s.project?.name ?? ""); const [ps, setPs] = useStructure(); @@ -176,7 +393,8 @@ export function ProjectStructurePage() { const roots = useMemo(() => effectiveRoots(detected, ps), [detected, ps]); useEffect(() => initProjectRoots(), []); const [children, setChildren] = useState>(new Map()); - const [expanded, setExpanded] = useState>(() => new Set(root ? [root] : [])); + const moduleRoot = root ? (moduleKey === "." ? root : `${root}/${moduleKey}`) : null; + const [expanded, setExpanded] = useState>(() => new Set(moduleRoot ? [moduleRoot] : [])); const [selected, setSelected] = useState(null); const load = useCallback( @@ -188,13 +406,23 @@ export function ProjectStructurePage() { [root], ); - const rootNode = useMemo(() => (root ? { path: root, name, rel: "" } : null), [root, name]); + const rootNode = useMemo( + () => + moduleRoot + ? { + path: moduleRoot, + name: moduleKey === "." ? name : moduleKey.split("/").pop()!, + rel: moduleKey === "." ? "" : moduleKey, + } + : null, + [moduleRoot, moduleKey, name], + ); const treeRoots = useMemo( () => rootNode ? [ rootNode, - ...ps.contentRoots.map((p) => ({ + ...(moduleKey === "." ? ps.contentRoots : []).map((p) => ({ path: p, name: p.slice(p.lastIndexOf("/") + 1) || p, rel: null, @@ -202,19 +430,19 @@ export function ProjectStructurePage() { })), ] : [], - [rootNode, ps.contentRoots], + [rootNode, ps.contentRoots, moduleKey], ); useEffect(() => { if (!root) return; let live = true; - for (const dir of [root, ...ps.contentRoots]) + for (const dir of [moduleRoot!, ...(moduleKey === "." ? ps.contentRoots : [])]) void readDirs(root, dir) .then((dirs) => live && setChildren((m) => new Map(m).set(dir, dirs))) .catch(() => {}); return () => { live = false; }; - }, [root, ps.contentRoots]); + }, [root, ps.contentRoots, moduleRoot, moduleKey]); if (!rootNode) return ( @@ -229,113 +457,1554 @@ export function ProjectStructurePage() { if (markable === null) return; setPs(markFolder(ps, markable, role === r ? null : r, detected)); }; - const unmarkedDetected = unmarkedRoots(detected, ps); - const restore = () => setPs(restoreDetected(ps, detected)); + const moduleDetected = + moduleKey === "." + ? detected + : detected.filter((folder) => folder.path === moduleKey || folder.path.startsWith(moduleKey + "/")); + const unmarkedDetected = unmarkedRoots(moduleDetected, ps); + const restore = () => setPs(restoreDetected(ps, moduleDetected)); return ( -
-
-
-
- - Mark as: - - {ROLES.map((r) => ( - - ))} -
-
- - aria-label="Content root" - roots={treeRoots} - getKey={(n) => n.path} - getLabel={(n) => n.name} - getChildren={(n) => children.get(n.path)} - isExpandable={() => true} - loadChildren={load} - expanded={expanded} - onExpandedChange={(set) => setExpanded(set)} - multiSelect={false} - selected={selected ? new Set([selected.path]) : new Set()} - onSelectionChange={(_s, lead) => setSelected(lead)} - getIcon={(n) => { - if (n.rel === null) return n.contentRoot ? "module" : folderIconName("plain"); - const r = rootRole(roots, n.rel); - if (!n.rel && !r) return "module"; - return folderIconName(r ? ROLES.find((x) => x.role === r)!.kind : "plain"); - }} - getDetail={(n) => (n.rel === "" ? (root ?? undefined) : n.contentRoot ? n.path : undefined)} - /> -
- +
+
+
+ + Mark as: + + {ROLES.map((r) => ( + + ))}
-
- - {ROLES.map((r) => { - const list = roots.filter((x) => x.kind === r.role); - return ( -
-
- - {r.title} -
- {list.length === 0 && ( -
- None -
- )} - {list.map((x) => { - const shown = x.path || name || "."; - return ( -
- - {shown} - {x.detectedFrom && ( - {tx`detected from ${x.detectedFrom}`} - )} - - setPs(markFolder(ps, x.path, null, detected))} - /> -
- ); - })} +
+ + aria-label="Content root" + roots={treeRoots} + getKey={(n) => n.path} + getLabel={(n) => n.name} + getChildren={(n) => children.get(n.path)} + isExpandable={() => true} + loadChildren={load} + expanded={expanded} + onExpandedChange={(set) => setExpanded(set)} + multiSelect={false} + selected={selected ? new Set([selected.path]) : new Set()} + onSelectionChange={(_s, lead) => setSelected(lead)} + getIcon={(n) => { + if (n.rel === null) return n.contentRoot ? "module" : folderIconName("plain"); + const r = rootRole(roots, n.rel); + if (!n.rel && !r) return "module"; + return folderIconName(r ? ROLES.find((x) => x.role === r)!.kind : "plain"); + }} + getDetail={(n) => (n.rel === "" ? (root ?? undefined) : n.contentRoot ? n.path : undefined)} + /> +
+ {moduleKey === "." && } +
+
+ {moduleKey === "." && } + {ROLES.map((r) => { + const list = roots.filter( + (x) => + x.kind === r.role && (moduleKey === "." || x.path === moduleKey || x.path.startsWith(moduleKey + "/")), + ); + return ( +
+
+ + {r.title}
- ); - })} - {unmarkedDetected.length > 0 && ( -
- {unmarkedDetected.length === 1 - ? tx("1 detected folder is unmarked.") - : tx`${unmarkedDetected.length} detected folders are unmarked.`}{" "} - + {list.length === 0 && ( +
+ None +
+ )} + {list.map((x) => { + const shown = x.path || name || "."; + return ( +
+ + {shown} + {x.detectedFrom && ( + {tx`detected from ${x.detectedFrom}`} + )} + + setPs(markFolder(ps, x.path, null, detected))} + /> +
+ ); + })}
- )} + ); + })} + {unmarkedDetected.length > 0 && (
- - { - "Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index)." - } - + {unmarkedDetected.length === 1 + ? tx("1 detected folder is unmarked.") + : tx`${unmarkedDetected.length} detected folders are unmarked.`}{" "} +
+ )} +
+ + { + "Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index)." + } +
); } + +interface DiscoveredModule { + key: string; + name: string; + buildTool: "maven" | "gradle" | "custom"; +} + +function ModulesSection() { + const root = useProject((s) => s.project?.root ?? null); + const projectName = useProject((s) => s.project?.name ?? ""); + const model = useBuildModelData(root); + const [modulesMap, setModulesMap] = useProjectSetting | undefined>( + "projectStructure.modules", + ); + const [libraries] = useProjectSetting("projectStructure.libraries"); + const [selectedModule, setSelectedModule] = useState("."); + const [subTab, setSubTab] = useState<"sources" | "paths" | "dependencies">("sources"); + const [selectedDepIdx, setSelectedDepIdx] = useState(-1); + const [moduleError, setModuleError] = useState(false); + const outId = useId(); + const testOutId = useId(); + + const modules = useMemo(() => { + const seen = new Set(); + const list: DiscoveredModule[] = []; + const add = (key: string, name: string, buildTool: "maven" | "gradle" | "custom") => { + const norm = normalizeFolder(key) || "."; + if (seen.has(norm)) return; + seen.add(norm); + list.push({ key: norm, name, buildTool }); + }; + add(".", projectName || "Root Module", model?.maven ? "maven" : model?.gradle?.length ? "gradle" : "custom"); + for (const p of model?.maven?.projects ?? []) { + add(p.dir, p.displayName || p.artifactId || p.dir, "maven"); + } + for (const gm of model?.gradle ?? []) { + for (const p of gm.projects) { + add(p.dir, p.name || p.path || p.dir, "gradle"); + } + } + for (const k of Object.keys(modulesMap ?? {})) { + add(k, k === "." ? projectName || "Root Module" : k.split("/").pop() || k, "custom"); + } + return list; + }, [projectName, model, modulesMap]); + + const activeKey = modules.some((m) => m.key === selectedModule) ? selectedModule : "."; + const currentModuleCfg: ModuleStructureValid = modulesMap?.[activeKey] ?? { + jdk: "", + languageLevel: "", + compilerOutput: "", + testCompilerOutput: "", + dependencies: [], + }; + + const updateModuleCfg = (patch: Partial) => { + const next: ModuleStructureValid = { ...currentModuleCfg, ...patch }; + setModulesMap({ ...(modulesMap ?? {}), [activeKey]: next }); + }; + + const addModule = async () => { + const picked = await choosePath({ + directory: true, + title: "Select Module Directory", + defaultPath: root ?? undefined, + }); + if (!picked) return; + if (!root || !inside(root, picked)) { + setModuleError(true); + return; + } + setModuleError(false); + const rel = normalizeFolder(relativePath(root, picked)) || "."; + if (!(modulesMap && modulesMap[rel])) { + setModulesMap({ + ...(modulesMap ?? {}), + [rel]: { jdk: "", languageLevel: "", compilerOutput: "", testCompilerOutput: "", dependencies: [] }, + }); + } + setSelectedModule(rel); + }; + + const removeModule = () => { + if (activeKey === "." || !modulesMap?.[activeKey]) return; + const next = { ...modulesMap }; + delete next[activeKey]; + setModulesMap(next); + setSelectedModule("."); + }; + + const addLibraryDependency = () => { + const firstLib = libraries?.[0]?.name ?? "project-lib"; + const dep: ModuleDependencyValid = { kind: "library", target: firstLib, scope: "compile", exported: false }; + updateModuleCfg({ dependencies: [...currentModuleCfg.dependencies, dep] }); + setSelectedDepIdx(currentModuleCfg.dependencies.length); + }; + + const addModuleDependency = () => { + const otherMod = modules.find((m) => m.key !== activeKey)?.key ?? "shared"; + const dep: ModuleDependencyValid = { kind: "module", target: otherMod, scope: "compile", exported: false }; + updateModuleCfg({ dependencies: [...currentModuleCfg.dependencies, dep] }); + setSelectedDepIdx(currentModuleCfg.dependencies.length); + }; + + const removeDependency = () => { + if (selectedDepIdx < 0 || selectedDepIdx >= currentModuleCfg.dependencies.length) return; + const next = currentModuleCfg.dependencies.filter((_, idx) => idx !== selectedDepIdx); + updateModuleCfg({ dependencies: next }); + setSelectedDepIdx(-1); + }; + + const activeMod = modules.find((m) => m.key === activeKey) ?? modules[0]; + + return ( +
+ {moduleError && ( +
+ Select a module directory inside the project. +
+ )} +
+
+
+ + + +
+
+ {modules.map((m) => ( + + ))} +
+
+ +
+
+ + + {activeMod?.name ?? activeKey} + + {activeKey !== "." && {activeKey}} + + + + +
+ + {subTab === "sources" && } + + {subTab === "paths" && ( +
+ {activeKey !== "." && ( +
+ + Per-module language levels and compiler output overrides are not supported yet. Configure them in + Maven or Gradle. + +
+ )} + + updateModuleCfg({ jdk })} + emptyLabel="Project SDK" + emptyDetail="inherit from Project" + directory + browseTitle="Select JDK Home Directory" + aria-label="Module SDK" + /> + + + updateModuleCfg({ compilerOutput: e.target.value })} + /> + { + const picked = await choosePath({ + directory: true, + title: "Select Compiler Output Directory", + defaultPath: currentModuleCfg.compilerOutput || root || undefined, + }); + if (picked) + updateModuleCfg({ + compilerOutput: root && inside(root, picked) ? relativePath(root, picked) : picked, + }); + }} + /> + + + + + updateModuleCfg({ testCompilerOutput: e.target.value })} + /> + { + const picked = await choosePath({ + directory: true, + title: "Select Test Output Directory", + defaultPath: currentModuleCfg.testCompilerOutput || root || undefined, + }); + if (picked) + updateModuleCfg({ + testCompilerOutput: root && inside(root, picked) ? relativePath(root, picked) : picked, + }); + }} + /> + + +
+ )} + + {subTab === "dependencies" && ( +
+
+ Custom module dependencies are not supported yet. Configure dependencies in Maven or Gradle. +
+
+
+ + + +
+
+ + + + + + + + + + + {currentModuleCfg.dependencies.length === 0 && ( + + + + )} + {currentModuleCfg.dependencies.map((dep, idx) => ( + setSelectedDepIdx(idx)} + > + + + + + + ))} + +
+ Export + + Dependency + + Kind + + Scope +
+ No custom module dependencies configured. +
+ { + const next = [...currentModuleCfg.dependencies]; + next[idx] = { ...dep, exported }; + updateModuleCfg({ dependencies: next }); + }} + aria-label="Export dependency" + /> + + { + const next = [...currentModuleCfg.dependencies]; + next[idx] = { ...dep, target: e.target.value }; + updateModuleCfg({ dependencies: next }); + }} + /> + + + {dep.kind === "module" ? Module : Library} + + +
+
+
+
+ )} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// 3. Libraries Section + +function LibrariesSection() { + const root = useProject((s) => s.project?.root ?? null); + const [rawLibs, setRawLibs] = useProjectSetting("projectStructure.libraries"); + const libs = rawLibs ?? []; + const [selectedIdx, setSelectedIdx] = useState(0); + const [detectedGroups, setDetectedGroups] = useState([]); + const nameId = useId(); + + useEffect(() => { + if (!root || !isTauri()) return; + let live = true; + call(commands.sdkProjectLibraries()) + .then((groups) => live && setDetectedGroups(groups)) + .catch(() => {}); + return () => { + live = false; + }; + }, [root, libs.length]); + + const activeIdx = selectedIdx >= 0 && selectedIdx < libs.length ? selectedIdx : libs.length > 0 ? 0 : -1; + const current = activeIdx >= 0 ? libs[activeIdx]! : null; + + const updateCurrent = (patch: Partial) => { + if (!current || activeIdx < 0) return; + const next = [...libs]; + next[activeIdx] = { ...current, ...patch }; + setRawLibs(next); + }; + + const addLibraryFromJar = async (directory: boolean) => { + const picked = await choosePath({ + directory, + title: directory ? "Select Classes Directory" : "Select Library JAR", + defaultPath: root ?? undefined, + }); + if (!picked) return; + const rel = root && inside(root, picked) ? relativePath(root, picked) : picked; + const baseName = + picked + .split(/[\\/]/) + .pop() + ?.replace(/\.jar$/i, "") || "Library"; + const created: ProjectLibraryValid = { + name: baseName, + classes: [rel], + sources: [], + javadocs: [], + scope: "compile", + }; + const next = [...libs, created]; + setRawLibs(next); + setSelectedIdx(next.length - 1); + }; + + const removeSelectedLibrary = () => { + if (activeIdx < 0) return; + const next = libs.filter((_, idx) => idx !== activeIdx); + setRawLibs(next); + setSelectedIdx(Math.max(0, activeIdx - 1)); + }; + + const appendPath = async (field: "classes" | "sources" | "javadocs", directory: boolean, title: string) => { + if (!current) return; + const picked = await choosePath({ directory, title, defaultPath: root ?? undefined }); + if (!picked) return; + const rel = root && inside(root, picked) ? relativePath(root, picked) : picked; + if (!current[field].includes(rel)) { + updateCurrent({ [field]: [...current[field], rel] }); + } + }; + + return ( +
+
+
+
+ void addLibraryFromJar(false)} + /> + void addLibraryFromJar(true)} + /> + +
+
+ {libs.length === 0 && ( +
+ No project libraries added. +
+ )} + {libs.map((lib, idx) => ( + + ))} +
+
+ +
+ {current ? ( +
+ + updateCurrent({ name: e.target.value })} /> + + + updateCurrent({ kind: v as "web" | "spring" | "jpa" })} + aria-label="Facet framework" + /> + + + updateCurrent({ module: e.target.value })} + /> + + + + updateCurrent({ descriptor: e.target.value })} + /> + { + const picked = await choosePath({ + directory: false, + title: "Select Deployment Descriptor", + defaultPath: root ?? undefined, + }); + if (picked) + updateCurrent({ + descriptor: root && inside(root, picked) ? relativePath(root, picked) : picked, + }); + }} + /> + + + {current.kind === "web" && ( + + + updateCurrent({ webRoot: e.target.value })} + /> + { + const picked = await choosePath({ + directory: true, + title: "Select Web Resource Directory", + defaultPath: root ?? undefined, + }); + if (picked) + updateCurrent({ + webRoot: root && inside(root, picked) ? relativePath(root, picked) : picked, + }); + }} + /> + + + )} +
+ ) : ( +
+
+ + Facets describe the frameworks (Web, Spring, JPA) used by a module. Adding a Web facet also registers + its WEB-INF/lib/*.jar archives with the Java language server. + +
+
+ )} + + {detectedSummary.length > 0 && ( +
+
+ {detectedSummary.map((d, i) => ( +
+ + + {d.kind} ({d.module}) + + {d.detail} +
+ ))} +
+
+ )} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// 5. Artifacts Section (JAR, WAR, Exploded WAR) + +function ArtifactsSection() { + const root = useProject((s) => s.project?.root ?? null); + const projectName = useProject((s) => s.project?.name ?? "app"); + const [rawArtifacts, setRawArtifacts] = useProjectSetting( + "projectStructure.artifacts", + ); + const [libraries] = useProjectSetting("projectStructure.libraries"); + const artifacts = rawArtifacts ?? []; + const [selectedIdx, setSelectedIdx] = useState(0); + const [webArtifacts, setWebArtifacts] = useState([]); + const nameId = useId(); + const outId = useId(); + const mainClassId = useId(); + + useEffect(() => { + if (!root || !isTauri()) return; + let live = true; + call(commands.runTomcatArtifacts()) + .then((list) => live && setWebArtifacts(list)) + .catch(() => {}); + return () => { + live = false; + }; + }, [root]); + + const activeIdx = selectedIdx >= 0 && selectedIdx < artifacts.length ? selectedIdx : artifacts.length > 0 ? 0 : -1; + const current = activeIdx >= 0 ? artifacts[activeIdx]! : null; + + const updateCurrent = (patch: Partial) => { + if (!current || activeIdx < 0) return; + const next = [...artifacts]; + next[activeIdx] = { ...current, ...patch }; + setRawArtifacts(next); + }; + + const addArtifact = (kind: ArtifactKindValid) => { + const base = projectName || "app"; + const suffix = kind === "jar" ? "jar" : kind === "war" ? "war" : "war exploded"; + const created: ArtifactConfigValid = { + name: `${base}:${suffix}`, + kind, + module: ".", + outputDirectory: `out/artifacts/${base}_${suffix.replace(/\s+/g, "_")}`, + mainClass: "", + includeLibraries: (libraries ?? []).map((l) => l.name), + }; + const next = [...artifacts, created]; + setRawArtifacts(next); + setSelectedIdx(next.length - 1); + }; + + const removeArtifact = () => { + if (activeIdx < 0) return; + const next = artifacts.filter((_, idx) => idx !== activeIdx); + setRawArtifacts(next); + setSelectedIdx(Math.max(0, activeIdx - 1)); + }; + + return ( +
+
+ Custom artifact packaging is not supported yet. Build and deploy artifacts defined by Maven or Gradle. +
+
+
+
+
+ + + + + +
+
+ {artifacts.length === 0 && ( +
+ No custom artifacts configured. +
+ )} + {artifacts.map((a, idx) => ( + + ))} +
+
+ +
+ {current ? ( +
+ + updateCurrent({ name: e.target.value })} /> + + + updateCurrent({ module: e.target.value })} + /> + + + + updateCurrent({ outputDirectory: e.target.value })} + /> + { + const picked = await choosePath({ + directory: true, + title: "Select Artifact Output Directory", + defaultPath: root ?? undefined, + }); + if (picked) + updateCurrent({ + outputDirectory: root && inside(root, picked) ? relativePath(root, picked) : picked, + }); + }} + /> + + + {current.kind === "jar" && ( + + updateCurrent({ mainClass: e.target.value })} + /> + + )} + {(libraries ?? []).length > 0 && ( + +
+ {(libraries ?? []).map((lib) => { + const included = current.includeLibraries.includes(lib.name); + return ( + { + const next = checked + ? [...current.includeLibraries, lib.name] + : current.includeLibraries.filter((n) => n !== lib.name); + updateCurrent({ includeLibraries: next }); + }} + /> + ); + })} +
+
+ )} +
+ ) : ( +
+
+ Configure JAR, WAR, or exploded WAR artifacts for packaging and application server deployment. +
+
+ )} + + {webArtifacts.length > 0 && ( +
+
+ {webArtifacts.map((w) => ( +
+ + + {w.name} ({w.module || "."}) + + {w.tool} +
+ ))} +
+
+ )} +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// 6. SDKs Section (IDEA 3-Column Master-Detail: SDK List | Selected SDK Details) + +function SdksSection() { + const byKind = useSdks((s) => s.byKind); + const [downloading, setDownloading] = useState(false); + const [selectedKey, setSelectedKey] = useState(""); + const [projectJdk, setProjectJdk] = useProjectSetting("languages.java.jdk"); + useDetected("jdk"); + + useEffect(() => { + for (const { kind } of SDK_KINDS) { + void detectSdks(kind); + } + }, []); + + const refreshAll = () => { + for (const { kind } of SDK_KINDS) { + void detectSdks(kind, true); + } + }; + + const rows = useMemo(() => { + const out: Array<{ key: string; kind: SdkKind; kindLabel: string; name: string; version: string; path: string }> = + []; + for (const { kind, label } of SDK_KINDS) { + for (const sdk of byKind[kind] ?? []) { + out.push({ + key: `${kind}:${sdk.path}`, + kind, + kindLabel: label, + name: sdk.name, + version: sdk.version ?? "", + path: sdk.path, + }); + } + } + return out; + }, [byKind]); + + const activeSdk = rows.find((r) => r.key === selectedKey) ?? rows[0]; + + return ( +
+
+
+
+ + + +
+
+ {rows.length === 0 && ( +
+ No SDK installations detected yet. +
+ )} + {rows.map((r) => ( + + ))} +
+
+ +
+ {activeSdk ? ( +
+
+ {activeSdk.kindLabel} + {activeSdk.name} + {activeSdk.version && {activeSdk.version}} + + {activeSdk.kind === "jdk" && ( + + )} +
+ + + +
+ ) : null} + +
+
+ + + + + + + + + + + {rows.length === 0 && ( + + + + )} + {rows.map((r) => ( + setSelectedKey(r.key)}> + + + + + + ))} + +
+ Type + + Name + + Version + + Home Path +
+ No SDK installations detected yet. +
+ {r.kindLabel} + {r.name}{r.version || "—"} + {tildify(r.path)} +
+
+
+
+
+ {downloading && ( + setDownloading(false)} + onDownloaded={(sdk) => { + setDownloading(false); + setProjectJdk(sdk.path); + }} + /> + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Exported Pages + +const NAV_GROUPS: Array<{ + title: string; + items: Array<{ id: ProjectStructureSectionId; label: string; icon: IconName }>; +}> = [ + { + title: "Project Settings", + items: [ + { id: "project", label: "Project", icon: "folder" }, + { id: "modules", label: "Modules", icon: "module" }, + { id: "libraries", label: "Libraries", icon: "folder-library" }, + { id: "facets", label: "Facets", icon: "file-config" }, + { id: "artifacts", label: "Artifacts", icon: "file-archive" }, + ], + }, + { + title: "Platform Settings", + items: [{ id: "sdks", label: "SDKs", icon: "settings" }], + }, +]; + +export function ProjectStructureDialog() { + const open = useSettingsDialog((s) => s.projectStructureOpen); + if (!open) return null; + return ; +} + +function ProjectStructureStatus() { + const error = useSettingsDialog((s) => s.error); + return ( + + {error && ( + + {tx`Could not save: ${error}`} + + )} + + ); +} + +function ProjectStructureDialogInner() { + const section = useSettingsDialog((s) => s.projectStructureSection); + const { staged, base, globalBase, saving } = useSettingsDialog(); + const hasProject = useProject((s) => !!s.project); + const scope = hasProject ? "project" : "global"; + const pageId = "projectStructure"; + const ctx = useMemo(() => ({ pageId, scope }) as const, [scope]); + const form = useSettingsFormFor(pageId, scope); + const pending = useMemo(() => pendingChanges({ base, globalBase, staged }).length > 0, [base, globalBase, staged]); + + const activeGroup = NAV_GROUPS.find((g) => g.items.some((i) => i.id === section)) ?? NAV_GROUPS[0]!; + const activeItem = activeGroup.items.find((i) => i.id === section) ?? activeGroup.items[0]!; + + const ok = async () => { + if (await applySettings()) closeProjectStructure(); + }; + + const buttons: DialogButton[] = [ + { label: "OK", role: "default", disabled: saving, onClick: () => void ok() }, + { label: "Cancel", role: "cancel", onClick: closeProjectStructure }, + { label: "Apply", disabled: !pending || saving, onClick: () => void applySettings() }, + ]; + + const onNavKeyDown = (e: React.KeyboardEvent) => { + if ((e.key !== "ArrowDown" && e.key !== "ArrowUp") || e.altKey || e.metaKey || e.ctrlKey) return; + e.preventDefault(); + const idx = SECTION_TABS.findIndex((t) => t.id === section); + const nextIdx = e.key === "ArrowDown" ? Math.min(SECTION_TABS.length - 1, idx + 1) : Math.max(0, idx - 1); + const next = SECTION_TABS[nextIdx]; + if (next) selectProjectStructureSection(next.id); + }; + + return ( + } + buttons={buttons} + > + + +
+ + +
+
+
+ + {activeGroup.title} + + + + {activeItem.label} + +
+ {pending && !saving && ( + + )} +
+
+ {section === "project" && } + {section === "modules" && } + {section === "libraries" && } + {section === "facets" && } + {section === "artifacts" && } + {section === "sdks" && } +
+
+
+
+
+
+ ); +} + +export function ProjectStructurePage({ initialSection = "modules" }: { initialSection?: ProjectStructureSectionId }) { + const [section, setSection] = useState(initialSection); + + return ( +
+
+ {SECTION_TABS.map((tab) => ( + + ))} +
+ {section === "project" && } + {section === "modules" && } + {section === "libraries" && } + {section === "facets" && } + {section === "artifacts" && } + {section === "sdks" && } +
+ ); +} diff --git a/src/features/settings/languages/Sdk.module.css b/src/features/settings/languages/Sdk.module.css index 7f256fc6c..06e7acb53 100644 --- a/src/features/settings/languages/Sdk.module.css +++ b/src/features/settings/languages/Sdk.module.css @@ -56,11 +56,15 @@ .tableToolbar { display: flex; align-items: center; + flex-wrap: wrap; gap: 2px; - height: 28px; - padding: 0 4px; + min-height: 28px; + padding: 2px 4px; border-bottom: 1px solid var(--border); } +.tableToolbar > * { + flex-shrink: 0; +} .spacer { flex: 1; } @@ -205,7 +209,7 @@ /* Project Structure */ .structure { display: grid; - grid-template-columns: minmax(0, 1fr) 260px; + grid-template-columns: minmax(0, 1fr) 220px; gap: 10px; min-height: 420px; } @@ -220,13 +224,15 @@ .markBar { display: flex; align-items: center; + flex-wrap: wrap; gap: 4px; - height: 30px; - padding: 0 6px; + min-height: 30px; + padding: 4px 6px; border-bottom: 1px solid var(--border); color: var(--fg-secondary); } .markButton { + flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px; @@ -237,6 +243,7 @@ background: transparent; color: var(--fg); font: inherit; + white-space: nowrap; } .markButton:hover:not(:disabled) { background: var(--bg-toolbar-hover); @@ -372,3 +379,290 @@ flex-direction: column; gap: 4px; } + +/* Project Structure sections (Project, Modules, Libraries, Facets, Artifacts, SDKs) */ +.sectionTabs { + display: flex; + align-items: center; + gap: 4px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.sectionTab { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 10px; + border: 0; + border-radius: var(--radius-toolbar-button); + background: transparent; + color: var(--fg-secondary); + font: inherit; + white-space: nowrap; + cursor: pointer; +} +.sectionTab:hover { + background: var(--bg-toolbar-hover); + color: var(--fg); +} +.sectionTab[aria-selected="true"] { + background: var(--bg-toolbar-selected); + color: var(--fg); + font-weight: 500; +} +.splitLayout { + display: grid; + grid-template-columns: 268px minmax(0, 1fr); + gap: 12px; + min-height: 360px; +} +.sideList { + display: flex; + flex-direction: column; + border-radius: var(--radius-small); + box-shadow: inset 0 0 0 1px var(--border); + overflow: hidden; + min-height: 0; +} +.sideList .tableToolbar button { + flex-shrink: 0; + padding: 0 6px; + gap: 3px; + font-size: var(--fs-small); +} +.sideItems { + flex: 1; + overflow: auto; + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; +} +.sideItem { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-height: var(--h-list-row); + padding: 0 8px; + border: 0; + border-radius: var(--radius-row); + background: transparent; + color: var(--fg); + font: inherit; + text-align: left; + cursor: pointer; +} +.sideItem:hover { + background: var(--bg-hover); +} +.sideItemActive, +.sideItemActive:hover { + background: var(--bg-list-selected); +} +.sideItemLabel { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.sideItemMeta { + color: var(--fg-muted); + font-size: var(--fs-small); +} +.detailPane { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} +.subTabs { + display: flex; + align-items: center; + gap: 4px; + border-bottom: 1px solid var(--border); + padding-bottom: 4px; +} +.entryList { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px; + border-radius: var(--radius-small); + box-shadow: inset 0 0 0 1px var(--border); +} +.entryRow { + display: flex; + align-items: center; + gap: 6px; + min-height: 24px; + padding: 0 4px; + font-family: var(--font-mono); + font-size: var(--fs-small); +} +.entryActions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.badge { + display: inline-flex; + align-items: center; + padding: 0 6px; + height: 18px; + border-radius: var(--radius-small); + background: var(--bg-toolbar-hover); + color: var(--fg-secondary); + font-size: var(--fs-small); +} + +/* Standalone IntelliJ IDEA-style Project Structure Dialog */ +.psDialog { + height: min(720px, calc(100vh - 48px)); + background: var(--bg-dialog, var(--bg-elev)); +} +.psDialogLayout { + height: 100%; + display: flex; + min-height: 0; + min-width: 0; +} +.psNavSidebar { + flex: none; + width: 196px; + display: flex; + flex-direction: column; + min-height: 0; + border-right: 1px solid var(--border); + padding: 8px 0; + overflow-y: auto; +} +.psNavGroup { + display: flex; + flex-direction: column; +} +.psNavGroup + .psNavGroup { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid var(--border); +} +.psNavGroupHeader { + padding: 4px 14px 4px; + font-size: var(--fs-small); + font-weight: 600; + color: var(--fg-subtle); + user-select: none; +} +.psNavItem { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 26px; + padding: 0 14px 0 22px; + border: 0; + background: transparent; + color: var(--fg); + font: inherit; + text-align: left; + cursor: pointer; +} +.psNavItem:hover { + background: var(--bg-hover); +} +.psNavItemActive, +.psNavItemActive:hover { + background: var(--bg-tree-selected); + color: var(--fg-strong); + font-weight: 500; +} +.psDialogMain { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} +.psDialogHeader { + flex: none; + display: flex; + align-items: center; + gap: 12px; + height: 38px; + padding: 0 18px; + border-bottom: 1px solid var(--border); +} +.psDialogCrumbs { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + color: var(--fg-strong); + font-weight: 600; +} +.psDialogCrumbSep { + color: var(--fg-subtle); +} +.psDialogReset { + flex: none; + padding: 0; + border: 0; + background: none; + color: var(--link); + font: inherit; + cursor: pointer; +} +.psDialogReset:hover { + color: var(--link-hover); + text-decoration: underline; +} +.psDialogBody { + flex: 1; + min-height: 0; + overflow: auto; + padding: 14px 18px 18px; + -webkit-user-select: text; + user-select: text; +} +.psModuleHeader { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + border-bottom: 1px solid var(--border); + padding-bottom: 4px; +} +.psModuleTitle { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 600; + color: var(--fg-strong); +} +.psFooterStatus { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.psFooterError { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--danger); +} + +.pendingFeature { + display: flex; + flex-direction: column; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} diff --git a/src/features/settings/model/staging.ts b/src/features/settings/model/staging.ts index b78d67bcb..f5953d32f 100644 --- a/src/features/settings/model/staging.ts +++ b/src/features/settings/model/staging.ts @@ -24,8 +24,27 @@ interface ExternalChange { apply(): Promise; } +export type ProjectStructureSection = "project" | "modules" | "libraries" | "facets" | "artifacts" | "sdks"; + +const PROJECT_STRUCTURE_SECTIONS: readonly ProjectStructureSection[] = [ + "project", + "modules", + "libraries", + "facets", + "artifacts", + "sdks", +]; + +function parseProjectStructureSection(raw?: string): ProjectStructureSection { + if (!raw) return "project"; + const clean = raw.replace(/^projectStructure\./, "").trim() as ProjectStructureSection; + return PROJECT_STRUCTURE_SECTIONS.includes(clean) ? clean : "project"; +} + export interface SettingsDialogState { open: boolean; + projectStructureOpen: boolean; + projectStructureSection: ProjectStructureSection; pageId: string; /** What the page should select when opened as `page#focus` (an inspection id). */ focus: string | null; @@ -54,6 +73,8 @@ export const SSH_SETTINGS_PAGE = "tools.sshConfigurations"; export const useSettingsDialog = create(() => ({ open: false, + projectStructureOpen: false, + projectStructureSection: "project", pageId: DEFAULT_PAGE, focus: null, query: "", @@ -84,6 +105,7 @@ export function openSettings(pageId?: string, opts: { newProjects?: boolean } = const target = pageId ? splitPageTarget(pageId) : { pageId: s.pageId, focus: null }; useSettingsDialog.setState({ open: true, + projectStructureOpen: false, pageId: target.pageId, focus: target.focus, query: "", @@ -100,12 +122,51 @@ export function openSettings(pageId?: string, opts: { newProjects?: boolean } = void loadGlobalBase(); } +export function openProjectStructure(section?: string) { + if (useSettingsDialog.getState().projectStructureOpen) { + if (section) selectProjectStructureSection(parseProjectStructureSection(section)); + return; + } + const base = validateSettings(useProject.getState().settings); + useSettingsDialog.setState({ + open: false, + projectStructureOpen: true, + projectStructureSection: parseProjectStructureSection(section), + base, + globalBase: base, + newProjects: false, + staged: {}, + stagedSecrets: {}, + stagedExternal: {}, + externalApplied: {}, + saving: false, + error: null, + }); + void loadGlobalBase(); +} + +export function closeProjectStructure() { + useSettingsDialog.setState({ + projectStructureOpen: false, + staged: {}, + stagedSecrets: {}, + stagedExternal: {}, + externalApplied: {}, + error: null, + saving: false, + }); +} + +export function selectProjectStructureSection(section: ProjectStructureSection) { + useSettingsDialog.setState({ projectStructureSection: section }); +} + /** With a project open the effective settings include its values; the Global scope shows the global layer. */ async function loadGlobalBase() { if (!isTauri() || !useProject.getState().project) return; try { const globalBase = validateSettings(await commands.settingsGetGlobal()); - useSettingsDialog.setState((s) => (s.open ? { globalBase } : {})); + useSettingsDialog.setState((s) => (s.open || s.projectStructureOpen ? { globalBase } : {})); } catch (e) { console.warn("cannot load global settings", e); } @@ -113,7 +174,7 @@ async function loadGlobalBase() { export function rebaseSettings() { const base = validateSettings(useProject.getState().settings); - useSettingsDialog.setState((s) => (s.open ? { base, globalBase: base } : {})); + useSettingsDialog.setState((s) => (s.open || s.projectStructureOpen ? { base, globalBase: base } : {})); void loadGlobalBase(); } diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index 4352bdac1..c456a7cc9 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -42,7 +42,22 @@ export const LANGUAGE_PAGES: PageSpec[] = [ title: "Project Structure", // IntelliJ IDEA order: … Version Control, Project Structure, Build, Execution, Deployment, Languages & Frameworks … order: 350, - keywords: ["content root", "sources root", "test sources", "resources", "excluded", "mark directory as"], + keywords: [ + "project structure", + "content root", + "sources root", + "test sources", + "resources", + "excluded", + "mark directory as", + "project sdk", + "language level", + "modules", + "libraries", + "facets", + "artifacts", + "sdks", + ], component: ProjectStructurePage, }, { diff --git a/src/platform/keymaps/ideaParity.test.ts b/src/platform/keymaps/ideaParity.test.ts index 6478c9fc2..47117b3e6 100644 --- a/src/platform/keymaps/ideaParity.test.ts +++ b/src/platform/keymaps/ideaParity.test.ts @@ -80,6 +80,7 @@ describe("IDEA keymap parity", () => { ["workbench.stopBackgroundProcesses", ["Meta+Shift+F2"], ["Ctrl+Shift+F2"]], ["navigate.back", ["Meta+[", "Meta+Alt+Left", "Button4"], ["Ctrl+Alt+Left", "Button4"]], ["navigate.forward", ["Meta+]", "Meta+Alt+Right", "Button5"], ["Ctrl+Alt+Right", "Button5"]], + ["file.projectStructure", ["Meta+;"], ["Ctrl+Alt+Shift+S"]], ])("%s", (id, mac, win) => { expect(intellijMacos[id]).toEqual(mac); expect(intellijWindows[id]).toEqual(win); diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index 6a3db68ea..30c957cd6 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -176,6 +176,7 @@ export const intellijMacos: Record = { // File / editor tabs "file.save": ["Meta+S"], "file.settings": ["Meta+,"], + "file.projectStructure": ["Meta+;"], "file.openProject": ["Meta+Shift+Alt+O"], "file.newScratchFile": ["Meta+Shift+N"], "editor.closeTab": ["Meta+W"], diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index bc4b04347..5fd52690d 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -177,6 +177,7 @@ export const intellijWindows: Record = { // File / editor tabs "file.save": ["Ctrl+S"], "file.settings": ["Ctrl+Alt+S"], + "file.projectStructure": ["Ctrl+Alt+Shift+S"], "file.openProject": [], "file.newScratchFile": ["Ctrl+Alt+Shift+Insert"], "editor.closeTab": ["Ctrl+F4"], diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index 6ca67978b..e4cba61ba 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -182,6 +182,7 @@ export const vscodeMacos: Record = { "file.save": ["Meta+S"], "file.new": ["Meta+N"], "file.settings": ["Meta+,"], + "file.projectStructure": [], "file.openProject": ["Meta+O"], "file.newScratchFile": [], "editor.closeTab": ["Meta+W"], diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index 2748ec187..8a35467c1 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -79,6 +79,56 @@ export const PHP_LANGUAGE_LEVELS = [ "5.6", ] as const; +/** `languages.java.languageLevel` values; `""` = SDK default (Auto). */ +export const JAVA_LANGUAGE_LEVELS = ["", "8", "11", "17", "21", "25"] as const; + +export type DependencyScopeValid = "compile" | "test" | "runtime" | "provided"; + +export const DEPENDENCY_SCOPES = ["compile", "test", "runtime", "provided"] as const; + +export interface ProjectLibraryValid { + name: string; + classes: string[]; + sources: string[]; + javadocs: string[]; + scope: DependencyScopeValid; +} + +export interface ModuleDependencyValid { + kind: "library" | "module"; + target: string; + scope: DependencyScopeValid; + exported: boolean; +} + +export interface ModuleStructureValid { + jdk: string; + languageLevel: (typeof JAVA_LANGUAGE_LEVELS)[number]; + compilerOutput: string; + testCompilerOutput: string; + dependencies: ModuleDependencyValid[]; +} + +export interface FacetConfigValid { + kind: "web" | "spring" | "jpa"; + module: string; + descriptor: string; + webRoot: string; +} + +export type ArtifactKindValid = "jar" | "war" | "warExploded"; + +export const ARTIFACT_KINDS = ["jar", "war", "warExploded"] as const; + +export interface ArtifactConfigValid { + name: string; + kind: ArtifactKindValid; + module: string; + outputDirectory: string; + mainClass: string; + includeLibraries: string[]; +} + export interface LanguagesValid { python: { interpreter: string; activateVirtualenv: boolean }; ruby: { interpreter: string }; @@ -109,6 +159,7 @@ export interface LanguagesValid { ruff: ExternalFormatterValid; java: { jdk: string; + languageLevel: (typeof JAVA_LANGUAGE_LEVELS)[number]; jdtlsJdk: string; jdtlsVmArgs: string; gradle: { @@ -148,6 +199,16 @@ export interface ProjectStructureValid { contentRoots: string[]; /** "Exclude files": `;`-separated name patterns. */ excludeFiles: string; + /** Project compiler output directory; empty = default. */ + compilerOutput: string; + /** Per-module overrides keyed by module relative path (`"."` for root). */ + modules: Record; + /** Project-level libraries. */ + libraries: ProjectLibraryValid[]; + /** Module framework facets (`web`, `spring`, `jpa`). */ + facets: FacetConfigValid[]; + /** Packaging artifacts (`jar`, `war`, `warExploded`). */ + artifacts: ArtifactConfigValid[]; } /** The folder lists of `ProjectStructureValid` (marks). */ @@ -172,6 +233,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = { ruff: DEFAULT_RUFF_FORMATTER, java: { jdk: "", + languageLevel: "", jdtlsJdk: "", jdtlsVmArgs: "", gradle: { linkedProjects: null, jvm: "", distribution: "wrapper", home: "", userHome: "", runUsing: "gradle" }, @@ -198,6 +260,11 @@ export const DEFAULT_PROJECT_STRUCTURE: ProjectStructureValid = { unmarked: [], contentRoots: [], excludeFiles: "", + compilerOutput: "", + modules: {}, + libraries: [], + facets: [], + artifacts: [], }; type Obj = Record; @@ -314,6 +381,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { ruff: validateFormatter(l.ruff, DEFAULT_RUFF_FORMATTER), java: { jdk: text(java.jdk, d.java.jdk), + languageLevel: oneOf(java.languageLevel, JAVA_LANGUAGE_LEVELS, d.java.languageLevel), jdtlsJdk: text(java.jdtlsJdk, d.java.jdtlsJdk), jdtlsVmArgs: text(java.jdtlsVmArgs, d.java.jdtlsVmArgs), gradle: { @@ -360,6 +428,97 @@ export function useMarkdownPreviewFontSize(): number { return useProject((s) => markdownPreviewFontSize(s.settings)); } +function validateModuleDependencies(raw: unknown): ModuleDependencyValid[] { + if (!Array.isArray(raw)) return []; + const out: ModuleDependencyValid[] = []; + for (const item of raw) { + const m = obj(item); + const target = text(m.target, ""); + if (!target) continue; + out.push({ + kind: oneOf(m.kind, ["library", "module"] as const, "library"), + target, + scope: oneOf(m.scope, DEPENDENCY_SCOPES, "compile"), + exported: bool(m.exported, false), + }); + } + return out; +} + +function validateModulesMap(raw: unknown): Record { + const m = obj(raw); + const out: Record = {}; + for (const [k, v] of Object.entries(m)) { + const key = normalizeFolder(k) || "."; + const mod = obj(v); + out[key] = { + jdk: text(mod.jdk, ""), + languageLevel: oneOf(mod.languageLevel, JAVA_LANGUAGE_LEVELS, ""), + compilerOutput: text(mod.compilerOutput, ""), + testCompilerOutput: text(mod.testCompilerOutput, ""), + dependencies: validateModuleDependencies(mod.dependencies), + }; + } + return out; +} + +function validateLibrariesList(raw: unknown): ProjectLibraryValid[] { + if (!Array.isArray(raw)) return []; + const out: ProjectLibraryValid[] = []; + for (const item of raw) { + const l = obj(item); + const name = text(l.name, ""); + const classes = strings(l.classes); + const sources = strings(l.sources); + const javadocs = strings(l.javadocs); + if (!name && classes.length === 0 && sources.length === 0) continue; + out.push({ + name: name || (classes[0]?.split(/[\\/]/).pop() ?? "Library"), + classes, + sources, + javadocs, + scope: oneOf(l.scope, DEPENDENCY_SCOPES, "compile"), + }); + } + return out; +} + +function validateFacetsList(raw: unknown): FacetConfigValid[] { + if (!Array.isArray(raw)) return []; + const out: FacetConfigValid[] = []; + for (const item of raw) { + const f = obj(item); + const kind = oneOf(f.kind, ["web", "spring", "jpa"] as const, "web"); + const module = normalizeFolder(text(f.module, ".")) || "."; + out.push({ + kind, + module, + descriptor: text(f.descriptor, ""), + webRoot: text(f.webRoot, ""), + }); + } + return out; +} + +function validateArtifactsList(raw: unknown): ArtifactConfigValid[] { + if (!Array.isArray(raw)) return []; + const out: ArtifactConfigValid[] = []; + for (const item of raw) { + const a = obj(item); + const name = text(a.name, ""); + if (!name) continue; + out.push({ + name, + kind: oneOf(a.kind, ARTIFACT_KINDS, "jar"), + module: normalizeFolder(text(a.module, ".")) || ".", + outputDirectory: text(a.outputDirectory, ""), + mainClass: text(a.mainClass, ""), + includeLibraries: strings(a.includeLibraries), + }); + } + return out; +} + export function validateProjectStructure(raw: unknown): ProjectStructureValid { const p = obj(raw); return { @@ -373,6 +532,11 @@ export function validateProjectStructure(raw: unknown): ProjectStructureValid { ? [...new Set(p.contentRoots.filter((x): x is string => typeof x === "string" && !!x.trim()))] : [], excludeFiles: typeof p.excludeFiles === "string" ? p.excludeFiles : "", + compilerOutput: text(p.compilerOutput, ""), + modules: validateModulesMap(p.modules), + libraries: validateLibrariesList(p.libraries), + facets: validateFacetsList(p.facets), + artifacts: validateArtifactsList(p.artifacts), }; } diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 9732d98b6..493b0cf13 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -178,6 +178,7 @@ "menu.file.openInNewWindow": "Open in New Window…", "menu.file.openProject": "Open…", "menu.file.save": "Save All", + "menu.file.projectStructure": "Project Structure…", "menu.file.settings": "Settings…", "menu.find": "Find", "menu.find.inFile": "Find…", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 8d1a6b985..c362bdcaa 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -66,5 +66,116 @@ "Couldn't regenerate the response": "Couldn't regenerate the response", "Reasoning": "Reasoning", "Branch '{name}'": "Branch '{name}'", - "Cannot copy the value": "Cannot copy the value" + "Cannot copy the value": "Cannot copy the value", + "Project Structure…": "Project Structure…", + "SDK default": "SDK default", + "from build / JDK": "from build / JDK", + "Language level": "Language level", + "Java source and bytecode compliance level when no build descriptor overrides it.": "Java source and bytecode compliance level when no build descriptor overrides it.", + "Java language level": "Java language level", + "Libraries": "Libraries", + "Facets": "Facets", + "Artifacts": "Artifacts", + "SDKs": "SDKs", + "inherit project": "inherit project", + "Compile": "Compile", + "Runtime": "Runtime", + "Provided": "Provided", + "WAR Archive": "WAR Archive", + "WAR (exploded)": "WAR (exploded)", + "No project is currently open.": "No project is currently open.", + "Default SDK used by modules, builds, and run configurations unless overridden at the module level.": "Default SDK used by modules, builds, and run configurations unless overridden at the module level.", + "Project SDK": "Project SDK", + "Default Java language level for source code assistance and compilation across project modules.": "Default Java language level for source code assistance and compilation across project modules.", + "Project language level": "Project language level", + "Compiler output": "Compiler output", + "Default directory where compiled classes are placed when not managed by Maven or Gradle.": "Default directory where compiled classes are placed when not managed by Maven or Gradle.", + "Select Compiler Output Directory": "Select Compiler Output Directory", + "JDK used to run the Eclipse JDT Language Server (jdtls 21+), independent of the target Project SDK.": "JDK used to run the Eclipse JDT Language Server (jdtls 21+), independent of the target Project SDK.", + "Select Module Directory": "Select Module Directory", + "Module settings": "Module settings", + "Module SDK & Compiler Output": "Module SDK & Compiler Output", + "Module SDK": "Module SDK", + "Override the Java language level for this module.": "Override the Java language level for this module.", + "Module language level": "Module language level", + "Output path": "Output path", + "Test output path": "Test output path", + "Select Test Output Directory": "Select Test Output Directory", + "Add Library": "Add Library", + "Add Module Dependency": "Add Module Dependency", + "Remove dependency": "Remove dependency", + "Export": "Export", + "Dependency": "Dependency", + "No custom module dependencies configured.": "No custom module dependencies configured.", + "Export dependency": "Export dependency", + "Dependency target": "Dependency target", + "Library": "Library", + "Dependency scope": "Dependency scope", + "Select Classes Directory": "Select Classes Directory", + "Select Library JAR": "Select Library JAR", + "Add JAR or Directory…": "Add JAR or Directory…", + "Add Classes Folder…": "Add Classes Folder…", + "Remove Library": "Remove Library", + "No project libraries added.": "No project libraries added.", + "Library Details": "Library Details", + "Library scope": "Library scope", + "Add JAR…": "Add JAR…", + "Add Folder…": "Add Folder…", + "Source archives (-sources.jar, .zip) or folders used for Go to Declaration.": "Source archives (-sources.jar, .zip) or folders used for Go to Declaration.", + "Attach Sources…": "Attach Sources…", + "Attach Source Folder…": "Attach Source Folder…", + "Project Libraries": "Project Libraries", + "Resolved Build & SDK Libraries": "Resolved Build & SDK Libraries", + "Maven WAR packaging": "Maven WAR packaging", + "Spring dependencies in pom.xml": "Spring dependencies in pom.xml", + "JPA / Hibernate dependencies in pom.xml": "JPA / Hibernate dependencies in pom.xml", + "Remove Facet": "Remove Facet", + "No custom facets configured.": "No custom facets configured.", + "Facet Configuration": "Facet Configuration", + "Framework": "Framework", + "Facet framework": "Facet framework", + "Module path ('.' for the root module).": "Module path ('.' for the root module).", + "Facet module": "Facet module", + "Deployment descriptor": "Deployment descriptor", + "Path to web.xml, persistence.xml, or Spring configuration file.": "Path to web.xml, persistence.xml, or Spring configuration file.", + "Select Deployment Descriptor": "Select Deployment Descriptor", + "Web resource directory": "Web resource directory", + "Directory containing web resources and WEB-INF/lib jars (automatically indexed by jdtls).": "Directory containing web resources and WEB-INF/lib jars (automatically indexed by jdtls).", + "Select Web Resource Directory": "Select Web Resource Directory", + "Framework Facets": "Framework Facets", + "Facets describe the frameworks (Web, Spring, JPA) used by a module. Adding a Web facet also registers its WEB-INF/lib/*.jar archives with the Java language server.": "Facets describe the frameworks (Web, Spring, JPA) used by a module. Adding a Web facet also registers its WEB-INF/lib/*.jar archives with the Java language server.", + "Auto-detected from Build": "Auto-detected from Build", + "WAR Exploded": "WAR Exploded", + "Remove Artifact": "Remove Artifact", + "No custom artifacts configured.": "No custom artifacts configured.", + "Artifact Settings": "Artifact Settings", + "Artifact type": "Artifact type", + "Artifact module": "Artifact module", + "Select Artifact Output Directory": "Select Artifact Output Directory", + "Entry-point class written to META-INF/MANIFEST.MF.": "Entry-point class written to META-INF/MANIFEST.MF.", + "Included libraries": "Included libraries", + "Project libraries packaged into the artifact.": "Project libraries packaged into the artifact.", + "Packaging Artifacts": "Packaging Artifacts", + "Configure JAR, WAR, or exploded WAR artifacts for packaging and application server deployment.": "Configure JAR, WAR, or exploded WAR artifacts for packaging and application server deployment.", + "Detected Build Web Artifacts": "Detected Build Web Artifacts", + "Home Path": "Home Path", + "No SDK installations detected yet.": "No SDK installations detected yet.", + "SDK Details": "SDK Details", + "Use as Project SDK": "Use as Project SDK", + "SDK home path": "SDK home path", + "Platform Settings": "Platform Settings", + "Project Structure sections": "Project Structure sections", + "Custom test output paths are not supported yet. Maven and Gradle manage their own test output.": "Custom test output paths are not supported yet. Maven and Gradle manage their own test output.", + "Custom module dependencies are not supported yet. Configure dependencies in Maven or Gradle.": "Custom module dependencies are not supported yet. Configure dependencies in Maven or Gradle.", + "Custom artifact packaging is not supported yet. Build and deploy artifacts defined by Maven or Gradle.": "Custom artifact packaging is not supported yet. Build and deploy artifacts defined by Maven or Gradle.", + "Add Module Overrides": "Add Module Overrides", + "Select a module directory inside the project.": "Select a module directory inside the project.", + "Directory for production classes, relative to the project root and inside this module.": "Directory for production classes, relative to the project root and inside this module.", + "Scope controls Run/Debug only. JARs are also available to Java code assistance.": "Scope controls Run/Debug only. JARs are also available to Java code assistance.", + "Binary JAR archives support Java code assistance and Run/Debug. Class folders are used by Run/Debug only.": "Binary JAR archives support Java code assistance and Run/Debug. Class folders are used by Run/Debug only.", + "Add JARs for Java code assistance and Run/Debug, or class directories for Run/Debug only. Libraries are listed under External Libraries.": "Add JARs for Java code assistance and Run/Debug, or class directories for Run/Debug only. Libraries are listed under External Libraries.", + "Per-module language levels and compiler output overrides are not supported yet. Configure them in Maven or Gradle.": "Per-module language levels and compiler output overrides are not supported yet. Configure them in Maven or Gradle.", + "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.": "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.", + "Remove Module Overrides": "Remove Module Overrides", + "Java compiler output must be inside its module: {output}": "Java compiler output must be inside its module: {output}" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 8bfd3df9d..cb4e3df66 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1660,6 +1660,104 @@ "Project Production Files": "项目生产文件", "Project Settings": "项目设置", "Project Structure": "项目结构", + "Project Structure…": "项目结构…", + "SDK default": "SDK 默认", + "from build / JDK": "来自构建 / JDK", + "Language level": "语言级别", + "Java source and bytecode compliance level when no build descriptor overrides it.": "没有构建配置覆盖时的 Java 源码和字节码兼容级别。", + "Java language level": "Java 语言级别", + "Libraries": "库", + "Facets": "Facet", + "Artifacts": "构件", + "SDKs": "SDK", + "inherit project": "继承项目设置", + "Compile": "编译", + "Runtime": "运行时", + "Provided": "已提供", + "WAR Archive": "WAR 归档", + "WAR (exploded)": "WAR(展开)", + "No project is currently open.": "当前未打开任何项目。", + "Default SDK used by modules, builds, and run configurations unless overridden at the module level.": "模块、构建和运行配置默认使用的 SDK(除非在模块级别覆盖)。", + "Project SDK": "项目 SDK", + "Default Java language level for source code assistance and compilation across project modules.": "项目各模块用于源码辅助和编译的默认 Java 语言级别。", + "Project language level": "项目语言级别", + "Compiler output": "编译器输出", + "Default directory where compiled classes are placed when not managed by Maven or Gradle.": "不由 Maven 或 Gradle 管理时,存放编译后类文件的默认目录。", + "Select Compiler Output Directory": "选择编译器输出目录", + "JDK used to run the Eclipse JDT Language Server (jdtls 21+), independent of the target Project SDK.": "用于运行 Eclipse JDT 语言服务器(jdtls 21+)的 JDK,独立于目标项目 SDK。", + "Select Module Directory": "选择模块目录", + "Module settings": "模块设置", + "Module SDK & Compiler Output": "模块 SDK 与编译器输出", + "Module SDK": "模块 SDK", + "Override the Java language level for this module.": "为此模块覆盖 Java 语言级别。", + "Module language level": "模块语言级别", + "Output path": "输出路径", + "Test output path": "测试输出路径", + "Select Test Output Directory": "选择测试输出目录", + "Add Library": "添加库", + "Add Module Dependency": "添加模块依赖", + "Remove dependency": "移除依赖", + "Export": "导出", + "Dependency": "依赖项", + "No custom module dependencies configured.": "尚未配置自定义模块依赖。", + "Export dependency": "导出依赖", + "Dependency target": "依赖目标", + "Library": "库", + "Dependency scope": "依赖范围", + "Select Classes Directory": "选择类目录", + "Select Library JAR": "选择库 JAR", + "Add JAR or Directory…": "添加 JAR 或目录…", + "Add Classes Folder…": "添加类文件夹…", + "Remove Library": "移除库", + "No project libraries added.": "尚未添加项目库。", + "Library Details": "库详情", + "Library scope": "库范围", + "Add JAR…": "添加 JAR…", + "Add Folder…": "添加文件夹…", + "Source archives (-sources.jar, .zip) or folders used for Go to Declaration.": "用于“转到声明”的源码归档(-sources.jar、.zip)或源码文件夹。", + "Attach Sources…": "附加源码…", + "Attach Source Folder…": "附加源码文件夹…", + "Project Libraries": "项目库", + "Resolved Build & SDK Libraries": "已解析的构建与 SDK 库", + "Maven WAR packaging": "Maven WAR 打包", + "Spring dependencies in pom.xml": "pom.xml 中的 Spring 依赖", + "JPA / Hibernate dependencies in pom.xml": "pom.xml 中的 JPA / Hibernate 依赖", + "Remove Facet": "移除 Facet", + "No custom facets configured.": "尚未配置自定义 Facet。", + "Facet Configuration": "Facet 配置", + "Framework": "框架", + "Facet framework": "Facet 框架", + "Module path ('.' for the root module).": "模块路径(根模块填 '.')。", + "Facet module": "Facet 模块", + "Deployment descriptor": "部署描述符", + "Path to web.xml, persistence.xml, or Spring configuration file.": "web.xml、persistence.xml 或 Spring 配置文件路径。", + "Select Deployment Descriptor": "选择部署描述符", + "Web resource directory": "Web 资源目录", + "Directory containing web resources and WEB-INF/lib jars (automatically indexed by jdtls).": "包含 Web 资源和 WEB-INF/lib JAR 的目录(由 jdtls 自动索引)。", + "Select Web Resource Directory": "选择 Web 资源目录", + "Framework Facets": "框架 Facet", + "Facets describe the frameworks (Web, Spring, JPA) used by a module. Adding a Web facet also registers its WEB-INF/lib/*.jar archives with the Java language server.": "Facet 用于描述模块所使用的框架(Web、Spring、JPA)。添加 Web Facet 还会将其 WEB-INF/lib/*.jar 归档注册到 Java 语言服务器。", + "Auto-detected from Build": "从构建配置自动检测", + "WAR Exploded": "WAR(展开)", + "Remove Artifact": "移除构件", + "No custom artifacts configured.": "尚未配置自定义构件。", + "Artifact Settings": "构件设置", + "Artifact type": "构件类型", + "Artifact module": "构件模块", + "Select Artifact Output Directory": "选择构件输出目录", + "Entry-point class written to META-INF/MANIFEST.MF.": "写入 META-INF/MANIFEST.MF 的入口主类。", + "Included libraries": "包含的库", + "Project libraries packaged into the artifact.": "打包进该构件的项目库。", + "Packaging Artifacts": "打包构件", + "Configure JAR, WAR, or exploded WAR artifacts for packaging and application server deployment.": "配置用于打包和应用服务器部署的 JAR、WAR 或展开式 WAR 构件。", + "Detected Build Web Artifacts": "检测到的构建 Web 构件", + "Home Path": "主目录路径", + "No SDK installations detected yet.": "尚未检测到已安装的 SDK。", + "SDK Details": "SDK 详情", + "Use as Project SDK": "设为项目 SDK", + "SDK home path": "SDK 主路径", + "Platform Settings": "平台设置", + "Project Structure sections": "项目结构分区", "Project Test Files": "项目测试文件", "Project View": "项目视图", "Project actions": "项目操作", @@ -6445,5 +6543,18 @@ "Couldn't regenerate the response": "无法重新生成回复", "Reasoning": "思考过程", "Cannot copy the value": "无法复制该值", - "The check could not run: {reason}": "检查无法运行:{reason}" + "The check could not run: {reason}": "检查无法运行:{reason}", + "Custom test output paths are not supported yet. Maven and Gradle manage their own test output.": "暂不支持自定义测试输出路径。测试输出由 Maven 和 Gradle 管理。", + "Custom module dependencies are not supported yet. Configure dependencies in Maven or Gradle.": "暂不支持自定义模块依赖。请在 Maven 或 Gradle 中配置依赖。", + "Custom artifact packaging is not supported yet. Build and deploy artifacts defined by Maven or Gradle.": "暂不支持自定义制品打包。请构建和部署 Maven 或 Gradle 定义的制品。", + "Add Module Overrides": "添加模块覆盖设置", + "Select a module directory inside the project.": "请选择项目内的模块目录。", + "Directory for production classes, relative to the project root and inside this module.": "生产类文件目录,相对于项目根目录,且必须位于当前模块内。", + "Scope controls Run/Debug only. JARs are also available to Java code assistance.": "作用域仅控制运行和调试。JAR 库也可用于 Java 代码辅助。", + "Binary JAR archives support Java code assistance and Run/Debug. Class folders are used by Run/Debug only.": "二进制 JAR 支持 Java 代码辅助、运行和调试。类文件夹仅用于运行和调试。", + "Add JARs for Java code assistance and Run/Debug, or class directories for Run/Debug only. Libraries are listed under External Libraries.": "添加 JAR 以支持 Java 代码辅助、运行和调试,或添加仅用于运行和调试的类文件夹。库会显示在“外部库”中。", + "Per-module language levels and compiler output overrides are not supported yet. Configure them in Maven or Gradle.": "暂不支持模块级语言级别和编译输出覆盖设置。请在 Maven 或 Gradle 中配置。", + "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.": "此模块运行和调试使用的 JDK。编译 JDK 由 Maven 和 Gradle 管理。", + "Remove Module Overrides": "移除模块覆盖设置", + "Java compiler output must be inside its module: {output}": "Java 编译输出必须位于所属模块目录内:{output}" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index ec37888a2..6607c844e 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -167,6 +167,7 @@ const UNTRANSLATED = new Set([ "npm", "OpenIDEA", "Oracle", + "Spring", "Spring Boot", "PostgreSQL", "Pyrefly", @@ -200,9 +201,11 @@ const UNTRANSLATED = new Set([ "GOROOT", "HEAD", "HTTP", + "JAR", "JDK", "JDK 1.4.x", "JDK 5 - 8", + "JPA", "LTS", "NULL", "ORDER BY", @@ -213,6 +216,8 @@ const UNTRANSLATED = new Set([ "TODO", "URL", "VCS", + "WAR", + "Web", "WHERE", "WIP", // Code, commands and example values @@ -222,6 +227,7 @@ const UNTRANSLATED = new Set([ "build --info", "className()", "clean install -DskipTests", + "com.example.Main", "com.example.Service#process, main.run, parse", "docker-compose.yml; docker-compose.override.yml", "file:src//*&&!file:*test*", @@ -234,11 +240,15 @@ const UNTRANSLATED = new Set([ "NAME", "NAME=value; OTHER=value", "node_modules/typescript", + "out", "PascalCase", "polls.tests", "CalculatorTest::testAdd", "phpunit.xml", "PHPUnit", + "src/main/webapp", + "target/classes", + "target/test-classes", "tests/Unit", "vendor/bin/phpunit", "pylyzer --server", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index b3309f9b5..9db8c6437 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -178,6 +178,7 @@ "menu.file.openInNewWindow": "在新窗口中打开…", "menu.file.openProject": "打开…", "menu.file.save": "全部保存", + "menu.file.projectStructure": "项目结构…", "menu.file.settings": "设置…", "menu.find": "查找", "menu.find.inFile": "查找…",