feat(plugins): let a plugin ship translations and read the UI language (#1781)

A package's locales/<tag>.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 <[email protected]>
This commit is contained in:
Arnaud Ritti authored and GitHub committed 2026-11-07 19:40:50 +08:00
1 parent e0408cc7d7
commit adc308eb99
50 files changed
+596 -40

No files matched your search

+1 -1
View File
@@ -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;
+19
View File
@@ -9,6 +9,11 @@ use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
/// The point behind a package's `locales/<tag>.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)
}
+7
View File
@@ -196,6 +196,8 @@ pub(super) struct Shared {
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
/// Validated UI declarations (settings schemas, declared views).
pub ui: Arc<ide_extension::UiStore>,
/// The UI language the windows report (BCP 47).
pub locale: RwLock<String>,
}
impl Shared {
@@ -207,6 +209,7 @@ impl Shared {
) -> Arc<Self> {
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<String> {
let schema: Vec<super::ui_points::SettingSchema> = self
.shared
+9
View File
@@ -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)?;
+1
View File
@@ -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(())
}
+10 -2
View File
@@ -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::<String>("tag")?);
Ok(())
}
});
let h = host.clone();
d.add("plugin_post_events", move |c| {
let h = h.clone();
async move {
+91 -3
View File
@@ -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<UiDecl, S
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
}
const MAX_CATALOG_ENTRIES: usize = 20_000;
pub fn locale_asset(tag: &str) -> 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/<tag>.json`: English source text → translation, for the plugin's own strings.
pub(super) fn locales(pkg: &Package, _decl: toml::Value) -> Result<UiDecl, String> {
let dir = Path::new(LOCALES_DIR);
let mut names: Vec<String> = 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<String, Value> = 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::<Vec<_>>(), ["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}");
}
}
}
+1
View File
@@ -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"
+40
View File
@@ -2419,6 +2419,46 @@ web = "panel/index.html"
assert!(assets[0].1.contains("<rect"));
}
#[test]
fn a_package_ships_translations_in_locales() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let package = |id: &str, catalogs: &[(&str, &str)]| {
let pkg = tmp.path().join(id);
std::fs::create_dir_all(pkg.join("locales")).unwrap();
for (name, text) in catalogs {
std::fs::write(pkg.join("locales").join(name), text).unwrap();
}
std::fs::write(
pkg.join("plugin.toml"),
format!(
"id = \"{id}\"\nname = \"Words\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\n\
[[contributes.commands]]\nid = \"count\"\ntitle = \"Count Words\"\n"
),
)
.unwrap();
pkg
};
for bad in ["{", "[]", r#"{"Count Words": 1}"#] {
let pkg = package("bad-catalog", &[("fr.json", bad)]);
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err(), "{bad}");
}
let pkg = package("words", &[("fr.json", r#"{"Count Words": "Compter les mots"}"#), ("zh-CN.json", "{}")]);
let info = f.install(&pkg);
assert!(info.error.is_none(), "{:?}", info.error);
let item = f.host.ui_items("locales").into_iter().find(|i| i.plugin_id == "words").expect("the catalogs");
assert_eq!(item.decl["tags"], serde_json::json!(["fr", "zh-CN"]));
let assets = f.host.assets("words", "locale/fr").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("Compter les mots"));
let other = f.install(&package("other", &[]));
assert!(other.error.is_none(), "{:?}", other.error);
assert!(f.host.assets("other", "locale/").unwrap().is_empty());
}
/// A plugin with web views and no code: its pages and their files are
/// installed with it and served only from the declared directories, while
/// it is enabled and only for the installed version; file icons and
+3
View File
@@ -164,6 +164,9 @@ async fn remote_projects_run_the_desktops_code_plugins_on_the_server() {
let kinds = invoke(&peer, "plugin_event_kinds", json!({})).await.unwrap();
assert!(kinds.as_array().unwrap().contains(&json!("file-saved")), "{kinds}");
invoke(&peer, "plugin_set_ui_locale", json!({ "tag": "fr" })).await.unwrap();
assert_eq!(plugins.host().ui_locale(), "fr");
// Saving a file that exists only on the server: Checkstyle reads that file.
let java = project.join("src/A.java");
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
+2
View File
@@ -173,6 +173,8 @@ pub trait HostApi: Send + Sync {
cwd: Option<&Path>,
) -> 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<String>;
}
+4
View File
@@ -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<String> {
Ok(self.host.locale())
}
}
impl storage::Host for StoreData {
@@ -1 +1 @@
9cdb9b328e2b72c2f8849548b1d5bc119044acb4136dac614319138db2e4890d
8a4006a730cf82fa412e28c1d181cdb96b7d16e5fe9a635b735d96d06862a80b
@@ -1 +1 @@
32634dd8c1172c6980cd1c4f9345fb2ed7b2e5cbacfd061bcefaa7fa9aad162e
f576b03db2e7bfaad3d5fc8c562c678c245d096bfa0c52bcadba4663991012bc
@@ -1 +1 @@
b7b63faf536d558cafc375a17765823015863e47d897e2128d453fbc8975e19c
52ae124ebab17dbbb182997efe13bdcd48943f3fcb7730f1cdf466cdbd1fc40b
@@ -1 +1 @@
809180a1c4ba29988af7b1ead1c31c1a3018fe3a1c266930ba588f34de1d3319
5bfac74cff6560738bbd639691211219cfe731e260cf02bb86aec3bd8f4edc06
@@ -1 +1 @@
6fd9e638afa22170ef2e7a6bd7351bd5e0200d6726cf5d5835be7c5c7cfa19dc
4a9eadbf94cc03648fd51e2ed892d23bdae68084762098981dce2a15b97c3fbd
@@ -1 +1 @@
81a1c6a7d78cf6ca6e463ffff6a4e243d668230a8d802028262ed5fc752f2f27
2c3be2b558b10824cd2420df5de0b7dee30c68a63cffc3f03cfae16ee2d96574
@@ -1 +1 @@
c3b3e3c96d62106398299a6704e6a39fb18cfa734be877d1137f005d16553b4b
e1cbbe2c814e554ef9622204ddcfce87c4d1919993d5e78f9739dfaa46ba128c
@@ -1 +1 @@
fac2994442eb96b09dea926546d2bacde333615392bc59fd70df73999ae84f62
332840c4966244f309be9853c44caae56a5706bc102b9df0b4de4475a2a5d2fd
@@ -1 +1 @@
2a97235992302d0dfda55a1774883c046fe8aed6353b0f047aafa8bfe0155fa3
ac8c4a1b4b4df9c3ffd4725e321df3f7836955a1c052387a276f0ad075c85527
+25
View File
@@ -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/<tag>.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
+1 -1
View File
@@ -1 +1 @@
5de6d0c1cb0ee1a92d36b57ec18590f03829b2c29cc1c306be453931c528c692
e6d7996c21d41d1cfe585c1037fbe4b04ba523c5fdba676f0852ee8a8438f6c9
+1 -1
View File
@@ -1 +1 @@
9a89ec93d158e460998cf472837d7e0e8aa76d5441cb1dd24c3184bd814a29c1
2cc22faa9f1a4b4c27d22f740144e5968083444898c36574d1b3e7780e003dc0
+1 -1
View File
@@ -1 +1 @@
652557045106ab5bd1126cb8e05965d030c4aa28c01ede31be3fe285aab98a67
2ed5f73c78e165a494d5f91f6ed2b55682e28a9d4e516553c9332b9d8ab84ab4
+1 -1
View File
@@ -1 +1 @@
7c8c97e02f754676ff512d10f454a7451468e2a98c2e992e790096215dd28658
3274e8023091ec67b018d10e67e6180c3bc875747d51779a3e67ebfe76178907
+1 -1
View File
@@ -1 +1 @@
1b6e3f30cc49d9d67d95f7655cbe337c5f8465c6ef7b8709cfab4734c3c40fdf
6060665c10f800787c9c7d03fbbd7dcdefb9fe475827b6b6902fdd72324f7d98
+1 -1
View File
@@ -1 +1 @@
3225880631e679780c3c7eb09683b598bb3803fcf7be7b6940369eada633c48f
929cbc24e4c07131e8a46aa333632b703909b6d978b36b474207f6ca233cc9c1
+1 -1
View File
@@ -1 +1 @@
a59623542b4df024f5a85bc2cd0e046189044a0c292c793384fdb5465e8eec48
2f26af0ac018d56a0f93a7060ac0a4180b9760c20a98465ef9d4a6f06878455b
+1 -1
View File
@@ -1 +1 @@
f230ae32c2d5ab469f23c6851fc7830a6ecce7538c7d435be39b5bbebe38a64f
7aafd74237661ec2907a4c8950b685a17bed760a10a2fc1062139171636e7f2d
+1 -1
View File
@@ -1 +1 @@
63f52f8cf8a90affc751ac7e19352a89f0726fc7c8dbbb303172ebd42e23af8c
af6ba8a45e97858fbc9c530bdb8e9e56c6229c9715a91bd543399fa0bc7cc04f
+1 -1
View File
@@ -1 +1 @@
178862630cf39bca56f5d54cd135dcac4a1ab74a3e10356163d00e5a718ae728
e9f8ce2cf2d1cc4bcd05f4fed106d11542a2fa4b12e256fe675cbb0aa41dd3af
+1 -1
View File
@@ -1 +1 @@
a6c188aed4fc57cd9955d6b47ef715c52dc3f8c7b69f7d18ea71b3932888710f
737d1a1eb063a6757e93f0359fc32ab2b624ef8c251a4cba31aa3c36f7587002
+13
View File
@@ -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<Option<String>, 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/<tag>.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`).
+13
View File
@@ -85,6 +85,7 @@ struct State {
terminals: Vec<MockTerminal>,
locations: Vec<(String, u32, u32)>,
settings: BTreeMap<String, String>,
locale: Option<String>,
}
/// 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);
+5
View File
@@ -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<bool, error>;
/// 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/<tag>.json`.
locale: func() -> string;
}
/// Persistent key-value storage of the plugin (survives restarts and idle
+1
View File
@@ -476,6 +476,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
plugin::plugin_link,
plugin::plugin_unlink,
plugin::plugin_assets,
plugin::plugin_set_ui_locale,
plugin::plugin_activation_event,
plugin::plugin_catalog,
plugin::plugin_uninstall,
+8
View File
@@ -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]
+2
View File
@@ -1041,6 +1041,8 @@ export const commands = {
pluginUnlink: (id: string) => typedError<null, IdeError>(__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<null, IdeError>(__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<null, IdeError>(__TAURI_INVOKE("plugin_activation_event", { kind, value })),
/**
+6 -2
View File
@@ -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);
+6 -2
View File
@@ -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<string, string> = {
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 (
<Section title={pointTitle(point)}>
{Details && items.length > 0 ? (
@@ -279,7 +283,7 @@ function PointSection({ plugin, point }: { plugin: PluginInfo; point: string })
.filter((c) => c.point === point)
.map((c, i) => (
<li key={`${c.label}-${i}`} className={styles.item}>
<span className={styles.itemMain}>{c.label}</span>
<span className={styles.itemMain}>{t(c.label)}</span>
</li>
))}
</ul>
+14 -5
View File
@@ -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();
};
}
+118
View File
@@ -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<string, unknown>): 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: "<p>Refresh</p>" }), t).body).toEqual({
kind: "html",
value: "<p>Refresh</p>",
});
});
});
+154
View File
@@ -0,0 +1,154 @@
/**
* Translation of a plugin's own text. A package's `locales/<tag>.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<string, string>;
export type PluginTranslate = (text: string) => string;
export const usePluginCatalogs = create<{ catalogs: Record<string, Catalog> }>(() => ({ 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<void> {
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<T>(map: Record<string, T> | undefined, key: string): T | undefined {
return map && Object.hasOwn(map, key) ? map[key] : undefined;
}
export function translatorOf(catalogs: Record<string, Catalog>, 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<string, readonly string[]> = {
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<string, unknown> = { ...(decl as Record<string, unknown>) };
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<string, Catalog>,
): 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),
};
}
+9 -3
View File
@@ -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 (
<BarButton
tooltip={item.tooltip ?? undefined}
aria-label={item.tooltip ?? item.text}
tooltip={tooltip}
aria-label={tooltip ?? text}
onClick={command ? () => void runPluginCommand(pluginId, command) : undefined}
>
{item.text}
{text}
</BarButton>
);
}
+3 -1
View File
@@ -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<string, ContextMenuId> = {
@@ -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") {
+4 -1
View File
@@ -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<View | null>(null);
const [rendered, setContent] = useState<View | null>(null);
const t = usePluginT(pluginId);
const content = useMemo(() => rendered && localizeView(rendered, t), [rendered, t]);
const [error, setError] = useState<string | null>(null);
const [viewport, setViewport] = useState<Viewport | null>(null);
const pluginRevision = useViewRevision(pluginId, view);
+1
View File
@@ -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…",
+1
View File
@@ -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": "中",
+5
View File
@@ -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<bool, error>;
/// 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/<tag>.json`.
locale: func() -> string;
}
/// Persistent key-value storage of the plugin (survives restarts and idle