diff --git a/gpui-app/src/ui/connection_view.rs b/gpui-app/src/ui/connection_view.rs index dc3fbd4..316fbe9 100644 --- a/gpui-app/src/ui/connection_view.rs +++ b/gpui-app/src/ui/connection_view.rs @@ -5,7 +5,7 @@ use std::sync::Arc; 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::input::{Input, InputEvent, InputState, Textarea, TextareaState}; use gpui_kit::component::menu::{DropdownMenu as _, PopupMenuItem}; use gpui_kit::component::select::{Select, SelectState}; use gpui_kit::component::switch::Switch; @@ -19,13 +19,13 @@ use crate::ui::IconName; use gpui_kit::{ div, hsla, px, App, AppContext as _, ClipboardItem, Context, Entity, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, - StatefulInteractiveElement as _, Styled as _, Window, + StatefulInteractiveElement as _, Styled as _, Subscription as InputSubscription, Window, }; use gpui_kit::component::IndexPath; use crate::model::{ - render_template, render_will_templates, ConnectionConfig, ConnectionStatus, Direction, LogLevel, MqttRecord, - PayloadFormat, PublishParams, SubscribeOptions, Subscription, + render_template, render_will_templates, ConnectionConfig, ConnectionStatus, Direction, GlobalVariable, + LogLevel, MqttRecord, PayloadFormat, PublishParams, SubscribeOptions, Subscription, }; use crate::mqtt::MqttEngine; use crate::ui::app::MqttXApp; @@ -176,6 +176,12 @@ pub struct ConnectionView { // 预设 preset_name: Entity, + + // 发布面板内联变量绑定 + /// 「变量」小节展开状态(仅在提取到占位符时渲染整节) + show_vars: bool, + /// 提取到的 `{{key}}` → 值输入框与变更订阅,按提取顺序排列 + var_rows: Vec<(String, Entity, InputSubscription)>, } impl ConnectionView { @@ -244,6 +250,8 @@ impl ConnectionView { msg_dir: DirFilter::All, detail_format: DetailFormat::Auto, preset_name, + show_vars: true, + var_rows: Vec::new(), } } @@ -1862,11 +1870,18 @@ impl ConnectionView { let user_props = self.user_props.clone(); let retain = self.retain; let show_props = self.show_props; + let show_vars = self.show_vars; let is_v5 = self.is_v5(cx); // 发布预设 let presets = self.with_app(cx, |app| app.presets.clone()).unwrap_or_default(); let connected = self.engine.is_connected(&self.conn_id); + // 当前提取到的占位符数量(仅用于提示,行构建在 sync_var_rows 完成) + let var_count = extract_placeholder_keys( + &self.pub_topic.read(cx).value(), + &self.payload.read(cx).value(), + ) + .len(); let mut card = v_flex() .w_full() @@ -1900,6 +1915,28 @@ impl ConnectionView { .child(div().w(px(92.)).child(Select::new(&pub_qos).small())) .child(div().w(px(124.)).child(Select::new(&payload_format).small())) .child(self.render_preset_menu(presets, cx)) + .child({ + // 变量注入开关:展开/收起内联绑定面板 + let count = var_count; + Button::new("vars-toggle") + .icon(IconName::Variable) + .ghost() + .small() + .when(show_vars, |b| b.selected(true)) + .tooltip(if count > 0 { + format!("变量注入({count} 个占位符)") + } else { + "变量注入".to_string() + }) + .on_click(cx.listener(|this, _, window, cx| { + if this.show_vars { + this.show_vars = false; + cx.notify(); + } else { + this.sync_var_rows(window, cx); + } + })) + }) .child( h_flex() .gap_1() @@ -1960,6 +1997,11 @@ impl ConnectionView { .child(Textarea::new(&payload)), ); + // 内联变量绑定面板:仅在展开且已提取到占位符时渲染 + if show_vars && !self.var_rows.is_empty() { + card = card.child(self.render_vars_panel(cx)); + } + if show_props && is_v5 { card = card.child( v_flex() @@ -2000,14 +2042,160 @@ impl ConnectionView { card } + /// 按当前 topic/payload 重新提取 `{{key}}`,增删变量输入行。 + /// 输入框创建需要 Window,因此只能在按钮回调里调用(无法在 render 中同步)。 + fn sync_var_rows(&mut self, window: &mut Window, cx: &mut Context) { + let topic = self.pub_topic.read(cx).value().to_string(); + let payload = self.payload.read(cx).value().to_string(); + let keys = extract_placeholder_keys(&topic, &payload); + if keys.is_empty() { + self.show_vars = false; + window.push_notification( + Notification::warning("主题/负载中没有 {{变量}} 引用"), + cx, + ); + cx.notify(); + return; + } + let vars = self.with_app(cx, |a| a.variables.clone()).unwrap_or_default(); + // 移除已不存在的 key(保留仍存在的行,避免丢未保存的输入) + self.var_rows.retain(|(k, _, _)| keys.contains(k)); + for key in keys { + if self.var_rows.iter().any(|(k, ..)| k == &key) { + continue; + } + let initial = vars + .iter() + .find(|v| v.key == key) + .map(|v| v.value.clone()) + .unwrap_or_default(); + let input = cx.new(|cx| { + let mut s = InputState::new(window, cx).placeholder("变量值"); + // set_value 不会触发 InputEvent::Change,不会误写回 + if !initial.is_empty() { + s.set_value(initial.as_str(), window, cx); + } + s + }); + // 编辑即写回全局变量(含持久化),预览与发布随之生效 + let key_for_sub = key.clone(); + let sub = cx.subscribe(&input, move |this, entity, ev: &InputEvent, cx| { + if !matches!(ev, InputEvent::Change) { + return; + } + let val = entity.read(cx).value().to_string(); + let key = key_for_sub.clone(); + let Some(app) = this.app.upgrade() else { + return; + }; + app.update(cx, |a, cx| { + match a.variables.iter_mut().find(|v| v.key == key) { + Some(v) => { + if v.value == val { + return; + } + v.value = val; + } + None => a.variables.push(GlobalVariable { key, value: val }), + } + let snapshot = a.variables.clone(); + a.save_variables(snapshot); + cx.notify(); + }); + }); + self.var_rows.push((key, input, sub)); + } + self.show_vars = true; + cx.notify(); + } + + /// 变量绑定面板:逐 key 编辑 + 实时渲染预览。 + fn render_vars_panel(&self, cx: &mut Context) -> impl IntoElement { + let vars = self.with_app(cx, |a| a.variables.clone()).unwrap_or_default(); + let topic_raw = self.pub_topic.read(cx).value().to_string(); + let payload_raw = self.payload.read(cx).value().to_string(); + let topic_rendered = render_template(&topic_raw, &vars); + let payload_rendered = render_template(&payload_raw, &vars); + let payload_preview: String = payload_rendered.chars().take(80).collect(); + let muted = cx.theme().muted_foreground; + + let mut rows = v_flex().gap_1(); + for (key, input, _) in &self.var_rows { + rows = rows.child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .w(px(160.)) + .min_w(px(0.)) + .text_xs() + .text_color(muted) + .child(format!("{{{{{key}}}}}")), + ) + .child(div().flex_1().min_w(px(0.)).child(Input::new(input).small())), + ); + } + + v_flex() + .gap_2() + .w_full() + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .text_xs() + .font_semibold() + .child(format!("变量({})", self.var_rows.len())), + ) + .child(div().flex_1()) + .child( + Button::new("vars-refresh") + .icon(IconName::RefreshCw) + .ghost() + .xsmall() + .tooltip("重新提取占位符") + .on_click(cx.listener(|this, _, window, cx| { + this.sync_var_rows(window, cx); + })), + ) + .child( + Button::new("vars-collapse") + .icon(IconName::ChevronUp) + .ghost() + .xsmall() + .tooltip("收起") + .on_click(cx.listener(|this, _, _, cx| { + this.show_vars = false; + cx.notify(); + })), + ), + ) + .child(rows) + .child( + div() + .text_xs() + .text_color(muted) + .child("内置:{{$ts}}(秒) {{$ts_ms}}(毫秒) {{$uuid}} · 发布时注入"), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child(format!("预览:{topic_rendered} | {payload_preview}")), + ) + } + /// 发布预设下拉:应用已有预设、保存当前、删除预设。 - fn render_preset_menu( - &self, + fn render_preset_menu( &self, presets: Vec, cx: &mut Context, ) -> impl IntoElement { let weak_view = cx.weak_entity(); let preset_name = self.preset_name.clone(); + let app_weak = self.app.clone(); let has = !presets.is_empty(); Button::new(SharedString::from(format!("preset-menu-{}", self.conn_id))) .icon(IconName::Bookmark) @@ -2046,6 +2234,15 @@ impl ConnectionView { } }, )); + // 打开预设管理对话框(新建 / 编辑 / 删除) + let manage_app = app_weak.clone(); + menu = menu.item(PopupMenuItem::new("管理预设…").on_click( + move |_ev, window, cx| { + if let Some(app) = manage_app.upgrade() { + crate::ui::presets_dialog::open(app, window, cx); + } + }, + )); // 删除子菜单 if has { let weak_del = weak_view.clone(); @@ -2306,6 +2503,34 @@ fn split_topic_list(raw: &str) -> Vec { out } +/// 从发布主题与负载中提取 `{{key}}` 占位符的 key 列表: +/// - key 两侧空白去除(trim); +/// - `$` 开头的内置变量(`$ts` / `$ts_ms` / `$uuid`)不提取; +/// - 重复 key 去重,保持首次出现顺序(主题在前、负载在后)。 +fn extract_placeholder_keys(topic: &str, payload: &str) -> Vec { + let mut out: Vec = Vec::new(); + for text in [topic, payload] { + let bytes = text.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == b'{' && bytes[i + 1] == b'{' { + if let Some(rel_end) = text[i + 2..].find("}}") { + let key = text[i + 2..i + 2 + rel_end].trim(); + if !key.is_empty() && !key.starts_with('$') && !out.iter().any(|k| k == key) { + out.push(key.to_string()); + } + i = i + 2 + rel_end + 2; + continue; + } + } + // 按字符推进,避免把多字节 UTF-8 拆开 + let ch = text[i..].chars().next().unwrap(); + i += ch.len_utf8(); + } + } + out +} + /// MQTT 主题过滤器匹配:`+` 匹配单层,`#` 匹配本层及以下(可匹配父级本身)。 fn topic_matches(filter: &str, topic: &str) -> bool { let f: Vec<&str> = filter.split('/').collect(); @@ -2434,3 +2659,30 @@ fn contains_ignore_case(hay: &str, needle: &str) -> bool { } hay.to_lowercase().contains(needle) } + +// ─── 单元测试 ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::extract_placeholder_keys; + + // 修复回归:提取须 trim、排除内置变量、去重且保持首次出现顺序 + #[test] + fn extract_keys_trims_excludes_builtin_and_dedups() { + let keys = extract_placeholder_keys( + "sensor/{{ device }}/state", + "{\"v\":{{value}},\"t\":{{ $ts }},\"u\":{{uuid}}}", + ); + assert_eq!(keys, vec!["device", "value", "uuid"], "实际: {keys:?}"); + } + + #[test] + fn extract_keys_empty_and_multibyte_safe() { + assert!(extract_placeholder_keys("", "").is_empty()); + assert!(extract_placeholder_keys("no placeholders here", "纯文本").is_empty()); + // 未闭合的 {{ 不提取、不越界 + assert!(extract_placeholder_keys("a{{b", "c}}d").is_empty()); + let keys = extract_placeholder_keys("温度{{名称}}", ""); + assert_eq!(keys, vec!["名称"]); + } +} diff --git a/gpui-app/src/ui/mod.rs b/gpui-app/src/ui/mod.rs index c2d5074..6918898 100644 --- a/gpui-app/src/ui/mod.rs +++ b/gpui-app/src/ui/mod.rs @@ -5,6 +5,7 @@ pub mod app; pub mod connection_form; pub mod connection_view; pub mod logo; +pub mod presets_dialog; pub mod settings_dialog; pub mod variables_dialog; pub mod widgets; diff --git a/gpui-app/src/ui/presets_dialog.rs b/gpui-app/src/ui/presets_dialog.rs new file mode 100644 index 0000000..bbf59f7 --- /dev/null +++ b/gpui-app/src/ui/presets_dialog.rs @@ -0,0 +1,610 @@ +//! 发布预设管理对话框:列出全部 `PublishPreset`,支持新建 / 选中编辑 / 删除(二次确认)。 +//! 保存按 **id** 更新,name 与其他预设冲突时报错不落盘;模板存原文,发送时才渲染。 + +use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants as _}; +use gpui_kit::component::dialog::DialogButtonProps; +use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState}; +use gpui_kit::component::select::{Select, SelectState}; +use gpui_kit::component::switch::Switch; +use gpui_kit::component::{ + h_flex, notification::Notification, v_flex, ActiveTheme as _, Disableable as _, + Sizable as _, StyledExt as _, WindowExt as _, +}; +use gpui_kit::prelude::FluentBuilder as _; +use crate::ui::IconName; +use gpui_kit::{ + div, px, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, +}; + +use crate::model::{PayloadFormat, PublishParams, PublishPreset}; +use crate::ui::app::MqttXApp; +use crate::ui::widgets::{field, make_select, KvEditor, OptionDelegate}; + +const QOS: [&str; 3] = ["QoS 0", "QoS 1", "QoS 2"]; +const PAYLOAD_FORMATS: [&str; 4] = ["Plaintext", "JSON", "Base64", "Hex"]; +/// 列表里 payload 预览的最大字符数(压平换行后截断) +const LIST_PAYLOAD_PREVIEW_CHARS: usize = 24; + +struct PresetsDialog { + app: Entity, + /// 与 `app.presets` 同步的本地工作副本;所有变更经 `persist` 落盘 + presets: Vec, + /// 当前编辑项下标;None = 未选中(保存/删除禁用) + selected: Option, + + name: Entity, + topic: Entity, + payload: Entity, + qos: Entity>, + retain: bool, + payload_format: Entity>, + content_type: Entity, + msg_expiry: Entity, + response_topic: Entity, + correlation_data: Entity, + user_props: Entity, +} + +/// 列表 payload 预览:换行/制表符压平后截取前 N 字符。 +fn payload_preview(payload: &str) -> String { + let flat: String = payload + .chars() + .map(|c| match c { + '\n' | '\r' | '\t' => ' ', + _ => c, + }) + .collect(); + let mut out: String = flat.chars().take(LIST_PAYLOAD_PREVIEW_CHARS).collect(); + if flat.chars().count() > LIST_PAYLOAD_PREVIEW_CHARS { + out.push('…'); + } + out +} + +impl PresetsDialog { + fn new( + app: Entity, + presets: Vec, + window: &mut Window, + cx: &mut Context, + ) -> Self { + // 有预设时默认选中第一项并回填表单,避免打开即空白 + let first = presets.first().cloned(); + let selected = if presets.is_empty() { None } else { Some(0) }; + let params = first + .as_ref() + .map(|p| p.params.clone()) + .unwrap_or_default(); + + let name_value = first.as_ref().map(|p| p.name.clone()).unwrap_or_default(); + let name = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("预设名称,如 温度上报") + .default_value(name_value) + }); + let topic = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("发布主题,支持 {{变量名}}") + .default_value(params.topic.clone()) + }); + let payload = cx.new(|cx| { + TextareaState::new(window, cx) + .placeholder("消息负载,支持 {{变量名}}") + .default_value(params.payload.clone()) + }); + let qos = make_select(&QOS, params.qos.min(2) as usize, window, cx); + let fmt_idx = PayloadFormat::ALL + .iter() + .position(|f| *f == params.payload_format) + .unwrap_or(0); + let payload_format = make_select(&PAYLOAD_FORMATS, fmt_idx, window, cx); + let content_type = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Content-Type(可选)") + .default_value(params.content_type.clone().unwrap_or_default()) + }); + let msg_expiry = cx.new(|cx| { + InputState::new(window, cx).placeholder("秒,如 60").default_value( + params + .message_expiry_interval + .map(|v| v.to_string()) + .unwrap_or_default(), + ) + }); + let response_topic = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Response Topic(可选)") + .default_value(params.response_topic.clone().unwrap_or_default()) + }); + let correlation_data = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Correlation Data(可选)") + .default_value(params.correlation_data.clone().unwrap_or_default()) + }); + let user_props = + cx.new(|cx| KvEditor::new("preset-props", ¶ms.user_properties, window, cx)); + + Self { + app, + presets, + selected, + name, + topic, + payload, + qos, + retain: params.retain, + payload_format, + content_type, + msg_expiry, + response_topic, + correlation_data, + user_props, + } + } + + /// 把本地工作副本写回 `app.presets` 并落盘(内部调 `save_presets`)。 + fn persist(&self, cx: &mut Context) { + let presets = self.presets.clone(); + self.app.update(cx, |a, cx| { + a.save_presets(presets); + cx.notify(); + }); + } + + /// 读取表单当前值为发布参数(不渲染模板,存原文)。 + fn collect(&self, cx: &App) -> PublishParams { + let qos = self + .qos + .read(cx) + .selected_value() + .copied() + .unwrap_or(0) as u8; + let format = PayloadFormat::ALL + [self.payload_format.read(cx).selected_value().copied().unwrap_or(0)]; + let opt = |s: String| { + let t = s.trim(); + (!t.is_empty()).then(|| t.to_string()) + }; + PublishParams { + topic: self.topic.read(cx).value().to_string(), + payload: self.payload.read(cx).value().to_string(), + payload_format: format, + qos, + retain: self.retain, + user_properties: self.user_props.read(cx).pairs(cx), + content_type: opt(self.content_type.read(cx).value().to_string()), + message_expiry_interval: { + let t = self.msg_expiry.read(cx).value().to_string(); + let t = t.trim(); + if t.is_empty() { + None + } else { + t.parse::().ok() + } + }, + response_topic: opt(self.response_topic.read(cx).value().to_string()), + correlation_data: opt(self.correlation_data.read(cx).value().to_string()), + ..Default::default() + } + } + + /// 选中项回填表单,进入编辑态。 + fn load(&mut self, idx: usize, window: &mut Window, cx: &mut Context) { + let Some(p) = self.presets.get(idx).cloned() else { + return; + }; + self.selected = Some(idx); + let params = p.params.clone(); + self.name + .update(cx, |s, cx| s.set_value(p.name, window, cx)); + self.topic + .update(cx, |s, cx| s.set_value(params.topic.clone(), window, cx)); + self.payload + .update(cx, |s, cx| s.set_value(params.payload.clone(), window, cx)); + let qos_idx = params.qos.min(2) as usize; + self.qos.update(cx, |s, cx| { + s.set_selected_index(Some(gpui_kit::component::IndexPath::new(qos_idx)), window, cx) + }); + let fmt_idx = PayloadFormat::ALL + .iter() + .position(|f| *f == params.payload_format) + .unwrap_or(0); + self.payload_format.update(cx, |s, cx| { + s.set_selected_index( + Some(gpui_kit::component::IndexPath::new(fmt_idx)), + window, + cx, + ) + }); + self.retain = params.retain; + let ct = params.content_type.clone().unwrap_or_default(); + self.content_type + .update(cx, |s, cx| s.set_value(ct, window, cx)); + let expiry = params + .message_expiry_interval + .map(|v| v.to_string()) + .unwrap_or_default(); + self.msg_expiry + .update(cx, |s, cx| s.set_value(expiry, window, cx)); + let rt = params.response_topic.clone().unwrap_or_default(); + self.response_topic + .update(cx, |s, cx| s.set_value(rt, window, cx)); + let cd = params.correlation_data.clone().unwrap_or_default(); + self.correlation_data + .update(cx, |s, cx| s.set_value(cd, window, cx)); + // KvEditor 不支持整体回填,直接重建实体 + self.user_props = + cx.new(|cx| KvEditor::new("preset-props", ¶ms.user_properties, window, cx)); + cx.notify(); + } + + /// 清空表单并退出编辑态(删除后无剩余预设时调用)。 + fn clear_form(&mut self, window: &mut Window, cx: &mut Context) { + self.selected = None; + self.name.update(cx, |s, cx| s.set_value("", window, cx)); + self.topic.update(cx, |s, cx| s.set_value("", window, cx)); + self.payload + .update(cx, |s, cx| s.set_value("", window, cx)); + self.qos.update(cx, |s, cx| { + s.set_selected_index(Some(gpui_kit::component::IndexPath::new(0)), window, cx) + }); + self.payload_format.update(cx, |s, cx| { + s.set_selected_index(Some(gpui_kit::component::IndexPath::new(0)), window, cx) + }); + self.retain = false; + self.content_type + .update(cx, |s, cx| s.set_value("", window, cx)); + self.msg_expiry + .update(cx, |s, cx| s.set_value("", window, cx)); + self.response_topic + .update(cx, |s, cx| s.set_value("", window, cx)); + self.correlation_data + .update(cx, |s, cx| s.set_value("", window, cx)); + self.user_props = cx.new(|cx| KvEditor::new("preset-props", &[], window, cx)); + cx.notify(); + } + + /// 「新建」:创建默认名称预设(重名自动加序号)并立即落盘、进入编辑。 + fn create_new(&mut self, window: &mut Window, cx: &mut Context) { + let mut name = "新预设".to_string(); + let mut n = 2; + while self.presets.iter().any(|p| p.name == name) { + name = format!("新预设 {n}"); + n += 1; + } + self.presets.push(PublishPreset::new(name, PublishParams::default())); + let idx = self.presets.len() - 1; + self.persist(cx); + self.load(idx, window, cx); + } + + /// 「保存」:name 校验 + 冲突检查通过后按 id 更新,再落盘。 + fn save(&mut self, window: &mut Window, cx: &mut Context) { + let Some(idx) = self.selected else { + window.push_notification(Notification::warning("请先选择或新建预设"), cx); + return; + }; + let name = self.name.read(cx).value().trim().to_string(); + if name.is_empty() { + window.push_notification(Notification::warning("请填写预设名称"), cx); + return; + } + // 与其他预设(不含自身)重名 → 报错且不落盘 + let own_id = self.presets[idx].id.clone(); + if self + .presets + .iter() + .any(|p| p.id != own_id && p.name == name) + { + window.push_notification( + Notification::error(format!("预设名称「{name}」已存在")), + cx, + ); + return; + } + // 过期秒数输入了但解析失败 → 拒绝保存(与发布路径校验一致) + let expiry_raw = self.msg_expiry.read(cx).value().to_string(); + let params = self.collect(cx); + if !expiry_raw.trim().is_empty() && params.message_expiry_interval.is_none() { + window.push_notification( + Notification::warning("消息过期须为非负整数(秒)"), + cx, + ); + return; + } + // 按 id 更新:id 不随改名变化,避免并发列表错位 + if let Some(p) = self.presets.iter_mut().find(|p| p.id == own_id) { + p.name = name.clone(); + p.params = params; + } + self.persist(cx); + window.push_notification(Notification::success(format!("预设「{name}」已保存")), cx); + } + + /// 「删除」:`open_alert_dialog` 二次确认后从列表移除并落盘。 + fn ask_delete(&mut self, window: &mut Window, cx: &mut Context) { + let Some(idx) = self.selected else { + window.push_notification(Notification::warning("请先选择要删除的预设"), cx); + return; + }; + let pid = self.presets[idx].id.clone(); + let pname = self.presets[idx].name.clone(); + let weak = cx.weak_entity(); + // 与发布预设下拉的删除确认保持一致:不可撤销操作先弹二次确认 + window.open_alert_dialog(cx, move |alert, _, _| { + let weak = weak.clone(); + let pid = pid.clone(); + alert + .title("删除预设") + .description(format!("确定删除预设「{pname}」吗?此操作不可撤销。")) + .button_props( + DialogButtonProps::default() + .show_cancel(true) + .cancel_text("取消") + .ok_text("删除") + .ok_variant(ButtonVariant::Danger), + ) + .on_ok(move |_, window, cx| { + weak.update(cx, |d, cx| { + d.presets.retain(|p| p.id != pid); + d.persist(cx); + if d.presets.is_empty() { + d.clear_form(window, cx); + } else { + // 删除后选中项前移,取原位置(越界则取最后一项) + let next = idx.min(d.presets.len() - 1); + d.load(next, window, cx); + } + }) + .ok(); + true + }) + }); + } +} + +impl Render for PresetsDialog { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + let selected_bg = cx.theme().secondary; + let hover_bg = cx.theme().muted; + let mono_font = cx.theme().mono_font_family.clone(); + + // 左:预设列表(name + topic + payload 前 N 字符;空态提示) + let mut list = v_flex().gap_0p5(); + if self.presets.is_empty() { + list = list.child( + v_flex() + .p_3() + .gap_1() + .items_center() + .child( + gpui_kit::component::Icon::new(IconName::Bookmark) + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .text_xs() + .text_color(muted) + .text_center() + .child("暂无发布预设,点击上方「新建」创建"), + ), + ); + } else { + for (i, p) in self.presets.iter().enumerate() { + let selected = self.selected == Some(i); + let name = p.name.clone(); + let topic = p.params.topic.clone(); + let preview = payload_preview(&p.params.payload); + list = list.child( + div() + .id(("preset-list-item", i)) + .px_2() + .py_1() + .rounded_md() + .cursor_pointer() + .when(selected, |d| d.bg(selected_bg)) + .when(!selected, |d| d.hover(move |this| this.bg(hover_bg))) + .on_click(cx.listener(move |this, _, window, cx| { + this.load(i, window, cx); + })) + .child( + v_flex() + .gap_0p5() + .child( + div() + .text_sm() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(name), + ) + .child( + div() + .text_xs() + .text_color(muted) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(topic), + ) + .child( + div() + .text_xs() + .font_family(mono_font.clone()) + .text_color(muted) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .child(preview), + ), + ), + ); + } + } + + // 右:编辑表单(不渲染模板,全部存原文) + let retain = self.retain; + let form = v_flex() + .flex_1() + .min_w(px(0.)) + .gap_2() + .child(field("预设名称", Input::new(&self.name))) + .child(field("发布主题", Input::new(&self.topic))) + .child(field( + "Payload(原文保存,发送时渲染变量)", + Textarea::new(&self.payload).h(px(96.)), + )) + .child( + h_flex() + .gap_2() + .child( + div() + .w(px(110.)) + .child(field("QoS", Select::new(&self.qos).small())), + ) + .child( + h_flex() + .gap_1() + .items_center() + .pt_4() + .child( + Switch::new("preset-retain") + .checked(retain) + .on_change(cx.listener(|this, v, _, cx| { + this.retain = *v; + cx.notify(); + })), + ) + .child(div().text_xs().child("Retain")), + ) + .child( + div() + .flex_1() + .min_w(px(0.)) + .child(field( + "Payload 格式", + Select::new(&self.payload_format).small(), + )), + ), + ) + .child(div().h(px(1.)).bg(cx.theme().border).w_full()) + .child(div().text_xs().font_semibold().child("MQTT 5 属性")) + .child( + h_flex() + .gap_2() + .child( + div().flex_1().min_w(px(0.)).child(field( + "Content-Type", + Input::new(&self.content_type).small(), + )), + ) + .child( + div().w(px(120.)).child(field( + "消息过期(秒)", + Input::new(&self.msg_expiry).small(), + )), + ), + ) + .child( + h_flex() + .gap_2() + .child( + div().flex_1().min_w(px(0.)).child(field( + "Response Topic", + Input::new(&self.response_topic).small(), + )), + ) + .child( + div().flex_1().min_w(px(0.)).child(field( + "Correlation Data", + Input::new(&self.correlation_data).small(), + )), + ), + ) + .child(field("用户属性", self.user_props.clone())); + + h_flex() + .gap_3() + .w_full() + // 左:列表 + 新建 + .child( + v_flex() + .w(px(220.)) + .flex_shrink_0() + .gap_1() + .child( + h_flex() + .justify_between() + .items_center() + .child(div().text_sm().font_semibold().child("发布预设")) + .child( + Button::new("preset-new") + .icon(IconName::Plus) + .label("新建") + .ghost() + .xsmall() + .on_click(cx.listener(|this, _, window, cx| { + this.create_new(window, cx); + })), + ), + ) + .child(list), + ) + // 右:表单 + .child(form) + } +} + +pub fn open(app: Entity, window: &mut Window, cx: &mut App) { + let presets = app.read(cx).presets.clone(); + let dialog_view: Entity = + cx.new(|cx| PresetsDialog::new(app, presets, window, cx)); + window.open_dialog(cx, move |dialog, _, cx| { + let body = dialog_view.clone(); + let for_save = dialog_view.clone(); + let for_delete = dialog_view.clone(); + // 每次重绘读取选中态,控制保存 / 删除按钮禁用 + let has_selection = dialog_view.read(cx).selected.is_some(); + dialog + .w(px(760.)) + .title("管理发布预设") + .child(body) + .footer( + h_flex() + .w_full() + .justify_between() + .child( + Button::new("preset-delete") + .icon(IconName::Trash) + .label("删除") + .danger() + .outline() + .disabled(!has_selection) + .on_click(move |_, window, cx| { + for_delete.update(cx, |d, cx| d.ask_delete(window, cx)); + }), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("preset-close") + .label("关闭") + .outline() + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child( + Button::new("preset-save") + .label("保存") + .primary() + .disabled(!has_selection) + .on_click(move |_, window, cx| { + for_save.update(cx, |d, cx| d.save(window, cx)); + }), + ), + ), + ) + }); +}