purple_ssh/app/
display_list.rs1use std::collections::HashMap;
6
7use super::{GroupBy, HostListItem, SortMode};
8use crate::app::App;
9use crate::ssh_config::model::{ConfigElement, HostEntry, PatternEntry, SshConfigFile};
10
11impl App {
12 pub(crate) fn build_display_list_from(
17 config: &SshConfigFile,
18 hosts: &[HostEntry],
19 patterns: &[PatternEntry],
20 ) -> Vec<HostListItem> {
21 let mut display_list = Vec::new();
22 let mut host_index = 0;
23 let mut pending_comment: Option<String> = None;
24
25 for element in &config.elements {
26 match element {
27 ConfigElement::GlobalLine(line) => {
28 let trimmed = line.trim();
29 if let Some(rest) = trimmed.strip_prefix('#') {
30 let text = rest.trim();
31 let text = text.strip_prefix("purple:group ").unwrap_or(text);
32 if !text.is_empty() {
33 pending_comment = Some(text.to_string());
34 }
35 } else if trimmed.is_empty() {
36 pending_comment = None;
38 } else {
39 pending_comment = None;
40 }
41 }
42 ConfigElement::HostBlock(block) => {
43 if crate::ssh_config::model::is_host_pattern(&block.host_pattern) {
44 pending_comment = None;
45 continue;
46 }
47
48 if host_index < hosts.len() {
49 if let Some(header) = pending_comment.take() {
50 display_list.push(HostListItem::GroupHeader(header));
51 }
52 display_list.push(HostListItem::Host { index: host_index });
53 host_index += 1;
54 }
55
56 pending_comment = Self::extract_trailing_comment(&block.directives);
58 }
59 ConfigElement::Include(include) => {
60 pending_comment = None;
61 for file in &include.resolved_files {
62 Self::build_display_list_from_included(
63 &file.elements,
64 &file.path,
65 hosts,
66 &mut host_index,
67 &mut display_list,
68 );
69 }
70 }
71 }
72 }
73
74 if !patterns.is_empty() {
76 let mut pattern_index = 0usize;
77 display_list.push(HostListItem::GroupHeader("Patterns".to_string()));
78 Self::append_pattern_items(&config.elements, &mut pattern_index, &mut display_list);
79 debug_assert_eq!(
80 pattern_index,
81 patterns.len(),
82 "append_pattern_items and collect_pattern_entries traversal mismatch"
83 );
84 }
85
86 display_list
87 }
88
89 fn append_pattern_items(
90 elements: &[ConfigElement],
91 pattern_index: &mut usize,
92 display_list: &mut Vec<HostListItem>,
93 ) {
94 for e in elements {
95 match e {
96 ConfigElement::HostBlock(block) => {
97 if crate::ssh_config::model::is_host_pattern(&block.host_pattern) {
98 display_list.push(HostListItem::Pattern {
99 index: *pattern_index,
100 });
101 *pattern_index += 1;
102 }
103 }
104 ConfigElement::Include(include) => {
105 for file in &include.resolved_files {
106 Self::append_pattern_items(&file.elements, pattern_index, display_list);
107 }
108 }
109 ConfigElement::GlobalLine(_) => {}
110 }
111 }
112 }
113
114 fn extract_trailing_comment(
119 directives: &[crate::ssh_config::model::Directive],
120 ) -> Option<String> {
121 let d = directives.last()?;
122 if !d.is_non_directive {
123 return None;
124 }
125 let trimmed = d.raw_line.trim();
126 if trimmed.is_empty() {
127 return None;
128 }
129 if let Some(rest) = trimmed.strip_prefix('#') {
130 let text = rest.trim();
131 if text.starts_with("purple:") && !text.starts_with("purple:group ") {
134 return None;
135 }
136 let text = text.strip_prefix("purple:group ").unwrap_or(text);
137 if !text.is_empty() {
138 return Some(text.to_string());
139 }
140 }
141 None
142 }
143
144 fn build_display_list_from_included(
145 elements: &[ConfigElement],
146 file_path: &std::path::Path,
147 hosts: &[HostEntry],
148 host_index: &mut usize,
149 display_list: &mut Vec<HostListItem>,
150 ) {
151 let mut pending_comment: Option<String> = None;
152 let file_name = file_path
153 .file_name()
154 .map(|f| f.to_string_lossy().to_string())
155 .unwrap_or_default();
156
157 if !file_name.is_empty() {
159 let has_hosts = elements.iter().any(|e| {
160 matches!(e, ConfigElement::HostBlock(b)
161 if !crate::ssh_config::model::is_host_pattern(&b.host_pattern)
162 )
163 });
164 if has_hosts {
165 display_list.push(HostListItem::GroupHeader(file_name));
166 }
167 }
168
169 for element in elements {
170 match element {
171 ConfigElement::GlobalLine(line) => {
172 let trimmed = line.trim();
173 if let Some(rest) = trimmed.strip_prefix('#') {
174 let text = rest.trim();
175 let text = text.strip_prefix("purple:group ").unwrap_or(text);
176 if !text.is_empty() {
177 pending_comment = Some(text.to_string());
178 }
179 } else {
180 pending_comment = None;
181 }
182 }
183 ConfigElement::HostBlock(block) => {
184 if crate::ssh_config::model::is_host_pattern(&block.host_pattern) {
185 pending_comment = None;
186 continue;
187 }
188
189 if *host_index < hosts.len() {
190 if let Some(header) = pending_comment.take() {
191 display_list.push(HostListItem::GroupHeader(header));
192 }
193 display_list.push(HostListItem::Host { index: *host_index });
194 *host_index += 1;
195 }
196
197 pending_comment = Self::extract_trailing_comment(&block.directives);
199 }
200 ConfigElement::Include(include) => {
201 pending_comment = None;
202 for file in &include.resolved_files {
203 Self::build_display_list_from_included(
204 &file.elements,
205 &file.path,
206 hosts,
207 host_index,
208 display_list,
209 );
210 }
211 }
212 }
213 }
214 }
215
216 pub fn apply_sort(&mut self) {
218 log::debug!(
219 "[purple] apply_sort: mode={} group_by={:?} hosts={}",
220 self.hosts_state.sort_mode.to_key(),
221 self.hosts_state.group_by,
222 self.hosts_state.list.len()
223 );
224 let selected_alias = self
226 .selected_host()
227 .map(|h| h.alias.clone())
228 .or_else(|| self.selected_pattern().map(|p| p.pattern.clone()));
229
230 self.hosts_state.multi_select.clear();
232 self.hosts_state.render_cache.invalidate();
234
235 if self.hosts_state.sort_mode == SortMode::Original
236 && matches!(self.hosts_state.group_by, GroupBy::None)
237 {
238 self.hosts_state.display_list = Self::build_display_list_from(
239 &self.hosts_state.ssh_config,
240 &self.hosts_state.list,
241 &self.hosts_state.patterns,
242 );
243 } else if self.hosts_state.sort_mode == SortMode::Original
244 && !matches!(self.hosts_state.group_by, GroupBy::None)
245 {
246 let indices: Vec<usize> = (0..self.hosts_state.list.len()).collect();
248 self.hosts_state.display_list = self.group_indices(&indices);
249 } else {
250 let mut indices: Vec<usize> = (0..self.hosts_state.list.len()).collect();
251 match self.hosts_state.sort_mode {
252 SortMode::AlphaAlias => {
253 indices.sort_by_cached_key(|&i| {
254 let stale = self.hosts_state.list[i].stale.is_some();
255 (stale, self.hosts_state.list[i].alias.to_ascii_lowercase())
256 });
257 }
258 SortMode::AlphaHostname => {
259 indices.sort_by_cached_key(|&i| {
260 let stale = self.hosts_state.list[i].stale.is_some();
261 (
262 stale,
263 self.hosts_state.list[i].hostname.to_ascii_lowercase(),
264 )
265 });
266 }
267 SortMode::Frecency => {
268 indices.sort_by(|a, b| {
269 let sa = self.hosts_state.list[*a].stale.is_some();
270 let sb = self.hosts_state.list[*b].stale.is_some();
271 sa.cmp(&sb).then_with(|| {
272 let score_a = self
273 .history
274 .frecency_score(&self.hosts_state.list[*a].alias);
275 let score_b = self
276 .history
277 .frecency_score(&self.hosts_state.list[*b].alias);
278 score_b.total_cmp(&score_a)
279 })
280 });
281 }
282 SortMode::MostRecent => {
283 indices.sort_by(|a, b| {
284 let sa = self.hosts_state.list[*a].stale.is_some();
285 let sb = self.hosts_state.list[*b].stale.is_some();
286 sa.cmp(&sb).then_with(|| {
287 let ts_a = self
288 .history
289 .last_connected(&self.hosts_state.list[*a].alias);
290 let ts_b = self
291 .history
292 .last_connected(&self.hosts_state.list[*b].alias);
293 ts_b.cmp(&ts_a)
294 })
295 });
296 }
297 SortMode::Status => {
298 indices.sort_by(|a, b| {
299 let sa = self.hosts_state.list[*a].stale.is_some();
300 let sb = self.hosts_state.list[*b].stale.is_some();
301 sa.cmp(&sb).then_with(|| {
302 let pa = self.ping.status.get(&self.hosts_state.list[*a].alias);
303 let pb = self.ping.status.get(&self.hosts_state.list[*b].alias);
304 super::ping_sort_key(pa).cmp(&super::ping_sort_key(pb))
305 })
306 });
307 }
308 _ => {}
309 }
310 self.hosts_state.display_list = self.group_indices(&indices);
311 }
312
313 if (self.hosts_state.sort_mode != SortMode::Original
316 || !matches!(self.hosts_state.group_by, GroupBy::None))
317 && !self.hosts_state.patterns.is_empty()
318 {
319 self.hosts_state
320 .display_list
321 .push(HostListItem::GroupHeader("Patterns".to_string()));
322 let mut pattern_index = 0usize;
323 Self::append_pattern_items(
324 &self.hosts_state.ssh_config.elements,
325 &mut pattern_index,
326 &mut self.hosts_state.display_list,
327 );
328 }
329
330 {
332 self.hosts_state.group_host_counts.clear();
333 let mut current_group: Option<&str> = None;
334 for item in &self.hosts_state.display_list {
335 match item {
336 HostListItem::GroupHeader(text) => {
337 current_group = Some(text.as_str());
338 }
339 HostListItem::Host { .. } | HostListItem::Pattern { .. } => {
340 if let Some(group) = current_group {
341 *self
342 .hosts_state
343 .group_host_counts
344 .entry(group.to_string())
345 .or_insert(0) += 1;
346 }
347 }
348 }
349 }
350 }
351
352 self.hosts_state.group_tab_order = match &self.hosts_state.group_by {
355 GroupBy::Tag(_) => {
356 let mut tag_counts: HashMap<String, usize> = HashMap::new();
357 for host in &self.hosts_state.list {
358 for tag in host.tags.iter() {
359 *tag_counts.entry(tag.clone()).or_insert(0) += 1;
360 }
361 }
362 for pattern in &self.hosts_state.patterns {
363 for tag in &pattern.tags {
364 *tag_counts.entry(tag.clone()).or_insert(0) += 1;
365 }
366 }
367 let mut sorted: Vec<(String, usize)> = tag_counts.into_iter().collect();
368 sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
369 let top: Vec<(String, usize)> = sorted.into_iter().take(10).collect();
370 self.hosts_state.group_host_counts =
371 top.iter().map(|(t, c)| (t.clone(), *c)).collect();
372 top.into_iter().map(|(t, _)| t).collect()
373 }
374 _ => {
375 let mut order = Vec::new();
376 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
377 for item in &self.hosts_state.display_list {
378 if let HostListItem::GroupHeader(text) = item {
379 if seen.insert(text.as_str()) {
380 order.push(text.clone());
381 }
382 }
383 }
384 order
385 }
386 };
387
388 if let Some(ref filter) = self.hosts_state.group_filter {
390 let is_tag_mode = matches!(self.hosts_state.group_by, GroupBy::Tag(_));
391 let mut filtered = Vec::with_capacity(self.hosts_state.display_list.len());
392
393 if is_tag_mode {
394 for item in std::mem::take(&mut self.hosts_state.display_list) {
397 match &item {
398 HostListItem::GroupHeader(_) => {} HostListItem::Host { index } => {
400 if let Some(host) = self.hosts_state.list.get(*index) {
401 if host
402 .tags
403 .iter()
404 .chain(host.provider_tags.iter())
405 .any(|t| t == filter)
406 {
407 filtered.push(item);
408 }
409 }
410 }
411 HostListItem::Pattern { index } => {
412 if let Some(pattern) = self.hosts_state.patterns.get(*index) {
413 if pattern.tags.iter().any(|t| t == filter) {
414 filtered.push(item);
415 }
416 }
417 }
418 }
419 }
420 } else {
421 let mut in_group = false;
423 for item in std::mem::take(&mut self.hosts_state.display_list) {
424 match &item {
425 HostListItem::GroupHeader(text) => {
426 in_group = text == filter;
427 }
428 _ => {
429 if in_group {
430 filtered.push(item);
431 }
432 }
433 }
434 }
435 }
436
437 self.hosts_state.display_list = filtered;
438 }
439
440 if let Some(alias) = selected_alias {
442 self.select_host_by_alias(&alias);
443 if self.selected_host().is_some() || self.selected_pattern().is_some() {
444 return;
445 }
446 }
447 self.select_first_host();
448 }
449
450 pub fn select_first_host(&mut self) {
452 if let Some(pos) = self.hosts_state.display_list.iter().position(|item| {
453 matches!(
454 item,
455 HostListItem::Host { .. } | HostListItem::Pattern { .. }
456 )
457 }) {
458 self.ui.list_state.select(Some(pos));
459 }
460 }
461
462 fn group_indices(&self, sorted_indices: &[usize]) -> Vec<HostListItem> {
466 match &self.hosts_state.group_by {
467 GroupBy::None => sorted_indices
468 .iter()
469 .map(|&i| HostListItem::Host { index: i })
470 .collect(),
471 GroupBy::Provider => {
472 Self::group_indices_by_provider(&self.hosts_state.list, sorted_indices)
473 }
474 GroupBy::Tag(tag) => {
475 Self::group_indices_by_tag(&self.hosts_state.list, sorted_indices, tag)
476 }
477 }
478 }
479
480 fn group_indices_by_provider(
481 hosts: &[HostEntry],
482 sorted_indices: &[usize],
483 ) -> Vec<HostListItem> {
484 let mut none_indices: Vec<usize> = Vec::new();
485 let mut provider_groups: Vec<(&str, Vec<usize>)> = Vec::new();
486 let mut provider_order: HashMap<&str, usize> = HashMap::new();
487
488 for &idx in sorted_indices {
489 match &hosts[idx].provider {
490 None => none_indices.push(idx),
491 Some(name) => {
492 if let Some(&group_idx) = provider_order.get(name.as_str()) {
493 provider_groups[group_idx].1.push(idx);
494 } else {
495 let group_idx = provider_groups.len();
496 provider_order.insert(name, group_idx);
497 provider_groups.push((name, vec![idx]));
498 }
499 }
500 }
501 }
502
503 let mut display_list = Vec::new();
504
505 for idx in &none_indices {
507 display_list.push(HostListItem::Host { index: *idx });
508 }
509
510 for (name, indices) in &provider_groups {
512 let header = crate::providers::provider_display_name(name);
513 display_list.push(HostListItem::GroupHeader(header.to_string()));
514 for &idx in indices {
515 display_list.push(HostListItem::Host { index: idx });
516 }
517 }
518 display_list
519 }
520
521 fn group_indices_by_tag(
525 hosts: &[HostEntry],
526 sorted_indices: &[usize],
527 tag: &str,
528 ) -> Vec<HostListItem> {
529 let mut without_tag: Vec<usize> = Vec::new();
530 let mut with_tag: Vec<usize> = Vec::new();
531
532 for &idx in sorted_indices {
533 if hosts[idx].tags.iter().any(|t| t == tag) {
534 with_tag.push(idx);
535 } else {
536 without_tag.push(idx);
537 }
538 }
539
540 let mut display_list = Vec::new();
541
542 for idx in &without_tag {
543 display_list.push(HostListItem::Host { index: *idx });
544 }
545
546 if !with_tag.is_empty() {
547 display_list.push(HostListItem::GroupHeader(tag.to_string()));
548 for &idx in &with_tag {
549 display_list.push(HostListItem::Host { index: idx });
550 }
551 }
552
553 display_list
554 }
555}