feat(i18n): embed JSON catalogs for GPUI settings
This commit is contained in:
1 parent
6119b1ebe5
commit
353bc175d6
23 files changed
+2323
-654
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
let missing_en = zh.difference(&en).cloned().collect::<Vec<_>>();
|
||||
panic!(
|
||||
"translation catalogs must have identical message ids; missing from zh-CN: \
|
||||
{missing_zh:?}; missing from en-US: {missing_en:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_keys(path: &Path, locale: &str) -> BTreeSet<String> {
|
||||
let source = std::fs::read_to_string(path)
|
||||
.unwrap_or_else(|error| panic!("failed to read {locale} translation catalog: {error}"));
|
||||
let root: serde_json::Value = serde_json::from_str(&source)
|
||||
.unwrap_or_else(|error| panic!("invalid {locale} translation catalog: {error}"));
|
||||
let mut keys = BTreeSet::new();
|
||||
collect_catalog_keys(&root, "", &mut keys, locale);
|
||||
keys
|
||||
}
|
||||
|
||||
fn collect_catalog_keys(
|
||||
value: &serde_json::Value,
|
||||
prefix: &str,
|
||||
keys: &mut BTreeSet<String>,
|
||||
locale: &str,
|
||||
) {
|
||||
match value {
|
||||
serde_json::Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") };
|
||||
collect_catalog_keys(value, &path, keys, locale);
|
||||
}
|
||||
},
|
||||
serde_json::Value::String(_) => {
|
||||
assert!(!prefix.is_empty(), "{locale} translation catalog contains an empty id");
|
||||
assert!(keys.insert(prefix.to_owned()), "duplicate {locale} message id: {prefix}");
|
||||
},
|
||||
_ => panic!("{locale} translation catalog leaf {prefix} must be a string"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the side-by-side new-ConPTY (`conpty.dll` + `OpenConsole.exe`) next to
|
||||
/// the built exe so the matching OpenConsole host is selected at runtime.
|
||||
///
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Option<Vec<u8>>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_url(provider: &AiProvider) -> Result<String, String> {
|
||||
fn test_url(provider: &AiProvider) -> Result<String, ProviderTestOutcome> {
|
||||
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<String, String> {
|
||||
})
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
@@ -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<String, String>,
|
||||
zh_cn: HashMap<String, String>,
|
||||
}
|
||||
|
||||
static TRANSLATIONS: OnceLock<TranslationCatalogs> = OnceLock::new();
|
||||
|
||||
fn parse_catalog(source: &str, locale: &str) -> HashMap<String, String> {
|
||||
let root: serde_json::Value = serde_json::from_str(source).unwrap_or_else(|error| {
|
||||
panic!("embedded {locale} translation catalog is invalid: {error}")
|
||||
});
|
||||
let mut messages = HashMap::new();
|
||||
flatten_catalog(&root, "", &mut messages, locale);
|
||||
messages
|
||||
}
|
||||
|
||||
fn flatten_catalog(
|
||||
value: &serde_json::Value,
|
||||
prefix: &str,
|
||||
output: &mut HashMap<String, String>,
|
||||
locale: &str,
|
||||
) {
|
||||
match value {
|
||||
serde_json::Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") };
|
||||
flatten_catalog(value, &path, output, locale);
|
||||
}
|
||||
},
|
||||
serde_json::Value::String(message) => {
|
||||
assert!(!prefix.is_empty(), "embedded {locale} catalog contains an empty message id");
|
||||
assert!(
|
||||
output.insert(prefix.to_owned(), message.clone()).is_none(),
|
||||
"embedded {locale} catalog contains duplicate message id {prefix}"
|
||||
);
|
||||
},
|
||||
_ => panic!("embedded {locale} catalog leaf {prefix} must be a string"),
|
||||
}
|
||||
}
|
||||
|
||||
fn catalogs() -> &'static TranslationCatalogs {
|
||||
TRANSLATIONS.get_or_init(|| {
|
||||
let en_us = parse_catalog(EN_US_JSON, "en-US");
|
||||
let zh_cn = parse_catalog(ZH_CN_JSON, "zh-CN");
|
||||
let mut en_keys = en_us.keys().collect::<Vec<_>>();
|
||||
let mut zh_keys = zh_cn.keys().collect::<Vec<_>>();
|
||||
en_keys.sort_unstable();
|
||||
zh_keys.sort_unstable();
|
||||
assert_eq!(
|
||||
en_keys, zh_keys,
|
||||
"embedded en-US and zh-CN catalogs must have identical message ids"
|
||||
);
|
||||
TranslationCatalogs { en_us, zh_cn }
|
||||
})
|
||||
}
|
||||
|
||||
/// Persisted UI language choice. The serialized values are stable because the
|
||||
/// runtime settings file is also a supported hand-editing surface.
|
||||
@@ -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::<Vec<_>>();
|
||||
let mut zh_keys = zh_cn.keys().collect::<Vec<_>>();
|
||||
en_keys.sort_unstable();
|
||||
zh_keys.sort_unstable();
|
||||
assert!(!en_keys.is_empty());
|
||||
assert_eq!(en_keys, zh_keys);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_uses_locale_then_english_then_key() {
|
||||
assert_eq!(UiLanguage::ZhCn.tr("settings.sidebar.network"), "网络");
|
||||
assert_eq!(UiLanguage::EnUs.tr("settings.sidebar.network"), "Network");
|
||||
assert_eq!(UiLanguage::EnUs.tr("missing.message.id"), "missing.message.id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_arguments_are_substituted() {
|
||||
assert_eq!(
|
||||
UiLanguage::EnUs.tr_args(
|
||||
"settings.network.status.success",
|
||||
&[("route", "Direct"), ("elapsed_ms", "12")],
|
||||
),
|
||||
"Network connection succeeded via Direct in 12 ms"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_outcomes_are_localized_at_the_display_boundary() {
|
||||
let outcome = crate::provider_test::ProviderTestOutcome::AuthFailed { status: 403 };
|
||||
assert_eq!(
|
||||
UiLanguage::EnUs.provider_test_message(&outcome),
|
||||
"Authentication failed (HTTP 403); check the API key"
|
||||
);
|
||||
assert_eq!(
|
||||
UiLanguage::ZhCn.provider_test_message(&outcome),
|
||||
"鉴权失败(HTTP 403),请检查 API Key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_routes_are_data_until_the_display_boundary() {
|
||||
let outcome =
|
||||
crate::proxy_test::ProxyTestOutcome::Success(crate::proxy_test::ProxyTestRoute::Direct);
|
||||
assert_eq!(
|
||||
UiLanguage::EnUs.proxy_test_message(&outcome, 12),
|
||||
"Network connection succeeded via Direct connection in 12 ms"
|
||||
);
|
||||
assert_eq!(
|
||||
UiLanguage::ZhCn.proxy_test_message(&outcome, 12),
|
||||
"网络连接正常 · 直接连接 · 12 ms"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual i18n microbenchmark"]
|
||||
fn measure_embedded_catalog_costs() {
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
let init_started = Instant::now();
|
||||
let en_us = parse_catalog(EN_US_JSON, "en-US");
|
||||
let zh_cn = parse_catalog(ZH_CN_JSON, "zh-CN");
|
||||
let mut en_keys = en_us.keys().collect::<Vec<_>>();
|
||||
let mut zh_keys = zh_cn.keys().collect::<Vec<_>>();
|
||||
en_keys.sort_unstable();
|
||||
zh_keys.sort_unstable();
|
||||
assert_eq!(en_keys, zh_keys);
|
||||
let init_elapsed = init_started.elapsed();
|
||||
|
||||
let lookup_iterations = 2_000_000u128;
|
||||
let lookup_started = Instant::now();
|
||||
for _ in 0..lookup_iterations {
|
||||
black_box(UiLanguage::EnUs.tr("settings.sidebar.network"));
|
||||
}
|
||||
let lookup_ns = lookup_started.elapsed().as_nanos() / lookup_iterations;
|
||||
|
||||
let template_iterations = 200_000u128;
|
||||
let template_started = Instant::now();
|
||||
for _ in 0..template_iterations {
|
||||
black_box(UiLanguage::EnUs.tr_args(
|
||||
"provider.test.success",
|
||||
&[("status", "200")],
|
||||
));
|
||||
}
|
||||
let template_ns = template_started.elapsed().as_nanos() / template_iterations;
|
||||
|
||||
eprintln!(
|
||||
"i18n resources={} bytes, messages={}, init={} us, lookup={} ns, template={} ns",
|
||||
EN_US_JSON.len() + ZH_CN_JSON.len(),
|
||||
en_us.len(),
|
||||
init_elapsed.as_micros(),
|
||||
lookup_ns,
|
||||
template_ns,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+16
-22
@@ -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<RuntimeDispatch>,
|
||||
) {
|
||||
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<Event> 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<T> 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<T> 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<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -207,11 +207,15 @@ impl SettingsPane {
|
||||
|
||||
/// 闭态 220×32 combobox + 开态右缘对齐浮层(行下 +6px)。
|
||||
pub(super) fn background_color_row(&mut self, cx: &mut Context<Self>) -> 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);
|
||||
|
||||
@@ -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>) -> 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::<Settings>().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<PathBuf> {
|
||||
|
||||
/// 读取主文件并按主应用语义合并 imports:imports 先加载,主文件最后覆盖。
|
||||
/// 途中吞掉的第一个错误经 `notice` 上浮(宽容解析 + 有去处的错误)。
|
||||
fn load_merged_toml(path: &Path, notice: &mut Option<String>) -> toml::Value {
|
||||
let main = read_toml(path, notice);
|
||||
fn load_merged_toml(path: &Path, notice: &mut Option<String>, language: UiLanguage) -> toml::Value {
|
||||
let main = read_toml(path, notice, language);
|
||||
|
||||
let imports: Vec<String> =
|
||||
[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<String>) -> 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<String>) -> toml::Value {
|
||||
merge_values(merged, main)
|
||||
}
|
||||
|
||||
fn read_toml(path: &Path, notice: &mut Option<String>) -> toml::Value {
|
||||
fn read_toml(path: &Path, notice: &mut Option<String>, 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<String>) -> 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<String>) -> 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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ProxyTestStatus> {
|
||||
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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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())
|
||||
|
||||
@@ -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<Self>) -> 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<Self>,
|
||||
) -> 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")),
|
||||
)
|
||||
}),
|
||||
))
|
||||
|
||||
@@ -47,7 +47,7 @@ impl SettingsPane {
|
||||
|
||||
/// 冲突检测(旧壳 `keymap_clash_info` 同义):同一 combo 多动作 → 逐行
|
||||
/// 标记 + 只报第一组的提示条。
|
||||
fn keymap_clashes(&self) -> (Vec<bool>, Option<String>) {
|
||||
fn keymap_clashes(&self, language: crate::display::UiLanguage) -> (Vec<bool>, Option<String>) {
|
||||
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<Self>) -> 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<Self>) -> 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")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<u64>,
|
||||
pub(super) test_status: Option<(String, bool)>,
|
||||
pub(super) test_status: Option<SshEditorTestStatus>,
|
||||
}
|
||||
|
||||
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>,
|
||||
) {
|
||||
// 列表将被改写,未决删除的快照会过期:先提交它。
|
||||
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<Self>) {
|
||||
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<String, String> {
|
||||
pub(super) fn ssh_destination_from_draft(
|
||||
&self,
|
||||
cx: &gpui::App,
|
||||
) -> Result<String, SshValidationError> {
|
||||
let port = self.ssh_port_input.read(cx).value().trim().to_string();
|
||||
if !port.is_empty() && !port.parse::<u16>().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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) -> Option<gpui::AnyElement> {
|
||||
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<Self>) -> 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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { .. })
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<dyn std::error::Error + Send + Sync>;
|
||||
type ClientSession = client::Handle<ClientHandler>;
|
||||
@@ -964,33 +965,22 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> 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<Result<String, SessionError>, tokio::time::error::Elapsed>,
|
||||
) -> (bool, String) {
|
||||
outcome: Result<Result<ProxyTestRoute, ProxyTestFailure>, 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<String, SessionError> {
|
||||
async fn proxy_test_once() -> Result<ProxyTestRoute, ProxyTestFailure> {
|
||||
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<String, SessionError> {
|
||||
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<String, SessionError> {
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|value| value.parse::<u16>().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<Box<dyn NetworkTestStream>, SessionError> {
|
||||
) -> Result<Box<dyn NetworkTestStream>, 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<dyn NetworkTestStream>)
|
||||
.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<dyn NetworkTestStream>)
|
||||
.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::<EventProxy>(&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<dyn NetworkTestStream>)
|
||||
.map_err(|err| format!("直接连接测试站点失败: {err}").into()),
|
||||
.map_err(|error| ProxyTestFailure::Direct(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user