feat(i18n): embed JSON catalogs for GPUI settings

This commit is contained in:
kuddev committed 2026-08-28 22:03:04 +08:00
1 parent 6119b1ebe5
commit 353bc175d6
23 files changed
+2323 -654

No files matched your search

+1
View File
@@ -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"
+54
View File
@@ -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.
///
+118
View File
@@ -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}"
}
}
}
+118
View File
@@ -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}"
}
}
}
+30 -34
View File
@@ -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]
+328 -1
View File
@@ -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,
);
}
}
+7 -10
View File
@@ -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();
}
+9 -15
View File
@@ -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
View File
@@ -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);
}
},
+2 -4
View File
@@ -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);
+60 -12
View File
@@ -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() {
+1
View File
@@ -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);
}
+88 -40
View File
@@ -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")),
)
}
}
+205 -121
View File
@@ -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))
+2
View File
@@ -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;
+31
View File
@@ -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 { .. })
}
}
+49
View File
@@ -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,
}
+55 -46
View File
@@ -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())),
}
}