Skip to main content

purple_ssh/app/
display_list.rs

1//! Display list construction, sorting and grouping. Implements `impl App`
2//! continuation with the builders that turn host + pattern entries into
3//! rendered list items, plus `apply_sort` and the group partitioning helpers.
4
5use 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    /// Build the display list with group headers from comments above host blocks.
13    /// Comments are associated with the host block directly below them (no blank line between).
14    /// Because the parser puts inter-block comments inside the preceding block's directives,
15    /// we also extract trailing comments from each HostBlock.
16    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                        // Blank line breaks the comment-to-host association
37                        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                    // Extract trailing comments from this block for the next host
57                    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        // Append pattern group at the bottom
75        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    /// Extract a trailing comment from a block's directives.
115    /// If the last non-blank line in the directives is a comment, return it as
116    /// a potential group header for the next host block.
117    /// Strips `purple:group ` prefix so headers display as the provider name.
118    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            // Skip purple metadata comments (purple:provider, purple:tags)
132            // Only purple:group should produce a group header
133            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        // Add file header for included files
158        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                    // Extract trailing comments from this block for the next host
198                    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    /// Rebuild the display list based on the current sort mode and group_by toggle.
217    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        // Preserve currently selected host or pattern across sort changes
225        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        // Multi-select indices become visually misleading after reorder
231        self.hosts_state.multi_select.clear();
232        // display_list is about to be rebuilt; group_alias_map depends on it
233        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            // Original order but grouped: extract flat indices from config order
247            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        // Append pattern group at the bottom (sorted/grouped paths skip
314        // build_display_list_from which already handles this)
315        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        // Compute group host counts before group filtering
331        {
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        // Build group tab order. For tag mode, compute from host tags (matching
353        // render_group_bar's tab list). For provider mode, extract from GroupHeaders.
354        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        // Filter by group if active
389        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                // In tag mode, filter by host tags directly (GroupHeaders don't
395                // cover all tags, only the active GroupBy tag).
396                for item in std::mem::take(&mut self.hosts_state.display_list) {
397                    match &item {
398                        HostListItem::GroupHeader(_) => {} // skip all headers
399                        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                // In provider/none mode, filter by GroupHeader matching
422                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        // Restore selection by alias, fall back to first host
441        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    /// Select the first selectable item in the display list (always skips headers).
451    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    /// Partition sorted indices by provider, inserting group headers.
463    /// Hosts without provider appear first (no header), then named provider
464    /// groups (in first-appearance order) with headers.
465    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        // Non-provider hosts first (no header)
506        for idx in &none_indices {
507            display_list.push(HostListItem::Host { index: *idx });
508        }
509
510        // Then provider groups with headers
511        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    /// Partition sorted indices by a user tag, inserting a group header.
522    /// Hosts without the tag appear first (no header), then hosts with the
523    /// tag appear under a single group header.
524    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}