mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
1 parent
e0408cc7d7
commit
adc308eb99
50 files changed
+595
-39
No files matched your search
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -1 +1 @@
|
||||
5de6d0c1cb0ee1a92d36b57ec18590f03829b2c29cc1c306be453931c528c692
|
||||
e6d7996c21d41d1cfe585c1037fbe4b04ba523c5fdba676f0852ee8a8438f6c9
|
||||
+1
-1
@@ -1 +1 @@
|
||||
9a89ec93d158e460998cf472837d7e0e8aa76d5441cb1dd24c3184bd814a29c1
|
||||
2cc22faa9f1a4b4c27d22f740144e5968083444898c36574d1b3e7780e003dc0
|
||||
+1
-1
@@ -1 +1 @@
|
||||
652557045106ab5bd1126cb8e05965d030c4aa28c01ede31be3fe285aab98a67
|
||||
2ed5f73c78e165a494d5f91f6ed2b55682e28a9d4e516553c9332b9d8ab84ab4
|
||||
+1
-1
@@ -1 +1 @@
|
||||
7c8c97e02f754676ff512d10f454a7451468e2a98c2e992e790096215dd28658
|
||||
3274e8023091ec67b018d10e67e6180c3bc875747d51779a3e67ebfe76178907
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1b6e3f30cc49d9d67d95f7655cbe337c5f8465c6ef7b8709cfab4734c3c40fdf
|
||||
6060665c10f800787c9c7d03fbbd7dcdefb9fe475827b6b6902fdd72324f7d98
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3225880631e679780c3c7eb09683b598bb3803fcf7be7b6940369eada633c48f
|
||||
929cbc24e4c07131e8a46aa333632b703909b6d978b36b474207f6ca233cc9c1
|
||||
@@ -1 +1 @@
|
||||
a59623542b4df024f5a85bc2cd0e046189044a0c292c793384fdb5465e8eec48
|
||||
2f26af0ac018d56a0f93a7060ac0a4180b9760c20a98465ef9d4a6f06878455b
|
||||
+1
-1
@@ -1 +1 @@
|
||||
f230ae32c2d5ab469f23c6851fc7830a6ecce7538c7d435be39b5bbebe38a64f
|
||||
7aafd74237661ec2907a4c8950b685a17bed760a10a2fc1062139171636e7f2d
|
||||
+1
-1
@@ -1 +1 @@
|
||||
63f52f8cf8a90affc751ac7e19352a89f0726fc7c8dbbb303172ebd42e23af8c
|
||||
af6ba8a45e97858fbc9c530bdb8e9e56c6229c9715a91bd543399fa0bc7cc04f
|
||||
+1
-1
@@ -1 +1 @@
|
||||
178862630cf39bca56f5d54cd135dcac4a1ab74a3e10356163d00e5a718ae728
|
||||
e9f8ce2cf2d1cc4bcd05f4fed106d11542a2fa4b12e256fe675cbb0aa41dd3af
|
||||
+1
-1
@@ -1 +1 @@
|
||||
a6c188aed4fc57cd9955d6b47ef715c52dc3f8c7b69f7d18ea71b3932888710f
|
||||
737d1a1eb063a6757e93f0359fc32ab2b624ef8c251a4cba31aa3c36f7587002
|
||||
@@ -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`).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 })),
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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": "中",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user