From adc308eb9993ed59a19d5d38487e0d344322877f Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:40:50 +0100 Subject: [PATCH] feat(plugins): let a plugin ship translations and read the UI language (#1781) A package's locales/.json (English source text -> translation) is applied to the plugin's own manifest titles and labels and to its view data; new ui.locale() (API 1.3) returns the UI language for text the plugin builds itself. Catalogs only ever apply to their own plugin; text without an entry stays English. Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-extension/src/lib.rs | 2 +- crates/ide-extension/src/registry.rs | 19 +++ crates/ide-host/src/plugins/api.rs | 7 + crates/ide-host/src/plugins/mod.rs | 9 + crates/ide-host/src/plugins/points.rs | 1 + crates/ide-host/src/plugins/remote_host.rs | 12 +- crates/ide-host/src/plugins/ui_points.rs | 94 ++++++++++- crates/ide-host/src/trust.rs | 1 + crates/ide-host/tests/plugins.rs | 40 +++++ crates/ide-host/tests/remote_plugins.rs | 3 + crates/ide-plugin/src/runtime.rs | 2 + crates/ide-plugin/src/wit_host.rs | 4 + .../fixtures/commit/dist/plugin.wasm.sources | 2 +- .../fixtures/hello/dist/plugin.wasm.sources | 2 +- .../fixtures/hover/dist/plugin.wasm.sources | 2 +- .../fixtures/indexer/dist/plugin.wasm.sources | 2 +- .../inlay-hints/dist/plugin.wasm.sources | 2 +- .../fixtures/jobs/dist/plugin.wasm.sources | 2 +- .../misbehave/dist/plugin.wasm.sources | 2 +- .../semantic-tokens/dist/plugin.wasm.sources | 2 +- .../fixtures/storage/dist/plugin.wasm.sources | 2 +- plugins/README.md | 25 +++ .../ai-completion/dist/plugin.wasm.sources | 2 +- .../checkstyle/dist/plugin.wasm.sources | 2 +- .../csv-editor/dist/plugin.wasm.sources | 2 +- .../git-toolbox/dist/plugin.wasm.sources | 2 +- .../graphviz/dist/plugin.wasm.sources | 2 +- .../key-promoter/dist/plugin.wasm.sources | 2 +- .../license-header/dist/plugin.wasm.sources | 2 +- .../official/mybatis/dist/plugin.wasm.sources | 2 +- .../regex-tester/dist/plugin.wasm.sources | 2 +- .../solidity/dist/plugin.wasm.sources | 2 +- .../string-tools/dist/plugin.wasm.sources | 2 +- sdk/rust/openidea-plugin/src/host.rs | 13 ++ sdk/rust/openidea-plugin/src/mock.rs | 13 ++ sdk/rust/openidea-plugin/wit/host.wit | 5 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/plugin.rs | 8 + src/bindings.ts | 2 + src/features/app/locale.ts | 8 +- src/features/plugins/PluginsPage.tsx | 8 +- src/features/plugins/bridge.ts | 19 ++- src/features/plugins/pluginText.test.ts | 118 ++++++++++++++ src/features/plugins/pluginText.ts | 154 ++++++++++++++++++ src/features/plugins/runtimeUi.tsx | 12 +- src/features/plugins/uiPoints.tsx | 4 +- src/features/plugins/views/PluginView.tsx | 5 +- src/platform/locales/text/fr.json | 1 + src/platform/locales/text/zh.json | 1 + wit/host.wit | 5 + 50 files changed, 596 insertions(+), 40 deletions(-) create mode 100644 src/features/plugins/pluginText.test.ts create mode 100644 src/features/plugins/pluginText.ts diff --git a/crates/ide-extension/src/lib.rs b/crates/ide-extension/src/lib.rs index fdf63d95..7ff7738d 100644 --- a/crates/ide-extension/src/lib.rs +++ b/crates/ide-extension/src/lib.rs @@ -31,6 +31,6 @@ pub use manifest::{ Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id, }; pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode}; -pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet}; +pub use registry::{ContributionInfo, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT, PreparedSet}; pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json}; pub use when::validate_when; diff --git a/crates/ide-extension/src/registry.rs b/crates/ide-extension/src/registry.rs index e8630fd8..59abfb9b 100644 --- a/crates/ide-extension/src/registry.rs +++ b/crates/ide-extension/src/registry.rs @@ -9,6 +9,11 @@ use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Arc; +/// The point behind a package's `locales/.json` catalogs: it has no +/// `[[contributes.locales]]` table, the directory declares it. +pub const LOCALES_POINT: &str = "locales"; +pub const LOCALES_DIR: &str = "locales"; + /// A contribution as the plugin details page lists it. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -127,6 +132,20 @@ impl ExtensionRegistry { set.items.push((point.id(), prepared)); } } + if let Some(point) = self.point(LOCALES_POINT) + && !pkg.manifest.contributes.contains_key(LOCALES_POINT) + && pkg.dir.join(LOCALES_DIR).is_dir() + { + let prepared = point + .prepare(pkg, toml::Value::Table(Default::default())) + .map_err(|e| format!("{LOCALES_DIR}: {}", strip_prefix(&e, LOCALES_POINT)))?; + for f in &prepared.files { + if !set.files.contains(f) { + set.files.push(f.clone()); + } + } + set.items.push((point.id(), prepared)); + } Ok(set) } diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 62e43dde..2a20f956 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -196,6 +196,8 @@ pub(super) struct Shared { pub pins: Arc>>, /// Validated UI declarations (settings schemas, declared views). pub ui: Arc, + /// The UI language the windows report (BCP 47). + pub locale: RwLock, } impl Shared { @@ -207,6 +209,7 @@ impl Shared { ) -> Arc { Arc::new(Self { ui, + locale: RwLock::new("en".into()), sink, workspace: RwLock::new(None), secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })), @@ -1238,6 +1241,10 @@ impl HostApi for PluginApi { Ok(()) } + fn locale(&self) -> String { + self.shared.locale.read().clone() + } + fn setting(&self, root: Option<&Path>, key: &str) -> Option { let schema: Vec = self .shared diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 8d66bf1e..380f3e4c 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -700,6 +700,15 @@ impl PluginHost { Ok(self.ui.assets_with_prefix(id, prefix).into_iter().map(|(k, v)| (k, v.to_string())).collect()) } + /// The UI language windows run in, as `locale()` reports it to plugin code. + pub fn set_ui_locale(&self, tag: &str) { + *self.shared.locale.write() = tag.to_owned(); + } + + pub fn ui_locale(&self) -> String { + self.shared.locale.read().clone() + } + pub fn set_enabled(&self, id: &str, enabled: bool) -> IdeResult<()> { let _guard = self.lifecycle.lock(); let plugin = self.get(id)?; diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs index 53bbe560..ba8b94f3 100644 --- a/crates/ide-host/src/plugins/points.rs +++ b/crates/ide-host/src/plugins/points.rs @@ -69,5 +69,6 @@ pub fn register_builtin_points( registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?; registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?; registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?; + registry.register(Arc::new(UiPoint::new(ide_extension::LOCALES_POINT, ui.clone(), p::locales)))?; Ok(()) } diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index be67dd51..b003ab92 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -14,8 +14,8 @@ //! The commands a window needs at run time (`plugin_run_command`, //! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`, //! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`, -//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`, `plugin_service_detail`, -//! `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`) +//! `plugin_line_markers`, `plugin_set_ui_locale`, `plugin_view_render`, `plugin_view_event`, +//! `plugin_service_detail`, `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`) //! carry the desktop commands' names and arguments, so a remote window's UI //! routes them here unchanged. Plugin results come back as `plugin` //! notifications ([`PluginEventMessage`]); secrets stay in the desktop's @@ -384,6 +384,14 @@ impl RemotePlugins { } }); let h = host.clone(); + d.add("plugin_set_ui_locale", move |c| { + let h = h.clone(); + async move { + alive(&h)?.set_ui_locale(&c.arg::("tag")?); + Ok(()) + } + }); + let h = host.clone(); d.add("plugin_post_events", move |c| { let h = h.clone(); async move { diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index 30609e73..0ff0d7c8 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -53,11 +53,11 @@ //! path = "templates/drawing.excalidraw" //! ``` -use ide_extension::{Package, UiDecl, decode, validate_item_id, validate_when}; +use ide_extension::{LOCALES_DIR, Package, UiDecl, decode, validate_item_id, validate_when}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::BTreeSet; -use std::path::PathBuf; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; /// A view whose tree shows in the Services tool window; the plugin's /// `detail-provider` renders the selected node's pane. @@ -588,6 +588,57 @@ pub(super) fn commit_check(pkg: &Package, decl: toml::Value) -> Result String { + format!("locale/{tag}") +} + +fn locale_tag_ok(tag: &str) -> bool { + let mut parts = tag.split('-'); + let language = parts.next().unwrap_or_default(); + (2..=3).contains(&language.len()) + && language.chars().all(|c| c.is_ascii_alphabetic()) + && parts.all(|p| (1..=8).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphanumeric())) +} + +/// `locales/.json`: English source text → translation, for the plugin's own strings. +pub(super) fn locales(pkg: &Package, _decl: toml::Value) -> Result { + let dir = Path::new(LOCALES_DIR); + let mut names: Vec = std::fs::read_dir(pkg.dir.join(dir)) + .map_err(|e| format!("{LOCALES_DIR}: {e}"))? + .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) + .filter(|name| name.ends_with(".json")) + .collect(); + names.sort(); + let mut tags = Vec::new(); + let mut files = Vec::new(); + let mut assets = Vec::new(); + for name in names { + let tag = name.trim_end_matches(".json"); + if !locale_tag_ok(tag) { + return Err(format!("{LOCALES_DIR}/{name}: the file name must be a language tag such as fr or zh-CN")); + } + let file = dir.join(&name); + let text = pkg.read_text(&file)?; + let catalog: BTreeMap = serde_json::from_str(&text) + .map_err(|e| format!("{}: not a JSON object of strings ({e})", file.display()))?; + if catalog.len() > MAX_CATALOG_ENTRIES { + return Err(format!("{}: more than {MAX_CATALOG_ENTRIES} entries", file.display())); + } + if let Some((key, _)) = catalog.iter().find(|(_, v)| !v.is_string()) { + return Err(format!("{}: the value of {key:?} is not a string", file.display())); + } + assets.push((locale_asset(tag), text)); + tags.push(tag.to_owned()); + files.push(file); + } + let mut out = UiDecl::new("locales", tags.join(", "), serde_json::json!({ "tags": tags })); + out.files = files; + out.assets = assets; + Ok(out) +} + #[cfg(test)] mod tests { use super::*; @@ -619,4 +670,41 @@ mod tests { assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "string", "default": 3}}}"#).is_err()); assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err()); } + + fn package_with_catalogs(files: &[(&str, &str)]) -> (tempfile::TempDir, Package) { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(LOCALES_DIR)).unwrap(); + for (name, text) in files { + std::fs::write(dir.path().join(LOCALES_DIR).join(name), text).unwrap(); + } + let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\""; + let pkg = Package::new(ide_extension::Manifest::parse(src).unwrap(), dir.path().to_path_buf()); + (dir, pkg) + } + + #[test] + fn catalogs_become_assets_by_language_tag() { + let (_dir, pkg) = package_with_catalogs(&[ + ("fr.json", r#"{"Refresh": "Actualiser"}"#), + ("zh-CN.json", r#"{"Refresh": "刷新"}"#), + ("notes.txt", "ignored"), + ]); + let decl = locales(&pkg, toml::Value::Table(Default::default())).unwrap(); + assert_eq!(decl.json["tags"], serde_json::json!(["fr", "zh-CN"])); + assert_eq!(decl.assets.iter().map(|(id, _)| id.as_str()).collect::>(), ["locale/fr", "locale/zh-CN"]); + } + + #[test] + fn invalid_catalogs_are_refused() { + for (name, text) in [ + ("fr.json", "{"), + ("fr.json", r#"["Refresh"]"#), + ("fr.json", r#"{"Refresh": 3}"#), + ("fr.json", r#"{"Refresh": {"a": "b"}}"#), + ("french!.json", "{}"), + ] { + let (_dir, pkg) = package_with_catalogs(&[(name, text)]); + assert!(locales(&pkg, toml::Value::Table(Default::default())).is_err(), "{name}: {text}"); + } + } } diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 759eddb8..2144c9d1 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -300,6 +300,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "plugin_set_theme_frame" | "plugin_catalog" | "plugin_assets" + | "plugin_set_ui_locale" | "plugin_market" | "plugin_market_url" | "plugin_set_market_url" diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 9198ac65..103e53ab 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -2419,6 +2419,46 @@ web = "panel/index.html" assert!(assets[0].1.contains(", ) -> Result<(), HostError>; fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>; + /// The IDE's UI language as a BCP 47 tag. + fn locale(&self) -> String; /// A setting of the plugin as JSON (project over global over the schema default). fn setting(&self, root: Option<&Path>, key: &str) -> Option; } diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 08815727..fbfacbd3 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -363,6 +363,10 @@ impl ui::Host for StoreData { let prompt = Prompt::Confirm { title: clip(title, MAX_TITLE), message: clip(body, 16 << 10) }; Ok(self.ask(prompt).map(|answer| answer.is_some())) } + + fn locale(&mut self) -> wasmtime::Result { + Ok(self.host.locale()) + } } impl storage::Host for StoreData { diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index 163d705e..09fad6f9 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -9cdb9b328e2b72c2f8849548b1d5bc119044acb4136dac614319138db2e4890d +8a4006a730cf82fa412e28c1d181cdb96b7d16e5fe9a635b735d96d06862a80b diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index b688f54a..dbe23ced 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -32634dd8c1172c6980cd1c4f9345fb2ed7b2e5cbacfd061bcefaa7fa9aad162e +f576b03db2e7bfaad3d5fc8c562c678c245d096bfa0c52bcadba4663991012bc diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index b674f051..31b75c0a 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -b7b63faf536d558cafc375a17765823015863e47d897e2128d453fbc8975e19c +52ae124ebab17dbbb182997efe13bdcd48943f3fcb7730f1cdf466cdbd1fc40b diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index 40d6ceee..f71786d4 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -809180a1c4ba29988af7b1ead1c31c1a3018fe3a1c266930ba588f34de1d3319 +5bfac74cff6560738bbd639691211219cfe731e260cf02bb86aec3bd8f4edc06 diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index f2dbd86d..415510c6 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -6fd9e638afa22170ef2e7a6bd7351bd5e0200d6726cf5d5835be7c5c7cfa19dc +4a9eadbf94cc03648fd51e2ed892d23bdae68084762098981dce2a15b97c3fbd diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 37199290..8abe3e13 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -81a1c6a7d78cf6ca6e463ffff6a4e243d668230a8d802028262ed5fc752f2f27 +2c3be2b558b10824cd2420df5de0b7dee30c68a63cffc3f03cfae16ee2d96574 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 30108e4f..a5d3ad78 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -c3b3e3c96d62106398299a6704e6a39fb18cfa734be877d1137f005d16553b4b +e1cbbe2c814e554ef9622204ddcfce87c4d1919993d5e78f9739dfaa46ba128c diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index 03d4d1ef..fad98daf 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -fac2994442eb96b09dea926546d2bacde333615392bc59fd70df73999ae84f62 +332840c4966244f309be9853c44caae56a5706bc102b9df0b4de4475a2a5d2fd diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index 3af557be..22a0f575 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -2a97235992302d0dfda55a1774883c046fe8aed6353b0f047aafa8bfe0155fa3 +ac8c4a1b4b4df9c3ffd4725e321df3f7836955a1c052387a276f0ad075c85527 diff --git a/plugins/README.md b/plugins/README.md index 47381a4a..c0fab4d9 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -367,6 +367,7 @@ then run `openidea-plugin check`. | `host::pick(title, items)`, `pick_many` | `ui.pick`, `ui.pick-many` | Asks the user to pick from `PickItem`s and waits: the chosen key(s), or `None` when dismissed. Only from a command or event of a window (else `invalid`); the wait isn't charged to the time limit. | | `host::input(prompt, value)` | `ui.input` | A one-line text box pre-filled with `value`; `None` when dismissed. Same rules as `pick`. | | `host::confirm(title, body)` | `ui.confirm` | OK / Cancel; `true` on OK. Same rules as `pick`. | +| `host::locale()` | `ui.locale` | The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`); no capability. See _Translations_. | | `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. | | `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. | | `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. | @@ -719,6 +720,30 @@ sends what Graphviz reports to the plugin, which publishes it as problems. The b checked in (it is tens of MB): run `plugins/build.sh official/excalidraw` before installing the plugin from the repository. +### Translations + +A package translates its own text with `locales/.json`, one file per language +(`fr.json`, `zh-CN.json`), no manifest entry needed. A catalog maps the English source +text to its translation, like the IDE's text catalogs: + +```json +{ "Count Words": "Compter les mots", "Refresh": "Actualiser" } +``` + +The IDE applies the catalog of the selected language (the exact tag, then the language, +then a variant such as `zh-TW` for `zh`) to the titles, categories, labels and tooltips +of the manifest's commands, views, editors, status bar items, settings (and their +properties), file templates, themes and secrets, and to what views show: toolbar +buttons, tree and list items, form field labels, table column names, messages and the +status line, and status bar items set by code. Text without an entry, and every text +when the language has no catalog, shows as written. A catalog applies to its own plugin +only and never changes the IDE's text. `openidea-plugin check` refuses a catalog that is +not a JSON object of strings. + +Text the plugin builds itself (notifications, error messages, `html` bodies, cell data) +is not translated: read `host::locale()` when making it. It follows a change of +language, and `MockHost::set_locale` sets it in tests. + ### Settings ```toml diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index e14bd913..91db1e46 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -5de6d0c1cb0ee1a92d36b57ec18590f03829b2c29cc1c306be453931c528c692 +e6d7996c21d41d1cfe585c1037fbe4b04ba523c5fdba676f0852ee8a8438f6c9 diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index d18c3eb6..dd3710b5 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -9a89ec93d158e460998cf472837d7e0e8aa76d5441cb1dd24c3184bd814a29c1 +2cc22faa9f1a4b4c27d22f740144e5968083444898c36574d1b3e7780e003dc0 diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index da4a2a37..ab28ff18 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -652557045106ab5bd1126cb8e05965d030c4aa28c01ede31be3fe285aab98a67 +2ed5f73c78e165a494d5f91f6ed2b55682e28a9d4e516553c9332b9d8ab84ab4 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 29ed91fd..fa91f9cb 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -7c8c97e02f754676ff512d10f454a7451468e2a98c2e992e790096215dd28658 +3274e8023091ec67b018d10e67e6180c3bc875747d51779a3e67ebfe76178907 diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index fad8aa80..7f64b5c9 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -1b6e3f30cc49d9d67d95f7655cbe337c5f8465c6ef7b8709cfab4734c3c40fdf +6060665c10f800787c9c7d03fbbd7dcdefb9fe475827b6b6902fdd72324f7d98 diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 0686d1aa..fad50328 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -3225880631e679780c3c7eb09683b598bb3803fcf7be7b6940369eada633c48f +929cbc24e4c07131e8a46aa333632b703909b6d978b36b474207f6ca233cc9c1 diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index e9fd184a..f6a069ce 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -a59623542b4df024f5a85bc2cd0e046189044a0c292c793384fdb5465e8eec48 +2f26af0ac018d56a0f93a7060ac0a4180b9760c20a98465ef9d4a6f06878455b diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index ded07295..0d1805bb 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -f230ae32c2d5ab469f23c6851fc7830a6ecce7538c7d435be39b5bbebe38a64f +7aafd74237661ec2907a4c8950b685a17bed760a10a2fc1062139171636e7f2d diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 014dda08..b21dadee 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -63f52f8cf8a90affc751ac7e19352a89f0726fc7c8dbbb303172ebd42e23af8c +af6ba8a45e97858fbc9c530bdb8e9e56c6229c9715a91bd543399fa0bc7cc04f diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 3d6de62b..74ceed13 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -178862630cf39bca56f5d54cd135dcac4a1ab74a3e10356163d00e5a718ae728 +e9f8ce2cf2d1cc4bcd05f4fed106d11542a2fa4b12e256fe675cbb0aa41dd3af diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 0b4279f8..8cd3dd33 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -a6c188aed4fc57cd9955d6b47ef715c52dc3f8c7b69f7d18ea71b3932888710f +737d1a1eb063a6757e93f0359fc32ab2b624ef8c251a4cba31aa3c36f7587002 diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 7ce3f982..7f95befd 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -125,6 +125,10 @@ mod imp { wit::views::reveal(view).map_err(error) } + pub fn locale() -> String { + wit::ui::locale() + } + pub fn storage_get(key: &str) -> Result, Error> { wit::storage::get(key).map_err(error) } @@ -738,6 +742,15 @@ pub fn reveal_view(view: &str) -> Result<(), Error> { imp::reveal_view(view) } +/// The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`, …), for the +/// text the plugin builds itself (notifications, error messages, HTML). The +/// strings of the manifest and of views are translated by the IDE from the +/// package's `locales/.json`. Read it when the text is made: it follows +/// a change of language. +pub fn locale() -> String { + imp::locale() +} + /// Delivers `kind` events to [`crate::Plugin::handle_events`] for the rest of /// the session (also after the instance is reclaimed: an event activates the /// plugin again). Needs `events` (`events.input` for `ActionPerformed`). diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index c49dd9b5..472a010b 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -85,6 +85,7 @@ struct State { terminals: Vec, locations: Vec<(String, u32, u32)>, settings: BTreeMap, + locale: Option, } /// A terminal the plugin opened. @@ -317,6 +318,11 @@ impl MockHost { self.state.borrow_mut().settings.insert(key.into(), json.into()); } + /// The UI language [`crate::host::locale`] reports (`en` by default). + pub fn set_locale(&self, tag: &str) { + self.state.borrow_mut().locale = Some(tag.into()); + } + /// A secret the user stored. pub fn set_secret(&self, name: &str, value: &str) { self.state.borrow_mut().secrets.insert(name.into(), value.into()); @@ -449,6 +455,10 @@ pub mod current { }) } + pub fn locale() -> String { + with(|s| s.locale.clone().unwrap_or_else(|| "en".into())) + } + pub fn open_url(url: &str) -> Result<(), Error> { let trimmed = url.trim(); if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) { @@ -933,6 +943,9 @@ mod tests { assert!(!host::confirm("Sure?", "Really").unwrap()); host::reveal_view("panel").unwrap(); assert_eq!(mock.revealed_views(), ["panel"]); + assert_eq!(host::locale(), "en"); + mock.set_locale("zh-CN"); + assert_eq!(host::locale(), "zh-CN"); { let task = host::begin_task("Work", true); task.report(1, Some(2), None); diff --git a/sdk/rust/openidea-plugin/wit/host.wit b/sdk/rust/openidea-plugin/wit/host.wit index 5e7f7cf3..f6871436 100644 --- a/sdk/rust/openidea-plugin/wit/host.wit +++ b/sdk/rust/openidea-plugin/wit/host.wit @@ -65,6 +65,11 @@ interface ui { /// Asks for confirmation with OK and Cancel: `true` on OK, `false` on /// Cancel or Escape. Same rules as `pick`. confirm: func(title: string, body: string) -> result; + /// The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`, …); it + /// follows a change of language. For the text the plugin builds itself: + /// the strings of its manifest and views are translated from + /// `locales/.json`. + locale: func() -> string; } /// Persistent key-value storage of the plugin (survives restarts and idle diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 02f4ec7b..d42a117f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -476,6 +476,7 @@ pub fn builder() -> tauri_specta::Builder { plugin::plugin_link, plugin::plugin_unlink, plugin::plugin_assets, + plugin::plugin_set_ui_locale, plugin::plugin_activation_event, plugin::plugin_catalog, plugin::plugin_uninstall, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 91587350..472d5586 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -259,6 +259,14 @@ pub async fn plugin_assets( blocking(move || host.assets(&plugin_id, &prefix)).await } +/// The UI language (BCP 47) that plugin code reads with `ui.locale()`. +#[tauri::command] +#[specta::specta] +pub async fn plugin_set_ui_locale(state: State<'_, AppState>, tag: String) -> IdeResult<()> { + state.plugins.set_ui_locale(&tag); + Ok(()) +} + /// An activation event from the UI: `kind` is `language`, `file` (project-relative path) or `view`. #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index dae10eb0..3bb01a27 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1041,6 +1041,8 @@ export const commands = { pluginUnlink: (id: string) => typedError(__TAURI_INVOKE("plugin_unlink", { id })), /** Validated assets of a plugin whose id starts with `prefix` (icon theme mapping and SVGs). */ pluginAssets: (pluginId: string, prefix: string) => typedError<([string, string])[], IdeError>(__TAURI_INVOKE("plugin_assets", { pluginId, prefix })), + /** The UI language (BCP 47) that plugin code reads with `ui.locale()`. */ + pluginSetUiLocale: (tag: string) => typedError(__TAURI_INVOKE("plugin_set_ui_locale", { tag })), /** An activation event from the UI: `kind` is `language`, `file` (project-relative path) or `view`. */ pluginActivationEvent: (kind: string, value: string) => typedError(__TAURI_INVOKE("plugin_activation_event", { kind, value })), /** diff --git a/src/features/app/locale.ts b/src/features/app/locale.ts index b48ad243..87e697eb 100644 --- a/src/features/app/locale.ts +++ b/src/features/app/locale.ts @@ -1,6 +1,6 @@ /** Keeps the UI locale in sync with `appearance.locale`; re-registers localised contributions. */ import { registerActions, type Action } from "@/platform/actions"; -import { setLocale, useI18n } from "@/platform/i18n"; +import { LOCALES, setLocale, useI18n } from "@/platform/i18n"; import { commands, isTauri, type Settings } from "@/platform/ipc"; import { useProject } from "@/platform/project"; @@ -14,7 +14,11 @@ export function installLocaleSync(): () => void { const value = localeSetting(settings); if (value === undefined || value === current) return; current = value; - void setLocale(value); + void setLocale(value).then((locale) => { + // A later switch may have superseded this one while its catalog loaded. + if (isTauri() && useI18n.getState().locale === locale) + void commands.pluginSetUiLocale(LOCALES[locale].tag).catch(() => {}); + }); }; const initial = useProject.getState().settings; if (initial) apply(initial); diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index 5637a1be..26b6eb2c 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -33,6 +33,7 @@ import { } from "@/ui"; import { RISK_LABEL, sortByRisk } from "./consent"; import { pluginIconName } from "./pluginIcon"; +import { localizeContributions, usePluginCatalogs, usePluginT } from "./pluginText"; import { itemsOf, uiPoint } from "./projection"; import { compareVersions, @@ -75,6 +76,7 @@ const POINT_TITLES: Record = { iconThemes: "File Icon Themes", languages: "Languages", providers: "Editor Providers", + locales: "Translations", }; const pointTitle = (point: string) => uiPoint(point)?.title ?? POINT_TITLES[point] ?? point; @@ -268,7 +270,9 @@ function pointsOf(plugin: PluginInfo): string[] { function PointSection({ plugin, point }: { plugin: PluginInfo; point: string }) { const ui = usePlugins((s) => s.ui); const Details = uiPoint(point)?.Details; - const items = itemsOf(ui, point, plugin.id); + const catalogs = usePluginCatalogs((s) => s.catalogs); + const t = usePluginT(plugin.id); + const items = itemsOf(localizeContributions(ui, catalogs), point, plugin.id); return (
{Details && items.length > 0 ? ( @@ -279,7 +283,7 @@ function PointSection({ plugin, point }: { plugin: PluginInfo; point: string }) .filter((c) => c.point === point) .map((c, i) => (
  • - {c.label} + {t(c.label)}
  • ))} diff --git a/src/features/plugins/bridge.ts b/src/features/plugins/bridge.ts index 4bb97c56..075c8a7a 100644 --- a/src/features/plugins/bridge.ts +++ b/src/features/plugins/bridge.ts @@ -10,6 +10,7 @@ import type { ActiveTheme, PluginEvent, PluginUpdate } from "@/bindings"; import { useEditorRegistry } from "@/editor/api"; import { refreshLanguageInfos } from "@/editor/core/cm/languages"; import { notifications } from "@/workbench/contributions"; +import { useI18n } from "@/platform/i18n"; import { useProducerNames } from "@/platform/inspections"; import { languageListChanged } from "@/platform/languageList"; import { @@ -32,6 +33,7 @@ import { useProject } from "@/platform/project"; import { pluginsBackend } from "./backend"; import { trackIconTheme } from "./iconThemes"; import { defineBuiltinUiPoints } from "./points"; +import { localizeContributions, loadPluginCatalogs, usePluginCatalogs } from "./pluginText"; import { clearUiContributions, syncUiContributions } from "./projection"; import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi"; import { grantPlugin, refreshPlugins, selectPluginTheme, updateAllPlugins, usePlugins } from "./store"; @@ -42,16 +44,23 @@ export { pluginActionId } from "./points"; export function syncProjection(): () => void { const apply = () => { const s = usePlugins.getState(); - syncUiContributions(s.ui, s.plugins); + syncUiContributions(localizeContributions(s.ui, usePluginCatalogs.getState().catalogs), s.plugins); // Their problems are listed by name in Settings › Inspections. useProducerNames.setState({ names: new Map(s.plugins.map((p) => [`plugin:${p.id}`, p.name])) }); }; + const loadCatalogs = () => void loadPluginCatalogs(usePlugins.getState().ui, useI18n.getState().locale); apply(); - const unsubscribe = usePlugins.subscribe((s, prev) => { - if (s.ui !== prev.ui || s.plugins !== prev.plugins) apply(); - }); + loadCatalogs(); + const unsubscribe = [ + usePlugins.subscribe((s, prev) => { + if (s.ui !== prev.ui) loadCatalogs(); + if (s.ui !== prev.ui || s.plugins !== prev.plugins) apply(); + }), + usePluginCatalogs.subscribe((s, prev) => s.catalogs !== prev.catalogs && apply()), + useI18n.subscribe((s, prev) => s.locale !== prev.locale && loadCatalogs()), + ]; return () => { - unsubscribe(); + unsubscribe.forEach((u) => u()); clearUiContributions(); }; } diff --git a/src/features/plugins/pluginText.test.ts b/src/features/plugins/pluginText.test.ts new file mode 100644 index 00000000..9deca5ce --- /dev/null +++ b/src/features/plugins/pluginText.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import type { UiContribution, View } from "@/bindings"; +import { localizeContributions, localizeView, pickCatalogTag, translatorOf } from "./pluginText"; + +const fr = { Refresh: "Actualiser", "Word Tools": "Outils de mots", Cancel: "Annuler" }; + +describe("pickCatalogTag", () => { + it.each([ + [["fr", "zh-CN"], "fr", "fr"], + [["fr", "zh-CN"], "zh", "zh-CN"], + [["zh"], "zh", "zh"], + [["zh-TW"], "zh", "zh-TW"], + [["pt-br"], "pt", "pt-br"], + [["fr"], "en", undefined], + [[], "fr", undefined], + ])("%j for %s is %s", (tags, locale, expected) => { + expect(pickCatalogTag(tags, locale)).toBe(expected); + }); +}); + +describe("localizeContributions", () => { + const item = (pluginId: string, point: string, decl: Record): UiContribution => ({ + pluginId, + point, + key: String(decl.id), + decl, + }); + + it("translates titles and setting labels of the plugin's own declarations only", () => { + const items = [ + item("a", "commands", { id: "x", title: "Refresh", category: "Word Tools" }), + item("a", "settings", { + id: "s", + title: "Word Tools", + properties: [{ key: "k", title: "Cancel", enum: ["Refresh"] }], + }), + item("a", "menus", { id: "m", menu: "tools", command: "Refresh" }), + item("b", "commands", { id: "y", title: "Refresh" }), + ]; + const out = localizeContributions(items, { a: fr }); + expect(out[0]!.decl).toMatchObject({ title: "Actualiser", category: "Outils de mots" }); + expect(out[1]!.decl).toMatchObject({ + title: "Outils de mots", + properties: [{ title: "Annuler", enum: ["Refresh"] }], + }); + expect(out[2]).toBe(items[2]); + expect(out[3]).toBe(items[3]); + }); + + it("keeps the names of secrets and translates their labels", () => { + const secret = item("a", "secrets", { id: "k", name: "Refresh", label: "Cancel", description: "Refresh" }); + expect(localizeContributions([secret], { a: fr })[0]!.decl).toMatchObject({ + name: "Refresh", + label: "Annuler", + description: "Actualiser", + }); + }); + + it("gives a tools menu entry the translated title of its command", () => { + const items = [ + item("a", "commands", { id: "x", title: "Refresh" }), + item("a", "menus", { id: "m", menu: "tools", command: "x" }), + ]; + expect(localizeContributions(items, { a: fr })[1]!.decl).toMatchObject({ commandTitle: "Actualiser" }); + }); + + it("ignores catalog entries inherited from Object.prototype", () => { + const items = [item("a", "commands", { id: "x", title: "constructor", category: "__proto__" })]; + expect(localizeContributions(items, { a: fr })[0]!.decl).toMatchObject({ + title: "constructor", + category: "__proto__", + }); + expect(translatorOf({ a: fr }, "constructor")("Refresh")).toBe("Refresh"); + expect(translatorOf({ a: fr }, "a")("toString")).toBe("toString"); + }); + + it("returns the declarations as they are without a catalog", () => { + const items = [item("a", "commands", { id: "x", title: "Refresh" })]; + expect(localizeContributions(items, {})).toBe(items); + }); +}); + +describe("localizeView", () => { + const t = translatorOf({ a: fr }, "a"); + const view = (body: View["body"]): View => ({ + toolbar: [{ id: "r", label: "Refresh", icon: null, enabled: true }], + body, + status: "Cancel", + }); + + it("translates buttons, tree items, fields and status, and keeps unknown text", () => { + const tree = localizeView( + view({ + kind: "tree", + value: [ + { key: "1", parent: null, label: "Refresh", icon: null, detail: "other", expandable: false, expanded: false }, + ], + }), + t, + ); + expect(tree.toolbar[0]!.label).toBe("Actualiser"); + expect(tree.status).toBe("Annuler"); + expect(tree.body).toMatchObject({ value: [{ label: "Actualiser", detail: "other" }] }); + + const form = localizeView( + view({ kind: "form", value: [{ key: "k", label: "Cancel", value: { kind: "text", value: "Cancel" } }] }), + t, + ); + expect(form.body).toMatchObject({ value: [{ label: "Annuler", value: { kind: "text", value: "Cancel" } }] }); + }); + + it("leaves html bodies alone", () => { + expect(localizeView(view({ kind: "html", value: "

    Refresh

    " }), t).body).toEqual({ + kind: "html", + value: "

    Refresh

    ", + }); + }); +}); diff --git a/src/features/plugins/pluginText.ts b/src/features/plugins/pluginText.ts new file mode 100644 index 00000000..22d7eda6 --- /dev/null +++ b/src/features/plugins/pluginText.ts @@ -0,0 +1,154 @@ +/** + * Translation of a plugin's own text. A package's `locales/.json` maps + * English source text to a translation; it is applied to the plugin's + * declarations and view data, and only to those: the IDE's catalogs are not + * touched and a plugin never sees another's. Text without an entry stays + * English. + */ +import { useCallback } from "react"; +import { create } from "zustand"; +import type { UiContribution, View } from "@/bindings"; +import { LOCALES, type Locale } from "@/platform/i18n"; +import { pluginsBackend } from "./backend"; + +type Catalog = Record; +export type PluginTranslate = (text: string) => string; + +export const usePluginCatalogs = create<{ catalogs: Record }>(() => ({ catalogs: {} })); + +const LOCALES_POINT = "locales"; + +/** The catalog of `tags` that serves `locale`: the exact tag (`zh-CN`), then the language (`zh`), then a variant (`zh-TW`). */ +export function pickCatalogTag(tags: readonly string[], locale: string): string | undefined { + const full = (LOCALES[locale as Locale]?.tag ?? locale).toLowerCase(); + const language = full.split("-")[0]!; + const lower = tags.map((t) => t.toLowerCase()); + const at = lower.indexOf(full) >= 0 ? lower.indexOf(full) : lower.indexOf(language); + if (at >= 0) return tags[at]; + return tags.find((t) => t.toLowerCase().split("-")[0] === language); +} + +let loadToken = 0; + +/** Reads the catalogs of the plugins that ship one for `locale`; the others have none. */ +export async function loadPluginCatalogs(ui: readonly UiContribution[], locale: string): Promise { + const token = ++loadToken; + const entries = await Promise.all( + ui + .filter((c) => c.point === LOCALES_POINT) + .map(async (c): Promise<[string, Catalog] | null> => { + const tags = ((c.decl as { tags?: string[] }).tags ?? []) as string[]; + const tag = pickCatalogTag(tags, locale); + if (!tag) return null; + try { + const assets = await pluginsBackend().assets(c.pluginId, `locale/${tag}`); + const text = assets.find(([id]) => id === `locale/${tag}`)?.[1]; + return text ? [c.pluginId, JSON.parse(text) as Catalog] : null; + } catch (e) { + console.warn(`plugin ${c.pluginId}: cannot load its ${tag} catalog`, e); + return null; + } + }), + ); + if (token !== loadToken) return; + const catalogs = Object.fromEntries(entries.filter((e) => e !== null)); + if (JSON.stringify(catalogs) !== JSON.stringify(usePluginCatalogs.getState().catalogs)) + usePluginCatalogs.setState({ catalogs }); +} + +/** `map[key]` when `map` has the key itself, not through its prototype (`constructor`, `__proto__`). */ +function ownEntry(map: Record | undefined, key: string): T | undefined { + return map && Object.hasOwn(map, key) ? map[key] : undefined; +} + +export function translatorOf(catalogs: Record, pluginId: string): PluginTranslate { + const catalog = ownEntry(catalogs, pluginId); + return catalog ? (text) => ownEntry(catalog, text) ?? text : (text) => text; +} + +/** `text` in the active language, for the plugin's own text; re-renders when the catalog changes. */ +export function usePluginT(pluginId: string): PluginTranslate { + const catalog = usePluginCatalogs((s) => ownEntry(s.catalogs, pluginId)); + return useCallback((text) => (catalog ? (ownEntry(catalog, text) ?? text) : text), [catalog]); +} + +/** The keys of each point's declaration that are shown text; ids, names of secrets and option values are not. */ +const TEXT_KEYS: Record = { + commands: ["title", "category"], + views: ["title"], + customEditors: ["title"], + statusBar: ["text", "tooltip"], + settings: ["title"], + fileTemplates: ["name"], + iconThemes: ["label"], + themes: ["name"], + secrets: ["label", "description"], +}; + +function localizeDecl(decl: unknown, keys: readonly string[], t: PluginTranslate): unknown { + if (!decl || typeof decl !== "object") return decl; + const out: Record = { ...(decl as Record) }; + 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)); + return out; +} + +/** The declarations with their titles and labels translated by their plugin's catalog. */ +export function localizeContributions( + items: readonly UiContribution[], + catalogs: Record, +): readonly UiContribution[] { + if (Object.keys(catalogs).length === 0) return items; + const localized = items.map((item) => { + const keys = TEXT_KEYS[item.point]; + return keys && ownEntry(catalogs, item.pluginId) + ? { ...item, decl: localizeDecl(item.decl, keys, translatorOf(catalogs, item.pluginId)) } + : item; + }); + // A menu entry is labelled with its command's title, which changes with the language but not with the entry. + const titles = new Map( + localized + .filter((c) => c.point === "commands") + .map((c) => [`${c.pluginId}/${(c.decl as { id: string }).id}`, (c.decl as { title: string }).title]), + ); + return localized.map((item) => { + const title = + item.point === "menus" ? titles.get(`${item.pluginId}/${(item.decl as { command: string }).command}`) : undefined; + return title !== undefined && ownEntry(catalogs, item.pluginId) + ? { ...item, decl: { ...(item.decl as object), commandTitle: title } } + : item; + }); +} + +/** A view with the buttons, labels, columns and messages translated; `html` bodies and cell data are left as they are. */ +export function localizeView(view: View, t: PluginTranslate): View { + const body = view.body; + const localized: View["body"] = (() => { + switch (body.kind) { + case "message": + return { ...body, value: t(body.value) }; + case "tree": + return { + ...body, + value: body.value.map((i) => ({ ...i, label: t(i.label), detail: i.detail && t(i.detail) })), + }; + case "list": + return { + ...body, + value: body.value.map((i) => ({ ...i, label: t(i.label), detail: i.detail && t(i.detail) })), + }; + case "form": + return { ...body, value: body.value.map((f) => ({ ...f, label: t(f.label) })) }; + case "table": + return { ...body, value: { ...body.value, columns: body.value.columns.map(t) } }; + default: + return body; + } + })(); + return { + toolbar: view.toolbar.map((b) => ({ ...b, label: t(b.label) })), + body: localized, + status: view.status && t(view.status), + }; +} diff --git a/src/features/plugins/runtimeUi.tsx b/src/features/plugins/runtimeUi.tsx index 0951cbca..6dcd1466 100644 --- a/src/features/plugins/runtimeUi.tsx +++ b/src/features/plugins/runtimeUi.tsx @@ -33,6 +33,7 @@ import { pickItems, promptInput } from "@/workbench/inputDialogs"; import { confirm } from "@/workbench/messages"; import { removeTask, upsertTask, useTasks } from "@/workbench/tasks"; import { pluginsBackend } from "./backend"; +import { usePluginT } from "./pluginText"; import { bumpView, deliverViewMessage, pluginToolWindowId } from "./views/viewStore"; import { runPluginCommand, usePlugins } from "./store"; @@ -81,16 +82,21 @@ function PluginStatusItemView({ itemKey }: { itemKey: ItemKey }) { const running = usePluginStatusItems((s) => s.items.get(itemKey)); const declared = useDeclaredStatusItems((s) => s.items.get(itemKey)); const entry = running ?? declared; + const t = usePluginT(entry?.pluginId ?? ""); if (!entry) return null; const { pluginId, item } = entry; const command = item.command; + // Declared items were translated with their declaration. + const translate = running ? t : (text: string) => text; + const tooltip = item.tooltip ? translate(item.tooltip) : undefined; + const text = translate(item.text); return ( void runPluginCommand(pluginId, command) : undefined} > - {item.text} + {text} ); } diff --git a/src/features/plugins/uiPoints.tsx b/src/features/plugins/uiPoints.tsx index bbd89295..483b403f 100644 --- a/src/features/plugins/uiPoints.tsx +++ b/src/features/plugins/uiPoints.tsx @@ -81,6 +81,8 @@ interface MenuDecl { view?: string | null; command: string; when?: string | null; + /** The command's title in the plugin's language, when it ships one. */ + commandTitle?: string; } const CONTEXT_MENUS: Record = { @@ -126,7 +128,7 @@ export function pluginViewItemMenuItems(pluginId: string, view: string, key: str function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () => void { if (decl.menu === "tools") { const action = pluginActionId(pluginId, decl.command); - const title = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`; + const title = decl.commandTitle ?? getAction(action)?.title ?? `${plugin.name}: ${decl.command}`; return registerMenuSlotItem("pluginTools", { action, label: title }); } if (decl.menu === "commit.message") { diff --git a/src/features/plugins/views/PluginView.tsx b/src/features/plugins/views/PluginView.tsx index bd006dc7..ebafe7f3 100644 --- a/src/features/plugins/views/PluginView.tsx +++ b/src/features/plugins/views/PluginView.tsx @@ -33,6 +33,7 @@ import { import { pluginsBackend } from "../backend"; import { subscribeTheme, themeKey } from "@/platform/themeWatch"; import { FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame"; +import { localizeView, usePluginT } from "../pluginText"; import { onViewMessage, useViewRevision } from "./viewStore"; import styles from "./PluginView.module.css"; @@ -54,7 +55,9 @@ const OVERSCAN = 50; const ROW_HEIGHT = 24; export function PluginView({ pluginId, view, doc = null, revision = 0, label, itemMenu }: PluginViewProps) { - const [content, setContent] = useState(null); + const [rendered, setContent] = useState(null); + const t = usePluginT(pluginId); + const content = useMemo(() => rendered && localizeView(rendered, t), [rendered, t]); const [error, setError] = useState(null); const [viewport, setViewport] = useState(null); const pluginRevision = useViewRevision(pluginId, view); diff --git a/src/platform/locales/text/fr.json b/src/platform/locales/text/fr.json index fb9398f3..1a6f1fdb 100644 --- a/src/platform/locales/text/fr.json +++ b/src/platform/locales/text/fr.json @@ -5783,6 +5783,7 @@ "Weekly Opus limit": "Limite hebdomadaire Opus", "Weekly Sonnet limit": "Limite hebdomadaire Sonnet", "Editor Providers": "Fournisseurs d'éditeurs", + "Translations": "Traductions", "Syntax errors": "Erreurs de syntaxe", "Pull Members Up…": "Remonter les membres…", "Push Members Down…": "Descendre les membres…", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 0d81d3d9..9232f653 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5990,6 +5990,7 @@ "Value of {label}": "{label} 的值", "The plugin can only send these with its network requests; it cannot read them.": "插件只能在网络请求中发送这些值,无法读取它们。", "Editor Providers": "编辑器提供者", + "Translations": "翻译", "Linked for development": "为开发而链接", "Low": "低", "Medium": "中", diff --git a/wit/host.wit b/wit/host.wit index 5e7f7cf3..f6871436 100644 --- a/wit/host.wit +++ b/wit/host.wit @@ -65,6 +65,11 @@ interface ui { /// Asks for confirmation with OK and Cancel: `true` on OK, `false` on /// Cancel or Escape. Same rules as `pick`. confirm: func(title: string, body: string) -> result; + /// The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`, …); it + /// follows a change of language. For the text the plugin builds itself: + /// the strings of its manifest and views are translated from + /// `locales/.json`. + locale: func() -> string; } /// Persistent key-value storage of the plugin (survives restarts and idle