From f8ce93f0dd71eeb8a0c9e964344bdcc0483b89b5 Mon Sep 17 00:00:00 2001 From: JoeAllen <2494136784@qq.com> Date: Mon, 28 Sep 2026 18:40:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20UI=20=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E9=97=AE=E9=A2=98=EF=BC=88=E5=9B=BE=E6=A0=87=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E5=85=A8=E9=87=8F=E5=8C=96=E3=80=81=E4=B8=BB=E5=8C=BA?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E8=B4=B4=E9=A1=B6=E3=80=81=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=BF=9D=E6=8A=A4=E4=B8=8E=2028=20=E9=A1=B9?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E7=BC=BA=E9=99=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gpui-app/src/main.rs | 4 +- gpui-app/src/model.rs | 13 + gpui-app/src/store.rs | 24 +- gpui-app/src/ui/app.rs | 225 +++++++++++--- gpui-app/src/ui/connection_form.rs | 224 ++++++++++---- gpui-app/src/ui/connection_view.rs | 478 +++++++++++++++++++++++------ gpui-app/src/ui/settings_dialog.rs | 31 +- gpui-app/src/ui/widgets.rs | 23 +- 8 files changed, 821 insertions(+), 201 deletions(-) diff --git a/gpui-app/src/main.rs b/gpui-app/src/main.rs index 8e8780a..d9f6bef 100644 --- a/gpui-app/src/main.rs +++ b/gpui-app/src/main.rs @@ -5,7 +5,9 @@ use mqttx_desktop::ui; fn main() { application() - .with_assets(gpui_kit::assets::Assets) + // AllAssets 嵌入全量 Lucide 图标;Assets 只含 default-icons.txt 的 101 个, + // 其余 IconName(MailOpen/SlidersHorizontal 等)运行时会渲染空白 + .with_assets(gpui_kit::assets::AllAssets) .run(|cx: &mut App| { gpui_kit::init(cx); diff --git a/gpui-app/src/model.rs b/gpui-app/src/model.rs index c3b17ef..64bcb9e 100644 --- a/gpui-app/src/model.rs +++ b/gpui-app/src/model.rs @@ -245,6 +245,19 @@ impl ConnectionConfig { pub fn display_address(&self) -> String { format!("{}:{}", self.host, self.port) } + + /// 与另一份配置相比,影响 broker 会话建立的关键参数是否变化。 + /// 这些字段改动只有在重新建立连接后才会生效,用于提示用户重连。 + pub fn session_params_changed(&self, other: &ConnectionConfig) -> bool { + self.host != other.host + || self.port != other.port + || self.transport != other.transport + || self.protocol != other.protocol + || self.client_id != other.client_id + || self.username != other.username + || self.password != other.password + || self.clean_start != other.clean_start + } } impl Default for ConnectionConfig { diff --git a/gpui-app/src/store.rs b/gpui-app/src/store.rs index 987679b..cc60a05 100644 --- a/gpui-app/src/store.rs +++ b/gpui-app/src/store.rs @@ -177,7 +177,7 @@ pub fn export_connections_to(path: &Path, conns: &[ConnectionConfig]) -> Result< /// 从 JSON 文件导入连接。 /// /// 容错规则:容忍 UTF-8 BOM;顶层允许是数组,也可能是 `{ "connections": [...] }` 对象; -/// 任一项不是合法连接配置时返回带序号的错误信息。 +/// 任一项不是合法连接配置、或 `id` 为空白时,返回带序号的错误信息。 pub fn import_connections_from(path: &Path) -> Result, String> { let bytes = std::fs::read(path).map_err(|e| format!("读取 {} 失败: {e}", path.display()))?; let mut text = @@ -202,7 +202,13 @@ pub fn import_connections_from(path: &Path) -> Result, Str let mut conns = Vec::with_capacity(items.len()); for (i, item) in items.into_iter().enumerate() { match serde_json::from_value::(item) { - Ok(c) => conns.push(c), + Ok(c) => { + // 空 id 会在去重/订阅关联时产生悬空引用,导入层直接拒绝 + if c.id.trim().is_empty() { + return Err(format!("第 {} 项的 id 不能为空", i + 1)); + } + conns.push(c); + } Err(e) => return Err(format!("第 {} 项不是合法的连接配置: {e}", i + 1)), } } @@ -262,4 +268,18 @@ mod tests { assert!(err.contains("第 2 项"), "错误应定位到序号: {err}"); let _ = std::fs::remove_file(&path); } + + #[test] + fn import_rejects_empty_id() { + let path = temp_path("empty-id.json"); + let mut c = ConnectionConfig::new(); + c.id = " ".into(); + let good = serde_json::to_value(ConnectionConfig::new()).unwrap(); + let body = serde_json::json!([good, serde_json::to_value(&c).unwrap()]).to_string(); + std::fs::write(&path, body).expect("写入测试文件"); + let err = import_connections_from(&path).expect_err("空 id 应报错"); + assert!(err.contains("第 2 项"), "错误应定位到序号: {err}"); + assert!(err.contains("id"), "错误应说明 id 为空: {err}"); + let _ = std::fs::remove_file(&path); + } } diff --git a/gpui-app/src/ui/app.rs b/gpui-app/src/ui/app.rs index a321caa..4584da4 100644 --- a/gpui-app/src/ui/app.rs +++ b/gpui-app/src/ui/app.rs @@ -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::(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::(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 = + let mut existing: std::collections::HashSet = 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) -> 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) -> 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() } } diff --git a/gpui-app/src/ui/connection_form.rs b/gpui-app/src/ui/connection_form.rs index aaa4490..a4e64b0 100644 --- a/gpui-app/src/ui/connection_form.rs +++ b/gpui-app/src/ui/connection_form.rs @@ -1,5 +1,7 @@ //! 新建/编辑连接的表单对话框内容。 +use std::cell::RefCell; +use std::collections::HashMap; use std::sync::Arc; use gpui_kit::component::scroll::ScrollableElement as _; @@ -21,7 +23,7 @@ use gpui_kit::{ use crate::model::{ConnectionConfig, LastWill, ProtocolVersion, SslConfig, TransportKind}; use crate::mqtt::MqttEngine; -use crate::ui::widgets::{field, make_select, OptionDelegate}; +use crate::ui::widgets::{field, field_ex, make_select, OptionDelegate}; const PROTOCOLS: [&str; 2] = ["MQTT 5.0", "MQTT 3.1.1"]; const TRANSPORTS: [&str; 4] = ["TCP", "TLS", "WebSocket", "WSS"]; @@ -76,12 +78,16 @@ pub struct ConnectionForm { will_retain: bool, ssl_ignore_ca: bool, - // 按需显示:高级分组默认折叠 + // 按需显示:高级分组默认折叠(编辑已配置内容时在 new() 里默认展开) show_auth: bool, show_mqtt5: bool, show_will: bool, show_ssl: bool, + // build() 校验失败时的字段级错误(字段 key → 提示文案), + // render 时映射成红字;build 只拿 &self,故用 RefCell 内部可变 + field_errors: RefCell>, + testing: bool, _subs: Vec, } @@ -220,25 +226,39 @@ impl ConnectionForm { let group_idx = c .group .as_deref() - .and_then(|g| groups.iter().position(|x| x == g)) + .and_then(|g| { + // 存储值可能带首尾空白,而下拉列表在上游已 trim, + // 直接相等比较会匹配不上 → 回填成「无分组」,保存即丢分组 + let g = g.trim(); + groups.iter().position(|x| x == g) + }) .map(|i| i + 1) .unwrap_or(0); let group_select = make_select(&group_labels, group_idx, window, cx); // 切换传输方式时,把端口同步成该方式的默认端口,并刷新 WS 路径的按需显示。 + // gpui 列表点击任意项(含当前项)都会发 Confirm,若不比对索引, + // 打开下拉点一下当前传输就会把自定义端口静默改回默认值。 + let last_transport = std::cell::Cell::new(transport_idx); let port_for_sub = port.clone(); let sub_transport = cx.subscribe_in( &transport, window, move |_this, state, event, window, cx| { if matches!(event, SelectEvent::Confirm(Some(_))) - && let Some(idx) = state.read(cx).selected_value() { - let kind = TransportKind::ALL[*idx]; - port_for_sub.update(cx, |p, cx| { - p.set_value(kind.default_port().to_string(), window, cx); - }); - cx.notify(); + && let Some(idx) = state.read(cx).selected_value().copied() + && let Some(kind) = TransportKind::ALL.get(idx).copied() + { + // 索引未变化:视为重新点选当前项,不覆盖用户输入的端口 + if last_transport.get() == idx { + return; } + last_transport.set(idx); + port_for_sub.update(cx, |p, cx| { + p.set_value(kind.default_port().to_string(), window, cx); + }); + cx.notify(); + } }, ); // 切换协议时刷新 MQTT 5 属性分组的按需显示。 @@ -252,6 +272,16 @@ impl ConnectionForm { }, ); + // 编辑已配置 SSL/会话过期等高级项的连接时默认展开,避免配置不可见 + let ssl_configured = !c.ssl.ca_file.trim().is_empty() + || !c.ssl.client_cert_file.trim().is_empty() + || !c.ssl.client_key_file.trim().is_empty() + || c.ssl.ignore_ca; + let mqtt5_configured = c.session_expiry_interval > 0 + || c.receive_maximum.is_some() + || c.maximum_packet_size.is_some() + || c.topic_alias_maximum.is_some(); + Self { engine, editing_id, @@ -290,9 +320,10 @@ impl ConnectionForm { will_retain, ssl_ignore_ca: c.ssl.ignore_ca, show_auth: true, - show_mqtt5: false, + show_mqtt5: mqtt5_configured, show_will: false, - show_ssl: false, + show_ssl: ssl_configured, + field_errors: RefCell::default(), testing: false, _subs: vec![sub_transport, sub_protocol], } @@ -302,6 +333,22 @@ impl ConnectionForm { e.read(cx).value().to_string() } + /// 记录字段级错误并返回同一文案,供 build 的 `?` 传播到 Toast。 + fn fail(&self, key: &'static str, msg: impl Into) -> String { + let msg = msg.into(); + self.field_errors.borrow_mut().insert(key, msg.clone()); + msg + } + + /// 把 Result 的错误挂到指定字段上再继续 `?` 传播。 + fn fail_as( + &self, + key: &'static str, + r: Result, + ) -> Result { + r.map_err(|e| self.fail(key, e)) + } + fn parse_u16(s: &str, label: &str) -> Result { s.trim() .parse::() @@ -329,39 +376,63 @@ impl ConnectionForm { } fn build_inner(&self, cx: &App, for_test: bool) -> Result { + // 每次构建先清空上一轮字段错误,失败时由 fail() 重新登记 + self.field_errors.borrow_mut().clear(); + let mut name = self.val(&self.name, cx); if name.trim().is_empty() { if !for_test { - return Err("连接名称不能为空".into()); + return Err(self.fail("name", "连接名称不能为空")); } name = "测试连接".into(); } + // 判空已按 trim,保存也存 trim 后的值,避免首尾空白入库 + let name = name.trim().to_string(); let host = self.val(&self.host, cx); if host.trim().is_empty() { - return Err("主机地址不能为空".into()); + return Err(self.fail("host", "主机地址不能为空")); } - let port = Self::parse_u16(&self.val(&self.port, cx), "端口")?; + let host = host.trim().to_string(); + let port = self.fail_as("port", Self::parse_u16(&self.val(&self.port, cx), "端口"))?; let mut client_id = self.val(&self.client_id, cx); if client_id.trim().is_empty() { if !for_test { - return Err("Client ID 不能为空".into()); + return Err(self.fail("client_id", "Client ID 不能为空")); } client_id = format!("mqttx-test-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); } - let keep_alive = Self::parse_u16(&self.val(&self.keep_alive, cx), "Keep Alive")?; - let session_expiry = self - .val(&self.session_expiry, cx) - .trim() - .parse::() - .unwrap_or(0); - let receive_maximum = Self::parse_opt_u32(&self.val(&self.receive_max, cx), "接收上限")? - .map(|v| u16::try_from(v).map_err(|_| "接收上限不能超过 65535".to_string())) - .transpose()?; - let maximum_packet_size = - Self::parse_opt_u32(&self.val(&self.max_packet, cx), "最大报文长度")?; - let topic_alias_maximum = Self::parse_opt_u32(&self.val(&self.topic_alias, cx), "主题别名")? - .map(|v| u16::try_from(v).map_err(|_| "主题别名上限不能超过 65535".to_string())) - .transpose()?; + let client_id = client_id.trim().to_string(); + let keep_alive = + self.fail_as("keep_alive", Self::parse_u16(&self.val(&self.keep_alive, cx), "Keep Alive"))?; + // 空串视为 0(会话随连接结束);非空必须是合法非负整数,不再静默吞掉 -1/abc + let session_expiry = { + let raw = self.val(&self.session_expiry, cx); + let t = raw.trim(); + if t.is_empty() { + 0 + } else { + t.parse::().map_err(|_| { + self.fail( + "session_expiry", + "会话过期间隔需要是非负整数(0=会话随连接结束)", + ) + })? + } + }; + let receive_maximum = self.fail_as("receive_max", (|| -> Result<_, String> { + Ok(Self::parse_opt_u32(&self.val(&self.receive_max, cx), "接收上限")? + .map(|v| u16::try_from(v).map_err(|_| "接收上限不能超过 65535".to_string())) + .transpose()) + })())??; + let maximum_packet_size = self.fail_as( + "max_packet", + Self::parse_opt_u32(&self.val(&self.max_packet, cx), "最大报文长度"), + )?; + let topic_alias_maximum = self.fail_as("topic_alias", (|| -> Result<_, String> { + Ok(Self::parse_opt_u32(&self.val(&self.topic_alias, cx), "主题别名")? + .map(|v| u16::try_from(v).map_err(|_| "主题别名上限不能超过 65535".to_string())) + .transpose()) + })())??; let conn_timeout_raw = self.val(&self.conn_timeout, cx); let connection_timeout_secs = if conn_timeout_raw.trim().is_empty() { @@ -372,7 +443,7 @@ impl ConnectionForm { .parse::() .ok() .filter(|v| *v >= 1) - .ok_or_else(|| "连接超时需要是 1~65535 的整数(秒)".to_string())? + .ok_or_else(|| self.fail("conn_timeout", "连接超时需要是 1~65535 的整数(秒)"))? }; let max_reconnect_raw = self.val(&self.max_reconnect, cx); let max_reconnect_times = if max_reconnect_raw.trim().is_empty() { @@ -381,7 +452,7 @@ impl ConnectionForm { max_reconnect_raw .trim() .parse::() - .map_err(|_| "最大重连次数需要是非负整数(0=无限)".to_string())? + .map_err(|_| self.fail("max_reconnect", "最大重连次数需要是非负整数(0=无限)"))? }; // 分组:新分组名非空时优先,否则取下拉所选(首项=无分组) @@ -416,6 +487,12 @@ impl ConnectionForm { .and_then(|i| TransportKind::ALL.get(*i).copied()) .unwrap_or_default(); + // WS/WSS 依赖 path 建立升级请求,缺失时连接必然失败,保存前拦截 + let path = self.val(&self.path, cx).trim().to_string(); + if transport.is_websocket() && path.is_empty() { + return Err(self.fail("path", "WS/WSS 传输下 WS 路径不能为空")); + } + let ssl = SslConfig { ca_file: self.val(&self.ssl_ca, cx).trim().to_string(), client_cert_file: self.val(&self.ssl_client_cert, cx).trim().to_string(), @@ -426,13 +503,25 @@ impl ConnectionForm { if transport.is_tls() && ssl.client_cert_file.is_empty() != ssl.client_key_file.is_empty() { - return Err("客户端证书与客户端密钥必须同时填写".into()); + return Err(self.fail("ssl_client_cert", "客户端证书与客户端密钥必须同时填写")); + } + // 证书文件不存在时握手才报错太晚,保存前给出字段级提示 + if transport.is_tls() { + for (key, label, p) in [ + ("ssl_ca", "CA 证书", &ssl.ca_file), + ("ssl_client_cert", "客户端证书", &ssl.client_cert_file), + ("ssl_client_key", "客户端私钥", &ssl.client_key_file), + ] { + if !p.is_empty() && !std::path::Path::new(p).exists() { + return Err(self.fail(key, format!("{label}路径不存在:{p}"))); + } + } } let last_will = if self.will_enabled { let topic = self.val(&self.will_topic, cx); if topic.trim().is_empty() { - return Err("遗嘱主题不能为空".into()); + return Err(self.fail("will_topic", "遗嘱主题不能为空")); } let opt_str = |v: String| { let t = v.trim().to_string(); @@ -456,7 +545,7 @@ impl ConnectionForm { name, host, port, - path: self.val(&self.path, cx), + path, protocol, transport, client_id, @@ -484,6 +573,8 @@ impl ConnectionForm { Ok(c) => c, Err(e) => { window.push_notification(Notification::error(e), cx); + // 触发重绘,让刚登记的字段级红字立即显示 + cx.notify(); return; } }; @@ -624,6 +715,33 @@ impl Render for ConnectionForm { self.transport.read(cx).selected_value(), Some(1) | Some(3) ); + // build() 校验失败登记的字段级错误,渲染成红字;红/星号用主题错误色 + let danger = cx.theme().danger; + let field_errors = self.field_errors.borrow().clone(); + let err = move |key: &'static str| -> Option { + field_errors.get(key).map(|s| SharedString::from(s.as_str())) + }; + // 折叠标题的「已配置 N 项」提示,避免编辑旧配置时看不到已填内容 + let ssl_count = [ + self.val(&self.ssl_ca, cx).trim().is_empty(), + self.val(&self.ssl_client_cert, cx).trim().is_empty(), + self.val(&self.ssl_client_key, cx).trim().is_empty(), + ] + .iter() + .filter(|empty| !**empty) + .count(); + let mqtt5_count = [ + !self.val(&self.session_expiry, cx).trim().is_empty(), + !self.val(&self.receive_max, cx).trim().is_empty(), + !self.val(&self.max_packet, cx).trim().is_empty(), + !self.val(&self.topic_alias, cx).trim().is_empty(), + ] + .iter() + .filter(|on| **on) + .count(); + let ssl_hint = (ssl_count > 0).then(|| SharedString::from(format!("已配置 {ssl_count} 项"))); + let mqtt5_hint = + (mqtt5_count > 0).then(|| SharedString::from(format!("已配置 {mqtt5_count} 项"))); v_flex() .gap_3() .max_h(px(560.)) @@ -633,11 +751,11 @@ impl Render for ConnectionForm { .child(section("基本信息", border, card_bg, v_flex().gap_2().child( h_flex().gap_2().w_full() - .child(div().flex_1().min_w(px(0.)).child(field("名称", Input::new(&self.name)))) - .child(div().w(px(120.)).child(field("端口", Input::new(&self.port)))) + .child(div().flex_1().min_w(px(0.)).child(field_ex("名称", Input::new(&self.name), true, err("name"), danger))) + .child(div().w(px(120.)).child(field_ex("端口", Input::new(&self.port), true, err("port"), danger))) ) .child(h_flex().gap_2().w_full() - .child(div().flex_1().min_w(px(0.)).child(field("主机", Input::new(&self.host)))) + .child(div().flex_1().min_w(px(0.)).child(field_ex("主机", Input::new(&self.host), true, err("host"), danger))) .child(div().flex_1().min_w(px(0.)).child(field("协议", Select::new(&self.protocol)))) .child(div().flex_1().min_w(px(0.)).child(field("传输", Select::new(&self.transport)))) ) @@ -646,7 +764,7 @@ impl Render for ConnectionForm { .child(div().flex_1().min_w(px(0.)).child(field("新建分组(优先于左侧选择)", Input::new(&self.group_new)))) ) .child(h_flex().gap_2().w_full().items_end() - .child(div().flex_1().min_w(px(0.)).child(field("Client ID", Input::new(&self.client_id)))) + .child(div().flex_1().min_w(px(0.)).child(field_ex("Client ID", Input::new(&self.client_id), true, err("client_id"), danger))) // 一键换一个随机 Client ID,与 ConnectionConfig::new 的生成逻辑一致 .child( Button::new("cid-regen") @@ -660,9 +778,9 @@ impl Render for ConnectionForm { cx.notify(); })), ) - // WS 路径仅对 WebSocket/WSS 有意义,按需显示 + // WS 路径仅对 WebSocket/WSS 有意义,按需显示;该传输下必填 .when(is_ws, |w| w.child( - div().flex_1().min_w(px(0.)).child(field("WS 路径", Input::new(&self.path))) + div().flex_1().min_w(px(0.)).child(field_ex("WS 路径", Input::new(&self.path), true, err("path"), danger)) )) ) )) @@ -674,11 +792,11 @@ impl Render for ConnectionForm { .child(h_flex().gap_2().w_full() .child(div().flex_1().min_w(px(0.)).child(field("用户名", Input::new(&self.username)))) .child(div().flex_1().min_w(px(0.)).child(field("密码", Input::new(&self.password).mask_toggle()))) - .child(div().w(px(110.)).child(field("Keep Alive (秒)", Input::new(&self.keep_alive)))) + .child(div().w(px(110.)).child(field_ex("Keep Alive (秒)", Input::new(&self.keep_alive), true, err("keep_alive"), danger))) ) .child(h_flex().gap_2().w_full() - .child(div().w(px(150.)).child(field("连接超时 (秒)", Input::new(&self.conn_timeout)))) - .child(div().w(px(170.)).child(field("最大重连次数 (0=无限)", Input::new(&self.max_reconnect)))) + .child(div().w(px(150.)).child(field_ex("连接超时 (秒)", Input::new(&self.conn_timeout), false, err("conn_timeout"), danger))) + .child(div().w(px(170.)).child(field_ex("最大重连次数 (0=无限)", Input::new(&self.max_reconnect), false, err("max_reconnect"), danger))) ) .child(self.switch_row("clean_start", "Clean Start / Clean Session", self.clean_start, cx)) .child(self.switch_row("auto_reconnect", "断线自动重连", self.auto_reconnect, cx)) @@ -687,25 +805,25 @@ impl Render for ConnectionForm { )) // ── SSL/TLS(仅 TLS/WSS 显示) ── .when(is_tls, |w| w.child(self.collapsible_section( - "sec-ssl", "SSL/TLS", None, false, self.show_ssl, + "sec-ssl", "SSL/TLS", ssl_hint, true, self.show_ssl, |f| f.show_ssl = !f.show_ssl, border, card_bg, v_flex().gap_2() // Phase 2 或后续接原生文件对话框;GPUI 暂无内置文件选择器,先用路径文本输入 - .child(field("CA 证书 (PEM)", Input::new(&self.ssl_ca))) - .child(field("客户端证书 (PEM)", Input::new(&self.ssl_client_cert))) - .child(field("客户端密钥 (PEM)", Input::new(&self.ssl_client_key))) + .child(field_ex("CA 证书 (PEM)", Input::new(&self.ssl_ca), false, err("ssl_ca"), danger)) + .child(field_ex("客户端证书 (PEM)", Input::new(&self.ssl_client_cert), false, err("ssl_client_cert"), danger)) + .child(field_ex("客户端密钥 (PEM)", Input::new(&self.ssl_client_key), false, err("ssl_client_key"), danger)) .child(self.switch_row("ssl_ignore_ca", "忽略 CA 校验(不验证服务器证书)", self.ssl_ignore_ca, cx)), cx, ))) // ── MQTT 5 高级属性(仅 v5 协议显示) ── .when(is_v5, |w| w.child(self.collapsible_section( - "sec-mqtt5", "MQTT 5 属性", None, false, self.show_mqtt5, + "sec-mqtt5", "MQTT 5 属性", mqtt5_hint, true, self.show_mqtt5, |f| f.show_mqtt5 = !f.show_mqtt5, border, card_bg, h_flex().gap_2().w_full() - .child(div().flex_1().min_w(px(0.)).child(field("会话过期间隔(秒)", Input::new(&self.session_expiry)))) - .child(div().flex_1().min_w(px(0.)).child(field("接收最大值", Input::new(&self.receive_max)))) - .child(div().flex_1().min_w(px(0.)).child(field("最大报文长度", Input::new(&self.max_packet)))) - .child(div().flex_1().min_w(px(0.)).child(field("主题别名上限", Input::new(&self.topic_alias)))), + .child(div().flex_1().min_w(px(0.)).child(field_ex("会话过期间隔(秒)", Input::new(&self.session_expiry), false, err("session_expiry"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field_ex("接收最大值", Input::new(&self.receive_max), false, err("receive_max"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field_ex("最大报文长度", Input::new(&self.max_packet), false, err("max_packet"), danger))) + .child(div().flex_1().min_w(px(0.)).child(field_ex("主题别名上限", Input::new(&self.topic_alias), false, err("topic_alias"), danger))), cx, ))) // ── 遗嘱 ── @@ -717,7 +835,7 @@ impl Render for ConnectionForm { .child(self.switch_row("will_enabled", "启用遗嘱消息", self.will_enabled, cx)) .when(self.will_enabled, |w| w .child(h_flex().gap_2() - .child(div().flex_1().min_w(px(0.)).child(field("遗嘱主题", Input::new(&self.will_topic)))) + .child(div().flex_1().min_w(px(0.)).child(field_ex("遗嘱主题", Input::new(&self.will_topic), true, err("will_topic"), danger))) .child(div().w(px(130.)).child(field("QoS", Select::new(&self.will_qos)))) ) .child(field("遗嘱内容", Input::new(&self.will_payload))) diff --git a/gpui-app/src/ui/connection_view.rs b/gpui-app/src/ui/connection_view.rs index 53d0c42..9549850 100644 --- a/gpui-app/src/ui/connection_view.rs +++ b/gpui-app/src/ui/connection_view.rs @@ -2,7 +2,8 @@ use std::sync::Arc; -use gpui_kit::component::button::{Button, ButtonGroup, ButtonVariants as _}; +use gpui_kit::component::button::{Button, ButtonGroup, ButtonVariant, ButtonVariants as _}; +use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::scroll::ScrollableElement as _; use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState}; use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem}; @@ -10,8 +11,8 @@ use gpui_kit::component::select::{Select, SelectState}; use gpui_kit::component::switch::Switch; use gpui_kit::component::tab::{Tab, TabBar}; use gpui_kit::component::{ - h_flex, notification::Notification, v_flex, ActiveTheme as _, Selectable as _, Sizable as _, - StyledExt as _, WindowExt as _, + h_flex, notification::Notification, v_flex, ActiveTheme as _, Disableable as _, + Selectable as _, Sizable as _, StyledExt as _, WindowExt as _, }; use gpui_kit::prelude::FluentBuilder as _; use crate::ui::IconName; @@ -35,6 +36,8 @@ const PAYLOAD_FORMATS: [&str; 4] = ["Plaintext", "JSON", "Base64", "Hex"]; /// Retain Handling 0/1/2 的下拉文案 const RETAIN_HANDLING: [&str; 3] = ["0 每次发送", "1 仅新订阅", "2 不发送"]; const MAX_RENDERED_MESSAGES: usize = 300; +/// 日志渲染上限,与 app.rs 的 MAX_LOGS 保持一致 +const MAX_RENDERED_LOGS: usize = 3000; /// 订阅色板预设:色相(度)+ 名称 const PRESET_HUES: [(f32, &str); 10] = [ (0., "红"), @@ -165,6 +168,9 @@ pub struct ConnectionView { filter: Entity, panel: Panel, expanded: Option, + /// 当前 detail_format 归属的消息:切换到另一条消息时据此重置格式, + /// 同一条内折叠/展开则保留用户选择 + detail_owner: Option, msg_dir: DirFilter, detail_format: DetailFormat, @@ -234,6 +240,7 @@ impl ConnectionView { filter, panel: Panel::Messages, expanded: None, + detail_owner: None, msg_dir: DirFilter::All, detail_format: DetailFormat::Auto, preset_name, @@ -317,6 +324,10 @@ impl ConnectionView { sub.enabled = !sub.enabled; let enabled = sub.enabled; let topic = sub.topic.clone(); + // 停用的订阅不再参与消息过滤,指向它的过滤一并清掉 + if !enabled && self.sub_filter.as_deref() == Some(topic.as_str()) { + self.sub_filter = None; + } let conn = self.conn_id.clone(); self.upsert_subscription(sub.clone(), cx); if self.engine.is_connected(&conn) { @@ -357,6 +368,22 @@ impl ConnectionView { cx.notify(); } + /// 重置订阅表单(主题/别名/高级字段):新增提交与取消编辑共用, + /// 避免高级选项残留到下一次订阅。 + fn reset_subscribe_form(&mut self, window: &mut Window, cx: &mut Context) { + self.sub_topic + .update(cx, |s, cx| s.set_value("", window, cx)); + self.sub_alias + .update(cx, |s, cx| s.set_value("", window, cx)); + self.sub_identifier + .update(cx, |s, cx| s.set_value("", window, cx)); + self.sub_no_local = false; + self.sub_rap = false; + self.sub_retain_handling.update(cx, |s, cx| { + s.set_selected_index(Some(IndexPath::new(0)), window, cx) + }); + } + // ── 订阅 / 发布动作 ───────────────────────────────────────────────────── fn do_subscribe(&mut self, window: &mut Window, cx: &mut Context) { @@ -403,6 +430,19 @@ impl ConnectionView { let connected = self.engine.is_connected(&self.conn_id); let conn = self.conn_id.clone(); + // 记录编辑来源:编辑态提交后以目标订阅回填,保住编辑上下文 + let was_editing = self.editing.clone(); + // 改名编辑时旧记录随后会被删除,先取出供新主题承接 id/color/enabled + let editing_old = was_editing.as_ref().and_then(|old_topic| { + self.with_app(cx, |app| { + app.subscriptions + .iter() + .find(|s| s.connection_id == conn && s.topic == *old_topic) + .cloned() + }) + .flatten() + }); + // 编辑提交:旧主题不在新列表里时先移除(含引擎退订),新主题由下方 upsert 覆盖 if let Some(old_topic) = self.editing.take() && !topics.contains(&old_topic) @@ -418,17 +458,59 @@ impl ConnectionView { } } - for topic in topics { - let mut sub = Subscription::new(conn.clone(), topic, qos); - sub.alias = alias.clone(); - if is_v5 { - sub.sub_identifier = sub_identifier; - sub.no_local = no_local; - sub.retain_as_published = rap; - sub.retain_handling = retain_handling; - } + for topic in &topics { + // 按 (连接, 主题) 查旧记录:继承 id/color/enabled,避免重复提交把备注色清掉、 + // 把停用中的订阅静默重启 + let existing = self + .with_app(cx, |app| { + app.subscriptions + .iter() + .find(|s| s.connection_id == conn && s.topic == *topic) + .cloned() + }) + .flatten(); + let sub = match existing { + Some(mut old) => { + old.qos = qos; + old.alias = alias.clone(); + if is_v5 { + old.sub_identifier = sub_identifier; + old.no_local = no_local; + old.retain_as_published = rap; + old.retain_handling = retain_handling; + } + old + } + None => { + // 改名编辑的单主题提交:新主题承接旧记录身份与备注,避免改名丢数据 + if topics.len() == 1 && let Some(mut old) = editing_old.clone() { + old.topic = topic.clone(); + old.qos = qos; + old.alias = alias.clone(); + if is_v5 { + old.sub_identifier = sub_identifier; + old.no_local = no_local; + old.retain_as_published = rap; + old.retain_handling = retain_handling; + } + old + } else { + let mut sub = Subscription::new(conn.clone(), topic.clone(), qos); + sub.alias = alias.clone(); + if is_v5 { + sub.sub_identifier = sub_identifier; + sub.no_local = no_local; + sub.retain_as_published = rap; + sub.retain_handling = retain_handling; + } + sub + } + } + }; + // 继承到的停用订阅保持退订状态,不因重新提交被拉起 + let keep_disabled = !sub.enabled; self.upsert_subscription(sub.clone(), cx); - if connected { + if connected && !keep_disabled { self.engine.subscribe_with_options( conn.clone(), sub.clone(), @@ -449,14 +531,37 @@ impl ConnectionView { window.push_notification(Notification::warning(hint), cx); } - self.sub_topic - .update(cx, |s, cx| s.set_value("", window, cx)); - self.sub_alias - .update(cx, |s, cx| s.set_value("", window, cx)); + // 编辑态:以刚保存的目标订阅回填表单继续保留上下文; + // 新增态:清空主题/别名并重置高级字段,避免残留到下一次订阅 + if was_editing.is_some() && topics.len() == 1 { + let target = topics[0].clone(); + let saved = self + .with_app(cx, |app| { + app.subscriptions + .iter() + .find(|s| s.connection_id == conn && s.topic == target) + .cloned() + }) + .flatten(); + if let Some(saved) = saved { + self.start_edit_subscription(&saved, window, cx); + } else { + self.editing = None; + self.reset_subscribe_form(window, cx); + } + } else { + self.editing = None; + self.reset_subscribe_form(window, cx); + } cx.notify(); } fn do_publish(&mut self, window: &mut Window, cx: &mut Context) { + // 未连接时消息发不出去,先拦截给出与订阅路径一致的提示 + if !self.engine.is_connected(&self.conn_id) { + window.push_notification(Notification::warning("未连接:消息未发送"), cx); + return; + } let mut params = self.collect_publish_params(cx); if params.topic.trim().is_empty() { window.push_notification(Notification::warning("发布主题不能为空"), cx); @@ -472,6 +577,11 @@ impl ConnectionView { let vars = self.with_app(cx, |app| app.variables.clone()).unwrap_or_default(); params.topic = render_template(¶ms.topic, &vars); params.payload = render_template(¶ms.payload, &vars); + // {{topic}} 等变量渲染后可能变空串,渲染完成后再校验一次 + if params.topic.trim().is_empty() { + window.push_notification(Notification::warning("发布主题不能为空"), cx); + return; + } // {{变量}} 同样作用于用户属性的 key/value params.user_properties = params .user_properties @@ -585,17 +695,19 @@ impl ConnectionView { impl ConnectionView { fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement { let id = self.conn_id.clone(); - let (name, status, is_v5) = self + let (name, status, is_v5, error) = self .with_app(cx, |app| { let cfg = app.connections.iter().find(|c| c.id == id); ( cfg.map(|c| c.name.clone()).unwrap_or_else(|| id.clone()), app.statuses.get(&id).copied().unwrap_or(ConnectionStatus::Disconnected), cfg.map(|c| c.protocol.is_v5()).unwrap_or(true), + app.errors.get(&id).cloned(), ) }) - .unwrap_or((id.clone(), ConnectionStatus::Disconnected, true)); + .unwrap_or((id.clone(), ConnectionStatus::Disconnected, true, None)); let connected = matches!(status, ConnectionStatus::Connected); + let connecting = matches!(status, ConnectionStatus::Connecting); let _ = is_v5; let status_text = status.label(); @@ -605,6 +717,10 @@ impl ConnectionView { ConnectionStatus::Error => cx.theme().danger, ConnectionStatus::Disconnected => cx.theme().muted_foreground, }; + // Error 状态把失败原因展示出来,避免只有「错误」二字无从排查 + let error_hint = matches!(status, ConnectionStatus::Error) + .then(|| error.clone()) + .flatten(); h_flex() .w_full() @@ -632,7 +748,20 @@ impl ConnectionView { .child(status_text), ), ) - .child(div().flex_1()) + // Error 副文本展示失败原因(截断避免挤掉按钮) + .when_some(error_hint.clone(), |h, e| { + h.child( + div() + .flex_1() + .min_w(px(0.)) + .text_xs() + .text_color(cx.theme().danger) + .overflow_hidden() + .text_ellipsis() + .child(e), + ) + }) + .when(error_hint.is_none(), |h| h.child(div().flex_1())) .child( Button::new(SharedString::from(format!("conn-toggle-{}", self.conn_id))) .icon(if connected { @@ -640,11 +769,30 @@ impl ConnectionView { } else { IconName::PlugZap }) - .label(if connected { "断开" } else { "连接" }) + .label(if connected { + "断开" + } else if connecting { + "连接中…" + } else { + "连接" + }) .when(connected, |b| b.danger().ghost()) .when(!connected, |b| b.primary().ghost()) + // Connecting 期间禁用,避免重复点击重启连接流程 + .disabled(connecting) .small() - .on_click(cx.listener(|this, _, _window, cx| { + .when_some(error_hint.clone(), |b, e| b.tooltip(e)) + .on_click(cx.listener(move |this, _, _window, cx| { + // Connecting 期间(按钮禁用之外再兜一层)不响应,防止重复 connect 重启流程 + let connecting = this + .with_app(cx, |app| { + app.statuses.get(&this.conn_id) + == Some(&ConnectionStatus::Connecting) + }) + .unwrap_or(false); + if connecting { + return; + } let cfg = this.config(cx); if let Some(cfg) = cfg { if this.engine.is_connected(&cfg.id) { @@ -662,7 +810,8 @@ impl ConnectionView { let sub_qos = self.sub_qos.clone(); let sub_alias = self.sub_alias.clone(); let is_v5 = self.is_v5(cx); - let editing = self.editing.is_some(); + let editing_topic = self.editing.clone(); + let editing = editing_topic.is_some(); let show_advanced = self.sub_show_advanced && is_v5; let mut bar = v_flex() @@ -675,6 +824,19 @@ impl ConnectionView { .px_3() .h_12() .items_center() + // 编辑态显式提示目标主题,避免被误当成新增提交 + .when_some(editing_topic.clone(), |h, topic| { + h.child( + div() + .px_1p5() + .py_0p5() + .rounded_md() + .bg(cx.theme().warning.alpha(0.15)) + .text_xs() + .text_color(cx.theme().warning) + .child(format!("正在编辑:{topic}")), + ) + }) .child(div().flex_1().min_w(px(0.)).child(Input::new(&sub_topic).small())) .child(div().w(px(96.)).child(Select::new(&sub_qos).small())) .child(div().w(px(112.)).child(Input::new(&sub_alias).small())) @@ -697,7 +859,9 @@ impl ConnectionView { Button::new(SharedString::from(format!("subscribe-{}", self.conn_id))) .icon(if editing { IconName::Check } else { IconName::Plus }) .label(if editing { "更新" } else { "订阅" }) - .primary() + // 编辑用 warning 色与新增的 primary 区分 + .when(editing, |b| b.warning()) + .when(!editing, |b| b.primary()) .small() .on_click(cx.listener(|this, _, window, cx| this.do_subscribe(window, cx))), ) @@ -708,8 +872,10 @@ impl ConnectionView { .ghost() .small() .tooltip("取消编辑") - .on_click(cx.listener(|this, _, _, cx| { + .on_click(cx.listener(|this, _, window, cx| { this.editing = None; + // 清空回填到输入框的主题/别名/高级字段,回到新增态 + this.reset_subscribe_form(window, cx); cx.notify(); })), ) @@ -853,7 +1019,8 @@ impl ConnectionView { let weak = weak_menu.clone(); let sid = sid.clone(); let swatch = hue_color(hue); - let label = SharedString::from(format!("{name} {hue:.0}°")); + // 菜单只显示颜色名,角度数值对用户没有意义 + let label = SharedString::from(name); menu = menu.item( PopupMenuItem::element(move |_, _| { h_flex() @@ -934,7 +1101,6 @@ impl ConnectionView { let del_cid = self.conn_id.clone(); let del_topic = topic.clone(); - let del_weak_filter = self.sub_filter.clone(); let del_btn = Button::new(SharedString::from(format!("sub-del-{}", sub_id))) .icon(IconName::Close) .ghost() @@ -942,7 +1108,7 @@ impl ConnectionView { .tooltip("取消订阅") .on_click(cx.listener(move |this, _, _, cx| { // 若删除的是当前过滤主题,一并清掉消息过滤 - if del_weak_filter.as_deref() == Some(del_topic.as_str()) { + if this.sub_filter.as_deref() == Some(del_topic.as_str()) { this.sub_filter = None; } if let Some(app) = this.app.upgrade() { @@ -951,7 +1117,10 @@ impl ConnectionView { cx.notify(); }); } - this.editing = None; + // 只有删除的是正在编辑的主题才退出编辑态,编辑别的不受影响 + if this.editing.as_deref() == Some(del_topic.as_str()) { + this.editing = None; + } cx.notify(); })); @@ -1062,7 +1231,7 @@ impl ConnectionView { let border = cx.theme().border; let muted = cx.theme().muted_foreground; let mono = cx.theme().mono_font_family.clone(); - let direction_label = if received { "接收" } else { "发送" }; + let direction_label = if received { "接收" } else { "发布" }; let show_millis = self .with_app(cx, |app| app.settings.show_millis) .unwrap_or(true); @@ -1284,10 +1453,15 @@ impl ConnectionView { } body = body.child( div() + .id(SharedString::from(format!("msg-detail-{}", seq))) .mt_1() .p_2() .rounded_lg() .bg(cx.theme().muted) + // 详情区点击不冒泡到行折叠,保证文本可选中 + .on_click(|_, _, cx| { + cx.stop_propagation(); + }) .child(details), ); } @@ -1312,11 +1486,16 @@ impl ConnectionView { ) .child(body) .on_click(cx.listener(move |this, _, _, cx| { - this.expanded = if this.expanded == Some(seq) { - None + if this.expanded == Some(seq) { + this.expanded = None; } else { - Some(seq) - }; + // 切换到另一条消息时格式回到 Auto,单条内折叠/展开仍记忆选择 + if this.detail_owner != Some(seq) { + this.detail_format = DetailFormat::Auto; + this.detail_owner = Some(seq); + } + this.expanded = Some(seq); + } cx.notify(); })) } @@ -1326,7 +1505,8 @@ impl ConnectionView { let conn = self.conn_id.clone(); let dir = self.msg_dir; let sub_filter = self.sub_filter.clone(); - let (records, subs): (Vec, Vec) = self + // 引用过滤 + 计数命中总数,仅克隆前 MAX_RENDERED_MESSAGES 条,避免全量拷贝 + let (records, subs, matched_total): (Vec, Vec, usize) = self .with_app(cx, |app| { let subs: Vec = app .subscriptions @@ -1334,37 +1514,31 @@ impl ConnectionView { .filter(|s| s.connection_id == conn) .cloned() .collect(); - let records: Vec = app - .messages - .get(&conn) - .map(|m| { - m.iter() - .rev() - // 方向 / 搜索 / 订阅过滤叠加,过滤后再截断保证最新消息优先 - .filter(|r| { - let dir_ok = match dir { - DirFilter::All => true, - DirFilter::Received => { - r.direction == Direction::Received - } - DirFilter::Published => { - r.direction == Direction::Published - } - }; - let query_ok = query.is_empty() - || r.topic.to_lowercase().contains(&query) - || r.payload.to_lowercase().contains(&query); - let sub_ok = sub_filter - .as_deref() - .map_or(true, |f| topic_matches(f, &r.topic)); - dir_ok && query_ok && sub_ok - }) - .take(MAX_RENDERED_MESSAGES) - .cloned() - .collect::>() - }) - .unwrap_or_default(); - (records, subs) + let mut matched = 0usize; + let mut records: Vec = Vec::new(); + if let Some(m) = app.messages.get(&conn) { + for r in m.iter().rev() { + // 方向 / 搜索 / 订阅过滤叠加,过滤后再截断保证最新消息优先 + let dir_ok = match dir { + DirFilter::All => true, + DirFilter::Received => r.direction == Direction::Received, + DirFilter::Published => r.direction == Direction::Published, + }; + let query_ok = query.is_empty() + || contains_ignore_case(&r.topic, &query) + || contains_ignore_case(&r.payload, &query); + let sub_ok = sub_filter + .as_deref() + .map_or(true, |f| topic_matches(f, &r.topic)); + if dir_ok && query_ok && sub_ok { + matched += 1; + if records.len() < MAX_RENDERED_MESSAGES { + records.push(r.clone()); + } + } + } + } + (records, subs, matched) }) .unwrap_or_default(); @@ -1504,27 +1678,65 @@ impl ConnectionView { .text_xs() .px_1p5() .text_color(cx.theme().muted_foreground) - .child(format!("{} 条", records.len())), + // 计数显示过滤命中总数,不受渲染截断影响 + .child(format!("{} 条", matched_total)), ) - .child( + .when(matched_total > records.len(), |h| { + h.child( + div() + .text_xs() + .px_1p5() + .text_color(cx.theme().muted_foreground) + .child(format!("仅显示最新 {} 条", MAX_RENDERED_MESSAGES)), + ) + }) + .child({ + let weak = cx.weak_entity(); + let clear_id = self.conn_id.clone(); + let empty = records.is_empty(); Button::new(SharedString::from(format!("msg-clear-{}", self.conn_id))) .icon(IconName::Eraser) .label("清空") .ghost() .small() + .disabled(empty) .tooltip("清空当前连接的消息") - .on_click(cx.listener(|this, _, _, cx| { - let id = this.conn_id.clone(); - this.expanded = None; - if let Some(app) = this.app.upgrade() { - app.update(cx, |app, cx| { - app.messages.remove(&id); - cx.notify(); - }); - } - cx.notify(); - })), - ), + .on_click(move |_, window, cx| { + // 清空不可撤销,先弹二次确认 + let weak = weak.clone(); + let clear_id = clear_id.clone(); + window.open_alert_dialog(cx, move |alert, _, _| { + let weak = weak.clone(); + let clear_id = clear_id.clone(); + alert + .title("清空消息") + .description("确定清空当前连接的全部消息吗?此操作不可撤销。") + .button_props( + DialogButtonProps::default() + .show_cancel(true) + .cancel_text("取消") + .ok_text("清空") + .ok_variant(ButtonVariant::Danger), + ) + .on_ok(move |_, _, cx| { + weak.update(cx, |view, cx| { + view.expanded = None; + view.detail_owner = None; + view.detail_format = DetailFormat::Auto; + if let Some(app) = view.app.upgrade() { + app.update(cx, |app, cx| { + app.messages.remove(&clear_id); + cx.notify(); + }); + } + cx.notify(); + }) + .ok(); + true + }) + }); + }) + }), ) .child(body) } @@ -1541,7 +1753,8 @@ impl ConnectionView { .iter() .rev() .filter(|l| l.connection_id == conn) - .take(2000) + // 与 app.rs 的 MAX_LOGS=3000 对齐,避免渲染截断早于存储上限 + .take(MAX_RENDERED_LOGS) .cloned() .collect() }) @@ -1644,6 +1857,7 @@ impl ConnectionView { // 发布预设 let presets = self.with_app(cx, |app| app.presets.clone()).unwrap_or_default(); + let connected = self.engine.is_connected(&self.conn_id); let mut card = v_flex() .w_full() @@ -1709,6 +1923,9 @@ impl ConnectionView { .label("发送") .primary() .small() + // 未连接时禁用,Ctrl+Enter 路径由 do_publish 内的前置检查兜底 + .disabled(!connected) + .when(!connected, |b| b.tooltip("未连接,无法发送")) .on_click(cx.listener(|this, _, window, cx| this.do_publish(window, cx))), ), ); @@ -1915,24 +2132,56 @@ impl ConnectionView { ); return; } - // collect 已覆盖用户属性与全部 v5 字段 + // 复用 do_publish 的过期秒数校验:输入了但解析不出即拒绝 let params = weak .read_with(cx, |view, cx| { - view.collect_publish_params(cx) + let expiry_raw = view + .msg_expiry + .read(cx) + .value() + .to_string(); + ( + view.collect_publish_params(cx), + expiry_raw, + ) }) .ok(); - if let (Some(view), Some(params)) = + if let (Some(view), Some((params, expiry_raw))) = (weak.upgrade(), params) - && let Some(app) = view.read(cx).app.upgrade() { + { + if !expiry_raw.trim().is_empty() + && params.message_expiry_interval.is_none() + { + window.push_notification( + Notification::warning( + "消息过期须为非负整数(秒)", + ), + cx, + ); + return; + } + if let Some(app) = view.read(cx).app.upgrade() { + // 同名即覆盖,提示语区分以明确发生了什么 + let existed = app + .read(cx) + .presets + .iter() + .any(|p| p.name == name); app.update(cx, |app, cx| { - app.upsert_publish_preset(name, params); + app.upsert_publish_preset(name.clone(), params); cx.notify(); }); + let msg = if existed { + format!("已覆盖预设「{name}」") + } else { + "预设已保存".to_string() + }; window.push_notification( - Notification::success("预设已保存"), + Notification::success(msg), cx, ); } + } window.close_dialog(cx); } }), @@ -1995,9 +2244,21 @@ impl Render for ConnectionView { // ─── 独立工具函数 ───────────────────────────────────────────────────────────── -/// 色相(度)→ gpui Hsla(h 分量为 0..=1 的整圆比例) +/// 色相(度)→ gpui Hsla(h 分量为 0..=1 的整圆比例)。 +/// 固定 L=0.52 时亮色相(黄绿)在浅色主题下过亮、暗色相(蓝靛)在深色主题下过暗, +/// 按色相段微调明度,保证两端主题下都可辨识。 fn hue_color(hue: f32) -> Hsla { - hsla(hue.rem_euclid(360.0) / 360.0, 0.72, 0.52, 1.0) + let h = hue.rem_euclid(360.0); + let l = if (30.0..=90.0).contains(&h) { + // 黄/黄绿本身明度感高,压低一点防刺眼 + 0.44 + } else if (200.0..=280.0).contains(&h) { + // 蓝/靛本身明度感低,抬高一点防发黑 + 0.60 + } else { + 0.52 + }; + hsla(h / 360.0, 0.72, l, 1.0) } /// 逗号 / 空白 / 换行分隔的多主题输入 → 去重后的主题列表(批量订阅) @@ -2104,12 +2365,39 @@ fn details_copy_text(record: &MqttRecord, show_millis: bool) -> String { out } -/// 折叠态只显示单行预览;完整内容由展开详情按所选格式渲染。 +/// 折叠态只显示单行预览:换行/制表符压平成空格后截 200 字符。 +/// 含替换字符(U+FFFD)说明负载不是合法文本,提示切 Hex 查看。 fn preview_payload(payload: &str) -> String { - let one_line: String = payload.chars().take(200).collect(); - if payload.chars().count() > 200 { - format!("{one_line}…") - } else { - one_line + let flat: String = payload + .chars() + .map(|c| match c { + '\n' | '\r' | '\t' => ' ', + _ => c, + }) + .collect(); + let mut out: String = flat.chars().take(200).collect(); + if flat.chars().count() > 200 { + out.push('…'); } + if payload.contains('\u{FFFD}') { + out.push_str("(非文本,详情可切 Hex)"); + } + out +} + +/// 大小写不敏感子串匹配。`needle` 已由调用方 to_lowercase 一次; +/// 先做零拷贝的字面匹配,未命中且原文含大写时才回退整串小写, +/// 避免渲染热路径对全部记录做 to_lowercase 拷贝。 +fn contains_ignore_case(hay: &str, needle: &str) -> bool { + if needle.is_empty() { + return true; + } + if hay.contains(needle) { + return true; + } + // 原文没有任何大写字符时,小写化不会改变匹配结果,直接判否 + if !hay.chars().any(|c| c.is_uppercase()) { + return false; + } + hay.to_lowercase().contains(needle) } diff --git a/gpui-app/src/ui/settings_dialog.rs b/gpui-app/src/ui/settings_dialog.rs index a6e96e1..2be43f3 100644 --- a/gpui-app/src/ui/settings_dialog.rs +++ b/gpui-app/src/ui/settings_dialog.rs @@ -21,7 +21,11 @@ use crate::ui::app::MqttXApp; use crate::ui::widgets::{field, make_select, OptionDelegate}; use crate::ui::IconName; -const THEMES: [&str; 3] = ["跟随系统", "浅色", "深色"]; +const THEMES: [&str; 3] = ["跟随系统(暂按浅色)", "浅色", "深色"]; + +/// 消息缓存条数的合法范围:太少不够回看,太多占内存 +const MAX_MESSAGES_MIN: usize = 100; +const MAX_MESSAGES_MAX: usize = 100_000; const SITE_URL: &str = "https://mqttx.app"; const REPO_URL: &str = "https://gitea.heavenlybook.cn/JoeAllen/mqttx"; @@ -66,14 +70,21 @@ impl SettingsDialog { Some(2) => ThemeModePref::Dark, _ => ThemeModePref::System, }; + // 显式校验范围而非静默钳制:越界直接报错,不落盘 let max_messages = self .max_messages .read(cx) .value() .trim() .parse::() - .map_err(|_| "消息缓存条数必须是正整数".to_string())? - .max(100); + .map_err(|_| format!( + "消息缓存条数需要是 {MAX_MESSAGES_MIN}~{MAX_MESSAGES_MAX} 的整数" + ))?; + if !(MAX_MESSAGES_MIN..=MAX_MESSAGES_MAX).contains(&max_messages) { + return Err(format!( + "消息缓存条数需要是 {MAX_MESSAGES_MIN}~{MAX_MESSAGES_MAX} 的整数" + )); + } // 四个字段全部显式写出,避免 ..Default::default() 把开关项重置为默认值 Ok(AppSettings { theme, @@ -111,7 +122,7 @@ impl Render for SettingsDialog { .w(px(500.)) .child(field("主题", Select::new(&self.theme))) .child(field( - "每条连接内存中保留的消息条数", + "每条连接内存中保留的消息条数(100~100000)", Input::new(&self.max_messages), )) .child( @@ -130,7 +141,17 @@ impl Render for SettingsDialog { .child( h_flex() .justify_between() - .child(div().text_sm().child("自动检查更新")) + .child( + h_flex().gap_1p5().items_center() + .child(div().text_sm().child("自动检查更新")) + // 更新通道尚未接入,先保留开关并明示状态 + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("即将支持"), + ), + ) .child( Switch::new("auto-check-update") .checked(self.auto_check_update) diff --git a/gpui-app/src/ui/widgets.rs b/gpui-app/src/ui/widgets.rs index ea7c756..f716d2d 100644 --- a/gpui-app/src/ui/widgets.rs +++ b/gpui-app/src/ui/widgets.rs @@ -86,13 +86,32 @@ pub fn make_select( // ─── 字段行 ────────────────────────────────────────────────────────────────── -/// 小号标签 + 控件的垂直字段容器。 +/// 小号标签 + 控件的垂直字段容器(可选字段,无校验态)。 pub fn field(label: impl Into, control: impl IntoElement) -> impl IntoElement { + field_ex(label, control, false, None, Hsla::default()) +} + +/// 字段行变体:`required` 时标签后追加红色 `*`; +/// `error` 为 Some 时在控件下方渲染红字提示(`danger` 为主题错误色)。 +pub fn field_ex( + label: impl Into, + control: impl IntoElement, + required: bool, + error: Option, + danger: Hsla, +) -> impl IntoElement { v_flex() .gap_1() .min_w(px(0.)) - .child(div().text_xs().child(label.into())) + .child( + h_flex() + .gap_0p5() + .items_baseline() + .child(div().text_xs().child(label.into())) + .children(required.then(|| div().text_xs().text_color(danger).child("*"))), + ) .child(control) + .children(error.map(|e| div().text_xs().text_color(danger).child(e))) } // ─── 状态颜色 ────────────────────────────────────────────────────────────────