Files
mqttx/gpui-app/src/ui/app.rs
T
JoeAllen bf6d165854
Release / Meta (push) Canceled after 0s
Release / Test (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Publish package (push) Canceled after 0s
feat: OTA 自更新(Gitea Release 为更新源)
- update.rs:检查最新 Release(latest 端点+列表回退)、版本比较、
  平台产物挑选(含 .sha256 侧车)、下载进度、sha256 校验、
  自替换(改名 .old → 换入新 exe → 拉起新进程)、启动清理遗留
- 设置 → 关于:检查更新按钮、发现新版本提示、下载进度条(Progress)、
  失败重试;自动检查更新开关真正生效(启动后台检查并弹通知)
- 版本 1.0.0 → 1.0.1(首个带 OTA 的发布版本)
- 顺手修 clippy 遗留警告(is_none_or、合并 if)
2026-09-28 23:44:56 +08:00

1450 lines
59 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 应用外壳:标题栏 + 连接侧边栏 + 多标签连接视图 + 引擎事件泵。
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use gpui_kit::component::button::{Button, ButtonVariants as _, ButtonVariant};
use gpui_kit::component::scroll::ScrollableElement as _;
use gpui_kit::component::dialog::DialogButtonProps;
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, ThemeRegistry, TitleBar, WindowExt as _,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::{
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
KeyBinding, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _,
Styled as _, Window,
};
use crate::aliyun::AliyunPreset;
use crate::model::{
AppSettings, ConnectionConfig, ConnectionStatus, Direction, GlobalVariable, LogEntry,
LogLevel, MqttRecord, PublishParams, PublishPreset, SubscribeOptions, Subscription,
ThemeModePref,
};
use crate::mqtt::{EngineEvent, MqttEngine};
use crate::store::Storage;
use crate::update;
use crate::ui::IconName;
use crate::ui::{
connection_form::ConnectionForm,
connection_view::ConnectionView,
widgets::status_color,
};
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>,
_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>,
/// 分组过滤状态(chips 点击切换,与搜索条件叠加)
group_filter: GroupFilter,
/// 原生文件对话框进行中标记(对话框阻塞后台线程,防止重复弹出)
file_dialog_open: bool,
/// 按日期缓存的日志文件句柄(日期, 文件),避免每条日志都重新打开文件
log_file: Option<(String, std::fs::File)>,
/// 系统外观变化订阅:设置为「跟随系统」时重应用主题,随实体存活
_appearance_obs: gpui_kit::Subscription,
}
impl MqttXApp {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let storage = Storage::new();
let connections = storage.load_connections();
let subscriptions = storage.load_subscriptions();
let presets = storage.load_presets();
let variables = storage.load_variables();
let aliyun_presets = storage.load_aliyun();
let settings = storage.load_settings();
apply_theme(settings.theme, cx);
let (tx, rx) = smol::channel::unbounded();
let engine = MqttEngine::new(tx);
let weak = cx.entity().downgrade();
let pump = cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| {
while let Ok(event) = rx.recv().await {
weak.update_in(cx, |app, window, cx| {
app.on_engine_event(event, window, cx);
})
.ok();
}
});
// OTA:清理上次自更新遗留文件;开启自动检查则后台查一次新版本
update::cleanup_old();
if settings.auto_check_update {
let rx = engine.run_blocking(update::check_latest);
let weak = cx.entity().downgrade();
cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| {
if let Ok(Ok(Some(info))) = rx.recv().await {
weak.update_in(cx, |_app, window, cx| {
window.push_notification(
Notification::info(format!(
"发现新版本 v{},可在 设置 → 关于 中更新",
info.version
)),
cx,
);
})
.ok();
}
})
.detach();
}
let search = cx.new(|cx| InputState::new(window, cx).placeholder("搜索连接…"));
// 「跟随系统」:监听系统外观(深浅色)变化,仅在设置为 System 时重应用主题。
// 订阅需随实体存活,dropped 即取消,故存入字段。
let appearance_weak = cx.entity().downgrade();
let appearance_obs = window.observe_window_appearance(move |window, cx| {
let Some(app) = appearance_weak.upgrade() else {
return;
};
if app.read(cx).settings.theme != ThemeModePref::System {
return;
}
let mode = ThemeMode::from(window.appearance());
Theme::change(mode, Some(window), cx);
});
// ── 全局快捷键:绑定 + action 注册 ──
// 元素级 on_action 依赖焦点路径,无焦点时分发从树根开始收不到;
// 全局 action 在冒泡末端必达,经 defer 取窗口避免在分发栈内重入。
cx.bind_keys([
KeyBinding::new("ctrl-n", NewConnection, None),
KeyBinding::new("ctrl-,", OpenSettings, None),
KeyBinding::new("ctrl-shift-e", ExportConnections, None),
KeyBinding::new("ctrl-shift-i", ImportConnections, None),
]);
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| {
// 与新建/设置一致:对话框打开时不叠加文件对话框
if window.has_active_dialog(cx) {
return;
}
entity.update(cx, |app, cx| app.export_connections(window, cx));
});
});
let weak = cx.entity().downgrade();
App::on_action::<ImportConnections>(cx, move |_, cx| {
let w = weak.clone();
run_on_active_window(cx, w, |entity, window, cx| {
if window.has_active_dialog(cx) {
return;
}
entity.update(cx, |app, cx| app.import_connections(window, cx));
});
});
Self {
storage,
engine,
_pump: pump,
connections,
subscriptions,
presets,
variables,
aliyun_presets,
settings,
statuses: HashMap::new(),
errors: HashMap::new(),
messages: HashMap::new(),
logs: VecDeque::with_capacity(MAX_LOGS),
seq: 0,
open_tabs: Vec::new(),
active_tab: None,
views: HashMap::new(),
search,
group_filter: GroupFilter::All,
file_dialog_open: false,
log_file: None,
_appearance_obs: appearance_obs,
}
}
// ── 引擎事件 ──────────────────────────────────────────────────────────
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)
.filter(|s| s.enabled)
.cloned()
.collect();
for sub in subs {
let opts = SubscribeOptions::from(&sub);
self.engine
.subscribe_with_options(connection_id.to_string(), sub, opts);
}
}
// ── 数据工具 ──────────────────────────────────────────────────────────
fn next_seq(&mut self) -> u64 {
self.seq += 1;
self.seq
}
fn push_message(&mut self, connection_id: 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) {
// 落盘只在事件路径执行(不在 render 热路径),与内存环形缓冲互不影响
self.write_log_file(&entry);
if self.logs.len() >= MAX_LOGS {
self.logs.pop_front();
}
self.logs.push_back(entry);
}
/// 追加写入按日切分的日志文件 `mqttx-YYYY-MM-DD.log`。
/// 文件句柄按日期缓存复用,只有跨日或写失败后才重新打开;
/// 失败只在首次 eprintln 一次后保持静默(成功一次即复位,便于恢复后再次提示),
/// 且绝不回调 log/push_log(防止递归触发日志)。
fn write_log_file(&mut self, entry: &LogEntry) {
use std::sync::atomic::{AtomicBool, Ordering};
static WRITE_FAILED: AtomicBool = AtomicBool::new(false);
match self.append_log_line(entry) {
Ok(()) => WRITE_FAILED.store(false, Ordering::Relaxed),
Err(e) => {
// 关句柄置空,下次写入自动重开重试
self.log_file = None;
if !WRITE_FAILED.swap(true, Ordering::Relaxed) {
eprintln!("[app] 写入日志文件失败(后续静默): {e}");
}
}
}
}
/// 打开(或复用按日缓存的)日志文件并追加一行。
fn append_log_line(&mut self, entry: &LogEntry) -> std::io::Result<()> {
use std::io::Write as _;
use chrono::TimeZone as _;
let ts = chrono::Local
.timestamp_millis_opt(entry.timestamp)
.single()
.unwrap_or_else(chrono::Local::now);
let day = ts.format("%Y-%m-%d").to_string();
let need_reopen = match &self.log_file {
Some((cached_day, _)) => *cached_day != day,
None => true,
};
if need_reopen {
let dir = self.storage.log_dir();
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("mqttx-{day}.log"));
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
self.log_file = Some((day, file));
}
// 连接名查不到时回退为 id,保证行格式恒定
let conn = self
.connections
.iter()
.find(|c| c.id == entry.connection_id)
.map(|c| c.name.as_str())
.unwrap_or(entry.connection_id.as_str());
let file = &mut self
.log_file
.as_mut()
.expect("上方已确保日志句柄存在")
.1;
writeln!(
file,
"{} [{}] {}/{}: {}",
ts.format("%Y-%m-%d %H:%M:%S%.3f"),
entry.level.label(),
conn,
entry.event,
entry.message
)
}
pub fn log(&mut self, conn: &str, level: LogLevel, event: &str, message: String) {
self.push_log(LogEntry {
timestamp: chrono::Local::now().timestamp_millis(),
connection_id: conn.to_string(),
level,
event: event.to_string(),
message,
details: None,
});
}
// ── 连接 CRUD / 生命周期 ──────────────────────────────────────────────
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.errors.remove(id);
self.views.remove(id);
self.open_tabs.retain(|t| t != id);
if self.active_tab.as_deref() == Some(id) {
self.active_tab = self.open_tabs.last().cloned();
}
self.storage.save_connections(&self.connections);
self.storage.save_subscriptions(&self.subscriptions);
cx.notify();
}
pub fn toggle_connection(&self, cfg: &ConnectionConfig) {
if self.engine.is_connected(&cfg.id) {
self.engine.close(&cfg.id, true);
} else {
// 遗嘱支持 {{变量}}:对副本注入,保存的配置仍为模板原文
let mut cfg = cfg.clone();
crate::model::render_will_templates(&mut cfg, &self.variables);
self.engine.connect(cfg);
}
}
pub fn open_tab(&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 data_dir(&self) -> PathBuf {
self.storage.dir().to_path_buf()
}
/// 日志目录(设置对话框展示 / 打开用)。
pub fn log_dir(&self) -> PathBuf {
self.storage.log_dir()
}
// ── 连接导入 / 导出 ────────────────────────────────────────────────────
/// 在后台线程弹出阻塞式原生文件对话框,完成后回主线程执行 `done`。
/// 对话框进行中置位,防止重复弹出。
fn spawn_file_dialog<R, F, D>(
&mut self,
work: F,
done: D,
window: &mut Window,
cx: &mut Context<Self>,
) where
F: FnOnce() -> R + Send + 'static,
R: Send + 'static,
D: FnOnce(&mut Self, R, &mut Window, &mut Context<Self>) + 'static,
{
if self.file_dialog_open {
return;
}
self.file_dialog_open = true;
// rfd 的阻塞对话框不可占用 UI 线程:结果经 smol 通道送回异步任务
let (tx, rx) = smol::channel::bounded::<R>(1);
std::thread::spawn(move || {
let _ = tx.send_blocking(work());
});
let weak = cx.entity().downgrade();
cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| {
let result = rx.recv().await.ok();
weak.update_in(cx, |app, window, cx| {
app.file_dialog_open = false;
if let Some(result) = result {
done(app, result, window, cx);
}
})
.ok();
})
.detach();
}
/// 导出全部连接为 JSON(原生保存对话框,默认文件名 connections-export.json)。
pub fn export_connections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let dir = self.storage.dir().to_path_buf();
self.spawn_file_dialog(
move || {
rfd::FileDialog::new()
.set_title("导出连接")
.set_directory(dir)
.set_file_name("connections-export.json")
.add_filter("JSON", &["json"])
.save_file()
},
|app, path, window, cx| {
let Some(path) = path else {
return; // 用户取消
};
let count = app.connections.len();
match crate::store::export_connections_to(&path, &app.connections) {
Ok(()) => window.push_notification(
Notification::success(format!(
"已导出 {count} 条连接到 {}",
path.display()
)),
cx,
),
Err(e) => window.push_notification(Notification::error(e), cx),
}
},
window,
cx,
);
}
/// 导入连接:按 id 去重(同 id 跳过),新 id 保留文件原值;读取解析在后台线程完成。
pub fn import_connections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let dir = self.storage.dir().to_path_buf();
self.spawn_file_dialog(
move || {
let Some(path) = rfd::FileDialog::new()
.set_title("导入连接")
.set_directory(dir)
.add_filter("JSON", &["json"])
.pick_file()
else {
return Ok(None); // 用户取消
};
crate::store::import_connections_from(&path).map(Some)
},
|app, result, window, cx| match result {
Err(e) => {
window.push_notification(Notification::error(e), cx);
}
Ok(None) => {}
Ok(Some(items)) => {
let mut existing: std::collections::HashSet<String> =
app.connections.iter().map(|c| c.id.clone()).collect();
let mut added = 0usize;
let mut skipped = 0usize;
for item in items {
if existing.contains(&item.id) {
skipped += 1;
} else {
// 记录已入库 id,导入文件内部的重复 id 也只收一条
existing.insert(item.id.clone());
app.connections.push(item);
added += 1;
}
}
if added > 0 {
app.storage.save_connections(&app.connections);
}
window.push_notification(
Notification::success(format!("导入 {added} 条,跳过 {skipped} 条")),
cx,
);
}
},
window,
cx,
);
}
// ── 对话框 ────────────────────────────────────────────────────────────
pub fn open_connection_form(
&mut self,
edit: Option<ConnectionConfig>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let app = cx.entity().downgrade();
let engine = self.engine.clone();
// 表单的分组下拉需要现有分组列表(去重、排序)
let mut groups: Vec<String> = self
.connections
.iter()
.filter_map(|c| c.group.as_ref().map(|g| g.trim().to_string()))
.filter(|g| !g.is_empty())
.collect();
groups.sort();
groups.dedup();
let form = cx.new(|cx| ConnectionForm::new(engine, edit, groups, window, cx));
window.open_dialog(cx, move |dialog, _, cx| {
let form_ok = form.clone();
let form_test = form.clone();
let app_ok = app.clone();
let testing = form_test.read(cx).is_testing();
dialog
.w(px(680.))
.title("连接配置")
.child(form.clone())
.footer(
h_flex()
.gap_2()
.w_full()
// 测试连接常驻左下角,保存前即可验证配置
.child(
Button::new("form-test")
.icon(if testing {
IconName::LoaderCircle
} else {
IconName::PlugZap
})
.label(if testing { "测试中…" } else { "测试连接" })
.outline()
.loading(testing)
.on_click(move |_, window, cx| {
form_test.update(cx, |f, cx| f.run_test(window, cx));
}),
)
.child(div().flex_1())
.child(
Button::new("form-cancel")
.label("取消")
.outline()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("form-ok")
.label("保存")
.primary()
.on_click(move |_, window, cx| {
let built = form_ok.read(cx).build(cx);
match built {
Ok(cfg) => {
// 已连接的连接改了关键参数,重连后才生效,先算好再保存
let mut stale = false;
app_ok
.update(cx, |app, cx| {
let connected = app.statuses.get(&cfg.id)
== Some(&ConnectionStatus::Connected);
if connected
&& app
.connections
.iter()
.find(|c| c.id == cfg.id)
.is_some_and(|old| {
old.session_params_changed(&cfg)
})
{
stale = true;
}
app.save_connection(cfg);
cx.notify();
})
.ok();
window.close_dialog(cx);
if stale {
window.push_notification(
Notification::info("配置已保存,重连后生效"),
cx,
);
}
}
Err(e) => {
// 触发重绘,让字段级红字即时显示(错误已写入表单内部)
form_ok.update(cx, |_, cx| cx.notify());
window.push_notification(
Notification::error(e),
cx,
);
}
}
}),
),
)
});
}
}
// ─── 主题 ────────────────────────────────────────────────────────────────────
/// 应用主题偏好。
///
/// 「跟随系统」读取 gpui 的窗口外观(`App::window_appearance`);系统深浅色变化由
/// Linear 风格主题(内嵌 JSON,模式与 gpui-component 默认主题同构)。
const LINEAR_THEME: &str = include_str!("linear-theme.json");
/// `MqttXApp::new` 里注册的 `Window::observe_window_appearance` 监听并重应用本函数。
///
/// 流程:先 `change` 确保 Theme 全局已建立(注册表 observer 依赖它)→ 注册
/// Linear Light/Dark(重名跳过,幂等)→ 把当前明暗对指向 Linear → 再 `change`
/// 应用其颜色/字号/圆角。
pub fn apply_theme(pref: ThemeModePref, cx: &mut App) {
let mode = match pref {
ThemeModePref::Light => ThemeMode::Light,
ThemeModePref::Dark => ThemeMode::Dark,
ThemeModePref::System => ThemeMode::from(cx.window_appearance()),
};
Theme::change(mode, None, cx);
if let Err(e) = ThemeRegistry::global_mut(cx).load_themes_from_str(LINEAR_THEME) {
eprintln!("加载 Linear 主题失败,回退默认主题: {e}");
}
{
let reg = ThemeRegistry::global(cx);
let light = reg.themes().get("Linear Light").cloned();
let dark = reg.themes().get("Linear Dark").cloned();
let theme = Theme::global_mut(cx);
if let Some(l) = light {
theme.light_theme = l;
}
if let Some(d) = dark {
theme.dark_theme = d;
}
}
Theme::change(mode, None, cx);
}
// ─── 渲染 ────────────────────────────────────────────────────────────────────
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();
let w4 = app_weak.clone();
let w5 = 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) = w4.upgrade() {
entity.update(cx, |app, cx| {
app.export_connections(window, cx)
});
}
},
))
.item(PopupMenuItem::new("导入连接").on_click(
move |_, window, cx| {
if let Some(entity) = w5.upgrade() {
entity.update(cx, |app, cx| {
app.import_connections(window, cx)
});
}
},
))
.separator()
.item(PopupMenuItem::new("设置").on_click(
move |_, window, cx| {
if let Some(entity) = w3.upgrade() {
crate::ui::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 dangling = matches!(&self.group_filter, GroupFilter::Named(name)
if !self.connections.iter().any(|c| {
c.group.as_deref().map(str::trim) == Some(name.as_str())
}));
if dangling {
self.group_filter = GroupFilter::All;
}
let filter = self.group_filter.clone();
let total = self.connections.len();
let hover_bg = cx.theme().muted;
let mut rows = v_flex().gap_0p5().flex_1().overflow_y_scrollbar();
let mut shown = 0usize;
for conn in self.connections.clone() {
// 分组过滤与搜索叠加(AND)
let group = conn.group.as_deref().map(str::trim).unwrap_or("");
let match_group = match &filter {
GroupFilter::All => true,
GroupFilter::Ungrouped => group.is_empty(),
GroupFilter::Named(name) => group == name.as_str(),
};
if !match_group {
continue;
}
if !query.is_empty() && !conn.name.to_lowercase().contains(&query) {
continue;
}
shown += 1;
let status = self
.statuses
.get(&conn.id)
.copied()
.unwrap_or(ConnectionStatus::Disconnected);
let active = self.active_tab.as_deref() == Some(conn.id.as_str());
let cfg = conn.clone();
let name = conn.name.clone();
let address = conn.display_address();
let row_id = conn.id.clone();
rows = rows.child(
h_flex()
.id(SharedString::from(format!("conn-row-{}", conn.id)))
.w_full()
.items_center()
.gap_2()
.mx_2()
.my_0p5()
.px_2()
.py_2()
.rounded_md()
.when(active, |this| this.bg(cx.theme().secondary))
.when(!active, |this| {
this.hover(move |this| this.bg(hover_bg))
})
.cursor_pointer()
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tab(&row_id, window, cx);
}))
.child(
div()
.size(px(7.))
.rounded_full()
.bg(status_color(status, cx)),
)
.child(
v_flex()
.flex_1()
.min_w(px(0.))
.gap_0p5()
.child(
div()
.text_sm()
.when(active, |t| t.font_semibold())
.overflow_hidden()
.text_ellipsis()
.child(name),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.overflow_hidden()
.text_ellipsis()
.child(address),
),
)
.child(self.render_conn_power(&cfg, status, cx))
.child(self.render_conn_menu(&conn.id, cx)),
);
}
let body = if self.connections.is_empty() {
v_flex()
.flex_1()
.items_center()
.justify_center()
.gap_2()
.p_4()
.child(
gpui_kit::component::Icon::new(IconName::PlugZap)
.text_color(cx.theme().muted_foreground),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("还没有连接,点击上方 + 新建"),
)
} else if shown == 0 {
v_flex()
.flex_1()
.items_center()
.justify_center()
.p_4()
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("没有匹配的连接"),
)
} else {
v_flex().flex_1().child(rows)
};
v_flex()
.w(px(264.))
.h_full()
.flex_shrink_0()
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.child(
h_flex()
.h_11()
.px_3()
.items_center()
.justify_between()
.child(
h_flex()
.gap_1p5()
.items_center()
.child(div().text_sm().font_semibold().child("连接"))
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(total.to_string()),
),
)
.child(
Button::new("sidebar-add")
.icon(IconName::Plus)
.tooltip("新建连接")
.ghost()
.xsmall()
.on_click(cx.listener(|this, _, window, cx| {
this.open_connection_form(None, window, cx);
})),
),
)
.child(v_flex().px_2().pb_2().child(Input::new(&search).small().prefix(
gpui_kit::component::Icon::new(IconName::Search).small(),
)))
.child(self.render_group_chips(cx))
.child(body)
}
/// 搜索框下方的分组 chips:全部 / 未分组 / 各分组(带计数徽标),点击切换过滤。
fn render_group_chips(&self, cx: &mut Context<Self>) -> impl IntoElement {
let mut names: Vec<String> = self
.connections
.iter()
.filter_map(|c| c.group.as_deref().map(str::trim))
.filter(|g| !g.is_empty())
.map(str::to_string)
.collect();
names.sort();
names.dedup();
let ungrouped = self
.connections
.iter()
.filter(|c| c.group.as_deref().map(str::trim).unwrap_or("").is_empty())
.count();
// 分组多时限制高度并纵向滚动,避免 chips 换行挤占下方连接列表
let mut row = h_flex()
.gap_1()
.flex_wrap()
.px_2()
.pb_1()
.max_h(px(88.))
.overflow_y_scrollbar();
row = row.child(self.render_group_chip(
"chip-all",
format!("全部 {}", self.connections.len()),
GroupFilter::All,
cx,
));
row = row.child(self.render_group_chip(
"chip-ungrouped",
format!("未分组 {ungrouped}"),
GroupFilter::Ungrouped,
cx,
));
for name in names {
let count = self
.connections
.iter()
.filter(|c| c.group.as_deref().map(str::trim) == Some(name.as_str()))
.count();
row = row.child(self.render_group_chip(
SharedString::from(format!("chip-{}", name)),
format!("{name} {count}"),
GroupFilter::Named(name),
cx,
));
}
row
}
fn render_group_chip(
&self,
id: impl Into<gpui_kit::ElementId>,
label: String,
filter: GroupFilter,
cx: &mut Context<Self>,
) -> impl IntoElement {
let selected = match (&self.group_filter, &filter) {
(GroupFilter::All, GroupFilter::All)
| (GroupFilter::Ungrouped, GroupFilter::Ungrouped) => true,
(GroupFilter::Named(a), GroupFilter::Named(b)) => a == b,
_ => false,
};
div()
.id(id)
.px_2()
.py_0p5()
.rounded_md()
.text_xs()
.when(selected, |d| d.bg(cx.theme().secondary).font_semibold())
.when(!selected, |d| {
d.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.hover(|d| d.bg(cx.theme().secondary))
})
.cursor_pointer()
.on_click(cx.listener(move |this, _, _, cx| {
// listener 是 Fn(可多次调用),只能克隆而不能移出捕获的 filter
this.group_filter = filter.clone();
cx.notify();
}))
.child(label)
}
fn render_conn_power(
&self,
cfg: &ConnectionConfig,
status: ConnectionStatus,
cx: &mut Context<Self>,
) -> Button {
let cfg = cfg.clone();
let connected = matches!(status, ConnectionStatus::Connected);
Button::new(SharedString::from(format!("conn-power-{}", cfg.id)))
.icon(if connected { IconName::Square } else { IconName::PlugZap })
.ghost()
.xsmall()
.tooltip(if connected { "断开" } else { "连接" })
.on_click(cx.listener(move |this, _, _, _| {
this.toggle_connection(&cfg);
}))
}
fn render_conn_menu(&self, conn_id: &str, cx: &mut Context<Self>) -> impl IntoElement {
let weak = cx.entity().downgrade();
let eid = conn_id.to_string();
Button::new(SharedString::from(format!("conn-menu-{}", conn_id)))
.icon(IconName::Ellipsis)
.ghost()
.xsmall()
.dropdown_menu(move |menu, _, _| {
let (e1, e2, e3) = (eid.clone(), eid.clone(), eid.clone());
let (w1, w2, w3) = (weak.clone(), weak.clone(), weak.clone());
menu.item(PopupMenuItem::new("编辑").on_click(move |_, window, cx| {
w1.update(cx, |app, cx| {
let cfg = app.connections.iter().find(|c| c.id == e1).cloned();
if let Some(cfg) = cfg {
app.open_connection_form(Some(cfg), window, cx);
}
})
.ok();
}))
.item(PopupMenuItem::new("复制连接").on_click(move |_, _, cx| {
w2.update(cx, |app, cx| {
app.duplicate_connection(&e2);
cx.notify();
})
.ok();
}))
.separator()
.item(PopupMenuItem::new("删除").on_click(move |_, window, cx| {
let Some(app) = w3.upgrade() else {
return;
};
// 二次确认:删除会级联清理订阅并断开连接,不可撤销
let (name, sub_count) = {
let a = app.read(cx);
let name = a
.connections
.iter()
.find(|c| c.id == e3)
.map(|c| c.name.clone())
.unwrap_or_else(|| e3.clone());
let n = a.subscriptions.iter().filter(|s| s.connection_id == e3).count();
(name, n)
};
let confirm_id = e3.clone();
window.open_alert_dialog(cx, move |alert, _, _| {
// 构建闭包是 Fn(可重复调用),克隆后再移入按钮回调
let app = app.clone();
let confirm_id = confirm_id.clone();
alert
.title("删除连接")
.description(SharedString::from(format!(
"确定删除「{name}」?将同时删除 {sub_count} 个订阅,并断开当前连接,此操作不可撤销。"
)))
.button_props(
DialogButtonProps::default()
.ok_text("删除")
.ok_variant(ButtonVariant::Danger)
.show_cancel(true),
)
.on_ok(move |_, _, cx| {
app.update(cx, |app, cx| app.delete_connection(&confirm_id, cx));
true
})
});
}))
})
}
fn render_main(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.open_tabs.is_empty() {
// 父级 h_flex 交叉轴居中子项,容器必须 h_full 占满高度才不会整体下沉
return v_flex()
.flex_1()
.h_full()
.min_h(px(0.))
.items_center()
.justify_center()
.gap_3()
.child(crate::ui::logo::logo(
px(72.),
cx.theme().muted,
cx.theme().muted_foreground,
))
.child(div().text_base().font_medium().child("开始使用 MQTTX"))
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.text_center()
.child("从左侧选择一个连接,或新建连接开始调试"),
)
.child(
Button::new("empty-new")
.icon(IconName::Plus)
.label("新建连接")
.outline()
.small()
.mt_1()
.on_click(cx.listener(|this, _, window, cx| {
this.open_connection_form(None, window, cx);
})),
)
.into_any_element();
}
let active = self.active_tab.clone().unwrap_or_default();
let mut bar = TabBar::new("conn-tabs")
// 连接名过长时截断省略,避免单个页签占满整行
.max_width(px(180.));
for id in self.open_tabs.clone() {
let name = self
.connections
.iter()
.find(|c| c.id == id)
.map(|c| c.name.clone())
.unwrap_or_else(|| id.clone());
let tab_id = id.clone();
let close_id = id.clone();
let is_active = id == active;
bar = bar.child(
Tab::new()
.label(SharedString::from(name))
.selected(is_active)
.suffix(
Button::new(SharedString::from(format!("tab-close-{}", id)))
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, _, cx| {
this.close_tab(&close_id, cx);
})),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tab(&tab_id, window, cx);
})),
);
}
let view = self.views.get(&active).cloned();
// h_full:父级 h_flex 交叉轴居中子项,不占满高度会被整体垂直居中;
// 视图根是 size_full,须包在 flex_1 容器里,否则会盖住 TabBar 并溢出。
v_flex()
.flex_1()
.h_full()
.min_h(px(0.))
.min_w(px(0.))
.child(div().flex_shrink_0().px_2().pt_1().child(bar))
.when_some(view, |this, v| {
this.child(v_flex().flex_1().min_h(px(0.)).child(v))
})
.into_any_element()
}
}