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 <[email protected]>
Цей коміт міститься в:
RuyimgByCNтаl0ng-ai authored and GitHub committed 2026-11-07 20:46:03 +08:00
1 джерело 1cab24fea1
коміт 75773dd889
32 files changed
+3524 -164

No files matched your search

+171
Переглянути файл
@@ -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<String>,
/// Source archives (`-sources.jar`, `.zip`) or source directories.
pub sources: Vec<String>,
/// Documentation archives (`-javadoc.jar`) or directories.
pub javadocs: Vec<String>,
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<ModuleDependency>,
}
/// 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<String>,
}
#[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<String>,
/// "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<String, ModuleStructureSettings>,
/// Project-level libraries (`JAR`s / class folders / source archives).
pub libraries: Vec<ProjectLibrary>,
/// Module framework facets (`web`, `spring`, `jpa`).
pub facets: Vec<FacetConfig>,
/// Packaging artifacts (`jar`, `war`, `warExploded`).
pub artifacts: Vec<ArtifactConfig>,
}
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<String> {
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)]
+68 -2
Переглянути файл
@@ -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]
+9 -2
Переглянути файл
@@ -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<PathBuf> = 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,
+228 -5
Переглянути файл
@@ -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<Vec<String>>) {
@@ -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<String> {
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<String> = Vec::new();
let mut sources = serde_json::Map::new();
for lib in &structure.libraries {
let class_entries: Vec<String> = lib.classes.iter().filter_map(|c| normalize(c)).collect();
let source_entries: Vec<String> = 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::<Vec<_>>();
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"));
}
}
+52 -7
Переглянути файл
@@ -144,6 +144,24 @@ pub fn project_jdk(root: &Path, s: &Settings) -> Option<crate::java::Jdk> {
}
}
/// Module-specific JDK from `projectStructure.modules`, falling back to [`project_jdk`].
pub fn module_jdk(root: &Path, module: &Path, s: &Settings) -> Option<crate::java::Jdk> {
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<Option<crate::java::Jdk>, 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"))?;
+6
Переглянути файл
@@ -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()]);
}
+31 -2
Переглянути файл
@@ -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<LibraryGroup> {
type Detect = fn(&Path, &Settings) -> Vec<LibraryGroup>;
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<LibraryGroup> {
// ------------------------------------------------------------------ jdk
fn jdk_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
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<LibraryGroup> {
})
}
fn project_structure_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
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<LibraryGroup> {
let name = jdk_name(&jdk.home).unwrap_or_else(|| format!("JDK {}", jdk.major));
+58
Переглянути файл
@@ -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));
}
+21
Переглянути файл
@@ -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" });
+4 -1
Переглянути файл
@@ -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
}
+3
Переглянути файл
@@ -230,6 +230,8 @@ pub fn spec(os: Os) -> Vec<Entry> {
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",
+79
Переглянути файл
@@ -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[],
};
/**
+1
Переглянути файл
@@ -101,6 +101,7 @@ const sdkKey = (s: ReturnType<typeof useProject.getState>) => {
l?.go?.goroot,
l?.go?.gopath,
l?.go?.modules,
s.settings?.projectStructure?.libraries,
]);
};
+71 -1
Переглянути файл
@@ -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(
<>
<SettingsDialog />
<ProjectStructureDialog />
<MessagesHost />
</>,
);
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(<ProjectStructureDialog />);
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(<ProjectStructureDialog />);
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(<ProjectStructureDialog />);
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", () => {
+96
Переглянути файл
@@ -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", () => {
+8
Переглянути файл
@@ -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 ? <Dialog /> : null;
}
export function LazyProjectStructureDialog() {
const open = useSettingsDialog((s) => s.projectStructureOpen);
return open ? <ProjectStructure /> : null;
}
+18 -1
Переглянути файл
@@ -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",
+17 -1
Переглянути файл
@@ -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<string>("languages.java.jdtlsVmArgs");
const [languageLevel, setLanguageLevel] = useSetting<string>("languages.java.languageLevel");
const id = useId();
return (
<div className={styles.page}>
@@ -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."
/>
<Row
label="Language level"
hint="Java source and bytecode compliance level when no build descriptor overrides it."
>
<Select
value={languageLevel}
options={JAVA_LEVEL_OPTIONS}
onChange={setLanguageLevel}
aria-label="Java language level"
/>
</Row>
</Section>
<Section title="Language Server">
<SdkRow
File diff suppressed because it is too large. Load diff
+299 -5
Переглянути файл
@@ -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;
}
+63 -2
Переглянути файл
@@ -24,8 +24,27 @@ interface ExternalChange {
apply(): Promise<unknown>;
}
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<SettingsDialogState>(() => ({
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();
}
+16 -1
Переглянути файл
@@ -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,
},
{
+1
Переглянути файл
@@ -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);
+1
Переглянути файл
@@ -176,6 +176,7 @@ export const intellijMacos: Record<string, string[]> = {
// 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"],
+1
Переглянути файл
@@ -177,6 +177,7 @@ export const intellijWindows: Record<string, string[]> = {
// 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"],
+1
Переглянути файл
@@ -182,6 +182,7 @@ export const vscodeMacos: Record<string, string[]> = {
"file.save": ["Meta+S"],
"file.new": ["Meta+N"],
"file.settings": ["Meta+,"],
"file.projectStructure": [],
"file.openProject": ["Meta+O"],
"file.newScratchFile": [],
"editor.closeTab": ["Meta+W"],
+164
Переглянути файл
@@ -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<string, ModuleStructureValid>;
/** 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<string, unknown>;
@@ -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<string, ModuleStructureValid> {
const m = obj(raw);
const out: Record<string, ModuleStructureValid> = {};
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),
};
}
+1
Переглянути файл
@@ -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…",
+112 -1
Переглянути файл
@@ -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}"
}
+112 -1
Переглянути файл
@@ -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}"
}
+10
Переглянути файл
@@ -167,6 +167,7 @@ const UNTRANSLATED = new Set<string>([
"npm",
"OpenIDEA",
"Oracle",
"Spring",
"Spring Boot",
"PostgreSQL",
"Pyrefly",
@@ -200,9 +201,11 @@ const UNTRANSLATED = new Set<string>([
"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<string>([
"TODO",
"URL",
"VCS",
"WAR",
"Web",
"WHERE",
"WIP",
// Code, commands and example values
@@ -222,6 +227,7 @@ const UNTRANSLATED = new Set<string>([
"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<string>([
"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",
+1
Переглянути файл
@@ -178,6 +178,7 @@
"menu.file.openInNewWindow": "在新窗口中打开…",
"menu.file.openProject": "打开…",
"menu.file.save": "全部保存",
"menu.file.projectStructure": "项目结构…",
"menu.file.settings": "设置…",
"menu.find": "查找",
"menu.find.inFile": "查找…",