refactor(ui): 拆分 app/connection_view/widgets 为子组件,对话框归拢 dialogs/

- ui/app/ 拆为门面、events(引擎事件泵)、actions(用户动作)、io(导入导出)、
  theme(主题)、titlebar/sidebar/main_area(渲染分区)
- ui/connection_view/ 拆为门面、subscribe、messages、publish、vars、util(纯函数)
- ui/widgets/ 拆为 select(可搜索下拉)、kv_editor、common(字段行/状态/时间)
- 五个对话框归拢 ui/dialogs/,linear-theme.json 随 theme.rs 迁入 ui/app/
- clippy 0 警告,全部路径经门面保持稳定
This commit is contained in:
2026-09-29 22:20:53 +08:00
parent b5089f5898
commit bc9f55c889
30 changed files with 4884 additions and 4652 deletions
+382
View File
@@ -0,0 +1,382 @@
//! 应用外壳:标题栏 + 连接侧边栏 + 多标签连接视图 + 引擎事件泵。
//!
//! - [`events`] 引擎事件 → 应用状态(连接状态、消息、日志)
//! - [`actions`] 用户动作(连接/订阅/预设/设置等状态写操作)
//! - [`io`] 连接配置导入导出
//! - [`theme`] 主题应用
//! - [`titlebar`] / [`sidebar`] / [`main_area`] 渲染分区
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::scroll::ScrollableElement as _;
use gpui_kit::component::input::InputState;
use gpui_kit::component::menu::DropdownMenu as _;
use gpui_kit::component::{
h_flex, notification::Notification, v_flex, ActiveTheme as _, Selectable as _, Sizable as _,
StyledExt as _, Theme, ThemeMode, WindowExt as _,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::{
div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
KeyBinding, ParentElement as _, Render, StatefulInteractiveElement as _,
Styled as _, Window,
};
use crate::aliyun::AliyunPreset;
use crate::model::{
AppSettings, ConnectionConfig, ConnectionStatus, GlobalVariable, LogEntry, MessageRing, PublishPreset,
Subscription, ThemeModePref,
};
use crate::mqtt::MqttEngine;
use crate::store::Storage;
use crate::update;
use crate::ui::connection_view::ConnectionView;
mod actions;
mod events;
mod io;
mod main_area;
mod sidebar;
mod theme;
mod titlebar;
use theme::apply_theme;
const MAX_LOGS: usize = 3000;
// 应用级快捷键动作(context=None:任意焦点状态下都匹配)。
gpui_kit::actions!(mqttx, [NewConnection, OpenSettings, ExportConnections, ImportConnections, OpenResourceMonitor]);
/// 侧边栏分组过滤;chips 只做列表过滤,分组重命名/删除通过编辑连接的分组字段完成。
#[derive(Clone, PartialEq, Eq)]
enum GroupFilter {
All,
Ungrouped,
Named(String),
}
/// 全局 action 回调只有 `&mut App`:先 defer 出当前分发栈,再取活动窗口回到实体执行,
/// 避免在窗口借用期间重入 `update`。
fn run_on_active_window(
cx: &mut App,
weak: gpui_kit::WeakEntity<MqttXApp>,
f: impl FnOnce(Entity<MqttXApp>, &mut Window, &mut App) + 'static,
) {
let Some(handle) = cx.active_window() else {
return;
};
cx.defer(move |cx| {
let Some(entity) = weak.upgrade() else {
return;
};
let _ = handle.update(cx, move |_, window, cx| f(entity, window, cx));
});
}
pub struct MqttXApp {
storage: Storage,
pub engine: std::sync::Arc<MqttEngine>,
_pump: gpui_kit::Task<()>,
// ── 持久化数据 ──
pub connections: Vec<ConnectionConfig>,
pub subscriptions: Vec<Subscription>,
pub presets: Vec<PublishPreset>,
pub variables: Vec<GlobalVariable>,
pub aliyun_presets: Vec<AliyunPreset>,
pub settings: AppSettings,
// ── 运行时状态 ──
pub statuses: HashMap<String, ConnectionStatus>,
pub errors: HashMap<String, String>,
/// 每连接消息环形缓冲:键为驻留后的连接 ID,条数 + 字节预算双重约束
pub messages: HashMap<Arc<str>, MessageRing>,
pub logs: VecDeque<LogEntry>,
pub seq: u64,
// ── 标签与视图 ──
open_tabs: Vec<String>,
active_tab: Option<String>,
views: HashMap<String, Entity<ConnectionView>>,
search: Entity<InputState>,
/// 分组过滤状态(chips 点击切换,与搜索条件叠加)
group_filter: GroupFilter,
/// 原生文件对话框进行中标记(对话框阻塞后台线程,防止重复弹出)
file_dialog_open: bool,
/// 按日期缓存的日志文件句柄(日期, 文件),避免每条日志都重新打开文件
log_file: Option<(String, std::io::BufWriter<std::fs::File>)>,
/// 系统外观变化订阅:设置为「跟随系统」时重应用主题,随实体存活
_appearance_obs: gpui_kit::Subscription,
/// 窗口置顶状态(标题栏钉子按钮切换)
pinned: bool,
}
impl MqttXApp {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let storage = Storage::new();
let connections = storage.load_connections();
let subscriptions = storage.load_subscriptions();
let presets = storage.load_presets();
let variables = storage.load_variables();
let aliyun_presets = storage.load_aliyun();
let settings = storage.load_settings();
apply_theme(settings.theme, cx);
let (tx, rx) = smol::channel::unbounded();
let engine = MqttEngine::new(tx);
let weak = cx.entity().downgrade();
let pump = cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| {
while let Ok(event) = rx.recv().await {
weak.update_in(cx, |app, window, cx| {
app.on_engine_event(event, window, cx);
})
.ok();
}
});
// OTA:清理上次自更新遗留文件;开启自动检查则后台静默检查并下载,
// 下载完成后弹对话框征得同意再安装(不自动重启)。
update::cleanup_old();
// 自动连接:勾选「启动时自动连接」的连接逐个拉起(引擎异步建连)
for cfg in connections
.iter()
.filter(|c| c.auto_connect)
.cloned()
.collect::<Vec<_>>()
{
let mut cfg = cfg;
crate::model::render_will_templates(&mut cfg, &variables);
engine.connect(cfg);
}
if settings.auto_check_update {
let rx = engine.run_blocking(update::check_and_download);
let weak = cx.entity().downgrade();
cx.spawn_in(window, async move |_this, cx: &mut gpui_kit::AsyncWindowContext| {
match rx.recv().await {
Ok(Ok(Some(staged))) => {
weak.update_in(cx, |_app, window, cx| {
window.open_dialog(cx, move |dialog, _, _cx| {
let staged = staged.clone();
let staged_skip = staged.clone();
let ver = staged.version.clone();
dialog
.w(px(440.))
.title("更新就绪")
.child(
v_flex().gap_2().child(
div().text_sm().child(format!(
"新版本 v{} 已下载完成(已通过 sha256 校验)。",
staged.version
)),
),
)
.footer(
h_flex()
.gap_2()
.justify_end()
.w_full()
.child(
Button::new("upd-skip")
.label("暂不更新")
.outline()
.on_click(move |_, window, cx| {
// 拒绝本次更新:删除暂存包,
// 下次启动不会被自动安装
update::discard_staged(&staged_skip);
window.close_dialog(cx);
}),
)
.child(
Button::new("upd-later")
.label("下次启动安装")
.outline()
.on_click(move |_, window, cx| {
// 同意标记:启动时据此自动安装;
// 用户跳过对话框则不会强制升级
update::mark_install_consent(&ver);
window.close_dialog(cx);
}),
)
.child(
Button::new("upd-now")
.label("立即安装")
.primary()
.on_click(move |_, window, cx| {
window.close_dialog(cx);
if let Err(e) =
update::install_staged(&staged)
{
window.push_notification(
Notification::error(format!(
"安装失败:{e}"
)),
cx,
);
} else {
cx.quit();
}
}),
),
)
});
})
.ok();
}
// 检查/下载失败不打扰用户,但也不能完全无声
Ok(Err(e)) => {
eprintln!("[app] 启动更新检查失败: {e}");
sentry::capture_message(
&format!("startup update check failed: {e}"),
sentry::Level::Warning,
);
}
_ => {}
}
})
.detach();
}
let search = cx.new(|cx| InputState::new(window, cx).placeholder("搜索连接…"));
// 自验后门:MQTTX_OPEN_RESMON=1 启动时直接打开资源监控
// (与 MQTTX_VERSION_OVERRIDE 同类的测试入口,供自动化验证 UI)。
// defer 到 effects 阶段执行,避免在 MqttXApp 构造借用内嵌套打开
if std::env::var("MQTTX_OPEN_RESMON").as_deref() == Ok("1") {
cx.defer_in(window, move |_, window, cx| {
crate::ui::dialogs::resource_dialog::open(cx.entity(), window, cx);
});
}
// 「跟随系统」:监听系统外观(深浅色)变化,仅在设置为 System 时重应用主题。
// 订阅需随实体存活,dropped 即取消,故存入字段。
let appearance_weak = cx.entity().downgrade();
let appearance_obs = window.observe_window_appearance(move |window, cx| {
let Some(app) = appearance_weak.upgrade() else {
return;
};
if app.read(cx).settings.theme != ThemeModePref::System {
return;
}
let mode = ThemeMode::from(window.appearance());
Theme::change(mode, Some(window), cx);
});
// ── 全局快捷键:绑定 + action 注册 ──
// 元素级 on_action 依赖焦点路径,无焦点时分发从树根开始收不到;
// 全局 action 在冒泡末端必达,经 defer 取窗口避免在分发栈内重入。
cx.bind_keys([
KeyBinding::new("ctrl-n", NewConnection, None),
KeyBinding::new("ctrl-,", OpenSettings, None),
KeyBinding::new("ctrl-shift-e", ExportConnections, None),
KeyBinding::new("ctrl-shift-i", ImportConnections, None),
KeyBinding::new("ctrl-shift-r", OpenResourceMonitor, None),
]);
let weak = cx.entity().downgrade();
// Context 上有同名 on_action(绘制期注册),这里必须走 App 的全局注册
App::on_action::<NewConnection>(cx, move |_, cx| {
let w = weak.clone();
run_on_active_window(cx, w, |entity, window, cx| {
if window.has_active_dialog(cx) {
return;
}
entity.update(cx, |app, cx| app.open_connection_form(None, window, cx));
});
});
let weak = cx.entity().downgrade();
App::on_action::<OpenSettings>(cx, move |_, cx| {
let w = weak.clone();
run_on_active_window(cx, w, |entity, window, cx| {
if window.has_active_dialog(cx) {
return;
}
crate::ui::dialogs::settings_dialog::open(entity, window, cx);
});
});
let weak = cx.entity().downgrade();
App::on_action::<OpenResourceMonitor>(cx, move |_, cx| {
let w = weak.clone();
run_on_active_window(cx, w, |entity, window, cx| {
if window.has_active_dialog(cx) {
return;
}
crate::ui::dialogs::resource_dialog::open(entity, window, cx);
});
});
let weak = cx.entity().downgrade();
App::on_action::<ExportConnections>(cx, move |_, cx| {
let w = weak.clone();
run_on_active_window(cx, w, |entity, window, cx| {
// 与新建/设置一致:对话框打开时不叠加文件对话框
if window.has_active_dialog(cx) {
return;
}
entity.update(cx, |app, cx| app.export_connections(window, cx));
});
});
let weak = cx.entity().downgrade();
App::on_action::<ImportConnections>(cx, move |_, cx| {
let w = weak.clone();
run_on_active_window(cx, w, |entity, window, cx| {
if window.has_active_dialog(cx) {
return;
}
entity.update(cx, |app, cx| app.import_connections(window, cx));
});
});
Self {
storage,
engine,
_pump: pump,
connections,
subscriptions,
presets,
variables,
aliyun_presets,
settings,
statuses: HashMap::new(),
errors: HashMap::new(),
messages: HashMap::new(),
logs: VecDeque::with_capacity(MAX_LOGS),
seq: 0,
open_tabs: Vec::new(),
active_tab: None,
views: HashMap::new(),
search,
group_filter: GroupFilter::All,
file_dialog_open: false,
log_file: None,
_appearance_obs: appearance_obs,
pinned: false,
}
}
// ── 引擎事件 ──────────────────────────────────────────────────────────
}
impl Render for MqttXApp {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// 0.6.x 的 Root 不自动渲染 dialog/sheet/notification 层,需由内容视图挂载。
v_flex()
.size_full()
.bg(cx.theme().background)
.text_color(cx.theme().foreground)
.child(self.render_titlebar(window, cx))
.child(
h_flex()
.flex_1()
.min_h(px(0.))
.child(self.render_sidebar(window, cx))
.child(self.render_main(window, cx)),
)
.children(gpui_kit::component::Root::render_sheet_layer(window, cx))
.children(gpui_kit::component::Root::render_dialog_layer(window, cx))
.children(gpui_kit::component::Root::render_notification_layer(window, cx))
}
}