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