feat: GPUI 版补齐 MQTTX 基础功能对标(分组/SSL证书/订阅增强/消息流控制/设置与日志)
This commit is contained in:
+392
-9
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1209
-224
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user