use std::sync::mpsc;
use std::time::Instant;
use crate::app::{self, App, Screen};
use crate::containers;
use crate::event::AppEvent;
use crate::file_browser;
use crate::providers;
use crate::ssh_config;
use crate::tui;
use crate::vault_ssh;
pub(crate) fn handle_tick(
app: &mut App,
anim: &mut crate::animation::AnimationState,
vault_signing: bool,
last_config_check: &mut Instant,
) {
app.tick_status();
app.tick_toast();
let provider_syncing = !app.providers.syncing.is_empty();
let tunnels_animating =
matches!(app.top_page, crate::app::TopPage::Tunnels) && !app.tunnels.active.is_empty();
if anim.has_checking_hosts(app)
|| vault_signing
|| provider_syncing
|| anim.has_reachable_hosts(app)
|| tunnels_animating
{
anim.tick_spinner();
}
if vault_signing {
if let Some(ref mut status) = app.status_center.status {
if status.sticky && !status.is_error() {
let frame = crate::animation::SPINNER_FRAMES
[anim.spinner_tick as usize % crate::animation::SPINNER_FRAMES.len()];
if let Some(updated) = crate::replace_spinner_frame(&status.text, frame) {
status.text = updated;
}
}
}
}
if provider_syncing {
if let Some(ref mut status) = app.status_center.status {
let frame = crate::animation::SPINNER_FRAMES
[anim.spinner_tick as usize % crate::animation::SPINNER_FRAMES.len()];
if let Some(updated) = crate::replace_spinner_frame(&status.text, frame) {
status.text = updated;
status.created_at = std::time::Instant::now();
}
}
}
if last_config_check.elapsed() >= std::time::Duration::from_secs(4) {
app.check_config_changed();
app.check_keys_changed();
*last_config_check = Instant::now();
}
let exited = app.poll_tunnels();
for (_alias, msg, is_error) in exited {
if is_error {
app.notify_background_error(msg);
} else {
app.notify_background(msg);
}
}
}
pub(crate) fn handle_ping_result(
app: &mut App,
alias: String,
rtt_ms: Option<u32>,
generation: u64,
) {
if generation == app.ping.generation {
let status = app::classify_ping(rtt_ms, app.ping.slow_threshold_ms);
let now = Instant::now();
log::debug!(
"ping-result: {} → {:?} (rtt={:?}ms, gen={})",
alias,
status,
rtt_ms,
generation
);
app.ping.status.insert(alias.clone(), status.clone());
app.ping.last_checked.insert(alias.clone(), now);
app::propagate_ping_to_dependents(
&app.hosts_state.list,
&mut app.ping.status,
&alias,
&status,
);
let mut propagated = 0usize;
for h in &app.hosts_state.list {
if h.proxy_jump == alias {
app.ping.last_checked.insert(h.alias.clone(), now);
propagated += 1;
}
}
if propagated > 0 {
log::debug!(
"ping-result: propagated bastion {} status+timestamp to {} dependent(s)",
alias,
propagated
);
}
if app.ping.filter_down_only {
app.apply_filter();
}
if app.hosts_state.sort_mode == app::SortMode::Status {
app.apply_sort();
}
if !app.ping.status.is_empty()
&& app
.ping
.status
.values()
.all(|s| !matches!(s, app::PingStatus::Checking))
{
app.ping.checked_at = Some(Instant::now());
}
}
}
pub(crate) fn handle_sync_progress(app: &mut App, provider: String, message: String) {
if app.providers.syncing.contains_key(&provider) && app.providers.sync_done.is_empty() {
let name = providers::provider_display_name(&provider);
let spinner = crate::animation::SPINNER_FRAMES[0];
app.notify_background(crate::messages::provider_progress(spinner, name, &message));
}
}
pub(crate) fn handle_sync_complete(
app: &mut App,
provider: String,
hosts: Vec<crate::providers::ProviderHost>,
last_config_check: &mut Instant,
) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let display_name = providers::provider_display_name(&provider);
let before_aliases = app.snapshot_alias_set();
let (_msg, is_err, total, added, updated, stale) =
app.apply_sync_result(&provider, hosts, false);
if is_err {
app.providers.sync_history.insert(
provider.clone(),
app::SyncRecord {
timestamp: now,
message: format!("{}: sync failed", display_name),
is_error: true,
},
);
app.providers.sync_had_errors = true;
} else {
let label = if total == 1 { "server" } else { "servers" };
let message = format!(
"{} {}{}",
total,
label,
crate::format_sync_diff(added, updated, stale)
);
app.providers.sync_history.insert(
provider.clone(),
app::SyncRecord {
timestamp: now,
message,
is_error: false,
},
);
app.providers.batch_added += added;
app.providers.batch_updated += updated;
app.providers.batch_stale += stale;
}
app.providers.syncing.remove(&provider);
app.providers.sync_done.push(display_name.to_string());
crate::set_sync_summary(app);
*last_config_check = Instant::now();
app.queue_new_aliases_since(&before_aliases);
}
pub(crate) fn handle_sync_partial(
app: &mut App,
provider: String,
hosts: Vec<crate::providers::ProviderHost>,
failures: usize,
total: usize,
last_config_check: &mut Instant,
) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let display_name = providers::provider_display_name(provider.as_str());
let before_aliases = app.snapshot_alias_set();
let (msg, is_err, synced, added, updated, stale) =
app.apply_sync_result(&provider, hosts, true);
if is_err {
app.providers.sync_history.insert(
provider.clone(),
app::SyncRecord {
timestamp: now,
message: msg,
is_error: true,
},
);
} else {
let label = if synced == 1 { "server" } else { "servers" };
app.providers.sync_history.insert(
provider.clone(),
app::SyncRecord {
timestamp: now,
message: format!(
"{} {}{} ({} of {} failed)",
synced,
label,
crate::format_sync_diff(added, updated, stale),
failures,
total
),
is_error: true,
},
);
app.providers.batch_added += added;
app.providers.batch_updated += updated;
app.providers.batch_stale += stale;
}
app.providers.sync_had_errors = true;
app.providers.syncing.remove(&provider);
app.providers.sync_done.push(display_name.to_string());
crate::set_sync_summary(app);
*last_config_check = Instant::now();
app.queue_new_aliases_since(&before_aliases);
}
pub(crate) fn handle_sync_error(
app: &mut App,
provider: String,
message: String,
last_config_check: &mut Instant,
) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let display_name = providers::provider_display_name(provider.as_str());
app.providers.sync_history.insert(
provider.clone(),
app::SyncRecord {
timestamp: now,
message: message.clone(),
is_error: true,
},
);
app.providers.sync_had_errors = true;
app.providers.syncing.remove(&provider);
app.providers.sync_done.push(display_name.to_string());
crate::set_sync_summary(app);
*last_config_check = Instant::now();
}
pub(crate) fn handle_update_available(app: &mut App, version: String, headline: Option<String>) {
app.update.available = Some(version);
app.update.headline = headline;
}
pub(crate) fn handle_file_browser_listing(
app: &mut App,
alias: String,
path: String,
entries: Result<Vec<crate::file_browser::FileEntry>, String>,
terminal: &mut tui::Tui,
) {
let mut record_connection = false;
if let Some(ref mut fb) = app.file_browser_session {
if fb.alias == alias {
fb.remote_loading = false;
match entries {
Ok(listing) => {
if !fb.connection_recorded {
fb.connection_recorded = true;
record_connection = true;
}
if fb.remote_path.is_empty() || fb.remote_path != path {
fb.remote_path = path;
}
fb.remote_entries = listing;
fb.remote_error = None;
fb.remote_list_state = ratatui::widgets::ListState::default();
fb.remote_list_state.select(Some(0));
}
Err(msg) => {
if fb.remote_path.is_empty() {
fb.remote_path = path;
}
fb.remote_error = Some(msg);
fb.remote_entries.clear();
}
}
}
}
if record_connection {
app.history.record(&alias);
app.record_key_use(&alias, crate::key_activity::now_secs());
app.apply_sort();
}
terminal.force_redraw();
}
pub(crate) fn handle_scp_complete(
app: &mut App,
alias: String,
success: bool,
message: String,
events_tx: &mpsc::Sender<AppEvent>,
terminal: &mut tui::Tui,
) {
let mut refresh_remote: Option<(
String,
Option<String>,
String,
bool,
file_browser::BrowserSort,
)> = None;
let matched = if let Some(ref mut fb) = app.file_browser_session {
if fb.alias == alias {
fb.transferring = None;
if success {
app.history.record(&alias);
crate::key_activity::record_and_flush(
&mut app.keys.activity,
&alias,
crate::key_activity::now_secs(),
);
app.hosts_state.render_cache.invalidate();
fb.local_selected.clear();
fb.remote_selected.clear();
match file_browser::list_local(&fb.local_path, fb.show_hidden, fb.sort) {
Ok(entries) => {
fb.local_entries = entries;
fb.local_error = None;
}
Err(e) => {
fb.local_entries = Vec::new();
fb.local_error = Some(e.to_string());
}
}
fb.local_list_state.select(Some(0));
if !fb.remote_path.is_empty() {
fb.remote_loading = true;
fb.remote_entries.clear();
fb.remote_error = None;
fb.remote_list_state = ratatui::widgets::ListState::default();
refresh_remote = Some((
fb.alias.clone(),
fb.askpass.clone(),
fb.remote_path.clone(),
fb.show_hidden,
fb.sort,
));
}
} else {
fb.transfer_error = Some(message.clone());
}
true
} else {
false
}
} else {
false
};
if matched && success {
app.notify_background(crate::messages::TRANSFER_COMPLETE);
app.apply_sort();
}
if let Some((fb_alias, askpass_fb, path, show_hidden, sort)) = refresh_remote {
let has_tunnel = app.tunnels.active.contains_key(&fb_alias);
let ctx = crate::ssh_context::OwnedSshContext {
alias: fb_alias,
config_path: app.reload.config_path.clone(),
askpass: askpass_fb,
bw_session: app.bw_session.clone(),
has_tunnel,
};
let tx = events_tx.clone();
file_browser::spawn_remote_listing(ctx, path, show_hidden, sort, move |a, p, e| {
let _ = tx.send(AppEvent::FileBrowserListing {
alias: a,
path: p,
entries: e,
});
});
}
crate::askpass::cleanup_marker(&alias);
terminal.force_redraw();
}
pub(crate) fn handle_snippet_host_done(
app: &mut App,
run_id: u64,
alias: String,
stdout: String,
stderr: String,
exit_code: Option<i32>,
) {
if exit_code == Some(0) {
app.history.record(&alias);
app.record_key_use(&alias, crate::key_activity::now_secs());
app.apply_sort();
}
if let Some(ref mut state) = app.snippets.output {
if state.run_id == run_id {
state.results.push(app::SnippetHostOutput {
alias,
stdout,
stderr,
exit_code,
});
}
}
}
pub(crate) fn handle_snippet_progress(app: &mut App, run_id: u64, completed: usize, total: usize) {
if let Some(ref mut state) = app.snippets.output {
if state.run_id == run_id {
state.completed = completed;
state.total = total;
}
}
}
pub(crate) fn handle_snippet_all_done(app: &mut App, run_id: u64) {
if let Some(ref mut state) = app.snippets.output {
if state.run_id == run_id {
state.all_done = true;
}
}
}
pub(crate) fn handle_key_push_result(
app: &mut App,
run_id: u64,
result: crate::key_push::KeyPushResult,
) {
if run_id != app.keys.push.run_id {
log::debug!(
"[purple] key_push: dropping stale result for alias={} (event run_id={} current={})",
result.alias,
run_id,
app.keys.push.run_id
);
return;
}
let expected = app.keys.push.expected_count;
if expected == 0 {
return;
}
app.keys.push.results.push(result);
if app.keys.push.results.len() < expected {
return;
}
finalize_key_push(app);
}
fn finalize_key_push(app: &mut App) {
use crate::key_push::KeyPushOutcome;
let mut appended = 0usize;
let mut already = 0usize;
let mut failed: Vec<(String, String)> = Vec::new();
for r in &app.keys.push.results {
match &r.outcome {
KeyPushOutcome::Appended => appended += 1,
KeyPushOutcome::AlreadyPresent => already += 1,
KeyPushOutcome::Failed(msg) => failed.push((r.alias.clone(), msg.clone())),
}
}
let total = app.keys.push.results.len();
app.status_center.clear_sticky_status();
if failed.is_empty() {
app.notify(crate::messages::key_push_success(appended, already));
} else if failed.len() == total {
app.notify_sticky_error(crate::messages::key_push_all_failed(total));
} else {
let mut body = crate::messages::key_push_partial_failure(appended + already, failed.len());
let preview: Vec<&str> = failed.iter().take(5).map(|(a, _)| a.as_str()).collect();
if !preview.is_empty() {
body.push_str(" Failed: ");
body.push_str(&preview.join(", "));
if failed.len() > preview.len() {
use std::fmt::Write;
let _ = write!(body, ", +{} more", failed.len() - preview.len());
}
body.push('.');
}
app.notify_sticky_error(body);
}
for (alias, msg) in &failed {
log::warn!("[external] key_push: failed alias={} err={}", alias, msg);
}
if appended > 0 {
let ssh_dir = crate::ssh_keys::resolve_ssh_dir();
if let Some(dir) = ssh_dir {
app.keys.list = crate::ssh_keys::discover_keys(&dir, &app.hosts_state.list);
if let Some(sel) = app.keys.list_state.selected() {
if app.keys.list.is_empty() {
app.keys.list_state.select(None);
} else if sel >= app.keys.list.len() {
app.keys.list_state.select(Some(app.keys.list.len() - 1));
}
}
}
}
app.keys.push.results.clear();
app.keys.push.expected_count = 0;
app.keys.push.selected.clear();
app.keys.push.cancel = None;
if let Some(handle) = app.keys.push.worker.take() {
let _ = handle.join();
}
}
pub(crate) fn handle_container_listing(
app: &mut App,
alias: String,
result: Result<containers::ContainerListing, containers::ContainerError>,
events_tx: &mpsc::Sender<AppEvent>,
) {
if !app.hosts_state.list.iter().any(|h| h.alias == alias) {
log::debug!(
"[purple] container_listing dropped: alias={} no longer in config",
alias
);
crate::askpass::cleanup_marker(&alias);
app.containers_overview.auto_list_in_flight.remove(&alias);
drive_refresh_batch(app, &alias, events_tx);
return;
}
match &result {
Ok(listing) => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
app.container_state.cache.insert(
alias.clone(),
containers::ContainerCacheEntry {
timestamp: now,
runtime: listing.runtime,
engine_version: listing.engine_version.clone(),
containers: listing.containers.clone(),
},
);
containers::save_container_cache(&app.container_state.cache);
crate::handler::containers_overview::prefetch_inspect_for_listing(
app,
&alias,
listing.runtime,
&listing.containers,
events_tx,
);
}
Err(e) => {
if let Some(rt) = e.runtime {
if let Some(entry) = app.container_state.cache.get_mut(&alias) {
entry.runtime = rt;
}
}
}
}
if let Some(ref mut state) = app.container_session {
if state.alias == alias {
match result {
Ok(listing) => {
state.runtime = Some(listing.runtime);
state.containers = listing.containers;
state.loading = false;
state.error = None;
if let Some(sel) = state.list_state.selected() {
if sel >= state.containers.len() && !state.containers.is_empty() {
state.list_state.select(Some(0));
}
} else if !state.containers.is_empty() {
state.list_state.select(Some(0));
}
}
Err(e) => {
if let Some(rt) = e.runtime {
state.runtime = Some(rt);
}
state.loading = false;
state.error = Some(e.message);
}
}
}
}
crate::askpass::cleanup_marker(&alias);
app.containers_overview.auto_list_in_flight.remove(&alias);
drive_refresh_batch(app, &alias, events_tx);
}
pub(crate) fn drive_refresh_batch(app: &mut App, alias: &str, events_tx: &mpsc::Sender<AppEvent>) {
let Some(batch) = app.containers_overview.refresh_batch.as_mut() else {
return;
};
if !batch.in_flight_aliases.remove(alias) {
log::debug!(
"[purple] refresh_batch: alias={} not in batch in_flight, ignoring",
alias
);
return;
}
batch.in_flight = batch.in_flight.saturating_sub(1);
batch.completed += 1;
let next = batch.queue.pop_front();
let total = batch.total;
let completed = batch.completed;
let queue_remaining = batch.queue.len();
let spawned = next.is_some();
if let Some(item) = next {
batch.in_flight += 1;
batch.in_flight_aliases.insert(item.alias.clone());
let config_path = app.reload.config_path.clone();
let bw_session = app.bw_session.clone();
let ctx = crate::ssh_context::OwnedSshContext {
alias: item.alias,
config_path,
askpass: item.askpass,
bw_session,
has_tunnel: item.has_tunnel,
};
let tx = events_tx.clone();
containers::spawn_container_listing(ctx, item.cached_runtime, move |a, r| {
let _ = tx.send(AppEvent::ContainerListing {
alias: a,
result: r,
});
});
}
let still_in_flight = app
.containers_overview
.refresh_batch
.as_ref()
.map(|b| b.in_flight)
.unwrap_or(0);
log::debug!(
"[purple] refresh_batch: alias={} done={}/{} in_flight={} queue={} spawned_next={}",
alias,
completed,
total,
still_in_flight,
queue_remaining,
spawned
);
if queue_remaining == 0 && still_in_flight == 0 {
app.containers_overview.refresh_batch = None;
app.status_center.status = None;
app.notify(crate::messages::container_refresh_complete(total));
} else {
app.notify_progress(crate::messages::container_refresh_progress(
completed, total,
));
}
}
pub(crate) fn handle_container_inspect_complete(
app: &mut App,
alias: String,
container_id: String,
result: Result<containers::ContainerInspect, String>,
) {
if !app.hosts_state.list.iter().any(|h| h.alias == alias) {
log::debug!(
"[purple] container_inspect_complete dropped: alias={} no longer in config",
alias
);
app.containers_overview
.inspect_cache
.in_flight
.remove(&container_id);
return;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
match &result {
Ok(_) => log::debug!(
"[purple] container_inspect_complete: alias={} id={} ok=true",
alias,
container_id,
),
Err(msg) => log::warn!(
"[external] container inspect failed: alias={} id={}: {}",
alias,
container_id,
msg,
),
}
app.containers_overview.inspect_cache.entries.insert(
container_id.clone(),
crate::app::InspectCacheEntry {
timestamp: now,
result,
},
);
app.containers_overview
.inspect_cache
.in_flight
.remove(&container_id);
}
pub(crate) fn handle_container_logs_tail_complete(
app: &mut App,
alias: String,
container_id: String,
result: Result<Vec<String>, String>,
) {
if !app.hosts_state.list.iter().any(|h| h.alias == alias) {
log::debug!(
"[purple] container_logs_tail_complete dropped: alias={} no longer in config",
alias
);
app.containers_overview
.logs_cache
.in_flight
.remove(&container_id);
return;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
match &result {
Ok(_) => log::debug!(
"[purple] container_logs_tail_complete: alias={} id={} ok=true",
alias,
container_id,
),
Err(msg) => log::warn!(
"[external] container logs tail failed: alias={} id={}: {}",
alias,
container_id,
msg,
),
}
app.containers_overview.logs_cache.entries.insert(
container_id.clone(),
crate::app::LogsCacheEntry {
timestamp: now,
result,
},
);
app.containers_overview
.logs_cache
.in_flight
.remove(&container_id);
}
pub(crate) fn handle_container_logs_complete(
app: &mut App,
alias: String,
container_id: String,
container_name: String,
result: Result<Vec<String>, String>,
) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if let Screen::ContainerLogs {
alias: scr_alias,
container_id: scr_id,
body,
error,
fetched_at,
scroll,
last_render_height,
search,
..
} = &mut app.screen
{
if scr_alias == &alias && scr_id == &container_id {
match result {
Ok(lines) => {
log::debug!(
"[purple] container_logs_complete: {} lines for alias={} id={}",
lines.len(),
alias,
container_id
);
*body = lines;
*error = None;
*fetched_at = now;
*scroll = crate::handler::container_logs::tail_scroll(
body.len(),
*last_render_height,
);
if let Some(s) = search.as_mut() {
crate::handler::container_logs::refresh_search(body, s);
log::debug!(
"[purple] container_logs: search refreshed query={:?} matches={}",
s.query,
s.matches.len()
);
crate::handler::container_logs::recenter_on_match(
body.len(),
*last_render_height,
s,
scroll,
);
}
}
Err(e) => {
log::warn!(
"[external] container_logs_complete: alias={} id={} error={}",
alias,
container_id,
e
);
body.clear();
*error = Some(e);
*fetched_at = now;
*scroll = 0;
}
}
return;
}
}
log::debug!(
"[purple] container_logs_complete dropped (overlay closed): alias={} id={} name={}",
alias,
container_id,
container_name
);
}
pub(crate) fn handle_container_action_complete(
app: &mut App,
alias: String,
action: containers::ContainerAction,
result: Result<(), String>,
events_tx: &mpsc::Sender<AppEvent>,
) {
let should_refresh = if let Some(ref mut state) = app.container_session {
if state.alias == alias {
state.action_in_progress = None;
match result {
Ok(()) => {
state.loading = true;
Some((state.alias.clone(), state.askpass.clone(), state.runtime))
}
Err(e) => {
state.error = Some(e);
None
}
}
} else {
None
}
} else {
None
};
if let Some((refresh_alias, askpass, cached_runtime)) = should_refresh {
app.notify(crate::messages::container_action_complete(action.as_str()));
let has_tunnel = app.tunnels.active.contains_key(&refresh_alias);
app.containers_overview
.auto_list_in_flight
.insert(refresh_alias.clone());
let ctx = crate::ssh_context::OwnedSshContext {
alias: refresh_alias,
config_path: app.reload.config_path.clone(),
askpass,
bw_session: app.bw_session.clone(),
has_tunnel,
};
let tx = events_tx.clone();
containers::spawn_container_listing(ctx, cached_runtime, move |a, r| {
let _ = tx.send(AppEvent::ContainerListing {
alias: a,
result: r,
});
});
}
crate::askpass::cleanup_marker(&alias);
}
pub(crate) fn handle_vault_sign_result(
app: &mut App,
alias: String,
existing_cert_file: String,
success: bool,
message: String,
) {
if success {
let mut host_missing = false;
if crate::should_write_certificate_file(&existing_cert_file) {
if let Ok(cert_path) = vault_ssh::cert_path_for(&alias) {
let updated = app
.hosts_state
.ssh_config
.set_host_certificate_file(&alias, &cert_path.to_string_lossy());
if !updated {
host_missing = true;
}
}
}
app.refresh_cert_cache(&alias);
if host_missing {
app.notify_error(crate::messages::vault_cert_saved_host_gone(&alias));
} else {
app.notify(crate::messages::vault_signed(&alias));
}
} else {
app.notify_error(crate::messages::vault_sign_failed(&alias, &message));
}
}
pub(crate) fn handle_vault_sign_progress(
app: &mut App,
alias: String,
done: usize,
total: usize,
spinner_tick: u64,
) {
const ALIAS_BUDGET: usize = 40;
let display_alias: String = if alias.chars().count() > ALIAS_BUDGET {
let cut: String = alias.chars().take(ALIAS_BUDGET - 1).collect();
format!("{}\u{2026}", cut)
} else {
alias.clone()
};
let spinner = crate::animation::SPINNER_FRAMES
[spinner_tick as usize % crate::animation::SPINNER_FRAMES.len()];
app.notify_progress(crate::messages::vault_signing_progress(
spinner,
done,
total,
&display_alias,
));
}
pub(crate) fn handle_vault_sign_all_done(
app: &mut App,
signed: u32,
failed: u32,
skipped: u32,
cancelled: bool,
aborted_message: Option<String>,
first_error: Option<String>,
) -> std::ops::ControlFlow<()> {
app.vault.signing_cancel = None;
if let Some(handle) = app.vault.sign_thread.take() {
log::debug!("[purple] vault sign thread: joining");
let _ = handle.join();
log::info!(
"[purple] vault sign thread: joined (signed={} failed={} skipped={} cancelled={})",
signed,
failed,
skipped,
cancelled
);
}
if let Some(msg) = aborted_message {
app.notify_sticky_error(msg);
return std::ops::ControlFlow::Break(()); }
if cancelled {
let msg = crate::messages::vault_signing_cancelled_summary(
signed,
failed,
first_error.as_deref(),
);
if failed > 0 {
app.notify_sticky_error(msg);
} else {
app.notify_info(msg);
}
return std::ops::ControlFlow::Break(()); }
let summary_msg =
crate::format_vault_sign_summary(signed, failed, skipped, first_error.as_deref());
if signed > 0 {
if app.is_form_open() {
app.vault.pending_config_write = true;
if failed > 0 {
app.notify_sticky_error(summary_msg);
} else {
app.notify_info(summary_msg);
}
} else if app.external_config_changed() {
let reapply: Vec<(String, String)> = app
.hosts_state
.ssh_config
.host_entries()
.into_iter()
.filter_map(|h| {
if h.vault_ssh.is_some()
&& crate::should_write_certificate_file(&h.certificate_file)
{
vault_ssh::cert_path_for(&h.alias)
.ok()
.map(|p| (h.alias.clone(), p.to_string_lossy().into_owned()))
} else {
None
}
})
.collect();
match ssh_config::model::SshConfigFile::parse(&app.reload.config_path) {
Ok(fresh) => {
app.hosts_state.ssh_config = fresh;
let mut reapplied = 0usize;
for (alias, cert_path) in &reapply {
let entry = app
.hosts_state
.ssh_config
.host_entries()
.into_iter()
.find(|h| &h.alias == alias);
if let Some(entry) = entry {
if crate::should_write_certificate_file(&entry.certificate_file)
&& app
.hosts_state
.ssh_config
.set_host_certificate_file(alias, cert_path)
{
reapplied += 1;
}
}
}
if reapplied > 0 {
if let Err(e) = app.hosts_state.ssh_config.write() {
app.notify_sticky_error(crate::messages::vault_config_reapply_failed(
signed as usize,
&e,
));
} else {
app.update_last_modified();
app.reload_hosts();
if failed > 0 {
app.notify_sticky_error(
crate::messages::vault_external_edits_merged(
&summary_msg,
reapplied,
),
);
} else {
app.notify_info(crate::messages::vault_external_edits_merged(
&summary_msg,
reapplied,
));
}
}
} else {
app.reload_hosts();
app.notify_sticky_error(crate::messages::vault_external_edits_no_write(
&summary_msg,
));
}
}
Err(e) => {
app.notify_sticky_error(crate::messages::vault_reparse_failed(
signed as usize,
&e,
));
}
}
} else if let Err(e) = app.hosts_state.ssh_config.write() {
app.notify_sticky_error(crate::messages::vault_config_update_failed(
signed as usize,
&e,
));
} else {
app.update_last_modified();
app.reload_hosts();
if failed > 0 {
app.notify_sticky_error(summary_msg);
} else {
app.notify_info(summary_msg);
}
}
} else if failed > 0 {
app.notify_sticky_error(summary_msg);
} else {
app.notify_info(summary_msg);
}
std::ops::ControlFlow::Continue(()) }
pub(crate) fn handle_cert_check_result(
app: &mut App,
alias: String,
status: vault_ssh::CertStatus,
) {
app.vault.cert_checks_in_flight.remove(&alias);
let mtime = crate::tui_loop::current_cert_mtime(&alias, app);
app.vault
.cert_cache
.insert(alias, (Instant::now(), status, mtime));
}
pub(crate) fn handle_cert_check_error(app: &mut App, alias: String, message: String) {
app.vault.cert_checks_in_flight.remove(&alias);
app.vault.cert_cache.insert(
alias.clone(),
(
Instant::now(),
vault_ssh::CertStatus::Invalid(message.clone()),
None,
),
);
app.notify_background_error(crate::messages::vault_cert_check_failed(&alias, &message));
}
#[cfg(test)]
mod key_push_tests {
use super::*;
use crate::app::App;
use crate::key_push::{KeyPushOutcome, KeyPushResult};
use crate::ssh_config::model::SshConfigFile;
fn make_app() -> App {
let scratch = tempfile::tempdir().expect("tempdir").keep();
crate::preferences::set_path_override(scratch.join("preferences"));
crate::containers::set_path_override(scratch.join("container_cache.jsonl"));
std::fs::create_dir_all(scratch.join("synthetic-ssh")).unwrap();
crate::ssh_keys::set_ssh_dir_override(scratch.join("synthetic-ssh"));
let config = SshConfigFile {
elements: SshConfigFile::parse_content(""),
path: scratch.join("test_config"),
crlf: false,
bom: false,
};
let mut app = App::new(config);
app.keys.push.run_id = 1;
app
}
fn result(alias: &str, outcome: KeyPushOutcome) -> KeyPushResult {
KeyPushResult {
alias: alias.to_string(),
outcome,
}
}
#[test]
fn handle_result_does_not_finalize_below_expected() {
let mut app = make_app();
app.keys.push.expected_count = 3;
handle_key_push_result(&mut app, 1, result("h1", KeyPushOutcome::AlreadyPresent));
assert_eq!(app.keys.push.results.len(), 1);
assert_eq!(app.keys.push.expected_count, 3, "should not finalize early");
}
#[test]
fn handle_result_skips_when_expected_zero() {
let mut app = make_app();
app.keys.push.expected_count = 0;
handle_key_push_result(&mut app, 1, result("h1", KeyPushOutcome::Appended));
assert!(app.keys.push.results.is_empty());
}
#[test]
fn handle_result_drops_stale_run_id() {
let mut app = make_app();
app.keys.push.expected_count = 2;
app.keys.push.run_id = 7;
handle_key_push_result(&mut app, 6, result("h-stale", KeyPushOutcome::Appended));
assert!(
app.keys.push.results.is_empty(),
"stale-run event must not push into the new run's results"
);
}
#[test]
fn finalize_all_already_present_emits_success_toast() {
let mut app = make_app();
app.keys.push.expected_count = 2;
app.keys
.push
.results
.push(result("h1", KeyPushOutcome::AlreadyPresent));
handle_key_push_result(&mut app, 1, result("h2", KeyPushOutcome::AlreadyPresent));
assert_eq!(app.keys.push.expected_count, 0);
assert!(app.keys.push.results.is_empty());
assert!(app.keys.push.selected.is_empty());
let toast = app.status_center.toast.as_ref().expect("toast set");
assert!(!toast.sticky, "fully-successful run is a plain toast");
}
#[test]
fn finalize_all_failed_emits_sticky_error() {
let mut app = make_app();
app.keys.push.expected_count = 2;
app.keys
.push
.results
.push(result("h1", KeyPushOutcome::Failed("oops".into())));
handle_key_push_result(
&mut app,
1,
result("h2", KeyPushOutcome::Failed("also bad".into())),
);
assert_eq!(app.keys.push.expected_count, 0);
let status = app.status_center.status.as_ref().expect("sticky status");
assert!(
status.sticky && status.is_error(),
"all-failed should be sticky-error"
);
}
#[test]
fn finalize_partial_failure_is_sticky_and_names_failed_hosts() {
let mut app = make_app();
app.keys.push.expected_count = 3;
app.keys
.push
.results
.push(result("h1", KeyPushOutcome::AlreadyPresent));
app.keys
.push
.results
.push(result("h2", KeyPushOutcome::Failed("bad".into())));
handle_key_push_result(&mut app, 1, result("h3", KeyPushOutcome::AlreadyPresent));
assert_eq!(app.keys.push.expected_count, 0);
let status = app
.status_center
.status
.as_ref()
.expect("sticky status set");
assert!(
status.sticky && status.is_error(),
"partial failure is sticky so the user sees which hosts failed"
);
assert!(
status.text.contains("h2"),
"failed alias must appear in body: {}",
status.text
);
}
#[test]
fn finalize_appended_refreshes_keys_against_override_dir_not_real_home() {
let mut app = make_app();
app.keys.push.expected_count = 1;
app.keys.list.push(crate::ssh_keys::SshKeyInfo {
name: "stale".into(),
display_path: "~/.ssh/stale".into(),
key_type: "ED25519".into(),
bits: "256".into(),
fingerprint: String::new(),
comment: String::new(),
linked_hosts: vec![],
bishop_art: String::new(),
strength_score: 90,
encrypted: false,
agent_loaded: false,
is_certificate: false,
mtime_ts: None,
});
handle_key_push_result(&mut app, 1, result("h", KeyPushOutcome::Appended));
assert!(
app.keys.list.is_empty(),
"discover_keys against an empty override dir should return zero keys"
);
assert_eq!(app.keys.list_state.selected(), None);
}
}