From bc9f55c889ed71a8935f9b5e8f5454d8c24aa022 Mon Sep 17 00:00:00 2001 From: JoeAllen <2494136784@qq.com> Date: Tue, 29 Sep 2026 22:20:53 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20=E6=8B=86=E5=88=86=20app/connec?= =?UTF-8?q?tion=5Fview/widgets=20=E4=B8=BA=E5=AD=90=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E8=AF=9D=E6=A1=86=E5=BD=92=E6=8B=A2=20dialog?= =?UTF-8?q?s/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui/app/ 拆为门面、events(引擎事件泵)、actions(用户动作)、io(导入导出)、 theme(主题)、titlebar/sidebar/main_area(渲染分区) - ui/connection_view/ 拆为门面、subscribe、messages、publish、vars、util(纯函数) - ui/widgets/ 拆为 select(可搜索下拉)、kv_editor、common(字段行/状态/时间) - 五个对话框归拢 ui/dialogs/,linear-theme.json 随 theme.rs 迁入 ui/app/ - clippy 0 警告,全部路径经门面保持稳定 --- README.md | 62 +- gpui-app/src/ui/app.rs | 1643 ---------- gpui-app/src/ui/app/actions.rs | 260 ++ gpui-app/src/ui/app/events.rs | 273 ++ gpui-app/src/ui/app/io.rs | 143 + gpui-app/src/ui/{ => app}/linear-theme.json | 0 gpui-app/src/ui/app/main_area.rs | 98 + gpui-app/src/ui/app/mod.rs | 382 +++ gpui-app/src/ui/app/sidebar.rs | 361 +++ gpui-app/src/ui/app/theme.rs | 47 + gpui-app/src/ui/app/titlebar.rs | 176 ++ gpui-app/src/ui/connection_view.rs | 2716 ----------------- gpui-app/src/ui/connection_view/messages.rs | 781 +++++ gpui-app/src/ui/connection_view/mod.rs | 332 ++ gpui-app/src/ui/connection_view/publish.rs | 576 ++++ gpui-app/src/ui/connection_view/subscribe.rs | 718 +++++ gpui-app/src/ui/connection_view/util.rs | 228 ++ gpui-app/src/ui/connection_view/vars.rs | 160 + .../src/ui/{ => dialogs}/aliyun_dialog.rs | 0 gpui-app/src/ui/dialogs/mod.rs | 7 + .../src/ui/{ => dialogs}/presets_dialog.rs | 0 .../src/ui/{ => dialogs}/resource_dialog.rs | 2 +- .../src/ui/{ => dialogs}/settings_dialog.rs | 0 .../src/ui/{ => dialogs}/variables_dialog.rs | 0 gpui-app/src/ui/mod.rs | 9 +- gpui-app/src/ui/widgets.rs | 272 -- gpui-app/src/ui/widgets/common.rs | 68 + gpui-app/src/ui/widgets/kv_editor.rs | 122 + gpui-app/src/ui/widgets/mod.rs | 9 + gpui-app/src/ui/widgets/select.rs | 91 + 30 files changed, 4884 insertions(+), 4652 deletions(-) delete mode 100644 gpui-app/src/ui/app.rs create mode 100644 gpui-app/src/ui/app/actions.rs create mode 100644 gpui-app/src/ui/app/events.rs create mode 100644 gpui-app/src/ui/app/io.rs rename gpui-app/src/ui/{ => app}/linear-theme.json (100%) create mode 100644 gpui-app/src/ui/app/main_area.rs create mode 100644 gpui-app/src/ui/app/mod.rs create mode 100644 gpui-app/src/ui/app/sidebar.rs create mode 100644 gpui-app/src/ui/app/theme.rs create mode 100644 gpui-app/src/ui/app/titlebar.rs delete mode 100644 gpui-app/src/ui/connection_view.rs create mode 100644 gpui-app/src/ui/connection_view/messages.rs create mode 100644 gpui-app/src/ui/connection_view/mod.rs create mode 100644 gpui-app/src/ui/connection_view/publish.rs create mode 100644 gpui-app/src/ui/connection_view/subscribe.rs create mode 100644 gpui-app/src/ui/connection_view/util.rs create mode 100644 gpui-app/src/ui/connection_view/vars.rs rename gpui-app/src/ui/{ => dialogs}/aliyun_dialog.rs (100%) create mode 100644 gpui-app/src/ui/dialogs/mod.rs rename gpui-app/src/ui/{ => dialogs}/presets_dialog.rs (100%) rename gpui-app/src/ui/{ => dialogs}/resource_dialog.rs (99%) rename gpui-app/src/ui/{ => dialogs}/settings_dialog.rs (100%) rename gpui-app/src/ui/{ => dialogs}/variables_dialog.rs (100%) delete mode 100644 gpui-app/src/ui/widgets.rs create mode 100644 gpui-app/src/ui/widgets/common.rs create mode 100644 gpui-app/src/ui/widgets/kv_editor.rs create mode 100644 gpui-app/src/ui/widgets/mod.rs create mode 100644 gpui-app/src/ui/widgets/select.rs diff --git a/README.md b/README.md index 6c33911..ae20b13 100644 --- a/README.md +++ b/README.md @@ -38,26 +38,60 @@ ## 目录结构 +每个文件与目录单一职责;模块门面(`mod.rs`)只做聚合导出与再分派。 + ``` gpui-app/ ├── Cargo.toml ├── src/ -│ ├── main.rs # 入口:创建窗口、挂载 gpui-kit Root -│ ├── lib.rs # 库导出(模型/引擎/UI 可被集成测试复用) -│ ├── model.rs # 数据模型(连接/订阅/消息/预设/变量/设置/模板渲染) -│ ├── mqtt.rs # MQTT 引擎:多连接生命周期、TLS/WS、重连、事件通道 -│ ├── store.rs # JSON 持久化(%APPDATA%/MQTTX-GPUI) -│ ├── aliyun.rs # 阿里云 IoT 签名与连接生成 +│ ├── main.rs # 入口:Sentry 初始化、创建窗口、挂载 gpui-kit Root +│ ├── lib.rs # 库导出(模型/引擎/UI 可被集成测试复用) +│ ├── model/ # 数据模型(纯数据层,不依赖 GPUI) +│ │ ├── mod.rs # 门面:聚合导出 + 模型测试 +│ │ ├── connection.rs # 连接配置:协议/传输/遗嘱/TLS/会话参数 +│ │ ├── subscription.rs # 订阅条目与订阅选项 +│ │ ├── message.rs # 消息记录 + 条数/字节双预算环形缓冲 +│ │ ├── publish.rs # 发布参数与发布预设 +│ │ ├── variable.rs # 全局变量与 {{占位符}} 模板渲染 +│ │ ├── log.rs # 运行日志级别与条目 +│ │ └── settings.rs # 应用设置与主题偏好 +│ ├── mqtt/ # MQTT 引擎(独立 tokio runtime) +│ │ ├── mod.rs # 门面:EngineEvent / MqttEngine 连接生命周期 +│ │ ├── tls.rs # rustls 配置与跳过校验 +│ │ ├── v5.rs # MQTT 5.0 客户端构建与事件循环 +│ │ └── v4.rs # MQTT 3.1.1 客户端构建与事件循环 +│ ├── update/ # OTA 自更新(Gitea Release 源) +│ │ ├── mod.rs # 门面:版本比较、资产命名约定 +│ │ ├── check.rs # 检查最新版本与资产挑选 +│ │ └── install.rs # 7z 下载暂存、sha256 校验、自替换安装 +│ ├── store.rs # JSON 持久化(损坏备份与 fsync) +│ ├── sysmon.rs # 自身进程资源采样(CPU/内存/线程/句柄) +│ ├── platform.rs # 平台窗口操作(Windows 置顶) +│ ├── aliyun.rs # 阿里云 IoT 签名与连接生成 │ └── ui/ -│ ├── app.rs # 应用外壳:标题栏/侧边栏/标签页/事件泵 -│ ├── connection_form.rs # 新建/编辑连接对话框 -│ ├── connection_view.rs # 连接工作区:订阅、消息流、发布面板、日志 -│ ├── aliyun_dialog.rs # 阿里云设备对话框 -│ ├── variables_dialog.rs # 全局变量对话框 -│ ├── settings_dialog.rs # 设置对话框 -│ └── widgets.rs # 复用组件(枚举下拉、键值对编辑器等) +│ ├── mod.rs # 界面层导出 +│ ├── app/ # 应用外壳 +│ │ ├── mod.rs # MqttXApp 状态、初始化与渲染分派 +│ │ ├── events.rs # 引擎事件 → 应用状态(消息/日志缓冲) +│ │ ├── actions.rs # 连接/订阅/预设/设置等用户动作 +│ │ ├── io.rs # 连接配置导入导出 +│ │ ├── theme.rs # Linear 风格主题应用 +│ │ ├── titlebar.rs # 标题栏(置顶钉子/主题切换/动作菜单) +│ │ ├── sidebar.rs # 侧边栏(搜索/分组/连接列表) +│ │ └── main_area.rs # 标签页栏与内容装配 +│ ├── connection_view/ # 连接工作区 +│ │ ├── mod.rs # ConnectionView 状态与渲染分派 +│ │ ├── subscribe.rs # 订阅表单/列表 +│ │ ├── messages.rs # 消息流、单条消息行与日志 +│ │ ├── publish.rs # 发布表单与发布预设 +│ │ ├── vars.rs # 模板变量面板 +│ │ └── util.rs # topic 匹配/格式化纯函数 +│ ├── connection_form.rs # 新建/编辑连接表单 +│ ├── dialogs/ # 模态对话框集合(设置/资源监控/发布预设/阿里云/变量) +│ ├── widgets/ # 复用小组件(枚举下拉/键值对编辑器/字段行/状态指示) +│ └── logo.rs # 品牌图标绘制 └── tests/ - └── engine_live.rs # 真实 broker 端到端测试(默认 ignored) + └── engine_live.rs # 真实 broker 端到端测试(默认 ignored) ``` ## 构建与运行 diff --git a/gpui-app/src/ui/app.rs b/gpui-app/src/ui/app.rs deleted file mode 100644 index 5b5b291..0000000 --- a/gpui-app/src/ui/app.rs +++ /dev/null @@ -1,1643 +0,0 @@ -//! 应用外壳:标题栏 + 连接侧边栏 + 多标签连接视图 + 引擎事件泵。 - -use std::collections::{HashMap, VecDeque}; -use std::path::PathBuf; -use std::sync::Arc; - -use gpui_kit::component::button::{Button, ButtonVariants as _, ButtonVariant}; -use gpui_kit::component::scroll::ScrollableElement as _; -use gpui_kit::component::dialog::DialogButtonProps; -use gpui_kit::component::input::{Input, InputState}; -use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem}; -use gpui_kit::component::tab::{Tab, TabBar}; -use gpui_kit::component::{ - h_flex, notification::Notification, v_flex, ActiveTheme as _, Selectable as _, Sizable as _, - StyledExt as _, Theme, ThemeMode, ThemeRegistry, TitleBar, WindowExt as _, -}; -use gpui_kit::prelude::FluentBuilder as _; -use gpui_kit::{ - div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, - KeyBinding, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, - Styled as _, Window, -}; - -use crate::aliyun::AliyunPreset; -use crate::model::{ - AppSettings, ConnectionConfig, ConnectionStatus, Direction, GlobalVariable, LogEntry, - LogLevel, MqttRecord, MessageRing, PublishParams, PublishPreset, SubscribeOptions, - Subscription, ThemeModePref, retained_payload, -}; -use crate::mqtt::{EngineEvent, MqttEngine}; -use crate::store::Storage; -use crate::update; -use crate::ui::IconName; -use crate::ui::{ - connection_form::ConnectionForm, - connection_view::ConnectionView, - widgets::status_color, -}; - -const MAX_LOGS: usize = 3000; - -// 应用级快捷键动作(context=None:任意焦点状态下都匹配)。 -gpui_kit::actions!(mqttx, [NewConnection, OpenSettings, ExportConnections, ImportConnections, OpenResourceMonitor]); - -/// 侧边栏分组过滤;chips 只做列表过滤,分组重命名/删除通过编辑连接的分组字段完成。 -#[derive(Clone, PartialEq, Eq)] -enum GroupFilter { - All, - Ungrouped, - Named(String), -} - -/// 全局 action 回调只有 `&mut App`:先 defer 出当前分发栈,再取活动窗口回到实体执行, -/// 避免在窗口借用期间重入 `update`。 -fn run_on_active_window( - cx: &mut App, - weak: gpui_kit::WeakEntity, - f: impl FnOnce(Entity, &mut Window, &mut App) + 'static, -) { - let Some(handle) = cx.active_window() else { - return; - }; - cx.defer(move |cx| { - let Some(entity) = weak.upgrade() else { - return; - }; - let _ = handle.update(cx, move |_, window, cx| f(entity, window, cx)); - }); -} - -pub struct MqttXApp { - storage: Storage, - pub engine: std::sync::Arc, - _pump: gpui_kit::Task<()>, - - // ── 持久化数据 ── - pub connections: Vec, - pub subscriptions: Vec, - pub presets: Vec, - pub variables: Vec, - pub aliyun_presets: Vec, - pub settings: AppSettings, - - // ── 运行时状态 ── - pub statuses: HashMap, - pub errors: HashMap, - /// 每连接消息环形缓冲:键为驻留后的连接 ID,条数 + 字节预算双重约束 - pub messages: HashMap, MessageRing>, - pub logs: VecDeque, - pub seq: u64, - - // ── 标签与视图 ── - open_tabs: Vec, - active_tab: Option, - views: HashMap>, - - search: Entity, - /// 分组过滤状态(chips 点击切换,与搜索条件叠加) - group_filter: GroupFilter, - /// 原生文件对话框进行中标记(对话框阻塞后台线程,防止重复弹出) - file_dialog_open: bool, - /// 按日期缓存的日志文件句柄(日期, 文件),避免每条日志都重新打开文件 - log_file: Option<(String, std::io::BufWriter)>, - /// 系统外观变化订阅:设置为「跟随系统」时重应用主题,随实体存活 - _appearance_obs: gpui_kit::Subscription, - /// 窗口置顶状态(标题栏钉子按钮切换) - pinned: bool, -} - -impl MqttXApp { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let storage = Storage::new(); - let connections = storage.load_connections(); - let subscriptions = storage.load_subscriptions(); - let presets = storage.load_presets(); - let variables = storage.load_variables(); - let aliyun_presets = storage.load_aliyun(); - let settings = storage.load_settings(); - - apply_theme(settings.theme, cx); - - let (tx, rx) = smol::channel::unbounded(); - let engine = MqttEngine::new(tx); - - let weak = cx.entity().downgrade(); - let pump = cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| { - while let Ok(event) = rx.recv().await { - weak.update_in(cx, |app, window, cx| { - app.on_engine_event(event, window, cx); - }) - .ok(); - } - }); - - // OTA:清理上次自更新遗留文件;开启自动检查则后台静默检查并下载, - // 下载完成后弹对话框征得同意再安装(不自动重启)。 - update::cleanup_old(); - // 自动连接:勾选「启动时自动连接」的连接逐个拉起(引擎异步建连) - for cfg in connections - .iter() - .filter(|c| c.auto_connect) - .cloned() - .collect::>() - { - let mut cfg = cfg; - crate::model::render_will_templates(&mut cfg, &variables); - engine.connect(cfg); - } - if settings.auto_check_update { - let rx = engine.run_blocking(update::check_and_download); - let weak = cx.entity().downgrade(); - cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| { - match rx.recv().await { - Ok(Ok(Some(staged))) => { - weak.update_in(cx, |_app, window, cx| { - window.open_dialog(cx, move |dialog, _, _cx| { - let staged = staged.clone(); - let staged_skip = staged.clone(); - let ver = staged.version.clone(); - dialog - .w(px(440.)) - .title("更新就绪") - .child( - v_flex().gap_2().child( - div().text_sm().child(format!( - "新版本 v{} 已下载完成(已通过 sha256 校验)。", - staged.version - )), - ), - ) - .footer( - h_flex() - .gap_2() - .justify_end() - .w_full() - .child( - Button::new("upd-skip") - .label("暂不更新") - .outline() - .on_click(move |_, window, cx| { - // 拒绝本次更新:删除暂存包, - // 下次启动不会被自动安装 - update::discard_staged(&staged_skip); - window.close_dialog(cx); - }), - ) - .child( - Button::new("upd-later") - .label("下次启动安装") - .outline() - .on_click(move |_, window, cx| { - // 同意标记:启动时据此自动安装; - // 用户跳过对话框则不会强制升级 - update::mark_install_consent(&ver); - window.close_dialog(cx); - }), - ) - .child( - Button::new("upd-now") - .label("立即安装") - .primary() - .on_click(move |_, window, cx| { - window.close_dialog(cx); - if let Err(e) = - update::install_staged(&staged) - { - window.push_notification( - Notification::error(format!( - "安装失败:{e}" - )), - cx, - ); - } else { - cx.quit(); - } - }), - ), - ) - }); - }) - .ok(); - } - // 检查/下载失败不打扰用户,但也不能完全无声 - Ok(Err(e)) => { - eprintln!("[app] 启动更新检查失败: {e}"); - sentry::capture_message( - &format!("startup update check failed: {e}"), - sentry::Level::Warning, - ); - } - _ => {} - } - }) - .detach(); - } - - let search = cx.new(|cx| InputState::new(window, cx).placeholder("搜索连接…")); - - // 自验后门:MQTTX_OPEN_RESMON=1 启动时直接打开资源监控 - // (与 MQTTX_VERSION_OVERRIDE 同类的测试入口,供自动化验证 UI)。 - // defer 到 effects 阶段执行,避免在 MqttXApp 构造借用内嵌套打开 - if std::env::var("MQTTX_OPEN_RESMON").as_deref() == Ok("1") { - cx.defer_in(window, move |_, window, cx| { - crate::ui::resource_dialog::open(cx.entity(), window, cx); - }); - } - - // 「跟随系统」:监听系统外观(深浅色)变化,仅在设置为 System 时重应用主题。 - // 订阅需随实体存活,dropped 即取消,故存入字段。 - let appearance_weak = cx.entity().downgrade(); - let appearance_obs = window.observe_window_appearance(move |window, cx| { - let Some(app) = appearance_weak.upgrade() else { - return; - }; - if app.read(cx).settings.theme != ThemeModePref::System { - return; - } - let mode = ThemeMode::from(window.appearance()); - Theme::change(mode, Some(window), cx); - }); - - // ── 全局快捷键:绑定 + action 注册 ── - // 元素级 on_action 依赖焦点路径,无焦点时分发从树根开始收不到; - // 全局 action 在冒泡末端必达,经 defer 取窗口避免在分发栈内重入。 - cx.bind_keys([ - KeyBinding::new("ctrl-n", NewConnection, None), - KeyBinding::new("ctrl-,", OpenSettings, None), - KeyBinding::new("ctrl-shift-e", ExportConnections, None), - KeyBinding::new("ctrl-shift-i", ImportConnections, None), - KeyBinding::new("ctrl-shift-r", OpenResourceMonitor, None), - ]); - let weak = cx.entity().downgrade(); - // Context 上有同名 on_action(绘制期注册),这里必须走 App 的全局注册 - App::on_action::(cx, move |_, cx| { - let w = weak.clone(); - run_on_active_window(cx, w, |entity, window, cx| { - if window.has_active_dialog(cx) { - return; - } - entity.update(cx, |app, cx| app.open_connection_form(None, window, cx)); - }); - }); - 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; - } - crate::ui::settings_dialog::open(entity, window, cx); - }); - }); - 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; - } - crate::ui::resource_dialog::open(entity, window, cx); - }); - }); - 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| app.export_connections(window, cx)); - }); - }); - 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| app.import_connections(window, cx)); - }); - }); - - Self { - storage, - engine, - _pump: pump, - connections, - subscriptions, - presets, - variables, - aliyun_presets, - settings, - statuses: HashMap::new(), - errors: HashMap::new(), - messages: HashMap::new(), - logs: VecDeque::with_capacity(MAX_LOGS), - seq: 0, - open_tabs: Vec::new(), - active_tab: None, - views: HashMap::new(), - search, - group_filter: GroupFilter::All, - file_dialog_open: false, - log_file: None, - _appearance_obs: appearance_obs, - pinned: false, - } - } - - // ── 引擎事件 ────────────────────────────────────────────────────────── - - fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window, cx: &mut Context) { - match event { - EngineEvent::Status { - connection_id, - status, - error, - .. - } => { - // 引擎 close 与事件循环退出之间有窗口,删除连接后其排队 - // 事件仍会送达:为已删除的连接重建状态/消息属于泄漏, - // 一律丢弃(删除时已主动 close)。 - if !self.connections.iter().any(|c| c.id == connection_id) { - return; - } - self.statuses.insert(connection_id.clone(), status); - match error { - Some(e) => { - self.errors.insert(connection_id.clone(), e); - } - None => { - self.errors.remove(&connection_id); - } - } - if status == ConnectionStatus::Connected { - self.on_connected(&connection_id); - } - if let Some(view) = self.views.get(&connection_id) { - view.update(cx, |_, cx| cx.notify()); - } - cx.notify(); - } - EngineEvent::Message(mut record) => { - let id = record.connection_id.clone(); - if !self.connections.iter().any(|c| c.id == id.as_ref()) { - return; - } - 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()); - } - } - EngineEvent::Published { - connection_id, - topic, - payload, - qos, - retain, - content_type, - user_properties, - response_topic, - correlation_data, - message_expiry_interval, - } => { - if !self.connections.iter().any(|c| c.id == connection_id) { - return; - } - let (payload, raw_bytes, payload_truncated) = retained_payload(&payload); - let record = MqttRecord { - seq: self.next_seq(), - connection_id: Arc::from(connection_id.as_str()), - direction: Direction::Published, - topic: Arc::from(topic.as_str()), - payload, - qos, - retain, - timestamp: chrono::Local::now().timestamp_millis(), - user_properties, - content_type, - response_topic, - correlation_data, - message_expiry_interval, - subscription_identifier: None, - payload_truncated, - 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()); - } - } - EngineEvent::SubscribeResult { - connection_id, - topic, - qos, - ok, - error, - } => { - if !self.connections.iter().any(|c| c.id == connection_id) { - return; - } - if ok { - // 乐观更新已在发送前入库;这里只在订阅仍存在时同步 QoS, - // 避免 SUBACK 前被删除的订阅被此事件用默认字段复活。 - if let Some(existing) = self.subscriptions.iter_mut().find(|s| { - s.connection_id == connection_id && s.topic == topic - }) { - existing.qos = qos; - } - } else { - if let Some(e) = error { - window.push_notification( - Notification::error(format!("订阅失败: {e}")), - cx, - ); - } - // broker 拒绝订阅:移除乐观插入的订阅行,与引擎状态保持一致 - // (不发 UNSUBSCRIBE——本就未订阅成功) - self.subscriptions - .retain(|s| !(s.connection_id == connection_id && s.topic == topic)); - self.storage.save_subscriptions(&self.subscriptions); - } - if let Some(view) = self.views.get(&connection_id) { - view.update(cx, |_, cx| cx.notify()); - } - } - EngineEvent::Log(entry) => { - self.push_log(entry); - if let Some(id) = self.active_tab.clone() - && let Some(view) = self.views.get(&id) { - view.update(cx, |_, cx| cx.notify()); - } - } - } - } - - fn on_connected(&mut self, connection_id: &str) { - // 连接成功后自动恢复已保存订阅(auto resubscribe),禁用的订阅跳过。 - let Some(config) = self.connections.iter().find(|c| c.id == connection_id).cloned() else { - return; - }; - if !config.auto_resubscribe { - return; - } - let subs: Vec = self - .subscriptions - .iter() - .filter(|s| s.connection_id == connection_id) - .filter(|s| s.enabled) - .cloned() - .collect(); - for sub in subs { - let opts = SubscribeOptions::from(&sub); - self.engine - .subscribe_with_options(connection_id.to_string(), sub, opts); - } - } - - // ── 数据工具 ────────────────────────────────────────────────────────── - - fn next_seq(&mut self) -> u64 { - self.seq += 1; - self.seq - } - - fn push_message(&mut self, connection_id: Arc, record: MqttRecord) { - let cap = self.settings.max_messages; - self.messages - .entry(connection_id) - .or_insert_with(|| MessageRing::new(cap)) - .push(record, cap); - } - - /// 清空指定连接的消息(内存缓冲随 drop 释放)。 - pub fn clear_messages(&mut self, id: &str) { - self.messages.remove(id); - } - - fn push_log(&mut self, entry: LogEntry) { - // 落盘只在事件路径执行(不在 render 热路径),与内存环形缓冲互不影响 - self.write_log_file(&entry); - if self.logs.len() >= MAX_LOGS { - self.logs.pop_front(); - } - self.logs.push_back(entry); - } - - /// 追加写入按日切分的日志文件 `mqttx-YYYY-MM-DD.log`。 - /// 文件句柄按日期缓存复用,只有跨日或写失败后才重新打开; - /// 失败只在首次 eprintln 一次后保持静默(成功一次即复位,便于恢复后再次提示), - /// 且绝不回调 log/push_log(防止递归触发日志)。 - fn write_log_file(&mut self, entry: &LogEntry) { - use std::sync::atomic::{AtomicBool, Ordering}; - - static WRITE_FAILED: AtomicBool = AtomicBool::new(false); - - match self.append_log_line(entry) { - Ok(()) => WRITE_FAILED.store(false, Ordering::Relaxed), - Err(e) => { - // 关句柄置空,下次写入自动重开重试 - self.log_file = None; - if !WRITE_FAILED.swap(true, Ordering::Relaxed) { - eprintln!("[app] 写入日志文件失败(后续静默): {e}"); - } - } - } - } - - /// 打开(或复用按日缓存的)日志文件并追加一行。 - /// 文件套 BufWriter:高频消息流下每条日志不再触发多次落盘 syscall; - /// 跨日重开/出错关句柄时随 drop 自动 flush。 - fn append_log_line(&mut self, entry: &LogEntry) -> std::io::Result<()> { - use std::io::Write as _; - use chrono::TimeZone as _; - - let ts = chrono::Local - .timestamp_millis_opt(entry.timestamp) - .single() - .unwrap_or_else(chrono::Local::now); - let day = ts.format("%Y-%m-%d").to_string(); - let need_reopen = match &self.log_file { - Some((cached_day, _)) => *cached_day != day, - None => true, - }; - if need_reopen { - let dir = self.storage.log_dir(); - std::fs::create_dir_all(&dir)?; - let path = dir.join(format!("mqttx-{day}.log")); - let file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path)?; - self.log_file = Some((day, std::io::BufWriter::new(file))); - } - // 连接名查不到时回退为 id,保证行格式恒定 - let conn = self - .connections - .iter() - .find(|c| c.id == entry.connection_id) - .map(|c| c.name.as_str()) - .unwrap_or(entry.connection_id.as_str()); - let file = &mut self - .log_file - .as_mut() - .expect("上方已确保日志句柄存在") - .1; - writeln!( - file, - "{} [{}] {}/{}: {}", - ts.format("%Y-%m-%d %H:%M:%S%.3f"), - entry.level.label(), - conn, - entry.event, - entry.message - ) - } - - pub fn log(&mut self, conn: &str, level: LogLevel, event: &str, message: String) { - self.push_log(LogEntry { - timestamp: chrono::Local::now().timestamp_millis(), - connection_id: conn.to_string(), - level, - event: event.to_string(), - message, - details: None, - }); - } - - // ── 连接 CRUD / 生命周期 ────────────────────────────────────────────── - - pub fn save_connection(&mut self, cfg: ConnectionConfig) { - if let Some(slot) = self.connections.iter_mut().find(|c| c.id == cfg.id) { - *slot = cfg; - } else { - self.connections.push(cfg); - } - self.storage.save_connections(&self.connections); - } - - pub fn duplicate_connection(&mut self, id: &str) { - let Some(src) = self.connections.iter().find(|c| c.id == id).cloned() else { - return; - }; - let mut copy = src; - copy.id = uuid::Uuid::new_v4().to_string(); - copy.name = format!("{} 副本", copy.name); - copy.client_id = - format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); - self.connections.push(copy); - self.storage.save_connections(&self.connections); - } - - pub fn delete_connection(&mut self, id: &str, cx: &mut Context) { - self.engine.close(id, false); - self.connections.retain(|c| c.id != id); - self.subscriptions.retain(|s| s.connection_id != id); - self.clear_messages(id); - self.statuses.remove(id); - self.errors.remove(id); - self.views.remove(id); - self.open_tabs.retain(|t| t != id); - if self.active_tab.as_deref() == Some(id) { - self.active_tab = self.open_tabs.last().cloned(); - } - self.storage.save_connections(&self.connections); - self.storage.save_subscriptions(&self.subscriptions); - cx.notify(); - } - - pub fn toggle_connection(&self, cfg: &ConnectionConfig) { - // Connecting 期间 is_connected 恒为 false:防抖,双击/误触不会 - // 并发两次 connect(会互相顶掉句柄并在 broker 侧形成重连风暴) - if self.statuses.get(&cfg.id) == Some(&ConnectionStatus::Connecting) { - return; - } - if self.engine.is_connected(&cfg.id) { - self.engine.close(&cfg.id, true); - } else { - // 遗嘱支持 {{变量}}:对副本注入,保存的配置仍为模板原文 - let mut cfg = cfg.clone(); - crate::model::render_will_templates(&mut cfg, &self.variables); - self.engine.connect(cfg); - } - } - - /// 打开的标签数(资源监控展示用)。 - pub fn open_tab_count(&self) -> usize { - self.open_tabs.len() - } - - pub fn open_tab(&mut self, conn_id: &str, window: &mut Window, cx: &mut Context) { - if !self.open_tabs.contains(&conn_id.to_string()) { - self.open_tabs.push(conn_id.to_string()); - let weak = cx.entity().downgrade(); - let engine = self.engine.clone(); - let view = cx.new(|cx| ConnectionView::new(conn_id.to_string(), weak, engine, window, cx)); - self.views.insert(conn_id.to_string(), view); - } - self.active_tab = Some(conn_id.to_string()); - cx.notify(); - } - - fn close_tab(&mut self, id: &str, cx: &mut Context) { - self.open_tabs.retain(|t| t != id); - self.views.remove(id); - if self.active_tab.as_deref() == Some(id) { - self.active_tab = self.open_tabs.last().cloned(); - } - cx.notify(); - } - - // ── 订阅 ────────────────────────────────────────────────────────────── - - pub fn add_subscription(&mut self, sub: Subscription) { - if !self - .subscriptions - .iter() - .any(|s| s.connection_id == sub.connection_id && s.topic == sub.topic) - { - self.subscriptions.push(sub); - self.storage.save_subscriptions(&self.subscriptions); - } - } - - pub fn remove_subscription(&mut self, connection_id: &str, topic: &str) { - self.engine - .unsubscribe(connection_id.to_string(), topic.to_string()); - self.subscriptions - .retain(|s| !(s.connection_id == connection_id && s.topic == topic)); - self.storage.save_subscriptions(&self.subscriptions); - } - - // ── 变量 / 预设 / 阿里云 / 设置 ────────────────────────────────────────── - - pub fn save_variables(&mut self, vars: Vec) { - self.variables = vars; - self.storage.save_variables(&self.variables); - } - - pub fn save_presets(&mut self, presets: Vec) { - self.presets = presets; - self.storage.save_presets(&self.presets); - } - - /// 保存一个发布预设(按 name 去重更新)。 - pub fn upsert_publish_preset(&mut self, name: String, params: PublishParams) { - match self.presets.iter_mut().find(|p| p.name == name) { - Some(p) => p.params = params, - None => self.presets.push(PublishPreset::new(name, params)), - } - self.storage.save_presets(&self.presets); - } - - pub fn delete_publish_preset(&mut self, id: &str) { - self.presets.retain(|p| p.id != id); - self.storage.save_presets(&self.presets); - } - - pub fn save_aliyun(&mut self, presets: Vec) { - self.aliyun_presets = presets; - self.storage.save_aliyun(&self.aliyun_presets); - } - - pub fn save_settings(&mut self, settings: AppSettings, cx: &mut App) { - apply_theme(settings.theme, cx); - self.settings = settings; - self.storage.save_settings(&self.settings); - } - - /// 数据目录(设置对话框展示 / 打开用)。 - pub fn data_dir(&self) -> PathBuf { - self.storage.dir().to_path_buf() - } - - /// 日志目录(设置对话框展示 / 打开用)。 - pub fn log_dir(&self) -> PathBuf { - self.storage.log_dir() - } - - // ── 连接导入 / 导出 ──────────────────────────────────────────────────── - - /// 在后台线程弹出阻塞式原生文件对话框,完成后回主线程执行 `done`。 - /// 对话框进行中置位,防止重复弹出。 - fn spawn_file_dialog( - &mut self, - work: F, - done: D, - window: &mut Window, - cx: &mut Context, - ) where - F: FnOnce() -> R + Send + 'static, - R: Send + 'static, - D: FnOnce(&mut Self, R, &mut Window, &mut Context) + 'static, - { - if self.file_dialog_open { - return; - } - self.file_dialog_open = true; - // rfd 的阻塞对话框不可占用 UI 线程:结果经 smol 通道送回异步任务 - let (tx, rx) = smol::channel::bounded::(1); - std::thread::spawn(move || { - let _ = tx.send_blocking(work()); - }); - let weak = cx.entity().downgrade(); - cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| { - let result = rx.recv().await.ok(); - weak.update_in(cx, |app, window, cx| { - app.file_dialog_open = false; - if let Some(result) = result { - done(app, result, window, cx); - } - }) - .ok(); - }) - .detach(); - } - - /// 导出全部连接为 JSON(原生保存对话框,默认文件名 connections-export.json)。 - pub fn export_connections(&mut self, window: &mut Window, cx: &mut Context) { - let dir = self.storage.dir().to_path_buf(); - self.spawn_file_dialog( - move || { - rfd::FileDialog::new() - .set_title("导出连接") - .set_directory(dir) - .set_file_name("connections-export.json") - .add_filter("JSON", &["json"]) - .save_file() - }, - |app, path, window, cx| { - let Some(path) = path else { - return; // 用户取消 - }; - let count = app.connections.len(); - match crate::store::export_connections_to(&path, &app.connections) { - Ok(()) => window.push_notification( - Notification::success(format!( - "已导出 {count} 条连接到 {}", - path.display() - )), - cx, - ), - Err(e) => window.push_notification(Notification::error(e), cx), - } - }, - window, - cx, - ); - } - - /// 导入连接:按 id 去重(同 id 跳过),新 id 保留文件原值;读取解析在后台线程完成。 - pub fn import_connections(&mut self, window: &mut Window, cx: &mut Context) { - let dir = self.storage.dir().to_path_buf(); - self.spawn_file_dialog( - move || { - let Some(path) = rfd::FileDialog::new() - .set_title("导入连接") - .set_directory(dir) - .add_filter("JSON", &["json"]) - .pick_file() - else { - return Ok(None); // 用户取消 - }; - crate::store::import_connections_from(&path).map(Some) - }, - |app, result, window, cx| match result { - Err(e) => { - window.push_notification(Notification::error(e), cx); - } - Ok(None) => {} - Ok(Some(items)) => { - let mut existing: std::collections::HashSet = - app.connections.iter().map(|c| c.id.clone()).collect(); - let mut added = 0usize; - let mut skipped = 0usize; - for item in items { - if existing.contains(&item.id) { - skipped += 1; - } else { - // 记录已入库 id,导入文件内部的重复 id 也只收一条 - existing.insert(item.id.clone()); - app.connections.push(item); - added += 1; - } - } - if added > 0 { - app.storage.save_connections(&app.connections); - } - window.push_notification( - Notification::success(format!("导入 {added} 条,跳过 {skipped} 条")), - cx, - ); - } - }, - window, - cx, - ); - } - - // ── 对话框 ──────────────────────────────────────────────────────────── - - pub fn open_connection_form( - &mut self, - edit: Option, - window: &mut Window, - cx: &mut Context, - ) { - let app = cx.entity().downgrade(); - let engine = self.engine.clone(); - // 表单的分组下拉需要现有分组列表(去重、排序) - let mut groups: Vec = self - .connections - .iter() - .filter_map(|c| c.group.as_ref().map(|g| g.trim().to_string())) - .filter(|g| !g.is_empty()) - .collect(); - groups.sort(); - groups.dedup(); - let form = cx.new(|cx| ConnectionForm::new(engine, edit, groups, window, cx)); - window.open_dialog(cx, move |dialog, _, cx| { - let form_ok = form.clone(); - let form_test = form.clone(); - let app_ok = app.clone(); - let testing = form_test.read(cx).is_testing(); - dialog - .w(px(680.)) - .title("连接配置") - .child(form.clone()) - .footer( - h_flex() - .gap_2() - .w_full() - // 测试连接常驻左下角,保存前即可验证配置 - .child( - Button::new("form-test") - .icon(if testing { - IconName::LoaderCircle - } else { - IconName::PlugZap - }) - .label(if testing { "测试中…" } else { "测试连接" }) - .outline() - .loading(testing) - .on_click(move |_, window, cx| { - form_test.update(cx, |f, cx| f.run_test(window, cx)); - }), - ) - .child(div().flex_1()) - .child( - Button::new("form-cancel") - .label("取消") - .outline() - .on_click(|_, window, cx| window.close_dialog(cx)), - ) - .child( - Button::new("form-ok") - .label("保存") - .primary() - .on_click(move |_, window, cx| { - let built = form_ok.read(cx).build(cx); - match built { - Ok(cfg) => { - // 已连接的连接改了关键参数,重连后才生效,先算好再保存 - let mut stale = false; - app_ok - .update(cx, |app, cx| { - let connected = app.statuses.get(&cfg.id) - == Some(&ConnectionStatus::Connected); - if connected - && app - .connections - .iter() - .find(|c| c.id == cfg.id) - .is_some_and(|old| { - old.session_params_changed(&cfg) - }) - { - stale = true; - } - app.save_connection(cfg); - cx.notify(); - }) - .ok(); - window.close_dialog(cx); - if stale { - window.push_notification( - Notification::info("配置已保存,重连后生效"), - cx, - ); - } - } - Err(e) => { - // 触发重绘,让字段级红字即时显示(错误已写入表单内部) - form_ok.update(cx, |_, cx| cx.notify()); - window.push_notification( - Notification::error(e), - cx, - ); - } - } - }), - ), - ) - }); - } -} - -// ─── 主题 ──────────────────────────────────────────────────────────────────── - -/// 应用主题偏好。 -/// -/// 「跟随系统」读取 gpui 的窗口外观(`App::window_appearance`);系统深浅色变化由 -/// Linear 风格主题(内嵌 JSON,模式与 gpui-component 默认主题同构)。 -const LINEAR_THEME: &str = include_str!("linear-theme.json"); - -/// `MqttXApp::new` 里注册的 `Window::observe_window_appearance` 监听并重应用本函数。 -/// -/// 流程:先 `change` 确保 Theme 全局已建立(注册表 observer 依赖它)→ 注册 -/// Linear Light/Dark(重名跳过,幂等)→ 把当前明暗对指向 Linear → 再 `change` -/// 应用其颜色/字号/圆角。 -pub fn apply_theme(pref: ThemeModePref, cx: &mut App) { - let mode = match pref { - ThemeModePref::Light => ThemeMode::Light, - ThemeModePref::Dark => ThemeMode::Dark, - ThemeModePref::System => ThemeMode::from(cx.window_appearance()), - }; - Theme::change(mode, None, cx); - if let Err(e) = ThemeRegistry::global_mut(cx).load_themes_from_str(LINEAR_THEME) { - eprintln!("加载 Linear 主题失败,回退默认主题: {e}"); - } - { - let reg = ThemeRegistry::global(cx); - let light = reg.themes().get("Linear Light").cloned(); - let dark = reg.themes().get("Linear Dark").cloned(); - let theme = Theme::global_mut(cx); - if let Some(l) = light { - theme.light_theme = l; - } - if let Some(d) = dark { - theme.dark_theme = d; - } - } - Theme::change(mode, None, cx); -} - -// ─── 渲染 ──────────────────────────────────────────────────────────────────── - -impl Render for MqttXApp { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - // 0.6.x 的 Root 不自动渲染 dialog/sheet/notification 层,需由内容视图挂载。 - v_flex() - .size_full() - .bg(cx.theme().background) - .text_color(cx.theme().foreground) - .child(self.render_titlebar(window, cx)) - .child( - h_flex() - .flex_1() - .min_h(px(0.)) - .child(self.render_sidebar(window, cx)) - .child(self.render_main(window, cx)), - ) - .children(gpui_kit::component::Root::render_sheet_layer(window, cx)) - .children(gpui_kit::component::Root::render_dialog_layer(window, cx)) - .children(gpui_kit::component::Root::render_notification_layer(window, cx)) - } -} - -impl MqttXApp { - fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let app_weak = cx.entity().downgrade(); - - TitleBar::new().child( - h_flex() - .w_full() - .h_full() - .items_center() - .justify_between() - .pl_2() - .child( - h_flex() - .gap_2() - .items_center() - .child(crate::ui::logo::logo( - px(26.), - cx.theme().primary, - cx.theme().primary_foreground, - )) - .child(div().text_sm().font_semibold().child("MQTTX")) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("MQTT 调试客户端"), - ), - ) - .child( - h_flex() - .gap_1() - .pr_2() - .items_center() - // 整条标题栏被 gpui-component 标记为 WindowControlArea::Drag - // (WM_NCHITTEST 返回 HTCAPTION),点击会变成拖动窗口。 - // 按钮簇必须遮挡鼠标:hit_test 收集到 BlockMouse hitbox - // 即截断,父级 Drag 区不再命中,按钮才能收到点击。 - .occlude() - .child( - Button::new("new-connection") - .icon(IconName::Plus) - .label("新建连接") - .primary() - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.open_connection_form(None, window, cx); - })), - ) - .child( - Button::new("pin-window") - .icon(if self.pinned { - IconName::PinOff - } else { - IconName::Pin - }) - .ghost() - .small() - .tooltip(if self.pinned { - "取消窗口置顶" - } else { - "窗口置顶" - }) - .on_click(cx.listener(|this, _, window, cx| { - let next = !this.pinned; - if crate::sysmon::set_topmost(window, next) { - this.pinned = next; - cx.notify(); - } else { - window.push_notification( - Notification::error("置顶操作失败"), - cx, - ); - } - })), - ) - .child(self.render_theme_toggle(cx)) - .child( - Button::new("more-menu") - .icon(IconName::Ellipsis) - .ghost() - .small() - .dropdown_menu(move |menu, _, _| { - let w1 = app_weak.clone(); - let w2 = app_weak.clone(); - let w3 = app_weak.clone(); - let w4 = app_weak.clone(); - let w5 = app_weak.clone(); - let w6 = app_weak.clone(); - menu.item( - PopupMenuItem::new("阿里云设备") - .on_click(move |_, window, cx| { - if let Some(entity) = w1.upgrade() { - crate::ui::aliyun_dialog::open(entity, window, cx); - } - }), - ) - .item(PopupMenuItem::new("全局变量").on_click( - move |_, window, cx| { - if let Some(entity) = w2.upgrade() { - crate::ui::variables_dialog::open(entity, window, cx); - } - }, - )) - .item(PopupMenuItem::new("资源监控").on_click( - move |_, window, cx| { - if let Some(entity) = w6.upgrade() { - crate::ui::resource_dialog::open(entity, window, cx); - } - }, - )) - .separator() - .item(PopupMenuItem::new("导出连接").on_click( - move |_, window, cx| { - if let Some(entity) = w4.upgrade() { - entity.update(cx, |app, cx| { - app.export_connections(window, cx) - }); - } - }, - )) - .item(PopupMenuItem::new("导入连接").on_click( - move |_, window, cx| { - if let Some(entity) = w5.upgrade() { - entity.update(cx, |app, cx| { - app.import_connections(window, cx) - }); - } - }, - )) - .separator() - .item(PopupMenuItem::new("设置").on_click( - move |_, window, cx| { - if let Some(entity) = w3.upgrade() { - crate::ui::settings_dialog::open(entity, window, cx); - } - }, - )) - }), - ), - ), - ) - } - - fn render_theme_toggle(&self, cx: &mut Context) -> Button { - let dark = cx.theme().mode == ThemeMode::Dark; - Button::new("toggle-theme") - .icon(if dark { IconName::Sun } else { IconName::Moon }) - .ghost() - .small() - .tooltip(if dark { "切换为浅色" } else { "切换为深色" }) - .on_click(cx.listener(|this, _, _, cx| { - let next = if cx.theme().mode == ThemeMode::Dark { - ThemeModePref::Light - } else { - ThemeModePref::Dark - }; - let mut s = this.settings.clone(); - s.theme = next; - this.save_settings(s, cx); - cx.notify(); - })) - } - - fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let query = self.search.read(cx).value().to_lowercase(); - let search = self.search.clone(); - // 分组被改名/删除后过滤器可能悬空(列表恒空),渲染前复位到「全部」 - let dangling = matches!(&self.group_filter, GroupFilter::Named(name) - if !self.connections.iter().any(|c| { - c.group.as_deref().map(str::trim) == Some(name.as_str()) - })); - if dangling { - self.group_filter = GroupFilter::All; - } - let filter = self.group_filter.clone(); - - let total = self.connections.len(); - let hover_bg = cx.theme().muted; - let mut rows = v_flex().gap_0p5().flex_1().overflow_y_scrollbar(); - let mut shown = 0usize; - for conn in self.connections.clone() { - // 分组过滤与搜索叠加(AND) - let group = conn.group.as_deref().map(str::trim).unwrap_or(""); - let match_group = match &filter { - GroupFilter::All => true, - GroupFilter::Ungrouped => group.is_empty(), - GroupFilter::Named(name) => group == name.as_str(), - }; - if !match_group { - continue; - } - if !query.is_empty() && !conn.name.to_lowercase().contains(&query) { - continue; - } - shown += 1; - let status = self - .statuses - .get(&conn.id) - .copied() - .unwrap_or(ConnectionStatus::Disconnected); - let active = self.active_tab.as_deref() == Some(conn.id.as_str()); - let cfg = conn.clone(); - let name = conn.name.clone(); - let address = conn.display_address(); - let row_id = conn.id.clone(); - - rows = rows.child( - h_flex() - .id(SharedString::from(format!("conn-row-{}", conn.id))) - .w_full() - .items_center() - .gap_2() - .mx_2() - .my_0p5() - .px_2() - .py_2() - .rounded_md() - .when(active, |this| this.bg(cx.theme().secondary)) - .when(!active, |this| { - this.hover(move |this| this.bg(hover_bg)) - }) - .cursor_pointer() - .on_click(cx.listener(move |this, _, window, cx| { - this.open_tab(&row_id, window, cx); - })) - .child( - div() - .size(px(7.)) - .rounded_full() - .bg(status_color(status, cx)), - ) - .child( - v_flex() - .flex_1() - .min_w(px(0.)) - .gap_0p5() - .child( - div() - .text_sm() - .when(active, |t| t.font_semibold()) - .overflow_hidden() - .text_ellipsis() - .child(name), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .overflow_hidden() - .text_ellipsis() - .child(address), - ), - ) - .child(self.render_conn_power(&cfg, status, cx)) - .child(self.render_conn_menu(&conn.id, cx)), - ); - } - - let body = if self.connections.is_empty() { - v_flex() - .flex_1() - .items_center() - .justify_center() - .gap_2() - .p_4() - .child( - gpui_kit::component::Icon::new(IconName::PlugZap) - .text_color(cx.theme().muted_foreground), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("还没有连接,点击上方 + 新建"), - ) - } else if shown == 0 { - v_flex() - .flex_1() - .items_center() - .justify_center() - .p_4() - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("没有匹配的连接"), - ) - } else { - v_flex().flex_1().child(rows) - }; - - v_flex() - .w(px(264.)) - .h_full() - .flex_shrink_0() - .bg(cx.theme().sidebar) - .text_color(cx.theme().sidebar_foreground) - .child( - h_flex() - .h_11() - .px_3() - .items_center() - .justify_between() - .child( - h_flex() - .gap_1p5() - .items_center() - .child(div().text_sm().font_semibold().child("连接")) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(total.to_string()), - ), - ) - .child( - Button::new("sidebar-add") - .icon(IconName::Plus) - .tooltip("新建连接") - .ghost() - .xsmall() - .on_click(cx.listener(|this, _, window, cx| { - this.open_connection_form(None, window, cx); - })), - ), - ) - .child(v_flex().px_2().pb_2().child(Input::new(&search).small().prefix( - gpui_kit::component::Icon::new(IconName::Search).small(), - ))) - .child(self.render_group_chips(cx)) - .child(body) - } - - /// 搜索框下方的分组 chips:全部 / 未分组 / 各分组(带计数徽标),点击切换过滤。 - fn render_group_chips(&self, cx: &mut Context) -> impl IntoElement { - let mut names: Vec = self - .connections - .iter() - .filter_map(|c| c.group.as_deref().map(str::trim)) - .filter(|g| !g.is_empty()) - .map(str::to_string) - .collect(); - names.sort(); - names.dedup(); - - let ungrouped = self - .connections - .iter() - .filter(|c| c.group.as_deref().map(str::trim).unwrap_or("").is_empty()) - .count(); - - // 分组多时限制高度并纵向滚动,避免 chips 换行挤占下方连接列表 - let mut row = h_flex() - .gap_1() - .flex_wrap() - .px_2() - .pb_1() - .max_h(px(88.)) - .overflow_y_scrollbar(); - row = row.child(self.render_group_chip( - "chip-all", - format!("全部 {}", self.connections.len()), - GroupFilter::All, - cx, - )); - row = row.child(self.render_group_chip( - "chip-ungrouped", - format!("未分组 {ungrouped}"), - GroupFilter::Ungrouped, - cx, - )); - for name in names { - let count = self - .connections - .iter() - .filter(|c| c.group.as_deref().map(str::trim) == Some(name.as_str())) - .count(); - row = row.child(self.render_group_chip( - SharedString::from(format!("chip-g-{name}")), - format!("{name} {count}"), - GroupFilter::Named(name), - cx, - )); - } - row - } - - fn render_group_chip( - &self, - id: impl Into, - label: String, - filter: GroupFilter, - cx: &mut Context, - ) -> impl IntoElement { - let selected = match (&self.group_filter, &filter) { - (GroupFilter::All, GroupFilter::All) - | (GroupFilter::Ungrouped, GroupFilter::Ungrouped) => true, - (GroupFilter::Named(a), GroupFilter::Named(b)) => a == b, - _ => false, - }; - div() - .id(id) - .px_2() - .py_0p5() - .rounded_md() - .text_xs() - .when(selected, |d| d.bg(cx.theme().secondary).font_semibold()) - .when(!selected, |d| { - d.bg(cx.theme().muted) - .text_color(cx.theme().muted_foreground) - .hover(|d| d.bg(cx.theme().secondary)) - }) - .cursor_pointer() - .on_click(cx.listener(move |this, _, _, cx| { - // listener 是 Fn(可多次调用),只能克隆而不能移出捕获的 filter - this.group_filter = filter.clone(); - cx.notify(); - })) - .child(label) - } - - fn render_conn_power( - &self, - cfg: &ConnectionConfig, - status: ConnectionStatus, - cx: &mut Context, - ) -> Button { - let cfg = cfg.clone(); - let connected = matches!(status, ConnectionStatus::Connected); - Button::new(SharedString::from(format!("conn-power-{}", cfg.id))) - .icon(if connected { IconName::Square } else { IconName::PlugZap }) - .ghost() - .xsmall() - .tooltip(if connected { "断开" } else { "连接" }) - .on_click(cx.listener(move |this, _, _, _| { - this.toggle_connection(&cfg); - })) - } - - fn render_conn_menu(&self, conn_id: &str, cx: &mut Context) -> impl IntoElement { - let weak = cx.entity().downgrade(); - let eid = conn_id.to_string(); - Button::new(SharedString::from(format!("conn-menu-{}", conn_id))) - .icon(IconName::Ellipsis) - .ghost() - .xsmall() - .dropdown_menu(move |menu, _, _| { - let (e1, e2, e3) = (eid.clone(), eid.clone(), eid.clone()); - let (w1, w2, w3) = (weak.clone(), weak.clone(), weak.clone()); - menu.item(PopupMenuItem::new("编辑").on_click(move |_, window, cx| { - w1.update(cx, |app, cx| { - let cfg = app.connections.iter().find(|c| c.id == e1).cloned(); - if let Some(cfg) = cfg { - app.open_connection_form(Some(cfg), window, cx); - } - }) - .ok(); - })) - .item(PopupMenuItem::new("复制连接").on_click(move |_, _, cx| { - w2.update(cx, |app, cx| { - app.duplicate_connection(&e2); - cx.notify(); - }) - .ok(); - })) - .separator() - .item(PopupMenuItem::new("删除").on_click(move |_, window, cx| { - let Some(app) = w3.upgrade() else { - return; - }; - // 二次确认:删除会级联清理订阅并断开连接,不可撤销 - let (name, sub_count) = { - let a = app.read(cx); - let name = a - .connections - .iter() - .find(|c| c.id == e3) - .map(|c| c.name.clone()) - .unwrap_or_else(|| e3.clone()); - let n = a.subscriptions.iter().filter(|s| s.connection_id == e3).count(); - (name, n) - }; - let confirm_id = e3.clone(); - window.open_alert_dialog(cx, move |alert, _, _| { - // 构建闭包是 Fn(可重复调用),克隆后再移入按钮回调 - let app = app.clone(); - let confirm_id = confirm_id.clone(); - alert - .title("删除连接") - .description(SharedString::from(format!( - "确定删除「{name}」?将同时删除 {sub_count} 个订阅,并断开当前连接,此操作不可撤销。" - ))) - .button_props( - DialogButtonProps::default() - .ok_text("删除") - .ok_variant(ButtonVariant::Danger) - .show_cancel(true), - ) - .on_ok(move |_, _, cx| { - app.update(cx, |app, cx| app.delete_connection(&confirm_id, cx)); - true - }) - }); - })) - }) - } - - fn render_main(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - if self.open_tabs.is_empty() { - // 父级 h_flex 交叉轴居中子项,容器必须 h_full 占满高度才不会整体下沉 - return v_flex() - .flex_1() - .h_full() - .min_h(px(0.)) - .items_center() - .justify_center() - .gap_3() - .child(crate::ui::logo::logo( - px(72.), - cx.theme().muted, - cx.theme().muted_foreground, - )) - .child(div().text_base().font_medium().child("开始使用 MQTTX")) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .text_center() - .child("从左侧选择一个连接,或新建连接开始调试"), - ) - .child( - Button::new("empty-new") - .icon(IconName::Plus) - .label("新建连接") - .outline() - .small() - .mt_1() - .on_click(cx.listener(|this, _, window, cx| { - this.open_connection_form(None, window, cx); - })), - ) - .into_any_element(); - } - - let active = self.active_tab.clone().unwrap_or_default(); - let mut bar = TabBar::new("conn-tabs") - // 连接名过长时截断省略,避免单个页签占满整行 - .max_width(px(180.)); - for id in self.open_tabs.clone() { - let name = self - .connections - .iter() - .find(|c| c.id == id) - .map(|c| c.name.clone()) - .unwrap_or_else(|| id.clone()); - let tab_id = id.clone(); - let close_id = id.clone(); - let is_active = id == active; - bar = bar.child( - Tab::new() - .label(SharedString::from(name)) - .selected(is_active) - .suffix( - Button::new(SharedString::from(format!("tab-close-{}", id))) - .icon(IconName::Close) - .ghost() - .xsmall() - .on_click(cx.listener(move |this, _, _, cx| { - this.close_tab(&close_id, cx); - })), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_tab(&tab_id, window, cx); - })), - ); - } - - let view = self.views.get(&active).cloned(); - // h_full:父级 h_flex 交叉轴居中子项,不占满高度会被整体垂直居中; - // 视图根是 size_full,须包在 flex_1 容器里,否则会盖住 TabBar 并溢出。 - v_flex() - .flex_1() - .h_full() - .min_h(px(0.)) - .min_w(px(0.)) - .child(div().flex_shrink_0().px_2().pt_1().child(bar)) - .when_some(view, |this, v| { - this.child(v_flex().flex_1().min_h(px(0.)).child(v)) - }) - .into_any_element() - } -} diff --git a/gpui-app/src/ui/app/actions.rs b/gpui-app/src/ui/app/actions.rs new file mode 100644 index 0000000..4eb4466 --- /dev/null +++ b/gpui-app/src/ui/app/actions.rs @@ -0,0 +1,260 @@ +//! 用户动作:连接/订阅/预设/变量/设置等状态写操作,与连接表单的打开。 + + +use gpui_kit::component::button::{ Button }; +use gpui_kit::component::{ h_flex, notification::Notification, }; +use gpui_kit::{ div, px, App, Context, Window, }; + +use crate::aliyun::AliyunPreset; +use crate::model::{ AppSettings, ConnectionConfig, ConnectionStatus, GlobalVariable, PublishParams, PublishPreset, Subscription, }; +use crate::ui::IconName; +use crate::ui::{ connection_form::ConnectionForm, connection_view::ConnectionView, }; + +use super::*; + +impl MqttXApp { + pub fn save_connection(&mut self, cfg: ConnectionConfig) { + if let Some(slot) = self.connections.iter_mut().find(|c| c.id == cfg.id) { + *slot = cfg; + } else { + self.connections.push(cfg); + } + self.storage.save_connections(&self.connections); + } + + pub fn duplicate_connection(&mut self, id: &str) { + let Some(src) = self.connections.iter().find(|c| c.id == id).cloned() else { + return; + }; + let mut copy = src; + copy.id = uuid::Uuid::new_v4().to_string(); + copy.name = format!("{} 副本", copy.name); + copy.client_id = + format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); + self.connections.push(copy); + self.storage.save_connections(&self.connections); + } + + pub fn delete_connection(&mut self, id: &str, cx: &mut Context) { + self.engine.close(id, false); + self.connections.retain(|c| c.id != id); + self.subscriptions.retain(|s| s.connection_id != id); + self.clear_messages(id); + self.statuses.remove(id); + self.errors.remove(id); + self.views.remove(id); + self.open_tabs.retain(|t| t != id); + if self.active_tab.as_deref() == Some(id) { + self.active_tab = self.open_tabs.last().cloned(); + } + self.storage.save_connections(&self.connections); + self.storage.save_subscriptions(&self.subscriptions); + cx.notify(); + } + + pub fn toggle_connection(&self, cfg: &ConnectionConfig) { + // Connecting 期间 is_connected 恒为 false:防抖,双击/误触不会 + // 并发两次 connect(会互相顶掉句柄并在 broker 侧形成重连风暴) + if self.statuses.get(&cfg.id) == Some(&ConnectionStatus::Connecting) { + return; + } + if self.engine.is_connected(&cfg.id) { + self.engine.close(&cfg.id, true); + } else { + // 遗嘱支持 {{变量}}:对副本注入,保存的配置仍为模板原文 + let mut cfg = cfg.clone(); + crate::model::render_will_templates(&mut cfg, &self.variables); + self.engine.connect(cfg); + } + } + + /// 打开的标签数(资源监控展示用)。 + pub fn open_tab_count(&self) -> usize { + self.open_tabs.len() + } + + pub fn open_tab(&mut self, conn_id: &str, window: &mut Window, cx: &mut Context) { + if !self.open_tabs.contains(&conn_id.to_string()) { + self.open_tabs.push(conn_id.to_string()); + let weak = cx.entity().downgrade(); + let engine = self.engine.clone(); + let view = cx.new(|cx| ConnectionView::new(conn_id.to_string(), weak, engine, window, cx)); + self.views.insert(conn_id.to_string(), view); + } + self.active_tab = Some(conn_id.to_string()); + cx.notify(); + } + +pub(super) fn close_tab(&mut self, id: &str, cx: &mut Context) { + self.open_tabs.retain(|t| t != id); + self.views.remove(id); + if self.active_tab.as_deref() == Some(id) { + self.active_tab = self.open_tabs.last().cloned(); + } + cx.notify(); + } + + // ── 订阅 ────────────────────────────────────────────────────────────── + + pub fn add_subscription(&mut self, sub: Subscription) { + if !self + .subscriptions + .iter() + .any(|s| s.connection_id == sub.connection_id && s.topic == sub.topic) + { + self.subscriptions.push(sub); + self.storage.save_subscriptions(&self.subscriptions); + } + } + + pub fn remove_subscription(&mut self, connection_id: &str, topic: &str) { + self.engine + .unsubscribe(connection_id.to_string(), topic.to_string()); + self.subscriptions + .retain(|s| !(s.connection_id == connection_id && s.topic == topic)); + self.storage.save_subscriptions(&self.subscriptions); + } + + // ── 变量 / 预设 / 阿里云 / 设置 ────────────────────────────────────────── + + pub fn save_variables(&mut self, vars: Vec) { + self.variables = vars; + self.storage.save_variables(&self.variables); + } + + pub fn save_presets(&mut self, presets: Vec) { + self.presets = presets; + self.storage.save_presets(&self.presets); + } + + /// 保存一个发布预设(按 name 去重更新)。 + pub fn upsert_publish_preset(&mut self, name: String, params: PublishParams) { + match self.presets.iter_mut().find(|p| p.name == name) { + Some(p) => p.params = params, + None => self.presets.push(PublishPreset::new(name, params)), + } + self.storage.save_presets(&self.presets); + } + + pub fn delete_publish_preset(&mut self, id: &str) { + self.presets.retain(|p| p.id != id); + self.storage.save_presets(&self.presets); + } + + pub fn save_aliyun(&mut self, presets: Vec) { + self.aliyun_presets = presets; + self.storage.save_aliyun(&self.aliyun_presets); + } + + pub fn save_settings(&mut self, settings: AppSettings, cx: &mut App) { + apply_theme(settings.theme, cx); + self.settings = settings; + self.storage.save_settings(&self.settings); + } + + + pub fn open_connection_form( + &mut self, + edit: Option, + window: &mut Window, + cx: &mut Context, + ) { + let app = cx.entity().downgrade(); + let engine = self.engine.clone(); + // 表单的分组下拉需要现有分组列表(去重、排序) + let mut groups: Vec = self + .connections + .iter() + .filter_map(|c| c.group.as_ref().map(|g| g.trim().to_string())) + .filter(|g| !g.is_empty()) + .collect(); + groups.sort(); + groups.dedup(); + let form = cx.new(|cx| ConnectionForm::new(engine, edit, groups, window, cx)); + window.open_dialog(cx, move |dialog, _, cx| { + let form_ok = form.clone(); + let form_test = form.clone(); + let app_ok = app.clone(); + let testing = form_test.read(cx).is_testing(); + dialog + .w(px(680.)) + .title("连接配置") + .child(form.clone()) + .footer( + h_flex() + .gap_2() + .w_full() + // 测试连接常驻左下角,保存前即可验证配置 + .child( + Button::new("form-test") + .icon(if testing { + IconName::LoaderCircle + } else { + IconName::PlugZap + }) + .label(if testing { "测试中…" } else { "测试连接" }) + .outline() + .loading(testing) + .on_click(move |_, window, cx| { + form_test.update(cx, |f, cx| f.run_test(window, cx)); + }), + ) + .child(div().flex_1()) + .child( + Button::new("form-cancel") + .label("取消") + .outline() + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child( + Button::new("form-ok") + .label("保存") + .primary() + .on_click(move |_, window, cx| { + let built = form_ok.read(cx).build(cx); + match built { + Ok(cfg) => { + // 已连接的连接改了关键参数,重连后才生效,先算好再保存 + let mut stale = false; + app_ok + .update(cx, |app, cx| { + let connected = app.statuses.get(&cfg.id) + == Some(&ConnectionStatus::Connected); + if connected + && app + .connections + .iter() + .find(|c| c.id == cfg.id) + .is_some_and(|old| { + old.session_params_changed(&cfg) + }) + { + stale = true; + } + app.save_connection(cfg); + cx.notify(); + }) + .ok(); + window.close_dialog(cx); + if stale { + window.push_notification( + Notification::info("配置已保存,重连后生效"), + cx, + ); + } + } + Err(e) => { + // 触发重绘,让字段级红字即时显示(错误已写入表单内部) + form_ok.update(cx, |_, cx| cx.notify()); + window.push_notification( + Notification::error(e), + cx, + ); + } + } + }), + ), + ) + }); + } +} diff --git a/gpui-app/src/ui/app/events.rs b/gpui-app/src/ui/app/events.rs new file mode 100644 index 0000000..cd307ba --- /dev/null +++ b/gpui-app/src/ui/app/events.rs @@ -0,0 +1,273 @@ +//! 引擎事件泵:把 MqttEngine 的状态/消息/日志事件写入应用状态。 + +use std::sync::Arc; + +use gpui_kit::component::{ notification::Notification, }; +use gpui_kit::{ Context, Window, }; + +use crate::model::{ ConnectionStatus, Direction, LogEntry, LogLevel, MqttRecord, MessageRing, SubscribeOptions, Subscription, retained_payload, }; +use crate::mqtt::EngineEvent; + +use super::*; + +impl MqttXApp { +pub(super) fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window, cx: &mut Context) { + match event { + EngineEvent::Status { + connection_id, + status, + error, + .. + } => { + // 引擎 close 与事件循环退出之间有窗口,删除连接后其排队 + // 事件仍会送达:为已删除的连接重建状态/消息属于泄漏, + // 一律丢弃(删除时已主动 close)。 + if !self.connections.iter().any(|c| c.id == connection_id) { + return; + } + self.statuses.insert(connection_id.clone(), status); + match error { + Some(e) => { + self.errors.insert(connection_id.clone(), e); + } + None => { + self.errors.remove(&connection_id); + } + } + if status == ConnectionStatus::Connected { + self.on_connected(&connection_id); + } + if let Some(view) = self.views.get(&connection_id) { + view.update(cx, |_, cx| cx.notify()); + } + cx.notify(); + } + EngineEvent::Message(mut record) => { + let id = record.connection_id.clone(); + if !self.connections.iter().any(|c| c.id == id.as_ref()) { + return; + } + 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()); + } + } + EngineEvent::Published { + connection_id, + topic, + payload, + qos, + retain, + content_type, + user_properties, + response_topic, + correlation_data, + message_expiry_interval, + } => { + if !self.connections.iter().any(|c| c.id == connection_id) { + return; + } + let (payload, raw_bytes, payload_truncated) = retained_payload(&payload); + let record = MqttRecord { + seq: self.next_seq(), + connection_id: Arc::from(connection_id.as_str()), + direction: Direction::Published, + topic: Arc::from(topic.as_str()), + payload, + qos, + retain, + timestamp: chrono::Local::now().timestamp_millis(), + user_properties, + content_type, + response_topic, + correlation_data, + message_expiry_interval, + subscription_identifier: None, + payload_truncated, + 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()); + } + } + EngineEvent::SubscribeResult { + connection_id, + topic, + qos, + ok, + error, + } => { + if !self.connections.iter().any(|c| c.id == connection_id) { + return; + } + if ok { + // 乐观更新已在发送前入库;这里只在订阅仍存在时同步 QoS, + // 避免 SUBACK 前被删除的订阅被此事件用默认字段复活。 + if let Some(existing) = self.subscriptions.iter_mut().find(|s| { + s.connection_id == connection_id && s.topic == topic + }) { + existing.qos = qos; + } + } else { + if let Some(e) = error { + window.push_notification( + Notification::error(format!("订阅失败: {e}")), + cx, + ); + } + // broker 拒绝订阅:移除乐观插入的订阅行,与引擎状态保持一致 + // (不发 UNSUBSCRIBE——本就未订阅成功) + self.subscriptions + .retain(|s| !(s.connection_id == connection_id && s.topic == topic)); + self.storage.save_subscriptions(&self.subscriptions); + } + if let Some(view) = self.views.get(&connection_id) { + view.update(cx, |_, cx| cx.notify()); + } + } + EngineEvent::Log(entry) => { + self.push_log(entry); + if let Some(id) = self.active_tab.clone() + && let Some(view) = self.views.get(&id) { + view.update(cx, |_, cx| cx.notify()); + } + } + } + } + + fn on_connected(&mut self, connection_id: &str) { + // 连接成功后自动恢复已保存订阅(auto resubscribe),禁用的订阅跳过。 + let Some(config) = self.connections.iter().find(|c| c.id == connection_id).cloned() else { + return; + }; + if !config.auto_resubscribe { + return; + } + let subs: Vec = self + .subscriptions + .iter() + .filter(|s| s.connection_id == connection_id) + .filter(|s| s.enabled) + .cloned() + .collect(); + for sub in subs { + let opts = SubscribeOptions::from(&sub); + self.engine + .subscribe_with_options(connection_id.to_string(), sub, opts); + } + } + + // ── 数据工具 ────────────────────────────────────────────────────────── + + fn next_seq(&mut self) -> u64 { + self.seq += 1; + self.seq + } + + fn push_message(&mut self, connection_id: Arc, record: MqttRecord) { + let cap = self.settings.max_messages; + self.messages + .entry(connection_id) + .or_insert_with(|| MessageRing::new(cap)) + .push(record, cap); + } + + /// 清空指定连接的消息(内存缓冲随 drop 释放)。 + pub fn clear_messages(&mut self, id: &str) { + self.messages.remove(id); + } + + fn push_log(&mut self, entry: LogEntry) { + // 落盘只在事件路径执行(不在 render 热路径),与内存环形缓冲互不影响 + self.write_log_file(&entry); + if self.logs.len() >= MAX_LOGS { + self.logs.pop_front(); + } + self.logs.push_back(entry); + } + + /// 追加写入按日切分的日志文件 `mqttx-YYYY-MM-DD.log`。 + /// 文件句柄按日期缓存复用,只有跨日或写失败后才重新打开; + /// 失败只在首次 eprintln 一次后保持静默(成功一次即复位,便于恢复后再次提示), + /// 且绝不回调 log/push_log(防止递归触发日志)。 + fn write_log_file(&mut self, entry: &LogEntry) { +use std::sync::atomic::{ AtomicBool, Ordering }; + + static WRITE_FAILED: AtomicBool = AtomicBool::new(false); + + match self.append_log_line(entry) { + Ok(()) => WRITE_FAILED.store(false, Ordering::Relaxed), + Err(e) => { + // 关句柄置空,下次写入自动重开重试 + self.log_file = None; + if !WRITE_FAILED.swap(true, Ordering::Relaxed) { + eprintln!("[app] 写入日志文件失败(后续静默): {e}"); + } + } + } + } + + /// 打开(或复用按日缓存的)日志文件并追加一行。 + /// 文件套 BufWriter:高频消息流下每条日志不再触发多次落盘 syscall; + /// 跨日重开/出错关句柄时随 drop 自动 flush。 + fn append_log_line(&mut self, entry: &LogEntry) -> std::io::Result<()> { + use std::io::Write as _; + use chrono::TimeZone as _; + + let ts = chrono::Local + .timestamp_millis_opt(entry.timestamp) + .single() + .unwrap_or_else(chrono::Local::now); + let day = ts.format("%Y-%m-%d").to_string(); + let need_reopen = match &self.log_file { + Some((cached_day, _)) => *cached_day != day, + None => true, + }; + if need_reopen { + let dir = self.storage.log_dir(); + std::fs::create_dir_all(&dir)?; + let path = dir.join(format!("mqttx-{day}.log")); + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + self.log_file = Some((day, std::io::BufWriter::new(file))); + } + // 连接名查不到时回退为 id,保证行格式恒定 + let conn = self + .connections + .iter() + .find(|c| c.id == entry.connection_id) + .map(|c| c.name.as_str()) + .unwrap_or(entry.connection_id.as_str()); + let file = &mut self + .log_file + .as_mut() + .expect("上方已确保日志句柄存在") + .1; + writeln!( + file, + "{} [{}] {}/{}: {}", + ts.format("%Y-%m-%d %H:%M:%S%.3f"), + entry.level.label(), + conn, + entry.event, + entry.message + ) + } + + pub fn log(&mut self, conn: &str, level: LogLevel, event: &str, message: String) { + self.push_log(LogEntry { + timestamp: chrono::Local::now().timestamp_millis(), + connection_id: conn.to_string(), + level, + event: event.to_string(), + message, + details: None, + }); + } + + // ── 连接 CRUD / 生命周期 ────────────────────────────────────────────── +} diff --git a/gpui-app/src/ui/app/io.rs b/gpui-app/src/ui/app/io.rs new file mode 100644 index 0000000..f399ee5 --- /dev/null +++ b/gpui-app/src/ui/app/io.rs @@ -0,0 +1,143 @@ +//! 连接配置导入/导出(文件对话框 + JSON 序列化)。 + +use std::path::PathBuf; + +use gpui_kit::component::{ notification::Notification, }; +use gpui_kit::{ Context, Window, }; + + +use super::*; + +impl MqttXApp { + /// 数据目录(设置对话框展示 / 打开用)。 + pub fn data_dir(&self) -> PathBuf { + self.storage.dir().to_path_buf() + } + + /// 日志目录(设置对话框展示 / 打开用)。 + pub fn log_dir(&self) -> PathBuf { + self.storage.log_dir() + } + + // ── 连接导入 / 导出 ──────────────────────────────────────────────────── + + /// 在后台线程弹出阻塞式原生文件对话框,完成后回主线程执行 `done`。 + /// 对话框进行中置位,防止重复弹出。 + fn spawn_file_dialog( + &mut self, + work: F, + done: D, + window: &mut Window, + cx: &mut Context, + ) where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, + D: FnOnce(&mut Self, R, &mut Window, &mut Context) + 'static, + { + if self.file_dialog_open { + return; + } + self.file_dialog_open = true; + // rfd 的阻塞对话框不可占用 UI 线程:结果经 smol 通道送回异步任务 + let (tx, rx) = smol::channel::bounded::(1); + std::thread::spawn(move || { + let _ = tx.send_blocking(work()); + }); + let weak = cx.entity().downgrade(); + cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| { + let result = rx.recv().await.ok(); + weak.update_in(cx, |app, window, cx| { + app.file_dialog_open = false; + if let Some(result) = result { + done(app, result, window, cx); + } + }) + .ok(); + }) + .detach(); + } + + /// 导出全部连接为 JSON(原生保存对话框,默认文件名 connections-export.json)。 + pub fn export_connections(&mut self, window: &mut Window, cx: &mut Context) { + let dir = self.storage.dir().to_path_buf(); + self.spawn_file_dialog( + move || { + rfd::FileDialog::new() + .set_title("导出连接") + .set_directory(dir) + .set_file_name("connections-export.json") + .add_filter("JSON", &["json"]) + .save_file() + }, + |app, path, window, cx| { + let Some(path) = path else { + return; // 用户取消 + }; + let count = app.connections.len(); + match crate::store::export_connections_to(&path, &app.connections) { + Ok(()) => window.push_notification( + Notification::success(format!( + "已导出 {count} 条连接到 {}", + path.display() + )), + cx, + ), + Err(e) => window.push_notification(Notification::error(e), cx), + } + }, + window, + cx, + ); + } + + /// 导入连接:按 id 去重(同 id 跳过),新 id 保留文件原值;读取解析在后台线程完成。 + pub fn import_connections(&mut self, window: &mut Window, cx: &mut Context) { + let dir = self.storage.dir().to_path_buf(); + self.spawn_file_dialog( + move || { + let Some(path) = rfd::FileDialog::new() + .set_title("导入连接") + .set_directory(dir) + .add_filter("JSON", &["json"]) + .pick_file() + else { + return Ok(None); // 用户取消 + }; + crate::store::import_connections_from(&path).map(Some) + }, + |app, result, window, cx| match result { + Err(e) => { + window.push_notification(Notification::error(e), cx); + } + Ok(None) => {} + Ok(Some(items)) => { + let mut existing: std::collections::HashSet = + app.connections.iter().map(|c| c.id.clone()).collect(); + let mut added = 0usize; + let mut skipped = 0usize; + for item in items { + if existing.contains(&item.id) { + skipped += 1; + } else { + // 记录已入库 id,导入文件内部的重复 id 也只收一条 + existing.insert(item.id.clone()); + app.connections.push(item); + added += 1; + } + } + if added > 0 { + app.storage.save_connections(&app.connections); + } + window.push_notification( + Notification::success(format!("导入 {added} 条,跳过 {skipped} 条")), + cx, + ); + } + }, + window, + cx, + ); + } + + // ── 对话框 ──────────────────────────────────────────────────────────── +} diff --git a/gpui-app/src/ui/linear-theme.json b/gpui-app/src/ui/app/linear-theme.json similarity index 100% rename from gpui-app/src/ui/linear-theme.json rename to gpui-app/src/ui/app/linear-theme.json diff --git a/gpui-app/src/ui/app/main_area.rs b/gpui-app/src/ui/app/main_area.rs new file mode 100644 index 0000000..1687ca2 --- /dev/null +++ b/gpui-app/src/ui/app/main_area.rs @@ -0,0 +1,98 @@ +//! 主区域:标签页栏与内容视图装配(含空态占位)。 + + +use gpui_kit::component::button::{ Button }; +use gpui_kit::component::tab::{ Tab, TabBar }; +use gpui_kit::component::{ v_flex, }; +use gpui_kit::{ div, px, Context, IntoElement, SharedString, Window, }; + +use crate::ui::IconName; + +use super::*; + +impl MqttXApp { +pub(super) fn render_main(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.open_tabs.is_empty() { + // 父级 h_flex 交叉轴居中子项,容器必须 h_full 占满高度才不会整体下沉 + return v_flex() + .flex_1() + .h_full() + .min_h(px(0.)) + .items_center() + .justify_center() + .gap_3() + .child(crate::ui::logo::logo( + px(72.), + cx.theme().muted, + cx.theme().muted_foreground, + )) + .child(div().text_base().font_medium().child("开始使用 MQTTX")) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .text_center() + .child("从左侧选择一个连接,或新建连接开始调试"), + ) + .child( + Button::new("empty-new") + .icon(IconName::Plus) + .label("新建连接") + .outline() + .small() + .mt_1() + .on_click(cx.listener(|this, _, window, cx| { + this.open_connection_form(None, window, cx); + })), + ) + .into_any_element(); + } + + let active = self.active_tab.clone().unwrap_or_default(); + let mut bar = TabBar::new("conn-tabs") + // 连接名过长时截断省略,避免单个页签占满整行 + .max_width(px(180.)); + for id in self.open_tabs.clone() { + let name = self + .connections + .iter() + .find(|c| c.id == id) + .map(|c| c.name.clone()) + .unwrap_or_else(|| id.clone()); + let tab_id = id.clone(); + let close_id = id.clone(); + let is_active = id == active; + bar = bar.child( + Tab::new() + .label(SharedString::from(name)) + .selected(is_active) + .suffix( + Button::new(SharedString::from(format!("tab-close-{}", id))) + .icon(IconName::Close) + .ghost() + .xsmall() + .on_click(cx.listener(move |this, _, _, cx| { + this.close_tab(&close_id, cx); + })), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_tab(&tab_id, window, cx); + })), + ); + } + + let view = self.views.get(&active).cloned(); + // h_full:父级 h_flex 交叉轴居中子项,不占满高度会被整体垂直居中; + // 视图根是 size_full,须包在 flex_1 容器里,否则会盖住 TabBar 并溢出。 + v_flex() + .flex_1() + .h_full() + .min_h(px(0.)) + .min_w(px(0.)) + .child(div().flex_shrink_0().px_2().pt_1().child(bar)) + .when_some(view, |this, v| { + this.child(v_flex().flex_1().min_h(px(0.)).child(v)) + }) + .into_any_element() + } +} diff --git a/gpui-app/src/ui/app/mod.rs b/gpui-app/src/ui/app/mod.rs new file mode 100644 index 0000000..f973bec --- /dev/null +++ b/gpui-app/src/ui/app/mod.rs @@ -0,0 +1,382 @@ +//! 应用外壳:标题栏 + 连接侧边栏 + 多标签连接视图 + 引擎事件泵。 +//! +//! - [`events`] 引擎事件 → 应用状态(连接状态、消息、日志) +//! - [`actions`] 用户动作(连接/订阅/预设/设置等状态写操作) +//! - [`io`] 连接配置导入导出 +//! - [`theme`] 主题应用 +//! - [`titlebar`] / [`sidebar`] / [`main_area`] 渲染分区 +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; + +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::input::InputState; +use gpui_kit::component::menu::DropdownMenu as _; +use gpui_kit::component::{ + h_flex, notification::Notification, v_flex, ActiveTheme as _, Selectable as _, Sizable as _, + StyledExt as _, Theme, ThemeMode, WindowExt as _, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + KeyBinding, ParentElement as _, Render, StatefulInteractiveElement as _, + Styled as _, Window, +}; + +use crate::aliyun::AliyunPreset; +use crate::model::{ + AppSettings, ConnectionConfig, ConnectionStatus, GlobalVariable, LogEntry, MessageRing, PublishPreset, + Subscription, ThemeModePref, +}; +use crate::mqtt::MqttEngine; +use crate::store::Storage; +use crate::update; +use crate::ui::connection_view::ConnectionView; + +mod actions; +mod events; +mod io; +mod main_area; +mod sidebar; +mod theme; +mod titlebar; + +use theme::apply_theme; + +const MAX_LOGS: usize = 3000; + +// 应用级快捷键动作(context=None:任意焦点状态下都匹配)。 +gpui_kit::actions!(mqttx, [NewConnection, OpenSettings, ExportConnections, ImportConnections, OpenResourceMonitor]); + +/// 侧边栏分组过滤;chips 只做列表过滤,分组重命名/删除通过编辑连接的分组字段完成。 +#[derive(Clone, PartialEq, Eq)] +enum GroupFilter { + All, + Ungrouped, + Named(String), +} + +/// 全局 action 回调只有 `&mut App`:先 defer 出当前分发栈,再取活动窗口回到实体执行, +/// 避免在窗口借用期间重入 `update`。 +fn run_on_active_window( + cx: &mut App, + weak: gpui_kit::WeakEntity, + f: impl FnOnce(Entity, &mut Window, &mut App) + 'static, +) { + let Some(handle) = cx.active_window() else { + return; + }; + cx.defer(move |cx| { + let Some(entity) = weak.upgrade() else { + return; + }; + let _ = handle.update(cx, move |_, window, cx| f(entity, window, cx)); + }); +} + +pub struct MqttXApp { + storage: Storage, + pub engine: std::sync::Arc, + _pump: gpui_kit::Task<()>, + + // ── 持久化数据 ── + pub connections: Vec, + pub subscriptions: Vec, + pub presets: Vec, + pub variables: Vec, + pub aliyun_presets: Vec, + pub settings: AppSettings, + + // ── 运行时状态 ── + pub statuses: HashMap, + pub errors: HashMap, + /// 每连接消息环形缓冲:键为驻留后的连接 ID,条数 + 字节预算双重约束 + pub messages: HashMap, MessageRing>, + pub logs: VecDeque, + pub seq: u64, + + // ── 标签与视图 ── + open_tabs: Vec, + active_tab: Option, + views: HashMap>, + + search: Entity, + /// 分组过滤状态(chips 点击切换,与搜索条件叠加) + group_filter: GroupFilter, + /// 原生文件对话框进行中标记(对话框阻塞后台线程,防止重复弹出) + file_dialog_open: bool, + /// 按日期缓存的日志文件句柄(日期, 文件),避免每条日志都重新打开文件 + log_file: Option<(String, std::io::BufWriter)>, + /// 系统外观变化订阅:设置为「跟随系统」时重应用主题,随实体存活 + _appearance_obs: gpui_kit::Subscription, + /// 窗口置顶状态(标题栏钉子按钮切换) + pinned: bool, +} + + +impl MqttXApp { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let storage = Storage::new(); + let connections = storage.load_connections(); + let subscriptions = storage.load_subscriptions(); + let presets = storage.load_presets(); + let variables = storage.load_variables(); + let aliyun_presets = storage.load_aliyun(); + let settings = storage.load_settings(); + + apply_theme(settings.theme, cx); + + let (tx, rx) = smol::channel::unbounded(); + let engine = MqttEngine::new(tx); + + let weak = cx.entity().downgrade(); + let pump = cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| { + while let Ok(event) = rx.recv().await { + weak.update_in(cx, |app, window, cx| { + app.on_engine_event(event, window, cx); + }) + .ok(); + } + }); + + // OTA:清理上次自更新遗留文件;开启自动检查则后台静默检查并下载, + // 下载完成后弹对话框征得同意再安装(不自动重启)。 + update::cleanup_old(); + // 自动连接:勾选「启动时自动连接」的连接逐个拉起(引擎异步建连) + for cfg in connections + .iter() + .filter(|c| c.auto_connect) + .cloned() + .collect::>() + { + let mut cfg = cfg; + crate::model::render_will_templates(&mut cfg, &variables); + engine.connect(cfg); + } + if settings.auto_check_update { + let rx = engine.run_blocking(update::check_and_download); + let weak = cx.entity().downgrade(); + cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| { + match rx.recv().await { + Ok(Ok(Some(staged))) => { + weak.update_in(cx, |_app, window, cx| { + window.open_dialog(cx, move |dialog, _, _cx| { + let staged = staged.clone(); + let staged_skip = staged.clone(); + let ver = staged.version.clone(); + dialog + .w(px(440.)) + .title("更新就绪") + .child( + v_flex().gap_2().child( + div().text_sm().child(format!( + "新版本 v{} 已下载完成(已通过 sha256 校验)。", + staged.version + )), + ), + ) + .footer( + h_flex() + .gap_2() + .justify_end() + .w_full() + .child( + Button::new("upd-skip") + .label("暂不更新") + .outline() + .on_click(move |_, window, cx| { + // 拒绝本次更新:删除暂存包, + // 下次启动不会被自动安装 + update::discard_staged(&staged_skip); + window.close_dialog(cx); + }), + ) + .child( + Button::new("upd-later") + .label("下次启动安装") + .outline() + .on_click(move |_, window, cx| { + // 同意标记:启动时据此自动安装; + // 用户跳过对话框则不会强制升级 + update::mark_install_consent(&ver); + window.close_dialog(cx); + }), + ) + .child( + Button::new("upd-now") + .label("立即安装") + .primary() + .on_click(move |_, window, cx| { + window.close_dialog(cx); + if let Err(e) = + update::install_staged(&staged) + { + window.push_notification( + Notification::error(format!( + "安装失败:{e}" + )), + cx, + ); + } else { + cx.quit(); + } + }), + ), + ) + }); + }) + .ok(); + } + // 检查/下载失败不打扰用户,但也不能完全无声 + Ok(Err(e)) => { + eprintln!("[app] 启动更新检查失败: {e}"); + sentry::capture_message( + &format!("startup update check failed: {e}"), + sentry::Level::Warning, + ); + } + _ => {} + } + }) + .detach(); + } + + let search = cx.new(|cx| InputState::new(window, cx).placeholder("搜索连接…")); + + // 自验后门:MQTTX_OPEN_RESMON=1 启动时直接打开资源监控 + // (与 MQTTX_VERSION_OVERRIDE 同类的测试入口,供自动化验证 UI)。 + // defer 到 effects 阶段执行,避免在 MqttXApp 构造借用内嵌套打开 + if std::env::var("MQTTX_OPEN_RESMON").as_deref() == Ok("1") { + cx.defer_in(window, move |_, window, cx| { + crate::ui::dialogs::resource_dialog::open(cx.entity(), window, cx); + }); + } + + // 「跟随系统」:监听系统外观(深浅色)变化,仅在设置为 System 时重应用主题。 + // 订阅需随实体存活,dropped 即取消,故存入字段。 + let appearance_weak = cx.entity().downgrade(); + let appearance_obs = window.observe_window_appearance(move |window, cx| { + let Some(app) = appearance_weak.upgrade() else { + return; + }; + if app.read(cx).settings.theme != ThemeModePref::System { + return; + } + let mode = ThemeMode::from(window.appearance()); + Theme::change(mode, Some(window), cx); + }); + + // ── 全局快捷键:绑定 + action 注册 ── + // 元素级 on_action 依赖焦点路径,无焦点时分发从树根开始收不到; + // 全局 action 在冒泡末端必达,经 defer 取窗口避免在分发栈内重入。 + cx.bind_keys([ + KeyBinding::new("ctrl-n", NewConnection, None), + KeyBinding::new("ctrl-,", OpenSettings, None), + KeyBinding::new("ctrl-shift-e", ExportConnections, None), + KeyBinding::new("ctrl-shift-i", ImportConnections, None), + KeyBinding::new("ctrl-shift-r", OpenResourceMonitor, None), + ]); + let weak = cx.entity().downgrade(); + // Context 上有同名 on_action(绘制期注册),这里必须走 App 的全局注册 + App::on_action::(cx, move |_, cx| { + let w = weak.clone(); + run_on_active_window(cx, w, |entity, window, cx| { + if window.has_active_dialog(cx) { + return; + } + entity.update(cx, |app, cx| app.open_connection_form(None, window, cx)); + }); + }); + 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; + } + crate::ui::dialogs::settings_dialog::open(entity, window, cx); + }); + }); + 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; + } + crate::ui::dialogs::resource_dialog::open(entity, window, cx); + }); + }); + 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| app.export_connections(window, cx)); + }); + }); + 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| app.import_connections(window, cx)); + }); + }); + + Self { + storage, + engine, + _pump: pump, + connections, + subscriptions, + presets, + variables, + aliyun_presets, + settings, + statuses: HashMap::new(), + errors: HashMap::new(), + messages: HashMap::new(), + logs: VecDeque::with_capacity(MAX_LOGS), + seq: 0, + open_tabs: Vec::new(), + active_tab: None, + views: HashMap::new(), + search, + group_filter: GroupFilter::All, + file_dialog_open: false, + log_file: None, + _appearance_obs: appearance_obs, + pinned: false, + } + } + + // ── 引擎事件 ────────────────────────────────────────────────────────── + +} + +impl Render for MqttXApp { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // 0.6.x 的 Root 不自动渲染 dialog/sheet/notification 层,需由内容视图挂载。 + v_flex() + .size_full() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child(self.render_titlebar(window, cx)) + .child( + h_flex() + .flex_1() + .min_h(px(0.)) + .child(self.render_sidebar(window, cx)) + .child(self.render_main(window, cx)), + ) + .children(gpui_kit::component::Root::render_sheet_layer(window, cx)) + .children(gpui_kit::component::Root::render_dialog_layer(window, cx)) + .children(gpui_kit::component::Root::render_notification_layer(window, cx)) + } +} diff --git a/gpui-app/src/ui/app/sidebar.rs b/gpui-app/src/ui/app/sidebar.rs new file mode 100644 index 0000000..f715a2d --- /dev/null +++ b/gpui-app/src/ui/app/sidebar.rs @@ -0,0 +1,361 @@ +//! 侧边栏:搜索、分组过滤 chips、连接列表与右键菜单。 + + +use gpui_kit::component::button::{ Button, ButtonVariant }; +use gpui_kit::component::dialog::DialogButtonProps; +use gpui_kit::component::input::Input; +use gpui_kit::component::menu::{ PopupMenuItem }; +use gpui_kit::component::{ h_flex, v_flex, }; +use gpui_kit::{ div, px, Context, IntoElement, SharedString, Window, }; + +use crate::model::{ ConnectionConfig, ConnectionStatus, }; +use crate::ui::IconName; +use crate::ui::widgets::status_color; + +use super::*; + +impl MqttXApp { +pub(super) fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let query = self.search.read(cx).value().to_lowercase(); + let search = self.search.clone(); + // 分组被改名/删除后过滤器可能悬空(列表恒空),渲染前复位到「全部」 + let dangling = matches!(&self.group_filter, GroupFilter::Named(name) + if !self.connections.iter().any(|c| { + c.group.as_deref().map(str::trim) == Some(name.as_str()) + })); + if dangling { + self.group_filter = GroupFilter::All; + } + let filter = self.group_filter.clone(); + + let total = self.connections.len(); + let hover_bg = cx.theme().muted; + let mut rows = v_flex().gap_0p5().flex_1().overflow_y_scrollbar(); + let mut shown = 0usize; + for conn in self.connections.clone() { + // 分组过滤与搜索叠加(AND) + let group = conn.group.as_deref().map(str::trim).unwrap_or(""); + let match_group = match &filter { + GroupFilter::All => true, + GroupFilter::Ungrouped => group.is_empty(), + GroupFilter::Named(name) => group == name.as_str(), + }; + if !match_group { + continue; + } + if !query.is_empty() && !conn.name.to_lowercase().contains(&query) { + continue; + } + shown += 1; + let status = self + .statuses + .get(&conn.id) + .copied() + .unwrap_or(ConnectionStatus::Disconnected); + let active = self.active_tab.as_deref() == Some(conn.id.as_str()); + let cfg = conn.clone(); + let name = conn.name.clone(); + let address = conn.display_address(); + let row_id = conn.id.clone(); + + rows = rows.child( + h_flex() + .id(SharedString::from(format!("conn-row-{}", conn.id))) + .w_full() + .items_center() + .gap_2() + .mx_2() + .my_0p5() + .px_2() + .py_2() + .rounded_md() + .when(active, |this| this.bg(cx.theme().secondary)) + .when(!active, |this| { + this.hover(move |this| this.bg(hover_bg)) + }) + .cursor_pointer() + .on_click(cx.listener(move |this, _, window, cx| { + this.open_tab(&row_id, window, cx); + })) + .child( + div() + .size(px(7.)) + .rounded_full() + .bg(status_color(status, cx)), + ) + .child( + v_flex() + .flex_1() + .min_w(px(0.)) + .gap_0p5() + .child( + div() + .text_sm() + .when(active, |t| t.font_semibold()) + .overflow_hidden() + .text_ellipsis() + .child(name), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .overflow_hidden() + .text_ellipsis() + .child(address), + ), + ) + .child(self.render_conn_power(&cfg, status, cx)) + .child(self.render_conn_menu(&conn.id, cx)), + ); + } + + let body = if self.connections.is_empty() { + v_flex() + .flex_1() + .items_center() + .justify_center() + .gap_2() + .p_4() + .child( + gpui_kit::component::Icon::new(IconName::PlugZap) + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("还没有连接,点击上方 + 新建"), + ) + } else if shown == 0 { + v_flex() + .flex_1() + .items_center() + .justify_center() + .p_4() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("没有匹配的连接"), + ) + } else { + v_flex().flex_1().child(rows) + }; + + v_flex() + .w(px(264.)) + .h_full() + .flex_shrink_0() + .bg(cx.theme().sidebar) + .text_color(cx.theme().sidebar_foreground) + .child( + h_flex() + .h_11() + .px_3() + .items_center() + .justify_between() + .child( + h_flex() + .gap_1p5() + .items_center() + .child(div().text_sm().font_semibold().child("连接")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(total.to_string()), + ), + ) + .child( + Button::new("sidebar-add") + .icon(IconName::Plus) + .tooltip("新建连接") + .ghost() + .xsmall() + .on_click(cx.listener(|this, _, window, cx| { + this.open_connection_form(None, window, cx); + })), + ), + ) + .child(v_flex().px_2().pb_2().child(Input::new(&search).small().prefix( + gpui_kit::component::Icon::new(IconName::Search).small(), + ))) + .child(self.render_group_chips(cx)) + .child(body) + } + + /// 搜索框下方的分组 chips:全部 / 未分组 / 各分组(带计数徽标),点击切换过滤。 + fn render_group_chips(&self, cx: &mut Context) -> impl IntoElement { + let mut names: Vec = self + .connections + .iter() + .filter_map(|c| c.group.as_deref().map(str::trim)) + .filter(|g| !g.is_empty()) + .map(str::to_string) + .collect(); + names.sort(); + names.dedup(); + + let ungrouped = self + .connections + .iter() + .filter(|c| c.group.as_deref().map(str::trim).unwrap_or("").is_empty()) + .count(); + + // 分组多时限制高度并纵向滚动,避免 chips 换行挤占下方连接列表 + let mut row = h_flex() + .gap_1() + .flex_wrap() + .px_2() + .pb_1() + .max_h(px(88.)) + .overflow_y_scrollbar(); + row = row.child(self.render_group_chip( + "chip-all", + format!("全部 {}", self.connections.len()), + GroupFilter::All, + cx, + )); + row = row.child(self.render_group_chip( + "chip-ungrouped", + format!("未分组 {ungrouped}"), + GroupFilter::Ungrouped, + cx, + )); + for name in names { + let count = self + .connections + .iter() + .filter(|c| c.group.as_deref().map(str::trim) == Some(name.as_str())) + .count(); + row = row.child(self.render_group_chip( + SharedString::from(format!("chip-g-{name}")), + format!("{name} {count}"), + GroupFilter::Named(name), + cx, + )); + } + row + } + + fn render_group_chip( + &self, + id: impl Into, + label: String, + filter: GroupFilter, + cx: &mut Context, + ) -> impl IntoElement { + let selected = match (&self.group_filter, &filter) { + (GroupFilter::All, GroupFilter::All) + | (GroupFilter::Ungrouped, GroupFilter::Ungrouped) => true, + (GroupFilter::Named(a), GroupFilter::Named(b)) => a == b, + _ => false, + }; + div() + .id(id) + .px_2() + .py_0p5() + .rounded_md() + .text_xs() + .when(selected, |d| d.bg(cx.theme().secondary).font_semibold()) + .when(!selected, |d| { + d.bg(cx.theme().muted) + .text_color(cx.theme().muted_foreground) + .hover(|d| d.bg(cx.theme().secondary)) + }) + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| { + // listener 是 Fn(可多次调用),只能克隆而不能移出捕获的 filter + this.group_filter = filter.clone(); + cx.notify(); + })) + .child(label) + } + + fn render_conn_power( + &self, + cfg: &ConnectionConfig, + status: ConnectionStatus, + cx: &mut Context, + ) -> Button { + let cfg = cfg.clone(); + let connected = matches!(status, ConnectionStatus::Connected); + Button::new(SharedString::from(format!("conn-power-{}", cfg.id))) + .icon(if connected { IconName::Square } else { IconName::PlugZap }) + .ghost() + .xsmall() + .tooltip(if connected { "断开" } else { "连接" }) + .on_click(cx.listener(move |this, _, _, _| { + this.toggle_connection(&cfg); + })) + } + + fn render_conn_menu(&self, conn_id: &str, cx: &mut Context) -> impl IntoElement { + let weak = cx.entity().downgrade(); + let eid = conn_id.to_string(); + Button::new(SharedString::from(format!("conn-menu-{}", conn_id))) + .icon(IconName::Ellipsis) + .ghost() + .xsmall() + .dropdown_menu(move |menu, _, _| { + let (e1, e2, e3) = (eid.clone(), eid.clone(), eid.clone()); + let (w1, w2, w3) = (weak.clone(), weak.clone(), weak.clone()); + menu.item(PopupMenuItem::new("编辑").on_click(move |_, window, cx| { + w1.update(cx, |app, cx| { + let cfg = app.connections.iter().find(|c| c.id == e1).cloned(); + if let Some(cfg) = cfg { + app.open_connection_form(Some(cfg), window, cx); + } + }) + .ok(); + })) + .item(PopupMenuItem::new("复制连接").on_click(move |_, _, cx| { + w2.update(cx, |app, cx| { + app.duplicate_connection(&e2); + cx.notify(); + }) + .ok(); + })) + .separator() + .item(PopupMenuItem::new("删除").on_click(move |_, window, cx| { + let Some(app) = w3.upgrade() else { + return; + }; + // 二次确认:删除会级联清理订阅并断开连接,不可撤销 + let (name, sub_count) = { + let a = app.read(cx); + let name = a + .connections + .iter() + .find(|c| c.id == e3) + .map(|c| c.name.clone()) + .unwrap_or_else(|| e3.clone()); + let n = a.subscriptions.iter().filter(|s| s.connection_id == e3).count(); + (name, n) + }; + let confirm_id = e3.clone(); + window.open_alert_dialog(cx, move |alert, _, _| { + // 构建闭包是 Fn(可重复调用),克隆后再移入按钮回调 + let app = app.clone(); + let confirm_id = confirm_id.clone(); + alert + .title("删除连接") + .description(SharedString::from(format!( + "确定删除「{name}」?将同时删除 {sub_count} 个订阅,并断开当前连接,此操作不可撤销。" + ))) + .button_props( + DialogButtonProps::default() + .ok_text("删除") + .ok_variant(ButtonVariant::Danger) + .show_cancel(true), + ) + .on_ok(move |_, _, cx| { + app.update(cx, |app, cx| app.delete_connection(&confirm_id, cx)); + true + }) + }); + })) + }) + } +} diff --git a/gpui-app/src/ui/app/theme.rs b/gpui-app/src/ui/app/theme.rs new file mode 100644 index 0000000..c4957b9 --- /dev/null +++ b/gpui-app/src/ui/app/theme.rs @@ -0,0 +1,47 @@ +//! 主题:加载 Linear 风格主题 JSON 并按偏好应用明暗模式。 + + +use gpui_kit::component::{ + Theme, ThemeMode, ThemeRegistry, +}; +use gpui_kit::App; + +use crate::model::ThemeModePref; + +/// 应用主题偏好。 +/// +/// 「跟随系统」读取 gpui 的窗口外观(`App::window_appearance`);系统深浅色变化由 +/// Linear 风格主题(内嵌 JSON,模式与 gpui-component 默认主题同构)。 +const LINEAR_THEME: &str = include_str!("linear-theme.json"); + +/// `MqttXApp::new` 里注册的 `Window::observe_window_appearance` 监听并重应用本函数。 +/// +/// 流程:先 `change` 确保 Theme 全局已建立(注册表 observer 依赖它)→ 注册 +/// Linear Light/Dark(重名跳过,幂等)→ 把当前明暗对指向 Linear → 再 `change` +/// 应用其颜色/字号/圆角。 +pub fn apply_theme(pref: ThemeModePref, cx: &mut App) { + let mode = match pref { + ThemeModePref::Light => ThemeMode::Light, + ThemeModePref::Dark => ThemeMode::Dark, + ThemeModePref::System => ThemeMode::from(cx.window_appearance()), + }; + Theme::change(mode, None, cx); + if let Err(e) = ThemeRegistry::global_mut(cx).load_themes_from_str(LINEAR_THEME) { + eprintln!("加载 Linear 主题失败,回退默认主题: {e}"); + } + { + let reg = ThemeRegistry::global(cx); + let light = reg.themes().get("Linear Light").cloned(); + let dark = reg.themes().get("Linear Dark").cloned(); + let theme = Theme::global_mut(cx); + if let Some(l) = light { + theme.light_theme = l; + } + if let Some(d) = dark { + theme.dark_theme = d; + } + } + Theme::change(mode, None, cx); +} + +// ─── 渲染 ──────────────────────────────────────────────────────────────────── diff --git a/gpui-app/src/ui/app/titlebar.rs b/gpui-app/src/ui/app/titlebar.rs new file mode 100644 index 0000000..2704ad7 --- /dev/null +++ b/gpui-app/src/ui/app/titlebar.rs @@ -0,0 +1,176 @@ +//! 标题栏:窗口控制、钉子置顶、主题切换与全局动作菜单。 + + +use gpui_kit::component::button::{ Button }; +use gpui_kit::component::menu::{ PopupMenuItem }; +use gpui_kit::component::{ h_flex, notification::Notification, ThemeMode, TitleBar, }; +use gpui_kit::{ div, px, Context, IntoElement, Window, }; + +use crate::model::ThemeModePref; +use crate::ui::IconName; + +use super::*; + +impl MqttXApp { +pub(super) fn render_titlebar(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let app_weak = cx.entity().downgrade(); + + TitleBar::new().child( + h_flex() + .w_full() + .h_full() + .items_center() + .justify_between() + .pl_2() + .child( + h_flex() + .gap_2() + .items_center() + .child(crate::ui::logo::logo( + px(26.), + cx.theme().primary, + cx.theme().primary_foreground, + )) + .child(div().text_sm().font_semibold().child("MQTTX")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("MQTT 调试客户端"), + ), + ) + .child( + h_flex() + .gap_1() + .pr_2() + .items_center() + // 整条标题栏被 gpui-component 标记为 WindowControlArea::Drag + // (WM_NCHITTEST 返回 HTCAPTION),点击会变成拖动窗口。 + // 按钮簇必须遮挡鼠标:hit_test 收集到 BlockMouse hitbox + // 即截断,父级 Drag 区不再命中,按钮才能收到点击。 + .occlude() + .child( + Button::new("new-connection") + .icon(IconName::Plus) + .label("新建连接") + .primary() + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.open_connection_form(None, window, cx); + })), + ) + .child( + Button::new("pin-window") + .icon(if self.pinned { + IconName::PinOff + } else { + IconName::Pin + }) + .ghost() + .small() + .tooltip(if self.pinned { + "取消窗口置顶" + } else { + "窗口置顶" + }) + .on_click(cx.listener(|this, _, window, cx| { + let next = !this.pinned; + if crate::platform::set_topmost(window, next) { + this.pinned = next; + cx.notify(); + } else { + window.push_notification( + Notification::error("置顶操作失败"), + cx, + ); + } + })), + ) + .child(self.render_theme_toggle(cx)) + .child( + Button::new("more-menu") + .icon(IconName::Ellipsis) + .ghost() + .small() + .dropdown_menu(move |menu, _, _| { + let w1 = app_weak.clone(); + let w2 = app_weak.clone(); + let w3 = app_weak.clone(); + let w4 = app_weak.clone(); + let w5 = app_weak.clone(); + let w6 = app_weak.clone(); + menu.item( + PopupMenuItem::new("阿里云设备") + .on_click(move |_, window, cx| { + if let Some(entity) = w1.upgrade() { + crate::ui::dialogs::aliyun_dialog::open(entity, window, cx); + } + }), + ) + .item(PopupMenuItem::new("全局变量").on_click( + move |_, window, cx| { + if let Some(entity) = w2.upgrade() { + crate::ui::dialogs::variables_dialog::open(entity, window, cx); + } + }, + )) + .item(PopupMenuItem::new("资源监控").on_click( + move |_, window, cx| { + if let Some(entity) = w6.upgrade() { + crate::ui::dialogs::resource_dialog::open(entity, window, cx); + } + }, + )) + .separator() + .item(PopupMenuItem::new("导出连接").on_click( + move |_, window, cx| { + if let Some(entity) = w4.upgrade() { + entity.update(cx, |app, cx| { + app.export_connections(window, cx) + }); + } + }, + )) + .item(PopupMenuItem::new("导入连接").on_click( + move |_, window, cx| { + if let Some(entity) = w5.upgrade() { + entity.update(cx, |app, cx| { + app.import_connections(window, cx) + }); + } + }, + )) + .separator() + .item(PopupMenuItem::new("设置").on_click( + move |_, window, cx| { + if let Some(entity) = w3.upgrade() { + crate::ui::dialogs::settings_dialog::open(entity, window, cx); + } + }, + )) + }), + ), + ), + ) + } + + fn render_theme_toggle(&self, cx: &mut Context) -> Button { + let dark = cx.theme().mode == ThemeMode::Dark; + Button::new("toggle-theme") + .icon(if dark { IconName::Sun } else { IconName::Moon }) + .ghost() + .small() + .tooltip(if dark { "切换为浅色" } else { "切换为深色" }) + .on_click(cx.listener(|this, _, _, cx| { + let next = if cx.theme().mode == ThemeMode::Dark { + ThemeModePref::Light + } else { + ThemeModePref::Dark + }; + let mut s = this.settings.clone(); + s.theme = next; + this.save_settings(s, cx); + cx.notify(); + })) + } +} diff --git a/gpui-app/src/ui/connection_view.rs b/gpui-app/src/ui/connection_view.rs deleted file mode 100644 index 84c4597..0000000 --- a/gpui-app/src/ui/connection_view.rs +++ /dev/null @@ -1,2716 +0,0 @@ -//! 单个连接的工作区:订阅列表 + 消息流 + 发布面板 + 日志。 - -use std::sync::Arc; - -use gpui_kit::component::button::{Button, ButtonGroup, ButtonVariant, ButtonVariants as _}; -use gpui_kit::component::dialog::DialogButtonProps; -use gpui_kit::component::scroll::ScrollableElement as _; -use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; -use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem}; -use gpui_kit::component::select::{Select, SelectState}; -use gpui_kit::component::switch::Switch; -use gpui_kit::component::tab::{Tab, TabBar}; -use gpui_kit::component::{ - h_flex, notification::Notification, v_flex, ActiveTheme as _, Disableable as _, - Selectable as _, Sizable as _, StyledExt as _, WindowExt as _, -}; -use gpui_kit::prelude::FluentBuilder as _; -use crate::ui::IconName; -use gpui_kit::{ - div, hsla, px, App, AppContext as _, ClipboardItem, Context, Entity, Hsla, - InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, - StatefulInteractiveElement as _, Styled as _, Subscription as InputSubscription, Window, -}; -use gpui_kit::component::IndexPath; - -use crate::model::{ - render_template, render_will_templates, ConnectionConfig, ConnectionStatus, Direction, - GlobalVariable, LogLevel, MqttRecord, PayloadFormat, PublishParams, SubscribeOptions, - Subscription, MAX_PAYLOAD_RETAIN, -}; -use crate::mqtt::MqttEngine; -use crate::ui::app::MqttXApp; -use crate::ui::widgets::{field, format_time, make_select, KvEditor, OptionDelegate}; - -const QOS: [&str; 3] = ["QoS 0", "QoS 1", "QoS 2"]; -const PAYLOAD_FORMATS: [&str; 4] = ["Plaintext", "JSON", "Base64", "Hex"]; -/// Retain Handling 0/1/2 的下拉文案 -const RETAIN_HANDLING: [&str; 3] = ["0 每次发送", "1 仅新订阅", "2 不发送"]; -const MAX_RENDERED_MESSAGES: usize = 300; -/// 日志渲染上限,与 app.rs 的 MAX_LOGS 保持一致 -const MAX_RENDERED_LOGS: usize = 3000; -/// 订阅色板预设:色相(度)+ 名称 -const PRESET_HUES: [(f32, &str); 10] = [ - (0., "红"), - (30., "橙"), - (60., "黄"), - (120., "绿"), - (160., "青"), - (200., "蓝"), - (240., "靛"), - (270., "紫"), - (300., "品红"), - (330., "玫红"), -]; - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Panel { - Messages, - Logs, -} - -/// 消息流方向过滤(与搜索、订阅过滤叠加生效) -#[derive(Clone, Copy, PartialEq, Eq)] -enum DirFilter { - All, - Received, - Published, -} - -impl DirFilter { - fn label(self) -> &'static str { - match self { - Self::All => "全部", - Self::Received => "接收", - Self::Published => "发布", - } - } - - fn index(self) -> usize { - match self { - Self::All => 0, - Self::Received => 1, - Self::Published => 2, - } - } - - fn from_index(i: usize) -> Self { - match i { - 1 => Self::Received, - 2 => Self::Published, - _ => Self::All, - } - } -} - -/// 展开消息详情里的 payload 展示格式。任一时刻只展开一条消息, -/// 视图级单值即可视作「逐消息」选择。 -#[derive(Clone, Copy, PartialEq, Eq)] -enum DetailFormat { - Auto, - Text, - Hex, - Base64, -} - -impl DetailFormat { - fn label(self) -> &'static str { - match self { - Self::Auto => "自动", - Self::Text => "文本", - Self::Hex => "Hex", - Self::Base64 => "Base64", - } - } - - fn index(self) -> usize { - match self { - Self::Auto => 0, - Self::Text => 1, - Self::Hex => 2, - Self::Base64 => 3, - } - } - - fn from_index(i: usize) -> Self { - match i { - 1 => Self::Text, - 2 => Self::Hex, - 3 => Self::Base64, - _ => Self::Auto, - } - } -} - -pub struct ConnectionView { - conn_id: String, - app: gpui_kit::WeakEntity, - engine: Arc, - - // ── 订阅 ── - sub_topic: Entity, - sub_qos: Entity>, - sub_alias: Entity, - // v5 高级订阅选项 - sub_identifier: Entity, - sub_no_local: bool, - sub_rap: bool, - sub_retain_handling: Entity>, - sub_show_advanced: bool, - /// 编辑中的旧主题:提交时用旧主题移除原订阅,避免改名过程丢数据 - editing: Option, - /// 点击订阅项激活的消息过滤主题(再点一次取消) - sub_filter: Option, - - // 发布 - pub_topic: Entity, - payload: Entity, - pub_qos: Entity>, - payload_format: Entity>, - retain: bool, - content_type: Entity, - user_props: Entity, - msg_expiry: Entity, - response_topic: Entity, - correlation_data: Entity, - show_props: bool, - - // 过滤与面板 - filter: Entity, - panel: Panel, - expanded: Option, - /// 当前 detail_format 归属的消息:切换到另一条消息时据此重置格式, - /// 同一条内折叠/展开则保留用户选择 - detail_owner: Option, - msg_dir: DirFilter, - detail_format: DetailFormat, - - // 预设 - preset_name: Entity, - - // 发布面板内联变量绑定 - /// 「变量」小节展开状态(仅在提取到占位符时渲染整节) - show_vars: bool, - /// 提取到的 `{{key}}` → 值输入框与变更订阅,按提取顺序排列 - var_rows: Vec<(String, Entity, InputSubscription)>, -} - -impl ConnectionView { - pub fn new( - conn_id: String, - app: gpui_kit::WeakEntity, - engine: std::sync::Arc, - window: &mut Window, - cx: &mut Context, - ) -> Self { - - let sub_topic = cx.new(|cx| { - InputState::new(window, cx).placeholder("订阅主题,支持 # +,多个用逗号/空格分隔") - }); - let sub_qos = make_select(&QOS, 0, window, cx); - let sub_alias = cx.new(|cx| InputState::new(window, cx).placeholder("别名(可选)")); - let sub_identifier = cx.new(|cx| InputState::new(window, cx).placeholder("订阅标识符")); - let sub_retain_handling = make_select(&RETAIN_HANDLING, 0, window, cx); - let pub_topic = cx.new(|cx| InputState::new(window, cx).placeholder("发布主题")); - let payload = cx.new(|cx| { - TextareaState::new(window, cx) - .placeholder("输入消息负载,支持 {{变量名}} 与 {{$ts}} {{$uuid}}") - }); - let pub_qos = make_select(&QOS, 0, window, cx); - let payload_format = make_select(&PAYLOAD_FORMATS, 0, window, cx); - let content_type = cx.new(|cx| InputState::new(window, cx).placeholder("Content-Type(可选)")); - let msg_expiry = cx.new(|cx| InputState::new(window, cx).placeholder("秒,如 60")); - let response_topic = - cx.new(|cx| InputState::new(window, cx).placeholder("Response Topic(可选)")); - let correlation_data = - cx.new(|cx| InputState::new(window, cx).placeholder("Correlation Data(可选)")); - let filter = cx.new(|cx| InputState::new(window, cx).placeholder("过滤主题或内容…")); - let user_props = cx.new(|cx| KvEditor::new("pub", &[], window, cx)); - let preset_name = - cx.new(|cx| InputState::new(window, cx).placeholder("预设名称,如 温度上报")); - - Self { - conn_id, - app, - engine, - sub_topic, - sub_qos, - sub_alias, - sub_identifier, - sub_no_local: false, - sub_rap: false, - sub_retain_handling, - sub_show_advanced: false, - editing: None, - sub_filter: None, - pub_topic, - payload, - pub_qos, - payload_format, - retain: false, - content_type, - user_props, - msg_expiry, - response_topic, - correlation_data, - show_props: false, - filter, - panel: Panel::Messages, - expanded: None, - detail_owner: None, - msg_dir: DirFilter::All, - detail_format: DetailFormat::Auto, - preset_name, - show_vars: true, - var_rows: Vec::new(), - } - } - - fn with_app(&self, cx: &App, f: impl FnOnce(&MqttXApp) -> R) -> Option { - self.app.upgrade().map(|a| f(a.read(cx))) - } - - fn config(&self, cx: &App) -> Option { - let id = self.conn_id.clone(); - self.with_app(cx, |app| { - app.connections.iter().find(|c| c.id == id).cloned() - }) - .flatten() - } - - fn is_v5(&self, cx: &App) -> bool { - self.config(cx).map(|c| c.protocol.is_v5()).unwrap_or(true) - } - - // ── 订阅数据操作 ──────────────────────────────────────────────────────── - - /// 订阅按 (连接, 主题) 唯一:先摘掉旧条目再走 `add_subscription`, - /// 借它入列 + `save_subscriptions` 落盘的逻辑完成 upsert(storage 字段是私有的)。 - /// 落盘顺序以 add 时为准,入列后移回原位以保持界面顺序稳定。 - fn upsert_subscription(&self, sub: Subscription, cx: &mut Context) { - let Some(app_entity) = self.app.upgrade() else { - return; - }; - app_entity.update(cx, |app, cx| { - let slot = app - .subscriptions - .iter() - .position(|s| s.connection_id == sub.connection_id && s.topic == sub.topic); - app.subscriptions.retain(|s| { - !(s.connection_id == sub.connection_id && s.topic == sub.topic) - }); - let new_id = sub.id.clone(); - app.add_subscription(sub); - if let Some(pos) = slot - && let Some(from) = app.subscriptions.iter().position(|s| s.id == new_id) - { - let moved = app.subscriptions.remove(from); - app.subscriptions.insert(pos.min(app.subscriptions.len()), moved); - } - cx.notify(); - }); - cx.notify(); - } - - fn set_subscription_color(&mut self, sub_id: &str, hue: Option, cx: &mut Context) { - let sub = self - .with_app(cx, |app| { - app.subscriptions - .iter() - .find(|s| s.id == sub_id) - .cloned() - }) - .flatten(); - if let Some(mut sub) = sub { - sub.color = hue; - self.upsert_subscription(sub, cx); - } - } - - /// 启停订阅:置灰/恢复行显示,并在已连接时对引擎退订/重订。 - fn toggle_subscription_enabled(&mut self, sub_id: &str, cx: &mut Context) { - let Some(mut sub) = self - .with_app(cx, |app| { - app.subscriptions - .iter() - .find(|s| s.id == sub_id) - .cloned() - }) - .flatten() - else { - return; - }; - sub.enabled = !sub.enabled; - let enabled = sub.enabled; - let topic = sub.topic.clone(); - // 停用的订阅不再参与消息过滤,指向它的过滤一并清掉 - if !enabled && self.sub_filter.as_deref() == Some(topic.as_str()) { - self.sub_filter = None; - } - let conn = self.conn_id.clone(); - self.upsert_subscription(sub.clone(), cx); - if self.engine.is_connected(&conn) { - if enabled { - self.engine - .subscribe_with_options(conn, sub.clone(), SubscribeOptions::from(&sub)); - } else { - self.engine.unsubscribe(conn, topic); - } - } - } - - /// 回填输入区进入编辑态;旧订阅保留到提交时才替换,中途放弃不丢数据。 - fn start_edit_subscription(&mut self, sub: &Subscription, window: &mut Window, cx: &mut Context) { - self.editing = Some(sub.topic.clone()); - self.sub_topic - .update(cx, |s, cx| s.set_value(sub.topic.clone(), window, cx)); - let qos_idx = (sub.qos.min(2)) as usize; - self.sub_qos.update(cx, |s, cx| { - s.set_selected_index(Some(IndexPath::new(qos_idx)), window, cx) - }); - let alias = sub.alias.clone().unwrap_or_default(); - self.sub_alias - .update(cx, |s, cx| s.set_value(alias, window, cx)); - let ident = sub - .sub_identifier - .map(|v| v.to_string()) - .unwrap_or_default(); - self.sub_identifier - .update(cx, |s, cx| s.set_value(ident, window, cx)); - self.sub_no_local = sub.no_local; - self.sub_rap = sub.retain_as_published; - let rh_idx = sub.retain_handling.min(2) as usize; - self.sub_retain_handling.update(cx, |s, cx| { - s.set_selected_index(Some(IndexPath::new(rh_idx)), window, cx) - }); - self.sub_show_advanced = true; - cx.notify(); - } - - /// 重置订阅表单(主题/别名/高级字段):新增提交与取消编辑共用, - /// 避免高级选项残留到下一次订阅。 - fn reset_subscribe_form(&mut self, window: &mut Window, cx: &mut Context) { - self.sub_topic - .update(cx, |s, cx| s.set_value("", window, cx)); - self.sub_alias - .update(cx, |s, cx| s.set_value("", window, cx)); - self.sub_identifier - .update(cx, |s, cx| s.set_value("", window, cx)); - self.sub_no_local = false; - self.sub_rap = false; - self.sub_retain_handling.update(cx, |s, cx| { - s.set_selected_index(Some(IndexPath::new(0)), window, cx) - }); - } - - // ── 订阅 / 发布动作 ───────────────────────────────────────────────────── - - fn do_subscribe(&mut self, window: &mut Window, cx: &mut Context) { - let raw = self.sub_topic.read(cx).value().to_string(); - let topics = split_topic_list(&raw); - if topics.is_empty() { - window.push_notification(Notification::warning("订阅主题不能为空"), cx); - return; - } - let qos = self.sub_qos.read(cx).selected_value().copied().unwrap_or(0) as u8; - let alias_raw = self.sub_alias.read(cx).value().to_string(); - let alias = { - let t = alias_raw.trim(); - (!t.is_empty()).then(|| t.to_string()) - }; - let is_v5 = self.is_v5(cx); - // v5 选项只对 v5 连接生效,v4 下不读取(避免无效输入阻断订阅) - let mut sub_identifier = None; - if is_v5 { - let ident = self.sub_identifier.read(cx).value().to_string(); - let t = ident.trim(); - if !t.is_empty() { - match t.parse::() { - Ok(v) if v > 0 => sub_identifier = Some(v), - _ => { - window.push_notification( - Notification::warning("订阅标识符须为正整数"), - cx, - ); - return; - } - } - } - } - let no_local = self.sub_no_local; - let rap = self.sub_rap; - let retain_handling = self - .sub_retain_handling - .read(cx) - .selected_value() - .copied() - .unwrap_or(0) as u8; - - let connected = self.engine.is_connected(&self.conn_id); - let conn = self.conn_id.clone(); - - // 记录编辑来源:编辑态提交后以目标订阅回填,保住编辑上下文 - let was_editing = self.editing.clone(); - // 改名编辑时旧记录随后会被删除,先取出供新主题承接 id/color/enabled - let editing_old = was_editing.as_ref().and_then(|old_topic| { - self.with_app(cx, |app| { - app.subscriptions - .iter() - .find(|s| s.connection_id == conn && s.topic == *old_topic) - .cloned() - }) - .flatten() - }); - - // 编辑提交:旧主题不在新列表里时先移除(含引擎退订),新主题由下方 upsert 覆盖 - if let Some(old_topic) = self.editing.take() - && !topics.contains(&old_topic) - { - if self.sub_filter.as_deref() == Some(old_topic.as_str()) { - self.sub_filter = None; - } - if let Some(app_entity) = self.app.upgrade() { - app_entity.update(cx, |app, cx| { - app.remove_subscription(&conn, &old_topic); - cx.notify(); - }); - } - } - - for topic in &topics { - // 按 (连接, 主题) 查旧记录:继承 id/color/enabled,避免重复提交把备注色清掉、 - // 把停用中的订阅静默重启 - let existing = self - .with_app(cx, |app| { - app.subscriptions - .iter() - .find(|s| s.connection_id == conn && s.topic == *topic) - .cloned() - }) - .flatten(); - let sub = match existing { - Some(mut old) => { - old.qos = qos; - old.alias = alias.clone(); - if is_v5 { - old.sub_identifier = sub_identifier; - old.no_local = no_local; - old.retain_as_published = rap; - old.retain_handling = retain_handling; - } - old - } - None => { - // 改名编辑的单主题提交:新主题承接旧记录身份与备注,避免改名丢数据 - if topics.len() == 1 && let Some(mut old) = editing_old.clone() { - old.topic = topic.clone(); - old.qos = qos; - old.alias = alias.clone(); - if is_v5 { - old.sub_identifier = sub_identifier; - old.no_local = no_local; - old.retain_as_published = rap; - old.retain_handling = retain_handling; - } - old - } else { - let mut sub = Subscription::new(conn.clone(), topic.clone(), qos); - sub.alias = alias.clone(); - if is_v5 { - sub.sub_identifier = sub_identifier; - sub.no_local = no_local; - sub.retain_as_published = rap; - sub.retain_handling = retain_handling; - } - sub - } - } - }; - // 继承到的停用订阅保持退订状态,不因重新提交被拉起 - let keep_disabled = !sub.enabled; - self.upsert_subscription(sub.clone(), cx); - if connected && !keep_disabled { - self.engine.subscribe_with_options( - conn.clone(), - sub.clone(), - SubscribeOptions::from(&sub), - ); - } - } - if !connected { - let hint = if self - .config(cx) - .map(|c| c.auto_resubscribe) - .unwrap_or(true) - { - "未连接:订阅已保存,连接后自动恢复" - } else { - "未连接:订阅已保存" - }; - window.push_notification(Notification::warning(hint), cx); - } - - // 编辑态:以刚保存的目标订阅回填表单继续保留上下文; - // 新增态:清空主题/别名并重置高级字段,避免残留到下一次订阅 - if was_editing.is_some() && topics.len() == 1 { - let target = topics[0].clone(); - let saved = self - .with_app(cx, |app| { - app.subscriptions - .iter() - .find(|s| s.connection_id == conn && s.topic == target) - .cloned() - }) - .flatten(); - if let Some(saved) = saved { - self.start_edit_subscription(&saved, window, cx); - } else { - self.editing = None; - self.reset_subscribe_form(window, cx); - } - } else { - self.editing = None; - self.reset_subscribe_form(window, cx); - } - cx.notify(); - } - - fn do_publish(&mut self, window: &mut Window, cx: &mut Context) { - // 未连接时消息发不出去,先拦截给出与订阅路径一致的提示 - if !self.engine.is_connected(&self.conn_id) { - window.push_notification(Notification::warning("未连接:消息未发送"), cx); - return; - } - let mut params = self.collect_publish_params(cx); - if params.topic.trim().is_empty() { - window.push_notification(Notification::warning("发布主题不能为空"), cx); - return; - } - // 过期秒数输入非法时 collect 会静默得到 None,这里显式拦截避免误发 - let expiry_raw = self.msg_expiry.read(cx).value().to_string(); - if !expiry_raw.trim().is_empty() && params.message_expiry_interval.is_none() { - window.push_notification(Notification::warning("消息过期须为非负整数(秒)"), cx); - return; - } - - let vars = self.with_app(cx, |app| app.variables.clone()).unwrap_or_default(); - params.topic = render_template(¶ms.topic, &vars); - params.payload = render_template(¶ms.payload, &vars); - // {{topic}} 等变量渲染后可能变空串,渲染完成后再校验一次 - if params.topic.trim().is_empty() { - window.push_notification(Notification::warning("发布主题不能为空"), cx); - return; - } - // {{变量}} 同样作用于用户属性的 key/value - params.user_properties = params - .user_properties - .into_iter() - .map(|(k, v)| (render_template(&k, &vars), render_template(&v, &vars))) - .collect(); - // v5 文本属性(Content-Type / Response Topic / Correlation Data)一并注入 - params.content_type = params.content_type.map(|v| render_template(&v, &vars)); - params.response_topic = params.response_topic.map(|v| render_template(&v, &vars)); - params.correlation_data = params.correlation_data.map(|v| render_template(&v, &vars)); - - if let PayloadFormat::Json = params.payload_format - && let Err(e) = serde_json::from_str::(¶ms.payload) { - window.push_notification( - Notification::warning(format!("JSON 格式无效: {e}")), - cx, - ); - return; - } - match params.payload_format.encode(¶ms.payload) { - Ok(bytes) => params.raw_bytes = Some(bytes), - Err(e) => { - window.push_notification(Notification::warning(e), cx); - return; - } - } - - // v3.1.1 忽略 v5 属性,避免残留输入带进发布报文 - if !self.is_v5(cx) { - params.user_properties.clear(); - params.content_type = None; - params.message_expiry_interval = None; - params.response_topic = None; - params.correlation_data = None; - } - self.engine.publish(self.conn_id.clone(), params); - } - - /// 读取当前发布面板上的参数(模板渲染前的原始值);保存预设与发布共用, - /// 须覆盖全部 v5 属性与用户属性。 - fn collect_publish_params(&self, cx: &App) -> PublishParams { - let qos = self.pub_qos.read(cx).selected_value().copied().unwrap_or(0) as u8; - let format = PayloadFormat::ALL - [self.payload_format.read(cx).selected_value().copied().unwrap_or(0)]; - let opt = |s: String| { - let t = s.trim(); - (!t.is_empty()).then(|| t.to_string()) - }; - PublishParams { - topic: self.pub_topic.read(cx).value().to_string(), - payload: self.payload.read(cx).value().to_string(), - payload_format: format, - qos, - retain: self.retain, - user_properties: self.user_props.read(cx).pairs(cx), - content_type: opt(self.content_type.read(cx).value().to_string()), - message_expiry_interval: { - let t = self.msg_expiry.read(cx).value().to_string(); - let t = t.trim(); - if t.is_empty() { None } else { t.parse::().ok() } - }, - response_topic: opt(self.response_topic.read(cx).value().to_string()), - correlation_data: opt(self.correlation_data.read(cx).value().to_string()), - ..Default::default() - } - } - - /// 把预设应用到发布面板控件。 - fn apply_publish_preset( - &mut self, - params: &PublishParams, - window: &mut Window, - cx: &mut Context, - ) { - self.pub_topic - .update(cx, |s, cx| s.set_value(params.topic.clone(), window, cx)); - self.payload - .update(cx, |s, cx| s.set_value(params.payload.clone(), window, cx)); - // 预设未指定 Content-Type 时清空旧值,避免上一次输入残留 - let ct = params.content_type.clone().unwrap_or_default(); - self.content_type - .update(cx, |s, cx| s.set_value(ct, window, cx)); - let expiry = params - .message_expiry_interval - .map(|v| v.to_string()) - .unwrap_or_default(); - self.msg_expiry - .update(cx, |s, cx| s.set_value(expiry, window, cx)); - let rt = params.response_topic.clone().unwrap_or_default(); - self.response_topic - .update(cx, |s, cx| s.set_value(rt, window, cx)); - let cd = params.correlation_data.clone().unwrap_or_default(); - self.correlation_data - .update(cx, |s, cx| s.set_value(cd, window, cx)); - // KvEditor 不支持运行时整体替换,直接重建实体回填用户属性 - self.user_props = cx.new(|cx| KvEditor::new("pub", ¶ms.user_properties, window, cx)); - let qos_idx = params.qos.min(2) as usize; - self.pub_qos.update(cx, |s, cx| { - s.set_selected_index(Some(IndexPath::new(qos_idx)), window, cx) - }); - let fmt_idx = PayloadFormat::ALL - .iter() - .position(|f| *f == params.payload_format) - .unwrap_or(0); - self.payload_format.update(cx, |s, cx| { - s.set_selected_index(Some(IndexPath::new(fmt_idx)), window, cx) - }); - self.retain = params.retain; - // 同步模板变量面板:预设替换了模板,旧占位符行必须随之清理, - // 否则残留行仍会写回全局变量 - self.sync_var_rows(window, cx); - cx.notify(); - } -} - -// ── 渲染 ────────────────────────────────────────────────────────────────────── - -impl ConnectionView { - fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement { - let id = self.conn_id.clone(); - let (name, status, is_v5, error) = self - .with_app(cx, |app| { - let cfg = app.connections.iter().find(|c| c.id == id); - ( - cfg.map(|c| c.name.clone()).unwrap_or_else(|| id.clone()), - app.statuses.get(&id).copied().unwrap_or(ConnectionStatus::Disconnected), - cfg.map(|c| c.protocol.is_v5()).unwrap_or(true), - app.errors.get(&id).cloned(), - ) - }) - .unwrap_or((id.clone(), ConnectionStatus::Disconnected, true, None)); - let connected = matches!(status, ConnectionStatus::Connected); - let connecting = matches!(status, ConnectionStatus::Connecting); - let _ = is_v5; - - let status_text = status.label(); - let status_tone = match status { - ConnectionStatus::Connected => cx.theme().success, - ConnectionStatus::Connecting => cx.theme().warning, - ConnectionStatus::Error => cx.theme().danger, - ConnectionStatus::Disconnected => cx.theme().muted_foreground, - }; - // Error 状态把失败原因展示出来,避免只有「错误」二字无从排查 - let error_hint = matches!(status, ConnectionStatus::Error) - .then(|| error.clone()) - .flatten(); - - h_flex() - .w_full() - .items_center() - .gap_2() - .px_3() - .h_12() - .border_b_1() - .border_color(cx.theme().border) - .child(div().size(px(8.)).rounded_full().bg(status_tone)) - .child(div().text_sm().font_semibold().child(name)) - .child( - h_flex() - .items_center() - .gap_1() - .px_1p5() - .py_0p5() - .rounded_md() - .bg(status_tone.alpha(0.12)) - .child(div().size(px(5.)).rounded_full().bg(status_tone)) - .child( - div() - .text_xs() - .text_color(status_tone) - .child(status_text), - ), - ) - // Error 副文本展示失败原因(截断避免挤掉按钮) - .when_some(error_hint.clone(), |h, e| { - h.child( - div() - .flex_1() - .min_w(px(0.)) - .text_xs() - .text_color(cx.theme().danger) - .overflow_hidden() - .text_ellipsis() - .child(e), - ) - }) - .when(error_hint.is_none(), |h| h.child(div().flex_1())) - .child( - Button::new(SharedString::from(format!("conn-toggle-{}", self.conn_id))) - .icon(if connected { - IconName::Square - } else { - IconName::PlugZap - }) - .label(if connected { - "断开" - } else if connecting { - "连接中…" - } else { - "连接" - }) - .when(connected, |b| b.danger().ghost()) - .when(!connected, |b| b.primary().ghost()) - // Connecting 期间禁用,避免重复点击重启连接流程 - .disabled(connecting) - .small() - .when_some(error_hint.clone(), |b, e| b.tooltip(e)) - .on_click(cx.listener(move |this, _, _window, cx| { - // Connecting 期间(按钮禁用之外再兜一层)不响应,防止重复 connect 重启流程 - let connecting = this - .with_app(cx, |app| { - app.statuses.get(&this.conn_id) - == Some(&ConnectionStatus::Connecting) - }) - .unwrap_or(false); - if connecting { - return; - } - let cfg = this.config(cx); - if let Some(mut cfg) = cfg { - if this.engine.is_connected(&cfg.id) { - this.engine.close(&cfg.id, true); - } else { - // 遗嘱 {{变量}} 在每次连接时按当前全局变量求值 - let vars = this - .with_app(cx, |app| app.variables.clone()) - .unwrap_or_default(); - render_will_templates(&mut cfg, &vars); - this.engine.connect(cfg); - } - } - })), - ) - } - - fn render_subscribe_bar(&mut self, cx: &mut Context) -> impl IntoElement { - let sub_topic = self.sub_topic.clone(); - let sub_qos = self.sub_qos.clone(); - let sub_alias = self.sub_alias.clone(); - let is_v5 = self.is_v5(cx); - let editing_topic = self.editing.clone(); - let editing = editing_topic.is_some(); - let show_advanced = self.sub_show_advanced && is_v5; - - let mut bar = v_flex() - .border_b_1() - .border_color(cx.theme().border) - .bg(cx.theme().secondary); - bar = bar.child( - h_flex() - .gap_2() - .px_3() - .h_12() - .items_center() - // 编辑态显式提示目标主题,避免被误当成新增提交 - .when_some(editing_topic.clone(), |h, topic| { - h.child( - div() - .px_1p5() - .py_0p5() - .rounded_md() - .bg(cx.theme().warning.alpha(0.15)) - .text_xs() - .text_color(cx.theme().warning) - .child(format!("正在编辑:{topic}")), - ) - }) - .child(div().flex_1().min_w(px(0.)).child(Input::new(&sub_topic).small())) - .child(div().w(px(96.)).child(Select::new(&sub_qos).small())) - .child(div().w(px(112.)).child(Input::new(&sub_alias).small())) - .when(is_v5, |h| { - h.child( - Button::new(SharedString::from(format!("sub-adv-{}", self.conn_id))) - .icon(IconName::SlidersHorizontal) - .label("高级") - .ghost() - .small() - .when(show_advanced, |b| b.selected(true)) - .tooltip("MQTT 5 订阅选项") - .on_click(cx.listener(|this, _, _, cx| { - this.sub_show_advanced = !this.sub_show_advanced; - cx.notify(); - })), - ) - }) - .child( - Button::new(SharedString::from(format!("subscribe-{}", self.conn_id))) - .icon(if editing { IconName::Check } else { IconName::Plus }) - .label(if editing { "更新" } else { "订阅" }) - // 编辑用 warning 色与新增的 primary 区分 - .when(editing, |b| b.warning()) - .when(!editing, |b| b.primary()) - .small() - .on_click(cx.listener(|this, _, window, cx| this.do_subscribe(window, cx))), - ) - .when(editing, |h| { - h.child( - Button::new(SharedString::from(format!("sub-edit-cancel-{}", self.conn_id))) - .icon(IconName::Close) - .ghost() - .small() - .tooltip("取消编辑") - .on_click(cx.listener(|this, _, window, cx| { - this.editing = None; - // 清空回填到输入框的主题/别名/高级字段,回到新增态 - this.reset_subscribe_form(window, cx); - cx.notify(); - })), - ) - }), - ); - - if show_advanced { - let sub_identifier = self.sub_identifier.clone(); - let sub_no_local = self.sub_no_local; - let sub_rap = self.sub_rap; - let sub_retain_handling = self.sub_retain_handling.clone(); - bar = bar.child( - h_flex() - .gap_4() - .px_3() - .pb_2() - .items_start() - .child( - div() - .w(px(150.)) - .child(field("订阅标识符", Input::new(&sub_identifier).small())), - ) - .child( - h_flex() - .gap_1() - .items_center() - .pt_4() - .child(Switch::new("sub-no-local").checked(sub_no_local).on_change( - cx.listener(|this, v, _, cx| { - this.sub_no_local = *v; - cx.notify(); - }), - )) - .child(div().text_xs().child("No Local")), - ) - .child( - h_flex() - .gap_1() - .items_center() - .pt_4() - .child(Switch::new("sub-rap").checked(sub_rap).on_change( - cx.listener(|this, v, _, cx| { - this.sub_rap = *v; - cx.notify(); - }), - )) - .child(div().text_xs().child("Retain As Published")), - ) - .child( - div().w(px(150.)).child(field( - "Retain Handling", - Select::new(&sub_retain_handling).small(), - )), - ), - ); - } - bar - } - - fn render_subscriptions(&self, cx: &mut Context) -> impl IntoElement { - let hover_bg = cx.theme().muted; - let muted_fg = cx.theme().muted_foreground; - let conn = self.conn_id.clone(); - let subs: Vec = self - .with_app(cx, |app| { - app.subscriptions - .iter() - .filter(|s| s.connection_id == conn) - .cloned() - .collect() - }) - .unwrap_or_default(); - let active_filter = self.sub_filter.clone(); - - let mut list = v_flex().gap_0p5(); - if subs.is_empty() { - list = list.child( - v_flex() - .p_3() - .gap_2() - .items_center() - .child( - gpui_kit::component::Icon::new(IconName::Hash) - .text_color(cx.theme().muted_foreground), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .text_center() - .child("订阅主题后,消息会显示在右侧"), - ), - ); - } - for sub in subs { - let enabled = sub.enabled; - let is_active = enabled && active_filter.as_deref() == Some(sub.topic.as_str()); - let topic = sub.topic.clone(); - let sub_id = sub.id.clone(); - let alias = sub.alias.clone().filter(|a| !a.trim().is_empty()); - let primary = alias.clone().unwrap_or_else(|| topic.clone()); - - // 颜色按钮:已设色显示色点,未设显示灰色「+」 - let color_btn = { - let weak_menu = cx.weak_entity(); - let sid = sub_id.clone(); - let trigger_id = SharedString::from(format!("sub-color-{}", sub_id)); - let trigger = if let Some(hue) = sub.color { - Button::new(trigger_id) - .ghost() - .xsmall() - .tooltip("订阅颜色") - .child( - div() - .size(px(11.)) - .rounded_full() - .bg(hue_color(hue)) - .border_1() - .border_color(cx.theme().border), - ) - } else { - Button::new(trigger_id) - .ghost() - .xsmall() - .tooltip("订阅颜色") - .child( - div() - .size(px(11.)) - .flex() - .items_center() - .justify_center() - .rounded_full() - .bg(cx.theme().muted) - .text_color(muted_fg) - .text_xs() - .child("+"), - ) - }; - trigger.dropdown_menu(move |mut menu, _, _| { - for (hue, name) in PRESET_HUES { - let weak = weak_menu.clone(); - let sid = sid.clone(); - let swatch = hue_color(hue); - // 菜单只显示颜色名,角度数值对用户没有意义 - let label = SharedString::from(name); - menu = menu.item( - PopupMenuItem::element(move |_, _| { - h_flex() - .gap_2() - .items_center() - .child( - div() - .size(px(10.)) - .rounded_full() - .bg(swatch) - .border_1() - .border_color(hsla(0., 0., 1., 0.6)), - ) - .child(div().text_sm().child(label.clone())) - }) - .on_click(move |_, _, cx| { - weak - .update(cx, |view, cx| { - view.set_subscription_color(&sid, Some(hue), cx); - }) - .ok(); - }), - ); - } - menu = menu.separator(); - let weak_rand = weak_menu.clone(); - let sid_rand = sid.clone(); - menu = menu.item(PopupMenuItem::new("随机颜色").on_click(move |_, _, cx| { - let hue = (uuid::Uuid::new_v4().as_u128() % 360) as f32; - weak_rand - .update(cx, |view, cx| { - view.set_subscription_color(&sid_rand, Some(hue), cx); - }) - .ok(); - })); - let weak_clear = weak_menu.clone(); - let sid_clear = sid.clone(); - menu = menu.item(PopupMenuItem::new("清除颜色").on_click(move |_, _, cx| { - weak_clear - .update(cx, |view, cx| { - view.set_subscription_color(&sid_clear, None, cx); - }) - .ok(); - })); - menu - }) - }; - - let eye_sid = sub_id.clone(); - let eye_btn = Button::new(SharedString::from(format!("sub-eye-{}", sub_id))) - .icon(if enabled { IconName::Eye } else { IconName::EyeOff }) - .ghost() - .xsmall() - .tooltip(if enabled { "停用订阅" } else { "启用订阅" }) - .on_click(cx.listener(move |this, _, _, cx| { - this.toggle_subscription_enabled(&eye_sid, cx); - })); - - let edit_sid = sub_id.clone(); - let edit_btn = Button::new(SharedString::from(format!("sub-edit-{}", sub_id))) - .icon(IconName::Pencil) - .ghost() - .xsmall() - .tooltip("编辑订阅") - .on_click(cx.listener(move |this, _, window, cx| { - let sub = this - .with_app(cx, |app| { - app.subscriptions - .iter() - .find(|s| s.id == edit_sid) - .cloned() - }) - .flatten(); - if let Some(sub) = sub { - this.start_edit_subscription(&sub, window, cx); - } - })); - - let del_cid = self.conn_id.clone(); - let del_topic = topic.clone(); - let del_btn = Button::new(SharedString::from(format!("sub-del-{}", sub_id))) - .icon(IconName::Close) - .ghost() - .xsmall() - .tooltip("取消订阅") - .on_click(cx.listener(move |this, _, _, cx| { - // 若删除的是当前过滤主题,一并清掉消息过滤 - if this.sub_filter.as_deref() == Some(del_topic.as_str()) { - this.sub_filter = None; - } - if let Some(app) = this.app.upgrade() { - app.update(cx, |app, cx| { - app.remove_subscription(&del_cid, &del_topic); - cx.notify(); - }); - } - // 只有删除的是正在编辑的主题才退出编辑态,编辑别的不受影响 - if this.editing.as_deref() == Some(del_topic.as_str()) { - this.editing = None; - } - cx.notify(); - })); - - // 点击区(非按钮):切换按该主题过滤消息流;禁用的订阅不参与 - let filter_topic = topic.clone(); - let click_zone = h_flex() - .id(SharedString::from(format!("sub-click-{}", sub_id))) - .flex_1() - .min_w(px(0.)) - .when(enabled, |z| { - z.cursor_pointer().on_click(cx.listener( - move |this, _, _, cx| { - this.sub_filter = if this.sub_filter.as_deref() - == Some(filter_topic.as_str()) - { - None - } else { - Some(filter_topic.clone()) - }; - cx.notify(); - }, - )) - }) - .child( - v_flex() - .flex_1() - .min_w(px(0.)) - .gap_0p5() - .child( - div() - .text_sm() - .overflow_hidden() - .text_ellipsis() - .when(!enabled, |t| t.text_color(muted_fg)) - .child(primary), - ) - .when_some(alias, |v, _| { - v.child( - div() - .text_xs() - .text_color(muted_fg) - .overflow_hidden() - .text_ellipsis() - .child(topic.clone()), - ) - }), - ); - - list = list.child( - h_flex() - .id(SharedString::from(format!("sub-{}-{}", self.conn_id, sub.id))) - .gap_1p5() - .items_center() - .px_2() - .py_1p5() - .rounded_md() - .when(is_active, |t| t.bg(cx.theme().secondary)) - .when(!is_active, |t| t.hover(move |this| this.bg(hover_bg))) - .when(!enabled, |t| t.opacity(0.55)) - .child(color_btn) - .child(click_zone) - .child( - div() - .text_xs() - .px_1() - .rounded_sm() - .bg(cx.theme().muted) - .text_color(muted_fg) - .child(format!("Q{}", sub.qos)), - ) - .child(eye_btn) - .child(edit_btn) - .child(del_btn), - ); - } - - v_flex() - .w(px(248.)) - .flex_shrink_0() - .h_full() - .bg(cx.theme().sidebar) - .text_color(cx.theme().sidebar_foreground) - .child( - h_flex() - .h_11() - .px_3() - .items_center() - .child(div().text_sm().font_semibold().child("订阅")), - ) - .child(div().flex_1().overflow_y_scrollbar().px_2().child(list)) - } - - fn render_message_row( - &self, - record: &MqttRecord, - sub_color: Option, - cx: &mut Context, - ) -> impl IntoElement { - let seq = record.seq; - let expanded = self.expanded == Some(seq); - let received = matches!(record.direction, Direction::Received); - let accent = if received { - cx.theme().primary - } else { - cx.theme().success - }; - let hover_bg = cx.theme().muted; - let border = cx.theme().border; - let muted = cx.theme().muted_foreground; - let mono = cx.theme().mono_font_family.clone(); - let direction_label = if received { "接收" } else { "发布" }; - let show_millis = self - .with_app(cx, |app| app.settings.show_millis) - .unwrap_or(true); - - let header = h_flex() - .gap_2() - .items_center() - .child( - div() - .size(px(18.)) - .flex() - .items_center() - .justify_center() - .rounded_full() - .bg(accent.alpha(0.14)) - .child( - gpui_kit::component::Icon::new(if received { - IconName::ArrowDown - } else { - IconName::ArrowUp - }) - .size_3() - .text_color(accent), - ), - ) - .child( - div() - .flex_1() - .min_w(px(0.)) - .text_sm() - .font_medium() - .overflow_hidden() - .text_ellipsis() - .font_family(mono.clone()) - // 匹配到订阅色时主题文字着色,否则保持默认前景色 - .when_some(sub_color, |t, hue| t.text_color(hue_color(hue))) - .child(record.topic.to_string()), - ) - .child( - div() - .text_xs() - .text_color(muted) - .font_family(mono.clone()) - .child(direction_label), - ) - .child( - div() - .text_xs() - .px_1() - .rounded_sm() - .bg(cx.theme().muted) - .text_color(muted) - .font_family(mono.clone()) - .child(format!("Q{}", record.qos)), - ) - .when(record.retain, |t| { - t.child( - div() - .text_xs() - .px_1() - .rounded_sm() - .bg(cx.theme().warning.alpha(0.15)) - .text_color(cx.theme().warning) - .child("retain"), - ) - }) - .when(record.payload_truncated, |t| { - t.child( - div() - .text_xs() - .px_1() - .rounded_sm() - .bg(cx.theme().warning.alpha(0.15)) - .text_color(cx.theme().warning) - .child(format!("已截断 {}KB", MAX_PAYLOAD_RETAIN / 1024)), - ) - }) - .child( - div() - .text_xs() - .text_color(muted) - .font_family(mono.clone()) - .child(format_time(record.timestamp, show_millis)), - ); - - let mut body = v_flex() - .flex_1() - .min_w(px(0.)) - .px_3() - .py_2() - .child(header) - .child( - div() - .text_sm() - .text_color(cx.theme().foreground) - .pt_0p5() - .font_family(mono.clone()) - .child(preview_payload(&record.payload)), - ); - - if expanded { - let fmt = self.detail_format; - let detail_text = format_payload_detail(&record.payload, record.raw_bytes.as_deref(), fmt); - let mut details = v_flex().gap_1p5().pt_1(); - - // 格式切换 + 复制按钮;stop_propagation 避免点击时又收起整行 - let fmt_group = ButtonGroup::new(SharedString::from(format!("msg-fmt-{}", seq))) - .compact() - .children( - [DetailFormat::Auto, DetailFormat::Text, DetailFormat::Hex, DetailFormat::Base64] - .into_iter() - .map(|f| { - Button::new(SharedString::from(format!( - "msg-fmt-btn-{}-{}", - seq, - f.index() - ))) - .label(f.label()) - .small() - .selected(f == fmt) - }), - ) - .on_click(cx.listener(|this, ixs: &Vec, _, cx| { - cx.stop_propagation(); - if let Some(&i) = ixs.first() { - this.detail_format = DetailFormat::from_index(i); - cx.notify(); - } - })); - - let copy_topic = record.topic.clone(); - let copy_payload = record.payload.clone(); - let copy_detail = details_copy_text(record, show_millis); - let detail_toolbar = h_flex() - .gap_1() - .items_center() - .child(fmt_group) - .child(div().flex_1()) - .child( - Button::new(SharedString::from(format!("msg-copy-topic-{}", seq))) - .icon(IconName::Copy) - .label("主题") - .ghost() - .xsmall() - .tooltip("复制主题") - .on_click(move |_, window, cx| { - cx.stop_propagation(); - cx.write_to_clipboard(ClipboardItem::new_string(copy_topic.to_string())); - window.push_notification(Notification::success("已复制主题"), cx); - }), - ) - .child( - Button::new(SharedString::from(format!("msg-copy-payload-{}", seq))) - .icon(IconName::Copy) - .label("负载") - .ghost() - .xsmall() - .tooltip("复制负载原文") - .on_click(move |_, window, cx| { - cx.stop_propagation(); - cx.write_to_clipboard(ClipboardItem::new_string(copy_payload.to_string())); - window.push_notification(Notification::success("已复制负载"), cx); - }), - ) - .child( - Button::new(SharedString::from(format!("msg-copy-detail-{}", seq))) - .icon(IconName::Copy) - .label("详情") - .ghost() - .xsmall() - .tooltip("复制完整详情(含 v5 属性)") - .on_click(move |_, window, cx| { - cx.stop_propagation(); - cx.write_to_clipboard(ClipboardItem::new_string(copy_detail.clone())); - window.push_notification(Notification::success("已复制详情"), cx); - }), - ); - details = details.child(detail_toolbar).child( - div() - .font_family(mono.clone()) - .text_xs() - .p_2() - .rounded_md() - .bg(cx.theme().background) - .border_1() - .border_color(border) - .child(detail_text), - ); - - if !record.user_properties.is_empty() { - let props: String = record - .user_properties - .iter() - .map(|(k, v)| format!("{k} = {v}")) - .collect::>() - .join("\n"); - details = details - .child(div().text_xs().font_semibold().child("用户属性")) - .child( - div() - .font_family(mono.clone()) - .text_xs() - .child(props), - ); - } - for (label, value) in [ - ("Content-Type", record.content_type.clone()), - ("Response Topic", record.response_topic.clone()), - ("Correlation Data", record.correlation_data.clone()), - ( - "消息过期", - record - .message_expiry_interval - .map(|v| format!("{v} 秒")), - ), - ( - "订阅标识符", - record.subscription_identifier.map(|v| v.to_string()), - ), - ] { - if let Some(v) = value { - details = details.child( - h_flex() - .gap_1() - .text_xs() - .child(div().text_color(muted).child(format!("{label}:"))) - .child(div().font_family(mono.clone()).child(v)), - ); - } - } - body = body.child( - div() - .id(SharedString::from(format!("msg-detail-{}", seq))) - .mt_1() - .p_2() - .rounded_lg() - .bg(cx.theme().muted) - // 详情区点击不冒泡到行折叠,保证文本可选中 - .on_click(|_, _, cx| { - cx.stop_propagation(); - }) - .child(details), - ); - } - - h_flex() - .id(SharedString::from(format!( - "msg-{}-{}", - record.connection_id, record.seq - ))) - .w_full() - .border_b_1() - .border_color(border) - .hover(move |this| this.bg(hover_bg)) - .cursor_pointer() - // 左侧色条:有订阅色时显示,否则透明以保持行内对齐 - .child( - div() - .w(px(3.)) - .self_stretch() - .rounded_full() - .bg(sub_color.map(hue_color).unwrap_or(hsla(0., 0., 0., 0.))), - ) - .child(body) - .on_click(cx.listener(move |this, _, _, cx| { - if this.expanded == Some(seq) { - this.expanded = None; - } else { - // 切换到另一条消息时格式回到 Auto,单条内折叠/展开仍记忆选择 - if this.detail_owner != Some(seq) { - this.detail_format = DetailFormat::Auto; - this.detail_owner = Some(seq); - } - this.expanded = Some(seq); - } - cx.notify(); - })) - } - - fn render_messages(&self, cx: &mut Context) -> impl IntoElement { - let query = self.filter.read(cx).value().to_lowercase(); - let conn = self.conn_id.clone(); - let dir = self.msg_dir; - let sub_filter = self.sub_filter.clone(); - // 引用过滤 + 计数命中总数,仅克隆前 MAX_RENDERED_MESSAGES 条,避免全量拷贝 - let (records, subs, matched_total, total): ( - Vec, - Vec, - usize, - usize, - ) = self - .with_app(cx, |app| { - let subs: Vec = app - .subscriptions - .iter() - .filter(|s| s.connection_id == conn) - .cloned() - .collect(); - let mut matched = 0usize; - let mut records: Vec = Vec::new(); - let total = app.messages.get(conn.as_str()).map_or(0, |m| m.len()); - if let Some(m) = app.messages.get(conn.as_str()) { - for r in m.iter().rev() { - // 方向 / 搜索 / 订阅过滤叠加,过滤后再截断保证最新消息优先 - let dir_ok = match dir { - DirFilter::All => true, - DirFilter::Received => r.direction == Direction::Received, - DirFilter::Published => r.direction == Direction::Published, - }; - let query_ok = query.is_empty() - || contains_ignore_case(&r.topic, &query) - || contains_ignore_case(&r.payload, &query); - let sub_ok = sub_filter - .as_deref() - .is_none_or(|f| topic_matches(f, &r.topic)); - if dir_ok && query_ok && sub_ok { - matched += 1; - if records.len() < MAX_RENDERED_MESSAGES { - records.push(r.clone()); - } - } - } - } - (records, subs, matched, total) - }) - .unwrap_or_default(); - - let mut body = v_flex().flex_1().overflow_y_scrollbar(); - if records.is_empty() { - let filtering = !query.is_empty() - || sub_filter.is_some() - || dir != DirFilter::All; - body = body.child( - v_flex() - .h_full() - .items_center() - .justify_center() - .gap_3() - .p_4() - .child( - div() - .size(px(56.)) - .flex() - .items_center() - .justify_center() - .rounded_full() - .bg(cx.theme().muted) - .child( - gpui_kit::component::Icon::new(IconName::MailOpen) - .size_6() - .text_color(cx.theme().muted_foreground), - ), - ) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(if filtering { - "没有匹配的消息" - } else { - "暂无消息,订阅主题后消息会显示在这里" - }), - ), - ); - } else { - // 最新消息在最上方(与 MQTTX 一致,避免长列表需要手动滚底) - for r in &records { - let color = pick_subscription_color(&subs, &r.topic); - body = body.child(self.render_message_row(r, color, cx)); - } - } - - // 方向过滤 seg - let dir_group = ButtonGroup::new(SharedString::from(format!( - "msg-dir-{}", - self.conn_id - ))) - .compact() - .children([DirFilter::All, DirFilter::Received, DirFilter::Published].into_iter().map( - |d| { - Button::new(SharedString::from(format!( - "msg-dir-btn-{}-{}", - self.conn_id, - d.index() - ))) - .label(d.label()) - .small() - .selected(d == dir) - }, - )) - .on_click(cx.listener(|this, ixs: &Vec, _, cx| { - if let Some(&i) = ixs.first() { - this.msg_dir = DirFilter::from_index(i); - cx.notify(); - } - })); - - // 订阅过滤 chip(点击订阅项激活) - let filter_chip = self.sub_filter.clone().map(|topic| { - h_flex() - .gap_1() - .items_center() - .px_1p5() - .py_0p5() - .rounded_md() - .bg(cx.theme().secondary) - .max_w(px(180.)) - .child( - gpui_kit::component::Icon::new(IconName::ListFilter) - .xsmall() - .text_color(cx.theme().muted_foreground), - ) - .child( - div() - .text_xs() - .overflow_hidden() - .text_ellipsis() - .child(topic), - ) - .child( - Button::new(SharedString::from(format!( - "msg-filter-clear-{}", - self.conn_id - ))) - .icon(IconName::Close) - .ghost() - .xsmall() - .tooltip("清除订阅过滤") - .on_click(cx.listener(|this, _, _, cx| { - this.sub_filter = None; - cx.notify(); - })), - ) - }); - - v_flex() - .flex_1() - .min_w(px(0.)) - .h_full() - .bg(cx.theme().background) - .child( - h_flex() - .h_11() - .px_3() - .gap_2() - .items_center() - .border_b_1() - .border_color(cx.theme().border) - .child(dir_group) - .child( - div() - .flex_1() - .min_w(px(0.)) - .child(Input::new(&self.filter).small().prefix( - gpui_kit::component::Icon::new(IconName::Search).small(), - )), - ) - .children(filter_chip) - .child( - div() - .text_xs() - .px_1p5() - .text_color(cx.theme().muted_foreground) - // 计数显示过滤命中总数,不受渲染截断影响 - .child(format!("{} 条", matched_total)), - ) - .when(matched_total > records.len(), |h| { - h.child( - div() - .text_xs() - .px_1p5() - .text_color(cx.theme().muted_foreground) - .child(format!("仅显示最新 {} 条", MAX_RENDERED_MESSAGES)), - ) - }) - .child({ - let weak = cx.weak_entity(); - let clear_id = self.conn_id.clone(); - // 清空针对连接的全部消息(非过滤结果): - // 禁用与否依据总数,过滤命中 0 条不应禁用 - let empty = total == 0; - Button::new(SharedString::from(format!("msg-clear-{}", self.conn_id))) - .icon(IconName::Eraser) - .label("清空") - .ghost() - .small() - .disabled(empty) - .tooltip("清空当前连接的消息") - .on_click(move |_, window, cx| { - // 清空不可撤销,先弹二次确认 - let weak = weak.clone(); - let clear_id = clear_id.clone(); - window.open_alert_dialog(cx, move |alert, _, _| { - let weak = weak.clone(); - let clear_id = clear_id.clone(); - alert - .title("清空消息") - .description("确定清空当前连接的全部消息吗?此操作不可撤销。") - .button_props( - DialogButtonProps::default() - .show_cancel(true) - .cancel_text("取消") - .ok_text("清空") - .ok_variant(ButtonVariant::Danger), - ) - .on_ok(move |_, _, cx| { - weak.update(cx, |view, cx| { - view.expanded = None; - view.detail_owner = None; - view.detail_format = DetailFormat::Auto; - if let Some(app) = view.app.upgrade() { - app.update(cx, |app, cx| { - app.clear_messages(&clear_id); - cx.notify(); - }); - } - cx.notify(); - }) - .ok(); - true - }) - }); - }) - }), - ) - .child(body) - } - - fn render_logs(&self, cx: &mut Context) -> impl IntoElement { - let muted = cx.theme().muted_foreground; - let conn = self.conn_id.clone(); - let show_millis = self - .with_app(cx, |app| app.settings.show_millis) - .unwrap_or(true); - let logs: Vec<_> = self - .with_app(cx, |app| { - app.logs - .iter() - .rev() - .filter(|l| l.connection_id == conn) - // 与 app.rs 的 MAX_LOGS=3000 对齐,避免渲染截断早于存储上限 - .take(MAX_RENDERED_LOGS) - .cloned() - .collect() - }) - .unwrap_or_default(); - - let mut body = v_flex().flex_1().overflow_y_scrollbar().p_2().gap_0p5(); - if logs.is_empty() { - body = body.child( - v_flex() - .p_4() - .gap_2() - .items_center() - .child( - gpui_kit::component::Icon::new(IconName::ScrollText) - .text_color(cx.theme().muted_foreground), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("暂无日志"), - ), - ); - } - let log_hover = cx.theme().muted; - for l in logs { - let color = match l.level { - LogLevel::Error => cx.theme().danger, - LogLevel::Warn => cx.theme().warning, - LogLevel::Debug => cx.theme().muted_foreground, - LogLevel::Info => cx.theme().foreground, - }; - body = body.child( - h_flex() - .gap_2() - .px_2() - .py_1() - .rounded_md() - .hover(move |this| this.bg(log_hover)) - .child( - div() - .text_xs() - .text_color(muted) - .font_family(cx.theme().mono_font_family.clone()) - .child(format_time(l.timestamp, show_millis)), - ) - .child( - div() - .text_xs() - .w_10() - .text_color(color) - .child(l.level.label()), - ) - .child( - div() - .text_xs() - .flex_1() - .min_w(px(0.)) - .font_family(cx.theme().mono_font_family.clone()) - .child(format!("[{}] {}", l.event, l.message)), - ), - ); - } - v_flex() - .flex_1() - .min_w(px(0.)) - .h_full() - .bg(cx.theme().background) - .child( - h_flex() - .h_11() - .px_3() - .items_center() - .gap_2() - .border_b_1() - .border_color(cx.theme().border) - .child( - div() - .text_xs() - .text_color(muted) - .font_family(cx.theme().mono_font_family.clone()) - .child("连接日志"), - ), - ) - .child(body) - } - fn render_publish_bar(&mut self, cx: &mut Context) -> impl IntoElement { - let pub_topic = self.pub_topic.clone(); - let payload = self.payload.clone(); - let pub_qos = self.pub_qos.clone(); - let payload_format = self.payload_format.clone(); - let content_type = self.content_type.clone(); - let msg_expiry = self.msg_expiry.clone(); - let response_topic = self.response_topic.clone(); - let correlation_data = self.correlation_data.clone(); - let user_props = self.user_props.clone(); - let retain = self.retain; - let show_props = self.show_props; - let show_vars = self.show_vars; - let is_v5 = self.is_v5(cx); - - // 发布预设 - let presets = self.with_app(cx, |app| app.presets.clone()).unwrap_or_default(); - let connected = self.engine.is_connected(&self.conn_id); - // 当前提取到的占位符数量(仅用于提示,行构建在 sync_var_rows 完成) - let var_count = extract_placeholder_keys( - &self.pub_topic.read(cx).value(), - &self.payload.read(cx).value(), - ) - .len(); - - let mut card = v_flex() - .w_full() - .border_t_1() - .border_color(cx.theme().border) - .bg(cx.theme().secondary) - .px_3() - .py_2() - .gap_2(); - - card = card.child( - h_flex() - .gap_2() - .items_center() - // Ctrl+Enter 快捷发送(焦点在主题输入上时) - .child( - div() - .id(SharedString::from(format!("pub-topic-wrap-{}", self.conn_id))) - .flex_1() - .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" { - cx.stop_propagation(); - this.do_publish(window, cx); - } - }, - )) - .child(Input::new(&pub_topic).small()), - ) - .child(div().w(px(92.)).child(Select::new(&pub_qos).small())) - .child(div().w(px(124.)).child(Select::new(&payload_format).small())) - .child(self.render_preset_menu(presets, cx)) - .child({ - // 变量注入开关:展开/收起内联绑定面板 - let count = var_count; - Button::new("vars-toggle") - .icon(IconName::Variable) - .ghost() - .small() - .when(show_vars, |b| b.selected(true)) - .tooltip(if count > 0 { - format!("变量注入({count} 个占位符)") - } else { - "变量注入".to_string() - }) - .on_click(cx.listener(|this, _, window, cx| { - if this.show_vars { - this.show_vars = false; - cx.notify(); - } else { - this.sync_var_rows(window, cx); - } - })) - }) - .child( - h_flex() - .gap_1() - .items_center() - .child(Switch::new("retain").checked(retain).on_change( - cx.listener(|this, v, _, cx| { - this.retain = *v; - cx.notify(); - }), - )) - .child(div().text_xs().child("Retain")), - ) - .when(is_v5, |h| { - h.child( - Button::new("toggle-props") - .icon(IconName::SlidersHorizontal) - .ghost() - .small() - .when(show_props, |b| b.selected(true)) - .tooltip("MQTT 5 属性") - .on_click(cx.listener(|this, _, _, cx| { - this.show_props = !this.show_props; - cx.notify(); - })), - ) - }) - .child( - Button::new(SharedString::from(format!("publish-{}", self.conn_id))) - .icon(IconName::SendHorizontal) - .label("发送") - .primary() - .small() - // 未连接时禁用,Ctrl+Enter 路径由 do_publish 内的前置检查兜底 - .disabled(!connected) - .when(!connected, |b| b.tooltip("未连接,无法发送")) - .on_click(cx.listener(|this, _, window, cx| this.do_publish(window, cx))), - ), - ); - - card = card.child( - div() - .id(SharedString::from(format!("payload-wrap-{}", self.conn_id))) - .h(px(110.)) - .rounded_md() - .border_1() - .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); - } - }, - )) - .child(Textarea::new(&payload)), - ); - - // 内联变量绑定面板:仅在展开且已提取到占位符时渲染 - if show_vars && !self.var_rows.is_empty() { - card = card.child(self.render_vars_panel(cx)); - } - - if show_props && is_v5 { - card = card.child( - v_flex() - .gap_2() - .w_full() - .child( - h_flex() - .gap_2() - .w_full() - .child( - div().w(px(220.)).child(field( - "Content-Type", - Input::new(&content_type).small(), - )), - ) - .child( - div().w(px(120.)).child(field( - "消息过期(秒)", - Input::new(&msg_expiry).small(), - )), - ) - .child( - div().flex_1().min_w(px(0.)).child(field( - "Response Topic", - Input::new(&response_topic).small(), - )), - ) - .child( - div().flex_1().min_w(px(0.)).child(field( - "Correlation Data", - Input::new(&correlation_data).small(), - )), - ), - ) - .child(user_props.clone()), - ); - } - card - } - - /// 按当前 topic/payload 重新提取 `{{key}}`,增删变量输入行。 - /// 输入框创建需要 Window,因此只能在按钮回调里调用(无法在 render 中同步)。 - fn sync_var_rows(&mut self, window: &mut Window, cx: &mut Context) { - let topic = self.pub_topic.read(cx).value().to_string(); - let payload = self.payload.read(cx).value().to_string(); - let keys = extract_placeholder_keys(&topic, &payload); - if keys.is_empty() { - self.show_vars = false; - window.push_notification( - Notification::warning("主题/负载中没有 {{变量}} 引用"), - cx, - ); - cx.notify(); - return; - } - let vars = self.with_app(cx, |a| a.variables.clone()).unwrap_or_default(); - // 移除已不存在的 key(保留仍存在的行,避免丢未保存的输入) - self.var_rows.retain(|(k, _, _)| keys.contains(k)); - for key in keys { - if self.var_rows.iter().any(|(k, ..)| k == &key) { - continue; - } - let initial = vars - .iter() - .find(|v| v.key == key) - .map(|v| v.value.clone()) - .unwrap_or_default(); - let input = cx.new(|cx| { - let mut s = InputState::new(window, cx).placeholder("变量值"); - // set_value 不会触发 InputEvent::Change,不会误写回 - if !initial.is_empty() { - s.set_value(initial.as_str(), window, cx); - } - s - }); - // 编辑即写回全局变量(含持久化),预览与发布随之生效 - let key_for_sub = key.clone(); - let sub = cx.subscribe(&input, move |this, entity, ev: &InputEvent, cx| { - if !matches!(ev, InputEvent::Change) { - return; - } - let val = entity.read(cx).value().to_string(); - let key = key_for_sub.clone(); - let Some(app) = this.app.upgrade() else { - return; - }; - app.update(cx, |a, cx| { - match a.variables.iter_mut().find(|v| v.key == key) { - Some(v) => { - if v.value == val { - return; - } - v.value = val; - } - None => a.variables.push(GlobalVariable { key, value: val }), - } - let snapshot = a.variables.clone(); - a.save_variables(snapshot); - cx.notify(); - }); - }); - self.var_rows.push((key, input, sub)); - } - self.show_vars = true; - cx.notify(); - } - - /// 变量绑定面板:逐 key 编辑 + 实时渲染预览。 - fn render_vars_panel(&self, cx: &mut Context) -> impl IntoElement { - let vars = self.with_app(cx, |a| a.variables.clone()).unwrap_or_default(); - let topic_raw = self.pub_topic.read(cx).value().to_string(); - let payload_raw = self.payload.read(cx).value().to_string(); - let topic_rendered = render_template(&topic_raw, &vars); - let payload_rendered = render_template(&payload_raw, &vars); - let payload_preview: String = payload_rendered.chars().take(80).collect(); - let muted = cx.theme().muted_foreground; - - let mut rows = v_flex().gap_1(); - for (key, input, _) in &self.var_rows { - rows = rows.child( - h_flex() - .gap_2() - .items_center() - .child( - div() - .w(px(160.)) - .min_w(px(0.)) - .text_xs() - .text_color(muted) - .child(format!("{{{{{key}}}}}")), - ) - .child(div().flex_1().min_w(px(0.)).child(Input::new(input).small())), - ); - } - - v_flex() - .gap_2() - .w_full() - .child( - h_flex() - .gap_2() - .items_center() - .child( - div() - .text_xs() - .font_semibold() - .child(format!("变量({})", self.var_rows.len())), - ) - .child(div().flex_1()) - .child( - Button::new("vars-refresh") - .icon(IconName::RefreshCw) - .ghost() - .xsmall() - .tooltip("重新提取占位符") - .on_click(cx.listener(|this, _, window, cx| { - this.sync_var_rows(window, cx); - })), - ) - .child( - Button::new("vars-collapse") - .icon(IconName::ChevronUp) - .ghost() - .xsmall() - .tooltip("收起") - .on_click(cx.listener(|this, _, _, cx| { - this.show_vars = false; - cx.notify(); - })), - ), - ) - .child(rows) - .child( - div() - .text_xs() - .text_color(muted) - .child("内置:{{$ts}}(秒) {{$ts_ms}}(毫秒) {{$uuid}} · 发布时注入"), - ) - .child( - div() - .text_xs() - .text_color(muted) - .child(format!("预览:{topic_rendered} | {payload_preview}")), - ) - } - - /// 发布预设下拉:应用已有预设、保存当前、删除预设。 - fn render_preset_menu( &self, - presets: Vec, - cx: &mut Context, - ) -> impl IntoElement { - let weak_view = cx.weak_entity(); - let preset_name = self.preset_name.clone(); - let app_weak = self.app.clone(); - let has = !presets.is_empty(); - Button::new(SharedString::from(format!("preset-menu-{}", self.conn_id))) - .icon(IconName::Bookmark) - .ghost() - .small() - .tooltip("消息预设") - .dropdown_menu(move |mut menu, window, cx| { - // 应用预设 - for p in &presets { - let weak = weak_view.clone(); - let params = p.params.clone(); - menu = menu.item( - PopupMenuItem::new(SharedString::from(p.name.clone())).on_click( - move |_ev, window, cx| { - if let Some(view) = weak.upgrade() { - view.update(cx, |view, cx| { - view.apply_publish_preset(¶ms, window, cx); - }); - } - }, - ), - ); - } - if has { - menu = menu.separator(); - } - // 保存当前 - let weak_save = weak_view.clone(); - let name_input = preset_name.clone(); - menu = menu.item(PopupMenuItem::new("保存当前为预设…").on_click( - move |_ev, window, cx| { - if let Some(view) = weak_save.upgrade() { - view.update(cx, |view, cx| { - view.open_save_preset_dialog(window, cx, name_input.clone()); - }); - } - }, - )); - // 打开预设管理对话框(新建 / 编辑 / 删除) - let manage_app = app_weak.clone(); - menu = menu.item(PopupMenuItem::new("管理预设…").on_click( - move |_ev, window, cx| { - if let Some(app) = manage_app.upgrade() { - crate::ui::presets_dialog::open(app, window, cx); - } - }, - )); - // 删除子菜单 - if has { - let weak_del = weak_view.clone(); - let del_presets = presets.clone(); - menu = menu.submenu( - "删除预设", - window, - cx, - move |submenu, _window, _cx| { - let mut submenu = submenu; - for p in &del_presets { - let weak2 = weak_del.clone(); - let id = p.id.clone(); - let name = p.name.clone(); - let label = format!("删除「{}」", p.name); - submenu = submenu.item( - PopupMenuItem::new(SharedString::from(label)).on_click( - move |_ev, window, cx| { - // 预设删除不可撤销,先弹二次确认 - let weak = weak2.clone(); - let pid = id.clone(); - let pname = name.clone(); - window.open_alert_dialog(cx, move |alert, _, _| { - let weak = weak.clone(); - let pid = pid.clone(); - alert - .title("删除预设") - .description(format!( - "确定删除预设「{pname}」吗?此操作不可撤销。" - )) - .button_props( - DialogButtonProps::default() - .show_cancel(true) - .cancel_text("取消") - .ok_text("删除") - .ok_variant(ButtonVariant::Danger), - ) - .on_ok(move |_, _, cx| { - weak.update(cx, |view, cx| { - if let Some(app) = view.app.upgrade() { - app.update(cx, |app, cx| { - app.delete_publish_preset(&pid); - cx.notify(); - }); - } - cx.notify(); - }) - .ok(); - true - }) - }); - }, - ), - ); - } - submenu - }, - ); - } - menu - }) - } - - /// 打开"保存为预设"命名对话框,保存当前发布参数。 - fn open_save_preset_dialog( - &mut self, - window: &mut Window, - cx: &mut Context, - name_input: Entity, - ) { - // 预填默认名 - let topic = self.pub_topic.read(cx).value().to_string(); - let default_name = if topic.is_empty() { - "新预设".to_string() - } else { - topic.chars().take(20).collect() - }; - name_input.update(cx, |s, cx| s.set_value(default_name, window, cx)); - - let weak = cx.weak_entity(); - let name_for_ok = name_input.clone(); - window.open_dialog(cx, move |dialog, _, _| { - dialog - .w(px(420.)) - .title("保存消息预设") - .child(v_flex().gap_3().child( - crate::ui::widgets::field("预设名称", Input::new(&name_for_ok)), - )) - .footer( - h_flex() - .gap_2() - .justify_end() - .w_full() - .child( - Button::new("preset-save-cancel") - .label("取消") - .outline() - .on_click(move |_, window, cx| window.close_dialog(cx)), - ) - .child( - Button::new("preset-save-ok") - .label("保存") - .primary() - .on_click({ - let name_input = name_for_ok.clone(); - let weak = weak.clone(); - move |_ev, window, cx| { - let name = name_input - .read(cx) - .value() - .trim() - .to_string(); - if name.is_empty() { - window.push_notification( - Notification::warning("请填写预设名称"), - cx, - ); - return; - } - // 复用 do_publish 的过期秒数校验:输入了但解析不出即拒绝 - let params = weak - .read_with(cx, |view, cx| { - let expiry_raw = view - .msg_expiry - .read(cx) - .value() - .to_string(); - ( - view.collect_publish_params(cx), - expiry_raw, - ) - }) - .ok(); - if let (Some(view), Some((params, expiry_raw))) = - (weak.upgrade(), params) - { - if !expiry_raw.trim().is_empty() - && params.message_expiry_interval.is_none() - { - window.push_notification( - Notification::warning( - "消息过期须为非负整数(秒)", - ), - cx, - ); - return; - } - if let Some(app) = view.read(cx).app.upgrade() { - // 同名即覆盖,提示语区分以明确发生了什么 - let existed = app - .read(cx) - .presets - .iter() - .any(|p| p.name == name); - app.update(cx, |app, cx| { - app.upsert_publish_preset(name.clone(), params); - cx.notify(); - }); - let msg = if existed { - format!("已覆盖预设「{name}」") - } else { - "预设已保存".to_string() - }; - window.push_notification( - Notification::success(msg), - cx, - ); - } - } - window.close_dialog(cx); - } - }), - ), - ) - }); - } -} - -impl Render for ConnectionView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let panel = self.panel; - v_flex() - .size_full() - .min_w(px(0.)) - .child(self.render_top_bar(cx)) - .child(self.render_subscribe_bar(cx)) - .child( - h_flex() - .flex_1() - .min_h(px(0.)) - .child(self.render_subscriptions(cx)) - .child( - v_flex() - .flex_1() - .min_w(px(0.)) - .h_full() - .child( - TabBar::new("workspace-tabs") - .segmented() - .selected_index(if panel == Panel::Messages { 0 } else { 1 }) - .child( - Tab::new() - .label("消息流") - .selected(panel == Panel::Messages) - .on_click(cx.listener(|this, _, _, cx| { - this.panel = Panel::Messages; - cx.notify(); - })), - ) - .child( - Tab::new() - .label("日志") - .selected(panel == Panel::Logs) - .on_click(cx.listener(|this, _, _, cx| { - this.panel = Panel::Logs; - cx.notify(); - })), - ), - ) - .child(match panel { - Panel::Messages => self.render_messages(cx).into_any_element(), - Panel::Logs => self.render_logs(cx).into_any_element(), - }), - ), - ) - .child(self.render_publish_bar(cx)) - } -} - -// ─── 独立工具函数 ───────────────────────────────────────────────────────────── - -/// 色相(度)→ gpui Hsla(h 分量为 0..=1 的整圆比例)。 -/// 固定 L=0.52 时亮色相(黄绿)在浅色主题下过亮、暗色相(蓝靛)在深色主题下过暗, -/// 按色相段微调明度,保证两端主题下都可辨识。 -fn hue_color(hue: f32) -> Hsla { - let h = hue.rem_euclid(360.0); - let l = if (30.0..=90.0).contains(&h) { - // 黄/黄绿本身明度感高,压低一点防刺眼 - 0.44 - } else if (200.0..=280.0).contains(&h) { - // 蓝/靛本身明度感低,抬高一点防发黑 - 0.60 - } else { - 0.52 - }; - hsla(h / 360.0, 0.72, l, 1.0) -} - -/// 逗号 / 空白 / 换行分隔的多主题输入 → 去重后的主题列表(批量订阅) -fn split_topic_list(raw: &str) -> Vec { - let mut out: Vec = Vec::new(); - for part in raw.split(|c: char| c == ',' || c.is_whitespace()) { - let t = part.trim(); - if !t.is_empty() && !out.iter().any(|x| x == t) { - out.push(t.to_string()); - } - } - out -} - -/// 从发布主题与负载中提取 `{{key}}` 占位符的 key 列表: -/// - key 两侧空白去除(trim); -/// - `$` 开头的内置变量(`$ts` / `$ts_ms` / `$uuid`)不提取; -/// - 重复 key 去重,保持首次出现顺序(主题在前、负载在后)。 -fn extract_placeholder_keys(topic: &str, payload: &str) -> Vec { - let mut out: Vec = Vec::new(); - for text in [topic, payload] { - let bytes = text.as_bytes(); - let mut i = 0; - while i + 1 < bytes.len() { - if bytes[i] == b'{' && bytes[i + 1] == b'{' && let Some(rel_end) = text[i + 2..].find("}}") { - let key = text[i + 2..i + 2 + rel_end].trim(); - if !key.is_empty() && !key.starts_with('$') && !out.iter().any(|k| k == key) { - out.push(key.to_string()); - } - i = i + 2 + rel_end + 2; - continue; - } - // 按字符推进,避免把多字节 UTF-8 拆开 - let ch = text[i..].chars().next().unwrap(); - i += ch.len_utf8(); - } - } - out -} - -/// MQTT 主题过滤器匹配:`+` 匹配单层,`#` 匹配本层及以下(可匹配父级本身)。 -fn topic_matches(filter: &str, topic: &str) -> bool { - let f: Vec<&str> = filter.split('/').collect(); - let t: Vec<&str> = topic.split('/').collect(); - for (i, part) in f.iter().enumerate() { - if *part == "#" { - // "a/#" 需要匹配 "a" 自身:剩余层级数 >= 过滤器已消费层数 - return i <= t.len(); - } - if i >= t.len() { - return false; - } - if *part != "+" && *part != t[i] { - return false; - } - } - f.len() == t.len() -} - -/// 挑选消息着色用的订阅色:先看精确匹配,再看通配匹配;只考虑启用且已设色的订阅。 -fn pick_subscription_color(subs: &[Subscription], topic: &str) -> Option { - let has_wild = |t: &str| t.contains('+') || t.contains('#'); - for s in subs.iter().filter(|s| s.enabled) { - if s.topic == topic && !has_wild(&s.topic) && s.color.is_some() { - return s.color; - } - } - for s in subs.iter().filter(|s| s.enabled) { - if has_wild(&s.topic) && topic_matches(&s.topic, topic) && s.color.is_some() { - return s.color; - } - } - None -} - -/// 展开详情里的 payload 渲染:自动 = 合法 JSON 美化,否则原文。 -/// Hex/Base64 优先使用入库保留的原始字节——非文本负载的 lossy 文本 -/// 已把非法字节替换为 U+FFFD,按文本字节展示会与线上报文不符。 -fn format_payload_detail(payload: &str, raw: Option<&[u8]>, format: DetailFormat) -> String { - match format { - DetailFormat::Auto => serde_json::from_str::(payload) - .ok() - .and_then(|v| serde_json::to_string_pretty(&v).ok()) - .unwrap_or_else(|| payload.to_string()), - DetailFormat::Text => payload.to_string(), - DetailFormat::Hex => raw - .unwrap_or(payload.as_bytes()) - .iter() - .map(|b| format!("{b:02x}")) - .collect::>() - .join(" "), - DetailFormat::Base64 => { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD.encode(raw.unwrap_or(payload.as_bytes())) - } - } -} - -/// 「复制详情」的可读文本:元数据 + v5 属性 + 用户属性 + 负载。 -fn details_copy_text(record: &MqttRecord, show_millis: bool) -> String { - let mut out = String::new(); - out.push_str(&format!("Topic: {}\n", record.topic)); - out.push_str(&format!("Direction: {}\n", record.direction.label())); - out.push_str(&format!("QoS: {}\n", record.qos)); - out.push_str(&format!("Retain: {}\n", record.retain)); - out.push_str(&format!( - "Time: {}\n", - format_time(record.timestamp, show_millis) - )); - if let Some(v) = record.content_type.as_deref() { - out.push_str(&format!("Content-Type: {v}\n")); - } - if let Some(v) = record.response_topic.as_deref() { - out.push_str(&format!("Response Topic: {v}\n")); - } - if let Some(v) = record.correlation_data.as_deref() { - out.push_str(&format!("Correlation Data: {v}\n")); - } - if let Some(v) = record.message_expiry_interval { - out.push_str(&format!("Message Expiry Interval: {v}\n")); - } - if let Some(v) = record.subscription_identifier { - out.push_str(&format!("Subscription Identifier: {v}\n")); - } - if !record.user_properties.is_empty() { - out.push_str("User Properties:\n"); - for (k, v) in &record.user_properties { - out.push_str(&format!(" {k} = {v}\n")); - } - } - out.push_str(&format!("Payload:\n{}", record.payload)); - out -} - -/// 折叠态只显示单行预览:换行/制表符压平成空格后截 200 字符。 -/// 含替换字符(U+FFFD)说明负载不是合法文本,提示切 Hex 查看。 -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('…'); - } - if payload.contains('\u{FFFD}') { - out.push_str("(非文本,详情可切 Hex)"); - } - out -} - -/// 大小写不敏感子串匹配。`needle` 已由调用方 to_lowercase 一次; -/// 先做零拷贝的字面匹配,未命中且原文含大写时才回退整串小写, -/// 避免渲染热路径对全部记录做 to_lowercase 拷贝。 -fn contains_ignore_case(hay: &str, needle: &str) -> bool { - if needle.is_empty() { - return true; - } - if hay.contains(needle) { - return true; - } - // 纯 ASCII 快速路径:双方均为 ASCII 且原文无 ASCII 大写时, - // 小写化恒等,可直接判否。不能用 is_uppercase 判断——个别 - // 小写字符(如 U+1E9B)的小写映射并不等于自身。 - if needle.is_ascii() - && hay.is_ascii() - && !hay.bytes().any(|b| b.is_ascii_uppercase()) - { - return false; - } - hay.to_lowercase().contains(needle) -} - -// ─── 单元测试 ──────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::extract_placeholder_keys; - - // 修复回归:提取须 trim、排除内置变量、去重且保持首次出现顺序 - #[test] - fn extract_keys_trims_excludes_builtin_and_dedups() { - let keys = extract_placeholder_keys( - "sensor/{{ device }}/state", - "{\"v\":{{value}},\"t\":{{ $ts }},\"u\":{{uuid}}}", - ); - assert_eq!(keys, vec!["device", "value", "uuid"], "实际: {keys:?}"); - } - - #[test] - fn extract_keys_empty_and_multibyte_safe() { - assert!(extract_placeholder_keys("", "").is_empty()); - assert!(extract_placeholder_keys("no placeholders here", "纯文本").is_empty()); - // 未闭合的 {{ 不提取、不越界 - assert!(extract_placeholder_keys("a{{b", "c}}d").is_empty()); - let keys = extract_placeholder_keys("温度{{名称}}", ""); - assert_eq!(keys, vec!["名称"]); - } -} diff --git a/gpui-app/src/ui/connection_view/messages.rs b/gpui-app/src/ui/connection_view/messages.rs new file mode 100644 index 0000000..dd8f7da --- /dev/null +++ b/gpui-app/src/ui/connection_view/messages.rs @@ -0,0 +1,781 @@ +//! 消息区:顶栏过滤/工具条、消息流渲染、单条消息行与日志视图。 + + +use gpui_kit::component::button::{ Button, ButtonGroup, ButtonVariant }; +use gpui_kit::component::dialog::DialogButtonProps; +use gpui_kit::component::input::Input; +use gpui_kit::component::{ h_flex, notification::Notification, v_flex, }; +use crate::ui::IconName; +use gpui_kit::{ div, hsla, px, ClipboardItem, Context, IntoElement, SharedString, }; + +use crate::model::{ render_will_templates, ConnectionStatus, Direction, LogLevel, MqttRecord, Subscription, MAX_PAYLOAD_RETAIN, }; +use crate::ui::widgets::format_time; + +use super::*; + +impl ConnectionView { +pub(super) fn render_top_bar(&self, cx: &mut Context) -> impl IntoElement { + let id = self.conn_id.clone(); + let (name, status, is_v5, error) = self + .with_app(cx, |app| { + let cfg = app.connections.iter().find(|c| c.id == id); + ( + cfg.map(|c| c.name.clone()).unwrap_or_else(|| id.clone()), + app.statuses.get(&id).copied().unwrap_or(ConnectionStatus::Disconnected), + cfg.map(|c| c.protocol.is_v5()).unwrap_or(true), + app.errors.get(&id).cloned(), + ) + }) + .unwrap_or((id.clone(), ConnectionStatus::Disconnected, true, None)); + let connected = matches!(status, ConnectionStatus::Connected); + let connecting = matches!(status, ConnectionStatus::Connecting); + let _ = is_v5; + + let status_text = status.label(); + let status_tone = match status { + ConnectionStatus::Connected => cx.theme().success, + ConnectionStatus::Connecting => cx.theme().warning, + ConnectionStatus::Error => cx.theme().danger, + ConnectionStatus::Disconnected => cx.theme().muted_foreground, + }; + // Error 状态把失败原因展示出来,避免只有「错误」二字无从排查 + let error_hint = matches!(status, ConnectionStatus::Error) + .then(|| error.clone()) + .flatten(); + + h_flex() + .w_full() + .items_center() + .gap_2() + .px_3() + .h_12() + .border_b_1() + .border_color(cx.theme().border) + .child(div().size(px(8.)).rounded_full().bg(status_tone)) + .child(div().text_sm().font_semibold().child(name)) + .child( + h_flex() + .items_center() + .gap_1() + .px_1p5() + .py_0p5() + .rounded_md() + .bg(status_tone.alpha(0.12)) + .child(div().size(px(5.)).rounded_full().bg(status_tone)) + .child( + div() + .text_xs() + .text_color(status_tone) + .child(status_text), + ), + ) + // Error 副文本展示失败原因(截断避免挤掉按钮) + .when_some(error_hint.clone(), |h, e| { + h.child( + div() + .flex_1() + .min_w(px(0.)) + .text_xs() + .text_color(cx.theme().danger) + .overflow_hidden() + .text_ellipsis() + .child(e), + ) + }) + .when(error_hint.is_none(), |h| h.child(div().flex_1())) + .child( + Button::new(SharedString::from(format!("conn-toggle-{}", self.conn_id))) + .icon(if connected { + IconName::Square + } else { + IconName::PlugZap + }) + .label(if connected { + "断开" + } else if connecting { + "连接中…" + } else { + "连接" + }) + .when(connected, |b| b.danger().ghost()) + .when(!connected, |b| b.primary().ghost()) + // Connecting 期间禁用,避免重复点击重启连接流程 + .disabled(connecting) + .small() + .when_some(error_hint.clone(), |b, e| b.tooltip(e)) + .on_click(cx.listener(move |this, _, _window, cx| { + // Connecting 期间(按钮禁用之外再兜一层)不响应,防止重复 connect 重启流程 + let connecting = this + .with_app(cx, |app| { + app.statuses.get(&this.conn_id) + == Some(&ConnectionStatus::Connecting) + }) + .unwrap_or(false); + if connecting { + return; + } + let cfg = this.config(cx); + if let Some(mut cfg) = cfg { + if this.engine.is_connected(&cfg.id) { + this.engine.close(&cfg.id, true); + } else { + // 遗嘱 {{变量}} 在每次连接时按当前全局变量求值 + let vars = this + .with_app(cx, |app| app.variables.clone()) + .unwrap_or_default(); + render_will_templates(&mut cfg, &vars); + this.engine.connect(cfg); + } + } + })), + ) + } + + fn render_message_row( + &self, + record: &MqttRecord, + sub_color: Option, + cx: &mut Context, + ) -> impl IntoElement { + let seq = record.seq; + let expanded = self.expanded == Some(seq); + let received = matches!(record.direction, Direction::Received); + let accent = if received { + cx.theme().primary + } else { + cx.theme().success + }; + let hover_bg = cx.theme().muted; + let border = cx.theme().border; + let muted = cx.theme().muted_foreground; + let mono = cx.theme().mono_font_family.clone(); + let direction_label = if received { "接收" } else { "发布" }; + let show_millis = self + .with_app(cx, |app| app.settings.show_millis) + .unwrap_or(true); + + let header = h_flex() + .gap_2() + .items_center() + .child( + div() + .size(px(18.)) + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(accent.alpha(0.14)) + .child( + gpui_kit::component::Icon::new(if received { + IconName::ArrowDown + } else { + IconName::ArrowUp + }) + .size_3() + .text_color(accent), + ), + ) + .child( + div() + .flex_1() + .min_w(px(0.)) + .text_sm() + .font_medium() + .overflow_hidden() + .text_ellipsis() + .font_family(mono.clone()) + // 匹配到订阅色时主题文字着色,否则保持默认前景色 + .when_some(sub_color, |t, hue| t.text_color(hue_color(hue))) + .child(record.topic.to_string()), + ) + .child( + div() + .text_xs() + .text_color(muted) + .font_family(mono.clone()) + .child(direction_label), + ) + .child( + div() + .text_xs() + .px_1() + .rounded_sm() + .bg(cx.theme().muted) + .text_color(muted) + .font_family(mono.clone()) + .child(format!("Q{}", record.qos)), + ) + .when(record.retain, |t| { + t.child( + div() + .text_xs() + .px_1() + .rounded_sm() + .bg(cx.theme().warning.alpha(0.15)) + .text_color(cx.theme().warning) + .child("retain"), + ) + }) + .when(record.payload_truncated, |t| { + t.child( + div() + .text_xs() + .px_1() + .rounded_sm() + .bg(cx.theme().warning.alpha(0.15)) + .text_color(cx.theme().warning) + .child(format!("已截断 {}KB", MAX_PAYLOAD_RETAIN / 1024)), + ) + }) + .child( + div() + .text_xs() + .text_color(muted) + .font_family(mono.clone()) + .child(format_time(record.timestamp, show_millis)), + ); + + let mut body = v_flex() + .flex_1() + .min_w(px(0.)) + .px_3() + .py_2() + .child(header) + .child( + div() + .text_sm() + .text_color(cx.theme().foreground) + .pt_0p5() + .font_family(mono.clone()) + .child(preview_payload(&record.payload)), + ); + + if expanded { + let fmt = self.detail_format; + let detail_text = format_payload_detail(&record.payload, record.raw_bytes.as_deref(), fmt); + let mut details = v_flex().gap_1p5().pt_1(); + + // 格式切换 + 复制按钮;stop_propagation 避免点击时又收起整行 + let fmt_group = ButtonGroup::new(SharedString::from(format!("msg-fmt-{}", seq))) + .compact() + .children( + [DetailFormat::Auto, DetailFormat::Text, DetailFormat::Hex, DetailFormat::Base64] + .into_iter() + .map(|f| { + Button::new(SharedString::from(format!( + "msg-fmt-btn-{}-{}", + seq, + f.index() + ))) + .label(f.label()) + .small() + .selected(f == fmt) + }), + ) + .on_click(cx.listener(|this, ixs: &Vec, _, cx| { + cx.stop_propagation(); + if let Some(&i) = ixs.first() { + this.detail_format = DetailFormat::from_index(i); + cx.notify(); + } + })); + + let copy_topic = record.topic.clone(); + let copy_payload = record.payload.clone(); + let copy_detail = details_copy_text(record, show_millis); + let detail_toolbar = h_flex() + .gap_1() + .items_center() + .child(fmt_group) + .child(div().flex_1()) + .child( + Button::new(SharedString::from(format!("msg-copy-topic-{}", seq))) + .icon(IconName::Copy) + .label("主题") + .ghost() + .xsmall() + .tooltip("复制主题") + .on_click(move |_, window, cx| { + cx.stop_propagation(); + cx.write_to_clipboard(ClipboardItem::new_string(copy_topic.to_string())); + window.push_notification(Notification::success("已复制主题"), cx); + }), + ) + .child( + Button::new(SharedString::from(format!("msg-copy-payload-{}", seq))) + .icon(IconName::Copy) + .label("负载") + .ghost() + .xsmall() + .tooltip("复制负载原文") + .on_click(move |_, window, cx| { + cx.stop_propagation(); + cx.write_to_clipboard(ClipboardItem::new_string(copy_payload.to_string())); + window.push_notification(Notification::success("已复制负载"), cx); + }), + ) + .child( + Button::new(SharedString::from(format!("msg-copy-detail-{}", seq))) + .icon(IconName::Copy) + .label("详情") + .ghost() + .xsmall() + .tooltip("复制完整详情(含 v5 属性)") + .on_click(move |_, window, cx| { + cx.stop_propagation(); + cx.write_to_clipboard(ClipboardItem::new_string(copy_detail.clone())); + window.push_notification(Notification::success("已复制详情"), cx); + }), + ); + details = details.child(detail_toolbar).child( + div() + .font_family(mono.clone()) + .text_xs() + .p_2() + .rounded_md() + .bg(cx.theme().background) + .border_1() + .border_color(border) + .child(detail_text), + ); + + if !record.user_properties.is_empty() { + let props: String = record + .user_properties + .iter() + .map(|(k, v)| format!("{k} = {v}")) + .collect::>() + .join("\n"); + details = details + .child(div().text_xs().font_semibold().child("用户属性")) + .child( + div() + .font_family(mono.clone()) + .text_xs() + .child(props), + ); + } + for (label, value) in [ + ("Content-Type", record.content_type.clone()), + ("Response Topic", record.response_topic.clone()), + ("Correlation Data", record.correlation_data.clone()), + ( + "消息过期", + record + .message_expiry_interval + .map(|v| format!("{v} 秒")), + ), + ( + "订阅标识符", + record.subscription_identifier.map(|v| v.to_string()), + ), + ] { + if let Some(v) = value { + details = details.child( + h_flex() + .gap_1() + .text_xs() + .child(div().text_color(muted).child(format!("{label}:"))) + .child(div().font_family(mono.clone()).child(v)), + ); + } + } + body = body.child( + div() + .id(SharedString::from(format!("msg-detail-{}", seq))) + .mt_1() + .p_2() + .rounded_lg() + .bg(cx.theme().muted) + // 详情区点击不冒泡到行折叠,保证文本可选中 + .on_click(|_, _, cx| { + cx.stop_propagation(); + }) + .child(details), + ); + } + + h_flex() + .id(SharedString::from(format!( + "msg-{}-{}", + record.connection_id, record.seq + ))) + .w_full() + .border_b_1() + .border_color(border) + .hover(move |this| this.bg(hover_bg)) + .cursor_pointer() + // 左侧色条:有订阅色时显示,否则透明以保持行内对齐 + .child( + div() + .w(px(3.)) + .self_stretch() + .rounded_full() + .bg(sub_color.map(hue_color).unwrap_or(hsla(0., 0., 0., 0.))), + ) + .child(body) + .on_click(cx.listener(move |this, _, _, cx| { + if this.expanded == Some(seq) { + this.expanded = None; + } else { + // 切换到另一条消息时格式回到 Auto,单条内折叠/展开仍记忆选择 + if this.detail_owner != Some(seq) { + this.detail_format = DetailFormat::Auto; + this.detail_owner = Some(seq); + } + this.expanded = Some(seq); + } + cx.notify(); + })) + } + +pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement { + let query = self.filter.read(cx).value().to_lowercase(); + let conn = self.conn_id.clone(); + let dir = self.msg_dir; + let sub_filter = self.sub_filter.clone(); + // 引用过滤 + 计数命中总数,仅克隆前 MAX_RENDERED_MESSAGES 条,避免全量拷贝 + let (records, subs, matched_total, total): ( + Vec, + Vec, + usize, + usize, + ) = self + .with_app(cx, |app| { + let subs: Vec = app + .subscriptions + .iter() + .filter(|s| s.connection_id == conn) + .cloned() + .collect(); + let mut matched = 0usize; + let mut records: Vec = Vec::new(); + let total = app.messages.get(conn.as_str()).map_or(0, |m| m.len()); + if let Some(m) = app.messages.get(conn.as_str()) { + for r in m.iter().rev() { + // 方向 / 搜索 / 订阅过滤叠加,过滤后再截断保证最新消息优先 + let dir_ok = match dir { + DirFilter::All => true, + DirFilter::Received => r.direction == Direction::Received, + DirFilter::Published => r.direction == Direction::Published, + }; + let query_ok = query.is_empty() + || contains_ignore_case(&r.topic, &query) + || contains_ignore_case(&r.payload, &query); + let sub_ok = sub_filter + .as_deref() + .is_none_or(|f| topic_matches(f, &r.topic)); + if dir_ok && query_ok && sub_ok { + matched += 1; + if records.len() < MAX_RENDERED_MESSAGES { + records.push(r.clone()); + } + } + } + } + (records, subs, matched, total) + }) + .unwrap_or_default(); + + let mut body = v_flex().flex_1().overflow_y_scrollbar(); + if records.is_empty() { + let filtering = !query.is_empty() + || sub_filter.is_some() + || dir != DirFilter::All; + body = body.child( + v_flex() + .h_full() + .items_center() + .justify_center() + .gap_3() + .p_4() + .child( + div() + .size(px(56.)) + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(cx.theme().muted) + .child( + gpui_kit::component::Icon::new(IconName::MailOpen) + .size_6() + .text_color(cx.theme().muted_foreground), + ), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(if filtering { + "没有匹配的消息" + } else { + "暂无消息,订阅主题后消息会显示在这里" + }), + ), + ); + } else { + // 最新消息在最上方(与 MQTTX 一致,避免长列表需要手动滚底) + for r in &records { + let color = pick_subscription_color(&subs, &r.topic); + body = body.child(self.render_message_row(r, color, cx)); + } + } + + // 方向过滤 seg + let dir_group = ButtonGroup::new(SharedString::from(format!( + "msg-dir-{}", + self.conn_id + ))) + .compact() + .children([DirFilter::All, DirFilter::Received, DirFilter::Published].into_iter().map( + |d| { + Button::new(SharedString::from(format!( + "msg-dir-btn-{}-{}", + self.conn_id, + d.index() + ))) + .label(d.label()) + .small() + .selected(d == dir) + }, + )) + .on_click(cx.listener(|this, ixs: &Vec, _, cx| { + if let Some(&i) = ixs.first() { + this.msg_dir = DirFilter::from_index(i); + cx.notify(); + } + })); + + // 订阅过滤 chip(点击订阅项激活) + let filter_chip = self.sub_filter.clone().map(|topic| { + h_flex() + .gap_1() + .items_center() + .px_1p5() + .py_0p5() + .rounded_md() + .bg(cx.theme().secondary) + .max_w(px(180.)) + .child( + gpui_kit::component::Icon::new(IconName::ListFilter) + .xsmall() + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .text_xs() + .overflow_hidden() + .text_ellipsis() + .child(topic), + ) + .child( + Button::new(SharedString::from(format!( + "msg-filter-clear-{}", + self.conn_id + ))) + .icon(IconName::Close) + .ghost() + .xsmall() + .tooltip("清除订阅过滤") + .on_click(cx.listener(|this, _, _, cx| { + this.sub_filter = None; + cx.notify(); + })), + ) + }); + + v_flex() + .flex_1() + .min_w(px(0.)) + .h_full() + .bg(cx.theme().background) + .child( + h_flex() + .h_11() + .px_3() + .gap_2() + .items_center() + .border_b_1() + .border_color(cx.theme().border) + .child(dir_group) + .child( + div() + .flex_1() + .min_w(px(0.)) + .child(Input::new(&self.filter).small().prefix( + gpui_kit::component::Icon::new(IconName::Search).small(), + )), + ) + .children(filter_chip) + .child( + div() + .text_xs() + .px_1p5() + .text_color(cx.theme().muted_foreground) + // 计数显示过滤命中总数,不受渲染截断影响 + .child(format!("{} 条", matched_total)), + ) + .when(matched_total > records.len(), |h| { + h.child( + div() + .text_xs() + .px_1p5() + .text_color(cx.theme().muted_foreground) + .child(format!("仅显示最新 {} 条", MAX_RENDERED_MESSAGES)), + ) + }) + .child({ + let weak = cx.weak_entity(); + let clear_id = self.conn_id.clone(); + // 清空针对连接的全部消息(非过滤结果): + // 禁用与否依据总数,过滤命中 0 条不应禁用 + let empty = total == 0; + Button::new(SharedString::from(format!("msg-clear-{}", self.conn_id))) + .icon(IconName::Eraser) + .label("清空") + .ghost() + .small() + .disabled(empty) + .tooltip("清空当前连接的消息") + .on_click(move |_, window, cx| { + // 清空不可撤销,先弹二次确认 + let weak = weak.clone(); + let clear_id = clear_id.clone(); + window.open_alert_dialog(cx, move |alert, _, _| { + let weak = weak.clone(); + let clear_id = clear_id.clone(); + alert + .title("清空消息") + .description("确定清空当前连接的全部消息吗?此操作不可撤销。") + .button_props( + DialogButtonProps::default() + .show_cancel(true) + .cancel_text("取消") + .ok_text("清空") + .ok_variant(ButtonVariant::Danger), + ) + .on_ok(move |_, _, cx| { + weak.update(cx, |view, cx| { + view.expanded = None; + view.detail_owner = None; + view.detail_format = DetailFormat::Auto; + if let Some(app) = view.app.upgrade() { + app.update(cx, |app, cx| { + app.clear_messages(&clear_id); + cx.notify(); + }); + } + cx.notify(); + }) + .ok(); + true + }) + }); + }) + }), + ) + .child(body) + } + +pub(super) fn render_logs(&self, cx: &mut Context) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + let conn = self.conn_id.clone(); + let show_millis = self + .with_app(cx, |app| app.settings.show_millis) + .unwrap_or(true); + let logs: Vec<_> = self + .with_app(cx, |app| { + app.logs + .iter() + .rev() + .filter(|l| l.connection_id == conn) + // 与 app.rs 的 MAX_LOGS=3000 对齐,避免渲染截断早于存储上限 + .take(MAX_RENDERED_LOGS) + .cloned() + .collect() + }) + .unwrap_or_default(); + + let mut body = v_flex().flex_1().overflow_y_scrollbar().p_2().gap_0p5(); + if logs.is_empty() { + body = body.child( + v_flex() + .p_4() + .gap_2() + .items_center() + .child( + gpui_kit::component::Icon::new(IconName::ScrollText) + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("暂无日志"), + ), + ); + } + let log_hover = cx.theme().muted; + for l in logs { + let color = match l.level { + LogLevel::Error => cx.theme().danger, + LogLevel::Warn => cx.theme().warning, + LogLevel::Debug => cx.theme().muted_foreground, + LogLevel::Info => cx.theme().foreground, + }; + body = body.child( + h_flex() + .gap_2() + .px_2() + .py_1() + .rounded_md() + .hover(move |this| this.bg(log_hover)) + .child( + div() + .text_xs() + .text_color(muted) + .font_family(cx.theme().mono_font_family.clone()) + .child(format_time(l.timestamp, show_millis)), + ) + .child( + div() + .text_xs() + .w_10() + .text_color(color) + .child(l.level.label()), + ) + .child( + div() + .text_xs() + .flex_1() + .min_w(px(0.)) + .font_family(cx.theme().mono_font_family.clone()) + .child(format!("[{}] {}", l.event, l.message)), + ), + ); + } + v_flex() + .flex_1() + .min_w(px(0.)) + .h_full() + .bg(cx.theme().background) + .child( + h_flex() + .h_11() + .px_3() + .items_center() + .gap_2() + .border_b_1() + .border_color(cx.theme().border) + .child( + div() + .text_xs() + .text_color(muted) + .font_family(cx.theme().mono_font_family.clone()) + .child("连接日志"), + ), + ) + .child(body) + } +} diff --git a/gpui-app/src/ui/connection_view/mod.rs b/gpui-app/src/ui/connection_view/mod.rs new file mode 100644 index 0000000..ae552a5 --- /dev/null +++ b/gpui-app/src/ui/connection_view/mod.rs @@ -0,0 +1,332 @@ +//! 单个连接的工作区:订阅列表 + 消息流 + 发布面板 + 日志。 +//! +//! - [`subscribe`] 订阅表单/列表的操作与渲染 +//! - [`messages`] 顶栏过滤、消息流与日志渲染 +//! - [`publish`] 发布表单/预设的操作与渲染 +//! - [`vars`] 模板变量面板 +//! - [`util`] topic 匹配、格式化等纯函数 +use std::sync::Arc; + +use gpui_kit::component::button::ButtonVariants as _; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::input::{InputState, TextareaState}; +use gpui_kit::component::menu::DropdownMenu as _; +use gpui_kit::component::select::SelectState; +use gpui_kit::component::tab::{Tab, TabBar}; +use gpui_kit::component::{ + h_flex, v_flex, ActiveTheme as _, Disableable as _, + Selectable as _, Sizable as _, StyledExt as _, WindowExt as _, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + px, App, AppContext as _, Context, Entity, + InteractiveElement as _, IntoElement, ParentElement as _, Render, + StatefulInteractiveElement as _, Styled as _, Subscription as InputSubscription, Window, +}; + +use crate::model::ConnectionConfig; +use crate::mqtt::MqttEngine; +use crate::ui::app::MqttXApp; +use crate::ui::widgets::{make_select, KvEditor, OptionDelegate}; + +mod messages; +mod publish; +mod subscribe; +mod util; +mod vars; +use util::*; + +const QOS: [&str; 3] = ["QoS 0", "QoS 1", "QoS 2"]; +const PAYLOAD_FORMATS: [&str; 4] = ["Plaintext", "JSON", "Base64", "Hex"]; +/// Retain Handling 0/1/2 的下拉文案 +const RETAIN_HANDLING: [&str; 3] = ["0 每次发送", "1 仅新订阅", "2 不发送"]; +const MAX_RENDERED_MESSAGES: usize = 300; +/// 日志渲染上限,与 app.rs 的 MAX_LOGS 保持一致 +const MAX_RENDERED_LOGS: usize = 3000; +/// 订阅色板预设:色相(度)+ 名称 +const PRESET_HUES: [(f32, &str); 10] = [ + (0., "红"), + (30., "橙"), + (60., "黄"), + (120., "绿"), + (160., "青"), + (200., "蓝"), + (240., "靛"), + (270., "紫"), + (300., "品红"), + (330., "玫红"), +]; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Panel { + Messages, + Logs, +} + +/// 消息流方向过滤(与搜索、订阅过滤叠加生效) +#[derive(Clone, Copy, PartialEq, Eq)] +enum DirFilter { + All, + Received, + Published, +} + +impl DirFilter { + fn label(self) -> &'static str { + match self { + Self::All => "全部", + Self::Received => "接收", + Self::Published => "发布", + } + } + + fn index(self) -> usize { + match self { + Self::All => 0, + Self::Received => 1, + Self::Published => 2, + } + } + + fn from_index(i: usize) -> Self { + match i { + 1 => Self::Received, + 2 => Self::Published, + _ => Self::All, + } + } +} + +/// 展开消息详情里的 payload 展示格式。任一时刻只展开一条消息, +/// 视图级单值即可视作「逐消息」选择。 +#[derive(Clone, Copy, PartialEq, Eq)] +enum DetailFormat { + Auto, + Text, + Hex, + Base64, +} + +impl DetailFormat { + fn label(self) -> &'static str { + match self { + Self::Auto => "自动", + Self::Text => "文本", + Self::Hex => "Hex", + Self::Base64 => "Base64", + } + } + + fn index(self) -> usize { + match self { + Self::Auto => 0, + Self::Text => 1, + Self::Hex => 2, + Self::Base64 => 3, + } + } + + fn from_index(i: usize) -> Self { + match i { + 1 => Self::Text, + 2 => Self::Hex, + 3 => Self::Base64, + _ => Self::Auto, + } + } +} + +pub struct ConnectionView { + conn_id: String, + app: gpui_kit::WeakEntity, + engine: Arc, + + // ── 订阅 ── + sub_topic: Entity, + sub_qos: Entity>, + sub_alias: Entity, + // v5 高级订阅选项 + sub_identifier: Entity, + sub_no_local: bool, + sub_rap: bool, + sub_retain_handling: Entity>, + sub_show_advanced: bool, + /// 编辑中的旧主题:提交时用旧主题移除原订阅,避免改名过程丢数据 + editing: Option, + /// 点击订阅项激活的消息过滤主题(再点一次取消) + sub_filter: Option, + + // 发布 + pub_topic: Entity, + payload: Entity, + pub_qos: Entity>, + payload_format: Entity>, + retain: bool, + content_type: Entity, + user_props: Entity, + msg_expiry: Entity, + response_topic: Entity, + correlation_data: Entity, + show_props: bool, + + // 过滤与面板 + filter: Entity, + panel: Panel, + expanded: Option, + /// 当前 detail_format 归属的消息:切换到另一条消息时据此重置格式, + /// 同一条内折叠/展开则保留用户选择 + detail_owner: Option, + msg_dir: DirFilter, + detail_format: DetailFormat, + + // 预设 + preset_name: Entity, + + // 发布面板内联变量绑定 + /// 「变量」小节展开状态(仅在提取到占位符时渲染整节) + show_vars: bool, + /// 提取到的 `{{key}}` → 值输入框与变更订阅,按提取顺序排列 + var_rows: Vec<(String, Entity, InputSubscription)>, +} + +impl ConnectionView { + pub fn new( + conn_id: String, + app: gpui_kit::WeakEntity, + engine: std::sync::Arc, + window: &mut Window, + cx: &mut Context, + ) -> Self { + + let sub_topic = cx.new(|cx| { + InputState::new(window, cx).placeholder("订阅主题,支持 # +,多个用逗号/空格分隔") + }); + let sub_qos = make_select(&QOS, 0, window, cx); + let sub_alias = cx.new(|cx| InputState::new(window, cx).placeholder("别名(可选)")); + let sub_identifier = cx.new(|cx| InputState::new(window, cx).placeholder("订阅标识符")); + let sub_retain_handling = make_select(&RETAIN_HANDLING, 0, window, cx); + let pub_topic = cx.new(|cx| InputState::new(window, cx).placeholder("发布主题")); + let payload = cx.new(|cx| { + TextareaState::new(window, cx) + .placeholder("输入消息负载,支持 {{变量名}} 与 {{$ts}} {{$uuid}}") + }); + let pub_qos = make_select(&QOS, 0, window, cx); + let payload_format = make_select(&PAYLOAD_FORMATS, 0, window, cx); + let content_type = cx.new(|cx| InputState::new(window, cx).placeholder("Content-Type(可选)")); + let msg_expiry = cx.new(|cx| InputState::new(window, cx).placeholder("秒,如 60")); + let response_topic = + cx.new(|cx| InputState::new(window, cx).placeholder("Response Topic(可选)")); + let correlation_data = + cx.new(|cx| InputState::new(window, cx).placeholder("Correlation Data(可选)")); + let filter = cx.new(|cx| InputState::new(window, cx).placeholder("过滤主题或内容…")); + let user_props = cx.new(|cx| KvEditor::new("pub", &[], window, cx)); + let preset_name = + cx.new(|cx| InputState::new(window, cx).placeholder("预设名称,如 温度上报")); + + Self { + conn_id, + app, + engine, + sub_topic, + sub_qos, + sub_alias, + sub_identifier, + sub_no_local: false, + sub_rap: false, + sub_retain_handling, + sub_show_advanced: false, + editing: None, + sub_filter: None, + pub_topic, + payload, + pub_qos, + payload_format, + retain: false, + content_type, + user_props, + msg_expiry, + response_topic, + correlation_data, + show_props: false, + filter, + panel: Panel::Messages, + expanded: None, + detail_owner: None, + msg_dir: DirFilter::All, + detail_format: DetailFormat::Auto, + preset_name, + show_vars: true, + var_rows: Vec::new(), + } + } + + fn with_app(&self, cx: &App, f: impl FnOnce(&MqttXApp) -> R) -> Option { + self.app.upgrade().map(|a| f(a.read(cx))) + } + + fn config(&self, cx: &App) -> Option { + let id = self.conn_id.clone(); + self.with_app(cx, |app| { + app.connections.iter().find(|c| c.id == id).cloned() + }) + .flatten() + } + + fn is_v5(&self, cx: &App) -> bool { + self.config(cx).map(|c| c.protocol.is_v5()).unwrap_or(true) + } + + +} + +impl Render for ConnectionView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let panel = self.panel; + v_flex() + .size_full() + .min_w(px(0.)) + .child(self.render_top_bar(cx)) + .child(self.render_subscribe_bar(cx)) + .child( + h_flex() + .flex_1() + .min_h(px(0.)) + .child(self.render_subscriptions(cx)) + .child( + v_flex() + .flex_1() + .min_w(px(0.)) + .h_full() + .child( + TabBar::new("workspace-tabs") + .segmented() + .selected_index(if panel == Panel::Messages { 0 } else { 1 }) + .child( + Tab::new() + .label("消息流") + .selected(panel == Panel::Messages) + .on_click(cx.listener(|this, _, _, cx| { + this.panel = Panel::Messages; + cx.notify(); + })), + ) + .child( + Tab::new() + .label("日志") + .selected(panel == Panel::Logs) + .on_click(cx.listener(|this, _, _, cx| { + this.panel = Panel::Logs; + cx.notify(); + })), + ), + ) + .child(match panel { + Panel::Messages => self.render_messages(cx).into_any_element(), + Panel::Logs => self.render_logs(cx).into_any_element(), + }), + ), + ) + .child(self.render_publish_bar(cx)) + } +} + diff --git a/gpui-app/src/ui/connection_view/publish.rs b/gpui-app/src/ui/connection_view/publish.rs new file mode 100644 index 0000000..2a2973c --- /dev/null +++ b/gpui-app/src/ui/connection_view/publish.rs @@ -0,0 +1,576 @@ +//! 发布:参数收集/发送、发布预设菜单与保存对话框。 + + +use gpui_kit::component::button::{ Button, ButtonVariant }; +use gpui_kit::component::dialog::DialogButtonProps; +use gpui_kit::component::input::{ Input, InputState, Textarea }; +use gpui_kit::component::menu::{ PopupMenuItem }; +use gpui_kit::component::select::Select; +use gpui_kit::component::switch::Switch; +use gpui_kit::component::{ h_flex, notification::Notification, v_flex, }; +use crate::ui::IconName; +use gpui_kit::{ div, px, App, Context, Entity, IntoElement, SharedString, Window, }; +use gpui_kit::component::IndexPath; + +use crate::model::{ render_template, PayloadFormat, PublishParams, }; +use crate::ui::widgets::{ field, KvEditor }; + +use super::*; + +impl ConnectionView { + fn do_publish(&mut self, window: &mut Window, cx: &mut Context) { + // 未连接时消息发不出去,先拦截给出与订阅路径一致的提示 + if !self.engine.is_connected(&self.conn_id) { + window.push_notification(Notification::warning("未连接:消息未发送"), cx); + return; + } + let mut params = self.collect_publish_params(cx); + if params.topic.trim().is_empty() { + window.push_notification(Notification::warning("发布主题不能为空"), cx); + return; + } + // 过期秒数输入非法时 collect 会静默得到 None,这里显式拦截避免误发 + let expiry_raw = self.msg_expiry.read(cx).value().to_string(); + if !expiry_raw.trim().is_empty() && params.message_expiry_interval.is_none() { + window.push_notification(Notification::warning("消息过期须为非负整数(秒)"), cx); + return; + } + + let vars = self.with_app(cx, |app| app.variables.clone()).unwrap_or_default(); + params.topic = render_template(¶ms.topic, &vars); + params.payload = render_template(¶ms.payload, &vars); + // {{topic}} 等变量渲染后可能变空串,渲染完成后再校验一次 + if params.topic.trim().is_empty() { + window.push_notification(Notification::warning("发布主题不能为空"), cx); + return; + } + // {{变量}} 同样作用于用户属性的 key/value + params.user_properties = params + .user_properties + .into_iter() + .map(|(k, v)| (render_template(&k, &vars), render_template(&v, &vars))) + .collect(); + // v5 文本属性(Content-Type / Response Topic / Correlation Data)一并注入 + params.content_type = params.content_type.map(|v| render_template(&v, &vars)); + params.response_topic = params.response_topic.map(|v| render_template(&v, &vars)); + params.correlation_data = params.correlation_data.map(|v| render_template(&v, &vars)); + + if let PayloadFormat::Json = params.payload_format + && let Err(e) = serde_json::from_str::(¶ms.payload) { + window.push_notification( + Notification::warning(format!("JSON 格式无效: {e}")), + cx, + ); + return; + } + match params.payload_format.encode(¶ms.payload) { + Ok(bytes) => params.raw_bytes = Some(bytes), + Err(e) => { + window.push_notification(Notification::warning(e), cx); + return; + } + } + + // v3.1.1 忽略 v5 属性,避免残留输入带进发布报文 + if !self.is_v5(cx) { + params.user_properties.clear(); + params.content_type = None; + params.message_expiry_interval = None; + params.response_topic = None; + params.correlation_data = None; + } + self.engine.publish(self.conn_id.clone(), params); + } + + /// 读取当前发布面板上的参数(模板渲染前的原始值);保存预设与发布共用, + /// 须覆盖全部 v5 属性与用户属性。 + fn collect_publish_params(&self, cx: &App) -> PublishParams { + let qos = self.pub_qos.read(cx).selected_value().copied().unwrap_or(0) as u8; + let format = PayloadFormat::ALL + [self.payload_format.read(cx).selected_value().copied().unwrap_or(0)]; + let opt = |s: String| { + let t = s.trim(); + (!t.is_empty()).then(|| t.to_string()) + }; + PublishParams { + topic: self.pub_topic.read(cx).value().to_string(), + payload: self.payload.read(cx).value().to_string(), + payload_format: format, + qos, + retain: self.retain, + user_properties: self.user_props.read(cx).pairs(cx), + content_type: opt(self.content_type.read(cx).value().to_string()), + message_expiry_interval: { + let t = self.msg_expiry.read(cx).value().to_string(); + let t = t.trim(); + if t.is_empty() { None } else { t.parse::().ok() } + }, + response_topic: opt(self.response_topic.read(cx).value().to_string()), + correlation_data: opt(self.correlation_data.read(cx).value().to_string()), + ..Default::default() + } + } + + /// 把预设应用到发布面板控件。 + fn apply_publish_preset( + &mut self, + params: &PublishParams, + window: &mut Window, + cx: &mut Context, + ) { + self.pub_topic + .update(cx, |s, cx| s.set_value(params.topic.clone(), window, cx)); + self.payload + .update(cx, |s, cx| s.set_value(params.payload.clone(), window, cx)); + // 预设未指定 Content-Type 时清空旧值,避免上一次输入残留 + let ct = params.content_type.clone().unwrap_or_default(); + self.content_type + .update(cx, |s, cx| s.set_value(ct, window, cx)); + let expiry = params + .message_expiry_interval + .map(|v| v.to_string()) + .unwrap_or_default(); + self.msg_expiry + .update(cx, |s, cx| s.set_value(expiry, window, cx)); + let rt = params.response_topic.clone().unwrap_or_default(); + self.response_topic + .update(cx, |s, cx| s.set_value(rt, window, cx)); + let cd = params.correlation_data.clone().unwrap_or_default(); + self.correlation_data + .update(cx, |s, cx| s.set_value(cd, window, cx)); + // KvEditor 不支持运行时整体替换,直接重建实体回填用户属性 + self.user_props = cx.new(|cx| KvEditor::new("pub", ¶ms.user_properties, window, cx)); + let qos_idx = params.qos.min(2) as usize; + self.pub_qos.update(cx, |s, cx| { + s.set_selected_index(Some(IndexPath::new(qos_idx)), window, cx) + }); + let fmt_idx = PayloadFormat::ALL + .iter() + .position(|f| *f == params.payload_format) + .unwrap_or(0); + self.payload_format.update(cx, |s, cx| { + s.set_selected_index(Some(IndexPath::new(fmt_idx)), window, cx) + }); + self.retain = params.retain; + // 同步模板变量面板:预设替换了模板,旧占位符行必须随之清理, + // 否则残留行仍会写回全局变量 + self.sync_var_rows(window, cx); + cx.notify(); + } + +pub(super) fn render_publish_bar(&mut self, cx: &mut Context) -> impl IntoElement { + let pub_topic = self.pub_topic.clone(); + let payload = self.payload.clone(); + let pub_qos = self.pub_qos.clone(); + let payload_format = self.payload_format.clone(); + let content_type = self.content_type.clone(); + let msg_expiry = self.msg_expiry.clone(); + let response_topic = self.response_topic.clone(); + let correlation_data = self.correlation_data.clone(); + let user_props = self.user_props.clone(); + let retain = self.retain; + let show_props = self.show_props; + let show_vars = self.show_vars; + let is_v5 = self.is_v5(cx); + + // 发布预设 + let presets = self.with_app(cx, |app| app.presets.clone()).unwrap_or_default(); + let connected = self.engine.is_connected(&self.conn_id); + // 当前提取到的占位符数量(仅用于提示,行构建在 sync_var_rows 完成) + let var_count = extract_placeholder_keys( + &self.pub_topic.read(cx).value(), + &self.payload.read(cx).value(), + ) + .len(); + + let mut card = v_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().border) + .bg(cx.theme().secondary) + .px_3() + .py_2() + .gap_2(); + + card = card.child( + h_flex() + .gap_2() + .items_center() + // Ctrl+Enter 快捷发送(焦点在主题输入上时) + .child( + div() + .id(SharedString::from(format!("pub-topic-wrap-{}", self.conn_id))) + .flex_1() + .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" { + cx.stop_propagation(); + this.do_publish(window, cx); + } + }, + )) + .child(Input::new(&pub_topic).small()), + ) + .child(div().w(px(92.)).child(Select::new(&pub_qos).small())) + .child(div().w(px(124.)).child(Select::new(&payload_format).small())) + .child(self.render_preset_menu(presets, cx)) + .child({ + // 变量注入开关:展开/收起内联绑定面板 + let count = var_count; + Button::new("vars-toggle") + .icon(IconName::Variable) + .ghost() + .small() + .when(show_vars, |b| b.selected(true)) + .tooltip(if count > 0 { + format!("变量注入({count} 个占位符)") + } else { + "变量注入".to_string() + }) + .on_click(cx.listener(|this, _, window, cx| { + if this.show_vars { + this.show_vars = false; + cx.notify(); + } else { + this.sync_var_rows(window, cx); + } + })) + }) + .child( + h_flex() + .gap_1() + .items_center() + .child(Switch::new("retain").checked(retain).on_change( + cx.listener(|this, v, _, cx| { + this.retain = *v; + cx.notify(); + }), + )) + .child(div().text_xs().child("Retain")), + ) + .when(is_v5, |h| { + h.child( + Button::new("toggle-props") + .icon(IconName::SlidersHorizontal) + .ghost() + .small() + .when(show_props, |b| b.selected(true)) + .tooltip("MQTT 5 属性") + .on_click(cx.listener(|this, _, _, cx| { + this.show_props = !this.show_props; + cx.notify(); + })), + ) + }) + .child( + Button::new(SharedString::from(format!("publish-{}", self.conn_id))) + .icon(IconName::SendHorizontal) + .label("发送") + .primary() + .small() + // 未连接时禁用,Ctrl+Enter 路径由 do_publish 内的前置检查兜底 + .disabled(!connected) + .when(!connected, |b| b.tooltip("未连接,无法发送")) + .on_click(cx.listener(|this, _, window, cx| this.do_publish(window, cx))), + ), + ); + + card = card.child( + div() + .id(SharedString::from(format!("payload-wrap-{}", self.conn_id))) + .h(px(110.)) + .rounded_md() + .border_1() + .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); + } + }, + )) + .child(Textarea::new(&payload)), + ); + + // 内联变量绑定面板:仅在展开且已提取到占位符时渲染 + if show_vars && !self.var_rows.is_empty() { + card = card.child(self.render_vars_panel(cx)); + } + + if show_props && is_v5 { + card = card.child( + v_flex() + .gap_2() + .w_full() + .child( + h_flex() + .gap_2() + .w_full() + .child( + div().w(px(220.)).child(field( + "Content-Type", + Input::new(&content_type).small(), + )), + ) + .child( + div().w(px(120.)).child(field( + "消息过期(秒)", + Input::new(&msg_expiry).small(), + )), + ) + .child( + div().flex_1().min_w(px(0.)).child(field( + "Response Topic", + Input::new(&response_topic).small(), + )), + ) + .child( + div().flex_1().min_w(px(0.)).child(field( + "Correlation Data", + Input::new(&correlation_data).small(), + )), + ), + ) + .child(user_props.clone()), + ); + } + card + } + + /// 发布预设下拉:应用已有预设、保存当前、删除预设。 + fn render_preset_menu( &self, + presets: Vec, + cx: &mut Context, + ) -> impl IntoElement { + let weak_view = cx.weak_entity(); + let preset_name = self.preset_name.clone(); + let app_weak = self.app.clone(); + let has = !presets.is_empty(); + Button::new(SharedString::from(format!("preset-menu-{}", self.conn_id))) + .icon(IconName::Bookmark) + .ghost() + .small() + .tooltip("消息预设") + .dropdown_menu(move |mut menu, window, cx| { + // 应用预设 + for p in &presets { + let weak = weak_view.clone(); + let params = p.params.clone(); + menu = menu.item( + PopupMenuItem::new(SharedString::from(p.name.clone())).on_click( + move |_ev, window, cx| { + if let Some(view) = weak.upgrade() { + view.update(cx, |view, cx| { + view.apply_publish_preset(¶ms, window, cx); + }); + } + }, + ), + ); + } + if has { + menu = menu.separator(); + } + // 保存当前 + let weak_save = weak_view.clone(); + let name_input = preset_name.clone(); + menu = menu.item(PopupMenuItem::new("保存当前为预设…").on_click( + move |_ev, window, cx| { + if let Some(view) = weak_save.upgrade() { + view.update(cx, |view, cx| { + view.open_save_preset_dialog(window, cx, name_input.clone()); + }); + } + }, + )); + // 打开预设管理对话框(新建 / 编辑 / 删除) + let manage_app = app_weak.clone(); + menu = menu.item(PopupMenuItem::new("管理预设…").on_click( + move |_ev, window, cx| { + if let Some(app) = manage_app.upgrade() { + crate::ui::dialogs::presets_dialog::open(app, window, cx); + } + }, + )); + // 删除子菜单 + if has { + let weak_del = weak_view.clone(); + let del_presets = presets.clone(); + menu = menu.submenu( + "删除预设", + window, + cx, + move |submenu, _window, _cx| { + let mut submenu = submenu; + for p in &del_presets { + let weak2 = weak_del.clone(); + let id = p.id.clone(); + let name = p.name.clone(); + let label = format!("删除「{}」", p.name); + submenu = submenu.item( + PopupMenuItem::new(SharedString::from(label)).on_click( + move |_ev, window, cx| { + // 预设删除不可撤销,先弹二次确认 + let weak = weak2.clone(); + let pid = id.clone(); + let pname = name.clone(); + window.open_alert_dialog(cx, move |alert, _, _| { + let weak = weak.clone(); + let pid = pid.clone(); + alert + .title("删除预设") + .description(format!( + "确定删除预设「{pname}」吗?此操作不可撤销。" + )) + .button_props( + DialogButtonProps::default() + .show_cancel(true) + .cancel_text("取消") + .ok_text("删除") + .ok_variant(ButtonVariant::Danger), + ) + .on_ok(move |_, _, cx| { + weak.update(cx, |view, cx| { + if let Some(app) = view.app.upgrade() { + app.update(cx, |app, cx| { + app.delete_publish_preset(&pid); + cx.notify(); + }); + } + cx.notify(); + }) + .ok(); + true + }) + }); + }, + ), + ); + } + submenu + }, + ); + } + menu + }) + } + + /// 打开"保存为预设"命名对话框,保存当前发布参数。 + fn open_save_preset_dialog( + &mut self, + window: &mut Window, + cx: &mut Context, + name_input: Entity, + ) { + // 预填默认名 + let topic = self.pub_topic.read(cx).value().to_string(); + let default_name = if topic.is_empty() { + "新预设".to_string() + } else { + topic.chars().take(20).collect() + }; + name_input.update(cx, |s, cx| s.set_value(default_name, window, cx)); + + let weak = cx.weak_entity(); + let name_for_ok = name_input.clone(); + window.open_dialog(cx, move |dialog, _, _| { + dialog + .w(px(420.)) + .title("保存消息预设") + .child(v_flex().gap_3().child( + crate::ui::widgets::field("预设名称", Input::new(&name_for_ok)), + )) + .footer( + h_flex() + .gap_2() + .justify_end() + .w_full() + .child( + Button::new("preset-save-cancel") + .label("取消") + .outline() + .on_click(move |_, window, cx| window.close_dialog(cx)), + ) + .child( + Button::new("preset-save-ok") + .label("保存") + .primary() + .on_click({ + let name_input = name_for_ok.clone(); + let weak = weak.clone(); + move |_ev, window, cx| { + let name = name_input + .read(cx) + .value() + .trim() + .to_string(); + if name.is_empty() { + window.push_notification( + Notification::warning("请填写预设名称"), + cx, + ); + return; + } + // 复用 do_publish 的过期秒数校验:输入了但解析不出即拒绝 + let params = weak + .read_with(cx, |view, cx| { + let expiry_raw = view + .msg_expiry + .read(cx) + .value() + .to_string(); + ( + view.collect_publish_params(cx), + expiry_raw, + ) + }) + .ok(); + if let (Some(view), Some((params, expiry_raw))) = + (weak.upgrade(), params) + { + if !expiry_raw.trim().is_empty() + && params.message_expiry_interval.is_none() + { + window.push_notification( + Notification::warning( + "消息过期须为非负整数(秒)", + ), + cx, + ); + return; + } + if let Some(app) = view.read(cx).app.upgrade() { + // 同名即覆盖,提示语区分以明确发生了什么 + let existed = app + .read(cx) + .presets + .iter() + .any(|p| p.name == name); + app.update(cx, |app, cx| { + app.upsert_publish_preset(name.clone(), params); + cx.notify(); + }); + let msg = if existed { + format!("已覆盖预设「{name}」") + } else { + "预设已保存".to_string() + }; + window.push_notification( + Notification::success(msg), + cx, + ); + } + } + window.close_dialog(cx); + } + }), + ), + ) + }); + } +} diff --git a/gpui-app/src/ui/connection_view/subscribe.rs b/gpui-app/src/ui/connection_view/subscribe.rs new file mode 100644 index 0000000..b6dc800 --- /dev/null +++ b/gpui-app/src/ui/connection_view/subscribe.rs @@ -0,0 +1,718 @@ +//! 订阅:表单操作、订阅列表渲染与主题配色。 + + +use gpui_kit::component::button::{ Button }; +use gpui_kit::component::input::Input; +use gpui_kit::component::menu::{ PopupMenuItem }; +use gpui_kit::component::select::Select; +use gpui_kit::component::switch::Switch; +use gpui_kit::component::{ h_flex, notification::Notification, v_flex, }; +use crate::ui::IconName; +use gpui_kit::{ div, hsla, px, Context, IntoElement, SharedString, Window, }; +use gpui_kit::component::IndexPath; + +use crate::model::{ SubscribeOptions, Subscription, }; +use crate::ui::widgets::field; + +use super::*; + +impl ConnectionView { + /// 订阅按 (连接, 主题) 唯一:先摘掉旧条目再走 `add_subscription`, + /// 借它入列 + `save_subscriptions` 落盘的逻辑完成 upsert(storage 字段是私有的)。 + /// 落盘顺序以 add 时为准,入列后移回原位以保持界面顺序稳定。 + fn upsert_subscription(&self, sub: Subscription, cx: &mut Context) { + let Some(app_entity) = self.app.upgrade() else { + return; + }; + app_entity.update(cx, |app, cx| { + let slot = app + .subscriptions + .iter() + .position(|s| s.connection_id == sub.connection_id && s.topic == sub.topic); + app.subscriptions.retain(|s| { + !(s.connection_id == sub.connection_id && s.topic == sub.topic) + }); + let new_id = sub.id.clone(); + app.add_subscription(sub); + if let Some(pos) = slot + && let Some(from) = app.subscriptions.iter().position(|s| s.id == new_id) + { + let moved = app.subscriptions.remove(from); + app.subscriptions.insert(pos.min(app.subscriptions.len()), moved); + } + cx.notify(); + }); + cx.notify(); + } + + fn set_subscription_color(&mut self, sub_id: &str, hue: Option, cx: &mut Context) { + let sub = self + .with_app(cx, |app| { + app.subscriptions + .iter() + .find(|s| s.id == sub_id) + .cloned() + }) + .flatten(); + if let Some(mut sub) = sub { + sub.color = hue; + self.upsert_subscription(sub, cx); + } + } + + /// 启停订阅:置灰/恢复行显示,并在已连接时对引擎退订/重订。 + fn toggle_subscription_enabled(&mut self, sub_id: &str, cx: &mut Context) { + let Some(mut sub) = self + .with_app(cx, |app| { + app.subscriptions + .iter() + .find(|s| s.id == sub_id) + .cloned() + }) + .flatten() + else { + return; + }; + sub.enabled = !sub.enabled; + let enabled = sub.enabled; + let topic = sub.topic.clone(); + // 停用的订阅不再参与消息过滤,指向它的过滤一并清掉 + if !enabled && self.sub_filter.as_deref() == Some(topic.as_str()) { + self.sub_filter = None; + } + let conn = self.conn_id.clone(); + self.upsert_subscription(sub.clone(), cx); + if self.engine.is_connected(&conn) { + if enabled { + self.engine + .subscribe_with_options(conn, sub.clone(), SubscribeOptions::from(&sub)); + } else { + self.engine.unsubscribe(conn, topic); + } + } + } + + /// 回填输入区进入编辑态;旧订阅保留到提交时才替换,中途放弃不丢数据。 + fn start_edit_subscription(&mut self, sub: &Subscription, window: &mut Window, cx: &mut Context) { + self.editing = Some(sub.topic.clone()); + self.sub_topic + .update(cx, |s, cx| s.set_value(sub.topic.clone(), window, cx)); + let qos_idx = (sub.qos.min(2)) as usize; + self.sub_qos.update(cx, |s, cx| { + s.set_selected_index(Some(IndexPath::new(qos_idx)), window, cx) + }); + let alias = sub.alias.clone().unwrap_or_default(); + self.sub_alias + .update(cx, |s, cx| s.set_value(alias, window, cx)); + let ident = sub + .sub_identifier + .map(|v| v.to_string()) + .unwrap_or_default(); + self.sub_identifier + .update(cx, |s, cx| s.set_value(ident, window, cx)); + self.sub_no_local = sub.no_local; + self.sub_rap = sub.retain_as_published; + let rh_idx = sub.retain_handling.min(2) as usize; + self.sub_retain_handling.update(cx, |s, cx| { + s.set_selected_index(Some(IndexPath::new(rh_idx)), window, cx) + }); + self.sub_show_advanced = true; + cx.notify(); + } + + /// 重置订阅表单(主题/别名/高级字段):新增提交与取消编辑共用, + /// 避免高级选项残留到下一次订阅。 + fn reset_subscribe_form(&mut self, window: &mut Window, cx: &mut Context) { + self.sub_topic + .update(cx, |s, cx| s.set_value("", window, cx)); + self.sub_alias + .update(cx, |s, cx| s.set_value("", window, cx)); + self.sub_identifier + .update(cx, |s, cx| s.set_value("", window, cx)); + self.sub_no_local = false; + self.sub_rap = false; + self.sub_retain_handling.update(cx, |s, cx| { + s.set_selected_index(Some(IndexPath::new(0)), window, cx) + }); + } + + // ── 订阅 / 发布动作 ───────────────────────────────────────────────────── + + fn do_subscribe(&mut self, window: &mut Window, cx: &mut Context) { + let raw = self.sub_topic.read(cx).value().to_string(); + let topics = split_topic_list(&raw); + if topics.is_empty() { + window.push_notification(Notification::warning("订阅主题不能为空"), cx); + return; + } + let qos = self.sub_qos.read(cx).selected_value().copied().unwrap_or(0) as u8; + let alias_raw = self.sub_alias.read(cx).value().to_string(); + let alias = { + let t = alias_raw.trim(); + (!t.is_empty()).then(|| t.to_string()) + }; + let is_v5 = self.is_v5(cx); + // v5 选项只对 v5 连接生效,v4 下不读取(避免无效输入阻断订阅) + let mut sub_identifier = None; + if is_v5 { + let ident = self.sub_identifier.read(cx).value().to_string(); + let t = ident.trim(); + if !t.is_empty() { + match t.parse::() { + Ok(v) if v > 0 => sub_identifier = Some(v), + _ => { + window.push_notification( + Notification::warning("订阅标识符须为正整数"), + cx, + ); + return; + } + } + } + } + let no_local = self.sub_no_local; + let rap = self.sub_rap; + let retain_handling = self + .sub_retain_handling + .read(cx) + .selected_value() + .copied() + .unwrap_or(0) as u8; + + let connected = self.engine.is_connected(&self.conn_id); + let conn = self.conn_id.clone(); + + // 记录编辑来源:编辑态提交后以目标订阅回填,保住编辑上下文 + let was_editing = self.editing.clone(); + // 改名编辑时旧记录随后会被删除,先取出供新主题承接 id/color/enabled + let editing_old = was_editing.as_ref().and_then(|old_topic| { + self.with_app(cx, |app| { + app.subscriptions + .iter() + .find(|s| s.connection_id == conn && s.topic == *old_topic) + .cloned() + }) + .flatten() + }); + + // 编辑提交:旧主题不在新列表里时先移除(含引擎退订),新主题由下方 upsert 覆盖 + if let Some(old_topic) = self.editing.take() + && !topics.contains(&old_topic) + { + if self.sub_filter.as_deref() == Some(old_topic.as_str()) { + self.sub_filter = None; + } + if let Some(app_entity) = self.app.upgrade() { + app_entity.update(cx, |app, cx| { + app.remove_subscription(&conn, &old_topic); + cx.notify(); + }); + } + } + + for topic in &topics { + // 按 (连接, 主题) 查旧记录:继承 id/color/enabled,避免重复提交把备注色清掉、 + // 把停用中的订阅静默重启 + let existing = self + .with_app(cx, |app| { + app.subscriptions + .iter() + .find(|s| s.connection_id == conn && s.topic == *topic) + .cloned() + }) + .flatten(); + let sub = match existing { + Some(mut old) => { + old.qos = qos; + old.alias = alias.clone(); + if is_v5 { + old.sub_identifier = sub_identifier; + old.no_local = no_local; + old.retain_as_published = rap; + old.retain_handling = retain_handling; + } + old + } + None => { + // 改名编辑的单主题提交:新主题承接旧记录身份与备注,避免改名丢数据 + if topics.len() == 1 && let Some(mut old) = editing_old.clone() { + old.topic = topic.clone(); + old.qos = qos; + old.alias = alias.clone(); + if is_v5 { + old.sub_identifier = sub_identifier; + old.no_local = no_local; + old.retain_as_published = rap; + old.retain_handling = retain_handling; + } + old + } else { + let mut sub = Subscription::new(conn.clone(), topic.clone(), qos); + sub.alias = alias.clone(); + if is_v5 { + sub.sub_identifier = sub_identifier; + sub.no_local = no_local; + sub.retain_as_published = rap; + sub.retain_handling = retain_handling; + } + sub + } + } + }; + // 继承到的停用订阅保持退订状态,不因重新提交被拉起 + let keep_disabled = !sub.enabled; + self.upsert_subscription(sub.clone(), cx); + if connected && !keep_disabled { + self.engine.subscribe_with_options( + conn.clone(), + sub.clone(), + SubscribeOptions::from(&sub), + ); + } + } + if !connected { + let hint = if self + .config(cx) + .map(|c| c.auto_resubscribe) + .unwrap_or(true) + { + "未连接:订阅已保存,连接后自动恢复" + } else { + "未连接:订阅已保存" + }; + window.push_notification(Notification::warning(hint), cx); + } + + // 编辑态:以刚保存的目标订阅回填表单继续保留上下文; + // 新增态:清空主题/别名并重置高级字段,避免残留到下一次订阅 + if was_editing.is_some() && topics.len() == 1 { + let target = topics[0].clone(); + let saved = self + .with_app(cx, |app| { + app.subscriptions + .iter() + .find(|s| s.connection_id == conn && s.topic == target) + .cloned() + }) + .flatten(); + if let Some(saved) = saved { + self.start_edit_subscription(&saved, window, cx); + } else { + self.editing = None; + self.reset_subscribe_form(window, cx); + } + } else { + self.editing = None; + self.reset_subscribe_form(window, cx); + } + cx.notify(); + } + +pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context) -> impl IntoElement { + let sub_topic = self.sub_topic.clone(); + let sub_qos = self.sub_qos.clone(); + let sub_alias = self.sub_alias.clone(); + let is_v5 = self.is_v5(cx); + let editing_topic = self.editing.clone(); + let editing = editing_topic.is_some(); + let show_advanced = self.sub_show_advanced && is_v5; + + let mut bar = v_flex() + .border_b_1() + .border_color(cx.theme().border) + .bg(cx.theme().secondary); + bar = bar.child( + h_flex() + .gap_2() + .px_3() + .h_12() + .items_center() + // 编辑态显式提示目标主题,避免被误当成新增提交 + .when_some(editing_topic.clone(), |h, topic| { + h.child( + div() + .px_1p5() + .py_0p5() + .rounded_md() + .bg(cx.theme().warning.alpha(0.15)) + .text_xs() + .text_color(cx.theme().warning) + .child(format!("正在编辑:{topic}")), + ) + }) + .child(div().flex_1().min_w(px(0.)).child(Input::new(&sub_topic).small())) + .child(div().w(px(96.)).child(Select::new(&sub_qos).small())) + .child(div().w(px(112.)).child(Input::new(&sub_alias).small())) + .when(is_v5, |h| { + h.child( + Button::new(SharedString::from(format!("sub-adv-{}", self.conn_id))) + .icon(IconName::SlidersHorizontal) + .label("高级") + .ghost() + .small() + .when(show_advanced, |b| b.selected(true)) + .tooltip("MQTT 5 订阅选项") + .on_click(cx.listener(|this, _, _, cx| { + this.sub_show_advanced = !this.sub_show_advanced; + cx.notify(); + })), + ) + }) + .child( + Button::new(SharedString::from(format!("subscribe-{}", self.conn_id))) + .icon(if editing { IconName::Check } else { IconName::Plus }) + .label(if editing { "更新" } else { "订阅" }) + // 编辑用 warning 色与新增的 primary 区分 + .when(editing, |b| b.warning()) + .when(!editing, |b| b.primary()) + .small() + .on_click(cx.listener(|this, _, window, cx| this.do_subscribe(window, cx))), + ) + .when(editing, |h| { + h.child( + Button::new(SharedString::from(format!("sub-edit-cancel-{}", self.conn_id))) + .icon(IconName::Close) + .ghost() + .small() + .tooltip("取消编辑") + .on_click(cx.listener(|this, _, window, cx| { + this.editing = None; + // 清空回填到输入框的主题/别名/高级字段,回到新增态 + this.reset_subscribe_form(window, cx); + cx.notify(); + })), + ) + }), + ); + + if show_advanced { + let sub_identifier = self.sub_identifier.clone(); + let sub_no_local = self.sub_no_local; + let sub_rap = self.sub_rap; + let sub_retain_handling = self.sub_retain_handling.clone(); + bar = bar.child( + h_flex() + .gap_4() + .px_3() + .pb_2() + .items_start() + .child( + div() + .w(px(150.)) + .child(field("订阅标识符", Input::new(&sub_identifier).small())), + ) + .child( + h_flex() + .gap_1() + .items_center() + .pt_4() + .child(Switch::new("sub-no-local").checked(sub_no_local).on_change( + cx.listener(|this, v, _, cx| { + this.sub_no_local = *v; + cx.notify(); + }), + )) + .child(div().text_xs().child("No Local")), + ) + .child( + h_flex() + .gap_1() + .items_center() + .pt_4() + .child(Switch::new("sub-rap").checked(sub_rap).on_change( + cx.listener(|this, v, _, cx| { + this.sub_rap = *v; + cx.notify(); + }), + )) + .child(div().text_xs().child("Retain As Published")), + ) + .child( + div().w(px(150.)).child(field( + "Retain Handling", + Select::new(&sub_retain_handling).small(), + )), + ), + ); + } + bar + } + +pub(super) fn render_subscriptions(&self, cx: &mut Context) -> impl IntoElement { + let hover_bg = cx.theme().muted; + let muted_fg = cx.theme().muted_foreground; + let conn = self.conn_id.clone(); + let subs: Vec = self + .with_app(cx, |app| { + app.subscriptions + .iter() + .filter(|s| s.connection_id == conn) + .cloned() + .collect() + }) + .unwrap_or_default(); + let active_filter = self.sub_filter.clone(); + + let mut list = v_flex().gap_0p5(); + if subs.is_empty() { + list = list.child( + v_flex() + .p_3() + .gap_2() + .items_center() + .child( + gpui_kit::component::Icon::new(IconName::Hash) + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .text_center() + .child("订阅主题后,消息会显示在右侧"), + ), + ); + } + for sub in subs { + let enabled = sub.enabled; + let is_active = enabled && active_filter.as_deref() == Some(sub.topic.as_str()); + let topic = sub.topic.clone(); + let sub_id = sub.id.clone(); + let alias = sub.alias.clone().filter(|a| !a.trim().is_empty()); + let primary = alias.clone().unwrap_or_else(|| topic.clone()); + + // 颜色按钮:已设色显示色点,未设显示灰色「+」 + let color_btn = { + let weak_menu = cx.weak_entity(); + let sid = sub_id.clone(); + let trigger_id = SharedString::from(format!("sub-color-{}", sub_id)); + let trigger = if let Some(hue) = sub.color { + Button::new(trigger_id) + .ghost() + .xsmall() + .tooltip("订阅颜色") + .child( + div() + .size(px(11.)) + .rounded_full() + .bg(hue_color(hue)) + .border_1() + .border_color(cx.theme().border), + ) + } else { + Button::new(trigger_id) + .ghost() + .xsmall() + .tooltip("订阅颜色") + .child( + div() + .size(px(11.)) + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(cx.theme().muted) + .text_color(muted_fg) + .text_xs() + .child("+"), + ) + }; + trigger.dropdown_menu(move |mut menu, _, _| { + for (hue, name) in PRESET_HUES { + let weak = weak_menu.clone(); + let sid = sid.clone(); + let swatch = hue_color(hue); + // 菜单只显示颜色名,角度数值对用户没有意义 + let label = SharedString::from(name); + menu = menu.item( + PopupMenuItem::element(move |_, _| { + h_flex() + .gap_2() + .items_center() + .child( + div() + .size(px(10.)) + .rounded_full() + .bg(swatch) + .border_1() + .border_color(hsla(0., 0., 1., 0.6)), + ) + .child(div().text_sm().child(label.clone())) + }) + .on_click(move |_, _, cx| { + weak + .update(cx, |view, cx| { + view.set_subscription_color(&sid, Some(hue), cx); + }) + .ok(); + }), + ); + } + menu = menu.separator(); + let weak_rand = weak_menu.clone(); + let sid_rand = sid.clone(); + menu = menu.item(PopupMenuItem::new("随机颜色").on_click(move |_, _, cx| { + let hue = (uuid::Uuid::new_v4().as_u128() % 360) as f32; + weak_rand + .update(cx, |view, cx| { + view.set_subscription_color(&sid_rand, Some(hue), cx); + }) + .ok(); + })); + let weak_clear = weak_menu.clone(); + let sid_clear = sid.clone(); + menu = menu.item(PopupMenuItem::new("清除颜色").on_click(move |_, _, cx| { + weak_clear + .update(cx, |view, cx| { + view.set_subscription_color(&sid_clear, None, cx); + }) + .ok(); + })); + menu + }) + }; + + let eye_sid = sub_id.clone(); + let eye_btn = Button::new(SharedString::from(format!("sub-eye-{}", sub_id))) + .icon(if enabled { IconName::Eye } else { IconName::EyeOff }) + .ghost() + .xsmall() + .tooltip(if enabled { "停用订阅" } else { "启用订阅" }) + .on_click(cx.listener(move |this, _, _, cx| { + this.toggle_subscription_enabled(&eye_sid, cx); + })); + + let edit_sid = sub_id.clone(); + let edit_btn = Button::new(SharedString::from(format!("sub-edit-{}", sub_id))) + .icon(IconName::Pencil) + .ghost() + .xsmall() + .tooltip("编辑订阅") + .on_click(cx.listener(move |this, _, window, cx| { + let sub = this + .with_app(cx, |app| { + app.subscriptions + .iter() + .find(|s| s.id == edit_sid) + .cloned() + }) + .flatten(); + if let Some(sub) = sub { + this.start_edit_subscription(&sub, window, cx); + } + })); + + let del_cid = self.conn_id.clone(); + let del_topic = topic.clone(); + let del_btn = Button::new(SharedString::from(format!("sub-del-{}", sub_id))) + .icon(IconName::Close) + .ghost() + .xsmall() + .tooltip("取消订阅") + .on_click(cx.listener(move |this, _, _, cx| { + // 若删除的是当前过滤主题,一并清掉消息过滤 + if this.sub_filter.as_deref() == Some(del_topic.as_str()) { + this.sub_filter = None; + } + if let Some(app) = this.app.upgrade() { + app.update(cx, |app, cx| { + app.remove_subscription(&del_cid, &del_topic); + cx.notify(); + }); + } + // 只有删除的是正在编辑的主题才退出编辑态,编辑别的不受影响 + if this.editing.as_deref() == Some(del_topic.as_str()) { + this.editing = None; + } + cx.notify(); + })); + + // 点击区(非按钮):切换按该主题过滤消息流;禁用的订阅不参与 + let filter_topic = topic.clone(); + let click_zone = h_flex() + .id(SharedString::from(format!("sub-click-{}", sub_id))) + .flex_1() + .min_w(px(0.)) + .when(enabled, |z| { + z.cursor_pointer().on_click(cx.listener( + move |this, _, _, cx| { + this.sub_filter = if this.sub_filter.as_deref() + == Some(filter_topic.as_str()) + { + None + } else { + Some(filter_topic.clone()) + }; + cx.notify(); + }, + )) + }) + .child( + v_flex() + .flex_1() + .min_w(px(0.)) + .gap_0p5() + .child( + div() + .text_sm() + .overflow_hidden() + .text_ellipsis() + .when(!enabled, |t| t.text_color(muted_fg)) + .child(primary), + ) + .when_some(alias, |v, _| { + v.child( + div() + .text_xs() + .text_color(muted_fg) + .overflow_hidden() + .text_ellipsis() + .child(topic.clone()), + ) + }), + ); + + list = list.child( + h_flex() + .id(SharedString::from(format!("sub-{}-{}", self.conn_id, sub.id))) + .gap_1p5() + .items_center() + .px_2() + .py_1p5() + .rounded_md() + .when(is_active, |t| t.bg(cx.theme().secondary)) + .when(!is_active, |t| t.hover(move |this| this.bg(hover_bg))) + .when(!enabled, |t| t.opacity(0.55)) + .child(color_btn) + .child(click_zone) + .child( + div() + .text_xs() + .px_1() + .rounded_sm() + .bg(cx.theme().muted) + .text_color(muted_fg) + .child(format!("Q{}", sub.qos)), + ) + .child(eye_btn) + .child(edit_btn) + .child(del_btn), + ); + } + + v_flex() + .w(px(248.)) + .flex_shrink_0() + .h_full() + .bg(cx.theme().sidebar) + .text_color(cx.theme().sidebar_foreground) + .child( + h_flex() + .h_11() + .px_3() + .items_center() + .child(div().text_sm().font_semibold().child("订阅")), + ) + .child(div().flex_1().overflow_y_scrollbar().px_2().child(list)) + } +} diff --git a/gpui-app/src/ui/connection_view/util.rs b/gpui-app/src/ui/connection_view/util.rs new file mode 100644 index 0000000..1041676 --- /dev/null +++ b/gpui-app/src/ui/connection_view/util.rs @@ -0,0 +1,228 @@ +//! 纯函数工具:topic 匹配、占位符提取、载荷预览/复制文本、订阅配色。 + + +use gpui_kit::{ hsla, Hsla, }; + +use crate::model::{ MqttRecord, Subscription, }; +use crate::ui::widgets::format_time; + +use super::*; + +/// 色相(度)→ gpui Hsla(h 分量为 0..=1 的整圆比例)。 +/// 固定 L=0.52 时亮色相(黄绿)在浅色主题下过亮、暗色相(蓝靛)在深色主题下过暗, +/// 按色相段微调明度,保证两端主题下都可辨识。 +pub(super) fn hue_color(hue: f32) -> Hsla { + let h = hue.rem_euclid(360.0); + let l = if (30.0..=90.0).contains(&h) { + // 黄/黄绿本身明度感高,压低一点防刺眼 + 0.44 + } else if (200.0..=280.0).contains(&h) { + // 蓝/靛本身明度感低,抬高一点防发黑 + 0.60 + } else { + 0.52 + }; + hsla(h / 360.0, 0.72, l, 1.0) +} + +/// 逗号 / 空白 / 换行分隔的多主题输入 → 去重后的主题列表(批量订阅) +pub(super) fn split_topic_list(raw: &str) -> Vec { + let mut out: Vec = Vec::new(); + for part in raw.split(|c: char| c == ',' || c.is_whitespace()) { + let t = part.trim(); + if !t.is_empty() && !out.iter().any(|x| x == t) { + out.push(t.to_string()); + } + } + out +} + +/// 从发布主题与负载中提取 `{{key}}` 占位符的 key 列表: +/// - key 两侧空白去除(trim); +/// - `$` 开头的内置变量(`$ts` / `$ts_ms` / `$uuid`)不提取; +/// - 重复 key 去重,保持首次出现顺序(主题在前、负载在后)。 +pub(super) fn extract_placeholder_keys(topic: &str, payload: &str) -> Vec { + let mut out: Vec = Vec::new(); + for text in [topic, payload] { + let bytes = text.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'{' && bytes[i + 1] == b'{' && let Some(rel_end) = text[i + 2..].find("}}") { + let key = text[i + 2..i + 2 + rel_end].trim(); + if !key.is_empty() && !key.starts_with('$') && !out.iter().any(|k| k == key) { + out.push(key.to_string()); + } + i = i + 2 + rel_end + 2; + continue; + } + // 按字符推进,避免把多字节 UTF-8 拆开 + let ch = text[i..].chars().next().unwrap(); + i += ch.len_utf8(); + } + } + out +} + +/// MQTT 主题过滤器匹配:`+` 匹配单层,`#` 匹配本层及以下(可匹配父级本身)。 +pub(super) fn topic_matches(filter: &str, topic: &str) -> bool { + let f: Vec<&str> = filter.split('/').collect(); + let t: Vec<&str> = topic.split('/').collect(); + for (i, part) in f.iter().enumerate() { + if *part == "#" { + // "a/#" 需要匹配 "a" 自身:剩余层级数 >= 过滤器已消费层数 + return i <= t.len(); + } + if i >= t.len() { + return false; + } + if *part != "+" && *part != t[i] { + return false; + } + } + f.len() == t.len() +} + +/// 挑选消息着色用的订阅色:先看精确匹配,再看通配匹配;只考虑启用且已设色的订阅。 +pub(super) fn pick_subscription_color(subs: &[Subscription], topic: &str) -> Option { + let has_wild = |t: &str| t.contains('+') || t.contains('#'); + for s in subs.iter().filter(|s| s.enabled) { + if s.topic == topic && !has_wild(&s.topic) && s.color.is_some() { + return s.color; + } + } + for s in subs.iter().filter(|s| s.enabled) { + if has_wild(&s.topic) && topic_matches(&s.topic, topic) && s.color.is_some() { + return s.color; + } + } + None +} + +/// 展开详情里的 payload 渲染:自动 = 合法 JSON 美化,否则原文。 +/// Hex/Base64 优先使用入库保留的原始字节——非文本负载的 lossy 文本 +/// 已把非法字节替换为 U+FFFD,按文本字节展示会与线上报文不符。 +pub(super) fn format_payload_detail(payload: &str, raw: Option<&[u8]>, format: DetailFormat) -> String { + match format { + DetailFormat::Auto => serde_json::from_str::(payload) + .ok() + .and_then(|v| serde_json::to_string_pretty(&v).ok()) + .unwrap_or_else(|| payload.to_string()), + DetailFormat::Text => payload.to_string(), + DetailFormat::Hex => raw + .unwrap_or(payload.as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(" "), + DetailFormat::Base64 => { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(raw.unwrap_or(payload.as_bytes())) + } + } +} + +/// 「复制详情」的可读文本:元数据 + v5 属性 + 用户属性 + 负载。 +pub(super) fn details_copy_text(record: &MqttRecord, show_millis: bool) -> String { + let mut out = String::new(); + out.push_str(&format!("Topic: {}\n", record.topic)); + out.push_str(&format!("Direction: {}\n", record.direction.label())); + out.push_str(&format!("QoS: {}\n", record.qos)); + out.push_str(&format!("Retain: {}\n", record.retain)); + out.push_str(&format!( + "Time: {}\n", + format_time(record.timestamp, show_millis) + )); + if let Some(v) = record.content_type.as_deref() { + out.push_str(&format!("Content-Type: {v}\n")); + } + if let Some(v) = record.response_topic.as_deref() { + out.push_str(&format!("Response Topic: {v}\n")); + } + if let Some(v) = record.correlation_data.as_deref() { + out.push_str(&format!("Correlation Data: {v}\n")); + } + if let Some(v) = record.message_expiry_interval { + out.push_str(&format!("Message Expiry Interval: {v}\n")); + } + if let Some(v) = record.subscription_identifier { + out.push_str(&format!("Subscription Identifier: {v}\n")); + } + if !record.user_properties.is_empty() { + out.push_str("User Properties:\n"); + for (k, v) in &record.user_properties { + out.push_str(&format!(" {k} = {v}\n")); + } + } + out.push_str(&format!("Payload:\n{}", record.payload)); + out +} + +/// 折叠态只显示单行预览:换行/制表符压平成空格后截 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('…'); + } + if payload.contains('\u{FFFD}') { + out.push_str("(非文本,详情可切 Hex)"); + } + out +} + +/// 大小写不敏感子串匹配。`needle` 已由调用方 to_lowercase 一次; +/// 先做零拷贝的字面匹配,未命中且原文含大写时才回退整串小写, +/// 避免渲染热路径对全部记录做 to_lowercase 拷贝。 +pub(super) fn contains_ignore_case(hay: &str, needle: &str) -> bool { + if needle.is_empty() { + return true; + } + if hay.contains(needle) { + return true; + } + // 纯 ASCII 快速路径:双方均为 ASCII 且原文无 ASCII 大写时, + // 小写化恒等,可直接判否。不能用 is_uppercase 判断——个别 + // 小写字符(如 U+1E9B)的小写映射并不等于自身。 + if needle.is_ascii() + && hay.is_ascii() + && !hay.bytes().any(|b| b.is_ascii_uppercase()) + { + return false; + } + hay.to_lowercase().contains(needle) +} + +// ─── 单元测试 ──────────────────────────────────────────────────────────────── + + +#[cfg(test)] +mod tests { + use super::extract_placeholder_keys; + + // 修复回归:提取须 trim、排除内置变量、去重且保持首次出现顺序 + #[test] + fn extract_keys_trims_excludes_builtin_and_dedups() { + let keys = extract_placeholder_keys( + "sensor/{{ device }}/state", + "{\"v\":{{value}},\"t\":{{ $ts }},\"u\":{{uuid}}}", + ); + assert_eq!(keys, vec!["device", "value", "uuid"], "实际: {keys:?}"); + } + + #[test] + fn extract_keys_empty_and_multibyte_safe() { + assert!(extract_placeholder_keys("", "").is_empty()); + assert!(extract_placeholder_keys("no placeholders here", "纯文本").is_empty()); + // 未闭合的 {{ 不提取、不越界 + assert!(extract_placeholder_keys("a{{b", "c}}d").is_empty()); + let keys = extract_placeholder_keys("温度{{名称}}", ""); + assert_eq!(keys, vec!["名称"]); + } +} diff --git a/gpui-app/src/ui/connection_view/vars.rs b/gpui-app/src/ui/connection_view/vars.rs new file mode 100644 index 0000000..e9ea033 --- /dev/null +++ b/gpui-app/src/ui/connection_view/vars.rs @@ -0,0 +1,160 @@ +//! 模板变量面板:扫描 topic/payload 占位符并管理输入行。 + + +use gpui_kit::component::button::{ Button }; +use gpui_kit::component::input::{ Input, InputEvent, InputState }; +use gpui_kit::component::{ h_flex, notification::Notification, v_flex, }; +use crate::ui::IconName; +use gpui_kit::{ div, px, Context, IntoElement, Window, }; + +use crate::model::{ render_template, GlobalVariable, }; + +use super::*; + +impl ConnectionView { + /// 输入框创建需要 Window,因此只能在按钮回调里调用(无法在 render 中同步)。 +pub(super) fn sync_var_rows(&mut self, window: &mut Window, cx: &mut Context) { + let topic = self.pub_topic.read(cx).value().to_string(); + let payload = self.payload.read(cx).value().to_string(); + let keys = extract_placeholder_keys(&topic, &payload); + if keys.is_empty() { + self.show_vars = false; + window.push_notification( + Notification::warning("主题/负载中没有 {{变量}} 引用"), + cx, + ); + cx.notify(); + return; + } + let vars = self.with_app(cx, |a| a.variables.clone()).unwrap_or_default(); + // 移除已不存在的 key(保留仍存在的行,避免丢未保存的输入) + self.var_rows.retain(|(k, _, _)| keys.contains(k)); + for key in keys { + if self.var_rows.iter().any(|(k, ..)| k == &key) { + continue; + } + let initial = vars + .iter() + .find(|v| v.key == key) + .map(|v| v.value.clone()) + .unwrap_or_default(); + let input = cx.new(|cx| { + let mut s = InputState::new(window, cx).placeholder("变量值"); + // set_value 不会触发 InputEvent::Change,不会误写回 + if !initial.is_empty() { + s.set_value(initial.as_str(), window, cx); + } + s + }); + // 编辑即写回全局变量(含持久化),预览与发布随之生效 + let key_for_sub = key.clone(); + let sub = cx.subscribe(&input, move |this, entity, ev: &InputEvent, cx| { + if !matches!(ev, InputEvent::Change) { + return; + } + let val = entity.read(cx).value().to_string(); + let key = key_for_sub.clone(); + let Some(app) = this.app.upgrade() else { + return; + }; + app.update(cx, |a, cx| { + match a.variables.iter_mut().find(|v| v.key == key) { + Some(v) => { + if v.value == val { + return; + } + v.value = val; + } + None => a.variables.push(GlobalVariable { key, value: val }), + } + let snapshot = a.variables.clone(); + a.save_variables(snapshot); + cx.notify(); + }); + }); + self.var_rows.push((key, input, sub)); + } + self.show_vars = true; + cx.notify(); + } + + /// 变量绑定面板:逐 key 编辑 + 实时渲染预览。 +pub(super) fn render_vars_panel(&self, cx: &mut Context) -> impl IntoElement { + let vars = self.with_app(cx, |a| a.variables.clone()).unwrap_or_default(); + let topic_raw = self.pub_topic.read(cx).value().to_string(); + let payload_raw = self.payload.read(cx).value().to_string(); + let topic_rendered = render_template(&topic_raw, &vars); + let payload_rendered = render_template(&payload_raw, &vars); + let payload_preview: String = payload_rendered.chars().take(80).collect(); + let muted = cx.theme().muted_foreground; + + let mut rows = v_flex().gap_1(); + for (key, input, _) in &self.var_rows { + rows = rows.child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .w(px(160.)) + .min_w(px(0.)) + .text_xs() + .text_color(muted) + .child(format!("{{{{{key}}}}}")), + ) + .child(div().flex_1().min_w(px(0.)).child(Input::new(input).small())), + ); + } + + v_flex() + .gap_2() + .w_full() + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .text_xs() + .font_semibold() + .child(format!("变量({})", self.var_rows.len())), + ) + .child(div().flex_1()) + .child( + Button::new("vars-refresh") + .icon(IconName::RefreshCw) + .ghost() + .xsmall() + .tooltip("重新提取占位符") + .on_click(cx.listener(|this, _, window, cx| { + this.sync_var_rows(window, cx); + })), + ) + .child( + Button::new("vars-collapse") + .icon(IconName::ChevronUp) + .ghost() + .xsmall() + .tooltip("收起") + .on_click(cx.listener(|this, _, _, cx| { + this.show_vars = false; + cx.notify(); + })), + ), + ) + .child(rows) + .child( + div() + .text_xs() + .text_color(muted) + .child("内置:{{$ts}}(秒) {{$ts_ms}}(毫秒) {{$uuid}} · 发布时注入"), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child(format!("预览:{topic_rendered} | {payload_preview}")), + ) + } + +} diff --git a/gpui-app/src/ui/aliyun_dialog.rs b/gpui-app/src/ui/dialogs/aliyun_dialog.rs similarity index 100% rename from gpui-app/src/ui/aliyun_dialog.rs rename to gpui-app/src/ui/dialogs/aliyun_dialog.rs diff --git a/gpui-app/src/ui/dialogs/mod.rs b/gpui-app/src/ui/dialogs/mod.rs new file mode 100644 index 0000000..f63836d --- /dev/null +++ b/gpui-app/src/ui/dialogs/mod.rs @@ -0,0 +1,7 @@ +//! 模态对话框集合:设置、资源监控、发布预设、阿里云预设、全局变量。 + +pub mod aliyun_dialog; +pub mod presets_dialog; +pub mod resource_dialog; +pub mod settings_dialog; +pub mod variables_dialog; diff --git a/gpui-app/src/ui/presets_dialog.rs b/gpui-app/src/ui/dialogs/presets_dialog.rs similarity index 100% rename from gpui-app/src/ui/presets_dialog.rs rename to gpui-app/src/ui/dialogs/presets_dialog.rs diff --git a/gpui-app/src/ui/resource_dialog.rs b/gpui-app/src/ui/dialogs/resource_dialog.rs similarity index 99% rename from gpui-app/src/ui/resource_dialog.rs rename to gpui-app/src/ui/dialogs/resource_dialog.rs index 4ee6243..cbe224a 100644 --- a/gpui-app/src/ui/resource_dialog.rs +++ b/gpui-app/src/ui/dialogs/resource_dialog.rs @@ -49,7 +49,7 @@ pub struct ResourceMonitorDialog { } impl ResourceMonitorDialog { - fn new(app: Entity, cx: &mut Context) -> Self { + fn new(app: Entity, _cx: &mut Context) -> Self { Self { app, sampler: Sampler::new(), diff --git a/gpui-app/src/ui/settings_dialog.rs b/gpui-app/src/ui/dialogs/settings_dialog.rs similarity index 100% rename from gpui-app/src/ui/settings_dialog.rs rename to gpui-app/src/ui/dialogs/settings_dialog.rs diff --git a/gpui-app/src/ui/variables_dialog.rs b/gpui-app/src/ui/dialogs/variables_dialog.rs similarity index 100% rename from gpui-app/src/ui/variables_dialog.rs rename to gpui-app/src/ui/dialogs/variables_dialog.rs diff --git a/gpui-app/src/ui/mod.rs b/gpui-app/src/ui/mod.rs index 0cc828d..f7b32e5 100644 --- a/gpui-app/src/ui/mod.rs +++ b/gpui-app/src/ui/mod.rs @@ -1,14 +1,11 @@ -//! GPUI 界面层。 +//! GPUI 界面层:主窗口(app)、连接工作区(connection_view)、 +//! 连接表单、对话框集合与共享小组件。 -pub mod aliyun_dialog; pub mod app; pub mod connection_form; pub mod connection_view; +pub mod dialogs; pub mod logo; -pub mod presets_dialog; -pub mod resource_dialog; -pub mod settings_dialog; -pub mod variables_dialog; pub mod widgets; /// 统一使用全量 Lucide 图标(gpui-kit-assets 已随应用加载)。 diff --git a/gpui-app/src/ui/widgets.rs b/gpui-app/src/ui/widgets.rs deleted file mode 100644 index e21f244..0000000 --- a/gpui-app/src/ui/widgets.rs +++ /dev/null @@ -1,272 +0,0 @@ -//! 跨界面复用的小组件:枚举下拉、键值对编辑器、状态指示、字段行等。 - -use gpui_kit::component::button::{Button, ButtonVariants as _}; -use gpui_kit::component::input::{Input, InputState}; -use gpui_kit::component::searchable_list::{SearchableListDelegate, SearchableListItem}; -use gpui_kit::component::select::{Select, SelectState}; -use gpui_kit::component::{h_flex, v_flex, ActiveTheme as _, Disableable as _, IndexPath, Sizable as _}; -use crate::ui::IconName; -use gpui_kit::{App, AppContext as _, Context, Entity, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window, div, px}; - -use crate::model::ConnectionStatus; - -// ─── 枚举下拉 ──────────────────────────────────────────────────────────────── - -#[derive(Clone)] -pub struct OptionItem { - pub value: usize, - pub label: SharedString, -} - -impl SearchableListItem for OptionItem { - type Value = usize; - fn title(&self) -> SharedString { - self.label.clone() - } - fn value(&self) -> &usize { - &self.value - } -} - -#[derive(Clone)] -pub struct OptionDelegate { - pub items: Vec, -} - -impl OptionDelegate { - pub fn new(labels: &[&str]) -> Self { - Self { - items: labels - .iter() - .enumerate() - .map(|(i, l)| OptionItem { - value: i, - label: (*l).into(), - }) - .collect(), - } - } -} - -impl SearchableListDelegate for OptionDelegate { - type Item = OptionItem; - fn items_count(&self, _section: usize) -> usize { - self.items.len() - } - fn item(&self, ix: IndexPath) -> Option<&Self::Item> { - self.items.get(ix.row) - } - fn position(&self, _value: &V) -> Option - where - Self::Item: SearchableListItem, - V: PartialEq, - { - // 选中项统一通过 IndexPath 构造,无需按值反查 - None - } -} - -/// 创建字符串枚举下拉状态(在任意实体上下文里调用)。 -pub fn make_select( - labels: &[&str], - selected: usize, - window: &mut Window, - cx: &mut C, -) -> Entity> { - let selected = selected.min(labels.len().saturating_sub(1)); - cx.new(|cx| { - SelectState::new( - OptionDelegate::new(labels), - Some(IndexPath::new(selected)), - window, - cx, - ) - }) -} - -// ─── 字段行 ────────────────────────────────────────────────────────────────── - -/// 小号标签 + 控件的垂直字段容器(可选字段,无校验态)。 -pub fn field(label: impl Into, control: impl IntoElement) -> impl IntoElement { - field_ex(label, control, false, None, Hsla::default()) -} - -/// 字段行变体:`required` 时标签后追加红色 `*`; -/// `error` 为 Some 时在控件下方渲染红字提示(`danger` 为主题错误色)。 -pub fn field_ex( - label: impl Into, - control: impl IntoElement, - required: bool, - error: Option, - danger: Hsla, -) -> impl IntoElement { - v_flex() - .gap_1() - .min_w(px(0.)) - .child( - h_flex() - .gap_0p5() - .items_baseline() - .child(div().text_xs().child(label.into())) - .children(required.then(|| div().text_xs().text_color(danger).child("*"))), - ) - .child(control) - .children(error.map(|e| div().text_xs().text_color(danger).child(e))) -} - -// ─── 状态颜色 ──────────────────────────────────────────────────────────────── - -pub fn status_color(status: ConnectionStatus, cx: &App) -> Hsla { - let t = cx.theme(); - match status { - ConnectionStatus::Connected => t.success, - ConnectionStatus::Connecting => t.warning, - ConnectionStatus::Error => t.danger, - ConnectionStatus::Disconnected => t.muted_foreground, - } -} - -pub fn status_dot(status: ConnectionStatus, cx: &App) -> impl IntoElement { - div() - .size(px(8.)) - .rounded_full() - .bg(status_color(status, cx)) -} - -// ─── 键值对编辑器 ──────────────────────────────────────────────────────────── - -/// MQTT 5 用户属性等 key/value 列表编辑器。 -pub struct KvEditor { - id_prefix: SharedString, - pub rows: Vec<(Entity, Entity)>, -} - -impl KvEditor { - pub fn new( - id_prefix: impl Into, - pairs: &[(String, String)], - window: &mut Window, - cx: &mut Context, - ) -> Self { - let mut editor = Self { - id_prefix: id_prefix.into(), - rows: Vec::new(), - }; - for (k, v) in pairs { - editor.push(Some((k.as_str(), v.as_str())), window, cx); - } - if editor.rows.is_empty() { - editor.push(None, window, cx); - } - editor - } - - fn push(&mut self, initial: Option<(&str, &str)>, window: &mut Window, cx: &mut Context) { - let key = cx.new(|cx| { - let mut s = InputState::new(window, cx).placeholder("key"); - if let Some((k, _)) = initial { - s.set_value(k, window, cx); - } - s - }); - let value = cx.new(|cx| { - let mut s = InputState::new(window, cx).placeholder("value"); - if let Some((_, v)) = initial { - s.set_value(v, window, cx); - } - s - }); - self.rows.push((key, value)); - } - - pub fn pairs(&self, cx: &App) -> Vec<(String, String)> { - // key 统一 trim 并去重(保留最后一条):否则带首尾空白的 key - // 永远无法被 {{key}} 模板引用,重复 key 也只有一条能生效。 - let mut out: Vec<(String, String)> = Vec::new(); - for (k, v) in self - .rows - .iter() - .map(|(k, v)| (k.read(cx).value().to_string(), v.read(cx).value().to_string())) - { - let k = k.trim().to_string(); - if k.is_empty() { - continue; - } - match out.iter_mut().find(|(ek, _)| *ek == k) { - Some(slot) => slot.1 = v, - None => out.push((k, v)), - } - } - out - } -} - -impl Render for KvEditor { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let prefix = self.id_prefix.clone(); - let count = self.rows.len(); - - let mut rows = v_flex().gap_1(); - for (i, (key, value)) in self.rows.iter().cloned().enumerate() { - rows = rows.child( - h_flex() - .id(SharedString::from(format!("kv-row-{prefix}-{i}"))) - .gap_1() - .child(div().flex_1().min_w(px(0.)).child(Input::new(&key))) - .child(div().flex_1().min_w(px(0.)).child(Input::new(&value))) - .child( - Button::new(SharedString::from(format!("kv-del-{prefix}-{i}"))) - .icon(IconName::Close) - .ghost() - .xsmall() - .disabled(count <= 1) - .on_click(cx.listener(move |this, _, _, cx| { - if this.rows.len() > 1 { - this.rows.remove(i); - cx.notify(); - } - })), - ), - ); - } - - v_flex() - .gap_2() - .child(rows) - .child( - Button::new(SharedString::from(format!("kv-add-{prefix}"))) - .icon(IconName::Plus) - .label("添加属性") - .ghost() - .xsmall() - .on_click(cx.listener(|this, _, window, cx| { - this.push(None, window, cx); - cx.notify(); - })), - ) - } -} - -// ─── 时间格式化 ────────────────────────────────────────────────────────────── - -pub fn format_time(ts_ms: i64, show_millis: bool) -> String { - use chrono::TimeZone as _; - let Some(dt) = chrono::Local.timestamp_millis_opt(ts_ms).single() else { - return String::new(); - }; - if show_millis { - dt.format("%H:%M:%S%.3f").to_string() - } else { - dt.format("%H:%M:%S").to_string() - } -} - -/// Select::new 的包装,统一外观。 -pub fn select_element( - state: &Entity>, -) -> Select -where - ::Value: PartialEq + Clone, -{ - Select::new(state) -} diff --git a/gpui-app/src/ui/widgets/common.rs b/gpui-app/src/ui/widgets/common.rs new file mode 100644 index 0000000..15f4587 --- /dev/null +++ b/gpui-app/src/ui/widgets/common.rs @@ -0,0 +1,68 @@ +//! 基础部件:字段行布局、连接状态指示、时间格式化。 + +use gpui_kit::component::{h_flex, v_flex, ActiveTheme as _}; +use gpui_kit::{div, px, App, Hsla, IntoElement, ParentElement as _, SharedString, Styled as _}; + +use crate::model::ConnectionStatus; + +/// 小号标签 + 控件的垂直字段容器(可选字段,无校验态)。 +pub fn field(label: impl Into, control: impl IntoElement) -> impl IntoElement { + field_ex(label, control, false, None, Hsla::default()) +} + +/// 字段行变体:`required` 时标签后追加红色 `*`; +/// `error` 为 Some 时在控件下方渲染红字提示(`danger` 为主题错误色)。 +pub fn field_ex( + label: impl Into, + control: impl IntoElement, + required: bool, + error: Option, + danger: Hsla, +) -> impl IntoElement { + v_flex() + .gap_1() + .min_w(px(0.)) + .child( + h_flex() + .gap_0p5() + .items_baseline() + .child(div().text_xs().child(label.into())) + .children(required.then(|| div().text_xs().text_color(danger).child("*"))), + ) + .child(control) + .children(error.map(|e| div().text_xs().text_color(danger).child(e))) +} + +// ─── 状态颜色 ──────────────────────────────────────────────────────────────── + +pub fn status_color(status: ConnectionStatus, cx: &App) -> Hsla { + let t = cx.theme(); + match status { + ConnectionStatus::Connected => t.success, + ConnectionStatus::Connecting => t.warning, + ConnectionStatus::Error => t.danger, + ConnectionStatus::Disconnected => t.muted_foreground, + } +} + +pub fn status_dot(status: ConnectionStatus, cx: &App) -> impl IntoElement { + div() + .size(px(8.)) + .rounded_full() + .bg(status_color(status, cx)) +} + +// ─── 键值对编辑器 ──────────────────────────────────────────────────────────── + + +pub fn format_time(ts_ms: i64, show_millis: bool) -> String { + use chrono::TimeZone as _; + let Some(dt) = chrono::Local.timestamp_millis_opt(ts_ms).single() else { + return String::new(); + }; + if show_millis { + dt.format("%H:%M:%S%.3f").to_string() + } else { + dt.format("%H:%M:%S").to_string() + } +} diff --git a/gpui-app/src/ui/widgets/kv_editor.rs b/gpui-app/src/ui/widgets/kv_editor.rs new file mode 100644 index 0000000..135d342 --- /dev/null +++ b/gpui-app/src/ui/widgets/kv_editor.rs @@ -0,0 +1,122 @@ +//! key/value 列表编辑器(MQTT 5 用户属性等)。 + +use gpui_kit::component::button::{ Button, ButtonVariants as _ }; +use gpui_kit::component::input::{ Input, InputState }; +use gpui_kit::component::{ h_flex, v_flex, Disableable as _, Sizable as _ }; +use crate::ui::IconName; +use gpui_kit::{ App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window, div, px }; + + +/// MQTT 5 用户属性等 key/value 列表编辑器。 +pub struct KvEditor { + id_prefix: SharedString, + pub rows: Vec<(Entity, Entity)>, +} + +impl KvEditor { + pub fn new( + id_prefix: impl Into, + pairs: &[(String, String)], + window: &mut Window, + cx: &mut Context, + ) -> Self { + let mut editor = Self { + id_prefix: id_prefix.into(), + rows: Vec::new(), + }; + for (k, v) in pairs { + editor.push(Some((k.as_str(), v.as_str())), window, cx); + } + if editor.rows.is_empty() { + editor.push(None, window, cx); + } + editor + } + + fn push(&mut self, initial: Option<(&str, &str)>, window: &mut Window, cx: &mut Context) { + let key = cx.new(|cx| { + let mut s = InputState::new(window, cx).placeholder("key"); + if let Some((k, _)) = initial { + s.set_value(k, window, cx); + } + s + }); + let value = cx.new(|cx| { + let mut s = InputState::new(window, cx).placeholder("value"); + if let Some((_, v)) = initial { + s.set_value(v, window, cx); + } + s + }); + self.rows.push((key, value)); + } + + pub fn pairs(&self, cx: &App) -> Vec<(String, String)> { + // key 统一 trim 并去重(保留最后一条):否则带首尾空白的 key + // 永远无法被 {{key}} 模板引用,重复 key 也只有一条能生效。 + let mut out: Vec<(String, String)> = Vec::new(); + for (k, v) in self + .rows + .iter() + .map(|(k, v)| (k.read(cx).value().to_string(), v.read(cx).value().to_string())) + { + let k = k.trim().to_string(); + if k.is_empty() { + continue; + } + match out.iter_mut().find(|(ek, _)| *ek == k) { + Some(slot) => slot.1 = v, + None => out.push((k, v)), + } + } + out + } +} + +impl Render for KvEditor { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let prefix = self.id_prefix.clone(); + let count = self.rows.len(); + + let mut rows = v_flex().gap_1(); + for (i, (key, value)) in self.rows.iter().cloned().enumerate() { + rows = rows.child( + h_flex() + .id(SharedString::from(format!("kv-row-{prefix}-{i}"))) + .gap_1() + .child(div().flex_1().min_w(px(0.)).child(Input::new(&key))) + .child(div().flex_1().min_w(px(0.)).child(Input::new(&value))) + .child( + Button::new(SharedString::from(format!("kv-del-{prefix}-{i}"))) + .icon(IconName::Close) + .ghost() + .xsmall() + .disabled(count <= 1) + .on_click(cx.listener(move |this, _, _, cx| { + if this.rows.len() > 1 { + this.rows.remove(i); + cx.notify(); + } + })), + ), + ); + } + + v_flex() + .gap_2() + .child(rows) + .child( + Button::new(SharedString::from(format!("kv-add-{prefix}"))) + .icon(IconName::Plus) + .label("添加属性") + .ghost() + .xsmall() + .on_click(cx.listener(|this, _, window, cx| { + this.push(None, window, cx); + cx.notify(); + })), + ) + } +} + +// ─── 时间格式化 ────────────────────────────────────────────────────────────── diff --git a/gpui-app/src/ui/widgets/mod.rs b/gpui-app/src/ui/widgets/mod.rs new file mode 100644 index 0000000..889a626 --- /dev/null +++ b/gpui-app/src/ui/widgets/mod.rs @@ -0,0 +1,9 @@ +//! 跨界面复用的小组件:枚举下拉、键值对编辑器、字段行、状态指示等。 + +mod common; +mod kv_editor; +mod select; + +pub use common::*; +pub use kv_editor::*; +pub use select::*; diff --git a/gpui-app/src/ui/widgets/select.rs b/gpui-app/src/ui/widgets/select.rs new file mode 100644 index 0000000..7b6d9c3 --- /dev/null +++ b/gpui-app/src/ui/widgets/select.rs @@ -0,0 +1,91 @@ +//! 可搜索的枚举下拉:选项模型、委托与统一外观的 Select 包装。 + +use gpui_kit::component::searchable_list::{ SearchableListDelegate, SearchableListItem }; +use gpui_kit::component::select::{ Select, SelectState }; +use gpui_kit::component::IndexPath; +use gpui_kit::{ Entity, SharedString, Window }; + +#[derive(Clone)] +pub struct OptionItem { + pub value: usize, + pub label: SharedString, +} + +impl SearchableListItem for OptionItem { + type Value = usize; + fn title(&self) -> SharedString { + self.label.clone() + } + fn value(&self) -> &usize { + &self.value + } +} + +#[derive(Clone)] +pub struct OptionDelegate { + pub items: Vec, +} + +impl OptionDelegate { + pub fn new(labels: &[&str]) -> Self { + Self { + items: labels + .iter() + .enumerate() + .map(|(i, l)| OptionItem { + value: i, + label: (*l).into(), + }) + .collect(), + } + } +} + +impl SearchableListDelegate for OptionDelegate { + type Item = OptionItem; + fn items_count(&self, _section: usize) -> usize { + self.items.len() + } + fn item(&self, ix: IndexPath) -> Option<&Self::Item> { + self.items.get(ix.row) + } + fn position(&self, _value: &V) -> Option + where + Self::Item: SearchableListItem, + V: PartialEq, + { + // 选中项统一通过 IndexPath 构造,无需按值反查 + None + } +} + +/// 创建字符串枚举下拉状态(在任意实体上下文里调用)。 +pub fn make_select( + labels: &[&str], + selected: usize, + window: &mut Window, + cx: &mut C, +) -> Entity> { + let selected = selected.min(labels.len().saturating_sub(1)); + cx.new(|cx| { + SelectState::new( + OptionDelegate::new(labels), + Some(IndexPath::new(selected)), + window, + cx, + ) + }) +} + +// ─── 字段行 ────────────────────────────────────────────────────────────────── + + +/// Select::new 的包装,统一外观。 +pub fn select_element( + state: &Entity>, +) -> Select +where + ::Value: PartialEq + Clone, +{ + Select::new(state) +}