diff --git a/README.md b/README.md index 3dc5609..5935a00 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ - **完整 MQTT 5 属性** — 用户属性(可视化键值对编辑器)、Content-Type、消息过期间隔、Response Topic、Correlation Data(连接/遗嘱/发布三处均支持) - **发布 / 订阅** — QoS 0/1/2、Retain、遗嘱消息(Last Will)、订阅自动恢复(auto resubscribe)、断线自动重连(可配置最大重连次数与连接超时) - **订阅增强** — 逗号/换行多主题批量订阅、订阅别名、颜色标签(消息按订阅着色)、启用/禁用、点击订阅过滤消息流、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 去重 - **SSL/TLS 证书** — 自定义 CA、客户端证书/私钥(双向认证)、忽略 CA 校验 - **全局变量** — 发布主题与负载中使用 `{{变量名}}`,内置 `{{$ts}}`、`{{$ts_ms}}`、`{{$uuid}}`,用户属性同样参与渲染 @@ -21,7 +22,7 @@ - **阿里云 IoT** — Token(GroupId@@@DeviceId + HMAC-SHA1 签名)与一机一密两种鉴权方式一键生成连接 - **连接测试** — 表单内握手测试(超时可配置),保存前验证连通性 - **日志面板** — 连接级事件日志(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 圆角),浅色 / 深色 / 跟随系统 - **设置** — 自动检查更新开关、消息缓存条数、时间戳毫秒、数据目录展示与打开、关于页(版本 / 许可) - **本地持久化** — 连接、订阅、预设、变量、设置以 JSON 存于系统数据目录 diff --git a/gpui-app/src/store.rs b/gpui-app/src/store.rs index c4886ed..455c711 100644 --- a/gpui-app/src/store.rs +++ b/gpui-app/src/store.rs @@ -207,6 +207,28 @@ impl Storage { 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, + #[serde(default)] + pub active_tab: Option, } impl Default for Storage { diff --git a/gpui-app/src/ui/app/actions.rs b/gpui-app/src/ui/app/actions.rs index 4eb4466..f793814 100644 --- a/gpui-app/src/ui/app/actions.rs +++ b/gpui-app/src/ui/app/actions.rs @@ -49,6 +49,7 @@ impl MqttXApp { } self.storage.save_connections(&self.connections); self.storage.save_subscriptions(&self.subscriptions); + self.persist_ui_state(); cx.notify(); } @@ -82,6 +83,11 @@ impl MqttXApp { self.views.insert(conn_id.to_string(), view); } 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(); } @@ -91,9 +97,18 @@ pub(super) fn close_tab(&mut self, id: &str, cx: &mut Context) { if self.active_tab.as_deref() == Some(id) { self.active_tab = self.open_tabs.last().cloned(); } + self.persist_ui_state(); 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) { @@ -146,10 +161,13 @@ pub(super) fn close_tab(&mut self, id: &str, cx: &mut Context) { 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); self.settings = settings; self.storage.save_settings(&self.settings); + // 切主题只更新全局令牌,不触发整窗重绘,未损坏区域会长期滞留旧主题 + // 像素(实测发布栏黑底不收敛),这里强制重画 + window.refresh(); } diff --git a/gpui-app/src/ui/app/events.rs b/gpui-app/src/ui/app/events.rs index edda680..3bdeb10 100644 --- a/gpui-app/src/ui/app/events.rs +++ b/gpui-app/src/ui/app/events.rs @@ -49,9 +49,7 @@ pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window } record.seq = self.next_seq(); self.push_message(id.clone(), record); - if let Some(view) = self.views.get(id.as_ref()) { - view.update(cx, |_, cx| cx.notify()); - } + self.notify_message_view(&id, cx); } EngineEvent::Published { connection_id, @@ -88,9 +86,7 @@ pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window raw_bytes, }; self.push_message(Arc::from(connection_id.as_str()), record); - if let Some(view) = self.views.get(connection_id.as_str()) { - view.update(cx, |_, cx| cx.notify()); - } + self.notify_message_view(&connection_id, cx); } EngineEvent::SubscribeResult { 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) { + 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 { self.seq += 1; self.seq diff --git a/gpui-app/src/ui/app/main_area.rs b/gpui-app/src/ui/app/main_area.rs index 1687ca2..46b05d6 100644 --- a/gpui-app/src/ui/app/main_area.rs +++ b/gpui-app/src/ui/app/main_area.rs @@ -82,6 +82,15 @@ pub(super) fn render_main(&mut self, _window: &mut Window, cx: &mut Context = + connections.iter().map(|c| c.id.clone()).collect(); + let restored_tabs: Vec = 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 { let rx = engine.run_blocking(update::check_and_download); let weak = cx.entity().downgrade(); @@ -270,6 +306,9 @@ impl MqttXApp { } let mode = ThemeMode::from(window.appearance()); Theme::change(mode, Some(window), cx); + // Theme::change 只更新全局令牌,不保证整窗重绘;未损坏区域会 + // 滞留旧主题像素(实测发布栏长期不收敛),强制重画一次 + window.refresh(); }); // ── 全局快捷键:绑定 + action 注册 ── @@ -281,6 +320,7 @@ impl MqttXApp { KeyBinding::new("ctrl-shift-e", ExportConnections, None), KeyBinding::new("ctrl-shift-i", ImportConnections, None), KeyBinding::new("ctrl-shift-r", OpenResourceMonitor, None), + KeyBinding::new("ctrl-enter", PublishMessage, None), ]); let weak = cx.entity().downgrade(); // Context 上有同名 on_action(绘制期注册),这里必须走 App 的全局注册 @@ -334,6 +374,24 @@ impl MqttXApp { entity.update(cx, |app, cx| app.import_connections(window, cx)); }); }); + // Ctrl+Enter 全局发送:焦点在负载框、QoS 下拉或任意位置都能发送, + // 不再依赖焦点恰好落在某个输入框上(无标签页时静默忽略) + let weak = cx.entity().downgrade(); + App::on_action::(cx, move |_, cx| { + let w = weak.clone(); + run_on_active_window(cx, w, |entity, window, cx| { + if window.has_active_dialog(cx) { + return; + } + entity.update(cx, |app, cx| { + 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 { storage, @@ -350,6 +408,8 @@ impl MqttXApp { messages: HashMap::new(), logs: VecDeque::with_capacity(MAX_LOGS), seq: 0, + // 标签恢复统一走下方 defer 的 open_tab(负责创建视图实体), + // 这里不能预填 open_tabs:预填会让 open_tab 误判标签已存在而跳过建视图 open_tabs: Vec::new(), active_tab: None, views: HashMap::new(), diff --git a/gpui-app/src/ui/app/sidebar.rs b/gpui-app/src/ui/app/sidebar.rs index f715a2d..1e099dc 100644 --- a/gpui-app/src/ui/app/sidebar.rs +++ b/gpui-app/src/ui/app/sidebar.rs @@ -52,6 +52,11 @@ pub(super) fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context, + err: Option, + danger: gpui_kit::Hsla, + cx: &mut Context, + ) -> 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, + window: &mut Window, + cx: &mut Context, + ) { + let (tx, rx) = smol::channel::bounded::>(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( &self, id: &'static str, @@ -828,18 +888,19 @@ 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_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(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.transport)))) ) .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("新建分组(优先于左侧选择)", 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(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, |f| f.show_ssl = !f.show_ssl, border, card_bg, v_flex().gap_2() - // Phase 2 或后续接原生文件对话框;GPUI 暂无内置文件选择器,先用路径文本输入 - .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.cert_path_row( + "ssl-ca-browse", "CA 证书 (PEM)", self.ssl_ca.clone(), + err("ssl_ca"), danger, cx, + )) + .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)), cx, ))) diff --git a/gpui-app/src/ui/connection_view/messages.rs b/gpui-app/src/ui/connection_view/messages.rs index 4ff2544..1da7393 100644 --- a/gpui-app/src/ui/connection_view/messages.rs +++ b/gpui-app/src/ui/connection_view/messages.rs @@ -403,11 +403,13 @@ pub(super) fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement .border_color(border) .hover(move |this| this.bg(hover_bg)) .cursor_pointer() - // 左侧色条:有订阅色时显示,否则透明以保持行内对齐 + // 左侧色条:有订阅色时显示,否则透明以保持行内对齐; + // 上下留 2px 间隙,避免相邻行的色条连成一整条竖线 .child( div() .w(px(3.)) .self_stretch() + .my_0p5() .rounded_full() .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) -> impl IntoElement .child(dir_group) .child( div() + .id(SharedString::from(format!("msg-filter-wrap-{}", self.conn_id))) .flex_1() .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( gpui_kit::component::Icon::new(IconName::Search).small(), )), @@ -626,6 +640,33 @@ pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement .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({ let weak = cx.weak_entity(); let clear_id = self.conn_id.clone(); diff --git a/gpui-app/src/ui/connection_view/mod.rs b/gpui-app/src/ui/connection_view/mod.rs index ae552a5..4f53b9f 100644 --- a/gpui-app/src/ui/connection_view/mod.rs +++ b/gpui-app/src/ui/connection_view/mod.rs @@ -178,6 +178,8 @@ pub struct ConnectionView { detail_owner: Option, msg_dir: DirFilter, detail_format: DetailFormat, + /// 消息流暂停滚动:消息照常入环形缓冲,仅不再触发本视图重绘 + paused: bool, // 预设 preset_name: Entity, @@ -254,6 +256,7 @@ impl ConnectionView { detail_owner: None, msg_dir: DirFilter::All, detail_format: DetailFormat::Auto, + paused: false, preset_name, show_vars: true, var_rows: Vec::new(), @@ -276,6 +279,11 @@ impl ConnectionView { self.config(cx).map(|c| c.protocol.is_v5()).unwrap_or(true) } + /// 消息流是否处于暂停滚动状态(引擎事件泵据此决定是否刷新本视图)。 + pub fn is_paused(&self) -> bool { + self.paused + } + } diff --git a/gpui-app/src/ui/connection_view/publish.rs b/gpui-app/src/ui/connection_view/publish.rs index 2a2973c..d747829 100644 --- a/gpui-app/src/ui/connection_view/publish.rs +++ b/gpui-app/src/ui/connection_view/publish.rs @@ -18,7 +18,9 @@ use crate::ui::widgets::{ field, KvEditor }; use super::*; impl ConnectionView { - fn do_publish(&mut self, window: &mut Window, cx: &mut Context) { + /// 发布当前面板参数。入口有三:发送按钮、发布主题框内 Enter、 + /// 全局 Ctrl+Enter action(app/mod.rs 注册)。 + pub(crate) fn do_publish(&mut self, window: &mut Window, cx: &mut Context) { // 未连接时消息发不出去,先拦截给出与订阅路径一致的提示 if !self.engine.is_connected(&self.conn_id) { window.push_notification(Notification::warning("未连接:消息未发送"), cx); @@ -79,7 +81,25 @@ impl ConnectionView { params.response_topic = 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); + if hidden { + window.push_notification( + Notification::success("已发送(被当前过滤条件隐藏,消息流中不显示)"), + cx, + ); + } } /// 读取当前发布面板上的参数(模板渲染前的原始值);保存预设与发布共用, @@ -196,7 +216,7 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into h_flex() .gap_2() .items_center() - // Ctrl+Enter 快捷发送(焦点在主题输入上时) + // 主题框内 Enter 直接发送(Ctrl+Enter 走全局 action,任意焦点可用) .child( div() .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) -> impl Into .min_w(px(0.)) .on_key_down(cx.listener( |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(); this.do_publish(window, cx); } @@ -285,15 +307,7 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl Into .border_color(cx.theme().border) .bg(cx.theme().background) .p_1() - // Ctrl+Enter 快捷发送(焦点在负载输入上时) - .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); - } - }, - )) + // Ctrl+Enter 发送由全局 action 兜底(焦点在本框内也能触发) .child(Textarea::new(&payload)), ); diff --git a/gpui-app/src/ui/connection_view/subscribe.rs b/gpui-app/src/ui/connection_view/subscribe.rs index b6dc800..96b11a7 100644 --- a/gpui-app/src/ui/connection_view/subscribe.rs +++ b/gpui-app/src/ui/connection_view/subscribe.rs @@ -340,7 +340,36 @@ pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context) -> impl In .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(112.)).child(Input::new(&sub_alias).small())) .when(is_v5, |h| { @@ -706,6 +735,9 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoEl .h_full() .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) + // 深色主题下订阅面板与消息区同色,补一条分隔线保持边界可辨 + .border_r_1() + .border_color(cx.theme().border) .child( h_flex() .h_11() diff --git a/gpui-app/src/ui/connection_view/util.rs b/gpui-app/src/ui/connection_view/util.rs index fa05afa..bb71cc8 100644 --- a/gpui-app/src/ui/connection_view/util.rs +++ b/gpui-app/src/ui/connection_view/util.rs @@ -157,26 +157,54 @@ pub(super) fn details_copy_text(record: &MqttRecord, show_millis: bool) -> Strin out } -/// 折叠态只显示单行预览:换行/制表符压平成空格后截 200 字符。 -/// 含替换字符(U+FFFD)说明负载不是合法文本,提示切 Hex 查看。 +/// 折叠态预览行数与字符上限:JSON 美化后多行完整展示,超大负载兜底截断 +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 { - 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('…'); - } + let parsed = serde_json::from_str::(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 + .chars() + .map(|c| match c { + '\n' | '\r' | '\t' => ' ', + _ => c, + }) + .collect(); + let mut s: String = flat.chars().take(200).collect(); + if flat.chars().count() > 200 { + s.push('…'); + } + s + } + }; if payload.contains('\u{FFFD}') { out.push_str("(非文本,详情可切 Hex)"); } 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::() + "…"; + } + out +} + /// 大小写不敏感子串匹配。`needle` 已由调用方 to_lowercase 一次; /// 先做零拷贝的字面匹配,未命中且原文含大写时才回退整串小写, /// 避免渲染热路径对全部记录做 to_lowercase 拷贝。 @@ -205,6 +233,7 @@ pub(super) fn contains_ignore_case(hay: &str, needle: &str) -> bool { #[cfg(test)] mod tests { use super::extract_placeholder_keys; + use super::preview_payload; // 修复回归:提取须 trim、排除内置变量、去重且保持首次出现顺序 #[test] @@ -225,4 +254,32 @@ mod tests { let keys = extract_placeholder_keys("温度{{名称}}", ""); 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 = (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("非文本")); + } } diff --git a/gpui-app/src/ui/dialogs/settings_dialog.rs b/gpui-app/src/ui/dialogs/settings_dialog.rs index 17504f3..b2917f8 100644 --- a/gpui-app/src/ui/dialogs/settings_dialog.rs +++ b/gpui-app/src/ui/dialogs/settings_dialog.rs @@ -24,7 +24,7 @@ use crate::ui::widgets::{field, make_select, OptionDelegate}; use crate::ui::IconName; use crate::update::{self, UpdateInfo}; -const THEMES: [&str; 3] = ["跟随系统(暂按浅色)", "浅色", "深色"]; +const THEMES: [&str; 3] = ["跟随系统", "浅色", "深色"]; /// 消息缓存条数的合法范围:太少不够回看,太多占内存 const MAX_MESSAGES_MIN: usize = 100; @@ -581,8 +581,9 @@ pub fn open(app: Entity, window: &mut Window, cx: &mut App) { let result = for_collect.read(cx).collect(cx); match result { Ok(settings) => { + // save_settings 内部会 apply_theme + 整窗 refresh app_for_save.update(cx, |a, cx| { - a.save_settings(settings, cx); + a.save_settings(settings, window, cx); cx.notify(); }); window.close_dialog(cx);