use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Clear, List, ListItem, Paragraph};
use unicode_width::UnicodeWidthStr;
use super::theme;
use crate::app::{App, ProviderFormField};
use crate::history::ConnectionHistory;
pub fn render_provider_list(frame: &mut Frame, app: &mut App) {
let sorted_names = app.sorted_provider_names();
let item_count = sorted_names.len();
let height = (item_count as u16 + 4).min(frame.area().height.saturating_sub(4));
let pct_width: u16 = 70;
let area = {
let r = super::centered_rect(pct_width, 80, frame.area());
super::centered_rect_fixed(r.width, height, frame.area())
};
frame.render_widget(Clear, area);
let title = Span::styled(" Providers ", theme::brand());
let block = Block::bordered()
.border_type(BorderType::Rounded)
.title(title)
.border_style(theme::accent());
let inner = block.inner(area);
frame.render_widget(block, area);
let content_width = inner.width as usize;
let items: Vec<ListItem> = sorted_names
.iter()
.map(|name| {
let display_name = crate::providers::provider_display_name(name.as_str());
let configured = app.provider_config.section(name.as_str()).is_some();
let name_col = format!(" {:<16}", display_name);
let mut spans = vec![Span::styled(name_col, theme::bold())];
let mut used = 17;
if configured {
let has_error = app.sync_history.get(name.as_str()).is_some_and(|r| r.is_error);
if has_error {
spans.push(Span::styled("\u{26A0}", theme::error()));
} else {
spans.push(Span::styled("\u{2713}", theme::success()));
}
used += 1;
if let Some(section) = app.provider_config.section(name.as_str()) {
if !section.auto_sync {
spans.push(Span::styled(" (manual)", theme::muted()));
used += 9;
}
}
let sync_detail = if app.syncing_providers.contains_key(name.as_str()) {
Some("syncing...".to_string())
} else if let Some(record) = app.sync_history.get(name.as_str()) {
let ago = ConnectionHistory::format_time_ago(record.timestamp);
if ago.is_empty() {
Some(record.message.clone())
} else {
Some(format!("{}, {}", record.message, ago))
}
} else {
None
};
if let Some(detail) = sync_detail {
let max = content_width.saturating_sub(used + 2);
if max > 1 {
spans.push(Span::styled(
format!(" {}", super::truncate(&detail, max)),
theme::muted(),
));
}
}
}
ListItem::new(Line::from(spans))
})
.collect();
let chunks = Layout::vertical([
Constraint::Min(1),
Constraint::Length(1),
])
.split(inner);
let list = List::new(items)
.highlight_style(theme::selected_row())
.highlight_symbol(" ");
frame.render_stateful_widget(list, chunks[0], &mut app.ui.provider_list_state);
if app.pending_provider_delete.is_some() {
let name = app.pending_provider_delete.as_deref().unwrap_or("");
let display = crate::providers::provider_display_name(name);
super::render_footer_with_status(frame, chunks[1], vec![
Span::styled(format!(" Remove {}? ", display), theme::bold()),
Span::styled("y", theme::accent_bold()),
Span::styled(" yes ", theme::muted()),
Span::styled("\u{2502} ", theme::muted()),
Span::styled("Esc", theme::accent_bold()),
Span::styled(" no", theme::muted()),
], app);
} else {
super::render_footer_with_status(frame, chunks[1], vec![
Span::styled(" s", theme::accent_bold()),
Span::styled(" sync ", theme::muted()),
Span::styled("\u{2502} ", theme::muted()),
Span::styled(" Enter", theme::primary_action()),
Span::styled(" configure ", theme::muted()),
Span::styled("\u{2502} ", theme::muted()),
Span::styled("d", theme::accent_bold()),
Span::styled(" remove ", theme::muted()),
Span::styled("\u{2502} ", theme::muted()),
Span::styled("Esc", theme::accent_bold()),
Span::styled(" back", theme::muted()),
], app);
}
}
pub fn render_provider_form(frame: &mut Frame, app: &mut App, provider_name: &str) {
let area = frame.area();
let display_name = crate::providers::provider_display_name(provider_name);
let title = format!(" Configure {} ", display_name);
let fields = ProviderFormField::fields_for(provider_name);
let block_height = 2 + fields.len() as u16 * 2;
let total_height = block_height + 1;
let base = super::centered_rect(70, 80, area);
let form_area = super::centered_rect_fixed(base.width, total_height, area);
frame.render_widget(Clear, form_area);
let block_area = Rect::new(form_area.x, form_area.y, form_area.width, block_height);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.title(Span::styled(title, theme::brand()))
.border_style(theme::border());
let inner = block.inner(block_area);
frame.render_widget(block, block_area);
for (i, &field) in fields.iter().enumerate() {
let divider_y = inner.y + (2 * i) as u16;
let content_y = divider_y + 1;
let is_focused = app.provider_form.focused_field == field;
let label_style = if is_focused { theme::accent_bold() } else { theme::muted() };
let is_required = matches!(field, ProviderFormField::Url)
|| (field == ProviderFormField::Token && provider_name != "aws")
|| (field == ProviderFormField::Project && provider_name == "gcp")
|| (field == ProviderFormField::Regions && matches!(provider_name, "aws" | "scaleway"));
let field_label = if field == ProviderFormField::Regions && matches!(provider_name, "scaleway" | "gcp") {
"Zones"
} else {
field.label()
};
let label = if is_required {
format!(" {}* ", field_label)
} else {
format!(" {} ", field_label)
};
render_divider(frame, block_area, divider_y, &label, label_style, theme::border());
let content_area = Rect::new(inner.x + 1, content_y, inner.width.saturating_sub(1), 1);
render_field_content(frame, content_area, field, &app.provider_form, provider_name);
}
let footer_area = Rect::new(form_area.x, form_area.y + block_height, form_area.width, 1);
super::render_footer_with_status(frame, footer_area, vec![
Span::styled(" Enter", theme::primary_action()),
Span::styled(" save ", theme::muted()),
Span::styled("\u{2502} ", theme::muted()),
Span::styled("Tab", theme::accent_bold()),
Span::styled(" next ", theme::muted()),
Span::styled("\u{2502} ", theme::muted()),
Span::styled("Esc", theme::accent_bold()),
Span::styled(" cancel", theme::muted()),
], app);
if app.ui.show_key_picker {
super::host_form::render_key_picker_overlay(frame, app);
}
if app.ui.show_region_picker {
render_region_picker_overlay(frame, app);
}
}
fn placeholder_for(field: ProviderFormField, provider_name: &str) -> &'static str {
match field {
ProviderFormField::Url => "https://pve.example.com:8006",
ProviderFormField::Token => match provider_name {
"proxmox" => "user@pam!token=secret",
"aws" => "AccessKeyId:Secret (or use Profile)",
"gcp" => "/path/to/service-account.json (or access token)",
_ => "your-api-token",
},
ProviderFormField::Profile => "Name from ~/.aws/credentials (or use Token)",
ProviderFormField::Project => "my-gcp-project-id",
ProviderFormField::Regions => match provider_name {
"gcp" => "Enter to select zones (empty = all)",
"scaleway" => "Enter to select zones",
_ => "Enter to select regions",
},
ProviderFormField::AliasPrefix => match provider_name {
"digitalocean" => "do",
"vultr" => "vultr",
"linode" => "linode",
"hetzner" => "hetzner",
"upcloud" => "uc",
"proxmox" => "pve",
"aws" => "aws",
"scaleway" => "scw",
"gcp" => "gcp",
_ => "prefix",
},
ProviderFormField::User => match provider_name {
"aws" => "ec2-user",
"gcp" => "ubuntu",
_ => "root",
},
ProviderFormField::IdentityFile => "Enter to pick a key",
ProviderFormField::VerifyTls | ProviderFormField::AutoSync => "",
}
}
fn render_divider(
frame: &mut Frame,
block_area: Rect,
y: u16,
label: &str,
label_style: Style,
border_style: Style,
) {
super::render_divider(frame, block_area, y, label, label_style, border_style);
}
fn render_field_content(
frame: &mut Frame,
area: Rect,
field: ProviderFormField,
form: &crate::app::ProviderFormFields,
provider_name: &str,
) {
let is_focused = form.focused_field == field;
if field == ProviderFormField::VerifyTls {
let value_text = if form.verify_tls {
"yes"
} else {
"no (accept self-signed)"
};
render_toggle_content(frame, area, value_text, is_focused);
return;
}
if field == ProviderFormField::AutoSync {
let value_text = if form.auto_sync {
"yes"
} else {
"no (sync manually)"
};
render_toggle_content(frame, area, value_text, is_focused);
return;
}
let value = match field {
ProviderFormField::Url => &form.url,
ProviderFormField::Token => &form.token,
ProviderFormField::Profile => &form.profile,
ProviderFormField::Project => &form.project,
ProviderFormField::Regions => &form.regions,
ProviderFormField::AliasPrefix => &form.alias_prefix,
ProviderFormField::User => &form.user,
ProviderFormField::IdentityFile => &form.identity_file,
ProviderFormField::VerifyTls | ProviderFormField::AutoSync => unreachable!(),
};
let display_value: String = if field == ProviderFormField::Token && !value.is_empty() && !is_focused {
let char_count = value.chars().count();
if char_count > 4 {
let last4: String = value.chars().skip(char_count - 4).collect();
format!("{}{}", "*".repeat(char_count - 4), last4)
} else {
value.clone()
}
} else {
value.clone()
};
let is_picker = matches!(field, ProviderFormField::IdentityFile | ProviderFormField::Regions);
let content = if value.is_empty() && is_focused && !is_picker {
Line::from(Span::styled(placeholder_for(field, provider_name), theme::muted()))
} else if is_picker && is_focused {
let inner_width = area.width as usize;
let arrow_pos = inner_width.saturating_sub(1);
let (display, display_style) = if value.is_empty() {
(placeholder_for(field, provider_name).to_string(), theme::muted())
} else {
(display_value.clone(), theme::bold())
};
let val_width = display.width();
let gap = arrow_pos.saturating_sub(val_width);
Line::from(vec![
Span::styled(display, display_style),
Span::raw(" ".repeat(gap)),
Span::styled("\u{25B8}", theme::muted()),
])
} else if display_value.is_empty() {
Line::from(Span::raw(""))
} else {
Line::from(Span::styled(display_value, theme::bold()))
};
frame.render_widget(Paragraph::new(content), area);
if is_focused {
let prefix: String = value.chars().take(form.cursor_pos).collect();
let cursor_x = area
.x
.saturating_add(prefix.width().min(u16::MAX as usize) as u16);
let cursor_y = area.y;
if area.width > 0 && cursor_x < area.x.saturating_add(area.width) {
frame.set_cursor_position((cursor_x, cursor_y));
}
}
}
fn render_toggle_content(
frame: &mut Frame,
area: Rect,
value_text: &str,
is_focused: bool,
) {
let content = if is_focused {
let inner_width = area.width as usize;
let val_width = value_text.width();
let gap = inner_width.saturating_sub(val_width + 3);
Line::from(vec![
Span::styled(value_text, theme::bold()),
Span::raw(" ".repeat(gap)),
Span::styled("\u{25C2} \u{25B8}", theme::muted()),
])
} else {
Line::from(Span::styled(value_text, theme::bold()))
};
frame.render_widget(Paragraph::new(content), area);
}
fn build_region_rows(provider: &str) -> Vec<(String, Option<&'static str>)> {
let (zones, groups) = crate::handler::zone_data_for(provider);
let mut rows = Vec::new();
for &(label, start, end) in groups {
rows.push((format!(" {}", label), None));
for &(code, name) in &zones[start..end] {
rows.push((format!("{} {}", code, name), Some(code)));
}
}
rows
}
fn render_region_picker_overlay(frame: &mut Frame, app: &mut App) {
let provider_name = match &app.screen {
crate::app::Screen::ProviderForm { provider } => provider.as_str(),
_ => "aws",
};
let rows = build_region_rows(provider_name);
let selected: std::collections::HashSet<&str> = app
.provider_form
.regions
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
let area = frame.area();
let visible_rows = 18u16;
let block_height = visible_rows + 2; let total_height = block_height + 1; let base = super::centered_rect(60, 80, area);
let picker_area = super::centered_rect_fixed(base.width, total_height, area);
frame.render_widget(Clear, picker_area);
let count = selected.len();
let zone_label = if matches!(provider_name, "scaleway" | "gcp") { "Zones" } else { "Regions" };
let title = format!(" Select {} ({} selected) ", zone_label, count);
let block_area = Rect::new(picker_area.x, picker_area.y, picker_area.width, block_height);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.title(Span::styled(title, theme::brand()))
.border_style(theme::accent());
let inner = block.inner(block_area);
frame.render_widget(block, block_area);
let cursor = app.ui.region_picker_cursor;
let scroll_offset = if cursor >= visible_rows as usize {
cursor - visible_rows as usize + 1
} else {
0
};
for (i, y) in (0..visible_rows as usize).zip(inner.y..) {
let idx = scroll_offset + i;
if idx >= rows.len() {
break;
}
let (label, region_code) = &rows[idx];
let is_cursor = idx == cursor;
if let Some(code) = region_code {
let is_selected = selected.contains(code);
let check = if is_selected { " \u{2713} " } else { " " };
let display = format!("{}{}", check, label);
let style = if is_cursor {
theme::selected_row()
} else if is_selected {
theme::bold()
} else {
theme::muted()
};
let row_area = Rect::new(inner.x, y, inner.width, 1);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
super::truncate(&display, inner.width as usize),
style,
))),
row_area,
);
} else {
let style = if is_cursor {
theme::selected_row()
} else {
theme::accent_bold()
};
let row_area = Rect::new(inner.x, y, inner.width, 1);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
super::truncate(label, inner.width as usize),
style,
))),
row_area,
);
}
}
let footer_area = Rect::new(picker_area.x, picker_area.y + block_height, picker_area.width, 1);
super::render_footer_with_status(frame, footer_area, vec![
Span::styled(" Space", theme::primary_action()),
Span::styled(" toggle ", theme::muted()),
Span::styled("\u{2502} ", theme::muted()),
Span::styled("Enter/Esc", theme::accent_bold()),
Span::styled(" done", theme::muted()),
], app);
}
#[cfg(test)]
mod tests {
use super::super::truncate;
#[test]
fn truncate_fits() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn truncate_exact_fit() {
assert_eq!(truncate("hello", 5), "hello");
}
#[test]
fn truncate_ascii() {
assert_eq!(truncate("hello world", 8), "hello w…");
}
#[test]
fn truncate_no_room() {
assert_eq!(truncate("hello", 1), "");
assert_eq!(truncate("hello", 0), "");
}
#[test]
fn truncate_wide_cjk() {
assert_eq!(truncate("你好世界", 5), "你好…");
}
#[test]
fn truncate_wide_cjk_odd_boundary() {
assert_eq!(truncate("你好世界", 4), "你…");
}
#[test]
fn truncate_mixed_ascii_cjk() {
assert_eq!(truncate("ab你好", 5), "ab你…");
}
#[test]
fn truncate_multibyte_emoji() {
assert_eq!(truncate("🚀🔥", 3), "🚀…");
}
}