fix: 修复 UI 审查问题(图标资产全量化、主区布局贴顶、订阅数据保护与 28 项交互缺陷)
This commit is contained in:
@@ -5,7 +5,9 @@ use mqttx_desktop::ui;
|
||||
|
||||
fn main() {
|
||||
application()
|
||||
.with_assets(gpui_kit::assets::Assets)
|
||||
// AllAssets 嵌入全量 Lucide 图标;Assets 只含 default-icons.txt 的 101 个,
|
||||
// 其余 IconName(MailOpen/SlidersHorizontal 等)运行时会渲染空白
|
||||
.with_assets(gpui_kit::assets::AllAssets)
|
||||
.run(|cx: &mut App| {
|
||||
gpui_kit::init(cx);
|
||||
|
||||
|
||||
@@ -245,6 +245,19 @@ impl ConnectionConfig {
|
||||
pub fn display_address(&self) -> String {
|
||||
format!("{}:{}", self.host, self.port)
|
||||
}
|
||||
|
||||
/// 与另一份配置相比,影响 broker 会话建立的关键参数是否变化。
|
||||
/// 这些字段改动只有在重新建立连接后才会生效,用于提示用户重连。
|
||||
pub fn session_params_changed(&self, other: &ConnectionConfig) -> bool {
|
||||
self.host != other.host
|
||||
|| self.port != other.port
|
||||
|| self.transport != other.transport
|
||||
|| self.protocol != other.protocol
|
||||
|| self.client_id != other.client_id
|
||||
|| self.username != other.username
|
||||
|| self.password != other.password
|
||||
|| self.clean_start != other.clean_start
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConnectionConfig {
|
||||
|
||||
+22
-2
@@ -177,7 +177,7 @@ pub fn export_connections_to(path: &Path, conns: &[ConnectionConfig]) -> Result<
|
||||
/// 从 JSON 文件导入连接。
|
||||
///
|
||||
/// 容错规则:容忍 UTF-8 BOM;顶层允许是数组,也可能是 `{ "connections": [...] }` 对象;
|
||||
/// 任一项不是合法连接配置时返回带序号的错误信息。
|
||||
/// 任一项不是合法连接配置、或 `id` 为空白时,返回带序号的错误信息。
|
||||
pub fn import_connections_from(path: &Path) -> Result<Vec<ConnectionConfig>, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("读取 {} 失败: {e}", path.display()))?;
|
||||
let mut text =
|
||||
@@ -202,7 +202,13 @@ pub fn import_connections_from(path: &Path) -> Result<Vec<ConnectionConfig>, Str
|
||||
let mut conns = Vec::with_capacity(items.len());
|
||||
for (i, item) in items.into_iter().enumerate() {
|
||||
match serde_json::from_value::<ConnectionConfig>(item) {
|
||||
Ok(c) => conns.push(c),
|
||||
Ok(c) => {
|
||||
// 空 id 会在去重/订阅关联时产生悬空引用,导入层直接拒绝
|
||||
if c.id.trim().is_empty() {
|
||||
return Err(format!("第 {} 项的 id 不能为空", i + 1));
|
||||
}
|
||||
conns.push(c);
|
||||
}
|
||||
Err(e) => return Err(format!("第 {} 项不是合法的连接配置: {e}", i + 1)),
|
||||
}
|
||||
}
|
||||
@@ -262,4 +268,18 @@ mod tests {
|
||||
assert!(err.contains("第 2 项"), "错误应定位到序号: {err}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_rejects_empty_id() {
|
||||
let path = temp_path("empty-id.json");
|
||||
let mut c = ConnectionConfig::new();
|
||||
c.id = " ".into();
|
||||
let good = serde_json::to_value(ConnectionConfig::new()).unwrap();
|
||||
let body = serde_json::json!([good, serde_json::to_value(&c).unwrap()]).to_string();
|
||||
std::fs::write(&path, body).expect("写入测试文件");
|
||||
let err = import_connections_from(&path).expect_err("空 id 应报错");
|
||||
assert!(err.contains("第 2 项"), "错误应定位到序号: {err}");
|
||||
assert!(err.contains("id"), "错误应说明 id 为空: {err}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
+165
-26
@@ -3,8 +3,9 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui_kit::component::button::{Button, ButtonVariants as _};
|
||||
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};
|
||||
@@ -95,6 +96,10 @@ pub struct MqttXApp {
|
||||
group_filter: GroupFilter,
|
||||
/// 原生文件对话框进行中标记(对话框阻塞后台线程,防止重复弹出)
|
||||
file_dialog_open: bool,
|
||||
/// 按日期缓存的日志文件句柄(日期, 文件),避免每条日志都重新打开文件
|
||||
log_file: Option<(String, std::fs::File)>,
|
||||
/// 系统外观变化订阅:设置为「跟随系统」时重应用主题,随实体存活
|
||||
_appearance_obs: gpui_kit::Subscription,
|
||||
}
|
||||
|
||||
impl MqttXApp {
|
||||
@@ -124,6 +129,20 @@ impl MqttXApp {
|
||||
|
||||
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 取窗口避免在分发栈内重入。
|
||||
@@ -158,6 +177,10 @@ impl MqttXApp {
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -165,6 +188,9 @@ impl MqttXApp {
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -190,6 +216,8 @@ impl MqttXApp {
|
||||
search,
|
||||
group_filter: GroupFilter::All,
|
||||
file_dialog_open: false,
|
||||
log_file: None,
|
||||
_appearance_obs: appearance_obs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,23 +364,50 @@ impl MqttXApp {
|
||||
}
|
||||
|
||||
/// 追加写入按日切分的日志文件 `mqttx-YYYY-MM-DD.log`。
|
||||
/// 每条都尝试写入;失败只在首次 eprintln 一次后保持静默,且绝不回调
|
||||
/// log/push_log(防止递归触发日志)。
|
||||
fn write_log_file(&self, entry: &LogEntry) {
|
||||
use std::io::Write as _;
|
||||
/// 文件句柄按日期缓存复用,只有跨日或写失败后才重新打开;
|
||||
/// 失败只在首次 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);
|
||||
|
||||
let result = (|| -> std::io::Result<()> {
|
||||
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-{}.log", ts.format("%Y-%m-%d")));
|
||||
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
|
||||
@@ -360,10 +415,11 @@ impl MqttXApp {
|
||||
.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)?;
|
||||
let file = &mut self
|
||||
.log_file
|
||||
.as_mut()
|
||||
.expect("上方已确保日志句柄存在")
|
||||
.1;
|
||||
writeln!(
|
||||
file,
|
||||
"{} [{}] {}/{}: {}",
|
||||
@@ -373,13 +429,6 @@ impl MqttXApp {
|
||||
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) {
|
||||
@@ -423,6 +472,7 @@ impl MqttXApp {
|
||||
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) {
|
||||
@@ -622,7 +672,7 @@ impl MqttXApp {
|
||||
}
|
||||
Ok(None) => {}
|
||||
Ok(Some(items)) => {
|
||||
let existing: std::collections::HashSet<String> =
|
||||
let mut existing: std::collections::HashSet<String> =
|
||||
app.connections.iter().map(|c| c.id.clone()).collect();
|
||||
let mut added = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
@@ -630,6 +680,8 @@ impl MqttXApp {
|
||||
if existing.contains(&item.id) {
|
||||
skipped += 1;
|
||||
} else {
|
||||
// 记录已入库 id,导入文件内部的重复 id 也只收一条
|
||||
existing.insert(item.id.clone());
|
||||
app.connections.push(item);
|
||||
added += 1;
|
||||
}
|
||||
@@ -711,15 +763,38 @@ impl MqttXApp {
|
||||
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,
|
||||
@@ -735,12 +810,15 @@ impl MqttXApp {
|
||||
|
||||
// ─── 主题 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 应用主题偏好。
|
||||
///
|
||||
/// 「跟随系统」读取 gpui 的窗口外观(`App::window_appearance`);系统深浅色变化由
|
||||
/// `MqttXApp::new` 里注册的 `Window::observe_window_appearance` 监听并重应用本函数。
|
||||
pub fn apply_theme(pref: ThemeModePref, cx: &mut App) {
|
||||
let mode = match pref {
|
||||
ThemeModePref::Light => ThemeMode::Light,
|
||||
ThemeModePref::Dark => ThemeMode::Dark,
|
||||
// 未做系统外观检测前,“跟随系统”暂按浅色处理
|
||||
ThemeModePref::System => ThemeMode::Light,
|
||||
ThemeModePref::System => ThemeMode::from(cx.window_appearance()),
|
||||
};
|
||||
Theme::change(mode, None, cx);
|
||||
}
|
||||
@@ -894,6 +972,14 @@ 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 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();
|
||||
@@ -1071,7 +1157,14 @@ impl MqttXApp {
|
||||
.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();
|
||||
// 分组多时限制高度并纵向滚动,避免 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()),
|
||||
@@ -1179,16 +1272,54 @@ impl MqttXApp {
|
||||
.ok();
|
||||
}))
|
||||
.separator()
|
||||
.item(PopupMenuItem::new("删除").on_click(move |_, _, cx| {
|
||||
w3.update(cx, |app, cx| app.delete_connection(&e3, cx)).ok();
|
||||
.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()
|
||||
@@ -1220,7 +1351,9 @@ impl MqttXApp {
|
||||
}
|
||||
|
||||
let active = self.active_tab.clone().unwrap_or_default();
|
||||
let mut bar = TabBar::new("conn-tabs");
|
||||
let mut bar = TabBar::new("conn-tabs")
|
||||
// 连接名过长时截断省略,避免单个页签占满整行
|
||||
.max_width(px(180.));
|
||||
for id in self.open_tabs.clone() {
|
||||
let name = self
|
||||
.connections
|
||||
@@ -1251,11 +1384,17 @@ impl MqttXApp {
|
||||
}
|
||||
|
||||
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))
|
||||
.when_some(view, |this, v| {
|
||||
this.child(v_flex().flex_1().min_h(px(0.)).child(v))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! 新建/编辑连接的表单对话框内容。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui_kit::component::scroll::ScrollableElement as _;
|
||||
@@ -21,7 +23,7 @@ use gpui_kit::{
|
||||
|
||||
use crate::model::{ConnectionConfig, LastWill, ProtocolVersion, SslConfig, TransportKind};
|
||||
use crate::mqtt::MqttEngine;
|
||||
use crate::ui::widgets::{field, make_select, OptionDelegate};
|
||||
use crate::ui::widgets::{field, field_ex, make_select, OptionDelegate};
|
||||
|
||||
const PROTOCOLS: [&str; 2] = ["MQTT 5.0", "MQTT 3.1.1"];
|
||||
const TRANSPORTS: [&str; 4] = ["TCP", "TLS", "WebSocket", "WSS"];
|
||||
@@ -76,12 +78,16 @@ pub struct ConnectionForm {
|
||||
will_retain: bool,
|
||||
ssl_ignore_ca: bool,
|
||||
|
||||
// 按需显示:高级分组默认折叠
|
||||
// 按需显示:高级分组默认折叠(编辑已配置内容时在 new() 里默认展开)
|
||||
show_auth: bool,
|
||||
show_mqtt5: bool,
|
||||
show_will: bool,
|
||||
show_ssl: bool,
|
||||
|
||||
// build() 校验失败时的字段级错误(字段 key → 提示文案),
|
||||
// render 时映射成红字;build 只拿 &self,故用 RefCell 内部可变
|
||||
field_errors: RefCell<HashMap<&'static str, String>>,
|
||||
|
||||
testing: bool,
|
||||
_subs: Vec<Subscription>,
|
||||
}
|
||||
@@ -220,20 +226,34 @@ impl ConnectionForm {
|
||||
let group_idx = c
|
||||
.group
|
||||
.as_deref()
|
||||
.and_then(|g| groups.iter().position(|x| x == g))
|
||||
.and_then(|g| {
|
||||
// 存储值可能带首尾空白,而下拉列表在上游已 trim,
|
||||
// 直接相等比较会匹配不上 → 回填成「无分组」,保存即丢分组
|
||||
let g = g.trim();
|
||||
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 路径的按需显示。
|
||||
// gpui 列表点击任意项(含当前项)都会发 Confirm,若不比对索引,
|
||||
// 打开下拉点一下当前传输就会把自定义端口静默改回默认值。
|
||||
let last_transport = std::cell::Cell::new(transport_idx);
|
||||
let port_for_sub = port.clone();
|
||||
let sub_transport = cx.subscribe_in(
|
||||
&transport,
|
||||
window,
|
||||
move |_this, state, event, window, cx| {
|
||||
if matches!(event, SelectEvent::Confirm(Some(_)))
|
||||
&& let Some(idx) = state.read(cx).selected_value() {
|
||||
let kind = TransportKind::ALL[*idx];
|
||||
&& let Some(idx) = state.read(cx).selected_value().copied()
|
||||
&& let Some(kind) = TransportKind::ALL.get(idx).copied()
|
||||
{
|
||||
// 索引未变化:视为重新点选当前项,不覆盖用户输入的端口
|
||||
if last_transport.get() == idx {
|
||||
return;
|
||||
}
|
||||
last_transport.set(idx);
|
||||
port_for_sub.update(cx, |p, cx| {
|
||||
p.set_value(kind.default_port().to_string(), window, cx);
|
||||
});
|
||||
@@ -252,6 +272,16 @@ impl ConnectionForm {
|
||||
},
|
||||
);
|
||||
|
||||
// 编辑已配置 SSL/会话过期等高级项的连接时默认展开,避免配置不可见
|
||||
let ssl_configured = !c.ssl.ca_file.trim().is_empty()
|
||||
|| !c.ssl.client_cert_file.trim().is_empty()
|
||||
|| !c.ssl.client_key_file.trim().is_empty()
|
||||
|| c.ssl.ignore_ca;
|
||||
let mqtt5_configured = c.session_expiry_interval > 0
|
||||
|| c.receive_maximum.is_some()
|
||||
|| c.maximum_packet_size.is_some()
|
||||
|| c.topic_alias_maximum.is_some();
|
||||
|
||||
Self {
|
||||
engine,
|
||||
editing_id,
|
||||
@@ -290,9 +320,10 @@ impl ConnectionForm {
|
||||
will_retain,
|
||||
ssl_ignore_ca: c.ssl.ignore_ca,
|
||||
show_auth: true,
|
||||
show_mqtt5: false,
|
||||
show_mqtt5: mqtt5_configured,
|
||||
show_will: false,
|
||||
show_ssl: false,
|
||||
show_ssl: ssl_configured,
|
||||
field_errors: RefCell::default(),
|
||||
testing: false,
|
||||
_subs: vec![sub_transport, sub_protocol],
|
||||
}
|
||||
@@ -302,6 +333,22 @@ impl ConnectionForm {
|
||||
e.read(cx).value().to_string()
|
||||
}
|
||||
|
||||
/// 记录字段级错误并返回同一文案,供 build 的 `?` 传播到 Toast。
|
||||
fn fail(&self, key: &'static str, msg: impl Into<String>) -> String {
|
||||
let msg = msg.into();
|
||||
self.field_errors.borrow_mut().insert(key, msg.clone());
|
||||
msg
|
||||
}
|
||||
|
||||
/// 把 Result 的错误挂到指定字段上再继续 `?` 传播。
|
||||
fn fail_as<T>(
|
||||
&self,
|
||||
key: &'static str,
|
||||
r: Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
r.map_err(|e| self.fail(key, e))
|
||||
}
|
||||
|
||||
fn parse_u16(s: &str, label: &str) -> Result<u16, String> {
|
||||
s.trim()
|
||||
.parse::<u16>()
|
||||
@@ -329,39 +376,63 @@ impl ConnectionForm {
|
||||
}
|
||||
|
||||
fn build_inner(&self, cx: &App, for_test: bool) -> Result<ConnectionConfig, String> {
|
||||
// 每次构建先清空上一轮字段错误,失败时由 fail() 重新登记
|
||||
self.field_errors.borrow_mut().clear();
|
||||
|
||||
let mut name = self.val(&self.name, cx);
|
||||
if name.trim().is_empty() {
|
||||
if !for_test {
|
||||
return Err("连接名称不能为空".into());
|
||||
return Err(self.fail("name", "连接名称不能为空"));
|
||||
}
|
||||
name = "测试连接".into();
|
||||
}
|
||||
// 判空已按 trim,保存也存 trim 后的值,避免首尾空白入库
|
||||
let name = name.trim().to_string();
|
||||
let host = self.val(&self.host, cx);
|
||||
if host.trim().is_empty() {
|
||||
return Err("主机地址不能为空".into());
|
||||
return Err(self.fail("host", "主机地址不能为空"));
|
||||
}
|
||||
let port = Self::parse_u16(&self.val(&self.port, cx), "端口")?;
|
||||
let host = host.trim().to_string();
|
||||
let port = self.fail_as("port", Self::parse_u16(&self.val(&self.port, cx), "端口"))?;
|
||||
let mut client_id = self.val(&self.client_id, cx);
|
||||
if client_id.trim().is_empty() {
|
||||
if !for_test {
|
||||
return Err("Client ID 不能为空".into());
|
||||
return Err(self.fail("client_id", "Client ID 不能为空"));
|
||||
}
|
||||
client_id = format!("mqttx-test-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||
}
|
||||
let keep_alive = Self::parse_u16(&self.val(&self.keep_alive, cx), "Keep Alive")?;
|
||||
let session_expiry = self
|
||||
.val(&self.session_expiry, cx)
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0);
|
||||
let receive_maximum = Self::parse_opt_u32(&self.val(&self.receive_max, cx), "接收上限")?
|
||||
let client_id = client_id.trim().to_string();
|
||||
let keep_alive =
|
||||
self.fail_as("keep_alive", Self::parse_u16(&self.val(&self.keep_alive, cx), "Keep Alive"))?;
|
||||
// 空串视为 0(会话随连接结束);非空必须是合法非负整数,不再静默吞掉 -1/abc
|
||||
let session_expiry = {
|
||||
let raw = self.val(&self.session_expiry, cx);
|
||||
let t = raw.trim();
|
||||
if t.is_empty() {
|
||||
0
|
||||
} else {
|
||||
t.parse::<u32>().map_err(|_| {
|
||||
self.fail(
|
||||
"session_expiry",
|
||||
"会话过期间隔需要是非负整数(0=会话随连接结束)",
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
let receive_maximum = self.fail_as("receive_max", (|| -> Result<_, String> {
|
||||
Ok(Self::parse_opt_u32(&self.val(&self.receive_max, cx), "接收上限")?
|
||||
.map(|v| u16::try_from(v).map_err(|_| "接收上限不能超过 65535".to_string()))
|
||||
.transpose()?;
|
||||
let maximum_packet_size =
|
||||
Self::parse_opt_u32(&self.val(&self.max_packet, cx), "最大报文长度")?;
|
||||
let topic_alias_maximum = Self::parse_opt_u32(&self.val(&self.topic_alias, cx), "主题别名")?
|
||||
.transpose())
|
||||
})())??;
|
||||
let maximum_packet_size = self.fail_as(
|
||||
"max_packet",
|
||||
Self::parse_opt_u32(&self.val(&self.max_packet, cx), "最大报文长度"),
|
||||
)?;
|
||||
let topic_alias_maximum = self.fail_as("topic_alias", (|| -> Result<_, String> {
|
||||
Ok(Self::parse_opt_u32(&self.val(&self.topic_alias, cx), "主题别名")?
|
||||
.map(|v| u16::try_from(v).map_err(|_| "主题别名上限不能超过 65535".to_string()))
|
||||
.transpose()?;
|
||||
.transpose())
|
||||
})())??;
|
||||
|
||||
let conn_timeout_raw = self.val(&self.conn_timeout, cx);
|
||||
let connection_timeout_secs = if conn_timeout_raw.trim().is_empty() {
|
||||
@@ -372,7 +443,7 @@ impl ConnectionForm {
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
.filter(|v| *v >= 1)
|
||||
.ok_or_else(|| "连接超时需要是 1~65535 的整数(秒)".to_string())?
|
||||
.ok_or_else(|| self.fail("conn_timeout", "连接超时需要是 1~65535 的整数(秒)"))?
|
||||
};
|
||||
let max_reconnect_raw = self.val(&self.max_reconnect, cx);
|
||||
let max_reconnect_times = if max_reconnect_raw.trim().is_empty() {
|
||||
@@ -381,7 +452,7 @@ impl ConnectionForm {
|
||||
max_reconnect_raw
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.map_err(|_| "最大重连次数需要是非负整数(0=无限)".to_string())?
|
||||
.map_err(|_| self.fail("max_reconnect", "最大重连次数需要是非负整数(0=无限)"))?
|
||||
};
|
||||
|
||||
// 分组:新分组名非空时优先,否则取下拉所选(首项=无分组)
|
||||
@@ -416,6 +487,12 @@ impl ConnectionForm {
|
||||
.and_then(|i| TransportKind::ALL.get(*i).copied())
|
||||
.unwrap_or_default();
|
||||
|
||||
// WS/WSS 依赖 path 建立升级请求,缺失时连接必然失败,保存前拦截
|
||||
let path = self.val(&self.path, cx).trim().to_string();
|
||||
if transport.is_websocket() && path.is_empty() {
|
||||
return Err(self.fail("path", "WS/WSS 传输下 WS 路径不能为空"));
|
||||
}
|
||||
|
||||
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(),
|
||||
@@ -426,13 +503,25 @@ impl ConnectionForm {
|
||||
if transport.is_tls()
|
||||
&& ssl.client_cert_file.is_empty() != ssl.client_key_file.is_empty()
|
||||
{
|
||||
return Err("客户端证书与客户端密钥必须同时填写".into());
|
||||
return Err(self.fail("ssl_client_cert", "客户端证书与客户端密钥必须同时填写"));
|
||||
}
|
||||
// 证书文件不存在时握手才报错太晚,保存前给出字段级提示
|
||||
if transport.is_tls() {
|
||||
for (key, label, p) in [
|
||||
("ssl_ca", "CA 证书", &ssl.ca_file),
|
||||
("ssl_client_cert", "客户端证书", &ssl.client_cert_file),
|
||||
("ssl_client_key", "客户端私钥", &ssl.client_key_file),
|
||||
] {
|
||||
if !p.is_empty() && !std::path::Path::new(p).exists() {
|
||||
return Err(self.fail(key, format!("{label}路径不存在:{p}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let last_will = if self.will_enabled {
|
||||
let topic = self.val(&self.will_topic, cx);
|
||||
if topic.trim().is_empty() {
|
||||
return Err("遗嘱主题不能为空".into());
|
||||
return Err(self.fail("will_topic", "遗嘱主题不能为空"));
|
||||
}
|
||||
let opt_str = |v: String| {
|
||||
let t = v.trim().to_string();
|
||||
@@ -456,7 +545,7 @@ impl ConnectionForm {
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
path: self.val(&self.path, cx),
|
||||
path,
|
||||
protocol,
|
||||
transport,
|
||||
client_id,
|
||||
@@ -484,6 +573,8 @@ impl ConnectionForm {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
window.push_notification(Notification::error(e), cx);
|
||||
// 触发重绘,让刚登记的字段级红字立即显示
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -624,6 +715,33 @@ impl Render for ConnectionForm {
|
||||
self.transport.read(cx).selected_value(),
|
||||
Some(1) | Some(3)
|
||||
);
|
||||
// build() 校验失败登记的字段级错误,渲染成红字;红/星号用主题错误色
|
||||
let danger = cx.theme().danger;
|
||||
let field_errors = self.field_errors.borrow().clone();
|
||||
let err = move |key: &'static str| -> Option<SharedString> {
|
||||
field_errors.get(key).map(|s| SharedString::from(s.as_str()))
|
||||
};
|
||||
// 折叠标题的「已配置 N 项」提示,避免编辑旧配置时看不到已填内容
|
||||
let ssl_count = [
|
||||
self.val(&self.ssl_ca, cx).trim().is_empty(),
|
||||
self.val(&self.ssl_client_cert, cx).trim().is_empty(),
|
||||
self.val(&self.ssl_client_key, cx).trim().is_empty(),
|
||||
]
|
||||
.iter()
|
||||
.filter(|empty| !**empty)
|
||||
.count();
|
||||
let mqtt5_count = [
|
||||
!self.val(&self.session_expiry, cx).trim().is_empty(),
|
||||
!self.val(&self.receive_max, cx).trim().is_empty(),
|
||||
!self.val(&self.max_packet, cx).trim().is_empty(),
|
||||
!self.val(&self.topic_alias, cx).trim().is_empty(),
|
||||
]
|
||||
.iter()
|
||||
.filter(|on| **on)
|
||||
.count();
|
||||
let ssl_hint = (ssl_count > 0).then(|| SharedString::from(format!("已配置 {ssl_count} 项")));
|
||||
let mqtt5_hint =
|
||||
(mqtt5_count > 0).then(|| SharedString::from(format!("已配置 {mqtt5_count} 项")));
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.max_h(px(560.))
|
||||
@@ -633,11 +751,11 @@ impl Render for ConnectionForm {
|
||||
.child(section("基本信息", border, card_bg,
|
||||
v_flex().gap_2().child(
|
||||
h_flex().gap_2().w_full()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("名称", Input::new(&self.name))))
|
||||
.child(div().w(px(120.)).child(field("端口", Input::new(&self.port))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("名称", Input::new(&self.name), true, err("name"), danger)))
|
||||
.child(div().w(px(120.)).child(field_ex("端口", Input::new(&self.port), true, err("port"), danger)))
|
||||
)
|
||||
.child(h_flex().gap_2().w_full()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("主机", Input::new(&self.host))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("主机", Input::new(&self.host), true, err("host"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("协议", Select::new(&self.protocol))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("传输", Select::new(&self.transport))))
|
||||
)
|
||||
@@ -646,7 +764,7 @@ impl Render for ConnectionForm {
|
||||
.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))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("Client ID", Input::new(&self.client_id), true, err("client_id"), danger)))
|
||||
// 一键换一个随机 Client ID,与 ConnectionConfig::new 的生成逻辑一致
|
||||
.child(
|
||||
Button::new("cid-regen")
|
||||
@@ -660,9 +778,9 @@ impl Render for ConnectionForm {
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
// WS 路径仅对 WebSocket/WSS 有意义,按需显示
|
||||
// WS 路径仅对 WebSocket/WSS 有意义,按需显示;该传输下必填
|
||||
.when(is_ws, |w| w.child(
|
||||
div().flex_1().min_w(px(0.)).child(field("WS 路径", Input::new(&self.path)))
|
||||
div().flex_1().min_w(px(0.)).child(field_ex("WS 路径", Input::new(&self.path), true, err("path"), danger))
|
||||
))
|
||||
)
|
||||
))
|
||||
@@ -674,11 +792,11 @@ impl Render for ConnectionForm {
|
||||
.child(h_flex().gap_2().w_full()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("用户名", Input::new(&self.username))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("密码", Input::new(&self.password).mask_toggle())))
|
||||
.child(div().w(px(110.)).child(field("Keep Alive (秒)", Input::new(&self.keep_alive))))
|
||||
.child(div().w(px(110.)).child(field_ex("Keep Alive (秒)", Input::new(&self.keep_alive), true, err("keep_alive"), danger)))
|
||||
)
|
||||
.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(div().w(px(150.)).child(field_ex("连接超时 (秒)", Input::new(&self.conn_timeout), false, err("conn_timeout"), danger)))
|
||||
.child(div().w(px(170.)).child(field_ex("最大重连次数 (0=无限)", Input::new(&self.max_reconnect), false, err("max_reconnect"), danger)))
|
||||
)
|
||||
.child(self.switch_row("clean_start", "Clean Start / Clean Session", self.clean_start, cx))
|
||||
.child(self.switch_row("auto_reconnect", "断线自动重连", self.auto_reconnect, cx))
|
||||
@@ -687,25 +805,25 @@ impl Render for ConnectionForm {
|
||||
))
|
||||
// ── SSL/TLS(仅 TLS/WSS 显示) ──
|
||||
.when(is_tls, |w| w.child(self.collapsible_section(
|
||||
"sec-ssl", "SSL/TLS", None, false, self.show_ssl,
|
||||
"sec-ssl", "SSL/TLS", ssl_hint, true, 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(field_ex("CA 证书 (PEM)", Input::new(&self.ssl_ca), false, err("ssl_ca"), danger))
|
||||
.child(field_ex("客户端证书 (PEM)", Input::new(&self.ssl_client_cert), false, err("ssl_client_cert"), danger))
|
||||
.child(field_ex("客户端密钥 (PEM)", Input::new(&self.ssl_client_key), false, err("ssl_client_key"), danger))
|
||||
.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,
|
||||
"sec-mqtt5", "MQTT 5 属性", mqtt5_hint, true, self.show_mqtt5,
|
||||
|f| f.show_mqtt5 = !f.show_mqtt5, border, card_bg,
|
||||
h_flex().gap_2().w_full()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("会话过期间隔(秒)", Input::new(&self.session_expiry))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("接收最大值", Input::new(&self.receive_max))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("最大报文长度", Input::new(&self.max_packet))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("主题别名上限", Input::new(&self.topic_alias)))),
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("会话过期间隔(秒)", Input::new(&self.session_expiry), false, err("session_expiry"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("接收最大值", Input::new(&self.receive_max), false, err("receive_max"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("最大报文长度", Input::new(&self.max_packet), false, err("max_packet"), danger)))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("主题别名上限", Input::new(&self.topic_alias), false, err("topic_alias"), danger))),
|
||||
cx,
|
||||
)))
|
||||
// ── 遗嘱 ──
|
||||
@@ -717,7 +835,7 @@ impl Render for ConnectionForm {
|
||||
.child(self.switch_row("will_enabled", "启用遗嘱消息", self.will_enabled, cx))
|
||||
.when(self.will_enabled, |w| w
|
||||
.child(h_flex().gap_2()
|
||||
.child(div().flex_1().min_w(px(0.)).child(field("遗嘱主题", Input::new(&self.will_topic))))
|
||||
.child(div().flex_1().min_w(px(0.)).child(field_ex("遗嘱主题", Input::new(&self.will_topic), true, err("will_topic"), danger)))
|
||||
.child(div().w(px(130.)).child(field("QoS", Select::new(&self.will_qos))))
|
||||
)
|
||||
.child(field("遗嘱内容", Input::new(&self.will_payload)))
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui_kit::component::button::{Button, ButtonGroup, ButtonVariants as _};
|
||||
use gpui_kit::component::button::{Button, ButtonGroup, ButtonVariant, ButtonVariants as _};
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::scroll::ScrollableElement as _;
|
||||
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
|
||||
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem};
|
||||
@@ -10,8 +11,8 @@ use gpui_kit::component::select::{Select, SelectState};
|
||||
use gpui_kit::component::switch::Switch;
|
||||
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 _, WindowExt as _,
|
||||
h_flex, notification::Notification, v_flex, ActiveTheme as _, Disableable as _,
|
||||
Selectable as _, Sizable as _, StyledExt as _, WindowExt as _,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
use crate::ui::IconName;
|
||||
@@ -35,6 +36,8 @@ const PAYLOAD_FORMATS: [&str; 4] = ["Plaintext", "JSON", "Base64", "Hex"];
|
||||
/// Retain Handling 0/1/2 的下拉文案
|
||||
const RETAIN_HANDLING: [&str; 3] = ["0 每次发送", "1 仅新订阅", "2 不发送"];
|
||||
const MAX_RENDERED_MESSAGES: usize = 300;
|
||||
/// 日志渲染上限,与 app.rs 的 MAX_LOGS 保持一致
|
||||
const MAX_RENDERED_LOGS: usize = 3000;
|
||||
/// 订阅色板预设:色相(度)+ 名称
|
||||
const PRESET_HUES: [(f32, &str); 10] = [
|
||||
(0., "红"),
|
||||
@@ -165,6 +168,9 @@ pub struct ConnectionView {
|
||||
filter: Entity<InputState>,
|
||||
panel: Panel,
|
||||
expanded: Option<u64>,
|
||||
/// 当前 detail_format 归属的消息:切换到另一条消息时据此重置格式,
|
||||
/// 同一条内折叠/展开则保留用户选择
|
||||
detail_owner: Option<u64>,
|
||||
msg_dir: DirFilter,
|
||||
detail_format: DetailFormat,
|
||||
|
||||
@@ -234,6 +240,7 @@ impl ConnectionView {
|
||||
filter,
|
||||
panel: Panel::Messages,
|
||||
expanded: None,
|
||||
detail_owner: None,
|
||||
msg_dir: DirFilter::All,
|
||||
detail_format: DetailFormat::Auto,
|
||||
preset_name,
|
||||
@@ -317,6 +324,10 @@ impl ConnectionView {
|
||||
sub.enabled = !sub.enabled;
|
||||
let enabled = sub.enabled;
|
||||
let topic = sub.topic.clone();
|
||||
// 停用的订阅不再参与消息过滤,指向它的过滤一并清掉
|
||||
if !enabled && self.sub_filter.as_deref() == Some(topic.as_str()) {
|
||||
self.sub_filter = None;
|
||||
}
|
||||
let conn = self.conn_id.clone();
|
||||
self.upsert_subscription(sub.clone(), cx);
|
||||
if self.engine.is_connected(&conn) {
|
||||
@@ -357,6 +368,22 @@ impl ConnectionView {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// 重置订阅表单(主题/别名/高级字段):新增提交与取消编辑共用,
|
||||
/// 避免高级选项残留到下一次订阅。
|
||||
fn reset_subscribe_form(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.sub_topic
|
||||
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||
self.sub_alias
|
||||
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||
self.sub_identifier
|
||||
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||
self.sub_no_local = false;
|
||||
self.sub_rap = false;
|
||||
self.sub_retain_handling.update(cx, |s, cx| {
|
||||
s.set_selected_index(Some(IndexPath::new(0)), window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
// ── 订阅 / 发布动作 ─────────────────────────────────────────────────────
|
||||
|
||||
fn do_subscribe(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -403,6 +430,19 @@ impl ConnectionView {
|
||||
let connected = self.engine.is_connected(&self.conn_id);
|
||||
let conn = self.conn_id.clone();
|
||||
|
||||
// 记录编辑来源:编辑态提交后以目标订阅回填,保住编辑上下文
|
||||
let was_editing = self.editing.clone();
|
||||
// 改名编辑时旧记录随后会被删除,先取出供新主题承接 id/color/enabled
|
||||
let editing_old = was_editing.as_ref().and_then(|old_topic| {
|
||||
self.with_app(cx, |app| {
|
||||
app.subscriptions
|
||||
.iter()
|
||||
.find(|s| s.connection_id == conn && s.topic == *old_topic)
|
||||
.cloned()
|
||||
})
|
||||
.flatten()
|
||||
});
|
||||
|
||||
// 编辑提交:旧主题不在新列表里时先移除(含引擎退订),新主题由下方 upsert 覆盖
|
||||
if let Some(old_topic) = self.editing.take()
|
||||
&& !topics.contains(&old_topic)
|
||||
@@ -418,8 +458,44 @@ impl ConnectionView {
|
||||
}
|
||||
}
|
||||
|
||||
for topic in topics {
|
||||
let mut sub = Subscription::new(conn.clone(), topic, qos);
|
||||
for topic in &topics {
|
||||
// 按 (连接, 主题) 查旧记录:继承 id/color/enabled,避免重复提交把备注色清掉、
|
||||
// 把停用中的订阅静默重启
|
||||
let existing = self
|
||||
.with_app(cx, |app| {
|
||||
app.subscriptions
|
||||
.iter()
|
||||
.find(|s| s.connection_id == conn && s.topic == *topic)
|
||||
.cloned()
|
||||
})
|
||||
.flatten();
|
||||
let sub = match existing {
|
||||
Some(mut old) => {
|
||||
old.qos = qos;
|
||||
old.alias = alias.clone();
|
||||
if is_v5 {
|
||||
old.sub_identifier = sub_identifier;
|
||||
old.no_local = no_local;
|
||||
old.retain_as_published = rap;
|
||||
old.retain_handling = retain_handling;
|
||||
}
|
||||
old
|
||||
}
|
||||
None => {
|
||||
// 改名编辑的单主题提交:新主题承接旧记录身份与备注,避免改名丢数据
|
||||
if topics.len() == 1 && let Some(mut old) = editing_old.clone() {
|
||||
old.topic = topic.clone();
|
||||
old.qos = qos;
|
||||
old.alias = alias.clone();
|
||||
if is_v5 {
|
||||
old.sub_identifier = sub_identifier;
|
||||
old.no_local = no_local;
|
||||
old.retain_as_published = rap;
|
||||
old.retain_handling = retain_handling;
|
||||
}
|
||||
old
|
||||
} else {
|
||||
let mut sub = Subscription::new(conn.clone(), topic.clone(), qos);
|
||||
sub.alias = alias.clone();
|
||||
if is_v5 {
|
||||
sub.sub_identifier = sub_identifier;
|
||||
@@ -427,8 +503,14 @@ impl ConnectionView {
|
||||
sub.retain_as_published = rap;
|
||||
sub.retain_handling = retain_handling;
|
||||
}
|
||||
sub
|
||||
}
|
||||
}
|
||||
};
|
||||
// 继承到的停用订阅保持退订状态,不因重新提交被拉起
|
||||
let keep_disabled = !sub.enabled;
|
||||
self.upsert_subscription(sub.clone(), cx);
|
||||
if connected {
|
||||
if connected && !keep_disabled {
|
||||
self.engine.subscribe_with_options(
|
||||
conn.clone(),
|
||||
sub.clone(),
|
||||
@@ -449,14 +531,37 @@ impl ConnectionView {
|
||||
window.push_notification(Notification::warning(hint), cx);
|
||||
}
|
||||
|
||||
self.sub_topic
|
||||
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||
self.sub_alias
|
||||
.update(cx, |s, cx| s.set_value("", window, cx));
|
||||
// 编辑态:以刚保存的目标订阅回填表单继续保留上下文;
|
||||
// 新增态:清空主题/别名并重置高级字段,避免残留到下一次订阅
|
||||
if was_editing.is_some() && topics.len() == 1 {
|
||||
let target = topics[0].clone();
|
||||
let saved = self
|
||||
.with_app(cx, |app| {
|
||||
app.subscriptions
|
||||
.iter()
|
||||
.find(|s| s.connection_id == conn && s.topic == target)
|
||||
.cloned()
|
||||
})
|
||||
.flatten();
|
||||
if let Some(saved) = saved {
|
||||
self.start_edit_subscription(&saved, window, cx);
|
||||
} else {
|
||||
self.editing = None;
|
||||
self.reset_subscribe_form(window, cx);
|
||||
}
|
||||
} else {
|
||||
self.editing = None;
|
||||
self.reset_subscribe_form(window, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn do_publish(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// 未连接时消息发不出去,先拦截给出与订阅路径一致的提示
|
||||
if !self.engine.is_connected(&self.conn_id) {
|
||||
window.push_notification(Notification::warning("未连接:消息未发送"), cx);
|
||||
return;
|
||||
}
|
||||
let mut params = self.collect_publish_params(cx);
|
||||
if params.topic.trim().is_empty() {
|
||||
window.push_notification(Notification::warning("发布主题不能为空"), cx);
|
||||
@@ -472,6 +577,11 @@ impl ConnectionView {
|
||||
let vars = self.with_app(cx, |app| app.variables.clone()).unwrap_or_default();
|
||||
params.topic = render_template(¶ms.topic, &vars);
|
||||
params.payload = render_template(¶ms.payload, &vars);
|
||||
// {{topic}} 等变量渲染后可能变空串,渲染完成后再校验一次
|
||||
if params.topic.trim().is_empty() {
|
||||
window.push_notification(Notification::warning("发布主题不能为空"), cx);
|
||||
return;
|
||||
}
|
||||
// {{变量}} 同样作用于用户属性的 key/value
|
||||
params.user_properties = params
|
||||
.user_properties
|
||||
@@ -585,17 +695,19 @@ impl ConnectionView {
|
||||
impl ConnectionView {
|
||||
fn render_top_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let id = self.conn_id.clone();
|
||||
let (name, status, is_v5) = self
|
||||
let (name, status, is_v5, error) = self
|
||||
.with_app(cx, |app| {
|
||||
let cfg = app.connections.iter().find(|c| c.id == id);
|
||||
(
|
||||
cfg.map(|c| c.name.clone()).unwrap_or_else(|| id.clone()),
|
||||
app.statuses.get(&id).copied().unwrap_or(ConnectionStatus::Disconnected),
|
||||
cfg.map(|c| c.protocol.is_v5()).unwrap_or(true),
|
||||
app.errors.get(&id).cloned(),
|
||||
)
|
||||
})
|
||||
.unwrap_or((id.clone(), ConnectionStatus::Disconnected, true));
|
||||
.unwrap_or((id.clone(), ConnectionStatus::Disconnected, true, None));
|
||||
let connected = matches!(status, ConnectionStatus::Connected);
|
||||
let connecting = matches!(status, ConnectionStatus::Connecting);
|
||||
let _ = is_v5;
|
||||
|
||||
let status_text = status.label();
|
||||
@@ -605,6 +717,10 @@ impl ConnectionView {
|
||||
ConnectionStatus::Error => cx.theme().danger,
|
||||
ConnectionStatus::Disconnected => cx.theme().muted_foreground,
|
||||
};
|
||||
// Error 状态把失败原因展示出来,避免只有「错误」二字无从排查
|
||||
let error_hint = matches!(status, ConnectionStatus::Error)
|
||||
.then(|| error.clone())
|
||||
.flatten();
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
@@ -632,7 +748,20 @@ impl ConnectionView {
|
||||
.child(status_text),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
// Error 副文本展示失败原因(截断避免挤掉按钮)
|
||||
.when_some(error_hint.clone(), |h, e| {
|
||||
h.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w(px(0.))
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.child(e),
|
||||
)
|
||||
})
|
||||
.when(error_hint.is_none(), |h| h.child(div().flex_1()))
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("conn-toggle-{}", self.conn_id)))
|
||||
.icon(if connected {
|
||||
@@ -640,11 +769,30 @@ impl ConnectionView {
|
||||
} else {
|
||||
IconName::PlugZap
|
||||
})
|
||||
.label(if connected { "断开" } else { "连接" })
|
||||
.label(if connected {
|
||||
"断开"
|
||||
} else if connecting {
|
||||
"连接中…"
|
||||
} else {
|
||||
"连接"
|
||||
})
|
||||
.when(connected, |b| b.danger().ghost())
|
||||
.when(!connected, |b| b.primary().ghost())
|
||||
// Connecting 期间禁用,避免重复点击重启连接流程
|
||||
.disabled(connecting)
|
||||
.small()
|
||||
.on_click(cx.listener(|this, _, _window, cx| {
|
||||
.when_some(error_hint.clone(), |b, e| b.tooltip(e))
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
// Connecting 期间(按钮禁用之外再兜一层)不响应,防止重复 connect 重启流程
|
||||
let connecting = this
|
||||
.with_app(cx, |app| {
|
||||
app.statuses.get(&this.conn_id)
|
||||
== Some(&ConnectionStatus::Connecting)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if connecting {
|
||||
return;
|
||||
}
|
||||
let cfg = this.config(cx);
|
||||
if let Some(cfg) = cfg {
|
||||
if this.engine.is_connected(&cfg.id) {
|
||||
@@ -662,7 +810,8 @@ impl ConnectionView {
|
||||
let sub_qos = self.sub_qos.clone();
|
||||
let sub_alias = self.sub_alias.clone();
|
||||
let is_v5 = self.is_v5(cx);
|
||||
let editing = self.editing.is_some();
|
||||
let editing_topic = self.editing.clone();
|
||||
let editing = editing_topic.is_some();
|
||||
let show_advanced = self.sub_show_advanced && is_v5;
|
||||
|
||||
let mut bar = v_flex()
|
||||
@@ -675,6 +824,19 @@ impl ConnectionView {
|
||||
.px_3()
|
||||
.h_12()
|
||||
.items_center()
|
||||
// 编辑态显式提示目标主题,避免被误当成新增提交
|
||||
.when_some(editing_topic.clone(), |h, topic| {
|
||||
h.child(
|
||||
div()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.rounded_md()
|
||||
.bg(cx.theme().warning.alpha(0.15))
|
||||
.text_xs()
|
||||
.text_color(cx.theme().warning)
|
||||
.child(format!("正在编辑:{topic}")),
|
||||
)
|
||||
})
|
||||
.child(div().flex_1().min_w(px(0.)).child(Input::new(&sub_topic).small()))
|
||||
.child(div().w(px(96.)).child(Select::new(&sub_qos).small()))
|
||||
.child(div().w(px(112.)).child(Input::new(&sub_alias).small()))
|
||||
@@ -697,7 +859,9 @@ impl ConnectionView {
|
||||
Button::new(SharedString::from(format!("subscribe-{}", self.conn_id)))
|
||||
.icon(if editing { IconName::Check } else { IconName::Plus })
|
||||
.label(if editing { "更新" } else { "订阅" })
|
||||
.primary()
|
||||
// 编辑用 warning 色与新增的 primary 区分
|
||||
.when(editing, |b| b.warning())
|
||||
.when(!editing, |b| b.primary())
|
||||
.small()
|
||||
.on_click(cx.listener(|this, _, window, cx| this.do_subscribe(window, cx))),
|
||||
)
|
||||
@@ -708,8 +872,10 @@ impl ConnectionView {
|
||||
.ghost()
|
||||
.small()
|
||||
.tooltip("取消编辑")
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.editing = None;
|
||||
// 清空回填到输入框的主题/别名/高级字段,回到新增态
|
||||
this.reset_subscribe_form(window, cx);
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
@@ -853,7 +1019,8 @@ impl ConnectionView {
|
||||
let weak = weak_menu.clone();
|
||||
let sid = sid.clone();
|
||||
let swatch = hue_color(hue);
|
||||
let label = SharedString::from(format!("{name} {hue:.0}°"));
|
||||
// 菜单只显示颜色名,角度数值对用户没有意义
|
||||
let label = SharedString::from(name);
|
||||
menu = menu.item(
|
||||
PopupMenuItem::element(move |_, _| {
|
||||
h_flex()
|
||||
@@ -934,7 +1101,6 @@ impl ConnectionView {
|
||||
|
||||
let del_cid = self.conn_id.clone();
|
||||
let del_topic = topic.clone();
|
||||
let del_weak_filter = self.sub_filter.clone();
|
||||
let del_btn = Button::new(SharedString::from(format!("sub-del-{}", sub_id)))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
@@ -942,7 +1108,7 @@ impl ConnectionView {
|
||||
.tooltip("取消订阅")
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
// 若删除的是当前过滤主题,一并清掉消息过滤
|
||||
if del_weak_filter.as_deref() == Some(del_topic.as_str()) {
|
||||
if this.sub_filter.as_deref() == Some(del_topic.as_str()) {
|
||||
this.sub_filter = None;
|
||||
}
|
||||
if let Some(app) = this.app.upgrade() {
|
||||
@@ -951,7 +1117,10 @@ impl ConnectionView {
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
// 只有删除的是正在编辑的主题才退出编辑态,编辑别的不受影响
|
||||
if this.editing.as_deref() == Some(del_topic.as_str()) {
|
||||
this.editing = None;
|
||||
}
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
@@ -1062,7 +1231,7 @@ impl ConnectionView {
|
||||
let border = cx.theme().border;
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let mono = cx.theme().mono_font_family.clone();
|
||||
let direction_label = if received { "接收" } else { "发送" };
|
||||
let direction_label = if received { "接收" } else { "发布" };
|
||||
let show_millis = self
|
||||
.with_app(cx, |app| app.settings.show_millis)
|
||||
.unwrap_or(true);
|
||||
@@ -1284,10 +1453,15 @@ impl ConnectionView {
|
||||
}
|
||||
body = body.child(
|
||||
div()
|
||||
.id(SharedString::from(format!("msg-detail-{}", seq)))
|
||||
.mt_1()
|
||||
.p_2()
|
||||
.rounded_lg()
|
||||
.bg(cx.theme().muted)
|
||||
// 详情区点击不冒泡到行折叠,保证文本可选中
|
||||
.on_click(|_, _, cx| {
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.child(details),
|
||||
);
|
||||
}
|
||||
@@ -1312,11 +1486,16 @@ impl ConnectionView {
|
||||
)
|
||||
.child(body)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.expanded = if this.expanded == Some(seq) {
|
||||
None
|
||||
if this.expanded == Some(seq) {
|
||||
this.expanded = None;
|
||||
} else {
|
||||
Some(seq)
|
||||
};
|
||||
// 切换到另一条消息时格式回到 Auto,单条内折叠/展开仍记忆选择
|
||||
if this.detail_owner != Some(seq) {
|
||||
this.detail_format = DetailFormat::Auto;
|
||||
this.detail_owner = Some(seq);
|
||||
}
|
||||
this.expanded = Some(seq);
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
}
|
||||
@@ -1326,7 +1505,8 @@ impl ConnectionView {
|
||||
let conn = self.conn_id.clone();
|
||||
let dir = self.msg_dir;
|
||||
let sub_filter = self.sub_filter.clone();
|
||||
let (records, subs): (Vec<MqttRecord>, Vec<Subscription>) = self
|
||||
// 引用过滤 + 计数命中总数,仅克隆前 MAX_RENDERED_MESSAGES 条,避免全量拷贝
|
||||
let (records, subs, matched_total): (Vec<MqttRecord>, Vec<Subscription>, usize) = self
|
||||
.with_app(cx, |app| {
|
||||
let subs: Vec<Subscription> = app
|
||||
.subscriptions
|
||||
@@ -1334,37 +1514,31 @@ impl ConnectionView {
|
||||
.filter(|s| s.connection_id == conn)
|
||||
.cloned()
|
||||
.collect();
|
||||
let records: Vec<MqttRecord> = app
|
||||
.messages
|
||||
.get(&conn)
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.rev()
|
||||
let mut matched = 0usize;
|
||||
let mut records: Vec<MqttRecord> = Vec::new();
|
||||
if let Some(m) = app.messages.get(&conn) {
|
||||
for r in m.iter().rev() {
|
||||
// 方向 / 搜索 / 订阅过滤叠加,过滤后再截断保证最新消息优先
|
||||
.filter(|r| {
|
||||
let dir_ok = match dir {
|
||||
DirFilter::All => true,
|
||||
DirFilter::Received => {
|
||||
r.direction == Direction::Received
|
||||
}
|
||||
DirFilter::Published => {
|
||||
r.direction == Direction::Published
|
||||
}
|
||||
DirFilter::Received => r.direction == Direction::Received,
|
||||
DirFilter::Published => r.direction == Direction::Published,
|
||||
};
|
||||
let query_ok = query.is_empty()
|
||||
|| r.topic.to_lowercase().contains(&query)
|
||||
|| r.payload.to_lowercase().contains(&query);
|
||||
|| contains_ignore_case(&r.topic, &query)
|
||||
|| contains_ignore_case(&r.payload, &query);
|
||||
let sub_ok = sub_filter
|
||||
.as_deref()
|
||||
.map_or(true, |f| topic_matches(f, &r.topic));
|
||||
dir_ok && query_ok && sub_ok
|
||||
})
|
||||
.take(MAX_RENDERED_MESSAGES)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(records, subs)
|
||||
if dir_ok && query_ok && sub_ok {
|
||||
matched += 1;
|
||||
if records.len() < MAX_RENDERED_MESSAGES {
|
||||
records.push(r.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(records, subs, matched)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -1504,27 +1678,65 @@ impl ConnectionView {
|
||||
.text_xs()
|
||||
.px_1p5()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("{} 条", records.len())),
|
||||
// 计数显示过滤命中总数,不受渲染截断影响
|
||||
.child(format!("{} 条", matched_total)),
|
||||
)
|
||||
.child(
|
||||
.when(matched_total > records.len(), |h| {
|
||||
h.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.px_1p5()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!("仅显示最新 {} 条", MAX_RENDERED_MESSAGES)),
|
||||
)
|
||||
})
|
||||
.child({
|
||||
let weak = cx.weak_entity();
|
||||
let clear_id = self.conn_id.clone();
|
||||
let empty = records.is_empty();
|
||||
Button::new(SharedString::from(format!("msg-clear-{}", self.conn_id)))
|
||||
.icon(IconName::Eraser)
|
||||
.label("清空")
|
||||
.ghost()
|
||||
.small()
|
||||
.disabled(empty)
|
||||
.tooltip("清空当前连接的消息")
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
let id = this.conn_id.clone();
|
||||
this.expanded = None;
|
||||
if let Some(app) = this.app.upgrade() {
|
||||
.on_click(move |_, window, cx| {
|
||||
// 清空不可撤销,先弹二次确认
|
||||
let weak = weak.clone();
|
||||
let clear_id = clear_id.clone();
|
||||
window.open_alert_dialog(cx, move |alert, _, _| {
|
||||
let weak = weak.clone();
|
||||
let clear_id = clear_id.clone();
|
||||
alert
|
||||
.title("清空消息")
|
||||
.description("确定清空当前连接的全部消息吗?此操作不可撤销。")
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.show_cancel(true)
|
||||
.cancel_text("取消")
|
||||
.ok_text("清空")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.on_ok(move |_, _, cx| {
|
||||
weak.update(cx, |view, cx| {
|
||||
view.expanded = None;
|
||||
view.detail_owner = None;
|
||||
view.detail_format = DetailFormat::Auto;
|
||||
if let Some(app) = view.app.upgrade() {
|
||||
app.update(cx, |app, cx| {
|
||||
app.messages.remove(&id);
|
||||
app.messages.remove(&clear_id);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
})
|
||||
.ok();
|
||||
true
|
||||
})
|
||||
});
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(body)
|
||||
}
|
||||
@@ -1541,7 +1753,8 @@ impl ConnectionView {
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|l| l.connection_id == conn)
|
||||
.take(2000)
|
||||
// 与 app.rs 的 MAX_LOGS=3000 对齐,避免渲染截断早于存储上限
|
||||
.take(MAX_RENDERED_LOGS)
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
@@ -1644,6 +1857,7 @@ impl ConnectionView {
|
||||
|
||||
// 发布预设
|
||||
let presets = self.with_app(cx, |app| app.presets.clone()).unwrap_or_default();
|
||||
let connected = self.engine.is_connected(&self.conn_id);
|
||||
|
||||
let mut card = v_flex()
|
||||
.w_full()
|
||||
@@ -1709,6 +1923,9 @@ impl ConnectionView {
|
||||
.label("发送")
|
||||
.primary()
|
||||
.small()
|
||||
// 未连接时禁用,Ctrl+Enter 路径由 do_publish 内的前置检查兜底
|
||||
.disabled(!connected)
|
||||
.when(!connected, |b| b.tooltip("未连接,无法发送"))
|
||||
.on_click(cx.listener(|this, _, window, cx| this.do_publish(window, cx))),
|
||||
),
|
||||
);
|
||||
@@ -1915,23 +2132,55 @@ impl ConnectionView {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// collect 已覆盖用户属性与全部 v5 字段
|
||||
// 复用 do_publish 的过期秒数校验:输入了但解析不出即拒绝
|
||||
let params = weak
|
||||
.read_with(cx, |view, cx| {
|
||||
view.collect_publish_params(cx)
|
||||
let expiry_raw = view
|
||||
.msg_expiry
|
||||
.read(cx)
|
||||
.value()
|
||||
.to_string();
|
||||
(
|
||||
view.collect_publish_params(cx),
|
||||
expiry_raw,
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
if let (Some(view), Some(params)) =
|
||||
if let (Some(view), Some((params, expiry_raw))) =
|
||||
(weak.upgrade(), params)
|
||||
&& let Some(app) = view.read(cx).app.upgrade() {
|
||||
app.update(cx, |app, cx| {
|
||||
app.upsert_publish_preset(name, params);
|
||||
cx.notify();
|
||||
});
|
||||
{
|
||||
if !expiry_raw.trim().is_empty()
|
||||
&& params.message_expiry_interval.is_none()
|
||||
{
|
||||
window.push_notification(
|
||||
Notification::success("预设已保存"),
|
||||
Notification::warning(
|
||||
"消息过期须为非负整数(秒)",
|
||||
),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(app) = view.read(cx).app.upgrade() {
|
||||
// 同名即覆盖,提示语区分以明确发生了什么
|
||||
let existed = app
|
||||
.read(cx)
|
||||
.presets
|
||||
.iter()
|
||||
.any(|p| p.name == name);
|
||||
app.update(cx, |app, cx| {
|
||||
app.upsert_publish_preset(name.clone(), params);
|
||||
cx.notify();
|
||||
});
|
||||
let msg = if existed {
|
||||
format!("已覆盖预设「{name}」")
|
||||
} else {
|
||||
"预设已保存".to_string()
|
||||
};
|
||||
window.push_notification(
|
||||
Notification::success(msg),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
@@ -1995,9 +2244,21 @@ impl Render for ConnectionView {
|
||||
|
||||
// ─── 独立工具函数 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// 色相(度)→ gpui Hsla(h 分量为 0..=1 的整圆比例)
|
||||
/// 色相(度)→ gpui Hsla(h 分量为 0..=1 的整圆比例)。
|
||||
/// 固定 L=0.52 时亮色相(黄绿)在浅色主题下过亮、暗色相(蓝靛)在深色主题下过暗,
|
||||
/// 按色相段微调明度,保证两端主题下都可辨识。
|
||||
fn hue_color(hue: f32) -> Hsla {
|
||||
hsla(hue.rem_euclid(360.0) / 360.0, 0.72, 0.52, 1.0)
|
||||
let h = hue.rem_euclid(360.0);
|
||||
let l = if (30.0..=90.0).contains(&h) {
|
||||
// 黄/黄绿本身明度感高,压低一点防刺眼
|
||||
0.44
|
||||
} else if (200.0..=280.0).contains(&h) {
|
||||
// 蓝/靛本身明度感低,抬高一点防发黑
|
||||
0.60
|
||||
} else {
|
||||
0.52
|
||||
};
|
||||
hsla(h / 360.0, 0.72, l, 1.0)
|
||||
}
|
||||
|
||||
/// 逗号 / 空白 / 换行分隔的多主题输入 → 去重后的主题列表(批量订阅)
|
||||
@@ -2104,12 +2365,39 @@ fn details_copy_text(record: &MqttRecord, show_millis: bool) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// 折叠态只显示单行预览;完整内容由展开详情按所选格式渲染。
|
||||
/// 折叠态只显示单行预览:换行/制表符压平成空格后截 200 字符。
|
||||
/// 含替换字符(U+FFFD)说明负载不是合法文本,提示切 Hex 查看。
|
||||
fn preview_payload(payload: &str) -> String {
|
||||
let one_line: String = payload.chars().take(200).collect();
|
||||
if payload.chars().count() > 200 {
|
||||
format!("{one_line}…")
|
||||
} else {
|
||||
one_line
|
||||
let flat: String = payload
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'\n' | '\r' | '\t' => ' ',
|
||||
_ => c,
|
||||
})
|
||||
.collect();
|
||||
let mut out: String = flat.chars().take(200).collect();
|
||||
if flat.chars().count() > 200 {
|
||||
out.push('…');
|
||||
}
|
||||
if payload.contains('\u{FFFD}') {
|
||||
out.push_str("(非文本,详情可切 Hex)");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 大小写不敏感子串匹配。`needle` 已由调用方 to_lowercase 一次;
|
||||
/// 先做零拷贝的字面匹配,未命中且原文含大写时才回退整串小写,
|
||||
/// 避免渲染热路径对全部记录做 to_lowercase 拷贝。
|
||||
fn contains_ignore_case(hay: &str, needle: &str) -> bool {
|
||||
if needle.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if hay.contains(needle) {
|
||||
return true;
|
||||
}
|
||||
// 原文没有任何大写字符时,小写化不会改变匹配结果,直接判否
|
||||
if !hay.chars().any(|c| c.is_uppercase()) {
|
||||
return false;
|
||||
}
|
||||
hay.to_lowercase().contains(needle)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@ use crate::ui::app::MqttXApp;
|
||||
use crate::ui::widgets::{field, make_select, OptionDelegate};
|
||||
use crate::ui::IconName;
|
||||
|
||||
const THEMES: [&str; 3] = ["跟随系统", "浅色", "深色"];
|
||||
const THEMES: [&str; 3] = ["跟随系统(暂按浅色)", "浅色", "深色"];
|
||||
|
||||
/// 消息缓存条数的合法范围:太少不够回看,太多占内存
|
||||
const MAX_MESSAGES_MIN: usize = 100;
|
||||
const MAX_MESSAGES_MAX: usize = 100_000;
|
||||
|
||||
const SITE_URL: &str = "https://mqttx.app";
|
||||
const REPO_URL: &str = "https://gitea.heavenlybook.cn/JoeAllen/mqttx";
|
||||
@@ -66,14 +70,21 @@ impl SettingsDialog {
|
||||
Some(2) => ThemeModePref::Dark,
|
||||
_ => ThemeModePref::System,
|
||||
};
|
||||
// 显式校验范围而非静默钳制:越界直接报错,不落盘
|
||||
let max_messages = self
|
||||
.max_messages
|
||||
.read(cx)
|
||||
.value()
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.map_err(|_| "消息缓存条数必须是正整数".to_string())?
|
||||
.max(100);
|
||||
.map_err(|_| format!(
|
||||
"消息缓存条数需要是 {MAX_MESSAGES_MIN}~{MAX_MESSAGES_MAX} 的整数"
|
||||
))?;
|
||||
if !(MAX_MESSAGES_MIN..=MAX_MESSAGES_MAX).contains(&max_messages) {
|
||||
return Err(format!(
|
||||
"消息缓存条数需要是 {MAX_MESSAGES_MIN}~{MAX_MESSAGES_MAX} 的整数"
|
||||
));
|
||||
}
|
||||
// 四个字段全部显式写出,避免 ..Default::default() 把开关项重置为默认值
|
||||
Ok(AppSettings {
|
||||
theme,
|
||||
@@ -111,7 +122,7 @@ impl Render for SettingsDialog {
|
||||
.w(px(500.))
|
||||
.child(field("主题", Select::new(&self.theme)))
|
||||
.child(field(
|
||||
"每条连接内存中保留的消息条数",
|
||||
"每条连接内存中保留的消息条数(100~100000)",
|
||||
Input::new(&self.max_messages),
|
||||
))
|
||||
.child(
|
||||
@@ -130,7 +141,17 @@ impl Render for SettingsDialog {
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex().gap_1p5().items_center()
|
||||
.child(div().text_sm().child("自动检查更新"))
|
||||
// 更新通道尚未接入,先保留开关并明示状态
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("即将支持"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Switch::new("auto-check-update")
|
||||
.checked(self.auto_check_update)
|
||||
|
||||
@@ -86,13 +86,32 @@ pub fn make_select<C: gpui_kit::AppContext>(
|
||||
|
||||
// ─── 字段行 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 小号标签 + 控件的垂直字段容器。
|
||||
/// 小号标签 + 控件的垂直字段容器(可选字段,无校验态)。
|
||||
pub fn field(label: impl Into<SharedString>, control: impl IntoElement) -> impl IntoElement {
|
||||
field_ex(label, control, false, None, Hsla::default())
|
||||
}
|
||||
|
||||
/// 字段行变体:`required` 时标签后追加红色 `*`;
|
||||
/// `error` 为 Some 时在控件下方渲染红字提示(`danger` 为主题错误色)。
|
||||
pub fn field_ex(
|
||||
label: impl Into<SharedString>,
|
||||
control: impl IntoElement,
|
||||
required: bool,
|
||||
error: Option<SharedString>,
|
||||
danger: Hsla,
|
||||
) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.min_w(px(0.))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.items_baseline()
|
||||
.child(div().text_xs().child(label.into()))
|
||||
.children(required.then(|| div().text_xs().text_color(danger).child("*"))),
|
||||
)
|
||||
.child(control)
|
||||
.children(error.map(|e| div().text_xs().text_color(danger).child(e)))
|
||||
}
|
||||
|
||||
// ─── 状态颜色 ────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user