use std::path::Path;
use ratatui::widgets::ListState;
use super::{
BulkTagAction, BulkTagApplyResult, BulkTagEditorState, BulkTagRow, HostListItem,
ProxyJumpCandidate, Screen,
};
use crate::app::App;
use crate::ssh_config::model::{HostEntry, PatternEntry};
use crate::ssh_keys;
impl App {
pub fn selected_host_index(&self) -> Option<usize> {
if self.search.query.is_some() {
let sel = self.ui.list_state.selected()?;
self.search.filtered_indices.get(sel).copied()
} else {
let sel = self.ui.list_state.selected()?;
match self.display_list.get(sel) {
Some(HostListItem::Host { index }) => Some(*index),
_ => None,
}
}
}
pub fn selected_host(&self) -> Option<&HostEntry> {
self.selected_host_index().and_then(|i| self.hosts.get(i))
}
pub fn selected_pattern(&self) -> Option<&PatternEntry> {
if self.search.query.is_some() {
let sel = self.ui.list_state.selected()?;
let host_count = self.search.filtered_indices.len();
if sel >= host_count {
let pattern_idx = sel - host_count;
return self
.search
.filtered_pattern_indices
.get(pattern_idx)
.and_then(|&i| self.patterns.get(i));
}
return None;
}
let sel = self.ui.list_state.selected()?;
match self.display_list.get(sel) {
Some(HostListItem::Pattern { index }) => self.patterns.get(*index),
_ => None,
}
}
pub fn is_pattern_selected(&self) -> bool {
if self.search.query.is_some() {
let Some(sel) = self.ui.list_state.selected() else {
return false;
};
let total =
self.search.filtered_indices.len() + self.search.filtered_pattern_indices.len();
return sel >= self.search.filtered_indices.len() && sel < total;
}
let Some(sel) = self.ui.list_state.selected() else {
return false;
};
matches!(
self.display_list.get(sel),
Some(HostListItem::Pattern { .. })
)
}
pub fn select_prev(&mut self) {
self.ui.detail_scroll = 0;
if self.search.query.is_some() {
let total =
self.search.filtered_indices.len() + self.search.filtered_pattern_indices.len();
super::cycle_selection(&mut self.ui.list_state, total, false);
} else {
self.select_prev_in_display_list();
}
}
pub fn select_next(&mut self) {
self.ui.detail_scroll = 0;
if self.search.query.is_some() {
let total =
self.search.filtered_indices.len() + self.search.filtered_pattern_indices.len();
super::cycle_selection(&mut self.ui.list_state, total, true);
} else {
self.select_next_in_display_list();
}
}
fn select_next_in_display_list(&mut self) {
if self.display_list.is_empty() {
return;
}
let len = self.display_list.len();
let current = self.ui.list_state.selected().unwrap_or(0);
for offset in 1..=len {
let idx = (current + offset) % len;
if matches!(
&self.display_list[idx],
HostListItem::Host { .. } | HostListItem::Pattern { .. }
) {
self.ui.list_state.select(Some(idx));
return;
}
}
}
fn select_prev_in_display_list(&mut self) {
if self.display_list.is_empty() {
return;
}
let len = self.display_list.len();
let current = self.ui.list_state.selected().unwrap_or(0);
for offset in 1..=len {
let idx = (current + len - offset) % len;
if matches!(
&self.display_list[idx],
HostListItem::Host { .. } | HostListItem::Pattern { .. }
) {
self.ui.list_state.select(Some(idx));
return;
}
}
}
pub fn page_down_host(&mut self) {
self.ui.detail_scroll = 0;
const PAGE_SIZE: usize = 10;
if self.search.query.is_some() {
super::page_down(
&mut self.ui.list_state,
self.search.filtered_indices.len(),
PAGE_SIZE,
);
} else {
let current = self.ui.list_state.selected().unwrap_or(0);
let mut target = current;
let mut items_skipped = 0;
let len = self.display_list.len();
for i in (current + 1)..len {
if matches!(
self.display_list[i],
HostListItem::Host { .. } | HostListItem::Pattern { .. }
) {
target = i;
items_skipped += 1;
if items_skipped >= PAGE_SIZE {
break;
}
}
}
if target != current {
self.ui.list_state.select(Some(target));
self.update_group_tab_follow();
}
}
}
pub fn page_up_host(&mut self) {
self.ui.detail_scroll = 0;
const PAGE_SIZE: usize = 10;
if self.search.query.is_some() {
super::page_up(
&mut self.ui.list_state,
self.search.filtered_indices.len(),
PAGE_SIZE,
);
} else {
let current = self.ui.list_state.selected().unwrap_or(0);
let mut target = current;
let mut items_skipped = 0;
for i in (0..current).rev() {
if matches!(
self.display_list[i],
HostListItem::Host { .. } | HostListItem::Pattern { .. }
) {
target = i;
items_skipped += 1;
if items_skipped >= PAGE_SIZE {
break;
}
}
}
if target != current {
self.ui.list_state.select(Some(target));
self.update_group_tab_follow();
}
}
}
pub fn scan_keys(&mut self) {
if let Some(home) = dirs::home_dir() {
let ssh_dir = home.join(".ssh");
self.keys = ssh_keys::discover_keys(Path::new(&ssh_dir), &self.hosts);
if !self.keys.is_empty() && self.ui.key_list_state.selected().is_none() {
self.ui.key_list_state.select(Some(0));
}
}
}
pub fn select_prev_key(&mut self) {
super::cycle_selection(&mut self.ui.key_list_state, self.keys.len(), false);
}
pub fn select_next_key(&mut self) {
super::cycle_selection(&mut self.ui.key_list_state, self.keys.len(), true);
}
pub fn select_prev_picker_key(&mut self) {
super::cycle_selection(&mut self.ui.key_picker_state, self.keys.len(), false);
}
pub fn select_next_picker_key(&mut self) {
super::cycle_selection(&mut self.ui.key_picker_state, self.keys.len(), true);
}
pub fn select_prev_password_source(&mut self) {
super::cycle_selection(
&mut self.ui.password_picker_state,
crate::askpass::PASSWORD_SOURCES.len(),
false,
);
}
pub fn select_next_password_source(&mut self) {
super::cycle_selection(
&mut self.ui.password_picker_state,
crate::askpass::PASSWORD_SOURCES.len(),
true,
);
}
pub fn proxyjump_candidates(&self) -> Vec<ProxyJumpCandidate> {
let editing_alias = match &self.screen {
Screen::EditHost { alias, .. } => Some(alias.as_str()),
_ => None,
};
let editing_hostname = match &self.screen {
Screen::EditHost { alias, .. } => self
.hosts
.iter()
.find(|h| h.alias == *alias)
.map(|h| h.hostname.as_str()),
_ => None,
};
let editing_suffix = editing_hostname.and_then(domain_suffix);
let usage_counts = proxyjump_usage_counts(&self.hosts, editing_alias);
let mut scored = score_proxyjump_candidates(
&self.hosts,
editing_alias,
editing_suffix.as_deref(),
&usage_counts,
);
scored.sort_by(|(sa, a), (sb, b)| sb.cmp(sa).then_with(|| a.alias.cmp(&b.alias)));
let suggested: Vec<&HostEntry> = scored
.iter()
.filter(|(s, _)| *s > 0)
.take(3)
.map(|(_, h)| *h)
.collect();
let suggested_aliases: std::collections::HashSet<&str> =
suggested.iter().map(|h| h.alias.as_str()).collect();
scored.sort_by(|(_, a), (_, b)| a.alias.cmp(&b.alias));
let rest: Vec<&HostEntry> = scored
.into_iter()
.map(|(_, h)| h)
.filter(|h| !suggested_aliases.contains(h.alias.as_str()))
.collect();
build_proxyjump_items(&suggested, &rest)
}
pub fn proxyjump_first_host_index(&self) -> Option<usize> {
self.proxyjump_candidates()
.iter()
.position(|c| matches!(c, ProxyJumpCandidate::Host { .. }))
}
pub fn select_prev_proxyjump(&mut self) {
step_proxyjump_selection(self, false);
}
pub fn select_next_proxyjump(&mut self) {
step_proxyjump_selection(self, true);
}
pub fn vault_role_candidates(&self) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut roles = Vec::new();
for host in &self.hosts {
if let Some(ref role) = host.vault_ssh {
if seen.insert(role.clone()) {
roles.push(role.clone());
}
}
}
for section in &self.provider_config.sections {
let role = section.vault_role.trim();
if !role.is_empty() && seen.insert(role.to_string()) {
roles.push(role.to_string());
}
}
roles.sort();
roles
}
pub fn select_prev_vault_role(&mut self) {
let len = self.vault_role_candidates().len();
super::cycle_selection(&mut self.ui.vault_role_picker_state, len, false);
}
pub fn select_next_vault_role(&mut self) {
let len = self.vault_role_candidates().len();
super::cycle_selection(&mut self.ui.vault_role_picker_state, len, true);
}
pub fn collect_unique_tags(&self) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut tags = Vec::new();
let mut has_stale = false;
let mut has_vault_ssh = false;
let mut has_vault_kv = false;
for host in &self.hosts {
for tag in host.provider_tags.iter().chain(host.tags.iter()) {
if seen.insert(tag.clone()) {
tags.push(tag.clone());
}
}
if let Some(ref provider) = host.provider {
if seen.insert(provider.clone()) {
tags.push(provider.clone());
}
}
if host.stale.is_some() {
has_stale = true;
}
if crate::vault_ssh::resolve_vault_role(
host.vault_ssh.as_deref(),
host.provider.as_deref(),
&self.provider_config,
)
.is_some()
{
has_vault_ssh = true;
}
if host
.askpass
.as_deref()
.map(|s| s.starts_with("vault:"))
.unwrap_or(false)
{
has_vault_kv = true;
}
}
for pattern in &self.patterns {
for tag in &pattern.tags {
if seen.insert(tag.clone()) {
tags.push(tag.clone());
}
}
}
if has_stale && seen.insert("stale".to_string()) {
tags.push("stale".to_string());
}
if !has_vault_ssh {
for section in &self.provider_config.sections {
if !section.vault_role.is_empty() {
has_vault_ssh = true;
break;
}
}
}
if has_vault_ssh && seen.insert("vault-ssh".to_string()) {
tags.push("vault-ssh".to_string());
}
if has_vault_kv && seen.insert("vault-kv".to_string()) {
tags.push("vault-kv".to_string());
}
tags.sort_by_cached_key(|a| a.to_lowercase());
tags
}
pub fn open_bulk_tag_editor(&mut self) -> bool {
let mut aliases: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
let mut alias_set: std::collections::HashSet<String> = std::collections::HashSet::new();
for &idx in &self.multi_select {
if let Some(host) = self.hosts.get(idx) {
if !alias_set.insert(host.alias.clone()) {
continue;
}
if host.source_file.is_some() {
skipped.push(host.alias.clone());
}
aliases.push(host.alias.clone());
}
}
if aliases.is_empty() {
return false;
}
aliases.sort();
skipped.sort();
let mut candidate_tags: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
for host in &self.hosts {
for tag in &host.tags {
candidate_tags.insert(tag.clone());
}
}
for pattern in &self.patterns {
for tag in &pattern.tags {
candidate_tags.insert(tag.clone());
}
}
let selected_set: std::collections::HashSet<&str> =
aliases.iter().map(|s| s.as_str()).collect();
let rows: Vec<BulkTagRow> = candidate_tags
.into_iter()
.map(|tag| {
let initial_count = self
.hosts
.iter()
.filter(|h| selected_set.contains(h.alias.as_str()))
.filter(|h| h.tags.iter().any(|t| t == &tag))
.count();
BulkTagRow {
tag,
initial_count,
action: BulkTagAction::Leave,
}
})
.collect();
self.bulk_tag_editor = BulkTagEditorState {
rows,
aliases,
skipped_included: skipped,
new_tag_input: None,
new_tag_cursor: 0,
};
self.ui.bulk_tag_editor_state = ListState::default();
if !self.bulk_tag_editor.rows.is_empty() {
self.ui.bulk_tag_editor_state.select(Some(0));
}
self.screen = Screen::BulkTagEditor;
true
}
pub fn bulk_tag_editor_next(&mut self) {
super::cycle_selection(
&mut self.ui.bulk_tag_editor_state,
self.bulk_tag_editor.rows.len(),
true,
);
}
pub fn bulk_tag_editor_prev(&mut self) {
super::cycle_selection(
&mut self.ui.bulk_tag_editor_state,
self.bulk_tag_editor.rows.len(),
false,
);
}
pub fn bulk_tag_editor_cycle_current(&mut self) {
let Some(idx) = self.ui.bulk_tag_editor_state.selected() else {
return;
};
if let Some(row) = self.bulk_tag_editor.rows.get_mut(idx) {
row.action = row.action.cycle();
}
}
pub fn bulk_tag_editor_commit_new_tag(&mut self) {
let Some(input) = self.bulk_tag_editor.new_tag_input.take() else {
return;
};
self.bulk_tag_editor.new_tag_cursor = 0;
let tag = input.trim().to_string();
if tag.is_empty() {
return;
}
if let Some(existing) = self.bulk_tag_editor.rows.iter().position(|r| r.tag == tag) {
self.bulk_tag_editor.rows[existing].action = BulkTagAction::AddToAll;
self.ui.bulk_tag_editor_state.select(Some(existing));
return;
}
let row = BulkTagRow {
tag,
initial_count: 0,
action: BulkTagAction::AddToAll,
};
let insert_at = self.bulk_tag_editor.rows.len();
self.bulk_tag_editor.rows.push(row);
self.ui.bulk_tag_editor_state.select(Some(insert_at));
}
pub fn bulk_tag_apply(&mut self) -> Result<BulkTagApplyResult, String> {
if self.bulk_tag_editor.aliases.is_empty() {
return Err("No hosts selected.".to_string());
}
let aliases = self.bulk_tag_editor.aliases.clone();
let rows = self.bulk_tag_editor.rows.clone();
let skipped_set: std::collections::HashSet<&str> = self
.bulk_tag_editor
.skipped_included
.iter()
.map(|s| s.as_str())
.collect();
let has_pending = rows.iter().any(|r| r.action != BulkTagAction::Leave);
if !has_pending {
return Ok(BulkTagApplyResult {
skipped_included: skipped_set.len(),
..Default::default()
});
}
let mut changed_hosts: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut added = 0usize;
let mut removed = 0usize;
let mut skipped_included = 0usize;
let mut undo_snapshot: Vec<(String, Vec<String>)> = Vec::new();
for alias in &aliases {
if skipped_set.contains(alias.as_str()) {
skipped_included += 1;
continue;
}
let Some(host) = self.hosts.iter().find(|h| &h.alias == alias) else {
continue;
};
let original_tags = host.tags.clone();
let mut new_tags = original_tags.clone();
let mut host_changed = false;
for row in &rows {
match row.action {
BulkTagAction::Leave => {}
BulkTagAction::AddToAll => {
if !new_tags.iter().any(|t| t == &row.tag) {
new_tags.push(row.tag.clone());
added += 1;
host_changed = true;
}
}
BulkTagAction::RemoveFromAll => {
let before = new_tags.len();
new_tags.retain(|t| t != &row.tag);
if new_tags.len() != before {
removed += 1;
host_changed = true;
}
}
}
}
if host_changed {
self.config.set_host_tags(alias, &new_tags);
changed_hosts.insert(alias.clone());
undo_snapshot.push((alias.clone(), original_tags));
}
}
if changed_hosts.is_empty() {
return Ok(BulkTagApplyResult {
skipped_included,
..Default::default()
});
}
let config_backup = self.config.clone();
if let Err(e) = self.config.write() {
log::error!("[purple] bulk tag apply write failed: {e}");
self.config = config_backup;
return Err(format!("Failed to save: {}", e));
}
log::debug!(
"bulk tag apply: {} hosts, +{} -{}, skipped {}",
changed_hosts.len(),
added,
removed,
skipped_included
);
if !undo_snapshot.is_empty() {
self.bulk_tag_undo = Some(undo_snapshot);
}
self.update_last_modified();
self.reload_hosts();
Ok(BulkTagApplyResult {
changed_hosts: changed_hosts.len(),
added,
removed,
skipped_included,
})
}
pub fn open_tag_picker(&mut self) {
self.tags.list = self.collect_unique_tags();
self.ui.tag_picker_state = ListState::default();
if !self.tags.list.is_empty() {
self.ui.tag_picker_state.select(Some(0));
}
self.screen = Screen::TagPicker;
}
pub fn select_prev_tag(&mut self) {
super::cycle_selection(&mut self.ui.tag_picker_state, self.tags.list.len(), false);
}
pub fn select_next_tag(&mut self) {
super::cycle_selection(&mut self.ui.tag_picker_state, self.tags.list.len(), true);
}
pub fn refresh_tunnel_list(&mut self, alias: &str) {
self.tunnel_list = self.config.find_tunnel_directives(alias);
}
pub fn select_prev_tunnel(&mut self) {
super::cycle_selection(
&mut self.ui.tunnel_list_state,
self.tunnel_list.len(),
false,
);
}
pub fn select_next_tunnel(&mut self) {
super::cycle_selection(&mut self.ui.tunnel_list_state, self.tunnel_list.len(), true);
}
pub fn select_prev_snippet(&mut self) {
super::cycle_selection(
&mut self.ui.snippet_picker_state,
self.snippet_store.snippets.len(),
false,
);
}
pub fn select_next_snippet(&mut self) {
super::cycle_selection(
&mut self.ui.snippet_picker_state,
self.snippet_store.snippets.len(),
true,
);
}
pub fn select_next_skipping_headers(&mut self) {
let current = self.ui.list_state.selected().unwrap_or(0);
for i in (current + 1)..self.display_list.len() {
if !matches!(self.display_list[i], HostListItem::GroupHeader(_)) {
self.ui.list_state.select(Some(i));
self.update_group_tab_follow();
return;
}
}
}
pub fn select_prev_skipping_headers(&mut self) {
let current = self.ui.list_state.selected().unwrap_or(0);
for i in (0..current).rev() {
if !matches!(self.display_list[i], HostListItem::GroupHeader(_)) {
self.ui.list_state.select(Some(i));
self.update_group_tab_follow();
return;
}
}
}
}
const JUMP_KEYWORDS: &[&str] = &["jump", "bastion", "gateway", "proxy", "gw"];
fn proxyjump_usage_counts(
hosts: &[HostEntry],
editing_alias: Option<&str>,
) -> std::collections::HashMap<String, u32> {
let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
for h in hosts {
if h.proxy_jump.is_empty() || editing_alias == Some(h.alias.as_str()) {
continue;
}
for hop in parse_proxy_jump_hops(&h.proxy_jump) {
*counts.entry(hop).or_insert(0) += 1;
}
}
counts
}
fn score_proxyjump_candidates<'a>(
hosts: &'a [HostEntry],
editing_alias: Option<&str>,
editing_suffix: Option<&str>,
usage_counts: &std::collections::HashMap<String, u32>,
) -> Vec<(u32, &'a HostEntry)> {
hosts
.iter()
.filter(|h| editing_alias.is_none_or(|a| h.alias != a))
.map(|h| {
let usage = usage_counts.get(&h.alias).copied().unwrap_or(0);
let kw = has_jump_keyword(&h.alias, &h.hostname);
let same = editing_suffix
.and_then(|suf| domain_suffix(&h.hostname).map(|s| s == suf))
.unwrap_or(false);
let score = usage * 10 + u32::from(kw) * 5 + u32::from(same) * 3;
(score, h)
})
.collect()
}
fn build_proxyjump_items(suggested: &[&HostEntry], rest: &[&HostEntry]) -> Vec<ProxyJumpCandidate> {
let mut items = Vec::with_capacity(suggested.len() + rest.len() + 2);
if !suggested.is_empty() {
items.push(ProxyJumpCandidate::SectionLabel("Suggestions"));
}
for h in suggested {
items.push(ProxyJumpCandidate::Host {
alias: h.alias.clone(),
hostname: h.hostname.clone(),
suggested: true,
});
}
if !suggested.is_empty() && !rest.is_empty() {
items.push(ProxyJumpCandidate::Separator);
}
for h in rest {
items.push(ProxyJumpCandidate::Host {
alias: h.alias.clone(),
hostname: h.hostname.clone(),
suggested: false,
});
}
items
}
pub(crate) fn parse_proxy_jump_hops(proxy_jump: &str) -> Vec<String> {
proxy_jump
.split(',')
.filter_map(|hop| {
let h = hop.trim();
if h.is_empty() {
return None;
}
let h = h.split_once('@').map_or(h, |(_, host)| host);
let h = if let Some(bracketed) = h.strip_prefix('[') {
let (inner, _) = bracketed.split_once(']')?;
inner
} else {
h.rsplit_once(':').map_or(h, |(host, _)| host)
};
if h.is_empty() {
None
} else {
Some(h.to_string())
}
})
.collect()
}
pub(crate) fn has_jump_keyword(alias: &str, hostname: &str) -> bool {
let a = alias.to_ascii_lowercase();
let h = hostname.to_ascii_lowercase();
JUMP_KEYWORDS
.iter()
.any(|kw| a.contains(kw) || h.contains(kw))
}
pub(crate) fn domain_suffix(hostname: &str) -> Option<String> {
let h = hostname.trim();
if h.is_empty() || h.starts_with('[') {
return None;
}
if h.parse::<std::net::IpAddr>().is_ok() {
return None;
}
let labels: Vec<&str> = h.split('.').collect();
if labels.len() < 2 {
return None;
}
let mut end = labels.len();
while end > 0 && labels[end - 1].is_empty() {
end -= 1;
}
if end < 2 {
return None;
}
let tail = &labels[end - 2..end];
Some(tail.join(".").to_ascii_lowercase())
}
fn step_proxyjump_selection(app: &mut App, forward: bool) {
let candidates = app.proxyjump_candidates();
let len = candidates.len();
if len == 0 {
app.ui.proxyjump_picker_state.select(None);
return;
}
let seed: usize = match app.ui.proxyjump_picker_state.selected() {
Some(idx) => idx,
None if forward => len - 1,
None => 0,
};
let mut next = seed;
for _ in 0..len {
next = if forward {
(next + 1) % len
} else {
(next + len - 1) % len
};
if matches!(candidates.get(next), Some(ProxyJumpCandidate::Host { .. })) {
app.ui.proxyjump_picker_state.select(Some(next));
return;
}
}
}