дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(run): project CMake profiles (#679)
CMake run configurations used fixed arguments (-B build, Debug) and only recognised existing build directories. Projects can now define CMake profiles under Settings › Build, Execution, Deployment › CMake: build type, generator, CMake arguments, build directory (default cmake-build-<type>), build arguments and environment. Each profile gets its own Configure, Build, CTest and target run configurations, named after the profile when there are several. Configure runs first only while the build directory has no CMakeCache.txt, and a generator change clears the stale cache before configuring. Projects without profiles keep the previous detection. Arguments are stored as lists and edited with the same quoting rules as program arguments. Toolchain selection is not part of this change. Refs #655
Цей коміт міститься в:
1 джерело
49f9c57bcc
коміт
210f9a78ac
18 files changed
+726
-12
No files matched your search
@@ -58,6 +58,43 @@ pub struct Settings {
|
||||
pub deployment: DeploymentSettings,
|
||||
/// Build, Execution, Deployment › Run Targets.
|
||||
pub run_targets: Vec<RunTarget>,
|
||||
pub cmake: CMakeSettings,
|
||||
}
|
||||
|
||||
/// Absent profiles retain automatic discovery for existing projects.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CMakeSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub profiles: Option<Vec<CMakeProfile>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CMakeProfile {
|
||||
pub name: String,
|
||||
pub build_type: String,
|
||||
pub generator: String,
|
||||
pub cmake_args: Vec<String>,
|
||||
/// Empty uses cmake-build-<lowercase build type>.
|
||||
pub build_dir: String,
|
||||
pub build_args: Vec<String>,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for CMakeProfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "Debug".into(),
|
||||
build_type: "Debug".into(),
|
||||
generator: String::new(),
|
||||
cmake_args: Vec::new(),
|
||||
build_dir: String::new(),
|
||||
build_args: Vec::new(),
|
||||
env: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -154,6 +191,7 @@ impl Default for Settings {
|
||||
ssh: SshSettings::default(),
|
||||
deployment: DeploymentSettings::default(),
|
||||
run_targets: Vec::new(),
|
||||
cmake: CMakeSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -765,6 +803,24 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cmake_profile_argument_arrays_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
assert!(settings.cmake.profiles.is_none());
|
||||
assert!(CMakeProfile::default().cmake_args.is_empty());
|
||||
assert!(CMakeProfile::default().build_args.is_empty());
|
||||
let profile = CMakeProfile {
|
||||
cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(),
|
||||
build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(),
|
||||
..Default::default()
|
||||
};
|
||||
settings.cmake.profiles = Some(vec![profile.clone()]);
|
||||
let value = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args));
|
||||
assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args));
|
||||
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_font_fallback_defaults_empty_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
|
||||
|
||||
@@ -50,10 +50,11 @@ fn detected_and_saved(
|
||||
suppressed: &[String],
|
||||
) -> Vec<RunConfig> {
|
||||
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
|
||||
let mut all: Vec<RunConfig> = ide_run::detect_excluding(root, &excluded_poms)
|
||||
.into_iter()
|
||||
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
|
||||
.collect();
|
||||
let mut all: Vec<RunConfig> =
|
||||
ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref())
|
||||
.into_iter()
|
||||
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
|
||||
.collect();
|
||||
all.extend(saved.iter().cloned());
|
||||
for config in all.iter_mut().filter(|c| c.kind != "gradle") {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings);
|
||||
|
||||
@@ -16,6 +16,14 @@ pub(crate) fn detect(root: &Path) -> Vec<RunConfig> {
|
||||
}
|
||||
|
||||
pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig> {
|
||||
detect_with_profiles(root, excluded_poms, None)
|
||||
}
|
||||
|
||||
pub(crate) fn detect_with_profiles(
|
||||
root: &Path,
|
||||
excluded_poms: &[PathBuf],
|
||||
profiles: Option<&[ide_core::settings::CMakeProfile]>,
|
||||
) -> Vec<RunConfig> {
|
||||
let mut out = Vec::new();
|
||||
detect_node(root, &mut out);
|
||||
detect_deno(root, &mut out);
|
||||
@@ -24,7 +32,7 @@ pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<Ru
|
||||
detect_maven(root, excluded_poms, &mut out);
|
||||
detect_go(root, &mut out);
|
||||
detect_python(root, &mut out);
|
||||
cmake::detect(root, &mut out);
|
||||
cmake::detect(root, profiles, &mut out);
|
||||
detect_make(root, &mut out);
|
||||
detect_just(root, &mut out);
|
||||
detect_godot(root, &mut out);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use super::{ConfigExt, config};
|
||||
use crate::{BeforeLaunchTask, RunConfig};
|
||||
use ide_core::settings::CMakeProfile;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -19,10 +20,16 @@ fn before(config: &mut RunConfig, id: &str) {
|
||||
config.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: id.into() });
|
||||
}
|
||||
|
||||
pub(super) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut Vec<RunConfig>) {
|
||||
if !root.join("CMakeLists.txt").is_file() {
|
||||
return;
|
||||
}
|
||||
if let Some(profiles) = profiles {
|
||||
for (index, profile) in profiles.iter().enumerate() {
|
||||
detect_profile(root, profile, index, profiles.len() > 1, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut builds = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(root) {
|
||||
builds.extend(entries.flatten().filter_map(|e| {
|
||||
@@ -79,11 +86,69 @@ pub(super) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
);
|
||||
before(&mut test, &build_id);
|
||||
out.push(test);
|
||||
targets(root, &build, &dir, &prefix, out);
|
||||
targets(root, &build, &dir, &prefix, None, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<RunConfig>) {
|
||||
fn detect_profile(root: &Path, profile: &CMakeProfile, index: usize, multiple: bool, out: &mut Vec<RunConfig>) {
|
||||
let dir = if profile.build_dir.trim().is_empty() {
|
||||
format!("cmake-build-{}", profile.build_type.to_lowercase())
|
||||
} else {
|
||||
profile.build_dir.clone()
|
||||
};
|
||||
let build = root.join(&dir);
|
||||
let prefix = format!("cmake:profile:{index}");
|
||||
let configure_id = format!("{prefix}:configure");
|
||||
let build_id = format!("{prefix}:build");
|
||||
let start = out.len();
|
||||
let mut configure =
|
||||
config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "cmake", &["-S", ".", "-B", &dir]);
|
||||
if !profile.generator.trim().is_empty() {
|
||||
configure.args.extend(["-G".into(), profile.generator.clone()]);
|
||||
}
|
||||
configure.args.push(format!("-DCMAKE_BUILD_TYPE={}", profile.build_type));
|
||||
configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into());
|
||||
configure.args.extend_from_slice(&profile.cmake_args);
|
||||
configure.extras.params.insert("buildDir".into(), dir.clone());
|
||||
out.push(configure);
|
||||
let mut all = config(
|
||||
&build_id,
|
||||
format!("CMake Build ({dir})"),
|
||||
"cmake",
|
||||
"cmake",
|
||||
&["--build", &dir, "--config", &profile.build_type],
|
||||
);
|
||||
all.args.extend_from_slice(&profile.build_args);
|
||||
if !build.join("CMakeCache.txt").is_file() {
|
||||
before(&mut all, &configure_id);
|
||||
}
|
||||
out.push(all);
|
||||
let mut test = config(
|
||||
format!("{prefix}:test"),
|
||||
format!("CTest ({dir})"),
|
||||
"cmake",
|
||||
"ctest",
|
||||
&["--test-dir", &dir, "-C", &profile.build_type, "--output-on-failure"],
|
||||
);
|
||||
before(&mut test, &build_id);
|
||||
out.push(test);
|
||||
targets(root, &build, &dir, &prefix, Some(profile), out);
|
||||
for config in &mut out[start..] {
|
||||
config.env = profile.env.clone();
|
||||
if multiple {
|
||||
config.name = format!("{} [{}]", config.name, profile.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn targets(
|
||||
root: &Path,
|
||||
build: &Path,
|
||||
dir: &str,
|
||||
prefix: &str,
|
||||
profile: Option<&CMakeProfile>,
|
||||
out: &mut Vec<RunConfig>,
|
||||
) {
|
||||
let reply = build.join(".cmake/api/v1/reply");
|
||||
let Some(index_path) = fs::read_dir(&reply).ok().and_then(|entries| {
|
||||
entries
|
||||
@@ -113,6 +178,9 @@ fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<Run
|
||||
let source_root = dunce::canonicalize(&source_root).unwrap_or(source_root);
|
||||
for configuration in configurations {
|
||||
let name = configuration["name"].as_str().unwrap_or("");
|
||||
if profile.is_some_and(|p| !name.is_empty() && name != p.build_type) {
|
||||
continue;
|
||||
}
|
||||
let Some(targets) = configuration["targets"].as_array() else { continue };
|
||||
for target in targets {
|
||||
let Some(target) = target["jsonFile"].as_str().and_then(|n| reply_file(&reply, n)) else { continue };
|
||||
@@ -130,8 +198,17 @@ fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<Run
|
||||
if !name.is_empty() {
|
||||
args.extend(["--config", name]);
|
||||
}
|
||||
let build_config =
|
||||
let mut build_config =
|
||||
config(&target_build_id, format!("Build {target_name} ({dir} {name})"), "cmake", "cmake", &args);
|
||||
if let Some(profile) = profile {
|
||||
if name.is_empty() {
|
||||
build_config.args.extend(["--config".into(), profile.build_type.clone()]);
|
||||
}
|
||||
build_config.args.extend_from_slice(&profile.build_args);
|
||||
if !build.join("CMakeCache.txt").is_file() {
|
||||
before(&mut build_config, &format!("{prefix}:configure"));
|
||||
}
|
||||
}
|
||||
out.push(build_config);
|
||||
let binary = build.join(artifact);
|
||||
let mut run =
|
||||
@@ -165,9 +242,99 @@ pub(crate) fn prepare(config: &RunConfig, cwd: &Path) -> crate::IdeResult<()> {
|
||||
&& config.args.first().is_some_and(|a| a == "-S")
|
||||
&& let Some(dir) = config.extras.params.get("buildDir")
|
||||
{
|
||||
let query = cwd.join(dir).join(".cmake/api/v1/query/client-openidea");
|
||||
let build = cwd.join(dir);
|
||||
let generator = config.args.windows(2).find(|a| a[0] == "-G").map(|a| a[1].as_str());
|
||||
if let Some(generator) = generator
|
||||
&& let Ok(cache) = fs::read_to_string(build.join("CMakeCache.txt"))
|
||||
&& let Some(cached) = cache.lines().find_map(|l| l.strip_prefix("CMAKE_GENERATOR:INTERNAL="))
|
||||
&& cached != generator
|
||||
{
|
||||
fs::remove_file(build.join("CMakeCache.txt"))?;
|
||||
let files = build.join("CMakeFiles");
|
||||
if files.is_dir() {
|
||||
fs::remove_dir_all(files)?;
|
||||
}
|
||||
}
|
||||
let query = build.join(".cmake/api/v1/query/client-openidea");
|
||||
fs::create_dir_all(&query)?;
|
||||
fs::write(query.join("codemodel-v2"), "")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn profiles_select_file_api_configuration_and_apply_target_options() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap();
|
||||
let reply = root.path().join("output/.cmake/api/v1/reply");
|
||||
fs::create_dir_all(&reply).unwrap();
|
||||
let write = |name: &str, value: Value| fs::write(reply.join(name), value.to_string()).unwrap();
|
||||
write(
|
||||
"index-1.json",
|
||||
serde_json::json!({"objects":[{"kind":"codemodel","version":{"major":2},"jsonFile":"model.json"}]}),
|
||||
);
|
||||
write(
|
||||
"model.json",
|
||||
serde_json::json!({"paths":{"source":root.path()},"configurations":[
|
||||
{"name":"Debug","targets":[{"jsonFile":"debug.json"}]},
|
||||
{"name":"Release","targets":[{"jsonFile":"release.json"}]}
|
||||
]}),
|
||||
);
|
||||
for name in ["debug", "release"] {
|
||||
write(
|
||||
&format!("{name}.json"),
|
||||
serde_json::json!({"name":"app","type":"EXECUTABLE","artifacts":[{"path":format!("{name}/app")}]}),
|
||||
);
|
||||
}
|
||||
let profile = CMakeProfile {
|
||||
name: "Production".into(),
|
||||
build_type: "Release".into(),
|
||||
build_dir: "output".into(),
|
||||
build_args: ["-j", "8", "--", "verbose=1"].map(String::from).into(),
|
||||
env: [("FOO".into(), "bar".into())].into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut configs = Vec::new();
|
||||
detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configs);
|
||||
let run = configs.iter().find(|c| c.kind == "native").unwrap();
|
||||
assert!(run.command.ends_with("release/app"));
|
||||
assert_eq!(run.env, profile.env);
|
||||
assert_eq!(configs.iter().filter(|c| c.kind == "native").count(), 1);
|
||||
let build = configs.iter().find(|c| c.id == run.extras.before_launch[0].target).unwrap();
|
||||
assert_eq!(
|
||||
build.args,
|
||||
["--build", "output", "--target", "app", "--config", "Release", "-j", "8", "--", "verbose=1"]
|
||||
);
|
||||
assert_eq!(build.env, profile.env);
|
||||
assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure");
|
||||
fs::write(root.path().join("output/CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Ninja\n").unwrap();
|
||||
let mut configured = Vec::new();
|
||||
detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configured);
|
||||
for id in ["cmake:profile:0:build", build.id.as_str()] {
|
||||
let build = configured.iter().find(|c| c.id == id).unwrap();
|
||||
assert!(build.extras.before_launch.is_empty(), "{id} should use CMake's automatic regeneration");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_change_clears_only_cmake_state_at_launch() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let build = root.path().join("cmake-build-debug");
|
||||
fs::create_dir_all(build.join("CMakeFiles")).unwrap();
|
||||
fs::write(build.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap();
|
||||
fs::write(build.join("keep.txt"), "artifact").unwrap();
|
||||
fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap();
|
||||
let mut configs = Vec::new();
|
||||
detect(root.path(), Some(&[CMakeProfile { generator: "Ninja".into(), ..Default::default() }]), &mut configs);
|
||||
assert!(build.join("CMakeCache.txt").exists());
|
||||
prepare(&configs[0], root.path()).unwrap();
|
||||
assert!(!build.join("CMakeCache.txt").exists());
|
||||
assert!(!build.join("CMakeFiles").exists());
|
||||
assert!(build.join("keep.txt").exists());
|
||||
assert!(build.join(".cmake/api/v1/query/client-openidea/codemodel-v2").exists());
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,15 @@ pub fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig
|
||||
detect::detect_excluding(root, excluded_poms)
|
||||
}
|
||||
|
||||
/// Detects configurations with explicit CMake profiles, or legacy discovery when absent.
|
||||
pub fn detect_with_profiles(
|
||||
root: &Path,
|
||||
excluded_poms: &[PathBuf],
|
||||
profiles: Option<&[ide_core::settings::CMakeProfile]>,
|
||||
) -> Vec<RunConfig> {
|
||||
detect::detect_with_profiles(root, excluded_poms, profiles)
|
||||
}
|
||||
|
||||
/// Reads `<root>/.openidea/run.toml`; a missing file yields no configs.
|
||||
pub fn load_user_configs(root: &Path) -> IdeResult<Vec<RunConfig>> {
|
||||
config::load(root)
|
||||
|
||||
@@ -652,3 +652,72 @@ fn go_default_build_output_is_ignored_by_version_control() {
|
||||
let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap();
|
||||
assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_profiles_control_commands_names_and_environment() {
|
||||
use ide_core::settings::CMakeProfile;
|
||||
let dir = project(&[("CMakeLists.txt", "project(app)")]);
|
||||
for generator in ["", "Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022"] {
|
||||
let profile = CMakeProfile {
|
||||
name: "Production".into(),
|
||||
build_type: "Release".into(),
|
||||
generator: generator.into(),
|
||||
cmake_args: ["-DFEATURE=ON", "-DMESSAGE=two words", "-DQUOTE=\"hello world\"", ""].map(String::from).into(),
|
||||
build_dir: "output dir".into(),
|
||||
build_args: ["-j", "8", "--", "VERBOSE=1"].map(String::from).into(),
|
||||
env: [("MY_ENV".into(), "yes".into())].into(),
|
||||
};
|
||||
let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(std::slice::from_ref(&profile)));
|
||||
let configure = find(&configs, "cmake:profile:0:configure");
|
||||
let mut expected = vec!["-S", ".", "-B", "output dir"];
|
||||
if !generator.is_empty() {
|
||||
expected.extend(["-G", generator]);
|
||||
}
|
||||
expected.extend([
|
||||
"-DCMAKE_BUILD_TYPE=Release",
|
||||
"-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
|
||||
"-DFEATURE=ON",
|
||||
"-DMESSAGE=two words",
|
||||
"-DQUOTE=\"hello world\"",
|
||||
"",
|
||||
]);
|
||||
assert_eq!(configure.args, expected);
|
||||
assert_eq!(configure.name, "CMake Configure (output dir)");
|
||||
let build = find(&configs, "cmake:profile:0:build");
|
||||
assert_eq!(build.args, ["--build", "output dir", "--config", "Release", "-j", "8", "--", "VERBOSE=1"]);
|
||||
assert_eq!(build.extras.before_launch[0].target, configure.id);
|
||||
assert!(configs.iter().all(|c| c.env == profile.env));
|
||||
let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(&[CMakeProfile::default(), profile]));
|
||||
assert!(find(&configs, "cmake:profile:0:build").name.ends_with("[Debug]"));
|
||||
assert!(find(&configs, "cmake:profile:1:build").name.ends_with("[Production]"));
|
||||
assert_eq!(find(&configs, "cmake:profile:0:build").args[1], "cmake-build-debug");
|
||||
}
|
||||
assert_eq!(ide_run::detect_with_profiles(dir.path(), &[], None), detect(dir.path()));
|
||||
assert!(ide_run::detect_with_profiles(dir.path(), &[], Some(&[])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_profile_build_configures_only_without_a_cache() {
|
||||
use ide_core::settings::CMakeProfile;
|
||||
let root = project(&[("CMakeLists.txt", "project(app)")]);
|
||||
for dir in ["", "custom output"] {
|
||||
let profile = CMakeProfile { build_dir: dir.into(), generator: "Ninja".into(), ..Default::default() };
|
||||
let build_dir = root.path().join(if dir.is_empty() { "cmake-build-debug" } else { dir });
|
||||
for cached in [false, true] {
|
||||
if cached {
|
||||
fs::create_dir_all(&build_dir).unwrap();
|
||||
// Generator changes are handled by an explicit Configure, not every Build.
|
||||
fs::write(build_dir.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap();
|
||||
}
|
||||
let configs = ide_run::detect_with_profiles(root.path(), &[], Some(std::slice::from_ref(&profile)));
|
||||
let build = find(&configs, "cmake:profile:0:build");
|
||||
if cached {
|
||||
assert!(build.extras.before_launch.is_empty());
|
||||
} else {
|
||||
assert_eq!(build.extras.before_launch.len(), 1);
|
||||
assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure");
|
||||
}
|
||||
assert_eq!(find(&configs, "cmake:profile:0:test").extras.before_launch[0].target, build.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1639,6 +1639,30 @@ export type ByteRange = {
|
||||
total: number,
|
||||
};
|
||||
|
||||
export type CMakeProfile = {
|
||||
name?: string,
|
||||
buildType?: string,
|
||||
generator?: string,
|
||||
cmakeArgs?: string[],
|
||||
/** Empty uses cmake-build-<lowercase build type>. */
|
||||
buildDir?: string,
|
||||
buildArgs?: string[],
|
||||
env?: { [key in string]: string },
|
||||
};
|
||||
|
||||
/** Absent profiles retain automatic discovery for existing projects. */
|
||||
export type CMakeSettings = CMakeSettings_Serialize | CMakeSettings_Deserialize;
|
||||
|
||||
/** Absent profiles retain automatic discovery for existing projects. */
|
||||
export type CMakeSettings_Deserialize = {
|
||||
profiles?: CMakeProfile[] | null,
|
||||
};
|
||||
|
||||
/** Absent profiles retain automatic discovery for existing projects. */
|
||||
export type CMakeSettings_Serialize = {
|
||||
profiles?: CMakeProfile[] | null,
|
||||
};
|
||||
|
||||
export type CallHierarchy = {
|
||||
items: HierarchyItem[],
|
||||
incomplete: ReferencesIncomplete | null,
|
||||
@@ -5635,6 +5659,7 @@ export type Settings_Deserialize = {
|
||||
deployment?: DeploymentSettings,
|
||||
/** Build, Execution, Deployment › Run Targets. */
|
||||
runTargets?: RunTarget[],
|
||||
cmake?: CMakeSettings_Deserialize,
|
||||
};
|
||||
|
||||
export type Settings_Serialize = {
|
||||
@@ -5690,6 +5715,7 @@ export type Settings_Serialize = {
|
||||
deployment: DeploymentSettings,
|
||||
/** Build, Execution, Deployment › Run Targets. */
|
||||
runTargets: RunTarget[],
|
||||
cmake: CMakeSettings_Serialize,
|
||||
};
|
||||
|
||||
export type Severity = "error" | "warning" | "info" | "hint";
|
||||
|
||||
@@ -538,7 +538,14 @@ export function activate(): () => void {
|
||||
}),
|
||||
// Detected configs carry the selected toolchains (interpreter, JDK, Maven home and options).
|
||||
onIdeEvent("settingsChanged", (e) => {
|
||||
if (e.key === "languages" || e.key.startsWith("languages.")) scheduleReload();
|
||||
if (
|
||||
!e.key ||
|
||||
e.key === "languages" ||
|
||||
e.key.startsWith("languages.") ||
|
||||
e.key === "cmake" ||
|
||||
e.key.startsWith("cmake.")
|
||||
)
|
||||
scheduleReload();
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateSettings } from "../model/schema";
|
||||
import { DEFAULT_CMAKE_PROFILE } from "@/platform/cmakeSettings";
|
||||
|
||||
describe("CMake settings", () => {
|
||||
it("preserves absent and explicitly empty profiles", () => {
|
||||
expect(validateSettings({}).cmake).toEqual({});
|
||||
expect(validateSettings({ cmake: { profiles: [] } }).cmake.profiles).toEqual([]);
|
||||
});
|
||||
it("preserves argument arrays and drops malformed argument values", () => {
|
||||
const cmakeArgs = ["-DMESSAGE=two words", '-DQUOTE="hello"', ""];
|
||||
const buildArgs = ["-j", "8", "--", "LABEL=it's a build"];
|
||||
const profiles = validateSettings({ cmake: { profiles: [{ cmakeArgs, buildArgs }] } }).cmake.profiles;
|
||||
expect(profiles?.[0]).toMatchObject({ cmakeArgs, buildArgs });
|
||||
expect(
|
||||
validateSettings({ cmake: { profiles: [{ cmakeArgs: ["-DFOO=bar", 42, null] }] } }).cmake.profiles?.[0]
|
||||
?.cmakeArgs,
|
||||
).toEqual(["-DFOO=bar"]);
|
||||
});
|
||||
it("retains custom types and generators while normalizing malformed fields", () => {
|
||||
expect(
|
||||
validateSettings({
|
||||
cmake: {
|
||||
profiles: [
|
||||
{
|
||||
name: "Custom",
|
||||
buildType: "Coverage",
|
||||
generator: "Custom generator",
|
||||
buildArgs: 5,
|
||||
env: { KEEP: "yes", DROP: 5 },
|
||||
},
|
||||
null,
|
||||
],
|
||||
},
|
||||
}).cmake.profiles,
|
||||
).toEqual([
|
||||
{
|
||||
...DEFAULT_CMAKE_PROFILE,
|
||||
name: "Custom",
|
||||
buildType: "Coverage",
|
||||
generator: "Custom generator",
|
||||
env: { KEEP: "yes" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { DEFAULT_CMAKE_PROFILE } from "@/platform/cmakeSettings";
|
||||
import { parseArgs } from "@/platform/commandLine";
|
||||
import { PageContext } from "../components/context";
|
||||
import { validateSettings } from "../model/schema";
|
||||
import { useSettingsDialog } from "../model/staging";
|
||||
import { CMakeProfiles } from "../pages/cmake";
|
||||
|
||||
const savedDialog = useSettingsDialog.getState();
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useSettingsDialog.setState(savedDialog, true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["CMake arguments", "cmakeArgs"],
|
||||
["Build arguments", "buildArgs"],
|
||||
] as const)("round-trips spaces, quotes and empty values in %s", async (label, key) => {
|
||||
const args = ["two words", 'a "quote"', "it's", "", String.raw`C:\some path\file`];
|
||||
useSettingsDialog.setState({
|
||||
base: validateSettings({ cmake: { profiles: [{ ...DEFAULT_CMAKE_PROFILE, [key]: args }] } }),
|
||||
staged: {},
|
||||
});
|
||||
const mount = () =>
|
||||
render(
|
||||
<PageContext.Provider value={{ pageId: "build.cmake", scope: "project" }}>
|
||||
<CMakeProfiles />
|
||||
</PageContext.Provider>,
|
||||
);
|
||||
const view = mount();
|
||||
const input = screen.getByLabelText<HTMLInputElement>(label);
|
||||
expect(parseArgs(input.value)).toEqual(args);
|
||||
await userEvent.clear(input);
|
||||
const text = String.raw`-DNAME="two words" "-DQUOTE=it's" ""`;
|
||||
await userEvent.type(input, text);
|
||||
expect(input).toHaveValue(text);
|
||||
fireEvent.blur(input);
|
||||
const expected = ["-DNAME=two words", "-DQUOTE=it's", ""];
|
||||
expect(parseArgs(input.value)).toEqual(expected);
|
||||
expect(useSettingsDialog.getState().staged["project|cmake.profiles"]?.value).toEqual([
|
||||
{ ...DEFAULT_CMAKE_PROFILE, [key]: expected },
|
||||
]);
|
||||
view.unmount();
|
||||
mount();
|
||||
expect(parseArgs(screen.getByLabelText<HTMLInputElement>(label).value)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("displays each profile's arguments when switching profiles", () => {
|
||||
useSettingsDialog.setState({
|
||||
base: validateSettings({
|
||||
cmake: {
|
||||
profiles: [
|
||||
{ ...DEFAULT_CMAKE_PROFILE, name: "Debug" },
|
||||
{ ...DEFAULT_CMAKE_PROFILE, name: "Release", cmakeArgs: ["-DMESSAGE=release build"] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
staged: {},
|
||||
});
|
||||
render(
|
||||
<PageContext.Provider value={{ pageId: "build.cmake", scope: "project" }}>
|
||||
<CMakeProfiles />
|
||||
</PageContext.Provider>,
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("CMake arguments"), { target: { value: '"unfinished ' } });
|
||||
fireEvent.click(screen.getByRole("option", { name: "Release" }));
|
||||
expect(parseArgs(screen.getByLabelText<HTMLInputElement>("CMake arguments").value)).toEqual([
|
||||
"-DMESSAGE=release build",
|
||||
]);
|
||||
});
|
||||
@@ -84,6 +84,29 @@ describe("Settings dialog", () => {
|
||||
expect(settingsSet).toHaveBeenLastCalledWith("global", "editor.encodings.nativeToAscii", "false");
|
||||
});
|
||||
|
||||
it("edits CMake profiles in project settings and persists them on Apply", async () => {
|
||||
useProject.setState({ project: { root: "/project" } as never });
|
||||
const dialog = await openDialog("build.cmake");
|
||||
expect(within(dialog).getByRole("option", { name: "Debug" })).toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
within(within(dialog).getByRole("toolbar", { name: "CMake profile actions" })).getByRole("button", {
|
||||
name: "Add",
|
||||
}),
|
||||
);
|
||||
fireEvent.change(within(dialog).getByLabelText("Name"), { target: { value: "Release" } });
|
||||
fireEvent.change(within(dialog).getByLabelText("Build type"), { target: { value: "Release" } });
|
||||
fireEvent.change(within(dialog).getByLabelText("Generator"), { target: { value: "Ninja" } });
|
||||
fireEvent.change(within(dialog).getByLabelText("Build arguments"), { target: { value: "-j 8" } });
|
||||
expect(settingsSet).not.toHaveBeenCalled();
|
||||
await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" })));
|
||||
const write = settingsSet.mock.calls.find((c) => c[1] === "cmake.profiles")!;
|
||||
expect(write[0]).toBe("project");
|
||||
expect(JSON.parse(write[2])).toEqual([
|
||||
expect.objectContaining({ name: "Debug", buildType: "Debug" }),
|
||||
expect.objectContaining({ name: "Release", buildType: "Release", generator: "Ninja", buildArgs: ["-j", "8"] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds Gradle JVM and applies project Gradle settings", async () => {
|
||||
useProject.setState({ project: { root: "/project" } as never });
|
||||
const dialog = await openDialog("build.gradle");
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* so `validateSettings` never throws — anything unknown or malformed falls
|
||||
* back to the default (mirrors `ide-core::settings` defaults).
|
||||
*/
|
||||
import { validateCMake } from "@/platform/cmakeSettings";
|
||||
import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames";
|
||||
import { validateFileTypeOverrides, validateFileTypes, type FileTypeAssociations } from "@/platform/fileTypes";
|
||||
import type { LanguageServerSettings, Settings } from "@/platform/ipc";
|
||||
@@ -82,6 +83,7 @@ export interface ValidSettings extends WorkflowSettings {
|
||||
/** System Settings › HTTP Proxy (global). */
|
||||
proxy: ProxySettingsValid;
|
||||
languages: LanguagesValid;
|
||||
cmake: ReturnType<typeof validateCMake>;
|
||||
projectStructure: ProjectStructureValid;
|
||||
inspections: InspectionSettingsValid;
|
||||
/** Editor › Natural Languages › Spelling. */
|
||||
@@ -150,6 +152,7 @@ export const DEFAULT_SETTINGS: ValidSettings = {
|
||||
notifications: DEFAULT_NOTIFICATIONS,
|
||||
proxy: DEFAULT_PROXY,
|
||||
languages: DEFAULT_LANGUAGES,
|
||||
cmake: {},
|
||||
projectStructure: DEFAULT_PROJECT_STRUCTURE,
|
||||
inspections: DEFAULT_INSPECTIONS,
|
||||
spelling: { ideWords: [], projectWords: [], customDictionaries: [] },
|
||||
@@ -254,6 +257,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
|
||||
notifications: validateNotifications(root.notifications),
|
||||
proxy: validateProxy(root.proxy),
|
||||
languages: validateLanguages(root.languages),
|
||||
cmake: validateCMake(root.cmake),
|
||||
projectStructure: validateProjectStructure(root.projectStructure),
|
||||
inspections: validateInspections(root.inspections),
|
||||
spelling: validateSpelling(root.spelling),
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.profile {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useId, useState } from "react";
|
||||
import { DEFAULT_CMAKE_PROFILE, type CMakeProfileValid } from "@/platform/cmakeSettings";
|
||||
import { formatArgs, parseArgs } from "@/platform/commandLine";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { IconButton, Input, T } from "@/ui";
|
||||
import { Row, Section } from "../components/Form";
|
||||
import { useSetting } from "../components/context";
|
||||
import { TableEditor } from "./workflowControls";
|
||||
import styles from "./Workflow.module.css";
|
||||
import own from "./CMake.module.css";
|
||||
|
||||
function ArgumentsInput({ id, value, onChange }: { id: string; value: string[]; onChange: (args: string[]) => void }) {
|
||||
const [text, setText] = useState<string>();
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
className={styles.wide}
|
||||
value={text ?? formatArgs(value)}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
onChange(parseArgs(e.target.value));
|
||||
}}
|
||||
onBlur={() => setText(undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CMakeProfiles() {
|
||||
const [saved, setProfiles] = useSetting<CMakeProfileValid[] | undefined>("cmake.profiles");
|
||||
const profiles = saved ?? [DEFAULT_CMAKE_PROFILE];
|
||||
const [selected, setSelected] = useState(0);
|
||||
const index = Math.min(selected, Math.max(0, profiles.length - 1));
|
||||
const current = profiles[index];
|
||||
const id = useId();
|
||||
const update = (patch: Partial<CMakeProfileValid>) =>
|
||||
setProfiles(profiles.map((p, i) => (i === index ? { ...p, ...patch } : p)));
|
||||
const fields = [
|
||||
["name", "Name"],
|
||||
["buildType", "Build type"],
|
||||
["generator", "Generator"],
|
||||
["cmakeArgs", "CMake arguments"],
|
||||
["buildDir", "Build directory"],
|
||||
["buildArgs", "Build arguments"],
|
||||
] as const;
|
||||
return (
|
||||
<div className={styles.toolsLayout}>
|
||||
<Section title="CMake profiles">
|
||||
<div className={styles.tableBox}>
|
||||
<div className={styles.toolbar} role="toolbar" aria-label={tx("CMake profile actions")}>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
title="Add"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setProfiles([
|
||||
...profiles,
|
||||
{ ...DEFAULT_CMAKE_PROFILE, name: tx`CMake profile ${profiles.length + 1}`, env: {} },
|
||||
]);
|
||||
setSelected(profiles.length);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon="minus"
|
||||
title="Remove"
|
||||
size="small"
|
||||
disabled={!current}
|
||||
onClick={() => {
|
||||
setProfiles(profiles.filter((_, i) => i !== index));
|
||||
setSelected(Math.max(0, index - 1));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.toolList} role="listbox" aria-label={tx("CMake profiles")}>
|
||||
{profiles.map((p, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
role="option"
|
||||
aria-selected={index === i}
|
||||
className={`${own.profile} ${styles.toolRow} ${index === i ? styles.toolRowSelected : ""}`}
|
||||
onClick={() => setSelected(i)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
{profiles.length === 0 && <T>No CMake profiles. Click + to add one.</T>}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
{current && (
|
||||
<Section title="Profile settings">
|
||||
{fields.map(([key, label]) => (
|
||||
<Row key={key} label={label} htmlFor={`${id}-${key}`} className={styles.alignedRow}>
|
||||
{key === "cmakeArgs" || key === "buildArgs" ? (
|
||||
<ArgumentsInput
|
||||
key={index}
|
||||
id={`${id}-${key}`}
|
||||
value={current[key]}
|
||||
onChange={(args) => update({ [key]: args })}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={`${id}-${key}`}
|
||||
className={styles.wide}
|
||||
value={current[key]}
|
||||
list={key === "buildType" || key === "generator" ? `${id}-${key}-options` : undefined}
|
||||
placeholder={
|
||||
key === "buildDir"
|
||||
? "cmake-build-" + current.buildType.toLowerCase()
|
||||
: key === "generator"
|
||||
? tx("CMake default")
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => update({ [key]: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
<datalist id={`${id}-buildType-options`}>
|
||||
{["Debug", "Release", "RelWithDebInfo", "MinSizeRel"].map((v) => (
|
||||
<option key={v} value={v} />
|
||||
))}
|
||||
</datalist>
|
||||
<datalist id={`${id}-generator-options`}>
|
||||
{["Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022", "Xcode"].map((v) => (
|
||||
<option key={v} value={v} />
|
||||
))}
|
||||
</datalist>
|
||||
<Row label="Environment variables" stacked>
|
||||
<TableEditor
|
||||
rows={Object.entries(current.env).map(([name, value]) => ({ name, value }))}
|
||||
columns={[
|
||||
{ key: "name", title: "Name", placeholder: "NAME" },
|
||||
{ key: "value", title: "Value", placeholder: "value" },
|
||||
]}
|
||||
onChange={(rows) => update({ env: Object.fromEntries(rows.map((r) => [r.name ?? "", r.value ?? ""])) })}
|
||||
empty="No variables"
|
||||
aria-label="Environment variables"
|
||||
/>
|
||||
</Row>
|
||||
<div className={styles.note}>
|
||||
<T>
|
||||
Quote arguments containing spaces. Build arguments go to cmake --build; use -- before native build tool
|
||||
options.
|
||||
</T>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* Stepping), Python Debugger and Run/Debug. Consumed by the run and debugger
|
||||
* features (and `ide-run` for encoding / termination timeout).
|
||||
*/
|
||||
import { CMakeProfiles } from "./cmake";
|
||||
import { WORKFLOW_LIMITS } from "@/platform/workflowSettings";
|
||||
import type { SelectOption } from "@/ui";
|
||||
import { GlobListEditor, NumberInput, Row } from "../components/Form";
|
||||
@@ -90,6 +91,25 @@ function TooltipDelayRow() {
|
||||
}
|
||||
|
||||
export const EXECUTION_PAGES: PageSpec[] = [
|
||||
{
|
||||
id: "build.cmake",
|
||||
title: "CMake",
|
||||
group: BUILD_GROUP,
|
||||
order: 357,
|
||||
projectScope: true,
|
||||
defaultScope: "project",
|
||||
keywords: [
|
||||
"profile",
|
||||
"generator",
|
||||
"Ninja",
|
||||
"Build type",
|
||||
"CMake arguments",
|
||||
"Build directory",
|
||||
"Build arguments",
|
||||
"Environment variables",
|
||||
],
|
||||
component: CMakeProfiles,
|
||||
},
|
||||
{
|
||||
id: "build.gradle",
|
||||
projectScope: true,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CMakeProfile } from "@/bindings";
|
||||
|
||||
export type CMakeProfileValid = Required<CMakeProfile>;
|
||||
|
||||
export const DEFAULT_CMAKE_PROFILE: CMakeProfileValid = {
|
||||
name: "Debug",
|
||||
buildType: "Debug",
|
||||
generator: "",
|
||||
cmakeArgs: [],
|
||||
buildDir: "",
|
||||
buildArgs: [],
|
||||
env: {},
|
||||
};
|
||||
|
||||
export function validateCMake(raw: unknown): { profiles?: CMakeProfileValid[] } {
|
||||
const profiles = (raw as { profiles?: unknown } | undefined)?.profiles;
|
||||
if (!Array.isArray(profiles)) return {};
|
||||
const string = (value: unknown, fallback = "") => (typeof value === "string" ? value : fallback);
|
||||
const args = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((arg): arg is string => typeof arg === "string") : [];
|
||||
return {
|
||||
profiles: profiles
|
||||
.filter((p) => p && typeof p === "object")
|
||||
.map((p) => ({
|
||||
name: string(p.name, "Debug"),
|
||||
buildType: string(p.buildType, "Debug"),
|
||||
generator: string(p.generator),
|
||||
cmakeArgs: args(p.cmakeArgs),
|
||||
buildDir: string(p.buildDir),
|
||||
buildArgs: args(p.buildArgs),
|
||||
env: Object.fromEntries(
|
||||
Object.entries(p.env && typeof p.env === "object" ? p.env : {}).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -5454,5 +5454,17 @@
|
||||
"Cannot read Java formatter XML {path}: {error}": "无法读取 Java 格式化 XML {path}:{error}",
|
||||
"Java formatter configuration unavailable": "Java 格式化配置不可用",
|
||||
"Choose an Eclipse formatter XML file.": "请选择 Eclipse 格式化 XML 文件。",
|
||||
"Not a formatter XML file: {path}": "不是格式化 XML 文件:{path}"
|
||||
"Not a formatter XML file: {path}": "不是格式化 XML 文件:{path}",
|
||||
"CMake profiles": "CMake 配置",
|
||||
"CMake profile actions": "CMake 配置操作",
|
||||
"No CMake profiles. Click + to add one.": "没有 CMake 配置。点击 + 添加。",
|
||||
"Profile settings": "配置设置",
|
||||
"Build type": "构建类型",
|
||||
"Generator": "生成器",
|
||||
"CMake arguments": "CMake 参数",
|
||||
"Build directory": "构建目录",
|
||||
"Build arguments": "构建参数",
|
||||
"CMake default": "CMake 默认",
|
||||
"Quote arguments containing spaces. Build arguments go to cmake --build; use -- before native build tool options.": "包含空格的参数请加引号。构建参数传给 cmake --build;原生构建工具参数前请加 --。",
|
||||
"CMake profile {0}": "CMake 配置 {0}"
|
||||
}
|
||||
@@ -80,6 +80,7 @@ const BUILT_AT_RUNTIME = new Set<string>([]);
|
||||
/** Text that stays as written in every locale: names, code, formats, shortcuts. */
|
||||
const UNTRANSLATED = new Set<string>([
|
||||
// Product names
|
||||
"CMake",
|
||||
"Prettier",
|
||||
"IntelliJ IDEA",
|
||||
// Go environment variables and placeholder values
|
||||
|
||||
Reference in new issue
Заблокувати користувача