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
+3 -1
View File
@@ -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);
+13
View File
@@ -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 {
+22 -2
View File
@@ -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<Vec<ConnectionConfig>, 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<Vec<ConnectionConfig>, Str
let mut conns = Vec::with_capacity(items.len());
for (i, item) in items.into_iter().enumerate() {
match serde_json::from_value::<ConnectionConfig>(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);
}
}
+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()
}
}
+171 -53
View File
@@ -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<HashMap<&'static str, String>>,
testing: bool,
_subs: Vec<Subscription>,
}
@@ -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>) -> String {
let msg = msg.into();
self.field_errors.borrow_mut().insert(key, msg.clone());
msg
}
/// 把 Result 的错误挂到指定字段上再继续 `?` 传播。
fn fail_as<T>(
&self,
key: &'static str,
r: Result<T, String>,
) -> Result<T, String> {
r.map_err(|e| self.fail(key, e))
}
fn parse_u16(s: &str, label: &str) -> Result<u16, String> {
s.trim()
.parse::<u16>()
@@ -329,39 +376,63 @@ impl ConnectionForm {
}
fn build_inner(&self, cx: &App, for_test: bool) -> Result<ConnectionConfig, String> {
// 每次构建先清空上一轮字段错误,失败时由 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::<u32>()
.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::<u32>().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::<u16>()
.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::<u32>()
.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<SharedString> {
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)))
+383 -95
View File
@@ -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<InputState>,
panel: Panel,
expanded: Option<u64>,
/// 当前 detail_format 归属的消息:切换到另一条消息时据此重置格式,
/// 同一条内折叠/展开则保留用户选择
detail_owner: Option<u64>,
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>) {
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<Self>) {
@@ -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<Self>) {
// 未连接时消息发不出去,先拦截给出与订阅路径一致的提示
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(&params.topic, &vars);
params.payload = render_template(&params.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<Self>) -> 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<MqttRecord>, Vec<Subscription>) = self
// 引用过滤 + 计数命中总数,仅克隆前 MAX_RENDERED_MESSAGES 条,避免全量拷贝
let (records, subs, matched_total): (Vec<MqttRecord>, Vec<Subscription>, usize) = self
.with_app(cx, |app| {
let subs: Vec<Subscription> = app
.subscriptions
@@ -1334,37 +1514,31 @@ impl ConnectionView {
.filter(|s| s.connection_id == conn)
.cloned()
.collect();
let records: Vec<MqttRecord> = 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::<Vec<_>>()
})
.unwrap_or_default();
(records, subs)
let mut matched = 0usize;
let mut records: Vec<MqttRecord> = 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)
}
+26 -5
View File
@@ -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::<usize>()
.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)
+21 -2
View File
@@ -86,13 +86,32 @@ pub fn make_select<C: gpui_kit::AppContext>(
// ─── 字段行 ──────────────────────────────────────────────────────────────────
/// 小号标签 + 控件的垂直字段容器。
/// 小号标签 + 控件的垂直字段容器(可选字段,无校验态)。
pub fn field(label: impl Into<SharedString>, control: impl IntoElement) -> impl IntoElement {
field_ex(label, control, false, None, Hsla::default())
}
/// 字段行变体:`required` 时标签后追加红色 `*`;
/// `error` 为 Some 时在控件下方渲染红字提示(`danger` 为主题错误色)。
pub fn field_ex(
label: impl Into<SharedString>,
control: impl IntoElement,
required: bool,
error: Option<SharedString>,
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)))
}
// ─── 状态颜色 ────────────────────────────────────────────────────────────────