From 353bc175d6040b0aebe43eb8fb939b52be7498a8 Mon Sep 17 00:00:00 2001 From: kuddev Date: Fri, 28 Aug 2026 22:03:04 +0800 Subject: [PATCH] feat(i18n): embed JSON catalogs for GPUI settings --- nebula_app/Cargo.toml | 1 + nebula_app/build.rs | 54 + nebula_app/i18n/en-US.json | 118 ++ nebula_app/i18n/zh-CN.json | 118 ++ nebula_app/src/ai_providers.rs | 64 +- nebula_app/src/display/i18n.rs | 329 +++- nebula_app/src/display/mod.rs | 17 +- nebula_app/src/display/settings.rs | 24 +- nebula_app/src/event.rs | 38 +- nebula_app/src/event/types.rs | 6 +- nebula_app/src/gpui_shell/background_color.rs | 12 +- nebula_app/src/gpui_shell/config.rs | 72 +- nebula_app/src/gpui_shell/mod.rs | 1 + nebula_app/src/gpui_shell/network_settings.rs | 128 +- nebula_app/src/gpui_shell/settings_pane.rs | 1378 +++++++++++++---- .../src/gpui_shell/settings_pane/design.rs | 6 +- .../gpui_shell/settings_pane/font_picker.rs | 65 +- .../src/gpui_shell/settings_pane/keymap.rs | 37 +- nebula_app/src/gpui_shell/ssh_settings.rs | 326 ++-- nebula_app/src/main.rs | 2 + nebula_app/src/provider_test.rs | 31 + nebula_app/src/proxy_test.rs | 49 + nebula_app/src/ssh_session.rs | 101 +- 23 files changed, 2323 insertions(+), 654 deletions(-) create mode 100644 nebula_app/i18n/en-US.json create mode 100644 nebula_app/i18n/zh-CN.json create mode 100644 nebula_app/src/provider_test.rs create mode 100644 nebula_app/src/proxy_test.rs diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index af35753..842b566 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -129,6 +129,7 @@ sha1 = "0.11.0" [build-dependencies] gl_generator = "0.14.0" +serde_json = "1" [dev-dependencies] clap_complete = "4.2.3" diff --git a/nebula_app/build.rs b/nebula_app/build.rs index 987b985..974352c 100644 --- a/nebula_app/build.rs +++ b/nebula_app/build.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::env; use std::fs::File; use std::path::Path; @@ -6,6 +7,8 @@ use std::process::Command; use gl_generator::{Api, Fallbacks, GlobalGenerator, Profile, Registry}; fn main() { + validate_i18n_catalogs(); + let mut version = String::from(env!("CARGO_PKG_VERSION")); if let Some(commit_hash) = commit_hash() { version = format!("{version} ({commit_hash})"); @@ -41,6 +44,57 @@ fn main() { } } +fn validate_i18n_catalogs() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let i18n_dir = Path::new(&manifest_dir).join("i18n"); + let en_path = i18n_dir.join("en-US.json"); + let zh_path = i18n_dir.join("zh-CN.json"); + println!("cargo:rerun-if-changed={}", en_path.display()); + println!("cargo:rerun-if-changed={}", zh_path.display()); + + let en = catalog_keys(&en_path, "en-US"); + let zh = catalog_keys(&zh_path, "zh-CN"); + if en != zh { + let missing_zh = en.difference(&zh).cloned().collect::>(); + let missing_en = zh.difference(&en).cloned().collect::>(); + panic!( + "translation catalogs must have identical message ids; missing from zh-CN: \ + {missing_zh:?}; missing from en-US: {missing_en:?}" + ); + } +} + +fn catalog_keys(path: &Path, locale: &str) -> BTreeSet { + let source = std::fs::read_to_string(path) + .unwrap_or_else(|error| panic!("failed to read {locale} translation catalog: {error}")); + let root: serde_json::Value = serde_json::from_str(&source) + .unwrap_or_else(|error| panic!("invalid {locale} translation catalog: {error}")); + let mut keys = BTreeSet::new(); + collect_catalog_keys(&root, "", &mut keys, locale); + keys +} + +fn collect_catalog_keys( + value: &serde_json::Value, + prefix: &str, + keys: &mut BTreeSet, + locale: &str, +) { + match value { + serde_json::Value::Object(object) => { + for (key, value) in object { + let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") }; + collect_catalog_keys(value, &path, keys, locale); + } + }, + serde_json::Value::String(_) => { + assert!(!prefix.is_empty(), "{locale} translation catalog contains an empty id"); + assert!(keys.insert(prefix.to_owned()), "duplicate {locale} message id: {prefix}"); + }, + _ => panic!("{locale} translation catalog leaf {prefix} must be a string"), + } +} + /// Copy the side-by-side new-ConPTY (`conpty.dll` + `OpenConsole.exe`) next to /// the built exe so the matching OpenConsole host is selected at runtime. /// diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json new file mode 100644 index 0000000..16fe149 --- /dev/null +++ b/nebula_app/i18n/en-US.json @@ -0,0 +1,118 @@ +{ + "language": { + "self_name": "English", + "system": "Follow system", + "zh_cn": "Simplified Chinese", + "en_us": "English" + }, + "settings": { + "sidebar": { + "application": "Application", + "appearance": "Appearance", + "profiles": "Profiles", + "providers": "AI Providers", + "ssh": "SSH", + "network": "Network", + "interaction": "Interaction", + "keymap": "Key Bindings", + "advanced": "Advanced", + "backup": "Backup" + }, + "network": { + "title": "Network proxy", + "mode": { + "label": "Proxy mode", + "off": "No proxy", + "system": "Follow system", + "custom": "Custom proxy" + }, + "address": { + "label": "Proxy address" + }, + "status": { + "idle": "Test whether the current settings can access the network", + "running": "Testing network access with the current settings...", + "success": "Network connection succeeded via {route} in {elapsed_ms} ms", + "failed": "Test failed: {error}", + "unexpected_end": "The network test ended unexpectedly; try again", + "start_failed": "Could not start the network test: {error}" + }, + "action": { + "test": "Test network", + "testing": "Testing..." + }, + "route": { + "direct": "Direct connection", + "direct_address": "Direct address", + "custom_command": "Custom command", + "proxy_server": "Proxy {server}", + "ssh_jump": "SSH {target}" + }, + "failure": { + "load_settings": "Could not read proxy settings: {error}", + "invalid_settings": "The proxy settings are invalid: {error}", + "timeout": "The network test timed out after {seconds} seconds", + "send_request": "Could not send the test request: {error}", + "read_response": "Could not read the test response: {error}", + "invalid_http_status_line": "The test site did not return a valid HTTP status line: {line}", + "http_status": "The test site returned HTTP {status}", + "proxy_server": "Could not reach the network through proxy {server}: {error}", + "custom_command": "The custom proxy command failed: {error}", + "jump_task": "Could not prepare the SSH jump host: {error}", + "jump_resolve": "Could not resolve SSH jump host {target}: {error}", + "jump_connect": "Could not connect to SSH jump host {target}: {error}", + "jump_channel": "Could not open an outbound channel through SSH jump host {target}: {error}", + "direct": "Could not connect directly to the test site: {error}" + } + }, + "appearance": { + "background_color_description": "Overrides the terminal background supplied by the theme. This also changes the color reported to applications by OSC 11, which TUIs use to choose light or dark mode.", + "background_cover_title": "Extend the background image beneath window controls?", + "background_cover_description": "The image will extend beneath the title bar, window buttons, tabs, and SSH sidebar. Low-contrast images may make controls harder to see; the interface retains a minimum-opacity safeguard." + }, + "font": { + "fallback_description": "The first family is preferred; missing glyphs fall back through the remaining families, with bundled Maple as the final safe fallback.", + "group_description": "The first family is primary; the rest are used in fallback order.", + "loading": "Enumerating system fonts...", + "no_matches": "No matching fonts" + }, + "keymap": { + "conflict": "{combo} is assigned to both \"{first}\" and \"{second}\"; only the earlier \"{first}\" action will run.", + "help": "Click a row to rebind · Backspace restores the default · Esc cancels" + }, + "input": { + "backup_secret": "Credentials are stored only in the system credential manager" + }, + "ssh": { + "default_keys": "If unspecified, IdentityFile and the default id_* private keys are used.", + "address_hint": "Supports user@host; you can also paste ssh://host:2222", + "auth_auto_description": "Tries available private keys first, then asks for a password. This is the best choice in most cases.", + "auth_interactive_description": "Stores no credentials and prompts in the terminal when connecting, including two-factor authentication.", + "save_password": "Save in Windows Credential Manager", + "hide_config_host": "Hide (config sources cannot be deleted)", + "empty_hint": "Use \"+ Add host\" or maintain aliases in ~/.ssh/config", + "shared_data_hint": "Uses the same data as the legacy shell. Deleting a config alias hides it and it can be restored." + } + }, + "provider": { + "test": { + "success": "Connection succeeded (HTTP {status})", + "invalid_endpoint": "The endpoint must start with http:// or https://", + "missing_model": "Enter a default model first", + "missing_api_key": "Save an API key first", + "credential_read_failed": "Could not read the API key from the credential manager", + "invalid_credential_encoding": "The API key in the credential manager has invalid encoding", + "timeout": "Connection timed out", + "host_not_found": "Server not found", + "connection_failed": "Could not connect to the server", + "io_error": "Network error: {kind}", + "tls_failed": "TLS handshake failed", + "request_failed": "Request failed", + "auth_failed": "Authentication failed (HTTP {status}); check the API key", + "endpoint_not_found": "Endpoint not found (HTTP {status}); check the API endpoint", + "rate_limited": "Service rate limited (HTTP {status}); it is reachable but quota cannot be verified now", + "http_status": "Service returned HTTP {status}", + "start_failed": "Could not start the provider test: {error}" + } + } +} diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json new file mode 100644 index 0000000..2326d54 --- /dev/null +++ b/nebula_app/i18n/zh-CN.json @@ -0,0 +1,118 @@ +{ + "language": { + "self_name": "简体中文", + "system": "跟随系统", + "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": "代理地址" + }, + "status": { + "idle": "测试当前设置是否可以访问网络", + "running": "正在通过当前设置测试网络…", + "success": "网络连接正常 · {route} · {elapsed_ms} ms", + "failed": "测试失败:{error}", + "unexpected_end": "网络测试任务意外结束,请重试", + "start_failed": "无法启动网络测试:{error}" + }, + "action": { + "test": "测试网络", + "testing": "测试中…" + }, + "route": { + "direct": "直接连接", + "direct_address": "直连地址", + "custom_command": "自定义命令", + "proxy_server": "代理 {server}", + "ssh_jump": "SSH {target}" + }, + "failure": { + "load_settings": "读取代理设置失败:{error}", + "invalid_settings": "代理设置无效:{error}", + "timeout": "网络测试超时({seconds} 秒无响应)", + "send_request": "发送网页测试请求失败:{error}", + "read_response": "读取网页测试响应失败:{error}", + "invalid_http_status_line": "目标站点没有返回有效 HTTP 状态行:{line}", + "http_status": "目标站点返回 HTTP {status}", + "proxy_server": "经代理 {server} 出网失败:{error}", + "custom_command": "自定义代理命令出网失败:{error}", + "jump_task": "解析跳板任务失败:{error}", + "jump_resolve": "解析跳板 {target} 失败:{error}", + "jump_connect": "连接跳板 {target} 失败:{error}", + "jump_channel": "经跳板 {target} 打开出网通道失败:{error}", + "direct": "直接连接测试站点失败:{error}" + } + }, + "appearance": { + "background_color_description": "覆盖主题自带的终端底色。改了它,OSC 11 上报给程序的背景色也跟着变——TUI 判断明暗模式靠的就是这个值。", + "background_cover_title": "让背景图覆盖窗口控件区域?", + "background_cover_description": "背景图会延伸到标题栏、窗口按钮、Tab 与 SSH 侧栏下方,低对比度图片可能影响操作可见性;界面仍会保留最低不透明度保护。" + }, + "font": { + "fallback_description": "第 1 项优先显示;缺失字形依次使用后续字体,内置 Maple 是最终安全兜底。", + "group_description": "第 1 项是主字体,其余按顺序回退", + "loading": "正在枚举系统字体…", + "no_matches": "没有匹配的字体" + }, + "keymap": { + "conflict": "{combo} 同时绑定了「{first}」与「{second}」——只有排前面的「{first}」会触发", + "help": "点击行改键 · Backspace 恢复默认 · Esc 取消" + }, + "input": { + "backup_secret": "凭据只写入系统凭据管理器" + }, + "ssh": { + "default_keys": "未指定,将用 IdentityFile 与默认 id_* 私钥", + "address_hint": "支持 user@host,也可粘贴 ssh://host:2222", + "auth_auto_description": "依次尝试可用私钥,失败再询问密码。多数情况选这个就行。", + "auth_interactive_description": "不预存任何凭据,连接时在终端里按提示输入(支持两步验证)。", + "save_password": "保存到 Windows 凭据管理器", + "hide_config_host": "隐藏(config 源不可删除)", + "empty_hint": "「+ 添加主机」或在 ~/.ssh/config 里维护别名", + "shared_data_hint": "与旧壳共用同一份数据;config 别名删除即隐藏,可恢复" + } + }, + "provider": { + "test": { + "success": "连接成功(HTTP {status})", + "invalid_endpoint": "请求地址必须以 http:// 或 https:// 开头", + "missing_model": "请先填写默认模型", + "missing_api_key": "请先保存 API Key", + "credential_read_failed": "无法从凭据管理器读取 API Key", + "invalid_credential_encoding": "凭据管理器中的 API Key 编码无效", + "timeout": "连接超时", + "host_not_found": "找不到服务器", + "connection_failed": "无法连接服务器", + "io_error": "网络错误:{kind}", + "tls_failed": "TLS 握手失败", + "request_failed": "请求失败", + "auth_failed": "鉴权失败(HTTP {status}),请检查 API Key", + "endpoint_not_found": "接口不存在(HTTP {status}),请检查请求地址", + "rate_limited": "服务限流(HTTP {status}),连接可达但当前无法验证额度", + "http_status": "服务返回 HTTP {status}", + "start_failed": "无法启动供应商测试:{error}" + } + } +} diff --git a/nebula_app/src/ai_providers.rs b/nebula_app/src/ai_providers.rs index 319466b..d8d37b2 100644 --- a/nebula_app/src/ai_providers.rs +++ b/nebula_app/src/ai_providers.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use crate::event::{Event, EventType}; +use crate::provider_test::ProviderTestOutcome; const STORE_FILE: &str = "nebula_providers.json"; @@ -221,8 +222,7 @@ pub struct ProviderTestRequest { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderTestResult { pub provider_id: String, - pub ok: bool, - pub message: String, + pub outcome: ProviderTestOutcome, pub elapsed_ms: u64, } @@ -400,10 +400,10 @@ pub fn load_api_key(id: &str) -> io::Result>> { } } -fn test_url(provider: &AiProvider) -> Result { +fn test_url(provider: &AiProvider) -> Result { let base = provider.base_url.trim().trim_end_matches('/'); if !(base.starts_with("https://") || base.starts_with("http://")) { - return Err("请求地址必须以 http:// 或 https:// 开头".to_owned()); + return Err(ProviderTestOutcome::InvalidEndpoint); } if provider.full_url { return Ok(base.to_owned()); @@ -417,16 +417,15 @@ fn test_url(provider: &AiProvider) -> Result { }) } -fn sanitized_error(error: &ureq::Error) -> String { - let message = match error { - ureq::Error::Timeout(_) => "连接超时".to_owned(), - ureq::Error::HostNotFound => "找不到服务器".to_owned(), - ureq::Error::ConnectionFailed => "无法连接服务器".to_owned(), - ureq::Error::Io(err) => format!("网络错误:{}", err.kind()), - ureq::Error::Tls(_) => "TLS 握手失败".to_owned(), - _ => "请求失败".to_owned(), - }; - message.chars().take(180).collect() +fn semantic_error(error: &ureq::Error) -> ProviderTestOutcome { + match error { + ureq::Error::Timeout(_) => ProviderTestOutcome::Timeout, + ureq::Error::HostNotFound => ProviderTestOutcome::HostNotFound, + ureq::Error::ConnectionFailed => ProviderTestOutcome::ConnectionFailed, + ureq::Error::Io(err) => ProviderTestOutcome::Io { kind: err.kind().to_string() }, + ureq::Error::Tls(_) => ProviderTestOutcome::Tls, + _ => ProviderTestOutcome::RequestFailed, + } } /// Blocking provider connectivity test shared by every UI runtime. @@ -436,28 +435,27 @@ fn sanitized_error(error: &ureq::Error) -> String { pub fn test_provider(provider: &AiProvider) -> ProviderTestResult { let started = Instant::now(); let elapsed = || started.elapsed().as_millis().min(u64::MAX as u128) as u64; - let finish = |ok: bool, message: String| ProviderTestResult { + let finish = |outcome: ProviderTestOutcome| ProviderTestResult { provider_id: provider.id.clone(), - ok, - message, + outcome, elapsed_ms: elapsed(), }; let url = match test_url(provider) { Ok(url) => url, - Err(message) => return finish(false, message), + Err(outcome) => return finish(outcome), }; if provider.model.trim().is_empty() { - return finish(false, "请先填写默认模型".to_owned()); + return finish(ProviderTestOutcome::MissingModel); } let key = if provider.kind.requires_api_key() { let secret = match load_api_key(&provider.id) { Ok(Some(secret)) if !secret.is_empty() => secret, - Ok(_) => return finish(false, "请先保存 API Key".to_owned()), - Err(_) => return finish(false, "无法从凭据管理器读取 API Key".to_owned()), + Ok(_) => return finish(ProviderTestOutcome::MissingApiKey), + Err(_) => return finish(ProviderTestOutcome::CredentialReadFailed), }; match String::from_utf8(secret) { Ok(key) => Zeroizing::new(key), - Err(_) => return finish(false, "凭据管理器中的 API Key 编码无效".to_owned()), + Err(_) => return finish(ProviderTestOutcome::InvalidCredentialEncoding), } } else { Zeroizing::new(String::new()) @@ -482,17 +480,17 @@ pub fn test_provider(provider: &AiProvider) -> ProviderTestResult { }; let response = match request.call() { Ok(response) => response, - Err(error) => return finish(false, sanitized_error(&error)), + Err(error) => return finish(semantic_error(&error)), }; let status = response.status().as_u16(); - let (ok, message) = match status { - 200..=299 => (true, format!("连接成功(HTTP {status})")), - 401 | 403 => (false, format!("鉴权失败(HTTP {status}),请检查 API Key")), - 404 => (false, "接口不存在(HTTP 404),请检查请求地址".to_owned()), - 429 => (false, "服务限流(HTTP 429),连接可达但当前无法验证额度".to_owned()), - _ => (false, format!("服务返回 HTTP {status}")), + let outcome = match status { + 200..=299 => ProviderTestOutcome::Success { status }, + 401 | 403 => ProviderTestOutcome::AuthFailed { status }, + 404 => ProviderTestOutcome::EndpointNotFound { status }, + 429 => ProviderTestOutcome::RateLimited { status }, + _ => ProviderTestOutcome::HttpStatus { status }, }; - finish(ok, message) + finish(outcome) } pub fn spawn_test( @@ -508,8 +506,7 @@ pub fn spawn_test( EventType::ProviderTestDone { request_id: request.request_id, provider_id: result.provider_id, - ok: result.ok, - message: result.message, + outcome: result.outcome, elapsed_ms: result.elapsed_ms, }, window_id, @@ -559,8 +556,7 @@ mod tests { provider.base_url = "not-a-url".into(); let result = test_provider(&provider); assert_eq!(result.provider_id, "broken"); - assert!(!result.ok); - assert!(result.message.contains("http://")); + assert_eq!(result.outcome, ProviderTestOutcome::InvalidEndpoint); } #[test] diff --git a/nebula_app/src/display/i18n.rs b/nebula_app/src/display/i18n.rs index f8cf796..63a5a7f 100644 --- a/nebula_app/src/display/i18n.rs +++ b/nebula_app/src/display/i18n.rs @@ -1,4 +1,69 @@ +use std::collections::HashMap; +use std::sync::OnceLock; + use crate::config::template::{self, TemplateLanguage}; +use crate::provider_test::ProviderTestOutcome; +use crate::proxy_test::{ProxyTestFailure, ProxyTestOutcome, ProxyTestRoute}; + +const EN_US_JSON: &str = include_str!("../../i18n/en-US.json"); +const ZH_CN_JSON: &str = include_str!("../../i18n/zh-CN.json"); + +#[derive(Debug)] +struct TranslationCatalogs { + en_us: HashMap, + zh_cn: HashMap, +} + +static TRANSLATIONS: OnceLock = OnceLock::new(); + +fn parse_catalog(source: &str, locale: &str) -> HashMap { + let root: serde_json::Value = serde_json::from_str(source).unwrap_or_else(|error| { + panic!("embedded {locale} translation catalog is invalid: {error}") + }); + let mut messages = HashMap::new(); + flatten_catalog(&root, "", &mut messages, locale); + messages +} + +fn flatten_catalog( + value: &serde_json::Value, + prefix: &str, + output: &mut HashMap, + locale: &str, +) { + match value { + serde_json::Value::Object(object) => { + for (key, value) in object { + let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") }; + flatten_catalog(value, &path, output, locale); + } + }, + serde_json::Value::String(message) => { + assert!(!prefix.is_empty(), "embedded {locale} catalog contains an empty message id"); + assert!( + output.insert(prefix.to_owned(), message.clone()).is_none(), + "embedded {locale} catalog contains duplicate message id {prefix}" + ); + }, + _ => panic!("embedded {locale} catalog leaf {prefix} must be a string"), + } +} + +fn catalogs() -> &'static TranslationCatalogs { + TRANSLATIONS.get_or_init(|| { + let en_us = parse_catalog(EN_US_JSON, "en-US"); + let zh_cn = parse_catalog(ZH_CN_JSON, "zh-CN"); + let mut en_keys = en_us.keys().collect::>(); + let mut zh_keys = zh_cn.keys().collect::>(); + en_keys.sort_unstable(); + zh_keys.sort_unstable(); + assert_eq!( + en_keys, zh_keys, + "embedded en-US and zh-CN catalogs must have identical message ids" + ); + TranslationCatalogs { en_us, zh_cn } + }) +} /// Persisted UI language choice. The serialized values are stable because the /// runtime settings file is also a supported hand-editing surface. @@ -51,17 +116,179 @@ pub enum UiLanguage { } 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::{LanguagePreference, UiLanguage}; + use super::{EN_US_JSON, LanguagePreference, UiLanguage, ZH_CN_JSON, parse_catalog}; #[test] fn persisted_language_values_are_stable() { @@ -81,4 +308,104 @@ mod tests { assert_eq!(LanguagePreference::ZhCn.resolved(), UiLanguage::ZhCn); assert_eq!(LanguagePreference::EnUs.resolved(), UiLanguage::EnUs); } + + #[test] + fn embedded_catalogs_have_identical_string_keys() { + let en_us = parse_catalog(EN_US_JSON, "en-US"); + let zh_cn = parse_catalog(ZH_CN_JSON, "zh-CN"); + let mut en_keys = en_us.keys().collect::>(); + let mut zh_keys = zh_cn.keys().collect::>(); + en_keys.sort_unstable(); + zh_keys.sort_unstable(); + assert!(!en_keys.is_empty()); + assert_eq!(en_keys, zh_keys); + } + + #[test] + fn lookup_uses_locale_then_english_then_key() { + assert_eq!(UiLanguage::ZhCn.tr("settings.sidebar.network"), "网络"); + assert_eq!(UiLanguage::EnUs.tr("settings.sidebar.network"), "Network"); + assert_eq!(UiLanguage::EnUs.tr("missing.message.id"), "missing.message.id"); + } + + #[test] + fn named_arguments_are_substituted() { + assert_eq!( + UiLanguage::EnUs.tr_args( + "settings.network.status.success", + &[("route", "Direct"), ("elapsed_ms", "12")], + ), + "Network connection succeeded via Direct in 12 ms" + ); + } + + #[test] + fn provider_outcomes_are_localized_at_the_display_boundary() { + let outcome = crate::provider_test::ProviderTestOutcome::AuthFailed { status: 403 }; + assert_eq!( + UiLanguage::EnUs.provider_test_message(&outcome), + "Authentication failed (HTTP 403); check the API key" + ); + assert_eq!( + UiLanguage::ZhCn.provider_test_message(&outcome), + "鉴权失败(HTTP 403),请检查 API Key" + ); + } + + #[test] + fn proxy_routes_are_data_until_the_display_boundary() { + let outcome = + crate::proxy_test::ProxyTestOutcome::Success(crate::proxy_test::ProxyTestRoute::Direct); + assert_eq!( + UiLanguage::EnUs.proxy_test_message(&outcome, 12), + "Network connection succeeded via Direct connection in 12 ms" + ); + assert_eq!( + UiLanguage::ZhCn.proxy_test_message(&outcome, 12), + "网络连接正常 · 直接连接 · 12 ms" + ); + } + + #[test] + #[ignore = "manual i18n microbenchmark"] + fn measure_embedded_catalog_costs() { + use std::hint::black_box; + use std::time::Instant; + + let init_started = Instant::now(); + let en_us = parse_catalog(EN_US_JSON, "en-US"); + let zh_cn = parse_catalog(ZH_CN_JSON, "zh-CN"); + let mut en_keys = en_us.keys().collect::>(); + let mut zh_keys = zh_cn.keys().collect::>(); + en_keys.sort_unstable(); + zh_keys.sort_unstable(); + assert_eq!(en_keys, zh_keys); + let init_elapsed = init_started.elapsed(); + + let lookup_iterations = 2_000_000u128; + let lookup_started = Instant::now(); + for _ in 0..lookup_iterations { + black_box(UiLanguage::EnUs.tr("settings.sidebar.network")); + } + let lookup_ns = lookup_started.elapsed().as_nanos() / lookup_iterations; + + let template_iterations = 200_000u128; + let template_started = Instant::now(); + for _ in 0..template_iterations { + black_box(UiLanguage::EnUs.tr_args( + "provider.test.success", + &[("status", "200")], + )); + } + let template_ns = template_started.elapsed().as_nanos() / template_iterations; + + eprintln!( + "i18n resources={} bytes, messages={}, init={} us, lookup={} ns, template={} ns", + EN_US_JSON.len() + ZH_CN_JSON.len(), + en_us.len(), + init_elapsed.as_micros(), + lookup_ns, + template_ns, + ); + } } diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 7b2fa47..dad3bbd 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -5816,8 +5816,7 @@ impl Display { &mut self, request_id: u64, provider_id: &str, - ok: bool, - message: &str, + outcome: &crate::provider_test::ProviderTestOutcome, elapsed_ms: u64, ) { if request_id != self.nebula_provider_test_seq @@ -5825,7 +5824,10 @@ impl Display { { return; } - self.nebula_provider_status = Some((format!("{message} · {elapsed_ms} ms"), !ok)); + self.nebula_provider_status = Some(( + format!("{} · {elapsed_ms} ms", self.ui_language().provider_test_message(outcome)), + !outcome.is_success(), + )); self.pending_update.dirty = true; } @@ -5931,8 +5933,7 @@ impl Display { pub(crate) fn proxy_test_done( &mut self, request_id: u64, - ok: bool, - message: &str, + outcome: crate::proxy_test::ProxyTestOutcome, elapsed_ms: u64, ) { if request_id != self.nebula_proxy_test_seq @@ -5940,11 +5941,7 @@ impl Display { { return; } - self.nebula_proxy_test_status = if ok { - settings::ProxyTestStatus::Success { elapsed_ms, route: message.to_owned() } - } else { - settings::ProxyTestStatus::Failed { message: message.to_owned() } - }; + self.nebula_proxy_test_status = settings::ProxyTestStatus::Complete { outcome, elapsed_ms }; self.pending_update.dirty = true; self.window.request_redraw(); } diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index 4fe07f6..8850761 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -233,12 +233,9 @@ pub(crate) enum ProxyTestStatus { #[default] Idle, Running, - Success { + Complete { + outcome: crate::proxy_test::ProxyTestOutcome, elapsed_ms: u64, - route: String, - }, - Failed { - message: String, }, } @@ -7227,16 +7224,13 @@ pub(super) fn draw_text( .to_owned(), sk.accent, ), - ProxyTestStatus::Success { elapsed_ms, route } => ( - format!( - "{} · {route} · {elapsed_ms} ms", - language.pick("网络连接正常", "Network connection succeeded"), - ), - Rgb::new(sk.ok.r, sk.ok.g, sk.ok.b), - ), - ProxyTestStatus::Failed { message } => ( - format!("{}:{message}", language.pick("测试失败", "Test failed")), - Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b), + ProxyTestStatus::Complete { outcome, elapsed_ms } => ( + language.proxy_test_message(outcome, *elapsed_ms), + if outcome.is_success() { + Rgb::new(sk.ok.r, sk.ok.g, sk.ok.b) + } else { + Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b) + }, ), }; let available = (button.0 - banner.0 - s(28.0)).max(cell_w); diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index 8a3d634..9569f3b 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -510,10 +510,7 @@ impl Processor { window.mark_session_dirty(); } } - if self.windows.is_empty() - && self.detached.is_empty() - && !self.cli_options.daemon - { + if self.windows.is_empty() && self.detached.is_empty() && !self.cli_options.daemon { event_loop.exit(); } } @@ -802,13 +799,8 @@ impl Processor { event_loop: &ActiveEventLoop, dispatch: &std::sync::Arc, ) { - if let RuntimeCommand::Exec { - window_id, - pane_id, - argv, - timeout_ms, - max_output_bytes, - } = &dispatch.command + if let RuntimeCommand::Exec { window_id, pane_id, argv, timeout_ms, max_output_bytes } = + &dispatch.command { let prepared = self.runtime_target_window(*window_id, Some(*pane_id)).and_then(|id| { self.windows @@ -1207,23 +1199,22 @@ impl ApplicationHandler for Processor { window_context.display.window.request_redraw(); } }, - (EventType::ProxyTestDone { request_id, ok, message, elapsed_ms }, Some(window_id)) => { + (EventType::ProxyTestDone { request_id, outcome, elapsed_ms }, Some(window_id)) => { if let Some(window_context) = self.windows.get_mut(window_id) { - window_context.display.proxy_test_done(request_id, ok, &message, elapsed_ms); + window_context.display.proxy_test_done(request_id, outcome, elapsed_ms); window_context.dirty = true; window_context.display.window.request_redraw(); } }, ( - EventType::ProviderTestDone { request_id, provider_id, ok, message, elapsed_ms }, + EventType::ProviderTestDone { request_id, provider_id, outcome, elapsed_ms }, Some(window_id), ) => { if let Some(window_context) = self.windows.get_mut(window_id) { window_context.display.provider_test_done( request_id, &provider_id, - ok, - &message, + &outcome, elapsed_ms, ); window_context.dirty = true; @@ -2121,7 +2112,13 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon self.event_proxy.clone(), self.display.window.id(), ) { - self.display.proxy_test_done(request_id, false, &format!("无法启动网络测试:{err}"), 0); + self.display.proxy_test_done( + request_id, + crate::proxy_test::ProxyTestOutcome::Failed( + crate::proxy_test::ProxyTestFailure::Start(err.to_string()), + ), + 0, + ); } } @@ -2137,8 +2134,7 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon self.display.provider_test_done( request_id, &provider_id, - false, - &format!("无法启动供应商测试:{err}"), + &crate::provider_test::ProviderTestOutcome::StartFailed { error: err.to_string() }, 0, ); } @@ -3323,9 +3319,7 @@ impl input::Processor> { // pane 场景里最后一次更新赢,读数不完美但不会互相打断。 TerminalEvent::Progress { state, value } => { let progress = crate::taskbar::TaskProgress::from_osc(state, value); - if let Some(hwnd) = - self.ctx.display.window.native_window_handle_id() - { + if let Some(hwnd) = self.ctx.display.window.native_window_handle_id() { crate::taskbar::apply(hwnd as isize, progress); } }, diff --git a/nebula_app/src/event/types.rs b/nebula_app/src/event/types.rs index fec085a..394b0ef 100644 --- a/nebula_app/src/event/types.rs +++ b/nebula_app/src/event/types.rs @@ -97,8 +97,7 @@ pub enum EventType { /// 设置→网络的真实出网测试完成。`request_id` 用于丢弃设置变化前的旧结果。 ProxyTestDone { request_id: u64, - ok: bool, - message: String, + outcome: crate::proxy_test::ProxyTestOutcome, elapsed_ms: u64, }, /// 设置→供应商的后台连通性测试结果。provider_id 与 request_id @@ -106,8 +105,7 @@ pub enum EventType { ProviderTestDone { request_id: u64, provider_id: String, - ok: bool, - message: String, + outcome: crate::provider_test::ProviderTestOutcome, elapsed_ms: u64, }, NebulaTick, diff --git a/nebula_app/src/gpui_shell/background_color.rs b/nebula_app/src/gpui_shell/background_color.rs index b51f7d9..293445d 100644 --- a/nebula_app/src/gpui_shell/background_color.rs +++ b/nebula_app/src/gpui_shell/background_color.rs @@ -207,11 +207,15 @@ impl SettingsPane { /// 闭态 220×32 combobox + 开态右缘对齐浮层(行下 +6px)。 pub(super) fn background_color_row(&mut self, cx: &mut Context) -> impl IntoElement { + let language = crate::gpui_shell::config::ui_language(cx); let custom = self.runtime.background.is_some(); let rgb = self.effective_background_rgb(cx); let hex = format_hex_rgb(rgb); - let label: SharedString = - if custom { hex.clone().into() } else { format!("主题默认 {hex}").into() }; + let label: SharedString = if custom { + hex.clone().into() + } else { + format!("{} {hex}", language.pick("主题默认", "Theme default")).into() + }; let open = self.bg_picker_open; let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin(); let accent = super::rgb_hsla(sk.accent.r, sk.accent.g, sk.accent.b); @@ -285,8 +289,8 @@ impl SettingsPane { let panel = if open { Some(self.background_picker_panel(cx)) } else { None }; let trigger_bounds = self.bg_picker_trigger_bounds; let row = self.row_with_reset( - "背景色", - "覆盖主题自带的终端底色。改了它,OSC 11 上报给程序的背景色也跟着变——TUI 判断明暗模式靠的就是这个值。", + language.pick("背景色", "Background color"), + language.tr("settings.appearance.background_color_description"), custom, |this, window, cx| { this.persist(&[("background", String::new())], cx); diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index 425c9df..c531334 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -18,11 +18,12 @@ use std::path::{Path, PathBuf}; -use gpui::Global; +use gpui::{App, Global}; use nebula_settings::{CursorShapeName, RuntimeSettings}; use nebula_terminal::vte::ansi::CursorShape; use serde::Deserialize; +use crate::display::{LanguagePreference, UiLanguage}; use crate::gpui_shell::terminal::colors::Palette; /// GPUI 对齐 legacy 的产品默认:配置未写 `cursor_blink` 时光标闪烁。 @@ -38,6 +39,8 @@ pub(crate) const fn effective_cursor_blink(configured: Option) -> bool { /// 应用启动时装载一次的全局设置。 pub struct Settings { + /// 已解析的界面语言。GPUI 组件只读这个内存全局,渲染路径不得重复读盘。 + pub ui_language: UiLanguage, pub font_family: String, pub font_bold_family: String, pub font_italic_family: String, @@ -80,22 +83,42 @@ pub struct Settings { impl Global for Settings {} +/// GPUI 壳唯一的界面语言入口。应用初始化会先注册 [`Settings`];测试或极早期 +/// 回调若尚未注册则回退英文,不能为取语言把磁盘 I/O 带进渲染路径。 +pub(crate) fn ui_language(cx: &App) -> UiLanguage { + cx.try_global::().map(|settings| settings.ui_language).unwrap_or(UiLanguage::EnUs) +} + +#[inline] +fn resolve_ui_language(preference: nebula_settings::LanguagePref) -> UiLanguage { + LanguagePreference::parse(preference.settings_value()).unwrap_or_default().resolved() +} + impl Settings { /// `theme`:**生效**主题(follow_system 折算后,见 /// `theme::effective_theme_name`)。不在这里自行读 RuntimeSettings 的 /// 原始主题,否则 chrome 层与终端 palette 会在跟随系统时分家。 pub fn load(theme: nebula_settings::ThemeName) -> Self { let runtime = RuntimeSettings::load(); + let ui_language = resolve_ui_language(runtime.language); let path = find_config_file(); let mut load_notice = None; let raw = path .as_deref() - .map(|p| load_merged_toml(p, &mut load_notice)) + .map(|p| load_merged_toml(p, &mut load_notice, ui_language)) .unwrap_or_else(|| toml::Value::Table(Default::default())); let raw: RawConfig = match raw.try_into() { Ok(config) => config, Err(err) => { - load_notice.get_or_insert_with(|| format!("nebula.toml 字段解析失败:{err}")); + load_notice.get_or_insert_with(|| { + format!( + "{}: {err}", + ui_language.pick( + "nebula.toml 字段解析失败", + "Failed to parse nebula.toml fields", + ) + ) + }); RawConfig::default() }, }; @@ -127,6 +150,7 @@ impl Settings { } Settings { + ui_language, font_bold_family: secondary(&raw.font.bold), font_italic_family: secondary(&raw.font.italic), font_bold_italic_family: secondary(&raw.font.bold_italic), @@ -316,8 +340,8 @@ fn find_config_file() -> Option { /// 读取主文件并按主应用语义合并 imports:imports 先加载,主文件最后覆盖。 /// 途中吞掉的第一个错误经 `notice` 上浮(宽容解析 + 有去处的错误)。 -fn load_merged_toml(path: &Path, notice: &mut Option) -> toml::Value { - let main = read_toml(path, notice); +fn load_merged_toml(path: &Path, notice: &mut Option, language: UiLanguage) -> toml::Value { + let main = read_toml(path, notice, language); let imports: Vec = [main.get("general").and_then(|g| g.get("import")), main.get("import")] @@ -332,7 +356,7 @@ fn load_merged_toml(path: &Path, notice: &mut Option) -> toml::Value { for import in imports { let import_path = resolve_import_path(&import, path); if import_path.exists() { - merged = merge_values(merged, read_toml(&import_path, notice)); + merged = merge_values(merged, read_toml(&import_path, notice, language)); } else { super::try_write_stderr(format_args!( "[nebula:gpui] config import not found: {}", @@ -343,7 +367,7 @@ fn load_merged_toml(path: &Path, notice: &mut Option) -> toml::Value { merge_values(merged, main) } -fn read_toml(path: &Path, notice: &mut Option) -> toml::Value { +fn read_toml(path: &Path, notice: &mut Option, language: UiLanguage) -> toml::Value { let text = match std::fs::read_to_string(path) { Ok(text) => text, Err(err) => { @@ -351,7 +375,13 @@ fn read_toml(path: &Path, notice: &mut Option) -> toml::Value { "[nebula:gpui] failed to read config {}: {err}", path.display() )); - notice.get_or_insert_with(|| format!("无法读取配置 {}: {err}", path.display())); + notice.get_or_insert_with(|| { + format!( + "{} {}: {err}", + language.pick("无法读取配置", "Failed to read configuration"), + path.display() + ) + }); return toml::Value::Table(Default::default()); }, }; @@ -363,8 +393,19 @@ fn read_toml(path: &Path, notice: &mut Option) -> toml::Value { "[nebula:gpui] failed to parse config {}: {err}", path.display() )); - let first_line = err.to_string().lines().next().unwrap_or("解析失败").to_owned(); - notice.get_or_insert_with(|| format!("配置 {} 解析失败:{first_line}", path.display())); + let first_line = err + .to_string() + .lines() + .next() + .unwrap_or(language.pick("解析失败", "Parse failed")) + .to_owned(); + notice.get_or_insert_with(|| { + format!( + "{} {}: {first_line}", + language.pick("配置解析失败", "Failed to parse configuration"), + path.display() + ) + }); toml::Value::Table(Default::default()) }, } @@ -587,9 +628,16 @@ fn build_palette(raw: &RawColors) -> Palette { #[cfg(test)] mod tests { - use super::{Settings, apply_theme, rgba8, runtime_background}; + use super::{Settings, apply_theme, resolve_ui_language, rgba8, runtime_background}; + use crate::display::UiLanguage; use crate::gpui_shell::terminal::colors::Palette; - use nebula_settings::ThemeName; + use nebula_settings::{LanguagePref, ThemeName}; + + #[test] + fn explicit_runtime_languages_resolve_without_reading_system_locale() { + assert_eq!(resolve_ui_language(LanguagePref::ZhCn), UiLanguage::ZhCn); + assert_eq!(resolve_ui_language(LanguagePref::EnUs), UiLanguage::EnUs); + } #[test] fn system_theme_owns_terminal_background_while_following_system() { diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index 5d3a7c7..ec693e4 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -159,6 +159,7 @@ fn init(cx: &mut App) { // 用户 nebula.toml + nebula_settings.txt,启动读一次;失败回退默认, // 具体错误由 config 的 notice 上浮,并在可用时写入 stderr。 let settings = config::Settings::load(theme::effective_theme_name(cx)); + gpui_component::set_locale(settings.ui_language.gpui_component_locale()); cx.set_global(settings); } diff --git a/nebula_app/src/gpui_shell/network_settings.rs b/nebula_app/src/gpui_shell/network_settings.rs index 7ad1077..b865301 100644 --- a/nebula_app/src/gpui_shell/network_settings.rs +++ b/nebula_app/src/gpui_shell/network_settings.rs @@ -41,18 +41,13 @@ pub(super) fn apply_proxy_test_result( seq: u64, status: &ProxyTestStatus, request_id: u64, - ok: bool, - message: &str, + outcome: crate::proxy_test::ProxyTestOutcome, elapsed_ms: u64, ) -> Option { if request_id != seq || !matches!(status, ProxyTestStatus::Running) { return None; } - Some(if ok { - ProxyTestStatus::Success { elapsed_ms, route: message.to_owned() } - } else { - ProxyTestStatus::Failed { message: message.to_owned() } - }) + Some(ProxyTestStatus::Complete { outcome, elapsed_ms }) } impl SettingsPane { @@ -96,8 +91,11 @@ impl SettingsPane { let receiver = match crate::ssh_session::start_proxy_test(request_id) { Ok(receiver) => receiver, Err(error) => { - self.proxy_test_status = ProxyTestStatus::Failed { - message: format!("无法启动网络测试:{error}"), + self.proxy_test_status = ProxyTestStatus::Complete { + outcome: crate::proxy_test::ProxyTestOutcome::Failed( + crate::proxy_test::ProxyTestFailure::Start(error.to_string()), + ), + elapsed_ms: 0, }; cx.notify(); return; @@ -107,14 +105,20 @@ impl SettingsPane { let result = receiver.await; let _ = this.update(cx, |pane, cx| { match result { - Ok(result) => pane.finish_proxy_test( - result.request_id, - result.ok, - &result.message, - result.elapsed_ms, - ), + Ok(result) => { + pane.finish_proxy_test(result.request_id, result.outcome, result.elapsed_ms) + }, Err(_) => { - pane.finish_proxy_test(request_id, false, "网络测试任务意外结束,请重试", 0) + if request_id == pane.proxy_test_seq + && matches!(pane.proxy_test_status, ProxyTestStatus::Running) + { + pane.proxy_test_status = ProxyTestStatus::Complete { + outcome: crate::proxy_test::ProxyTestOutcome::Failed( + crate::proxy_test::ProxyTestFailure::UnexpectedEnd, + ), + elapsed_ms: 0, + }; + } }, } cx.notify(); @@ -124,13 +128,17 @@ impl SettingsPane { cx.notify(); } - fn finish_proxy_test(&mut self, request_id: u64, ok: bool, message: &str, elapsed_ms: u64) { + fn finish_proxy_test( + &mut self, + request_id: u64, + outcome: crate::proxy_test::ProxyTestOutcome, + elapsed_ms: u64, + ) { if let Some(status) = apply_proxy_test_result( self.proxy_test_seq, &self.proxy_test_status, request_id, - ok, - message, + outcome, elapsed_ms, ) { self.proxy_test_status = status; @@ -145,7 +153,8 @@ impl SettingsPane { pub(super) fn section_network(&mut self, cx: &mut Context) -> gpui::Div { let custom = shows_manual_proxy_address(self.runtime.ssh_proxy_mode); - self.group("网络代理", cx) + let language = crate::gpui_shell::config::ui_language(cx); + self.group(language.tr("settings.network.title"), cx) .child(self.proxy_test_banner(cx)) .child(div().h(px(PROXY_TEST_GAP)).w_full().flex_shrink_0()) .child(self.proxy_mode_row(cx)) @@ -153,24 +162,27 @@ impl SettingsPane { } fn proxy_test_banner(&self, cx: &mut Context) -> gpui::Div { + let language = crate::gpui_shell::config::ui_language(cx); let theme = cx.theme(); let running = matches!(self.proxy_test_status, ProxyTestStatus::Running); let (status, status_color) = match &self.proxy_test_status { - ProxyTestStatus::Idle => { - (SharedString::from("测试当前设置是否可以访问网络"), theme.muted_foreground) - }, - ProxyTestStatus::Running => { - (SharedString::from("正在通过当前设置测试网络…"), theme.link) - }, - ProxyTestStatus::Success { elapsed_ms, route } => ( - SharedString::from(format!("网络连接正常 · {route} · {elapsed_ms} ms")), - theme.success, + ProxyTestStatus::Idle => ( + SharedString::from(language.tr("settings.network.status.idle")), + theme.muted_foreground, ), - ProxyTestStatus::Failed { message } => { - (SharedString::from(format!("测试失败:{message}")), theme.danger) + ProxyTestStatus::Running => { + (SharedString::from(language.tr("settings.network.status.running")), theme.link) }, + ProxyTestStatus::Complete { outcome, elapsed_ms } => ( + SharedString::from(language.proxy_test_message(outcome, *elapsed_ms)), + if outcome.is_success() { theme.success } else { theme.danger }, + ), + }; + let caption = if running { + language.tr("settings.network.action.testing") + } else { + language.tr("settings.network.action.test") }; - let caption = if running { "测试中…" } else { "测试网络" }; h_flex() .w_full() .h(px(PROXY_TEST_BANNER_H)) @@ -200,9 +212,10 @@ impl SettingsPane { } fn proxy_mode_row(&self, cx: &Context) -> impl IntoElement { + let language = crate::gpui_shell::config::ui_language(cx); let select = self.select_of("ssh_proxy_mode"); self.row( - "代理方式", + language.tr("settings.network.mode.label"), "", div() .w(px(PROXY_MODE_SELECT_W)) @@ -213,8 +226,9 @@ impl SettingsPane { } fn proxy_address_row(&self, cx: &Context) -> impl IntoElement { + let language = crate::gpui_shell::config::ui_language(cx); self.row( - "代理地址", + language.tr("settings.network.address.label"), "", h_flex() .flex_1() @@ -239,6 +253,7 @@ impl SettingsPane { mod tests { use super::{apply_proxy_test_result, shows_manual_proxy_address}; use crate::display::ProxyTestStatus; + use crate::proxy_test::{ProxyTestFailure, ProxyTestOutcome, ProxyTestRoute}; use nebula_settings::ProxyModeName; #[test] @@ -252,18 +267,51 @@ mod tests { fn stale_proxy_test_results_are_discarded_after_invalidate() { let seq = 7; let running = ProxyTestStatus::Running; - assert_eq!(apply_proxy_test_result(seq, &running, 6, true, "直接连接", 12), None); assert_eq!( - apply_proxy_test_result(seq, &ProxyTestStatus::Idle, seq, true, "直接连接", 12), + apply_proxy_test_result( + seq, + &running, + 6, + ProxyTestOutcome::Success(ProxyTestRoute::Direct), + 12, + ), None ); assert_eq!( - apply_proxy_test_result(seq, &running, seq, true, "直接连接", 41), - Some(ProxyTestStatus::Success { elapsed_ms: 41, route: "直接连接".into() }) + apply_proxy_test_result( + seq, + &ProxyTestStatus::Idle, + seq, + ProxyTestOutcome::Success(ProxyTestRoute::Direct), + 12, + ), + None ); assert_eq!( - apply_proxy_test_result(seq, &running, seq, false, "连接被拒绝", 8), - Some(ProxyTestStatus::Failed { message: "连接被拒绝".into() }) + apply_proxy_test_result( + seq, + &running, + seq, + ProxyTestOutcome::Success(ProxyTestRoute::Direct), + 41, + ), + Some(ProxyTestStatus::Complete { + outcome: ProxyTestOutcome::Success(ProxyTestRoute::Direct), + elapsed_ms: 41 + }) + ); + assert_eq!( + apply_proxy_test_result( + seq, + &running, + seq, + ProxyTestOutcome::Failed(ProxyTestFailure::Direct("refused".into())), + 8, + ), + Some(ProxyTestStatus::Complete { + outcome: ProxyTestOutcome::Failed(ProxyTestFailure::Direct("refused".into())), + elapsed_ms: 8 + }) ); } } diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 90c0d48..9741f70 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -60,15 +60,41 @@ const BUG_REPORT_TEMPLATE: &str = "bug_report.yml"; /// 左侧分区的稳定路由表。2026-08-28 产品裁定:默认 GPUI 导航收敛为常用项, /// 暂时隐藏“AI 供应商”和“备份”;页面实现与索引继续保留。后续恢复入口时只改 /// [`HIDDEN_NAV_SECTIONS`],不得删除或重排这里的条目。 -const SECTIONS: [&str; 10] = - ["应用", "外观", "配置文件", "AI 供应商", "SSH", "网络", "交互", "按键映射", "高级", "备份"]; +const SECTION_IDS: [&str; 10] = [ + "application", + "appearance", + "profiles", + "providers", + "ssh", + "network", + "interaction", + "keymap", + "advanced", + "backup", +]; const HIDDEN_NAV_SECTIONS: &[usize] = &[3, 9]; -/// 保留原来的分组展开顺序,组名不再渲染;数组里仍保存稳定的 [`SECTIONS`] +/// 保留原来的分组展开顺序,组名不再渲染;数组里仍保存稳定的 [`SECTION_IDS`] /// 下标,不复制设置状态或路由。 const NAV_GROUPS: [(&str, &[usize]); 3] = - [("工作区", &[0, 1, 2, 6, 7]), ("连接与智能", &[3, 4, 5]), ("系统", &[8, 9])]; + [("workspace", &[0, 1, 2, 6, 7]), ("connections", &[3, 4, 5]), ("system", &[8, 9])]; + +fn section_label(index: usize, language: crate::display::UiLanguage) -> &'static str { + match SECTION_IDS.get(index).copied() { + Some("application") => language.tr("settings.sidebar.application"), + Some("appearance") => language.tr("settings.sidebar.appearance"), + Some("profiles") => language.tr("settings.sidebar.profiles"), + Some("providers") => language.tr("settings.sidebar.providers"), + Some("ssh") => language.tr("settings.sidebar.ssh"), + Some("network") => language.tr("settings.sidebar.network"), + Some("interaction") => language.tr("settings.sidebar.interaction"), + Some("keymap") => language.tr("settings.sidebar.keymap"), + Some("advanced") => language.tr("settings.sidebar.advanced"), + Some("backup") => language.tr("settings.sidebar.backup"), + _ => "", + } +} fn is_nav_section_visible(index: usize) -> bool { !HIDDEN_NAV_SECTIONS.contains(&index) @@ -222,6 +248,124 @@ pub enum SettingsPaneEvent { pub(super) type SharedSelect = Entity>>; type SharedShellSelect = Entity>>; +/// 固定下拉只在这里把稳定 value 映射为显示文案。创建和语言切换刷新共用 +/// 同一入口,避免 `SelectState` 留着构造时的旧语言。 +fn localized_select_labels( + key: &str, + values: &[&'static str], + language: crate::display::UiLanguage, +) -> Vec { + let labels: Vec<&'static str> = match key { + "language" => vec![ + language.tr("language.system"), + language.tr("language.zh_cn"), + language.tr("language.en_us"), + ], + "cursor_shape" => vec![ + language.pick("条形(│)", "Bar (│)"), + language.pick("下划线(_)", "Underscore (_)"), + language.pick("实心框(█)", "Filled box (█)"), + language.pick("空心框(□)", "Empty box (□)"), + ], + "tabs_position" => { + vec![language.pick("左侧边栏", "Left sidebar"), language.pick("顶部", "Top")] + }, + "tab_reveal" => vec![language.pick("滑动", "Slide"), language.pick("立即", "Instant")], + "density" => vec![language.pick("标准", "Standard"), language.pick("紧凑", "Compact")], + "new_tab_position" => vec![ + language.pick("当前标签之后", "After current tab"), + language.pick("列表末尾", "End of list"), + ], + "windowing_behavior" => vec![ + language.pick("创建新窗口", "Create a new window"), + language.pick("附加到最近使用的窗口", "Attach to the most recent window"), + language.pick( + "附加到此桌面最近使用的窗口", + "Attach to the most recent window on this desktop", + ), + ], + "vcs_display" => vec![ + language.pick("自动检测", "Auto detect"), + language.pick("仅 Git", "Git only"), + language.pick("仅 SVN", "SVN only"), + ], + "cell_width_mode" => { + vec![language.pick("紧凑", "Compact"), language.pick("宽松", "Relaxed")] + }, + "bell" => vec![ + language.pick("关", "Off"), + language.pick("闪烁", "Visual"), + language.pick("声音", "Sound"), + language.pick("闪烁 + 声音", "Visual + sound"), + ], + "blur" => vec![ + language.pick("无", "None"), + language.pick("Mica(低开销)", "Mica (low cost)"), + language.pick("Mica Alt(低开销)", "Mica Alt (low cost)"), + language.pick("Aero(玻璃)", "Aero (glass)"), + language.pick("Acrylic(高开销)", "Acrylic (high cost)"), + ], + "accept" => vec![ + language.pick("右方向键", "Right arrow"), + "Tab", + language.pick("Tab 或右方向键", "Tab or Right arrow"), + ], + "completion_style" => { + vec![language.pick("行内灰字", "Inline ghost"), language.pick("弹窗列表", "Popup list")] + }, + "background_image_fit" => vec![ + language.pick("拉伸", "Fill"), + language.pick("适应", "Uniform"), + language.pick("填充", "Uniform to fill"), + language.pick("原始尺寸", "Original size"), + ], + "background_image_alignment" => vec![ + language.pick("左上", "Top left"), + language.pick("顶部", "Top"), + language.pick("右上", "Top right"), + language.pick("左侧", "Left"), + language.pick("居中", "Center"), + language.pick("右侧", "Right"), + language.pick("左下", "Bottom left"), + language.pick("底部", "Bottom"), + language.pick("右下", "Bottom right"), + ], + "ssh_proxy_mode" => vec![ + language.tr("settings.network.mode.off"), + language.tr("settings.network.mode.system"), + language.tr("settings.network.mode.custom"), + ], + _ => values.to_vec(), + }; + debug_assert_eq!(labels.len(), values.len(), "localized select label/value mismatch: {key}"); + labels.into_iter().map(SharedString::from).collect() +} + +fn provider_input_placeholders(language: crate::display::UiLanguage) -> [&'static str; 5] { + [ + language.pick("供应商名称", "Provider name"), + language.pick("备注(可包含空格)", "Note (spaces allowed)"), + language.pick("官方网站", "Official website"), + language.pick("API 请求地址", "API endpoint"), + language.pick("默认模型", "Default model"), + ] +} + +fn localized_input_placeholder(key: &str, language: crate::display::UiLanguage) -> &'static str { + match key { + "ssh_label" => language.pick("给这台机器起个名字", "Name this host"), + "ssh_password" => language.pick("留空则连接时询问", "Leave empty to ask when connecting"), + "ssh_icon_filter" => language.pick("搜索图标…", "Search icons..."), + "font_family" => language.pick("输入字体名称", "Enter a font family"), + "backup_password" => { + language.pick("备份密码(至少 8 位)", "Backup password (at least 8 characters)") + }, + "backup_secret" => language.tr("settings.input.backup_secret"), + "keymap_search" => language.pick("搜索动作或按键…", "Search actions or keys..."), + _ => "", + } +} + #[derive(Clone)] struct ShellSelectItem { id: String, @@ -247,7 +391,321 @@ enum AboutUpdateState { Failed(String), } -use super::ssh_settings::{SshDeleteUndo, SshEditorState}; +#[derive(Clone, Debug)] +enum ProviderStatus { + Saved, + Added, + AtLeastOneRequired, + Deleted, + ApiKeySaved, + Testing, + TestResult { outcome: crate::provider_test::ProviderTestOutcome, elapsed_ms: u64 }, + CodexConfirmation, + AppliedToCodex(std::path::PathBuf), + Error(String), +} + +impl ProviderStatus { + fn is_error(&self) -> bool { + match self { + Self::AtLeastOneRequired | Self::Error(_) => true, + Self::TestResult { outcome, .. } => !outcome.is_success(), + _ => false, + } + } + + fn text(&self, language: crate::display::UiLanguage) -> String { + match self { + Self::Saved => language.pick("供应商配置已保存", "Provider settings saved").into(), + Self::Added => language.pick("已添加自定义供应商", "Custom provider added").into(), + Self::AtLeastOneRequired => { + language.pick("至少保留一个供应商", "Keep at least one provider").into() + }, + Self::Deleted => language + .pick("供应商及其凭据已删除", "Provider and its credentials deleted") + .into(), + Self::ApiKeySaved => language + .pick( + "API Key 已保存到系统凭据管理器", + "API key saved to the system credential manager", + ) + .into(), + Self::Testing => language.pick("正在测试连接…", "Testing connection...").into(), + Self::TestResult { outcome, elapsed_ms } => { + format!("{} · {elapsed_ms} ms", language.provider_test_message(outcome)) + }, + Self::CodexConfirmation => language + .pick( + "再次点击确认:API Key 将明文写入 Codex auth.json(原文件会备份)", + "Click again to confirm: the API key will be written in plain text to Codex auth.json (the original file will be backed up)", + ) + .into(), + Self::AppliedToCodex(path) => format!( + "{}: {}", + language.pick("已应用到 Codex", "Applied to Codex"), + path.display() + ), + Self::Error(error) => format!( + "{}: {error}", + language.pick("操作失败", "Operation failed") + ), + } + } +} + +#[derive(Clone, Debug)] +enum BackupCompletion { + Exported(std::path::PathBuf), + Restored, + Pushed(String), + Pulled(String), +} + +#[derive(Clone, Debug)] +enum BackupStatus { + PassphraseTooShort, + SelectionRequired, + Processing, + RemoteConfigSaved, + CredentialEmpty, + CredentialUnsupported, + CredentialSaved, + Completed(BackupCompletion), + Error(String), +} + +impl BackupStatus { + fn is_error(&self) -> bool { + matches!( + self, + Self::PassphraseTooShort + | Self::SelectionRequired + | Self::CredentialEmpty + | Self::CredentialUnsupported + | Self::Error(_) + ) + } + + fn text(&self, language: crate::display::UiLanguage) -> String { + match self { + Self::PassphraseTooShort => language + .pick("备份密码至少 8 位", "The backup password must be at least 8 characters") + .into(), + Self::SelectionRequired => language + .pick("请至少勾选一个备份类别", "Select at least one backup category") + .into(), + Self::Processing => language.pick("处理中…", "Processing...").into(), + Self::RemoteConfigSaved => { + language.pick("远端配置已保存", "Remote configuration saved").into() + }, + Self::CredentialEmpty => { + language.pick("凭据不能为空", "Credentials cannot be empty").into() + }, + Self::CredentialUnsupported => language + .pick( + "当前协议不需要独立凭据", + "The current protocol does not use a separate credential", + ) + .into(), + Self::CredentialSaved => language + .pick( + "凭据已写入系统凭据管理器", + "Credential saved to the system credential manager", + ) + .into(), + Self::Completed(BackupCompletion::Exported(path)) => format!( + "{}: {}", + language.pick("已导出加密备份", "Encrypted backup exported"), + path.display() + ), + Self::Completed(BackupCompletion::Restored) => language + .pick( + "已从备份恢复(字体/托盘等部分设置重启后生效)", + "Backup restored (some settings, including fonts and tray options, apply after restart)", + ) + .into(), + Self::Completed(BackupCompletion::Pushed(location)) => { + format!("{} {location}", language.pick("已推送到", "Pushed to")) + }, + Self::Completed(BackupCompletion::Pulled(name)) => format!( + "{} {name} {}", + language.pick("已从", "Restored from"), + language.pick("恢复(部分设置重启后生效)", "(some settings apply after restart)") + ), + Self::Error(error) => format!( + "{}: {error}", + language.pick("备份操作失败", "Backup operation failed") + ), + } + } +} + +#[derive(Debug)] +enum TerminalImportError { + Scan(String), + NoSupportedTerminal, + Load(String), + Import(String), + Save(String), +} + +impl TerminalImportError { + fn text(self, language: crate::display::UiLanguage) -> String { + match self { + Self::Scan(error) => format!( + "{}: {error}", + language.pick("无法扫描终端目录", "Could not scan the terminal directory") + ), + Self::NoSupportedTerminal => language + .pick( + "目录中未找到受支持的终端程序", + "No supported terminal program was found in the directory", + ) + .into(), + Self::Load(error) => format!( + "{}: {error}", + language.pick("无法读取终端配置", "Could not read terminal profiles") + ), + Self::Import(error) => format!( + "{}: {error}", + language.pick("无法导入终端", "Could not import the terminal") + ), + Self::Save(error) => format!( + "{}: {error}", + language.pick("无法保存终端配置", "Could not save terminal profiles") + ), + } + } +} + +#[derive(Clone, Debug)] +pub(super) enum SshStatus { + Saved(String), + Pinned, + Imported(usize), + Opening(String), + DeleteCommitted { hidden_config: bool }, + CleanupPartial(String), + Restored(String), + Validation(SshValidationError), + PersistFailed(String), + DeleteFailed(String), + UndoFailed(String), + TestStartFailed(String), + ProfileLoadFailed(String), + ProfileSaveFailed(String), + HostListSaveFailed(String), + CredentialSaveFailed(String), + SavedWithCleanupError { destination: String, error: String }, + Error(String), +} + +impl SshStatus { + pub(super) fn is_error(&self) -> bool { + matches!( + self, + Self::CleanupPartial(_) + | Self::Validation(_) + | Self::PersistFailed(_) + | Self::DeleteFailed(_) + | Self::UndoFailed(_) + | Self::TestStartFailed(_) + | Self::ProfileLoadFailed(_) + | Self::ProfileSaveFailed(_) + | Self::HostListSaveFailed(_) + | Self::CredentialSaveFailed(_) + | Self::SavedWithCleanupError { .. } + | Self::Error(_) + ) + } + + pub(super) fn text(&self, language: crate::display::UiLanguage) -> String { + match self { + Self::Saved(destination) => { + format!("{} {destination}", language.pick("已保存", "Saved")) + }, + Self::Pinned => language.pick("置顶状态已更新", "Pin status updated").into(), + Self::Imported(count) => format!( + "{} {count} {}", + language.pick("已导入,config 源共", "Imported"), + language.pick("个别名", "config aliases") + ), + Self::Opening(host) => { + format!("{} {host}…", language.pick("正在打开", "Opening")) + }, + Self::DeleteCommitted { hidden_config: true } => language + .pick( + "已隐藏 config 别名,并清理 Nebula Profile 与凭据", + "Config alias hidden; Nebula profile and credentials removed", + ) + .into(), + Self::DeleteCommitted { hidden_config: false } => language + .pick( + "已删除主机、Profile 与凭据", + "Host, profile, and credentials deleted", + ) + .into(), + Self::CleanupPartial(details) => format!( + "{}: {details}", + language.pick( + "主机已从列表移除,但部分清理失败", + "Host removed from the list, but some cleanup failed", + ) + ), + Self::Restored(host) => { + format!("{} {host}", language.pick("已恢复", "Restored")) + }, + Self::Validation(error) => error.text(language).into(), + Self::PersistFailed(error) => format!( + "{}: {error}", + language.pick("写入设置失败", "Failed to write settings") + ), + Self::DeleteFailed(error) => format!( + "{}: {error}", + language.pick("删除主机失败", "Failed to delete host") + ), + Self::UndoFailed(error) => { + format!("{}: {error}", language.pick("撤销失败", "Undo failed")) + }, + Self::TestStartFailed(error) => format!( + "{}: {error}", + language.pick("无法启动连接测试", "Could not start the connection test") + ), + Self::ProfileLoadFailed(error) => format!( + "{}: {error}", + language.pick("加载 SSH Profile 失败", "Failed to load the SSH profile") + ), + Self::ProfileSaveFailed(error) => format!( + "{}: {error}", + language.pick("保存 SSH Profile 失败", "Failed to save the SSH profile") + ), + Self::HostListSaveFailed(error) => format!( + "{}: {error}", + language.pick("保存主机列表失败", "Failed to save the host list") + ), + Self::CredentialSaveFailed(error) => format!( + "{}: {error}", + language.pick( + "Profile 已保存,但密码写入凭据管理器失败", + "The profile was saved, but the password could not be written to the credential manager", + ) + ), + Self::SavedWithCleanupError { destination, error } => format!( + "{} {destination}, {}: {error}", + language.pick("已保存", "Saved"), + language.pick( + "但旧地址凭据清理失败", + "but credentials for the previous address could not be removed", + ) + ), + Self::Error(error) => { + format!("{}: {error}", language.pick("SSH 操作失败", "SSH operation failed")) + }, + } + } +} + +use super::ssh_settings::{SshDeleteUndo, SshEditorState, SshValidationError}; impl ShellSelectItem { fn new(id: String, name: String, scale_factor: f32) -> Self { @@ -259,10 +717,10 @@ impl ShellSelectItem { } /// 置顶的导入行。没有品牌贴图,[`Self::view`] 会给它文件夹图标。 - fn import_action() -> Self { + fn import_action(language: crate::display::UiLanguage) -> Self { Self { id: SHELL_IMPORT_ACTION_ID.to_owned(), - name: "导入终端目录…".into(), + name: language.pick("导入终端目录…", "Import terminal directory...").into(), closed_image: None, row_image: None, } @@ -299,7 +757,11 @@ impl ShellSelectItem { /// 顺序 = 置顶导入行 → 已安装 shell(`detect_shells` 菜单序)→ 用户导入的 /// 终端 profile。导入项以 `profile:<家族>|` 作设置值([`Profile::settings_id`] /// 同一形状),品牌图标因此仍能按家族查到。 -fn shell_select_items(current: &str, scale_factor: f32) -> (Vec, usize) { +fn shell_select_items( + current: &str, + scale_factor: f32, + language: crate::display::UiLanguage, +) -> (Vec, usize) { let mut items: Vec = crate::shell_detect::detect_shells() .into_iter() .map(|shell| ShellSelectItem::new(shell.id, shell.name, scale_factor)) @@ -337,26 +799,28 @@ fn shell_select_items(current: &str, scale_factor: f32) -> (Vec } let selected = items.iter().position(|item| item.id == current).unwrap_or(0); // 导入行最后才插到首位:它不参与选中判定,所以选中行号整体后移一位。 - items.insert(0, ShellSelectItem::import_action()); + items.insert(0, ShellSelectItem::import_action(language)); (items, selected + 1) } /// 扫描目录并落盘(阻塞 IO,调用方须放后台执行器)。 /// 逻辑与旧壳 `Display::import_terminal_directory` 逐句对齐,只是把 toast /// 换成 `Result`,由 UI 线程决定怎么呈现。 -fn import_terminal_directory_blocking(directory: &std::path::Path) -> Result { +fn import_terminal_directory_blocking( + directory: &std::path::Path, +) -> Result { let found = crate::terminal_profiles::scan_directory(directory) - .map_err(|error| format!("无法扫描终端目录:{error}"))?; + .map_err(|error| TerminalImportError::Scan(error.to_string()))?; if found.is_empty() { - return Err("目录中未找到受支持的终端程序".to_owned()); + return Err(TerminalImportError::NoSupportedTerminal); } let mut profiles = crate::terminal_profiles::TerminalProfiles::load() - .map_err(|error| format!("无法读取终端配置:{error}"))?; + .map_err(|error| TerminalImportError::Load(error.to_string()))?; let count = found.len(); for profile in found { - profiles.upsert(profile).map_err(|error| format!("无法导入终端:{error}"))?; + profiles.upsert(profile).map_err(|error| TerminalImportError::Import(error.to_string()))?; } - profiles.save().map_err(|error| format!("无法保存终端配置:{error}"))?; + profiles.save().map_err(|error| TerminalImportError::Save(error.to_string()))?; Ok(count) } @@ -447,7 +911,7 @@ pub struct SettingsPane { /// Name / note / website / endpoint / model. API keys deliberately do not /// use a GPUI text widget; the native credential dialog is write-only. provider_inputs: Vec>, - provider_status: Option<(String, bool)>, + provider_status: Option, provider_test_seq: u64, provider_test_running: bool, provider_codex_confirm: Option, @@ -469,14 +933,14 @@ pub struct SettingsPane { pub(super) ssh_editor: Option, pub(super) ssh_editor_seq: u64, pub(super) ssh_test_seq: u64, - pub(super) ssh_status: Option<(String, bool)>, + pub(super) ssh_status: Option, pub(super) ssh_show_hidden: bool, /// 删除确认(二次点击生效,旧壳确认对话框的轻量对应)。 pub(super) ssh_delete_confirm: Option, /// 未决删除的撤销窗口(8 秒;见 `ssh_settings::SshDeleteUndo`)。 pub(super) ssh_delete_undo: Option, pub(super) ssh_undo_seq: u64, - /// 可直接编辑的字体链及其建议弹层;逗号分隔语义与 Windows Terminal 一致。 + /// 可直接编辑的字体链及其建议弹层;逗号分隔主字体与 fallback 字体。 pub(super) font_picker_open: bool, font_loading: bool, /// None = 尚未枚举;首次展开时在后台线程装配(几百字体的机器上 @@ -492,7 +956,7 @@ pub struct SettingsPane { backup_selection: crate::encrypted_backup::BackupSelection, /// 备份密码(masked;只在导出/恢复动作瞬时读取,不落任何配置)。 backup_pass_input: Entity, - backup_status: Option<(String, bool)>, + backup_status: Option, /// 导出/恢复/推送进行中(按钮禁用 + 忽略过期完成回调)。 backup_busy: bool, backup_seq: u64, @@ -521,11 +985,11 @@ pub struct SettingsPane { impl SettingsPane { pub fn new(window: &mut Window, cx: &mut Context) -> 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, - labels: &'static [&'static str], values: &'static [&'static str], current: &str, window: &mut Window, @@ -533,7 +997,7 @@ impl SettingsPane { let ix = values.iter().position(|v| *v == current).unwrap_or(0); let select = cx.new(|cx| { SelectState::new( - labels.iter().map(|l| SharedString::from(*l)).collect::>(), + localized_select_labels(key, values, language), Some(IndexPath::default().row(ix)), window, cx, @@ -545,12 +1009,16 @@ impl SettingsPane { move |this: &mut Self, entity: &SharedSelect, event: &SelectEvent>, - _window: &mut Window, + window: &mut Window, cx: &mut Context| { 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(); + } } } }, @@ -564,17 +1032,15 @@ impl SettingsPane { add_select( "language", - &["跟随系统", "简体中文", "English"], &["system", "zh-CN", "en-US"], runtime.language.settings_value(), window, cx, ); - add_select("theme", &THEME_VALUES, &THEME_VALUES, runtime.theme.prompt_name(), 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, @@ -582,7 +1048,6 @@ impl SettingsPane { ); add_select( "tabs_position", - &["左侧边栏", "顶部"], &["sidebar", "top"], runtime.tabs_position.settings_value(), window, @@ -590,7 +1055,6 @@ impl SettingsPane { ); add_select( "tab_reveal", - &["滑动", "立即"], &["slide", "instant"], runtime.tab_reveal.settings_value(), window, @@ -598,7 +1062,6 @@ impl SettingsPane { ); add_select( "density", - &["标准", "紧凑"], &["standard", "compact"], runtime.density.settings_value(), window, @@ -606,7 +1069,6 @@ impl SettingsPane { ); add_select( "new_tab_position", - &["当前标签之后", "列表末尾"], &["after_current", "end"], runtime.new_tab_position.settings_value(), window, @@ -614,7 +1076,6 @@ impl SettingsPane { ); add_select( "windowing_behavior", - &["创建新窗口", "附加到最近使用的窗口", "附加到此桌面最近使用的窗口"], &["use_new", "use_any_existing", "use_existing"], runtime.windowing_behavior.settings_value(), window, @@ -622,7 +1083,6 @@ impl SettingsPane { ); add_select( "vcs_display", - &["自动检测", "仅 Git", "仅 SVN"], &["auto", "git", "svn"], runtime.vcs_display.settings_value(), window, @@ -630,7 +1090,6 @@ impl SettingsPane { ); add_select( "cell_width_mode", - &["紧凑", "宽松"], &["compact", "relaxed"], runtime.cell_width_mode.settings_value(), window, @@ -639,7 +1098,6 @@ impl SettingsPane { // 文案照抄旧壳 `accept_label` / `completion_style_label`。 add_select( "bell", - &["关", "闪烁", "声音", "闪烁 + 声音"], &["none", "visual", "audible", "both"], runtime.bell.settings_value(), window, @@ -650,7 +1108,6 @@ impl SettingsPane { // Mica / Mica Alt 使用系统壁纸 backdrop,不由 Nebula 读取或重采样。 add_select( "blur", - &["无", "Mica(低开销)", "Mica Alt(低开销)", "Aero(玻璃)", "Acrylic(高开销)"], &["none", "mica", "mica-alt", "aero", "acrylic"], runtime.blur.settings_value(), window, @@ -658,7 +1115,6 @@ impl SettingsPane { ); add_select( "accept", - &["右方向键", "Tab", "Tab 或右方向键"], &["right", "tab", "both"], runtime.accept.settings_value(), window, @@ -666,7 +1122,6 @@ impl SettingsPane { ); add_select( "completion_style", - &["行内灰字", "弹窗列表"], &["inline", "popup"], runtime.completion_style.settings_value(), window, @@ -682,7 +1137,6 @@ impl SettingsPane { // 顺序与文案照抄旧壳 `BACKGROUND_FIT_OPTIONS` / `background_image_fit_label`。 add_select( "background_image_fit", - &["拉伸", "适应", "填充", "原始尺寸"], &["fill", "uniform", "uniform_to_fill", "none"], bgimg_fit, window, @@ -696,7 +1150,6 @@ impl SettingsPane { // 九宫格顺序照抄旧壳 `BACKGROUND_ALIGNMENT_OPTIONS`(左上 → 右下)。 add_select( "background_image_alignment", - &["左上", "顶部", "右上", "左侧", "居中", "右侧", "左下", "底部", "右下"], &[ "top_left", "top", @@ -714,7 +1167,6 @@ impl SettingsPane { ); add_select( "ssh_proxy_mode", - &["不使用代理", "跟随系统", "自定义代理"], &["off", "system", "custom"], runtime.ssh_proxy_mode.settings_value(), window, @@ -726,7 +1178,8 @@ impl SettingsPane { // 选项 = 彩色品牌 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); + 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) }); @@ -853,8 +1306,7 @@ impl SettingsPane { active_provider.base_url, active_provider.model, ]; - let provider_placeholders = - ["供应商名称", "备注(可包含空格)", "官方网站", "API 请求地址", "默认模型"]; + let provider_placeholders = provider_input_placeholders(language); let provider_inputs = provider_values .into_iter() .zip(provider_placeholders) @@ -879,12 +1331,19 @@ impl SettingsPane { .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("给这台机器起个名字")); - let ssh_password_input = - cx.new(|cx| InputState::new(window, cx).masked(true).placeholder("留空则连接时询问")); - let ssh_icon_filter_input = - cx.new(|cx| InputState::new(window, cx).placeholder("搜索图标…")); + 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)) + }); for input in [ ssh_destination_input.clone(), ssh_port_input.clone(), @@ -986,7 +1445,9 @@ impl SettingsPane { 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("备份密码(至少 8 位)") + InputState::new(window, cx) + .masked(true) + .placeholder(localized_input_placeholder("backup_password", language)) }), backup_status: None, backup_busy: false, @@ -1001,10 +1462,15 @@ impl SettingsPane { }, backup_remote_inputs, backup_secret_input: cx.new(|cx| { - InputState::new(window, cx).masked(true).placeholder("凭据只写入系统凭据管理器") + 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("搜索动作或按键…")); + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(localized_input_placeholder("keymap_search", language)) + }); subscriptions.push(cx.subscribe_in( &input, window, @@ -1041,6 +1507,7 @@ impl SettingsPane { 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); if updates.iter().any(|(key, _)| matches!(*key, "ssh_proxy_mode" | "ssh_proxy_url")) { self.invalidate_proxy_test(); @@ -1049,6 +1516,40 @@ impl SettingsPane { cx.notify(); } + /// 语言切换不重建输入/下拉实体:重建会丢焦点、编辑值、undo 和订阅。 + /// 固定候选按稳定 value 保留索引,随后仅替换显示项与 placeholder。 + fn refresh_localized_controls(&mut self, window: &mut Window, cx: &mut Context) { + let language = crate::gpui_shell::config::ui_language(cx); + for (key, select, values) in &self.selects { + let selected = select.read(cx).selected_index(cx); + let items = localized_select_labels(key, values, language); + select.update(cx, |state, cx| { + state.set_items(items, window, cx); + state.set_selected_index(selected, window, cx); + }); + } + self.refresh_shell_items(window, cx); + + for (input, placeholder) in + self.provider_inputs.iter().zip(provider_input_placeholders(language)) + { + input.update(cx, |state, cx| state.set_placeholder(placeholder, window, cx)); + } + for (input, key) in [ + (&self.ssh_label_input, "ssh_label"), + (&self.ssh_password_input, "ssh_password"), + (&self.ssh_icon_filter_input, "ssh_icon_filter"), + (&self.font_family_input, "font_family"), + (&self.backup_pass_input, "backup_password"), + (&self.backup_secret_input, "backup_secret"), + (&self.keymap_search_input, "keymap_search"), + ] { + let placeholder = localized_input_placeholder(key, language); + input.update(cx, |state, cx| state.set_placeholder(placeholder, window, cx)); + } + cx.notify(); + } + fn toggle( &mut self, key: &'static str, @@ -1069,33 +1570,33 @@ impl SettingsPane { /// 旧壳 `request_toggle_background_image_cover_chrome`:关→开要确认 /// (壳变半透明、控件对比度下降);开→关直接生效。 - fn request_cover_chrome(&mut self, enable: bool, window: &mut Window, cx: &mut Context) { if !enable { + fn request_cover_chrome(&mut self, enable: bool, window: &mut Window, cx: &mut Context) { + if !enable { self.persist(&[("background_image_cover_chrome", "0".to_owned())], cx); return; } if self.runtime.background_image_cover_chrome { return; } + let language = crate::gpui_shell::config::ui_language(cx); let pane = cx.entity().downgrade(); window.open_dialog(cx, move |dialog, window, _cx| { let pane = pane.clone(); confirm_dialog( dialog, window, - "让背景图覆盖窗口控件区域?", - SharedString::from( - "背景图会延伸到标题栏、窗口按钮、Tab 与 SSH 侧栏下方,低对比度图片可能影响操作可见性;界面仍会保留最低不透明度保护。", - ), - "开启", - "取消", + language.tr("settings.appearance.background_cover_title"), + SharedString::from(language.tr("settings.appearance.background_cover_description")), + language.pick("开启", "Enable"), + language.pick("取消", "Cancel"), ButtonVariant::Primary, ) - .on_ok(move |_, _, cx| { - let _ = pane.update(cx, |this, cx| { - this.persist(&[("background_image_cover_chrome", "1".to_owned())], cx); - }); - true - }) + .on_ok(move |_, _, cx| { + let _ = pane.update(cx, |this, cx| { + this.persist(&[("background_image_cover_chrome", "1".to_owned())], cx); + }); + true + }) }); cx.notify(); } @@ -1238,15 +1739,23 @@ impl SettingsPane { // 用户点的是动作行,不是换 shell:先把闭态标题拨回真正生效的那项, // 否则扫描期间下拉会显示「导入终端目录…」,像是默认 shell 被改掉了。 self.restore_shell_selection(window, cx); + let language = crate::gpui_shell::config::ui_language(cx); #[cfg(windows)] - let picked = pick_folder_with_wsl_places(window, "选择终端安装目录"); + let picked = pick_folder_with_wsl_places( + window, + language.pick("选择终端安装目录", "Select a terminal installation directory"), + ); #[cfg(not(windows))] let picked = cx.prompt_for_paths(gpui::PathPromptOptions { files: false, directories: true, multiple: false, - prompt: Some("选择终端安装目录".into()), + prompt: Some( + language + .pick("选择终端安装目录", "Select a terminal installation directory") + .into(), + ), }); cx.spawn_in(window, async move |this, cx| { #[cfg(windows)] @@ -1270,17 +1779,23 @@ impl SettingsPane { /// 导入收尾:提示结果,成功则重建候选并广播配置变更。 fn finish_terminal_import( &mut self, - outcome: Result, + outcome: Result, window: &mut Window, cx: &mut Context, ) { match outcome { Ok(count) => { + let language = crate::gpui_shell::config::ui_language(cx); + let message = format!( + "{} {count} {}", + language.pick("已导入", "Imported"), + language.pick("个终端,立即可用", "terminals; they are ready to use") + ); crate::gpui_shell::toast::toast( window, cx, crate::display::ToastKind::Success, - format!("已导入 {count} 个终端,立即可用"), + message, ); self.refresh_shell_items(window, cx); // 新 profile 要立即进入 Tab 的 Shell 面板;它不是普通运行时 @@ -1288,7 +1803,8 @@ impl SettingsPane { cx.emit(SettingsPaneEvent::TerminalProfilesChanged); cx.notify(); }, - Err(message) => { + Err(error) => { + let message = error.text(crate::gpui_shell::config::ui_language(cx)); crate::gpui_shell::toast::toast( window, cx, @@ -1302,7 +1818,11 @@ impl SettingsPane { /// 按当前设置值重建下拉候选(导入后新增行即时可见)。 fn refresh_shell_items(&mut self, window: &mut Window, cx: &mut Context) { let current = self.runtime.shell.clone().unwrap_or_else(|| "powershell".into()); - let (items, selected) = shell_select_items(¤t, window.scale_factor().max(0.5)); + let (items, selected) = shell_select_items( + ¤t, + window.scale_factor().max(0.5), + crate::gpui_shell::config::ui_language(cx), + ); self.shell_select.update(cx, |state, cx| { state.set_items(items, window, cx); state.set_selected_index(Some(IndexPath::default().row(selected)), window, cx); @@ -1330,13 +1850,7 @@ impl SettingsPane { /// /// 值不在 `values` 里(光标形状的"未设置"写空串)时落到第 0 项,与 /// `add_select` 建初始索引时的同一条回落规则一致。 - fn sync_select( - &mut self, - key: &str, - value: &str, - window: &mut Window, - cx: &mut Context, - ) { + fn sync_select(&mut self, key: &str, value: &str, window: &mut Window, cx: &mut Context) { let Some((_, select, values)) = self.selects.iter().find(|(k, _, _)| *k == key) else { return; }; @@ -1367,9 +1881,13 @@ impl SettingsPane { } fn shell_select_row(&self, cx: &Context) -> impl IntoElement { + let language = crate::gpui_shell::config::ui_language(cx); self.row( - "默认 Shell", - "新标签用哪个程序开。已经开着的标签不受影响——它们跟的是各自创建时的选择。", + language.pick("默认 Shell", "Default shell"), + language.pick( + "新标签用哪个程序开。已经开着的标签不受影响——它们跟的是各自创建时的选择。", + "Chooses the program used for new tabs. Existing tabs are unaffected because each retains the shell chosen when it was created.", + ), div() .w(px(SETTINGS_SELECT_WIDTH)) .font_family(cx.theme().mono_font_family.clone()) @@ -1632,6 +2150,7 @@ impl SettingsPane { /// 旧壳启动目录:点路径打开选文件夹;空着显示「继承当前目录」; /// 有值时右侧「清除」。不是手填文本框。 fn startup_directory_row(&self, cx: &mut Context) -> impl IntoElement { + let language = crate::gpui_shell::config::ui_language(cx); let current = self .runtime .startup_directory @@ -1639,12 +2158,19 @@ impl SettingsPane { .map(str::trim) .filter(|path| !path.is_empty()); let has_dir = current.is_some(); - let label: SharedString = - current.map(str::to_owned).unwrap_or_else(|| "继承当前目录".to_owned()).into(); + let label: SharedString = current + .map(str::to_owned) + .unwrap_or_else(|| { + language.pick("继承当前目录", "Inherit current directory").to_owned() + }) + .into(); let color = if has_dir { cx.theme().link } else { cx.theme().muted_foreground }; self.row( - "启动目录", - "新标签落在哪个目录。不设则继承 Nebula 自己的工作目录,从资源管理器右键进来时就是那个文件夹。", + language.pick("启动目录", "Startup directory"), + language.pick( + "新标签落在哪个目录。不设则继承 Nebula 自己的工作目录,从资源管理器右键进来时就是那个文件夹。", + "Chooses the directory for new tabs. When unset, they inherit Nebula's working directory, such as the folder used to launch it from File Explorer.", + ), h_flex() .gap_2() .items_center() @@ -1662,7 +2188,7 @@ impl SettingsPane { })), ) .when(has_dir, |row| { - row.child(NebulaButton::new("startup-directory-clear").label("清除").on_click( + row.child(NebulaButton::new("startup-directory-clear").label(language.pick("清除", "Clear")).on_click( cx.listener(|this, _, _, cx| { this.clear_startup_directory(cx); }), @@ -1673,14 +2199,20 @@ impl SettingsPane { } fn pick_startup_directory(&mut self, window: &mut Window, cx: &mut Context) { + let language = crate::gpui_shell::config::ui_language(cx); #[cfg(windows)] - let picked = pick_folder_with_wsl_places(window, "选择终端启动目录"); + let picked = pick_folder_with_wsl_places( + window, + language.pick("选择终端启动目录", "Select the terminal startup directory"), + ); #[cfg(not(windows))] let picked = cx.prompt_for_paths(gpui::PathPromptOptions { files: false, directories: true, multiple: false, - prompt: Some("选择终端启动目录".into()), + prompt: Some( + language.pick("选择终端启动目录", "Select the terminal startup directory").into(), + ), }); cx.spawn(async move |this, cx| { #[cfg(windows)] @@ -1725,7 +2257,7 @@ impl SettingsPane { .child(div().w(px(280.0)).child(Input::new(input))) .child( NebulaButton::new(SharedString::from(format!("save-{key}"))) - .label("保存") + .label(crate::gpui_shell::config::ui_language(cx).pick("保存", "Save")) .on_click(cx.listener(move |this, _, _, cx| { let value = state.read(cx).value().to_string(); this.persist(&[(key, value)], cx); @@ -1798,11 +2330,14 @@ impl SettingsPane { } fn choose_background_image(&mut self, cx: &mut Context) { + let language = crate::gpui_shell::config::ui_language(cx); let picked = cx.prompt_for_paths(gpui::PathPromptOptions { files: true, directories: false, multiple: false, - prompt: Some("选择终端背景图片".into()), + prompt: Some( + language.pick("选择终端背景图片", "Select a terminal background image").into(), + ), }); cx.spawn(async move |this, cx| { let Ok(Ok(Some(paths))) = picked.await else { return }; @@ -1816,6 +2351,7 @@ impl SettingsPane { } fn background_image_row(&self, cx: &mut Context) -> impl IntoElement { + let language = crate::gpui_shell::config::ui_language(cx); let current = self.runtime.background_image.clone(); let has_image = current.as_ref().is_some_and(|path| !path.trim().is_empty()); let path_label: Option = @@ -1828,8 +2364,11 @@ impl SettingsPane { .into() }); self.row_with_reset( - "背景图片", - "铺在终端文字后面。图片本身不参与配色,字色仍由主题决定;看不清就调下面的不透明度。", + language.pick("背景图片", "Background image"), + language.pick( + "铺在终端文字后面。图片本身不参与配色,字色仍由主题决定;看不清就调下面的不透明度。", + "Draws an image behind terminal text. The image does not affect colors; text colors still come from the theme. Reduce the opacity below if text is hard to read.", + ), has_image, |this, _, cx| { this.persist(&[("background_image", String::new())], cx); @@ -1837,7 +2376,7 @@ impl SettingsPane { h_flex() .items_center() .gap_2() - .child(NebulaButton::new("background-image-choose").label("选择图片").on_click( + .child(NebulaButton::new("background-image-choose").label(language.pick("选择图片", "Choose image")).on_click( cx.listener(|this, _, _, cx| { this.choose_background_image(cx); }), @@ -1899,11 +2438,11 @@ impl SettingsPane { crate::ai_providers::apply_metadata_draft(&mut self.provider_store.providers[index], draft); match crate::ai_providers::save(&self.provider_store) { Ok(()) => { - self.provider_status = Some(("供应商配置已保存".to_owned(), false)); + self.provider_status = Some(ProviderStatus::Saved); true }, Err(error) => { - self.provider_status = Some((error.to_string(), true)); + self.provider_status = Some(ProviderStatus::Error(error.to_string())); false }, } @@ -1919,8 +2458,8 @@ impl SettingsPane { self.provider_store.active_id = id; self.provider_codex_confirm = None; match crate::ai_providers::save(&self.provider_store) { - Ok(()) => self.provider_status = Some(("已添加自定义供应商".to_owned(), false)), - Err(error) => self.provider_status = Some((error.to_string(), true)), + Ok(()) => self.provider_status = Some(ProviderStatus::Added), + Err(error) => self.provider_status = Some(ProviderStatus::Error(error.to_string())), } self.sync_provider_inputs(window, cx); cx.notify(); @@ -1929,18 +2468,18 @@ impl SettingsPane { fn delete_provider(&mut self, window: &mut Window, cx: &mut Context) { let Some(index) = self.active_provider_index() else { return }; if self.provider_store.providers.len() <= 1 { - self.provider_status = Some(("至少保留一个供应商".to_owned(), true)); + self.provider_status = Some(ProviderStatus::AtLeastOneRequired); cx.notify(); return; } let id = self.provider_store.providers[index].id.clone(); match crate::ai_providers::remove_provider(&mut self.provider_store, &id) { Ok(()) => { - self.provider_status = Some(("供应商及其凭据已删除".to_owned(), false)); + self.provider_status = Some(ProviderStatus::Deleted); self.provider_codex_confirm = None; self.sync_provider_inputs(window, cx); }, - Err(error) => self.provider_status = Some((error.to_string(), true)), + Err(error) => self.provider_status = Some(ProviderStatus::Error(error.to_string())), } cx.notify(); } @@ -1956,7 +2495,7 @@ impl SettingsPane { } self.provider_codex_confirm = None; if let Err(error) = crate::ai_providers::save(&self.provider_store) { - self.provider_status = Some((error.to_string(), true)); + self.provider_status = Some(ProviderStatus::Error(error.to_string())); } cx.notify(); } @@ -1966,13 +2505,13 @@ impl SettingsPane { let provider = &mut self.provider_store.providers[index]; match crate::ai_providers::prompt_and_store_api_key(provider) { Ok(true) => { - self.provider_status = Some(("API Key 已保存到系统凭据管理器".to_owned(), false)); + self.provider_status = Some(ProviderStatus::ApiKeySaved); if let Err(error) = crate::ai_providers::save(&self.provider_store) { - self.provider_status = Some((error.to_string(), true)); + self.provider_status = Some(ProviderStatus::Error(error.to_string())); } }, Ok(false) => {}, - Err(error) => self.provider_status = Some((error.to_string(), true)), + Err(error) => self.provider_status = Some(ProviderStatus::Error(error.to_string())), } cx.notify(); } @@ -1986,7 +2525,7 @@ impl SettingsPane { self.provider_test_seq = self.provider_test_seq.wrapping_add(1); let sequence = self.provider_test_seq; self.provider_test_running = true; - self.provider_status = Some(("正在测试连接…".to_owned(), false)); + self.provider_status = Some(ProviderStatus::Testing); let task = cx .background_executor() @@ -2000,8 +2539,10 @@ impl SettingsPane { return; } pane.provider_test_running = false; - pane.provider_status = - Some((format!("{} · {} ms", result.message, result.elapsed_ms), !result.ok)); + pane.provider_status = Some(ProviderStatus::TestResult { + outcome: result.outcome, + elapsed_ms: result.elapsed_ms, + }); cx.notify(); }); }) @@ -2017,17 +2558,14 @@ impl SettingsPane { let provider = self.provider_store.providers[index].clone(); if self.provider_codex_confirm.as_deref() != Some(provider.id.as_str()) { self.provider_codex_confirm = Some(provider.id); - self.provider_status = Some(( - "再次点击确认:API Key 将明文写入 Codex auth.json(原文件会备份)".to_owned(), - false, - )); + self.provider_status = Some(ProviderStatus::CodexConfirmation); cx.notify(); return; } self.provider_codex_confirm = None; self.provider_status = Some(match crate::codex_config::apply_provider(&provider) { - Ok(path) => (format!("已应用到 Codex:{}", path.display()), false), - Err(error) => (error, true), + Ok(path) => ProviderStatus::AppliedToCodex(path), + Err(error) => ProviderStatus::Error(error), }); cx.notify(); } @@ -2105,22 +2643,44 @@ impl SettingsPane { } fn section_home(&mut self, cx: &mut Context) -> gpui::Div { + let language = crate::gpui_shell::config::ui_language(cx); let theme = cx.theme(); let muted = theme.muted_foreground; let checking = matches!(self.about_update, AboutUpdateState::Checking); let (status, status_color): (SharedString, Hsla) = match &self.about_update { - AboutUpdateState::Idle => ("通过 GitHub Releases 检查新版本。".into(), muted), - AboutUpdateState::Checking => ("正在检查更新…".into(), muted), - AboutUpdateState::UpToDate(latest) => { - (format!("已是最新版本(GitHub v{latest})").into(), theme.success) + AboutUpdateState::Idle => ( + language + .pick( + "通过 GitHub Releases 检查新版本。", + "Check GitHub Releases for a newer version.", + ) + .into(), + muted, + ), + AboutUpdateState::Checking => { + (language.pick("正在检查更新…", "Checking for updates...").into(), muted) }, - AboutUpdateState::Available(latest) => { - (format!("发现新版本 v{latest}").into(), theme.warning) - }, - AboutUpdateState::Failed(error) => (format!("检查失败:{error}").into(), theme.danger), + AboutUpdateState::UpToDate(latest) => ( + format!("{} (GitHub v{latest})", language.pick("已是最新版本", "Up to date")) + .into(), + theme.success, + ), + AboutUpdateState::Available(latest) => ( + format!("{} v{latest}", language.pick("发现新版本", "New version available")) + .into(), + theme.warning, + ), + AboutUpdateState::Failed(error) => ( + format!("{}: {error}", language.pick("检查失败", "Update check failed")).into(), + theme.danger, + ), }; let update_button = NebulaButton::new("about-check-updates") - .label(if checking { "正在检查…" } else { "检查更新" }) + .label(if checking { + language.pick("正在检查…", "Checking...") + } else { + language.pick("检查更新", "Check for updates") + }) .outline() .disabled(checking) .on_click(cx.listener(|this, _, window, cx| this.check_for_updates(window, cx))); @@ -2205,7 +2765,11 @@ impl SettingsPane { .group_hover("about-transcript", |el| el.visible()) .hover(|el| el.bg(theme.list_hover)) .tooltip(|window, cx| { - gpui_component::tooltip::Tooltip::new("复制这段信息").build(window, cx) + gpui_component::tooltip::Tooltip::new( + crate::gpui_shell::config::ui_language(cx) + .pick("复制这段信息", "Copy this information"), + ) + .build(window, cx) }) .on_click(move |_, _, cx| { cx.write_to_clipboard(gpui::ClipboardItem::new_string( @@ -2226,8 +2790,11 @@ impl SettingsPane { .child(Self::about_action_row( "about-report-issue", IconName::TriangleAlert, - "反馈问题", - "生成包含版本、平台与构建方式的预填 GitHub Issue", + language.pick("反馈问题", "Report an issue"), + language.pick( + "生成包含版本、平台与构建方式的预填 GitHub Issue", + "Open a prefilled GitHub issue with version, platform, and build details", + ), issue_url(), cx, )) @@ -2235,15 +2802,18 @@ impl SettingsPane { "about-github", IconName::Github, "GitHub", - "源代码", + language.pick("源代码", "Source code"), REPOSITORY_URL.to_owned(), cx, )) .child(Self::about_action_row( "about-releases", IconName::BookOpen, - "更新内容", - "查看发布说明并下载最新版本", + language.pick("更新内容", "Release notes"), + language.pick( + "查看发布说明并下载最新版本", + "View release notes and download the latest version", + ), crate::update_check::RELEASES_PAGE.to_owned(), cx, )); @@ -2265,77 +2835,82 @@ impl SettingsPane { } fn section_appearance(&mut self, cx: &mut Context) -> gpui::Div { + let language = crate::gpui_shell::config::ui_language(cx); // 旧壳 spinner 只显示整数(`{:.0}`);步进也按整数走(见下)。 let font_size: SharedString = format!("{:.0} px", self.terminal_font_size_px(cx)).into(); 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(); - let preview = self.group("预览", cx).child(self.appearance_preview(cx)); - let themes = self.group("主题", cx).child(self.theme_previews(cx)); - let theme_mode = self.group("主题模式", cx).child(self.switch_row( + let preview = + self.group(language.pick("预览", "Preview"), cx).child(self.appearance_preview(cx)); + let themes = self.group(language.pick("主题", "Theme"), cx).child(self.theme_previews(cx)); + let theme_mode = self.group(language.pick("主题模式", "Theme mode"), cx).child(self.switch_row( "follow_system_theme", - "跟随系统明暗模式", - "开着时 Windows 切浅色/深色,Nebula 跟着换;上面手选的主题只在系统当前这个模式下生效。", + language.pick("跟随系统明暗模式", "Follow system light/dark mode"), + language.pick( + "开着时 Windows 切浅色/深色,Nebula 跟着换;上面手选的主题只在系统当前这个模式下生效。", + "When Windows changes between light and dark mode, Nebula follows it. The theme selected above applies to the system's current mode.", + ), self.runtime.follow_system_theme, cx, )); let custom_background = self - .group("自定义背景", cx) + .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", "背景图像拉伸模式", "拉伸会把图压变形;适应保持比例、四周留边;填充保持比例但裁掉溢出的部分;原始尺寸按图片自己的像素铺。", cx)) - .child(self.select_row("background_image_alignment", "背景图像对齐", "图片没铺满或被裁掉时,保留哪一侧。壁纸类的图通常选顶部或居中,主体才不会被切走。", 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.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."), &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."), self.runtime.background_image_cover_chrome, cx, )); let cursor = self - .group("光标", cx) + .group(language.pick("光标", "Cursor"), cx) .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."), 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."), self.runtime.cursor_blink.unwrap_or(DEFAULT_CURSOR_BLINK), cx, )); let interface = self - .group("界面", cx) + .group(language.pick("界面", "Interface"), cx) .child(self.select_row( "language", - "语言", - "只改 Nebula 自己的界面。终端里程序输出什么语言由它们自己的环境变量决定,不受这里影响。", + language.pick("语言", "Language"), + language.pick("只改 Nebula 自己的界面。终端里程序输出什么语言由它们自己的环境变量决定,不受这里影响。", "Changes only Nebula's interface. Programs inside the terminal choose their language from their own environment and are not affected."), cx, )) - .child(self.select_row("density", "界面外观", "紧凑会收窄标签行高与设置页行距这些界面留白。终端内容的行距不归它管,那是字体的事。", 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( - "终端正文不透明度", - "1 = 完全不透明。调低会透出后方窗口,配合下面的窗口模糊才不至于让文字压在杂乱内容上。", + language.pick("终端正文不透明度", "Terminal content opacity"), + language.pick("1 = 完全不透明。调低会透出后方窗口,配合下面的窗口模糊才不至于让文字压在杂乱内容上。", "1 is fully opaque. Lower values reveal windows behind Nebula; use window blur to keep terminal text readable over busy content."), &self.opacity_slider, opacity, cx, )) - .child(self.select_row("blur", "背景模糊", "五者是五套成本模型,不是越靠后越好:Mica 只取系统壁纸的色调、最省;Aero 与 Acrylic 每帧实时模糊窗口后方的真实内容,Acrylic 还多一层着色与噪点。", 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)); let terminal = self - .group("终端外观", cx) + .group(language.pick("终端外观", "Terminal appearance"), cx) .child(self.stepper_row( - "终端字号(Ctrl+滚轮缩放)", - "只改终端网格。侧栏与设置页的文字锚在配置字号上,不会跟着一起放大。", + language.pick("终端字号(Ctrl+滚轮缩放)", "Terminal font size (Ctrl+wheel)"), + language.pick("只改终端网格。侧栏与设置页的文字锚在配置字号上,不会跟着一起放大。", "Changes only the terminal grid. Sidebar and Settings text stay anchored to the configured UI size."), "font-size", font_size, // 整数步进:分数字号(滚轮缩放遗留,如 15.30)先吸附回 // 最近的整数档,再继续 ±1——不会出现 15.3→14.3 这类漂移。 @@ -2349,18 +2924,18 @@ impl SettingsPane { }, cx, )) - .child(self.select_row("cell_width_mode", "字体间距", "列宽的取整方式。紧凑向下取整、字更密;宽松向上补一像素,专治 `Maple Mono` 这类平均字宽带小数的字体把字形挤扁。只作用于终端网格。", 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.switch_row( "fetch", - "启动欢迎信息", - "新会话开头跑一次 `fastfetch` 打印系统信息。默认关,因为开新标签会因此慢一拍。", + 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."), self.runtime.fetch, cx, )) .child(self.switch_row( "powerline", - "Powerline 提示符", - "给 Nebula 注入的提示符加箭头分段。需要终端字体带 Powerline 字形,否则那些箭头会显示成方框。", + language.pick("Powerline 提示符", "Powerline prompt"), + language.pick("给 Nebula 注入的提示符加箭头分段。需要终端字体带 Powerline 字形,否则那些箭头会显示成方框。", "Adds arrow segments to Nebula's injected prompt. The terminal font must include Powerline glyphs or the arrows will render as boxes."), self.runtime.powerline, cx, )); @@ -2378,43 +2953,45 @@ impl SettingsPane { } fn section_profiles(&mut self, window: &mut Window, cx: &mut Context) -> gpui::Div { + let language = crate::gpui_shell::config::ui_language(cx); let font_picker = self.font_picker_dropdown(window, cx); let terminal = self - .group("终端", cx) + .group(language.pick("终端", "Terminal"), cx) .child(self.shell_select_row(cx)) .child(self.startup_directory_row(cx)) .child(self.select_row( "bell", - "终端铃声", - "程序发出 BEL 时的反应。开放工位上选闪烁,免得一次 Tab 补全失败响得整层楼都听见。", + language.pick("终端铃声", "Terminal bell"), + language.pick("程序发出 BEL 时的反应。开放工位上选闪烁,免得一次 Tab 补全失败响得整层楼都听见。", "Controls how Nebula responds to BEL. Visual feedback is useful in shared spaces where an audible bell would be disruptive."), cx, )) .child(font_picker); let completion = self - .group("补全", cx) + .group(language.pick("补全", "Completion"), cx) .child(self.switch_row( "ghost", - "启用命令补全", - "按历史在光标后给出灰色建议,按下接受键才真正写进命令行。关掉后不再出现任何建议。", + language.pick("启用命令补全", "Enable command completion"), + language.pick("按历史在光标后给出灰色建议,按下接受键才真正写进命令行。关掉后不再出现任何建议。", "Shows history-based suggestions after the cursor and inserts one only when the accept key is pressed. Disable this to hide all suggestions."), self.runtime.ghost, cx, )) .child(self.select_row( "accept", - "补全接受键", - "用哪个键把灰色建议收下。Tab 在不少 shell 里已经绑了原生补全,撞车时改成右方向键。", + language.pick("补全接受键", "Completion accept key"), + language.pick("用哪个键把灰色建议收下。Tab 在不少 shell 里已经绑了原生补全,撞车时改成右方向键。", "Chooses the key that accepts a suggestion. Many shells already use Tab for native completion; use Right arrow if they conflict."), cx, )) .child(self.select_row( "completion_style", - "补全样式", - "行内灰字续在光标后,不挡住下方输出;弹窗列表能一次看到多个候选,代价是会盖住一片终端内容。", + language.pick("补全样式", "Completion style"), + language.pick("行内灰字续在光标后,不挡住下方输出;弹窗列表能一次看到多个候选,代价是会盖住一片终端内容。", "Inline ghost text follows the cursor without covering output. A popup shows several candidates at once but obscures part of the terminal."), cx, )); v_flex().w_full().gap(px(GROUP_GAP)).child(terminal).child(completion) } fn section_providers(&mut self, cx: &mut Context) -> gpui::Div { + let language = crate::gpui_shell::config::ui_language(cx); let theme = cx.theme(); let hover_bg = crate::gpui_shell::theme::settings_hover_bg(cx, false); let active_index = self.active_provider_index().unwrap_or(0); @@ -2454,14 +3031,18 @@ impl SettingsPane { if let Some(provider) = active { let key_status: SharedString = if provider.api_key_set { if provider.api_key_hint.is_empty() { - "已保存在系统凭据管理器".into() + language + .pick("已保存在系统凭据管理器", "Saved in the system credential manager") + .into() } else { provider.api_key_hint.clone().into() } } else if provider.kind.requires_api_key() { - "未设置".into() + language.pick("未设置", "Not set").into() } else { - "此供应商不需要 API Key".into() + language + .pick("此供应商不需要 API Key", "This provider does not require an API key") + .into() }; let enabled = provider.enabled; let goals = provider.codex_goals; @@ -2469,8 +3050,11 @@ impl SettingsPane { editor = editor .child( self.row( - "启用", - "关掉后这个供应商不再出现在 AI 启动器里,配置和密钥都留着,随时可以开回来。", + language.pick("启用", "Enabled"), + language.pick( + "关掉后这个供应商不再出现在 AI 启动器里,配置和密钥都留着,随时可以开回来。", + "When disabled, this provider is hidden from the AI launcher. Its settings and key are kept so it can be enabled again later.", + ), crate::gpui_shell::widgets::NebulaSwitch::new("provider-enabled") .checked(enabled) .on_click(cx.listener(|this, value: &bool, _, cx| { @@ -2480,39 +3064,48 @@ impl SettingsPane { ), ) .child(self.row( - "名称", + language.pick("名称", "Name"), "", div().w(px(330.0)).child(Input::new(&self.provider_inputs[0])), cx, )) .child(self.row( - "备注", + language.pick("备注", "Note"), "", div().w(px(330.0)).child(Input::new(&self.provider_inputs[1])), cx, )) .child(self.row( - "官方网站", + language.pick("官方网站", "Official website"), "", div().w(px(330.0)).child(Input::new(&self.provider_inputs[2])), cx, )) .child(self.row( - "API 请求地址", - "供应商的 API 根地址,多数以 `/v1` 结尾。这里填错不会在保存时报错,而是等到第一次对话请求才失败。", + language.pick("API 请求地址", "API endpoint"), + language.pick( + "供应商的 API 根地址,多数以 `/v1` 结尾。这里填错不会在保存时报错,而是等到第一次对话请求才失败。", + "The provider's API base URL, usually ending in `/v1`. An invalid address is detected by the first conversation request, not when these settings are saved.", + ), div().w(px(330.0)).child(Input::new(&self.provider_inputs[3])), cx, )) .child(self.row( - "默认模型", - "新会话默认用的模型名,按供应商文档里的写法逐字填——名字对不上时同样是发起请求那一刻才报错。", + language.pick("默认模型", "Default model"), + language.pick( + "新会话默认用的模型名,按供应商文档里的写法逐字填——名字对不上时同样是发起请求那一刻才报错。", + "The model name used for new conversations. Enter it exactly as documented by the provider; an invalid name is detected when a request is sent.", + ), div().w(px(330.0)).child(Input::new(&self.provider_inputs[4])), cx, )) .child( self.row( "API Key", - "密钥不写进设置文件,这里只显示它是否已经设过。换一把新的直接点替换,旧的会被覆盖。", + language.pick( + "密钥不写进设置文件,这里只显示它是否已经设过。换一把新的直接点替换,旧的会被覆盖。", + "The key is not written to the settings file. This row only shows whether one is stored; replacing it overwrites the old key.", + ), h_flex() .gap_2() .items_center() @@ -2525,9 +3118,9 @@ impl SettingsPane { .child( NebulaButton::new("provider-set-key") .label(if provider.api_key_set { - "替换…" + language.pick("替换…", "Replace...") } else { - "设置…" + language.pick("设置…", "Set...") }) .on_click(cx.listener(|this, _, _, cx| { this.prompt_provider_key(cx); @@ -2539,7 +3132,10 @@ impl SettingsPane { .child( self.row( "Codex Goals", - "写进 `~/.codex/config.toml` 的 `features.goals`。这是 Codex 自己的特性开关,Nebula 只负责把它落到配置里,其它供应商不受影响。", + language.pick( + "写进 `~/.codex/config.toml` 的 `features.goals`。这是 Codex 自己的特性开关,Nebula 只负责把它落到配置里,其它供应商不受影响。", + "Writes `features.goals` to `~/.codex/config.toml`. This is a Codex feature flag; Nebula only persists it and other providers are unaffected.", + ), crate::gpui_shell::widgets::NebulaSwitch::new("provider-codex-goals") .checked(goals) .on_click(cx.listener(|this, value: &bool, _, cx| { @@ -2550,8 +3146,11 @@ impl SettingsPane { ) .child( self.row( - "Codex 远程压缩", - "同上,对应 `features.remote_compaction_v2`。同样只在用 Codex 时生效。", + language.pick("Codex 远程压缩", "Codex remote compaction"), + language.pick( + "同上,对应 `features.remote_compaction_v2`。同样只在用 Codex 时生效。", + "As above, this controls `features.remote_compaction_v2` and only applies to Codex.", + ), crate::gpui_shell::widgets::NebulaSwitch::new("provider-codex-remote") .checked(remote) .on_click(cx.listener(|this, value: &bool, _, cx| { @@ -2563,7 +3162,7 @@ impl SettingsPane { .child( h_flex() .gap_2() - .child(NebulaButton::new("provider-save").label("保存").on_click( + .child(NebulaButton::new("provider-save").label(language.pick("保存", "Save")).on_click( cx.listener(|this, _, _, cx| { this.save_provider_metadata(cx); cx.notify(); @@ -2572,22 +3171,22 @@ impl SettingsPane { .child( NebulaButton::new("provider-test") .label(if self.provider_test_running { - "测试中…" + language.pick("测试中…", "Testing...") } else { - "测试连接" + language.pick("测试连接", "Test connection") }) .disabled(self.provider_test_running) .on_click(cx.listener(|this, _, _, cx| { this.test_provider(cx); })), ) - .child(NebulaButton::new("provider-codex").label("应用到 Codex").on_click( + .child(NebulaButton::new("provider-codex").label(language.pick("应用到 Codex", "Apply to Codex")).on_click( cx.listener(|this, _, _, cx| { this.apply_provider_to_codex(cx); }), )) .child( - NebulaButton::new("provider-delete").label("删除").danger().on_click( + NebulaButton::new("provider-delete").label(language.pick("删除", "Delete")).danger().on_click( cx.listener(|this, _, window, cx| { this.delete_provider(window, cx); }), @@ -2595,10 +3194,14 @@ impl SettingsPane { ), ); } else { - editor = editor.child(div().text_color(theme.muted_foreground).child("没有供应商配置")); + editor = editor.child( + div() + .text_color(theme.muted_foreground) + .child(language.pick("没有供应商配置", "No provider configured")), + ); } - self.group("供应商", cx) + self.group(language.pick("供应商", "Providers"), cx) .child( h_flex() .w_full() @@ -2613,16 +3216,18 @@ impl SettingsPane { .overflow_y_scrollbar() .children(provider_rows) .child( - NebulaButton::new("provider-add").label("+ 自定义供应商").on_click( - cx.listener(|this, _, window, cx| { + NebulaButton::new("provider-add") + .label(language.pick("+ 自定义供应商", "+ Custom provider")) + .on_click(cx.listener(|this, _, window, cx| { this.add_provider(window, cx); - }), - ), + })), ), ) .child(editor), ) - .when_some(self.provider_status.clone(), |group, (message, error)| { + .when_some(self.provider_status.clone(), |group, status| { + let error = status.is_error(); + let message = status.text(language); group.child( div() .text_color(if error { theme.danger } else { theme.success }) @@ -2638,23 +3243,23 @@ impl SettingsPane { fn backup_passphrase(&mut self, cx: &mut Context) -> Option { let pass = self.backup_pass_input.read(cx).value().to_string(); if pass.chars().count() < 8 { - self.backup_status = Some(("备份密码至少 8 位".to_owned(), true)); + self.backup_status = Some(BackupStatus::PassphraseTooShort); cx.notify(); return None; } Some(pass) } - /// 后台执行一段备份计算并把结果写回状态行。`Ok` 文案由任务给出。 + /// 后台任务只返回语义结果,显示语言在渲染时决定,切换语言不会留下旧文案。 fn backup_run_async( &mut self, - task: impl std::future::Future> + Send + 'static, + task: impl std::future::Future> + Send + 'static, cx: &mut Context, ) { self.backup_seq = self.backup_seq.wrapping_add(1); let seq = self.backup_seq; self.backup_busy = true; - self.backup_status = Some(("处理中…".to_owned(), false)); + self.backup_status = Some(BackupStatus::Processing); let task = cx.background_executor().spawn(task); cx.spawn(async move |this, cx| { let result = task.await; @@ -2664,8 +3269,8 @@ impl SettingsPane { } pane.backup_busy = false; pane.backup_status = Some(match result { - Ok(message) => (message, false), - Err(error) => (error, true), + Ok(completion) => BackupStatus::Completed(completion), + Err(error) => BackupStatus::Error(error), }); cx.notify(); }); @@ -2681,7 +3286,7 @@ impl SettingsPane { } let Some(pass) = self.backup_passphrase(cx) else { return }; if self.backup_selection.is_empty() { - self.backup_status = Some(("请至少勾选一个备份类别".to_owned(), true)); + self.backup_status = Some(BackupStatus::SelectionRequired); cx.notify(); return; } @@ -2704,7 +3309,7 @@ impl SettingsPane { let packet = crate::encrypted_backup::seal(&archive, &pass)?; std::fs::write(&path, packet) .map_err(|err| format!("写入备份文件失败:{err}"))?; - Ok(format!("已导出加密备份:{}", path.display())) + Ok(BackupCompletion::Exported(path)) }, cx, ); @@ -2723,7 +3328,11 @@ impl SettingsPane { files: true, directories: false, multiple: false, - prompt: Some("选择 Nebula 加密备份".into()), + prompt: Some( + crate::gpui_shell::config::ui_language(cx) + .pick("选择 Nebula 加密备份", "Select an encrypted Nebula backup") + .into(), + ), }); cx.spawn(async move |this, cx| { let Ok(Ok(Some(paths))) = picked.await else { return }; @@ -2734,7 +3343,7 @@ impl SettingsPane { let packet = std::fs::read(&path) .map_err(|err| format!("读取备份文件失败:{err}"))?; crate::encrypted_backup::restore(&packet, &pass)?; - Ok("已从备份恢复(字体/托盘等部分设置重启后生效)".to_owned()) + Ok(BackupCompletion::Restored) }, cx, ); @@ -2774,8 +3383,8 @@ impl SettingsPane { input_ix += 1; } self.backup_status = Some(match self.backup_remote.save() { - Ok(()) => ("远端配置已保存".to_owned(), false), - Err(err) => (err, true), + Ok(()) => BackupStatus::RemoteConfigSaved, + Err(err) => BackupStatus::Error(err), }); cx.notify(); } @@ -2785,7 +3394,7 @@ impl SettingsPane { self.save_remote_config(cx); let secret = self.backup_secret_input.read(cx).value().to_string(); if secret.is_empty() { - self.backup_status = Some(("凭据不能为空".to_owned(), true)); + self.backup_status = Some(BackupStatus::CredentialEmpty); cx.notify(); return; } @@ -2799,11 +3408,15 @@ impl SettingsPane { crate::backup_remote::BackupProtocol::S3 => { crate::backup_remote::store_s3_secret(&self.backup_remote.s3_access_key, &secret) }, - _ => Err("当前协议不需要独立凭据".to_owned()), + _ => { + self.backup_status = Some(BackupStatus::CredentialUnsupported); + cx.notify(); + return; + }, }; self.backup_status = Some(match result { - Ok(()) => ("凭据已写入系统凭据管理器".to_owned(), false), - Err(err) => (err, true), + Ok(()) => BackupStatus::CredentialSaved, + Err(err) => BackupStatus::Error(err), }); self.backup_secret_input.update(cx, |input, cx| input.set_value("", window, cx)); cx.notify(); @@ -2817,7 +3430,7 @@ impl SettingsPane { let Some(pass) = self.backup_passphrase(cx) else { return }; self.save_remote_config(cx); if self.backup_selection.is_empty() { - self.backup_status = Some(("请至少勾选一个备份类别".to_owned(), true)); + self.backup_status = Some(BackupStatus::SelectionRequired); cx.notify(); return; } @@ -2828,7 +3441,7 @@ impl SettingsPane { let archive = crate::encrypted_backup::collect(selection)?; let packet = crate::encrypted_backup::seal(&archive, &pass)?; let location = crate::backup_remote::push(&packet)?; - Ok(format!("已推送到 {location}")) + Ok(BackupCompletion::Pushed(location)) }, cx, ); @@ -2846,7 +3459,7 @@ impl SettingsPane { crate::backup_remote::validate()?; let (name, packet) = crate::backup_remote::pull_latest()?; crate::encrypted_backup::restore(&packet, &pass)?; - Ok(format!("已从 {name} 恢复(部分设置重启后生效)")) + Ok(BackupCompletion::Pulled(name)) }, cx, ); @@ -2890,6 +3503,7 @@ impl SettingsPane { fn section_backup(&mut self, cx: &mut Context) -> gpui::Div { use crate::backup_remote::BackupProtocol; + let language = crate::gpui_shell::config::ui_language(cx); let theme = cx.theme(); let muted = theme.muted_foreground; let busy = self.backup_busy; @@ -2897,25 +3511,70 @@ impl SettingsPane { // 类别开关(与共享 `BackupSelection` 一一对应)。 let categories: [(&'static str, &'static str, bool, fn(&mut Self, bool)); 9] = [ - ("bk-appearance", "外观与主题", selection.appearance, |s, v| { - s.backup_selection.appearance = v; - }), - ("bk-config", "终端配置", selection.config, |s, v| s.backup_selection.config = v), - ("bk-ssh", "SSH 主机(脱敏)", selection.ssh, |s, v| s.backup_selection.ssh = v), - ("bk-sync", "同步配置", selection.sync, |s, v| s.backup_selection.sync = v), - ("bk-assistant", "AI 助手配置", selection.assistant, |s, v| { - s.backup_selection.assistant = v; - }), - ("bk-session", "会话与工作区", selection.session, |s, v| { - s.backup_selection.session = v; - }), - ("bk-dirhist", "目录历史", selection.directory_history, |s, v| { - s.backup_selection.directory_history = v; - }), - ("bk-cmdhist", "命令历史", selection.command_history, |s, v| { - s.backup_selection.command_history = v; - }), - ("bk-fonts", "自装字体", selection.fonts, |s, v| s.backup_selection.fonts = v), + ( + "bk-appearance", + language.pick("外观与主题", "Appearance and themes"), + selection.appearance, + |s, v| { + s.backup_selection.appearance = v; + }, + ), + ( + "bk-config", + language.pick("终端配置", "Terminal configuration"), + selection.config, + |s, v| s.backup_selection.config = v, + ), + ( + "bk-ssh", + language.pick("SSH 主机(脱敏)", "SSH hosts (redacted)"), + selection.ssh, + |s, v| s.backup_selection.ssh = v, + ), + ( + "bk-sync", + language.pick("同步配置", "Sync configuration"), + selection.sync, + |s, v| s.backup_selection.sync = v, + ), + ( + "bk-assistant", + language.pick("AI 助手配置", "AI assistant configuration"), + selection.assistant, + |s, v| { + s.backup_selection.assistant = v; + }, + ), + ( + "bk-session", + language.pick("会话与工作区", "Sessions and workspaces"), + selection.session, + |s, v| { + s.backup_selection.session = v; + }, + ), + ( + "bk-dirhist", + language.pick("目录历史", "Directory history"), + selection.directory_history, + |s, v| { + s.backup_selection.directory_history = v; + }, + ), + ( + "bk-cmdhist", + language.pick("命令历史", "Command history"), + selection.command_history, + |s, v| { + s.backup_selection.command_history = v; + }, + ), + ( + "bk-fonts", + language.pick("自装字体", "Installed fonts"), + selection.fonts, + |s, v| s.backup_selection.fonts = v, + ), ]; // 这九行是一份勾选清单,不逐项写说明:清单的价值在于一眼扫完, // 每行挂两行小字会把它撑成九屏。范围与边界由组顶部那句统一交代 @@ -2936,8 +3595,8 @@ impl SettingsPane { let protocol = self.backup_remote.protocol; let protocol_buttons = [ - (BackupProtocol::Off, "关闭"), - (BackupProtocol::Folder, "目录"), + (BackupProtocol::Off, language.pick("关闭", "Off")), + (BackupProtocol::Folder, language.pick("目录", "Folder")), (BackupProtocol::WebDav, "WebDAV"), (BackupProtocol::S3, "S3"), (BackupProtocol::Sftp, "SFTP"), @@ -2956,10 +3615,18 @@ impl SettingsPane { // 非密文槽位标签(顺序 = `BackupRemoteConfig::slot` 的下标序)。 let slot_labels: &[&'static str] = match protocol { BackupProtocol::Off => &[], - BackupProtocol::Folder => &["目标目录"], - BackupProtocol::WebDav => &["WebDAV 地址", "用户名"], - BackupProtocol::S3 => &["Endpoint", "Region", "桶/前缀", "Access Key"], - BackupProtocol::Sftp => &["SSH 目的地 (user@host)", "远端目录"], + BackupProtocol::Folder => &[language.pick("目标目录", "Target folder")], + BackupProtocol::WebDav => &[ + language.pick("WebDAV 地址", "WebDAV address"), + language.pick("用户名", "Username"), + ], + BackupProtocol::S3 => { + &["Endpoint", "Region", language.pick("桶/前缀", "Bucket/prefix"), "Access Key"] + }, + BackupProtocol::Sftp => &[ + language.pick("SSH 目的地 (user@host)", "SSH destination (user@host)"), + language.pick("远端目录", "Remote directory"), + ], }; let slot_rows = slot_labels.iter().enumerate().map(|(ix, label)| { let input = self.backup_remote_inputs.get(ix).cloned(); @@ -2973,16 +3640,18 @@ impl SettingsPane { let secret_ready = crate::backup_remote::protocol_secret_set(protocol); let secret_label = match protocol { - BackupProtocol::WebDav => Some("WebDAV 密码"), + BackupProtocol::WebDav => Some(language.pick("WebDAV 密码", "WebDAV password")), BackupProtocol::S3 => Some("S3 Secret Key"), _ => None, }; let mut remote_group = self - .group("远端同步", cx) + .group(language.pick("远端同步", "Remote sync"), cx) .child(div().text_xs().text_color(muted).child( - "推送 = 当前勾选类别加密打包后上传;恢复最新 = 拉取远端最新包解密落盘。\ - SFTP 复用上方 SSH 主机的认证。", + language.pick( + "推送 = 当前勾选类别加密打包后上传;恢复最新 = 拉取远端最新包解密落盘。SFTP 复用上方 SSH 主机的认证。", + "Push encrypts and uploads the selected categories. Restore latest downloads, decrypts, and applies the newest remote archive. SFTP reuses SSH host authentication configured above.", + ), )) .child(h_flex().gap_2().children(protocol_buttons)) .children(slot_rows); @@ -2995,16 +3664,18 @@ impl SettingsPane { .gap_2() .items_center() .child(div().text_xs().text_color(muted).child(if secret_ready { - "已设置" + language.pick("已设置", "Set") } else { - "未设置" + language.pick("未设置", "Not set") })) .child(div().w(px(220.0)).child(Input::new(&self.backup_secret_input))) - .child(NebulaButton::new("bk-store-secret").label("保存凭据").on_click( - cx.listener(|this, _, window, cx| { - this.store_remote_secret(window, cx); - }), - )), + .child( + NebulaButton::new("bk-store-secret") + .label(language.pick("保存凭据", "Save credential")) + .on_click(cx.listener(|this, _, window, cx| { + this.store_remote_secret(window, cx); + })), + ), cx, ), ); @@ -3013,41 +3684,51 @@ impl SettingsPane { remote_group = remote_group.child( h_flex() .gap_2() - .child(NebulaButton::new("bk-save-remote").label("保存配置").on_click( - cx.listener(|this, _, _, cx| { - this.save_remote_config(cx); - }), - )) + .child( + NebulaButton::new("bk-save-remote") + .label(language.pick("保存配置", "Save configuration")) + .on_click(cx.listener(|this, _, _, cx| { + this.save_remote_config(cx); + })), + ) .child( NebulaButton::new("bk-push") - .label(if busy { "处理中…" } else { "立即推送" }) + .label(if busy { + language.pick("处理中…", "Processing...") + } else { + language.pick("立即推送", "Push now") + }) .disabled(busy) .on_click(cx.listener(|this, _, _, cx| { this.push_remote(cx); })), ) .child( - NebulaButton::new("bk-pull").label("恢复最新备份").disabled(busy).on_click( - cx.listener(|this, _, _, cx| { + NebulaButton::new("bk-pull") + .label(language.pick("恢复最新备份", "Restore latest backup")) + .disabled(busy) + .on_click(cx.listener(|this, _, _, cx| { this.pull_remote(cx); - }), - ), + })), ), ); } let local_group = self - .group("加密备份", cx) + .group(language.pick("加密备份", "Encrypted backup"), cx) .child( div() .text_xs() .text_color(muted) - .child("端到端加密(密码不落盘);SSH 私钥永不进包,主机列表脱敏导出。"), + .child(language.pick( + "端到端加密(密码不落盘);SSH 私钥永不进包,主机列表脱敏导出。", + "End-to-end encrypted; the password is never persisted. SSH private keys are never included and host lists are exported with sensitive data removed.", + )), ) .children(category_rows) .child(self.row( - "备份密码", - "导出时用它加密整个包,恢复时要一模一样的一串。密码不落盘、也无从找回——忘了这份备份就打不开了。", + language.pick("备份密码", "Backup password"), + language.pick("导出时用它加密整个包,恢复时要一模一样的一串。密码不落盘、也无从找回——忘了这份备份就打不开了。", "This password encrypts the entire export and the exact same value is required to restore it. It is neither persisted nor recoverable; losing it makes the backup unreadable."), div().w(px(300.0)).child(Input::new(&self.backup_pass_input)), cx, )) @@ -3056,7 +3737,11 @@ impl SettingsPane { .gap_2() .child( NebulaButton::new("bk-export") - .label(if busy { "处理中…" } else { "导出到文件…" }) + .label(if busy { + language.pick("处理中…", "Processing...") + } else { + language.pick("导出到文件…", "Export to file...") + }) .disabled(busy) .on_click(cx.listener(|this, _, _, cx| { this.export_backup(cx); @@ -3064,7 +3749,7 @@ impl SettingsPane { ) .child( NebulaButton::new("bk-restore") - .label("从文件恢复…") + .label(language.pick("从文件恢复…", "Restore from file...")) .disabled(busy) .on_click(cx.listener(|this, _, _, cx| { this.restore_backup(cx); @@ -3074,7 +3759,9 @@ impl SettingsPane { v_flex().w_full().gap(px(GROUP_GAP)).child(local_group).child(remote_group).when_some( self.backup_status.clone(), - |page, (message, error)| { + |page, status| { + let error = status.is_error(); + let message = status.text(language); page.child( div() .pt_4() @@ -3086,97 +3773,99 @@ impl SettingsPane { } fn section_interaction(&mut self, cx: &mut Context) -> gpui::Div { + let language = crate::gpui_shell::config::ui_language(cx); // 分区名已经写在页头上,组标题再叫一遍"交互"就是同一个词说两遍。 // 拆成两组反而各自有了名字:一组管鼠标怎么用,一组管标签往哪放。 v_flex() .w_full() .gap(px(GROUP_GAP)) .child( - self.group("鼠标与选区", cx) + self.group(language.pick("鼠标与选区", "Mouse and selection"), cx) .child(self.switch_row( "copy_on_select", - "选中即复制(关 = 右键复制/粘贴)", - "开着时松开鼠标就进剪贴板,右键直接粘贴。关掉后选中不复制,右键弹菜单——Windows 终端的老习惯。", + language.pick("选中即复制(关 = 右键复制/粘贴)", "Copy on select (off = right-click copy/paste)"), + language.pick("开着时松开鼠标就进剪贴板,右键直接粘贴。关掉后选中不复制,右键弹出复制/粘贴菜单。", "When enabled, releasing the mouse copies the selection and right-click pastes. When disabled, selection does not copy and right-click opens a copy/paste menu."), self.runtime.copy_on_select, cx, )) .child(self.switch_row( "panel_resize", - "拖拽调节侧栏宽度", - "关掉后侧栏与面板的分界线钉死,拖不动;宽度仍可在别处改,只是不会被误拖。", + language.pick("拖拽调节侧栏宽度", "Drag to resize the sidebar"), + language.pick("关掉后侧栏与面板的分界线钉死,拖不动;宽度仍可在别处改,只是不会被误拖。", "When disabled, the divider between the sidebar and panel cannot be dragged. The width can still be changed elsewhere without accidental resizing."), self.runtime.panel_resize, cx, )) .child(self.switch_row( "cjk_bold_regular", - "CJK 粗体使用常规字形(提亮不加粗)", - "中日韩字形笔画密,字体引擎合成的伪粗体会糊成一团。开着时这些字改用提亮表达加粗,拉丁字母照旧走真粗体。", + language.pick("CJK 粗体使用常规字形(提亮不加粗)", "Use regular glyphs for CJK bold (brighten without thickening)"), + language.pick("中日韩字形笔画密,字体引擎合成的伪粗体会糊成一团。开着时这些字改用提亮表达加粗,拉丁字母照旧走真粗体。", "Synthetic bold can blur dense CJK glyphs. When enabled, CJK bold is represented by brighter regular glyphs while Latin text still uses true bold."), self.runtime.cjk_bold_regular, cx, )), ) .child( - self.group("标签与窗口", cx) + self.group(language.pick("标签与窗口", "Tabs and windows"), cx) .child(self.select_row( "tabs_position", - "标签栏位置", - "左侧边栏放得下完整路径和分屏结构,顶部则把纵向空间全留给终端。", + language.pick("标签栏位置", "Tab bar position"), + language.pick("左侧边栏放得下完整路径和分屏结构,顶部则把纵向空间全留给终端。", "The left sidebar can show full paths and split structure; the top position reserves all vertical space for the terminal."), cx, )) .child(self.select_row( "tab_reveal", - "标签展开动效", - "滑动=新标签带位移动画;即时=直接出现。远程桌面或低配机上选即时能少掉一次重绘。", + language.pick("标签展开动效", "Tab reveal animation"), + language.pick("滑动=新标签带位移动画;即时=直接出现。远程桌面或低配机上选即时能少掉一次重绘。", "Slide animates new tabs into place; Instant shows them immediately. Instant avoids an extra redraw on remote desktops or slower computers."), cx, )) .child(self.select_row( "new_tab_position", - "新标签位置", - "只管新建的标签插在哪。会话恢复与工作区导入按各自记录的顺序排,不看这一项。", + language.pick("新标签位置", "New tab position"), + language.pick("只管新建的标签插在哪。会话恢复与工作区导入按各自记录的顺序排,不看这一项。", "Controls only where newly created tabs are inserted. Restored sessions and imported workspaces retain their recorded order."), cx, )) .child(self.select_row( "windowing_behavior", - "新建实例行为", - "从桌面或命令行再启动一次 Nebula 时:另开一个窗口,还是把它作为新标签并进已有窗口。", + language.pick("新建实例行为", "New instance behavior"), + language.pick("从桌面或命令行再启动一次 Nebula 时:另开一个窗口,还是把它作为新标签并进已有窗口。", "When Nebula is launched again from the desktop or command line, choose whether to open a new window or attach the request as a tab in an existing window."), cx, )) .child(self.select_row( "vcs_display", - "侧栏版本控制(Git/SVN)", - "侧栏那块状态读哪种仓库。自动检测按目录里的 `.git` / `.svn` 判断,只有两者并存时才需要手动指定。", + language.pick("侧栏版本控制(Git/SVN)", "Sidebar version control (Git/SVN)"), + language.pick("侧栏那块状态读哪种仓库。自动检测按目录里的 `.git` / `.svn` 判断,只有两者并存时才需要手动指定。", "Chooses which repository the sidebar status reads. Auto detect checks `.git` and `.svn`; manual selection is only needed when both are present."), cx, )), ) } fn section_advanced(&mut self, cx: &mut Context) -> gpui::Div { - self.group("会话生命周期", cx) + let language = crate::gpui_shell::config::ui_language(cx); + self.group(language.pick("会话生命周期", "Session lifecycle"), cx) .child(self.switch_row( "keep_session", - "关窗后保留后台会话", - "开着时点 × 只是把窗口收走,里面的 shell 继续在常驻进程里跑、可以再附着回来;关掉则连 shell 一起杀,未保存的东西会丢。", + language.pick("关窗后保留后台会话", "Keep sessions running after the window closes"), + language.pick("开着时点 × 只是把窗口收走,里面的 shell 继续在常驻进程里跑、可以再附着回来;关掉则连 shell 一起杀,未保存的东西会丢。", "When enabled, closing the window leaves its shells running in the resident process so they can be reattached. When disabled, closing the window terminates them and unsaved work is lost."), self.runtime.keep_session, cx, )) .child(self.switch_row( "restore_session", - "启动时恢复上次标签", - "重开时按上次的标签与分屏布局重建,工作目录一起回来。进程不会续命——恢复出来的是新 shell。", + language.pick("启动时恢复上次标签", "Restore previous tabs at startup"), + language.pick("重开时按上次的标签与分屏布局重建,工作目录一起回来。进程不会续命——恢复出来的是新 shell。", "Rebuilds the previous tab and split layout, including working directories. Processes are not resumed; restored tabs start new shells."), self.runtime.restore_session, cx, )) .child(self.switch_row( "resume_ai", - "恢复会话时自动接续 AI 对话", - "恢复出来的 AI 标签接着上次那段对话,而不是开一段新的。", + language.pick("恢复会话时自动接续 AI 对话", "Resume AI conversations when restoring sessions"), + language.pick("恢复出来的 AI 标签接着上次那段对话,而不是开一段新的。", "Restored AI tabs continue their previous conversation instead of starting a new one."), self.runtime.resume_ai, cx, )) .child(self.switch_row( "tray", - "常驻系统托盘图标", - "在通知区域留一个图标,正在跑的 AI 会话从那里能直接看到状态。", + language.pick("常驻系统托盘图标", "Keep an icon in the system tray"), + language.pick("在通知区域留一个图标,正在跑的 AI 会话从那里能直接看到状态。", "Keeps an icon in the notification area where the status of running AI sessions is visible."), self.runtime.tray, cx, )) @@ -3201,6 +3890,7 @@ impl SettingsPane { fn render_nav(&self, cx: &mut Context) -> gpui::AnyElement { use gpui::IntoElement as _; + let language = crate::gpui_shell::config::ui_language(cx); let theme = cx.theme(); let muted = theme.muted_foreground; // 选中底直接取 workspace 侧栏那一枚 token。两处都是"当前在哪"的 @@ -3260,7 +3950,7 @@ impl SettingsPane { .child(Icon::default().path(section_icon(ix)).small().text_color( if active { active_fg } else { muted }, )) - .child(SECTIONS[ix]), + .child(section_label(ix, language)), ); } nav.into_any_element() @@ -3277,6 +3967,7 @@ impl Focusable for SettingsPane { impl Render for SettingsPane { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let language = crate::gpui_shell::config::ui_language(cx); let nav = self.render_nav(cx); let content = self.section_content(window, cx); // 这里**不再**挂 font_family。旧壳设置页整页走终端 mono,是因为自绘 @@ -3373,7 +4064,7 @@ impl Render for SettingsPane { // 的锚点,允许它比正文大一档。 .text_size(px(base_px * 1.2)) .font_weight(gpui::FontWeight::SEMIBOLD) - .child(SECTIONS[self.active_section]), + .child(section_label(self.active_section, language)), ) .when(show_reset, |header| { header.child( @@ -3387,8 +4078,12 @@ impl Render for SettingsPane { .cursor_pointer() .hover(|el| el.bg(cx.theme().list_hover)) .tooltip(|window, cx| { + let language = crate::gpui_shell::config::ui_language(cx); gpui_component::tooltip::Tooltip::new( - "还原外观为默认值", + language.pick( + "还原外观为默认值", + "Restore appearance defaults", + ), ) .build(window, cx) }) @@ -3537,27 +4232,90 @@ mod tests { #[test] fn settings_nav_visibility_keeps_stable_routes_but_hides_two_entries() { - let visibility: Vec<_> = (0..SECTIONS.len()).map(is_nav_section_visible).collect(); + let visibility: Vec<_> = (0..SECTION_IDS.len()).map(is_nav_section_visible).collect(); assert_eq!(visibility, vec![true, true, true, false, true, true, true, true, true, false]); + assert_eq!( + SECTION_IDS, + [ + "application", + "appearance", + "profiles", + "providers", + "ssh", + "network", + "interaction", + "keymap", + "advanced", + "backup", + ] + ); } #[test] fn settings_nav_preserves_the_group_expansion_order_without_headings() { let visible: Vec<_> = visible_nav_sections().collect(); assert_eq!(visible, vec![0, 1, 2, 6, 7, 4, 5, 8]); - let labels: Vec<_> = visible.into_iter().map(|index| SECTIONS[index]).collect(); + let zh_labels: Vec<_> = visible + .iter() + .map(|index| section_label(*index, crate::display::UiLanguage::ZhCn)) + .collect(); assert_eq!( - labels, + zh_labels, + vec!["应用", "外观", "配置文件", "交互", "按键映射", "SSH", "网络", "高级"] + ); + let en_labels: Vec<_> = visible + .iter() + .map(|index| section_label(*index, crate::display::UiLanguage::EnUs)) + .collect(); + assert_eq!( + en_labels, vec![ - "应用", - "外观", - "配置文件", - "交互", - "按键映射", + "Application", + "Appearance", + "Profiles", + "Interaction", + "Key Bindings", "SSH", - "网络", - "高级" + "Network", + "Advanced", ] ); } + + #[test] + fn localized_select_labels_keep_stable_value_cardinality() { + let cases: &[(&str, &[&str])] = &[ + ("language", &["system", "zh-CN", "en-US"]), + ("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()); + } + } + assert_eq!( + localized_select_labels("tabs_position", cases[2].1, crate::display::UiLanguage::EnUs), + vec![SharedString::from("Left sidebar"), SharedString::from("Top")] + ); + } + + #[test] + fn cached_semantic_statuses_render_in_the_current_language() { + let provider = ProviderStatus::Saved; + assert_eq!(provider.text(crate::display::UiLanguage::ZhCn), "供应商配置已保存"); + assert_eq!(provider.text(crate::display::UiLanguage::EnUs), "Provider settings saved"); + + let backup = BackupStatus::CredentialSaved; + assert_eq!(backup.text(crate::display::UiLanguage::ZhCn), "凭据已写入系统凭据管理器"); + assert_eq!( + backup.text(crate::display::UiLanguage::EnUs), + "Credential saved to the system credential manager" + ); + + let ssh = SshStatus::Opening("server.example".to_owned()); + assert_eq!(ssh.text(crate::display::UiLanguage::ZhCn), "正在打开 server.example…"); + assert_eq!(ssh.text(crate::display::UiLanguage::EnUs), "Opening server.example…"); + } } diff --git a/nebula_app/src/gpui_shell/settings_pane/design.rs b/nebula_app/src/gpui_shell/settings_pane/design.rs index aa00ee9..45100b3 100644 --- a/nebula_app/src/gpui_shell/settings_pane/design.rs +++ b/nebula_app/src/gpui_shell/settings_pane/design.rs @@ -137,7 +137,11 @@ impl SettingsPane { .group_hover(hover_group, |el| el.visible()) .hover(|el| el.bg(cx.theme().list_hover)) .tooltip(|window, cx| { - gpui_component::tooltip::Tooltip::new("还原为默认值").build(window, cx) + gpui_component::tooltip::Tooltip::new( + crate::gpui_shell::config::ui_language(cx) + .pick("还原为默认值", "Restore default"), + ) + .build(window, cx) }) .on_click(cx.listener(move |this, _, window, cx| on_reset(this, window, cx))) .child(Icon::new(IconName::Undo2).xsmall()) diff --git a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs index c57670e..e593abb 100644 --- a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs @@ -18,7 +18,12 @@ impl SettingsPane { .map(|settings| settings.font_family.clone()) .or(runtime_font_family) .unwrap_or_else(|| REQUIRED_FONT_FAMILY.to_owned()); - cx.new(|cx| InputState::new(window, cx).placeholder("输入字体名称").default_value(initial)) + let language = crate::gpui_shell::config::ui_language(cx); + cx.new(|cx| { + InputState::new(window, cx) + .placeholder(language.pick("输入字体名称", "Enter a font family")) + .default_value(initial) + }) } /// 当前生效字体链(settings.txt 覆盖 toml 后的值,可能含逗号 fallback)。 @@ -190,6 +195,7 @@ impl SettingsPane { /// 与标准 Select 共用 220px 控件列。Input 自己负责长文本的单行滚动, /// 逗号链再长也只能在字段内部移动,不能反向撑开设置行。 fn font_picker_row(&self, cx: &mut Context) -> gpui::AnyElement { + let language = crate::gpui_shell::config::ui_language(cx); let picker = cx.entity().downgrade(); let control = div() .id("font-family-input-shell") @@ -203,7 +209,7 @@ impl SettingsPane { Input::new(&self.font_family_input) .w_full() .cleanable(false) - .aria_label("终端字体"), + .aria_label(language.pick("终端字体", "Terminal font")), ) // 弹层仍须取输入框的真实窗口坐标,才能正确处理滚动、缩放与 DPI。 .child( @@ -227,8 +233,8 @@ impl SettingsPane { .size_full(), ); self.row( - "终端字体", - "第 1 项优先显示;缺失字形依次使用后续字体,内置 Maple 是最终安全兜底。", + language.pick("终端字体", "Terminal font"), + language.tr("settings.font.fallback_description"), control, cx, ) @@ -260,6 +266,7 @@ impl SettingsPane { list_height: gpui::Pixels, cx: &mut Context, ) -> gpui::Div { + let language = crate::gpui_shell::config::ui_language(cx); let muted = cx.theme().muted_foreground; let hover_bg = cx.theme().list_hover; let selected_bg = cx.theme().list_active; @@ -334,11 +341,11 @@ impl SettingsPane { }) .child(display_name), ) - .when(index == 0, |row| row.child(badge("主", muted))) + .when(index == 0, |row| row.child(badge(language.pick("主", "Primary"), muted))) .child(Self::chain_action( SharedString::from(format!("font-chain-up-{index}")), IconName::ArrowUp, - "上移", + language.pick("上移", "Move up"), index == 0, move |this, window, cx| { this.move_font_family(index, -1, window, cx); @@ -348,7 +355,7 @@ impl SettingsPane { .child(Self::chain_action( SharedString::from(format!("font-chain-down-{index}")), IconName::ArrowDown, - "下移", + language.pick("下移", "Move down"), index + 1 == family_count, move |this, window, cx| { this.move_font_family(index, 1, window, cx); @@ -358,7 +365,7 @@ impl SettingsPane { .child(Self::chain_action( SharedString::from(format!("font-chain-delete-{index}")), IconName::Delete, - "移出字体组", + language.pick("移出字体组", "Remove from font group"), family_count == 1, move |this, window, cx| this.remove_font_family(index, window, cx), cx, @@ -403,17 +410,19 @@ impl SettingsPane { }) .child(display_name), ) - .when(bundled, |row| row.child(badge("内置", muted))) + .when(bundled, |row| row.child(badge(language.pick("内置", "Bundled"), muted))) .when(entry.source == FontSource::Imported, |row| { - row.child(badge("导入", muted)) + row.child(badge(language.pick("导入", "Imported"), muted)) + }) + .when(!entry.monospaced, |row| { + row.child(badge(language.pick("比例", "Proportional"), warning)) }) - .when(!entry.monospaced, |row| row.child(badge("比例", warning))) .child( Button::new(SharedString::from(format!("font-add-{index}"))) .icon(IconName::Plus) .ghost() .xsmall() - .tooltip("加入字体组") + .tooltip(language.pick("加入字体组", "Add to font group")) .disabled(already_selected) .on_click(cx.listener(move |this, _, window, cx| { cx.stop_propagation(); @@ -436,12 +445,17 @@ impl SettingsPane { .popover_style(cx) .occlude() .overflow_hidden() - .child(div().text_sm().font_weight(gpui::FontWeight::SEMIBOLD).child("字体组")) + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(language.pick("字体组", "Font group")), + ) .child( div() .text_xs() .text_color(muted) - .child("第 1 项是主字体,其余按顺序回退"), + .child(language.tr("settings.font.group_description")), ) .when(self.font_loading, |panel| { panel.child( @@ -449,7 +463,12 @@ impl SettingsPane { .gap_2() .items_center() .child(Spinner::new().xsmall()) - .child(div().text_sm().text_color(muted).child("正在枚举系统字体…")), + .child( + div() + .text_sm() + .text_color(muted) + .child(language.tr("settings.font.loading")), + ), ) }) .when(!self.font_loading, |panel| { @@ -461,7 +480,13 @@ impl SettingsPane { // 避免排序操作与候选行落进滚动条命中区而发生误触。 .pr(px(16.0)) .gap_1() - .child(div().px_1().text_xs().text_color(muted).child("当前顺序")) + .child( + div() + .px_1() + .text_xs() + .text_color(muted) + .child(language.pick("当前顺序", "Current order")), + ) .children(selected_rows) .child( div() @@ -472,12 +497,16 @@ impl SettingsPane { .px_1() .text_xs() .text_color(muted) - .child("可用字体"), + .child(language.pick("可用字体", "Available fonts")), ) .children(available_rows) .when(available_empty, |list| { list.child( - div().py_2().text_sm().text_color(muted).child("没有匹配的字体"), + div() + .py_2() + .text_sm() + .text_color(muted) + .child(language.tr("settings.font.no_matches")), ) }), )) diff --git a/nebula_app/src/gpui_shell/settings_pane/keymap.rs b/nebula_app/src/gpui_shell/settings_pane/keymap.rs index b9ceb58..0558d6b 100644 --- a/nebula_app/src/gpui_shell/settings_pane/keymap.rs +++ b/nebula_app/src/gpui_shell/settings_pane/keymap.rs @@ -47,7 +47,7 @@ impl SettingsPane { /// 冲突检测(旧壳 `keymap_clash_info` 同义):同一 combo 多动作 → 逐行 /// 标记 + 只报第一组的提示条。 - fn keymap_clashes(&self) -> (Vec, Option) { + fn keymap_clashes(&self, language: crate::display::UiLanguage) -> (Vec, Option) { use crate::display::keymap; let total = keymap::editable_row_count(); let custom = keymap::build_bindings(&self.keymap_binds); @@ -65,11 +65,11 @@ impl SettingsPane { } let name = |flat: usize| -> String { if flat == keymap::QUICK_TERMINAL_ROW { - "快速终端".to_owned() + language.pick("快速终端", "Quick terminal").to_owned() } else { keymap::EDITABLE_ACTIONS .get(flat - 1) - .map(|(_, zh, _)| (*zh).to_owned()) + .map(|(_, zh, en)| language.pick(zh, en).to_owned()) .unwrap_or_default() } }; @@ -86,8 +86,9 @@ impl SettingsPane { rows[b] = true; if note.is_none() { let (a_name, b_name) = (name(a), name(b)); - note = Some(format!( - "{combo_a} 同时绑定了「{a_name}」与「{b_name}」——只有排前面的「{a_name}」会触发" + note = Some(language.tr_args( + "settings.keymap.conflict", + &[("combo", &combo_a), ("first", &a_name), ("second", &b_name)], )); } } @@ -208,7 +209,9 @@ impl SettingsPane { chip.border_1().border_color(theme.border).text_color(color) }) .child(if text.is_empty() && !capturing { - SharedString::from("未绑定") + SharedString::from( + crate::gpui_shell::config::ui_language(cx).pick("未绑定", "Unbound"), + ) } else { SharedString::from(text.to_owned()) }) @@ -217,13 +220,14 @@ impl SettingsPane { /// 一行「动作 + 键帽」。点击行进入捕获态(旧壳点行即捕获)。 fn keymap_row(&self, flat: usize, clash: bool, cx: &Context) -> impl IntoElement { use crate::display::keymap; + let language = crate::gpui_shell::config::ui_language(cx); let custom = keymap::build_bindings(&self.keymap_binds); let label: SharedString = if flat == keymap::QUICK_TERMINAL_ROW { - "快速终端".into() + language.pick("快速终端", "Quick terminal").into() } else { keymap::EDITABLE_ACTIONS .get(flat - 1) - .map(|(_, zh, _)| (*zh).to_owned()) + .map(|(_, zh, en)| language.pick(zh, en).to_owned()) .unwrap_or_default() .into() }; @@ -242,7 +246,7 @@ impl SettingsPane { let capturing = self.keymap_capture == Some(flat); let cap_text = if capturing { if self.keymap_capture_preview.is_empty() { - "按新按键…".to_owned() + language.pick("按新按键…", "Press a new key...").to_owned() } else { format!("{}…", self.keymap_capture_preview) } @@ -287,14 +291,15 @@ impl SettingsPane { pub(super) fn section_keymap(&mut self, cx: &mut Context) -> gpui::Div { use crate::display::keymap; + let language = crate::gpui_shell::config::ui_language(cx); let visible = self.keymap_visible(cx); - let (clash_rows, clash_note) = self.keymap_clashes(); + let (clash_rows, clash_note) = self.keymap_clashes(language); // 分组渲染(旧壳无框分组裁定):组内可见行为空的组整组隐藏;组 // 标题 0.86× 小字压在行块上方。 let mut groups_block = v_flex().w_full().gap_1(); let mut start = 0usize; - for (zh, _en, count) in keymap::GROUPS { + for (zh, en, count) in keymap::GROUPS { let end = start + count; let rows: Vec<_> = visible .iter() @@ -308,7 +313,7 @@ impl SettingsPane { .pb_1() .text_size(px(self.font_size_px(cx) * 0.86)) .text_color(cx.theme().muted_foreground) - .child(*zh), + .child(language.pick(zh, en)), ); for row in rows { groups_block = groups_block.child(row); @@ -332,9 +337,9 @@ impl SettingsPane { .pb_1() .text_size(px(self.font_size_px(cx) * 0.86)) .text_color(cx.theme().muted_foreground) - .child("只读"), + .child(language.pick("只读", "Read-only")), ); - for (zh, _en, combo) in readonly { + for (zh, en, combo) in readonly { groups_block = groups_block.child( h_flex() .w_full() @@ -348,7 +353,7 @@ impl SettingsPane { .min_w_0() .pl_4() .text_color(crate::gpui_shell::theme::faint_ink(cx)) - .child(*zh), + .child(language.pick(zh, en)), ) .child( div() @@ -407,7 +412,7 @@ impl SettingsPane { .pt_4() .text_size(px(self.font_size_px(cx) * 0.86)) .text_color(cx.theme().muted_foreground) - .child("点击行改键 · Backspace 恢复默认 · Esc 取消"), + .child(language.tr("settings.keymap.help")), ) } } diff --git a/nebula_app/src/gpui_shell/ssh_settings.rs b/nebula_app/src/gpui_shell/ssh_settings.rs index 948ca5d..c011794 100644 --- a/nebula_app/src/gpui_shell/ssh_settings.rs +++ b/nebula_app/src/gpui_shell/ssh_settings.rs @@ -24,7 +24,7 @@ use gpui::{ }; use crate::gpui_shell::prelude::*; -use crate::gpui_shell::settings_pane::{SettingsPane, SettingsPaneEvent}; +use crate::gpui_shell::settings_pane::{SettingsPane, SettingsPaneEvent, SshStatus}; use crate::gpui_shell::widgets::NebulaButton; /// 删除撤销窗口时长,旧壳 Undo 条同值。 @@ -53,6 +53,60 @@ const SSH_EDITOR_FOOTER_H: f32 = 56.0; const SSH_HOST_ROW_H: f32 = 58.0; const SSH_HOST_GAP: f32 = 8.0; +#[derive(Clone, Copy, Debug)] +pub(super) enum SshValidationError { + InvalidPort, + MissingDestination, + UnsafeDestination, +} + +impl SshValidationError { + pub(super) fn text(self, language: crate::display::UiLanguage) -> &'static str { + match self { + Self::InvalidPort => language + .pick("端口需要是 1–65535 之间的数字", "The port must be a number from 1 to 65535"), + Self::MissingDestination => language.pick( + "请输入 SSH 地址,例如 user@example.com", + "Enter an SSH address, for example user@example.com", + ), + Self::UnsafeDestination => language.pick( + "地址不能包含空白、控制字符或 shell 分隔符", + "The address cannot contain whitespace, control characters, or shell separators", + ), + } + } +} + +#[derive(Clone, Debug)] +pub(super) enum SshEditorTestStatus { + Connecting, + Succeeded(u64), + Failed(String), + TaskEnded, +} + +impl SshEditorTestStatus { + fn is_error(&self) -> bool { + matches!(self, Self::Failed(_) | Self::TaskEnded) + } + + fn text(&self, language: crate::display::UiLanguage) -> String { + match self { + Self::Connecting => language.pick("正在连接…", "Connecting...").into(), + Self::Succeeded(elapsed_ms) => { + format!("{} · {elapsed_ms} ms", language.pick("连接成功", "Connection succeeded")) + }, + Self::Failed(message) => message.clone(), + Self::TaskEnded => language + .pick( + "连接测试任务意外结束,请重试", + "The connection test ended unexpectedly; try again", + ) + .into(), + } + } +} + /// 设置页 SSH 添加/编辑面板的非文本草稿。文字实体常驻在 `SettingsPane`, /// 这样输入事件可以统一使测试结果失效;草稿只保存认证、密钥和编辑身份。 #[derive(Clone)] @@ -67,7 +121,7 @@ pub(super) struct SshEditorState { pub(super) show_password: bool, pub(super) revision: u64, pub(super) test_request_id: Option, - pub(super) test_status: Option<(String, bool)>, + pub(super) test_status: Option, } impl SshEditorState { @@ -108,15 +162,15 @@ impl SettingsPane { pub(super) fn ssh_apply( &mut self, mutate: impl FnOnce(&mut crate::gpui_shell::ssh_hosts::SshHostLists), - status: &str, + status: SshStatus, cx: &mut Context, ) { // 列表将被改写,未决删除的快照会过期:先提交它。 self.commit_pending_ssh_delete(); mutate(&mut self.ssh_hosts); match self.ssh_hosts.persist() { - Ok(()) => self.ssh_status = Some((status.to_owned(), false)), - Err(err) => self.ssh_status = Some((format!("写入设置失败: {err}"), true)), + Ok(()) => self.ssh_status = Some(status), + Err(err) => self.ssh_status = Some(SshStatus::PersistFailed(err.to_string())), } self.ssh_delete_confirm = None; cx.notify(); @@ -133,7 +187,7 @@ impl SettingsPane { let mut hosts = self.ssh_hosts.clone(); hosts.remove(host); if let Err(error) = hosts.persist() { - self.ssh_status = Some((format!("删除主机失败: {error}"), true)); + self.ssh_status = Some(SshStatus::DeleteFailed(error.to_string())); self.ssh_delete_confirm = None; cx.notify(); return; @@ -180,20 +234,13 @@ impl SettingsPane { } #[cfg(windows)] if let Err(error) = crate::ssh_credentials::forget_password(&host) { - cleanup_errors.push(format!("凭据: {error}")); + cleanup_errors.push(format!("Credential: {error}")); } self.ssh_status = if cleanup_errors.is_empty() { - Some(( - if undo.from_config { - "已隐藏 config 别名,并清理 Nebula Profile 与凭据".to_owned() - } else { - "已删除主机、Profile 与凭据".to_owned() - }, - false, - )) + Some(SshStatus::DeleteCommitted { hidden_config: undo.from_config }) } else { - Some((format!("主机已从列表移除,但部分清理失败: {}", cleanup_errors.join(";")), true)) + Some(SshStatus::CleanupPartial(cleanup_errors.join("; "))) }; } @@ -201,12 +248,12 @@ impl SettingsPane { pub(super) fn undo_ssh_delete(&mut self, cx: &mut Context) { let Some(undo) = self.ssh_delete_undo.take() else { return }; if let Err(error) = undo.lists_before.persist() { - self.ssh_status = Some((format!("撤销失败: {error}"), true)); + self.ssh_status = Some(SshStatus::UndoFailed(error.to_string())); cx.notify(); return; } self.ssh_hosts = undo.lists_before; - self.ssh_status = Some((format!("已恢复 {}", undo.host), false)); + self.ssh_status = Some(SshStatus::Restored(undo.host)); cx.notify(); } @@ -277,22 +324,25 @@ impl SettingsPane { cx.notify(); } - pub(super) fn ssh_destination_from_draft(&self, cx: &gpui::App) -> Result { + pub(super) fn ssh_destination_from_draft( + &self, + cx: &gpui::App, + ) -> Result { let port = self.ssh_port_input.read(cx).value().trim().to_string(); if !port.is_empty() && !port.parse::().is_ok_and(|value| value > 0) { // en-dash 区间写法与旧壳一字不差。 - return Err("端口需要是 1–65535 之间的数字".to_owned()); + return Err(SshValidationError::InvalidPort); } let address = self.ssh_destination_input.read(cx).value().trim().to_string(); let destination = crate::display::join_destination_port(&address, &port); if destination.is_empty() { - return Err("请输入 SSH 地址,例如 user@example.com".to_owned()); + return Err(SshValidationError::MissingDestination); } if destination .chars() .any(|ch| ch.is_whitespace() || ch.is_control() || ";&|<>\"'`".contains(ch)) { - return Err("地址不能包含空白、控制字符或 shell 分隔符".to_owned()); + return Err(SshValidationError::UnsafeDestination); } Ok(destination) } @@ -331,7 +381,7 @@ impl SettingsPane { } } }, - Err(message) => this.ssh_status = Some((message, true)), + Err(message) => this.ssh_status = Some(SshStatus::Error(message)), } cx.notify(); }); @@ -342,8 +392,8 @@ impl SettingsPane { pub(super) fn test_ssh_editor(&mut self, cx: &mut Context) { let destination = match self.ssh_destination_from_draft(cx) { Ok(destination) => destination, - Err(message) => { - self.ssh_status = Some((message, true)); + Err(error) => { + self.ssh_status = Some(SshStatus::Validation(error)); cx.notify(); return; }, @@ -368,14 +418,13 @@ impl SettingsPane { let receiver = match crate::ssh_session::start_test(request) { Ok(receiver) => receiver, Err(error) => { - self.ssh_status = Some((format!("无法启动连接测试: {error}"), true)); + self.ssh_status = Some(SshStatus::TestStartFailed(error.to_string())); cx.notify(); return; }, }; editor.test_request_id = Some(request_id); - // 旧壳测试态文案。 - editor.test_status = Some(("正在连接…".to_owned(), false)); + editor.test_status = Some(SshEditorTestStatus::Connecting); self.ssh_status = None; cx.spawn(async move |this, cx| { let result = receiver.await; @@ -386,11 +435,9 @@ impl SettingsPane { } editor.test_request_id = None; editor.test_status = Some(match result { - Ok(result) if result.ok => { - (format!("连接成功 · {} ms", result.elapsed_ms), false) - }, - Ok(result) => (result.message, true), - Err(_) => ("连接测试任务意外结束,请重试".to_owned(), true), + Ok(result) if result.ok => SshEditorTestStatus::Succeeded(result.elapsed_ms), + Ok(result) => SshEditorTestStatus::Failed(result.message), + Err(_) => SshEditorTestStatus::TaskEnded, }); cx.notify(); }); @@ -402,8 +449,8 @@ impl SettingsPane { pub(super) fn save_ssh_editor(&mut self, window: &mut Window, cx: &mut Context) { let destination = match self.ssh_destination_from_draft(cx) { Ok(destination) => destination, - Err(message) => { - self.ssh_status = Some((message, true)); + Err(error) => { + self.ssh_status = Some(SshStatus::Validation(error)); cx.notify(); return; }, @@ -414,7 +461,7 @@ impl SettingsPane { let mut profiles = match crate::ssh_profiles::SshProfiles::load(&profile_path) { Ok(profiles) => profiles, Err(error) => { - self.ssh_status = Some((format!("加载 SSH Profile 失败: {error}"), true)); + self.ssh_status = Some(SshStatus::ProfileLoadFailed(error.to_string())); self.ssh_editor = Some(editor); cx.notify(); return; @@ -430,7 +477,9 @@ impl SettingsPane { hosts.remove(original); } let label = match self.ssh_label_input.read(cx).value().trim() { - "" => profiles.next_default_label("主机"), + "" => profiles.next_default_label( + crate::gpui_shell::config::ui_language(cx).pick("主机", "Host"), + ), value => value.to_owned(), }; profiles.upsert(crate::ssh_profiles::SshProfileAuth { @@ -441,14 +490,14 @@ impl SettingsPane { icon: editor.icon.clone(), }); if let Err(error) = profiles.save(&profile_path) { - self.ssh_status = Some((format!("保存 SSH Profile 失败: {error}"), true)); + self.ssh_status = Some(SshStatus::ProfileSaveFailed(error.to_string())); self.ssh_editor = Some(editor); cx.notify(); return; } hosts.remember(&destination); if let Err(error) = hosts.persist() { - self.ssh_status = Some((format!("保存主机列表失败: {error}"), true)); + self.ssh_status = Some(SshStatus::HostListSaveFailed(error.to_string())); self.ssh_editor = Some(editor); cx.notify(); return; @@ -463,8 +512,7 @@ impl SettingsPane { if let Err(error) = crate::ssh_credentials::store_password(&destination, password.as_bytes()) { - self.ssh_status = - Some((format!("Profile 已保存,但密码写入凭据管理器失败: {error}"), true)); + self.ssh_status = Some(SshStatus::CredentialSaveFailed(error.to_string())); self.ssh_editor = Some(editor); cx.notify(); return; @@ -491,8 +539,13 @@ impl SettingsPane { editor.private_keys.clear(); self.ssh_editor = None; self.ssh_status = credential_cleanup_error.map_or_else( - || Some((format!("已保存 {destination}"), false)), - |error| Some((format!("已保存 {destination},但旧地址凭据清理失败: {error}"), true)), + || Some(SshStatus::Saved(destination.clone())), + |error| { + Some(SshStatus::SavedWithCleanupError { + destination: destination.clone(), + error: error.to_string(), + }) + }, ); window.focus(&self.focus_handle, cx); cx.notify(); @@ -501,6 +554,7 @@ impl SettingsPane { // ---- UI:添加/编辑弹窗 ---- pub(super) fn ssh_editor_modal(&mut self, cx: &mut Context) -> Option { + let language = crate::gpui_shell::config::ui_language(cx); let editor = self.ssh_editor.as_ref()?.clone(); // 头像与它的弹层先建好:两者都要 `&mut Context`(listener / 实体 // 弱引用),而下面的 `cx.theme()` 会把 cx 借成不可变直到函数结束。 @@ -512,30 +566,29 @@ impl SettingsPane { let danger = theme.danger; let (shows_password, shows_keys) = crate::display::auth_sections(editor.auth); let title = if editor.original_destination.is_some() { - "编辑 SSH 主机" + language.pick("编辑 SSH 主机", "Edit SSH host") } else { - "添加 SSH 主机" + language.pick("添加 SSH 主机", "Add SSH host") }; - let dest_error = self.ssh_status.as_ref().and_then(|(message, error)| { - (*error - && (message.contains("地址") - || message.contains("端口") - || message.contains("SSH 地址"))) - .then_some(message.clone()) - }); - let status = editor.test_status.clone().or_else(|| { - self.ssh_status.clone().filter(|(message, error)| { - !(*error - && (message.contains("地址") - || message.contains("端口") - || message.contains("SSH 地址"))) - }) + let dest_error = self.ssh_status.as_ref().and_then(|status| match status { + SshStatus::Validation(error) => Some(error.text(language).to_owned()), + _ => None, }); + let status = editor + .test_status + .as_ref() + .map(|status| (status.text(language), status.is_error())) + .or_else(|| { + self.ssh_status.as_ref().and_then(|status| { + (!matches!(status, SshStatus::Validation(_))) + .then(|| (status.text(language), status.is_error())) + }) + }); let destination_preview: SharedString = { let address = self.ssh_destination_input.read(cx).value(); let port = self.ssh_port_input.read(cx).value(); if address.trim().is_empty() { - "未填地址".into() + language.pick("未填地址", "No address entered").into() } else { crate::display::join_destination_port(address.trim(), port.trim()).into() } @@ -595,7 +648,7 @@ impl SettingsPane { .icon(IconName::Close) .ghost() .xsmall() - .tooltip("移除私钥") + .tooltip(language.pick("移除私钥", "Remove private key")) .on_click(cx.listener(move |this, _, _, cx| { if let Some(editor) = this.ssh_editor.as_mut() { if index < editor.private_keys.len() { @@ -626,7 +679,7 @@ impl SettingsPane { .truncate() .cursor_pointer() .hover(|row| row.bg(theme.list_hover)) - .child("未指定,将用 IdentityFile 与默认 id_* 私钥") + .child(language.tr("settings.ssh.default_keys")) .on_click(cx.listener(|this, _, window, cx| { this.add_ssh_private_key(window, cx); })) @@ -640,7 +693,7 @@ impl SettingsPane { .text_color(theme.primary) .cursor_pointer() .hover(|link| link.text_color(theme.foreground)) - .child("+ 添加私钥") + .child(language.pick("+ 添加私钥", "+ Add private key")) .on_click(cx.listener(|this, _, window, cx| { this.add_ssh_private_key(window, cx); })); @@ -652,12 +705,12 @@ impl SettingsPane { .child(div().w(px(SSH_EDITOR_LABEL_W)).flex_shrink_0().text_sm().child(label)) .child(div().flex_1().min_w_0().child(control)) }; - let dest_hint = dest_error.as_deref().unwrap_or("支持 user@host,也可粘贴 ssh://host:2222"); + let dest_hint = dest_error.as_deref().unwrap_or(language.tr("settings.ssh.address_hint")); let dest_hint_color = if dest_error.is_some() { danger } else { muted }; let auth_note = if editor.auth == crate::ssh_profiles::SshAuthMode::Auto { - "依次尝试可用私钥,失败再询问密码。多数情况选这个就行。" + language.tr("settings.ssh.auth_auto_description") } else { - "不预存任何凭据,连接时在终端里按提示输入(支持两步验证)。" + language.tr("settings.ssh.auth_interactive_description") }; Some( @@ -719,7 +772,7 @@ impl SettingsPane { Button::new("ssh-editor-close") .icon(IconName::Close) .ghost() - .tooltip("关闭") + .tooltip(language.pick("关闭", "Close")) .on_click(cx.listener(|this, _, window, cx| { this.close_ssh_editor(window, cx); })), @@ -785,7 +838,7 @@ impl SettingsPane { div() .text_sm() .text_color(theme.group_box_foreground) - .child("连接"), + .child(language.pick("连接", "Connection")), ) .child( h_flex() @@ -799,7 +852,7 @@ impl SettingsPane { .flex_shrink_0() .text_sm() .gap(px(2.0)) - .child("地址") + .child(language.pick("地址", "Address")) .child( div().text_color(danger).child("*"), ), @@ -829,7 +882,7 @@ impl SettingsPane { .w(px(SSH_EDITOR_LABEL_W)) .flex_shrink_0() .text_sm() - .child("端口"), + .child(language.pick("端口", "Port")), ) .child( div() @@ -840,7 +893,7 @@ impl SettingsPane { div() .text_xs() .text_color(muted) - .child("默认 22"), + .child(language.pick("默认 22", "Default: 22")), ), ), ) @@ -857,10 +910,10 @@ impl SettingsPane { div() .text_sm() .text_color(theme.group_box_foreground) - .child("认证"), + .child(language.pick("认证", "Authentication")), ) .child(field_row( - "方式", + language.pick("方式", "Method"), h_flex() .w_full() .h(px(SSH_EDITOR_CTL_H)) @@ -871,28 +924,28 @@ impl SettingsPane { .bg(theme.input) .child(mode_chip( "ssh-auth-password", - "密码", + language.pick("密码", "Password"), crate::ssh_profiles::SshAuthMode::Password, editor.auth == crate::ssh_profiles::SshAuthMode::Password, )) .child(mode_chip( "ssh-auth-key", - "密钥", + language.pick("密钥", "Key"), crate::ssh_profiles::SshAuthMode::PublicKey, editor.auth == crate::ssh_profiles::SshAuthMode::PublicKey, )) .child(mode_chip( "ssh-auth-auto", - "自动", + language.pick("自动", "Auto"), crate::ssh_profiles::SshAuthMode::Auto, editor.auth == crate::ssh_profiles::SshAuthMode::Auto, )) .child(mode_chip( "ssh-auth-interactive", - "交互式", + language.pick("交互式", "Interactive"), crate::ssh_profiles::SshAuthMode::KeyboardInteractive, editor.auth == crate::ssh_profiles::SshAuthMode::KeyboardInteractive, @@ -912,7 +965,7 @@ impl SettingsPane { .w(px(SSH_EDITOR_LABEL_W)) .flex_shrink_0() .text_sm() - .child("密码"), + .child(language.pick("密码", "Password")), ) .child( div().flex_1().min_w_0().child( @@ -931,7 +984,9 @@ impl SettingsPane { Checkbox::new("ssh-save-password") .small() .checked(editor.save_password) - .label("保存到 Windows 凭据管理器") + .label(language.tr( + "settings.ssh.save_password", + )) .on_click(cx.listener( |this, value: &bool, _, cx| { if let Some(editor) = @@ -965,7 +1020,7 @@ impl SettingsPane { .flex() .items_center() .text_sm() - .child("私钥"), + .child(language.pick("私钥", "Private keys")), ) .child( v_flex() @@ -1028,9 +1083,9 @@ impl SettingsPane { .child( NebulaButton::new("ssh-editor-test") .label(if editor.testing() { - "测试中…" + language.pick("测试中…", "Testing...") } else { - "测试连接" + language.pick("测试连接", "Test connection") }) .outline() .disabled(editor.testing()) @@ -1043,7 +1098,7 @@ impl SettingsPane { .gap_2() .child( NebulaButton::new("ssh-editor-cancel") - .label("取消") + .label(language.pick("取消", "Cancel")) .outline() .on_click(cx.listener(|this, _, window, cx| { this.close_ssh_editor(window, cx); @@ -1051,7 +1106,7 @@ impl SettingsPane { ) .child( NebulaButton::new("ssh-editor-save") - .label("保存") + .label(language.pick("保存", "Save")) .primary() .on_click(cx.listener(|this, _, window, cx| { this.save_ssh_editor(window, cx); @@ -1170,6 +1225,7 @@ impl SettingsPane { if !self.ssh_icon_picker_open { return None; } + let language = crate::gpui_shell::config::ui_language(cx); let trigger = self.ssh_icon_trigger_bounds?; let theme = cx.theme(); let muted = theme.muted_foreground; @@ -1184,7 +1240,7 @@ impl SettingsPane { let query = self.ssh_icon_filter_input.read(cx).value().to_string(); // 目录、分组标题与搜索匹配全部走共享的 `picker_rows`:两壳的选择器 // 因此永远列出同一批图标、按同一种方式分组、对同一个词命中。 - let picker_model = picker_rows(&query, true); + let picker_model = picker_rows(&query, language == crate::display::UiLanguage::ZhCn); let picker_content_h = picker_model .iter() .map(|row| match row { @@ -1208,10 +1264,10 @@ impl SettingsPane { .into_any_element(), PickerRow::Option(option) => { let (icon, name, id) = match option.and_then(|index| CATALOG.get(index)) { - Some(icon) => (icon, icon.zh, Some(icon.id)), + Some(icon) => (icon, language.pick(icon.zh, icon.en), Some(icon.id)), // 未识别时头像本来就回落到 DEFAULT_ID;选择器也显示 // 同一张脸,避免挑选前后出现两枚不同的“终端”图标。 - None => (resolve(None), "自动识别", None), + None => (resolve(None), language.pick("自动识别", "Auto detect"), None), }; let icon_size = PICKER_ICON_BASE_SIZE * scale_for(icon, PICKER_ICON_BASE_SIZE * 0.6, PICKER_ICON_INK_W); @@ -1276,7 +1332,7 @@ impl SettingsPane { .items_center() .text_xs() .text_color(muted) - .child("没有匹配的图标") + .child(language.pick("没有匹配的图标", "No matching icons")) .into_any_element() } else { // 滚动组件必须拿到确定高度;只设 max-height 时,它在自动高度 @@ -1308,6 +1364,7 @@ impl SettingsPane { /// 主机列表:一个圆角卡片,行间细分隔线,操作按钮 hover 显形(连接为 /// 文本按钮,编辑/置顶/删除为图标按钮)。删除走 8 秒撤销窗口。 pub(super) fn section_ssh(&mut self, cx: &mut Context) -> gpui::Div { + let language = crate::gpui_shell::config::ui_language(cx); let theme = cx.theme(); let hover_bg = crate::gpui_shell::theme::settings_hover_bg(cx, false); let muted = theme.muted_foreground; @@ -1434,14 +1491,14 @@ impl SettingsPane { ) .child( Button::new(SharedString::from(format!("ssh-connect-{ix}"))) - .label("连接") + .label(language.pick("连接", "Connect")) .small() .primary() .invisible() .group_hover(row_group.clone(), |button| button.visible()) .on_click(cx.listener(move |this, _, _, cx| { cx.emit(SettingsPaneEvent::LaunchSsh(connect_host.clone())); - this.ssh_status = Some((format!("正在打开 {connect_host}…"), false)); + this.ssh_status = Some(SshStatus::Opening(connect_host.clone())); cx.notify(); })), ) @@ -1450,7 +1507,7 @@ impl SettingsPane { .icon(IconName::Settings2) .ghost() .small() - .tooltip("编辑主机") + .tooltip(language.pick("编辑主机", "Edit host")) .invisible() .group_hover(row_group.clone(), |button| button.visible()) .on_click(cx.listener(move |this, _, window, cx| { @@ -1462,13 +1519,17 @@ impl SettingsPane { .icon(if pinned { IconName::StarOff } else { IconName::Star }) .ghost() .small() - .tooltip(if pinned { "取消置顶" } else { "置顶" }) + .tooltip(if pinned { + language.pick("取消置顶", "Unpin") + } else { + language.pick("置顶", "Pin") + }) .invisible() .group_hover(row_group.clone(), |button| button.visible()) .on_click(cx.listener(move |this, _, _, cx| { this.ssh_apply( |lists| lists.toggle_pin(&pin_host), - "置顶状态已更新", + SshStatus::Pinned, cx, ); })), @@ -1477,16 +1538,19 @@ impl SettingsPane { Button::new(SharedString::from(format!("ssh-delete-{ix}"))) .map(|button| { if confirm { - button.label("确认删除").danger().small() + button + .label(language.pick("确认删除", "Confirm delete")) + .danger() + .small() } else { button .icon(IconName::Delete) .ghost() .small() .tooltip(if from_config { - "隐藏(config 源不可删除)" + language.tr("settings.ssh.hide_config_host") } else { - "删除" + language.pick("删除", "Delete") }) } }) @@ -1534,11 +1598,11 @@ impl SettingsPane { ) .child( NebulaButton::new(SharedString::from(format!("ssh-restore-{ix}"))) - .label("恢复") + .label(language.pick("恢复", "Restore")) .on_click(cx.listener(move |this, _, _, cx| { this.ssh_apply( |lists| lists.restore_hidden(&restore_host), - "已恢复到主机列表", + SshStatus::Restored(restore_host.clone()), cx, ); })), @@ -1550,13 +1614,17 @@ impl SettingsPane { let hidden_count = self.ssh_hosts.hidden_hosts().len(); let undo_bar = self.ssh_delete_undo.as_ref().map(|undo| (undo.host.clone(), undo.seq)); - self.group("SSH 主机", cx) + self.group(language.pick("SSH 主机", "SSH hosts"), cx) .child( h_flex() .h(px(32.0)) .items_center() .gap_2() - .child(div().text_color(theme.foreground).child("已保存主机")) + .child( + div() + .text_color(theme.foreground) + .child(language.pick("已保存主机", "Saved hosts")), + ) .when(host_count > 0, |header| { header.child( div() @@ -1570,11 +1638,12 @@ impl SettingsPane { }) .child(div().flex_1()) .child( - NebulaButton::new("ssh-add-host").label("+ 添加主机").primary().on_click( - cx.listener(|this, _, window, cx| { + NebulaButton::new("ssh-add-host") + .label(language.pick("+ 添加主机", "+ Add host")) + .primary() + .on_click(cx.listener(|this, _, window, cx| { this.open_ssh_editor(None, window, cx); - }), - ), + })), ), ) .child(div().h(px(SSH_HOST_GAP))) @@ -1592,12 +1661,16 @@ impl SettingsPane { .py_6() .gap_1() .items_center() - .child(div().text_color(muted).child("还没有保存的主机")) + .child( + div().text_color(muted).child( + language.pick("还没有保存的主机", "No saved hosts yet"), + ), + ) .child( div() .text_xs() .text_color(muted) - .child("「+ 添加主机」或在 ~/.ssh/config 里维护别名"), + .child(language.tr("settings.ssh.empty_hint")), ), ) }), @@ -1607,23 +1680,29 @@ impl SettingsPane { h_flex() .gap_2() .items_center() - .child(NebulaButton::new("ssh-import").label("导入 ~/.ssh/config").on_click( - cx.listener(|this, _, _, cx| { - this.ssh_hosts = crate::gpui_shell::ssh_hosts::SshHostLists::load(); - let count = crate::ssh::ssh_config_hosts().len(); - this.ssh_status = - Some((format!("已导入,config 源共 {count} 个别名"), false)); - cx.notify(); - }), - )) + .child( + NebulaButton::new("ssh-import") + .label(language.pick("导入 ~/.ssh/config", "Import ~/.ssh/config")) + .on_click(cx.listener(|this, _, _, cx| { + this.ssh_hosts = crate::gpui_shell::ssh_hosts::SshHostLists::load(); + let count = crate::ssh::ssh_config_hosts().len(); + this.ssh_status = Some(SshStatus::Imported(count)); + cx.notify(); + })), + ) .when(hidden_count > 0, |row| { let show = self.ssh_show_hidden; row.child( NebulaButton::new("ssh-toggle-hidden") .label(if show { - SharedString::from("收起已隐藏") + SharedString::from( + language.pick("收起已隐藏", "Collapse hidden"), + ) } else { - SharedString::from(format!("已隐藏 {hidden_count} 项")) + SharedString::from(format!( + "{} {hidden_count}", + language.pick("已隐藏", "Hidden") + )) }) .on_click(cx.listener(|this, _, _, cx| { this.ssh_show_hidden = !this.ssh_show_hidden; @@ -1636,7 +1715,7 @@ impl SettingsPane { div() .text_xs() .text_color(muted) - .child("与旧壳共用同一份数据;config 别名删除即隐藏,可恢复"), + .child(language.tr("settings.ssh.shared_data_hint")), ), ) .when_some(hidden_rows, |group, rows| { @@ -1661,17 +1740,22 @@ impl SettingsPane { .bg(theme.muted) .child(Icon::new(IconName::Undo2).xsmall().text_color(muted)) .child(div().flex_1().text_sm().child(SharedString::from(format!( - "已删除 {host},{SSH_DELETE_UNDO_SECS} 秒内可撤销" + "{} {host}; {} {SSH_DELETE_UNDO_SECS} {}", + language.pick("已删除", "Deleted"), + language.pick("可在", "undo within"), + language.pick("秒", "seconds") )))) .child( NebulaButton::new("ssh-undo-delete") - .label("撤销") + .label(language.pick("撤销", "Undo")) .outline() .on_click(cx.listener(|this, _, _, cx| this.undo_ssh_delete(cx))), ), ) }) - .when_some(self.ssh_status.clone(), |group, (message, error)| { + .when_some(self.ssh_status.clone(), |group, status| { + let error = status.is_error(); + let message = status.text(language); group.child( div() .pt(px(6.0)) diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index e08a053..515fd70 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -70,6 +70,8 @@ mod platform; #[cfg(unix)] mod polling; mod process_tree; +mod provider_test; +mod proxy_test; mod remote_dirs; mod renderer; mod runtime_api; diff --git a/nebula_app/src/provider_test.rs b/nebula_app/src/provider_test.rs new file mode 100644 index 0000000..a7c72a0 --- /dev/null +++ b/nebula_app/src/provider_test.rs @@ -0,0 +1,31 @@ +//! Provider connectivity test results shared by the business and presentation layers. +//! +//! The test worker returns stable meaning and interpolation parameters only. UI +//! runtimes turn these variants into localized text at their display boundary. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderTestOutcome { + Success { status: u16 }, + InvalidEndpoint, + MissingModel, + MissingApiKey, + CredentialReadFailed, + InvalidCredentialEncoding, + Timeout, + HostNotFound, + ConnectionFailed, + Io { kind: String }, + Tls, + RequestFailed, + AuthFailed { status: u16 }, + EndpointNotFound { status: u16 }, + RateLimited { status: u16 }, + HttpStatus { status: u16 }, + StartFailed { error: String }, +} + +impl ProviderTestOutcome { + pub const fn is_success(&self) -> bool { + matches!(self, Self::Success { .. }) + } +} diff --git a/nebula_app/src/proxy_test.rs b/nebula_app/src/proxy_test.rs new file mode 100644 index 0000000..339b315 --- /dev/null +++ b/nebula_app/src/proxy_test.rs @@ -0,0 +1,49 @@ +//! Semantic results for the settings-page network connectivity test. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProxyTestRoute { + Direct, + DirectAddress, + CustomCommand, + ProxyServer(String), + SshJump(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProxyTestFailure { + LoadSettings(String), + InvalidSettings(String), + Timeout { seconds: u64 }, + SendRequest(String), + ReadResponse(String), + InvalidHttpStatusLine(String), + HttpStatus { status: u16 }, + ProxyServer { server: String, error: String }, + CustomCommand(String), + JumpTask(String), + JumpResolve { target: String, error: String }, + JumpConnect { target: String, error: String }, + JumpChannel { target: String, error: String }, + Direct(String), + Start(String), + UnexpectedEnd, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProxyTestOutcome { + Success(ProxyTestRoute), + Failed(ProxyTestFailure), +} + +impl ProxyTestOutcome { + pub const fn is_success(&self) -> bool { + matches!(self, Self::Success(_)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyTestResult { + pub request_id: u64, + pub outcome: ProxyTestOutcome, + pub elapsed_ms: u64, +} diff --git a/nebula_app/src/ssh_session.rs b/nebula_app/src/ssh_session.rs index c0cc082..3d22e02 100644 --- a/nebula_app/src/ssh_session.rs +++ b/nebula_app/src/ssh_session.rs @@ -25,6 +25,7 @@ use russh::keys::{HashAlg, PrivateKeyWithHashAlg}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use crate::event::EventProxy; +use crate::proxy_test::{ProxyTestFailure, ProxyTestOutcome, ProxyTestResult, ProxyTestRoute}; type SessionError = Box; type ClientSession = client::Handle; @@ -964,33 +965,22 @@ impl NetworkTestStream for T {} /// 一次出网测试的完成数据。旧 winit 壳经事件投递;GPUI 设置页走 oneshot, /// 握手与 HTTP 探测仍是 [`proxy_test_once`] 这一条路径。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProxyTestResult { - pub request_id: u64, - pub ok: bool, - pub message: String, - pub elapsed_ms: u64, -} - -fn proxy_test_timeout_message() -> String { - format!("网络测试超时({} 秒无响应)", TEST_TIMEOUT.as_secs()) -} - fn proxy_test_pair( - outcome: Result, tokio::time::error::Elapsed>, -) -> (bool, String) { + outcome: Result, tokio::time::error::Elapsed>, +) -> ProxyTestOutcome { match outcome { - Ok(Ok(route)) => (true, route), - Ok(Err(err)) => (false, err.to_string()), - Err(_) => (false, proxy_test_timeout_message()), + Ok(Ok(route)) => ProxyTestOutcome::Success(route), + Ok(Err(error)) => ProxyTestOutcome::Failed(error), + Err(_) => { + ProxyTestOutcome::Failed(ProxyTestFailure::Timeout { seconds: TEST_TIMEOUT.as_secs() }) + }, } } async fn run_proxy_test(request_id: u64) -> ProxyTestResult { let started = std::time::Instant::now(); - let (ok, message) = - proxy_test_pair(tokio::time::timeout(TEST_TIMEOUT, proxy_test_once()).await); - ProxyTestResult { request_id, ok, message, elapsed_ms: started.elapsed().as_millis() as u64 } + let outcome = proxy_test_pair(tokio::time::timeout(TEST_TIMEOUT, proxy_test_once()).await); + ProxyTestResult { request_id, outcome, elapsed_ms: started.elapsed().as_millis() as u64 } } /// 启动出网测试并交给调用方异步等待。GPUI 没有 winit `EventLoopProxy`, @@ -1019,8 +1009,7 @@ pub fn spawn_proxy_test( let _ = proxy.send_event(crate::event::Event::new( crate::event::EventType::ProxyTestDone { request_id: result.request_id, - ok: result.ok, - message: result.message, + outcome: result.outcome, elapsed_ms: result.elapsed_ms, }, window_id, @@ -1029,18 +1018,21 @@ pub fn spawn_proxy_test( Ok(()) } -async fn proxy_test_once() -> Result { +async fn proxy_test_once() -> Result { let global = tokio::task::spawn_blocking(crate::ssh_proxy::SshProxyConfig::load_global) .await - .map_err(|err| format!("读取代理设置失败: {err}"))?; - let link = - global.resolve(None, NETWORK_TEST_HOST).map_err(|err| format!("代理设置无效: {err}"))?; + .map_err(|error| ProxyTestFailure::LoadSettings(error.to_string()))?; + let link = global + .resolve(None, NETWORK_TEST_HOST) + .map_err(|error| ProxyTestFailure::InvalidSettings(error.to_string()))?; let route = match &link { - Some(crate::ssh_proxy::ProxyLink::Server(server)) => server.display(), - Some(crate::ssh_proxy::ProxyLink::Jump(target)) => format!("SSH {target}"), - Some(crate::ssh_proxy::ProxyLink::Command(_)) => "自定义命令".to_owned(), - None if global.mode == crate::ssh_proxy::ProxyMode::Custom => "直连地址".to_owned(), - None => "直接连接".to_owned(), + Some(crate::ssh_proxy::ProxyLink::Server(server)) => { + ProxyTestRoute::ProxyServer(server.display()) + }, + Some(crate::ssh_proxy::ProxyLink::Jump(target)) => ProxyTestRoute::SshJump(target.clone()), + Some(crate::ssh_proxy::ProxyLink::Command(_)) => ProxyTestRoute::CustomCommand, + None if global.mode == crate::ssh_proxy::ProxyMode::Custom => ProxyTestRoute::DirectAddress, + None => ProxyTestRoute::Direct, }; let mut stream = proxy_test_stream(link.as_ref()).await?; stream @@ -1048,14 +1040,16 @@ async fn proxy_test_once() -> Result { b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\nUser-Agent: Nebula-Network-Test\r\n\r\n", ) .await - .map_err(|err| format!("发送网页测试请求失败: {err}"))?; - stream.flush().await.map_err(|err| format!("发送网页测试请求失败: {err}"))?; + .map_err(|error| ProxyTestFailure::SendRequest(error.to_string()))?; + stream.flush().await.map_err(|error| ProxyTestFailure::SendRequest(error.to_string()))?; let mut response = Vec::with_capacity(1024); let mut chunk = [0u8; 512]; while response.len() < 2048 && !response.windows(2).any(|part| part == b"\r\n") { - let read = - stream.read(&mut chunk).await.map_err(|err| format!("读取网页测试响应失败: {err}"))?; + let read = stream + .read(&mut chunk) + .await + .map_err(|error| ProxyTestFailure::ReadResponse(error.to_string()))?; if read == 0 { break; } @@ -1067,47 +1061,59 @@ async fn proxy_test_once() -> Result { .split_whitespace() .nth(1) .and_then(|value| value.parse::().ok()) - .ok_or_else(|| format!("目标站点没有返回有效 HTTP 状态行: {status_line}"))?; + .ok_or_else(|| ProxyTestFailure::InvalidHttpStatusLine(status_line.to_owned()))?; if !(200..500).contains(&status) { - return Err(format!("目标站点返回 HTTP {status}").into()); + return Err(ProxyTestFailure::HttpStatus { status }); } Ok(route) } async fn proxy_test_stream( link: Option<&crate::ssh_proxy::ProxyLink>, -) -> Result, SessionError> { +) -> Result, ProxyTestFailure> { use crate::ssh_proxy::ProxyLink; match link { Some(ProxyLink::Server(server)) => { crate::ssh_proxy::connect(server, NETWORK_TEST_HOST, NETWORK_TEST_PORT) .await .map(|stream| Box::new(stream) as Box) - .map_err(|err| format!("经代理 {} 出网失败: {err}", server.display()).into()) + .map_err(|error| ProxyTestFailure::ProxyServer { + server: server.display(), + error: error.to_string(), + }) }, Some(ProxyLink::Command(command)) => { crate::ssh_proxy::connect_command(command, NETWORK_TEST_HOST, NETWORK_TEST_PORT) .await .map(|stream| Box::new(stream) as Box) - .map_err(|err| format!("自定义代理命令出网失败: {err}").into()) + .map_err(|error| ProxyTestFailure::CustomCommand(error.to_string())) }, Some(ProxyLink::Jump(spec)) => { let (jump_destination, jump_profile) = tokio::task::spawn_blocking({ let spec = spec.clone(); move || { - let destination = SshDestination::resolve(&spec)?; + let destination = SshDestination::resolve(&spec).map_err(|error| { + ProxyTestFailure::JumpResolve { + target: spec.clone(), + error: error.to_string(), + } + })?; let path = crate::display::nebula_data_dir().join("ssh_profiles.json"); let profile = crate::ssh_profiles::SshProfiles::load(&path) .unwrap_or_default() .for_destination(&spec); - Ok::<_, io::Error>((destination, profile)) + Ok::<_, ProxyTestFailure>((destination, profile)) } }) .await - .map_err(|err| format!("解析跳板任务失败: {err}"))??; + .map_err(|error| ProxyTestFailure::JumpTask(error.to_string()))??; let jump = authenticated_session_at::(&jump_destination, &jump_profile, None, 1) - .await?; + .await + .map_err(|error| ProxyTestFailure::JumpConnect { + target: spec.clone(), + error: error.to_string(), + })?; let channel = { let session = jump.lock().await; session @@ -1118,14 +1124,17 @@ async fn proxy_test_stream( 0, ) .await - .map_err(|err| format!("经跳板 {spec} 打开出网通道失败: {err}"))? + .map_err(|error| ProxyTestFailure::JumpChannel { + target: spec.clone(), + error: error.to_string(), + })? }; Ok(Box::new(channel.into_stream())) }, None => tokio::net::TcpStream::connect((NETWORK_TEST_HOST, NETWORK_TEST_PORT)) .await .map(|stream| Box::new(stream) as Box) - .map_err(|err| format!("直接连接测试站点失败: {err}").into()), + .map_err(|error| ProxyTestFailure::Direct(error.to_string())), } }