par_term/
ssh_connect_ui.rs1use 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#[derive(Debug, Clone)]
18pub enum SshConnectAction {
19 None,
21 Connect {
23 host: SshHost,
24 profile_override: Option<ProfileId>,
25 },
26 Cancel,
28}
29
30pub 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 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 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 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 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 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 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 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 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 }
317}