дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(plugins): add fixed filenames and file template variables (#2032)
Closes #1954 Plugin file templates could only take a typed name plus a fixed extension, so dot-files such as .dockerignore and templates needing more than a name weren't expressible. Templates (API ^1.3) can now declare a fixed `fileName` and named `variables` that the New File dialog asks for, and plugin templates get the same Java/Kotlin/Go/PHP package inference as built-in ones. Unsafe filenames and existing files are refused. The dialog no longer submits on Enter while an IME is composing, and its variable form has proper padding. Adds a file-templates test fixture. Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
1 джерело
9e6cb7b93e
коміт
f6531542de
30 files changed
+1205
-92
No files matched your search
@@ -559,11 +559,21 @@ struct FileTemplateDecl {
|
||||
id: String,
|
||||
name: String,
|
||||
/// Without the dot.
|
||||
#[serde(default)]
|
||||
extension: String,
|
||||
file_name: Option<String>,
|
||||
variables: Option<Vec<FileTemplateVariable>>,
|
||||
/// The new file's content (`$NAME$`, `$END$` and the other template variables work).
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FileTemplateVariable {
|
||||
name: String,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
const MAX_TEMPLATE: usize = 256 << 10;
|
||||
|
||||
/// `[[contributes.fileTemplates]]`: an entry of the New menu.
|
||||
@@ -572,8 +582,59 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
|
||||
validate_item_id(&d.id, "file template id")?;
|
||||
title(FILE_TEMPLATES, &d.name)?;
|
||||
let what = format!("file template {:?}", d.id);
|
||||
if d.file_name.is_some() || d.variables.is_some() {
|
||||
pkg.manifest.needs_api("contributes.fileTemplates: fileName / variables", 3)?;
|
||||
}
|
||||
if let Some(name) = &d.file_name {
|
||||
let stem = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
|
||||
if name.is_empty()
|
||||
|| name.len() > 255
|
||||
|| name.trim() != name
|
||||
|| name.ends_with('.')
|
||||
|| matches!(name.as_str(), "." | "..")
|
||||
|| name.chars().any(|c| c.is_control() || "/\\<>:\"|?*".contains(c))
|
||||
|| matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& matches!(stem.as_bytes().get(3), Some(b'1'..=b'9'))
|
||||
&& stem.len() == 4)
|
||||
{
|
||||
return Err(format!("{what}: `fileName` must be a safe single file name"));
|
||||
}
|
||||
}
|
||||
let mut names = std::collections::HashSet::new();
|
||||
let variables = d.variables.unwrap_or_default();
|
||||
if variables.len() > 32 {
|
||||
return Err(format!("{what}: at most 32 variables are allowed"));
|
||||
}
|
||||
for variable in &variables {
|
||||
let name = &variable.name;
|
||||
if name.is_empty()
|
||||
|| name.len() > 64
|
||||
|| !name
|
||||
.bytes()
|
||||
.enumerate()
|
||||
.all(|(i, c)| c.is_ascii_alphabetic() || c == b'_' || i > 0 && c.is_ascii_digit())
|
||||
|| matches!(
|
||||
name.as_str(),
|
||||
"NAME"
|
||||
| "FILE_NAME"
|
||||
| "PACKAGE"
|
||||
| "PACKAGE_NAME"
|
||||
| "PROJECT_NAME"
|
||||
| "USER"
|
||||
| "DATE"
|
||||
| "TIME"
|
||||
| "YEAR"
|
||||
| "END"
|
||||
)
|
||||
|| !names.insert(name)
|
||||
{
|
||||
return Err(format!("{what}: invalid, reserved or duplicate variable name {name:?}"));
|
||||
}
|
||||
title("file template variable prompt", &variable.prompt)?;
|
||||
}
|
||||
let ext = d.extension.trim_start_matches('.');
|
||||
if ext.is_empty()
|
||||
if (ext.is_empty() && d.file_name.is_none())
|
||||
|| ext.len() > 32
|
||||
|| !ext.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
||||
{
|
||||
@@ -583,7 +644,7 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
|
||||
if body.len() > MAX_TEMPLATE {
|
||||
return Err(format!("{what}: {} is larger than {} KiB", d.path.display(), MAX_TEMPLATE >> 10));
|
||||
}
|
||||
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body });
|
||||
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body, "fileName": d.file_name, "variables": variables });
|
||||
let mut out = UiDecl::new(d.id.clone(), d.name.clone(), json);
|
||||
out.files = vec![d.path.clone()];
|
||||
Ok(out)
|
||||
|
||||
@@ -2910,6 +2910,81 @@ path = "templates/new.draw"
|
||||
assert!(f.host.web_page("web-demo", "draw").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_templates_fixture_installs_without_write_capabilities() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&sample("file-templates"));
|
||||
assert!(info.capabilities.is_empty());
|
||||
let templates = f.host.ui_items("fileTemplates");
|
||||
assert_eq!(templates.len(), 4);
|
||||
let template = |id: &str| templates.iter().find(|t| t.decl["id"] == id).unwrap();
|
||||
assert_eq!(template("dockerignore").decl["fileName"], ".dockerignore");
|
||||
assert_eq!(template("attributes").decl["variables"].as_array().unwrap().len(), 2);
|
||||
assert!(template("repository").decl["body"].as_str().unwrap().contains("$PACKAGE$"));
|
||||
assert!(f.installed("file-templates").join("templates/repository.txt").is_file());
|
||||
assert_eq!(f.run("file-templates", "ping", None).unwrap().as_deref(), Some("File templates ready"));
|
||||
f.host.set_enabled("file-templates", false).unwrap();
|
||||
assert!(f.host.ui_items("fileTemplates").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_templates_api_13_names_and_variables() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = tmp.path().join("template-demo");
|
||||
std::fs::create_dir_all(&pkg).unwrap();
|
||||
std::fs::write(pkg.join("body.txt"), "$ENTITY$ $PACKAGE$\n").unwrap();
|
||||
let install = |api: &str, fields: &str| {
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!(
|
||||
r#"id = "template-demo"
|
||||
name = "Templates"
|
||||
version = "1.0.0"
|
||||
engines.openidea = "{api}"
|
||||
[[contributes.fileTemplates]]
|
||||
id = "template"
|
||||
name = "Template"
|
||||
path = "body.txt"
|
||||
{fields}
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
f.host.install_from_dir(&pkg, &Consent::all())
|
||||
};
|
||||
for fields in [r#"fileName = ".dockerignore""#, "extension = \"java\"\nvariables = []"] {
|
||||
let err = install("^1.2", fields).unwrap_err().message;
|
||||
assert!(err.contains("1.3"), "{err}");
|
||||
}
|
||||
for name in ["", ".", "..", "../escape", "a/b", r"a\b", "/absolute", "C:evil", "a?", " a", "a.", "NUL.txt", "COM1"]
|
||||
{
|
||||
let fields = format!("fileName = {}", serde_json::to_string(name).unwrap());
|
||||
assert!(install("^1.3", &fields).is_err(), "accepted {name:?}");
|
||||
}
|
||||
for vars in [
|
||||
r#"[{ name = "", prompt = "Entity" }]"#,
|
||||
r#"[{ name = "1bad", prompt = "Entity" }]"#,
|
||||
r#"[{ name = "bad-name", prompt = "Entity" }]"#,
|
||||
r#"[{ name = "PACKAGE", prompt = "Entity" }]"#,
|
||||
r#"[{ name = "ENTITY", prompt = "" }]"#,
|
||||
r#"[{ name = "ENTITY", prompt = "Entity" }, { name = "ENTITY", prompt = "Again" }]"#,
|
||||
] {
|
||||
assert!(install("^1.3", &format!("extension = \"java\"\nvariables = {vars}")).is_err(), "accepted {vars}");
|
||||
}
|
||||
install(
|
||||
"^1.3",
|
||||
r#"fileName = ".dockerignore"
|
||||
variables = [{ name = "ENTITY", prompt = "Entity class" }]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let items = f.host.ui_items("fileTemplates");
|
||||
assert_eq!(items[0].decl["fileName"], ".dockerignore");
|
||||
assert_eq!(items[0].decl["extension"], "");
|
||||
assert_eq!(items[0].decl["variables"][0]["prompt"], "Entity class");
|
||||
assert_eq!(items[0].decl["body"], "$ENTITY$ $PACKAGE$\n");
|
||||
}
|
||||
|
||||
/// A view needs a page or code to render it; a page's directory can't be the package itself.
|
||||
#[test]
|
||||
fn web_view_declarations_are_checked() {
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-file-templates"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-file-templates"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
93f7501bb780898346fbf0614af254949dd17dd7c705581befc5e485422e1866
|
||||
@@ -0,0 +1,38 @@
|
||||
id = "file-templates"
|
||||
name = "File Templates"
|
||||
version = "0.1.0"
|
||||
description = "API 1.3 fixture: exact filenames, variables and inferred packages."
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:ping"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "ping"
|
||||
title = "File Templates: Ping"
|
||||
|
||||
[[contributes.fileTemplates]]
|
||||
id = "dockerignore"
|
||||
name = "Docker ignore"
|
||||
extension = "dockerignore"
|
||||
fileName = ".dockerignore"
|
||||
path = "templates/dockerignore.txt"
|
||||
|
||||
[[contributes.fileTemplates]]
|
||||
id = "attributes"
|
||||
name = "Git attributes"
|
||||
fileName = ".gitattributes"
|
||||
path = "templates/attributes.txt"
|
||||
variables = [{ name = "PATTERN", prompt = "File pattern" }, { name = "FILTER", prompt = "Attribute filter" }]
|
||||
|
||||
[[contributes.fileTemplates]]
|
||||
id = "repository"
|
||||
name = "Java repository"
|
||||
extension = "java"
|
||||
path = "templates/repository.txt"
|
||||
variables = [{ name = "ENTITY", prompt = "Entity class" }, { name = "TABLE", prompt = "Table name" }]
|
||||
|
||||
[[contributes.fileTemplates]]
|
||||
id = "kotlin"
|
||||
name = "Kotlin entity"
|
||||
extension = "kt"
|
||||
path = "templates/kotlin.txt"
|
||||
@@ -0,0 +1,15 @@
|
||||
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FileTemplates;
|
||||
|
||||
impl Plugin for FileTemplates {
|
||||
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"ping" => Ok(Some("File templates ready".into())),
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(FileTemplates);
|
||||
@@ -0,0 +1 @@
|
||||
$PATTERN$ filter=$FILTER$
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,5 @@
|
||||
package $PACKAGE$
|
||||
|
||||
class $NAME$ {
|
||||
$END$
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package $PACKAGE$;
|
||||
|
||||
// Table: $TABLE$
|
||||
public interface $NAME$ {
|
||||
$ENTITY$ find();
|
||||
$END$
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
test("plugin templates keep exact names and collect variables before creation", async ({ workbench: page }) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async () => {
|
||||
window.__ipc.handlers.fs_create_file = () => null;
|
||||
window.__ipc.handlers.fs_probe = () => ({ size: 0, binary: false, tooLarge: false });
|
||||
window.__ipc.handlers.project_roots = () => ({ detected: [] });
|
||||
const module = "/src/workbench/contributions.ts";
|
||||
const { addContributedFileTemplate } = await import(/* @vite-ignore */ module);
|
||||
for (const [id, fileName, variables] of [
|
||||
["ignore", ".dockerignore", []],
|
||||
[
|
||||
"attributes",
|
||||
".gitattributes",
|
||||
[
|
||||
{ name: "FILTER", prompt: "Attribute filter" },
|
||||
{ name: "PATTERN", prompt: "File pattern" },
|
||||
],
|
||||
],
|
||||
]) {
|
||||
addContributedFileTemplate({
|
||||
id: `plugin.fixture.${id}`,
|
||||
name: String(fileName),
|
||||
icon: "file",
|
||||
extension: "txt",
|
||||
nameStyle: "asis",
|
||||
fileName,
|
||||
variables,
|
||||
reformat: false,
|
||||
kinds: [{ id: "file", label: String(fileName), body: "" }],
|
||||
});
|
||||
}
|
||||
const storeModule = "/src/features/templates/files/store.ts";
|
||||
const { fileTemplates, saveFileTemplate } = await import(/* @vite-ignore */ storeModule);
|
||||
// Older saved customizations contain the body but none of the new declaration fields.
|
||||
const legacy = { ...fileTemplates().find((t: { id: string }) => t.id === "plugin.fixture.attributes") };
|
||||
delete legacy.fileName;
|
||||
delete legacy.variables;
|
||||
delete legacy.inferPackage;
|
||||
saveFileTemplate(legacy);
|
||||
const actions = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ actions);
|
||||
await executeAction("templates.new.plugin.fixture.ignore", { directory: "/dev/acme-api" });
|
||||
});
|
||||
await expect.poll(async () => (await ipcCalls(page, "fs_create_file")).length).toBe(1);
|
||||
expect((await ipcCalls(page, "fs_create_file"))[0]).toEqual({ path: "/dev/acme-api/.dockerignore" });
|
||||
await expect(page.getByRole("dialog", { name: "New .dockerignore" })).not.toBeVisible();
|
||||
const open = async () =>
|
||||
page.evaluate(async () => {
|
||||
const module = "/src/platform/actions.ts";
|
||||
const { executeAction } = await import(/* @vite-ignore */ module);
|
||||
await executeAction("templates.new.plugin.fixture.attributes", { directory: "/dev/acme-api" });
|
||||
});
|
||||
await open();
|
||||
const dialog = page.getByRole("dialog", { name: "New .gitattributes" });
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(2);
|
||||
await dialog.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await expect(dialog.getByText("Enter a value for each variable")).toBeVisible();
|
||||
await dialog.getByLabel("Attribute filter").fill("lfs");
|
||||
await dialog.getByLabel("File pattern").fill("*.bin");
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
expect(await ipcCalls(page, "fs_create_file")).toHaveLength(1);
|
||||
await open();
|
||||
await dialog.getByLabel("Attribute filter").fill("lfs");
|
||||
await dialog.getByLabel("File pattern").fill("*.bin");
|
||||
await dialog.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await expect.poll(async () => (await ipcCalls(page, "fs_create_file")).length).toBe(2);
|
||||
expect((await ipcCalls(page, "fs_create_file"))[1]).toEqual({ path: "/dev/acme-api/.gitattributes" });
|
||||
});
|
||||
@@ -153,7 +153,7 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
|
||||
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_), `group?`, `when?` (API 1.3) | without `web` |
|
||||
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
|
||||
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
|
||||
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
|
||||
| `fileTemplates` | `id`, `name`, `extension`, `path`; API 1.3: `fileName`, `variables = [{ name, prompt }]` — New menu | no |
|
||||
| `commitChecks` | `id`, `title` — a before-commit check in Commit Options (see _Commits_) | yes |
|
||||
|
||||
A `viewItem.context` menu needs `view`, the id of one of the plugin's own
|
||||
@@ -212,6 +212,38 @@ a key set from a command or event applies to that window, one set outside any
|
||||
applies to every window, and a window's value wins over the global one. Keys are
|
||||
kept across a window reload and idle reclaim, and dropped when the plugin stops.
|
||||
|
||||
### File template names and variables (API 1.3)
|
||||
|
||||
With `engines.openidea = "^1.3"`, `fileName` creates an exact single filename
|
||||
(including dotfiles) without a name prompt. `extension` may be omitted for these
|
||||
files; it is never appended to `fileName`. Without `fileName`, naming behaves as before.
|
||||
Declared variables are required text fields shown together in the new-file dialog;
|
||||
cancelling creates nothing. Names must be unique ASCII identifiers and cannot
|
||||
replace built-in variables (`NAME`, `PACKAGE`, `END`, etc.). Prompts can be translated
|
||||
through the plugin's text catalog. Values are inserted literally with `$ENTITY$`
|
||||
or `${ENTITY}` syntax.
|
||||
|
||||
```toml
|
||||
engines.openidea = "^1.3"
|
||||
|
||||
[[contributes.fileTemplates]]
|
||||
id = "ignore"
|
||||
name = "Docker ignore"
|
||||
fileName = ".dockerignore"
|
||||
path = "templates/dockerignore.txt"
|
||||
variables = [{ name = "OUTPUT", prompt = "Output folder" }]
|
||||
```
|
||||
|
||||
Plugin templates infer `$PACKAGE$` with the built-in rules for their extension:
|
||||
Java/Kotlin use source roots, Go uses sibling packages or the directory, and PHP
|
||||
uses Composer PSR-4 mappings. No `packageStyle` manifest field is needed.
|
||||
|
||||
These declarations do not grant the plugin filesystem access: files are created
|
||||
only when the user invokes a template, through the IDE's normal New File flow.
|
||||
See `crates/ide-plugin/tests/fixtures/file-templates` for a minimal buildable
|
||||
plugin covering dotfiles, multiple variables, and Java/Kotlin packages. Build
|
||||
its component with `plugins/build.sh crates/ide-plugin/tests/fixtures/file-templates`.
|
||||
|
||||
### Background associations
|
||||
|
||||
`[[contributes.documentIndexers]]` declares file `extensions` and requires the optional
|
||||
|
||||
@@ -2,8 +2,15 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import type { PluginInfo, ViewEvent } from "@/bindings";
|
||||
import { addFileIconProvider, fileIconName, registerExternalIcons } from "@/ui";
|
||||
import { addContributedFileTemplate, fileTemplates, isBuiltinFileTemplate } from "@/features/templates/files/store";
|
||||
import {
|
||||
addContributedFileTemplate,
|
||||
fileTemplates,
|
||||
isBuiltinFileTemplate,
|
||||
mergeFileTemplates,
|
||||
} from "@/features/templates/files/store";
|
||||
import { createMemoryPluginsBackend, setPluginsBackend, webSchemeUrl } from "../backend";
|
||||
import { defineViewUiPoints } from "../uiPoints";
|
||||
import { clearUiContributions, syncUiContributions } from "../projection";
|
||||
import { usePlugins } from "../store";
|
||||
import { FRAME_TAG, frameDocument } from "../views/frame";
|
||||
import {
|
||||
@@ -261,6 +268,74 @@ describe("contributed file icons and templates", () => {
|
||||
expect(fileIconName("a.excalidraw")).toBe(builtin);
|
||||
});
|
||||
|
||||
it("projects filename and variable declarations through contributions and withdraws them", () => {
|
||||
const undefine = defineViewUiPoints();
|
||||
try {
|
||||
syncUiContributions(
|
||||
[
|
||||
{
|
||||
pluginId: "fixture",
|
||||
point: "fileTemplates",
|
||||
key: "ignore",
|
||||
decl: {
|
||||
id: "ignore",
|
||||
name: "Ignore",
|
||||
extension: "",
|
||||
fileName: ".dockerignore",
|
||||
body: "$OUTPUT$",
|
||||
variables: [{ name: "OUTPUT", prompt: "Output folder" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
[{ id: "fixture" } as PluginInfo],
|
||||
);
|
||||
expect(fileTemplates().find((t) => t.id === "plugin.fixture.ignore")).toMatchObject({
|
||||
fileName: ".dockerignore",
|
||||
extension: "",
|
||||
inferPackage: true,
|
||||
variables: [{ name: "OUTPUT", prompt: "Output folder" }],
|
||||
kinds: [{ body: "$OUTPUT$" }],
|
||||
});
|
||||
clearUiContributions();
|
||||
expect(fileTemplates().some((t) => t.id === "plugin.fixture.ignore")).toBe(false);
|
||||
} finally {
|
||||
clearUiContributions();
|
||||
undefine();
|
||||
}
|
||||
});
|
||||
|
||||
it("inherits new declaration fields without losing a legacy template customization", () => {
|
||||
const legacy = {
|
||||
id: "plugin.fixture.repository",
|
||||
name: "Custom Repository",
|
||||
icon: "file-text",
|
||||
extension: "java",
|
||||
nameStyle: "asis" as const,
|
||||
kinds: [{ id: "file", label: "File", body: "// custom $ENTITY$ $PACKAGE$" }],
|
||||
};
|
||||
const remove = addContributedFileTemplate({
|
||||
...legacy,
|
||||
name: "Repository",
|
||||
fileName: "Repository.java",
|
||||
inferPackage: true,
|
||||
variables: [{ name: "ENTITY", prompt: "Entity class" }],
|
||||
kinds: [{ id: "file", label: "File", body: "$END$" }],
|
||||
});
|
||||
try {
|
||||
expect(
|
||||
mergeFileTemplates({ templates: { [legacy.id]: legacy }, deleted: [] }).find((t) => t.id === legacy.id),
|
||||
).toMatchObject({
|
||||
name: "Custom Repository",
|
||||
fileName: "Repository.java",
|
||||
inferPackage: true,
|
||||
variables: [{ name: "ENTITY", prompt: "Entity class" }],
|
||||
kinds: legacy.kinds,
|
||||
});
|
||||
} finally {
|
||||
remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("adds a plugin's template to the New menu like a built-in", () => {
|
||||
const remove = addContributedFileTemplate({
|
||||
id: "plugin.acme.draw",
|
||||
|
||||
@@ -79,6 +79,22 @@ describe("localizeContributions", () => {
|
||||
expect(translatorOf({ a: fr }, "a")("toString")).toBe("toString");
|
||||
});
|
||||
|
||||
it("translates variable prompts without translating variable names or filenames", () => {
|
||||
const items = [
|
||||
item("a", "fileTemplates", {
|
||||
id: "repo",
|
||||
name: "Word Tools",
|
||||
fileName: "Refresh",
|
||||
variables: [{ name: "Refresh", prompt: "Cancel" }],
|
||||
}),
|
||||
];
|
||||
expect(localizeContributions(items, { a: fr })[0]!.decl).toMatchObject({
|
||||
name: "Outils de mots",
|
||||
fileName: "Refresh",
|
||||
variables: [{ name: "Refresh", prompt: "Annuler" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the declarations as they are without a catalog", () => {
|
||||
const items = [item("a", "commands", { id: "x", title: "Refresh" })];
|
||||
expect(localizeContributions(items, {})).toBe(items);
|
||||
|
||||
@@ -92,6 +92,7 @@ function localizeDecl(decl: unknown, keys: readonly string[], t: PluginTranslate
|
||||
for (const key of keys) if (typeof out[key] === "string") out[key] = t(out[key]);
|
||||
if (Array.isArray(out.properties))
|
||||
out.properties = out.properties.map((p) => localizeDecl(p, ["title", "description"], t));
|
||||
if (Array.isArray(out.variables)) out.variables = out.variables.map((v) => localizeDecl(v, ["prompt"], t));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
registerExternalIcons,
|
||||
type MenuItem,
|
||||
} from "@/ui";
|
||||
import { addContributedFileTemplate } from "@/features/templates/files/store";
|
||||
import { addContributedFileTemplate } from "@/workbench/contributions";
|
||||
import {
|
||||
registerEditorKind,
|
||||
registerFileEditorProvider,
|
||||
@@ -377,6 +377,8 @@ interface FileIconDecl {
|
||||
}
|
||||
|
||||
interface FileTemplateDecl {
|
||||
fileName?: string | null;
|
||||
variables?: { name: string; prompt: string }[];
|
||||
id: string;
|
||||
name: string;
|
||||
extension: string;
|
||||
@@ -384,8 +386,8 @@ interface FileTemplateDecl {
|
||||
}
|
||||
|
||||
/** A new file's icon: the plugin's own file icon for it, else the built-in one. */
|
||||
function templateIcon(pluginId: string, extension: string): string {
|
||||
const file = `untitled.${extension}`;
|
||||
function templateIcon(pluginId: string, extension: string, fileName?: string | null): string {
|
||||
const file = fileName ?? `untitled.${extension}`;
|
||||
const own = usePlugins
|
||||
.getState()
|
||||
.ui.find(
|
||||
@@ -580,9 +582,12 @@ export function defineViewUiPoints(): () => void {
|
||||
addContributedFileTemplate({
|
||||
id: `plugin.${pluginId}.${decl.id}`,
|
||||
name: decl.name,
|
||||
icon: templateIcon(pluginId, decl.extension),
|
||||
icon: templateIcon(pluginId, decl.extension, decl.fileName),
|
||||
extension: decl.extension,
|
||||
nameStyle: "asis",
|
||||
fileName: decl.fileName ?? undefined,
|
||||
variables: decl.variables,
|
||||
inferPackage: true,
|
||||
kinds: [{ id: "file", label: decl.name, body: decl.body }],
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -235,3 +235,36 @@ describe("reformat according to style", () => {
|
||||
expect(caretAfterReformat("a b|".replace("|", ""), 3, "a")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin template names and values", () => {
|
||||
it.each([".dockerignore", ".gitattributes", "Dockerfile", "Order.java"])("keeps exact filename %s", (fileName) => {
|
||||
expect(planFile({ ...tpl("java"), fileName }, "/p", "ignored")?.path).toBe(`/p/${fileName}`);
|
||||
});
|
||||
it.each([
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"../escape",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
"/absolute",
|
||||
"C:evil",
|
||||
"a?",
|
||||
" a",
|
||||
"a.",
|
||||
"NUL.txt",
|
||||
"com1",
|
||||
"a\n",
|
||||
])("rejects unsafe filename %j", (fileName) => {
|
||||
expect(planFile({ ...tpl("file"), fileName }, "/p", "")).toBeNull();
|
||||
});
|
||||
it("inserts variables literally and preserves UTF-16 caret offsets", () => {
|
||||
expect(
|
||||
renderFileTemplate("$ENTITY$ ${ENTITY} $NAME$ $END$x", {
|
||||
name: "Repo",
|
||||
fileName: "Repo.java",
|
||||
variables: { ENTITY: "😀$NAME$", NAME: "bad" },
|
||||
}),
|
||||
).toEqual({ text: "😀$NAME$ 😀$NAME$ Repo x", caret: "😀$NAME$ 😀$NAME$ Repo ".length });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { NewFileDialogHost, openNewFileDialog } from "../files/NewFileDialog";
|
||||
import { useI18n } from "@/platform/i18n";
|
||||
import { createFromTemplate } from "../files/create";
|
||||
import type { FileTemplate } from "../files/model";
|
||||
|
||||
vi.mock("../files/create", async (original) => ({
|
||||
...(await original<typeof import("../files/create")>()),
|
||||
packageFor: vi.fn(async () => "com.acme"),
|
||||
createFromTemplate: vi.fn(async (_t, _k, _d, _n, done) => {
|
||||
done?.();
|
||||
return "/p/file";
|
||||
}),
|
||||
}));
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
const template = (extra: Partial<FileTemplate> = {}): FileTemplate => ({
|
||||
id: "plugin.demo.repo",
|
||||
name: "Repository",
|
||||
icon: "file",
|
||||
extension: "java",
|
||||
nameStyle: "asis",
|
||||
kinds: [{ id: "file", label: "Repository", body: "$ENTITY$ $PACKAGE$ $END$" }],
|
||||
...extra,
|
||||
});
|
||||
|
||||
function open(t: FileTemplate) {
|
||||
render(<NewFileDialogHost />);
|
||||
act(() => openNewFileDialog({ directory: "/p", template: t }));
|
||||
}
|
||||
|
||||
describe("plugin file creation dialog", () => {
|
||||
it("creates a fixed dotfile without asking for a name", async () => {
|
||||
open(template({ fileName: ".dockerignore" }));
|
||||
expect(screen.queryByLabelText("Name")).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(createFromTemplate).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(createFromTemplate).mock.calls[0]![3]).toBe(".dockerignore");
|
||||
});
|
||||
|
||||
it("asks all variables together, validates them and creates only on submit", async () => {
|
||||
open(
|
||||
template({
|
||||
variables: [
|
||||
{ name: "ENTITY", prompt: "Entity class" },
|
||||
{ name: "TABLE", prompt: "Table" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "OrderRepository" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
expect(screen.getByText("Enter a value for each variable")).toBeInTheDocument();
|
||||
expect(createFromTemplate).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByLabelText("Entity class"), { target: { value: "Order" } });
|
||||
fireEvent.change(screen.getByLabelText("Table"), { target: { value: "orders" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
await waitFor(() => expect(createFromTemplate).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(createFromTemplate).mock.calls[0]![5]).toEqual({ ENTITY: "Order", TABLE: "orders" });
|
||||
});
|
||||
|
||||
it("cancels fixed-name variables without creating anything", () => {
|
||||
open(template({ fileName: ".gitattributes", variables: [{ name: "FILTER", prompt: "Filter" }] }));
|
||||
expect(screen.queryByLabelText("Name")).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Filter"), { target: { value: "lfs" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(createFromTemplate).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves plugin prompts when they collide with the IDE text catalog", () => {
|
||||
const texts = useI18n.getState().texts;
|
||||
useI18n.setState({ texts: { Cancel: "Annuler" } });
|
||||
try {
|
||||
open(template({ variables: [{ name: "FILTER", prompt: "Cancel" }] }));
|
||||
expect(screen.getByRole("textbox", { name: "Cancel" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Annuler" })).toBeInTheDocument();
|
||||
} finally {
|
||||
useI18n.setState({ texts });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ field: "Name", ime: { isComposing: true } },
|
||||
{ field: "Name", ime: { keyCode: 229 } },
|
||||
{ field: "Filter", ime: { isComposing: true } },
|
||||
{ field: "Filter", ime: { keyCode: 229 } },
|
||||
])("does not submit while confirming IME text (%j)", async ({ field, ime }) => {
|
||||
open(
|
||||
template(
|
||||
field === "Name" ? {} : { fileName: ".gitattributes", variables: [{ name: "FILTER", prompt: "Filter" }] },
|
||||
),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(field), { target: { value: "文本" } });
|
||||
fireEvent.keyDown(screen.getByLabelText(field), { key: "Enter", ...ime });
|
||||
expect(createFromTemplate).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
fireEvent.keyDown(screen.getByLabelText(field), { key: "Enter" });
|
||||
await waitFor(() => expect(createFromTemplate).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("does not create when the variables dialog closes with Escape", () => {
|
||||
open(template({ variables: [{ name: "ENTITY", prompt: "Entity class" }] }));
|
||||
fireEvent.keyDown(screen.getByLabelText("Entity class"), { key: "Escape" });
|
||||
expect(createFromTemplate).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { packageFor, createFromTemplate } from "../files/create";
|
||||
import { BUILTIN_FILE_TEMPLATES } from "../files/builtins";
|
||||
import type { FileTemplate } from "../files/model";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useProjectRoots } from "@/platform/projectRoots";
|
||||
import { commands } from "@/platform/ipc";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ read: vi.fn() }));
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
isTauri: () => true,
|
||||
call: (result: unknown) => result,
|
||||
commands: {
|
||||
fsReadDir: vi.fn(async () => [{ kind: "file", name: "sibling.go", path: "/p/sibling.go" }]),
|
||||
fsReadText: mocks.read,
|
||||
fsCreateFile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const plugin = (extension: string): FileTemplate => ({
|
||||
id: "plugin.demo.repo",
|
||||
name: "Repository",
|
||||
icon: "file",
|
||||
extension,
|
||||
nameStyle: "asis",
|
||||
inferPackage: true,
|
||||
kinds: [{ id: "file", label: "Repository", body: "$PACKAGE$" }],
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useProject.setState({ project: null });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("plugin package inference uses built-in rules", () => {
|
||||
it.each(["java", "kt"])("uses custom source roots for %s", async (extension) => {
|
||||
useProject.setState({ project: { root: "/p", name: "p" } as never });
|
||||
useProjectRoots.setState({ root: "/p", detected: [{ path: "generated", kind: "source", detectedFrom: null }] });
|
||||
const dir = "/p/generated/com/acme";
|
||||
expect(await packageFor(plugin(extension), dir)).toBe("com.acme");
|
||||
const builtin = BUILTIN_FILE_TEMPLATES.find((t) => t.extension === extension)!;
|
||||
expect(await packageFor(plugin(extension), dir)).toBe(await packageFor(builtin, dir));
|
||||
});
|
||||
it("infers language from an exact filename when extension is omitted", async () => {
|
||||
useProject.setState({ project: { root: "/p", name: "p" } as never });
|
||||
expect(await packageFor({ ...plugin(""), fileName: "Order.java" }, "/p/src/main/java/com/acme")).toBe("com.acme");
|
||||
});
|
||||
it("uses the actual fixed filename instead of a conflicting declared extension", async () => {
|
||||
useProject.setState({ project: { root: "/p", name: "p" } as never });
|
||||
expect(await packageFor({ ...plugin("txt"), fileName: "Order.java" }, "/p/src/main/java/com/acme")).toBe(
|
||||
"com.acme",
|
||||
);
|
||||
expect(await packageFor({ ...plugin("java"), fileName: ".dockerignore" }, "/p/src/main/java/com/acme")).toBe("");
|
||||
});
|
||||
it("uses an existing Go package", async () => {
|
||||
useProject.setState({ project: { root: "/p", name: "p" } as never });
|
||||
mocks.read.mockResolvedValue("package service\n");
|
||||
expect(await packageFor(plugin("go"), "/p/pkg/store")).toBe("service");
|
||||
});
|
||||
it("uses Composer PSR-4 namespaces for PHP", async () => {
|
||||
useProject.setState({ project: { root: "/p", name: "p" } as never });
|
||||
mocks.read.mockImplementation(async (path: string) => {
|
||||
if (path !== "/p/composer.json") throw new Error("missing");
|
||||
return JSON.stringify({ autoload: { "psr-4": { "App\\": "src/" } } });
|
||||
});
|
||||
expect(await packageFor(plugin("php"), "/p/src/Http")).toBe("App\\Http");
|
||||
});
|
||||
it("does not infer a package for ordinary file formats", async () => {
|
||||
expect(await packageFor(plugin("md"), "/p/src/main/java/com/acme")).toBe("");
|
||||
});
|
||||
it("checks cancellation after async package inference and before filesystem creation", async () => {
|
||||
await createFromTemplate(plugin("md"), plugin("md").kinds[0]!, "/p", "Readme", undefined, {}, () => true);
|
||||
expect(commands.fsCreateFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,21 @@
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.variableDialog {
|
||||
--form-inset: calc((var(--space-3) + var(--space-4)) / 2);
|
||||
}
|
||||
.variableDialog .form {
|
||||
padding-inline: var(--form-inset);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.variableDialog [data-dialog-footer] {
|
||||
padding-inline: var(--form-inset);
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.kinds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/** "New ▸ <template>" dialog: template chooser, then name + kind (IDEA's New Class dialog). */
|
||||
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { useEffect, useEffectEvent, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { create } from "zustand";
|
||||
import clsx from "clsx";
|
||||
import { relativePath, useProject } from "@/platform/project";
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { Dialog, Icon, Input, List, listNavigate } from "@/ui";
|
||||
import { createFromTemplate, packageFor } from "./create";
|
||||
import { createFromTemplate, packageFor, packageStyleFor } from "./create";
|
||||
import { planFile, withKind, type FileTemplate } from "./model";
|
||||
import { useFileTemplates } from "./store";
|
||||
import styles from "./NewFileDialog.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { tx, VerbatimTextContext } from "@/platform/i18n";
|
||||
|
||||
interface Request {
|
||||
directory: string;
|
||||
@@ -70,6 +71,7 @@ function TemplateStep({
|
||||
const [active, setActive] = useState(0);
|
||||
const root = useProject((s) => s.project?.root ?? "");
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (isImeKey(e.nativeEvent)) return;
|
||||
const next = listNavigate(e.key, { items: templates, active, wrap: true });
|
||||
if (next !== null) {
|
||||
e.preventDefault();
|
||||
@@ -117,7 +119,19 @@ function NameStep({
|
||||
initialName?: string;
|
||||
initialKind?: string;
|
||||
}) {
|
||||
const [name, setName] = useState(initialName ?? "");
|
||||
const [name, setName] = useState(template.fileName ?? initialName ?? "");
|
||||
const variables = template.variables ?? [];
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(variables.map((v) => [v.name, ""])),
|
||||
);
|
||||
const active = useRef(true);
|
||||
const submitting = useRef(false);
|
||||
useEffect(() => {
|
||||
active.current = true;
|
||||
return () => {
|
||||
active.current = false;
|
||||
};
|
||||
}, []);
|
||||
const [kind, setKind] = useState(() =>
|
||||
Math.max(
|
||||
0,
|
||||
@@ -129,30 +143,46 @@ function NameStep({
|
||||
const [pkg, setPkg] = useState("");
|
||||
const root = useProject((s) => s.project?.root ?? "");
|
||||
const effective = withKind(template, template.kinds[kind]);
|
||||
const plan = name.trim() ? planFile(effective, directory, name) : null;
|
||||
const plan = planFile(effective, directory, name);
|
||||
const packageStyle = packageStyleFor(effective);
|
||||
|
||||
useEffect(() => {
|
||||
if (!template.packageStyle) return;
|
||||
if (!packageStyle) return;
|
||||
let live = true;
|
||||
void packageFor(template, plan?.directory ?? directory).then((p) => live && setPkg(p));
|
||||
void packageFor(withKind(template, template.kinds[kind]), plan?.directory ?? directory).then(
|
||||
(p) => live && setPkg(p),
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [template, directory, plan?.directory]);
|
||||
}, [template, kind, directory, plan?.directory, packageStyle]);
|
||||
|
||||
const submit = async () => {
|
||||
if (busy) return;
|
||||
if (submitting.current) return;
|
||||
if (!plan) {
|
||||
setError(name.trim() ? "Invalid name" : "Enter a name");
|
||||
setError(tx(name.trim() ? "Invalid name" : "Enter a name"));
|
||||
return;
|
||||
}
|
||||
if (variables.some((v) => !values[v.name]?.trim())) {
|
||||
setError(tx("Enter a value for each variable"));
|
||||
return;
|
||||
}
|
||||
submitting.current = true;
|
||||
setBusy(true);
|
||||
let created = false;
|
||||
try {
|
||||
await createFromTemplate(template, template.kinds[kind] ?? template.kinds[0]!, directory, name, () => {
|
||||
created = true;
|
||||
close();
|
||||
});
|
||||
await createFromTemplate(
|
||||
template,
|
||||
template.kinds[kind] ?? template.kinds[0]!,
|
||||
directory,
|
||||
name,
|
||||
() => {
|
||||
created = true;
|
||||
if (active.current) close();
|
||||
},
|
||||
values,
|
||||
() => !active.current,
|
||||
);
|
||||
} catch (e) {
|
||||
if (created) {
|
||||
notifications().notify({
|
||||
@@ -163,11 +193,20 @@ function NameStep({
|
||||
return;
|
||||
}
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
submitting.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const autoCreate = useEffectEvent(submit);
|
||||
useEffect(() => {
|
||||
if (template.fileName === undefined || variables.length) return;
|
||||
const timer = setTimeout(() => void autoCreate(), 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [template.fileName, variables.length]);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (isImeKey(e.nativeEvent)) return;
|
||||
if (template.kinds.length > 1) {
|
||||
const next = listNavigate(e.key === "ArrowUp" || e.key === "ArrowDown" ? e.key : "", {
|
||||
items: template.kinds,
|
||||
@@ -195,20 +234,50 @@ function NameStep({
|
||||
width={380}
|
||||
position="top"
|
||||
aria-label={tx`New ${template.name}`}
|
||||
className={variables.length ? styles.variableDialog : undefined}
|
||||
buttons={
|
||||
variables.length
|
||||
? [
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
{ label: "Create", role: "default", onClick: () => void submit(), disabled: busy },
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<Input
|
||||
data-autofocus
|
||||
value={name}
|
||||
invalid={!!error}
|
||||
placeholder={effective.extension ? tx`Name (.${effective.extension})` : "File name with extension"}
|
||||
aria-label="Name"
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{template.fileName === undefined && (
|
||||
<Input
|
||||
disabled={busy}
|
||||
data-autofocus
|
||||
value={name}
|
||||
invalid={!!error}
|
||||
placeholder={effective.extension ? tx`Name (.${effective.extension})` : "File name with extension"}
|
||||
aria-label="Name"
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
)}
|
||||
{variables.map((v, i) => (
|
||||
<VerbatimTextContext.Provider key={v.name} value={true}>
|
||||
<label className={styles.field}>
|
||||
{v.prompt}
|
||||
<Input
|
||||
data-autofocus={template.fileName !== undefined && i === 0 ? true : undefined}
|
||||
aria-label={v.prompt}
|
||||
value={values[v.name] ?? ""}
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
setValues((s) => ({ ...s, [v.name]: e.target.value }));
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</label>
|
||||
</VerbatimTextContext.Provider>
|
||||
))}
|
||||
{template.kinds.length > 1 && (
|
||||
<div className={styles.kinds} role="listbox" aria-label={tx("Kind")}>
|
||||
{template.kinds.map((k, i) => (
|
||||
@@ -233,11 +302,7 @@ function NameStep({
|
||||
{error ?? (
|
||||
<>
|
||||
{where}
|
||||
{template.packageStyle && pkg
|
||||
? template.packageStyle === "php"
|
||||
? ` · namespace ${pkg}`
|
||||
: ` · package ${pkg}`
|
||||
: ""}
|
||||
{packageStyle && pkg ? (packageStyle === "php" ? ` · namespace ${pkg}` : ` · package ${pkg}`) : ""}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Creates a file from a template and fills it through the editor. */
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { notifyFileOperation } from "@/platform/fileOps";
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
type FileTemplate,
|
||||
type FileTemplateKind,
|
||||
} from "./model";
|
||||
import { BUILTIN_FILE_TEMPLATES } from "./builtins";
|
||||
import { templateIncludes } from "./store";
|
||||
|
||||
async function siblingGoPackage(directory: string): Promise<string | null> {
|
||||
@@ -66,11 +68,24 @@ async function composerNamespace(directory: string, root: string | null): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export function packageStyleFor(t: FileTemplate): FileTemplate["packageStyle"] {
|
||||
const dot = t.fileName?.lastIndexOf(".") ?? -1;
|
||||
const extension = t.fileName === undefined ? t.extension : dot > 0 ? t.fileName.slice(dot + 1) : "";
|
||||
return (
|
||||
t.packageStyle ??
|
||||
(t.inferPackage
|
||||
? BUILTIN_FILE_TEMPLATES.find((b) => b.packageStyle && b.extension.toLowerCase() === extension.toLowerCase())
|
||||
?.packageStyle
|
||||
: undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export async function packageFor(t: FileTemplate, directory: string): Promise<string> {
|
||||
const root = useProject.getState().project?.root ?? null;
|
||||
if (t.packageStyle === "jvm") return inferJvmPackage(directory, root, packageRoots());
|
||||
if (t.packageStyle === "go") return inferGoPackage(directory, root, await siblingGoPackage(directory));
|
||||
if (t.packageStyle === "php") return composerNamespace(directory, root);
|
||||
const style = packageStyleFor(t);
|
||||
if (style === "jvm") return inferJvmPackage(directory, root, packageRoots());
|
||||
if (style === "go") return inferGoPackage(directory, root, await siblingGoPackage(directory));
|
||||
if (style === "php") return composerNamespace(directory, root);
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -109,14 +124,17 @@ export async function createFromTemplate(
|
||||
input: string,
|
||||
/** Called once the file exists (e.g. to close the dialog before the editor takes focus). */
|
||||
onCreated?: () => void,
|
||||
variables?: Readonly<Record<string, string>>,
|
||||
cancelled?: () => boolean,
|
||||
): Promise<string> {
|
||||
const plan = planFile(withKind(t, kind), directory, input);
|
||||
if (!plan) throw new Error("Invalid file name");
|
||||
if (!plan) throw new Error(tx("Invalid file name"));
|
||||
const project = useProject.getState().project;
|
||||
const packageName = await packageFor(t, plan.directory);
|
||||
const packageName = await packageFor(withKind(t, kind), plan.directory);
|
||||
const { text, caret } = renderFileTemplate(
|
||||
kind.body,
|
||||
{
|
||||
variables,
|
||||
name: plan.name,
|
||||
fileName: plan.fileName,
|
||||
packageName,
|
||||
@@ -125,6 +143,7 @@ export async function createFromTemplate(
|
||||
},
|
||||
templateIncludes(),
|
||||
);
|
||||
if (cancelled?.()) return plan.path;
|
||||
await call(commands.fsCreateFile(plan.path));
|
||||
notifyFileOperation({ kind: "created", paths: [plan.path] });
|
||||
onCreated?.();
|
||||
|
||||
@@ -13,35 +13,8 @@
|
||||
import { formatDate, pascalCase, snakeCase, kebabCase } from "../live/model";
|
||||
import { BUILTIN_CODE_TEMPLATES, BUILTIN_INCLUDES } from "./builtins";
|
||||
|
||||
export type NameStyle = "asis" | "snake" | "pascal" | "kebab";
|
||||
|
||||
export interface FileTemplateKind {
|
||||
id: string;
|
||||
label: string;
|
||||
body: string;
|
||||
/** Overrides the template's extension (Stylesheet › CSS / SCSS / Less). */
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export interface FileTemplate {
|
||||
id: string;
|
||||
/** Menu label, e.g. "Rust File". */
|
||||
name: string;
|
||||
icon: string;
|
||||
/** Without the dot; empty for "File…" (the name carries the extension). */
|
||||
extension: string;
|
||||
nameStyle: NameStyle;
|
||||
/** Package inference: `jvm` (Java/Kotlin source roots) or `go` (directory). */
|
||||
packageStyle?: "jvm" | "go" | "php";
|
||||
/** Dotted names (`com.acme.Foo`) create package directories. */
|
||||
dottedPackages?: boolean;
|
||||
kinds: FileTemplateKind[];
|
||||
enabled?: boolean;
|
||||
/** Prefilled name for fixed-name files (`Dockerfile`, `.editorconfig`). */
|
||||
defaultName?: string;
|
||||
/** Reformat according to style: run Reformat Code on the new file (unset: on, as IDEA). */
|
||||
reformat?: boolean;
|
||||
}
|
||||
import type { FileTemplate, FileTemplateKind, NameStyle } from "@/platform/fileTemplates";
|
||||
export type { FileTemplate, FileTemplateKind, NameStyle } from "@/platform/fileTemplates";
|
||||
|
||||
/** A fragment for `#parse` (Includes tab: "File Header") or a Code tab template. */
|
||||
export interface TemplateFragment {
|
||||
@@ -133,10 +106,27 @@ export interface PlannedFile {
|
||||
* template's naming style.
|
||||
*/
|
||||
export function planFile(
|
||||
t: Pick<FileTemplate, "extension" | "nameStyle" | "dottedPackages">,
|
||||
t: Pick<FileTemplate, "extension" | "nameStyle" | "dottedPackages" | "fileName">,
|
||||
directory: string,
|
||||
input: string,
|
||||
): PlannedFile | null {
|
||||
if (t.fileName !== undefined) {
|
||||
const fileName = t.fileName;
|
||||
const stem = fileName.split(".")[0]!.trimEnd();
|
||||
if (
|
||||
!fileName ||
|
||||
new TextEncoder().encode(fileName).length > 255 ||
|
||||
fileName.trim() !== fileName ||
|
||||
fileName.endsWith(".") ||
|
||||
/[\\/<>:"|?*]/.test(fileName) ||
|
||||
Array.from(fileName).some((c) => c.charCodeAt(0) < 32 || (c.charCodeAt(0) >= 127 && c.charCodeAt(0) <= 159)) ||
|
||||
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(stem)
|
||||
)
|
||||
return null;
|
||||
const dir = directory.replace(/\/+$/, "");
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
return { path: `${dir}/${fileName}`, directory: dir, name: dot > 0 ? fileName.slice(0, dot) : fileName, fileName };
|
||||
}
|
||||
let raw = input
|
||||
.trim()
|
||||
.replace(/\\/g, "/")
|
||||
@@ -271,6 +261,7 @@ export interface FileTemplateVars {
|
||||
projectName?: string;
|
||||
user?: string;
|
||||
now?: Date;
|
||||
variables?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/** Renders a body; returns the text and the caret offset (`$END$`, else the end). */
|
||||
@@ -281,6 +272,7 @@ export function renderFileTemplate(
|
||||
): { text: string; caret: number } {
|
||||
const now = vars.now ?? new Date();
|
||||
const values: Record<string, string> = {
|
||||
...vars.variables,
|
||||
NAME: vars.name,
|
||||
FILE_NAME: vars.fileName,
|
||||
PACKAGE: vars.packageName ?? "",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* is a copy for the open project (keyed by its root), seeded from Default on
|
||||
* first use.
|
||||
*/
|
||||
import { useContributedFileTemplates } from "@/workbench/contributions";
|
||||
export { addContributedFileTemplate } from "@/workbench/contributions";
|
||||
import { create } from "zustand";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { loadJson, saveJson } from "../storage";
|
||||
@@ -29,32 +31,23 @@ interface Customizations {
|
||||
const EMPTY: Customizations = { templates: {}, deleted: [], includes: {}, deletedIncludes: [], code: {} };
|
||||
|
||||
/** Templates plugins contribute (`fileTemplates`): treated like built-ins (customised, reset, hidden). */
|
||||
let contributed: readonly FileTemplate[] = [];
|
||||
const contributed = () => useContributedFileTemplates.getState().templates;
|
||||
|
||||
const isBase = (id: string) => BUILTIN_IDS.has(id) || contributed.some((t) => t.id === id);
|
||||
const isBase = (id: string) => BUILTIN_IDS.has(id) || contributed().some((t) => t.id === id);
|
||||
|
||||
export function mergeFileTemplates(c: Pick<Customizations, "templates" | "deleted">): FileTemplate[] {
|
||||
const deleted = new Set(c.deleted);
|
||||
const base = [...BUILTIN_FILE_TEMPLATES, ...contributed];
|
||||
const out = base.filter((t) => !deleted.has(t.id)).map((t) => c.templates[t.id] ?? t);
|
||||
const base = [...BUILTIN_FILE_TEMPLATES, ...contributed()];
|
||||
const out = base
|
||||
.filter((t) => !deleted.has(t.id))
|
||||
.map((t) => {
|
||||
const custom = c.templates[t.id];
|
||||
return custom ? { ...t, ...custom } : t;
|
||||
});
|
||||
for (const t of Object.values(c.templates)) if (!isBase(t.id) && !deleted.has(t.id)) out.push(t);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Adds a plugin's template to the New menu until the returned function is called. */
|
||||
export function addContributedFileTemplate(t: FileTemplate): () => void {
|
||||
contributed = [...contributed.filter((x) => x.id !== t.id), t];
|
||||
refreshMerged();
|
||||
return () => {
|
||||
contributed = contributed.filter((x) => x !== t);
|
||||
refreshMerged();
|
||||
};
|
||||
}
|
||||
|
||||
function refreshMerged() {
|
||||
useFileTemplates.setState((s) => ({ templates: mergeFileTemplates(s.custom) }));
|
||||
}
|
||||
|
||||
export function mergeIncludes(c: Pick<Customizations, "includes" | "deletedIncludes">): TemplateFragment[] {
|
||||
const deleted = new Set(c.deletedIncludes);
|
||||
const out = BUILTIN_INCLUDES.filter((f) => !deleted.has(f.name)).map((f) => c.includes[f.name] ?? f);
|
||||
@@ -103,6 +96,9 @@ function snapshot(root: string | null): FileTemplatesState {
|
||||
}
|
||||
|
||||
export const useFileTemplates = create<FileTemplatesState>(() => snapshot(projectRoot()));
|
||||
useContributedFileTemplates.subscribe(() => {
|
||||
useFileTemplates.setState((s) => ({ templates: mergeFileTemplates(s.custom) }));
|
||||
});
|
||||
|
||||
function commit(custom: Customizations) {
|
||||
const { scheme, root } = useFileTemplates.getState();
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export type NameStyle = "asis" | "snake" | "pascal" | "kebab";
|
||||
|
||||
export interface FileTemplateKind {
|
||||
id: string;
|
||||
label: string;
|
||||
body: string;
|
||||
/** Overrides the template's extension (Stylesheet › CSS / SCSS / Less). */
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export interface FileTemplate {
|
||||
id: string;
|
||||
/** Menu label, e.g. "Rust File". */
|
||||
name: string;
|
||||
icon: string;
|
||||
/** Without the dot; empty for "File…" (the name carries the extension). */
|
||||
extension: string;
|
||||
nameStyle: NameStyle;
|
||||
/** Package inference: `jvm` (Java/Kotlin source roots) or `go` (directory). */
|
||||
packageStyle?: "jvm" | "go" | "php";
|
||||
/** Dotted names (`com.acme.Foo`) create package directories. */
|
||||
dottedPackages?: boolean;
|
||||
kinds: FileTemplateKind[];
|
||||
enabled?: boolean;
|
||||
/** Prefilled name for fixed-name files (`Dockerfile`, `.editorconfig`). */
|
||||
defaultName?: string;
|
||||
/** Exact filename supplied by a plugin; skips the name prompt. */
|
||||
fileName?: string;
|
||||
variables?: { name: string; prompt: string }[];
|
||||
/** Infer package style from the extension using the built-in rules. */
|
||||
inferPackage?: boolean;
|
||||
/** Reformat according to style: run Reformat Code on the new file (unset: on, as IDEA). */
|
||||
reformat?: boolean;
|
||||
}
|
||||
@@ -7,6 +7,11 @@
|
||||
"Run `{command}` in a terminal.": "Run `{command}` in a terminal.",
|
||||
"Run `{command}` in a terminal and sign in.": "Run `{command}` in a terminal and sign in.",
|
||||
"{name} needs you to sign in. {hint}": "{name} needs you to sign in. {hint}",
|
||||
"Create": "Create",
|
||||
"Enter a name": "Enter a name",
|
||||
"Invalid name": "Invalid name",
|
||||
"Invalid file name": "Invalid file name",
|
||||
"Enter a value for each variable": "Enter a value for each variable",
|
||||
"OpenIDEA or JetBrains settings": "OpenIDEA or JetBrains settings",
|
||||
"Import JetBrains Settings": "Import JetBrains Settings",
|
||||
"These settings will be imported:": "These settings will be imported:",
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
"Run `{command}` in a terminal.": "在终端中运行 `{command}`。",
|
||||
"Run `{command}` in a terminal and sign in.": "在终端中运行 `{command}` 并登录。",
|
||||
"{name} needs you to sign in. {hint}": "{name} 需要你登录。{hint}",
|
||||
"Invalid name": "无效的名称",
|
||||
"Invalid file name": "无效的文件名",
|
||||
"Enter a value for each variable": "请为每个变量输入值",
|
||||
"Cannot copy reference": "无法复制引用",
|
||||
"Group actions": "分组操作",
|
||||
"New Project Group": "新建项目组",
|
||||
|
||||
@@ -1663,3 +1663,13 @@ export function registerCloneSource(source: CloneSourceContribution): () => void
|
||||
export function cloneSourceList(sources = useCloneSources.getState().sources): CloneSourceContribution[] {
|
||||
return [...sources.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
// File templates contributed by plugins, consumed by the templates feature.
|
||||
export const useContributedFileTemplates = create<{ templates: import("@/platform/fileTemplates").FileTemplate[] }>(
|
||||
() => ({ templates: [] }),
|
||||
);
|
||||
|
||||
export function addContributedFileTemplate(t: import("@/platform/fileTemplates").FileTemplate): () => void {
|
||||
useContributedFileTemplates.setState((s) => ({ templates: [...s.templates.filter((x) => x.id !== t.id), t] }));
|
||||
return () => useContributedFileTemplates.setState((s) => ({ templates: s.templates.filter((x) => x !== t) }));
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача