Files
mqttx/gpui-app/src/ui/connection_view/subscribe.rs
T
JoeAllen 967bf9dd78 feat(ui): 界面体验一轮打磨(快捷键/暂停滚动/会话恢复/JSON 预览)
- 全局 Ctrl+Enter 发送;订阅框 Enter 提交、Esc 退出编辑;过滤框 Esc 清空
- 消息流新增「暂停/恢复」:暂停时画面不随新消息跳动,消息仍入环形缓冲
- JSON 负载折叠态预览按原始数据美化展示(多行缩进,超 40 行兜底截断)
- 标签页持久化(ui-state.json),重启恢复工作区;无可恢复标签时自动打开
  首个自连连接的工作区,避免「已连上却演空态」
- 侧边栏 Error 连接行内红字显示失败原因;侧边栏/订阅面板补竖分隔线
- 消息订阅色条上下留隙,不再连成一整条竖线
- 发布被当前过滤条件隐藏时给出「已发送」提示
- 连接表单:主机+端口同行;TLS 证书三个路径字段加「浏览…」(rfd)
- 主题切换/系统外观变化后强制整窗重绘,修未收敛区域的旧色残影
- 设置主题文案改「跟随系统」(跟随逻辑此前已实现)
2026-09-30 16:15:58 +08:00

751 lines
32 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 gpui_kit::component::button::{ Button };
use gpui_kit::component::input::Input;
use gpui_kit::component::menu::{ PopupMenuItem };
use gpui_kit::component::select::Select;
use gpui_kit::component::switch::Switch;
use gpui_kit::component::{ h_flex, notification::Notification, v_flex, };
use crate::ui::IconName;
use gpui_kit::{ div, hsla, px, Context, IntoElement, SharedString, Window, };
use gpui_kit::component::IndexPath;
use crate::model::{ SubscribeOptions, Subscription, };
use crate::ui::widgets::field;
use super::*;
impl ConnectionView {
/// 订阅按 (连接, 主题) 唯一:先摘掉旧条目再走 `add_subscription`,
/// 借它入列 + `save_subscriptions` 落盘的逻辑完成 upsert(storage 字段是私有的)。
/// 落盘顺序以 add 时为准,入列后移回原位以保持界面顺序稳定。
fn upsert_subscription(&self, sub: Subscription, cx: &mut Context<Self>) {
let Some(app_entity) = self.app.upgrade() else {
return;
};
app_entity.update(cx, |app, cx| {
let slot = app
.subscriptions
.iter()
.position(|s| s.connection_id == sub.connection_id && s.topic == sub.topic);
app.subscriptions.retain(|s| {
!(s.connection_id == sub.connection_id && s.topic == sub.topic)
});
let new_id = sub.id.clone();
app.add_subscription(sub);
if let Some(pos) = slot
&& let Some(from) = app.subscriptions.iter().position(|s| s.id == new_id)
{
let moved = app.subscriptions.remove(from);
app.subscriptions.insert(pos.min(app.subscriptions.len()), moved);
}
cx.notify();
});
cx.notify();
}
fn set_subscription_color(&mut self, sub_id: &str, hue: Option<f32>, cx: &mut Context<Self>) {
let sub = self
.with_app(cx, |app| {
app.subscriptions
.iter()
.find(|s| s.id == sub_id)
.cloned()
})
.flatten();
if let Some(mut sub) = sub {
sub.color = hue;
self.upsert_subscription(sub, cx);
}
}
/// 启停订阅:置灰/恢复行显示,并在已连接时对引擎退订/重订。
fn toggle_subscription_enabled(&mut self, sub_id: &str, cx: &mut Context<Self>) {
let Some(mut sub) = self
.with_app(cx, |app| {
app.subscriptions
.iter()
.find(|s| s.id == sub_id)
.cloned()
})
.flatten()
else {
return;
};
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) {
if enabled {
self.engine
.subscribe_with_options(conn, sub.clone(), SubscribeOptions::from(&sub));
} else {
self.engine.unsubscribe(conn, topic);
}
}
}
/// 回填输入区进入编辑态;旧订阅保留到提交时才替换,中途放弃不丢数据。
fn start_edit_subscription(&mut self, sub: &Subscription, window: &mut Window, cx: &mut Context<Self>) {
self.editing = Some(sub.topic.clone());
self.sub_topic
.update(cx, |s, cx| s.set_value(sub.topic.clone(), window, cx));
let qos_idx = (sub.qos.min(2)) as usize;
self.sub_qos.update(cx, |s, cx| {
s.set_selected_index(Some(IndexPath::new(qos_idx)), window, cx)
});
let alias = sub.alias.clone().unwrap_or_default();
self.sub_alias
.update(cx, |s, cx| s.set_value(alias, window, cx));
let ident = sub
.sub_identifier
.map(|v| v.to_string())
.unwrap_or_default();
self.sub_identifier
.update(cx, |s, cx| s.set_value(ident, window, cx));
self.sub_no_local = sub.no_local;
self.sub_rap = sub.retain_as_published;
let rh_idx = sub.retain_handling.min(2) as usize;
self.sub_retain_handling.update(cx, |s, cx| {
s.set_selected_index(Some(IndexPath::new(rh_idx)), window, cx)
});
self.sub_show_advanced = true;
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>) {
let raw = self.sub_topic.read(cx).value().to_string();
let topics = split_topic_list(&raw);
if topics.is_empty() {
window.push_notification(Notification::warning("订阅主题不能为空"), cx);
return;
}
let qos = self.sub_qos.read(cx).selected_value().copied().unwrap_or(0) as u8;
let alias_raw = self.sub_alias.read(cx).value().to_string();
let alias = {
let t = alias_raw.trim();
(!t.is_empty()).then(|| t.to_string())
};
let is_v5 = self.is_v5(cx);
// v5 选项只对 v5 连接生效,v4 下不读取(避免无效输入阻断订阅)
let mut sub_identifier = None;
if is_v5 {
let ident = self.sub_identifier.read(cx).value().to_string();
let t = ident.trim();
if !t.is_empty() {
match t.parse::<u32>() {
Ok(v) if v > 0 => sub_identifier = Some(v),
_ => {
window.push_notification(
Notification::warning("订阅标识符须为正整数"),
cx,
);
return;
}
}
}
}
let no_local = self.sub_no_local;
let rap = self.sub_rap;
let retain_handling = self
.sub_retain_handling
.read(cx)
.selected_value()
.copied()
.unwrap_or(0) as u8;
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)
{
if self.sub_filter.as_deref() == Some(old_topic.as_str()) {
self.sub_filter = None;
}
if let Some(app_entity) = self.app.upgrade() {
app_entity.update(cx, |app, cx| {
app.remove_subscription(&conn, &old_topic);
cx.notify();
});
}
}
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;
sub.no_local = no_local;
sub.retain_as_published = rap;
sub.retain_handling = retain_handling;
}
sub
}
}
};
// 继承到的停用订阅保持退订状态,不因重新提交被拉起
let keep_disabled = !sub.enabled;
self.upsert_subscription(sub.clone(), cx);
if connected && !keep_disabled {
self.engine.subscribe_with_options(
conn.clone(),
sub.clone(),
SubscribeOptions::from(&sub),
);
}
}
if !connected {
let hint = if self
.config(cx)
.map(|c| c.auto_resubscribe)
.unwrap_or(true)
{
"未连接:订阅已保存,连接后自动恢复"
} else {
"未连接:订阅已保存"
};
window.push_notification(Notification::warning(hint), 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();
}
pub(super) fn render_subscribe_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let sub_topic = self.sub_topic.clone();
let sub_qos = self.sub_qos.clone();
let sub_alias = self.sub_alias.clone();
let is_v5 = self.is_v5(cx);
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()
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().secondary);
bar = bar.child(
h_flex()
.gap_2()
.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()
.id(SharedString::from(format!("sub-topic-wrap-{}", self.conn_id)))
.flex_1()
.min_w(px(0.))
// Enter 直接提交订阅;Esc 退出编辑态或清空输入,
// 调试时不必为每次订阅伸手点按钮
.on_key_down(cx.listener(
|this, ev: &gpui_kit::KeyDownEvent, window, cx| {
if ev.keystroke.key == "enter"
&& !ev.keystroke.modifiers.control
&& !ev.keystroke.modifiers.alt
{
cx.stop_propagation();
this.do_subscribe(window, cx);
} else if ev.keystroke.key == "escape" {
cx.stop_propagation();
if this.editing.is_some() {
this.editing = None;
this.reset_subscribe_form(window, cx);
} else {
this.sub_topic
.update(cx, |s, cx| s.set_value("", window, cx));
}
cx.notify();
}
},
))
.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()))
.when(is_v5, |h| {
h.child(
Button::new(SharedString::from(format!("sub-adv-{}", self.conn_id)))
.icon(IconName::SlidersHorizontal)
.label("高级")
.ghost()
.small()
.when(show_advanced, |b| b.selected(true))
.tooltip("MQTT 5 订阅选项")
.on_click(cx.listener(|this, _, _, cx| {
this.sub_show_advanced = !this.sub_show_advanced;
cx.notify();
})),
)
})
.child(
Button::new(SharedString::from(format!("subscribe-{}", self.conn_id)))
.icon(if editing { IconName::Check } else { IconName::Plus })
.label(if editing { "更新" } else { "订阅" })
// 编辑用 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))),
)
.when(editing, |h| {
h.child(
Button::new(SharedString::from(format!("sub-edit-cancel-{}", self.conn_id)))
.icon(IconName::Close)
.ghost()
.small()
.tooltip("取消编辑")
.on_click(cx.listener(|this, _, window, cx| {
this.editing = None;
// 清空回填到输入框的主题/别名/高级字段,回到新增态
this.reset_subscribe_form(window, cx);
cx.notify();
})),
)
}),
);
if show_advanced {
let sub_identifier = self.sub_identifier.clone();
let sub_no_local = self.sub_no_local;
let sub_rap = self.sub_rap;
let sub_retain_handling = self.sub_retain_handling.clone();
bar = bar.child(
h_flex()
.gap_4()
.px_3()
.pb_2()
.items_start()
.child(
div()
.w(px(150.))
.child(field("订阅标识符", Input::new(&sub_identifier).small())),
)
.child(
h_flex()
.gap_1()
.items_center()
.pt_4()
.child(Switch::new("sub-no-local").checked(sub_no_local).on_change(
cx.listener(|this, v, _, cx| {
this.sub_no_local = *v;
cx.notify();
}),
))
.child(div().text_xs().child("No Local")),
)
.child(
h_flex()
.gap_1()
.items_center()
.pt_4()
.child(Switch::new("sub-rap").checked(sub_rap).on_change(
cx.listener(|this, v, _, cx| {
this.sub_rap = *v;
cx.notify();
}),
))
.child(div().text_xs().child("Retain As Published")),
)
.child(
div().w(px(150.)).child(field(
"Retain Handling",
Select::new(&sub_retain_handling).small(),
)),
),
);
}
bar
}
pub(super) fn render_subscriptions(&self, cx: &mut Context<Self>) -> impl IntoElement {
let hover_bg = cx.theme().muted;
let muted_fg = cx.theme().muted_foreground;
let conn = self.conn_id.clone();
let subs: Vec<Subscription> = self
.with_app(cx, |app| {
app.subscriptions
.iter()
.filter(|s| s.connection_id == conn)
.cloned()
.collect()
})
.unwrap_or_default();
let active_filter = self.sub_filter.clone();
let mut list = v_flex().gap_0p5();
if subs.is_empty() {
list = list.child(
v_flex()
.p_3()
.gap_2()
.items_center()
.child(
gpui_kit::component::Icon::new(IconName::Hash)
.text_color(cx.theme().muted_foreground),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.text_center()
.child("订阅主题后,消息会显示在右侧"),
),
);
}
for sub in subs {
let enabled = sub.enabled;
let is_active = enabled && active_filter.as_deref() == Some(sub.topic.as_str());
let topic = sub.topic.clone();
let sub_id = sub.id.clone();
let alias = sub.alias.clone().filter(|a| !a.trim().is_empty());
let primary = alias.clone().unwrap_or_else(|| topic.clone());
// 颜色按钮:已设色显示色点,未设显示灰色「+」
let color_btn = {
let weak_menu = cx.weak_entity();
let sid = sub_id.clone();
let trigger_id = SharedString::from(format!("sub-color-{}", sub_id));
let trigger = if let Some(hue) = sub.color {
Button::new(trigger_id)
.ghost()
.xsmall()
.tooltip("订阅颜色")
.child(
div()
.size(px(11.))
.rounded_full()
.bg(hue_color(hue))
.border_1()
.border_color(cx.theme().border),
)
} else {
Button::new(trigger_id)
.ghost()
.xsmall()
.tooltip("订阅颜色")
.child(
div()
.size(px(11.))
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(cx.theme().muted)
.text_color(muted_fg)
.text_xs()
.child("+"),
)
};
trigger.dropdown_menu(move |mut menu, _, _| {
for (hue, name) in PRESET_HUES {
let weak = weak_menu.clone();
let sid = sid.clone();
let swatch = hue_color(hue);
// 菜单只显示颜色名,角度数值对用户没有意义
let label = SharedString::from(name);
menu = menu.item(
PopupMenuItem::element(move |_, _| {
h_flex()
.gap_2()
.items_center()
.child(
div()
.size(px(10.))
.rounded_full()
.bg(swatch)
.border_1()
.border_color(hsla(0., 0., 1., 0.6)),
)
.child(div().text_sm().child(label.clone()))
})
.on_click(move |_, _, cx| {
weak
.update(cx, |view, cx| {
view.set_subscription_color(&sid, Some(hue), cx);
})
.ok();
}),
);
}
menu = menu.separator();
let weak_rand = weak_menu.clone();
let sid_rand = sid.clone();
menu = menu.item(PopupMenuItem::new("随机颜色").on_click(move |_, _, cx| {
let hue = (uuid::Uuid::new_v4().as_u128() % 360) as f32;
weak_rand
.update(cx, |view, cx| {
view.set_subscription_color(&sid_rand, Some(hue), cx);
})
.ok();
}));
let weak_clear = weak_menu.clone();
let sid_clear = sid.clone();
menu = menu.item(PopupMenuItem::new("清除颜色").on_click(move |_, _, cx| {
weak_clear
.update(cx, |view, cx| {
view.set_subscription_color(&sid_clear, None, cx);
})
.ok();
}));
menu
})
};
let eye_sid = sub_id.clone();
let eye_btn = Button::new(SharedString::from(format!("sub-eye-{}", sub_id)))
.icon(if enabled { IconName::Eye } else { IconName::EyeOff })
.ghost()
.xsmall()
.tooltip(if enabled { "停用订阅" } else { "启用订阅" })
.on_click(cx.listener(move |this, _, _, cx| {
this.toggle_subscription_enabled(&eye_sid, cx);
}));
let edit_sid = sub_id.clone();
let edit_btn = Button::new(SharedString::from(format!("sub-edit-{}", sub_id)))
.icon(IconName::Pencil)
.ghost()
.xsmall()
.tooltip("编辑订阅")
.on_click(cx.listener(move |this, _, window, cx| {
let sub = this
.with_app(cx, |app| {
app.subscriptions
.iter()
.find(|s| s.id == edit_sid)
.cloned()
})
.flatten();
if let Some(sub) = sub {
this.start_edit_subscription(&sub, window, cx);
}
}));
let del_cid = self.conn_id.clone();
let del_topic = topic.clone();
let del_btn = Button::new(SharedString::from(format!("sub-del-{}", sub_id)))
.icon(IconName::Close)
.ghost()
.xsmall()
.tooltip("取消订阅")
.on_click(cx.listener(move |this, _, _, cx| {
// 若删除的是当前过滤主题,一并清掉消息过滤
if this.sub_filter.as_deref() == Some(del_topic.as_str()) {
this.sub_filter = None;
}
if let Some(app) = this.app.upgrade() {
app.update(cx, |app, cx| {
app.remove_subscription(&del_cid, &del_topic);
cx.notify();
});
}
// 只有删除的是正在编辑的主题才退出编辑态,编辑别的不受影响
if this.editing.as_deref() == Some(del_topic.as_str()) {
this.editing = None;
}
cx.notify();
}));
// 点击区(非按钮):切换按该主题过滤消息流;禁用的订阅不参与
let filter_topic = topic.clone();
let click_zone = h_flex()
.id(SharedString::from(format!("sub-click-{}", sub_id)))
.flex_1()
.min_w(px(0.))
.when(enabled, |z| {
z.cursor_pointer().on_click(cx.listener(
move |this, _, _, cx| {
this.sub_filter = if this.sub_filter.as_deref()
== Some(filter_topic.as_str())
{
None
} else {
Some(filter_topic.clone())
};
cx.notify();
},
))
})
.child(
v_flex()
.flex_1()
.min_w(px(0.))
.gap_0p5()
.child(
div()
.text_sm()
.overflow_hidden()
.text_ellipsis()
.when(!enabled, |t| t.text_color(muted_fg))
.child(primary),
)
.when_some(alias, |v, _| {
v.child(
div()
.text_xs()
.text_color(muted_fg)
.overflow_hidden()
.text_ellipsis()
.child(topic.clone()),
)
}),
);
list = list.child(
h_flex()
.id(SharedString::from(format!("sub-{}-{}", self.conn_id, sub.id)))
.gap_1p5()
.items_center()
.px_2()
.py_1p5()
.rounded_md()
.when(is_active, |t| t.bg(cx.theme().secondary))
.when(!is_active, |t| t.hover(move |this| this.bg(hover_bg)))
.when(!enabled, |t| t.opacity(0.55))
.child(color_btn)
.child(click_zone)
.child(
div()
.text_xs()
.px_1()
.rounded_sm()
.bg(cx.theme().muted)
.text_color(muted_fg)
.child(format!("Q{}", sub.qos)),
)
.child(eye_btn)
.child(edit_btn)
.child(del_btn),
);
}
v_flex()
.w(px(248.))
.flex_shrink_0()
.h_full()
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
// 深色主题下订阅面板与消息区同色,补一条分隔线保持边界可辨
.border_r_1()
.border_color(cx.theme().border)
.child(
h_flex()
.h_11()
.px_3()
.items_center()
.child(div().text_sm().font_semibold().child("订阅")),
)
.child(div().flex_1().overflow_y_scrollbar().px_2().child(list))
}
}