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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
))
|
||||
}),
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user