use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::app::{App, Screen};
use crate::event::AppEvent;
use crate::providers;
use crate::providers::ProviderKind;
mod region;
#[cfg(test)]
pub(super) use region::region_picker_rows;
pub(crate) use region::zone_data_for;
pub(super) fn handle_provider_list_key(
app: &mut App,
key: KeyEvent,
events_tx: &mpsc::Sender<AppEvent>,
) {
if app.providers.pending_delete().is_some() && key.code != KeyCode::Char('?') {
match super::route_confirm_key(key) {
super::ConfirmAction::Yes => {
let pending_id = app.providers.take_pending_delete_id();
let Some(name) = app.providers.take_pending_delete() else {
return;
};
if let Some(id) = pending_id {
let Some(old_section) = app.providers.config().section_by_id(&id).cloned()
else {
return;
};
app.providers.config_mut().remove_section_by_id(&id);
if let Err(e) = app.providers.config().save() {
app.providers.config_mut().set_section(old_section);
app.notify_error(crate::messages::failed_to_save(&e));
} else {
app.providers.sync_history_mut().remove(&id.to_string());
crate::app::SyncRecord::save_all(app.providers.sync_history());
if app
.providers
.config()
.sections_for_provider(&id.provider)
.is_empty()
{
app.providers.expanded_providers_mut().remove(&id.provider);
}
let display_name = crate::providers::provider_display_name(&id.provider);
app.notify(crate::messages::provider_removed(display_name));
}
} else if let Some(old_section) =
app.providers.config().section(name.as_str()).cloned()
{
app.providers.config_mut().remove_section(name.as_str());
if let Err(e) = app.providers.config().save() {
app.providers.config_mut().set_section(old_section);
app.notify_error(crate::messages::failed_to_save(&e));
} else {
app.providers.sync_history_mut().remove(name.as_str());
crate::app::SyncRecord::save_all(app.providers.sync_history());
app.providers.expanded_providers_mut().remove(&name);
let display_name = crate::providers::provider_display_name(name.as_str());
app.notify(crate::messages::provider_removed(display_name));
}
}
}
super::ConfirmAction::No => {
app.providers.cancel_delete();
}
super::ConfirmAction::Ignored => {}
}
return;
}
let rows = app.providers.provider_list_rows();
let row_count = rows.len();
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
for cancel_flag in app.providers.syncing().values() {
cancel_flag.store(true, Ordering::Relaxed);
}
app.set_screen(Screen::HostList);
}
KeyCode::Char('j') | KeyCode::Down => {
crate::app::cycle_selection(app.ui.provider_list_state_mut(), row_count, true);
}
KeyCode::Char('k') | KeyCode::Up => {
crate::app::cycle_selection(app.ui.provider_list_state_mut(), row_count, false);
}
KeyCode::PageDown => {
crate::app::page_down(app.ui.provider_list_state_mut(), row_count, 10);
}
KeyCode::PageUp => {
crate::app::page_up(app.ui.provider_list_state_mut(), row_count, 10);
}
KeyCode::Char(' ') => {
if let Some(idx) = app.ui.provider_list_state().selected() {
if let Some(crate::app::ProviderRow::Header { name, config_count }) = rows.get(idx)
{
if *config_count >= 2 {
let n = name.clone();
let now_expanded = app.providers.toggle_expanded(&n);
log::debug!(
"provider tree: {} '{}'",
if now_expanded {
"expanded"
} else {
"collapsed"
},
n
);
}
}
}
}
KeyCode::Char('a') => {
if let Some(idx) = app.ui.provider_list_state().selected() {
let provider_name = rows.get(idx).map(|r| r.provider_name().to_string());
if let Some(name) = provider_name {
open_add_config_flow(app, &name);
}
}
}
KeyCode::Enter => {
if let Some(index) = app.ui.provider_list_state().selected() {
let row = match rows.get(index) {
Some(r) => r.clone(),
None => return,
};
if let crate::app::ProviderRow::Header { name, config_count } = &row {
if *config_count >= 2 {
let now_expanded = app.providers.toggle_expanded(name);
log::debug!(
"provider tree: {} '{}'",
if now_expanded {
"expanded"
} else {
"collapsed"
},
name
);
return;
}
}
let target_id = match &row {
crate::app::ProviderRow::Header { name, config_count } => {
if *config_count == 1 {
app.providers
.config()
.sections_for_provider(name)
.first()
.map(|s| s.id.clone())
.unwrap_or_else(|| {
crate::providers::config::ProviderConfigId::bare(name.clone())
})
} else {
crate::providers::config::ProviderConfigId::bare(name.clone())
}
}
crate::app::ProviderRow::Leaf { id } => id.clone(),
};
app.open_provider_form(target_id);
}
}
KeyCode::Char('s') => {
if app.demo_mode {
app.notify_warning(crate::messages::DEMO_SYNC_DISABLED);
return;
}
let row = match app
.ui
.provider_list_state()
.selected()
.and_then(|i| rows.get(i))
{
Some(r) => r.clone(),
None => return,
};
let sections_to_sync: Vec<providers::config::ProviderSection> = match &row {
crate::app::ProviderRow::Header { name, .. } => app
.providers
.config()
.sections_for_provider(name)
.into_iter()
.cloned()
.collect(),
crate::app::ProviderRow::Leaf { id } => app
.providers
.config()
.section_by_id(id)
.cloned()
.into_iter()
.collect(),
};
if sections_to_sync.is_empty() {
let name = row.provider_name();
let display_name = crate::providers::provider_display_name(name);
app.notify_error(crate::messages::provider_configure_first(display_name));
return;
}
for section in sections_to_sync {
let key = section.id.to_string();
if app.providers.syncing().contains_key(&key) {
continue;
}
app.providers.reset_batch_if_idle();
let cancel = Arc::new(AtomicBool::new(false));
app.providers.syncing_mut().insert(key, cancel.clone());
app.providers.bump_batch_total();
super::sync::spawn_provider_sync(§ion, events_tx.clone(), cancel);
}
crate::set_sync_summary(app);
}
KeyCode::Char('d') => {
let row = match app
.ui
.provider_list_state()
.selected()
.and_then(|i| rows.get(i))
{
Some(r) => r.clone(),
None => return,
};
match &row {
crate::app::ProviderRow::Leaf { id } => {
if app.providers.config().section_by_id(id).is_some() {
app.providers.request_delete(id.clone());
}
}
crate::app::ProviderRow::Header { name, config_count } => {
if *config_count == 0 {
let display_name = crate::providers::provider_display_name(name);
app.notify(crate::messages::provider_not_configured(display_name));
} else if *config_count >= 2 {
app.notify(crate::messages::EXPAND_TO_REMOVE_CONFIG.to_string());
} else {
if let Some(section) = app.providers.config().section(name) {
app.providers.request_delete(section.id.clone());
}
}
}
}
}
KeyCode::Char('?') => {
let old = std::mem::replace(&mut app.screen, Screen::HostList);
app.set_screen(Screen::Help {
return_screen: Box::new(old),
});
}
KeyCode::Char('X') => {
let row = match app
.ui
.provider_list_state()
.selected()
.and_then(|i| rows.get(i))
{
Some(r) => r.clone(),
None => return,
};
let stale = app.hosts_state.ssh_config().stale_hosts();
let entries = app.hosts_state.ssh_config().host_entries();
let (display, scope_provider, provider_stale): (String, Option<String>, Vec<_>) =
match &row {
crate::app::ProviderRow::Header { name, .. } => {
let display = crate::providers::provider_display_name(name).to_string();
let scope = name.clone();
let filtered: Vec<_> = stale
.iter()
.filter(|(alias, _)| {
entries.iter().any(|e| {
e.alias == *alias
&& e.provider.as_deref() == Some(name.as_str())
})
})
.collect();
(display, Some(scope), filtered)
}
crate::app::ProviderRow::Leaf { id } => {
let display = format!(
"{} ({})",
crate::providers::provider_display_name(&id.provider),
id.label.as_deref().unwrap_or("")
);
let prov = id.provider.clone();
let label = id.label.clone();
let filtered: Vec<_> = stale
.iter()
.filter(|(alias, _)| {
entries.iter().any(|e| {
e.alias == *alias
&& e.provider.as_deref() == Some(prov.as_str())
&& e.provider_label == label
})
})
.collect();
(display, Some(prov), filtered)
}
};
if provider_stale.is_empty() {
app.notify_warning(crate::messages::no_stale_hosts_for(&display));
} else {
let aliases: Vec<String> =
provider_stale.into_iter().map(|(a, _)| a.clone()).collect();
app.set_screen(Screen::ConfirmPurgeStale {
aliases,
provider: scope_provider,
});
}
}
_ => {}
}
}
pub fn handle_label_migration_key(
app: &mut App,
key: KeyEvent,
_events_tx: &mpsc::Sender<AppEvent>,
) {
let provider = match &app.screen {
Screen::ProviderLabelMigration { provider } => provider.clone(),
_ => return,
};
match key.code {
KeyCode::Esc => {
app.providers.cancel_label_migration();
app.set_screen(Screen::Providers);
}
KeyCode::Tab | KeyCode::Down => {
if let Some(p) = app.providers.pending_label_migration_mut() {
p.focused = match p.focused {
crate::app::LabelMigrationField::Existing => {
crate::app::LabelMigrationField::New
}
crate::app::LabelMigrationField::New => {
crate::app::LabelMigrationField::Existing
}
};
p.cursor_pos = p.focused_value().chars().count();
}
}
KeyCode::BackTab | KeyCode::Up => {
if let Some(p) = app.providers.pending_label_migration_mut() {
p.focused = match p.focused {
crate::app::LabelMigrationField::Existing => {
crate::app::LabelMigrationField::New
}
crate::app::LabelMigrationField::New => {
crate::app::LabelMigrationField::Existing
}
};
p.cursor_pos = p.focused_value().chars().count();
}
}
KeyCode::Left => {
if let Some(p) = app.providers.pending_label_migration_mut() {
if p.cursor_pos > 0 {
p.cursor_pos -= 1;
}
}
}
KeyCode::Right => {
if let Some(p) = app.providers.pending_label_migration_mut() {
let max = p.focused_value().chars().count();
if p.cursor_pos < max {
p.cursor_pos += 1;
}
}
}
KeyCode::Home => {
if let Some(p) = app.providers.pending_label_migration_mut() {
p.cursor_pos = 0;
}
}
KeyCode::End => {
if let Some(p) = app.providers.pending_label_migration_mut() {
p.cursor_pos = p.focused_value().chars().count();
}
}
KeyCode::Backspace => {
if let Some(p) = app.providers.pending_label_migration_mut() {
if p.cursor_pos > 0 {
let cursor_pos = p.cursor_pos;
let target = p.focused_value_mut();
let mut chars: Vec<char> = target.chars().collect();
chars.remove(cursor_pos - 1);
*target = chars.into_iter().collect();
p.cursor_pos -= 1;
}
}
}
KeyCode::Delete => {
if let Some(p) = app.providers.pending_label_migration_mut() {
let len = p.focused_value().chars().count();
if p.cursor_pos < len {
let cursor_pos = p.cursor_pos;
let target = p.focused_value_mut();
let mut chars: Vec<char> = target.chars().collect();
chars.remove(cursor_pos);
*target = chars.into_iter().collect();
}
}
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
let allowed = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-';
if !allowed {
return;
}
if let Some(p) = app.providers.pending_label_migration_mut() {
let cursor_pos = p.cursor_pos;
let target = p.focused_value_mut();
if target.len() < 32 {
let mut chars: Vec<char> = target.chars().collect();
chars.insert(cursor_pos, c);
*target = chars.into_iter().collect();
p.cursor_pos += 1;
}
}
}
KeyCode::Enter => {
let (existing, new) = match app.providers.pending_label_migration() {
Some(p) => (p.existing_label.clone(), p.new_label.clone()),
None => return,
};
if let Err(e) = crate::providers::config::validate_label(&existing) {
app.notify_error(crate::messages::label_invalid(&e));
return;
}
if let Err(e) = crate::providers::config::validate_label(&new) {
app.notify_error(crate::messages::label_invalid(&e));
return;
}
if existing == new {
app.notify_error(crate::messages::LABEL_MUST_DIFFER.to_string());
return;
}
app.open_provider_form(crate::providers::config::ProviderConfigId::labeled(
provider.clone(),
new,
));
}
_ => {}
}
}
fn open_add_config_flow(app: &mut App, provider_name: &str) {
let existing = app.providers.config().sections_for_provider(provider_name);
match existing.len() {
0 => {
app.open_provider_form(crate::providers::config::ProviderConfigId::bare(
provider_name,
));
}
1 if existing[0].id.label.is_none() => {
log::debug!("provider lazy migration: started for '{}'", provider_name);
app.providers
.set_pending_label_migration(Some(crate::app::PendingLabelMigration {
provider: provider_name.to_string(),
existing_label: "default".to_string(),
new_label: String::new(),
focused: crate::app::LabelMigrationField::Existing,
cursor_pos: "default".chars().count(),
}));
app.set_screen(Screen::ProviderLabelMigration {
provider: provider_name.to_string(),
});
}
_ => {
app.open_provider_form(crate::providers::config::ProviderConfigId {
provider: provider_name.to_string(),
label: Some(String::new()),
});
}
}
}
fn warn_aws_token_format(app: &mut App, provider_name: &str) {
if provider_name.parse::<ProviderKind>().ok() != Some(ProviderKind::Aws) {
return;
}
if app.providers.form_mut().focused_field != crate::app::ProviderFormField::Token {
return;
}
let token = app.providers.form_mut().token.trim();
if token.is_empty() {
return;
}
if !token.contains(':') {
app.notify_warning(crate::messages::TOKEN_FORMAT_AWS);
}
}
pub(super) fn handle_provider_form_key(
app: &mut App,
key: KeyEvent,
events_tx: &mpsc::Sender<AppEvent>,
) {
if app.ui.key_picker().open {
super::picker::handle_key_picker_shared(app, key, true);
return;
}
if app.ui.region_picker().open {
region::handle_region_picker(app, key);
return;
}
let provider_name = match &app.screen {
Screen::ProviderForm { id } => id.provider.clone(),
_ => return,
};
let visible = app.providers.form_mut().visible_fields(&provider_name);
let fields: &[crate::app::ProviderFormField] = &visible;
let is_toggle = |f: crate::app::ProviderFormField| f.is_toggle();
let is_picker = |f: crate::app::ProviderFormField| f.is_picker(&provider_name);
if app.forms.is_discard_pending() {
match super::route_confirm_key(key) {
super::ConfirmAction::Yes => {
app.forms.dismiss_discard_confirm();
app.close_provider_form();
}
super::ConfirmAction::No => {
app.forms.dismiss_discard_confirm();
}
super::ConfirmAction::Ignored => {}
}
return;
}
match key.code {
KeyCode::Esc => {
if app.provider_form_is_dirty() {
app.forms.request_discard_confirm();
} else {
app.close_provider_form();
}
}
KeyCode::Tab | KeyCode::Down => {
warn_aws_token_format(app, &provider_name);
if !app.providers.form_mut().expanded {
let all = &visible;
let req_count = all
.iter()
.filter(|f| {
crate::app::ProviderFormField::is_required_field(**f, &provider_name)
})
.count();
let required = &all[..req_count];
if required.is_empty() {
app.providers.form_mut().focused_field =
app.providers.form_mut().focused_field.next(fields);
} else {
let pos = required
.iter()
.position(|f| *f == app.providers.form_mut().focused_field);
if let Some(idx) = pos {
if idx + 1 < required.len() {
app.providers.form_mut().focused_field = required[idx + 1];
} else if req_count < all.len() {
app.providers.form_mut().expanded = true;
app.providers.form_mut().focused_field = all[req_count];
} else {
app.providers.form_mut().focused_field = required[0];
}
} else {
app.providers.form_mut().focused_field =
app.providers.form_mut().focused_field.next(fields);
}
}
} else {
app.providers.form_mut().focused_field =
app.providers.form_mut().focused_field.next(fields);
}
app.providers.form_mut().sync_cursor_to_end();
}
KeyCode::BackTab | KeyCode::Up => {
warn_aws_token_format(app, &provider_name);
if !app.providers.form_mut().expanded {
let all = &visible;
let req_count = all
.iter()
.filter(|f| {
crate::app::ProviderFormField::is_required_field(**f, &provider_name)
})
.count();
let required = &all[..req_count];
if required.is_empty() {
app.providers.form_mut().focused_field =
app.providers.form_mut().focused_field.prev(fields);
} else {
let pos = required
.iter()
.position(|f| *f == app.providers.form_mut().focused_field);
if let Some(idx) = pos {
let prev_idx = if idx > 0 { idx - 1 } else { required.len() - 1 };
app.providers.form_mut().focused_field = required[prev_idx];
} else {
app.providers.form_mut().focused_field = required[required.len() - 1];
}
}
} else {
app.providers.form_mut().focused_field =
app.providers.form_mut().focused_field.prev(fields);
}
app.providers.form_mut().sync_cursor_to_end();
}
KeyCode::Left if app.providers.form_mut().cursor_pos > 0 => {
app.providers.form_mut().cursor_pos -= 1;
}
KeyCode::Right => {
let len = app.providers.form_mut().focused_value().chars().count();
if app.providers.form_mut().cursor_pos < len {
app.providers.form_mut().cursor_pos += 1;
}
}
KeyCode::Home => {
app.providers.form_mut().cursor_pos = 0;
}
KeyCode::End => {
app.providers.form_mut().sync_cursor_to_end();
}
KeyCode::Enter => {
submit_provider_form(app, events_tx);
}
KeyCode::Char(' ')
if app.providers.form_mut().focused_field
== crate::app::ProviderFormField::VerifyTls =>
{
app.providers.form_mut().verify_tls = !app.providers.form_mut().verify_tls;
}
KeyCode::Char(' ')
if app.providers.form_mut().focused_field
== crate::app::ProviderFormField::AutoSync =>
{
app.providers.form_mut().auto_sync = !app.providers.form_mut().auto_sync;
}
KeyCode::Char(' ')
if is_picker(app.providers.form_mut().focused_field)
&& app.providers.form_mut().focused_value().is_empty() =>
{
let f = app.providers.form_mut().focused_field;
if f == crate::app::ProviderFormField::IdentityFile {
app.open_key_picker();
} else if f == crate::app::ProviderFormField::Regions {
app.open_region_picker();
}
}
KeyCode::Char(c) => {
let f = app.providers.form_mut().focused_field;
if is_toggle(f) {
} else if f == crate::app::ProviderFormField::Label {
let allowed = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-';
if allowed && app.providers.form_mut().label.len() < 32 {
app.providers.form_mut().insert_char(c);
}
} else {
app.providers.form_mut().insert_char(c);
}
}
KeyCode::Backspace => {
let f = app.providers.form_mut().focused_field;
if !is_toggle(f) {
app.providers.form_mut().delete_char_before_cursor();
}
}
_ => {}
}
}
fn submit_provider_form(app: &mut App, events_tx: &mpsc::Sender<AppEvent>) {
if app.demo_mode {
app.notify_warning(crate::messages::DEMO_PROVIDER_CHANGES_DISABLED);
app.set_screen(Screen::Providers);
return;
}
let mut form_id = match &app.screen {
Screen::ProviderForm { id } => id.clone(),
_ => return,
};
let provider_name = form_id.provider.clone();
let kind = provider_name.parse::<ProviderKind>().ok();
if app.providers.form_mut().label_entry {
let typed = app.providers.form_mut().label.trim().to_string();
if let Err(e) = providers::config::validate_label(&typed) {
app.notify_error(crate::messages::label_invalid(&e));
app.providers.form_mut().focused_field = crate::app::ProviderFormField::Label;
return;
}
let candidate =
providers::config::ProviderConfigId::labeled(provider_name.clone(), typed.clone());
if app.providers.config().section_by_id(&candidate).is_some() {
app.notify_error(crate::messages::label_already_in_use(&typed));
app.providers.form_mut().focused_field = crate::app::ProviderFormField::Label;
return;
}
let short = providers::get_provider(provider_name.as_str())
.map(|p| p.short_label().to_string())
.unwrap_or_else(|| provider_name.clone());
if app.providers.form_mut().alias_prefix.trim() == short {
app.providers.form_mut().alias_prefix = format!("{}-{}", short, typed);
}
form_id = candidate;
}
if app.provider_config_changed_since_form_open() {
app.notify_error(crate::messages::PROVIDER_CONFIG_CHANGED_EXTERNALLY);
return;
}
let control_char_field = {
let pf = app.providers.form();
[
(&pf.url, "URL"),
(&pf.token, "Token"),
(&pf.alias_prefix, "Alias Prefix"),
(&pf.user, "User"),
(&pf.identity_file, "Identity File"),
(&pf.profile, "Profile"),
(&pf.project, "Project ID"),
(&pf.regions, "Regions"),
]
.into_iter()
.find(|(value, _)| value.chars().any(|c| c.is_control()))
.map(|(_, name)| name)
};
if let Some(name) = control_char_field {
app.notify_warning(crate::messages::contains_control_chars(name));
return;
}
if kind == Some(ProviderKind::Proxmox) {
let url = app.providers.form_mut().url.trim();
if url.is_empty() {
app.notify_warning(crate::messages::URL_REQUIRED_PROXMOX);
return;
}
if !url.to_ascii_lowercase().starts_with("https://") {
app.notify_error(crate::messages::PROVIDER_URL_REQUIRES_HTTPS);
return;
}
}
if app.providers.form_mut().token.trim().is_empty()
&& kind != Some(ProviderKind::Tailscale)
&& (kind != Some(ProviderKind::Aws) || app.providers.form_mut().profile.trim().is_empty())
{
let hint = if kind == Some(ProviderKind::Gcp) {
crate::messages::PROVIDER_TOKEN_REQUIRED_GCP.to_string()
} else if kind == Some(ProviderKind::Oracle) {
crate::messages::PROVIDER_TOKEN_REQUIRED_ORACLE.to_string()
} else {
let display_name = crate::providers::provider_display_name(provider_name.as_str());
crate::messages::provider_token_required(display_name)
};
app.notify_error(hint);
return;
}
if kind == Some(ProviderKind::Gcp) && app.providers.form_mut().project.trim().is_empty() {
app.notify_warning(crate::messages::PROJECT_REQUIRED_GCP);
return;
}
if kind == Some(ProviderKind::Oracle) && app.providers.form_mut().compartment.trim().is_empty()
{
app.notify_warning(crate::messages::COMPARTMENT_REQUIRED_OCI);
return;
}
if kind == Some(ProviderKind::Aws) && app.providers.form_mut().regions.trim().is_empty() {
app.notify_warning(crate::messages::REGIONS_REQUIRED_AWS);
return;
}
if kind == Some(ProviderKind::Scaleway) && app.providers.form_mut().regions.trim().is_empty() {
app.notify_warning(crate::messages::ZONES_REQUIRED_SCALEWAY);
return;
}
if kind == Some(ProviderKind::Azure) {
let subs = app.providers.form().regions.trim().to_string();
if subs.is_empty() {
app.notify_warning(crate::messages::SUBSCRIPTIONS_REQUIRED_AZURE);
return;
}
for sub in subs.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
if !crate::providers::azure::is_valid_subscription_id(sub) {
app.notify_error(crate::messages::azure_subscription_id_invalid(sub));
return;
}
}
}
let token = app.providers.form_mut().token.trim().to_string();
let alias_prefix = app.providers.form_mut().alias_prefix.trim().to_string();
if crate::ssh_config::model::is_host_pattern(&alias_prefix) {
app.notify_warning(crate::messages::ALIAS_PREFIX_INVALID);
return;
}
let user = {
let u = app.providers.form_mut().user.trim();
if u.is_empty() {
"root".to_string()
} else {
u.to_string()
}
};
if user.contains(char::is_whitespace) {
app.notify_warning(crate::messages::USER_NO_WHITESPACE);
return;
}
let vault_role_trimmed = app.providers.form_mut().vault_role.trim();
if !vault_role_trimmed.is_empty() && !crate::vault_ssh::is_valid_role(vault_role_trimmed) {
app.notify_warning(crate::messages::VAULT_ROLE_FORMAT);
return;
}
let section = providers::config::ProviderSection {
id: form_id.clone(),
token: token.clone(),
alias_prefix,
user,
identity_file: app.providers.form_mut().identity_file.trim().to_string(),
url: app.providers.form_mut().url.trim().to_string(),
verify_tls: app.providers.form_mut().verify_tls,
auto_sync: app.providers.form_mut().auto_sync,
profile: app.providers.form_mut().profile.trim().to_string(),
regions: app.providers.form_mut().regions.trim().to_string(),
project: app.providers.form_mut().project.trim().to_string(),
compartment: app.providers.form_mut().compartment.trim().to_string(),
vault_role: app.providers.form_mut().vault_role.trim().to_string(),
vault_addr: app.providers.form_mut().vault_addr.trim().to_string(),
};
let old_section_by_id = app.providers.config().section_by_id(&form_id).cloned();
let bare_id = providers::config::ProviderConfigId::bare(provider_name.clone());
let old_bare_section = app.providers.config().section_by_id(&bare_id).cloned();
let pending_migration = app.providers.pending_label_migration().cloned();
if let Some(migration) = &pending_migration {
if migration.provider == provider_name {
if let Some(mut existing) = old_bare_section.clone() {
let new_id = providers::config::ProviderConfigId::labeled(
migration.provider.clone(),
migration.existing_label.clone(),
);
existing.id = new_id.clone();
let short = providers::get_provider(provider_name.as_str())
.map(|p| p.short_label().to_string())
.unwrap_or_else(|| provider_name.clone());
if existing.alias_prefix == short {
existing.alias_prefix = format!("{}-{}", short, migration.existing_label);
}
app.providers.config_mut().remove_section_by_id(&bare_id);
app.providers.config_mut().set_section(existing);
}
}
}
app.providers.config_mut().set_section(section);
if let Err(e) = app.providers.config().save() {
log::warn!(
"[config] Save failed for [{}]: {}; rolling back in-memory state",
form_id,
e
);
match old_section_by_id {
Some(old) => app.providers.config_mut().set_section(old),
None => app.providers.config_mut().remove_section_by_id(&form_id),
}
if let Some(old_bare) = old_bare_section {
if let Some(migration) = &pending_migration {
let migrated_id = providers::config::ProviderConfigId::labeled(
migration.provider.clone(),
migration.existing_label.clone(),
);
app.providers
.config_mut()
.remove_section_by_id(&migrated_id);
}
app.providers.config_mut().set_section(old_bare);
}
app.providers.cancel_label_migration();
app.notify_error(crate::messages::failed_to_save(&e));
return;
}
if let Some(migration) = &pending_migration {
let rewritten = app
.hosts_state
.ssh_config_mut()
.rewrite_legacy_markers_to_label(&migration.provider, &migration.existing_label);
if rewritten > 0 {
log::debug!(
"provider lazy migration: rewrote {} legacy marker(s) for '{}' to label '{}'",
rewritten,
migration.provider,
migration.existing_label
);
if let Err(e) = app.hosts_state.ssh_config().write() {
app.notify_error(crate::messages::failed_to_save(&e));
}
}
log::debug!("provider lazy migration: completed for '{}'", provider_name);
}
app.providers.cancel_label_migration();
let display_name = crate::providers::provider_display_name(provider_name.as_str());
let sync_section = app.providers.config().section_by_id(&form_id).cloned();
let sync_key = sync_section
.as_ref()
.map(|s| s.id.to_string())
.unwrap_or_else(|| form_id.to_string());
if !app.providers.syncing().contains_key(&sync_key) {
if let Some(sync_section) = sync_section {
app.providers.reset_batch_if_idle();
let cancel = Arc::new(AtomicBool::new(false));
app.providers.syncing_mut().insert(sync_key, cancel.clone());
app.providers.bump_batch_total();
app.notify(crate::messages::provider_saved_syncing(display_name));
super::sync::spawn_provider_sync(&sync_section, events_tx.clone(), cancel);
crate::set_sync_summary(app);
}
} else {
app.notify(crate::messages::provider_saved(display_name));
}
app.close_provider_form();
}
#[cfg(test)]
mod label_migration_tests {
use super::*;
use crate::app::LabelMigrationField;
use crate::ssh_config::model::SshConfigFile;
use crossterm::event::KeyModifiers;
fn make_app() -> App {
let scratch = tempfile::tempdir().expect("tempdir").keep();
crate::preferences::set_path_override(scratch.join("preferences"));
let config = SshConfigFile {
elements: SshConfigFile::parse_content(""),
path: scratch.join("test_config"),
crlf: false,
bom: false,
};
let mut app = App::new(config);
app.screen = Screen::ProviderLabelMigration {
provider: "aws".to_string(),
};
app.providers
.set_pending_label_migration(Some(crate::app::PendingLabelMigration {
provider: "aws".to_string(),
existing_label: "default".to_string(),
new_label: String::new(),
focused: LabelMigrationField::New,
cursor_pos: 0,
}));
app
}
fn k(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn esc_returns_to_providers_and_clears_pending() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
let mut app = make_app();
let (tx, _rx) = mpsc::channel();
handle_label_migration_key(&mut app, k(KeyCode::Esc), &tx);
assert!(matches!(app.screen, Screen::Providers));
assert!(app.providers.pending_label_migration().is_none());
}
#[test]
fn tab_toggles_focused_field() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
let mut app = make_app();
let (tx, _rx) = mpsc::channel();
handle_label_migration_key(&mut app, k(KeyCode::Tab), &tx);
let p = app.providers.pending_label_migration().unwrap();
assert!(matches!(p.focused, LabelMigrationField::Existing));
}
}
#[cfg(test)]
mod labeled_add_tests {
use super::*;
use crate::providers::config::{ProviderConfigId, ProviderSection};
use crate::ssh_config::model::SshConfigFile;
use crossterm::event::KeyModifiers;
fn make_app() -> App {
let scratch = tempfile::tempdir().expect("tempdir").keep();
crate::preferences::set_path_override(scratch.join("preferences"));
let config = SshConfigFile {
elements: SshConfigFile::parse_content(""),
path: scratch.join("test_config"),
crlf: false,
bom: false,
};
let mut app = App::new(config);
let providers_path = scratch.join("providers");
*app.providers.config_mut() = crate::providers::config::ProviderConfig {
sections: Vec::new(),
path_override: Some(providers_path),
};
app
}
fn k(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn proxmox_section(label: Option<&str>) -> ProviderSection {
ProviderSection {
id: match label {
Some(l) => ProviderConfigId::labeled("proxmox", l),
None => ProviderConfigId::bare("proxmox"),
},
token: "user@pam!t=secret".to_string(),
alias_prefix: "pve".to_string(),
user: "root".to_string(),
identity_file: String::new(),
url: "https://pve.example.com:8006".to_string(),
verify_tls: false,
auto_sync: false,
profile: String::new(),
regions: String::new(),
project: String::new(),
compartment: String::new(),
vault_role: String::new(),
vault_addr: String::new(),
}
}
#[test]
fn open_add_flow_with_existing_labeled_enters_label_mode() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
let mut app = make_app();
app.providers
.config_mut()
.set_section(proxmox_section(Some("server1")));
open_add_config_flow(&mut app, "proxmox");
assert!(matches!(app.screen, Screen::ProviderForm { ref id }
if id.provider == "proxmox" && id.label.as_deref() == Some("")));
assert!(app.providers.form_mut().label_entry);
assert_eq!(
app.providers.form_mut().focused_field,
crate::app::ProviderFormField::Label
);
assert_eq!(app.providers.form_mut().label, "");
}
#[test]
fn open_add_flow_with_bare_only_goes_to_migration_not_label_mode() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
let mut app = make_app();
app.providers
.config_mut()
.set_section(proxmox_section(None));
open_add_config_flow(&mut app, "proxmox");
assert!(matches!(
app.screen,
Screen::ProviderLabelMigration { ref provider } if provider == "proxmox"
));
}
#[test]
fn open_add_flow_with_zero_existing_opens_bare_form() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
let mut app = make_app();
open_add_config_flow(&mut app, "proxmox");
assert!(matches!(app.screen, Screen::ProviderForm { ref id }
if id.provider == "proxmox" && id.label.is_none()));
assert!(!app.providers.form_mut().label_entry);
}
#[test]
fn open_form_for_existing_labeled_does_not_enable_label_entry() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
let mut app = make_app();
app.providers
.config_mut()
.set_section(proxmox_section(Some("server1")));
app.open_provider_form(ProviderConfigId::labeled("proxmox", "server1"));
assert!(!app.providers.form_mut().label_entry);
assert_ne!(
app.providers.form_mut().focused_field,
crate::app::ProviderFormField::Label
);
}
#[test]
fn visible_fields_prepends_label_when_label_entry_is_on() {
let mut form = crate::app::ProviderFormFields::new();
form.label_entry = true;
let visible = form.visible_fields("proxmox");
assert_eq!(visible.first(), Some(&crate::app::ProviderFormField::Label));
}
#[test]
fn visible_fields_omits_label_when_label_entry_is_off() {
let mut form = crate::app::ProviderFormFields::new();
form.label_entry = false;
let visible = form.visible_fields("proxmox");
assert!(!visible.contains(&crate::app::ProviderFormField::Label));
}
#[test]
fn char_input_on_label_field_rejects_illegal_chars() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
let mut app = make_app();
app.providers
.config_mut()
.set_section(proxmox_section(Some("server1")));
open_add_config_flow(&mut app, "proxmox");
let (tx, _rx) = mpsc::channel();
handle_provider_form_key(&mut app, k(KeyCode::Char('w')), &tx);
handle_provider_form_key(&mut app, k(KeyCode::Char('o')), &tx);
handle_provider_form_key(&mut app, k(KeyCode::Char('r')), &tx);
handle_provider_form_key(&mut app, k(KeyCode::Char('k')), &tx);
handle_provider_form_key(&mut app, k(KeyCode::Char('A')), &tx);
handle_provider_form_key(&mut app, k(KeyCode::Char(' ')), &tx);
handle_provider_form_key(&mut app, k(KeyCode::Char('@')), &tx);
assert_eq!(app.providers.form_mut().label, "work");
}
#[test]
fn char_input_on_label_field_caps_at_32_chars() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
let mut app = make_app();
app.providers
.config_mut()
.set_section(proxmox_section(Some("server1")));
open_add_config_flow(&mut app, "proxmox");
let (tx, _rx) = mpsc::channel();
for _ in 0..40 {
handle_provider_form_key(&mut app, k(KeyCode::Char('a')), &tx);
}
assert_eq!(app.providers.form_mut().label.len(), 32);
}
#[test]
fn submit_with_empty_label_keeps_form_open_and_focuses_label() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
let mut app = make_app();
app.providers
.config_mut()
.set_section(proxmox_section(Some("server1")));
open_add_config_flow(&mut app, "proxmox");
let (tx, _rx) = mpsc::channel();
handle_provider_form_key(&mut app, k(KeyCode::Enter), &tx);
assert!(matches!(app.screen, Screen::ProviderForm { .. }));
assert_eq!(
app.providers.form_mut().focused_field,
crate::app::ProviderFormField::Label
);
assert_eq!(
app.providers
.config()
.sections_for_provider("proxmox")
.len(),
1
);
}
#[test]
fn submit_third_labeled_config_persists_with_typed_label() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
let mut app = make_app();
app.providers
.config_mut()
.set_section(proxmox_section(Some("server1")));
open_add_config_flow(&mut app, "proxmox");
let (tx, _rx) = mpsc::channel();
for c in "server2".chars() {
handle_provider_form_key(&mut app, k(KeyCode::Char(c)), &tx);
}
app.providers.form_mut().expanded = true;
app.providers.form_mut().focused_field = crate::app::ProviderFormField::Url;
app.providers.form_mut().sync_cursor_to_end();
for c in "https://pve2.example.com:8006".chars() {
handle_provider_form_key(&mut app, k(KeyCode::Char(c)), &tx);
}
app.providers.form_mut().focused_field = crate::app::ProviderFormField::Token;
app.providers.form_mut().sync_cursor_to_end();
for c in "user@pam!t=secret".chars() {
handle_provider_form_key(&mut app, k(KeyCode::Char(c)), &tx);
}
handle_provider_form_key(&mut app, k(KeyCode::Enter), &tx);
let names: Vec<String> = app
.providers
.config()
.sections_for_provider("proxmox")
.iter()
.map(|s| s.id.to_string())
.collect();
assert!(
names.contains(&"proxmox:server1".to_string()),
"got {names:?} (label={:?} url={:?} token_len={})",
app.providers.form().label,
app.providers.form().url,
app.providers.form().token.len()
);
assert!(
names.contains(&"proxmox:server2".to_string()),
"got {names:?} (label={:?} url={:?} token_len={})",
app.providers.form().label,
app.providers.form().url,
app.providers.form().token.len()
);
}
#[test]
fn delete_then_readd_same_label_persists_through_label_entry() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
let mut app = make_app();
app.providers.config_mut().set_section(ProviderSection {
alias_prefix: "pve-server1".to_string(),
..proxmox_section(Some("server1"))
});
app.providers.config_mut().set_section(ProviderSection {
alias_prefix: "pve-server2".to_string(),
..proxmox_section(Some("server2"))
});
assert_eq!(
app.providers
.config()
.sections_for_provider("proxmox")
.len(),
2
);
app.providers
.config_mut()
.remove_section_by_id(&ProviderConfigId::labeled("proxmox", "server2"));
assert_eq!(
app.providers
.config()
.sections_for_provider("proxmox")
.len(),
1
);
open_add_config_flow(&mut app, "proxmox");
assert!(app.providers.form_mut().label_entry);
let (tx, _rx) = mpsc::channel();
for c in "server2".chars() {
handle_provider_form_key(&mut app, k(KeyCode::Char(c)), &tx);
}
app.providers.form_mut().expanded = true;
app.providers.form_mut().focused_field = crate::app::ProviderFormField::Url;
app.providers.form_mut().sync_cursor_to_end();
for c in "https://pve-readd.example.com:8006".chars() {
handle_provider_form_key(&mut app, k(KeyCode::Char(c)), &tx);
}
app.providers.form_mut().focused_field = crate::app::ProviderFormField::Token;
app.providers.form_mut().sync_cursor_to_end();
for c in "user@pam!t=secret".chars() {
handle_provider_form_key(&mut app, k(KeyCode::Char(c)), &tx);
}
handle_provider_form_key(&mut app, k(KeyCode::Enter), &tx);
let names: Vec<String> = app
.providers
.config()
.sections_for_provider("proxmox")
.iter()
.map(|s| s.id.to_string())
.collect();
assert!(
names.contains(&"proxmox:server1".to_string()),
"got {names:?}"
);
assert!(
names.contains(&"proxmox:server2".to_string()),
"got {names:?}"
);
let readded = app
.providers
.config()
.section_by_id(&ProviderConfigId::labeled("proxmox", "server2"))
.expect("readded section must be present");
assert_eq!(readded.url, "https://pve-readd.example.com:8006");
}
#[test]
fn submit_with_duplicate_label_keeps_form_open() {
let _lock = crate::demo_flag::GLOBAL_TEST_LOCK.lock().unwrap();
let mut app = make_app();
app.providers
.config_mut()
.set_section(proxmox_section(Some("server1")));
open_add_config_flow(&mut app, "proxmox");
let (tx, _rx) = mpsc::channel();
for c in "server1".chars() {
handle_provider_form_key(&mut app, k(KeyCode::Char(c)), &tx);
}
handle_provider_form_key(&mut app, k(KeyCode::Tab), &tx);
for c in "fake-url-fake".chars() {
handle_provider_form_key(&mut app, k(KeyCode::Char(c)), &tx);
}
handle_provider_form_key(&mut app, k(KeyCode::Enter), &tx);
assert!(matches!(app.screen, Screen::ProviderForm { .. }));
assert_eq!(
app.providers.form_mut().focused_field,
crate::app::ProviderFormField::Label
);
assert_eq!(
app.providers
.config()
.sections_for_provider("proxmox")
.len(),
1
);
}
}