feat(ui): 界面体验一轮打磨(快捷键/暂停滚动/会话恢复/JSON 预览)
- 全局 Ctrl+Enter 发送;订阅框 Enter 提交、Esc 退出编辑;过滤框 Esc 清空 - 消息流新增「暂停/恢复」:暂停时画面不随新消息跳动,消息仍入环形缓冲 - JSON 负载折叠态预览按原始数据美化展示(多行缩进,超 40 行兜底截断) - 标签页持久化(ui-state.json),重启恢复工作区;无可恢复标签时自动打开 首个自连连接的工作区,避免「已连上却演空态」 - 侧边栏 Error 连接行内红字显示失败原因;侧边栏/订阅面板补竖分隔线 - 消息订阅色条上下留隙,不再连成一整条竖线 - 发布被当前过滤条件隐藏时给出「已发送」提示 - 连接表单:主机+端口同行;TLS 证书三个路径字段加「浏览…」(rfd) - 主题切换/系统外观变化后强制整窗重绘,修未收敛区域的旧色残影 - 设置主题文案改「跟随系统」(跟随逻辑此前已实现)
This commit is contained in:
@@ -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 存于系统数据目录
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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();
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)))
|
)))
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, ¶ms.topic))
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| (!query.is_empty()
|
||||||
|
&& !contains_ignore_case(¶ms.topic, &query)
|
||||||
|
&& !contains_ignore_case(¶ms.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)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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("非文本"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user