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:
@@ -38,24 +38,58 @@
|
||||
|
||||
## 目录结构
|
||||
|
||||
每个文件与目录单一职责;模块门面(`mod.rs`)只做聚合导出与再分派。
|
||||
|
||||
```
|
||||
gpui-app/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── main.rs # 入口:创建窗口、挂载 gpui-kit Root
|
||||
│ ├── main.rs # 入口:Sentry 初始化、创建窗口、挂载 gpui-kit Root
|
||||
│ ├── lib.rs # 库导出(模型/引擎/UI 可被集成测试复用)
|
||||
│ ├── model.rs # 数据模型(连接/订阅/消息/预设/变量/设置/模板渲染)
|
||||
│ ├── mqtt.rs # MQTT 引擎:多连接生命周期、TLS/WS、重连、事件通道
|
||||
│ ├── store.rs # JSON 持久化(%APPDATA%/MQTTX-GPUI)
|
||||
│ ├── 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)
|
||||
```
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 / 生命周期 ──────────────────────────────────────────────
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 对话框 ────────────────────────────────────────────────────────────
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
});
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ─── 渲染 ────────────────────────────────────────────────────────────────────
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶ms.topic, &vars);
|
||||
params.payload = render_template(¶ms.payload, &vars);
|
||||
// {{topic}} 等变量渲染后可能变空串,渲染完成后再校验一次
|
||||
if params.topic.trim().is_empty() {
|
||||
window.push_notification(Notification::warning("发布主题不能为空"), cx);
|
||||
return;
|
||||
}
|
||||
// {{变量}} 同样作用于用户属性的 key/value
|
||||
params.user_properties = params
|
||||
.user_properties
|
||||
.into_iter()
|
||||
.map(|(k, v)| (render_template(&k, &vars), render_template(&v, &vars)))
|
||||
.collect();
|
||||
// v5 文本属性(Content-Type / Response Topic / Correlation Data)一并注入
|
||||
params.content_type = params.content_type.map(|v| render_template(&v, &vars));
|
||||
params.response_topic = params.response_topic.map(|v| render_template(&v, &vars));
|
||||
params.correlation_data = params.correlation_data.map(|v| render_template(&v, &vars));
|
||||
|
||||
if let PayloadFormat::Json = params.payload_format
|
||||
&& let Err(e) = serde_json::from_str::<serde_json::Value>(¶ms.payload) {
|
||||
window.push_notification(
|
||||
Notification::warning(format!("JSON 格式无效: {e}")),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
match params.payload_format.encode(¶ms.payload) {
|
||||
Ok(bytes) => params.raw_bytes = Some(bytes),
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::warning(e), cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// v3.1.1 忽略 v5 属性,避免残留输入带进发布报文
|
||||
if !self.is_v5(cx) {
|
||||
params.user_properties.clear();
|
||||
params.content_type = None;
|
||||
params.message_expiry_interval = None;
|
||||
params.response_topic = None;
|
||||
params.correlation_data = None;
|
||||
}
|
||||
self.engine.publish(self.conn_id.clone(), params);
|
||||
}
|
||||
|
||||
/// 读取当前发布面板上的参数(模板渲染前的原始值);保存预设与发布共用,
|
||||
/// 须覆盖全部 v5 属性与用户属性。
|
||||
fn collect_publish_params(&self, cx: &App) -> PublishParams {
|
||||
let qos = self.pub_qos.read(cx).selected_value().copied().unwrap_or(0) as u8;
|
||||
let format = PayloadFormat::ALL
|
||||
[self.payload_format.read(cx).selected_value().copied().unwrap_or(0)];
|
||||
let opt = |s: String| {
|
||||
let t = s.trim();
|
||||
(!t.is_empty()).then(|| t.to_string())
|
||||
};
|
||||
PublishParams {
|
||||
topic: self.pub_topic.read(cx).value().to_string(),
|
||||
payload: self.payload.read(cx).value().to_string(),
|
||||
payload_format: format,
|
||||
qos,
|
||||
retain: self.retain,
|
||||
user_properties: self.user_props.read(cx).pairs(cx),
|
||||
content_type: opt(self.content_type.read(cx).value().to_string()),
|
||||
message_expiry_interval: {
|
||||
let t = self.msg_expiry.read(cx).value().to_string();
|
||||
let t = t.trim();
|
||||
if t.is_empty() { None } else { t.parse::<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", ¶ms.user_properties, window, cx));
|
||||
let qos_idx = params.qos.min(2) as usize;
|
||||
self.pub_qos.update(cx, |s, cx| {
|
||||
s.set_selected_index(Some(IndexPath::new(qos_idx)), window, cx)
|
||||
});
|
||||
let fmt_idx = PayloadFormat::ALL
|
||||
.iter()
|
||||
.position(|f| *f == params.payload_format)
|
||||
.unwrap_or(0);
|
||||
self.payload_format.update(cx, |s, cx| {
|
||||
s.set_selected_index(Some(IndexPath::new(fmt_idx)), window, cx)
|
||||
});
|
||||
self.retain = params.retain;
|
||||
// 同步模板变量面板:预设替换了模板,旧占位符行必须随之清理,
|
||||
// 否则残留行仍会写回全局变量
|
||||
self.sync_var_rows(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn render_publish_bar(&mut self, cx: &mut Context<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(¶ms, window, cx);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if has {
|
||||
menu = menu.separator();
|
||||
}
|
||||
// 保存当前
|
||||
let weak_save = weak_view.clone();
|
||||
let name_input = preset_name.clone();
|
||||
menu = menu.item(PopupMenuItem::new("保存当前为预设…").on_click(
|
||||
move |_ev, window, cx| {
|
||||
if let Some(view) = weak_save.upgrade() {
|
||||
view.update(cx, |view, cx| {
|
||||
view.open_save_preset_dialog(window, cx, name_input.clone());
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
// 打开预设管理对话框(新建 / 编辑 / 删除)
|
||||
let manage_app = app_weak.clone();
|
||||
menu = menu.item(PopupMenuItem::new("管理预设…").on_click(
|
||||
move |_ev, window, cx| {
|
||||
if let Some(app) = manage_app.upgrade() {
|
||||
crate::ui::dialogs::presets_dialog::open(app, window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
// 删除子菜单
|
||||
if has {
|
||||
let weak_del = weak_view.clone();
|
||||
let del_presets = presets.clone();
|
||||
menu = menu.submenu(
|
||||
"删除预设",
|
||||
window,
|
||||
cx,
|
||||
move |submenu, _window, _cx| {
|
||||
let mut submenu = submenu;
|
||||
for p in &del_presets {
|
||||
let weak2 = weak_del.clone();
|
||||
let id = p.id.clone();
|
||||
let name = p.name.clone();
|
||||
let label = format!("删除「{}」", p.name);
|
||||
submenu = submenu.item(
|
||||
PopupMenuItem::new(SharedString::from(label)).on_click(
|
||||
move |_ev, window, cx| {
|
||||
// 预设删除不可撤销,先弹二次确认
|
||||
let weak = weak2.clone();
|
||||
let pid = id.clone();
|
||||
let pname = name.clone();
|
||||
window.open_alert_dialog(cx, move |alert, _, _| {
|
||||
let weak = weak.clone();
|
||||
let pid = pid.clone();
|
||||
alert
|
||||
.title("删除预设")
|
||||
.description(format!(
|
||||
"确定删除预设「{pname}」吗?此操作不可撤销。"
|
||||
))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.show_cancel(true)
|
||||
.cancel_text("取消")
|
||||
.ok_text("删除")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.on_ok(move |_, _, cx| {
|
||||
weak.update(cx, |view, cx| {
|
||||
if let Some(app) = view.app.upgrade() {
|
||||
app.update(cx, |app, cx| {
|
||||
app.delete_publish_preset(&pid);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
true
|
||||
})
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
submenu
|
||||
},
|
||||
);
|
||||
}
|
||||
menu
|
||||
})
|
||||
}
|
||||
|
||||
/// 打开"保存为预设"命名对话框,保存当前发布参数。
|
||||
fn open_save_preset_dialog(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<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))
|
||||
}
|
||||
}
|
||||
@@ -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!["名称"]);
|
||||
}
|
||||
}
|
||||
@@ -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}")),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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(),
|
||||
@@ -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 已随应用加载)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 时间格式化 ──────────────────────────────────────────────────────────────
|
||||
@@ -0,0 +1,9 @@
|
||||
//! 跨界面复用的小组件:枚举下拉、键值对编辑器、字段行、状态指示等。
|
||||
|
||||
mod common;
|
||||
mod kv_editor;
|
||||
mod select;
|
||||
|
||||
pub use common::*;
|
||||
pub use kv_editor::*;
|
||||
pub use select::*;
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user