diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f1630c..b19a772 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@ Every release entry is provided in English and Simplified Chinese.
- Added an opt-in terminal network proxy setting. On Windows, new local sessions inherit the system HTTP/HTTPS proxy when enabled; existing sessions keep their environment.
- Added per-host SSH proxy and jump-host options, with separate proxy credentials and a connection-route preview.
- Added an answer reader for captured Claude Code and Codex responses, with Markdown, formulas, source-text mode, and local image previews.
+- Added UI language choices for Traditional Chinese, French, German, Spanish, Brazilian Portuguese, Italian, Russian, Japanese, and Korean alongside English and Simplified Chinese. Initial coverage includes navigation, common actions, appearance, and network controls; untranslated text falls back to English.
+- Added a settings search that opens the matching section and a confirmed Restore defaults action that backs up preferences while retaining saved hosts and other user data.
#### Fixed
- `Ctrl+C` copies and clears a terminal selection, and still sends an interrupt when no selection is present. Text inputs keep their own copy behavior.
@@ -29,6 +31,8 @@ Every release entry is provided in English and Simplified Chinese.
- Added native platform adapters and preview packaging instructions for Linux and macOS. Native build, test and package validation remains a separate requirement before publishing those previews.
- Updated the public project and installation guides to use Pebrel consistently, while retaining the existing repository, command, configuration, and download identifiers.
- Added contribution, architecture, and translation guides with explicit compatibility and review requirements. GitHub branch protection still requires separate server-side setup and verification.
+- Made the settings navigation more compact, kept Application first, labeled terminal options more clearly, and added matching navigation icons. Appearance settings now show font, cursor, interface, and background controls in groups with expandable help.
+- Reduced the intensity of hover and selected backgrounds in light themes.
### 简体中文
@@ -37,6 +41,8 @@ Every release entry is provided in English and Simplified Chinese.
- 新增默认关闭的终端网络代理设置。Windows 下启用后,新建本地会话继承系统 HTTP/HTTPS 代理;现有会话保留原有环境。
- SSH 主机新增独立的代理与跳板配置,支持单独保存代理凭据并预览连接路线。
- 为已捕获的 Claude Code 和 Codex 回答新增阅读视图,支持 Markdown、公式、原文模式和本地图片预览。
+- 在英语和简体中文之外,界面语言新增繁体中文、法语、德语、西班牙语、巴西葡萄牙语、意大利语、俄语、日语及韩语。初版覆盖导航、常用操作、外观与网络控件,未翻译文案回退英文。
+- 新增可跳转到匹配分区的设置搜索,以及需要确认的“恢复默认”操作;重置前备份偏好设置,并保留已保存主机等用户数据。
#### 修复
- 终端中 `Ctrl+C` 会复制并清除选区,没有选区时仍发送中断;文本输入框保留自身的复制行为。
@@ -53,6 +59,8 @@ Every release entry is provided in English and Simplified Chinese.
- 新增 Linux 和 macOS 原生平台适配及预览打包说明;发布这些预览前仍需单独完成原生构建、测试和打包验证。
- 公开项目说明与安装指南统一采用 Pebrel 名称,同时保留现有仓库、命令、配置与下载标识。
- 新增贡献、架构和翻译指南,明确兼容性与审查要求;GitHub 分支保护仍需单独在服务端配置并核验。
+- 设置导航更紧凑,“应用”保持首项,终端选项名称更清晰,并配有含义匹配的图标;外观设置按字体、光标、界面和背景分组,提供可展开的详细说明。
+- 降低浅色主题中悬停与选中背景的色彩强度。
### Contributors
- [@Sakyvo](https://github.com/Sakyvo): terminal network proxy, conditional `Ctrl+C` copy, and word deletion with `Ctrl+Backspace` / 终端网络代理、`Ctrl+C` 按选区复制及 `Ctrl+Backspace` 按词删除。
diff --git a/docs/release-notes/unreleased.md b/docs/release-notes/unreleased.md
index 692e7c3..84fe65c 100644
--- a/docs/release-notes/unreleased.md
+++ b/docs/release-notes/unreleased.md
@@ -9,6 +9,8 @@ This file contains changes that are not part of a numbered release yet.
- Added an opt-in terminal network proxy setting. On Windows, new local sessions inherit the system HTTP/HTTPS proxy when enabled; existing sessions keep their environment.
- Added per-host SSH proxy and jump-host options, with separate proxy credentials and a connection-route preview.
- Added an answer reader for captured Claude Code and Codex responses, with Markdown, formulas, source-text mode, and local image previews.
+- Added UI language choices for Traditional Chinese, French, German, Spanish, Brazilian Portuguese, Italian, Russian, Japanese, and Korean alongside English and Simplified Chinese. Initial coverage includes navigation, common actions, appearance, and network controls; untranslated text falls back to English.
+- Added a settings search that opens the matching section and a confirmed Restore defaults action that backs up preferences while retaining saved hosts and other user data.
### Fixed
- `Ctrl+C` copies and clears a terminal selection, and still sends an interrupt when no selection is present. Text inputs keep their own copy behavior.
@@ -25,6 +27,8 @@ This file contains changes that are not part of a numbered release yet.
- Added native platform adapters and preview packaging instructions for Linux and macOS. Native build, test and package validation remains a separate requirement before publishing those previews.
- Updated the public project and installation guides to use Pebrel consistently, while retaining the existing repository, command, configuration, and download identifiers.
- Added contribution, architecture, and translation guides with explicit compatibility and review requirements. GitHub branch protection still requires separate server-side setup and verification.
+- Made the settings navigation more compact, kept Application first, labeled terminal options more clearly, and added matching navigation icons. Appearance settings now show font, cursor, interface, and background controls in groups with expandable help.
+- Reduced the intensity of hover and selected backgrounds in light themes.
## 中文
@@ -33,6 +37,8 @@ This file contains changes that are not part of a numbered release yet.
- 新增默认关闭的终端网络代理设置。Windows 下启用后,新建本地会话继承系统 HTTP/HTTPS 代理;现有会话保留原有环境。
- SSH 主机新增独立的代理与跳板配置,支持单独保存代理凭据并预览连接路线。
- 为已捕获的 Claude Code 和 Codex 回答新增阅读视图,支持 Markdown、公式、原文模式和本地图片预览。
+- 在英语和简体中文之外,界面语言新增繁体中文、法语、德语、西班牙语、巴西葡萄牙语、意大利语、俄语、日语及韩语。初版覆盖导航、常用操作、外观与网络控件,未翻译文案回退英文。
+- 新增可跳转到匹配分区的设置搜索,以及需要确认的“恢复默认”操作;重置前备份偏好设置,并保留已保存主机等用户数据。
### 修复
- 终端中 `Ctrl+C` 会复制并清除选区,没有选区时仍发送中断;文本输入框保留自身的复制行为。
@@ -49,6 +55,8 @@ This file contains changes that are not part of a numbered release yet.
- 新增 Linux 和 macOS 原生平台适配及预览打包说明;发布这些预览前仍需单独完成原生构建、测试和打包验证。
- 公开项目说明与安装指南统一采用 Pebrel 名称,同时保留现有仓库、命令、配置与下载标识。
- 新增贡献、架构和翻译指南,明确兼容性与审查要求;GitHub 分支保护仍需单独在服务端配置并核验。
+- 设置导航更紧凑,“应用”保持首项,终端选项名称更清晰,并配有含义匹配的图标;外观设置按字体、光标、界面和背景分组,提供可展开的详细说明。
+- 降低浅色主题中悬停与选中背景的色彩强度。
## Contributors
- [@Sakyvo](https://github.com/Sakyvo): terminal network proxy, conditional `Ctrl+C` copy, and word deletion with `Ctrl+Backspace` / 终端网络代理、`Ctrl+C` 按选区复制及 `Ctrl+Backspace` 按词删除。
diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml
index 74420b8..9b3d157 100644
--- a/nebula_app/Cargo.toml
+++ b/nebula_app/Cargo.toml
@@ -2,7 +2,7 @@
name = "nebula"
version = "1.5.0"
license = "GPL-3.0-or-later"
-description = "Nebula Terminal — a premium GPU-accelerated terminal emulator"
+description = "Pebrel — a GPU-accelerated terminal emulator"
readme = "README.md"
homepage = "https://github.com/Kuddev/nebula"
repository = "https://github.com/Kuddev/nebula"
@@ -138,7 +138,9 @@ rusqlite = { version = "0.40.2", features = ["bundled"] }
sha1 = "0.11.0"
[build-dependencies]
+nebula-settings = { path = "../nebula_settings", version = "1.5.0" }
gl_generator = { version = "0.14.0", optional = true }
+serde = "1"
serde_json = "1"
[dev-dependencies]
diff --git a/nebula_app/assets/icons/nebula-keymap.svg b/nebula_app/assets/icons/nebula-keymap.svg
index 20e3aaa..26bef2d 100644
--- a/nebula_app/assets/icons/nebula-keymap.svg
+++ b/nebula_app/assets/icons/nebula-keymap.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/nebula_app/assets/icons/nebula-layout-grid.svg b/nebula_app/assets/icons/nebula-layout-grid.svg
new file mode 100644
index 0000000..9935437
--- /dev/null
+++ b/nebula_app/assets/icons/nebula-layout-grid.svg
@@ -0,0 +1 @@
+
diff --git a/nebula_app/assets/icons/nebula-mouse-pointer.svg b/nebula_app/assets/icons/nebula-mouse-pointer.svg
new file mode 100644
index 0000000..71503b2
--- /dev/null
+++ b/nebula_app/assets/icons/nebula-mouse-pointer.svg
@@ -0,0 +1 @@
+
diff --git a/nebula_app/assets/icons/nebula-pin.svg b/nebula_app/assets/icons/nebula-pin.svg
new file mode 100644
index 0000000..d23daff
--- /dev/null
+++ b/nebula_app/assets/icons/nebula-pin.svg
@@ -0,0 +1,4 @@
+
diff --git a/nebula_app/assets/icons/nebula-sliders.svg b/nebula_app/assets/icons/nebula-sliders.svg
new file mode 100644
index 0000000..542e624
--- /dev/null
+++ b/nebula_app/assets/icons/nebula-sliders.svg
@@ -0,0 +1 @@
+
diff --git a/nebula_app/build.rs b/nebula_app/build.rs
index e430c48..b402db3 100644
--- a/nebula_app/build.rs
+++ b/nebula_app/build.rs
@@ -1,15 +1,21 @@
-use std::collections::BTreeSet;
use std::env;
use std::path::Path;
use std::process::Command;
+#[path = "build/i18n.rs"]
+mod i18n;
+
#[cfg(feature = "legacy-shell")]
use gl_generator::{Api, Fallbacks, GlobalGenerator, Profile, Registry};
#[cfg(feature = "legacy-shell")]
use std::fs::File;
fn main() {
- validate_i18n_catalogs();
+ i18n::generate(
+ &Path::new(&env::var("CARGO_MANIFEST_DIR").unwrap()).join("i18n"),
+ &Path::new(&env::var("OUT_DIR").unwrap()).join("translations.rs"),
+ )
+ .unwrap_or_else(|error| panic!("translation catalog validation failed: {error}"));
let mut version = String::from(env!("CARGO_PKG_VERSION"));
if let Some(commit_hash) = commit_hash() {
@@ -56,57 +62,6 @@ fn generate_legacy_gl_bindings() {
.unwrap();
}
-fn validate_i18n_catalogs() {
- let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
- let i18n_dir = Path::new(&manifest_dir).join("i18n");
- let en_path = i18n_dir.join("en-US.json");
- let zh_path = i18n_dir.join("zh-CN.json");
- println!("cargo:rerun-if-changed={}", en_path.display());
- println!("cargo:rerun-if-changed={}", zh_path.display());
-
- let en = catalog_keys(&en_path, "en-US");
- let zh = catalog_keys(&zh_path, "zh-CN");
- if en != zh {
- let missing_zh = en.difference(&zh).cloned().collect::>();
- let missing_en = zh.difference(&en).cloned().collect::>();
- panic!(
- "translation catalogs must have identical message ids; missing from zh-CN: \
- {missing_zh:?}; missing from en-US: {missing_en:?}"
- );
- }
-}
-
-fn catalog_keys(path: &Path, locale: &str) -> BTreeSet {
- let source = std::fs::read_to_string(path)
- .unwrap_or_else(|error| panic!("failed to read {locale} translation catalog: {error}"));
- let root: serde_json::Value = serde_json::from_str(&source)
- .unwrap_or_else(|error| panic!("invalid {locale} translation catalog: {error}"));
- let mut keys = BTreeSet::new();
- collect_catalog_keys(&root, "", &mut keys, locale);
- keys
-}
-
-fn collect_catalog_keys(
- value: &serde_json::Value,
- prefix: &str,
- keys: &mut BTreeSet,
- locale: &str,
-) {
- match value {
- serde_json::Value::Object(object) => {
- for (key, value) in object {
- let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") };
- collect_catalog_keys(value, &path, keys, locale);
- }
- },
- serde_json::Value::String(_) => {
- assert!(!prefix.is_empty(), "{locale} translation catalog contains an empty id");
- assert!(keys.insert(prefix.to_owned()), "duplicate {locale} message id: {prefix}");
- },
- _ => panic!("{locale} translation catalog leaf {prefix} must be a string"),
- }
-}
-
/// Copy the side-by-side new-ConPTY (`conpty.dll` + `OpenConsole.exe`) next to
/// the built exe so the matching OpenConsole host is selected at runtime.
///
diff --git a/nebula_app/build/i18n.rs b/nebula_app/build/i18n.rs
new file mode 100644
index 0000000..0049386
--- /dev/null
+++ b/nebula_app/build/i18n.rs
@@ -0,0 +1,221 @@
+use std::collections::{BTreeMap, BTreeSet};
+use std::fmt::Write;
+use std::path::Path;
+
+use nebula_settings::LanguagePref;
+
+#[path = "i18n/catalog.rs"]
+mod catalog;
+
+pub fn generate(directory: &Path, output: &Path) -> Result<(), String> {
+ let mut catalogs = BTreeMap::new();
+ for info in LanguagePref::LANGUAGES {
+ let path = directory.join(format!("{}.json", info.code));
+ println!("cargo:rerun-if-changed={}", path.display());
+ let source = std::fs::read_to_string(&path)
+ .map_err(|error| format!("{}: {error}", path.display()))?;
+ let messages =
+ catalog::parse(&source).map_err(|error| format!("{}: {error}", info.code))?;
+ catalogs.insert(info.code, messages);
+ }
+ let english = &catalogs["en-US"];
+ let chinese = &catalogs["zh-CN"];
+ if english.is_empty() || !english.keys().eq(chinese.keys()) {
+ return Err(
+ "English and Simplified Chinese must have identical nonempty message ids".into()
+ );
+ }
+ for (locale, messages) in &catalogs {
+ for (key, message) in messages {
+ let base =
+ english.get(key).ok_or_else(|| format!("{locale}: unknown message id {key}"))?;
+ if catalog::placeholders(base)? != catalog::placeholders(message)? {
+ return Err(format!("{locale}: placeholder mismatch for {key}"));
+ }
+ }
+ }
+ let mut variants = BTreeSet::new();
+ let keys = english.keys().collect::>();
+ let mut code = String::new();
+ code.push_str(
+ "#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n#[repr(usize)]\npub enum Message {\n",
+ );
+ for key in &keys {
+ let variant = message_variant(key)?;
+ if !variants.insert(variant.clone()) {
+ return Err(format!("message variant collision: {key}"));
+ }
+ writeln!(code, "{variant},").unwrap();
+ }
+ code.push_str(
+ "}\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n#[repr(usize)]\npub enum UiLanguage {\n",
+ );
+ for info in LanguagePref::LANGUAGES {
+ writeln!(code, "{},", info.rust_variant).unwrap();
+ }
+ code.push_str("}\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]\npub enum LanguagePreference {\n#[default]\nSystem,\n");
+ for info in LanguagePref::LANGUAGES {
+ writeln!(code, "{},", info.rust_variant).unwrap();
+ }
+ code.push_str("}\nimpl LanguagePreference {\npub const ALL: &'static [Self] = &[Self::System,");
+ for info in LanguagePref::LANGUAGES {
+ write!(code, "Self::{},", info.rust_variant).unwrap();
+ }
+ code.push_str("];\npub const fn shared(self) -> nebula_settings::LanguagePref { match self {\nSelf::System => nebula_settings::LanguagePref::System,\n");
+ for info in LanguagePref::LANGUAGES {
+ writeln!(code, "Self::{0} => nebula_settings::LanguagePref::{0},", info.rust_variant)
+ .unwrap();
+ }
+ code.push_str("}}\npub const fn explicit(self) -> Option { match self {\nSelf::System => None,\n");
+ for info in LanguagePref::LANGUAGES {
+ writeln!(code, "Self::{0} => Some(UiLanguage::{0}),", info.rust_variant).unwrap();
+ }
+ code.push_str("}}}\nimpl From for LanguagePreference {\nfn from(value: nebula_settings::LanguagePref) -> Self {match value {\nnebula_settings::LanguagePref::System => Self::System,\n");
+ for info in LanguagePref::LANGUAGES {
+ writeln!(code, "nebula_settings::LanguagePref::{0} => Self::{0},", info.rust_variant)
+ .unwrap();
+ }
+ code.push_str("}}}\nimpl UiLanguage {\npub const ALL: &'static [Self] = &[");
+ for info in LanguagePref::LANGUAGES {
+ write!(code, "Self::{},", info.rust_variant).unwrap();
+ }
+ code.push_str("];\npub const fn code(self) -> &'static str { match self {\n");
+ for info in LanguagePref::LANGUAGES {
+ writeln!(code, "Self::{} => {:?},", info.rust_variant, info.code).unwrap();
+ }
+ code.push_str("}}\npub const fn gpui_component_locale(self) -> &'static str { match self {\n");
+ for info in LanguagePref::LANGUAGES {
+ writeln!(code, "Self::{} => {:?},", info.rust_variant, info.component_locale).unwrap();
+ }
+ code.push_str("}}}\nfn message_id(key: &str) -> Option { match key {\n");
+ for key in &keys {
+ writeln!(code, "{key:?} => Some(Message::{}),", message_variant(key)?).unwrap();
+ }
+ code.push_str("_ => None,\n}}\n");
+ let mut aliases = BTreeMap::new();
+ let mut ambiguous = BTreeSet::new();
+ for (index, key) in keys.iter().enumerate() {
+ let source = &english[*key];
+ if let Some(previous) = aliases.insert(source, index) {
+ if catalogs.values().any(|messages| messages.get(*key) != messages.get(keys[previous]))
+ {
+ ambiguous.insert(source);
+ }
+ }
+ }
+ code.push_str("fn source_id(english: &str) -> Option { match english {\n");
+ for (source, index) in aliases {
+ if !ambiguous.contains(source) {
+ writeln!(code, "{source:?} => Some(Message::{}),", message_variant(keys[index])?)
+ .unwrap();
+ }
+ }
+ code.push_str("_ => None,\n}}\n");
+ writeln!(code, "const MESSAGES: [[&str; {}]; {}] = [", keys.len(), catalogs.len()).unwrap();
+ for info in LanguagePref::LANGUAGES {
+ code.push_str("[\n");
+ for key in &keys {
+ let message = catalogs[info.code].get(*key).unwrap_or(&english[*key]);
+ writeln!(code, "{message:?},").unwrap();
+ }
+ code.push_str("],\n");
+ }
+ code.push_str("];\n");
+ writeln!(code, "pub const MESSAGE_COUNT: usize = {};", keys.len()).unwrap();
+ let bytes =
+ catalogs.values().flat_map(|messages| messages.values()).map(String::len).sum::();
+ writeln!(code, "pub const TRANSLATED_BYTES: usize = {bytes};").unwrap();
+ std::fs::write(output, code).map_err(|error| error.to_string())
+}
+
+fn message_variant(key: &str) -> Result {
+ let mut variant = String::new();
+ for part in key.split(['.', '_', '-']) {
+ let mut letters = part.chars();
+ let first = letters.next().ok_or_else(|| format!("empty id segment: {key}"))?;
+ if !first.is_ascii_alphabetic()
+ || !letters.clone().all(|letter| letter.is_ascii_alphanumeric())
+ {
+ return Err(format!("invalid message id: {key}"));
+ }
+ variant.push(first.to_ascii_uppercase());
+ variant.extend(letters);
+ }
+ Ok(variant)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::catalog;
+
+ fn fixture(english: &str, chinese: &str, french: &str) -> Result {
+ static SEQUENCE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
+ let sequence = SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let root = std::env::temp_dir()
+ .join(format!("pebrel-catalog-test-{}-{sequence}", std::process::id()));
+ std::fs::create_dir(&root).unwrap();
+ for info in nebula_settings::LanguagePref::LANGUAGES {
+ let source = match info.code {
+ "en-US" => english,
+ "zh-CN" => chinese,
+ "fr-FR" => french,
+ _ => "{}",
+ };
+ std::fs::write(root.join(format!("{}.json", info.code)), source).unwrap();
+ }
+ let output = root.join("generated.rs");
+ let result = super::generate(&root, &output)
+ .and_then(|()| std::fs::read_to_string(output).map_err(|error| error.to_string()));
+ std::fs::remove_dir_all(&root).unwrap();
+ result
+ }
+
+ #[test]
+ fn partial_catalogs_fall_back_at_build_time() {
+ let source =
+ fixture(r#"{"greeting":"Hello {name}"}"#, r#"{"greeting":"你好 {name}"}"#, "{}")
+ .unwrap();
+ assert!(source.contains("pub enum Message"));
+ assert!(source.contains("Greeting,"));
+ assert!(source.matches("\"Hello {name}\",").count() >= 10);
+ }
+
+ #[test]
+ fn rejects_unknown_keys_placeholder_mismatches_and_id_collisions() {
+ let english = r#"{"greeting":"Hello {name}"}"#;
+ let chinese = r#"{"greeting":"你好 {name}"}"#;
+ assert!(fixture(english, chinese, r#"{"extra":"Unknown"}"#).is_err());
+ assert!(fixture(english, chinese, r#"{"greeting":"Bonjour {user}"}"#).is_err());
+ let colliding = r#"{"a_b":"First","a":{"b":"Second"}}"#;
+ assert!(fixture(colliding, colliding, "{}").is_err());
+ }
+
+ #[test]
+ fn ambiguous_source_phrases_require_contextual_ids() {
+ let source = fixture(
+ r#"{"verb":"Open","state":"Open"}"#,
+ r#"{"verb":"打开","state":"打开状态"}"#,
+ r#"{"verb":"Ouvrir","state":"Ouvert"}"#,
+ )
+ .unwrap();
+ assert!(!source.contains("\"Open\" => Some("));
+ assert!(source.contains("\"verb\" => Some(Message::Verb)"));
+ }
+
+ #[test]
+ fn rejects_duplicate_empty_and_nonstring_messages() {
+ for source in
+ [r#"{"a":"A","a":"B"}"#, r#"{"a.b":"A","a":{"b":"B"}}"#, r#"{"a":null}"#, r#"{"a":""}"#]
+ {
+ assert!(catalog::parse(source).is_err(), "{source}");
+ }
+ }
+
+ #[test]
+ fn placeholders_support_unicode_text_repetitions_and_escaped_braces() {
+ let names = catalog::placeholders("你好 {name}: {{code}} {name} {count}").unwrap();
+ assert_eq!(names.into_iter().collect::>(), ["count", "name"]);
+ assert!(catalog::placeholders("broken {name").is_err());
+ assert!(catalog::placeholders("broken }").is_err());
+ }
+}
diff --git a/nebula_app/build/i18n/catalog.rs b/nebula_app/build/i18n/catalog.rs
new file mode 100644
index 0000000..51cca07
--- /dev/null
+++ b/nebula_app/build/i18n/catalog.rs
@@ -0,0 +1,99 @@
+use std::collections::BTreeMap;
+use std::fmt;
+
+use serde::Deserialize;
+use serde::de::{self, MapAccess, Visitor};
+
+enum Node {
+ Text(String),
+ Group(BTreeMap),
+}
+
+impl<'de> Deserialize<'de> for Node {
+ fn deserialize(deserializer: Deserializer) -> Result
+ where
+ Deserializer: de::Deserializer<'de>,
+ {
+ struct NodeVisitor;
+ impl<'de> Visitor<'de> for NodeVisitor {
+ type Value = Node;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("a translation string or object")
+ }
+
+ fn visit_str(self, value: &str) -> Result {
+ Ok(Node::Text(value.to_owned()))
+ }
+
+ fn visit_string(self, value: String) -> Result {
+ Ok(Node::Text(value))
+ }
+
+ fn visit_map>(self, mut map: Map) -> Result {
+ let mut entries = BTreeMap::new();
+ while let Some((key, value)) = map.next_entry::()? {
+ if key.is_empty() || entries.insert(key.clone(), value).is_some() {
+ return Err(de::Error::custom(format!("empty or duplicate key: {key:?}")));
+ }
+ }
+ Ok(Node::Group(entries))
+ }
+ }
+ deserializer.deserialize_any(NodeVisitor)
+ }
+}
+
+pub fn parse(source: &str) -> Result, String> {
+ fn flatten(
+ node: Node,
+ prefix: &str,
+ output: &mut BTreeMap,
+ ) -> Result<(), String> {
+ match node {
+ Node::Text(message) => {
+ if prefix.is_empty() || message.trim().is_empty() {
+ return Err(format!("empty message or id: {prefix:?}"));
+ }
+ if output.insert(prefix.to_owned(), message).is_some() {
+ return Err(format!("duplicate flattened id: {prefix}"));
+ }
+ },
+ Node::Group(entries) => {
+ for (key, value) in entries {
+ let key = if prefix.is_empty() { key } else { format!("{prefix}.{key}") };
+ flatten(value, &key, output)?;
+ }
+ },
+ }
+ Ok(())
+ }
+ let root = serde_json::from_str(source).map_err(|error| error.to_string())?;
+ let mut output = BTreeMap::new();
+ flatten(root, "", &mut output)?;
+ Ok(output)
+}
+
+pub fn placeholders(message: &str) -> Result, String> {
+ let mut names = std::collections::BTreeSet::new();
+ let mut rest = message;
+ while let Some(position) = rest.find(['{', '}']) {
+ rest = &rest[position..];
+ if rest.starts_with("{{") || rest.starts_with("}}") {
+ rest = &rest[2..];
+ continue;
+ }
+ if !rest.starts_with('{') {
+ return Err(format!("unmatched closing brace: {message}"));
+ }
+ let end = rest.find('}').ok_or_else(|| format!("unclosed placeholder: {message}"))?;
+ let name = &rest[1..end];
+ if name.is_empty() || !name.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
+ {
+ return Err(format!("invalid placeholder {name:?}"));
+ }
+ names.insert(name);
+ rest = &rest[end + 1..];
+ }
+ Ok(names)
+}
diff --git a/nebula_app/i18n/de-DE.json b/nebula_app/i18n/de-DE.json
new file mode 100644
index 0000000..af6269f
--- /dev/null
+++ b/nebula_app/i18n/de-DE.json
@@ -0,0 +1,177 @@
+{
+ "language": {
+ "system": "Systemeinstellung",
+ "self_name": "Deutsch",
+ "zh_cn": "简体中文",
+ "en_us": "English"
+ },
+ "settings": {
+ "sidebar": {
+ "application": "Anwendung",
+ "appearance": "Darstellung",
+ "profiles": "Terminal",
+ "providers": "KI-Anbieter",
+ "ssh": "SSH",
+ "network": "Netzwerk",
+ "interaction": "Interaktion",
+ "keymap": "Tastenbelegung",
+ "advanced": "Erweitert",
+ "backup": "Sicherung"
+ },
+ "network": {
+ "title": "Netzwerkproxy",
+ "mode": {
+ "label": "Proxymodus",
+ "off": "Kein Proxy",
+ "system": "Systemeinstellung",
+ "custom": "Benutzerdefinierter Proxy"
+ },
+ "address": {
+ "label": "Proxyadresse"
+ },
+ "action": {
+ "test": "Netzwerk testen",
+ "testing": "Test läuft…"
+ },
+ "route": {
+ "direct": "Direktverbindung",
+ "direct_address": "Direkte Adresse",
+ "custom_command": "Benutzerdefinierter Befehl"
+ }
+ },
+ "font": {
+ "loading": "Systemschriftarten werden geladen…",
+ "no_matches": "Keine passenden Schriftarten"
+ }
+ },
+ "common": {
+ "settings": "Einstellungen",
+ "language": "Sprache",
+ "about": "Über",
+ "theme": "Design",
+ "app_icon": "App-Symbol",
+ "application_icon": "Anwendungssymbol",
+ "font": "Schriftart",
+ "font_size": "Schriftgröße",
+ "size": "Größe",
+ "terminal_font": "Terminalschriftart",
+ "font_preview": "Schriftvorschau",
+ "increase_font": "Schrift vergrößern",
+ "decrease_font": "Schrift verkleinern",
+ "change_theme": "Design ändern",
+ "change_icon": "Symbol ändern",
+ "restore_defaults": "Standard wiederherstellen",
+ "reset": "Zurücksetzen",
+ "save": "Speichern",
+ "saved": "Gespeichert",
+ "cancel": "Abbrechen",
+ "confirm": "Bestätigen",
+ "close": "Schließen",
+ "open": "Öffnen",
+ "add": "Hinzufügen",
+ "remove": "Entfernen",
+ "delete": "Löschen",
+ "edit": "Bearbeiten",
+ "rename": "Umbenennen",
+ "copy": "Kopieren",
+ "paste": "Einfügen",
+ "cut": "Ausschneiden",
+ "select_all": "Alles auswählen",
+ "clear": "Leeren",
+ "search": "Suchen",
+ "search_settings": "Einstellungen suchen",
+ "no_results": "Keine Ergebnisse",
+ "no_matches": "Keine Treffer",
+ "refresh": "Aktualisieren",
+ "retry": "Erneut versuchen",
+ "loading": "Wird geladen…",
+ "on": "Ein",
+ "off": "Aus",
+ "enabled": "Aktiviert",
+ "disabled": "Deaktiviert",
+ "default": "Standard",
+ "custom": "Benutzerdefiniert",
+ "none": "Keine",
+ "auto": "Automatisch",
+ "all": "Alle",
+ "name": "Name",
+ "path": "Pfad",
+ "directory": "Verzeichnis",
+ "files": "Dateien",
+ "new_file": "Neue Datei",
+ "new_folder": "Neuer Ordner",
+ "new_tab": "Neuer Tab",
+ "new_window": "Neues Fenster",
+ "close_tab": "Tab schließen",
+ "duplicate_tab": "Tab duplizieren",
+ "terminal": "Terminal",
+ "connect": "Verbinden",
+ "disconnect": "Trennen",
+ "connected": "Verbunden",
+ "connecting": "Verbindung wird hergestellt…",
+ "host": "Host",
+ "username": "Benutzername",
+ "password": "Passwort",
+ "port": "Port",
+ "authentication": "Authentifizierung",
+ "private_key": "Privater Schlüssel",
+ "upload": "Hochladen",
+ "download": "Herunterladen",
+ "overwrite": "Überschreiben",
+ "skip": "Überspringen",
+ "keep_both": "Beide behalten",
+ "import": "Importieren",
+ "export": "Exportieren",
+ "choose": "Auswählen",
+ "browse": "Durchsuchen",
+ "version": "Version",
+ "check_updates": "Nach Updates suchen",
+ "checking": "Wird geprüft…",
+ "not_checked": "Noch nicht geprüft",
+ "up_to_date": "Aktuell",
+ "update_available": "Update verfügbar",
+ "view_update": "Update anzeigen",
+ "install_update": "Update installieren",
+ "later": "Später",
+ "light": "Hell",
+ "dark": "Dunkel",
+ "opacity": "Deckkraft",
+ "background": "Hintergrund",
+ "background_image": "Hintergrundbild",
+ "background_color": "Hintergrundfarbe",
+ "foreground": "Vordergrund",
+ "text": "Text",
+ "standard": "Standard",
+ "compact": "Kompakt",
+ "relaxed": "Locker",
+ "left": "Links",
+ "right": "Rechts",
+ "top": "Oben",
+ "bottom": "Unten",
+ "center": "Mitte",
+ "left_sidebar": "Linke Seitenleiste",
+ "scroll": "Scrollen",
+ "cursor": "Cursor",
+ "notifications": "Benachrichtigungen",
+ "sound": "Ton",
+ "visual": "Visuell",
+ "success": "Erfolgreich",
+ "failed": "Fehlgeschlagen",
+ "warning": "Warnung",
+ "details": "Details",
+ "help": "Hilfe",
+ "shortcuts": "Tastenkürzel",
+ "command": "Befehl",
+ "run": "Ausführen",
+ "stop": "Anhalten",
+ "history": "Verlauf",
+ "saved_commands": "Gespeicherte Befehle",
+ "session": "Sitzung",
+ "key": "Taste",
+ "action": "Aktion",
+ "update_notice": "Pebrel v{latest} ist verfügbar (aktuell v{current})",
+ "copied_lines": "{lines} Zeilen in die Zwischenablage kopiert",
+ "paste_lines": "{lines} Zeilen einfügen?",
+ "sent_to": "An {target} gesendet"
+ }
+}
diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json
index 16fe149..140eef6 100644
--- a/nebula_app/i18n/en-US.json
+++ b/nebula_app/i18n/en-US.json
@@ -9,7 +9,7 @@
"sidebar": {
"application": "Application",
"appearance": "Appearance",
- "profiles": "Profiles",
+ "profiles": "Terminal",
"providers": "AI Providers",
"ssh": "SSH",
"network": "Network",
@@ -114,5 +114,138 @@
"http_status": "Service returned HTTP {status}",
"start_failed": "Could not start the provider test: {error}"
}
+ },
+ "common": {
+ "settings": "Settings",
+ "language": "Language",
+ "about": "About",
+ "theme": "Theme",
+ "app_icon": "App icon",
+ "application_icon": "Application icon",
+ "font": "Font",
+ "font_size": "Font size",
+ "size": "Size",
+ "terminal_font": "Terminal font",
+ "font_preview": "Font preview",
+ "increase_font": "Increase font size",
+ "decrease_font": "Decrease font size",
+ "change_theme": "Change theme",
+ "change_icon": "Change icon",
+ "restore_defaults": "Restore defaults",
+ "reset": "Reset",
+ "save": "Save",
+ "saved": "Saved",
+ "cancel": "Cancel",
+ "confirm": "Confirm",
+ "close": "Close",
+ "open": "Open",
+ "add": "Add",
+ "remove": "Remove",
+ "delete": "Delete",
+ "edit": "Edit",
+ "rename": "Rename",
+ "copy": "Copy",
+ "paste": "Paste",
+ "cut": "Cut",
+ "select_all": "Select all",
+ "clear": "Clear",
+ "search": "Search",
+ "search_settings": "Search settings",
+ "no_results": "No results",
+ "no_matches": "No matches",
+ "refresh": "Refresh",
+ "retry": "Retry",
+ "loading": "Loading...",
+ "on": "On",
+ "off": "Off",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "default": "Default",
+ "custom": "Custom",
+ "none": "None",
+ "auto": "Auto",
+ "all": "All",
+ "name": "Name",
+ "path": "Path",
+ "directory": "Directory",
+ "files": "Files",
+ "new_file": "New file",
+ "new_folder": "New folder",
+ "new_tab": "New tab",
+ "new_window": "New window",
+ "close_tab": "Close tab",
+ "duplicate_tab": "Duplicate tab",
+ "terminal": "Terminal",
+ "connect": "Connect",
+ "disconnect": "Disconnect",
+ "connected": "Connected",
+ "connecting": "Connecting...",
+ "host": "Host",
+ "username": "Username",
+ "password": "Password",
+ "port": "Port",
+ "authentication": "Authentication",
+ "private_key": "Private key",
+ "upload": "Upload",
+ "download": "Download",
+ "overwrite": "Overwrite",
+ "skip": "Skip",
+ "keep_both": "Keep both",
+ "import": "Import",
+ "export": "Export",
+ "choose": "Choose",
+ "browse": "Browse",
+ "version": "Version",
+ "check_updates": "Check for updates",
+ "checking": "Checking...",
+ "not_checked": "Not checked yet",
+ "up_to_date": "Up to date",
+ "update_available": "Update available",
+ "view_update": "View update",
+ "install_update": "Install update",
+ "later": "Later",
+ "light": "Light",
+ "dark": "Dark",
+ "opacity": "Opacity",
+ "background": "Background",
+ "background_image": "Background image",
+ "background_color": "Background color",
+ "foreground": "Foreground",
+ "text": "Text",
+ "standard": "Standard",
+ "compact": "Compact",
+ "relaxed": "Relaxed",
+ "left": "Left",
+ "right": "Right",
+ "top": "Top",
+ "bottom": "Bottom",
+ "center": "Center",
+ "left_sidebar": "Left sidebar",
+ "scroll": "Scroll",
+ "cursor": "Cursor",
+ "notifications": "Notifications",
+ "sound": "Sound",
+ "visual": "Visual",
+ "success": "Success",
+ "failed": "Failed",
+ "warning": "Warning",
+ "details": "Details",
+ "help": "Help",
+ "shortcuts": "Keyboard shortcuts",
+ "command": "Command",
+ "run": "Run",
+ "stop": "Stop",
+ "history": "History",
+ "saved_commands": "Saved commands",
+ "session": "Session",
+ "key": "Key",
+ "action": "Action",
+ "update_notice": "Pebrel v{latest} is available (current v{current})",
+ "copied_lines": "Copied {lines} lines to clipboard",
+ "paste_lines": "Paste {lines} lines?",
+ "sent_to": "Sent to {target}",
+ "yes": "Yes",
+ "no": "No",
+ "confirmation_expired": "The confirmation request changed or is no longer active."
}
}
diff --git a/nebula_app/i18n/es-ES.json b/nebula_app/i18n/es-ES.json
new file mode 100644
index 0000000..080b3c6
--- /dev/null
+++ b/nebula_app/i18n/es-ES.json
@@ -0,0 +1,177 @@
+{
+ "language": {
+ "system": "Seguir el sistema",
+ "self_name": "Español",
+ "zh_cn": "简体中文",
+ "en_us": "English"
+ },
+ "settings": {
+ "sidebar": {
+ "application": "Aplicación",
+ "appearance": "Apariencia",
+ "profiles": "Terminal",
+ "providers": "Proveedores de IA",
+ "ssh": "SSH",
+ "network": "Red",
+ "interaction": "Interacción",
+ "keymap": "Atajos de teclado",
+ "advanced": "Avanzado",
+ "backup": "Copia de seguridad"
+ },
+ "network": {
+ "title": "Proxy de red",
+ "mode": {
+ "label": "Modo de proxy",
+ "off": "Sin proxy",
+ "system": "Seguir el sistema",
+ "custom": "Proxy personalizado"
+ },
+ "address": {
+ "label": "Dirección del proxy"
+ },
+ "action": {
+ "test": "Probar red",
+ "testing": "Probando…"
+ },
+ "route": {
+ "direct": "Conexión directa",
+ "direct_address": "Dirección directa",
+ "custom_command": "Comando personalizado"
+ }
+ },
+ "font": {
+ "loading": "Cargando fuentes del sistema…",
+ "no_matches": "No hay fuentes coincidentes"
+ }
+ },
+ "common": {
+ "settings": "Configuración",
+ "language": "Idioma",
+ "about": "Acerca de",
+ "theme": "Tema",
+ "app_icon": "Icono de la aplicación",
+ "application_icon": "Icono de la aplicación",
+ "font": "Fuente",
+ "font_size": "Tamaño de fuente",
+ "size": "Tamaño",
+ "terminal_font": "Fuente del terminal",
+ "font_preview": "Vista previa de fuente",
+ "increase_font": "Aumentar fuente",
+ "decrease_font": "Reducir fuente",
+ "change_theme": "Cambiar tema",
+ "change_icon": "Cambiar icono",
+ "restore_defaults": "Restaurar valores predeterminados",
+ "reset": "Restablecer",
+ "save": "Guardar",
+ "saved": "Guardado",
+ "cancel": "Cancelar",
+ "confirm": "Confirmar",
+ "close": "Cerrar",
+ "open": "Abrir",
+ "add": "Añadir",
+ "remove": "Quitar",
+ "delete": "Eliminar",
+ "edit": "Editar",
+ "rename": "Cambiar nombre",
+ "copy": "Copiar",
+ "paste": "Pegar",
+ "cut": "Cortar",
+ "select_all": "Seleccionar todo",
+ "clear": "Limpiar",
+ "search": "Buscar",
+ "search_settings": "Buscar ajustes",
+ "no_results": "Sin resultados",
+ "no_matches": "Sin coincidencias",
+ "refresh": "Actualizar",
+ "retry": "Reintentar",
+ "loading": "Cargando…",
+ "on": "Activado",
+ "off": "Desactivado",
+ "enabled": "Habilitado",
+ "disabled": "Deshabilitado",
+ "default": "Predeterminado",
+ "custom": "Personalizado",
+ "none": "Ninguno",
+ "auto": "Automático",
+ "all": "Todo",
+ "name": "Nombre",
+ "path": "Ruta",
+ "directory": "Directorio",
+ "files": "Archivos",
+ "new_file": "Nuevo archivo",
+ "new_folder": "Nueva carpeta",
+ "new_tab": "Nueva pestaña",
+ "new_window": "Nueva ventana",
+ "close_tab": "Cerrar pestaña",
+ "duplicate_tab": "Duplicar pestaña",
+ "terminal": "Terminal",
+ "connect": "Conectar",
+ "disconnect": "Desconectar",
+ "connected": "Conectado",
+ "connecting": "Conectando…",
+ "host": "Servidor",
+ "username": "Nombre de usuario",
+ "password": "Contraseña",
+ "port": "Puerto",
+ "authentication": "Autenticación",
+ "private_key": "Clave privada",
+ "upload": "Subir",
+ "download": "Descargar",
+ "overwrite": "Sobrescribir",
+ "skip": "Omitir",
+ "keep_both": "Conservar ambos",
+ "import": "Importar",
+ "export": "Exportar",
+ "choose": "Elegir",
+ "browse": "Examinar",
+ "version": "Versión",
+ "check_updates": "Buscar actualizaciones",
+ "checking": "Comprobando…",
+ "not_checked": "Sin comprobar",
+ "up_to_date": "Actualizado",
+ "update_available": "Actualización disponible",
+ "view_update": "Ver actualización",
+ "install_update": "Instalar actualización",
+ "later": "Más tarde",
+ "light": "Claro",
+ "dark": "Oscuro",
+ "opacity": "Opacidad",
+ "background": "Fondo",
+ "background_image": "Imagen de fondo",
+ "background_color": "Color de fondo",
+ "foreground": "Primer plano",
+ "text": "Texto",
+ "standard": "Estándar",
+ "compact": "Compacto",
+ "relaxed": "Amplio",
+ "left": "Izquierda",
+ "right": "Derecha",
+ "top": "Arriba",
+ "bottom": "Abajo",
+ "center": "Centro",
+ "left_sidebar": "Barra lateral izquierda",
+ "scroll": "Desplazar",
+ "cursor": "Cursor",
+ "notifications": "Notificaciones",
+ "sound": "Sonido",
+ "visual": "Visual",
+ "success": "Correcto",
+ "failed": "Error",
+ "warning": "Advertencia",
+ "details": "Detalles",
+ "help": "Ayuda",
+ "shortcuts": "Atajos de teclado",
+ "command": "Comando",
+ "run": "Ejecutar",
+ "stop": "Detener",
+ "history": "Historial",
+ "saved_commands": "Comandos guardados",
+ "session": "Sesión",
+ "key": "Tecla",
+ "action": "Acción",
+ "update_notice": "Pebrel v{latest} está disponible (actual: v{current})",
+ "copied_lines": "Se copiaron {lines} líneas al portapapeles",
+ "paste_lines": "¿Pegar {lines} líneas?",
+ "sent_to": "Enviado a {target}"
+ }
+}
diff --git a/nebula_app/i18n/fr-FR.json b/nebula_app/i18n/fr-FR.json
new file mode 100644
index 0000000..1c2afd0
--- /dev/null
+++ b/nebula_app/i18n/fr-FR.json
@@ -0,0 +1,248 @@
+{
+ "language": {
+ "system": "Suivre le système",
+ "self_name": "Français",
+ "zh_cn": "简体中文",
+ "en_us": "English"
+ },
+ "settings": {
+ "sidebar": {
+ "application": "Application",
+ "appearance": "Apparence",
+ "profiles": "Terminal",
+ "providers": "Fournisseurs d’IA",
+ "ssh": "SSH",
+ "network": "Réseau",
+ "interaction": "Interaction",
+ "keymap": "Raccourcis clavier",
+ "advanced": "Avancé",
+ "backup": "Sauvegarde"
+ },
+ "network": {
+ "title": "Proxy réseau",
+ "mode": {
+ "label": "Mode proxy",
+ "off": "Sans proxy",
+ "system": "Suivre le système",
+ "custom": "Proxy personnalisé"
+ },
+ "address": {
+ "label": "Adresse du proxy"
+ },
+ "action": {
+ "test": "Tester le réseau",
+ "testing": "Test en cours…"
+ },
+ "route": {
+ "direct": "Connexion directe",
+ "direct_address": "Adresse directe",
+ "custom_command": "Commande personnalisée",
+ "proxy_server": "Proxy {server}",
+ "ssh_jump": "SSH {target}"
+ },
+ "status": {
+ "idle": "Vérifier l’accès au réseau avec les paramètres actuels",
+ "running": "Test de l’accès au réseau avec les paramètres actuels…",
+ "success": "Connexion réseau réussie via {route} en {elapsed_ms} ms",
+ "failed": "Échec du test : {error}",
+ "unexpected_end": "Le test réseau s’est arrêté de manière inattendue ; réessayez",
+ "start_failed": "Impossible de démarrer le test réseau : {error}"
+ },
+ "failure": {
+ "load_settings": "Impossible de lire les paramètres du proxy : {error}",
+ "invalid_settings": "Les paramètres du proxy sont invalides : {error}",
+ "timeout": "Le test réseau a expiré après {seconds} secondes",
+ "send_request": "Impossible d’envoyer la requête de test : {error}",
+ "read_response": "Impossible de lire la réponse du test : {error}",
+ "invalid_http_status_line": "Le site de test n’a pas renvoyé de ligne de statut HTTP valide : {line}",
+ "http_status": "Le site de test a renvoyé HTTP {status}",
+ "proxy_server": "Impossible d’accéder au réseau via le proxy {server} : {error}",
+ "custom_command": "La commande de proxy personnalisée a échoué : {error}",
+ "jump_task": "Impossible de préparer le serveur de rebond SSH : {error}",
+ "jump_resolve": "Impossible de résoudre le serveur de rebond SSH {target} : {error}",
+ "jump_connect": "Impossible de se connecter au serveur de rebond SSH {target} : {error}",
+ "jump_channel": "Impossible d’ouvrir un canal sortant via le serveur de rebond SSH {target} : {error}",
+ "direct": "Impossible de se connecter directement au site de test : {error}"
+ }
+ },
+ "font": {
+ "loading": "Chargement des polices système…",
+ "no_matches": "Aucune police correspondante",
+ "fallback_description": "La première famille est prioritaire ; les glyphes manquants sont recherchés dans les familles suivantes, puis dans la police Maple intégrée en dernier recours.",
+ "group_description": "La première famille est principale ; les autres sont utilisées dans l’ordre de repli."
+ },
+ "appearance": {
+ "background_color_description": "Remplace le fond du terminal fourni par le thème. Cela modifie aussi la couleur signalée aux applications par OSC 11, utilisée par les interfaces en terminal pour choisir le mode clair ou sombre.",
+ "background_cover_title": "Étendre l’image d’arrière-plan sous les commandes de la fenêtre ?",
+ "background_cover_description": "L’image s’étendra sous la barre de titre, les boutons de fenêtre, les onglets et la barre latérale SSH. Les images peu contrastées peuvent rendre les commandes moins visibles ; une opacité minimale est conservée."
+ },
+ "keymap": {
+ "conflict": "{combo} est affecté à « {first} » et « {second} » ; seule la première action « {first} » sera exécutée.",
+ "help": "Cliquez sur une ligne pour réaffecter · Retour arrière rétablit la valeur par défaut · Échap annule"
+ },
+ "input": {
+ "backup_secret": "Les identifiants sont stockés uniquement dans le gestionnaire d’identifiants du système"
+ },
+ "ssh": {
+ "default_keys": "Sans indication, IdentityFile et les clés privées id_* par défaut sont utilisés.",
+ "address_hint": "Accepte user@host ; vous pouvez aussi coller ssh://host:2222",
+ "auth_auto_description": "Essaie d’abord les clés privées disponibles, puis demande un mot de passe. Ce mode convient à la plupart des cas.",
+ "auth_interactive_description": "Ne conserve aucun identifiant et les demande dans le terminal à la connexion, y compris pour l’authentification à deux facteurs.",
+ "save_password": "Enregistrer dans le Gestionnaire d’identifiants Windows",
+ "hide_config_host": "Masquer (les sources de configuration ne peuvent pas être supprimées)",
+ "empty_hint": "Utilisez « + Ajouter un hôte » ou gérez les alias dans ~/.ssh/config",
+ "shared_data_hint": "Partage les données avec l’ancienne interface. Supprimer un alias de configuration le masque ; il peut être restauré."
+ }
+ },
+ "common": {
+ "settings": "Paramètres",
+ "language": "Langue",
+ "about": "À propos",
+ "theme": "Thème",
+ "app_icon": "Icône de l’application",
+ "application_icon": "Icône de l’application",
+ "font": "Police",
+ "font_size": "Taille de police",
+ "size": "Taille",
+ "terminal_font": "Police du terminal",
+ "font_preview": "Aperçu de la police",
+ "increase_font": "Agrandir la police",
+ "decrease_font": "Réduire la police",
+ "change_theme": "Changer de thème",
+ "change_icon": "Changer d’icône",
+ "restore_defaults": "Rétablir les valeurs par défaut",
+ "reset": "Réinitialiser",
+ "save": "Enregistrer",
+ "saved": "Enregistré",
+ "cancel": "Annuler",
+ "confirm": "Confirmer",
+ "close": "Fermer",
+ "open": "Ouvrir",
+ "add": "Ajouter",
+ "remove": "Retirer",
+ "delete": "Supprimer",
+ "edit": "Modifier",
+ "rename": "Renommer",
+ "copy": "Copier",
+ "paste": "Coller",
+ "cut": "Couper",
+ "select_all": "Tout sélectionner",
+ "clear": "Effacer",
+ "search": "Rechercher",
+ "search_settings": "Rechercher un paramètre",
+ "no_results": "Aucun résultat",
+ "no_matches": "Aucune correspondance",
+ "refresh": "Actualiser",
+ "retry": "Réessayer",
+ "loading": "Chargement…",
+ "on": "Activé",
+ "off": "Désactivé",
+ "enabled": "Activé",
+ "disabled": "Désactivé",
+ "default": "Par défaut",
+ "custom": "Personnalisé",
+ "none": "Aucun",
+ "auto": "Auto",
+ "all": "Tout",
+ "name": "Nom",
+ "path": "Chemin",
+ "directory": "Dossier",
+ "files": "Fichiers",
+ "new_file": "Nouveau fichier",
+ "new_folder": "Nouveau dossier",
+ "new_tab": "Nouvel onglet",
+ "new_window": "Nouvelle fenêtre",
+ "close_tab": "Fermer l’onglet",
+ "duplicate_tab": "Dupliquer l’onglet",
+ "terminal": "Terminal",
+ "connect": "Connecter",
+ "disconnect": "Déconnecter",
+ "connected": "Connecté",
+ "connecting": "Connexion…",
+ "host": "Hôte",
+ "username": "Nom d’utilisateur",
+ "password": "Mot de passe",
+ "port": "Port",
+ "authentication": "Authentification",
+ "private_key": "Clé privée",
+ "upload": "Téléverser",
+ "download": "Télécharger",
+ "overwrite": "Remplacer",
+ "skip": "Ignorer",
+ "keep_both": "Conserver les deux",
+ "import": "Importer",
+ "export": "Exporter",
+ "choose": "Choisir",
+ "browse": "Parcourir",
+ "version": "Version",
+ "check_updates": "Rechercher des mises à jour",
+ "checking": "Vérification…",
+ "not_checked": "Pas encore vérifié",
+ "up_to_date": "À jour",
+ "update_available": "Mise à jour disponible",
+ "view_update": "Voir la mise à jour",
+ "install_update": "Installer la mise à jour",
+ "later": "Plus tard",
+ "light": "Clair",
+ "dark": "Sombre",
+ "opacity": "Opacité",
+ "background": "Arrière-plan",
+ "background_image": "Image d’arrière-plan",
+ "background_color": "Couleur d’arrière-plan",
+ "foreground": "Premier plan",
+ "text": "Texte",
+ "standard": "Standard",
+ "compact": "Compact",
+ "relaxed": "Aéré",
+ "left": "Gauche",
+ "right": "Droite",
+ "top": "Haut",
+ "bottom": "Bas",
+ "center": "Centre",
+ "left_sidebar": "Barre latérale gauche",
+ "scroll": "Défilement",
+ "cursor": "Curseur",
+ "notifications": "Notifications",
+ "sound": "Son",
+ "visual": "Visuel",
+ "success": "Réussi",
+ "failed": "Échec",
+ "warning": "Avertissement",
+ "details": "Détails",
+ "help": "Aide",
+ "shortcuts": "Raccourcis clavier",
+ "command": "Commande",
+ "run": "Exécuter",
+ "stop": "Arrêter",
+ "history": "Historique",
+ "saved_commands": "Commandes enregistrées",
+ "session": "Session",
+ "key": "Touche",
+ "action": "Action",
+ "update_notice": "Pebrel v{latest} est disponible (version actuelle : v{current})",
+ "copied_lines": "{lines} lignes copiées dans le presse-papiers",
+ "paste_lines": "Coller {lines} lignes ?",
+ "sent_to": "Envoyé à {target}"
+ },
+ "provider": {
+ "test": {
+ "success": "Connexion réussie (HTTP {status})",
+ "invalid_endpoint": "L’adresse doit commencer par http:// ou https://",
+ "missing_model": "Saisissez d’abord un modèle par défaut",
+ "missing_api_key": "Enregistrez d’abord une clé API",
+ "credential_read_failed": "Impossible de lire la clé API dans le gestionnaire d’identifiants",
+ "invalid_credential_encoding": "La clé API du gestionnaire d’identifiants présente un encodage invalide",
+ "timeout": "La connexion a expiré",
+ "host_not_found": "Serveur introuvable",
+ "connection_failed": "Impossible de se connecter au serveur",
+ "io_error": "Erreur réseau : {kind}",
+ "tls_failed": "Échec de la négociation TLS",
+ "request_failed": "Échec de la requête",
+ "auth_failed": "Échec de l’authentification (HTTP {status}) ; vérifiez la clé API",
+ "endpoint_not_found": "Point de terminaison introuvable (HTTP {status}) ; vérifiez l’adresse de l’API",
+ "rate_limited": "Limite de requêtes atteinte (HTTP {status}) ; le service est accessible, mais le quota ne peut pas être vérifié actuellement",
+ "http_status": "Le service a renvoyé HTTP {status}",
+ "start_failed": "Impossible de démarrer le test du fournisseur : {error}"
+ }
+ }
+}
diff --git a/nebula_app/i18n/it-IT.json b/nebula_app/i18n/it-IT.json
new file mode 100644
index 0000000..2c05661
--- /dev/null
+++ b/nebula_app/i18n/it-IT.json
@@ -0,0 +1,177 @@
+{
+ "language": {
+ "system": "Segui il sistema",
+ "self_name": "Italiano",
+ "zh_cn": "简体中文",
+ "en_us": "English"
+ },
+ "settings": {
+ "sidebar": {
+ "application": "Applicazione",
+ "appearance": "Aspetto",
+ "profiles": "Terminale",
+ "providers": "Fornitori IA",
+ "ssh": "SSH",
+ "network": "Rete",
+ "interaction": "Interazione",
+ "keymap": "Scorciatoie da tastiera",
+ "advanced": "Avanzate",
+ "backup": "Backup"
+ },
+ "network": {
+ "title": "Proxy di rete",
+ "mode": {
+ "label": "Modalità proxy",
+ "off": "Nessun proxy",
+ "system": "Segui il sistema",
+ "custom": "Proxy personalizzato"
+ },
+ "address": {
+ "label": "Indirizzo proxy"
+ },
+ "action": {
+ "test": "Verifica rete",
+ "testing": "Verifica in corso…"
+ },
+ "route": {
+ "direct": "Connessione diretta",
+ "direct_address": "Indirizzo diretto",
+ "custom_command": "Comando personalizzato"
+ }
+ },
+ "font": {
+ "loading": "Caricamento dei caratteri di sistema…",
+ "no_matches": "Nessun carattere corrispondente"
+ }
+ },
+ "common": {
+ "settings": "Impostazioni",
+ "language": "Lingua",
+ "about": "Informazioni",
+ "theme": "Tema",
+ "app_icon": "Icona dell’app",
+ "application_icon": "Icona dell’applicazione",
+ "font": "Carattere",
+ "font_size": "Dimensione carattere",
+ "size": "Dimensione",
+ "terminal_font": "Carattere del terminale",
+ "font_preview": "Anteprima carattere",
+ "increase_font": "Ingrandisci carattere",
+ "decrease_font": "Riduci carattere",
+ "change_theme": "Cambia tema",
+ "change_icon": "Cambia icona",
+ "restore_defaults": "Ripristina predefiniti",
+ "reset": "Ripristina",
+ "save": "Salva",
+ "saved": "Salvato",
+ "cancel": "Annulla",
+ "confirm": "Conferma",
+ "close": "Chiudi",
+ "open": "Apri",
+ "add": "Aggiungi",
+ "remove": "Rimuovi",
+ "delete": "Elimina",
+ "edit": "Modifica",
+ "rename": "Rinomina",
+ "copy": "Copia",
+ "paste": "Incolla",
+ "cut": "Taglia",
+ "select_all": "Seleziona tutto",
+ "clear": "Cancella",
+ "search": "Cerca",
+ "search_settings": "Cerca impostazioni",
+ "no_results": "Nessun risultato",
+ "no_matches": "Nessuna corrispondenza",
+ "refresh": "Aggiorna",
+ "retry": "Riprova",
+ "loading": "Caricamento…",
+ "on": "Attivo",
+ "off": "Disattivo",
+ "enabled": "Abilitato",
+ "disabled": "Disabilitato",
+ "default": "Predefinito",
+ "custom": "Personalizzato",
+ "none": "Nessuno",
+ "auto": "Automatico",
+ "all": "Tutto",
+ "name": "Nome",
+ "path": "Percorso",
+ "directory": "Cartella",
+ "files": "File",
+ "new_file": "Nuovo file",
+ "new_folder": "Nuova cartella",
+ "new_tab": "Nuova scheda",
+ "new_window": "Nuova finestra",
+ "close_tab": "Chiudi scheda",
+ "duplicate_tab": "Duplica scheda",
+ "terminal": "Terminale",
+ "connect": "Connetti",
+ "disconnect": "Disconnetti",
+ "connected": "Connesso",
+ "connecting": "Connessione…",
+ "host": "Host",
+ "username": "Nome utente",
+ "password": "Password",
+ "port": "Porta",
+ "authentication": "Autenticazione",
+ "private_key": "Chiave privata",
+ "upload": "Carica",
+ "download": "Scarica",
+ "overwrite": "Sovrascrivi",
+ "skip": "Salta",
+ "keep_both": "Mantieni entrambi",
+ "import": "Importa",
+ "export": "Esporta",
+ "choose": "Scegli",
+ "browse": "Sfoglia",
+ "version": "Versione",
+ "check_updates": "Cerca aggiornamenti",
+ "checking": "Verifica in corso…",
+ "not_checked": "Non ancora verificato",
+ "up_to_date": "Aggiornato",
+ "update_available": "Aggiornamento disponibile",
+ "view_update": "Mostra aggiornamento",
+ "install_update": "Installa aggiornamento",
+ "later": "Più tardi",
+ "light": "Chiaro",
+ "dark": "Scuro",
+ "opacity": "Opacità",
+ "background": "Sfondo",
+ "background_image": "Immagine di sfondo",
+ "background_color": "Colore di sfondo",
+ "foreground": "Primo piano",
+ "text": "Testo",
+ "standard": "Standard",
+ "compact": "Compatto",
+ "relaxed": "Ampio",
+ "left": "Sinistra",
+ "right": "Destra",
+ "top": "Alto",
+ "bottom": "Basso",
+ "center": "Centro",
+ "left_sidebar": "Barra laterale sinistra",
+ "scroll": "Scorri",
+ "cursor": "Cursore",
+ "notifications": "Notifiche",
+ "sound": "Suono",
+ "visual": "Visivo",
+ "success": "Riuscito",
+ "failed": "Non riuscito",
+ "warning": "Avviso",
+ "details": "Dettagli",
+ "help": "Aiuto",
+ "shortcuts": "Scorciatoie da tastiera",
+ "command": "Comando",
+ "run": "Esegui",
+ "stop": "Interrompi",
+ "history": "Cronologia",
+ "saved_commands": "Comandi salvati",
+ "session": "Sessione",
+ "key": "Tasto",
+ "action": "Azione",
+ "update_notice": "Pebrel v{latest} è disponibile (attuale: v{current})",
+ "copied_lines": "{lines} righe copiate negli appunti",
+ "paste_lines": "Incollare {lines} righe?",
+ "sent_to": "Inviato a {target}"
+ }
+}
diff --git a/nebula_app/i18n/ja-JP.json b/nebula_app/i18n/ja-JP.json
new file mode 100644
index 0000000..0a56ca2
--- /dev/null
+++ b/nebula_app/i18n/ja-JP.json
@@ -0,0 +1,177 @@
+{
+ "language": {
+ "system": "システムに従う",
+ "self_name": "日本語",
+ "zh_cn": "简体中文",
+ "en_us": "English"
+ },
+ "settings": {
+ "sidebar": {
+ "application": "アプリケーション",
+ "appearance": "外観",
+ "profiles": "ターミナル",
+ "providers": "AI プロバイダー",
+ "ssh": "SSH",
+ "network": "ネットワーク",
+ "interaction": "操作",
+ "keymap": "キー割り当て",
+ "advanced": "詳細設定",
+ "backup": "バックアップ"
+ },
+ "network": {
+ "title": "ネットワークプロキシ",
+ "mode": {
+ "label": "プロキシモード",
+ "off": "プロキシなし",
+ "system": "システムに従う",
+ "custom": "カスタムプロキシ"
+ },
+ "address": {
+ "label": "プロキシアドレス"
+ },
+ "action": {
+ "test": "ネットワークをテスト",
+ "testing": "テスト中…"
+ },
+ "route": {
+ "direct": "直接接続",
+ "direct_address": "直接アドレス",
+ "custom_command": "カスタムコマンド"
+ }
+ },
+ "font": {
+ "loading": "システムフォントを読み込み中…",
+ "no_matches": "一致するフォントがありません"
+ }
+ },
+ "common": {
+ "settings": "設定",
+ "language": "言語",
+ "about": "このアプリについて",
+ "theme": "テーマ",
+ "app_icon": "アプリアイコン",
+ "application_icon": "アプリアイコン",
+ "font": "フォント",
+ "font_size": "フォントサイズ",
+ "size": "サイズ",
+ "terminal_font": "ターミナルフォント",
+ "font_preview": "フォントのプレビュー",
+ "increase_font": "文字を大きくする",
+ "decrease_font": "文字を小さくする",
+ "change_theme": "テーマを変更",
+ "change_icon": "アイコンを変更",
+ "restore_defaults": "既定値に戻す",
+ "reset": "リセット",
+ "save": "保存",
+ "saved": "保存しました",
+ "cancel": "キャンセル",
+ "confirm": "確認",
+ "close": "閉じる",
+ "open": "開く",
+ "add": "追加",
+ "remove": "削除",
+ "delete": "削除",
+ "edit": "編集",
+ "rename": "名前を変更",
+ "copy": "コピー",
+ "paste": "貼り付け",
+ "cut": "切り取り",
+ "select_all": "すべて選択",
+ "clear": "クリア",
+ "search": "検索",
+ "search_settings": "設定を検索",
+ "no_results": "結果がありません",
+ "no_matches": "一致する項目がありません",
+ "refresh": "更新",
+ "retry": "再試行",
+ "loading": "読み込み中…",
+ "on": "オン",
+ "off": "オフ",
+ "enabled": "有効",
+ "disabled": "無効",
+ "default": "既定",
+ "custom": "カスタム",
+ "none": "なし",
+ "auto": "自動",
+ "all": "すべて",
+ "name": "名前",
+ "path": "パス",
+ "directory": "ディレクトリ",
+ "files": "ファイル",
+ "new_file": "新しいファイル",
+ "new_folder": "新しいフォルダー",
+ "new_tab": "新しいタブ",
+ "new_window": "新しいウィンドウ",
+ "close_tab": "タブを閉じる",
+ "duplicate_tab": "タブを複製",
+ "terminal": "ターミナル",
+ "connect": "接続",
+ "disconnect": "切断",
+ "connected": "接続済み",
+ "connecting": "接続中…",
+ "host": "ホスト",
+ "username": "ユーザー名",
+ "password": "パスワード",
+ "port": "ポート",
+ "authentication": "認証",
+ "private_key": "秘密鍵",
+ "upload": "アップロード",
+ "download": "ダウンロード",
+ "overwrite": "上書き",
+ "skip": "スキップ",
+ "keep_both": "両方を保持",
+ "import": "インポート",
+ "export": "エクスポート",
+ "choose": "選択",
+ "browse": "参照",
+ "version": "バージョン",
+ "check_updates": "更新を確認",
+ "checking": "確認中…",
+ "not_checked": "未確認",
+ "up_to_date": "最新です",
+ "update_available": "更新があります",
+ "view_update": "更新を表示",
+ "install_update": "更新をインストール",
+ "later": "後で",
+ "light": "ライト",
+ "dark": "ダーク",
+ "opacity": "不透明度",
+ "background": "背景",
+ "background_image": "背景画像",
+ "background_color": "背景色",
+ "foreground": "前景",
+ "text": "テキスト",
+ "standard": "標準",
+ "compact": "コンパクト",
+ "relaxed": "ゆったり",
+ "left": "左",
+ "right": "右",
+ "top": "上",
+ "bottom": "下",
+ "center": "中央",
+ "left_sidebar": "左サイドバー",
+ "scroll": "スクロール",
+ "cursor": "カーソル",
+ "notifications": "通知",
+ "sound": "サウンド",
+ "visual": "視覚通知",
+ "success": "成功",
+ "failed": "失敗",
+ "warning": "警告",
+ "details": "詳細",
+ "help": "ヘルプ",
+ "shortcuts": "キーボードショートカット",
+ "command": "コマンド",
+ "run": "実行",
+ "stop": "停止",
+ "history": "履歴",
+ "saved_commands": "保存済みコマンド",
+ "session": "セッション",
+ "key": "キー",
+ "action": "アクション",
+ "update_notice": "Pebrel v{latest} を利用できます(現在 v{current})",
+ "copied_lines": "{lines} 行をクリップボードにコピーしました",
+ "paste_lines": "{lines} 行を貼り付けますか?",
+ "sent_to": "{target} に送信しました"
+ }
+}
diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json
new file mode 100644
index 0000000..467393f
--- /dev/null
+++ b/nebula_app/i18n/ko-KR.json
@@ -0,0 +1,177 @@
+{
+ "language": {
+ "system": "시스템 설정 따르기",
+ "self_name": "한국어",
+ "zh_cn": "简体中文",
+ "en_us": "English"
+ },
+ "settings": {
+ "sidebar": {
+ "application": "애플리케이션",
+ "appearance": "모양",
+ "profiles": "터미널",
+ "providers": "AI 공급자",
+ "ssh": "SSH",
+ "network": "네트워크",
+ "interaction": "상호 작용",
+ "keymap": "키 바인딩",
+ "advanced": "고급",
+ "backup": "백업"
+ },
+ "network": {
+ "title": "네트워크 프록시",
+ "mode": {
+ "label": "프록시 모드",
+ "off": "프록시 사용 안 함",
+ "system": "시스템 설정 따르기",
+ "custom": "사용자 지정 프록시"
+ },
+ "address": {
+ "label": "프록시 주소"
+ },
+ "action": {
+ "test": "네트워크 테스트",
+ "testing": "테스트 중…"
+ },
+ "route": {
+ "direct": "직접 연결",
+ "direct_address": "직접 주소",
+ "custom_command": "사용자 지정 명령"
+ }
+ },
+ "font": {
+ "loading": "시스템 글꼴을 불러오는 중…",
+ "no_matches": "일치하는 글꼴이 없습니다"
+ }
+ },
+ "common": {
+ "settings": "설정",
+ "language": "언어",
+ "about": "정보",
+ "theme": "테마",
+ "app_icon": "앱 아이콘",
+ "application_icon": "앱 아이콘",
+ "font": "글꼴",
+ "font_size": "글꼴 크기",
+ "size": "크기",
+ "terminal_font": "터미널 글꼴",
+ "font_preview": "글꼴 미리 보기",
+ "increase_font": "글꼴 크기 늘리기",
+ "decrease_font": "글꼴 크기 줄이기",
+ "change_theme": "테마 변경",
+ "change_icon": "아이콘 변경",
+ "restore_defaults": "기본값 복원",
+ "reset": "초기화",
+ "save": "저장",
+ "saved": "저장됨",
+ "cancel": "취소",
+ "confirm": "확인",
+ "close": "닫기",
+ "open": "열기",
+ "add": "추가",
+ "remove": "제거",
+ "delete": "삭제",
+ "edit": "편집",
+ "rename": "이름 바꾸기",
+ "copy": "복사",
+ "paste": "붙여넣기",
+ "cut": "잘라내기",
+ "select_all": "모두 선택",
+ "clear": "지우기",
+ "search": "검색",
+ "search_settings": "설정 검색",
+ "no_results": "결과 없음",
+ "no_matches": "일치 항목 없음",
+ "refresh": "새로 고침",
+ "retry": "다시 시도",
+ "loading": "불러오는 중…",
+ "on": "켜짐",
+ "off": "꺼짐",
+ "enabled": "활성화됨",
+ "disabled": "비활성화됨",
+ "default": "기본값",
+ "custom": "사용자 지정",
+ "none": "없음",
+ "auto": "자동",
+ "all": "모두",
+ "name": "이름",
+ "path": "경로",
+ "directory": "디렉터리",
+ "files": "파일",
+ "new_file": "새 파일",
+ "new_folder": "새 폴더",
+ "new_tab": "새 탭",
+ "new_window": "새 창",
+ "close_tab": "탭 닫기",
+ "duplicate_tab": "탭 복제",
+ "terminal": "터미널",
+ "connect": "연결",
+ "disconnect": "연결 해제",
+ "connected": "연결됨",
+ "connecting": "연결 중…",
+ "host": "호스트",
+ "username": "사용자 이름",
+ "password": "암호",
+ "port": "포트",
+ "authentication": "인증",
+ "private_key": "개인 키",
+ "upload": "업로드",
+ "download": "다운로드",
+ "overwrite": "덮어쓰기",
+ "skip": "건너뛰기",
+ "keep_both": "둘 다 유지",
+ "import": "가져오기",
+ "export": "내보내기",
+ "choose": "선택",
+ "browse": "찾아보기",
+ "version": "버전",
+ "check_updates": "업데이트 확인",
+ "checking": "확인 중…",
+ "not_checked": "아직 확인하지 않음",
+ "up_to_date": "최신 상태",
+ "update_available": "업데이트 가능",
+ "view_update": "업데이트 보기",
+ "install_update": "업데이트 설치",
+ "later": "나중에",
+ "light": "밝게",
+ "dark": "어둡게",
+ "opacity": "불투명도",
+ "background": "배경",
+ "background_image": "배경 이미지",
+ "background_color": "배경색",
+ "foreground": "전경",
+ "text": "텍스트",
+ "standard": "표준",
+ "compact": "좁게",
+ "relaxed": "넓게",
+ "left": "왼쪽",
+ "right": "오른쪽",
+ "top": "위쪽",
+ "bottom": "아래쪽",
+ "center": "가운데",
+ "left_sidebar": "왼쪽 사이드바",
+ "scroll": "스크롤",
+ "cursor": "커서",
+ "notifications": "알림",
+ "sound": "소리",
+ "visual": "시각 효과",
+ "success": "성공",
+ "failed": "실패",
+ "warning": "경고",
+ "details": "세부 정보",
+ "help": "도움말",
+ "shortcuts": "키보드 단축키",
+ "command": "명령",
+ "run": "실행",
+ "stop": "중지",
+ "history": "기록",
+ "saved_commands": "저장된 명령",
+ "session": "세션",
+ "key": "키",
+ "action": "동작",
+ "update_notice": "Pebrel v{latest} 사용 가능(현재 v{current})",
+ "copied_lines": "{lines}줄을 클립보드에 복사했습니다",
+ "paste_lines": "{lines}줄을 붙여넣을까요?",
+ "sent_to": "{target}(으)로 전송했습니다"
+ }
+}
diff --git a/nebula_app/i18n/pt-BR.json b/nebula_app/i18n/pt-BR.json
new file mode 100644
index 0000000..d8a00b9
--- /dev/null
+++ b/nebula_app/i18n/pt-BR.json
@@ -0,0 +1,177 @@
+{
+ "language": {
+ "system": "Seguir o sistema",
+ "self_name": "Português (Brasil)",
+ "zh_cn": "简体中文",
+ "en_us": "English"
+ },
+ "settings": {
+ "sidebar": {
+ "application": "Aplicativo",
+ "appearance": "Aparência",
+ "profiles": "Terminal",
+ "providers": "Provedores de IA",
+ "ssh": "SSH",
+ "network": "Rede",
+ "interaction": "Interação",
+ "keymap": "Atalhos de teclado",
+ "advanced": "Avançado",
+ "backup": "Backup"
+ },
+ "network": {
+ "title": "Proxy de rede",
+ "mode": {
+ "label": "Modo de proxy",
+ "off": "Sem proxy",
+ "system": "Seguir o sistema",
+ "custom": "Proxy personalizado"
+ },
+ "address": {
+ "label": "Endereço do proxy"
+ },
+ "action": {
+ "test": "Testar rede",
+ "testing": "Testando…"
+ },
+ "route": {
+ "direct": "Conexão direta",
+ "direct_address": "Endereço direto",
+ "custom_command": "Comando personalizado"
+ }
+ },
+ "font": {
+ "loading": "Carregando fontes do sistema…",
+ "no_matches": "Nenhuma fonte correspondente"
+ }
+ },
+ "common": {
+ "settings": "Configurações",
+ "language": "Idioma",
+ "about": "Sobre",
+ "theme": "Tema",
+ "app_icon": "Ícone do aplicativo",
+ "application_icon": "Ícone do aplicativo",
+ "font": "Fonte",
+ "font_size": "Tamanho da fonte",
+ "size": "Tamanho",
+ "terminal_font": "Fonte do terminal",
+ "font_preview": "Prévia da fonte",
+ "increase_font": "Aumentar fonte",
+ "decrease_font": "Diminuir fonte",
+ "change_theme": "Alterar tema",
+ "change_icon": "Alterar ícone",
+ "restore_defaults": "Restaurar padrões",
+ "reset": "Redefinir",
+ "save": "Salvar",
+ "saved": "Salvo",
+ "cancel": "Cancelar",
+ "confirm": "Confirmar",
+ "close": "Fechar",
+ "open": "Abrir",
+ "add": "Adicionar",
+ "remove": "Remover",
+ "delete": "Excluir",
+ "edit": "Editar",
+ "rename": "Renomear",
+ "copy": "Copiar",
+ "paste": "Colar",
+ "cut": "Recortar",
+ "select_all": "Selecionar tudo",
+ "clear": "Limpar",
+ "search": "Pesquisar",
+ "search_settings": "Pesquisar configurações",
+ "no_results": "Nenhum resultado",
+ "no_matches": "Nenhuma correspondência",
+ "refresh": "Atualizar",
+ "retry": "Tentar novamente",
+ "loading": "Carregando…",
+ "on": "Ativado",
+ "off": "Desativado",
+ "enabled": "Habilitado",
+ "disabled": "Desabilitado",
+ "default": "Padrão",
+ "custom": "Personalizado",
+ "none": "Nenhum",
+ "auto": "Automático",
+ "all": "Todos",
+ "name": "Nome",
+ "path": "Caminho",
+ "directory": "Diretório",
+ "files": "Arquivos",
+ "new_file": "Novo arquivo",
+ "new_folder": "Nova pasta",
+ "new_tab": "Nova aba",
+ "new_window": "Nova janela",
+ "close_tab": "Fechar aba",
+ "duplicate_tab": "Duplicar aba",
+ "terminal": "Terminal",
+ "connect": "Conectar",
+ "disconnect": "Desconectar",
+ "connected": "Conectado",
+ "connecting": "Conectando…",
+ "host": "Host",
+ "username": "Nome de usuário",
+ "password": "Senha",
+ "port": "Porta",
+ "authentication": "Autenticação",
+ "private_key": "Chave privada",
+ "upload": "Enviar",
+ "download": "Baixar",
+ "overwrite": "Substituir",
+ "skip": "Ignorar",
+ "keep_both": "Manter ambos",
+ "import": "Importar",
+ "export": "Exportar",
+ "choose": "Escolher",
+ "browse": "Procurar",
+ "version": "Versão",
+ "check_updates": "Verificar atualizações",
+ "checking": "Verificando…",
+ "not_checked": "Ainda não verificado",
+ "up_to_date": "Atualizado",
+ "update_available": "Atualização disponível",
+ "view_update": "Ver atualização",
+ "install_update": "Instalar atualização",
+ "later": "Mais tarde",
+ "light": "Claro",
+ "dark": "Escuro",
+ "opacity": "Opacidade",
+ "background": "Fundo",
+ "background_image": "Imagem de fundo",
+ "background_color": "Cor de fundo",
+ "foreground": "Primeiro plano",
+ "text": "Texto",
+ "standard": "Padrão",
+ "compact": "Compacto",
+ "relaxed": "Amplo",
+ "left": "Esquerda",
+ "right": "Direita",
+ "top": "Topo",
+ "bottom": "Base",
+ "center": "Centro",
+ "left_sidebar": "Barra lateral esquerda",
+ "scroll": "Rolar",
+ "cursor": "Cursor",
+ "notifications": "Notificações",
+ "sound": "Som",
+ "visual": "Visual",
+ "success": "Sucesso",
+ "failed": "Falhou",
+ "warning": "Aviso",
+ "details": "Detalhes",
+ "help": "Ajuda",
+ "shortcuts": "Atalhos de teclado",
+ "command": "Comando",
+ "run": "Executar",
+ "stop": "Parar",
+ "history": "Histórico",
+ "saved_commands": "Comandos salvos",
+ "session": "Sessão",
+ "key": "Tecla",
+ "action": "Ação",
+ "update_notice": "Pebrel v{latest} está disponível (atual: v{current})",
+ "copied_lines": "{lines} linhas copiadas para a área de transferência",
+ "paste_lines": "Colar {lines} linhas?",
+ "sent_to": "Enviado para {target}"
+ }
+}
diff --git a/nebula_app/i18n/ru-RU.json b/nebula_app/i18n/ru-RU.json
new file mode 100644
index 0000000..14e5a1e
--- /dev/null
+++ b/nebula_app/i18n/ru-RU.json
@@ -0,0 +1,177 @@
+{
+ "language": {
+ "system": "Как в системе",
+ "self_name": "Русский",
+ "zh_cn": "简体中文",
+ "en_us": "English"
+ },
+ "settings": {
+ "sidebar": {
+ "application": "Приложение",
+ "appearance": "Внешний вид",
+ "profiles": "Терминал",
+ "providers": "Провайдеры ИИ",
+ "ssh": "SSH",
+ "network": "Сеть",
+ "interaction": "Взаимодействие",
+ "keymap": "Сочетания клавиш",
+ "advanced": "Дополнительно",
+ "backup": "Резервная копия"
+ },
+ "network": {
+ "title": "Сетевой прокси",
+ "mode": {
+ "label": "Режим прокси",
+ "off": "Без прокси",
+ "system": "Как в системе",
+ "custom": "Свой прокси"
+ },
+ "address": {
+ "label": "Адрес прокси"
+ },
+ "action": {
+ "test": "Проверить сеть",
+ "testing": "Проверка…"
+ },
+ "route": {
+ "direct": "Прямое соединение",
+ "direct_address": "Прямой адрес",
+ "custom_command": "Своя команда"
+ }
+ },
+ "font": {
+ "loading": "Загрузка системных шрифтов…",
+ "no_matches": "Подходящие шрифты не найдены"
+ }
+ },
+ "common": {
+ "settings": "Настройки",
+ "language": "Язык",
+ "about": "О программе",
+ "theme": "Тема",
+ "app_icon": "Значок приложения",
+ "application_icon": "Значок приложения",
+ "font": "Шрифт",
+ "font_size": "Размер шрифта",
+ "size": "Размер",
+ "terminal_font": "Шрифт терминала",
+ "font_preview": "Просмотр шрифта",
+ "increase_font": "Увеличить шрифт",
+ "decrease_font": "Уменьшить шрифт",
+ "change_theme": "Сменить тему",
+ "change_icon": "Сменить значок",
+ "restore_defaults": "Восстановить настройки",
+ "reset": "Сбросить",
+ "save": "Сохранить",
+ "saved": "Сохранено",
+ "cancel": "Отмена",
+ "confirm": "Подтвердить",
+ "close": "Закрыть",
+ "open": "Открыть",
+ "add": "Добавить",
+ "remove": "Убрать",
+ "delete": "Удалить",
+ "edit": "Изменить",
+ "rename": "Переименовать",
+ "copy": "Копировать",
+ "paste": "Вставить",
+ "cut": "Вырезать",
+ "select_all": "Выбрать всё",
+ "clear": "Очистить",
+ "search": "Поиск",
+ "search_settings": "Поиск настроек",
+ "no_results": "Нет результатов",
+ "no_matches": "Нет совпадений",
+ "refresh": "Обновить",
+ "retry": "Повторить",
+ "loading": "Загрузка…",
+ "on": "Вкл.",
+ "off": "Выкл.",
+ "enabled": "Включено",
+ "disabled": "Отключено",
+ "default": "По умолчанию",
+ "custom": "Свой",
+ "none": "Нет",
+ "auto": "Авто",
+ "all": "Все",
+ "name": "Название",
+ "path": "Путь",
+ "directory": "Каталог",
+ "files": "Файлы",
+ "new_file": "Новый файл",
+ "new_folder": "Новая папка",
+ "new_tab": "Новая вкладка",
+ "new_window": "Новое окно",
+ "close_tab": "Закрыть вкладку",
+ "duplicate_tab": "Дублировать вкладку",
+ "terminal": "Терминал",
+ "connect": "Подключиться",
+ "disconnect": "Отключиться",
+ "connected": "Подключено",
+ "connecting": "Подключение…",
+ "host": "Хост",
+ "username": "Имя пользователя",
+ "password": "Пароль",
+ "port": "Порт",
+ "authentication": "Аутентификация",
+ "private_key": "Закрытый ключ",
+ "upload": "Загрузить на сервер",
+ "download": "Скачать",
+ "overwrite": "Перезаписать",
+ "skip": "Пропустить",
+ "keep_both": "Сохранить оба",
+ "import": "Импортировать",
+ "export": "Экспортировать",
+ "choose": "Выбрать",
+ "browse": "Обзор",
+ "version": "Версия",
+ "check_updates": "Проверить обновления",
+ "checking": "Проверка…",
+ "not_checked": "Ещё не проверено",
+ "up_to_date": "Установлена последняя версия",
+ "update_available": "Доступно обновление",
+ "view_update": "Посмотреть обновление",
+ "install_update": "Установить обновление",
+ "later": "Позже",
+ "light": "Светлая",
+ "dark": "Тёмная",
+ "opacity": "Непрозрачность",
+ "background": "Фон",
+ "background_image": "Фоновое изображение",
+ "background_color": "Цвет фона",
+ "foreground": "Передний план",
+ "text": "Текст",
+ "standard": "Стандартный",
+ "compact": "Компактный",
+ "relaxed": "Свободный",
+ "left": "Слева",
+ "right": "Справа",
+ "top": "Сверху",
+ "bottom": "Снизу",
+ "center": "По центру",
+ "left_sidebar": "Левая панель",
+ "scroll": "Прокрутка",
+ "cursor": "Курсор",
+ "notifications": "Уведомления",
+ "sound": "Звук",
+ "visual": "Визуальный",
+ "success": "Успешно",
+ "failed": "Ошибка",
+ "warning": "Предупреждение",
+ "details": "Подробности",
+ "help": "Справка",
+ "shortcuts": "Сочетания клавиш",
+ "command": "Команда",
+ "run": "Выполнить",
+ "stop": "Остановить",
+ "history": "История",
+ "saved_commands": "Сохранённые команды",
+ "session": "Сеанс",
+ "key": "Клавиша",
+ "action": "Действие",
+ "update_notice": "Доступен Pebrel v{latest} (текущая версия v{current})",
+ "copied_lines": "Скопировано строк в буфер обмена: {lines}",
+ "paste_lines": "Вставить строки ({lines})?",
+ "sent_to": "Отправлено в {target}"
+ }
+}
diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json
index 2326d54..fcc054b 100644
--- a/nebula_app/i18n/zh-CN.json
+++ b/nebula_app/i18n/zh-CN.json
@@ -9,7 +9,7 @@
"sidebar": {
"application": "应用",
"appearance": "外观",
- "profiles": "配置文件",
+ "profiles": "终端",
"providers": "AI 供应商",
"ssh": "SSH",
"network": "网络",
@@ -114,5 +114,138 @@
"http_status": "服务返回 HTTP {status}",
"start_failed": "无法启动供应商测试:{error}"
}
+ },
+ "common": {
+ "settings": "设置",
+ "language": "语言",
+ "about": "关于",
+ "theme": "主题",
+ "app_icon": "应用图标",
+ "application_icon": "应用图标",
+ "font": "字体",
+ "font_size": "字号",
+ "size": "字号",
+ "terminal_font": "终端字体",
+ "font_preview": "字体预览",
+ "increase_font": "增大字号",
+ "decrease_font": "减小字号",
+ "change_theme": "更换主题",
+ "change_icon": "更换图标",
+ "restore_defaults": "恢复默认",
+ "reset": "重置",
+ "save": "保存",
+ "saved": "已保存",
+ "cancel": "取消",
+ "confirm": "确认",
+ "close": "关闭",
+ "open": "打开",
+ "add": "添加",
+ "remove": "移除",
+ "delete": "删除",
+ "edit": "编辑",
+ "rename": "重命名",
+ "copy": "复制",
+ "paste": "粘贴",
+ "cut": "剪切",
+ "select_all": "全选",
+ "clear": "清除",
+ "search": "搜索",
+ "search_settings": "搜索设置",
+ "no_results": "没有结果",
+ "no_matches": "没有匹配项",
+ "refresh": "刷新",
+ "retry": "重试",
+ "loading": "加载中…",
+ "on": "开启",
+ "off": "关",
+ "enabled": "已启用",
+ "disabled": "已禁用",
+ "default": "默认",
+ "custom": "自定义",
+ "none": "无",
+ "auto": "自动",
+ "all": "全部",
+ "name": "名称",
+ "path": "路径",
+ "directory": "目录",
+ "files": "文件",
+ "new_file": "新建文件",
+ "new_folder": "新建文件夹",
+ "new_tab": "新建标签",
+ "new_window": "新建窗口",
+ "close_tab": "关闭标签",
+ "duplicate_tab": "复制标签",
+ "terminal": "终端",
+ "connect": "连接",
+ "disconnect": "断开",
+ "connected": "已连接",
+ "connecting": "连接中…",
+ "host": "主机",
+ "username": "用户名",
+ "password": "密码",
+ "port": "端口",
+ "authentication": "认证",
+ "private_key": "私钥",
+ "upload": "上传",
+ "download": "下载",
+ "overwrite": "覆盖",
+ "skip": "跳过",
+ "keep_both": "保留两者",
+ "import": "导入",
+ "export": "导出",
+ "choose": "选择",
+ "browse": "浏览",
+ "version": "版本",
+ "check_updates": "检查更新",
+ "checking": "正在检查…",
+ "not_checked": "尚未检查",
+ "up_to_date": "已是最新版本",
+ "update_available": "发现新版本",
+ "view_update": "查看更新",
+ "install_update": "安装更新",
+ "later": "稍后",
+ "light": "浅色",
+ "dark": "深色",
+ "opacity": "不透明度",
+ "background": "背景",
+ "background_image": "背景图片",
+ "background_color": "背景颜色",
+ "foreground": "前景",
+ "text": "文字",
+ "standard": "标准",
+ "compact": "紧凑",
+ "relaxed": "宽松",
+ "left": "左侧",
+ "right": "右侧",
+ "top": "顶部",
+ "bottom": "底部",
+ "center": "居中",
+ "left_sidebar": "左侧边栏",
+ "scroll": "滚动",
+ "cursor": "光标",
+ "notifications": "通知",
+ "sound": "声音",
+ "visual": "闪烁",
+ "success": "成功",
+ "failed": "失败",
+ "warning": "警告",
+ "details": "详情",
+ "help": "帮助",
+ "shortcuts": "键盘快捷键",
+ "command": "命令",
+ "run": "运行",
+ "stop": "停止",
+ "history": "历史",
+ "saved_commands": "保存的命令",
+ "session": "会话",
+ "key": "按键",
+ "action": "操作",
+ "update_notice": "Pebrel v{latest} 已发布(当前 v{current})",
+ "copied_lines": "已复制 {lines} 行到剪贴板",
+ "paste_lines": "粘贴 {lines} 行文本?",
+ "sent_to": "已发送到 {target}",
+ "yes": "是",
+ "no": "否",
+ "confirmation_expired": "确认请求已经变更或结束。"
}
}
diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json
new file mode 100644
index 0000000..c9d5a31
--- /dev/null
+++ b/nebula_app/i18n/zh-TW.json
@@ -0,0 +1,177 @@
+{
+ "language": {
+ "system": "跟隨系統",
+ "self_name": "繁體中文",
+ "zh_cn": "简体中文",
+ "en_us": "English"
+ },
+ "settings": {
+ "sidebar": {
+ "application": "應用程式",
+ "appearance": "外觀",
+ "profiles": "終端機",
+ "providers": "AI 供應商",
+ "ssh": "SSH",
+ "network": "網路",
+ "interaction": "互動",
+ "keymap": "按鍵對應",
+ "advanced": "進階",
+ "backup": "備份"
+ },
+ "network": {
+ "title": "網路代理",
+ "mode": {
+ "label": "代理模式",
+ "off": "不使用代理",
+ "system": "跟隨系統",
+ "custom": "自訂代理"
+ },
+ "address": {
+ "label": "代理位址"
+ },
+ "action": {
+ "test": "測試網路",
+ "testing": "測試中…"
+ },
+ "route": {
+ "direct": "直接連線",
+ "direct_address": "直連位址",
+ "custom_command": "自訂命令"
+ }
+ },
+ "font": {
+ "loading": "正在列舉系統字型…",
+ "no_matches": "沒有符合的字型"
+ }
+ },
+ "common": {
+ "settings": "設定",
+ "language": "語言",
+ "about": "關於",
+ "theme": "佈景主題",
+ "app_icon": "應用程式圖示",
+ "application_icon": "應用程式圖示",
+ "font": "字型",
+ "font_size": "字型大小",
+ "size": "大小",
+ "terminal_font": "終端機字型",
+ "font_preview": "字型預覽",
+ "increase_font": "放大字型",
+ "decrease_font": "縮小字型",
+ "change_theme": "更換佈景主題",
+ "change_icon": "更換圖示",
+ "restore_defaults": "還原預設值",
+ "reset": "重設",
+ "save": "儲存",
+ "saved": "已儲存",
+ "cancel": "取消",
+ "confirm": "確認",
+ "close": "關閉",
+ "open": "開啟",
+ "add": "新增",
+ "remove": "移除",
+ "delete": "刪除",
+ "edit": "編輯",
+ "rename": "重新命名",
+ "copy": "複製",
+ "paste": "貼上",
+ "cut": "剪下",
+ "select_all": "全選",
+ "clear": "清除",
+ "search": "搜尋",
+ "search_settings": "搜尋設定",
+ "no_results": "沒有結果",
+ "no_matches": "沒有符合項目",
+ "refresh": "重新整理",
+ "retry": "重試",
+ "loading": "載入中…",
+ "on": "開啟",
+ "off": "關閉",
+ "enabled": "已啟用",
+ "disabled": "已停用",
+ "default": "預設",
+ "custom": "自訂",
+ "none": "無",
+ "auto": "自動",
+ "all": "全部",
+ "name": "名稱",
+ "path": "路徑",
+ "directory": "目錄",
+ "files": "檔案",
+ "new_file": "新增檔案",
+ "new_folder": "新增資料夾",
+ "new_tab": "新增分頁",
+ "new_window": "新增視窗",
+ "close_tab": "關閉分頁",
+ "duplicate_tab": "複製分頁",
+ "terminal": "終端機",
+ "connect": "連線",
+ "disconnect": "中斷連線",
+ "connected": "已連線",
+ "connecting": "連線中…",
+ "host": "主機",
+ "username": "使用者名稱",
+ "password": "密碼",
+ "port": "連接埠",
+ "authentication": "驗證",
+ "private_key": "私密金鑰",
+ "upload": "上傳",
+ "download": "下載",
+ "overwrite": "覆寫",
+ "skip": "略過",
+ "keep_both": "保留兩者",
+ "import": "匯入",
+ "export": "匯出",
+ "choose": "選擇",
+ "browse": "瀏覽",
+ "version": "版本",
+ "check_updates": "檢查更新",
+ "checking": "正在檢查…",
+ "not_checked": "尚未檢查",
+ "up_to_date": "已是最新版本",
+ "update_available": "發現新版本",
+ "view_update": "檢視更新",
+ "install_update": "安裝更新",
+ "later": "稍後",
+ "light": "淺色",
+ "dark": "深色",
+ "opacity": "不透明度",
+ "background": "背景",
+ "background_image": "背景圖片",
+ "background_color": "背景色彩",
+ "foreground": "前景",
+ "text": "文字",
+ "standard": "標準",
+ "compact": "緊湊",
+ "relaxed": "寬鬆",
+ "left": "左側",
+ "right": "右側",
+ "top": "頂端",
+ "bottom": "底端",
+ "center": "置中",
+ "left_sidebar": "左側邊欄",
+ "scroll": "捲動",
+ "cursor": "游標",
+ "notifications": "通知",
+ "sound": "音效",
+ "visual": "閃爍",
+ "success": "成功",
+ "failed": "失敗",
+ "warning": "警告",
+ "details": "詳細資訊",
+ "help": "說明",
+ "shortcuts": "鍵盤快速鍵",
+ "command": "命令",
+ "run": "執行",
+ "stop": "停止",
+ "history": "歷程",
+ "saved_commands": "已儲存的命令",
+ "session": "工作階段",
+ "key": "按鍵",
+ "action": "動作",
+ "update_notice": "Pebrel v{latest} 已發布(目前 v{current})",
+ "copied_lines": "已複製 {lines} 行到剪貼簿",
+ "paste_lines": "貼上 {lines} 行文字?",
+ "sent_to": "已傳送至 {target}"
+ }
+}
diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs
index 3fe9582..6b3948d 100644
--- a/nebula_app/src/ai_hook.rs
+++ b/nebula_app/src/ai_hook.rs
@@ -742,7 +742,9 @@ fn parse_envelope(bytes: &[u8]) -> Option {
raw_context: sanitized_raw_context(&payload),
});
let answer = crate::assistant_answer::AssistantAnswer::from_hook(&source, &payload);
- let answer_cwd = payload.get("cwd").and_then(Value::as_str)
+ let answer_cwd = payload
+ .get("cwd")
+ .and_then(Value::as_str)
.filter(|path| path.len() <= 4096 && !path.chars().any(char::is_control))
.map(std::path::PathBuf::from)
.filter(|path| path.is_absolute());
diff --git a/nebula_app/src/config/template.rs b/nebula_app/src/config/template.rs
index 8644eb7..ec97328 100644
--- a/nebula_app/src/config/template.rs
+++ b/nebula_app/src/config/template.rs
@@ -1,4 +1,3 @@
-use std::env;
use std::fmt::{self, Display, Formatter};
use std::fs;
use std::io::Write;
@@ -40,7 +39,7 @@ impl Display for TemplateError {
),
Self::InvalidLanguage(language) => write!(
formatter,
- "unsupported language {language:?}; expected system, zh-CN, or en-US"
+ "unsupported language {language:?}; expected system or a supported UI locale"
),
Self::Io(error) => Display::fmt(error, formatter),
}
@@ -68,51 +67,17 @@ pub fn resolve_template_language(
locale: Option<&str>,
) -> Result {
let requested = explicit.or(saved).unwrap_or("system");
- match requested {
- "zh-CN" => Ok(TemplateLanguage::ZhCn),
- "en-US" => Ok(TemplateLanguage::EnUs),
- "system" => Ok(if locale.is_some_and(is_chinese_locale) {
- TemplateLanguage::ZhCn
- } else {
- TemplateLanguage::EnUs
- }),
- other => Err(TemplateError::InvalidLanguage(other.to_owned())),
- }
+ let preference = crate::i18n::LanguagePreference::parse(requested)
+ .ok_or_else(|| TemplateError::InvalidLanguage(requested.to_owned()))?;
+ let language =
+ preference.explicit().unwrap_or_else(|| crate::i18n::UiLanguage::for_locale(locale));
+ Ok(match language {
+ crate::i18n::UiLanguage::ZhCn | crate::i18n::UiLanguage::ZhTw => TemplateLanguage::ZhCn,
+ _ => TemplateLanguage::EnUs,
+ })
}
-pub fn system_locale() -> Option {
- system_locale_impl().or_else(environment_locale)
-}
-
-#[cfg(windows)]
-fn system_locale_impl() -> Option {
- use windows_sys::Win32::Globalization::GetUserDefaultLocaleName;
-
- let mut buffer = [0_u16; 85];
- let length = unsafe { GetUserDefaultLocaleName(buffer.as_mut_ptr(), buffer.len() as i32) };
- if length <= 1 {
- return None;
- }
- String::from_utf16(&buffer[..length as usize - 1]).ok()
-}
-
-#[cfg(not(windows))]
-fn system_locale_impl() -> Option {
- None
-}
-
-fn environment_locale() -> Option {
- ["LC_ALL", "LC_MESSAGES", "LANG"]
- .into_iter()
- .find_map(|name| env::var(name).ok().filter(|value| !value.is_empty()))
-}
-
-fn is_chinese_locale(locale: &str) -> bool {
- locale
- .split(['-', '_', '.', '@'])
- .next()
- .is_some_and(|language| language.eq_ignore_ascii_case("zh"))
-}
+pub use crate::i18n::system_locale;
pub fn write_template(
path: &Path,
@@ -214,6 +179,23 @@ mod tests {
);
}
+ #[test]
+ fn additional_ui_languages_use_existing_templates_without_errors() {
+ assert_eq!(
+ resolve_template_language(None, Some("fr-FR"), None).unwrap(),
+ TemplateLanguage::EnUs
+ );
+ assert_eq!(
+ resolve_template_language(Some("zh-TW"), None, None).unwrap(),
+ TemplateLanguage::ZhCn
+ );
+ for preference in crate::i18n::LanguagePreference::ALL {
+ assert!(
+ resolve_template_language(Some(preference.as_str()), None, Some("de-DE")).is_ok()
+ );
+ }
+ }
+
#[test]
fn existing_file_requires_force_and_force_creates_backup() {
let temp = tempfile::tempdir().unwrap();
diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs
index 00e5c57..f1cc605 100644
--- a/nebula_app/src/display/command_palette.rs
+++ b/nebula_app/src/display/command_palette.rs
@@ -11,8 +11,8 @@
use std::path::PathBuf;
-use super::{NebulaTheme, SizeInfo};
use super::ui::geometry as layout_geometry;
+use super::{NebulaTheme, SizeInfo};
use crate::config::ui_config::Profile;
use crate::shell_detect::DetectedShell;
use unicode_width::UnicodeWidthChar;
diff --git a/nebula_app/src/display/file_dialog.rs b/nebula_app/src/display/file_dialog.rs
index ef7c5f9..14cdff7 100644
--- a/nebula_app/src/display/file_dialog.rs
+++ b/nebula_app/src/display/file_dialog.rs
@@ -86,30 +86,12 @@ pub(super) fn classify_private_key_contents(contents: &[u8]) -> PrivateKeyFileKi
return PrivateKeyFileKind::PublicKey;
}
if [
- concat!(
- "-----BEGIN OPENSSH ",
- "PRIVATE KEY-----"
- ),
- concat!(
- "-----BEGIN RSA ",
- "PRIVATE KEY-----"
- ),
- concat!(
- "-----BEGIN EC ",
- "PRIVATE KEY-----"
- ),
- concat!(
- "-----BEGIN ENCRYPTED ",
- "PRIVATE KEY-----"
- ),
- concat!(
- "-----BEGIN ",
- "PRIVATE KEY-----"
- ),
- concat!(
- "PuTTY-User-Key-",
- "File-"
- ),
+ concat!("-----BEGIN OPENSSH ", "PRIVATE KEY-----"),
+ concat!("-----BEGIN RSA ", "PRIVATE KEY-----"),
+ concat!("-----BEGIN EC ", "PRIVATE KEY-----"),
+ concat!("-----BEGIN ENCRYPTED ", "PRIVATE KEY-----"),
+ concat!("-----BEGIN ", "PRIVATE KEY-----"),
+ concat!("PuTTY-User-Key-", "File-"),
]
.iter()
.any(|header| trimmed.starts_with(header))
@@ -254,21 +236,15 @@ mod tests {
fn encrypted_and_putty_private_key_headers_are_accepted_for_later_unlock() {
assert_eq!(
classify_private_key_contents(
- concat!(
- "-----BEGIN OPENSSH ",
- "PRIVATE KEY-----\nnot-decoded-until-passphrase\n"
- )
- .as_bytes()
+ concat!("-----BEGIN OPENSSH ", "PRIVATE KEY-----\nnot-decoded-until-passphrase\n")
+ .as_bytes()
),
PrivateKeyFileKind::PrivateKey
);
assert_eq!(
classify_private_key_contents(
- concat!(
- "PuTTY-User-Key-",
- "File-3: ssh-ed25519\nEncryption: aes256-cbc"
- )
- .as_bytes()
+ concat!("PuTTY-User-Key-", "File-3: ssh-ed25519\nEncryption: aes256-cbc")
+ .as_bytes()
),
PrivateKeyFileKind::PrivateKey
);
diff --git a/nebula_app/src/display/i18n.rs b/nebula_app/src/display/i18n.rs
deleted file mode 100644
index 8067fdc..0000000
--- a/nebula_app/src/display/i18n.rs
+++ /dev/null
@@ -1,408 +0,0 @@
-use std::collections::HashMap;
-use std::sync::OnceLock;
-
-use crate::config::template::{self, TemplateLanguage};
-use crate::provider_test::ProviderTestOutcome;
-use crate::proxy_test::{ProxyTestFailure, ProxyTestOutcome, ProxyTestRoute};
-
-const EN_US_JSON: &str = include_str!("../../i18n/en-US.json");
-const ZH_CN_JSON: &str = include_str!("../../i18n/zh-CN.json");
-
-#[derive(Debug)]
-struct TranslationCatalogs {
- en_us: HashMap,
- zh_cn: HashMap,
-}
-
-static TRANSLATIONS: OnceLock = OnceLock::new();
-
-fn parse_catalog(source: &str, locale: &str) -> HashMap {
- let root: serde_json::Value = serde_json::from_str(source).unwrap_or_else(|error| {
- panic!("embedded {locale} translation catalog is invalid: {error}")
- });
- let mut messages = HashMap::new();
- flatten_catalog(&root, "", &mut messages, locale);
- messages
-}
-
-fn flatten_catalog(
- value: &serde_json::Value,
- prefix: &str,
- output: &mut HashMap,
- locale: &str,
-) {
- match value {
- serde_json::Value::Object(object) => {
- for (key, value) in object {
- let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") };
- flatten_catalog(value, &path, output, locale);
- }
- },
- serde_json::Value::String(message) => {
- assert!(!prefix.is_empty(), "embedded {locale} catalog contains an empty message id");
- assert!(
- output.insert(prefix.to_owned(), message.clone()).is_none(),
- "embedded {locale} catalog contains duplicate message id {prefix}"
- );
- },
- _ => panic!("embedded {locale} catalog leaf {prefix} must be a string"),
- }
-}
-
-fn catalogs() -> &'static TranslationCatalogs {
- TRANSLATIONS.get_or_init(|| {
- let en_us = parse_catalog(EN_US_JSON, "en-US");
- let zh_cn = parse_catalog(ZH_CN_JSON, "zh-CN");
- let mut en_keys = en_us.keys().collect::>();
- let mut zh_keys = zh_cn.keys().collect::>();
- en_keys.sort_unstable();
- zh_keys.sort_unstable();
- assert_eq!(
- en_keys, zh_keys,
- "embedded en-US and zh-CN catalogs must have identical message ids"
- );
- TranslationCatalogs { en_us, zh_cn }
- })
-}
-
-/// Persisted UI language choice. The serialized values are stable because the
-/// runtime settings file is also a supported hand-editing surface.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
-pub enum LanguagePreference {
- #[default]
- System,
- ZhCn,
- EnUs,
-}
-
-impl LanguagePreference {
- pub fn parse(value: &str) -> Option {
- match value.trim() {
- "system" => Some(Self::System),
- "zh-CN" => Some(Self::ZhCn),
- "en-US" => Some(Self::EnUs),
- _ => None,
- }
- }
-
- pub const fn as_str(self) -> &'static str {
- match self {
- Self::System => "system",
- Self::ZhCn => "zh-CN",
- Self::EnUs => "en-US",
- }
- }
-
- pub fn resolved(self) -> UiLanguage {
- let template = template::resolve_template_language(
- Some(self.as_str()),
- None,
- template::system_locale().as_deref(),
- )
- .unwrap_or(TemplateLanguage::EnUs);
- match template {
- TemplateLanguage::ZhCn => UiLanguage::ZhCn,
- TemplateLanguage::EnUs => UiLanguage::EnUs,
- }
- }
-}
-
-/// Resolved language used by the current process. `pick` keeps translations
-/// adjacent at call sites, which makes missing English text obvious in review.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum UiLanguage {
- ZhCn,
- EnUs,
-}
-
-impl UiLanguage {
- pub const fn gpui_component_locale(self) -> &'static str {
- match self {
- Self::ZhCn => "zh-CN",
- Self::EnUs => "en",
- }
- }
-
- pub const fn pick<'a>(self, zh_cn: &'a str, en_us: &'a str) -> &'a str {
- match self {
- Self::ZhCn => zh_cn,
- Self::EnUs => en_us,
- }
- }
-
- /// Look up a message in the embedded in-memory catalog. Every locale uses
- /// the stable target-locale -> English -> key fallback chain.
- pub fn tr(self, key: &'static str) -> &'static str {
- let catalogs = catalogs();
- let current = match self {
- Self::ZhCn => &catalogs.zh_cn,
- Self::EnUs => &catalogs.en_us,
- };
- current.get(key).or_else(|| catalogs.en_us.get(key)).map(String::as_str).unwrap_or(key)
- }
-
- /// Named template substitution is centralized so call sites do not invent
- /// incompatible placeholder syntaxes.
- pub fn tr_args(self, key: &'static str, args: &[(&str, &str)]) -> String {
- let mut message = self.tr(key).to_owned();
- for (name, value) in args {
- message = message.replace(&format!("{{{name}}}"), value);
- }
- message
- }
-
- pub fn provider_test_message(self, outcome: &ProviderTestOutcome) -> String {
- let status_message =
- |key: &'static str, status: u16| self.tr_args(key, &[("status", &status.to_string())]);
- match outcome {
- ProviderTestOutcome::Success { status } => {
- status_message("provider.test.success", *status)
- },
- ProviderTestOutcome::InvalidEndpoint => {
- self.tr("provider.test.invalid_endpoint").into()
- },
- ProviderTestOutcome::MissingModel => self.tr("provider.test.missing_model").into(),
- ProviderTestOutcome::MissingApiKey => self.tr("provider.test.missing_api_key").into(),
- ProviderTestOutcome::CredentialReadFailed => {
- self.tr("provider.test.credential_read_failed").into()
- },
- ProviderTestOutcome::InvalidCredentialEncoding => {
- self.tr("provider.test.invalid_credential_encoding").into()
- },
- ProviderTestOutcome::Timeout => self.tr("provider.test.timeout").into(),
- ProviderTestOutcome::HostNotFound => self.tr("provider.test.host_not_found").into(),
- ProviderTestOutcome::ConnectionFailed => {
- self.tr("provider.test.connection_failed").into()
- },
- ProviderTestOutcome::Io { kind } => {
- self.tr_args("provider.test.io_error", &[("kind", kind)])
- },
- ProviderTestOutcome::Tls => self.tr("provider.test.tls_failed").into(),
- ProviderTestOutcome::RequestFailed => self.tr("provider.test.request_failed").into(),
- ProviderTestOutcome::AuthFailed { status } => {
- status_message("provider.test.auth_failed", *status)
- },
- ProviderTestOutcome::EndpointNotFound { status } => {
- status_message("provider.test.endpoint_not_found", *status)
- },
- ProviderTestOutcome::RateLimited { status } => {
- status_message("provider.test.rate_limited", *status)
- },
- ProviderTestOutcome::HttpStatus { status } => {
- status_message("provider.test.http_status", *status)
- },
- ProviderTestOutcome::StartFailed { error } => {
- self.tr_args("provider.test.start_failed", &[("error", error)])
- },
- }
- }
-
- pub fn proxy_test_message(self, outcome: &ProxyTestOutcome, elapsed_ms: u64) -> String {
- match outcome {
- ProxyTestOutcome::Success(route) => {
- let route = self.proxy_test_route(route);
- self.tr_args(
- "settings.network.status.success",
- &[("route", &route), ("elapsed_ms", &elapsed_ms.to_string())],
- )
- },
- ProxyTestOutcome::Failed(failure) => self.proxy_test_failure(failure),
- }
- }
-
- fn proxy_test_route(self, route: &ProxyTestRoute) -> String {
- match route {
- ProxyTestRoute::Direct => self.tr("settings.network.route.direct").into(),
- ProxyTestRoute::DirectAddress => {
- self.tr("settings.network.route.direct_address").into()
- },
- ProxyTestRoute::CustomCommand => {
- self.tr("settings.network.route.custom_command").into()
- },
- ProxyTestRoute::ProxyServer(server) => {
- self.tr_args("settings.network.route.proxy_server", &[("server", server)])
- },
- ProxyTestRoute::SshJump(target) => {
- self.tr_args("settings.network.route.ssh_jump", &[("target", target)])
- },
- }
- }
-
- fn proxy_test_failure(self, failure: &ProxyTestFailure) -> String {
- match failure {
- ProxyTestFailure::LoadSettings(error) => {
- self.tr_args("settings.network.failure.load_settings", &[("error", error)])
- },
- ProxyTestFailure::InvalidSettings(error) => {
- self.tr_args("settings.network.failure.invalid_settings", &[("error", error)])
- },
- ProxyTestFailure::Timeout { seconds } => self
- .tr_args("settings.network.failure.timeout", &[("seconds", &seconds.to_string())]),
- ProxyTestFailure::SendRequest(error) => {
- self.tr_args("settings.network.failure.send_request", &[("error", error)])
- },
- ProxyTestFailure::ReadResponse(error) => {
- self.tr_args("settings.network.failure.read_response", &[("error", error)])
- },
- ProxyTestFailure::InvalidHttpStatusLine(line) => {
- self.tr_args("settings.network.failure.invalid_http_status_line", &[("line", line)])
- },
- ProxyTestFailure::HttpStatus { status } => self.tr_args(
- "settings.network.failure.http_status",
- &[("status", &status.to_string())],
- ),
- ProxyTestFailure::ProxyServer { server, error } => self.tr_args(
- "settings.network.failure.proxy_server",
- &[("server", server), ("error", error)],
- ),
- ProxyTestFailure::CustomCommand(error) => {
- self.tr_args("settings.network.failure.custom_command", &[("error", error)])
- },
- ProxyTestFailure::JumpTask(error) => {
- self.tr_args("settings.network.failure.jump_task", &[("error", error)])
- },
- ProxyTestFailure::JumpResolve { target, error } => self.tr_args(
- "settings.network.failure.jump_resolve",
- &[("target", target), ("error", error)],
- ),
- ProxyTestFailure::JumpConnect { target, error } => self.tr_args(
- "settings.network.failure.jump_connect",
- &[("target", target), ("error", error)],
- ),
- ProxyTestFailure::JumpChannel { target, error } => self.tr_args(
- "settings.network.failure.jump_channel",
- &[("target", target), ("error", error)],
- ),
- ProxyTestFailure::Direct(error) => {
- self.tr_args("settings.network.failure.direct", &[("error", error)])
- },
- ProxyTestFailure::Start(error) => {
- self.tr_args("settings.network.status.start_failed", &[("error", error)])
- },
- ProxyTestFailure::UnexpectedEnd => {
- self.tr("settings.network.status.unexpected_end").into()
- },
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{EN_US_JSON, LanguagePreference, UiLanguage, ZH_CN_JSON, parse_catalog};
-
- #[test]
- fn persisted_language_values_are_stable() {
- for (raw, expected) in [
- ("system", LanguagePreference::System),
- ("zh-CN", LanguagePreference::ZhCn),
- ("en-US", LanguagePreference::EnUs),
- ] {
- assert_eq!(LanguagePreference::parse(raw), Some(expected));
- assert_eq!(expected.as_str(), raw);
- }
- assert_eq!(LanguagePreference::parse("zh"), None);
- }
-
- #[test]
- fn explicit_languages_do_not_depend_on_the_system_locale() {
- assert_eq!(LanguagePreference::ZhCn.resolved(), UiLanguage::ZhCn);
- assert_eq!(LanguagePreference::EnUs.resolved(), UiLanguage::EnUs);
- }
-
- #[test]
- fn embedded_catalogs_have_identical_string_keys() {
- let en_us = parse_catalog(EN_US_JSON, "en-US");
- let zh_cn = parse_catalog(ZH_CN_JSON, "zh-CN");
- let mut en_keys = en_us.keys().collect::>();
- let mut zh_keys = zh_cn.keys().collect::>();
- en_keys.sort_unstable();
- zh_keys.sort_unstable();
- assert!(!en_keys.is_empty());
- assert_eq!(en_keys, zh_keys);
- }
-
- #[test]
- fn lookup_uses_locale_then_english_then_key() {
- assert_eq!(UiLanguage::ZhCn.tr("settings.sidebar.network"), "网络");
- assert_eq!(UiLanguage::EnUs.tr("settings.sidebar.network"), "Network");
- assert_eq!(UiLanguage::EnUs.tr("missing.message.id"), "missing.message.id");
- }
-
- #[test]
- fn named_arguments_are_substituted() {
- assert_eq!(
- UiLanguage::EnUs.tr_args(
- "settings.network.status.success",
- &[("route", "Direct"), ("elapsed_ms", "12")],
- ),
- "Network connection succeeded via Direct in 12 ms"
- );
- }
-
- #[test]
- fn provider_outcomes_are_localized_at_the_display_boundary() {
- let outcome = crate::provider_test::ProviderTestOutcome::AuthFailed { status: 403 };
- assert_eq!(
- UiLanguage::EnUs.provider_test_message(&outcome),
- "Authentication failed (HTTP 403); check the API key"
- );
- assert_eq!(
- UiLanguage::ZhCn.provider_test_message(&outcome),
- "鉴权失败(HTTP 403),请检查 API Key"
- );
- }
-
- #[test]
- fn proxy_routes_are_data_until_the_display_boundary() {
- let outcome =
- crate::proxy_test::ProxyTestOutcome::Success(crate::proxy_test::ProxyTestRoute::Direct);
- assert_eq!(
- UiLanguage::EnUs.proxy_test_message(&outcome, 12),
- "Network connection succeeded via Direct connection in 12 ms"
- );
- assert_eq!(
- UiLanguage::ZhCn.proxy_test_message(&outcome, 12),
- "网络连接正常 · 直接连接 · 12 ms"
- );
- }
-
- #[test]
- #[ignore = "manual i18n microbenchmark"]
- fn measure_embedded_catalog_costs() {
- use std::hint::black_box;
- use std::time::Instant;
-
- let init_started = Instant::now();
- let en_us = parse_catalog(EN_US_JSON, "en-US");
- let zh_cn = parse_catalog(ZH_CN_JSON, "zh-CN");
- let mut en_keys = en_us.keys().collect::>();
- let mut zh_keys = zh_cn.keys().collect::>();
- en_keys.sort_unstable();
- zh_keys.sort_unstable();
- assert_eq!(en_keys, zh_keys);
- let init_elapsed = init_started.elapsed();
-
- let lookup_iterations = 2_000_000u128;
- let lookup_started = Instant::now();
- for _ in 0..lookup_iterations {
- black_box(UiLanguage::EnUs.tr("settings.sidebar.network"));
- }
- let lookup_ns = lookup_started.elapsed().as_nanos() / lookup_iterations;
-
- let template_iterations = 200_000u128;
- let template_started = Instant::now();
- for _ in 0..template_iterations {
- black_box(UiLanguage::EnUs.tr_args("provider.test.success", &[("status", "200")]));
- }
- let template_ns = template_started.elapsed().as_nanos() / template_iterations;
-
- eprintln!(
- "i18n resources={} bytes, messages={}, init={} us, lookup={} ns, template={} ns",
- EN_US_JSON.len() + ZH_CN_JSON.len(),
- en_us.len(),
- init_elapsed.as_micros(),
- lookup_ns,
- template_ns,
- );
- }
-}
diff --git a/nebula_app/src/display/message_queue_entry.rs b/nebula_app/src/display/message_queue_entry.rs
index 3050e60..dace3b9 100644
--- a/nebula_app/src/display/message_queue_entry.rs
+++ b/nebula_app/src/display/message_queue_entry.rs
@@ -240,7 +240,7 @@ fn summary_text(state: MessageQueueEntry, language: UiLanguage) -> String {
let regular = state.pending.saturating_sub(state.high_risk);
match language {
UiLanguage::ZhCn => format!("{} 个高风险 · {} 个待处理", state.high_risk, regular),
- UiLanguage::EnUs => format!("{} high risk · {} pending", state.high_risk, regular),
+ _ => format!("{} high risk · {} pending", state.high_risk, regular),
}
}
diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs
index a6f9707..3cc1bd4 100644
--- a/nebula_app/src/display/mod.rs
+++ b/nebula_app/src/display/mod.rs
@@ -76,7 +76,6 @@ pub(crate) mod context_menu;
mod context_menu_model;
mod document_model;
mod file_operations;
-mod i18n;
pub mod markdown_view;
mod message_queue_entry;
mod network_proxy_model;
@@ -101,6 +100,7 @@ pub(crate) fn quick_terminal_hotkey_from_settings(config: &UiConfig) -> String {
settings::nebula_settings_load(config).quick_terminal_hotkey
}
+pub use crate::i18n::{LanguagePreference, UiLanguage};
pub use background_color_model::BgPickerPart;
pub(crate) use background_color_model::{BACKGROUND_SWATCHES, hsv_to_rgb, rgb_to_hsv};
pub(crate) use chrome::chrome_settings_button_rect;
@@ -112,7 +112,6 @@ pub(crate) use command_completion::{
};
pub use context_menu_model::{ContextMenuAction, ContextMenuHit, ContextMenuTarget};
pub(crate) use file_operations::send_to_recycle_bin;
-pub use i18n::{LanguagePreference, UiLanguage};
pub(crate) use input_state::{
nebula_clear_line, nebula_input_backspace, nebula_input_char, nebula_input_delete_word,
nebula_input_text, nebula_shell_prompt_restored_from_raw_grid,
diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs
index 50e9199..146172c 100644
--- a/nebula_app/src/display/settings.rs
+++ b/nebula_app/src/display/settings.rs
@@ -168,8 +168,7 @@ pub(super) const BACKGROUND_ALIGNMENT_OPTIONS: [BackgroundImageAlignment; 9] = [
BackgroundImageAlignment::BottomRight,
];
-pub(super) const LANGUAGE_OPTIONS: [LanguagePreference; 3] =
- [LanguagePreference::System, LanguagePreference::ZhCn, LanguagePreference::EnUs];
+pub(super) const LANGUAGE_OPTIONS: &[LanguagePreference] = LanguagePreference::ALL;
pub(super) const ACCEPT_OPTIONS: [AcceptKey; 3] =
[AcceptKey::Both, AcceptKey::Tab, AcceptKey::Right];
@@ -532,8 +531,7 @@ fn backup_remote_input_display(
fn language_label(preference: LanguagePreference, language: UiLanguage) -> &'static str {
match preference {
LanguagePreference::System => language.pick("跟随系统", "Follow system"),
- LanguagePreference::ZhCn => "简体中文",
- LanguagePreference::EnUs => "English",
+ _ => preference.shared().native_name(),
}
}
@@ -7973,12 +7971,12 @@ pub(super) fn draw_text(
mod tests {
use super::{
CELL_WIDTH_MODE_OPTIONS, CellWidthMode, KeymapPaneState, NEW_TAB_POSITION_OPTIONS,
- NebulaSettingsSection, NewTabPosition, ProxyChoice, ProxyPaneState,
- SHOW_BACKUP_SETTINGS, SHOW_WEBDAV_SYNC_SETTINGS, STANDARD_ROW_ACTION_W, SettingsHit,
- TabRevealMotion, UiLanguage, advanced_content_end, background_color_popup,
- cell_width_mode_label, font_popup_row_count, font_popup_slot, new_tab_position_label,
- opacity_from_pointer, proxy_section_title_y, row_action_rect, settings_geometry,
- settings_hit, ssh_proxy_manual_controls, ssh_proxy_mode_control, ssh_proxy_test_button,
+ NebulaSettingsSection, NewTabPosition, ProxyChoice, ProxyPaneState, SHOW_BACKUP_SETTINGS,
+ SHOW_WEBDAV_SYNC_SETTINGS, STANDARD_ROW_ACTION_W, SettingsHit, TabRevealMotion, UiLanguage,
+ advanced_content_end, background_color_popup, cell_width_mode_label, font_popup_row_count,
+ font_popup_slot, new_tab_position_label, opacity_from_pointer, proxy_section_title_y,
+ row_action_rect, settings_geometry, settings_hit, ssh_proxy_manual_controls,
+ ssh_proxy_mode_control, ssh_proxy_test_button,
};
use crate::display::SizeInfo;
use crate::display::ui::tokens::Density;
diff --git a/nebula_app/src/display/side_panel/vcs.rs b/nebula_app/src/display/side_panel/vcs.rs
index b2edc7a..ee34448 100644
--- a/nebula_app/src/display/side_panel/vcs.rs
+++ b/nebula_app/src/display/side_panel/vcs.rs
@@ -1066,12 +1066,8 @@ pub(crate) fn parse_git_history(output: &str) -> Vec {
let subject = fields.next().unwrap_or_default().to_owned();
let author = fields.next().unwrap_or_default().to_owned();
let timestamp = fields.next().and_then(|value| value.parse().ok()).unwrap_or(0);
- let parent_hashes = fields
- .next()
- .unwrap_or_default()
- .split_whitespace()
- .map(str::to_owned)
- .collect();
+ let parent_hashes =
+ fields.next().unwrap_or_default().split_whitespace().map(str::to_owned).collect();
Some(GitCommit {
full_hash,
short_hash,
diff --git a/nebula_app/src/display/ssh_connect.rs b/nebula_app/src/display/ssh_connect.rs
index fce5b07..ea5544b 100644
--- a/nebula_app/src/display/ssh_connect.rs
+++ b/nebula_app/src/display/ssh_connect.rs
@@ -14,14 +14,14 @@ use std::time::{Duration, Instant};
use unicode_width::UnicodeWidthChar;
+#[cfg(feature = "legacy-shell")]
+use super::NebulaTheme;
use super::SizeInfo;
use super::color::Rgb;
-use super::i18n::UiLanguage;
#[cfg(feature = "legacy-shell")]
use super::ui::icons;
use super::ui::tokens::{Density, radius, space, type_scale};
-#[cfg(feature = "legacy-shell")]
-use super::NebulaTheme;
+use crate::i18n::UiLanguage;
#[cfg(feature = "legacy-shell")]
use crate::renderer::ui::{Gradient, Rgba, UiQuad};
#[cfg(feature = "legacy-shell")]
diff --git a/nebula_app/src/display/terminal_math.rs b/nebula_app/src/display/terminal_math.rs
index 805050d..ff594ab 100644
--- a/nebula_app/src/display/terminal_math.rs
+++ b/nebula_app/src/display/terminal_math.rs
@@ -949,8 +949,8 @@ impl TextGrid {
mut position: GridPosition,
delimiter: &[char],
) -> Option {
- let standalone_opener = self.span_is_blank(open.row, position.column, self.columns)
- && open.row == position.row;
+ let standalone_opener =
+ self.span_is_blank(open.row, position.column, self.columns) && open.row == position.row;
let last_row = open.row.saturating_add(DISPLAY_BLOCK_GAP_SEARCH_ROWS);
let mut content_rows = 0usize;
let mut bridged = false;
diff --git a/nebula_app/src/display/ui/mod.rs b/nebula_app/src/display/ui/mod.rs
index b470541..7c2ca05 100644
--- a/nebula_app/src/display/ui/mod.rs
+++ b/nebula_app/src/display/ui/mod.rs
@@ -51,8 +51,8 @@
pub mod caret;
mod color_math;
-pub mod guardrails;
pub(crate) mod geometry;
+pub mod guardrails;
pub mod icons;
pub mod keycap;
mod keycap_model;
diff --git a/nebula_app/src/display/ui/widgets.rs b/nebula_app/src/display/ui/widgets.rs
index 1c49fd6..7214520 100644
--- a/nebula_app/src/display/ui/widgets.rs
+++ b/nebula_app/src/display/ui/widgets.rs
@@ -17,8 +17,8 @@ use super::theme::Skin;
use super::tokens::{Density, radius};
use crate::renderer::ui::{Rgba, UiQuad};
-pub(crate) use super::scrollbar::{OverlayScrollbar, overlay_scrollbar};
pub(crate) use super::geometry::{centered_y, pane_geometry, pane_geometry_in_horizontal_bounds};
+pub(crate) use super::scrollbar::{OverlayScrollbar, overlay_scrollbar};
pub(crate) type Rect = (f32, f32, f32, f32);
diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs
index b3adee5..9f03fee 100644
--- a/nebula_app/src/event.rs
+++ b/nebula_app/src/event.rs
@@ -1413,7 +1413,7 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon
let language = self.display.ui_language();
let message = match language {
UiLanguage::ZhCn => format!("已复制 {lines} 行到剪贴板"),
- UiLanguage::EnUs => format!("Copied {lines} lines to clipboard"),
+ _ => format!("Copied {lines} lines to clipboard"),
};
self.display.push_toast(message, ToastKind::Info);
}
diff --git a/nebula_app/src/gpui_shell/assets.rs b/nebula_app/src/gpui_shell/assets.rs
index e5338c6..ef73ea8 100644
--- a/nebula_app/src/gpui_shell/assets.rs
+++ b/nebula_app/src/gpui_shell/assets.rs
@@ -27,6 +27,10 @@ macro_rules! icons {
const NEBULA_ICONS: &[(&str, &[u8])] = icons![
"command-manager",
"keymap",
+ "layout-grid",
+ "mouse-pointer",
+ "sliders",
+ "pin",
"pencil",
"trash-2",
"vcs-changes",
@@ -59,6 +63,10 @@ impl AssetSource for NebulaAssets {
/// 自带图标的路径。走路径而不是 `IconName`:扩展那个枚举等于改 fork。
pub mod nav {
+ pub const LAYOUT_GRID: &str = "icons/nebula-layout-grid.svg";
+ pub const MOUSE_POINTER: &str = "icons/nebula-mouse-pointer.svg";
+ pub const SLIDERS: &str = "icons/nebula-sliders.svg";
+ pub const PIN: &str = "icons/nebula-pin.svg";
/// 保存命令列表。闪电表达快捷执行,右侧三横线表达可管理的命令集合。
pub const COMMAND_MANAGER: &str = "icons/nebula-command-manager.svg";
/// 键盘。lucide 没有,而「按键映射」原来错挂在字号图标上。
diff --git a/nebula_app/src/gpui_shell/background_color.rs b/nebula_app/src/gpui_shell/background_color.rs
index 293445d..b93005e 100644
--- a/nebula_app/src/gpui_shell/background_color.rs
+++ b/nebula_app/src/gpui_shell/background_color.rs
@@ -18,7 +18,7 @@ use crate::gpui_shell::prelude::*;
use gpui_component::input::InputEvent;
use nebula_settings::{format_hex_rgb, parse_hex_rgb};
-use super::SettingsPane;
+use super::{SettingsPane, help};
const COMBOBOX_W: f32 = 220.0;
const COMBOBOX_H: f32 = 32.0;
@@ -290,7 +290,7 @@ impl SettingsPane {
let trigger_bounds = self.bg_picker_trigger_bounds;
let row = self.row_with_reset(
language.pick("背景色", "Background color"),
- language.tr("settings.appearance.background_color_description"),
+ help("background", language),
custom,
|this, window, cx| {
this.persist(&[("background", String::new())], cx);
diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs
index 05cc95f..f23052d 100644
--- a/nebula_app/src/gpui_shell/config.rs
+++ b/nebula_app/src/gpui_shell/config.rs
@@ -94,7 +94,7 @@ pub(crate) fn ui_language(cx: &App) -> UiLanguage {
#[inline]
fn resolve_ui_language(preference: nebula_settings::LanguagePref) -> UiLanguage {
- LanguagePreference::parse(preference.settings_value()).unwrap_or_default().resolved()
+ LanguagePreference::from(preference).resolved()
}
impl Settings {
diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs
index 95e312f..f572ae4 100644
--- a/nebula_app/src/gpui_shell/settings_pane.rs
+++ b/nebula_app/src/gpui_shell/settings_pane.rs
@@ -27,6 +27,7 @@ use gpui_component::select::{SelectEvent, SelectItem};
use nebula_settings::{RuntimeSettings, ThemeName, format_hex_rgb, persist_keys};
use design::GROUP_GAP;
+use setting_help::{SettingHelp, help};
use std::sync::Arc;
use std::time::Duration;
@@ -45,210 +46,25 @@ mod backup;
mod design;
mod font_picker;
mod providers;
+mod reset;
+mod search_header;
+mod setting_help;
mod theme_picker;
+mod initialization;
mod keymap;
+mod localization;
+mod navigation;
+mod shell_picker;
+mod status;
-/// 主题下拉(展示名 = 持久化名,与旧壳一致)。
-const THEME_VALUES: [&str; 9] = [
- "Nebula",
- "SilverLight",
- "SteelDark",
- "LimestoneLight",
- "CoalDark",
- "LinenLight",
- "MossDark",
- "Nord",
- "Paper",
-];
-
-const REPOSITORY_URL: &str = "https://github.com/Kuddev/nebula";
-const BUG_REPORT_TEMPLATE: &str = "bug_report.yml";
-
-/// 左侧分区的稳定路由表。2026-08-28 产品裁定:默认 GPUI 导航收敛为常用项,
-/// 暂时隐藏“AI 供应商”和“备份”;页面实现与索引继续保留。后续恢复入口时只改
-/// [`HIDDEN_NAV_SECTIONS`],不得删除或重排这里的条目。
-const SECTION_IDS: [&str; 10] = [
- "application",
- "appearance",
- "profiles",
- "providers",
- "ssh",
- "network",
- "interaction",
- "keymap",
- "advanced",
- "backup",
-];
-
-/// Bilingual search aliases for the stable section routes. Search is a route
-/// finder, so a query such as "font", "opacity", or "更新" lands on the
-/// section that owns the control instead of merely filtering the current page.
-const SECTION_SEARCH_TERMS: [&str; 10] = [
- "application app 应用 update 更新 version 版本 github support 支持",
- "appearance 外观 theme 主题 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标",
- "profiles 配置文件 shell terminal 终端 completion 补全 startup 启动",
- "providers provider ai 供应商 模型 api",
- "ssh host 主机 remote 远程 connection 连接",
- "network 网络 proxy 代理 connectivity 连接",
- "interaction 交互 copy 复制 paste 粘贴 tab 标签 panel 面板",
- "keymap key binding shortcut 按键映射 快捷键",
- "advanced 高级 session 会话 tray 托盘 restore 恢复",
- "backup 备份 export 导出 restore 恢复",
-];
-
-const HIDDEN_NAV_SECTIONS: &[usize] = &[3, 9];
-
-/// 保留原来的分组展开顺序,组名不再渲染;数组里仍保存稳定的 [`SECTION_IDS`]
-/// 下标,不复制设置状态或路由。
-const NAV_GROUPS: [(&str, &[usize]); 3] =
- [("workspace", &[0, 1, 2, 6, 7]), ("connections", &[3, 4, 5]), ("system", &[8, 9])];
-
-fn section_label(index: usize, language: crate::display::UiLanguage) -> &'static str {
- match SECTION_IDS.get(index).copied() {
- Some("application") => language.tr("settings.sidebar.application"),
- Some("appearance") => language.tr("settings.sidebar.appearance"),
- Some("profiles") => language.tr("settings.sidebar.profiles"),
- Some("providers") => language.tr("settings.sidebar.providers"),
- Some("ssh") => language.tr("settings.sidebar.ssh"),
- Some("network") => language.tr("settings.sidebar.network"),
- Some("interaction") => language.tr("settings.sidebar.interaction"),
- Some("keymap") => language.tr("settings.sidebar.keymap"),
- Some("advanced") => language.tr("settings.sidebar.advanced"),
- Some("backup") => language.tr("settings.sidebar.backup"),
- _ => "",
- }
-}
-
-fn is_nav_section_visible(index: usize) -> bool {
- !HIDDEN_NAV_SECTIONS.contains(&index)
-}
-
-fn visible_nav_sections() -> impl Iterator- {
- NAV_GROUPS
- .iter()
- .flat_map(|(_, sections)| sections.iter().copied())
- .filter(|index| is_nav_section_visible(*index))
-}
-
-// 这些几何值逐项来自旧壳 `display/settings.rs::settings_geometry`。GPUI
-// 设置页沿用同一节奏,避免组件默认间距把标题、分组和表单压成一条均匀列表。
-const SETTINGS_NAV_WIDTH: f32 = 232.0;
-const SETTINGS_HEADER_HEIGHT: f32 = 52.0;
-
-const SETTINGS_GROUP_GAP: f32 = 32.0;
-const SETTINGS_GROUP_TITLE_HEIGHT: f32 = 26.0;
-const SETTINGS_GROUP_TITLE_GAP: f32 = 16.0;
-const SETTINGS_ROW_HEIGHT: f32 = 44.0;
-const SETTINGS_ROW_GAP: f32 = 8.0;
-/// 标准设置选择器的实际宽度。字体输入与 Select 共用,避免同列控件漂移。
-const SETTINGS_SELECT_WIDTH: f32 = 220.0;
-
-/// 列表/触发条上的展示名:族名偶尔带着导入文件后缀,界面上剥掉。
-fn font_display_name(name: &str) -> String {
- let trimmed = name.trim();
- let lower = trimmed.to_ascii_lowercase();
- for ext in [".ttf", ".otf", ".ttc", ".otc", ".woff", ".woff2"] {
- if let Some(stem) = lower.strip_suffix(ext) {
- return trimmed[..stem.len()].to_owned();
- }
- }
- trimmed.to_owned()
-}
-
-/// 导航图标。
-///
-/// 统一走路径字符串而不是 `IconName`:绝大多数取 lucide 的现成图标,但
-/// 「按键映射」在 lucide 里没有对应项(原来错挂成 `ALargeSmall`,那是字号
-/// 图标),只能自带一枚——两种来源用同一个类型表达,调用点就不必分叉。
-fn section_icon(index: usize) -> SharedString {
- use gpui_component::IconNamed as _;
- match index {
- 0 => IconName::LayoutDashboard.path(),
- 1 => IconName::Palette.path(),
- 2 => IconName::GalleryVerticalEnd.path(),
- 3 => IconName::Bot.path(),
- 4 => IconName::SquareTerminal.path(),
- 5 => IconName::Globe.path(),
- 6 => IconName::Inspector.path(),
- 7 => crate::gpui_shell::assets::nav::KEYMAP.into(),
- 8 => IconName::Settings2.path(),
- _ => IconName::Inbox.path(),
- }
-}
-
-fn chrome_theme(theme: ThemeName) -> crate::display::NebulaTheme {
- use crate::display::NebulaTheme;
- match theme {
- ThemeName::Nebula => NebulaTheme::Nebula,
- ThemeName::SilverLight => NebulaTheme::SilverLight,
- ThemeName::SteelDark => NebulaTheme::SteelDark,
- ThemeName::LimestoneLight => NebulaTheme::LimestoneLight,
- ThemeName::CoalDark => NebulaTheme::CoalDark,
- ThemeName::LinenLight => NebulaTheme::LinenLight,
- ThemeName::MossDark => NebulaTheme::MossDark,
- ThemeName::Nord => NebulaTheme::Nord,
- ThemeName::Paper => NebulaTheme::Paper,
- }
-}
-
-fn rgb_hsla(r: u8, g: u8, b: u8) -> Hsla {
- GpuiRgba { r: f32::from(r) / 255.0, g: f32::from(g) / 255.0, b: f32::from(b) / 255.0, a: 1.0 }
- .into()
-}
-
-fn query_component(value: &str) -> String {
- const HEX: &[u8; 16] = b"0123456789ABCDEF";
- let mut encoded = String::with_capacity(value.len());
- for byte in value.bytes() {
- if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
- encoded.push(char::from(byte));
- } else {
- encoded.push('%');
- encoded.push(char::from(HEX[(byte >> 4) as usize]));
- encoded.push(char::from(HEX[(byte & 0x0f) as usize]));
- }
- }
- encoded
-}
-
-/// GitHub issue form 预填合同:模板、版本、平台、安装来源
-/// 和诊断摘要都来自当前运行实例,用户只需补充复现步骤与实际表现。
-fn issue_url() -> String {
- let version = env!("CARGO_PKG_VERSION");
- let platform = match std::env::consts::OS {
- "windows" => "Windows",
- "macos" => "macOS",
- "linux" => "Linux",
- _ => "Other",
- };
- let install_source = if cfg!(debug_assertions) {
- "Built from source (cargo build / cargo run)"
- } else {
- "GitHub Release (.msi / .exe / portable archive)"
- };
- let build = if cfg!(debug_assertions) { "debug" } else { "release" };
- let logs = format!(
- "Reported from {} Settings ({} {version}).\n\nPlatform: {} {}\nBuild: {build}",
- crate::brand::NAME,
- crate::brand::NAME,
- std::env::consts::OS,
- std::env::consts::ARCH,
- );
- let params = [
- ("template", BUG_REPORT_TEMPLATE),
- ("title", "[Bug] "),
- ("version", version),
- ("platform", platform),
- ("install_source", install_source),
- ("logs", logs.as_str()),
- ]
- .into_iter()
- .map(|(key, value)| format!("{key}={}", query_component(value)))
- .collect::>()
- .join("&");
- format!("{REPOSITORY_URL}/issues/new?{params}")
-}
+use localization::*;
+use navigation::*;
+use shell_picker::*;
+pub(super) use status::SshStatus;
+use status::{
+ AboutUpdateState, BackupCompletion, BackupStatus, ProviderStatus, TerminalImportError,
+};
/// 宿主(workspace)监听:设置已写盘 / 终端目录已变 / 请求打开 SSH 会话。
pub enum SettingsPaneEvent {
@@ -264,640 +80,8 @@ pub enum SettingsPaneEvent {
pub(super) type SharedSelect = Entity>>;
type SharedShellSelect = Entity>>;
-/// 固定下拉只在这里把稳定 value 映射为显示文案。创建和语言切换刷新共用
-/// 同一入口,避免 `SelectState` 留着构造时的旧语言。
-fn localized_select_labels(
- key: &str,
- values: &[&'static str],
- language: crate::display::UiLanguage,
-) -> Vec {
- let labels: Vec<&'static str> = match key {
- "language" => vec![
- language.tr("language.system"),
- language.tr("language.zh_cn"),
- language.tr("language.en_us"),
- ],
- "cursor_shape" => vec![
- language.pick("条形(│)", "Bar (│)"),
- language.pick("下划线(_)", "Underscore (_)"),
- language.pick("实心框(█)", "Filled box (█)"),
- language.pick("空心框(□)", "Empty box (□)"),
- ],
- "tabs_position" => {
- vec![language.pick("左侧边栏", "Left sidebar"), language.pick("顶部", "Top")]
- },
- "tab_reveal" => vec![language.pick("滑动", "Slide"), language.pick("立即", "Instant")],
- "density" => vec![language.pick("标准", "Standard"), language.pick("紧凑", "Compact")],
- "new_tab_position" => vec![
- language.pick("当前标签之后", "After current tab"),
- language.pick("列表末尾", "End of list"),
- ],
- "windowing_behavior" => vec![
- language.pick("创建新窗口", "Create a new window"),
- language.pick("附加到最近使用的窗口", "Attach to the most recent window"),
- language.pick(
- "附加到此桌面最近使用的窗口",
- "Attach to the most recent window on this desktop",
- ),
- ],
- "vcs_display" => vec![
- language.pick("自动检测", "Auto detect"),
- language.pick("仅 Git", "Git only"),
- language.pick("仅 SVN", "SVN only"),
- ],
- "cell_width_mode" => {
- vec![language.pick("紧凑", "Compact"), language.pick("宽松", "Relaxed")]
- },
- "bell" => vec![
- language.pick("关", "Off"),
- language.pick("闪烁", "Visual"),
- language.pick("声音", "Sound"),
- language.pick("闪烁 + 声音", "Visual + sound"),
- ],
- "blur" => vec![
- language.pick("无", "None"),
- language.pick("Mica(低开销)", "Mica (low cost)"),
- language.pick("Mica Alt(低开销)", "Mica Alt (low cost)"),
- language.pick("Aero(玻璃)", "Aero (glass)"),
- language.pick("Acrylic(高开销)", "Acrylic (high cost)"),
- ],
- "accept" => vec![
- language.pick("右方向键", "Right arrow"),
- "Tab",
- language.pick("Tab 或右方向键", "Tab or Right arrow"),
- ],
- "completion_style" => {
- vec![language.pick("行内灰字", "Inline ghost"), language.pick("弹窗列表", "Popup list")]
- },
- "background_image_fit" => vec![
- language.pick("拉伸", "Fill"),
- language.pick("适应", "Uniform"),
- language.pick("填充", "Uniform to fill"),
- language.pick("原始尺寸", "Original size"),
- ],
- "background_image_alignment" => vec![
- language.pick("左上", "Top left"),
- language.pick("顶部", "Top"),
- language.pick("右上", "Top right"),
- language.pick("左侧", "Left"),
- language.pick("居中", "Center"),
- language.pick("右侧", "Right"),
- language.pick("左下", "Bottom left"),
- language.pick("底部", "Bottom"),
- language.pick("右下", "Bottom right"),
- ],
- "ssh_proxy_mode" => vec![
- language.tr("settings.network.mode.off"),
- language.tr("settings.network.mode.system"),
- language.tr("settings.network.mode.custom"),
- ],
- _ => values.to_vec(),
- };
- debug_assert_eq!(labels.len(), values.len(), "localized select label/value mismatch: {key}");
- labels.into_iter().map(SharedString::from).collect()
-}
-
-fn provider_input_placeholders(language: crate::display::UiLanguage) -> [&'static str; 5] {
- [
- language.pick("供应商名称", "Provider name"),
- language.pick("备注(可包含空格)", "Note (spaces allowed)"),
- language.pick("官方网站", "Official website"),
- language.pick("API 请求地址", "API endpoint"),
- language.pick("默认模型", "Default model"),
- ]
-}
-
-fn localized_input_placeholder(key: &str, language: crate::display::UiLanguage) -> &'static str {
- match key {
- "ssh_label" => language.pick("例如:开发服务器", "e.g. Development server"),
- "ssh_password" => language.pick("留空则连接时询问", "Leave empty to ask when connecting"),
- "ssh_proxy_username" => language.pick("可选", "Optional"),
- "ssh_proxy_password" => {
- language.pick("留空则保留已存密码", "Leave empty to keep the saved password")
- },
- "ssh_jump_host" => language
- .pick("user@bastion:22 或 SSH config 别名", "user@bastion:22 or SSH config alias"),
- "ssh_icon_filter" => language.pick("搜索图标…", "Search icons..."),
- "font_family" => language.pick("输入字体名称", "Enter a font family"),
- "backup_password" => {
- language.pick("备份密码(至少 8 位)", "Backup password (at least 8 characters)")
- },
- "backup_secret" => language.tr("settings.input.backup_secret"),
- "keymap_search" => language.pick("搜索动作或按键…", "Search actions or keys..."),
- _ => "",
- }
-}
-
-#[derive(Clone)]
-struct ShellSelectItem {
- id: String,
- name: SharedString,
- closed_image: Option>,
- row_image: Option>,
-}
-
-/// 下拉首行那条「导入终端目录」动作行的哨兵 id。
-///
-/// 它不是任何真实 shell:选中只触发目录扫描,绝不会写进 `shell` 设置。
-/// 取这个形状是因为 `shell_detect` 的 id 全是普通标识符(`pwsh`、`cmd`、
-/// `wsl:`、`profile:<家族>|`),双下划线包裹不可能与之相撞。
-const SHELL_IMPORT_ACTION_ID: &str = "__nebula_import_terminal_dir__";
-
-#[derive(Clone, Debug, Default)]
-enum AboutUpdateState {
- #[default]
- Idle,
- Checking,
- UpToDate(String),
- Available(String),
- Failed(String),
-}
-
-#[derive(Clone, Debug)]
-enum ProviderStatus {
- Saved,
- Added,
- AtLeastOneRequired,
- Deleted,
- ApiKeySaved,
- Testing,
- TestResult { outcome: crate::provider_test::ProviderTestOutcome, elapsed_ms: u64 },
- CodexConfirmation,
- AppliedToCodex(std::path::PathBuf),
- Error(String),
-}
-
-impl ProviderStatus {
- fn is_error(&self) -> bool {
- match self {
- Self::AtLeastOneRequired | Self::Error(_) => true,
- Self::TestResult { outcome, .. } => !outcome.is_success(),
- _ => false,
- }
- }
-
- fn text(&self, language: crate::display::UiLanguage) -> String {
- match self {
- Self::Saved => language.pick("供应商配置已保存", "Provider settings saved").into(),
- Self::Added => language.pick("已添加自定义供应商", "Custom provider added").into(),
- Self::AtLeastOneRequired => {
- language.pick("至少保留一个供应商", "Keep at least one provider").into()
- },
- Self::Deleted => language
- .pick("供应商及其凭据已删除", "Provider and its credentials deleted")
- .into(),
- Self::ApiKeySaved => language
- .pick(
- "API Key 已保存到系统凭据管理器",
- "API key saved to the system credential manager",
- )
- .into(),
- Self::Testing => language.pick("正在测试连接…", "Testing connection...").into(),
- Self::TestResult { outcome, elapsed_ms } => {
- format!("{} · {elapsed_ms} ms", language.provider_test_message(outcome))
- },
- Self::CodexConfirmation => language
- .pick(
- "再次点击确认:API Key 将明文写入 Codex auth.json(原文件会备份)",
- "Click again to confirm: the API key will be written in plain text to Codex auth.json (the original file will be backed up)",
- )
- .into(),
- Self::AppliedToCodex(path) => format!(
- "{}: {}",
- language.pick("已应用到 Codex", "Applied to Codex"),
- path.display()
- ),
- Self::Error(error) => format!(
- "{}: {error}",
- language.pick("操作失败", "Operation failed")
- ),
- }
- }
-}
-
-#[derive(Clone, Debug)]
-enum BackupCompletion {
- Exported(std::path::PathBuf),
- Restored,
- Pushed(String),
- Pulled(String),
-}
-
-#[derive(Clone, Debug)]
-enum BackupStatus {
- PassphraseTooShort,
- SelectionRequired,
- Processing,
- RemoteConfigSaved,
- CredentialEmpty,
- CredentialUnsupported,
- CredentialSaved,
- Completed(BackupCompletion),
- Error(String),
-}
-
-impl BackupStatus {
- fn is_error(&self) -> bool {
- matches!(
- self,
- Self::PassphraseTooShort
- | Self::SelectionRequired
- | Self::CredentialEmpty
- | Self::CredentialUnsupported
- | Self::Error(_)
- )
- }
-
- fn text(&self, language: crate::display::UiLanguage) -> String {
- match self {
- Self::PassphraseTooShort => language
- .pick("备份密码至少 8 位", "The backup password must be at least 8 characters")
- .into(),
- Self::SelectionRequired => language
- .pick("请至少勾选一个备份类别", "Select at least one backup category")
- .into(),
- Self::Processing => language.pick("处理中…", "Processing...").into(),
- Self::RemoteConfigSaved => {
- language.pick("远端配置已保存", "Remote configuration saved").into()
- },
- Self::CredentialEmpty => {
- language.pick("凭据不能为空", "Credentials cannot be empty").into()
- },
- Self::CredentialUnsupported => language
- .pick(
- "当前协议不需要独立凭据",
- "The current protocol does not use a separate credential",
- )
- .into(),
- Self::CredentialSaved => language
- .pick(
- "凭据已写入系统凭据管理器",
- "Credential saved to the system credential manager",
- )
- .into(),
- Self::Completed(BackupCompletion::Exported(path)) => format!(
- "{}: {}",
- language.pick("已导出加密备份", "Encrypted backup exported"),
- path.display()
- ),
- Self::Completed(BackupCompletion::Restored) => language
- .pick(
- "已从备份恢复(字体/托盘等部分设置重启后生效)",
- "Backup restored (some settings, including fonts and tray options, apply after restart)",
- )
- .into(),
- Self::Completed(BackupCompletion::Pushed(location)) => {
- format!("{} {location}", language.pick("已推送到", "Pushed to"))
- },
- Self::Completed(BackupCompletion::Pulled(name)) => format!(
- "{} {name} {}",
- language.pick("已从", "Restored from"),
- language.pick("恢复(部分设置重启后生效)", "(some settings apply after restart)")
- ),
- Self::Error(error) => format!(
- "{}: {error}",
- language.pick("备份操作失败", "Backup operation failed")
- ),
- }
- }
-}
-
-#[derive(Debug)]
-enum TerminalImportError {
- Scan(String),
- NoSupportedTerminal,
- Load(String),
- Import(String),
- Save(String),
-}
-
-impl TerminalImportError {
- fn text(self, language: crate::display::UiLanguage) -> String {
- match self {
- Self::Scan(error) => format!(
- "{}: {error}",
- language.pick("无法扫描终端目录", "Could not scan the terminal directory")
- ),
- Self::NoSupportedTerminal => language
- .pick(
- "目录中未找到受支持的终端程序",
- "No supported terminal program was found in the directory",
- )
- .into(),
- Self::Load(error) => format!(
- "{}: {error}",
- language.pick("无法读取终端配置", "Could not read terminal profiles")
- ),
- Self::Import(error) => format!(
- "{}: {error}",
- language.pick("无法导入终端", "Could not import the terminal")
- ),
- Self::Save(error) => format!(
- "{}: {error}",
- language.pick("无法保存终端配置", "Could not save terminal profiles")
- ),
- }
- }
-}
-
-#[derive(Clone, Debug)]
-pub(super) enum SshStatus {
- Saved(String),
- Pinned,
- Imported(usize),
- Opening(String),
- DeleteCommitted { hidden_config: bool },
- CleanupPartial(String),
- Restored(String),
- Validation(SshValidationError),
- PersistFailed(String),
- DeleteFailed(String),
- UndoFailed(String),
- TestStartFailed(String),
- ProfileLoadFailed(String),
- ProfileSaveFailed(String),
- HostListSaveFailed(String),
- CredentialSaveFailed(String),
- SavedWithCleanupError { destination: String, error: String },
- Error(String),
-}
-
-impl SshStatus {
- pub(super) fn is_error(&self) -> bool {
- matches!(
- self,
- Self::CleanupPartial(_)
- | Self::Validation(_)
- | Self::PersistFailed(_)
- | Self::DeleteFailed(_)
- | Self::UndoFailed(_)
- | Self::TestStartFailed(_)
- | Self::ProfileLoadFailed(_)
- | Self::ProfileSaveFailed(_)
- | Self::HostListSaveFailed(_)
- | Self::CredentialSaveFailed(_)
- | Self::SavedWithCleanupError { .. }
- | Self::Error(_)
- )
- }
-
- pub(super) fn text(&self, language: crate::display::UiLanguage) -> String {
- match self {
- Self::Saved(destination) => {
- format!("{} {destination}", language.pick("已保存", "Saved"))
- },
- Self::Pinned => language.pick("置顶状态已更新", "Pin status updated").into(),
- Self::Imported(count) => format!(
- "{} {count} {}",
- language.pick("已导入,config 源共", "Imported"),
- language.pick("个别名", "config aliases")
- ),
- Self::Opening(host) => {
- format!("{} {host}…", language.pick("正在打开", "Opening"))
- },
- Self::DeleteCommitted { hidden_config: true } => language
- .pick(
- "已隐藏 config 别名,并清理 Pebrel Profile 与凭据",
- "Config alias hidden; Pebrel profile and credentials removed",
- )
- .into(),
- Self::DeleteCommitted { hidden_config: false } => language
- .pick(
- "已删除主机、Profile 与凭据",
- "Host, profile, and credentials deleted",
- )
- .into(),
- Self::CleanupPartial(details) => format!(
- "{}: {details}",
- language.pick(
- "主机已从列表移除,但部分清理失败",
- "Host removed from the list, but some cleanup failed",
- )
- ),
- Self::Restored(host) => {
- format!("{} {host}", language.pick("已恢复", "Restored"))
- },
- Self::Validation(error) => error.text(language).into(),
- Self::PersistFailed(error) => format!(
- "{}: {error}",
- language.pick("写入设置失败", "Failed to write settings")
- ),
- Self::DeleteFailed(error) => format!(
- "{}: {error}",
- language.pick("删除主机失败", "Failed to delete host")
- ),
- Self::UndoFailed(error) => {
- format!("{}: {error}", language.pick("撤销失败", "Undo failed"))
- },
- Self::TestStartFailed(error) => format!(
- "{}: {error}",
- language.pick("无法启动连接测试", "Could not start the connection test")
- ),
- Self::ProfileLoadFailed(error) => format!(
- "{}: {error}",
- language.pick("加载 SSH Profile 失败", "Failed to load the SSH profile")
- ),
- Self::ProfileSaveFailed(error) => format!(
- "{}: {error}",
- language.pick("保存 SSH Profile 失败", "Failed to save the SSH profile")
- ),
- Self::HostListSaveFailed(error) => format!(
- "{}: {error}",
- language.pick("保存主机列表失败", "Failed to save the host list")
- ),
- Self::CredentialSaveFailed(error) => format!(
- "{}: {error}",
- language.pick(
- "Profile 已保存,但密码写入凭据管理器失败",
- "The profile was saved, but the password could not be written to the credential manager",
- )
- ),
- Self::SavedWithCleanupError { destination, error } => format!(
- "{} {destination}, {}: {error}",
- language.pick("已保存", "Saved"),
- language.pick(
- "但旧地址凭据清理失败",
- "but credentials for the previous address could not be removed",
- )
- ),
- Self::Error(error) => {
- format!("{}: {error}", language.pick("SSH 操作失败", "SSH operation failed"))
- },
- }
- }
-}
-
use super::ssh_settings::{SshDeleteUndo, SshEditorState, SshValidationError};
-impl ShellSelectItem {
- fn new(id: String, name: String, scale_factor: f32) -> Self {
- // Select 的闭态和菜单行尺寸不同。分别生成与物理像素一一对应的纹理,
- // 避免把 128px 原图交给 GPUI 在每帧缩小而产生模糊边缘。
- let closed_image = crate::gpui_shell::widgets::shell_brand_image(&id, 20.0, scale_factor);
- let row_image = crate::gpui_shell::widgets::shell_brand_image(&id, 24.0, scale_factor);
- Self { id, name: name.into(), closed_image, row_image }
- }
-
- /// 置顶的导入行。没有品牌贴图,[`Self::view`] 会给它文件夹图标。
- fn import_action(language: crate::display::UiLanguage) -> Self {
- Self {
- id: SHELL_IMPORT_ACTION_ID.to_owned(),
- name: language.pick("导入终端目录…", "Import terminal directory...").into(),
- closed_image: None,
- row_image: None,
- }
- }
-
- fn is_import_action(&self) -> bool {
- self.id == SHELL_IMPORT_ACTION_ID
- }
-
- fn view(&self, size: f32, image: Option<&Arc>) -> gpui::AnyElement {
- let icon: gpui::AnyElement = if let Some(image) = image {
- gpui::StyledImage::object_fit(
- img(image.clone()).size(px(size)).flex_shrink_0(),
- gpui::ObjectFit::Contain,
- )
- .into_any_element()
- } else if self.is_import_action() {
- // 动作行与真实 shell 行必须一眼分得开:文件夹口 = 「去别处拿」。
- Icon::new(IconName::FolderOpen).xsmall().into_any_element()
- } else {
- Icon::new(IconName::SquareTerminal).xsmall().into_any_element()
- };
- h_flex()
- .gap_2()
- .items_center()
- .child(icon)
- .child(div().flex_1().min_w_0().child(self.name.clone()))
- .into_any_element()
- }
-}
-
-/// 默认 Shell 下拉的全部候选,以及应当选中的行号。
-///
-/// 顺序 = 置顶导入行 → 已安装 shell(`detect_shells` 菜单序)→ 用户导入的
-/// 终端 profile。导入项以 `profile:<家族>|` 作设置值([`Profile::settings_id`]
-/// 同一形状),品牌图标因此仍能按家族查到。
-fn shell_select_items(
- current: &str,
- scale_factor: f32,
- language: crate::display::UiLanguage,
-) -> (Vec, usize) {
- let mut items: Vec = crate::shell_detect::detect_shells()
- .into_iter()
- .map(|shell| ShellSelectItem::new(shell.id, shell.name, scale_factor))
- .collect();
- if items.is_empty() {
- // 非 Windows 构建不做安装探测,但历史配置仍支持这两个由 PTY
- // 集成层负责启动的稳定 id,设置页不能因此变成空下拉。
- items = vec![
- ShellSelectItem::new("powershell".into(), "PowerShell".into(), scale_factor),
- ShellSelectItem::new("bash".into(), "Git Bash".into(), scale_factor),
- ];
- }
- // 导入的终端目录:`merge_terminal_profiles` 已把它们并进配置的 profile
- // 列表,这里让设置页也能直接选为默认 Shell——否则导入完看不见结果。
- if let Ok(store) = crate::terminal_profiles::TerminalProfiles::load() {
- for profile in store.as_config_profiles() {
- let Some(id) = profile.settings_id() else { continue };
- if items.iter().any(|item| item.id == id) {
- continue;
- }
- items.push(ShellSelectItem::new(id, profile.name, scale_factor));
- }
- }
- if !items.iter().any(|item| item.id == current) {
- // 检测结果可能暂时找不到已保存的 WSL/profile id;先把它保留在首位,
- // 用户仍可看到并重新选择,下一次检测恢复后不会丢失持久化值。
- items.insert(
- 0,
- ShellSelectItem::new(
- current.to_owned(),
- crate::shell_detect::display_name_for_id(current).to_owned(),
- scale_factor,
- ),
- );
- }
- let selected = items.iter().position(|item| item.id == current).unwrap_or(0);
- // 导入行最后才插到首位:它不参与选中判定,所以选中行号整体后移一位。
- items.insert(0, ShellSelectItem::import_action(language));
- (items, selected + 1)
-}
-
-/// 扫描目录并落盘(阻塞 IO,调用方须放后台执行器)。
-/// 逻辑与旧壳 `Display::import_terminal_directory` 逐句对齐,只是把 toast
-/// 换成 `Result`,由 UI 线程决定怎么呈现。
-fn import_terminal_directory_blocking(
- directory: &std::path::Path,
-) -> Result {
- let found = crate::terminal_profiles::scan_directory(directory)
- .map_err(|error| TerminalImportError::Scan(error.to_string()))?;
- if found.is_empty() {
- return Err(TerminalImportError::NoSupportedTerminal);
- }
- let mut profiles = crate::terminal_profiles::TerminalProfiles::load()
- .map_err(|error| TerminalImportError::Load(error.to_string()))?;
- let count = found.len();
- for profile in found {
- profiles.upsert(profile).map_err(|error| TerminalImportError::Import(error.to_string()))?;
- }
- profiles.save().map_err(|error| TerminalImportError::Save(error.to_string()))?;
- Ok(count)
-}
-
-/// 原生模态对话框不能在 GPUI update 借用中运行:它自己的消息泵会重入
-/// wndproc,造成 AppCell 二次可变借用。与 SSH 私钥选择器一样,先在 UI
-/// 线程捕获 HWND,再让专用线程运行旧壳的 IFileOpenDialog。
-#[cfg(windows)]
-fn pick_folder_with_wsl_places(
- window: &Window,
- title: &'static str,
-) -> futures::channel::oneshot::Receiver