par_term/
ssh_connect_ui.rs1use crate::profile::ProfileId;
7use crate::ssh::mdns::MdnsDiscovery;
8use crate::ssh::{SshHost, SshHostSource, discover_local_hosts};
9use egui::{Color32, Context, epaint::Shadow};
10
11#[derive(Debug, Clone)]
13pub enum SshConnectAction {
14 None,
16 Connect {
18 host: SshHost,
19 profile_override: Option<ProfileId>,
20 },
21 Cancel,
23}
24
25pub 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 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 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 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 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 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 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 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 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}