feat: GPUI 版补齐 MQTTX 基础功能对标(分组/SSL证书/订阅增强/消息流控制/设置与日志)

This commit is contained in:
2026-09-28 14:41:15 +08:00
parent bf6937b776
commit c1c4fc011e
10 changed files with 2550 additions and 280 deletions
+392 -9
View File
@@ -1,6 +1,7 @@
//! 应用外壳:标题栏 + 连接侧边栏 + 多标签连接视图 + 引擎事件泵。
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::scroll::ScrollableElement as _;
@@ -13,14 +14,16 @@ use gpui_kit::component::{
};
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,
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
KeyBinding, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _,
Styled as _, Window,
};
use crate::aliyun::AliyunPreset;
use crate::model::{
AppSettings, ConnectionConfig, ConnectionStatus, Direction, GlobalVariable, LogEntry,
LogLevel, MqttRecord, PublishParams, PublishPreset, Subscription, ThemeModePref,
LogLevel, MqttRecord, PublishParams, PublishPreset, SubscribeOptions, Subscription,
ThemeModePref,
};
use crate::mqtt::{EngineEvent, MqttEngine};
use crate::store::Storage;
@@ -33,6 +36,35 @@ use crate::ui::{
const MAX_LOGS: usize = 3000;
// 应用级快捷键动作(context=None:任意焦点状态下都匹配)。
gpui_kit::actions!(mqttx, [NewConnection, OpenSettings, ExportConnections, ImportConnections]);
/// 侧边栏分组过滤;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>,
@@ -59,6 +91,10 @@ pub struct MqttXApp {
views: HashMap<String, Entity<ConnectionView>>,
search: Entity<InputState>,
/// 分组过滤状态(chips 点击切换,与搜索条件叠加)
group_filter: GroupFilter,
/// 原生文件对话框进行中标记(对话框阻塞后台线程,防止重复弹出)
file_dialog_open: bool,
}
impl MqttXApp {
@@ -88,7 +124,51 @@ impl MqttXApp {
let search = cx.new(|cx| InputState::new(window, cx).placeholder("搜索连接…"));
// ── 全局快捷键:绑定 + 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),
]);
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::settings_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| {
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| {
entity.update(cx, |app, cx| app.import_connections(window, cx));
});
});
Self {
storage,
engine,
@@ -108,6 +188,8 @@ impl MqttXApp {
active_tab: None,
views: HashMap::new(),
search,
group_filter: GroupFilter::All,
file_dialog_open: false,
}
}
@@ -206,7 +288,7 @@ impl MqttXApp {
}
fn on_connected(&mut self, connection_id: &str) {
// 连接成功后自动恢复已保存订阅(auto resubscribe)。
// 连接成功后自动恢复已保存订阅(auto resubscribe),禁用的订阅跳过。
let Some(config) = self.connections.iter().find(|c| c.id == connection_id).cloned() else {
return;
};
@@ -217,10 +299,13 @@ impl MqttXApp {
.subscriptions
.iter()
.filter(|s| s.connection_id == connection_id)
.filter(|s| s.enabled)
.cloned()
.collect();
for sub in subs {
self.engine.subscribe(connection_id.to_string(), sub);
let opts = SubscribeOptions::from(&sub);
self.engine
.subscribe_with_options(connection_id.to_string(), sub, opts);
}
}
@@ -242,12 +327,61 @@ impl MqttXApp {
}
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(&self, entry: &LogEntry) {
use std::io::Write as _;
use std::sync::atomic::{AtomicBool, Ordering};
static WRITE_FAILED: AtomicBool = AtomicBool::new(false);
let result = (|| -> std::io::Result<()> {
use chrono::TimeZone as _;
let ts = chrono::Local
.timestamp_millis_opt(entry.timestamp)
.single()
.unwrap_or_else(chrono::Local::now);
let dir = self.storage.log_dir();
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("mqttx-{}.log", ts.format("%Y-%m-%d")));
// 连接名查不到时回退为 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 mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(
file,
"{} [{}] {}/{}: {}",
ts.format("%Y-%m-%d %H:%M:%S%.3f"),
entry.level.label(),
conn,
entry.event,
entry.message
)
})();
if let Err(e) = result
&& !WRITE_FAILED.swap(true, Ordering::Relaxed)
{
eprintln!("[app] 写入日志文件失败(后续静默): {e}");
}
}
pub fn log(&mut self, conn: &str, level: LogLevel, event: &str, message: String) {
self.push_log(LogEntry {
timestamp: chrono::Local::now().timestamp_millis(),
@@ -386,6 +520,134 @@ impl MqttXApp {
self.storage.save_settings(&self.settings);
}
/// 数据目录(设置对话框展示 / 打开用)。
pub fn data_dir(&self) -> PathBuf {
self.storage.dir().to_path_buf()
}
/// 日志目录(设置对话框展示 / 打开用)。
pub fn log_dir(&self) -> PathBuf {
self.storage.log_dir()
}
// ── 连接导入 / 导出 ────────────────────────────────────────────────────
/// 在后台线程弹出阻塞式原生文件对话框,完成后回主线程执行 `done`。
/// 对话框进行中置位,防止重复弹出。
fn spawn_file_dialog<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 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 {
app.connections.push(item);
added += 1;
}
}
if added > 0 {
app.storage.save_connections(&app.connections);
}
window.push_notification(
Notification::success(format!("导入 {added} 条,跳过 {skipped} 条")),
cx,
);
}
},
window,
cx,
);
}
// ── 对话框 ────────────────────────────────────────────────────────────
pub fn open_connection_form(
@@ -396,7 +658,16 @@ impl MqttXApp {
) {
let app = cx.entity().downgrade();
let engine = self.engine.clone();
let form = cx.new(|cx| ConnectionForm::new(engine, edit, window, cx));
// 表单的分组下拉需要现有分组列表(去重、排序)
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();
@@ -550,6 +821,8 @@ impl MqttXApp {
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();
menu.item(
PopupMenuItem::new("阿里云设备")
.on_click(move |_, window, cx| {
@@ -566,6 +839,25 @@ impl MqttXApp {
},
))
.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() {
@@ -602,12 +894,23 @@ impl MqttXApp {
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 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;
}
@@ -692,8 +995,6 @@ impl MqttXApp {
.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()
@@ -748,9 +1049,91 @@ impl MqttXApp {
.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();
let mut row = h_flex().gap_1().flex_wrap().px_2().pb_1();
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-{}", 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,
+176 -4
View File
@@ -3,6 +3,7 @@
use std::sync::Arc;
use gpui_kit::component::scroll::ScrollableElement as _;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::select::{Select, SelectEvent, SelectState};
use gpui_kit::component::switch::Switch;
@@ -18,18 +19,22 @@ use gpui_kit::{
Styled as _, Subscription, Window,
};
use crate::model::{ConnectionConfig, LastWill, ProtocolVersion, TransportKind};
use crate::model::{ConnectionConfig, LastWill, ProtocolVersion, SslConfig, 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"];
/// 分组下拉的首项:选择它表示不归属任何分组。
const NO_GROUP: &str = "无分组";
pub struct ConnectionForm {
engine: Arc<MqttEngine>,
editing_id: Option<String>,
created_at: i64,
/// 已有连接的分组列表(去重),用于分组下拉
groups: Vec<String>,
name: Entity<InputState>,
host: Entity<InputState>,
@@ -43,24 +48,39 @@ pub struct ConnectionForm {
receive_max: Entity<InputState>,
max_packet: Entity<InputState>,
topic_alias: Entity<InputState>,
conn_timeout: Entity<InputState>,
max_reconnect: Entity<InputState>,
will_topic: Entity<InputState>,
will_payload: Entity<InputState>,
will_content_type: Entity<InputState>,
will_response_topic: Entity<InputState>,
// SSL/TLS 区块(transport 为 TLS/WSS 时显示)
ssl_ca: Entity<InputState>,
ssl_client_cert: Entity<InputState>,
ssl_client_key: Entity<InputState>,
// 新分组名输入;非空时优先于下拉选择
group_new: Entity<InputState>,
protocol: Entity<SelectState<OptionDelegate>>,
transport: Entity<SelectState<OptionDelegate>>,
will_qos: Entity<SelectState<OptionDelegate>>,
group_select: Entity<SelectState<OptionDelegate>>,
clean_start: bool,
auto_resubscribe: bool,
auto_reconnect: bool,
will_enabled: bool,
will_retain: bool,
ssl_ignore_ca: bool,
// 按需显示:高级分组默认折叠
show_auth: bool,
show_mqtt5: bool,
show_will: bool,
show_ssl: bool,
testing: bool,
_subs: Vec<Subscription>,
@@ -105,6 +125,7 @@ impl ConnectionForm {
pub fn new(
engine: Arc<MqttEngine>,
edit: Option<ConnectionConfig>,
groups: Vec<String>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -127,6 +148,18 @@ impl ConnectionForm {
.masked(true)
});
let keep_alive = input(&c.keep_alive.to_string(), "60", window, cx);
let conn_timeout = input(
&c.connection_timeout_secs.to_string(),
"秒(默认 10)",
window,
cx,
);
let max_reconnect = input(
&c.max_reconnect_times.to_string(),
"0=无限重连",
window,
cx,
);
let session_expiry = input(
&if c.session_expiry_interval > 0 {
c.session_expiry_interval.to_string()
@@ -141,7 +174,13 @@ impl ConnectionForm {
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) =
let ssl_ca = input(&c.ssl.ca_file, "CA 证书 PEM 路径(可选)", window, cx);
let ssl_client_cert = input(&c.ssl.client_cert_file, "客户端证书 PEM 路径(可选)", window, cx);
let ssl_client_key = input(&c.ssl.client_key_file, "客户端私钥 PEM 路径(可选)", window, cx);
let group_new = input("", "新分组名(可选)", window, cx);
let (will_topic, will_payload, will_enabled, will_retain, will_qos_idx, will_content_type, will_response_topic) =
match &c.last_will {
Some(w) => (
input(&w.topic, "遗嘱主题", window, cx),
@@ -149,6 +188,8 @@ impl ConnectionForm {
true,
w.retain,
w.qos as usize,
input(w.content_type.as_deref().unwrap_or(""), "内容类型(可选)", window, cx),
input(w.response_topic.as_deref().unwrap_or(""), "响应主题(可选)", window, cx),
),
None => (
input("", "遗嘱主题", window, cx),
@@ -156,6 +197,8 @@ impl ConnectionForm {
false,
false,
0,
input("", "内容类型(可选)", window, cx),
input("", "响应主题(可选)", window, cx),
),
};
@@ -171,6 +214,16 @@ impl ConnectionForm {
.unwrap_or(0);
let transport = make_select(&TRANSPORTS, transport_idx, window, cx);
let will_qos = make_select(&QOS, will_qos_idx, window, cx);
// 分组下拉:首项固定为「无分组」,其后是已有分组
let mut group_labels = vec![NO_GROUP];
group_labels.extend(groups.iter().map(|s| s.as_str()));
let group_idx = c
.group
.as_deref()
.and_then(|g| groups.iter().position(|x| x == g))
.map(|i| i + 1)
.unwrap_or(0);
let group_select = make_select(&group_labels, group_idx, window, cx);
// 切换传输方式时,把端口同步成该方式的默认端口,并刷新 WS 路径的按需显示。
let port_for_sub = port.clone();
@@ -203,6 +256,7 @@ impl ConnectionForm {
engine,
editing_id,
created_at,
groups,
name,
host,
port,
@@ -211,23 +265,34 @@ impl ConnectionForm {
username,
password,
keep_alive,
conn_timeout,
max_reconnect,
session_expiry,
receive_max,
max_packet,
topic_alias,
ssl_ca,
ssl_client_cert,
ssl_client_key,
group_new,
will_topic,
will_payload,
will_content_type,
will_response_topic,
protocol,
transport,
will_qos,
group_select,
clean_start: c.clean_start,
auto_resubscribe: c.auto_resubscribe,
auto_reconnect: c.auto_reconnect,
will_enabled,
will_retain,
ssl_ignore_ca: c.ssl.ignore_ca,
show_auth: true,
show_mqtt5: false,
show_will: false,
show_ssl: false,
testing: false,
_subs: vec![sub_transport, sub_protocol],
}
@@ -298,6 +363,48 @@ impl ConnectionForm {
.map(|v| u16::try_from(v).map_err(|_| "主题别名上限不能超过 65535".to_string()))
.transpose()?;
let conn_timeout_raw = self.val(&self.conn_timeout, cx);
let connection_timeout_secs = if conn_timeout_raw.trim().is_empty() {
10
} else {
conn_timeout_raw
.trim()
.parse::<u16>()
.ok()
.filter(|v| *v >= 1)
.ok_or_else(|| "连接超时需要是 1~65535 的整数(秒)".to_string())?
};
let max_reconnect_raw = self.val(&self.max_reconnect, cx);
let max_reconnect_times = if max_reconnect_raw.trim().is_empty() {
0
} else {
max_reconnect_raw
.trim()
.parse::<u32>()
.map_err(|_| "最大重连次数需要是非负整数(0=无限)".to_string())?
};
// 分组:新分组名非空时优先,否则取下拉所选(首项=无分组)
let group = {
let new_name = self.val(&self.group_new, cx);
let trimmed = new_name.trim();
if !trimmed.is_empty() {
Some(trimmed.to_string())
} else {
let idx = self
.group_select
.read(cx)
.selected_value()
.copied()
.unwrap_or(0);
if idx == 0 {
None
} else {
self.groups.get(idx - 1).cloned()
}
}
};
let protocol = match self.protocol.read(cx).selected_value() {
Some(1) => ProtocolVersion::V311,
_ => ProtocolVersion::V5,
@@ -309,18 +416,35 @@ impl ConnectionForm {
.and_then(|i| TransportKind::ALL.get(*i).copied())
.unwrap_or_default();
let ssl = SslConfig {
ca_file: self.val(&self.ssl_ca, cx).trim().to_string(),
client_cert_file: self.val(&self.ssl_client_cert, cx).trim().to_string(),
client_key_file: self.val(&self.ssl_client_key, cx).trim().to_string(),
ignore_ca: self.ssl_ignore_ca,
};
// 只填证书或只填私钥必然握手失败;仅 TLS/WSS 下 SSL 生效,按生效传输校验
if transport.is_tls()
&& ssl.client_cert_file.is_empty() != ssl.client_key_file.is_empty()
{
return Err("客户端证书与客户端密钥必须同时填写".into());
}
let last_will = if self.will_enabled {
let topic = self.val(&self.will_topic, cx);
if topic.trim().is_empty() {
return Err("遗嘱主题不能为空".into());
}
let opt_str = |v: String| {
let t = v.trim().to_string();
if t.is_empty() { None } else { Some(t) }
};
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,
content_type: opt_str(self.val(&self.will_content_type, cx)),
response_topic: opt_str(self.val(&self.will_response_topic, cx)),
})
} else {
None
@@ -348,6 +472,10 @@ impl ConnectionForm {
auto_reconnect: self.auto_reconnect,
last_will,
created_at: self.created_at,
group,
connection_timeout_secs,
max_reconnect_times,
ssl,
})
}
@@ -406,6 +534,7 @@ impl ConnectionForm {
"auto_reconnect" => this.auto_reconnect = v,
"will_enabled" => this.will_enabled = v,
"will_retain" => this.will_retain = v,
"ssl_ignore_ca" => this.ssl_ignore_ca = v,
_ => {}
}
cx.notify();
@@ -490,6 +619,11 @@ impl Render for ConnectionForm {
self.transport.read(cx).selected_value(),
Some(2) | Some(3)
);
// SSL/TLS 区块只对 TLS/WSS 有意义
let is_tls = matches!(
self.transport.read(cx).selected_value(),
Some(1) | Some(3)
);
v_flex()
.gap_3()
.max_h(px(560.))
@@ -508,7 +642,24 @@ impl Render for ConnectionForm {
.child(div().flex_1().min_w(px(0.)).child(field("传输", Select::new(&self.transport))))
)
.child(h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("分组", Select::new(&self.group_select))))
.child(div().flex_1().min_w(px(0.)).child(field("新建分组(优先于左侧选择)", Input::new(&self.group_new))))
)
.child(h_flex().gap_2().w_full().items_end()
.child(div().flex_1().min_w(px(0.)).child(field("Client ID", Input::new(&self.client_id))))
// 一键换一个随机 Client ID,与 ConnectionConfig::new 的生成逻辑一致
.child(
Button::new("cid-regen")
.icon(IconName::RefreshCw)
.ghost()
.tooltip("重新生成 Client ID")
.on_click(cx.listener(|this, _, window, cx| {
let cid = crate::model::generate_client_id();
this.client_id
.update(cx, |s, cx| s.set_value(cid, window, cx));
cx.notify();
})),
)
// WS 路径仅对 WebSocket/WSS 有意义,按需显示
.when(is_ws, |w| w.child(
div().flex_1().min_w(px(0.)).child(field("WS 路径", Input::new(&self.path)))
@@ -525,11 +676,27 @@ impl Render for ConnectionForm {
.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(h_flex().gap_2().w_full()
.child(div().w(px(150.)).child(field("连接超时 (秒)", Input::new(&self.conn_timeout))))
.child(div().w(px(170.)).child(field("最大重连次数 (0=无限)", Input::new(&self.max_reconnect))))
)
.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,
))
// ── SSL/TLS(仅 TLS/WSS 显示) ──
.when(is_tls, |w| w.child(self.collapsible_section(
"sec-ssl", "SSL/TLS", None, false, self.show_ssl,
|f| f.show_ssl = !f.show_ssl, border, card_bg,
v_flex().gap_2()
// Phase 2 或后续接原生文件对话框;GPUI 暂无内置文件选择器,先用路径文本输入
.child(field("CA 证书 (PEM)", Input::new(&self.ssl_ca)))
.child(field("客户端证书 (PEM)", Input::new(&self.ssl_client_cert)))
.child(field("客户端密钥 (PEM)", Input::new(&self.ssl_client_key)))
.child(self.switch_row("ssl_ignore_ca", "忽略 CA 校验(不验证服务器证书)", self.ssl_ignore_ca, cx)),
cx,
)))
// ── MQTT 5 高级属性(仅 v5 协议显示) ──
.when(is_v5, |w| w.child(self.collapsible_section(
"sec-mqtt5", "MQTT 5 属性", None, false, self.show_mqtt5,
@@ -554,6 +721,11 @@ impl Render for ConnectionForm {
.child(div().w(px(130.)).child(field("QoS", Select::new(&self.will_qos))))
)
.child(field("遗嘱内容", Input::new(&self.will_payload)))
// v5 遗嘱属性;3.1.1 连接会忽略这两项
.child(h_flex().gap_2().w_full()
.child(div().flex_1().min_w(px(0.)).child(field("内容类型 (Content Type)", Input::new(&self.will_content_type))))
.child(div().flex_1().min_w(px(0.)).child(field("响应主题 (Response Topic)", Input::new(&self.will_response_topic))))
)
.child(self.switch_row("will_retain", "Retain", self.will_retain, cx))
),
cx,
File diff suppressed because it is too large Load Diff
+170 -9
View File
@@ -1,11 +1,15 @@
//! 应用设置对话框:主题、消息缓存条数、时间戳显示。
//! 应用设置对话框:主题、消息缓存、时间戳、自动检查更新、数据/日志目录、关于。
use std::path::{Path, PathBuf};
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::clipboard::Clipboard;
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 _,
h_flex, notification::Notification, v_flex, ActiveTheme as _, Sizable as _, StyledExt as _,
WindowExt as _,
};
use gpui_kit::{
div, px, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
@@ -15,17 +19,30 @@ use gpui_kit::{
use crate::model::{AppSettings, ThemeModePref};
use crate::ui::app::MqttXApp;
use crate::ui::widgets::{field, make_select, OptionDelegate};
use crate::ui::IconName;
const THEMES: [&str; 3] = ["跟随系统", "浅色", "深色"];
const SITE_URL: &str = "https://mqttx.app";
const REPO_URL: &str = "https://gitea.heavenlybook.cn/JoeAllen/mqttx";
struct SettingsDialog {
theme: Entity<SelectState<OptionDelegate>>,
max_messages: Entity<InputState>,
show_millis: bool,
auto_check_update: bool,
data_dir: PathBuf,
log_dir: PathBuf,
}
impl SettingsDialog {
fn new(initial: AppSettings, window: &mut Window, cx: &mut Context<Self>) -> Self {
fn new(
initial: AppSettings,
data_dir: PathBuf,
log_dir: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let theme_idx = match initial.theme {
ThemeModePref::System => 0,
ThemeModePref::Light => 1,
@@ -37,6 +54,9 @@ impl SettingsDialog {
InputState::new(window, cx).default_value(initial.max_messages.to_string())
}),
show_millis: initial.show_millis,
auto_check_update: initial.auto_check_update,
data_dir,
log_dir,
}
}
@@ -54,20 +74,41 @@ impl SettingsDialog {
.parse::<usize>()
.map_err(|_| "消息缓存条数必须是正整数".to_string())?
.max(100);
// 四个字段全部显式写出,避免 ..Default::default() 把开关项重置为默认值
Ok(AppSettings {
theme,
max_messages,
show_millis: self.show_millis,
..Default::default()
auto_check_update: self.auto_check_update,
})
}
}
/// 用系统文件管理器打开目录;失败弹 toast(不写日志,避免与日志落盘互相触发)。
fn open_in_file_manager(path: &Path, window: &mut Window, cx: &mut App) {
#[cfg(target_os = "windows")]
let mut cmd = std::process::Command::new("explorer");
#[cfg(target_os = "macos")]
let mut cmd = std::process::Command::new("open");
#[cfg(all(unix, not(target_os = "macos")))]
let mut cmd = std::process::Command::new("xdg-open");
cmd.arg(path);
if let Err(e) = cmd.spawn() {
window.push_notification(Notification::error(format!("打开目录失败: {e}")), cx);
}
}
impl Render for SettingsDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let data_dir = self.data_dir.to_string_lossy().into_owned();
let log_dir = self.log_dir.to_string_lossy().into_owned();
let data_dir_for_open = self.data_dir.clone();
let log_dir_for_open = self.log_dir.clone();
v_flex()
.gap_4()
.w(px(420.))
.w(px(500.))
.child(field("主题", Select::new(&self.theme)))
.child(field(
"每条连接内存中保留的消息条数",
@@ -86,20 +127,140 @@ impl Render for SettingsDialog {
})),
),
)
.child(
h_flex()
.justify_between()
.child(div().text_sm().child("自动检查更新"))
.child(
Switch::new("auto-check-update")
.checked(self.auto_check_update)
.on_change(cx.listener(|this, v, _, cx| {
this.auto_check_update = *v;
cx.notify();
})),
),
)
.child(div().h(px(1.)).bg(cx.theme().border).w_full())
.child(field(
"数据目录",
h_flex()
.gap_1()
.w_full()
.child(
div()
.flex_1()
.min_w(px(0.))
.text_xs()
.whitespace_nowrap()
.overflow_hidden()
.text_ellipsis()
.text_color(cx.theme().muted_foreground)
.child(data_dir.clone()),
)
.child(Clipboard::new("copy-data-dir").value(data_dir.clone()))
.child(
Button::new("open-data-dir")
.icon(IconName::FolderOpen)
.label("打开目录")
.outline()
.xsmall()
.on_click(move |_, window, cx| {
open_in_file_manager(&data_dir_for_open, window, cx);
}),
),
))
.child(field(
"日志目录",
h_flex()
.gap_1()
.w_full()
.child(
div()
.flex_1()
.min_w(px(0.))
.text_xs()
.whitespace_nowrap()
.overflow_hidden()
.text_ellipsis()
.text_color(cx.theme().muted_foreground)
.child(log_dir.clone()),
)
.child(
Button::new("open-log-dir")
.icon(IconName::FolderOpen)
.label("打开日志目录")
.outline()
.xsmall()
.on_click(move |_, window, cx| {
// 日志目录可能尚未创建(从未产生过日志)
if let Err(e) = std::fs::create_dir_all(&log_dir_for_open) {
window.push_notification(
Notification::error(format!("创建日志目录失败: {e}")),
cx,
);
return;
}
open_in_file_manager(&log_dir_for_open, window, cx);
}),
),
))
.child(div().h(px(1.)).bg(cx.theme().border).w_full())
.child(
v_flex().gap_1().child(
v_flex()
.gap_1()
.child(div().text_sm().font_semibold().child("关于"))
.child(
h_flex()
.gap_2()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(format!("版本 {}", env!("CARGO_PKG_VERSION")))
.child("Apache-2.0 许可证"),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("对标 MQTTX 的 GPUI 跨平台 MQTT 调试客户端"),
)
.child(
h_flex().gap_1().child(
Button::new("about-site")
.label("mqttx.app")
.ghost()
.xsmall()
.tooltip(SITE_URL)
.on_click(|_, _, cx| cx.open_url(SITE_URL)),
).child(
Button::new("about-repo")
.label("gitea.heavenlybook.cn/JoeAllen/mqttx")
.ghost()
.xsmall()
.tooltip(REPO_URL)
.on_click(|_, _, cx| cx.open_url(REPO_URL)),
),
),
),
)
}
}
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 (initial, data_dir, log_dir) = {
let state = app.read(cx);
(state.settings.clone(), state.data_dir(), state.log_dir())
};
let dialog_view: Entity<SettingsDialog> = cx.new(|cx| {
SettingsDialog::new(initial, data_dir, log_dir, 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.))
.w(px(560.))
.title("设置")
.child(body)
.footer(