feat(ui): 侧栏/订阅面板/发布栏抽屉式折叠

- 连接侧栏:Ctrl+B 或把手按钮收起为 40px 窄 rail(展开把手 + 新建连接),
  状态随 ui-state.json 持久化;展开时标题行带收起按钮
- 订阅面板:标题行收起按钮,收起后左侧 32px rail 拉回
- 发布面板:工具条右端把手收起为细条(34px),细条上拉回;
  收起后全局 Ctrl+Enter 发送仍可用
This commit is contained in:
2026-09-30 16:36:59 +08:00
parent 1dca18873c
commit c05b258c5c
8 changed files with 185 additions and 17 deletions
+2 -1
View File
@@ -22,7 +22,8 @@
- **阿里云 IoT** — Token(GroupId@@@DeviceId + HMAC-SHA1 签名)与一机一密两种鉴权方式一键生成连接
- **连接测试** — 表单内握手测试(超时可配置),保存前验证连通性
- **日志面板** — 连接级事件日志(CONNACK/SUBACK/PUBACK/错误/重连等),按日落盘 `mqttx-YYYY-MM-DD.log`,设置内一键打开日志目录
- **快捷键** — `Ctrl+N` 新建连接、`Ctrl+,` 设置、`Ctrl+Shift+E` 导出、`Ctrl+Shift+I` 导入、`Ctrl+Shift+R` 资源监控、`Ctrl+Enter` 发送消息(全局生效)、`Enter` 提交订阅、`Esc` 退出编辑/清空输入
- **快捷键** — `Ctrl+N` 新建连接、`Ctrl+,` 设置、`Ctrl+Shift+E` 导出、`Ctrl+Shift+I` 导入、`Ctrl+Shift+R` 资源监控、`Ctrl+Enter` 发送消息(全局生效)、`Ctrl+B` 收起/展开连接侧栏、`Enter` 提交订阅、`Esc` 退出编辑/清空输入
- **抽屉式布局** — 连接侧栏(持久化,Ctrl+B)、订阅面板、底部发布面板均可一键收起为窄条抽屉,最大化消息流空间
- **主题** — Linear 风格配色(近黑/纯白扁平底 + 靛蓝 #5e6ad2 主色,14px 紧凑排版、6px 圆角),浅色 / 深色 / 跟随系统
- **设置** — 自动检查更新开关、消息缓存条数、时间戳毫秒、数据目录展示与打开、关于页(版本 / 许可)
- **本地持久化** — 连接、订阅、预设、变量、设置以 JSON 存于系统数据目录
+3
View File
@@ -229,6 +229,9 @@ pub struct UiState {
pub open_tabs: Vec<String>,
#[serde(default)]
pub active_tab: Option<String>,
/// 连接侧栏抽屉是否收起(抽屉式折叠,Ctrl+B 切换)
#[serde(default)]
pub sidebar_collapsed: bool,
}
impl Default for Storage {
+8
View File
@@ -106,9 +106,17 @@ pub(super) fn close_tab(&mut self, id: &str, cx: &mut Context<Self>) {
self.storage.save_ui_state(&UiState {
open_tabs: self.open_tabs.clone(),
active_tab: self.active_tab.clone(),
sidebar_collapsed: self.sidebar_collapsed,
});
}
/// 收起/展开连接侧栏抽屉(Ctrl+B 或 rail/标题栏按钮),状态随 ui-state 持久化。
pub fn toggle_sidebar(&mut self, cx: &mut Context<Self>) {
self.sidebar_collapsed = !self.sidebar_collapsed;
self.persist_ui_state();
cx.notify();
}
// ── 订阅 ──────────────────────────────────────────────────────────────
pub fn add_subscription(&mut self, sub: Subscription) {
+19 -3
View File
@@ -48,7 +48,7 @@ const MAX_LOGS: usize = 3000;
const EVENT_QUEUE_CAPACITY: usize = 1024;
// 应用级快捷键动作(context=None:任意焦点状态下都匹配)。
gpui_kit::actions!(mqttx, [NewConnection, OpenSettings, ExportConnections, ImportConnections, OpenResourceMonitor, PublishMessage]);
gpui_kit::actions!(mqttx, [NewConnection, OpenSettings, ExportConnections, ImportConnections, OpenResourceMonitor, PublishMessage, ToggleSidebar]);
/// 侧边栏分组过滤;chips 只做列表过滤,分组重命名/删除通过编辑连接的分组字段完成。
#[derive(Clone, PartialEq, Eq)]
@@ -80,7 +80,6 @@ pub struct MqttXApp {
storage: Storage,
pub engine: std::sync::Arc<MqttEngine>,
_pump: gpui_kit::Task<()>,
// ── 持久化数据 ──
pub connections: Vec<ConnectionConfig>,
pub subscriptions: Vec<Subscription>,
@@ -115,6 +114,8 @@ pub struct MqttXApp {
_appearance_obs: gpui_kit::Subscription,
/// 窗口置顶状态(标题栏钉子按钮切换)
pinned: bool,
/// 连接侧栏抽屉收起状态(Ctrl+B / rail 按钮切换,随 ui-state 持久化)
sidebar_collapsed: bool,
}
@@ -321,6 +322,7 @@ impl MqttXApp {
KeyBinding::new("ctrl-shift-i", ImportConnections, None),
KeyBinding::new("ctrl-shift-r", OpenResourceMonitor, None),
KeyBinding::new("ctrl-enter", PublishMessage, None),
KeyBinding::new("ctrl-b", ToggleSidebar, None),
]);
let weak = cx.entity().downgrade();
// Context 上有同名 on_action(绘制期注册),这里必须走 App 的全局注册
@@ -392,6 +394,14 @@ impl MqttXApp {
});
});
});
// Ctrl+B 抽屉式收起/展开连接侧栏
let weak = cx.entity().downgrade();
App::on_action::<ToggleSidebar>(cx, move |_, cx| {
let w = weak.clone();
run_on_active_window(cx, w, |entity, _, cx| {
entity.update(cx, |app, cx| app.toggle_sidebar(cx));
});
});
Self {
storage,
@@ -419,6 +429,7 @@ impl MqttXApp {
log_file: None,
_appearance_obs: appearance_obs,
pinned: false,
sidebar_collapsed: ui_state.sidebar_collapsed,
}
}
@@ -438,7 +449,12 @@ impl Render for MqttXApp {
h_flex()
.flex_1()
.min_h(px(0.))
.child(self.render_sidebar(window, cx))
// 连接侧栏抽屉:收起时只剩窄 rail(Ctrl+B 或 rail 按钮拉回)
.child(if self.sidebar_collapsed {
self.render_sidebar_rail(cx).into_any_element()
} else {
self.render_sidebar(window, cx).into_any_element()
})
.child(self.render_main(window, cx)),
)
}
+47 -2
View File
@@ -15,8 +15,39 @@ use crate::ui::widgets::status_color;
use super::*;
impl MqttXApp {
pub(super) fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let query = self.search.read(cx).value().to_lowercase();
/// 侧栏收起后的窄 rail:抽屉把手(拉回侧栏)+ 新建连接入口。
pub(super) fn render_sidebar_rail(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.w(px(40.))
.h_full()
.flex_shrink_0()
.items_center()
.pt_2()
.gap_1()
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.border_r_1()
.border_color(cx.theme().border)
.child(
Button::new("sidebar-rail-expand")
.icon(IconName::PanelLeftOpen)
.ghost()
.small()
.tooltip("展开连接侧栏(Ctrl+B)")
.on_click(cx.listener(|this, _, _, cx| this.toggle_sidebar(cx))),
)
.child(
Button::new("sidebar-rail-add")
.icon(IconName::Plus)
.ghost()
.small()
.tooltip("新建连接")
.on_click(cx.listener(|this, _, window, cx| {
this.open_connection_form(None, window, cx);
})),
)
}
pub(super) 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)
@@ -185,6 +216,19 @@ pub(super) fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context<S
.child(total.to_string()),
),
)
.child(
h_flex()
.gap_0p5()
.child(
Button::new("sidebar-collapse")
.icon(IconName::PanelLeftClose)
.ghost()
.xsmall()
.tooltip("收起侧栏(Ctrl+B)")
.on_click(cx.listener(|this, _, _, cx| {
this.toggle_sidebar(cx)
})),
)
.child(
Button::new("sidebar-add")
.icon(IconName::Plus)
@@ -195,6 +239,7 @@ pub(super) fn render_sidebar(&mut self, _window: &mut Window, cx: &mut Context<S
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(),
+17 -2
View File
@@ -180,6 +180,10 @@ pub struct ConnectionView {
detail_format: DetailFormat,
/// 消息流暂停滚动:消息照常入环形缓冲,仅不再触发本视图重绘
paused: bool,
/// 订阅面板抽屉收起状态(会话内有效)
subs_collapsed: bool,
/// 底部发布栏抽屉收起状态(会话内有效)
publish_collapsed: bool,
// 预设
preset_name: Entity<InputState>,
@@ -257,6 +261,8 @@ impl ConnectionView {
msg_dir: DirFilter::All,
detail_format: DetailFormat::Auto,
paused: false,
subs_collapsed: false,
publish_collapsed: false,
preset_name,
show_vars: true,
var_rows: Vec::new(),
@@ -299,7 +305,12 @@ impl Render for ConnectionView {
h_flex()
.flex_1()
.min_h(px(0.))
.child(self.render_subscriptions(cx))
// 订阅面板抽屉:收起时只剩窄 rail
.child(if self.subs_collapsed {
self.render_subs_rail(cx).into_any_element()
} else {
self.render_subscriptions(cx).into_any_element()
})
.child(
v_flex()
.flex_1()
@@ -334,7 +345,11 @@ impl Render for ConnectionView {
}),
),
)
.child(self.render_publish_bar(cx))
.child(if self.publish_collapsed {
self.render_publish_rail(cx).into_any_element()
} else {
self.render_publish_bar(cx).into_any_element()
})
}
}
@@ -18,6 +18,37 @@ use crate::ui::widgets::{ field, KvEditor };
use super::*;
impl ConnectionView {
/// 发布栏收起后的细条:抽屉把手。
pub(super) fn render_publish_rail(&self, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.w_full()
.h(px(34.))
.px_3()
.items_center()
.gap_2()
.flex_shrink_0()
.border_t_1()
.border_color(cx.theme().border)
.bg(cx.theme().secondary)
.child(
Button::new("pub-rail-expand")
.icon(IconName::PanelBottomOpen)
.ghost()
.small()
.tooltip("展开发布面板")
.on_click(cx.listener(|this, _, _, cx| {
this.publish_collapsed = false;
cx.notify();
})),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("发布面板已收起"),
)
}
/// 发布当前面板参数。入口有三:发送按钮、发布主题框内 Enter、
/// 全局 Ctrl+Enter action(app/mod.rs 注册)。
pub(crate) fn do_publish(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -295,6 +326,18 @@ pub(super) fn render_publish_bar(&mut self, cx: &mut Context<Self>) -> impl Into
.disabled(!connected)
.when(!connected, |b| b.tooltip("未连接,无法发送"))
.on_click(cx.listener(|this, _, window, cx| this.do_publish(window, cx))),
)
// 发布栏抽屉把手:收起整个底部面板
.child(
Button::new("pub-collapse")
.icon(IconName::PanelBottom)
.ghost()
.small()
.tooltip("收起发布面板")
.on_click(cx.listener(|this, _, _, cx| {
this.publish_collapsed = true;
cx.notify();
})),
),
);
+38 -1
View File
@@ -17,6 +17,31 @@ use crate::ui::widgets::field;
use super::*;
impl ConnectionView {
/// 订阅面板收起后的窄 rail:抽屉把手。
pub(super) fn render_subs_rail(&self, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.w(px(32.))
.h_full()
.flex_shrink_0()
.items_center()
.pt_2()
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.border_r_1()
.border_color(cx.theme().border)
.child(
Button::new("subs-rail-expand")
.icon(IconName::PanelLeftOpen)
.ghost()
.small()
.tooltip("展开订阅面板")
.on_click(cx.listener(|this, _, _, cx| {
this.subs_collapsed = false;
cx.notify();
})),
)
}
/// 订阅按 (连接, 主题) 唯一:先摘掉旧条目再走 `add_subscription`,
/// 借它入列 + `save_subscriptions` 落盘的逻辑完成 upsert(storage 字段是私有的)。
/// 落盘顺序以 add 时为准,入列后移回原位以保持界面顺序稳定。
@@ -743,7 +768,19 @@ pub(super) fn render_subscriptions(&self, cx: &mut Context<Self>) -> impl IntoEl
.h_11()
.px_3()
.items_center()
.child(div().text_sm().font_semibold().child("订阅")),
.justify_between()
.child(div().text_sm().font_semibold().child("订阅"))
.child(
Button::new("subs-collapse")
.icon(IconName::PanelLeftClose)
.ghost()
.xsmall()
.tooltip("收起订阅面板")
.on_click(cx.listener(|this, _, _, cx| {
this.subs_collapsed = true;
cx.notify();
})),
),
)
.child(div().flex_1().overflow_y_scrollbar().px_2().child(list))
}