feat(settings): expand UI languages and simplify preferences

Share the locale registry and generate typed static catalogs with tested
fallback and allocation contracts. Add searchable sections, a backed-up
defaults reset, compact navigation and expandable appearance help.

Include the existing formatter corrections required by the repository
hook, and remove the obsolete settings-pane line-budget exception after
its construction, localization and navigation responsibilities move out.
Update both English and Chinese unreleased notes.
This commit is contained in:
kuddev committed 2026-09-06 19:23:32 +08:00
1 parent 461975bfb2
commit 1552a5f7e5
85 files changed
+5699 -2868

No files matched your search

+8
View File
@@ -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` 按词删除。
+8
View File
@@ -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` 按词删除。
+3 -1
View File
@@ -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]
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="6.5" width="19" height="11" rx="2.2"/><path d="M6.4 10h.01M10 10h.01M13.6 10h.01M17.2 10h.01M6.4 14h.01M17.2 14h.01"/><path d="M10 14h3.6"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h1m3 0h1m3 0h1m3 0h1M6 14h1m3 0h8"/></svg>

Before

Width:  |  Height:  |  Size: 323 B

After

Width:  |  Height:  |  Size: 266 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><rect x="14" y="14" width="6" height="6" rx="1"/></svg>

After

Width:  |  Height:  |  Size: 359 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m6 3 13 9-6 1-3 6L6 3Zm7 10 5 7"/></svg>

After

Width:  |  Height:  |  Size: 210 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 10V13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.71429 7.088C6.7142 7.26164 6.66963 7.43181 6.58559 7.57938C6.50156 7.72695 6.38138 7.84606 6.23857 7.92333L5.47571 8.34333C5.33291 8.4206 5.21273 8.53972 5.12869 8.68729C5.04465 8.83486 5.00008 9.00503 5 9.17867V9.53333C5 9.6571 5.04515 9.7758 5.12553 9.86332C5.2059 9.95083 5.31491 10 5.42857 10H10.5714C10.6851 10 10.7941 9.95083 10.8745 9.86332C10.9548 9.7758 11 9.6571 11 9.53333V9.17867C10.9999 9.00503 10.9553 8.83486 10.8713 8.68729C10.7873 8.53972 10.6671 8.4206 10.5243 8.34333L9.76143 7.92333C9.61862 7.84606 9.49844 7.72695 9.41441 7.57938C9.33037 7.43181 9.2858 7.26164 9.28571 7.088V5.33333C9.28571 5.20957 9.33087 5.09087 9.41124 5.00335C9.49161 4.91583 9.60062 4.86667 9.71429 4.86667C9.94161 4.86667 10.1596 4.76833 10.3204 4.5933C10.4811 4.41827 10.5714 4.18087 10.5714 3.93333C10.5714 3.6858 10.4811 3.4484 10.3204 3.27337C10.1596 3.09833 9.94161 3 9.71429 3H6.28571C6.05839 3 5.84037 3.09833 5.67962 3.27337C5.51888 3.4484 5.42857 3.6858 5.42857 3.93333C5.42857 4.18087 5.51888 4.41827 5.67962 4.5933C5.84037 4.76833 6.05839 4.86667 6.28571 4.86667C6.39938 4.86667 6.50839 4.91583 6.58876 5.00335C6.66913 5.09087 6.71429 5.20957 6.71429 5.33333V7.088Z" fill="black" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h5m6 0h7M3 17h11m6 0h1"/><circle cx="11" cy="7" r="3"/><circle cx="17" cy="17" r="3"/></svg>

After

Width:  |  Height:  |  Size: 266 B

+8 -53
View File
@@ -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::<Vec<_>>();
let missing_en = zh.difference(&en).cloned().collect::<Vec<_>>();
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<String> {
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<String>,
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.
///
+221
View File
@@ -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::<Vec<_>>();
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<UiLanguage> { 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<nebula_settings::LanguagePref> 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<Message> { 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<Message> { 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::<usize>();
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<String, String> {
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<String, String> {
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::<Vec<_>>(), ["count", "name"]);
assert!(catalog::placeholders("broken {name").is_err());
assert!(catalog::placeholders("broken }").is_err());
}
}
+99
View File
@@ -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<String, Node>),
}
impl<'de> Deserialize<'de> for Node {
fn deserialize<Deserializer>(deserializer: Deserializer) -> Result<Self, Deserializer::Error>
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<Error: de::Error>(self, value: &str) -> Result<Node, Error> {
Ok(Node::Text(value.to_owned()))
}
fn visit_string<Error: de::Error>(self, value: String) -> Result<Node, Error> {
Ok(Node::Text(value))
}
fn visit_map<Map: MapAccess<'de>>(self, mut map: Map) -> Result<Node, Map::Error> {
let mut entries = BTreeMap::new();
while let Some((key, value)) = map.next_entry::<String, Node>()? {
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<BTreeMap<String, String>, String> {
fn flatten(
node: Node,
prefix: &str,
output: &mut BTreeMap<String, String>,
) -> 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<std::collections::BTreeSet<&str>, 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)
}
+177
View File
@@ -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"
}
}
+134 -1
View File
@@ -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."
}
}
+177
View File
@@ -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}"
}
}
+248
View File
@@ -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}"
}
}
}
+177
View File
@@ -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}"
}
}
+177
View File
@@ -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} に送信しました"
}
}
+177
View File
@@ -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}(으)로 전송했습니다"
}
}
+177
View File
@@ -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}"
}
}
+177
View File
@@ -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}"
}
}
+134 -1
View File
@@ -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": "确认请求已经变更或结束。"
}
}
+177
View File
@@ -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}"
}
}
+3 -1
View File
@@ -742,7 +742,9 @@ fn parse_envelope(bytes: &[u8]) -> Option<AiHookEvent> {
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());
+27 -45
View File
@@ -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<TemplateLanguage, TemplateError> {
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<String> {
system_locale_impl().or_else(environment_locale)
}
#[cfg(windows)]
fn system_locale_impl() -> Option<String> {
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<String> {
None
}
fn environment_locale() -> Option<String> {
["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();
+1 -1
View File
@@ -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;
+10 -34
View File
@@ -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
);
-408
View File
@@ -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<String, String>,
zh_cn: HashMap<String, String>,
}
static TRANSLATIONS: OnceLock<TranslationCatalogs> = OnceLock::new();
fn parse_catalog(source: &str, locale: &str) -> HashMap<String, String> {
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<String, String>,
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::<Vec<_>>();
let mut zh_keys = zh_cn.keys().collect::<Vec<_>>();
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<Self> {
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::<Vec<_>>();
let mut zh_keys = zh_cn.keys().collect::<Vec<_>>();
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::<Vec<_>>();
let mut zh_keys = zh_cn.keys().collect::<Vec<_>>();
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,
);
}
}
@@ -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),
}
}
+1 -2
View File
@@ -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,
+8 -10
View File
@@ -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;
+2 -6
View File
@@ -1066,12 +1066,8 @@ pub(crate) fn parse_git_history(output: &str) -> Vec<GitCommit> {
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,
+3 -3
View File
@@ -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")]
+2 -2
View File
@@ -949,8 +949,8 @@ impl TextGrid {
mut position: GridPosition,
delimiter: &[char],
) -> Option<GridPosition> {
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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -1413,7 +1413,7 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> 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);
}
+8
View File
@@ -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 没有,而「按键映射」原来错挂在字号图标上。
@@ -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);
+1 -1
View File
@@ -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 {
File diff suppressed because it is too large. Load diff
@@ -88,69 +88,13 @@ impl SettingsPane {
.into_any_element()
}
fn appearance_typography(&mut self, window: &mut Window, cx: &mut Context<Self>) -> gpui::Div {
pub(super) fn terminal_font_size_row(&self, cx: &Context<Self>) -> gpui::AnyElement {
let language = crate::gpui_shell::config::ui_language(cx);
let colors = AppearanceColors::current(cx);
let size = self.terminal_font_size_px(cx);
let family = self.current_font_chain(cx);
let summary = format!(
"{} · {size:.0} px",
font_display_name(family.split(',').next().unwrap_or(&family))
);
let expanded = self.typography_expanded;
let header = Button::new("appearance-typography-toggle")
.ghost()
.w_full()
.h(px(88.0))
.px_0()
.child(
h_flex()
.w_full()
.gap(px(18.0))
.justify_between()
.child(appearance_copy(
language.pick("终端字体", "Terminal font"),
language.pick(
"字体与字号,按需调整。",
"Adjust the font and size when needed.",
),
colors,
))
.child(
h_flex()
.max_w(px(280.0))
.gap(px(19.0))
.text_color(colors.secondary)
.text_size(px(12.0))
.child(div().truncate().child(summary))
.child(
Icon::new(if expanded {
IconName::ChevronDown
} else {
IconName::ChevronRight
})
.size(px(14.0)),
),
),
)
.on_click(cx.listener(|this, _, window, cx| {
this.typography_expanded = !this.typography_expanded;
if !this.typography_expanded {
this.close_font_picker(window, true, cx);
}
cx.notify();
}));
let section = v_flex().w_full().border_b_1().border_color(colors.line).child(header);
if !expanded {
return section;
}
let font_picker = self.font_picker_dropdown(window, cx);
let stepper = h_flex()
.w(px(142.0))
.h(px(36.0))
.rounded(px(6.0))
.border_1()
.border_color(colors.control)
.items_center()
.child(
Button::new("appearance-font-smaller")
.icon(IconName::Minus)
@@ -165,7 +109,7 @@ impl SettingsPane {
);
})),
)
.child(div().flex_1().text_center().text_size(px(12.0)).child(format!("{size:.0} px")))
.child(div().flex_1().text_center().child(format!("{size:.0} px")))
.child(
Button::new("appearance-font-larger")
.icon(IconName::Plus)
@@ -180,106 +124,13 @@ impl SettingsPane {
);
})),
);
let theme = crate::gpui_shell::theme::effective_theme_name(cx);
section
.child(
h_flex()
.w_full()
.items_start()
.flex_wrap()
.gap(px(24.0))
.pb(px(24.0))
.child(
v_flex()
.flex_1()
.min_w(px(220.0))
.gap(px(8.0))
.child(
div()
.text_size(px(11.5))
.text_color(colors.secondary)
.child(language.pick("字体", "Font")),
)
.child(font_picker),
)
.child(
v_flex()
.flex_1()
.gap(px(8.0))
.child(
div()
.text_size(px(11.5))
.text_color(colors.secondary)
.child(language.pick("字号", "Size")),
)
.child(stepper),
),
)
.child(
v_flex()
.w_full()
.max_w(px(520.0))
.pb(px(24.0))
.child(self.terminal_appearance_preview(theme, true, false, window, cx))
.child(
h_flex()
.mt(px(9.0))
.justify_between()
.text_size(px(10.0))
.text_color(colors.muted)
.child(
h_flex()
.gap(px(5.0))
.child(Icon::new(IconName::Eye).size(px(11.0)))
.child(language.pick("字体预览", "Font preview")),
)
.child(chrome_theme(theme).short_label()),
),
)
}
pub(super) fn appearance_page_header(&self, cx: &mut Context<Self>) -> gpui::Div {
let language = crate::gpui_shell::config::ui_language(cx);
let colors = AppearanceColors::current(cx);
h_flex()
.w_full()
.text_color(colors.ink)
.min_h(px(110.0))
.flex_shrink_0()
.px(px(40.0))
.py(px(25.0))
.gap(px(20.0))
.border_b_1()
.border_color(colors.line)
.justify_between()
.child(
v_flex()
.min_w_0()
.gap(px(5.0))
.child(
div()
.text_size(px(25.0))
.font_semibold()
.child(language.pick("外观", "Appearance")),
)
.child(div().text_size(px(12.0)).text_color(colors.secondary).child(
language.pick(
"主题、应用图标与显示偏好。",
"Themes, app icons and display preferences.",
),
)),
)
.child(
Button::new("appearance-reset")
.icon(IconName::Undo2)
.label(language.pick("恢复默认", "Restore defaults"))
.ghost()
.h(px(32.0))
.px(px(10.0))
.text_size(px(12.0))
.text_color(colors.secondary)
.on_click(cx.listener(|this, _, window, cx| this.reset_appearance(window, cx))),
)
self.row(
language.pick("终端字号(Ctrl+滚轮缩放)", "Terminal font size (Ctrl+wheel)"),
language.pick("只调整终端文字大小。", "Changes only the terminal text size."),
stepper,
cx,
)
.into_any_element()
}
pub(super) fn section_appearance(
@@ -291,133 +142,54 @@ impl SettingsPane {
let colors = AppearanceColors::current(cx);
let theme = self.appearance_trigger(true, window, cx);
let icon = self.appearance_trigger(false, window, cx);
let typography = self.appearance_typography(window, cx);
let platform_note = if cfg!(windows) {
language.pick("选择自动保存。固定快捷方式与安装器仍使用默认图标。", "Choices are saved automatically. Pinned shortcuts and the installer keep the default icon.")
} else {
language.pick("选择自动保存。图标仅应用于应用内,不更改 Dock 或启动器图标。", "Choices are saved automatically. Icons change within the app, not in the Dock or launcher.")
};
let advanced =
self.appearance_advanced_expanded.then(|| self.appearance_advanced_settings(cx));
v_flex()
let selectors = v_flex()
.w_full()
.text_color(colors.ink)
.child(
v_flex()
.w_full()
.child(
h_flex()
.w_full()
.min_h(px(58.0))
.gap(px(25.0))
.justify_between()
.child(appearance_copy(
language.pick("主题", "Theme"),
language.pick(
"终端与界面的配色,统一选择。",
"One palette for the terminal and interface.",
),
colors,
))
.child(theme),
)
.child(
h_flex()
.w_full()
.mt(px(22.0))
.pt(px(18.0))
.gap(px(16.0))
.justify_between()
.border_t_1()
.border_color(colors.line)
.text_size(px(11.5))
.text_color(colors.secondary)
.child(language.pick(
"随系统自动切换深浅色",
"Switch between light and dark with the system",
))
.child(
h_flex()
.gap(px(9.0))
.child(language.pick("跟随系统", "Follow system"))
.child(
crate::gpui_shell::widgets::NebulaSwitch::new(
"appearance-follow-system",
)
.checked(self.runtime.follow_system_theme)
.on_click(
cx.listener(|this, checked: &bool, window, cx| {
this.set_follow_system_theme(*checked, window, cx);
}),
),
),
),
),
)
.gap(px(24.0))
.child(
h_flex()
.w_full()
.mt(px(29.0))
.py(px(25.0))
.min_h(px(58.0))
.gap(px(25.0))
.items_center()
.justify_between()
.child(appearance_copy(
language.pick("主题", "Theme"),
language.pick(
"终端与界面的配色,统一选择。",
"One palette for the terminal and interface.",
),
colors,
))
.child(theme),
)
.child(self.switch_row(
"follow_system_theme",
language.pick("跟随系统", "Follow system"),
language.pick(
"随系统自动切换深浅色。",
"Switches between light and dark with the system.",
),
self.runtime.follow_system_theme,
cx,
))
.child(
h_flex()
.w_full()
.min_h(px(58.0))
.gap(px(25.0))
.items_center()
.justify_between()
.border_t_1()
.border_b_1()
.border_color(colors.line)
.child(appearance_copy(
language.pick("应用图标", "App icon"),
language.pick(
"独立于主题,切换配色时保持不变。",
"Independent of the theme. Stays the same when colors change.",
"Independent of the theme; changing colors keeps the icon.",
),
colors,
))
.child(icon),
)
.child(typography)
.child(
h_flex()
.w_full()
.pt(px(21.0))
.gap(px(12.0))
.items_start()
.justify_between()
.child(
h_flex()
.flex_1()
.min_w_0()
.gap(px(6.0))
.items_start()
.text_size(px(10.5))
.text_color(colors.muted)
.child(Icon::new(IconName::Info).size(px(12.0)))
.child(platform_note),
)
.child(
Button::new("appearance-advanced-toggle")
.ghost()
.h(px(24.0))
.text_size(px(10.5))
.text_color(colors.secondary)
.label(language.pick("更多外观设置", "More appearance settings"))
.icon(if self.appearance_advanced_expanded {
IconName::ChevronDown
} else {
IconName::ChevronRight
})
.on_click(cx.listener(|this, _, window, cx| {
this.appearance_advanced_expanded =
!this.appearance_advanced_expanded;
if !this.appearance_advanced_expanded {
this.close_background_picker(cx);
window.focus(&this.focus_handle, cx);
}
cx.notify();
})),
),
)
.when_some(advanced, |section, advanced| {
section.child(div().mt(px(24.0)).child(advanced))
})
);
let settings = self.appearance_advanced_settings(window, cx);
v_flex().w_full().gap(px(GROUP_GAP)).child(selectors).child(settings)
}
}
@@ -1,8 +1,13 @@
use super::*;
impl SettingsPane {
pub(super) fn appearance_advanced_settings(&mut self, cx: &mut Context<Self>) -> gpui::Div {
pub(super) fn appearance_advanced_settings(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> gpui::Div {
let language = crate::gpui_shell::config::ui_language(cx);
let font_picker = self.font_picker_dropdown(window, cx);
let opacity: SharedString = format!("{:.0}%", self.runtime.opacity * 100.0).into();
let wallpaper_opacity: SharedString =
format!("{:.0}%", self.runtime.background_image_opacity * 100.0).into();
@@ -10,19 +15,32 @@ impl SettingsPane {
.group(language.pick("自定义背景", "Custom background"), cx)
.child(self.background_color_row(cx))
.child(self.background_image_row(cx))
.child(self.select_row("background_image_fit", language.pick("背景图像拉伸模式", "Background image fit"), language.pick("拉伸会把图压变形;适应保持比例、四周留边;填充保持比例但裁掉溢出的部分;原始尺寸按图片自己的像素铺。", "Fill distorts the image; Uniform preserves its aspect ratio and may leave margins; Uniform to fill preserves the ratio but crops overflow; Original size uses the image's own pixels."), cx))
.child(self.select_row("background_image_alignment", language.pick("背景图像对齐", "Background image alignment"), language.pick("图片没铺满或被裁掉时,保留哪一侧。壁纸类的图通常选顶部或居中,主体才不会被切走。", "Choose which edge to preserve when the image does not fill the area or is cropped. Top or center usually keeps a wallpaper's subject visible."), cx))
.child(self.select_row(
"background_image_fit",
language.pick("背景图像拉伸模式", "Background image fit"),
help("background_image_fit", language),
cx,
))
.child(self.select_row(
"background_image_alignment",
language.pick("背景图像对齐", "Background image alignment"),
help("background_image_alignment", language),
cx,
))
.child(self.slider_row(
language.pick("背景图像不透明度", "Background image opacity"),
language.pick("压得越低文字越清楚。图片不参与配色,字色始终由主题决定。", "Lower values make text easier to read. The image does not affect colors; text colors always come from the theme."),
help("background_image_opacity", language),
&self.wallpaper_opacity_slider,
wallpaper_opacity,
cx,
))
.child(self.switch_row(
"background_image_cover_chrome",
language.pick("将背景图扩展到标题栏和侧边栏", "Extend the background image into the title bar and sidebar"),
language.pick("开着时整窗一张图,界面和终端连成一片;关掉则图片只铺终端区域,侧栏与标题栏保持纯色、文字更稳。", "When enabled, one image covers the entire window. When disabled, it covers only the terminal while the sidebar and title bar keep a solid background."),
language.pick(
"将背景图扩展到标题栏和侧边栏",
"Extend the background image into the title bar and sidebar",
),
help("background_image_cover_chrome", language),
self.runtime.background_image_cover_chrome,
cx,
));
@@ -31,13 +49,13 @@ impl SettingsPane {
.child(self.select_row(
"cursor_shape",
language.pick("光标形状", "Cursor shape"),
language.pick("条形贴近编辑器的手感,实心框在满屏输出里最容易一眼找到。", "A bar feels closer to an editor; a filled box is easiest to spot in dense terminal output."),
help("cursor_shape", language),
cx,
))
.child(self.switch_row(
"cursor_blink",
language.pick("光标闪烁", "Blink cursor"),
language.pick("关掉后光标常亮。长时间盯屏时不闪更省心,代价是光标在密集输出里没那么显眼。", "When disabled, the cursor stays lit. A steady cursor is calmer during long sessions but less visible in dense output."),
help("cursor_blink", language),
self.runtime.cursor_blink.unwrap_or(DEFAULT_CURSOR_BLINK),
cx,
));
@@ -46,42 +64,61 @@ impl SettingsPane {
.child(self.switch_row(
"tab_close_visible",
language.pick("显示标签关闭按钮", "Show tab close buttons"),
language.pick(
"关闭后侧栏与顶栏的标签都不再显示关闭按钮;中键点击标签仍可关闭。",
"Hides close buttons in both sidebar and top tabs. Middle-click still closes a tab.",
),
help("tab_close_visible", language),
self.runtime.tab_close_visible,
cx,
))
.child(self.select_row(
"language",
language.pick("语言", "Language"),
language.pick("只改 Pebrel 自己的界面。终端里程序输出什么语言由它们自己的环境变量决定,不受这里影响。", "Changes only Pebrel's interface. Programs inside the terminal choose their language from their own environment and are not affected."),
language.text(crate::i18n::Message::CommonLanguage),
help("language", language),
cx,
))
.child(self.select_row(
"density",
language.pick("界面外观", "Interface density"),
help("density", language),
cx,
))
.child(self.select_row("density", language.pick("界面外观", "Interface density"), language.pick("紧凑会收窄标签行高与设置页行距这些界面留白。终端内容的行距不归它管,那是字体的事。", "Compact reduces interface spacing such as tab height and Settings row gaps. It does not change terminal line spacing, which is controlled by the font."), cx))
.child(self.slider_row(
language.pick("终端正文不透明度", "Terminal content opacity"),
language.pick("1 = 完全不透明。调低会透出后方窗口,配合下面的窗口模糊才不至于让文字压在杂乱内容上。", "1 is fully opaque. Lower values reveal windows behind Pebrel; use window blur to keep terminal text readable over busy content."),
help("opacity", language),
&self.opacity_slider,
opacity,
cx,
))
.child(self.select_row("blur", language.pick("背景模糊", "Background blur"), language.pick("五者是五套成本模型,不是越靠后越好:Mica 只取系统壁纸的色调、最省;Aero 与 Acrylic 每帧实时模糊窗口后方的真实内容,Acrylic 还多一层着色与噪点。", "These are different performance models, not quality levels. Mica only samples the wallpaper tint and costs least; Aero and Acrylic blur live content behind the window every frame, and Acrylic adds tint and noise."), cx));
.child(self.select_row(
"blur",
language.pick("背景模糊", "Background blur"),
help("blur", language),
cx,
));
let terminal = self
.group(language.pick("终端外观", "Terminal appearance"), cx)
.child(self.select_row("cell_width_mode", language.pick("字体间距", "Character spacing"), language.pick("列宽的取整方式。紧凑向下取整、字更密;宽松向上补一像素,专治 `Maple Mono` 这类平均字宽带小数的字体把字形挤扁。只作用于终端网格。", "Controls how terminal cell width is rounded. Compact rounds down for denser text; Relaxed adds a pixel to prevent fonts with fractional widths, such as `Maple Mono`, from looking squeezed. This affects only the terminal grid."), cx))
.child(self.row(
language.pick("终端字体", "Terminal font"),
help("font_family", language),
font_picker,
cx,
))
.child(self.terminal_font_size_row(cx))
.child(self.select_row(
"cell_width_mode",
language.pick("字体间距", "Character spacing"),
help("cell_width_mode", language),
cx,
))
.child(self.switch_row(
"fetch",
language.pick("启动欢迎信息", "Startup system information"),
language.pick("新会话开头跑一次 `fastfetch` 打印系统信息。默认关,因为开新标签会因此慢一拍。", "Runs `fastfetch` at the start of a new session. It is off by default because it adds a delay when opening a tab."),
help("fetch", language),
self.runtime.fetch,
cx,
))
.child(self.switch_row(
"powerline",
language.pick("Powerline 提示符", "Powerline prompt"),
language.pick("给 Pebrel 注入的提示符加箭头分段。需要终端字体带 Powerline 字形,否则那些箭头会显示成方框。", "Adds arrow segments to Pebrel's injected prompt. The terminal font must include Powerline glyphs or the arrows will render as boxes."),
help("powerline", language),
self.runtime.powerline,
cx,
));
@@ -89,9 +126,9 @@ impl SettingsPane {
v_flex()
.w_full()
.gap(px(GROUP_GAP))
.child(custom_background)
.child(terminal)
.child(cursor)
.child(interface)
.child(terminal)
.child(custom_background)
}
}
@@ -77,42 +77,19 @@ pub(super) struct AppearanceColors {
impl AppearanceColors {
pub(super) fn current(cx: &App) -> Self {
let light = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().is_light;
let colors = if light {
[
0xffffff, 0xf6f7f8, 0xe5e9ed, 0x2c3440, 0x697586, 0x7e8998, 0xe9ecf0, 0xd9dfe6,
0x35414f, 0xffffff, 0x1d2733,
]
} else {
[
0x282d36, 0x303640, 0x3f4957, 0xe3e8ef, 0xafb9c7, 0x99a5b5, 0x3a414d, 0x4b5564,
0xd8e1ed, 0x25303f, 0x080c14,
]
};
let [
surface,
subtle,
selected,
ink,
secondary,
muted,
line,
control,
primary,
on_primary,
scrim,
] = colors.map(|color| Hsla::from(gpui::rgb(color)));
let theme = cx.theme();
Self {
surface,
subtle,
selected,
ink,
secondary,
muted,
line,
control,
primary,
on_primary,
scrim: scrim.opacity(if light { 0.30 } else { 0.42 }),
surface: crate::gpui_shell::theme::settings_panel_bg(cx),
subtle: crate::gpui_shell::theme::settings_hover_bg(cx, false),
selected: theme.list_active,
ink: theme.foreground,
secondary: theme.muted_foreground,
muted: crate::gpui_shell::theme::faint_ink(cx),
line: crate::gpui_shell::theme::settings_hairline(cx),
control: theme.border,
primary: theme.primary,
on_primary: theme.primary_foreground,
scrim: Hsla::from(gpui::rgb(0x000000)).opacity(if light { 0.30 } else { 0.42 }),
}
}
}
@@ -10,8 +10,8 @@
//! 1. **层次是正交维度的乘积,不是字号堆叠。** 字号只有两档(正文 / 说明),
//! 层级交给字重(600/500/400)和位置(标题出线)。把标题一放大就会出现三
//! 套字号,那是另一种病。
//! 2. **留白表达归属,不表达距离。** 2 / 16 / 24 三级:2px 说"这两行是同一
//! 件事",16px 说"这是两件事",24px 说"这是两组事"。均匀间距无论调多大都
//! 2. **留白表达归属,不表达距离。** 4 / 24 / 32 三级:4px 说"这两行是同一
//! 件事",24px 说"这是两件事",32px 说"这是两组事"。均匀间距无论调多大都
//! 只会得到一条稀疏的平线。
//! 3. **说明写后果,不写定义。**「关掉后关窗即杀掉所有 shell」比「控制会话
//! 保留行为」有用——用户在这一行要做的判断是"关掉会怎样"。
@@ -25,15 +25,15 @@ use super::*;
/// 说明文字相对正文的字号比。全页只有两档字号:正文走用户基准字号,说明小
/// 一档。
pub(super) const DESC_SCALE: f32 = 0.82;
/// label ↔ 说明。这 2px 在说"这两行是同一件事"。
const LABEL_DESC_GAP: f32 = 2.0;
/// label ↔ 说明。这 4px 在说"这两行是同一件事"。
const LABEL_DESC_GAP: f32 = 4.0;
/// 行内上下留白,行与行之间因此是它的两倍。用 padding 而不是行间 gap,左侧
/// 轨道才连得上——断成一截一截的话,"哪几段亮着"根本读不出来。
const ROW_PAD_Y: f32 = 8.0;
const ROW_PAD_Y: f32 = 12.0;
/// 紧凑密度下的同一个值(「界面外观」里的密度开关对设置页真实生效)。
const ROW_PAD_Y_COMPACT: f32 = 5.0;
const ROW_PAD_Y_COMPACT: f32 = 8.0;
/// 组与组。
pub(super) const GROUP_GAP: f32 = 24.0;
pub(super) const GROUP_GAP: f32 = 32.0;
/// 轨道宽度。
const RAIL_W: f32 = 2.0;
/// 内容相对轨道的缩进。标题左对齐轨道本身、行内容缩进这么多——标题是命名者
@@ -79,7 +79,7 @@ impl SettingsPane {
// 不设宽度则走 flex 交叉轴 stretch:布局算法直接拉伸,不依赖父宽解析。
v_flex().w_full().child(
div()
.pb(px(5.0))
.pb(px(10.0))
.text_size(px(base_px * DESC_SCALE))
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().muted_foreground)
@@ -102,11 +102,11 @@ impl SettingsPane {
pub(crate) fn row(
&self,
label: &'static str,
desc: &'static str,
desc: impl Into<SettingHelp>,
control: impl IntoElement,
cx: &Context<Self>,
) -> impl IntoElement {
self.row_shell(label, desc, None, false, RowLayout::Standard, control, cx)
self.row_shell(label, desc.into(), None, false, RowLayout::Standard, control, cx)
}
/// 带撤销的设置行:该项被覆盖过时,左侧轨道这一段亮起来,行内出现 ↶。
@@ -117,7 +117,7 @@ impl SettingsPane {
pub(crate) fn row_with_reset(
&self,
label: &'static str,
desc: &'static str,
desc: impl Into<SettingHelp>,
dirty: bool,
on_reset: impl Fn(&mut Self, &mut Window, &mut Context<Self>) + 'static,
control: impl IntoElement,
@@ -147,7 +147,7 @@ impl SettingsPane {
.child(Icon::new(IconName::Undo2).xsmall())
.into_any_element()
});
self.row_shell(label, desc, reset, dirty, RowLayout::Standard, control, cx)
self.row_shell(label, desc.into(), reset, dirty, RowLayout::Standard, control, cx)
}
/// 行 hover 组名。↶ 要跟着**整行**的 hover 显形,而不是自己被指到才现
@@ -200,7 +200,7 @@ impl SettingsPane {
fn row_shell(
&self,
label: &'static str,
desc: &'static str,
desc: SettingHelp,
reset: Option<gpui::AnyElement>,
dirty: bool,
layout: RowLayout,
@@ -209,6 +209,7 @@ impl SettingsPane {
) -> impl IntoElement {
let theme = cx.theme();
let base_px = self.font_size_px(cx);
let expanded = self.expanded_setting_help.contains(label);
let pad_y = if self.runtime.density == nebula_settings::DensityName::Compact {
ROW_PAD_Y_COMPACT
} else {
@@ -227,16 +228,51 @@ impl SettingsPane {
.text_color(theme.foreground)
.child(label),
)
.when(desc.details.is_some(), |heading| {
let language = crate::gpui_shell::config::ui_language(cx);
let action = if expanded {
language.pick("收起说明", "Hide details")
} else {
language.pick("详细说明", "More details")
};
heading.child(
Button::new(SharedString::from(format!("settings-help-{label}")))
.icon(IconName::Info)
.ghost()
.size(px(22.0))
.text_color(theme.muted_foreground)
.accessibility_id(SharedString::from(format!(
"settings-help-{label}"
)))
.toggled(expanded)
.tooltip(format!("{label}: {action}"))
.on_click(cx.listener(move |this, _, _, cx| {
if !this.expanded_setting_help.insert(label) {
this.expanded_setting_help.remove(label);
}
cx.notify();
})),
)
})
.children(reset),
)
.when(!desc.is_empty(), |text| {
.when(!desc.summary.is_empty(), |text| {
text.child(
div()
.mt(px(LABEL_DESC_GAP))
.text_size(px(base_px * DESC_SCALE))
.font_weight(FontWeight::NORMAL)
.text_color(theme.muted_foreground)
.child(Self::desc_text(desc, cx)),
.child(Self::desc_text(desc.summary, cx)),
)
})
.when_some(desc.details.filter(|_| expanded), |text, details| {
text.child(
div()
.mt(px(8.0))
.text_size(px(base_px * DESC_SCALE))
.text_color(theme.muted_foreground)
.child(Self::desc_text(details, cx)),
)
});
let control = control.into_any_element();
@@ -235,7 +235,7 @@ impl SettingsPane {
}
self.row(
language.pick("终端字体", "Terminal font"),
language.tr("settings.font.fallback_description"),
help("font_family", language),
control,
cx,
)
@@ -0,0 +1,579 @@
use super::*;
impl SettingsPane {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let runtime = RuntimeSettings::load();
let language = crate::gpui_shell::config::ui_language(cx);
let mut selects: Vec<(&'static str, SharedSelect, &'static [&'static str])> = Vec::new();
let mut subscriptions = Vec::new();
let mut add_select = |key: &'static str,
values: &'static [&'static str],
current: &str,
window: &mut Window,
cx: &mut Context<Self>| {
let ix = values.iter().position(|v| *v == current).unwrap_or(0);
let select = cx.new(|cx| {
SelectState::new(
localized_select_labels(key, values, language),
Some(IndexPath::default().row(ix)),
window,
cx,
)
});
subscriptions.push(cx.subscribe_in(
&select,
window,
move |this: &mut Self,
entity: &SharedSelect,
event: &SelectEvent<Vec<SharedString>>,
window: &mut Window,
cx: &mut Context<Self>| {
if let SelectEvent::Confirm(Some(_)) = event {
let row = entity.read(cx).selected_index(cx).map(|path| path.row);
if let Some(value) = row.and_then(|row| values.get(row)) {
this.persist(&[(key, (*value).to_string())], cx);
if key == "language" {
this.refresh_localized_controls(window, cx);
cx.refresh_windows();
}
}
}
},
));
selects.push((key, select, values));
};
let cursor_current =
runtime.cursor_shape.map(|shape| shape.settings_value()).unwrap_or("beam");
let shell_current = runtime.shell.clone().unwrap_or_else(|| "powershell".into());
add_select(
"language",
nebula_settings::LanguagePref::VALUES,
runtime.language.settings_value(),
window,
cx,
);
add_select("theme", &THEME_VALUES, runtime.theme.prompt_name(), window, cx);
// 选项顺序与文案照抄旧壳 `CURSOR_SHAPE_OPTIONS` / `cursor_shape_label`。
add_select(
"cursor_shape",
&["beam", "underline", "block", "hollow"],
cursor_current,
window,
cx,
);
add_select(
"tabs_position",
&["sidebar", "top"],
runtime.tabs_position.settings_value(),
window,
cx,
);
add_select(
"tab_reveal",
&["slide", "instant"],
runtime.tab_reveal.settings_value(),
window,
cx,
);
add_select(
"density",
&["standard", "compact"],
runtime.density.settings_value(),
window,
cx,
);
add_select(
"new_tab_position",
&["after_current", "end"],
runtime.new_tab_position.settings_value(),
window,
cx,
);
add_select(
"windowing_behavior",
&["use_new", "use_any_existing", "use_existing"],
runtime.windowing_behavior.settings_value(),
window,
cx,
);
add_select(
"vcs_display",
&["auto", "git", "svn"],
runtime.vcs_display.settings_value(),
window,
cx,
);
add_select(
"cell_width_mode",
&["compact", "relaxed"],
runtime.cell_width_mode.settings_value(),
window,
cx,
);
// 文案照抄旧壳 `accept_label` / `completion_style_label`。
add_select(
"bell",
&["none", "visual", "audible", "both"],
runtime.bell.settings_value(),
window,
cx,
);
// 五档按 DWM 每帧成本排列,不是质量递进;用户按性能预算选择。
// Aero 是实时玻璃,Acrylic 是实时材质模糊;两者都会采样窗口后方真实内容。
// Mica / Mica Alt 使用系统壁纸 backdrop,不由 Nebula 读取或重采样。
add_select(
"blur",
&["none", "mica", "mica-alt", "aero", "acrylic"],
runtime.blur.settings_value(),
window,
cx,
);
add_select(
"accept",
&["right", "tab", "both"],
runtime.accept.settings_value(),
window,
cx,
);
add_select(
"completion_style",
&["inline", "popup"],
runtime.completion_style.settings_value(),
window,
cx,
);
// 壁纸 fit/对齐:存原文,经旧壳 renderer::image 的 parse 归一化
// (兼容 cover/contain 等别名),展示用规范记号。
let bgimg_fit = crate::renderer::image::BackgroundImageFit::parse(
runtime.background_image_fit.as_deref().unwrap_or(""),
)
.unwrap_or_default()
.settings_value();
// 顺序与文案照抄旧壳 `BACKGROUND_FIT_OPTIONS` / `background_image_fit_label`。
add_select(
"background_image_fit",
&["fill", "uniform", "uniform_to_fill", "none"],
bgimg_fit,
window,
cx,
);
let bgimg_align = crate::renderer::image::BackgroundImageAlignment::parse(
runtime.background_image_alignment.as_deref().unwrap_or(""),
)
.unwrap_or_default()
.settings_value();
// 九宫格顺序照抄旧壳 `BACKGROUND_ALIGNMENT_OPTIONS`(左上 → 右下)。
add_select(
"background_image_alignment",
&[
"top_left",
"top",
"top_right",
"left",
"center",
"right",
"bottom_left",
"bottom",
"bottom_right",
],
bgimg_align,
window,
cx,
);
add_select(
"ssh_proxy_mode",
&["off", "system", "custom"],
runtime.ssh_proxy_mode.settings_value(),
window,
cx,
);
// 与旧壳的默认 Shell 菜单共用检测层:不能在设置页另维护一份两项
// 白名单,否则 CMD/Nushell/WSL 会出现在新建终端菜单,却无法设为默认。
// 选项 = 彩色品牌 PNG(extra/shell-icons,与旧壳设置页/命令面板同
// 一批资产)+ 名称,闭态与下拉同源(SelectItem::display_title/render)。
let shell_icon_scale = window.scale_factor().max(0.5);
let (shell_items, shell_index) =
shell_select_items(&shell_current, shell_icon_scale, language);
let shell_select = cx.new(|cx| {
SelectState::new(shell_items, Some(IndexPath::default().row(shell_index)), window, cx)
});
subscriptions.push(cx.subscribe_in(
&shell_select,
window,
move |this: &mut Self,
_entity: &SharedShellSelect,
event: &SelectEvent<Vec<ShellSelectItem>>,
window: &mut Window,
cx: &mut Context<Self>| {
if let SelectEvent::Confirm(Some(id)) = event {
// 置顶那行是动作不是选项:走导入流程,且不落盘。
if id == SHELL_IMPORT_ACTION_ID {
this.import_terminal_directory(window, cx);
} else {
this.persist(&[("shell", id.clone())], cx);
}
}
},
));
let bg_hex_input = {
let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg;
let rgb = runtime.background.unwrap_or([term.r, term.g, term.b]);
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("#rrggbb")
.default_value(format_hex_rgb(rgb))
});
subscriptions.push(cx.subscribe_in(
&input,
window,
|this: &mut Self,
_: &Entity<InputState>,
event: &InputEvent,
window: &mut Window,
cx: &mut Context<Self>| {
this.on_bg_hex_event(event, window, cx);
},
));
input
};
let opacity_slider = cx.new(|_| {
SliderState::new().min(0.00).max(1.00).step(0.05).default_value(runtime.opacity)
});
subscriptions.push(cx.subscribe(&opacity_slider, |this, _, event: &SliderEvent, cx| {
if let SliderEvent::Change(value) = event {
this.set_opacity(value.start(), cx);
}
}));
let wallpaper_opacity_slider = cx.new(|_| {
SliderState::new()
.min(0.05)
.max(1.00)
.step(0.05)
.default_value(runtime.background_image_opacity)
});
subscriptions.push(cx.subscribe(
&wallpaper_opacity_slider,
|this, _, event: &SliderEvent, cx| {
if let SliderEvent::Change(value) = event {
this.set_wallpaper_opacity(value.start(), cx);
}
},
));
let (proxy_protocol, proxy_address) =
crate::display::manual_proxy_parts(&runtime.ssh_proxy_url);
let proxy_protocol_ix = crate::display::MANUAL_PROXY_PROTOCOL_OPTIONS
.iter()
.position(|item| *item == proxy_protocol)
.unwrap_or(0);
let proxy_protocol_select = cx.new(|cx| {
SelectState::new(
vec![SharedString::from("SOCKS5"), SharedString::from("HTTP")],
Some(IndexPath::default().row(proxy_protocol_ix)),
window,
cx,
)
});
subscriptions.push(cx.subscribe_in(
&proxy_protocol_select,
window,
|this: &mut Self,
_,
event: &SelectEvent<Vec<SharedString>>,
_,
cx: &mut Context<Self>| {
if matches!(event, SelectEvent::Confirm(Some(_))) {
this.commit_proxy_address(cx);
}
},
));
let proxy_url_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("127.0.0.1:7890")
.pattern(regex::Regex::new(r"^[^\s]{0,256}$").expect("static regex"))
.default_value(proxy_address.to_owned())
});
subscriptions.push(cx.subscribe_in(
&proxy_url_input,
window,
|this: &mut Self, _, event: &InputEvent, _, cx: &mut Context<Self>| {
this.on_proxy_address_event(event, cx);
},
));
let provider_store = crate::ai_providers::load();
let active_provider = provider_store
.providers
.iter()
.find(|provider| provider.id == provider_store.active_id)
.or_else(|| provider_store.providers.first())
.cloned()
.unwrap_or_else(|| {
crate::ai_providers::AiProvider::preset(
crate::ai_providers::ProviderKind::Custom,
"custom-1",
)
});
let provider_values = [
active_provider.name,
active_provider.note,
active_provider.website_url,
active_provider.base_url,
active_provider.model,
];
let provider_placeholders = provider_input_placeholders(language);
let provider_inputs = provider_values
.into_iter()
.zip(provider_placeholders)
.map(|(value, placeholder)| {
cx.new(|cx| {
InputState::new(window, cx).placeholder(placeholder).default_value(value)
})
})
.collect();
// 远端备份的非密文槽位输入(容量按最多槽位的协议 S3 = 4);值在
// 构造体内按当前协议回填。
let backup_remote_inputs: Vec<Entity<InputState>> =
(0..4).map(|_| cx.new(|cx| InputState::new(window, cx))).collect();
let ssh_username_input = cx.new(|cx| InputState::new(window, cx).placeholder("root"));
// 地址框现在只承担 host/IP;仍兼容粘贴整段 user@host,由共享 helper
// 在保存/测试时拆出内嵌用户名。
let ssh_destination_input =
cx.new(|cx| InputState::new(window, cx).placeholder("example.com / 192.0.2.1"));
// 端口键入即过滤:至多 5 位数字(旧壳同规则;范围校验在保存/测试时做)。
let ssh_port_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("22")
.pattern(regex::Regex::new(r"^\d{0,5}$").expect("static regex"))
});
let ssh_label_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(localized_input_placeholder("ssh_label", language))
});
let ssh_password_input = cx.new(|cx| {
InputState::new(window, cx)
.masked(true)
.placeholder(localized_input_placeholder("ssh_password", language))
});
let ssh_icon_filter_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(localized_input_placeholder("ssh_icon_filter", language))
});
let ssh_proxy_host_input =
cx.new(|cx| InputState::new(window, cx).placeholder("127.0.0.1"));
let ssh_proxy_port_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("1080")
.pattern(regex::Regex::new(r"^\d{0,5}$").expect("static regex"))
});
let ssh_proxy_username_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(localized_input_placeholder("ssh_proxy_username", language))
});
let ssh_proxy_password_input = cx.new(|cx| {
InputState::new(window, cx)
.masked(true)
.placeholder(localized_input_placeholder("ssh_proxy_password", language))
});
let ssh_jump_host_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(localized_input_placeholder("ssh_jump_host", language))
});
for input in [
ssh_username_input.clone(),
ssh_destination_input.clone(),
ssh_port_input.clone(),
ssh_label_input.clone(),
ssh_password_input.clone(),
ssh_proxy_host_input.clone(),
ssh_proxy_port_input.clone(),
ssh_proxy_username_input.clone(),
ssh_proxy_password_input.clone(),
ssh_jump_host_input.clone(),
] {
subscriptions.push(cx.subscribe_in(
&input,
window,
|this: &mut Self, _, event: &InputEvent, _, cx: &mut Context<Self>| {
if matches!(event, InputEvent::Change) {
this.touch_ssh_editor(cx);
} else if matches!(event, InputEvent::Focus | InputEvent::Blur) {
cx.notify();
}
},
));
}
let font_family_input =
Self::new_font_family_input(runtime.font_family.clone(), window, cx);
subscriptions.push(cx.subscribe_in(
&font_family_input,
window,
|this: &mut Self, _, event: &InputEvent, window, cx| {
this.on_font_family_input_event(event, window, cx);
},
));
let bg_picker_hsv = {
let term = crate::gpui_shell::theme::chrome_theme_resolved(cx).palette().term_bg;
let rgb = runtime.background.unwrap_or([term.r, term.g, term.b]);
crate::display::rgb_to_hsv(crate::display::color::Rgb::new(rgb[0], rgb[1], rgb[2]))
};
// GPUI 会先匹配 KeyBinding action,再派发元素的 capture/bubble KeyDown。
// 因此录制 PageDown 等已有快捷键必须在应用级 interceptor 提前截住;
// 焦点检查保证后台设置标签或普通设置输入不受影响。
let keymap_interceptor = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| {
if this.keymap_capture.is_some() && this.focus_handle.contains_focused(window, cx) {
cx.stop_propagation();
this.handle_keymap_capture(&event.keystroke, cx);
}
});
subscriptions.push(cx.intercept_keystrokes(keymap_interceptor));
let appearance_interceptor = cx.listener(Self::intercept_appearance_picker);
subscriptions.push(cx.intercept_keystrokes(appearance_interceptor));
let settings_search_input = cx.new(|cx| {
InputState::new(window, cx).placeholder(language.pick(
"搜索全部设置,例如「字号」「透明度」「更新」",
"Search all settings, e.g. font, opacity, update",
))
});
subscriptions.push(cx.subscribe_in(
&settings_search_input,
window,
|_this: &mut Self,
_: &Entity<InputState>,
event: &InputEvent,
_: &mut Window,
cx: &mut Context<Self>| {
if matches!(event, InputEvent::Change | InputEvent::Focus | InputEvent::Blur) {
cx.notify();
}
},
));
Self {
focus_handle: cx.focus_handle(),
runtime,
active_section: 1,
appearance_picker: None,
theme_picker_trigger: cx.focus_handle(),
icon_picker_trigger: cx.focus_handle(),
expanded_setting_help: std::collections::HashSet::new(),
about_update: AboutUpdateState::Idle,
about_update_seq: 0,
about_last_checked: None,
settings_search_input,
settings_search_trigger_bounds: None,
selects,
shell_select,
bg_picker_open: false,
bg_picker_hsv,
bg_picker_drag: None,
bg_hex_input,
bg_hex_focused: false,
bg_hex_syncing: false,
bg_picker_trigger_bounds: None,
bg_sv_bounds: None,
bg_hue_bounds: None,
opacity_slider,
wallpaper_opacity_slider,
proxy_url_input,
proxy_protocol_select,
proxy_test_seq: 0,
proxy_test_status: crate::display::ProxyTestStatus::Idle,
provider_store,
provider_inputs,
provider_status: None,
provider_test_seq: 0,
provider_test_running: false,
provider_codex_confirm: None,
ssh_hosts: crate::gpui_shell::ssh_hosts::SshHostLists::load(),
ssh_username_input,
ssh_destination_input,
ssh_port_input,
ssh_label_input,
ssh_password_input,
ssh_proxy_host_input,
ssh_proxy_port_input,
ssh_proxy_username_input,
ssh_proxy_password_input,
ssh_jump_host_input,
ssh_username_picker_open: false,
ssh_username_trigger_bounds: None,
ssh_icon_picker_open: false,
ssh_icon_filter_input,
ssh_icon_trigger_bounds: None,
ssh_editor: None,
ssh_editor_focus_handle: cx.focus_handle(),
ssh_editor_seq: 0,
ssh_test_seq: 0,
ssh_status: None,
ssh_show_hidden: false,
ssh_delete_confirm: None,
ssh_delete_undo: None,
ssh_undo_seq: 0,
font_picker_open: false,
font_loading: false,
font_system: None,
font_imported: Vec::new(),
font_family_input,
font_picker_trigger_bounds: None,
backup_selection: crate::encrypted_backup::BackupSelection::default(),
backup_pass_input: cx.new(|cx| {
InputState::new(window, cx)
.masked(true)
.placeholder(localized_input_placeholder("backup_password", language))
}),
backup_status: None,
backup_busy: false,
backup_seq: 0,
backup_remote: {
let cfg = crate::backup_remote::BackupRemoteConfig::load();
for (ix, input) in backup_remote_inputs.iter().enumerate() {
let value = cfg.slot(ix).unwrap_or_default().to_owned();
input.update(cx, |input, cx| input.set_value(value, window, cx));
}
cfg
},
backup_remote_inputs,
backup_secret_input: cx.new(|cx| {
InputState::new(window, cx)
.masked(true)
.placeholder(localized_input_placeholder("backup_secret", language))
}),
keymap_search_input: {
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(localized_input_placeholder("keymap_search", language))
});
subscriptions.push(cx.subscribe_in(
&input,
window,
|_this: &mut Self,
_: &Entity<InputState>,
event: &InputEvent,
_: &mut Window,
cx: &mut Context<Self>| {
// 搜索词变化只影响可见行集合;捕获态不因打字被打断
// (捕获期间焦点在分区根上,输入框收不到键)。
if matches!(event, InputEvent::Change) {
cx.notify();
}
},
));
input
},
keymap_capture: None,
keymap_capture_preview: String::new(),
keymap_binds: nebula_settings::keybind_pairs(),
slider_persist: None,
_subscriptions: subscriptions,
}
}
}
@@ -0,0 +1,135 @@
use gpui::SharedString;
/// 固定下拉只在这里把稳定 value 映射为显示文案。创建和语言切换刷新共用
/// 同一入口,避免 `SelectState` 留着构造时的旧语言。
pub(super) fn localized_select_labels(
key: &str,
values: &[&'static str],
language: crate::display::UiLanguage,
) -> Vec<SharedString> {
let labels: Vec<&'static str> = match key {
"language" => nebula_settings::LanguagePref::ALL
.iter()
.map(|preference| {
if *preference == nebula_settings::LanguagePref::System {
language.tr("language.system")
} else {
preference.native_name()
}
})
.collect(),
"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()
}
pub(super) 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"),
]
}
pub(super) 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..."),
_ => "",
}
}
@@ -0,0 +1,204 @@
use super::*;
/// 主题下拉(展示名 = 持久化名,与旧壳一致)。
pub(super) const THEME_VALUES: [&str; 9] = [
"Nebula",
"SilverLight",
"SteelDark",
"LimestoneLight",
"CoalDark",
"LinenLight",
"MossDark",
"Nord",
"Paper",
];
pub(super) const REPOSITORY_URL: &str = "https://github.com/Kuddev/nebula";
pub(super) const BUG_REPORT_TEMPLATE: &str = "bug_report.yml";
/// 左侧分区的稳定路由表。2026-08-28 产品裁定:默认 GPUI 导航收敛为常用项,
/// 暂时隐藏“AI 供应商”和“备份”;页面实现与索引继续保留。后续恢复入口时只改
/// [`HIDDEN_NAV_SECTIONS`],不得删除或重排这里的条目。
pub(super) 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.
pub(super) 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 恢复",
];
pub(super) const HIDDEN_NAV_SECTIONS: &[usize] = &[3, 9];
/// 保留原来的分组展开顺序,组名不再渲染;数组里仍保存稳定的 [`SECTION_IDS`]
/// 下标,不复制设置状态或路由。
pub(super) const NAV_GROUPS: [(&str, &[usize]); 3] =
[("workspace", &[0, 1, 2, 6, 7]), ("connections", &[3, 4, 5]), ("system", &[8, 9])];
pub(super) 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"),
_ => "",
}
}
pub(super) fn is_nav_section_visible(index: usize) -> bool {
!HIDDEN_NAV_SECTIONS.contains(&index)
}
pub(super) fn visible_nav_sections() -> impl Iterator<Item = usize> {
NAV_GROUPS
.iter()
.flat_map(|(_, sections)| sections.iter().copied())
.filter(|index| is_nav_section_visible(*index))
}
// 导航使用组件的小字号,与搜索菜单一致;尺寸是逻辑像素,由窗口统一处理 DPI。
pub(super) const SETTINGS_NAV_WIDTH: f32 = 208.0;
pub(super) const SETTINGS_NAV_ROW_HEIGHT: f32 = 34.0;
pub(super) const SETTINGS_NAV_ICON_SIZE: f32 = 16.0;
pub(super) const SETTINGS_HEADER_HEIGHT: f32 = 74.0;
// 正文分组与表单继续沿用现有几何节奏。
pub(super) const SETTINGS_GROUP_GAP: f32 = 32.0;
pub(super) const SETTINGS_GROUP_TITLE_HEIGHT: f32 = 26.0;
pub(super) const SETTINGS_GROUP_TITLE_GAP: f32 = 16.0;
pub(super) const SETTINGS_ROW_HEIGHT: f32 = 48.0;
pub(super) const SETTINGS_ROW_GAP: f32 = 8.0;
/// 标准设置选择器的实际宽度。字体输入与 Select 共用,避免同列控件漂移。
pub(super) const SETTINGS_SELECT_WIDTH: f32 = 220.0;
/// 列表/触发条上的展示名:族名偶尔带着导入文件后缀,界面上剥掉。
pub(super) 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`,那是字号
/// 图标),只能自带一枚——两种来源用同一个类型表达,调用点就不必分叉。
pub(super) fn section_icon(index: usize) -> SharedString {
use gpui_component::IconNamed as _;
match index {
0 => crate::gpui_shell::assets::nav::LAYOUT_GRID.into(),
1 => IconName::Palette.path(),
2 => IconName::Folder.path(),
3 => IconName::Bot.path(),
4 => IconName::SquareTerminal.path(),
5 => IconName::Globe.path(),
6 => crate::gpui_shell::assets::nav::MOUSE_POINTER.into(),
7 => crate::gpui_shell::assets::nav::KEYMAP.into(),
8 => crate::gpui_shell::assets::nav::SLIDERS.into(),
_ => IconName::Inbox.path(),
}
}
pub(super) 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,
}
}
pub(super) 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()
}
pub(super) 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 预填合同:模板、版本、平台、安装来源
/// 和诊断摘要都来自当前运行实例,用户只需补充复现步骤与实际表现。
pub(super) 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::<Vec<_>>()
.join("&");
format!("{REPOSITORY_URL}/issues/new?{params}")
}
@@ -0,0 +1,66 @@
use super::*;
impl SettingsPane {
pub(super) fn reset_all_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let language = crate::gpui_shell::config::ui_language(cx);
self.slider_persist = None;
let backup = match nebula_settings::restore_default_settings() {
Ok(backup) => backup,
Err(error) => {
crate::gpui_shell::toast::toast(
window,
cx,
crate::gpui_shell::toast::ToastKind::Warning,
format!(
"{}: {error}",
language.pick("恢复默认设置失败", "Failed to restore defaults")
),
);
return;
},
};
let active_section = self.active_section;
let about_update = std::mem::replace(&mut self.about_update, AboutUpdateState::Idle);
let about_last_checked = self.about_last_checked.take();
let about_update_seq = self.about_update_seq;
let proxy_test_seq = self.proxy_test_seq.wrapping_add(1);
let provider_test_seq = self.provider_test_seq.wrapping_add(1);
let ssh_editor_seq = self.ssh_editor_seq.wrapping_add(1);
let ssh_test_seq = self.ssh_test_seq.wrapping_add(1);
let ssh_undo_seq = self.ssh_undo_seq.wrapping_add(1);
let backup_seq = self.backup_seq.wrapping_add(1);
let settings = crate::gpui_shell::config::Settings::load(
crate::gpui_shell::theme::effective_theme_name(cx),
);
gpui_component::set_locale(settings.ui_language.gpui_component_locale());
cx.set_global(settings);
*self = Self::new(window, cx);
self.active_section = active_section;
self.about_update = about_update;
self.about_last_checked = about_last_checked;
self.about_update_seq = about_update_seq;
self.proxy_test_seq = proxy_test_seq;
self.provider_test_seq = provider_test_seq;
self.ssh_editor_seq = ssh_editor_seq;
self.ssh_test_seq = ssh_test_seq;
self.ssh_undo_seq = ssh_undo_seq;
self.backup_seq = backup_seq;
window.focus(&self.focus_handle, cx);
cx.emit(SettingsPaneEvent::Changed);
cx.refresh_windows();
cx.notify();
crate::gpui_shell::toast::toast(
window,
cx,
crate::gpui_shell::toast::ToastKind::Success,
if backup.is_some() {
language.pick("已恢复默认设置;主机与凭据保持不变,原设置已备份。", "Defaults restored. Hosts and credentials are unchanged; previous settings were backed up.")
} else {
language.pick(
"已恢复默认设置;主机与凭据保持不变。",
"Defaults restored. Hosts and credentials are unchanged.",
)
},
);
}
}
@@ -0,0 +1,114 @@
use super::*;
impl SettingsPane {
pub(super) fn render_search_header(
&self,
window: &Window,
cx: &Context<Self>,
) -> gpui::AnyElement {
let language = crate::gpui_shell::config::ui_language(cx);
let query = self.settings_search_input.read(cx).value().trim().to_lowercase();
let results: Vec<usize> = if query.is_empty() {
Vec::new()
} else {
visible_nav_sections()
.filter(|index| {
SECTION_SEARCH_TERMS[*index].contains(&query)
|| section_label(*index, language).to_lowercase().contains(&query)
})
.take(6)
.collect()
};
let bounds = self.settings_search_trigger_bounds;
let hairline = crate::gpui_shell::theme::settings_hairline(cx);
let hover = cx.theme().list_hover;
let focused = self.settings_search_input.read(cx).focus_handle(cx).is_focused(window);
let panel = (focused && !results.is_empty()).then(|| {
v_flex()
.w(bounds.map(|bounds| bounds.size.width).unwrap_or(px(680.0)))
.p_1()
.gap_1()
.rounded_md()
.border_1()
.border_color(hairline)
.bg(crate::gpui_shell::theme::settings_panel_bg(cx))
.text_sm()
.line_height(px(20.0))
.shadow_lg()
.occlude()
.children(results.into_iter().map(|index| {
h_flex()
.id(("settings-search-result", index))
.h(px(SETTINGS_NAV_ROW_HEIGHT))
.px_2()
.gap_2()
.rounded_md()
.cursor_pointer()
.hover(move |row| row.bg(hover))
.on_click(cx.listener(move |this, _, window, cx| {
this.active_section = index;
this.settings_search_input
.update(cx, |input, cx| input.set_value("", window, cx));
cx.notify();
}))
.child(
Icon::default()
.path(section_icon(index))
.size(px(SETTINGS_NAV_ICON_SIZE)),
)
.child(section_label(index, language))
}))
});
let trigger = cx.entity().downgrade();
let search = div()
.relative()
.w_full()
.child(
Input::new(&self.settings_search_input)
.w_full()
.cleanable(true)
.prefix(
Icon::new(IconName::Search)
.xsmall()
.text_color(cx.theme().muted_foreground),
)
.aria_label(language.pick("在全部设置中搜索", "Search all settings")),
)
.child(
gpui::canvas(
move |bounds, _, cx| {
let _ = trigger.update(cx, |pane, cx| {
if pane.settings_search_trigger_bounds != Some(bounds) {
pane.settings_search_trigger_bounds = Some(bounds);
cx.notify();
}
});
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
.when_some(panel.zip(bounds), |search, (panel, bounds)| {
search.child(
deferred(
anchored()
.anchor(gpui::Anchor::TopLeft)
.position(bounds.bottom_left())
.offset(gpui::point(px(0.0), px(6.0)))
.snap_to_window_with_margin(px(8.0))
.child(panel),
)
.with_priority(3),
)
});
h_flex()
.w_full()
.h(px(SETTINGS_HEADER_HEIGHT))
.flex_shrink_0()
.px_5()
.items_center()
.child(search)
.into_any_element()
}
}
@@ -0,0 +1,221 @@
use crate::display::UiLanguage;
#[derive(Clone, Copy)]
pub(super) struct SettingHelp {
pub summary: &'static str,
pub details: Option<&'static str>,
}
impl From<&'static str> for SettingHelp {
fn from(summary: &'static str) -> Self {
Self { summary, details: None }
}
}
pub(super) fn help(key: &str, language: UiLanguage) -> SettingHelp {
let (summary, details) = match key {
"background" => (
language.pick("只更改终端底色,设置页仍跟随主题。", "Changes only the terminal background; Settings keeps the theme colors."),
None,
),
"background_image" => (
language.pick("在终端文字后方显示图片。", "Shows an image behind terminal text."),
Some(language.pick("图片不改变文字配色;看不清时可降低背景图像不透明度。", "Text colors still come from the theme. Lower the image opacity if text is hard to read.")),
),
"background_image_fit" => (
language.pick("调整图片的铺放方式。", "Chooses how the background image fills the terminal."),
Some(language.pick("拉伸会改变比例;适应和填充保持比例;原始尺寸不缩放。", "Fill stretches the image. Uniform and Uniform to fill preserve its aspect ratio. Original size does not scale it.")),
),
"background_image_alignment" => (
language.pick("设置图片在终端中的位置。", "Positions the image within the terminal."),
None,
),
"background_image_opacity" => (
language.pick("数值越低,图片越淡。", "Lower values make the image less prominent."),
None,
),
"background_image_cover_chrome" => (
language.pick("关闭后,图片只显示在终端区域。", "When disabled, the image appears only behind the terminal."),
None,
),
"cursor_shape" => ("", None),
"cursor_blink" => (
language.pick("关闭后,光标保持常亮。", "When disabled, the cursor stays lit."),
None,
),
"tab_close_visible" => (
language.pick("隐藏后,仍可用鼠标中键关闭标签。", "Middle-click still closes tabs when their close buttons are hidden."),
None,
),
"language" => (
language.pick("只更改应用界面的语言。", "Changes only the application interface language."),
Some(language.pick("终端内程序的输出语言由各自的环境设置决定。", "Programs inside the terminal choose their output language from their own environment.")),
),
"density" => (
language.pick("调整界面留白,不改变终端行距。", "Adjusts interface spacing without changing terminal line spacing."),
None,
),
"opacity" => (
language.pick("100% 为完全不透明,不影响设置页。", "100% is fully opaque. The Settings page is unaffected."),
None,
),
"blur" => (
language.pick("选择窗口背景的模糊效果。", "Chooses the window background effect."),
Some(language.pick("Mica 系列开销较低;Aero 和 Acrylic 实时模糊窗口后方的内容,开销较高。", "Mica effects use fewer resources. Aero and Acrylic blur live content behind the window and cost more.")),
),
"cell_width_mode" => (
language.pick("调整终端字符的列宽。", "Adjusts terminal character cell width."),
Some(language.pick("紧凑向下取整;宽松向上补齐,避免小数字宽的字体显得拥挤。只影响终端网格。", "Compact rounds down. Relaxed rounds up to avoid squeezing fonts with fractional widths. Only the terminal grid is affected.")),
),
"fetch" => (
language.pick("新会话启动时显示系统信息。", "Shows system information when a new session starts."),
Some(language.pick("通过 fastfetch 显示系统信息;关闭可减少新标签的启动等待。", "Uses fastfetch to display system information. Disabling it reduces startup work for new tabs.")),
),
"powerline" => (
language.pick("显示分段提示符,需字体支持。", "Shows a segmented prompt. Requires a compatible font."),
Some(language.pick("字体须包含 Powerline 字形,否则分段箭头可能显示为方框。", "The font must include Powerline glyphs or the segment arrows may appear as boxes.")),
),
"shell" => (
language.pick("用于新标签,不影响已打开的终端。", "Used for new tabs. Existing terminals are unaffected."),
None,
),
"startup_directory" => (
language.pick("未设置时,继承应用的启动目录。", "When unset, inherits the directory used to launch the application."),
Some(language.pick("从资源管理器的文件夹中启动时,默认进入该文件夹。", "Launching from a File Explorer folder uses that folder by default.")),
),
"bell" => (
language.pick("程序提示时,选择声音或闪烁提醒。", "Chooses sound or visual feedback for terminal alerts."),
None,
),
"font_family" => (
language.pick("按字体组顺序查找可用字形。", "Uses the font group in order to find available glyphs."),
Some(language.pick("前面的字体缺少字形时,会继续尝试后面的字体。", "When a font lacks a glyph, the next font in the group is tried.")),
),
"ghost" => (
language.pick("根据历史命令给出建议,确认后才填入。", "Suggests commands from history and inserts them only after acceptance."),
None,
),
"accept" => (
language.pick("选择填入建议时使用的按键。", "Chooses the key used to accept a suggestion."),
Some(language.pick("如果 Tab 与 Shell 自带补全冲突,可改用右方向键。", "Use Right arrow if Tab conflicts with the shell's own completion.")),
),
"completion_style" => (
language.pick("选择行内建议或候选列表。", "Chooses inline suggestions or a list of candidates."),
Some(language.pick("行内建议不遮挡下方输出;候选列表能同时显示多项建议。", "Inline suggestions leave output visible. A popup list shows several candidates at once.")),
),
"copy_on_select" => (
language.pick("松开鼠标自动复制,右键直接粘贴。", "Copies on mouse release and pastes on right-click."),
Some(language.pick("关闭后,选中不会自动复制;右键改为打开复制与粘贴菜单。", "When disabled, selection does not copy automatically and right-click opens the copy/paste menu.")),
),
"multiline_paste_confirm" => (
language.pick("在普通 shell 中粘贴多行或高风险内容时先确认。", "Asks before multiline or risky pastes in a plain shell."),
Some(language.pick("检查换行、提权命令和控制字符。Bracketed paste 模式与全屏程序不受此项保护;关闭后直接粘贴。", "Checks line breaks, privileged commands and control characters. Bracketed paste and full-screen apps are unaffected; disabling this pastes directly.")),
),
"panel_resize" => (
language.pick("关闭后锁定分界线,避免误拖。", "Locks the divider when disabled to prevent accidental resizing."),
None,
),
"cjk_bold_regular" => (
language.pick("用提亮代替加粗,让密集笔画更清楚。", "Brightens dense glyphs instead of thickening their strokes."),
Some(language.pick("只影响中日韩(CJK)粗体字形,拉丁字母仍使用真正的粗体。", "Only CJK bold glyphs are affected. Latin letters still use true bold.")),
),
"tabs_position" => ("", None),
"tab_reveal" => (
language.pick("新标签滑动出现,或立即显示。", "Slides new tabs into view or shows them immediately."),
None,
),
"new_tab_position" => (
language.pick("只影响新标签,不改变恢复时的顺序。", "Affects new tabs only, not the order of restored tabs."),
None,
),
"windowing_behavior" => (
language.pick("再次启动应用时,新开窗口或加入已有窗口。", "Opens a new window or joins an existing one when launched again."),
None,
),
"vcs_display" => (
language.pick("选择侧栏显示 Git 还是 SVN 状态。", "Chooses whether the sidebar shows Git or SVN status."),
Some(language.pick("自动检测会识别 .git 和 .svn;同目录存在两者时可手动指定。", "Auto detect checks for .git and .svn. Choose manually when both are present.")),
),
"keep_session" => (
language.pick("关窗后终端继续运行;关闭此项会结束会话。", "Keeps terminals running after closing the window; disabling it ends sessions."),
Some(language.pick("关闭此项后,关窗会终止 Shell,未保存的工作可能丢失。开启后可重新附着到后台会话。", "When disabled, closing the window terminates its shells and may lose unsaved work. When enabled, background sessions can be reattached.")),
),
"restore_session" => (
language.pick("恢复标签、布局和目录,不恢复原进程。", "Restores tabs, layout and directories, not the original processes."),
Some(language.pick("恢复的标签会启动新的 Shell,不会让已结束的程序继续运行。", "Restored tabs start new shells; terminated programs are not resumed.")),
),
"resume_ai" => (
language.pick("恢复 AI 标签时,继续上次的对话。", "Continues the previous conversation when restoring an AI tab."),
None,
),
"tray" => (
language.pick("在通知区域查看 AI 会话状态。", "Shows AI session status in the notification area."),
None,
),
_ => unreachable!("unknown settings help key: {key}"),
};
SettingHelp { summary, details }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn descriptions_stay_short_in_both_interface_languages() {
let keys = [
"background",
"background_image",
"background_image_fit",
"background_image_alignment",
"background_image_opacity",
"background_image_cover_chrome",
"cursor_shape",
"cursor_blink",
"tab_close_visible",
"language",
"density",
"opacity",
"blur",
"cell_width_mode",
"fetch",
"powerline",
"shell",
"startup_directory",
"bell",
"font_family",
"ghost",
"accept",
"completion_style",
"copy_on_select",
"multiline_paste_confirm",
"panel_resize",
"cjk_bold_regular",
"tabs_position",
"tab_reveal",
"new_tab_position",
"windowing_behavior",
"vcs_display",
"keep_session",
"restore_session",
"resume_ai",
"tray",
];
for key in keys {
for language in [UiLanguage::ZhCn, UiLanguage::EnUs] {
let description = help(key, language);
let limit = if language == UiLanguage::ZhCn { 32 } else { 100 };
assert!(description.summary.chars().count() <= limit, "long summary: {key}");
assert!(description.details.is_none_or(|details| !details.is_empty()));
}
}
}
#[test]
fn critical_limits_remain_in_the_short_description() {
let language = UiLanguage::ZhCn;
assert!(help("keep_session", language).summary.contains("结束会话"));
assert!(help("restore_session", language).summary.contains("不恢复原进程"));
assert!(help("multiline_paste_confirm", language).details.unwrap().contains("全屏程序"));
}
}
@@ -0,0 +1,185 @@
use super::*;
#[derive(Clone)]
pub(super) struct ShellSelectItem {
pub(super) id: String,
pub(super) name: SharedString,
pub(super) closed_image: Option<Arc<RenderImage>>,
pub(super) row_image: Option<Arc<RenderImage>>,
}
/// 下拉首行那条「导入终端目录」动作行的哨兵 id。
///
/// 它不是任何真实 shell:选中只触发目录扫描,绝不会写进 `shell` 设置。
/// 取这个形状是因为 `shell_detect` 的 id 全是普通标识符(`pwsh`、`cmd`、
/// `wsl:<distro>`、`profile:<家族>|<id>`),双下划线包裹不可能与之相撞。
pub(super) const SHELL_IMPORT_ACTION_ID: &str = "__nebula_import_terminal_dir__";
impl ShellSelectItem {
pub(super) 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`] 会给它文件夹图标。
pub(super) 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,
}
}
pub(super) fn is_import_action(&self) -> bool {
self.id == SHELL_IMPORT_ACTION_ID
}
pub(super) fn view(&self, size: f32, image: Option<&Arc<RenderImage>>) -> 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:<家族>|<id>` 作设置值([`Profile::settings_id`]
/// 同一形状),品牌图标因此仍能按家族查到。
pub(super) fn shell_select_items(
current: &str,
scale_factor: f32,
language: crate::display::UiLanguage,
) -> (Vec<ShellSelectItem>, usize) {
let mut items: Vec<ShellSelectItem> = 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 线程决定怎么呈现。
pub(super) fn import_terminal_directory_blocking(
directory: &std::path::Path,
) -> Result<usize, TerminalImportError> {
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)]
pub(super) fn pick_folder_with_wsl_places(
window: &Window,
title: &'static str,
) -> futures::channel::oneshot::Receiver<Option<std::path::PathBuf>> {
use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
let owner = HasWindowHandle::window_handle(window)
.ok()
.and_then(|handle| match handle.as_raw() {
RawWindowHandle::Win32(handle) => Some(handle.hwnd.get() as usize),
_ => None,
})
.unwrap_or(0);
let (tx, rx) = futures::channel::oneshot::channel();
std::thread::spawn(move || {
let selected = crate::display::file_dialog::pick_folder_with_hwnd(owner as _, title);
let _ = tx.send(selected);
});
rx
}
impl SelectItem for ShellSelectItem {
type Value = String;
fn title(&self) -> SharedString {
self.name.clone()
}
fn display_title(&self) -> Option<gpui::AnyElement> {
// 闭态留出 chevron 与上下内边距;20px 在 32px Select 内不会挤字。
Some(self.view(20.0, self.closed_image.as_ref()))
}
fn render(&self, _: &mut Window, _: &mut App) -> impl IntoElement {
// 旧壳 ShellPickerRow 的品牌图标是 24×24 逻辑像素。
self.view(24.0, self.row_image.as_ref())
}
fn value(&self) -> &Self::Value {
&self.id
}
fn matches(&self, query: &str) -> bool {
self.name.to_lowercase().contains(&query.to_lowercase())
|| self.id.to_lowercase().contains(&query.to_lowercase())
}
}
@@ -0,0 +1,324 @@
use crate::gpui_shell::ssh_settings::SshValidationError;
#[derive(Clone, Debug, Default)]
pub(super) enum AboutUpdateState {
#[default]
Idle,
Checking,
UpToDate(String),
Available(String),
Failed(String),
}
#[derive(Clone, Debug)]
pub(super) 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 {
pub(in crate::gpui_shell) fn is_error(&self) -> bool {
match self {
Self::AtLeastOneRequired | Self::Error(_) => true,
Self::TestResult { outcome, .. } => !outcome.is_success(),
_ => false,
}
}
pub(in crate::gpui_shell) 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)]
pub(super) enum BackupCompletion {
Exported(std::path::PathBuf),
Restored,
Pushed(String),
Pulled(String),
}
#[derive(Clone, Debug)]
pub(super) enum BackupStatus {
PassphraseTooShort,
SelectionRequired,
Processing,
RemoteConfigSaved,
CredentialEmpty,
CredentialUnsupported,
CredentialSaved,
Completed(BackupCompletion),
Error(String),
}
impl BackupStatus {
pub(in crate::gpui_shell) fn is_error(&self) -> bool {
matches!(
self,
Self::PassphraseTooShort
| Self::SelectionRequired
| Self::CredentialEmpty
| Self::CredentialUnsupported
| Self::Error(_)
)
}
pub(in crate::gpui_shell) 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)]
pub(super) enum TerminalImportError {
Scan(String),
NoSupportedTerminal,
Load(String),
Import(String),
Save(String),
}
impl TerminalImportError {
pub(super) 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(in crate::gpui_shell) 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(in crate::gpui_shell) 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(in crate::gpui_shell) 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"))
},
}
}
}
@@ -22,17 +22,14 @@ fn settings_nav_visibility_keeps_stable_routes_but_hides_two_entries() {
}
#[test]
fn settings_nav_preserves_the_group_expansion_order_without_headings() {
fn settings_nav_starts_with_application_then_frequent_options() {
let visible: Vec<_> = visible_nav_sections().collect();
assert_eq!(visible, vec![0, 1, 2, 6, 7, 4, 5, 8]);
let zh_labels: Vec<_> = visible
.iter()
.map(|index| section_label(*index, crate::display::UiLanguage::ZhCn))
.collect();
assert_eq!(
zh_labels,
vec!["应用", "外观", "配置文件", "交互", "按键映射", "SSH", "网络", "高级"]
);
assert_eq!(zh_labels, vec!["应用", "外观", "终端", "交互", "按键映射", "SSH", "网络", "高级"]);
let en_labels: Vec<_> = visible
.iter()
.map(|index| section_label(*index, crate::display::UiLanguage::EnUs))
@@ -42,7 +39,7 @@ fn settings_nav_preserves_the_group_expansion_order_without_headings() {
vec![
"Application",
"Appearance",
"Profiles",
"Terminal",
"Interaction",
"Key Bindings",
"SSH",
@@ -55,14 +52,14 @@ fn settings_nav_preserves_the_group_expansion_order_without_headings() {
#[test]
fn localized_select_labels_keep_stable_value_cardinality() {
let cases: &[(&str, &[&str])] = &[
("language", &["system", "zh-CN", "en-US"]),
("language", nebula_settings::LanguagePref::VALUES),
("cursor_shape", &["beam", "underline", "block", "hollow"]),
("tabs_position", &["sidebar", "top"]),
("bell", &["off", "visual", "sound", "both"]),
];
for (key, values) in cases {
for language in [crate::display::UiLanguage::ZhCn, crate::display::UiLanguage::EnUs] {
assert_eq!(localized_select_labels(key, values, language).len(), values.len());
for language in crate::display::UiLanguage::ALL {
assert_eq!(localized_select_labels(key, values, *language).len(), values.len());
}
}
assert_eq!(
@@ -71,6 +68,18 @@ fn localized_select_labels_keep_stable_value_cardinality() {
);
}
#[test]
fn language_picker_uses_native_names_and_translated_system_option() {
let labels = localized_select_labels(
"language",
nebula_settings::LanguagePref::VALUES,
crate::display::UiLanguage::FrFr,
);
assert_eq!(labels[0], SharedString::from("Suivre le système"));
assert!(labels.contains(&SharedString::from("Français")));
assert!(labels.contains(&SharedString::from("日本語")));
}
#[test]
fn cached_semantic_statuses_render_in_the_current_language() {
let provider = ProviderStatus::Saved;
+44 -23
View File
@@ -27,8 +27,8 @@ use crate::gpui_shell::prelude::*;
use crate::gpui_shell::settings_pane::{SettingsPane, SettingsPaneEvent, SshStatus};
use crate::gpui_shell::widgets::NebulaButton;
mod editor;
mod advanced;
mod editor;
/// 删除撤销窗口时长,旧壳 Undo 条同值。
const SSH_DELETE_UNDO_SECS: u64 = 8;
@@ -258,7 +258,8 @@ impl SettingsPane {
let profile_path = crate::display::nebula_data_dir().join("ssh_profiles.json");
match crate::ssh_profiles::SshProfiles::load(&profile_path) {
Ok(mut profiles) => {
let proxy_credential = profiles.for_destination(&host).connection.proxy_credential_target(&host);
let proxy_credential =
profiles.for_destination(&host).connection.proxy_credential_target(&host);
profiles.remove(&host);
if let Err(error) = profiles.save(&profile_path) {
cleanup_errors.push(format!("Profile: {error}"));
@@ -338,7 +339,10 @@ impl SettingsPane {
let mut editor = SshEditorState::new(self.ssh_editor_seq, destination);
editor.username_suggestions = profiles.usernames();
let labels = profiles.labels();
editor.jump_choices = self.ssh_hosts.merged().into_iter()
editor.jump_choices = self
.ssh_hosts
.merged()
.into_iter()
.filter(|host| editor.original_destination.as_deref() != Some(host))
.map(|host| {
let label = labels.get(&host).cloned().unwrap_or_else(|| host.clone());
@@ -358,17 +362,24 @@ impl SettingsPane {
editor.original_connection = profile.connection.clone();
}
let connection = &editor.connection;
self.ssh_proxy_host_input.update(cx, |input, cx| input.set_value(connection.proxy_host.clone(), window, cx));
self.ssh_proxy_host_input
.update(cx, |input, cx| input.set_value(connection.proxy_host.clone(), window, cx));
self.ssh_proxy_port_input.update(cx, |input, cx| {
input.set_value(connection.proxy_port.map(|port| port.to_string()).unwrap_or_default(), window, cx);
input.set_value(
connection.proxy_port.map(|port| port.to_string()).unwrap_or_default(),
window,
cx,
);
input.set_placeholder(connection.effective_proxy_port().to_string(), window, cx);
});
self.ssh_proxy_username_input.update(cx, |input, cx| input.set_value(connection.proxy_username.clone(), window, cx));
self.ssh_proxy_username_input
.update(cx, |input, cx| input.set_value(connection.proxy_username.clone(), window, cx));
self.ssh_proxy_password_input.update(cx, |input, cx| {
input.set_value("", window, cx);
input.set_masked(true, window, cx);
});
self.ssh_jump_host_input.update(cx, |input, cx| input.set_value(connection.jump_host.clone(), window, cx));
self.ssh_jump_host_input
.update(cx, |input, cx| input.set_value(connection.jump_host.clone(), window, cx));
let label = profile.and_then(|profile| profile.label).unwrap_or_default();
self.ssh_username_input.update(cx, |input, cx| input.set_value(username, window, cx));
self.ssh_destination_input.update(cx, |input, cx| input.set_value(address, window, cx));
@@ -378,11 +389,15 @@ impl SettingsPane {
self.ssh_password_input.update(cx, |input, cx| {
input.set_value("", window, cx);
let language = crate::gpui_shell::config::ui_language(cx);
input.set_placeholder(if adding {
language.pick("留空则连接时询问", "Leave empty to ask when connecting")
} else {
language.pick("留空则保留原有凭据", "Leave empty to keep existing credentials")
}, window, cx);
input.set_placeholder(
if adding {
language.pick("留空则连接时询问", "Leave empty to ask when connecting")
} else {
language.pick("留空则保留原有凭据", "Leave empty to keep existing credentials")
},
window,
cx,
);
});
// 图标选择器每次开编辑器都从收起、无搜索词开始:上一台主机的搜索词
// 留在框里,下一台打开时列表看着像被莫名筛过。
@@ -510,14 +525,15 @@ impl SettingsPane {
return;
},
};
let (proxy_password, password) = match self.ssh_test_passwords(&destination, &connection, cx) {
Ok(passwords) => passwords,
Err(error) => {
self.ssh_status = Some(SshStatus::Error(error.to_string()));
cx.notify();
return;
},
};
let (proxy_password, password) =
match self.ssh_test_passwords(&destination, &connection, cx) {
Ok(passwords) => passwords,
Err(error) => {
self.ssh_status = Some(SshStatus::Error(error.to_string()));
cx.notify();
return;
},
};
let Some(editor) = self.ssh_editor.as_mut() else { return };
if editor.testing() {
return;
@@ -671,8 +687,12 @@ impl SettingsPane {
cx.notify();
return;
}
let mut password = zeroize::Zeroizing::new(self.ssh_password_input.read(cx).value().to_string());
if password.is_empty() && editor.save_password && crate::display::auth_sections(editor.auth).0 {
let mut password =
zeroize::Zeroizing::new(self.ssh_password_input.read(cx).value().to_string());
if password.is_empty()
&& editor.save_password
&& crate::display::auth_sections(editor.auth).0
{
if let Some(original) = original.as_deref().filter(|old| *old != destination) {
match crate::ssh_credentials::load_stored_password(original) {
Ok(Some(secret)) => {
@@ -680,7 +700,8 @@ impl SettingsPane {
match std::str::from_utf8(&secret) {
Ok(secret) => *password = secret.to_owned(),
Err(error) => {
self.ssh_status = Some(SshStatus::CredentialSaveFailed(error.to_string()));
self.ssh_status =
Some(SshStatus::CredentialSaveFailed(error.to_string()));
self.ssh_editor = Some(editor);
cx.notify();
return;
+1 -1
View File
@@ -3,8 +3,8 @@
//! 边界:本模块之外不出现 nebula_terminal 类型;nebula_terminal 之内不出现
//! GPUI 类型。会话生命周期归 `session`,绘制归 `element`,交互归 `view`。
pub mod colors;
mod answer_reader;
pub mod colors;
pub mod element;
mod inline_image;
pub mod keymap;
+53 -38
View File
@@ -141,12 +141,8 @@ fn paste_needs_confirmation(text: &str, mode: TermMode) -> bool {
/// 当前 UI 语言。`RuntimeSettings` 每次读盘,所以只在用户动作(复制提示、
/// 粘贴确认)时取,不进渲染热路径。
fn ui_language() -> crate::display::UiLanguage {
match nebula_settings::RuntimeSettings::load().language {
nebula_settings::LanguagePref::System => crate::display::LanguagePreference::System,
nebula_settings::LanguagePref::ZhCn => crate::display::LanguagePreference::ZhCn,
nebula_settings::LanguagePref::EnUs => crate::display::LanguagePreference::EnUs,
}
.resolved()
crate::display::LanguagePreference::from(nebula_settings::RuntimeSettings::load().language)
.resolved()
}
/// 终端视图对宿主(Panel/Workspace)暴露的状态变化。
@@ -1127,9 +1123,8 @@ impl TerminalView {
fn drive_inline_image_decode(&mut self, cx: &mut Context<Self>) {
let Some(pending) = self.inline_images.start_next() else { return };
let decode = cx
.background_executor()
.spawn(async move { super::inline_image::decode(pending) });
let decode =
cx.background_executor().spawn(async move { super::inline_image::decode(pending) });
cx.spawn(async move |this, cx| {
let result = decode.await;
let _ = this.update(cx, |view, cx| {
@@ -1631,12 +1626,8 @@ impl TerminalView {
// 原始按键可能紧接着再次到来;先清除选区,下一次 Copy 才能按
// “未处理”传播回终端,而不是重复复制并再次弹 toast。
session.term.lock().selection = None;
let message = match ui_language() {
crate::display::UiLanguage::EnUs => {
format!("Copied {lines} lines to clipboard")
},
_ => format!("已复制 {lines} 行到剪贴板"),
};
let message = ui_language()
.format(crate::i18n::Message::CommonCopiedLines, &[("lines", &lines.to_string())]);
crate::gpui_shell::toast::toast(window, cx, crate::display::ToastKind::Info, message);
cx.notify();
}
@@ -1672,11 +1663,9 @@ impl TerminalView {
// bracketed 的对端(codex/vim/PSReadLine)整块收下、自己决定怎么处理;
// 裸 shell 是换行落地即执行。风险不同,话就得说得不一样。
let runs_line_by_line = !self.term_mode().contains(TermMode::BRACKETED_PASTE);
let title: SharedString = match language {
crate::display::UiLanguage::EnUs => format!("Paste {lines} lines?"),
_ => format!("粘贴 {lines} 行文本?"),
}
.into();
let title: SharedString = language
.format(crate::i18n::Message::CommonPasteLines, &[("lines", &lines.to_string())])
.into();
let body: SharedString = if runs_line_by_line {
language.pick(
"shell 会把这些内容逐行执行。请确认来源可信。",
@@ -2141,7 +2130,10 @@ impl Drop for TerminalView {
impl Focusable for TerminalView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.answer_reader.as_ref().map_or_else(|| self.focus_handle.clone(), |reader| reader.read(cx).focus_handle.clone())
self.answer_reader.as_ref().map_or_else(
|| self.focus_handle.clone(),
|reader| reader.read(cx).focus_handle.clone(),
)
}
}
@@ -2390,14 +2382,29 @@ impl Render for TerminalView {
}
if let Some(answer) = self.answers.latest.clone() {
let provider = if answer.provider == "claude" { "Claude Code" } else { "Codex" };
return div().size_full().relative()
return div()
.size_full()
.relative()
.child(root)
.child(crate::gpui_shell::prelude::h_flex().absolute().top_0().right_2().px_2().gap_2().items_center()
.bg(cx.theme().background)
.child(div().text_xs().child(format!("{provider} · 回答")))
.child(crate::gpui_shell::prelude::Button::new("answer-open")
.label("阅读").small()
.on_click(cx.listener(|view, _, window, cx| view.open_answer(window, cx)))))
.child(
crate::gpui_shell::prelude::h_flex()
.absolute()
.top_0()
.right_2()
.px_2()
.gap_2()
.items_center()
.bg(cx.theme().background)
.child(div().text_xs().child(format!("{provider} · 回答")))
.child(
crate::gpui_shell::prelude::Button::new("answer-open")
.label("阅读")
.small()
.on_click(
cx.listener(|view, _, window, cx| view.open_answer(window, cx)),
),
),
)
.into_any_element();
}
root.into_any_element()
@@ -2411,11 +2418,16 @@ impl TerminalView {
if self.agent_status == crate::ai_agents::AgentStatus::Blocked {
reader.update(cx, |reader, cx| reader.needs_attention(cx));
}
cx.subscribe_in(&reader, window, |view, _, _: &super::answer_reader::ReaderEvent, window, cx| {
view.answer_reader = None;
window.focus(&view.focus_handle, cx);
cx.notify();
}).detach();
cx.subscribe_in(
&reader,
window,
|view, _, _: &super::answer_reader::ReaderEvent, window, cx| {
view.answer_reader = None;
window.focus(&view.focus_handle, cx);
cx.notify();
},
)
.detach();
let focus = reader.read(cx).focus_handle.clone();
window.focus(&focus, cx);
self.answer_reader = Some(reader);
@@ -2423,11 +2435,14 @@ impl TerminalView {
}
pub(super) fn uses_source_reader_math(&mut self) -> bool {
self.preserve_agent_math_source |= self.running_program.as_deref().is_some_and(|program| {
matches!(program, "claude" | "codex")
}) || self.ai_session.as_ref().is_some_and(|session| {
matches!(session.source.as_str(), "claude" | "codex")
});
self.preserve_agent_math_source |= self
.running_program
.as_deref()
.is_some_and(|program| matches!(program, "claude" | "codex"))
|| self
.ai_session
.as_ref()
.is_some_and(|session| matches!(session.source.as_str(), "claude" | "codex"));
self.preserve_agent_math_source
}
}
@@ -702,11 +702,15 @@ impl TerminalView {
// 进程就是主 agent(子代理必须由它 spawn,不可能先到)。
let from_primary_agent = self.claim_primary_agent(event);
if from_primary_agent && self.ssh_destination.is_none() && self.exited.is_none() {
if self.answers.observe(event, self.pane_id) && let Some(reader) = &self.answer_reader {
if self.answers.observe(event, self.pane_id)
&& let Some(reader) = &self.answer_reader
{
reader.update(cx, |reader, cx| reader.answer_arrived(cx));
}
}
if event.kind == AiHookKind::NeedsAttention && let Some(reader) = &self.answer_reader {
if event.kind == AiHookKind::NeedsAttention
&& let Some(reader) = &self.answer_reader
{
reader.update(cx, |reader, cx| reader.needs_attention(cx));
}
if from_primary_agent
+19 -8
View File
@@ -467,7 +467,7 @@ pub fn settings_hairline(cx: &App) -> Hsla {
pub fn settings_hover_bg(cx: &App, strong: bool) -> Hsla {
let sk = chrome_theme(effective_theme_name(cx)).skin();
let (hover_alpha, strong_alpha) = if sk.is_light { (22, 34) } else { (30, 46) };
let (hover_alpha, strong_alpha) = if sk.is_light { (10, 18) } else { (30, 46) };
let alpha = if strong { strong_alpha } else { hover_alpha };
wash(Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, alpha))
}
@@ -571,16 +571,27 @@ fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) {
theme.overlay = wash(sk.veil);
// 悬停 / 选中水洗。
theme.accent = wash(sk.hover);
let hover = if sk.is_light {
wash(Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 10))
} else {
wash(sk.hover)
};
let selected = if sk.is_light {
wash(Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 18))
} else {
wash(sk.accent_soft)
};
let list_selected = if sk.is_light { selected } else { wash(sk.hover_strong) };
theme.accent = hover;
theme.accent_foreground = ink(sk.ink_strong);
theme.list_hover = wash(sk.hover);
theme.list_active = wash(sk.hover_strong);
theme.list_hover = hover;
theme.list_active = list_selected;
theme.list_active_border = transparent;
theme.sidebar_foreground = ink(sk.ink);
theme.sidebar_accent = wash(sk.accent_soft);
theme.sidebar_accent = selected;
theme.sidebar_accent_foreground = ink(sk.ink_strong);
theme.tab_foreground = ink(sk.ink_dim);
theme.tab_active = wash(sk.accent_soft);
theme.tab_active = selected;
theme.tab_active_foreground = ink(sk.ink_strong);
// 按钮。
@@ -594,8 +605,8 @@ fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) {
theme.primary_active = shift3(soft_accent.r, soft_accent.g, soft_accent.b, 0.18);
theme.primary_foreground = ink(sk.ink_on_accent);
theme.secondary = wash(sk.surface);
theme.secondary_hover = wash(sk.hover);
theme.secondary_active = wash(sk.hover_strong);
theme.secondary_hover = hover;
theme.secondary_active = list_selected;
theme.secondary_foreground = ink(sk.ink);
// 语义三色。
+2 -6
View File
@@ -819,12 +819,8 @@ fn reveal_in_file_manager(path: &Path) {
}
fn workspace_ui_language() -> crate::display::UiLanguage {
match nebula_settings::RuntimeSettings::load().language {
nebula_settings::LanguagePref::System => crate::display::LanguagePreference::System,
nebula_settings::LanguagePref::ZhCn => crate::display::LanguagePreference::ZhCn,
nebula_settings::LanguagePref::EnUs => crate::display::LanguagePreference::EnUs,
}
.resolved()
crate::display::LanguagePreference::from(nebula_settings::RuntimeSettings::load().language)
.resolved()
}
fn new_tab_insert_index(
@@ -325,7 +325,7 @@ impl NebulaWorkspace {
indexed_count,
indexed_more
),
crate::display::UiLanguage::EnUs => format!(
_ => format!(
"{} results · {}{} indexed",
self.side_panel.file_search_total(),
indexed_count,
@@ -433,12 +433,10 @@ impl NebulaWorkspace {
window,
cx,
crate::display::ToastKind::Info,
match language {
crate::display::UiLanguage::EnUs => {
format!("Sent to {target}")
},
_ => format!("已发送到 {target}"),
},
language.format(
crate::i18n::Message::CommonSentTo,
&[("target", &target)],
),
);
true
},
@@ -20,15 +20,12 @@ pub(crate) fn show_update_notification(
) {
let language = workspace_ui_language();
let title: SharedString = language.pick("发现新版本", "Update available").into();
let message: SharedString = match language {
crate::display::UiLanguage::ZhCn => {
format!("Nebula v{} 已发布(当前 v{})", result.latest, result.current)
},
crate::display::UiLanguage::EnUs => {
format!("Nebula v{} is available (current v{})", result.latest, result.current)
},
}
.into();
let message: SharedString = language
.format(
crate::i18n::Message::CommonUpdateNotice,
&[("latest", &result.latest), ("current", &result.current)],
)
.into();
let action_label: SharedString = language.pick("查看更新", "View update").into();
let action_result = result.clone();
let notification = Notification::warning(message)
@@ -127,7 +124,7 @@ fn show_download_outcome_notification(
format_bytes(bytes)
)
},
crate::display::UiLanguage::EnUs => format!(
_ => format!(
"The v{} installer passed SHA-256 verification ({})",
downloaded_version,
format_bytes(bytes)
@@ -177,11 +177,7 @@ impl NebulaWorkspace {
),
)
.child(
div()
.truncate()
.text_xs()
.text_color(muted)
.child(meta),
div().truncate().text_xs().text_color(muted).child(meta),
),
)
.into_any_element(),
@@ -1172,11 +1168,7 @@ fn git_graph_rows(commits: &[crate::display::side_panel::GitCommit]) -> Vec<GitG
occupied.extend(parent_lanes.iter().cloned());
allocate_git_lane_color(&occupied, &mut next_color)
};
parent_lanes.push(GitActiveLane {
id: next_lane_id,
target: parent.clone(),
color,
});
parent_lanes.push(GitActiveLane { id: next_lane_id, target: parent.clone(), color });
next_lane_id = next_lane_id.wrapping_add(1);
}
let parent_count = parent_lanes.len();
@@ -670,9 +670,13 @@ pub(crate) fn dispatch_shell_events(events: Vec<GpuiShellEvent>, cx: &mut App) {
continue;
};
let pending = request.clone();
if entry.handle.update(cx, move |_, window, cx| {
super::ssh_dialog::show(pending, window, cx);
}).is_err() {
if entry
.handle
.update(cx, move |_, window, cx| {
super::ssh_dialog::show(pending, window, cx);
})
.is_err()
{
request.respond(crate::ssh_prompt::PromptResponse::Cancel);
}
},
+48
View File
@@ -0,0 +1,48 @@
pub(super) fn substitute(template: &str, args: &[(&str, &str)]) -> String {
let extra = args.iter().map(|(_, value)| value.len()).sum::<usize>();
let mut output = String::with_capacity(template.len().saturating_add(extra));
let mut rest = template;
while let Some(position) = rest.find(['{', '}']) {
output.push_str(&rest[..position]);
rest = &rest[position..];
if rest.starts_with("{{") || rest.starts_with("}}") {
output.push_str(&rest[..1]);
rest = &rest[2..];
continue;
}
if !rest.starts_with('{') {
output.push('}');
rest = &rest[1..];
continue;
}
let Some(end) = rest.find('}') else { break };
let name = &rest[1..end];
let replacement = args.iter().find(|(argument, _)| *argument == name);
output.push_str(replacement.map(|(_, value)| *value).unwrap_or(&rest[..end + 1]));
rest = &rest[end + 1..];
}
output.push_str(rest);
output
}
#[cfg(test)]
mod tests {
use super::substitute;
#[test]
fn argument_values_are_never_interpreted_as_more_placeholders() {
assert_eq!(
substitute("{name}: {status}", &[("name", "{status}"), ("status", "200")]),
"{status}: 200"
);
}
#[test]
fn preserves_unicode_missing_arguments_and_escaped_braces() {
assert_eq!(
substitute("你好 {name} / {missing} {{code}}", &[("name", "世界")]),
"你好 世界 / {missing} {code}"
);
assert_eq!(substitute("unfinished {name", &[]), "unfinished {name");
}
}
+21
View File
@@ -0,0 +1,21 @@
pub fn system_locale() -> Option<String> {
native_locale().or_else(|| {
["LC_ALL", "LC_MESSAGES", "LANG"]
.into_iter()
.find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty()))
})
}
#[cfg(windows)]
fn native_locale() -> Option<String> {
use windows_sys::Win32::Globalization::GetUserDefaultLocaleName;
let mut buffer = [0_u16; 85];
let length = unsafe { GetUserDefaultLocaleName(buffer.as_mut_ptr(), buffer.len() as i32) };
(length > 1).then(|| String::from_utf16(&buffer[..length as usize - 1]).ok()).flatten()
}
#[cfg(not(windows))]
fn native_locale() -> Option<String> {
None
}
+56
View File
@@ -0,0 +1,56 @@
mod format;
mod locale;
pub use locale::system_locale;
include!(concat!(env!("OUT_DIR"), "/translations.rs"));
impl LanguagePreference {
pub fn parse(value: &str) -> Option<Self> {
nebula_settings::LanguagePref::from_settings(value).map(Self::from)
}
pub const fn as_str(self) -> &'static str {
self.shared().settings_value()
}
pub fn resolved(self) -> UiLanguage {
self.explicit().unwrap_or_else(|| UiLanguage::for_locale(system_locale().as_deref()))
}
}
impl UiLanguage {
pub fn for_locale(locale: Option<&str>) -> Self {
locale
.and_then(nebula_settings::LanguagePref::from_locale)
.and_then(|preference| LanguagePreference::from(preference).explicit())
.unwrap_or(Self::EnUs)
}
pub const fn text(self, message: Message) -> &'static str {
MESSAGES[self as usize][message as usize]
}
pub fn pick<'text>(self, zh_cn: &'text str, en_us: &'text str) -> &'text str {
match self {
Self::ZhCn => zh_cn,
Self::EnUs => en_us,
_ => source_id(en_us).map(|message| self.text(message)).unwrap_or(en_us),
}
}
pub fn tr(self, key: &'static str) -> &'static str {
message_id(key).map(|message| self.text(message)).unwrap_or(key)
}
pub fn format(self, message: Message, args: &[(&str, &str)]) -> String {
format::substitute(self.text(message), args)
}
pub fn tr_args(self, key: &'static str, args: &[(&str, &str)]) -> String {
format::substitute(self.tr(key), args)
}
}
#[cfg(test)]
mod tests;
+171
View File
@@ -0,0 +1,171 @@
use crate::i18n::UiLanguage;
use crate::provider_test::ProviderTestOutcome;
use crate::proxy_test::{ProxyTestFailure, ProxyTestOutcome, ProxyTestRoute};
impl UiLanguage {
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::UiLanguage;
#[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"
);
}
}
+83
View File
@@ -0,0 +1,83 @@
use super::{LanguagePreference, Message, UiLanguage};
#[test]
fn language_metadata_and_settings_values_remain_in_sync() {
assert_eq!(UiLanguage::ALL.len(), nebula_settings::LanguagePref::LANGUAGES.len());
for preference in LanguagePreference::ALL {
assert_eq!(LanguagePreference::parse(preference.as_str()), Some(*preference));
if let Some(language) = preference.explicit() {
assert_eq!(language.code(), preference.as_str());
assert_eq!(preference.resolved(), language);
}
}
}
#[test]
fn negotiates_locale_and_falls_back_to_english() {
assert_eq!(UiLanguage::for_locale(Some("fr_CA.UTF-8")), UiLanguage::FrFr);
assert_eq!(UiLanguage::for_locale(Some("zh-Hant-HK")), UiLanguage::ZhTw);
assert_eq!(UiLanguage::for_locale(Some("unsupported")), UiLanguage::EnUs);
assert_eq!(UiLanguage::for_locale(None), UiLanguage::EnUs);
}
#[test]
fn typed_and_compatibility_lookups_agree() {
assert_eq!(UiLanguage::FrFr.text(Message::SettingsSidebarNetwork), "Réseau");
assert_eq!(UiLanguage::JaJp.tr("settings.sidebar.network"), "ネットワーク");
for language in UiLanguage::ALL {
assert_eq!(
language.tr("settings.sidebar.network"),
language.text(Message::SettingsSidebarNetwork)
);
assert_eq!(language.tr("missing.message.id"), "missing.message.id");
}
}
#[test]
fn inline_migration_preserves_bilingual_text_and_english_fallback() {
assert_eq!(UiLanguage::ZhCn.pick("网络", "Network"), "网络");
assert_eq!(UiLanguage::EnUs.pick("网络", "Network"), "Network");
assert_eq!(UiLanguage::FrFr.pick("网络", "Network"), "Réseau");
assert_eq!(UiLanguage::FrFr.pick("未迁移文案", "Unmigrated text"), "Unmigrated text");
}
#[test]
fn arguments_are_localized_without_losing_placeholders() {
assert_eq!(
UiLanguage::EnUs.tr_args("provider.test.success", &[("status", "200")]),
"Connection succeeded (HTTP 200)"
);
assert_eq!(
UiLanguage::FrFr.tr_args("provider.test.success", &[("status", "200")]),
"Connexion réussie (HTTP 200)"
);
assert_eq!(
UiLanguage::FrFr.format(Message::ProviderTestSuccess, &[("status", "200")]),
"Connexion réussie (HTTP 200)"
);
}
#[test]
#[ignore = "manual static translation microbenchmark"]
fn measure_static_catalog_costs() {
use std::hint::black_box;
use std::time::Instant;
let count = 2_000_000u128;
let started = Instant::now();
for _ in 0..count {
black_box(black_box(UiLanguage::FrFr).text(black_box(Message::SettingsSidebarNetwork)));
}
eprintln!(
"typed lookup: {} ns/op; {} messages; {} locales; {} translated bytes",
started.elapsed().as_nanos() / count,
super::MESSAGE_COUNT,
UiLanguage::ALL.len(),
super::TRANSLATED_BYTES
);
let started = Instant::now();
for _ in 0..count {
black_box(black_box(UiLanguage::FrFr).tr(black_box("settings.sidebar.network")));
}
eprintln!("key lookup: {} ns/op", started.elapsed().as_nanos() / count);
}
+3
View File
@@ -67,11 +67,14 @@ mod font_install;
mod git_worktree;
#[cfg(feature = "gpui-shell")]
mod gpui_shell;
mod i18n;
#[cfg(feature = "legacy-shell")]
mod input;
#[cfg(all(feature = "gpui-shell", not(feature = "legacy-shell")))]
#[path = "product_input.rs"]
mod input;
#[path = "i18n/outcomes.rs"]
mod localized_status;
mod logging;
#[cfg(target_os = "macos")]
mod macos;
+2 -1
View File
@@ -17,7 +17,8 @@ pub fn default_shell_id() -> String {
#[cfg(unix)]
{
let shell = nebula_terminal::tty::default_shell_program().unwrap_or_default();
Path::new(&shell).file_name()
Path::new(&shell)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or(default_unix_shell_id())
+1 -3
View File
@@ -24,8 +24,6 @@ pub(crate) mod file_dialog;
mod file_operations;
#[path = "../display/hint.rs"]
pub(crate) mod hint;
#[path = "../display/i18n.rs"]
mod i18n;
#[path = "../display/image_viewer.rs"]
pub mod image_viewer;
#[path = "../display/input_state.rs"]
@@ -60,7 +58,7 @@ mod text_path_model;
mod toast_kind;
pub mod ui;
pub use i18n::{LanguagePreference, UiLanguage};
pub use crate::i18n::{LanguagePreference, UiLanguage};
pub use program_identity::AiLogo;
pub use size_info::SizeInfo;
pub(crate) use ssh_ui::merge_ssh_hosts;
+2 -2
View File
@@ -10,10 +10,10 @@ pub mod keycap;
pub mod os_icons;
#[path = "../display/ui/color_math.rs"]
pub mod surface;
#[path = "../display/ui/theme.rs"]
pub mod theme;
#[path = "../display/ui/text_field.rs"]
pub(crate) mod text_field;
#[path = "../display/ui/theme.rs"]
pub mod theme;
#[path = "../display/ui/tokens.rs"]
pub mod tokens;
#[path = "../display/ui/scrollbar.rs"]
+1 -4
View File
@@ -405,10 +405,7 @@ pub fn forget_password(destination: &str) -> std::io::Result<()> {
crate::platform::credentials::delete(&credential_target(destination))
}
pub fn store_private_key_passphrase(
private_key: &[u8],
passphrase: &[u8],
) -> std::io::Result<()> {
pub fn store_private_key_passphrase(private_key: &[u8], passphrase: &[u8]) -> std::io::Result<()> {
crate::platform::credentials::store_with_username(
&private_key_credential_target(private_key),
"private-key",
+1 -1
View File
@@ -4,8 +4,8 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize};
mod connection;
pub use connection::{SshConnectionOptions, SshHostJumpMode, SshHostProxyMode};
pub(crate) use connection::validate_ssh_destination;
pub use connection::{SshConnectionOptions, SshHostJumpMode, SshHostProxyMode};
const PROFILE_VERSION: u32 = 1;
const USERNAME_HISTORY_CAP: usize = 12;
+34 -41
View File
@@ -769,11 +769,8 @@ async fn authenticated_route<H: SshEventHost>(
unattended: bool,
) -> Result<AcquiredSession, SessionError> {
let key = route.pool_key();
let existing = if unattended {
None
} else {
connection_pool().lock().await.get(&key).cloned()
};
let existing =
if unattended { None } else { connection_pool().lock().await.get(&key).cloned() };
if let Some(existing) = existing {
if !existing.lock().await.is_closed() {
info!("复用已认证 SSH 连接: {key}");
@@ -868,13 +865,10 @@ async fn open_transport(
RouteTransport::Jump(jump) => {
let spec = &jump.destination.original;
info!("经跳板 {spec} 连接 {}:{}", destination.host, destination.port);
let acquired = Box::pin(authenticated_route(
jump,
None::<&NoopSshEventHost>,
unattended,
))
.await
.map_err(|err| format!("连接跳板 {spec} 失败: {err}"))?;
let acquired =
Box::pin(authenticated_route(jump, None::<&NoopSshEventHost>, unattended))
.await
.map_err(|err| format!("连接跳板 {spec} 失败: {err}"))?;
let channel = {
let session = acquired.session.lock().await;
session
@@ -905,8 +899,7 @@ async fn open_transport(
client::connect_stream(config, stream, handler).await?
},
RouteTransport::Direct => {
client::connect(config, (destination.host.as_str(), destination.port), handler)
.await?
client::connect(config, (destination.host.as_str(), destination.port), handler).await?
},
};
Ok(OpenedTransport { session, jump_sessions })
@@ -950,7 +943,9 @@ async fn authenticate(
reusable_password =
crate::ssh_credentials::load_stored_password(&destination.original)
.unwrap_or_else(|error| {
warn!("Could not read saved SSH password; prompting instead: {error}");
warn!(
"Could not read saved SSH password; prompting instead: {error}"
);
None
});
loaded_stored_password = true;
@@ -1177,9 +1172,7 @@ async fn run_test(request: SshTestRequest) -> SshTestResult {
let outcome = tokio::time::timeout(TEST_TIMEOUT, async {
let resolved = tokio::task::spawn_blocking({
let raw = raw.clone();
move || {
SshDestination::resolve(&raw)
}
move || SshDestination::resolve(&raw)
})
.await
.map_err(|err| -> SessionError {
@@ -1193,13 +1186,9 @@ async fn run_test(request: SshTestRequest) -> SshTestResult {
icon: None,
connection: request.connection.clone(),
};
let route = resolve_connection_route(
&resolved,
&profile,
0,
request.proxy_password.clone(),
)
.await?;
let route =
resolve_connection_route(&resolved, &profile, 0, request.proxy_password.clone())
.await?;
test_connect(&route, &request).await
})
.await;
@@ -1452,15 +1441,15 @@ async fn proxy_test_stream(
let route = resolve_connection_route(&jump_destination, &jump_profile, 1, None)
.await
.map_err(|error| ProxyTestFailure::JumpConnect {
target: spec.clone(),
error: error.to_string(),
})?;
let jump = authenticated_route(&route, None::<&NoopSshEventHost>, true).await.map_err(
|error| ProxyTestFailure::JumpConnect {
target: spec.clone(),
error: error.to_string(),
})?;
let jump = authenticated_route(&route, None::<&NoopSshEventHost>, true)
.await
.map_err(|error| ProxyTestFailure::JumpConnect {
target: spec.clone(),
error: error.to_string(),
})?;
},
)?;
let channel = {
let session = jump.session.lock().await;
session
@@ -1490,10 +1479,7 @@ async fn proxy_test_stream(
}
}
async fn test_connect(
route: &ResolvedRoute,
request: &SshTestRequest,
) -> Result<(), SessionError> {
async fn test_connect(route: &ResolvedRoute, request: &SshTestRequest) -> Result<(), SessionError> {
let config = Arc::new(client::Config {
inactivity_timeout: None,
keepalive_interval: None,
@@ -1685,7 +1671,8 @@ async fn try_private_key(
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.and_then(|passphrase| russh::keys::load_secret_key(path, Some(passphrase)).ok());
if key.is_none() && stored.is_some() {
if let Err(error) = crate::ssh_credentials::forget_private_key_passphrase(&private_key) {
if let Err(error) = crate::ssh_credentials::forget_private_key_passphrase(&private_key)
{
warn!("Could not remove rejected SSH key passphrase: {error}");
}
}
@@ -1809,8 +1796,10 @@ async fn prompt_secret(
if crate::ssh_prompt::available() {
let _initial = zeroize::Zeroizing::new(initial.unwrap_or_default());
return crate::ssh_prompt::secret(
destination, allow_save && crate::platform::credentials::can_store(),
).await;
destination,
allow_save && crate::platform::credentials::can_store(),
)
.await;
}
tokio::task::spawn_blocking(move || {
let initial = zeroize::Zeroizing::new(initial.unwrap_or_default());
@@ -1854,8 +1843,12 @@ fn render_error<H: SshEventHost>(
async fn confirm_new_host(host: &str, port: u16, key: &ssh_key::PublicKey) -> bool {
if crate::ssh_prompt::available() {
return crate::ssh_prompt::confirm_host(
host, port, key.fingerprint(ssh_key::HashAlg::Sha256).to_string(),
).await.unwrap_or_else(|error| {
host,
port,
key.fingerprint(ssh_key::HashAlg::Sha256).to_string(),
)
.await
.unwrap_or_else(|error| {
warn!("SSH host confirmation failed: {error}");
false
});
-2
View File
@@ -18,8 +18,6 @@ const GRANDFATHERED: &[(&str, usize)] = &[
("nebula_app/src/display/settings.rs", 8650),
// 2026-08-31 下调(5150 → 4980):VCS 抽屉页搬到 workspace/vcs_panel.rs。
("nebula_app/src/gpui_shell/workspace.rs", 4880),
// 2026-08-17 下调(3600 → 3550):网络区拆到 network_settings.rs。
("nebula_app/src/gpui_shell/settings_pane.rs", 3120),
("nebula_app/src/display/command_palette.rs", 3980),
("nebula_app/src/window_context.rs", 3750),
("nebula_app/src/event.rs", 3620),
+64
View File
@@ -0,0 +1,64 @@
#[path = "../build/i18n.rs"]
mod catalog_builder;
#[path = "../src/i18n/mod.rs"]
mod i18n;
use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;
use std::hint::black_box;
thread_local! {
static TRACK_ALLOCATIONS: Cell<bool> = const { Cell::new(false) };
static ALLOCATIONS: Cell<usize> = const { Cell::new(0) };
}
struct CountingAllocator;
fn record_allocation() {
let _ = TRACK_ALLOCATIONS.try_with(|tracking| {
if tracking.get() {
let _ = ALLOCATIONS.try_with(|count| count.set(count.get() + 1));
}
});
}
unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record_allocation();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record_allocation();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
#[test]
fn first_and_repeated_translation_lookups_allocate_nothing() {
ALLOCATIONS.with(|count| count.set(0));
TRACK_ALLOCATIONS.with(|tracking| tracking.set(true));
for language in i18n::UiLanguage::ALL {
for _ in 0..1_000 {
black_box(language.text(black_box(i18n::Message::SettingsSidebarNetwork)));
black_box(language.tr(black_box("settings.sidebar.network")));
black_box(language.pick(black_box("网络"), black_box("Network")));
black_box(language.pick(black_box("新文案"), black_box("Unmigrated text")));
}
}
TRACK_ALLOCATIONS.with(|tracking| tracking.set(false));
assert_eq!(ALLOCATIONS.with(Cell::get), 0);
}
#[test]
fn embedded_translations_stay_within_the_initial_payload_budget() {
assert!(i18n::TRANSLATED_BYTES < 256 * 1024);
assert!(i18n::MESSAGE_COUNT >= 200);
}
+141
View File
@@ -0,0 +1,141 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LanguageInfo {
pub preference: LanguagePref,
pub code: &'static str,
pub native_name: &'static str,
pub component_locale: &'static str,
pub rust_variant: &'static str,
}
macro_rules! languages {
($( $variant:ident => ($code:literal, $native:literal, $component:literal) ),+ $(,)?) => {
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LanguagePref {
#[default]
System,
$( $variant, )+
}
impl LanguagePref {
pub const ALL: &'static [Self] = &[Self::System, $( Self::$variant, )+];
pub const VALUES: &'static [&'static str] = &["system", $( $code, )+];
pub const LANGUAGES: &'static [LanguageInfo] = &[
$( LanguageInfo {
preference: Self::$variant,
code: $code,
native_name: $native,
component_locale: $component,
rust_variant: stringify!($variant),
}, )+
];
pub fn from_settings(value: &str) -> Option<Self> {
match value.trim() {
"system" => Some(Self::System),
$( $code => Some(Self::$variant), )+
_ => None,
}
}
pub const fn settings_value(self) -> &'static str {
match self {
Self::System => "system",
$( Self::$variant => $code, )+
}
}
pub const fn native_name(self) -> &'static str {
match self {
Self::System => "System",
$( Self::$variant => $native, )+
}
}
}
};
}
languages! {
ZhCn => ("zh-CN", "简体中文", "zh-CN"),
EnUs => ("en-US", "English", "en"),
ZhTw => ("zh-TW", "繁體中文", "zh-TW"),
FrFr => ("fr-FR", "Français", "en"),
DeDe => ("de-DE", "Deutsch", "en"),
EsEs => ("es-ES", "Español", "en"),
PtBr => ("pt-BR", "Português (Brasil)", "en"),
ItIt => ("it-IT", "Italiano", "it"),
RuRu => ("ru-RU", "Русский", "en"),
JaJp => ("ja-JP", "日本語", "en"),
KoKr => ("ko-KR", "한국어", "en"),
}
impl LanguagePref {
pub fn from_locale(locale: &str) -> Option<Self> {
let locale = locale.trim().split(['.', '@']).next()?;
let mut parts = locale.split(['-', '_']);
let primary = parts.next()?;
if primary.eq_ignore_ascii_case("zh") {
let mut traditional_script = false;
let mut simplified_script = false;
let mut traditional_region = false;
for part in parts {
traditional_script |= part.eq_ignore_ascii_case("Hant");
simplified_script |= part.eq_ignore_ascii_case("Hans");
traditional_region |=
["TW", "HK", "MO"].iter().any(|region| part.eq_ignore_ascii_case(region));
}
if traditional_script {
return Some(Self::ZhTw);
}
if simplified_script {
return Some(Self::ZhCn);
}
return Some(if traditional_region { Self::ZhTw } else { Self::ZhCn });
}
Self::LANGUAGES
.iter()
.find(|info| {
info.code.split('-').next().is_some_and(|base| base.eq_ignore_ascii_case(primary))
})
.map(|info| info.preference)
}
}
#[cfg(test)]
mod tests {
use super::LanguagePref;
#[test]
fn every_language_round_trips_without_changing_saved_values() {
assert_eq!(&LanguagePref::VALUES[..3], &["system", "zh-CN", "en-US"]);
for (preference, value) in LanguagePref::ALL.iter().zip(LanguagePref::VALUES) {
assert_eq!(preference.settings_value(), *value);
assert_eq!(LanguagePref::from_settings(value), Some(*preference));
assert!(!preference.native_name().is_empty());
}
assert_eq!(LanguagePref::from_settings("zh"), None);
assert_eq!(LanguagePref::from_settings("unknown"), None);
}
#[test]
fn system_locale_negotiates_regions_scripts_and_posix_suffixes() {
for (locale, expected) in [
("fr_CA.UTF-8", LanguagePref::FrFr),
("DE-at", LanguagePref::DeDe),
("es_MX", LanguagePref::EsEs),
("pt-PT", LanguagePref::PtBr),
("ja_JP.UTF-8", LanguagePref::JaJp),
("ko-KR", LanguagePref::KoKr),
("ru_RU@variant", LanguagePref::RuRu),
("it-CH", LanguagePref::ItIt),
("en-GB", LanguagePref::EnUs),
("zh-Hant", LanguagePref::ZhTw),
("zh_HK", LanguagePref::ZhTw),
("zh-Hans-TW", LanguagePref::ZhCn),
("zh_CN.UTF-8", LanguagePref::ZhCn),
] {
assert_eq!(LanguagePref::from_locale(locale), Some(expected), "{locale}");
}
assert_eq!(LanguagePref::from_locale("C.UTF-8"), None);
assert_eq!(LanguagePref::from_locale("unknown"), None);
}
}
+4 -27
View File
@@ -18,6 +18,10 @@ use std::path::PathBuf;
mod app_icon;
pub use app_icon::{AppIconName, AppIconPalette};
mod language;
pub use language::{LanguageInfo, LanguagePref};
mod reset;
pub use reset::restore_default_settings;
/// Settings/data directory shared by every shell and renderer.
///
@@ -572,33 +576,6 @@ impl CursorShapeName {
/// 下面的小枚举都是 `nebula_settings.txt` 的键值域,取值与旧壳设置页
/// 逐字一致(parse 宽容:未知值回退默认)。
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub enum LanguagePref {
#[default]
System,
ZhCn,
EnUs,
}
impl LanguagePref {
pub fn from_settings(value: &str) -> Option<Self> {
match value.trim() {
"system" => Some(Self::System),
"zh-CN" => Some(Self::ZhCn),
"en-US" => Some(Self::EnUs),
_ => None,
}
}
pub fn settings_value(self) -> &'static str {
match self {
Self::System => "system",
Self::ZhCn => "zh-CN",
Self::EnUs => "en-US",
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub enum AcceptKeyName {
Right,
+193
View File
@@ -0,0 +1,193 @@
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
const RESET_KEYS: &[&str] = &[
"language",
"theme",
"app_icon",
"follow_system_theme",
"font_family",
"font_size",
"cursor_shape",
"cursor_blink",
"copy_on_select",
"multiline_paste_confirm",
"tab_close_visible",
"terminal_proxy",
"powerline",
"shell",
"executor",
"startup_directory",
"ghost",
"accept",
"completion_style",
"cjk_bold_regular",
"tabs_position",
"tab_reveal",
"density",
"new_tab_position",
"windowing_behavior",
"cell_width_mode",
"vcs_display",
"bell",
"fetch",
"auto_check_updates",
"keep_session",
"restore_session",
"resume_ai",
"tray",
"blur",
"opacity",
"background",
"background_image",
"background_image_opacity",
"background_image_fit",
"background_image_alignment",
"background_image_cover_chrome",
"panel_resize",
"sidebar_w",
"ssh_proxy_mode",
"ssh_proxy_url",
"ssh_proxy_no_proxy",
"quick_terminal_hotkey",
"pane_card_radius",
"pane_card_gutter",
"pane_card_shadow",
"pane_card_divider",
"keybind",
];
static RESET_SEQUENCE: AtomicU64 = AtomicU64::new(0);
pub fn restore_default_settings() -> io::Result<Option<PathBuf>> {
restore_defaults_at(&super::settings_path())
}
fn default_settings_text(text: &str) -> String {
text.split_inclusive('\n')
.filter(|line| {
!line.split_once('=').is_some_and(|(key, _)| {
RESET_KEYS.iter().any(|known| key.trim().eq_ignore_ascii_case(known))
})
})
.collect()
}
fn restore_defaults_at(path: &Path) -> io::Result<Option<PathBuf>> {
let original = match fs::read_to_string(path) {
Ok(text) => Some(text),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => return Err(error),
};
let restored = default_settings_text(original.as_deref().unwrap_or_default());
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos();
let sequence = RESET_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let suffix = format!("{timestamp}-{}-{sequence}", std::process::id());
let backup =
original.as_ref().map(|_| path.with_extension(format!("before-reset-{suffix}.bak")));
if let (Some(text), Some(backup)) = (&original, &backup) {
let mut file = OpenOptions::new().write(true).create_new(true).open(backup)?;
file.write_all(text.as_bytes())?;
file.sync_all()?;
}
let temporary = path.with_extension(format!("reset-{suffix}.tmp"));
let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary)?;
let written = file.write_all(restored.as_bytes()).and_then(|_| file.sync_all());
drop(file);
let result = written.and_then(|_| fs::rename(&temporary, path));
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result.map(|_| backup)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{RawSettings, RuntimeSettings};
#[test]
fn reset_removes_all_overrides_and_keeps_user_data() {
let text = "# preferences\r\n THEME = Nord\r\ncopy_on_select=1\nkeybind=ctrl+x:Copy\nFONT_SIZE=30\nexecutor=custom\nssh_hosts=saved-host\nai_provider=custom\nfuture_setting=keep\n";
let result = default_settings_text(text);
assert_eq!(
result,
"# preferences\r\nssh_hosts=saved-host\nai_provider=custom\nfuture_setting=keep\n"
);
let runtime = RuntimeSettings::from_raw(&RawSettings::from_text(&result));
let defaults = RuntimeSettings::from_raw(&RawSettings::default());
assert_eq!(runtime.theme, defaults.theme);
assert_eq!(runtime.copy_on_select, defaults.copy_on_select);
assert!(runtime.font_size_px.is_none());
assert!(runtime.shell.is_none());
assert!(crate::keybind_pairs_from_text(&result).is_empty());
}
#[test]
fn duplicate_keys_and_legacy_aliases_cannot_override_the_reset() {
let text = "theme=Nord\nTHEME=Paper\nshell=pwsh\nexecutor=cmd\nkeybind=ctrl+a:Copy\nkeybind=ctrl+b:Paste\n";
assert_eq!(default_settings_text(text), "");
}
#[test]
fn reset_covers_every_runtime_settings_key() {
let source = include_str!("lib.rs");
let reader = source.split("pub fn from_raw(raw: &RawSettings) -> Self").nth(1).unwrap();
let reader: String = reader
.split("/// 解析")
.next()
.unwrap()
.chars()
.filter(|character| !character.is_whitespace())
.collect();
for accessor in ["raw.value(\"", "raw.f32(\"", "raw.bool_on(\""] {
for expression in reader.split(accessor).skip(1) {
let key = expression.split('"').next().unwrap();
assert!(RESET_KEYS.contains(&key), "missing reset key: {key}");
}
}
}
#[test]
fn reset_backs_up_the_original_without_touching_adjacent_files() {
let directory = std::env::temp_dir().join(format!(
"nebula-reset-test-{}-{}",
std::process::id(),
RESET_SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&directory).unwrap();
let path = directory.join("nebula_settings.txt");
let hosts = directory.join("ssh_hosts.json");
fs::write(&path, "theme=Nord\nssh_hosts=keep\n").unwrap();
fs::write(&hosts, "user data").unwrap();
let backup = restore_defaults_at(&path).unwrap().unwrap();
assert_eq!(fs::read_to_string(&backup).unwrap(), "theme=Nord\nssh_hosts=keep\n");
assert_eq!(fs::read_to_string(&path).unwrap(), "ssh_hosts=keep\n");
assert_eq!(fs::read_to_string(&hosts).unwrap(), "user data");
let next_backup = restore_defaults_at(&path).unwrap().unwrap();
assert_ne!(backup, next_backup);
assert_eq!(fs::read_to_string(&backup).unwrap(), "theme=Nord\nssh_hosts=keep\n");
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn invalid_settings_file_is_not_overwritten() {
let directory = std::env::temp_dir().join(format!(
"nebula-reset-invalid-{}-{}",
std::process::id(),
RESET_SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&directory).unwrap();
let path = directory.join("nebula_settings.txt");
fs::write(&path, [0xff, 0xfe, 0x80]).unwrap();
assert!(restore_defaults_at(&path).is_err());
assert_eq!(fs::read(&path).unwrap(), [0xff, 0xfe, 0x80]);
fs::remove_dir_all(directory).unwrap();
}
}
+185
View File
@@ -0,0 +1,185 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nebula-settings"
version = "1.5.0"
[[package]]
name = "pebrel-i18n-contract"
version = "0.0.0"
dependencies = [
"nebula-settings",
"serde",
"serde_json",
"windows-sys",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "pebrel-i18n-contract"
version = "0.0.0"
edition = "2024"
publish = false
[workspace]
[lib]
path = "../../nebula_app/tests/i18n_contract.rs"
[dependencies]
nebula-settings = { path = "../../nebula_settings" }
serde = "1"
serde_json = "1"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Globalization"] }
[build-dependencies]
nebula-settings = { path = "../../nebula_settings" }
serde = "1"
serde_json = "1"
[profile.dev]
debug = 0
incremental = false
[profile.release]
debug = 0
incremental = false
+8
View File
@@ -0,0 +1,8 @@
#[path = "../../nebula_app/build/i18n.rs"]
mod i18n;
fn main() {
let root = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let output = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
i18n::generate(&root.join("../../nebula_app/i18n"), &output.join("translations.rs")).unwrap();
}