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]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-08 02:37:26 +08:00
1 джерело 9e6cb7b93e
коміт f6531542de
30 files changed
+1205 -92

No files matched your search

+63 -2
Переглянути файл
@@ -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)
+75
Переглянути файл
@@ -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"
+20
Переглянути файл
@@ -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"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
93f7501bb780898346fbf0614af254949dd17dd7c705581befc5e485422e1866
+38
Переглянути файл
@@ -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"
+15
Переглянути файл
@@ -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);
+1
Переглянути файл
@@ -0,0 +1 @@
$PATTERN$ filter=$FILTER$
+2
Переглянути файл
@@ -0,0 +1,2 @@
node_modules/
dist/
+5
Переглянути файл
@@ -0,0 +1,5 @@
package $PACKAGE$
class $NAME$ {
$END$
}
+7
Переглянути файл
@@ -0,0 +1,7 @@
package $PACKAGE$;
// Table: $TABLE$
public interface $NAME$ {
$ENTITY$ find();
$END$
}
+70
Переглянути файл
@@ -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" });
});
+33 -1
Переглянути файл
@@ -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
+76 -1
Переглянути файл
@@ -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",
+16
Переглянути файл
@@ -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);
+1
Переглянути файл
@@ -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;
}
+9 -4
Переглянути файл
@@ -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 }],
}),
}),
+33
Переглянути файл
@@ -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 });
});
});
+108
Переглянути файл
@@ -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();
});
});
+76
Переглянути файл
@@ -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();
});
});
+15
Переглянути файл
@@ -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;
+96 -31
Переглянути файл
@@ -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>
+24 -5
Переглянути файл
@@ -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?.();
+22 -30
Переглянути файл
@@ -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 ?? "",
+14 -18
Переглянути файл
@@ -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();
+34
Переглянути файл
@@ -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;
}
+5
Переглянути файл
@@ -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:",
+3
Переглянути файл
@@ -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": "新建项目组",
+10
Переглянути файл
@@ -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) }));
}