Skip to main content

par_term/
ssh_connect_ui.rs

1//! SSH Quick Connect dialog.
2//!
3//! An egui modal overlay for browsing and connecting to SSH hosts.
4//! Opened via Cmd+Shift+S (macOS) or Ctrl+Shift+S (Linux/Windows).
5
6use crate::profile::ProfileId;
7use crate::ui_constants::{
8    SSH_CONNECT_DIALOG_MAX_HEIGHT, SSH_CONNECT_DIALOG_MAX_WIDTH, SSH_CONNECT_DIALOG_MIN_HEIGHT,
9    SSH_CONNECT_DIALOG_MIN_WIDTH, SSH_CONNECT_HOST_ROW_HEIGHT, SSH_CONNECT_INNER_MARGIN,
10    SSH_CONNECT_LIST_BOTTOM_RESERVE, SSH_CONNECT_SEARCH_BAR_HEIGHT,
11};
12use egui::{Color32, Context, epaint::Shadow};
13use par_term_ssh::mdns::MdnsDiscovery;
14use par_term_ssh::{SshHost, SshHostSource, discover_local_hosts};
15
16/// Action returned by the quick connect dialog.
17#[derive(Debug, Clone)]
18pub enum SshConnectAction {
19    /// No action (dialog still showing)
20    None,
21    /// Connect to the selected host
22    Connect {
23        host: SshHost,
24        profile_override: Option<ProfileId>,
25    },
26    /// Dialog was cancelled
27    Cancel,
28}
29
30/// SSH Quick Connect UI state.
31pub struct SshConnectUI {
32    visible: bool,
33    search_query: String,
34    hosts: Vec<SshHost>,
35    selected_index: usize,
36    selected_profile: Option<ProfileId>,
37    mdns: MdnsDiscovery,
38    mdns_enabled: bool,
39    hosts_loaded: bool,
40    request_focus: bool,
41}
42
43impl Default for SshConnectUI {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl SshConnectUI {
50    pub fn new() -> Self {
51        Self {
52            visible: false,
53            search_query: String::new(),
54            hosts: Vec::new(),
55            selected_index: 0,
56            selected_profile: None,
57            mdns: MdnsDiscovery::new(),
58            mdns_enabled: false,
59            hosts_loaded: false,
60            request_focus: false,
61        }
62    }
63
64    pub fn open(&mut self, mdns_enabled: bool, mdns_timeout: u32) {
65        self.visible = true;
66        self.search_query.clear();
67        self.selected_index = 0;
68        self.selected_profile = None;
69        self.mdns_enabled = mdns_enabled;
70        self.request_focus = true;
71        self.hosts = discover_local_hosts();
72        self.hosts_loaded = true;
73        if mdns_enabled {
74            self.mdns.start_scan(mdns_timeout);
75        }
76    }
77
78    pub fn close(&mut self) {
79        self.visible = false;
80        self.hosts.clear();
81        self.mdns.clear();
82        self.hosts_loaded = false;
83    }
84
85    pub fn is_visible(&self) -> bool {
86        self.visible
87    }
88
89    pub fn show(&mut self, ctx: &Context) -> SshConnectAction {
90        if !self.visible {
91            return SshConnectAction::None;
92        }
93
94        // Poll mDNS for newly discovered hosts
95        if self.mdns.poll() {
96            for host in self.mdns.hosts() {
97                let dominated = self
98                    .hosts
99                    .iter()
100                    .any(|h| h.hostname == host.hostname && h.port == host.port);
101                if !dominated {
102                    self.hosts.push(host.clone());
103                }
104            }
105        }
106
107        let mut action = SshConnectAction::None;
108        let screen_rect = ctx.content_rect();
109        let dialog_width = (screen_rect.width() * 0.5)
110            .clamp(SSH_CONNECT_DIALOG_MIN_WIDTH, SSH_CONNECT_DIALOG_MAX_WIDTH);
111        let dialog_height = (screen_rect.height() * 0.6)
112            .clamp(SSH_CONNECT_DIALOG_MIN_HEIGHT, SSH_CONNECT_DIALOG_MAX_HEIGHT);
113
114        egui::Area::new(egui::Id::new("ssh_connect_overlay"))
115            .fixed_pos(egui::pos2(
116                (screen_rect.width() - dialog_width) / 2.0,
117                (screen_rect.height() - dialog_height) / 2.5,
118            ))
119            .order(egui::Order::Foreground)
120            .show(ctx, |ui| {
121                egui::Frame::popup(ui.style())
122                    .inner_margin(SSH_CONNECT_INNER_MARGIN)
123                    .shadow(Shadow {
124                        offset: [0, 4],
125                        blur: 16,
126                        spread: 8,
127                        color: Color32::from_black_alpha(100),
128                    })
129                    .show(ui, |ui| {
130                        ui.set_width(dialog_width);
131                        ui.set_max_height(dialog_height);
132
133                        // Title
134                        ui.horizontal(|ui| {
135                            ui.heading("SSH Quick Connect");
136                            if self.mdns.is_scanning() {
137                                ui.spinner();
138                                ui.label(egui::RichText::new("Scanning...").weak().size(11.0));
139                            }
140                        });
141                        ui.add_space(8.0);
142
143                        // Search bar
144                        let search_response = ui.add_sized(
145                            [
146                                dialog_width - SSH_CONNECT_INNER_MARGIN * 2.0,
147                                SSH_CONNECT_SEARCH_BAR_HEIGHT,
148                            ],
149                            egui::TextEdit::singleline(&mut self.search_query)
150                                .hint_text("Search hosts...")
151                                .desired_width(dialog_width - SSH_CONNECT_INNER_MARGIN * 2.0),
152                        );
153
154                        if self.request_focus {
155                            search_response.request_focus();
156                            self.request_focus = false;
157                        }
158
159                        ui.add_space(8.0);
160
161                        // Filter hosts by search query
162                        let query_lower = self.search_query.to_lowercase();
163                        let filtered: Vec<usize> = self
164                            .hosts
165                            .iter()
166                            .enumerate()
167                            .filter(|(_, h)| {
168                                if query_lower.is_empty() {
169                                    return true;
170                                }
171                                h.alias.to_lowercase().contains(&query_lower)
172                                    || h.hostname
173                                        .as_deref()
174                                        .is_some_and(|n| n.to_lowercase().contains(&query_lower))
175                                    || h.user
176                                        .as_deref()
177                                        .is_some_and(|u| u.to_lowercase().contains(&query_lower))
178                            })
179                            .map(|(i, _)| i)
180                            .collect();
181
182                        if !filtered.is_empty() {
183                            self.selected_index = self.selected_index.min(filtered.len() - 1);
184                        }
185
186                        // Keyboard navigation
187                        let mut enter_pressed = false;
188                        if search_response.has_focus() {
189                            if ui.input(|i| i.key_pressed(egui::Key::ArrowDown))
190                                && self.selected_index + 1 < filtered.len()
191                            {
192                                self.selected_index += 1;
193                            }
194                            if ui.input(|i| i.key_pressed(egui::Key::ArrowUp))
195                                && self.selected_index > 0
196                            {
197                                self.selected_index -= 1;
198                            }
199                            if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
200                                enter_pressed = true;
201                            }
202                            if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
203                                action = SshConnectAction::Cancel;
204                            }
205                        }
206
207                        // Host list grouped by source
208                        egui::ScrollArea::vertical()
209                            .max_height(dialog_height - SSH_CONNECT_LIST_BOTTOM_RESERVE)
210                            .show(ui, |ui| {
211                                if filtered.is_empty() {
212                                    ui.label(
213                                        egui::RichText::new("No hosts found.").weak().italics(),
214                                    );
215                                    return;
216                                }
217
218                                let mut current_source: Option<&SshHostSource> = None;
219                                for (display_idx, &host_idx) in filtered.iter().enumerate() {
220                                    let host = &self.hosts[host_idx];
221
222                                    // Group header when source changes
223                                    if current_source != Some(&host.source) {
224                                        current_source = Some(&host.source);
225                                        ui.add_space(4.0);
226                                        ui.label(
227                                            egui::RichText::new(host.source.to_string())
228                                                .strong()
229                                                .size(11.0)
230                                                .color(Color32::from_rgb(140, 140, 180)),
231                                        );
232                                        ui.separator();
233                                    }
234
235                                    let is_selected = display_idx == self.selected_index;
236                                    let response = ui.add_sized(
237                                        [
238                                            dialog_width - SSH_CONNECT_INNER_MARGIN * 3.0,
239                                            SSH_CONNECT_HOST_ROW_HEIGHT,
240                                        ],
241                                        egui::Button::new(egui::RichText::new(format!(
242                                            "  {}  {}",
243                                            host.alias,
244                                            host.connection_string()
245                                        )))
246                                        .fill(
247                                            if is_selected {
248                                                Color32::from_rgb(50, 50, 70)
249                                            } else {
250                                                Color32::TRANSPARENT
251                                            },
252                                        ),
253                                    );
254
255                                    if response.clicked() || (enter_pressed && is_selected) {
256                                        action = SshConnectAction::Connect {
257                                            host: host.clone(),
258                                            profile_override: self.selected_profile,
259                                        };
260                                    }
261                                    if response.hovered() {
262                                        self.selected_index = display_idx;
263                                    }
264                                }
265                            });
266
267                        // Bottom bar with cancel button and keyboard hints
268                        ui.add_space(8.0);
269                        ui.separator();
270                        ui.horizontal(|ui| {
271                            if ui.button("Cancel").clicked() {
272                                action = SshConnectAction::Cancel;
273                            }
274                            ui.with_layout(
275                                egui::Layout::right_to_left(egui::Align::Center),
276                                |ui| {
277                                    ui.label(
278                                        egui::RichText::new(
279                                            "Up/Down Navigate  Enter Connect  Esc Cancel",
280                                        )
281                                        .weak()
282                                        .size(10.0),
283                                    );
284                                },
285                            );
286                        });
287                    });
288            });
289
290        match &action {
291            SshConnectAction::Cancel | SshConnectAction::Connect { .. } => self.close(),
292            SshConnectAction::None => {}
293        }
294
295        action
296    }
297}
298
299impl crate::traits::OverlayComponent for SshConnectUI {
300    type Action = SshConnectAction;
301
302    fn show(&mut self, ctx: &egui::Context) -> Self::Action {
303        SshConnectUI::show(self, ctx)
304    }
305
306    fn is_visible(&self) -> bool {
307        self.is_visible()
308    }
309
310    fn set_visible(&mut self, visible: bool) {
311        if !visible {
312            self.close();
313        }
314        // Note: setting visible=true requires mdns_enabled and mdns_timeout parameters.
315        // Use open(mdns_enabled, mdns_timeout) to show this dialog.
316    }
317}