diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index 1b74aedec..a38dc44d8 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -559,11 +559,21 @@ struct FileTemplateDecl { id: String, name: String, /// Without the dot. + #[serde(default)] extension: String, + file_name: Option, + variables: Option>, /// 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 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 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) diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 7d6157ae5..d2b7393db 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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() { diff --git a/crates/ide-plugin/tests/fixtures/file-templates/Cargo.lock b/crates/ide-plugin/tests/fixtures/file-templates/Cargo.lock new file mode 100644 index 000000000..abf437ba4 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/file-templates/Cargo.lock @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/file-templates/Cargo.toml b/crates/ide-plugin/tests/fixtures/file-templates/Cargo.toml new file mode 100644 index 000000000..016a8771a --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/file-templates/Cargo.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm new file mode 100644 index 000000000..22d911225 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources new file mode 100644 index 000000000..262bf91db --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources @@ -0,0 +1 @@ +93f7501bb780898346fbf0614af254949dd17dd7c705581befc5e485422e1866 diff --git a/crates/ide-plugin/tests/fixtures/file-templates/plugin.toml b/crates/ide-plugin/tests/fixtures/file-templates/plugin.toml new file mode 100644 index 000000000..50d976b4a --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/file-templates/plugin.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/file-templates/src/lib.rs b/crates/ide-plugin/tests/fixtures/file-templates/src/lib.rs new file mode 100644 index 000000000..ee5973104 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/file-templates/src/lib.rs @@ -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, Error> { + match id { + "ping" => Ok(Some("File templates ready".into())), + _ => Err(Error::NotFound(id.into())), + } + } +} + +register_plugin!(FileTemplates); diff --git a/crates/ide-plugin/tests/fixtures/file-templates/templates/attributes.txt b/crates/ide-plugin/tests/fixtures/file-templates/templates/attributes.txt new file mode 100644 index 000000000..cb812b41c --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/file-templates/templates/attributes.txt @@ -0,0 +1 @@ +$PATTERN$ filter=$FILTER$ diff --git a/crates/ide-plugin/tests/fixtures/file-templates/templates/dockerignore.txt b/crates/ide-plugin/tests/fixtures/file-templates/templates/dockerignore.txt new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/file-templates/templates/dockerignore.txt @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/crates/ide-plugin/tests/fixtures/file-templates/templates/kotlin.txt b/crates/ide-plugin/tests/fixtures/file-templates/templates/kotlin.txt new file mode 100644 index 000000000..556e7b696 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/file-templates/templates/kotlin.txt @@ -0,0 +1,5 @@ +package $PACKAGE$ + +class $NAME$ { + $END$ +} diff --git a/crates/ide-plugin/tests/fixtures/file-templates/templates/repository.txt b/crates/ide-plugin/tests/fixtures/file-templates/templates/repository.txt new file mode 100644 index 000000000..23291aa52 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/file-templates/templates/repository.txt @@ -0,0 +1,7 @@ +package $PACKAGE$; + +// Table: $TABLE$ +public interface $NAME$ { + $ENTITY$ find(); + $END$ +} diff --git a/e2e/pluginFileTemplates.spec.ts b/e2e/pluginFileTemplates.spec.ts new file mode 100644 index 000000000..8eeba00c4 --- /dev/null +++ b/e2e/pluginFileTemplates.spec.ts @@ -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" }); +}); diff --git a/plugins/README.md b/plugins/README.md index dcb3bb618..c351f66f9 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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 diff --git a/src/features/plugins/__tests__/webViews.test.tsx b/src/features/plugins/__tests__/webViews.test.tsx index acb477b3a..9ee8f0b93 100644 --- a/src/features/plugins/__tests__/webViews.test.tsx +++ b/src/features/plugins/__tests__/webViews.test.tsx @@ -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", diff --git a/src/features/plugins/pluginText.test.ts b/src/features/plugins/pluginText.test.ts index 1436b9065..2d5c3fdc1 100644 --- a/src/features/plugins/pluginText.test.ts +++ b/src/features/plugins/pluginText.test.ts @@ -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); diff --git a/src/features/plugins/pluginText.ts b/src/features/plugins/pluginText.ts index eb3b9a591..e24332630 100644 --- a/src/features/plugins/pluginText.ts +++ b/src/features/plugins/pluginText.ts @@ -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; } diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index b8331a926..a01af14cb 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -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 }], }), }), diff --git a/src/features/templates/__tests__/files.test.ts b/src/features/templates/__tests__/files.test.ts index f9ba9e538..814b72df9 100644 --- a/src/features/templates/__tests__/files.test.ts +++ b/src/features/templates/__tests__/files.test.ts @@ -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 }); + }); +}); diff --git a/src/features/templates/__tests__/pluginFiles.test.tsx b/src/features/templates/__tests__/pluginFiles.test.tsx new file mode 100644 index 000000000..36a7765a5 --- /dev/null +++ b/src/features/templates/__tests__/pluginFiles.test.tsx @@ -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()), + 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 => ({ + 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(); + 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(); + }); +}); diff --git a/src/features/templates/__tests__/pluginPackages.test.ts b/src/features/templates/__tests__/pluginPackages.test.ts new file mode 100644 index 000000000..85d351059 --- /dev/null +++ b/src/features/templates/__tests__/pluginPackages.test.ts @@ -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()), + 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(); + }); +}); diff --git a/src/features/templates/files/NewFileDialog.module.css b/src/features/templates/files/NewFileDialog.module.css index 92dca6e4e..1a48b4e1f 100644 --- a/src/features/templates/files/NewFileDialog.module.css +++ b/src/features/templates/files/NewFileDialog.module.css @@ -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; diff --git a/src/features/templates/files/NewFileDialog.tsx b/src/features/templates/files/NewFileDialog.tsx index f00efbacb..9a659bb31 100644 --- a/src/features/templates/files/NewFileDialog.tsx +++ b/src/features/templates/files/NewFileDialog.tsx @@ -1,15 +1,16 @@ /** "New ▸