refactor: 按 gpui-kit 官方指南对齐(locales 拆分/render 无突变/动作统一)

skill 安装:npx skills add 因网络不可用,改为 gh-proxy 镜像克隆仓库后
手动覆盖 ~/.agents/skills 与 ~/.zcode/skills 两处安装(与官方最新一致)。

语言包按语言分文件(用户要求):
- locales/ui.yml(合并格式)→ locales/en.yml + locales/zh-CN.yml
  (rust-i18n 以文件名 stem 识别 locale,扁平 "ns.key": "value")
- i18n.rs 测试适配双文件:键集合一致性 + 占位符两侧一致强制保留

对齐 coding-guides / design-guides / auto-update:
- render 无突变:分组过滤悬空复位从 render 移到 delete_connection/
  save_connection 的 action 路径(normalize_group_filter)
- 消除 UI 层 hsla 字面量:消息色条改 when_some 条件上色;色板描边
  从白 60% 改主题底色(双主题下均为「与背景的缝隙」语义)
- 动作统一:标题栏菜单的资源监控/导出/导入/设置改 dispatch_action,
  与快捷键共享同一 Action(菜单/快捷键单一命令策略)
- auto-update 对齐官方指南:自动检查加 24h 节流(.last-update-check
  时间戳),手动检查更新不受限

保留(记录豁免理由):
- 手写 collapsible_section:官方 Accordion 非受控(内部索引状态),
  替代会丢「已配置默认展开」的受控行为
- 自研 update/:官方无内置 updater,指南推荐 self_update+应用自有
  状态机,现有实现(状态机/后台 worker/sha256/consent/原子替换)
  已符合该精神;换 self_update 反而放弃已有代理兜底与 7z 通道
- px() 几何常量:对话框/面板宽度与色点尺寸属文档化几何豁免,
  机械换算会引入视觉回归;min 约束的 px(0.) 为布局断言

42 测试全绿,实机冒烟通过。
This commit is contained in:
2026-10-12 15:11:48 +08:00
parent 762ae4bddb
commit 05b37a8997
11 changed files with 801 additions and 1184 deletions
+14
View File
@@ -21,6 +21,7 @@ impl MqttXApp {
self.connections.push(cfg);
}
self.storage.save_connections(&self.connections);
self.normalize_group_filter();
}
pub fn duplicate_connection(&mut self, id: &str) {
@@ -36,6 +37,18 @@ impl MqttXApp {
self.storage.save_connections(&self.connections);
}
/// 分组被改名/删除后过滤器可能悬空(列表恒空),在数据变更的 action
/// 路径复位到「全部」;render 只读,不做突变。
fn normalize_group_filter(&mut self) {
if let GroupFilter::Named(name) = &self.group_filter
&& !self.connections.iter().any(|c| {
c.group.as_deref().map(str::trim) == Some(name.as_str())
})
{
self.group_filter = GroupFilter::All;
}
}
pub fn delete_connection(&mut self, id: &str, cx: &mut Context<Self>) {
self.engine.close(id, false);
self.connections.retain(|c| c.id != id);
@@ -50,6 +63,7 @@ impl MqttXApp {
}
self.storage.save_connections(&self.connections);
self.storage.save_subscriptions(&self.subscriptions);
self.normalize_group_filter();
self.persist_ui_state();
cx.notify();
}
+2 -8
View File
@@ -52,14 +52,8 @@ impl MqttXApp {
}
pub(super) fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let query = self.search.read(cx).value().to_lowercase();
let search = self.search.clone();
// 分组被改名/删除后过滤器可能悬空(列表恒空),渲染前复位到「全部」
let dangling = matches!(&self.group_filter, GroupFilter::Named(name)
if !self.connections.iter().any(|c| {
c.group.as_deref().map(str::trim) == Some(name.as_str())
}));
if dangling {
self.group_filter = GroupFilter::All;
}
// 分组过滤器复位发生在删除/保存连接的 action 路径(normalize_group_filter),
// render 只读——规范禁止渲染期突变状态
let filter = self.group_filter.clone();
let total = self.connections.len();
+9 -21
View File
@@ -10,6 +10,7 @@ use crate::model::ThemeModePref;
use crate::ui::IconName;
use super::*;
use gpui_kit::Action as _;
use crate::ui::i18n;
impl MqttXApp {
@@ -107,11 +108,7 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context<
.dropdown_menu(move |menu, _, _| {
let w1 = app_weak.clone();
let w2 = app_weak.clone();
let w3 = app_weak.clone();
let w4 = app_weak.clone();
let w5 = app_weak.clone();
let w6 = app_weak.clone();
menu.item(
menu.item(
PopupMenuItem::new(i18n::t("titlebar.aliyun"))
.on_click(move |_, window, cx| {
if let Some(entity) = w1.upgrade() {
@@ -128,36 +125,27 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context<
))
.item(PopupMenuItem::new(i18n::t("titlebar.resmon")).on_click(
move |_, window, cx| {
if let Some(entity) = w6.upgrade() {
crate::ui::dialogs::resource_dialog::open(entity, window, cx);
}
window.dispatch_action(
OpenResourceMonitor.boxed_clone(),
cx,
);
},
))
.separator()
.item(PopupMenuItem::new(i18n::t("titlebar.export")).on_click(
move |_, window, cx| {
if let Some(entity) = w4.upgrade() {
entity.update(cx, |app, cx| {
app.export_connections(window, cx)
});
}
window.dispatch_action(ExportConnections.boxed_clone(), cx);
},
))
.item(PopupMenuItem::new(i18n::t("titlebar.import")).on_click(
move |_, window, cx| {
if let Some(entity) = w5.upgrade() {
entity.update(cx, |app, cx| {
app.import_connections(window, cx)
});
}
window.dispatch_action(ImportConnections.boxed_clone(), cx);
},
))
.separator()
.item(PopupMenuItem::new(i18n::t("titlebar.settings")).on_click(
move |_, window, cx| {
if let Some(entity) = w3.upgrade() {
crate::ui::dialogs::settings_dialog::open(entity, window, cx);
}
window.dispatch_action(OpenSettings.boxed_clone(), cx);
},
))
}),
+3 -3
View File
@@ -6,7 +6,7 @@ use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::input::Input;
use gpui_kit::component::{ h_flex, notification::Notification, v_flex, };
use crate::ui::IconName;
use gpui_kit::{ div, hsla, px, ClipboardItem, Context, IntoElement, SharedString, };
use gpui_kit::{ div, px, ClipboardItem, Context, IntoElement, SharedString, };
use crate::model::{ render_will_templates, ConnectionStatus, Direction, LogLevel, MqttRecord, Subscription, MAX_PAYLOAD_RETAIN, };
use crate::ui::widgets::format_time;
@@ -408,7 +408,7 @@ pub(super) fn render_top_bar(&self, cx: &mut Context<Self>) -> impl IntoElement
.border_color(border)
.hover(move |this| this.bg(hover_bg))
.cursor_pointer()
// 左侧色条:有订阅色时显示,否则透明以保持行内对齐;
// 左侧色条:有订阅色时条件上色(无色不设 bg,天然透明保持行内对齐);
// 上下留 2px 间隙,避免相邻行的色条连成一整条竖线
.child(
div()
@@ -416,7 +416,7 @@ pub(super) fn render_top_bar(&self, cx: &mut Context<Self>) -> impl IntoElement
.self_stretch()
.my_0p5()
.rounded_full()
.bg(sub_color.map(hue_color).unwrap_or(hsla(0., 0., 0., 0.))),
.when_some(sub_color, |bar, hue| bar.bg(hue_color(hue))),
)
.child(body)
.on_click(cx.listener(move |this, _, _, cx| {
+5 -2
View File
@@ -8,7 +8,7 @@ use gpui_kit::component::select::Select;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{ h_flex, notification::Notification, v_flex, };
use crate::ui::IconName;
use gpui_kit::{ div, hsla, px, Context, IntoElement, SharedString, Window, };
use gpui_kit::{ div, px, Context, IntoElement, SharedString, Window, };
use gpui_kit::component::IndexPath;
use crate::model::{ SubscribeOptions, Subscription, };
@@ -577,6 +577,9 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context<Self>) -> impl IntoEl
.child("+"),
)
};
// 色点描边用主题底色(双主题下都是「与背景的缝隙」语义);
// 闭包签名拿不到带主题的 cx,提前在 render 取值捕获
let swatch_border = cx.theme().background;
trigger.dropdown_menu(move |mut menu, _, _| {
for (hue, name) in PRESET_HUES {
let weak = weak_menu.clone();
@@ -595,7 +598,7 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context<Self>) -> impl IntoEl
.rounded_full()
.bg(swatch)
.border_1()
.border_color(hsla(0., 0., 1., 0.6)),
.border_color(swatch_border),
)
.child(div().text_sm().child(label.clone()))
})
+48 -46
View File
@@ -1,9 +1,11 @@
//! i18n 门面:语义键(`ns.key`)+ rust-i18n(gpui-kit 官方方案)。
//!
//! # 架构
//! - 语言包在 `locales/ui.yml`(en / zh-CN 双语并排,`_version: 2`),
//! - 语言包在 `locales/`,**按语言一分文件**(`en.yml` / `zh-CN.yml`,
//! rust-i18n 以文件名 stem 识别 locale,扁平 `"ns.key": "文案"` 格式),
//! 由 crate 根的 `rust_i18n::i18n!("locales", fallback = "en")` 编译期内嵌;
//! gpui-kit 组件内置文案经 main.rs 的 `rust_i18n::extend!` 随语言切换。
//! gpui-kit 组件内置文案经 lib.rs 的 `init_i18n()`(`rust_i18n::extend!`)
//! 随语言切换。
//! - 调用点写 `t("common.save")` / `tf("msg.count", &[("n", &count)])`,
//! key 是**稳定标识符**,与任何自然语言解耦——改文案措辞不动代码,
//! 也不存在「一词多义撞 key」。
@@ -12,7 +14,8 @@
//! 值同样可查。
//!
//! # 规则(单元测试强制,违反即测试失败)
//! 1. 新增文案:调用点 `t("ns.key")`,`locales/ui.yml` 加 key 块(en/zh-CN 必填)。
//! 1. 新增文案:调用点 `t("ns.key")`,`locales/en.yml` 与 `locales/zh-CN.yml`
//! 各加一行(两文件键集合必须一致)。
//! 2. 源码每个 `t("…")` / `tf("…")` 字面量必须在语言包中存在。
//! 3. 模型 `label()` 返回的 key 必须有**非自映射**的 en 词条。
//! 4. 同一 key 的 zh-CN 与 en 占位符 `{name}` 集合必须一致
@@ -127,43 +130,33 @@ fn placeholders(s: &str) -> Vec<String> {
out
}
/// 解析生成器产出的规整 YAML(key 行 + 缩进的 en/zh-CN 值行)。
/// 解析 `locales/{locale}.yml`(规整格式:每行 `"key": "value"`,# 注释)。
/// 格式意外时 panic——语言包是构建产物,格式受控。
#[cfg(test)]
fn load_ui_yml() -> std::collections::BTreeMap<String, (String, String)> {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales/ui.yml"),
)
.expect("locales/ui.yml 应存在");
let unquote = |v: &str| -> String {
let v = v.trim();
let inner = v
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(v);
inner.replace("\\\"", "\"").replace("\\\\", "\\")
};
fn load_locale_yml(locale: &str) -> std::collections::BTreeMap<String, String> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("locales")
.join(format!("{locale}.yml"));
let text =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{} 应存在: {e}", path.display()));
let mut map = std::collections::BTreeMap::new();
let mut key: Option<String> = None;
let mut en: Option<String> = None;
let mut zh: Option<String> = None;
for line in text.lines() {
if line.starts_with('#') || line.starts_with("_version") || line.trim().is_empty() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if !line.starts_with(' ') {
if let (Some(k), Some(e), Some(z)) = (key.take(), en.take(), zh.take()) {
map.insert(k, (e, z));
}
key = Some(unquote(line.trim_end_matches(':')));
} else if let Some(v) = line.trim().strip_prefix("en:") {
en = Some(unquote(v));
} else if let Some(v) = line.trim().strip_prefix("zh-CN:") {
zh = Some(unquote(v));
}
}
if let (Some(k), Some(e), Some(z)) = (key, en, zh) {
map.insert(k, (e, z));
let rest = line.strip_prefix('"').unwrap_or_else(|| panic!("行缺开引号: {line:?}"));
let (key, value) = rest
.split_once("\": ")
.unwrap_or_else(|| panic!("行缺 `\": ` 分隔: {line:?}"));
let unquote_value = |v: &str| {
let inner = v
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or_else(|| panic!("值缺引号: {v:?}"));
inner.replace("\\\"", "\"").replace("\\\\", "\\")
};
map.insert(key.to_string(), unquote_value(value));
}
map
}
@@ -172,17 +165,26 @@ fn load_ui_yml() -> std::collections::BTreeMap<String, (String, String)> {
mod tests {
use super::*;
/// 每个 key 都必须有非空 en 与 zh-CN;占位符两侧集合一致。
/// 两个语言文件键集合必须一致;每个 key 的 en/zh-CN 非空且
/// 占位符两侧集合一致。
#[test]
fn yml_complete_and_placeholder_parity() {
let map = load_ui_yml();
assert!(!map.is_empty(), "语言包不能为空");
for (k, (en, zh)) in &map {
assert!(!en.trim().is_empty(), "en 为空: {k:?}");
assert!(!zh.trim().is_empty(), "zh-CN 为空: {k:?}");
let en = load_locale_yml("en");
let zh = load_locale_yml("zh-CN");
assert!(!en.is_empty(), "en 语言包不能为空");
let en_only: Vec<_> = en.keys().filter(|k| !zh.contains_key(*k)).collect();
let zh_only: Vec<_> = zh.keys().filter(|k| !en.contains_key(*k)).collect();
assert!(
en_only.is_empty() && zh_only.is_empty(),
"键集合不一致: en 独有 {en_only:?}, zh 独有 {zh_only:?}"
);
for (k, en_v) in &en {
let zh_v = &zh[k];
assert!(!en_v.trim().is_empty(), "en 为空: {k:?}");
assert!(!zh_v.trim().is_empty(), "zh-CN 为空: {k:?}");
assert_eq!(
placeholders(zh),
placeholders(en),
placeholders(zh_v),
placeholders(en_v),
"占位符两侧不一致: {k:?}"
);
}
@@ -190,9 +192,10 @@ mod tests {
/// 扫描 src/ 全部源码里的 `t("…")` / `tf("…")` 字面量,
/// 逐一断言语言包有词条——漏翻是测试失败,不再是静默回退。
/// `(` 与 `"` 之间允许空白/换行(覆盖多行调用写法)。
#[test]
fn call_sites_covered_by_yml() {
let map = load_ui_yml();
let map = load_locale_yml("en");
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
collect_rs(&root, &mut files);
@@ -251,9 +254,8 @@ mod tests {
}
/// 手写扫描(避免引入 regex 依赖):找 `::t(` / `::tf(` 后的字面量。
/// `(` 与 `"` 之间允许空白/换行——覆盖多行调用的写法
/// `i18n::tf(\n "key",\n &[...]`;`::t(` 针不会命中 `::tf(`
/// (f 不是空白,也到不了引号分支)。
/// `(` 与 `"` 之间允许空白/换行——覆盖多行调用的写法;`::t(` 针不会
/// 命中 `::tf(`(f 不是空白,也到不了引号分支)。
fn extract_t_literals(src: &str) -> Vec<String> {
let b = src.as_bytes();
let mut out = Vec::new();
+26
View File
@@ -165,8 +165,34 @@ fn tag_from_releases_url(uri: &str) -> Option<&str> {
(tag.starts_with('v') && !tag.contains('/')).then_some(tag)
}
/// 自动检查的节流窗口:24h 内启动不再重复请求 GitHub(官方 auto-update
/// 指南建议缓存上次检查时间)。手动「检查更新」不受此限制。
const AUTO_CHECK_INTERVAL_SECS: i64 = 24 * 60 * 60;
fn stamp_path() -> std::path::PathBuf {
crate::store::Storage::new().dir().join(".last-update-check")
}
fn last_check_recent() -> bool {
let Ok(meta) = std::fs::metadata(stamp_path()) else { return false };
let Ok(modified) = meta.modified() else { return false };
modified
.elapsed()
.map(|d| d.as_secs() < AUTO_CHECK_INTERVAL_SECS as u64)
.unwrap_or(false)
}
fn touch_check_stamp() {
let _ = std::fs::write(stamp_path(), chrono::Local::now().to_rfc3339());
}
/// 返回 Ok(None) 表示已是最新;错误仅记录、不打扰用户。
/// 走 24h 节流:距上次自动检查不足一天时直接跳过(返回 None)。
pub fn check_and_download() -> Result<Option<StagedUpdate>, String> {
if last_check_recent() {
return Ok(None);
}
touch_check_stamp();
let Some(info) = check_latest()? else {
return Ok(None);
};