feat: 重写为 GPUI 原生桌面应用并接入 Gitea 软件包发布

- gpui-kit 0.6 原生 UI:连接管理、消息流/日志、发布预设、全局变量、
  设置、阿里云连接预设、原生 Logo
- MQTT 5.0/3.1.1 引擎(rumqttc,TCP/TLS/WS/WSS),独立 tokio runtime
  + 事件泵,真实 broker 端到端测试
- 连接表单:保存前可一键测试连接(宽松校验,自动生成临时 Client ID),
  高级属性(认证/MQTT 5 属性/遗嘱)按需折叠显示,WS 路径仅 WS/WSS 显示
- bug 修复:Hex 奇数长度显式报错、测试连接唯一 Client ID、Base64/Hex
  发布发送真实字节、v5 发布记录携带 Content-Type/用户属性、预设应用
  清除旧 Content-Type、模板时间基准统一;补 8 个单元测试
- Gitea 发布:Generic Package 上传脚本(bash/PowerShell)+ Actions
  release 工作流(tag 触发,双平台构建),README 使用说明
This commit is contained in:
2026-09-28 11:39:47 +08:00
parent 0152a10ee6
commit bf6937b776
25 changed files with 15287 additions and 120 deletions
+298
View File
@@ -0,0 +1,298 @@
//! 阿里云 IoT 设备接入对话框:保存预设,并可一键生成连接并连接。
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::select::{Select, SelectState};
use gpui_kit::component::{
h_flex, notification::Notification, v_flex, ActiveTheme as _, Sizable as _, StyledExt as _,
WindowExt as _,
};
use gpui_kit::prelude::FluentBuilder as _;
use crate::ui::IconName;
use gpui_kit::{
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, StatefulInteractiveElement as _,
IntoElement, ParentElement as _, Render, Styled as _, Window,
};
use crate::aliyun::{AliyunAuthMode, AliyunPreset};
use crate::model::ConnectionConfig;
use crate::ui::app::MqttXApp;
use crate::ui::widgets::{field, make_select, OptionDelegate};
const AUTH_MODES: [&str; 2] = ["Group 令牌 (Token)", "一机一密"];
struct AliyunDialog {
app: Entity<MqttXApp>,
presets: Vec<AliyunPreset>,
selected: Option<usize>,
name: Entity<InputState>,
group: Entity<InputState>,
instance_id: Entity<InputState>,
region: Entity<InputState>,
auth_mode: Entity<SelectState<OptionDelegate>>,
access_key_id: Entity<InputState>,
access_key_secret: Entity<InputState>,
group_id: Entity<InputState>,
device_id: Entity<InputState>,
device_secret: Entity<InputState>,
}
macro_rules! text_field {
($cx:expr, $window:expr, $value:expr, $ph:expr) => {
$cx.new(|cx| {
InputState::new($window, cx)
.default_value($value)
.placeholder($ph)
})
};
}
impl AliyunDialog {
fn new(app: Entity<MqttXApp>, presets: Vec<AliyunPreset>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let first = presets.first().cloned().unwrap_or_else(AliyunPreset::new);
Self {
app,
presets,
selected: None,
name: text_field!(cx, window, &first.name, "预设名称"),
group: text_field!(cx, window, &first.group, "分组(可选)"),
instance_id: text_field!(cx, window, &first.instance_id, "post-cn-xxxx"),
region: text_field!(cx, window, &first.region, "如 cn-shanghai(可留空)"),
auth_mode: make_select(
&AUTH_MODES,
match first.auth_mode {
AliyunAuthMode::Token => 0,
AliyunAuthMode::DeviceCredential => 1,
},
window,
cx,
),
access_key_id: text_field!(cx, window, &first.access_key_id, "AccessKey ID"),
access_key_secret: cx.new(|cx| {
InputState::new(window, cx)
.default_value(&first.access_key_secret)
.placeholder("AccessKey Secret")
.masked(true)
}),
group_id: text_field!(cx, window, &first.group_id, "GID-xxx"),
device_id: text_field!(cx, window, &first.device_id, "设备 ID"),
device_secret: cx.new(|cx| {
InputState::new(window, cx)
.default_value(&first.device_secret)
.placeholder("一机一密:DeviceSecret")
.masked(true)
}),
}
}
fn collect(&self, cx: &App) -> AliyunPreset {
let v = |e: &Entity<InputState>| e.read(cx).value().to_string();
let id = self
.selected
.and_then(|i| self.presets.get(i))
.map(|p| p.id.clone())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
AliyunPreset {
id,
name: v(&self.name),
group: v(&self.group),
instance_id: v(&self.instance_id),
region: v(&self.region),
auth_mode: match self.auth_mode.read(cx).selected_value() {
Some(1) => AliyunAuthMode::DeviceCredential,
_ => AliyunAuthMode::Token,
},
access_key_id: v(&self.access_key_id),
access_key_secret: v(&self.access_key_secret),
group_id: v(&self.group_id),
device_id: v(&self.device_id),
device_secret: v(&self.device_secret),
}
}
fn save_preset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let preset = self.collect(cx);
if preset.name.trim().is_empty() {
window.push_notification(Notification::warning("请填写预设名称"), cx);
return;
}
match self.selected {
Some(i) if i < self.presets.len() => self.presets[i] = preset,
_ => self.presets.push(preset),
}
let saved = self.presets.clone();
self.app.update(cx, |a, cx| {
a.save_aliyun(saved);
cx.notify();
});
window.push_notification(Notification::success("预设已保存"), cx);
}
fn connect(&self, window: &mut Window, cx: &mut Context<Self>) {
let preset = self.collect(cx);
match preset.to_connection() {
Ok(conn) => {
let mut conn: ConnectionConfig = conn;
conn.name = preset.name.clone();
self.app.update(cx, |a, cx| {
a.save_connection(conn.clone());
a.engine.connect(conn);
cx.notify();
});
window.close_dialog(cx);
}
Err(e) => {
window.push_notification(Notification::error(format!("无法生成连接: {e}")), cx);
}
}
}
}
impl Render for AliyunDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.gap_3()
.w_full()
// 左:预设列表
.child(
v_flex()
.w(px(160.))
.gap_1()
.child(div().text_xs().font_semibold().child("已保存预设"))
.children(
self.presets
.iter()
.enumerate()
.map(|(i, p)| {
let selected = self.selected == Some(i);
div()
.id(("aliyun-preset", i))
.px_2()
.py_1()
.rounded_md()
.text_sm()
.when(selected, |d| d.bg(cx.theme().secondary))
.cursor_pointer()
.child(p.name.clone())
.on_click(cx.listener(move |this, _, _, cx| {
// 选中仅做高亮;表单编辑的是“新建/另存”工作流
this.selected = Some(i);
cx.notify();
}))
}),
)
.child(
Button::new("aliyun-new")
.icon(IconName::Plus)
.label("新建")
.ghost()
.small()
.on_click(cx.listener(|this, _, _, cx| {
this.selected = None;
cx.notify();
})),
),
)
// 右:表单
.child(
v_flex()
.flex_1()
.min_w(px(0.))
.gap_2()
.child(field("预设名称", Input::new(&self.name)))
.child(
h_flex()
.gap_2()
.child(
div().flex_1().min_w(px(0.)).child(field(
"实例 ID",
Input::new(&self.instance_id),
)),
)
.child(
div().flex_1().min_w(px(0.)).child(field(
"地域",
Input::new(&self.region),
)),
),
)
.child(field("鉴权方式", Select::new(&self.auth_mode)))
.child(
h_flex()
.gap_2()
.child(
div().flex_1().min_w(px(0.)).child(field(
"AccessKey ID",
Input::new(&self.access_key_id),
)),
)
.child(
div().flex_1().min_w(px(0.)).child(field(
"AccessKey Secret",
Input::new(&self.access_key_secret).mask_toggle(),
)),
),
)
.child(
h_flex()
.gap_2()
.child(
div().flex_1().min_w(px(0.)).child(field(
"Group ID",
Input::new(&self.group_id),
)),
)
.child(
div().flex_1().min_w(px(0.)).child(field(
"设备 ID",
Input::new(&self.device_id),
)),
),
)
.child(field(
"DeviceSecret(一机一密)",
Input::new(&self.device_secret).mask_toggle(),
))
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("Token 模式使用 MQTT 3.1.1,clientId={GroupId}@@@{设备ID},密码为 HMAC-SHA1 签名"),
)
.child(
h_flex()
.gap_2()
.justify_end()
.child(
Button::new("aliyun-save")
.label("保存预设")
.outline()
.on_click(cx.listener(|this, _, window, cx| {
this.save_preset(window, cx)
})),
)
.child(
Button::new("aliyun-connect")
.label("生成并连接")
.primary()
.on_click(cx.listener(|this, _, window, cx| {
this.connect(window, cx)
})),
),
),
)
}
}
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
let presets = app.read(cx).aliyun_presets.clone();
let dialog_view = cx.new(|cx| AliyunDialog::new(app, presets, window, cx));
window.open_dialog(cx, move |dialog, _, _| {
dialog
.w(px(760.))
.title("阿里云 IoT 设备")
.child(dialog_view.clone())
.footer(gpui_kit::div())
});
}
+878
View File
@@ -0,0 +1,878 @@
//! 应用外壳:标题栏 + 连接侧边栏 + 多标签连接视图 + 引擎事件泵。
use std::collections::{HashMap, VecDeque};
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::scroll::ScrollableElement as _;
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem};
use gpui_kit::component::tab::{Tab, TabBar};
use gpui_kit::component::{
h_flex, notification::Notification, v_flex, ActiveTheme as _, Selectable as _, Sizable as _,
StyledExt as _, Theme, ThemeMode, TitleBar, WindowExt as _,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::{
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, StatefulInteractiveElement as _,
IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window,
};
use crate::aliyun::AliyunPreset;
use crate::model::{
AppSettings, ConnectionConfig, ConnectionStatus, Direction, GlobalVariable, LogEntry,
LogLevel, MqttRecord, PublishParams, PublishPreset, Subscription, ThemeModePref,
};
use crate::mqtt::{EngineEvent, MqttEngine};
use crate::store::Storage;
use crate::ui::IconName;
use crate::ui::{
connection_form::ConnectionForm,
connection_view::ConnectionView,
widgets::status_color,
};
const MAX_LOGS: usize = 3000;
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>,
pub messages: HashMap<String, Vec<MqttRecord>>,
pub logs: VecDeque<LogEntry>,
pub seq: u64,
// ── 标签与视图 ──
open_tabs: Vec<String>,
active_tab: Option<String>,
views: HashMap<String, Entity<ConnectionView>>,
search: Entity<InputState>,
}
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();
}
});
let search = cx.new(|cx| InputState::new(window, cx).placeholder("搜索连接…"));
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,
}
}
// ── 引擎事件 ──────────────────────────────────────────────────────────
fn on_engine_event(&mut self, event: EngineEvent, window: &mut Window, cx: &mut Context<Self>) {
match event {
EngineEvent::Status {
connection_id,
status,
error,
..
} => {
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();
record.seq = self.next_seq();
self.push_message(id.clone(), record);
if let Some(view) = self.views.get(&id) {
view.update(cx, |_, cx| cx.notify());
}
}
EngineEvent::Published {
connection_id,
topic,
payload,
qos,
retain,
content_type,
user_properties,
} => {
let record = MqttRecord {
seq: self.next_seq(),
connection_id: connection_id.clone(),
direction: Direction::Published,
topic,
payload,
qos,
retain,
timestamp: chrono::Local::now().timestamp_millis(),
user_properties,
content_type,
response_topic: None,
correlation_data: None,
message_expiry_interval: None,
subscription_identifier: None,
};
self.push_message(connection_id.clone(), record);
if let Some(view) = self.views.get(&connection_id) {
view.update(cx, |_, cx| cx.notify());
}
}
EngineEvent::SubscribeResult {
connection_id,
topic,
qos,
ok,
error,
} => {
if ok {
self.add_subscription(Subscription::new(connection_id.clone(), topic, qos));
} else if let Some(e) = error {
window.push_notification(
Notification::error(format!("订阅失败: {e}")),
cx,
);
}
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)
.cloned()
.collect();
for sub in subs {
self.engine.subscribe(connection_id.to_string(), sub);
}
}
// ── 数据工具 ──────────────────────────────────────────────────────────
fn next_seq(&mut self) -> u64 {
self.seq += 1;
self.seq
}
fn push_message(&mut self, connection_id: String, record: MqttRecord) {
let cap = self.settings.max_messages.max(100);
let list = self.messages.entry(connection_id).or_default();
if list.len() >= cap {
let drop_n = list.len() - cap + 1;
list.drain(0..drop_n);
}
list.push(record);
}
fn push_log(&mut self, entry: LogEntry) {
if self.logs.len() >= MAX_LOGS {
self.logs.pop_front();
}
self.logs.push_back(entry);
}
pub fn log(&mut self, conn: &str, level: LogLevel, event: &str, message: String) {
self.push_log(LogEntry {
timestamp: chrono::Local::now().timestamp_millis(),
connection_id: conn.to_string(),
level,
event: event.to_string(),
message,
details: None,
});
}
// ── 连接 CRUD / 生命周期 ──────────────────────────────────────────────
pub fn save_connection(&mut self, cfg: ConnectionConfig) {
if let Some(slot) = self.connections.iter_mut().find(|c| c.id == cfg.id) {
*slot = cfg;
} else {
self.connections.push(cfg);
}
self.storage.save_connections(&self.connections);
}
pub fn duplicate_connection(&mut self, id: &str) {
let Some(src) = self.connections.iter().find(|c| c.id == id).cloned() else {
return;
};
let mut copy = src;
copy.id = uuid::Uuid::new_v4().to_string();
copy.name = format!("{} 副本", copy.name);
copy.client_id =
format!("mqttx_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
self.connections.push(copy);
self.storage.save_connections(&self.connections);
}
pub fn delete_connection(&mut self, id: &str, cx: &mut Context<Self>) {
self.engine.close(id, false);
self.connections.retain(|c| c.id != id);
self.subscriptions.retain(|s| s.connection_id != id);
self.messages.remove(id);
self.statuses.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) {
if self.engine.is_connected(&cfg.id) {
self.engine.close(&cfg.id, true);
} else {
self.engine.connect(cfg.clone());
}
}
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();
}
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 form = cx.new(|cx| ConnectionForm::new(engine, edit, 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) => {
app_ok
.update(cx, |app, cx| {
app.save_connection(cfg);
cx.notify();
})
.ok();
window.close_dialog(cx);
}
Err(e) => {
window.push_notification(
Notification::error(e),
cx,
);
}
}
}),
),
)
});
}
}
// ─── 主题 ────────────────────────────────────────────────────────────────────
pub fn apply_theme(pref: ThemeModePref, cx: &mut App) {
let mode = match pref {
ThemeModePref::Light => ThemeMode::Light,
ThemeModePref::Dark => ThemeMode::Dark,
// 未做系统外观检测前,“跟随系统”暂按浅色处理
ThemeModePref::System => ThemeMode::Light,
};
Theme::change(mode, None, cx);
}
// ─── 渲染 ────────────────────────────────────────────────────────────────────
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))
}
}
impl MqttXApp {
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()
.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(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();
menu.item(
PopupMenuItem::new("阿里云设备")
.on_click(move |_, window, cx| {
if let Some(entity) = w1.upgrade() {
crate::ui::aliyun_dialog::open(entity, window, cx);
}
}),
)
.item(PopupMenuItem::new("全局变量").on_click(
move |_, window, cx| {
if let Some(entity) = w2.upgrade() {
crate::ui::variables_dialog::open(entity, window, cx);
}
},
))
.separator()
.item(PopupMenuItem::new("设置").on_click(
move |_, window, cx| {
if let Some(entity) = w3.upgrade() {
crate::ui::settings_dialog::open(entity, window, cx);
}
},
))
}),
),
),
)
}
fn render_theme_toggle(&self, cx: &mut Context<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();
}))
}
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 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() {
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 query.is_empty() {
v_flex().flex_1().child(rows)
} 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(body)
}
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 |_, _, cx| {
w3.update(cx, |app, cx| app.delete_connection(&e3, cx)).ok();
}))
})
}
fn render_main(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.open_tabs.is_empty() {
return v_flex()
.flex_1()
.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");
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();
v_flex()
.flex_1()
.min_w(px(0.))
.child(div().flex_shrink_0().px_2().pt_1().child(bar))
.when_some(view, |this, v| this.child(v))
.into_any_element()
}
}
+583
View File
@@ -0,0 +1,583 @@
//! 新建/编辑连接的表单对话框内容。
use std::sync::Arc;
use gpui_kit::component::scroll::ScrollableElement as _;
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::select::{Select, SelectEvent, SelectState};
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{
h_flex, notification::Notification, v_flex, ActiveTheme as _, Icon, Sizable as _,
StyledExt as _, WindowExt as _,
};
use gpui_kit::prelude::FluentBuilder as _;
use crate::ui::IconName;
use gpui_kit::{
div, percentage, px, App, AppContext as _, Context, Entity, InteractiveElement as _,
IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _,
Styled as _, Subscription, Window,
};
use crate::model::{ConnectionConfig, LastWill, ProtocolVersion, TransportKind};
use crate::mqtt::MqttEngine;
use crate::ui::widgets::{field, make_select, OptionDelegate};
const PROTOCOLS: [&str; 2] = ["MQTT 5.0", "MQTT 3.1.1"];
const TRANSPORTS: [&str; 4] = ["TCP", "TLS", "WebSocket", "WSS"];
const QOS: [&str; 3] = ["QoS 0", "QoS 1", "QoS 2"];
pub struct ConnectionForm {
engine: Arc<MqttEngine>,
editing_id: Option<String>,
created_at: i64,
name: Entity<InputState>,
host: Entity<InputState>,
port: Entity<InputState>,
path: Entity<InputState>,
client_id: Entity<InputState>,
username: Entity<InputState>,
password: Entity<InputState>,
keep_alive: Entity<InputState>,
session_expiry: Entity<InputState>,
receive_max: Entity<InputState>,
max_packet: Entity<InputState>,
topic_alias: Entity<InputState>,
will_topic: Entity<InputState>,
will_payload: Entity<InputState>,
protocol: Entity<SelectState<OptionDelegate>>,
transport: Entity<SelectState<OptionDelegate>>,
will_qos: Entity<SelectState<OptionDelegate>>,
clean_start: bool,
auto_resubscribe: bool,
auto_reconnect: bool,
will_enabled: bool,
will_retain: bool,
// 按需显示:高级分组默认折叠
show_auth: bool,
show_mqtt5: bool,
show_will: bool,
testing: bool,
_subs: Vec<Subscription>,
}
impl ConnectionForm {
/// 对话框 footer 的测试按钮需要读取测试进行中的状态。
pub fn is_testing(&self) -> bool {
self.testing
}
}
fn input(
value: &str,
placeholder: &str,
window: &mut Window,
cx: &mut Context<ConnectionForm>,
) -> Entity<InputState> {
cx.new(|cx| {
InputState::new(window, cx)
.placeholder(placeholder)
.default_value(value)
})
}
fn optional_input(
value: Option<u32>,
placeholder: &str,
window: &mut Window,
cx: &mut Context<ConnectionForm>,
) -> Entity<InputState> {
cx.new(|cx| {
let s = InputState::new(window, cx).placeholder(placeholder);
match value {
Some(v) => s.default_value(v.to_string()),
None => s,
}
})
}
impl ConnectionForm {
pub fn new(
engine: Arc<MqttEngine>,
edit: Option<ConnectionConfig>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let c = edit.clone().unwrap_or_default();
let editing_id = edit.as_ref().map(|e| e.id.clone());
let created_at = edit.as_ref().map(|e| e.created_at).unwrap_or_else(|| {
chrono::Local::now().timestamp()
});
let name = input(&c.name, "连接名称", window, cx);
let host = input(&c.host, "broker.example.com", window, cx);
let port = input(&c.port.to_string(), "端口", window, cx);
let path = input(&c.path, "/mqtt", window, cx);
let client_id = input(&c.client_id, "Client ID", window, cx);
let username = input(&c.username, "用户名(可选)", window, cx);
let password = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("密码(可选)")
.default_value(&c.password)
.masked(true)
});
let keep_alive = input(&c.keep_alive.to_string(), "60", window, cx);
let session_expiry = input(
&if c.session_expiry_interval > 0 {
c.session_expiry_interval.to_string()
} else {
String::new()
},
"秒(0=会话随连接结束)",
window,
cx,
);
let receive_max = optional_input(c.receive_maximum.map(u32::from), "服务端接收上限", window, cx);
let max_packet = optional_input(c.maximum_packet_size, "最大报文长度", window, cx);
let topic_alias = optional_input(c.topic_alias_maximum.map(u32::from), "主题别名上限", window, cx);
let (will_topic, will_payload, will_enabled, will_retain, will_qos_idx) =
match &c.last_will {
Some(w) => (
input(&w.topic, "遗嘱主题", window, cx),
input(&w.payload, "遗嘱消息", window, cx),
true,
w.retain,
w.qos as usize,
),
None => (
input("", "遗嘱主题", window, cx),
input("", "遗嘱消息", window, cx),
false,
false,
0,
),
};
let protocol = make_select(
&PROTOCOLS,
if c.protocol.is_v5() { 0 } else { 1 },
window,
cx,
);
let transport_idx = TransportKind::ALL
.iter()
.position(|t| *t == c.transport)
.unwrap_or(0);
let transport = make_select(&TRANSPORTS, transport_idx, window, cx);
let will_qos = make_select(&QOS, will_qos_idx, window, cx);
// 切换传输方式时,把端口同步成该方式的默认端口,并刷新 WS 路径的按需显示。
let port_for_sub = port.clone();
let sub_transport = cx.subscribe_in(
&transport,
window,
move |_this, state, event, window, cx| {
if matches!(event, SelectEvent::Confirm(Some(_)))
&& let Some(idx) = state.read(cx).selected_value() {
let kind = TransportKind::ALL[*idx];
port_for_sub.update(cx, |p, cx| {
p.set_value(kind.default_port().to_string(), window, cx);
});
cx.notify();
}
},
);
// 切换协议时刷新 MQTT 5 属性分组的按需显示。
let sub_protocol = cx.subscribe_in(
&protocol,
window,
move |_this, _state, event, _window, cx| {
if matches!(event, SelectEvent::Confirm(Some(_))) {
cx.notify();
}
},
);
Self {
engine,
editing_id,
created_at,
name,
host,
port,
path,
client_id,
username,
password,
keep_alive,
session_expiry,
receive_max,
max_packet,
topic_alias,
will_topic,
will_payload,
protocol,
transport,
will_qos,
clean_start: c.clean_start,
auto_resubscribe: c.auto_resubscribe,
auto_reconnect: c.auto_reconnect,
will_enabled,
will_retain,
show_auth: true,
show_mqtt5: false,
show_will: false,
testing: false,
_subs: vec![sub_transport, sub_protocol],
}
}
fn val(&self, e: &Entity<InputState>, cx: &App) -> String {
e.read(cx).value().to_string()
}
fn parse_u16(s: &str, label: &str) -> Result<u16, String> {
s.trim()
.parse::<u16>()
.map_err(|_| format!("{label} 需要是 0~65535 的整数"))
}
fn parse_opt_u32(s: &str, label: &str) -> Result<Option<u32>, String> {
let s = s.trim();
if s.is_empty() {
return Ok(None);
}
s.parse::<u32>()
.map(Some)
.map_err(|_| format!("{label} 需要是非负整数"))
}
pub fn build(&self, cx: &App) -> Result<ConnectionConfig, String> {
self.build_inner(cx, false)
}
/// 测试连接专用的宽松构建:名称与 Client ID 不阻塞测试
///(握手时会生成临时唯一 Client ID)。
fn build_for_test(&self, cx: &App) -> Result<ConnectionConfig, String> {
self.build_inner(cx, true)
}
fn build_inner(&self, cx: &App, for_test: bool) -> Result<ConnectionConfig, String> {
let mut name = self.val(&self.name, cx);
if name.trim().is_empty() {
if !for_test {
return Err("连接名称不能为空".into());
}
name = "测试连接".into();
}
let host = self.val(&self.host, cx);
if host.trim().is_empty() {
return Err("主机地址不能为空".into());
}
let port = Self::parse_u16(&self.val(&self.port, cx), "端口")?;
let mut client_id = self.val(&self.client_id, cx);
if client_id.trim().is_empty() {
if !for_test {
return Err("Client ID 不能为空".into());
}
client_id = format!("mqttx-test-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
}
let keep_alive = Self::parse_u16(&self.val(&self.keep_alive, cx), "Keep Alive")?;
let session_expiry = self
.val(&self.session_expiry, cx)
.trim()
.parse::<u32>()
.unwrap_or(0);
let receive_maximum = Self::parse_opt_u32(&self.val(&self.receive_max, cx), "接收上限")?
.map(|v| u16::try_from(v).map_err(|_| "接收上限不能超过 65535".to_string()))
.transpose()?;
let maximum_packet_size =
Self::parse_opt_u32(&self.val(&self.max_packet, cx), "最大报文长度")?;
let topic_alias_maximum = Self::parse_opt_u32(&self.val(&self.topic_alias, cx), "主题别名")?
.map(|v| u16::try_from(v).map_err(|_| "主题别名上限不能超过 65535".to_string()))
.transpose()?;
let protocol = match self.protocol.read(cx).selected_value() {
Some(1) => ProtocolVersion::V311,
_ => ProtocolVersion::V5,
};
let transport = self
.transport
.read(cx)
.selected_value()
.and_then(|i| TransportKind::ALL.get(*i).copied())
.unwrap_or_default();
let last_will = if self.will_enabled {
let topic = self.val(&self.will_topic, cx);
if topic.trim().is_empty() {
return Err("遗嘱主题不能为空".into());
}
Some(LastWill {
topic,
payload: self.val(&self.will_payload, cx),
qos: self.will_qos.read(cx).selected_value().copied().unwrap_or(0) as u8,
retain: self.will_retain,
content_type: None,
response_topic: None,
})
} else {
None
};
let id = self.editing_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
Ok(ConnectionConfig {
id,
name,
host,
port,
path: self.val(&self.path, cx),
protocol,
transport,
client_id,
username: self.val(&self.username, cx),
password: self.val(&self.password, cx),
clean_start: self.clean_start,
keep_alive,
session_expiry_interval: session_expiry,
receive_maximum,
maximum_packet_size,
topic_alias_maximum,
auto_resubscribe: self.auto_resubscribe,
auto_reconnect: self.auto_reconnect,
last_will,
created_at: self.created_at,
})
}
pub fn run_test(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let mut cfg = match self.build_for_test(cx) {
Ok(c) => c,
Err(e) => {
window.push_notification(Notification::error(e), cx);
return;
}
};
// 测试连接用临时 id,不影响已有连接
cfg.id = uuid::Uuid::new_v4().to_string();
let rx = self.engine.test_connection(cfg);
self.testing = true;
cx.notify();
cx.spawn_in(window, async move |this, cx| {
let result = rx.recv().await.unwrap_or(Err("测试任务丢失".into()));
this.update_in(cx, |form, window, cx| {
form.testing = false;
match result {
Ok(()) => window.push_notification(
Notification::success("连接测试成功"),
cx,
),
Err(e) => window.push_notification(Notification::error(format!(
"连接测试失败: {e}"
)), cx),
}
cx.notify();
})
.ok();
})
.detach();
}
fn switch_row(
&self,
id: &'static str,
label: &'static str,
checked: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
h_flex()
.justify_between()
.child(div().text_sm().child(label))
.child(
Switch::new(id)
.checked(checked)
.on_change(cx.listener(move |this, v, _, cx| {
let v = *v;
match id {
"clean_start" => this.clean_start = v,
"auto_resubscribe" => this.auto_resubscribe = v,
"auto_reconnect" => this.auto_reconnect = v,
"will_enabled" => this.will_enabled = v,
"will_retain" => this.will_retain = v,
_ => {}
}
cx.notify();
})),
)
}
/// 可折叠分组:点击标题行切换展开/收起,收起时不渲染内容。
#[allow(clippy::too_many_arguments)]
fn collapsible_section(
&self,
id: &'static str,
title: &'static str,
hint: Option<SharedString>,
hint_accent: bool,
open: bool,
toggle: fn(&mut Self),
border: gpui_kit::Hsla,
bg: gpui_kit::Hsla,
body: impl IntoElement,
cx: &mut Context<Self>,
) -> impl IntoElement {
let title: SharedString = title.into();
v_flex().gap_2().child(
v_flex()
.child(
h_flex()
.id(id)
.justify_between()
.rounded_md()
.px_1()
.py_1()
.cursor_pointer()
.hover(|s| s.bg(cx.theme().secondary_hover))
.on_click(cx.listener(move |this, _, _, cx| {
toggle(this);
cx.notify();
}))
.child(
h_flex()
.gap_1p5()
.items_center()
.child(div().text_sm().font_semibold().child(title))
.children(hint.map(|h| {
let color = if hint_accent {
cx.theme().accent
} else {
cx.theme().muted_foreground
};
div().text_xs().text_color(color).child(h)
})),
)
.child(
Icon::new(IconName::ChevronDown)
.xsmall()
.text_color(cx.theme().muted_foreground)
.rotate(percentage(if open { 0.5 } else { 0. })),
),
)
.when(open, |w| {
w.child(
div()
.rounded_lg()
.border_1()
.border_color(border)
.bg(bg)
.p_3()
.child(body),
)
}),
)
}
}
impl Render for ConnectionForm {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let border = cx.theme().border;
let card_bg = cx.theme().secondary;
// 协议/传输决定高级分组的按需显示
let is_v5 = self.protocol.read(cx).selected_value() != Some(&1);
let is_ws = matches!(
self.transport.read(cx).selected_value(),
Some(2) | Some(3)
);
v_flex()
.gap_3()
.max_h(px(560.))
.overflow_y_scrollbar()
.pr_1()
// ── 基本信息(始终展开) ──
.child(section("基本信息", border, card_bg,
v_flex().gap_2().child(
h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("名称", Input::new(&self.name))))
.child(div().w(px(120.)).child(field("端口", Input::new(&self.port))))
)
.child(h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("主机", Input::new(&self.host))))
.child(div().flex_1().min_w(px(0.)).child(field("协议", Select::new(&self.protocol))))
.child(div().flex_1().min_w(px(0.)).child(field("传输", Select::new(&self.transport))))
)
.child(h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("Client ID", Input::new(&self.client_id))))
// WS 路径仅对 WebSocket/WSS 有意义,按需显示
.when(is_ws, |w| w.child(
div().flex_1().min_w(px(0.)).child(field("WS 路径", Input::new(&self.path)))
))
)
))
// ── 认证与心跳 ──
.child(self.collapsible_section(
"sec-auth", "认证与心跳", None, false, self.show_auth,
|f| f.show_auth = !f.show_auth, border, card_bg,
v_flex().gap_2()
.child(h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("用户名", Input::new(&self.username))))
.child(div().flex_1().min_w(px(0.)).child(field("密码", Input::new(&self.password).mask_toggle())))
.child(div().w(px(110.)).child(field("Keep Alive (秒)", Input::new(&self.keep_alive))))
)
.child(self.switch_row("clean_start", "Clean Start / Clean Session", self.clean_start, cx))
.child(self.switch_row("auto_reconnect", "断线自动重连", self.auto_reconnect, cx))
.child(self.switch_row("auto_resubscribe", "连接后自动恢复订阅", self.auto_resubscribe, cx)),
cx,
))
// ── MQTT 5 高级属性(仅 v5 协议显示) ──
.when(is_v5, |w| w.child(self.collapsible_section(
"sec-mqtt5", "MQTT 5 属性", None, false, self.show_mqtt5,
|f| f.show_mqtt5 = !f.show_mqtt5, border, card_bg,
h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("会话过期间隔(秒)", Input::new(&self.session_expiry))))
.child(div().flex_1().min_w(px(0.)).child(field("接收最大值", Input::new(&self.receive_max))))
.child(div().flex_1().min_w(px(0.)).child(field("最大报文长度", Input::new(&self.max_packet))))
.child(div().flex_1().min_w(px(0.)).child(field("主题别名上限", Input::new(&self.topic_alias)))),
cx,
)))
// ── 遗嘱 ──
.child(self.collapsible_section(
"sec-will", "遗嘱消息 (Last Will)",
self.will_enabled.then(|| "已启用".into()), true, self.show_will,
|f| f.show_will = !f.show_will, border, card_bg,
v_flex().gap_2()
.child(self.switch_row("will_enabled", "启用遗嘱消息", self.will_enabled, cx))
.when(self.will_enabled, |w| w
.child(h_flex().gap_2()
.child(div().flex_1().min_w(px(0.)).child(field("遗嘱主题", Input::new(&self.will_topic))))
.child(div().w(px(130.)).child(field("QoS", Select::new(&self.will_qos))))
)
.child(field("遗嘱内容", Input::new(&self.will_payload)))
.child(self.switch_row("will_retain", "Retain", self.will_retain, cx))
),
cx,
))
}
}
fn section(
title: impl Into<gpui_kit::SharedString>,
border: gpui_kit::Hsla,
bg: gpui_kit::Hsla,
body: impl IntoElement,
) -> impl IntoElement {
let title: gpui_kit::SharedString = title.into();
v_flex()
.gap_2()
.child(div().text_sm().font_semibold().child(title))
.child(
div()
.rounded_lg()
.border_1()
.border_color(border)
.bg(bg)
.p_3()
.child(body),
)
}
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
//! MQTTX 品牌 Logo:纯 GPUI 原语绘制的 pub/sub 拓扑徽标。
//!
//! - 圆角方块作为品牌底(主色);
//! - 中心圆点代表 broker,连线连向发布端与订阅端,
//! 表达 MQTT “发布 / 代理 / 订阅” 的星型拓扑;
//! - 末端节点用环形描边,与中心实心 broker 区分。
use gpui_kit::{
canvas, div, px, App, Bounds, Corners, Edges, Hsla, IntoElement, ParentElement,
PathBuilder, Pixels, Point, Size, Styled as _, Window,
};
/// 绘制一个指定边长的 Logo(容器被设置为该尺寸)。
pub fn logo(side: Pixels, bg: Hsla, fg: Hsla) -> impl IntoElement {
let inner = canvas(
move |_bounds, _window, _cx| (bg, fg, side),
move |bounds, (bg, fg, side), window, _cx| {
// 品牌圆角方块
let radius = side * 0.26;
window.paint_quad(gpui_kit::quad(
bounds,
Corners::all(radius),
bg,
Edges::all(px(0.)),
bg,
gpui_kit::BorderStyle::default(),
));
let origin = bounds.origin;
// 归一化比例坐标 → 像素
let at = move |x: f32, y: f32| Point::new(origin.x + side * x, origin.y + side * y);
// 中心 broker 与三个客户端节点(右上任一发布、右下与左侧订阅)
let center = (0.5_f32, 0.5_f32);
let nodes = [(0.78_f32, 0.27_f32), (0.78, 0.73), (0.23, 0.5)];
// 连线,两端补圆点形成圆头效果(不依赖 lyon LineCap)
for &(nx, ny) in &nodes {
let a = at(center.0, center.1);
let b = at(nx, ny);
if let Some(p) = line_path(a, b, side * 0.04) {
window.paint_path(p, fg.alpha(0.9));
}
paint_dot(window, a, side * 0.05, fg.alpha(0.9));
paint_dot(window, b, side * 0.05, fg.alpha(0.9));
}
// 实心中心 broker(覆盖端点)
paint_dot(window, at(center.0, center.1), side * 0.17, fg);
// 环形末端节点
for &(nx, ny) in &nodes {
paint_ring(window, at(nx, ny), side * 0.15, side * 0.03, bg, fg);
}
},
)
.absolute()
.size_full();
div()
.relative()
.w(side)
.h(side)
.child(inner)
}
fn paint_dot(window: &mut Window, center: Point<Pixels>, d: Pixels, color: Hsla) {
let r = d * 0.5;
let bounds = Bounds {
origin: Point::new(center.x - r, center.y - r),
size: Size {
width: d,
height: d,
},
};
window.paint_quad(gpui_kit::quad(
bounds,
Corners::all(r),
color,
Edges::all(px(0.)),
color,
gpui_kit::BorderStyle::default(),
));
}
fn paint_ring(
window: &mut Window,
center: Point<Pixels>,
d: Pixels,
thickness: Pixels,
fill: Hsla,
border: Hsla,
) {
let r = d * 0.5;
let bounds = Bounds {
origin: Point::new(center.x - r, center.y - r),
size: Size {
width: d,
height: d,
},
};
window.paint_quad(gpui_kit::quad(
bounds,
Corners::all(r),
fill,
Edges::all(thickness),
border,
gpui_kit::BorderStyle::Solid,
));
}
fn line_path(
a: Point<Pixels>,
b: Point<Pixels>,
width: Pixels,
) -> Option<gpui_kit::Path<Pixels>> {
let mut pb = PathBuilder::stroke(width);
pb.move_to(a);
pb.line_to(b);
pb.build().ok()
}
#[allow(unused_imports)]
use App as _App;
+13
View File
@@ -0,0 +1,13 @@
//! GPUI 界面层。
pub mod aliyun_dialog;
pub mod app;
pub mod connection_form;
pub mod connection_view;
pub mod logo;
pub mod settings_dialog;
pub mod variables_dialog;
pub mod widgets;
/// 统一使用全量 Lucide 图标(gpui-kit-assets 已随应用加载)。
pub use gpui_kit::assets::IconName;
+138
View File
@@ -0,0 +1,138 @@
//! 应用设置对话框:主题、消息缓存条数、时间戳显示。
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::select::{Select, SelectState};
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{
h_flex, notification::Notification, v_flex, WindowExt as _,
};
use gpui_kit::{
div, px, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
Styled as _, Window,
};
use crate::model::{AppSettings, ThemeModePref};
use crate::ui::app::MqttXApp;
use crate::ui::widgets::{field, make_select, OptionDelegate};
const THEMES: [&str; 3] = ["跟随系统", "浅色", "深色"];
struct SettingsDialog {
theme: Entity<SelectState<OptionDelegate>>,
max_messages: Entity<InputState>,
show_millis: bool,
}
impl SettingsDialog {
fn new(initial: AppSettings, window: &mut Window, cx: &mut Context<Self>) -> Self {
let theme_idx = match initial.theme {
ThemeModePref::System => 0,
ThemeModePref::Light => 1,
ThemeModePref::Dark => 2,
};
Self {
theme: make_select(&THEMES, theme_idx, window, cx),
max_messages: cx.new(|cx| {
InputState::new(window, cx).default_value(initial.max_messages.to_string())
}),
show_millis: initial.show_millis,
}
}
fn collect(&self, cx: &App) -> Result<AppSettings, String> {
let theme = match self.theme.read(cx).selected_value() {
Some(1) => ThemeModePref::Light,
Some(2) => ThemeModePref::Dark,
_ => ThemeModePref::System,
};
let max_messages = self
.max_messages
.read(cx)
.value()
.trim()
.parse::<usize>()
.map_err(|_| "消息缓存条数必须是正整数".to_string())?
.max(100);
Ok(AppSettings {
theme,
max_messages,
show_millis: self.show_millis,
..Default::default()
})
}
}
impl Render for SettingsDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_4()
.w(px(420.))
.child(field("主题", Select::new(&self.theme)))
.child(field(
"每条连接内存中保留的消息条数",
Input::new(&self.max_messages),
))
.child(
h_flex()
.justify_between()
.child(div().text_sm().child("时间戳显示毫秒"))
.child(
Switch::new("show-millis")
.checked(self.show_millis)
.on_change(cx.listener(|this, v, _, cx| {
this.show_millis = *v;
cx.notify();
})),
),
)
}
}
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
let initial = app.read(cx).settings.clone();
let dialog_view: Entity<SettingsDialog> =
cx.new(|cx| SettingsDialog::new(initial, window, cx));
let app_save = app.clone();
window.open_dialog(cx, move |dialog, _window, _cx| {
let body = dialog_view.clone();
let for_collect = dialog_view.clone();
let app_for_save = app_save.clone();
dialog
.w(px(480.))
.title("设置")
.child(body)
.footer(
h_flex()
.gap_2()
.justify_end()
.w_full()
.child(
Button::new("settings-cancel")
.label("取消")
.outline()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("settings-ok")
.label("保存")
.primary()
.on_click(move |_, window, cx| {
let result = for_collect.read(cx).collect(cx);
match result {
Ok(settings) => {
app_for_save.update(cx, |a, cx| {
a.save_settings(settings, cx);
cx.notify();
});
window.close_dialog(cx);
}
Err(e) => {
window.push_notification(Notification::error(e), cx);
}
}
}),
),
)
});
}
+74
View File
@@ -0,0 +1,74 @@
//! 全局变量管理对话框。变量可在发布主题/负载中以 `{{key}}` 引用。
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::{h_flex, v_flex, WindowExt as _};
use gpui_kit::{
px, App, AppContext as _, Entity, ParentElement as _, Styled as _, Window,
};
use crate::model::GlobalVariable;
use crate::ui::app::MqttXApp;
use crate::ui::widgets::KvEditor;
pub fn open(app: Entity<MqttXApp>, window: &mut Window, cx: &mut App) {
let pairs: Vec<(String, String)> = app
.read(cx)
.variables
.iter()
.map(|v| (v.key.clone(), v.value.clone()))
.collect();
let editor: Entity<KvEditor> =
cx.new(|cx| KvEditor::new("global-var", &pairs, window, cx));
let app = app.clone();
window.open_dialog(cx, move |dialog, _, _| {
let editor_body = editor.clone();
let for_ok = editor.clone();
let app_ok = app.clone();
dialog
.w(px(560.))
.title("全局变量")
.child(
v_flex()
.gap_2()
.child(
v_flex()
.text_sm()
.gap_1()
.child("在发布主题、消息负载中使用 {{变量名}} 引用。")
.child("内置变量:{{$ts}}(秒)、{{$ts_ms}}(毫秒)、{{$uuid}}"),
)
.child(editor_body),
)
.footer(
h_flex()
.gap_2()
.justify_end()
.w_full()
.child(
Button::new("vars-cancel")
.label("取消")
.outline()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("vars-ok")
.label("保存")
.primary()
.on_click(move |_, window, cx| {
let vars = for_ok
.read(cx)
.pairs(cx)
.into_iter()
.map(|(key, value)| GlobalVariable { key, value })
.collect();
app_ok.update(cx, |a, cx| {
a.save_variables(vars);
cx.notify();
});
window.close_dialog(cx);
}),
),
)
});
}
+240
View File
@@ -0,0 +1,240 @@
//! 跨界面复用的小组件:枚举下拉、键值对编辑器、状态指示、字段行等。
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 {
v_flex()
.gap_1()
.min_w(px(0.))
.child(div().text_xs().child(label.into()))
.child(control)
}
// ─── 状态颜色 ────────────────────────────────────────────────────────────────
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)> {
self.rows
.iter()
.map(|(k, v)| (k.read(cx).value().to_string(), v.read(cx).value().to_string()))
.filter(|(k, _)| !k.trim().is_empty())
.collect()
}
}
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)
}