From f6531542de31e83c0243b7592f8888d3c9488bc3 Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:37:26 +0100 Subject: [PATCH] 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 <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/src/plugins/ui_points.rs | 65 +++- crates/ide-host/tests/plugins.rs | 75 ++++ .../tests/fixtures/file-templates/Cargo.lock | 333 ++++++++++++++++++ .../tests/fixtures/file-templates/Cargo.toml | 20 ++ .../fixtures/file-templates/dist/plugin.wasm | Bin 0 -> 68044 bytes .../file-templates/dist/plugin.wasm.sources | 1 + .../tests/fixtures/file-templates/plugin.toml | 38 ++ .../tests/fixtures/file-templates/src/lib.rs | 15 + .../file-templates/templates/attributes.txt | 1 + .../file-templates/templates/dockerignore.txt | 2 + .../file-templates/templates/kotlin.txt | 5 + .../file-templates/templates/repository.txt | 7 + e2e/pluginFileTemplates.spec.ts | 70 ++++ plugins/README.md | 34 +- .../plugins/__tests__/webViews.test.tsx | 77 +++- src/features/plugins/pluginText.test.ts | 16 + src/features/plugins/pluginText.ts | 1 + src/features/plugins/uiPoints.tsx | 13 +- .../templates/__tests__/files.test.ts | 33 ++ .../templates/__tests__/pluginFiles.test.tsx | 108 ++++++ .../__tests__/pluginPackages.test.ts | 76 ++++ .../templates/files/NewFileDialog.module.css | 15 + .../templates/files/NewFileDialog.tsx | 127 +++++-- src/features/templates/files/create.ts | 29 +- src/features/templates/files/model.ts | 52 ++- src/features/templates/files/store.ts | 32 +- src/platform/fileTemplates.ts | 34 ++ src/platform/locales/text/en.json | 5 + src/platform/locales/text/zh.json | 3 + src/workbench/contributions.ts | 10 + 30 files changed, 1205 insertions(+), 92 deletions(-) create mode 100644 crates/ide-plugin/tests/fixtures/file-templates/Cargo.lock create mode 100644 crates/ide-plugin/tests/fixtures/file-templates/Cargo.toml create mode 100644 crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm create mode 100644 crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources create mode 100644 crates/ide-plugin/tests/fixtures/file-templates/plugin.toml create mode 100644 crates/ide-plugin/tests/fixtures/file-templates/src/lib.rs create mode 100644 crates/ide-plugin/tests/fixtures/file-templates/templates/attributes.txt create mode 100644 crates/ide-plugin/tests/fixtures/file-templates/templates/dockerignore.txt create mode 100644 crates/ide-plugin/tests/fixtures/file-templates/templates/kotlin.txt create mode 100644 crates/ide-plugin/tests/fixtures/file-templates/templates/repository.txt create mode 100644 e2e/pluginFileTemplates.spec.ts create mode 100644 src/features/templates/__tests__/pluginFiles.test.tsx create mode 100644 src/features/templates/__tests__/pluginPackages.test.ts create mode 100644 src/platform/fileTemplates.ts 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 0000000000000000000000000000000000000000..22d911225a4cfc0553a962c3c25ed75eb4292a21 GIT binary patch literal 68044 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 -> 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 ▸