use ratatui::Frame;
use ratatui::layout::Alignment;
use ratatui::text::{Line, Span};
use ratatui::widgets::Clear;
use unicode_width::UnicodeWidthStr;
use super::design;
use super::theme;
use crate::app::App;
pub fn render(frame: &mut Frame, app: &App, alias: &str) {
let siblings = app.hosts_state.ssh_config().siblings_of(alias);
let has_siblings = !siblings.is_empty();
let popup_w: u16 = if has_siblings { 60 } else { 52 };
let mut content: Vec<Line<'static>> = vec![Line::from(Span::styled(
format!(" Delete \"{}\"?", alias),
theme::bold(),
))];
if has_siblings {
content.push(Line::from(""));
content.push(Line::from(Span::styled(
format!(
" {}",
crate::messages::confirm_delete_siblings_note(&siblings)
),
theme::muted(),
)));
}
let footer_spans = design::confirm_footer_destructive("delete", "keep")
.to_line()
.spans;
design::render_confirm_popup(
frame,
popup_w,
design::PopupKind::Destructive,
"Confirm Delete",
content,
footer_spans,
app,
);
}
pub fn render_host_key_reset(frame: &mut Frame, app: &App, hostname: &str) {
let display = super::truncate(hostname, 40);
let content: Vec<Line<'static>> = vec![
Line::from(Span::styled(
format!(" Host key for {} changed.", display),
theme::bold(),
)),
Line::from(Span::styled(
" This can happen after a server reinstall.",
theme::muted(),
)),
Line::from(Span::styled(
" Remove old key and reconnect?",
theme::muted(),
)),
];
let footer_spans = design::confirm_footer_destructive("reset", "keep")
.to_line()
.spans;
design::render_confirm_popup(
frame,
52,
design::PopupKind::Destructive,
"Host Key Changed",
content,
footer_spans,
app,
);
}
pub fn render_confirm_import(frame: &mut Frame, app: &App, count: usize) {
let content: Vec<Line<'static>> = vec![Line::from(Span::styled(
format!(
" Import {} host{} from known_hosts?",
count,
if count == 1 { "" } else { "s" },
),
theme::bold(),
))];
let footer_spans = design::confirm_footer_destructive("import", "skip")
.to_line()
.spans;
design::render_confirm_popup(
frame,
52,
design::PopupKind::Neutral,
"Import",
content,
footer_spans,
app,
);
}
pub fn render_confirm_purge_stale(
frame: &mut Frame,
app: &App,
aliases: &[String],
provider: &Option<String>,
) {
let count = aliases.len();
let max_shown = 6;
let mut host_lines: Vec<Line<'static>> = aliases
.iter()
.take(max_shown)
.map(|a| {
let truncated = super::truncate(a, 46);
Line::from(Span::styled(format!(" {}", truncated), theme::muted()))
})
.collect();
if count > max_shown {
let remaining = count - max_shown;
host_lines.push(Line::from(Span::styled(
format!(
" +{} more host{}",
remaining,
if remaining == 1 { "" } else { "s" }
),
theme::muted(),
)));
}
let main_line = if let Some(prov) = provider {
let display = crate::providers::provider_display_name(prov);
format!(
" Remove {} stale {} host{}?",
count,
display,
if count == 1 { "" } else { "s" },
)
} else {
format!(
" Remove {} stale host{}?",
count,
if count == 1 { "" } else { "s" },
)
};
let mut content: Vec<Line<'static>> = vec![Line::from(Span::styled(main_line, theme::bold()))];
content.extend(host_lines);
let footer_spans = design::confirm_footer_destructive("purge", "keep")
.to_line()
.spans;
design::render_confirm_popup(
frame,
52,
design::PopupKind::Destructive,
"Purge Stale",
content,
footer_spans,
app,
);
}
pub fn render_key_push(frame: &mut Frame, app: &App, key_index: usize, aliases: &[String]) {
let key_name = app
.keys
.list()
.get(key_index)
.map(|k| format!("{}.pub", k.name))
.unwrap_or_else(|| "key".to_string());
let count = aliases.len();
let max_shown = 6;
let mut host_lines: Vec<Line<'static>> = aliases
.iter()
.take(max_shown)
.map(|a| {
let truncated = super::truncate(a, 46);
Line::from(Span::styled(format!(" {}", truncated), theme::muted()))
})
.collect();
if count > max_shown {
let remaining = count - max_shown;
host_lines.push(Line::from(Span::styled(
format!(
" +{} more host{}",
remaining,
if remaining == 1 { "" } else { "s" }
),
theme::muted(),
)));
}
let mut content: Vec<Line<'static>> = vec![
Line::from(Span::styled(
format!(
" {}",
crate::messages::key_push_confirm_body(&key_name, count)
),
theme::bold(),
)),
Line::from(""),
];
content.extend(host_lines);
let footer_spans = design::confirm_footer_destructive("push", "keep")
.to_line()
.spans;
design::render_confirm_popup(
frame,
56,
design::PopupKind::Neutral,
"Push Key",
content,
footer_spans,
app,
);
}
pub fn render_confirm_vault_sign(frame: &mut Frame, app: &App, signable: &[String]) {
let count = signable.len();
let preview_limit = 5;
let shown: Vec<&str> = signable
.iter()
.take(preview_limit)
.map(String::as_str)
.collect();
let preview_text = if count > preview_limit {
format!("{} ... +{} more", shown.join(", "), count - preview_limit)
} else if count > 0 {
shown.join(", ")
} else {
String::new()
};
let mut content: Vec<Line<'static>> = vec![
Line::from(Span::styled(
format!(
" Sign {} SSH certificate{} via the Vault SSH secrets engine?",
count,
if count == 1 { "" } else { "s" },
),
theme::bold(),
)),
Line::from(""),
];
if preview_text.is_empty() {
content.push(Line::from(""));
} else {
content.push(Line::from(Span::styled(
format!(" {}", preview_text),
theme::muted(),
)));
}
content.push(Line::from(""));
content.push(Line::from(Span::styled(
" Hosts with a still-valid certificate are skipped.".to_string(),
theme::muted(),
)));
let footer_spans = design::confirm_footer_destructive("sign", "skip")
.to_line()
.spans;
design::render_confirm_popup(
frame,
72,
design::PopupKind::Neutral,
"Sign Vault SSH Certificates",
content,
footer_spans,
app,
);
}
const TYPEWRITER_DELAY_MS: u128 = 100;
const TYPEWRITER_CHAR_MS: u128 = 15;
const WELCOME_ZOOM_MS: u128 = 350;
const LOGO_LINE_INTERVAL_MS: u128 = 50;
fn typewriter_spans<'a>(spans: Vec<Span<'a>>, budget: &mut usize) -> Vec<Span<'a>> {
let total_chars: usize = spans.iter().map(|s| s.content.chars().count()).sum();
if *budget >= total_chars {
*budget -= total_chars;
return spans;
}
let mut result = Vec::new();
let mut visible_chars = 0;
for span in &spans {
if *budget == 0 {
break;
}
let char_count = span.content.chars().count();
if char_count <= *budget {
result.push(span.clone());
*budget -= char_count;
visible_chars += char_count;
} else {
let truncated: String = span.content.chars().take(*budget).collect();
visible_chars += *budget;
result.push(Span::styled(truncated, span.style));
*budget = 0;
}
}
let pad = total_chars.saturating_sub(visible_chars);
if pad > 0 {
result.push(Span::raw(" ".repeat(pad)));
}
result
}
pub fn render_welcome(
frame: &mut Frame,
app: &App,
has_backup: bool,
host_count: usize,
known_hosts_count: usize,
) {
let has_hosts = host_count > 0;
let elapsed = app
.ui
.welcome_opened()
.map(|t| t.elapsed().as_millis())
.unwrap_or(u128::MAX);
let logo_start = WELCOME_ZOOM_MS;
let logo_lines_visible = if elapsed <= logo_start {
0usize
} else {
(((elapsed - logo_start) / LOGO_LINE_INTERVAL_MS) as usize + 1).min(design::LOGO.len())
};
let text_start =
logo_start + (design::LOGO.len() as u128) * LOGO_LINE_INTERVAL_MS + TYPEWRITER_DELAY_MS;
let mut char_budget = if elapsed <= text_start {
0usize
} else {
((elapsed - text_start) / TYPEWRITER_CHAR_MS) as usize
};
let mut extra = 2usize; if has_hosts {
extra += 3; } else if known_hosts_count > 0 {
extra += 5; }
if has_backup {
extra += 3; }
let logo_max_w = design::LOGO
.iter()
.map(|l| UnicodeWidthStr::width(*l))
.max()
.unwrap_or(0);
let content_height = 22 + extra;
let dialog_width = ((logo_max_w as u16) + 24).max(56);
let area = super::centered_rect_fixed(dialog_width, content_height as u16, frame.area());
frame.render_widget(Clear, area);
let block = design::plain_overlay_block();
let mut text: Vec<Line<'_>> = Vec::new();
text.push(Line::from(""));
text.push(Line::from(""));
text.push(Line::from(""));
for i in 0..design::LOGO.len() {
if i < logo_lines_visible {
text.push(
design::logo_line(i, theme::border_search(), theme::logo_dot())
.alignment(Alignment::Center),
);
} else {
text.push(Line::from(""));
}
}
text.push(Line::from(""));
text.push(Line::from(""));
text.push(Line::from(""));
let sub_spans = vec![Span::styled(
"Your SSH config, supercharged.",
theme::muted(),
)];
text.push(
Line::from(typewriter_spans(sub_spans, &mut char_budget)).alignment(Alignment::Center),
);
text.push(Line::from(""));
text.push(Line::from(""));
let hint_spans = vec![
Span::styled("Press ", theme::muted()),
Span::styled(" ? ", theme::footer_key()),
Span::styled(" anytime for help.", theme::muted()),
];
text.push(
Line::from(typewriter_spans(hint_spans, &mut char_budget)).alignment(Alignment::Center),
);
if has_hosts {
text.push(Line::from(""));
text.push(Line::from(""));
let info = format!(
"{} host{} loaded from ~/.ssh/config.",
host_count,
if host_count == 1 { "" } else { "s" },
);
let spans = vec![Span::styled(info, theme::muted())];
text.push(
Line::from(typewriter_spans(spans, &mut char_budget)).alignment(Alignment::Center),
);
} else if known_hosts_count > 0 {
text.push(Line::from(""));
text.push(Line::from(""));
let info = format!(
"Found {} host{} in known_hosts.",
known_hosts_count,
if known_hosts_count == 1 { "" } else { "s" },
);
let spans = vec![Span::styled(info, theme::muted())];
text.push(
Line::from(typewriter_spans(spans, &mut char_budget)).alignment(Alignment::Center),
);
text.push(Line::from(""));
let hint_spans = vec![
Span::styled("Press ", theme::muted()),
Span::styled(" I ", theme::footer_key()),
Span::styled(" to import them.", theme::muted()),
];
text.push(
Line::from(typewriter_spans(hint_spans, &mut char_budget)).alignment(Alignment::Center),
);
}
if has_backup {
text.push(Line::from(""));
let b1 = vec![Span::styled(
"Your original config has been backed up",
theme::muted(),
)];
text.push(Line::from(typewriter_spans(b1, &mut char_budget)).alignment(Alignment::Center));
let b2 = vec![Span::styled("to ~/.purple/config.original", theme::muted())];
text.push(Line::from(typewriter_spans(b2, &mut char_budget)).alignment(Alignment::Center));
}
text.push(Line::from(""));
if char_budget > 0 {
text.push(
Line::from(vec![
Span::styled(" Enter ", theme::footer_key()),
Span::styled(" continue", theme::muted()),
])
.alignment(Alignment::Center),
);
} else {
text.push(Line::from(""));
}
text.push(Line::from(""));
text.push(Line::from(""));
text.push(Line::from(""));
design::render_body_wrapped(frame, area, block, text);
}
#[cfg(test)]
fn welcome_height_and_lines(
has_backup: bool,
host_count: usize,
known_hosts_count: usize,
) -> (usize, usize) {
let has_hosts = host_count > 0;
let mut extra = 2usize; if has_hosts {
extra += 3;
} else if known_hosts_count > 0 {
extra += 5;
}
if has_backup {
extra += 3;
}
let height = 22 + extra;
let lines = height - 2;
(height, lines)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn welcome_height_hosts_backup_no_known() {
let (height, lines) = welcome_height_and_lines(true, 5, 0);
assert_eq!(
lines,
height - 2,
"has_hosts=true, has_backup=true, known=0"
);
}
#[test]
fn welcome_height_hosts_backup_with_known() {
let (height, lines) = welcome_height_and_lines(true, 5, 10);
assert_eq!(
lines,
height - 2,
"has_hosts=true, has_backup=true, known=10"
);
}
#[test]
fn welcome_height_hosts_no_backup_no_known() {
let (height, lines) = welcome_height_and_lines(false, 5, 0);
assert_eq!(
lines,
height - 2,
"has_hosts=true, has_backup=false, known=0"
);
}
#[test]
fn welcome_height_hosts_no_backup_with_known() {
let (height, lines) = welcome_height_and_lines(false, 5, 10);
assert_eq!(
lines,
height - 2,
"has_hosts=true, has_backup=false, known=10"
);
}
#[test]
fn welcome_height_no_hosts_backup_no_known() {
let (height, lines) = welcome_height_and_lines(true, 0, 0);
assert_eq!(
lines,
height - 2,
"has_hosts=false, has_backup=true, known=0"
);
}
#[test]
fn welcome_height_no_hosts_backup_with_known() {
let (height, lines) = welcome_height_and_lines(true, 0, 10);
assert_eq!(
lines,
height - 2,
"has_hosts=false, has_backup=true, known=10"
);
}
#[test]
fn welcome_height_no_hosts_no_backup_no_known() {
let (height, lines) = welcome_height_and_lines(false, 0, 0);
assert_eq!(
lines,
height - 2,
"has_hosts=false, has_backup=false, known=0"
);
}
#[test]
fn welcome_height_no_hosts_no_backup_with_known() {
let (height, lines) = welcome_height_and_lines(false, 0, 10);
assert_eq!(
lines,
height - 2,
"has_hosts=false, has_backup=false, known=10"
);
}
#[test]
fn welcome_height_single_host() {
let (height, lines) = welcome_height_and_lines(false, 1, 0);
assert_eq!(lines, height - 2, "single host");
}
#[test]
fn welcome_height_single_known_host() {
let (height, lines) = welcome_height_and_lines(false, 0, 1);
assert_eq!(lines, height - 2, "single known_hosts entry");
}
#[test]
fn confirm_import_pluralization_single() {
let msg = format!(
" Import {} host{} from known_hosts?",
1,
if 1 == 1 { "" } else { "s" },
);
assert_eq!(msg, " Import 1 host from known_hosts?");
}
#[test]
fn confirm_import_pluralization_multiple() {
let msg = format!(
" Import {} host{} from known_hosts?",
42,
if 42 == 1 { "" } else { "s" },
);
assert_eq!(msg, " Import 42 hosts from known_hosts?");
}
#[test]
fn welcome_hosts_pluralization_single() {
let msg = format!(
"Found {} host{} in your SSH config.",
1,
if 1 == 1 { "" } else { "s" },
);
assert_eq!(msg, "Found 1 host in your SSH config.");
}
#[test]
fn welcome_hosts_pluralization_multiple() {
let msg = format!(
"Found {} host{} in your SSH config.",
12,
if 12 == 1 { "" } else { "s" },
);
assert_eq!(msg, "Found 12 hosts in your SSH config.");
}
#[test]
fn welcome_known_hosts_pluralization_single() {
let msg = format!(
"Found {} host{} in known_hosts.",
1,
if 1 == 1 { "" } else { "s" },
);
assert_eq!(msg, "Found 1 host in known_hosts.");
}
#[test]
fn welcome_known_hosts_pluralization_multiple() {
let msg = format!(
"Found {} host{} in known_hosts.",
34,
if 34 == 1 { "" } else { "s" },
);
assert_eq!(msg, "Found 34 hosts in known_hosts.");
}
#[test]
fn test_purge_stale_pluralization_single_no_provider() {
let count = 1;
let msg = format!(
" Remove {} stale host{}?",
count,
if count == 1 { "" } else { "s" },
);
assert_eq!(msg, " Remove 1 stale host?");
}
#[test]
fn test_purge_stale_pluralization_multiple_no_provider() {
let count = 7;
let msg = format!(
" Remove {} stale host{}?",
count,
if count == 1 { "" } else { "s" },
);
assert_eq!(msg, " Remove 7 stale hosts?");
}
#[test]
fn test_purge_stale_pluralization_with_provider() {
let display = "DigitalOcean";
let count = 3;
let msg = format!(
" Remove {} stale {} host{}?",
count,
display,
if count == 1 { "" } else { "s" },
);
assert_eq!(msg, " Remove 3 stale DigitalOcean hosts?");
}
#[test]
fn test_purge_stale_pluralization_single_with_provider() {
let display = "Vultr";
let msg = format!(
" Remove {} stale {} host{}?",
1,
display,
if 1 == 1 { "" } else { "s" },
);
assert_eq!(msg, " Remove 1 stale Vultr host?");
}
}