дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
1cab24fea1
коміт
75773dd889
32 files changed
+3524
-164
No files matched your search
@@ -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)]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"))?;
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,6 +101,7 @@ const sdkKey = (s: ReturnType<typeof useProject.getState>) => {
|
||||
l?.go?.goroot,
|
||||
l?.go?.gopath,
|
||||
l?.go?.modules,
|
||||
s.settings?.projectStructure?.libraries,
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
"menu.file.openInNewWindow": "在新窗口中打开…",
|
||||
"menu.file.openProject": "打开…",
|
||||
"menu.file.save": "全部保存",
|
||||
"menu.file.projectStructure": "项目结构…",
|
||||
"menu.file.settings": "设置…",
|
||||
"menu.find": "查找",
|
||||
"menu.find.inFile": "查找…",
|
||||
|
||||
Reference in new issue
Заблокувати користувача