fix: 修复 UI 审查问题(图标资产全量化、主区布局贴顶、订阅数据保护与 28 项交互缺陷)

This commit is contained in:
2026-09-28 18:40:58 +08:00
parent c1c4fc011e
commit f8ce93f0dd
8 changed files with 821 additions and 201 deletions
+182 -43
View File
@@ -3,8 +3,9 @@
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::button::{Button, ButtonVariants as _, ButtonVariant};
use gpui_kit::component::scroll::ScrollableElement as _;
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem};
use gpui_kit::component::tab::{Tab, TabBar};
@@ -95,6 +96,10 @@ pub struct MqttXApp {
group_filter: GroupFilter,
/// 原生文件对话框进行中标记(对话框阻塞后台线程,防止重复弹出)
file_dialog_open: bool,
/// 按日期缓存的日志文件句柄(日期, 文件),避免每条日志都重新打开文件
log_file: Option<(String, std::fs::File)>,
/// 系统外观变化订阅:设置为「跟随系统」时重应用主题,随实体存活
_appearance_obs: gpui_kit::Subscription,
}
impl MqttXApp {
@@ -124,6 +129,20 @@ impl MqttXApp {
let search = cx.new(|cx| InputState::new(window, cx).placeholder("搜索连接…"));
// 「跟随系统」:监听系统外观(深浅色)变化,仅在设置为 System 时重应用主题。
// 订阅需随实体存活,dropped 即取消,故存入字段。
let appearance_weak = cx.entity().downgrade();
let appearance_obs = window.observe_window_appearance(move |window, cx| {
let Some(app) = appearance_weak.upgrade() else {
return;
};
if app.read(cx).settings.theme != ThemeModePref::System {
return;
}
let mode = ThemeMode::from(window.appearance());
Theme::change(mode, Some(window), cx);
});
// ── 全局快捷键:绑定 + action 注册 ──
// 元素级 on_action 依赖焦点路径,无焦点时分发从树根开始收不到;
// 全局 action 在冒泡末端必达,经 defer 取窗口避免在分发栈内重入。
@@ -158,6 +177,10 @@ impl MqttXApp {
App::on_action::<ExportConnections>(cx, move |_, cx| {
let w = weak.clone();
run_on_active_window(cx, w, |entity, window, cx| {
// 与新建/设置一致:对话框打开时不叠加文件对话框
if window.has_active_dialog(cx) {
return;
}
entity.update(cx, |app, cx| app.export_connections(window, cx));
});
});
@@ -165,6 +188,9 @@ impl MqttXApp {
App::on_action::<ImportConnections>(cx, move |_, cx| {
let w = weak.clone();
run_on_active_window(cx, w, |entity, window, cx| {
if window.has_active_dialog(cx) {
return;
}
entity.update(cx, |app, cx| app.import_connections(window, cx));
});
});
@@ -190,6 +216,8 @@ impl MqttXApp {
search,
group_filter: GroupFilter::All,
file_dialog_open: false,
log_file: None,
_appearance_obs: appearance_obs,
}
}
@@ -336,50 +364,71 @@ impl MqttXApp {
}
/// 追加写入按日切分的日志文件 `mqttx-YYYY-MM-DD.log`。
/// 每条都尝试写入;失败只在首次 eprintln 一次后保持静默,且绝不回调
/// log/push_log(防止递归触发日志)。
fn write_log_file(&self, entry: &LogEntry) {
use std::io::Write as _;
/// 文件句柄按日期缓存复用,只有跨日或写失败后才重新打开;
/// 失败只在首次 eprintln 一次后保持静默(成功一次即复位,便于恢复后再次提示),
/// 且绝不回调 log/push_log(防止递归触发日志)。
fn write_log_file(&mut self, entry: &LogEntry) {
use std::sync::atomic::{AtomicBool, Ordering};
static WRITE_FAILED: AtomicBool = AtomicBool::new(false);
let result = (|| -> std::io::Result<()> {
use chrono::TimeZone as _;
let ts = chrono::Local
.timestamp_millis_opt(entry.timestamp)
.single()
.unwrap_or_else(chrono::Local::now);
match self.append_log_line(entry) {
Ok(()) => WRITE_FAILED.store(false, Ordering::Relaxed),
Err(e) => {
// 关句柄置空,下次写入自动重开重试
self.log_file = None;
if !WRITE_FAILED.swap(true, Ordering::Relaxed) {
eprintln!("[app] 写入日志文件失败(后续静默): {e}");
}
}
}
}
/// 打开(或复用按日缓存的)日志文件并追加一行。
fn append_log_line(&mut self, entry: &LogEntry) -> std::io::Result<()> {
use std::io::Write as _;
use chrono::TimeZone as _;
let ts = chrono::Local
.timestamp_millis_opt(entry.timestamp)
.single()
.unwrap_or_else(chrono::Local::now);
let day = ts.format("%Y-%m-%d").to_string();
let need_reopen = match &self.log_file {
Some((cached_day, _)) => *cached_day != day,
None => true,
};
if need_reopen {
let dir = self.storage.log_dir();
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("mqttx-{}.log", ts.format("%Y-%m-%d")));
// 连接名查不到时回退为 id,保证行格式恒定
let conn = self
.connections
.iter()
.find(|c| c.id == entry.connection_id)
.map(|c| c.name.as_str())
.unwrap_or(entry.connection_id.as_str());
let mut file = std::fs::OpenOptions::new()
let path = dir.join(format!("mqttx-{day}.log"));
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(
file,
"{} [{}] {}/{}: {}",
ts.format("%Y-%m-%d %H:%M:%S%.3f"),
entry.level.label(),
conn,
entry.event,
entry.message
)
})();
if let Err(e) = result
&& !WRITE_FAILED.swap(true, Ordering::Relaxed)
{
eprintln!("[app] 写入日志文件失败(后续静默): {e}");
self.log_file = Some((day, file));
}
// 连接名查不到时回退为 id,保证行格式恒定
let conn = self
.connections
.iter()
.find(|c| c.id == entry.connection_id)
.map(|c| c.name.as_str())
.unwrap_or(entry.connection_id.as_str());
let file = &mut self
.log_file
.as_mut()
.expect("上方已确保日志句柄存在")
.1;
writeln!(
file,
"{} [{}] {}/{}: {}",
ts.format("%Y-%m-%d %H:%M:%S%.3f"),
entry.level.label(),
conn,
entry.event,
entry.message
)
}
pub fn log(&mut self, conn: &str, level: LogLevel, event: &str, message: String) {
@@ -423,6 +472,7 @@ impl MqttXApp {
self.subscriptions.retain(|s| s.connection_id != id);
self.messages.remove(id);
self.statuses.remove(id);
self.errors.remove(id);
self.views.remove(id);
self.open_tabs.retain(|t| t != id);
if self.active_tab.as_deref() == Some(id) {
@@ -622,7 +672,7 @@ impl MqttXApp {
}
Ok(None) => {}
Ok(Some(items)) => {
let existing: std::collections::HashSet<String> =
let mut existing: std::collections::HashSet<String> =
app.connections.iter().map(|c| c.id.clone()).collect();
let mut added = 0usize;
let mut skipped = 0usize;
@@ -630,6 +680,8 @@ impl MqttXApp {
if existing.contains(&item.id) {
skipped += 1;
} else {
// 记录已入库 id,导入文件内部的重复 id 也只收一条
existing.insert(item.id.clone());
app.connections.push(item);
added += 1;
}
@@ -711,15 +763,38 @@ impl MqttXApp {
let built = form_ok.read(cx).build(cx);
match built {
Ok(cfg) => {
// 已连接的连接改了关键参数,重连后才生效,先算好再保存
let mut stale = false;
app_ok
.update(cx, |app, cx| {
let connected = app.statuses.get(&cfg.id)
== Some(&ConnectionStatus::Connected);
if connected
&& app
.connections
.iter()
.find(|c| c.id == cfg.id)
.is_some_and(|old| {
old.session_params_changed(&cfg)
})
{
stale = true;
}
app.save_connection(cfg);
cx.notify();
})
.ok();
window.close_dialog(cx);
if stale {
window.push_notification(
Notification::info("配置已保存,重连后生效"),
cx,
);
}
}
Err(e) => {
// 触发重绘,让字段级红字即时显示(错误已写入表单内部)
form_ok.update(cx, |_, cx| cx.notify());
window.push_notification(
Notification::error(e),
cx,
@@ -735,12 +810,15 @@ impl MqttXApp {
// ─── 主题 ────────────────────────────────────────────────────────────────────
/// 应用主题偏好。
///
/// 「跟随系统」读取 gpui 的窗口外观(`App::window_appearance`);系统深浅色变化由
/// `MqttXApp::new` 里注册的 `Window::observe_window_appearance` 监听并重应用本函数。
pub fn apply_theme(pref: ThemeModePref, cx: &mut App) {
let mode = match pref {
ThemeModePref::Light => ThemeMode::Light,
ThemeModePref::Dark => ThemeMode::Dark,
// 未做系统外观检测前,“跟随系统”暂按浅色处理
ThemeModePref::System => ThemeMode::Light,
ThemeModePref::System => ThemeMode::from(cx.window_appearance()),
};
Theme::change(mode, None, cx);
}
@@ -894,6 +972,14 @@ impl MqttXApp {
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;
}
let filter = self.group_filter.clone();
let total = self.connections.len();
@@ -1071,7 +1157,14 @@ impl MqttXApp {
.filter(|c| c.group.as_deref().map(str::trim).unwrap_or("").is_empty())
.count();
let mut row = h_flex().gap_1().flex_wrap().px_2().pb_1();
// 分组多时限制高度并纵向滚动,避免 chips 换行挤占下方连接列表
let mut row = h_flex()
.gap_1()
.flex_wrap()
.px_2()
.pb_1()
.max_h(px(88.))
.overflow_y_scrollbar();
row = row.child(self.render_group_chip(
"chip-all",
format!("全部 {}", self.connections.len()),
@@ -1179,16 +1272,54 @@ impl MqttXApp {
.ok();
}))
.separator()
.item(PopupMenuItem::new("删除").on_click(move |_, _, cx| {
w3.update(cx, |app, cx| app.delete_connection(&e3, cx)).ok();
.item(PopupMenuItem::new("删除").on_click(move |_, window, cx| {
let Some(app) = w3.upgrade() else {
return;
};
// 二次确认:删除会级联清理订阅并断开连接,不可撤销
let (name, sub_count) = {
let a = app.read(cx);
let name = a
.connections
.iter()
.find(|c| c.id == e3)
.map(|c| c.name.clone())
.unwrap_or_else(|| e3.clone());
let n = a.subscriptions.iter().filter(|s| s.connection_id == e3).count();
(name, n)
};
let confirm_id = e3.clone();
window.open_alert_dialog(cx, move |alert, _, _| {
// 构建闭包是 Fn(可重复调用),克隆后再移入按钮回调
let app = app.clone();
let confirm_id = confirm_id.clone();
alert
.title("删除连接")
.description(SharedString::from(format!(
"确定删除「{name}」?将同时删除 {sub_count} 个订阅,并断开当前连接,此操作不可撤销。"
)))
.button_props(
DialogButtonProps::default()
.ok_text("删除")
.ok_variant(ButtonVariant::Danger)
.show_cancel(true),
)
.on_ok(move |_, _, cx| {
app.update(cx, |app, cx| app.delete_connection(&confirm_id, cx));
true
})
});
}))
})
}
fn render_main(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.open_tabs.is_empty() {
// 父级 h_flex 交叉轴居中子项,容器必须 h_full 占满高度才不会整体下沉
return v_flex()
.flex_1()
.h_full()
.min_h(px(0.))
.items_center()
.justify_center()
.gap_3()
@@ -1220,7 +1351,9 @@ impl MqttXApp {
}
let active = self.active_tab.clone().unwrap_or_default();
let mut bar = TabBar::new("conn-tabs");
let mut bar = TabBar::new("conn-tabs")
// 连接名过长时截断省略,避免单个页签占满整行
.max_width(px(180.));
for id in self.open_tabs.clone() {
let name = self
.connections
@@ -1251,11 +1384,17 @@ impl MqttXApp {
}
let view = self.views.get(&active).cloned();
// h_full:父级 h_flex 交叉轴居中子项,不占满高度会被整体垂直居中;
// 视图根是 size_full,须包在 flex_1 容器里,否则会盖住 TabBar 并溢出。
v_flex()
.flex_1()
.h_full()
.min_h(px(0.))
.min_w(px(0.))
.child(div().flex_shrink_0().px_2().pt_1().child(bar))
.when_some(view, |this, v| this.child(v))
.when_some(view, |this, v| {
this.child(v_flex().flex_1().min_h(px(0.)).child(v))
})
.into_any_element()
}
}