feat(ui): 界面体验一轮打磨(快捷键/暂停滚动/会话恢复/JSON 预览)

- 全局 Ctrl+Enter 发送;订阅框 Enter 提交、Esc 退出编辑;过滤框 Esc 清空
- 消息流新增「暂停/恢复」:暂停时画面不随新消息跳动,消息仍入环形缓冲
- JSON 负载折叠态预览按原始数据美化展示(多行缩进,超 40 行兜底截断)
- 标签页持久化(ui-state.json),重启恢复工作区;无可恢复标签时自动打开
  首个自连连接的工作区,避免「已连上却演空态」
- 侧边栏 Error 连接行内红字显示失败原因;侧边栏/订阅面板补竖分隔线
- 消息订阅色条上下留隙,不再连成一整条竖线
- 发布被当前过滤条件隐藏时给出「已发送」提示
- 连接表单:主机+端口同行;TLS 证书三个路径字段加「浏览…」(rfd)
- 主题切换/系统外观变化后强制整窗重绘,修未收敛区域的旧色残影
- 设置主题文案改「跟随系统」(跟随逻辑此前已实现)
This commit is contained in:
2026-09-30 16:15:58 +08:00
parent f5cd0cdc4f
commit 967bf9dd78
15 changed files with 407 additions and 51 deletions
+3 -2
View File
@@ -13,7 +13,8 @@
- **完整 MQTT 5 属性** — 用户属性(可视化键值对编辑器)、Content-Type、消息过期间隔、Response Topic、Correlation Data(连接/遗嘱/发布三处均支持) - **完整 MQTT 5 属性** — 用户属性(可视化键值对编辑器)、Content-Type、消息过期间隔、Response Topic、Correlation Data(连接/遗嘱/发布三处均支持)
- **发布 / 订阅** — QoS 0/1/2、Retain、遗嘱消息(Last Will)、订阅自动恢复(auto resubscribe)、断线自动重连(可配置最大重连次数与连接超时) - **发布 / 订阅** — QoS 0/1/2、Retain、遗嘱消息(Last Will)、订阅自动恢复(auto resubscribe)、断线自动重连(可配置最大重连次数与连接超时)
- **订阅增强** — 逗号/换行多主题批量订阅、订阅别名、颜色标签(消息按订阅着色)、启用/禁用、点击订阅过滤消息流、MQTT 5 订阅选项(订阅标识符 / No Local / Retain As Published / Retain Handling) - **订阅增强** — 逗号/换行多主题批量订阅、订阅别名、颜色标签(消息按订阅着色)、启用/禁用、点击订阅过滤消息流、MQTT 5 订阅选项(订阅标识符 / No Local / Retain As Published / Retain Handling)
- **消息流** — 收发方向区分、时间戳、QoS/Retain 标记、点击展开查看格式化 JSON 与 v5 属性、全部/接收/发布过滤、主题/内容搜索、逐条 payload 格式切换(自动/文本/Hex/Base64)、复制主题/负载/详情、清空历史 - **消息流** — 收发方向区分、时间戳、QoS/Retain 标记、点击展开查看格式化 JSON 与 v5 属性、全部/接收/发布过滤、主题/内容搜索、逐条 payload 格式切换(自动/文本/Hex/Base64)、复制主题/负载/详情、暂停滚动(高频消息流下锁定画面阅读,消息仍后台接收)、清空历史
- **JSON 预览** — 折叠态对 JSON 负载按原始数据美化展示(多行缩进,常规负载完整显示,超大负载兜底截断)
- **连接导入 / 导出** — 原生文件对话框(rfd)导出/导入连接 JSON,按 id 去重 - **连接导入 / 导出** — 原生文件对话框(rfd)导出/导入连接 JSON,按 id 去重
- **SSL/TLS 证书** — 自定义 CA、客户端证书/私钥(双向认证)、忽略 CA 校验 - **SSL/TLS 证书** — 自定义 CA、客户端证书/私钥(双向认证)、忽略 CA 校验
- **全局变量** — 发布主题与负载中使用 `{{变量名}}`,内置 `{{$ts}}`、`{{$ts_ms}}`、`{{$uuid}}`,用户属性同样参与渲染 - **全局变量** — 发布主题与负载中使用 `{{变量名}}`,内置 `{{$ts}}`、`{{$ts_ms}}`、`{{$uuid}}`,用户属性同样参与渲染
@@ -21,7 +22,7 @@
- **阿里云 IoT** — Token(GroupId@@@DeviceId + HMAC-SHA1 签名)与一机一密两种鉴权方式一键生成连接 - **阿里云 IoT** — Token(GroupId@@@DeviceId + HMAC-SHA1 签名)与一机一密两种鉴权方式一键生成连接
- **连接测试** — 表单内握手测试(超时可配置),保存前验证连通性 - **连接测试** — 表单内握手测试(超时可配置),保存前验证连通性
- **日志面板** — 连接级事件日志(CONNACK/SUBACK/PUBACK/错误/重连等),按日落盘 `mqttx-YYYY-MM-DD.log`,设置内一键打开日志目录 - **日志面板** — 连接级事件日志(CONNACK/SUBACK/PUBACK/错误/重连等),按日落盘 `mqttx-YYYY-MM-DD.log`,设置内一键打开日志目录
- **快捷键** — `Ctrl+N` 新建连接、`Ctrl+,` 设置、`Ctrl+Shift+E` 导出、`Ctrl+Shift+I` 导入、`Ctrl+Enter` 发送消息 - **快捷键** — `Ctrl+N` 新建连接、`Ctrl+,` 设置、`Ctrl+Shift+E` 导出、`Ctrl+Shift+I` 导入、`Ctrl+Shift+R` 资源监控、`Ctrl+Enter` 发送消息(全局生效)、`Enter` 提交订阅、`Esc` 退出编辑/清空输入
- **主题** — Linear 风格配色(近黑/纯白扁平底 + 靛蓝 #5e6ad2 主色,14px 紧凑排版、6px 圆角),浅色 / 深色 / 跟随系统 - **主题** — Linear 风格配色(近黑/纯白扁平底 + 靛蓝 #5e6ad2 主色,14px 紧凑排版、6px 圆角),浅色 / 深色 / 跟随系统
- **设置** — 自动检查更新开关、消息缓存条数、时间戳毫秒、数据目录展示与打开、关于页(版本 / 许可) - **设置** — 自动检查更新开关、消息缓存条数、时间戳毫秒、数据目录展示与打开、关于页(版本 / 许可)
- **本地持久化** — 连接、订阅、预设、变量、设置以 JSON 存于系统数据目录 - **本地持久化** — 连接、订阅、预设、变量、设置以 JSON 存于系统数据目录
+22
View File
@@ -207,6 +207,28 @@ impl Storage {
elog!("保存设置失败: {e}"); elog!("保存设置失败: {e}");
} }
} }
// ── ui state(标签页会话恢复) ──
pub fn load_ui_state(&self) -> UiState {
self.read_json("ui-state.json")
.ok()
.flatten()
.unwrap_or_default()
}
pub fn save_ui_state(&self, state: &UiState) {
if let Err(e) = self.write_json("ui-state.json", state) {
elog!("保存界面状态失败: {e}");
}
}
}
/// 会话 UI 状态:打开的标签页与活动标签,用于重启后恢复工作区。
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct UiState {
#[serde(default)]
pub open_tabs: Vec<String>,
#[serde(default)]
pub active_tab: Option<String>,
} }
impl Default for Storage { impl Default for Storage {
+19 -1
View File
@@ -49,6 +49,7 @@ impl MqttXApp {
} }
self.storage.save_connections(&self.connections); self.storage.save_connections(&self.connections);
self.storage.save_subscriptions(&self.subscriptions); self.storage.save_subscriptions(&self.subscriptions);
self.persist_ui_state();
cx.notify(); cx.notify();
} }
@@ -82,6 +83,11 @@ impl MqttXApp {
self.views.insert(conn_id.to_string(), view); self.views.insert(conn_id.to_string(), view);
} }
self.active_tab = Some(conn_id.to_string()); self.active_tab = Some(conn_id.to_string());
self.persist_ui_state();
#[cfg(debug_assertions)]
if std::env::var("MQTTX_DEBUG_TABS").as_deref() == Ok("1") {
eprintln!("[debug] open_tab {conn_id} views={}", self.views.len());
}
cx.notify(); cx.notify();
} }
@@ -91,9 +97,18 @@ pub(super) fn close_tab(&mut self, id: &str, cx: &mut Context<Self>) {
if self.active_tab.as_deref() == Some(id) { if self.active_tab.as_deref() == Some(id) {
self.active_tab = self.open_tabs.last().cloned(); self.active_tab = self.open_tabs.last().cloned();
} }
self.persist_ui_state();
cx.notify(); cx.notify();
} }
/// 把当前打开的标签页写入 ui-state.json,重启后恢复工作区。
fn persist_ui_state(&self) {
self.storage.save_ui_state(&UiState {
open_tabs: self.open_tabs.clone(),
active_tab: self.active_tab.clone(),
});
}
// ── 订阅 ────────────────────────────────────────────────────────────── // ── 订阅 ──────────────────────────────────────────────────────────────
pub fn add_subscription(&mut self, sub: Subscription) { pub fn add_subscription(&mut self, sub: Subscription) {
@@ -146,10 +161,13 @@ pub(super) fn close_tab(&mut self, id: &str, cx: &mut Context<Self>) {
self.storage.save_aliyun(&self.aliyun_presets); self.storage.save_aliyun(&self.aliyun_presets);
} }
pub fn save_settings(&mut self, settings: AppSettings, cx: &mut App) { pub fn save_settings(&mut self, settings: AppSettings, window: &mut Window, cx: &mut App) {
apply_theme(settings.theme, cx); apply_theme(settings.theme, cx);
self.settings = settings; self.settings = settings;
self.storage.save_settings(&self.settings); self.storage.save_settings(&self.settings);
// 切主题只更新全局令牌,不触发整窗重绘,未损坏区域会长期滞留旧主题
// 像素(实测发布栏黑底不收敛),这里强制重画
window.refresh();
} }
+12 -6
View File
@@ -49,9 +49,7 @@ pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window
} }
record.seq = self.next_seq(); record.seq = self.next_seq();
self.push_message(id.clone(), record); self.push_message(id.clone(), record);
if let Some(view) = self.views.get(id.as_ref()) { self.notify_message_view(&id, cx);
view.update(cx, |_, cx| cx.notify());
}
} }
EngineEvent::Published { EngineEvent::Published {
connection_id, connection_id,
@@ -88,9 +86,7 @@ pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window
raw_bytes, raw_bytes,
}; };
self.push_message(Arc::from(connection_id.as_str()), record); self.push_message(Arc::from(connection_id.as_str()), record);
if let Some(view) = self.views.get(connection_id.as_str()) { self.notify_message_view(&connection_id, cx);
view.update(cx, |_, cx| cx.notify());
}
} }
EngineEvent::SubscribeResult { EngineEvent::SubscribeResult {
connection_id, connection_id,
@@ -161,6 +157,16 @@ pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window
// ── 数据工具 ────────────────────────────────────────────────────────── // ── 数据工具 ──────────────────────────────────────────────────────────
/// 消息到达后刷新对应工作区;用户按下「暂停」时跳过,
/// 避免高频消息流不断把正在阅读的内容顶走(消息仍照常入环形缓冲)。
fn notify_message_view(&self, connection_id: &str, cx: &mut Context<Self>) {
if let Some(view) = self.views.get(connection_id)
&& !view.read(cx).is_paused()
{
view.update(cx, |_, cx| cx.notify());
}
}
fn next_seq(&mut self) -> u64 { fn next_seq(&mut self) -> u64 {
self.seq += 1; self.seq += 1;
self.seq self.seq
+9
View File
@@ -82,6 +82,15 @@ pub(super) fn render_main(&mut self, _window: &mut Window, cx: &mut Context<Self
} }
let view = self.views.get(&active).cloned(); let view = self.views.get(&active).cloned();
#[cfg(debug_assertions)]
if std::env::var("MQTTX_DEBUG_TABS").as_deref() == Ok("1") {
eprintln!(
"[debug] render_main active={active} views={} tabs={:?} view_found={}",
self.views.len(),
self.open_tabs,
view.is_some()
);
}
// h_full:父级 h_flex 交叉轴居中子项,不占满高度会被整体垂直居中; // h_full:父级 h_flex 交叉轴居中子项,不占满高度会被整体垂直居中;
// 视图根是 size_full,须包在 flex_1 容器里,否则会盖住 TabBar 并溢出。 // 视图根是 size_full,须包在 flex_1 容器里,否则会盖住 TabBar 并溢出。
v_flex() v_flex()
+62 -2
View File
@@ -29,7 +29,7 @@ use crate::model::{
Subscription, ThemeModePref, Subscription, ThemeModePref,
}; };
use crate::mqtt::MqttEngine; use crate::mqtt::MqttEngine;
use crate::store::Storage; use crate::store::{Storage, UiState};
use crate::update; use crate::update;
use crate::ui::connection_view::ConnectionView; use crate::ui::connection_view::ConnectionView;
@@ -48,7 +48,7 @@ const MAX_LOGS: usize = 3000;
const EVENT_QUEUE_CAPACITY: usize = 1024; const EVENT_QUEUE_CAPACITY: usize = 1024;
// 应用级快捷键动作(context=None:任意焦点状态下都匹配)。 // 应用级快捷键动作(context=None:任意焦点状态下都匹配)。
gpui_kit::actions!(mqttx, [NewConnection, OpenSettings, ExportConnections, ImportConnections, OpenResourceMonitor]); gpui_kit::actions!(mqttx, [NewConnection, OpenSettings, ExportConnections, ImportConnections, OpenResourceMonitor, PublishMessage]);
/// 侧边栏分组过滤;chips 只做列表过滤,分组重命名/删除通过编辑连接的分组字段完成。 /// 侧边栏分组过滤;chips 只做列表过滤,分组重命名/删除通过编辑连接的分组字段完成。
#[derive(Clone, PartialEq, Eq)] #[derive(Clone, PartialEq, Eq)]
@@ -159,6 +159,42 @@ impl MqttXApp {
crate::model::render_will_templates(&mut cfg, &variables); crate::model::render_will_templates(&mut cfg, &variables);
engine.connect(cfg); engine.connect(cfg);
} }
// 会话恢复:载入上次打开的标签页(过滤掉已删除的连接)。
// 若无可恢复标签且启用了自动连接,则自动打开第一个自动连接的工作区,
// 避免「连接已在后台连上、主区却演空态」的不连贯。
let ui_state = storage.load_ui_state();
let conn_ids: std::collections::HashSet<String> =
connections.iter().map(|c| c.id.clone()).collect();
let restored_tabs: Vec<String> = ui_state
.open_tabs
.into_iter()
.filter(|id| conn_ids.contains(id))
.collect();
let restored_active = ui_state
.active_tab
.filter(|id| restored_tabs.contains(id));
let auto_first = connections
.iter()
.find(|c| c.auto_connect)
.map(|c| c.id.clone());
let defer_tabs = restored_tabs.clone();
let defer_active = restored_active.clone();
cx.defer_in(window, move |app, window, cx| {
if defer_tabs.is_empty() {
if let Some(id) = auto_first {
app.open_tab(&id, window, cx);
}
return;
}
for id in defer_tabs {
app.open_tab(&id, window, cx);
}
if let Some(id) = defer_active {
app.active_tab = Some(id);
}
cx.notify();
});
if settings.auto_check_update { if settings.auto_check_update {
let rx = engine.run_blocking(update::check_and_download); let rx = engine.run_blocking(update::check_and_download);
let weak = cx.entity().downgrade(); let weak = cx.entity().downgrade();
@@ -270,6 +306,9 @@ impl MqttXApp {
} }
let mode = ThemeMode::from(window.appearance()); let mode = ThemeMode::from(window.appearance());
Theme::change(mode, Some(window), cx); Theme::change(mode, Some(window), cx);
// Theme::change 只更新全局令牌,不保证整窗重绘;未损坏区域会
// 滞留旧主题像素(实测发布栏长期不收敛),强制重画一次
window.refresh();
}); });
// ── 全局快捷键:绑定 + action 注册 ── // ── 全局快捷键:绑定 + action 注册 ──
@@ -281,6 +320,7 @@ impl MqttXApp {
KeyBinding::new("ctrl-shift-e", ExportConnections, None), KeyBinding::new("ctrl-shift-e", ExportConnections, None),
KeyBinding::new("ctrl-shift-i", ImportConnections, None), KeyBinding::new("ctrl-shift-i", ImportConnections, None),
KeyBinding::new("ctrl-shift-r", OpenResourceMonitor, None), KeyBinding::new("ctrl-shift-r", OpenResourceMonitor, None),
KeyBinding::new("ctrl-enter", PublishMessage, None),
]); ]);
let weak = cx.entity().downgrade(); let weak = cx.entity().downgrade();
// Context 上有同名 on_action(绘制期注册),这里必须走 App 的全局注册 // Context 上有同名 on_action(绘制期注册),这里必须走 App 的全局注册
@@ -334,6 +374,24 @@ impl MqttXApp {
entity.update(cx, |app, cx| app.import_connections(window, cx)); entity.update(cx, |app, cx| app.import_connections(window, cx));
}); });
}); });
// Ctrl+Enter 全局发送:焦点在负载框、QoS 下拉或任意位置都能发送,
// 不再依赖焦点恰好落在某个输入框上(无标签页时静默忽略)
let weak = cx.entity().downgrade();
App::on_action::<PublishMessage>(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| {
if let Some(tab) = app.active_tab.clone()
&& let Some(view) = app.views.get(&tab).cloned()
{
view.update(cx, |view, cx| view.do_publish(window, cx));
}
});
});
});
Self { Self {
storage, storage,
@@ -350,6 +408,8 @@ impl MqttXApp {
messages: HashMap::new(), messages: HashMap::new(),
logs: VecDeque::with_capacity(MAX_LOGS), logs: VecDeque::with_capacity(MAX_LOGS),
seq: 0, seq: 0,
// 标签恢复统一走下方 defer 的 open_tab(负责创建视图实体),
// 这里不能预填 open_tabs:预填会让 open_tab 误判标签已存在而跳过建视图
open_tabs: Vec::new(), open_tabs: Vec::new(),
active_tab: None, active_tab: None,
views: HashMap::new(), views: HashMap::new(),
+19 -1
View File
@@ -52,6 +52,11 @@ pub(super) fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context<S
.get(&conn.id) .get(&conn.id)
.copied() .copied()
.unwrap_or(ConnectionStatus::Disconnected); .unwrap_or(ConnectionStatus::Disconnected);
// Error 状态把失败原因直接展示在行内(截断防溢出):
// 只有一个红点时用户必须点进 tab 才知道为什么失败
let error_line = matches!(status, ConnectionStatus::Error)
.then(|| self.errors.get(&conn.id).cloned())
.flatten();
let active = self.active_tab.as_deref() == Some(conn.id.as_str()); let active = self.active_tab.as_deref() == Some(conn.id.as_str());
let cfg = conn.clone(); let cfg = conn.clone();
let name = conn.name.clone(); let name = conn.name.clone();
@@ -103,7 +108,17 @@ pub(super) fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context<S
.overflow_hidden() .overflow_hidden()
.text_ellipsis() .text_ellipsis()
.child(address), .child(address),
), )
.when_some(error_line, |v, e| {
v.child(
div()
.text_xs()
.text_color(cx.theme().danger)
.overflow_hidden()
.text_ellipsis()
.child(e),
)
}),
) )
.child(self.render_conn_power(&cfg, status, cx)) .child(self.render_conn_power(&cfg, status, cx))
.child(self.render_conn_menu(&conn.id, cx)), .child(self.render_conn_menu(&conn.id, cx)),
@@ -149,6 +164,9 @@ pub(super) fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context<S
.flex_shrink_0() .flex_shrink_0()
.bg(cx.theme().sidebar) .bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground) .text_color(cx.theme().sidebar_foreground)
// 深色主题下侧边栏与主区同色,补一条分隔线保持边界可辨
.border_r_1()
.border_color(cx.theme().border)
.child( .child(
h_flex() h_flex()
.h_11() .h_11()
+2 -2
View File
@@ -167,7 +167,7 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context<
.ghost() .ghost()
.small() .small()
.tooltip(if dark { "切换为浅色" } else { "切换为深色" }) .tooltip(if dark { "切换为浅色" } else { "切换为深色" })
.on_click(cx.listener(|this, _, _, cx| { .on_click(cx.listener(|this, _, window, cx| {
let next = if cx.theme().mode == ThemeMode::Dark { let next = if cx.theme().mode == ThemeMode::Dark {
ThemeModePref::Light ThemeModePref::Light
} else { } else {
@@ -175,7 +175,7 @@ pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context<
}; };
let mut s = this.settings.clone(); let mut s = this.settings.clone();
s.theme = next; s.theme = next;
this.save_settings(s, cx); this.save_settings(s, window, cx);
cx.notify(); cx.notify();
})) }))
} }
+77 -8
View File
@@ -185,7 +185,7 @@ impl ConnectionForm {
let ssl_client_cert = input(&c.ssl.client_cert_file, "客户端证书 PEM 路径(可选)", window, cx); let ssl_client_cert = input(&c.ssl.client_cert_file, "客户端证书 PEM 路径(可选)", window, cx);
let ssl_client_key = input(&c.ssl.client_key_file, "客户端私钥 PEM 路径(可选)", window, cx); let ssl_client_key = input(&c.ssl.client_key_file, "客户端私钥 PEM 路径(可选)", window, cx);
let group_new = input("", "新分组名(可选)", window, cx); let group_new = input("", "填写后优先于左侧所选分组", window, cx);
let (will_topic, will_payload, will_enabled, will_retain, will_qos_idx, will_content_type, will_response_topic) = let (will_topic, will_payload, will_enabled, will_retain, will_qos_idx, will_content_type, will_response_topic) =
match &c.last_will { match &c.last_will {
@@ -681,6 +681,66 @@ impl ConnectionForm {
.detach(); .detach();
} }
/// 证书路径字段 + 「浏览…」按钮:路径仍可手输,按钮负责选文件。
fn cert_path_row(
&self,
id: &'static str,
label: &'static str,
target: Entity<InputState>,
err: Option<SharedString>,
danger: gpui_kit::Hsla,
cx: &mut Context<Self>,
) -> impl IntoElement {
h_flex()
.gap_1()
.w_full()
.child(
div()
.flex_1()
.min_w(px(0.))
.child(field_ex(label, Input::new(&target), false, err, danger)),
)
.child(
Button::new(id)
.label("浏览…")
.outline()
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.browse_cert_path(target.clone(), window, cx);
})),
)
}
/// 弹原生文件对话框并把所选路径回填到输入框。rfd 的阻塞式对话框
/// 不可占用 UI 线程,与连接导入/导出同模式:后台线程 + smol 通道回传。
fn browse_cert_path(
&self,
target: Entity<InputState>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let (tx, rx) = smol::channel::bounded::<Option<std::path::PathBuf>>(1);
std::thread::spawn(move || {
let picked = rfd::FileDialog::new()
.set_title("选择证书 / 密钥文件")
.add_filter("证书 / 密钥 (PEM)", &["pem", "crt", "cer", "key", "der"])
.pick_file();
let _ = tx.send_blocking(picked);
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some(path)) = rx.recv().await {
let text = path.display().to_string();
this.update_in(cx, |_form, window, cx| {
target.update(cx, |s, cx| s.set_value(text, window, cx));
// 回填的路径可能本就带字段错误提示,触发重绘即时更新
cx.notify();
})
.ok();
}
})
.detach();
}
fn switch_row( fn switch_row(
&self, &self,
id: &'static str, id: &'static str,
@@ -828,18 +888,19 @@ impl Render for ConnectionForm {
// ── 基本信息(始终展开) ── // ── 基本信息(始终展开) ──
.child(section("基本信息", border, card_bg, .child(section("基本信息", border, card_bg,
v_flex().gap_2().child( v_flex().gap_2().child(
// 主机与端口是一对,放同一行;名称与协议/传输同行
h_flex().gap_2().w_full() h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field_ex("名称", Input::new(&self.name), true, err("name"), danger))) .child(div().flex_1().min_w(px(0.)).child(field_ex("主机", Input::new(&self.host), true, err("host"), danger)))
.child(div().w(px(120.)).child(field_ex("端口", Input::new(&self.port), true, err("port"), 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(h_flex().gap_2().w_full()
.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_ex("名称", Input::new(&self.name), true, err("name"), 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.protocol))))
.child(div().flex_1().min_w(px(0.)).child(field("传输", Select::new(&self.transport)))) .child(div().flex_1().min_w(px(0.)).child(field("传输", Select::new(&self.transport))))
) )
.child(h_flex().gap_2().w_full() .child(h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("分组", Select::new(&self.group_select)))) .child(div().flex_1().min_w(px(0.)).child(field("分组", Select::new(&self.group_select))))
.child(div().flex_1().min_w(px(0.)).child(field("新建分组(优先于左侧选择)", Input::new(&self.group_new)))) .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(h_flex().gap_2().w_full().items_end()
.child(div().flex_1().min_w(px(0.)).child(field_ex("Client ID", Input::new(&self.client_id), true, err("client_id"), danger))) .child(div().flex_1().min_w(px(0.)).child(field_ex("Client ID", Input::new(&self.client_id), true, err("client_id"), danger)))
@@ -887,10 +948,18 @@ impl Render for ConnectionForm {
"sec-ssl", "SSL/TLS", ssl_hint, true, self.show_ssl, "sec-ssl", "SSL/TLS", ssl_hint, true, self.show_ssl,
|f| f.show_ssl = !f.show_ssl, border, card_bg, |f| f.show_ssl = !f.show_ssl, border, card_bg,
v_flex().gap_2() v_flex().gap_2()
// Phase 2 或后续接原生文件对话框;GPUI 暂无内置文件选择器,先用路径文本输入 .child(self.cert_path_row(
.child(field_ex("CA 证书 (PEM)", Input::new(&self.ssl_ca), false, err("ssl_ca"), danger)) "ssl-ca-browse", "CA 证书 (PEM)", self.ssl_ca.clone(),
.child(field_ex("客户端证书 (PEM)", Input::new(&self.ssl_client_cert), false, err("ssl_client_cert"), danger)) err("ssl_ca"), danger, cx,
.child(field_ex("客户端密钥 (PEM)", Input::new(&self.ssl_client_key), false, err("ssl_client_key"), danger)) ))
.child(self.cert_path_row(
"ssl-cert-browse", "客户端证书 (PEM)", self.ssl_client_cert.clone(),
err("ssl_client_cert"), danger, cx,
))
.child(self.cert_path_row(
"ssl-key-browse", "客户端密钥 (PEM)", self.ssl_client_key.clone(),
err("ssl_client_key"), danger, cx,
))
.child(self.switch_row("ssl_ignore_ca", "忽略 CA 校验(不验证服务器证书)", self.ssl_ignore_ca, cx)), .child(self.switch_row("ssl_ignore_ca", "忽略 CA 校验(不验证服务器证书)", self.ssl_ignore_ca, cx)),
cx, cx,
))) )))
+42 -1
View File
@@ -403,11 +403,13 @@ pub(super) fn render_top_bar(&self, cx: &mut Context<Self>) -> impl IntoElement
.border_color(border) .border_color(border)
.hover(move |this| this.bg(hover_bg)) .hover(move |this| this.bg(hover_bg))
.cursor_pointer() .cursor_pointer()
// 左侧色条:有订阅色时显示,否则透明以保持行内对齐 // 左侧色条:有订阅色时显示,否则透明以保持行内对齐;
// 上下留 2px 间隙,避免相邻行的色条连成一整条竖线
.child( .child(
div() div()
.w(px(3.)) .w(px(3.))
.self_stretch() .self_stretch()
.my_0p5()
.rounded_full() .rounded_full()
.bg(sub_color.map(hue_color).unwrap_or(hsla(0., 0., 0., 0.))), .bg(sub_color.map(hue_color).unwrap_or(hsla(0., 0., 0., 0.))),
) )
@@ -602,8 +604,20 @@ pub(super) fn render_messages(&self, cx: &mut Context<Self>) -> impl IntoElement
.child(dir_group) .child(dir_group)
.child( .child(
div() div()
.id(SharedString::from(format!("msg-filter-wrap-{}", self.conn_id)))
.flex_1() .flex_1()
.min_w(px(0.)) .min_w(px(0.))
// Esc 清空过滤词:高频调试时比全选删除顺手
.on_key_down(cx.listener(
|this, ev: &gpui_kit::KeyDownEvent, window, cx| {
if ev.keystroke.key == "escape" {
cx.stop_propagation();
this.filter
.update(cx, |s, cx| s.set_value("", window, cx));
cx.notify();
}
},
))
.child(Input::new(&self.filter).small().prefix( .child(Input::new(&self.filter).small().prefix(
gpui_kit::component::Icon::new(IconName::Search).small(), gpui_kit::component::Icon::new(IconName::Search).small(),
)), )),
@@ -626,6 +640,33 @@ pub(super) fn render_messages(&self, cx: &mut Context<Self>) -> impl IntoElement
.child(format!("仅显示最新 {} 条", MAX_RENDERED_MESSAGES)), .child(format!("仅显示最新 {} 条", MAX_RENDERED_MESSAGES)),
) )
}) })
.child({
// 暂停滚动:高频消息流下锁定当前画面以便阅读/展开,
// 消息仍照常入环形缓冲,恢复后直接显示最新
let paused = self.paused;
Button::new(SharedString::from(format!(
"msg-pause-{}",
self.conn_id
)))
.icon(if paused {
IconName::Play
} else {
IconName::Pause
})
.label(if paused { "恢复" } else { "暂停" })
.ghost()
.small()
.when(paused, |b| b.selected(true))
.tooltip(if paused {
"恢复实时滚动"
} else {
"暂停消息流滚动(消息仍在后台接收)"
})
.on_click(cx.listener(|this, _, _, cx| {
this.paused = !this.paused;
cx.notify();
}))
})
.child({ .child({
let weak = cx.weak_entity(); let weak = cx.weak_entity();
let clear_id = self.conn_id.clone(); let clear_id = self.conn_id.clone();
+8
View File
@@ -178,6 +178,8 @@ pub struct ConnectionView {
detail_owner: Option<u64>, detail_owner: Option<u64>,
msg_dir: DirFilter, msg_dir: DirFilter,
detail_format: DetailFormat, detail_format: DetailFormat,
/// 消息流暂停滚动:消息照常入环形缓冲,仅不再触发本视图重绘
paused: bool,
// 预设 // 预设
preset_name: Entity<InputState>, preset_name: Entity<InputState>,
@@ -254,6 +256,7 @@ impl ConnectionView {
detail_owner: None, detail_owner: None,
msg_dir: DirFilter::All, msg_dir: DirFilter::All,
detail_format: DetailFormat::Auto, detail_format: DetailFormat::Auto,
paused: false,
preset_name, preset_name,
show_vars: true, show_vars: true,
var_rows: Vec::new(), var_rows: Vec::new(),
@@ -276,6 +279,11 @@ impl ConnectionView {
self.config(cx).map(|c| c.protocol.is_v5()).unwrap_or(true) self.config(cx).map(|c| c.protocol.is_v5()).unwrap_or(true)
} }
/// 消息流是否处于暂停滚动状态(引擎事件泵据此决定是否刷新本视图)。
pub fn is_paused(&self) -> bool {
self.paused
}
} }
+26 -12
View File
@@ -18,7 +18,9 @@ use crate::ui::widgets::{ field, KvEditor };
use super::*; use super::*;
impl ConnectionView { impl ConnectionView {
fn do_publish(&mut self, window: &mut Window, cx: &mut Context<Self>) { /// 发布当前面板参数。入口有三:发送按钮、发布主题框内 Enter、
/// 全局 Ctrl+Enter action(app/mod.rs 注册)。
pub(crate) fn do_publish(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// 未连接时消息发不出去,先拦截给出与订阅路径一致的提示 // 未连接时消息发不出去,先拦截给出与订阅路径一致的提示
if !self.engine.is_connected(&self.conn_id) { if !self.engine.is_connected(&self.conn_id) {
window.push_notification(Notification::warning("未连接:消息未发送"), cx); window.push_notification(Notification::warning("未连接:消息未发送"), cx);
@@ -79,7 +81,25 @@ impl ConnectionView {
params.response_topic = None; params.response_topic = None;
params.correlation_data = None; params.correlation_data = None;
} }
// 发出的消息若会被当前过滤条件隐藏(方向/订阅/搜索),发布记录在
// 消息流里看不到,用户容易误以为没发出去,此时给一条显式反馈
let query = self.filter.read(cx).value().to_lowercase();
let hidden = self.msg_dir == DirFilter::Received
|| self
.sub_filter
.as_deref()
.map(|f| !topic_matches(f, &params.topic))
.unwrap_or(false)
|| (!query.is_empty()
&& !contains_ignore_case(&params.topic, &query)
&& !contains_ignore_case(&params.payload, &query));
self.engine.publish(self.conn_id.clone(), params); self.engine.publish(self.conn_id.clone(), params);
if hidden {
window.push_notification(
Notification::success("已发送(被当前过滤条件隐藏,消息流中不显示)"),
cx,
);
}
} }
/// 读取当前发布面板上的参数(模板渲染前的原始值);保存预设与发布共用, /// 读取当前发布面板上的参数(模板渲染前的原始值);保存预设与发布共用,
@@ -196,7 +216,7 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context<Self>) -> impl Into
h_flex() h_flex()
.gap_2() .gap_2()
.items_center() .items_center()
// Ctrl+Enter 快捷发送(焦点在主题输入上时) // 主题框内 Enter 直接发送(Ctrl+Enter 走全局 action,任意焦点可用)
.child( .child(
div() div()
.id(SharedString::from(format!("pub-topic-wrap-{}", self.conn_id))) .id(SharedString::from(format!("pub-topic-wrap-{}", self.conn_id)))
@@ -204,7 +224,9 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context<Self>) -> impl Into
.min_w(px(0.)) .min_w(px(0.))
.on_key_down(cx.listener( .on_key_down(cx.listener(
|this, ev: &gpui_kit::KeyDownEvent, window, cx| { |this, ev: &gpui_kit::KeyDownEvent, window, cx| {
if ev.keystroke.modifiers.control && ev.keystroke.key == "enter" { if ev.keystroke.key == "enter"
&& !ev.keystroke.modifiers.control
{
cx.stop_propagation(); cx.stop_propagation();
this.do_publish(window, cx); this.do_publish(window, cx);
} }
@@ -285,15 +307,7 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context<Self>) -> impl Into
.border_color(cx.theme().border) .border_color(cx.theme().border)
.bg(cx.theme().background) .bg(cx.theme().background)
.p_1() .p_1()
// Ctrl+Enter 快捷发送(焦点在负载输入上时) // Ctrl+Enter 发送由全局 action 兜底(焦点在本框内也能触发)
.on_key_down(cx.listener(
|this, ev: &gpui_kit::KeyDownEvent, window, cx| {
if ev.keystroke.modifiers.control && ev.keystroke.key == "enter" {
cx.stop_propagation();
this.do_publish(window, cx);
}
},
))
.child(Textarea::new(&payload)), .child(Textarea::new(&payload)),
); );
+33 -1
View File
@@ -340,7 +340,36 @@ pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context<Self>) -> impl In
.child(format!("正在编辑:{topic}")), .child(format!("正在编辑:{topic}")),
) )
}) })
.child(div().flex_1().min_w(px(0.)).child(Input::new(&sub_topic).small())) .child(
div()
.id(SharedString::from(format!("sub-topic-wrap-{}", self.conn_id)))
.flex_1()
.min_w(px(0.))
// Enter 直接提交订阅;Esc 退出编辑态或清空输入,
// 调试时不必为每次订阅伸手点按钮
.on_key_down(cx.listener(
|this, ev: &gpui_kit::KeyDownEvent, window, cx| {
if ev.keystroke.key == "enter"
&& !ev.keystroke.modifiers.control
&& !ev.keystroke.modifiers.alt
{
cx.stop_propagation();
this.do_subscribe(window, cx);
} else if ev.keystroke.key == "escape" {
cx.stop_propagation();
if this.editing.is_some() {
this.editing = None;
this.reset_subscribe_form(window, cx);
} else {
this.sub_topic
.update(cx, |s, cx| s.set_value("", window, cx));
}
cx.notify();
}
},
))
.child(Input::new(&sub_topic).small()),
)
.child(div().w(px(96.)).child(Select::new(&sub_qos).small())) .child(div().w(px(96.)).child(Select::new(&sub_qos).small()))
.child(div().w(px(112.)).child(Input::new(&sub_alias).small())) .child(div().w(px(112.)).child(Input::new(&sub_alias).small()))
.when(is_v5, |h| { .when(is_v5, |h| {
@@ -706,6 +735,9 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context<Self>) -> impl IntoEl
.h_full() .h_full()
.bg(cx.theme().sidebar) .bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground) .text_color(cx.theme().sidebar_foreground)
// 深色主题下订阅面板与消息区同色,补一条分隔线保持边界可辨
.border_r_1()
.border_color(cx.theme().border)
.child( .child(
h_flex() h_flex()
.h_11() .h_11()
+61 -4
View File
@@ -157,9 +157,20 @@ pub(super) fn details_copy_text(record: &MqttRecord, show_millis: bool) -> Strin
out out
} }
/// 折叠态只显示单行预览:换行/制表符压平成空格后截 200 字符。 /// 折叠态预览行数与字符上限:JSON 美化后多行完整展示,超大负载兜底截断
/// 含替换字符(U+FFFD)说明负载不是合法文本,提示切 Hex 查看。 const PREVIEW_MAX_LINES: usize = 40;
const PREVIEW_MAX_CHARS: usize = 4000;
/// 折叠态预览:JSON 负载按原始数据美化展示(缩进多行),非 JSON
/// 压平换行/制表符截 200 字符;含替换字符(U+FFFD)提示切 Hex。
pub(super) fn preview_payload(payload: &str) -> String { pub(super) fn preview_payload(payload: &str) -> String {
let parsed = serde_json::from_str::<serde_json::Value>(payload).ok();
let mut out = match &parsed {
Some(value) => {
let pretty = serde_json::to_string_pretty(value).unwrap_or_default();
truncate_preview(pretty)
}
None => {
let flat: String = payload let flat: String = payload
.chars() .chars()
.map(|c| match c { .map(|c| match c {
@@ -167,16 +178,33 @@ pub(super) fn preview_payload(payload: &str) -> String {
_ => c, _ => c,
}) })
.collect(); .collect();
let mut out: String = flat.chars().take(200).collect(); let mut s: String = flat.chars().take(200).collect();
if flat.chars().count() > 200 { if flat.chars().count() > 200 {
out.push('…'); s.push('…');
} }
s
}
};
if payload.contains('\u{FFFD}') { if payload.contains('\u{FFFD}') {
out.push_str("(非文本,详情可切 Hex)"); out.push_str("(非文本,详情可切 Hex)");
} }
out out
} }
/// 截断预览文本:最多 [`PREVIEW_MAX_LINES`] 行、[`PREVIEW_MAX_CHARS`] 字符。
fn truncate_preview(text: String) -> String {
let mut lines: Vec<&str> = text.lines().take(PREVIEW_MAX_LINES).collect();
let more = text.lines().count() > PREVIEW_MAX_LINES;
if more {
lines.push("…");
}
let mut out = lines.join("\n");
if out.chars().count() > PREVIEW_MAX_CHARS {
out = out.chars().take(PREVIEW_MAX_CHARS).collect::<String>() + "…";
}
out
}
/// 大小写不敏感子串匹配。`needle` 已由调用方 to_lowercase 一次; /// 大小写不敏感子串匹配。`needle` 已由调用方 to_lowercase 一次;
/// 先做零拷贝的字面匹配,未命中且原文含大写时才回退整串小写, /// 先做零拷贝的字面匹配,未命中且原文含大写时才回退整串小写,
/// 避免渲染热路径对全部记录做 to_lowercase 拷贝。 /// 避免渲染热路径对全部记录做 to_lowercase 拷贝。
@@ -205,6 +233,7 @@ pub(super) fn contains_ignore_case(hay: &str, needle: &str) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::extract_placeholder_keys; use super::extract_placeholder_keys;
use super::preview_payload;
// 修复回归:提取须 trim、排除内置变量、去重且保持首次出现顺序 // 修复回归:提取须 trim、排除内置变量、去重且保持首次出现顺序
#[test] #[test]
@@ -225,4 +254,32 @@ mod tests {
let keys = extract_placeholder_keys("温度{{名称}}", ""); let keys = extract_placeholder_keys("温度{{名称}}", "");
assert_eq!(keys, vec!["名称"]); assert_eq!(keys, vec!["名称"]);
} }
#[test]
fn preview_json_is_pretty_and_complete_for_typical_payloads() {
let payload = r#"{"type":"heartbeat","ts":1790751279869,"data":{"version":"0.1.0","lon":117.229,"lat":31.8206,"uptime":1716450,"rssi":-52}}"#;
let out = preview_payload(payload);
// JSON 被美化(多行缩进)且常规负载完整展示、不带任何加工注释
assert!(out.contains('\n'), "JSON 应多行展示: {out}");
assert!(out.contains("\"rssi\": -52"), "常规负载应完整显示: {out}");
assert!(!out.contains("//"), "预览不应附加注释: {out}");
assert!(!out.contains('…'), "常规负载不应截断: {out}");
}
#[test]
fn preview_truncates_very_deep_json() {
// 50 个元素的数组美化后超过 40 行,应截断并带省略号
let items: Vec<String> = (0..50).map(|i| i.to_string()).collect();
let payload = format!("{{\"a\":[{}]}}", items.join(","));
let deep = preview_payload(&payload);
assert!(deep.lines().count() <= 41, "超行应截断: {deep}");
assert!(deep.contains('…'), "截断应有省略号: {deep}");
}
#[test]
fn preview_non_json_still_flat() {
assert_eq!(preview_payload("hello\nworld"), "hello world");
assert_eq!(preview_payload("纯文本"), "纯文本");
assert!(preview_payload("bad\ntext\u{FFFD}").contains("非文本"));
}
} }
+3 -2
View File
@@ -24,7 +24,7 @@ use crate::ui::widgets::{field, make_select, OptionDelegate};
use crate::ui::IconName; use crate::ui::IconName;
use crate::update::{self, UpdateInfo}; use crate::update::{self, UpdateInfo};
const THEMES: [&str; 3] = ["跟随系统(暂按浅色)", "浅色", "深色"]; const THEMES: [&str; 3] = ["跟随系统", "浅色", "深色"];
/// 消息缓存条数的合法范围:太少不够回看,太多占内存 /// 消息缓存条数的合法范围:太少不够回看,太多占内存
const MAX_MESSAGES_MIN: usize = 100; const MAX_MESSAGES_MIN: usize = 100;
@@ -581,8 +581,9 @@ pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
let result = for_collect.read(cx).collect(cx); let result = for_collect.read(cx).collect(cx);
match result { match result {
Ok(settings) => { Ok(settings) => {
// save_settings 内部会 apply_theme + 整窗 refresh
app_for_save.update(cx, |a, cx| { app_for_save.update(cx, |a, cx| {
a.save_settings(settings, cx); a.save_settings(settings, window, cx);
cx.notify(); cx.notify();
}); });
window.close_dialog(cx); window.close_dialog(cx);