use std::collections::HashMap;
use std::path::PathBuf;
use ratatui::widgets::ListState;
use crate::history::ConnectionHistory;
use crate::ssh_config::model::SshConfigFile;
use crate::ssh_keys::SshKeyInfo;
pub(super) fn contains_ci(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
if haystack.is_ascii() && needle.is_ascii() {
return haystack
.as_bytes()
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle.as_bytes()));
}
let needle_lower: Vec<char> = needle.chars().map(|c| c.to_ascii_lowercase()).collect();
let haystack_chars: Vec<char> = haystack.chars().collect();
haystack_chars.windows(needle_lower.len()).any(|window| {
window
.iter()
.zip(needle_lower.iter())
.all(|(h, n)| h.to_ascii_lowercase() == *n)
})
}
pub(super) fn eq_ci(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b)
}
mod baselines;
mod container_state;
mod containers_overview;
mod display_list;
mod form_state;
mod forms;
mod groups;
mod host_state;
mod hosts;
pub(crate) mod jump;
pub(crate) mod ping;
mod provider_state;
mod reload_state;
mod screen;
mod search;
mod selection;
mod snippet_state;
mod status_state;
mod tag_state;
mod tunnel_state;
mod ui_state;
mod update;
mod vault;
pub use baselines::{FormBaseline, ProviderFormBaseline, SnippetFormBaseline, TunnelFormBaseline};
pub use container_state::ContainerState;
pub use containers_overview::{
ContainerActionRequest, ContainerExecRequest, ContainerLogsRequest, ContainersOverviewState,
ContainersSortMode, InspectCacheEntry, LIST_CACHE_TTL_SECS, LOGS_TAIL, LogsCacheEntry,
REFRESH_MAX_PARALLEL, RefreshBatch, RefreshQueueItem,
};
pub use form_state::FormState;
pub(crate) use forms::char_to_byte_pos;
pub use forms::{
FormField, HostForm, ProviderFormField, ProviderFormFields, SnippetForm, SnippetFormField,
SnippetHostOutput, SnippetOutputState, SnippetParamFormState, TunnelForm, TunnelFormField,
};
pub use host_state::{
DeletedHost, GroupBy, HostListItem, HostState, ProxyJumpCandidate, SortMode, ViewMode,
health_summary_spans, health_summary_spans_for,
};
pub use ping::{
PingState, PingStatus, classify_ping, ping_sort_key, propagate_ping_to_dependents, status_glyph,
};
pub use provider_state::{
LabelMigrationField, PendingLabelMigration, ProviderRow, ProviderState, SyncRecord,
};
pub use reload_state::{ConflictState, ReloadState};
pub use screen::{Screen, StackMember, TopPage, WhatsNewState};
pub use search::SearchState;
pub use snippet_state::SnippetState;
pub use status_state::{MessageClass, StatusCenter, StatusMessage};
pub use tag_state::{
BulkTagAction, BulkTagApplyResult, BulkTagEditorState, BulkTagRow, TagState,
select_display_tags,
};
pub use tunnel_state::{TunnelSortMode, TunnelState};
pub use ui_state::UiSelection;
pub use update::UpdateState;
pub use vault::VaultState;
impl Drop for App {
fn drop(&mut self) {
for (alias, mut tunnel) in self.tunnels.active.drain() {
if let Err(e) = tunnel.child.kill() {
log::debug!("[external] Failed to kill tunnel for {alias} on shutdown: {e}");
}
let _ = tunnel.child.wait();
}
if let Some(ref cancel) = self.vault.signing_cancel {
cancel.store(true, std::sync::atomic::Ordering::Relaxed);
}
if let Some(handle) = self.vault.sign_thread.take() {
let _ = handle.join();
}
}
}
pub struct App {
pub screen: Screen,
pub top_page: TopPage,
pub running: bool,
pub hosts_state: HostState,
pub pending_connect: Option<(String, Option<String>)>,
pub pending_container_exec: Option<ContainerExecRequest>,
pub pending_container_logs: Option<ContainerLogsRequest>,
pub pending_container_actions: std::collections::VecDeque<ContainerActionRequest>,
pub pending_container_fetch_aliases: Vec<String>,
pub status_center: StatusCenter,
pub ui: UiSelection,
pub search: SearchState,
pub reload: ReloadState,
pub conflict: ConflictState,
pub keys: Vec<SshKeyInfo>,
pub tags: TagState,
pub forms: FormState,
pub history: ConnectionHistory,
pub detail_toggle_pending: bool,
pub providers: ProviderState,
pub ping: PingState,
pub vault: VaultState,
pub tunnels: TunnelState,
pub snippets: SnippetState,
pub update: UpdateState,
pub bw_session: Option<String>,
pub file_browser: Option<crate::file_browser::FileBrowserState>,
pub file_browser_paths: HashMap<String, (PathBuf, String)>,
pub container_state: Option<ContainerState>,
pub container_cache: HashMap<String, crate::containers::ContainerCacheEntry>,
pub containers_overview: ContainersOverviewState,
pub known_hosts_count: usize,
pub welcome_opened: Option<std::time::Instant>,
pub demo_mode: bool,
pub pending_vault_config_write: bool,
pub jump: Option<JumpState>,
pub esc_quit_hint_shown: bool,
}
impl App {
pub fn new(config: SshConfigFile) -> Self {
let hosts = config.host_entries();
let patterns = config.pattern_entries();
let display_list = Self::build_display_list_from(&config, &hosts, &patterns);
let initial_selection = display_list.iter().position(|item| {
matches!(
item,
HostListItem::Host { .. } | HostListItem::Pattern { .. }
)
});
let reload = ReloadState::from_config(&config);
let hosts_state = HostState::from_config(config, hosts, patterns, display_list);
Self {
screen: Screen::HostList,
top_page: TopPage::default(),
running: true,
hosts_state,
pending_connect: None,
pending_container_exec: None,
pending_container_logs: None,
pending_container_actions: std::collections::VecDeque::new(),
pending_container_fetch_aliases: Vec::new(),
status_center: StatusCenter::default(),
ui: UiSelection::new_with_initial_selection(initial_selection),
search: SearchState::default(),
reload,
conflict: ConflictState::default(),
keys: Vec::new(),
tags: TagState::default(),
forms: FormState::default(),
history: ConnectionHistory::load(),
detail_toggle_pending: false,
providers: ProviderState::load(),
ping: PingState::from_preferences(),
vault: VaultState::default(),
tunnels: TunnelState::default(),
snippets: SnippetState::with_store_loaded(),
update: UpdateState::with_current_hint(),
bw_session: None,
file_browser: None,
file_browser_paths: HashMap::new(),
container_state: None,
container_cache: crate::containers::load_container_cache(),
containers_overview: ContainersOverviewState::default(),
known_hosts_count: 0,
welcome_opened: None,
demo_mode: false,
pending_vault_config_write: false,
jump: None,
esc_quit_hint_shown: false,
}
}
pub fn snapshot_alias_set(&self) -> std::collections::HashSet<String> {
self.hosts_state
.list
.iter()
.map(|h| h.alias.clone())
.collect()
}
pub fn queue_new_aliases_since(&mut self, before_aliases: &std::collections::HashSet<String>) {
for h in &self.hosts_state.list {
if !before_aliases.contains(&h.alias) {
self.pending_container_fetch_aliases.push(h.alias.clone());
}
}
}
pub fn reload_hosts(&mut self) {
let had_pending_vault_write = self.pending_vault_config_write;
let mut flushed_vault_write = false;
if self.pending_vault_config_write && !self.is_form_open() {
match self.hosts_state.ssh_config.write() {
Ok(()) => flushed_vault_write = true,
Err(e) => self.notify_error(crate::messages::vault_config_write_after_sign(&e)),
}
}
self.pending_vault_config_write = false;
log::debug!(
"[config] reload_hosts: pending_vault_write={had_pending_vault_write} flushed={flushed_vault_write}"
);
let had_search = self.search.query.take();
let selected_alias = self
.selected_host()
.map(|h| h.alias.clone())
.or_else(|| self.selected_pattern().map(|p| p.pattern.clone()));
self.tunnels.summaries_cache.clear();
self.hosts_state.render_cache.invalidate();
self.hosts_state.list = self.hosts_state.ssh_config.host_entries();
self.hosts_state.patterns = self.hosts_state.ssh_config.pattern_entries();
let valid_for_certs: std::collections::HashSet<&str> = self
.hosts_state
.list
.iter()
.map(|h| h.alias.as_str())
.collect();
self.vault
.cert_cache
.retain(|alias, _| valid_for_certs.contains(alias.as_str()));
self.vault
.cert_checks_in_flight
.retain(|alias| valid_for_certs.contains(alias.as_str()));
if self.hosts_state.sort_mode == SortMode::Original
&& matches!(self.hosts_state.group_by, GroupBy::None)
{
self.hosts_state.display_list = Self::build_display_list_from(
&self.hosts_state.ssh_config,
&self.hosts_state.list,
&self.hosts_state.patterns,
);
} else {
self.apply_sort();
}
if matches!(self.screen, Screen::TagPicker | Screen::BulkTagEditor) {
self.screen = Screen::HostList;
self.forms.bulk_tag_editor = BulkTagEditorState::default();
}
self.hosts_state.multi_select.clear();
let valid_aliases: std::collections::HashSet<&str> = self
.hosts_state
.list
.iter()
.map(|h| h.alias.as_str())
.collect();
let pre_container_cache = self.container_cache.len();
self.container_cache
.retain(|alias, _| valid_aliases.contains(alias.as_str()));
let dropped_container_hosts =
pre_container_cache.saturating_sub(self.container_cache.len());
if dropped_container_hosts > 0 {
log::debug!(
"[purple] reload_hosts: dropped {} orphan container_cache host(s)",
dropped_container_hosts
);
crate::containers::save_container_cache(&self.container_cache);
}
let valid_container_ids: std::collections::HashSet<String> = self
.container_cache
.values()
.flat_map(|e| e.containers.iter().map(|c| c.id.clone()))
.collect();
let pre_inspect = self.containers_overview.inspect_cache.entries.len();
self.containers_overview
.inspect_cache
.entries
.retain(|id, _| valid_container_ids.contains(id));
self.containers_overview
.inspect_cache
.in_flight
.retain(|id| valid_container_ids.contains(id));
self.containers_overview
.logs_cache
.entries
.retain(|id, _| valid_container_ids.contains(id));
self.containers_overview
.logs_cache
.in_flight
.retain(|id| valid_container_ids.contains(id));
self.containers_overview
.auto_list_in_flight
.retain(|alias| valid_aliases.contains(alias.as_str()));
let dropped_inspect =
pre_inspect.saturating_sub(self.containers_overview.inspect_cache.entries.len());
if dropped_inspect > 0 {
log::debug!(
"[purple] reload_hosts: dropped {} orphan inspect_cache entrie(s)",
dropped_inspect
);
}
let pre_status = self.ping.status.len();
let pre_checked = self.ping.last_checked.len();
self.ping
.status
.retain(|alias, _| valid_aliases.contains(alias.as_str()));
self.ping
.last_checked
.retain(|alias, _| valid_aliases.contains(alias.as_str()));
let dropped = pre_status.saturating_sub(self.ping.status.len())
+ pre_checked.saturating_sub(self.ping.last_checked.len());
if dropped > 0 {
log::debug!(
"[purple] reload_hosts: pruned {} orphan ping entrie(s); {} aliases remain",
dropped,
valid_aliases.len()
);
}
if let Some(query) = had_search {
self.search.query = Some(query);
self.apply_filter();
} else {
self.search.query = None;
self.search.filtered_indices.clear();
self.search.filtered_pattern_indices.clear();
if self.hosts_state.list.is_empty() && self.hosts_state.patterns.is_empty() {
self.ui.list_state.select(None);
} else if let Some(pos) = self.hosts_state.display_list.iter().position(|item| {
matches!(
item,
HostListItem::Host { .. } | HostListItem::Pattern { .. }
)
}) {
let current = self.ui.list_state.selected().unwrap_or(0);
if current >= self.hosts_state.display_list.len()
|| !matches!(
self.hosts_state.display_list.get(current),
Some(HostListItem::Host { .. } | HostListItem::Pattern { .. })
)
{
self.ui.list_state.select(Some(pos));
}
} else {
self.ui.list_state.select(None);
}
}
if let Some(alias) = selected_alias {
self.select_host_by_alias(&alias);
}
log::debug!(
"[config] reload_hosts: hosts={} patterns={} display_items={}",
self.hosts_state.list.len(),
self.hosts_state.patterns.len(),
self.hosts_state.display_list.len(),
);
}
pub fn refresh_cert_cache(&mut self, alias: &str) {
if crate::demo_flag::is_demo() {
return;
}
let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
self.vault.cert_cache.remove(alias);
return;
};
let role_some = crate::vault_ssh::resolve_vault_role(
host.vault_ssh.as_deref(),
host.provider.as_deref(),
host.provider_label.as_deref(),
&self.providers.config,
)
.is_some();
if !role_some {
self.vault.cert_cache.remove(alias);
return;
}
let cert_path = match crate::vault_ssh::resolve_cert_path(alias, &host.certificate_file) {
Ok(p) => p,
Err(_) => {
self.vault.cert_cache.remove(alias);
return;
}
};
let status = crate::vault_ssh::check_cert_validity(&cert_path);
let mtime = std::fs::metadata(&cert_path)
.ok()
.and_then(|m| m.modified().ok());
self.vault.cert_cache.insert(
alias.to_string(),
(std::time::Instant::now(), status, mtime),
);
}
#[cfg(test)]
pub fn sorted_provider_names(&self) -> Vec<String> {
self.providers.sorted_names()
}
pub fn is_form_open(&self) -> bool {
matches!(
self.screen,
Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
)
}
pub fn open_jump(&mut self, mode: JumpMode) {
log::debug!("jump: open mode={:?}", mode);
let mut state = JumpState::for_mode(mode);
let recents_file = jump::load_recents();
state.recents = self.resolve_recents(&recents_file);
self.jump = Some(state);
self.recompute_jump_hits();
}
fn resolve_recents(&self, file: &RecentsFile) -> Vec<JumpHit> {
let mode = self
.jump
.as_ref()
.map(|p| p.mode)
.unwrap_or(JumpMode::Hosts);
let mut out = Vec::with_capacity(file.entries.len());
for entry in &file.entries {
if let Some(hit) = self.resolve_recent_ref(&entry.target, mode) {
out.push(hit);
}
}
out
}
#[cfg(test)]
pub(crate) fn resolve_recent_ref_for_test(
&self,
r: &RecentRef,
mode: JumpMode,
) -> Option<JumpHit> {
self.resolve_recent_ref(r, mode)
}
fn resolve_recent_ref(&self, r: &RecentRef, mode: JumpMode) -> Option<JumpHit> {
match r.kind {
SourceKind::Action => {
let key_char = r.key.chars().next()?;
let actions = JumpAction::for_mode(mode);
actions
.iter()
.find(|a| a.key == key_char)
.copied()
.map(JumpHit::Action)
}
SourceKind::Host => {
let host = self.hosts_state.list.iter().find(|h| h.alias == r.key)?;
Some(JumpHit::Host(HostHit {
alias: host.alias.clone(),
hostname: host.hostname.clone(),
tags: host.tags.clone(),
provider: host.provider.clone(),
user: host.user.clone(),
identity_file: host.identity_file.clone(),
proxy_jump: host.proxy_jump.clone(),
vault_ssh: host.vault_ssh.clone(),
}))
}
SourceKind::Tunnel => {
let (alias, port_str) = r.key.split_once(':')?;
let port: u16 = port_str.parse().ok()?;
let rules = self.hosts_state.ssh_config.find_tunnel_directives(alias);
let rule = rules.iter().find(|r| r.bind_port == port)?;
Some(JumpHit::Tunnel(TunnelHit {
alias: alias.to_string(),
bind_port: rule.bind_port,
bind_port_str: rule.bind_port.to_string(),
destination: rule.display(),
active: self.tunnels.active.contains_key(alias),
}))
}
SourceKind::Container => {
let (alias, name) = r.key.split_once('/')?;
let entry = self.container_cache.get(alias)?;
let info = entry.containers.iter().find(|c| c.names == name)?;
Some(JumpHit::Container(ContainerHit {
alias: alias.to_string(),
container_name: info.names.clone(),
container_id: info.id.clone(),
state: info.state.clone(),
}))
}
SourceKind::Snippet => {
let snippet = self.snippets.store.get(&r.key)?;
Some(JumpHit::Snippet(SnippetHit {
name: snippet.name.clone(),
command_preview: preview(&snippet.command, 40),
}))
}
}
}
pub fn recompute_jump_hits(&mut self) {
let Some(mut state) = self.jump.take() else {
return;
};
let prior_identity = state
.visible_hits()
.get(state.selected)
.map(|h| h.identity());
let candidates = self.collect_jump_candidates(state.mode);
if state.query.is_empty() {
state.hits = candidates;
state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
self.jump = Some(state);
return;
}
let (scope, effective_query) = parse_query_scope(&state.query);
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};
let matcher_state = state
.matcher
.get_or_insert_with(|| Matcher::new(Config::DEFAULT));
let pattern = Pattern::parse(effective_query, CaseMatching::Smart, Normalization::Smart);
let mut buf: Vec<char> = Vec::new();
let mut scored: Vec<(JumpHit, u32)> = Vec::with_capacity(candidates.len());
for hit in candidates {
let mut best: u32 = 0;
let scoped_haystacks = scoped_haystacks_for(&hit, scope);
let haystacks: Vec<&str> = if let Some(hs) = scoped_haystacks {
hs
} else {
hit.haystacks()
};
for haystack in haystacks {
buf.clear();
let chars = Utf32Str::new(haystack, &mut buf);
if let Some(score) = pattern.score(chars, matcher_state) {
best = best.max(score);
}
}
if let JumpHit::Action(a) = &hit {
let single = effective_query.chars().next();
if effective_query.chars().count() == 1
&& single
.map(|c| c.eq_ignore_ascii_case(&a.key))
.unwrap_or(false)
{
let mode_match = matches!(
(state.mode, a.target),
(JumpMode::Hosts, JumpActionTarget::Hosts)
| (JumpMode::Tunnels, JumpActionTarget::Tunnels)
| (JumpMode::Containers, JumpActionTarget::Containers)
);
let bump = if mode_match { 20_000 } else { 10_000 };
best = best.saturating_add(bump);
}
}
let floor = match &hit {
JumpHit::Action(_) => PALETTE_ACTION_FLOOR,
_ => 1,
};
if best >= floor {
scored.push((hit, best));
}
}
scored.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| kind_rank(a.0.kind()).cmp(&kind_rank(b.0.kind())))
});
let mut per_kind: [usize; 5] = [0; 5];
let mut filtered: Vec<JumpHit> = Vec::with_capacity(scored.len().min(160));
for (hit, _) in scored {
let slot = kind_rank(hit.kind()) as usize;
if per_kind[slot] < PALETTE_PER_SECTION_CAP {
per_kind[slot] += 1;
filtered.push(hit);
}
}
state.hits = filtered;
state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
self.jump = Some(state);
}
fn collect_jump_candidates(&self, mode: JumpMode) -> Vec<JumpHit> {
let mut out: Vec<JumpHit> = Vec::new();
for h in &self.hosts_state.list {
out.push(JumpHit::Host(HostHit {
alias: h.alias.clone(),
hostname: h.hostname.clone(),
tags: h.tags.clone(),
provider: h.provider.clone(),
user: h.user.clone(),
identity_file: h.identity_file.clone(),
proxy_jump: h.proxy_jump.clone(),
vault_ssh: h.vault_ssh.clone(),
}));
}
for h in &self.hosts_state.list {
let rules = self.hosts_state.ssh_config.find_tunnel_directives(&h.alias);
for rule in rules {
out.push(JumpHit::Tunnel(TunnelHit {
alias: h.alias.clone(),
bind_port: rule.bind_port,
bind_port_str: rule.bind_port.to_string(),
destination: rule.display(),
active: self.tunnels.active.contains_key(&h.alias),
}));
}
}
for (alias, entry) in &self.container_cache {
for info in &entry.containers {
out.push(JumpHit::Container(ContainerHit {
alias: alias.clone(),
container_name: info.names.clone(),
container_id: info.id.clone(),
state: info.state.clone(),
}));
}
}
for snippet in &self.snippets.store.snippets {
out.push(JumpHit::Snippet(SnippetHit {
name: snippet.name.clone(),
command_preview: preview(&snippet.command, 40),
}));
}
for a in JumpAction::for_mode(mode) {
out.push(JumpHit::Action(*a));
}
out
}
pub fn record_jump_hit(&mut self, hit: &JumpHit) {
if self.demo_mode {
log::debug!("jump: record skipped (demo mode)");
return;
}
let mut file = jump::load_recents();
jump::touch_recent(&mut file, hit.identity());
if let Err(e) = jump::save_recents(&file) {
log::warn!("[purple] failed to save recents: {e}");
}
}
pub fn flush_pending_vault_write(&mut self) -> bool {
if !self.pending_vault_config_write || self.is_form_open() {
return false;
}
self.reload_hosts();
true
}
#[deprecated(note = "use notify() / notify_error() instead")]
#[allow(deprecated)]
pub fn set_status(&mut self, text: impl Into<String>, is_error: bool) {
self.status_center.set_status(text, is_error);
}
pub fn post_init(&mut self) {
let outcome = crate::onboarding::evaluate();
if let Some(text) = outcome.upgrade_toast {
self.enqueue_sticky_toast(text);
}
}
fn enqueue_sticky_toast(&mut self, text: String) {
log::debug!("[purple] enqueue sticky toast: {}", text);
let msg = StatusMessage {
text,
class: MessageClass::Success,
tick_count: 0,
sticky: true,
created_at: std::time::Instant::now(),
};
self.status_center.toast = Some(msg);
}
#[deprecated(note = "use notify_info() instead")]
#[allow(deprecated)]
pub fn set_info_status(&mut self, text: impl Into<String>) {
self.status_center.set_info_status(text);
}
#[deprecated(note = "use notify_background() / notify_background_error() instead")]
#[allow(deprecated)]
pub fn set_background_status(&mut self, text: impl Into<String>, is_error: bool) {
self.status_center.set_background_status(text, is_error);
}
#[deprecated(note = "use notify_progress() / notify_sticky_error() instead")]
#[allow(deprecated)]
pub fn set_sticky_status(&mut self, text: impl Into<String>, is_error: bool) {
self.status_center.set_sticky_status(text, is_error);
}
#[allow(deprecated)]
pub fn notify(&mut self, text: impl Into<String>) {
self.set_status(text, false);
}
#[allow(deprecated)]
pub fn notify_error(&mut self, text: impl Into<String>) {
self.set_status(text, true);
}
#[allow(deprecated)]
pub fn notify_background(&mut self, text: impl Into<String>) {
self.set_background_status(text, false);
}
#[allow(deprecated)]
pub fn notify_background_error(&mut self, text: impl Into<String>) {
self.set_background_status(text, true);
}
pub fn notify_warning(&mut self, text: impl Into<String>) {
let msg = StatusMessage {
text: text.into(),
class: MessageClass::Warning,
tick_count: 0,
sticky: false,
created_at: std::time::Instant::now(),
};
log::debug!("toast <- Warning: {}", msg.text);
self.status_center.push_toast(msg);
}
#[allow(deprecated)]
pub fn notify_progress(&mut self, text: impl Into<String>) {
self.set_sticky_status(text, false);
}
#[allow(deprecated)]
pub fn notify_sticky_error(&mut self, text: impl Into<String>) {
self.set_sticky_status(text, true);
}
#[allow(deprecated)]
pub fn notify_info(&mut self, text: impl Into<String>) {
self.set_info_status(text);
}
pub fn tick_status(&mut self) {
if !self.providers.syncing.is_empty() {
return;
}
if let Some(ref status) = self.status_center.status {
if status.sticky {
return;
}
let timeout_ms = status.timeout_ms();
if timeout_ms != u64::MAX && status.created_at.elapsed().as_millis() as u64 > timeout_ms
{
log::debug!("footer status expired: {}", status.text);
self.status_center.status = None;
}
}
}
pub fn tick_toast(&mut self) {
self.status_center.tick_toast();
}
pub fn check_config_changed(&mut self) {
if matches!(
self.screen,
Screen::AddHost
| Screen::EditHost { .. }
| Screen::ProviderForm { .. }
| Screen::TunnelList { .. }
| Screen::TunnelForm { .. }
| Screen::HostDetail { .. }
| Screen::SnippetPicker { .. }
| Screen::SnippetForm { .. }
| Screen::SnippetOutput { .. }
| Screen::SnippetParamForm { .. }
| Screen::FileBrowser { .. }
| Screen::Containers { .. }
| Screen::ConfirmDelete { .. }
| Screen::ConfirmHostKeyReset { .. }
| Screen::ConfirmPurgeStale { .. }
| Screen::ConfirmImport { .. }
| Screen::ConfirmVaultSign { .. }
| Screen::TagPicker
| Screen::BulkTagEditor
| Screen::ThemePicker
| Screen::WhatsNew(_)
) || self.tags.input.is_some()
{
return;
}
let current_mtime = reload_state::get_mtime(&self.reload.config_path);
let changed = current_mtime != self.reload.last_modified
|| self
.reload
.include_mtimes
.iter()
.any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
|| self
.reload
.include_dir_mtimes
.iter()
.any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime);
if changed {
if let Ok(new_config) = SshConfigFile::parse(&self.reload.config_path) {
let before_aliases = self.snapshot_alias_set();
self.hosts_state.ssh_config = new_config;
self.hosts_state.undo_stack.clear();
log::debug!(
"[config] external config change: clearing {} ping result(s) + timestamps",
self.ping.status.len()
);
self.ping.status.clear();
self.ping.last_checked.clear();
self.ping.filter_down_only = false;
self.ping.checked_at = None;
self.reload_hosts();
self.reload.last_modified = current_mtime;
self.reload.include_mtimes =
reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
self.reload.include_dir_mtimes =
reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
let count = self.hosts_state.list.len();
self.notify_background(crate::messages::config_reloaded(count));
self.queue_new_aliases_since(&before_aliases);
}
}
}
pub fn external_config_changed(&self) -> bool {
let current_mtime = reload_state::get_mtime(&self.reload.config_path);
current_mtime != self.reload.last_modified
|| self
.reload
.include_mtimes
.iter()
.any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
|| self
.reload
.include_dir_mtimes
.iter()
.any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
}
pub fn update_last_modified(&mut self) {
self.reload.last_modified = reload_state::get_mtime(&self.reload.config_path);
self.reload.include_mtimes =
reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
self.reload.include_dir_mtimes =
reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
}
pub fn has_any_vault_role(&self) -> bool {
for host in &self.hosts_state.list {
if host.vault_ssh.is_some() {
return true;
}
}
for section in &self.providers.config.sections {
if !section.vault_role.is_empty() {
return true;
}
}
false
}
pub fn poll_tunnels(&mut self) -> Vec<(String, String, bool)> {
self.tunnels.poll()
}
pub fn refresh_tunnel_bind_ports(&mut self) {
let mut ports: Vec<(String, u16, u32)> = Vec::new();
for (alias, tunnel) in &self.tunnels.active {
let pid = tunnel.child.id();
for rule in self.hosts_state.ssh_config.find_tunnel_directives(alias) {
ports.push((alias.clone(), rule.bind_port, pid));
}
}
self.tunnels.set_lsof_ports(ports);
}
}
pub(crate) fn cycle_selection(state: &mut ListState, len: usize, forward: bool) {
if len == 0 {
return;
}
let i = match state.selected() {
Some(i) => {
if forward {
if i >= len - 1 { 0 } else { i + 1 }
} else if i == 0 {
len - 1
} else {
i - 1
}
}
None => 0,
};
state.select(Some(i));
}
pub(crate) fn page_down(state: &mut ListState, len: usize, page_size: usize) {
if len == 0 {
return;
}
let current = state.selected().unwrap_or(0);
let next = (current + page_size).min(len - 1);
state.select(Some(next));
}
pub(crate) fn page_up(state: &mut ListState, len: usize, page_size: usize) {
if len == 0 {
return;
}
let current = state.selected().unwrap_or(0);
let prev = current.saturating_sub(page_size);
state.select(Some(prev));
}
pub use jump::{
ContainerHit, HostHit, JumpAction, JumpActionTarget, JumpHit, RecentRef, RecentsFile,
SnippetHit, SourceKind, TunnelHit,
};
#[cfg(test)]
pub type PaletteCommand = JumpAction;
static ALL_JUMP_ACTIONS: &[JumpAction] = &[
JumpAction {
key: 'a',
key_str: "a",
label: "Hosts: Add host",
aliases: &["new", "create"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'A',
key_str: "A",
label: "Hosts: Add pattern",
aliases: &["new pattern", "wildcard"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'e',
key_str: "e",
label: "Hosts: Edit host",
aliases: &["modify", "change"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'd',
key_str: "d",
label: "Hosts: Delete host",
aliases: &["remove", "rm"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'c',
key_str: "c",
label: "Hosts: Clone host",
aliases: &["duplicate", "copy"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'u',
key_str: "u",
label: "Hosts: Undo delete",
aliases: &["restore"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 't',
key_str: "t",
label: "Hosts: Tag host",
aliases: &["label", "category"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'i',
key_str: "i",
label: "Hosts: Show all directives",
aliases: &["raw", "config", "settings"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'y',
key_str: "y",
label: "Clipboard: Copy SSH command",
aliases: &["yank"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'x',
key_str: "x",
label: "Clipboard: Copy config block",
aliases: &["yank config"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'X',
key_str: "X",
label: "Hosts: Purge stale hosts",
aliases: &["clean", "cleanup"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'F',
key_str: "F",
label: "Files: Browse remote files",
aliases: &[
"browse",
"filesystem",
"scp",
"sftp",
"transfer",
"explorer",
"open",
],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'C',
key_str: "C",
label: "Containers: List containers",
aliases: &["docker", "podman", "ps", "open"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'K',
key_str: "K",
label: "Keys: Manage SSH keys",
aliases: &["identity", "id_rsa", "id_ed25519", "private key", "open"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'S',
key_str: "S",
label: "Providers: Manage cloud sync",
aliases: &["cloud", "aws", "gcp", "azure", "hetzner", "sync", "open"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'V',
key_str: "V",
label: "Vault: Sign certificate",
aliases: &["hashicorp", "ssh cert", "vault ssh"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'I',
key_str: "I",
label: "Hosts: Import from known_hosts",
aliases: &["known", "import"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'm',
key_str: "m",
label: "Settings: Switch theme",
aliases: &["color", "appearance", "dark", "light"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'n',
key_str: "n",
label: "Help: What's new",
aliases: &["changelog", "news", "release notes"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'r',
key_str: "r",
label: "Snippets: Run snippet",
aliases: &["execute", "command"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'R',
key_str: "R",
label: "Snippets: Run on all visible",
aliases: &["batch", "execute all"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'p',
key_str: "p",
label: "Hosts: Ping host",
aliases: &["health", "check"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'P',
key_str: "P",
label: "Hosts: Ping all hosts",
aliases: &["health all"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: '!',
key_str: "!",
label: "Hosts: Show down only",
aliases: &["filter offline", "down only"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'T',
key_str: "T",
label: "Tunnels: Manage tunnels",
aliases: &["forward", "port forward", "ssh -L", "ssh -R", "open"],
target: JumpActionTarget::Hosts,
},
JumpAction {
key: 'a',
key_str: "a",
label: "Tunnels: Add tunnel",
aliases: &["new tunnel", "create tunnel", "forward"],
target: JumpActionTarget::Tunnels,
},
JumpAction {
key: 'e',
key_str: "e",
label: "Tunnels: Edit tunnel",
aliases: &["modify tunnel"],
target: JumpActionTarget::Tunnels,
},
JumpAction {
key: 'd',
key_str: "d",
label: "Tunnels: Delete tunnel",
aliases: &["remove tunnel"],
target: JumpActionTarget::Tunnels,
},
JumpAction {
key: 's',
key_str: "s",
label: "Tunnels: Sort",
aliases: &["order tunnels"],
target: JumpActionTarget::Tunnels,
},
JumpAction {
key: 'R',
key_str: "R",
label: "Containers: Refresh all hosts",
aliases: &["reload containers", "fetch", "rescan"],
target: JumpActionTarget::Containers,
},
JumpAction {
key: 's',
key_str: "s",
label: "Containers: Cycle sort",
aliases: &["order containers", "sort by host", "sort by name"],
target: JumpActionTarget::Containers,
},
JumpAction {
key: 'v',
key_str: "v",
label: "Containers: Toggle detail panel",
aliases: &["show details", "hide details", "compact view"],
target: JumpActionTarget::Containers,
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum JumpMode {
#[default]
Hosts,
Tunnels,
Containers,
}
pub const PALETTE_PER_SECTION_CAP: usize = 32;
pub const JUMP_EMPTY_STATE_ACTIONS_CAP: usize = 6;
const EMPTY_STATE_TAB_BIAS: usize = 3;
const CATEGORY_PRIORITY: &[&str] = &[
"Hosts",
"Tunnels",
"Containers",
"Files",
"Vault",
"Keys",
"Providers",
"Snippets",
"Clipboard",
"Settings",
"Help",
];
const PALETTE_ACTION_FLOOR: u32 = 30;
pub fn parse_query_scope(query: &str) -> (Option<QueryScope>, &str) {
if let Some((prefix, rest)) = query.split_once(':') {
let scope = match prefix.trim() {
"user" => Some(QueryScope::User),
"host" => Some(QueryScope::Hostname),
"proxy" => Some(QueryScope::ProxyJump),
"vault" => Some(QueryScope::VaultSsh),
"tag" => Some(QueryScope::Tag),
_ => None,
};
if scope.is_some() {
return (scope, rest.trim_start());
}
}
(None, query)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueryScope {
User,
Hostname,
ProxyJump,
VaultSsh,
Tag,
}
fn preview(s: &str, max: usize) -> String {
let s = s.replace('\n', " ");
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max {
s
} else {
let mut out: String = chars.iter().take(max.saturating_sub(3)).collect();
out.push_str("...");
out
}
}
fn scoped_haystacks_for(hit: &JumpHit, scope: Option<QueryScope>) -> Option<Vec<&str>> {
let scope = scope?;
match (hit, scope) {
(JumpHit::Host(h), QueryScope::User) if !h.user.is_empty() => Some(vec![&h.user]),
(JumpHit::Host(h), QueryScope::Hostname) if !h.hostname.is_empty() => {
Some(vec![&h.hostname])
}
(JumpHit::Host(h), QueryScope::ProxyJump) if !h.proxy_jump.is_empty() => {
Some(vec![&h.proxy_jump])
}
(JumpHit::Host(h), QueryScope::VaultSsh) => h.vault_ssh.as_deref().map(|s| vec![s]),
(JumpHit::Host(h), QueryScope::Tag) => Some(h.tags.iter().map(|t| t.as_str()).collect()),
_ => None,
}
}
pub fn match_source_for_host(host: &HostHit, query: &str) -> Option<MatchSource> {
if query.is_empty() {
return None;
}
let q = query.to_lowercase();
let alias_hit = host.alias.to_lowercase().contains(&q);
let hostname_hit = host.hostname.to_lowercase().contains(&q);
if alias_hit || hostname_hit {
return None;
}
if !host.user.is_empty() && host.user.to_lowercase().contains(&q) {
return Some(MatchSource::User);
}
if !host.proxy_jump.is_empty() && host.proxy_jump.to_lowercase().contains(&q) {
return Some(MatchSource::ProxyJump);
}
if let Some(role) = &host.vault_ssh {
if role.to_lowercase().contains(&q) {
return Some(MatchSource::VaultSsh);
}
}
if !host.identity_file.is_empty() && host.identity_file.to_lowercase().contains(&q) {
return Some(MatchSource::IdentityFile);
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchSource {
User,
ProxyJump,
VaultSsh,
IdentityFile,
}
fn round_robin_actions_by_category(actions: impl Iterator<Item = JumpAction>) -> Vec<JumpHit> {
let mut buckets: Vec<(String, Vec<JumpAction>)> = Vec::new();
for action in actions {
let category = action
.label
.split_once(':')
.map(|(c, _)| c.trim().to_string())
.unwrap_or_else(|| "Other".to_string());
if let Some(slot) = buckets.iter_mut().find(|(c, _)| c == &category) {
slot.1.push(action);
} else {
buckets.push((category, vec![action]));
}
}
let priority_index = |cat: &str| -> usize {
CATEGORY_PRIORITY
.iter()
.position(|p| *p == cat)
.unwrap_or(usize::MAX)
};
buckets.sort_by_key(|(c, _)| priority_index(c));
let mut out: Vec<JumpHit> = Vec::new();
let mut depth = 0usize;
let max_depth = buckets.iter().map(|(_, v)| v.len()).max().unwrap_or(0);
while depth < max_depth {
for (_, bucket) in &buckets {
if let Some(action) = bucket.get(depth) {
out.push(JumpHit::Action(*action));
}
}
depth += 1;
}
out
}
fn round_robin_actions_with_bias(
actions: impl Iterator<Item = JumpAction>,
preferred: JumpActionTarget,
bump: usize,
) -> Vec<JumpHit> {
let collected: Vec<JumpAction> = actions.collect();
let biased: Vec<JumpAction> = collected
.iter()
.filter(|a| a.target == preferred)
.take(bump)
.copied()
.collect();
let biased_keys: std::collections::HashSet<char> = biased.iter().map(|a| a.key).collect();
let rest: Vec<JumpAction> = collected
.into_iter()
.filter(|a| !(biased_keys.contains(&a.key) && a.target == preferred))
.collect();
let mut out: Vec<JumpHit> = biased.into_iter().map(JumpHit::Action).collect();
out.extend(round_robin_actions_by_category(rest.into_iter()));
out
}
fn kind_rank(k: SourceKind) -> u8 {
match k {
SourceKind::Host => 0,
SourceKind::Tunnel => 1,
SourceKind::Container => 2,
SourceKind::Snippet => 3,
SourceKind::Action => 4,
}
}
fn restore_selection(hits: &[JumpHit], prior: Option<&RecentRef>, fallback: usize) -> usize {
if let Some(target) = prior {
if let Some(idx) = hits.iter().position(|h| &h.identity() == target) {
return idx;
}
}
fallback.min(hits.len().saturating_sub(1))
}
impl JumpAction {
#[cfg(test)]
pub fn all() -> &'static [JumpAction] {
ALL_JUMP_ACTIONS
}
pub fn for_mode(_mode: JumpMode) -> &'static [JumpAction] {
ALL_JUMP_ACTIONS
}
}
#[derive(Debug, Default)]
pub struct JumpState {
pub query: String,
pub selected: usize,
pub mode: JumpMode,
pub hits: Vec<JumpHit>,
pub recents: Vec<JumpHit>,
pub cursor_revealed: bool,
pub matcher: Option<nucleo_matcher::Matcher>,
}
impl Clone for JumpState {
fn clone(&self) -> Self {
Self {
query: self.query.clone(),
selected: self.selected,
mode: self.mode,
hits: self.hits.clone(),
recents: self.recents.clone(),
cursor_revealed: self.cursor_revealed,
matcher: None,
}
}
}
impl JumpState {
pub fn for_mode(mode: JumpMode) -> Self {
Self {
mode,
..Self::default()
}
}
pub fn push_query(&mut self, c: char) {
if self.query.len() < 64 {
self.query.push(c);
}
}
pub fn pop_query(&mut self) {
self.query.pop();
}
pub fn visible_hits(&self) -> Vec<JumpHit> {
if self.query.is_empty() {
let mut out: Vec<JumpHit> = self.recents.clone();
out.extend(self.empty_state_actions());
out
} else {
self.hits.clone()
}
}
fn filtered_actions_for_empty_state(&self) -> Vec<JumpAction> {
let recent_keys: std::collections::HashSet<RecentRef> =
self.recents.iter().map(|h| h.identity()).collect();
JumpAction::for_mode(self.mode)
.iter()
.filter(|a| {
let id = RecentRef::new(SourceKind::Action, a.key.to_string());
!recent_keys.contains(&id)
})
.copied()
.collect()
}
fn empty_state_actions(&self) -> Vec<JumpHit> {
let filtered = self.filtered_actions_for_empty_state();
let preferred_target = match self.mode {
JumpMode::Hosts => None,
JumpMode::Tunnels => Some(JumpActionTarget::Tunnels),
JumpMode::Containers => Some(JumpActionTarget::Containers),
};
let actions = match preferred_target {
Some(t) => round_robin_actions_with_bias(filtered.into_iter(), t, EMPTY_STATE_TAB_BIAS),
None => round_robin_actions_by_category(filtered.into_iter()),
};
actions
.into_iter()
.take(JUMP_EMPTY_STATE_ACTIONS_CAP)
.collect()
}
pub fn empty_state_actions_total(&self) -> usize {
self.filtered_actions_for_empty_state().len()
}
pub fn grouped_hits(&self) -> Vec<(SourceKind, Vec<JumpHit>)> {
let visible = self.visible_hits();
let mut out = Vec::with_capacity(SourceKind::render_order().len());
for kind in SourceKind::render_order() {
let group: Vec<JumpHit> = visible
.iter()
.filter(|h| h.kind() == kind)
.cloned()
.collect();
if !group.is_empty() {
out.push((kind, group));
}
}
out
}
pub fn empty_state_groups(&self) -> Vec<(&'static str, Vec<JumpHit>)> {
let mut out: Vec<(&'static str, Vec<JumpHit>)> = Vec::new();
if !self.recents.is_empty() {
out.push(("RECENT", self.recents.clone()));
}
let actions = self.empty_state_actions();
if !actions.is_empty() {
out.push(("ACTIONS", actions));
}
out
}
pub fn selected_section(&self) -> Option<SourceKind> {
self.visible_hits().get(self.selected).map(|h| h.kind())
}
#[cfg(test)]
pub fn filtered_commands(&self) -> Vec<JumpAction> {
let all = JumpAction::for_mode(self.mode);
if self.query.is_empty() {
return all.to_vec();
}
let q = self.query.to_lowercase();
all.iter()
.filter(|cmd| {
cmd.label.to_lowercase().contains(&q)
|| cmd.aliases.iter().any(|a| a.to_lowercase().contains(&q))
})
.copied()
.collect()
}
pub fn jump_next_section(&mut self) {
let visible = self.visible_hits();
if visible.is_empty() {
return;
}
if self.query.is_empty() {
let n_recent = self.recents.len();
if n_recent == 0 || n_recent >= visible.len() {
return;
}
if self.selected < n_recent {
self.selected = n_recent; } else {
self.selected = 0; }
return;
}
let groups = self.grouped_hits();
if groups.len() < 2 {
return;
}
let cur_kind = match self.selected_section() {
Some(k) => k,
None => {
self.selected = 0;
return;
}
};
let cur_idx = groups.iter().position(|(k, _)| *k == cur_kind).unwrap_or(0);
let next_idx = (cur_idx + 1) % groups.len();
let next_kind = groups[next_idx].0;
if let Some(pos) = visible.iter().position(|h| h.kind() == next_kind) {
self.selected = pos;
}
}
}
#[cfg(test)]
mod tests;