diff --git a/Cargo.lock b/Cargo.lock index 13b670f558..35ecb4f1c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4007,6 +4007,7 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "crossterm 0.28.1", + "indexmap", "miette", "openshell-bootstrap", "openshell-core", diff --git a/Cargo.toml b/Cargo.toml index f450cd5c8c..859eb30131 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,6 +104,7 @@ pin-project-lite = "0.2" tokio-stream = "0.1" protobuf-src = "1.1.0" url = "2" +indexmap = "2" # Database sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "sqlite", "migrate"] } diff --git a/crates/openshell-tui/Cargo.toml b/crates/openshell-tui/Cargo.toml index 2381661364..131a8ccff2 100644 --- a/crates/openshell-tui/Cargo.toml +++ b/crates/openshell-tui/Cargo.toml @@ -27,6 +27,7 @@ owo-colors = { workspace = true } serde = { workspace = true } tracing = { workspace = true } url = { workspace = true } +indexmap = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 4538b207d6..4d3b2c0c86 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use indexmap::IndexMap; use openshell_bootstrap::GatewayMetadataSource; use openshell_core::auth::EdgeAuthInterceptor; use openshell_core::proto::open_shell_client::OpenShellClient; @@ -347,6 +348,10 @@ pub enum ProviderKeyField { EnvVarName, /// Custom env var value (generic / no-known-env-vars types only). GenericValue, + /// Config key name input. + ConfigKeyName, + /// Config key value input. + ConfigKeyValue, Submit, } @@ -363,6 +368,14 @@ pub struct CreateProviderForm { pub credentials: Vec<(String, String)>, /// Which credential row is focused. pub cred_cursor: usize, + /// Provider config key-value pairs (e.g. `ANTHROPIC_BASE_URL`). + pub config: IndexMap, + /// Which existing config entry is selected (for deletion). + pub config_cursor: usize, + /// Config key being entered. + pub config_key_input: String, + /// Config value being entered. + pub config_value_input: String, /// For generic / types with no known env vars: custom env var name. pub generic_env_name: String, /// For generic / types with no known env vars: custom value. @@ -497,9 +510,38 @@ pub struct UpdateProviderForm { pub provider_type: String, pub credential_key: String, pub new_value: String, + pub config: IndexMap, + pub config_key_input: String, + pub config_value_input: String, + pub config_cursor: usize, + pub focus: UpdateProviderField, pub status: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateProviderField { + CredentialValue, + ConfigKey, + ConfigValue, + Submit, +} + +// --------------------------------------------------------------------------- +// Shared config helpers +// --------------------------------------------------------------------------- + +fn flush_config_input( + config: &mut IndexMap, + key_input: &mut String, + value_input: &mut String, +) -> bool { + if !key_input.is_empty() && !value_input.is_empty() { + config.insert(std::mem::take(key_input), std::mem::take(value_input)); + return true; + } + false +} + // --------------------------------------------------------------------------- // App state // --------------------------------------------------------------------------- @@ -2111,6 +2153,10 @@ impl App { name: String::new(), credentials: Vec::new(), cred_cursor: 0, + config: IndexMap::new(), + config_cursor: 0, + config_key_input: String::new(), + config_value_input: String::new(), generic_env_name: String::new(), generic_value: String::new(), key_field: ProviderKeyField::Name, @@ -2219,24 +2265,58 @@ impl App { form.name.clear(); form.credentials.clear(); form.cred_cursor = 0; + form.config.clear(); + form.config_cursor = 0; + form.config_key_input.clear(); + form.config_value_input.clear(); form.generic_env_name.clear(); form.generic_value.clear(); } KeyCode::Tab => { if form.is_generic { - // Name → EnvVarName → GenericValue → Submit → Name - form.key_field = match form.key_field { - ProviderKeyField::Name => ProviderKeyField::EnvVarName, - ProviderKeyField::EnvVarName => ProviderKeyField::GenericValue, - ProviderKeyField::GenericValue => ProviderKeyField::Submit, - _ => ProviderKeyField::Name, - }; + // Name → EnvVarName → GenericValue → ConfigKeyName → ConfigKeyValue → Submit → Name + match form.key_field { + ProviderKeyField::Name => { + form.key_field = ProviderKeyField::EnvVarName; + } + ProviderKeyField::EnvVarName => { + form.key_field = ProviderKeyField::GenericValue; + } + ProviderKeyField::GenericValue => { + form.key_field = ProviderKeyField::ConfigKeyName; + } + ProviderKeyField::ConfigKeyName => { + form.key_field = ProviderKeyField::ConfigKeyValue; + } + ProviderKeyField::ConfigKeyValue => { + if flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ) { + form.key_field = ProviderKeyField::ConfigKeyName; + } else if form.config_key_input.is_empty() + && form.config_value_input.is_empty() + { + form.key_field = ProviderKeyField::Submit; + } else { + form.status = Some( + "Both key and value required to add config entry." + .to_string(), + ); + form.key_field = ProviderKeyField::ConfigKeyName; + } + } + _ => { + form.key_field = ProviderKeyField::Name; + } + } } else { - // Name → Credential[0..N-1] → Submit → Name + // Name → Credential[0..N-1] → ConfigKeyName → ConfigKeyValue → Submit → Name match form.key_field { ProviderKeyField::Name => { if form.credentials.is_empty() { - form.key_field = ProviderKeyField::Submit; + form.key_field = ProviderKeyField::ConfigKeyName; } else { form.key_field = ProviderKeyField::Credential; form.cred_cursor = 0; @@ -2246,7 +2326,29 @@ impl App { if form.cred_cursor < form.credentials.len().saturating_sub(1) { form.cred_cursor += 1; } else { + form.key_field = ProviderKeyField::ConfigKeyName; + } + } + ProviderKeyField::ConfigKeyName => { + form.key_field = ProviderKeyField::ConfigKeyValue; + } + ProviderKeyField::ConfigKeyValue => { + if flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ) { + form.key_field = ProviderKeyField::ConfigKeyName; + } else if form.config_key_input.is_empty() + && form.config_value_input.is_empty() + { form.key_field = ProviderKeyField::Submit; + } else { + form.status = Some( + "Both key and value required to add config entry." + .to_string(), + ); + form.key_field = ProviderKeyField::ConfigKeyName; } } _ => { @@ -2260,7 +2362,9 @@ impl App { form.key_field = match form.key_field { ProviderKeyField::EnvVarName => ProviderKeyField::Name, ProviderKeyField::GenericValue => ProviderKeyField::EnvVarName, - ProviderKeyField::Submit => ProviderKeyField::GenericValue, + ProviderKeyField::ConfigKeyName => ProviderKeyField::GenericValue, + ProviderKeyField::ConfigKeyValue => ProviderKeyField::ConfigKeyName, + ProviderKeyField::Submit => ProviderKeyField::ConfigKeyValue, _ => ProviderKeyField::Submit, }; } else { @@ -2272,7 +2376,10 @@ impl App { form.key_field = ProviderKeyField::Name; } } - ProviderKeyField::Submit => { + ProviderKeyField::ConfigKeyValue => { + form.key_field = ProviderKeyField::ConfigKeyName; + } + ProviderKeyField::ConfigKeyName => { if form.credentials.is_empty() { form.key_field = ProviderKeyField::Name; } else { @@ -2280,6 +2387,9 @@ impl App { form.cred_cursor = form.credentials.len().saturating_sub(1); } } + ProviderKeyField::Submit => { + form.key_field = ProviderKeyField::ConfigKeyValue; + } _ => { form.key_field = ProviderKeyField::Submit; } @@ -2293,6 +2403,52 @@ impl App { Self::handle_text_input(value, key); } } + ProviderKeyField::ConfigKeyName => match key.code { + KeyCode::Enter => { + flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ); + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !form.config.is_empty() { + let key_to_remove = form + .config + .keys() + .nth(form.config_cursor) + .cloned() + .unwrap_or_default(); + form.config.shift_remove(&key_to_remove); + form.config_cursor = + form.config_cursor.min(form.config.len().saturating_sub(1)); + } + } + KeyCode::Up => { + form.config_cursor = form.config_cursor.saturating_sub(1); + } + KeyCode::Down => { + if !form.config.is_empty() { + form.config_cursor = + (form.config_cursor + 1).min(form.config.len() - 1); + } + } + _ => { + Self::handle_text_input(&mut form.config_key_input, key); + } + }, + ProviderKeyField::ConfigKeyValue => match key.code { + KeyCode::Enter => { + flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ); + } + _ => { + Self::handle_text_input(&mut form.config_value_input, key); + } + }, ProviderKeyField::EnvVarName => { Self::handle_text_input(&mut form.generic_env_name, key); } @@ -2414,6 +2570,17 @@ impl App { .get(self.provider_selected) .cloned() .unwrap_or_default(); + let existing_config = self + .provider_entries + .get(self.provider_selected) + .map(|e| { + e.provider + .config + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + }) + .unwrap_or_default(); // If we don't know the credential key, derive from registry. let key = if cred_key.is_empty() { @@ -2431,6 +2598,11 @@ impl App { provider_type: ptype, credential_key: key, new_value: String::new(), + config: existing_config, + config_key_input: String::new(), + config_value_input: String::new(), + config_cursor: 0, + focus: UpdateProviderField::CredentialValue, status: None, }); } @@ -2444,18 +2616,108 @@ impl App { KeyCode::Esc => { self.update_provider_form = None; } - KeyCode::Enter => { - if form.new_value.is_empty() { - form.status = Some("Value is required.".to_string()); - return; + KeyCode::Tab => match form.focus { + UpdateProviderField::CredentialValue => { + form.focus = UpdateProviderField::ConfigKey; } - self.pending_provider_update = true; - } - KeyCode::Char(c) => form.new_value.push(c), - KeyCode::Backspace => { - form.new_value.pop(); - } - _ => {} + UpdateProviderField::ConfigKey => { + form.focus = UpdateProviderField::ConfigValue; + } + UpdateProviderField::ConfigValue => { + if flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ) { + form.focus = UpdateProviderField::ConfigKey; + } else if form.config_key_input.is_empty() && form.config_value_input.is_empty() + { + form.focus = UpdateProviderField::Submit; + } else { + form.status = + Some("Both key and value required to add config entry.".to_string()); + form.focus = UpdateProviderField::ConfigKey; + } + } + UpdateProviderField::Submit => { + form.focus = UpdateProviderField::CredentialValue; + } + }, + KeyCode::BackTab => match form.focus { + UpdateProviderField::CredentialValue => { + form.focus = UpdateProviderField::Submit; + } + UpdateProviderField::ConfigKey => { + form.focus = UpdateProviderField::CredentialValue; + } + UpdateProviderField::ConfigValue => { + form.focus = UpdateProviderField::ConfigKey; + } + UpdateProviderField::Submit => { + form.focus = UpdateProviderField::ConfigValue; + } + }, + _ => match form.focus { + UpdateProviderField::CredentialValue => { + Self::handle_text_input(&mut form.new_value, key); + } + UpdateProviderField::ConfigKey => match key.code { + KeyCode::Enter => { + flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ); + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !form.config.is_empty() { + let key_to_remove = form + .config + .keys() + .nth(form.config_cursor) + .cloned() + .unwrap_or_default(); + form.config.shift_remove(&key_to_remove); + form.config_cursor = + form.config_cursor.min(form.config.len().saturating_sub(1)); + } + } + KeyCode::Up => { + form.config_cursor = form.config_cursor.saturating_sub(1); + } + KeyCode::Down => { + if !form.config.is_empty() { + form.config_cursor = + (form.config_cursor + 1).min(form.config.len() - 1); + } + } + _ => { + Self::handle_text_input(&mut form.config_key_input, key); + } + }, + UpdateProviderField::ConfigValue => match key.code { + KeyCode::Enter => { + flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ); + } + _ => { + Self::handle_text_input(&mut form.config_value_input, key); + } + }, + UpdateProviderField::Submit => { + if key.code == KeyCode::Enter { + if form.new_value.is_empty() && form.config.is_empty() { + form.status = + Some("Credential value or config keys required.".to_string()); + return; + } + self.pending_provider_update = true; + } + } + }, } } @@ -2593,7 +2855,12 @@ impl App { }, ); - let mut config_lines = provider.config.keys().cloned().collect::>(); + let mut config_lines = provider + .config + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>(); + config_lines.sort(); if config_lines.is_empty() { config_lines.push("".to_string()); @@ -2931,4 +3198,35 @@ mod tests { assert_eq!(gateway.source_label(), "unknown"); } + + #[test] + fn flush_config_input_inserts_when_both_present() { + let mut config = IndexMap::new(); + let mut key = "FOO".to_string(); + let mut val = "bar".to_string(); + assert!(flush_config_input(&mut config, &mut key, &mut val)); + assert_eq!(config.get("FOO"), Some(&"bar".to_string())); + assert!(key.is_empty()); + assert!(val.is_empty()); + } + + #[test] + fn flush_config_input_noop_when_key_empty() { + let mut config = IndexMap::new(); + let mut key = String::new(); + let mut val = "bar".to_string(); + assert!(!flush_config_input(&mut config, &mut key, &mut val)); + assert!(config.is_empty()); + assert_eq!(val, "bar"); + } + + #[test] + fn flush_config_input_noop_when_value_empty() { + let mut config = IndexMap::new(); + let mut key = "FOO".to_string(); + let mut val = String::new(); + assert!(!flush_config_input(&mut config, &mut key, &mut val)); + assert!(config.is_empty()); + assert_eq!(key, "FOO"); + } } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 7992666d38..1c95ce6d00 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1597,6 +1597,11 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { form.name.clone() }; let credentials = form.discovered_credentials.clone().unwrap_or_default(); + let config: HashMap = form + .config + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); tokio::spawn(async move { // Try with the chosen name, retry with suffix on collision. @@ -1618,7 +1623,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { }), r#type: ptype.clone(), credentials: credentials.clone(), - config: HashMap::default(), + config: config.clone(), credential_expires_at_ms: HashMap::default(), }), }; @@ -1694,10 +1699,17 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { let ptype = form.provider_type.clone(); let cred_key = form.credential_key.clone(); let new_value = form.new_value.clone(); + let config: HashMap = form + .config + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); tokio::spawn(async move { let mut credentials = HashMap::new(); - credentials.insert(cred_key, new_value); + if !new_value.is_empty() { + credentials.insert(cred_key, new_value); + } let req = openshell_core::proto::UpdateProviderRequest { provider: Some(openshell_core::proto::Provider { @@ -1710,7 +1722,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { }), r#type: ptype, credentials, - config: HashMap::default(), + config, credential_expires_at_ms: HashMap::default(), }), credential_expires_at_ms: HashMap::default(), diff --git a/crates/openshell-tui/src/ui/create_provider.rs b/crates/openshell-tui/src/ui/create_provider.rs index a0896aa46a..58c69fda38 100644 --- a/crates/openshell-tui/src/ui/create_provider.rs +++ b/crates/openshell-tui/src/ui/create_provider.rs @@ -6,10 +6,14 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; -use crate::app::{App, CreateProviderPhase, ProviderKeyField}; +use crate::app::{App, CreateProviderPhase, ProviderKeyField, UpdateProviderField}; + +use indexmap::IndexMap; use super::centered_rect; +const MAX_VISIBLE_CONFIG: usize = 6; + /// Draw the create provider modal overlay. pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { let t = &app.theme; @@ -201,14 +205,18 @@ fn draw_enter_key( let has_warning = form.warning.is_some(); let warning_rows: u16 = if has_warning { 2 } else { 0 }; // warning + spacer + #[allow(clippy::cast_possible_truncation)] + let config_rows = (form.config.len() as u16).min(MAX_VISIBLE_CONFIG as u16) + 3; #[allow(clippy::cast_possible_truncation)] let content_height = if form.is_generic { - // type(1) + name(2) + spacer(1) + env_name(2) + value(2) + spacer(1) + submit(1) + status(1) + hint(1) - warning_rows + 12 + // type(1) + name(2) + spacer(1) + env_name(2) + value(2) + spacer(1) + // + config_section + spacer(1) + submit(1) + status(1) + hint(1) + warning_rows + 1 + 2 + 1 + 2 + 2 + 1 + config_rows + 1 + 1 + 1 + 1 } else { let num_creds = form.credentials.len().clamp(1, 8) as u16; - // type(1) + name(2) + spacer(1) + creds + spacer(1) + submit(1) + status(1) + hint(1) - warning_rows + 1 + 2 + 1 + num_creds + 1 + 1 + 1 + 1 + // type(1) + name(2) + spacer(1) + creds + spacer(1) + // + config_section + spacer(1) + submit(1) + status(1) + hint(1) + warning_rows + 1 + 2 + 1 + num_creds + 1 + config_rows + 1 + 1 + 1 + 1 }; let modal_height = (content_height + 4).min(area.height.saturating_sub(2)); let popup_area = centered_rect(modal_width, modal_height, area); @@ -241,6 +249,17 @@ fn draw_enter_key( let num_creds = form.credentials.len().clamp(1, 8) as u16; constraints.push(Constraint::Length(num_creds)); // credential rows } + + constraints.push(Constraint::Length(1)); // spacer before config + constraints.push(Constraint::Length(1)); // config keys label + #[allow(clippy::cast_possible_truncation)] + let num_config = form.config.len().min(MAX_VISIBLE_CONFIG) as u16; + if num_config > 0 { + constraints.push(Constraint::Length(num_config)); // existing config entries + } + constraints.push(Constraint::Length(1)); // config key input + constraints.push(Constraint::Length(1)); // config value input + constraints.push(Constraint::Length(1)); // spacer constraints.push(Constraint::Length(1)); // submit constraints.push(Constraint::Length(1)); // status @@ -361,7 +380,65 @@ fn draw_enter_key( } idx += 1; - // Spacer. + // Spacer before config. + idx += 1; + + // Config Keys label. + let config_focused = matches!( + form.key_field, + ProviderKeyField::ConfigKeyName | ProviderKeyField::ConfigKeyValue + ); + let header_style = if config_focused { + t.accent_bold + } else { + t.muted + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled("Config Keys:", header_style))), + chunks[idx], + ); + idx += 1; + + // Existing config entries. + if !form.config.is_empty() { + render_config_entries( + frame, + &form.config, + form.config_cursor, + config_focused, + chunks[idx], + t, + ); + idx += 1; + } + + // Config key input. + let editing_key = form.key_field == ProviderKeyField::ConfigKeyName; + render_config_input_field( + frame, + "Key", + &form.config_key_input, + editing_key, + "key", + chunks[idx], + t, + ); + idx += 1; + + // Config value input. + let editing_val = form.key_field == ProviderKeyField::ConfigKeyValue; + render_config_input_field( + frame, + "Val", + &form.config_value_input, + editing_val, + "value", + chunks[idx], + t, + ); + idx += 1; + + // Spacer before submit. idx += 1; // Submit button. @@ -383,20 +460,7 @@ fn draw_enter_key( idx += 1; // Status. - if let Some(ref status) = form.status { - let style = if status.contains("required") - || status.contains("failed") - || status.contains("Failed") - { - t.status_err - } else { - t.status_ok - }; - frame.render_widget( - Paragraph::new(Line::from(Span::styled(format!(" {status}"), style))), - chunks[idx], - ); - } + render_status(frame, form.status.as_deref(), chunks[idx], t); idx += 1; // Hint. @@ -405,6 +469,8 @@ fn draw_enter_key( Span::styled(" Next ", t.muted), Span::styled("[S-Tab]", t.key_hint), Span::styled(" Prev ", t.muted), + Span::styled("[C-d]", t.key_hint), + Span::styled(" Delete ", t.muted), Span::styled("[Enter]", t.key_hint), Span::styled(" Submit ", t.muted), Span::styled("[Esc]", t.key_hint), @@ -669,9 +735,14 @@ pub fn draw_update(frame: &mut Frame<'_>, app: &App, area: Rect) { return; }; - let modal_width = 60u16.min(area.width.saturating_sub(4)); - // name(1) + type(1) + spacer(1) + key_label(1) + value(1) + cursor_hint(1) + spacer(1) + status(1) + hint(1) - let content_height = 9; + let modal_width = 64u16.min(area.width.saturating_sub(4)); + + #[allow(clippy::cast_possible_truncation)] + let num_config = form.config.len().min(MAX_VISIBLE_CONFIG) as u16; + let config_rows = num_config + 3; // existing entries + label(1) + key input(1) + value input(1) + // name(1) + type(1) + spacer(1) + key_label(1) + value(1) + spacer(1) + // + config_section + spacer(1) + submit(1) + status(1) + hint(1) + let content_height: u16 = 1 + 1 + 1 + 1 + 1 + 1 + config_rows + 1 + 1 + 1 + 1; let modal_height = (content_height + 4).min(area.height.saturating_sub(2)); let popup_area = centered_rect(modal_width, modal_height, area); @@ -686,91 +757,181 @@ pub fn draw_update(frame: &mut Frame<'_>, app: &App, area: Rect) { let inner = block.inner(popup_area); frame.render_widget(block, popup_area); + let mut constraints = vec![ + Constraint::Length(1), // name + Constraint::Length(1), // type + Constraint::Length(1), // spacer + Constraint::Length(1), // key label + Constraint::Length(1), // value input + Constraint::Length(1), // spacer before config + Constraint::Length(1), // config keys label + ]; + if num_config > 0 { + constraints.push(Constraint::Length(num_config)); // existing config entries + } + constraints.extend([ + Constraint::Length(1), // config key input + Constraint::Length(1), // config value input + Constraint::Length(1), // spacer before submit + Constraint::Length(1), // submit + Constraint::Length(1), // status + Constraint::Length(1), // hint + Constraint::Min(0), + ]); + let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // name - Constraint::Length(1), // type - Constraint::Length(1), // spacer - Constraint::Length(1), // key label - Constraint::Length(1), // value input - Constraint::Length(1), // cursor hint - Constraint::Length(1), // spacer - Constraint::Length(1), // status - Constraint::Length(1), // hint - Constraint::Min(0), - ]) + .constraints(constraints) .split(inner); + let mut idx = 0; + + // Name. frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled("Name: ", t.muted), Span::styled(&form.provider_name, t.heading), ])), - chunks[0], + chunks[idx], ); + idx += 1; + // Type. frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled("Type: ", t.muted), Span::styled(&form.provider_type, t.text), ])), - chunks[1], + chunks[idx], ); + idx += 1; + + // Spacer. + idx += 1; + // Credential key label. + let cred_focused = form.focus == UpdateProviderField::CredentialValue; let key_label = if form.credential_key.is_empty() { - "New value:" + "New value" } else { &form.credential_key }; frame.render_widget( Paragraph::new(Line::from(Span::styled( format!("{key_label}:"), - t.accent_bold, + if cred_focused { t.accent_bold } else { t.muted }, ))), - chunks[3], + chunks[idx], ); + idx += 1; - // Mask the input value as dots. + // Credential value input (masked). let masked: String = "*".repeat(form.new_value.len()); - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(format!(" {masked}"), t.accent), - Span::styled("_", t.accent), - ])), - chunks[4], - ); + let cred_style = if cred_focused { t.accent } else { t.muted }; + let mut cred_spans = vec![Span::styled(format!(" {masked}"), cred_style)]; + if cred_focused { + cred_spans.push(Span::styled("_", t.accent)); + } + frame.render_widget(Paragraph::new(Line::from(cred_spans)), chunks[idx]); + idx += 1; + + // Spacer before config. + idx += 1; + // Config Keys label. + let config_focused = matches!( + form.focus, + UpdateProviderField::ConfigKey | UpdateProviderField::ConfigValue + ); + let header_style = if config_focused { + t.accent_bold + } else { + t.muted + }; frame.render_widget( - Paragraph::new(Line::from(Span::styled( - " Type the new credential value", - t.muted, - ))), - chunks[5], + Paragraph::new(Line::from(Span::styled("Config Keys:", header_style))), + chunks[idx], ); + idx += 1; - if let Some(ref status) = form.status { - let style = if status.contains("required") - || status.contains("failed") - || status.contains("Failed") - { - t.status_err - } else { - t.status_ok - }; - frame.render_widget( - Paragraph::new(Line::from(Span::styled(format!(" {status}"), style))), - chunks[7], + // Existing config entries. + if !form.config.is_empty() { + render_config_entries( + frame, + &form.config, + form.config_cursor, + config_focused, + chunks[idx], + t, ); + idx += 1; } + // Config key input. + let editing_key = form.focus == UpdateProviderField::ConfigKey; + render_config_input_field( + frame, + "Key", + &form.config_key_input, + editing_key, + "key", + chunks[idx], + t, + ); + idx += 1; + + // Config value input. + let editing_val = form.focus == UpdateProviderField::ConfigValue; + render_config_input_field( + frame, + "Val", + &form.config_value_input, + editing_val, + "value", + chunks[idx], + t, + ); + idx += 1; + + // Spacer before submit. + idx += 1; + + // Submit button. + let submit_focused = form.focus == UpdateProviderField::Submit; + let submit_style = if submit_focused { + t.accent_bold + } else { + t.muted + }; + let submit_label = if submit_focused { + " > Update Provider" + } else { + " Update Provider" + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(submit_label, submit_style))), + chunks[idx], + ); + idx += 1; + + // Status. + render_status(frame, form.status.as_deref(), chunks[idx], t); + idx += 1; + + // Hint. let hint = Line::from(vec![ + Span::styled("[Tab]", t.key_hint), + Span::styled(" Next ", t.muted), + Span::styled("[S-Tab]", t.key_hint), + Span::styled(" Prev ", t.muted), + Span::styled("[C-d]", t.key_hint), + Span::styled(" Delete ", t.muted), Span::styled("[Enter]", t.key_hint), - Span::styled(" Update ", t.muted), + Span::styled(" Submit ", t.muted), Span::styled("[Esc]", t.key_hint), Span::styled(" Cancel", t.muted), ]); - frame.render_widget(Paragraph::new(hint), chunks[8]); + frame.render_widget(Paragraph::new(hint), chunks[idx]); } // --------------------------------------------------------------------------- @@ -813,3 +974,96 @@ fn draw_secret_field( }; frame.render_widget(Paragraph::new(display), chunks[1]); } + +fn render_config_entries( + frame: &mut Frame<'_>, + config: &IndexMap, + config_cursor: usize, + config_focused: bool, + area: Rect, + theme: &crate::theme::Theme, +) { + let t = theme; + let overflow = config.len() > MAX_VISIBLE_CONFIG; + let take_count = if overflow { + MAX_VISIBLE_CONFIG - 1 + } else { + MAX_VISIBLE_CONFIG + }; + let mut config_lines: Vec> = config + .iter() + .enumerate() + .take(take_count) + .map(|(i, (key, value))| { + let is_selected = config_focused && i == config_cursor; + let style = if is_selected { t.accent_bold } else { t.text }; + Line::from(vec![ + Span::styled(format!(" {key}="), style), + Span::styled(value.as_str(), if is_selected { t.accent } else { t.muted }), + ]) + }) + .collect(); + if overflow { + let remaining = config.len() - take_count; + config_lines.push(Line::from(Span::styled( + format!(" \u{2026}and {remaining} more"), + t.muted, + ))); + } + frame.render_widget(Paragraph::new(config_lines), area); +} + +fn render_config_input_field( + frame: &mut Frame<'_>, + label: &str, + input: &str, + editing: bool, + placeholder: &str, + area: Rect, + theme: &crate::theme::Theme, +) { + let t = theme; + let display = if input.is_empty() { + if editing { + "_".to_string() + } else { + placeholder.to_string() + } + } else { + let mut s = input.to_string(); + if editing { + s.push('_'); + } + s + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!(" {label}: "), t.muted), + Span::styled(display, if editing { t.accent } else { t.muted }), + ])), + area, + ); +} + +fn render_status( + frame: &mut Frame<'_>, + status: Option<&str>, + area: Rect, + theme: &crate::theme::Theme, +) { + let t = theme; + if let Some(status) = status { + let style = if status.contains("required") + || status.contains("failed") + || status.contains("Failed") + { + t.status_err + } else { + t.status_ok + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(format!(" {status}"), style))), + area, + ); + } +}