refactor(ui): 拆分 app/connection_view/widgets 为子组件,对话框归拢 dialogs/

- 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 警告,全部路径经门面保持稳定
This commit is contained in:
2026-09-29 22:20:53 +08:00
parent b5089f5898
commit bc9f55c889
30 changed files with 4884 additions and 4652 deletions
+48 -14
View File
@@ -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)
```
## 构建与运行
File diff suppressed because it is too large Load Diff
+260
View File
@@ -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>) {
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<Self>) {
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>) {
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<GlobalVariable>) {
self.variables = vars;
self.storage.save_variables(&self.variables);
}
pub fn save_presets(&mut self, presets: Vec<PublishPreset>) {
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<AliyunPreset>) {
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<ConnectionConfig>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let app = cx.entity().downgrade();
let engine = self.engine.clone();
// 表单的分组下拉需要现有分组列表(去重、排序)
let mut groups: Vec<String> = 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,
);
}
}
}),
),
)
});
}
}
+273
View File
@@ -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<Self>) {
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<Subscription> = 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<str>, 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 / 生命周期 ──────────────────────────────────────────────
}
+143
View File
@@ -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<R, F, D>(
&mut self,
work: F,
done: D,
window: &mut Window,
cx: &mut Context<Self>,
) where
F: FnOnce() -> R + Send + 'static,
R: Send + 'static,
D: FnOnce(&mut Self, R, &mut Window, &mut Context<Self>) + 'static,
{
if self.file_dialog_open {
return;
}
self.file_dialog_open = true;
// rfd 的阻塞对话框不可占用 UI 线程:结果经 smol 通道送回异步任务
let (tx, rx) = smol::channel::bounded::<R>(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<Self>) {
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<Self>) {
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<String> =
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,
);
}
// ── 对话框 ────────────────────────────────────────────────────────────
}
+98
View File
@@ -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<Self>) -> impl IntoElement {
if self.open_tabs.is_empty() {
// 父级 h_flex 交叉轴居中子项,容器必须 h_full 占满高度才不会整体下沉
return v_flex()
.flex_1()
.h_full()
.min_h(px(0.))
.items_center()
.justify_center()
.gap_3()
.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()
}
}
+382
View File
@@ -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<MqttXApp>,
f: impl FnOnce(Entity<MqttXApp>, &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<MqttEngine>,
_pump: gpui_kit::Task<()>,
// ── 持久化数据 ──
pub connections: Vec<ConnectionConfig>,
pub subscriptions: Vec<Subscription>,
pub presets: Vec<PublishPreset>,
pub variables: Vec<GlobalVariable>,
pub aliyun_presets: Vec<AliyunPreset>,
pub settings: AppSettings,
// ── 运行时状态 ──
pub statuses: HashMap<String, ConnectionStatus>,
pub errors: HashMap<String, String>,
/// 每连接消息环形缓冲:键为驻留后的连接 ID,条数 + 字节预算双重约束
pub messages: HashMap<Arc<str>, MessageRing>,
pub logs: VecDeque<LogEntry>,
pub seq: u64,
// ── 标签与视图 ──
open_tabs: Vec<String>,
active_tab: Option<String>,
views: HashMap<String, Entity<ConnectionView>>,
search: Entity<InputState>,
/// 分组过滤状态(chips 点击切换,与搜索条件叠加)
group_filter: GroupFilter,
/// 原生文件对话框进行中标记(对话框阻塞后台线程,防止重复弹出)
file_dialog_open: bool,
/// 按日期缓存的日志文件句柄(日期, 文件),避免每条日志都重新打开文件
log_file: Option<(String, std::io::BufWriter<std::fs::File>)>,
/// 系统外观变化订阅:设置为「跟随系统」时重应用主题,随实体存活
_appearance_obs: gpui_kit::Subscription,
/// 窗口置顶状态(标题栏钉子按钮切换)
pinned: bool,
}
impl MqttXApp {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> 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::<Vec<_>>()
{
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::<NewConnection>(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::<OpenSettings>(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::<OpenResourceMonitor>(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::<ExportConnections>(cx, move |_, cx| {
let w = weak.clone();
run_on_active_window(cx, w, |entity, window, cx| {
// 与新建/设置一致:对话框打开时不叠加文件对话框
if window.has_active_dialog(cx) {
return;
}
entity.update(cx, |app, cx| app.export_connections(window, cx));
});
});
let weak = cx.entity().downgrade();
App::on_action::<ImportConnections>(cx, move |_, cx| {
let w = weak.clone();
run_on_active_window(cx, w, |entity, window, cx| {
if window.has_active_dialog(cx) {
return;
}
entity.update(cx, |app, cx| app.import_connections(window, cx));
});
});
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<Self>) -> 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))
}
}
+361
View File
@@ -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<Self>) -> impl IntoElement {
let query = self.search.read(cx).value().to_lowercase();
let search = self.search.clone();
// 分组被改名/删除后过滤器可能悬空(列表恒空),渲染前复位到「全部」
let dangling = matches!(&self.group_filter, GroupFilter::Named(name)
if !self.connections.iter().any(|c| {
c.group.as_deref().map(str::trim) == Some(name.as_str())
}));
if dangling {
self.group_filter = GroupFilter::All;
}
let filter = self.group_filter.clone();
let total = self.connections.len();
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<Self>) -> impl IntoElement {
let mut names: Vec<String> = 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<gpui_kit::ElementId>,
label: String,
filter: GroupFilter,
cx: &mut Context<Self>,
) -> 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<Self>,
) -> 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<Self>) -> 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
})
});
}))
})
}
}
+47
View File
@@ -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);
}
// ─── 渲染 ────────────────────────────────────────────────────────────────────
+176
View File
@@ -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<Self>) -> 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<Self>) -> 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();
}))
}
}
File diff suppressed because it is too large Load Diff
+781
View File
@@ -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<Self>) -> 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<f32>,
cx: &mut Context<Self>,
) -> 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<usize>, _, 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::<Vec<_>>()
.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<Self>) -> 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<MqttRecord>,
Vec<Subscription>,
usize,
usize,
) = self
.with_app(cx, |app| {
let subs: Vec<Subscription> = app
.subscriptions
.iter()
.filter(|s| s.connection_id == conn)
.cloned()
.collect();
let mut matched = 0usize;
let mut records: Vec<MqttRecord> = 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<usize>, _, 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<Self>) -> 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)
}
}
+332
View File
@@ -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<MqttXApp>,
engine: Arc<MqttEngine>,
// ── 订阅 ──
sub_topic: Entity<InputState>,
sub_qos: Entity<SelectState<OptionDelegate>>,
sub_alias: Entity<InputState>,
// v5 高级订阅选项
sub_identifier: Entity<InputState>,
sub_no_local: bool,
sub_rap: bool,
sub_retain_handling: Entity<SelectState<OptionDelegate>>,
sub_show_advanced: bool,
/// 编辑中的旧主题:提交时用旧主题移除原订阅,避免改名过程丢数据
editing: Option<String>,
/// 点击订阅项激活的消息过滤主题(再点一次取消)
sub_filter: Option<String>,
// 发布
pub_topic: Entity<InputState>,
payload: Entity<TextareaState>,
pub_qos: Entity<SelectState<OptionDelegate>>,
payload_format: Entity<SelectState<OptionDelegate>>,
retain: bool,
content_type: Entity<InputState>,
user_props: Entity<KvEditor>,
msg_expiry: Entity<InputState>,
response_topic: Entity<InputState>,
correlation_data: Entity<InputState>,
show_props: bool,
// 过滤与面板
filter: Entity<InputState>,
panel: Panel,
expanded: Option<u64>,
/// 当前 detail_format 归属的消息:切换到另一条消息时据此重置格式,
/// 同一条内折叠/展开则保留用户选择
detail_owner: Option<u64>,
msg_dir: DirFilter,
detail_format: DetailFormat,
// 预设
preset_name: Entity<InputState>,
// 发布面板内联变量绑定
/// 「变量」小节展开状态(仅在提取到占位符时渲染整节)
show_vars: bool,
/// 提取到的 `{{key}}` → 值输入框与变更订阅,按提取顺序排列
var_rows: Vec<(String, Entity<InputState>, InputSubscription)>,
}
impl ConnectionView {
pub fn new(
conn_id: String,
app: gpui_kit::WeakEntity<MqttXApp>,
engine: std::sync::Arc<MqttEngine>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<R>(&self, cx: &App, f: impl FnOnce(&MqttXApp) -> R) -> Option<R> {
self.app.upgrade().map(|a| f(a.read(cx)))
}
fn config(&self, cx: &App) -> Option<ConnectionConfig> {
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<Self>) -> 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))
}
}
+576
View File
@@ -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<Self>) {
// 未连接时消息发不出去,先拦截给出与订阅路径一致的提示
if !self.engine.is_connected(&self.conn_id) {
window.push_notification(Notification::warning("未连接:消息未发送"), cx);
return;
}
let mut params = self.collect_publish_params(cx);
if params.topic.trim().is_empty() {
window.push_notification(Notification::warning("发布主题不能为空"), cx);
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(&params.topic, &vars);
params.payload = render_template(&params.payload, &vars);
// {{topic}} 等变量渲染后可能变空串,渲染完成后再校验一次
if params.topic.trim().is_empty() {
window.push_notification(Notification::warning("发布主题不能为空"), cx);
return;
}
// {{变量}} 同样作用于用户属性的 key/value
params.user_properties = params
.user_properties
.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::<serde_json::Value>(&params.payload) {
window.push_notification(
Notification::warning(format!("JSON 格式无效: {e}")),
cx,
);
return;
}
match params.payload_format.encode(&params.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::<u32>().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>,
) {
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", &params.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<Self>) -> 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<crate::model::PublishPreset>,
cx: &mut Context<Self>,
) -> 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(&params, 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<Self>,
name_input: Entity<InputState>,
) {
// 预填默认名
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);
}
}),
),
)
});
}
}
@@ -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<Self>) {
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<f32>, cx: &mut Context<Self>) {
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<Self>) {
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>) {
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>) {
self.sub_topic
.update(cx, |s, cx| s.set_value("", window, cx));
self.sub_alias
.update(cx, |s, cx| s.set_value("", window, cx));
self.sub_identifier
.update(cx, |s, cx| s.set_value("", window, cx));
self.sub_no_local = false;
self.sub_rap = false;
self.sub_retain_handling.update(cx, |s, cx| {
s.set_selected_index(Some(IndexPath::new(0)), window, cx)
});
}
// ── 订阅 / 发布动作 ─────────────────────────────────────────────────────
fn do_subscribe(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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::<u32>() {
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<Self>) -> 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<Self>) -> impl IntoElement {
let hover_bg = cx.theme().muted;
let muted_fg = cx.theme().muted_foreground;
let conn = self.conn_id.clone();
let subs: Vec<Subscription> = 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))
}
}
+228
View File
@@ -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<String> {
let mut out: Vec<String> = 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<String> {
let mut out: Vec<String> = 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<f32> {
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::<serde_json::Value>(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::<Vec<_>>()
.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!["名称"]);
}
}
+160
View File
@@ -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<Self>) {
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<Self>) -> 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}")),
)
}
}
+7
View File
@@ -0,0 +1,7 @@
//! 模态对话框集合:设置、资源监控、发布预设、阿里云预设、全局变量。
pub mod aliyun_dialog;
pub mod presets_dialog;
pub mod resource_dialog;
pub mod settings_dialog;
pub mod variables_dialog;
@@ -49,7 +49,7 @@ pub struct ResourceMonitorDialog {
}
impl ResourceMonitorDialog {
fn new(app: Entity<MqttXApp>, cx: &mut Context<Self>) -> Self {
fn new(app: Entity<MqttXApp>, _cx: &mut Context<Self>) -> Self {
Self {
app,
sampler: Sampler::new(),
+3 -6
View File
@@ -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 已随应用加载)。
-272
View File
@@ -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<OptionItem>,
}
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<V>(&self, _value: &V) -> Option<IndexPath>
where
Self::Item: SearchableListItem<Value = V>,
V: PartialEq,
{
// 选中项统一通过 IndexPath 构造,无需按值反查
None
}
}
/// 创建字符串枚举下拉状态(在任意实体上下文里调用)。
pub fn make_select<C: gpui_kit::AppContext>(
labels: &[&str],
selected: usize,
window: &mut Window,
cx: &mut C,
) -> Entity<SelectState<OptionDelegate>> {
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<SharedString>, control: impl IntoElement) -> impl IntoElement {
field_ex(label, control, false, None, Hsla::default())
}
/// 字段行变体:`required` 时标签后追加红色 `*`;
/// `error` 为 Some 时在控件下方渲染红字提示(`danger` 为主题错误色)。
pub fn field_ex(
label: impl Into<SharedString>,
control: impl IntoElement,
required: bool,
error: Option<SharedString>,
danger: Hsla,
) -> impl IntoElement {
v_flex()
.gap_1()
.min_w(px(0.))
.child(
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<InputState>, Entity<InputState>)>,
}
impl KvEditor {
pub fn new(
id_prefix: impl Into<SharedString>,
pairs: &[(String, String)],
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) {
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<Self>) -> 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<D: SearchableListDelegate>(
state: &Entity<SelectState<D>>,
) -> Select<D>
where
<D::Item as SearchableListItem>::Value: PartialEq + Clone,
{
Select::new(state)
}
+68
View File
@@ -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<SharedString>, control: impl IntoElement) -> impl IntoElement {
field_ex(label, control, false, None, Hsla::default())
}
/// 字段行变体:`required` 时标签后追加红色 `*`;
/// `error` 为 Some 时在控件下方渲染红字提示(`danger` 为主题错误色)。
pub fn field_ex(
label: impl Into<SharedString>,
control: impl IntoElement,
required: bool,
error: Option<SharedString>,
danger: Hsla,
) -> impl IntoElement {
v_flex()
.gap_1()
.min_w(px(0.))
.child(
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()
}
}
+122
View File
@@ -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<InputState>, Entity<InputState>)>,
}
impl KvEditor {
pub fn new(
id_prefix: impl Into<SharedString>,
pairs: &[(String, String)],
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) {
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<Self>) -> 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();
})),
)
}
}
// ─── 时间格式化 ──────────────────────────────────────────────────────────────
+9
View File
@@ -0,0 +1,9 @@
//! 跨界面复用的小组件:枚举下拉、键值对编辑器、字段行、状态指示等。
mod common;
mod kv_editor;
mod select;
pub use common::*;
pub use kv_editor::*;
pub use select::*;
+91
View File
@@ -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<OptionItem>,
}
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<V>(&self, _value: &V) -> Option<IndexPath>
where
Self::Item: SearchableListItem<Value = V>,
V: PartialEq,
{
// 选中项统一通过 IndexPath 构造,无需按值反查
None
}
}
/// 创建字符串枚举下拉状态(在任意实体上下文里调用)。
pub fn make_select<C: gpui_kit::AppContext>(
labels: &[&str],
selected: usize,
window: &mut Window,
cx: &mut C,
) -> Entity<SelectState<OptionDelegate>> {
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<D: SearchableListDelegate>(
state: &Entity<SelectState<D>>,
) -> Select<D>
where
<D::Item as SearchableListItem>::Value: PartialEq + Clone,
{
Select::new(state)
}