From 4b27142fe181c3abd98d6c554bd67fc0f4f696bf Mon Sep 17 00:00:00 2001 From: InfiniteTabs <18383533+InfiniteTabs@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:07:48 +0200 Subject: [PATCH] fix: add stack generator to tui --- cli/src/tui/app.rs | 80 +- cli/src/tui/events/main_handler.rs | 7 + cli/src/tui/events/mod.rs | 2 + cli/src/tui/events/stack_builder_handler.rs | 360 +++++++ cli/src/tui/handlers.rs | 9 +- cli/src/tui/renderers/common.rs | 1 + cli/src/tui/state/mod.rs | 2 + cli/src/tui/state/stack_builder_state.rs | 839 ++++++++++++++++ cli/src/tui/ui.rs | 14 +- cli/src/tui/widgets/mod.rs | 2 + cli/src/tui/widgets/stack_builder.rs | 1003 +++++++++++++++++++ 11 files changed, 2316 insertions(+), 3 deletions(-) create mode 100644 cli/src/tui/events/stack_builder_handler.rs create mode 100644 cli/src/tui/state/stack_builder_state.rs create mode 100644 cli/src/tui/widgets/stack_builder.rs diff --git a/cli/src/tui/app.rs b/cli/src/tui/app.rs index 3eec49c..7b72362 100644 --- a/cli/src/tui/app.rs +++ b/cli/src/tui/app.rs @@ -2,7 +2,8 @@ use anyhow::Result; use super::state::{ claim_builder_state::ClaimBuilderState, detail_state::DetailState, events_state::EventsState, - modal_state::ModalState, search_state::SearchState, view_state::ViewState, + modal_state::ModalState, search_state::SearchState, stack_builder_state::StackBuilderState, + view_state::ViewState, }; use super::utils::NavItem; use crate::current_region_handler; @@ -39,6 +40,8 @@ pub enum PendingAction { ReloadCurrentDeploymentDetail, SaveClaimToFile, RunClaimFromBuilder, + OpenStackBuilder, + SaveStackToFile, } #[derive(Debug, Clone)] @@ -102,6 +105,7 @@ pub struct App { pub modal_state: ModalState, pub search_state: SearchState, pub claim_builder_state: ClaimBuilderState, + pub stack_builder_state: StackBuilderState, // ==================== LEGACY FIELDS (TRANSITIONING) ==================== // These are kept for backward compatibility during migration. @@ -171,6 +175,7 @@ impl App { let modal_state = ModalState::new(); let search_state = SearchState::new(); let claim_builder_state = ClaimBuilderState::new(); + let stack_builder_state = StackBuilderState::new(); Self { // Core app state @@ -190,6 +195,7 @@ impl App { modal_state, search_state, claim_builder_state, + stack_builder_state, // Legacy fields - initialized from defaults for backward compatibility // View state @@ -641,6 +647,12 @@ impl App { PendingAction::RunClaimFromBuilder => { self.run_claim_from_builder().await?; } + PendingAction::OpenStackBuilder => { + self.open_stack_builder().await?; + } + PendingAction::SaveStackToFile => { + self.save_stack_to_file().await?; + } } Ok(()) @@ -708,6 +720,12 @@ impl App { PendingAction::RunClaimFromBuilder => { self.set_loading("Running claim..."); } + PendingAction::OpenStackBuilder => { + self.set_loading("Loading modules..."); + } + PendingAction::SaveStackToFile => { + self.set_loading("Saving stack to file..."); + } } } @@ -2346,6 +2364,66 @@ impl App { self.clear_loading(); Ok(()) } + + /// Open the stack builder with all available modules + pub async fn open_stack_builder(&mut self) -> Result<()> { + // Load all modules (from all tracks) + let modules = current_region_handler() + .await + .get_all_latest_module("") + .await?; + + self.stack_builder_state.open(modules); + self.clear_loading(); + + Ok(()) + } + + /// Save the stack builder's generated YAML to a file + pub async fn save_stack_to_file(&mut self) -> Result<()> { + use std::fs; + use std::path::PathBuf; + + // Generate the YAML if not already generated + if self.stack_builder_state.generated_yaml.is_empty() { + self.stack_builder_state.generate_yaml(); + } + + let mut saved_files = Vec::new(); + let mut errors = Vec::new(); + + // Save each file separately + for (filename, content) in &self.stack_builder_state.generated_files { + let mut filepath = PathBuf::from("./"); + filepath.push(filename); + + match fs::write(&filepath, content) { + Ok(_) => { + saved_files.push(filepath.display().to_string()); + } + Err(e) => { + errors.push(format!("{}: {}", filename, e)); + } + } + } + + // Show results + if errors.is_empty() { + let file_list = saved_files.join(", "); + self.detail_state + .show_message(format!("✅ Stack saved! Files: {}", file_list)); + // Close the stack builder after successful save + self.stack_builder_state.close(); + } else { + let error_msg = errors.join("; "); + self.detail_state + .show_error(&format!("Failed to save some files: {}", error_msg)); + } + + self.clear_loading(); + + Ok(()) + } } // Implement VersionItem trait for Module to work with VersionsModal widget diff --git a/cli/src/tui/events/main_handler.rs b/cli/src/tui/events/main_handler.rs index afb73bf..b94988e 100644 --- a/cli/src/tui/events/main_handler.rs +++ b/cli/src/tui/events/main_handler.rs @@ -10,6 +10,13 @@ impl MainHandler { // Handle Ctrl+key combinations first if modifiers.contains(KeyModifiers::CONTROL) { match key { + KeyCode::Char('n') => { + if matches!(app.current_view, crate::tui::app::View::Stacks) { + // Open stack builder - load all modules + app.schedule_action(PendingAction::OpenStackBuilder); + } + return Ok(()); + } KeyCode::Char('r') => { if matches!(app.current_view, crate::tui::app::View::Deployments) { // Get the current deployment diff --git a/cli/src/tui/events/mod.rs b/cli/src/tui/events/mod.rs index a20af6b..e6ab1a0 100644 --- a/cli/src/tui/events/mod.rs +++ b/cli/src/tui/events/mod.rs @@ -3,9 +3,11 @@ pub mod detail_handler; pub mod events_handler; pub mod main_handler; pub mod modal_handler; +pub mod stack_builder_handler; pub use claim_builder_handler::ClaimBuilderHandler; pub use detail_handler::DetailHandler; pub use events_handler::EventsHandler; pub use main_handler::MainHandler; pub use modal_handler::ModalHandler; +pub use stack_builder_handler::StackBuilderHandler; diff --git a/cli/src/tui/events/stack_builder_handler.rs b/cli/src/tui/events/stack_builder_handler.rs new file mode 100644 index 0000000..9995f5b --- /dev/null +++ b/cli/src/tui/events/stack_builder_handler.rs @@ -0,0 +1,360 @@ +use anyhow::Result; +use crossterm::event::{KeyCode, KeyModifiers}; + +use crate::tui::app::App; +use crate::tui::state::stack_builder_state::StackBuilderPage; + +pub struct StackBuilderHandler; + +impl StackBuilderHandler { + pub fn handle_key(app: &mut App, key: KeyCode, modifiers: KeyModifiers) -> Result<()> { + let state = &mut app.stack_builder_state; + + // If reference picker is showing, handle it first + if state.showing_reference_picker { + return handle_reference_picker_key(app, key, modifiers); + } + + // If modal is showing, handle modal keys + if state.showing_module_modal { + return handle_modal_key(app, key, modifiers); + } + + // Handle Ctrl key combinations first + if modifiers.contains(KeyModifiers::CONTROL) { + match key { + KeyCode::Char('a') => { + // Add module (only on module list page) + if state.current_page == StackBuilderPage::ModuleList { + state.open_module_modal(); + } + return Ok(()); + } + KeyCode::Char('r') => { + // Open reference picker (only on variable configuration page) + if state.current_page == StackBuilderPage::VariableConfiguration { + state.open_reference_picker(); + } + return Ok(()); + } + KeyCode::Char('n') => { + // Move to next page + if let Err(err) = state.next_page() { + state.validation_error = Some(err); + } + return Ok(()); + } + KeyCode::Char('b') => { + // Move to previous page (back) + state.previous_page(); + return Ok(()); + } + KeyCode::Char('d') => { + // Delete selected module instance (only on module list page) + if state.current_page == StackBuilderPage::ModuleList { + if state.selected_instance_index < state.module_instances.len() { + state.remove_module_instance(state.selected_instance_index); + } + } + return Ok(()); + } + KeyCode::Char('s') => { + // Save to file (only on preview page) + if state.current_page == StackBuilderPage::Preview { + app.schedule_action(crate::tui::app::PendingAction::SaveStackToFile); + } + return Ok(()); + } + KeyCode::Char('y') => { + // Copy to clipboard (only on preview page) + if state.current_page == StackBuilderPage::Preview { + match arboard::Clipboard::new() { + Ok(mut clipboard) => match clipboard.set_text(&state.generated_yaml) { + Ok(_) => { + app.detail_state.show_message( + "✅ Stack YAML copied to clipboard!".to_string(), + ); + } + Err(e) => { + app.detail_state + .show_error(&format!("Failed to copy to clipboard: {}", e)); + } + }, + Err(e) => { + app.detail_state + .show_error(&format!("Failed to access clipboard: {}", e)); + } + } + } + return Ok(()); + } + _ => {} + } + } + + // Handle regular key presses based on current page + match state.current_page { + StackBuilderPage::ModuleList => { + handle_module_list_key(app, key)?; + } + StackBuilderPage::VariableConfiguration => { + handle_variable_configuration_key(app, key)?; + } + StackBuilderPage::Preview => { + handle_preview_key(app, key)?; + } + } + + Ok(()) + } +} + +fn handle_modal_key(app: &mut App, key: KeyCode, _modifiers: KeyModifiers) -> Result<()> { + let state = &mut app.stack_builder_state; + + if state.editing_instance_name { + // Handle instance name input + match key { + KeyCode::Esc => { + state.cancel_modal(); + } + KeyCode::Enter => { + if let Err(err) = state.confirm_modal_selection() { + state.validation_error = Some(err); + } + } + KeyCode::Backspace => { + state.backspace(); + } + KeyCode::Left => { + state.move_cursor_left(); + } + KeyCode::Right => { + state.move_cursor_right(); + } + KeyCode::Char(c) => { + state.insert_char(c); + } + _ => {} + } + } else { + // Handle module selection in modal + match key { + KeyCode::Esc => { + state.cancel_modal(); + } + KeyCode::Tab => { + state.next_modal_module(); + } + KeyCode::BackTab => { + state.previous_modal_module(); + } + KeyCode::Up => { + state.previous_modal_module(); + } + KeyCode::Down => { + state.next_modal_module(); + } + KeyCode::PageUp => { + state.page_up_modal(10); + } + KeyCode::PageDown => { + state.page_down_modal(10); + } + KeyCode::Enter => { + state.select_modal_module(); + } + _ => {} + } + } + + Ok(()) +} + +fn handle_module_list_key(app: &mut App, key: KeyCode) -> Result<()> { + let state = &mut app.stack_builder_state; + + match key { + KeyCode::Esc => { + state.close(); + } + KeyCode::Tab => { + // Toggle between stack name and module instances + if state.editing_stack_name { + if !state.module_instances.is_empty() { + state.editing_stack_name = false; + state.selected_instance_index = 0; + } + } else { + state.editing_stack_name = true; + } + } + KeyCode::Enter => { + // Start editing stack name if not already editing + if !state.editing_stack_name { + state.editing_stack_name = true; + } + } + KeyCode::Up => { + if state.editing_stack_name { + // Can't go up from stack name, do nothing + } else if state.selected_instance_index == 0 { + // At first instance, go back to stack name + state.editing_stack_name = true; + } else { + state.previous_selected_instance(); + } + } + KeyCode::Down => { + if state.editing_stack_name { + // Move from stack name to first module instance + if !state.module_instances.is_empty() { + state.editing_stack_name = false; + state.selected_instance_index = 0; + } + } else { + // Navigate through module instances + state.next_selected_instance(); + } + } + KeyCode::Backspace => { + if state.editing_stack_name { + state.backspace(); + } + } + KeyCode::Left => { + if state.editing_stack_name { + state.move_cursor_left(); + } + } + KeyCode::Right => { + if state.editing_stack_name { + state.move_cursor_right(); + } + } + KeyCode::Char(c) => { + if state.editing_stack_name { + state.insert_char(c); + } + } + _ => {} + } + + Ok(()) +} + +fn handle_variable_configuration_key(app: &mut App, key: KeyCode) -> Result<()> { + let state = &mut app.stack_builder_state; + + match key { + KeyCode::Esc => { + state.close(); + } + KeyCode::Up => { + state.previous_variable(); + } + KeyCode::Down => { + state.next_variable(); + } + KeyCode::Left => { + state.previous_instance(); + } + KeyCode::Right => { + state.next_instance(); + } + KeyCode::Backspace => { + state.backspace(); + } + KeyCode::Char(c) => { + state.insert_char(c); + } + _ => {} + } + + Ok(()) +} + +fn handle_preview_key(app: &mut App, key: KeyCode) -> Result<()> { + let state = &mut app.stack_builder_state; + + match key { + KeyCode::Esc => { + state.close(); + } + KeyCode::Up => { + state.scroll_preview_up(); + } + KeyCode::Down => { + state.scroll_preview_down(); + } + KeyCode::PageUp => { + state.page_up_preview(10); + } + KeyCode::PageDown => { + state.page_down_preview(10); + } + _ => {} + } + + Ok(()) +} + +fn handle_reference_picker_key( + app: &mut App, + key: KeyCode, + _modifiers: KeyModifiers, +) -> Result<()> { + let state = &mut app.stack_builder_state; + + use crate::tui::state::stack_builder_state::ReferencePickerStep; + + match state.reference_picker_step { + ReferencePickerStep::SelectInstance => match key { + KeyCode::Esc => { + state.close_reference_picker(); + } + KeyCode::Tab => { + state.next_reference_instance(); + } + KeyCode::BackTab => { + state.previous_reference_instance(); + } + KeyCode::Up => { + state.previous_reference_instance(); + } + KeyCode::Down => { + state.next_reference_instance(); + } + KeyCode::Enter => { + state.select_reference_instance(); + } + _ => {} + }, + ReferencePickerStep::SelectOutput => match key { + KeyCode::Esc => { + state.back_to_instance_selection(); + } + KeyCode::Tab => { + state.next_reference_output(); + } + KeyCode::BackTab => { + state.previous_reference_output(); + } + KeyCode::Up => { + state.previous_reference_output(); + } + KeyCode::Down => { + state.next_reference_output(); + } + KeyCode::Enter => { + state.confirm_reference_selection(); + } + KeyCode::Backspace => { + state.back_to_instance_selection(); + } + _ => {} + }, + } + + Ok(()) +} diff --git a/cli/src/tui/handlers.rs b/cli/src/tui/handlers.rs index 9e8c1b2..5f64d51 100644 --- a/cli/src/tui/handlers.rs +++ b/cli/src/tui/handlers.rs @@ -3,7 +3,10 @@ use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use std::time::Duration; use super::app::App; -use super::events::{ClaimBuilderHandler, DetailHandler, EventsHandler, MainHandler, ModalHandler}; +use super::events::{ + ClaimBuilderHandler, DetailHandler, EventsHandler, MainHandler, ModalHandler, + StackBuilderHandler, +}; pub async fn handle_events(app: &mut App) -> Result<()> { if event::poll(Duration::from_millis(100))? { @@ -37,6 +40,10 @@ fn handle_key_event(app: &mut App, key: KeyCode, modifiers: KeyModifiers) -> Res return ClaimBuilderHandler::handle_key(app, key, modifiers); } + if app.stack_builder_state.showing_stack_builder { + return StackBuilderHandler::handle_key(app, key, modifiers); + } + if app.events_state.showing_events { return EventsHandler::handle_key(app, key); } diff --git a/cli/src/tui/renderers/common.rs b/cli/src/tui/renderers/common.rs index 3adc36e..bf93462 100644 --- a/cli/src/tui/renderers/common.rs +++ b/cli/src/tui/renderers/common.rs @@ -191,6 +191,7 @@ fn get_footer_actions(app: &App) -> Vec<(&'static str, &'static str)> { vec![ ("←→", "Switch Track"), ("/", "Search"), + ("Ctrl+N", "New Stack"), ("Enter", "Details"), ("r", "Reload"), ("Ctrl+C", "Quit"), diff --git a/cli/src/tui/state/mod.rs b/cli/src/tui/state/mod.rs index 0ccd32d..117bd28 100644 --- a/cli/src/tui/state/mod.rs +++ b/cli/src/tui/state/mod.rs @@ -3,6 +3,7 @@ pub mod detail_state; pub mod events_state; pub mod modal_state; pub mod search_state; +pub mod stack_builder_state; pub mod view_state; pub use claim_builder_state::ClaimBuilderState; @@ -10,4 +11,5 @@ pub use detail_state::DetailState; pub use events_state::EventsState; pub use modal_state::ModalState; pub use search_state::SearchState; +pub use stack_builder_state::StackBuilderState; pub use view_state::ViewState; diff --git a/cli/src/tui/state/stack_builder_state.rs b/cli/src/tui/state/stack_builder_state.rs new file mode 100644 index 0000000..425c120 --- /dev/null +++ b/cli/src/tui/state/stack_builder_state.rs @@ -0,0 +1,839 @@ +use env_defs::{ModuleResp, TfVariable}; +use env_utils::to_camel_case; + +/// Represents a module instance in the stack being built +#[derive(Debug, Clone)] +pub struct ModuleInstance { + pub instance_name: String, + pub module: Option, + pub module_name: String, + pub version: String, + pub variable_inputs: Vec, +} + +/// Represents a single variable input field +#[derive(Debug, Clone)] +pub struct VariableInput { + pub name: String, + pub description: String, + pub var_type: String, + pub default_value: Option, + pub is_required: bool, + pub is_sensitive: bool, + pub user_value: String, + pub cursor_position: usize, +} + +impl VariableInput { + pub fn from_tf_variable(var: &TfVariable) -> Self { + let is_required = var.default.is_none(); + let default_str = var.default.as_ref().map(|v| { + if v.is_null() { + String::new() + } else { + serde_json::to_string(v).unwrap_or_default() + } + }); + + Self { + name: var.name.clone(), + description: var.description.clone(), + var_type: var._type.to_string(), + default_value: default_str.clone(), + is_required, + is_sensitive: var.sensitive, + user_value: if is_required { + default_str.unwrap_or_default() + } else { + String::new() + }, + cursor_position: 0, + } + } + + pub fn insert_char(&mut self, c: char) { + self.user_value.insert(self.cursor_position, c); + self.cursor_position += 1; + } + + pub fn backspace(&mut self) { + if self.cursor_position > 0 { + self.user_value.remove(self.cursor_position - 1); + self.cursor_position -= 1; + } + } + + pub fn move_cursor_left(&mut self) { + if self.cursor_position > 0 { + self.cursor_position -= 1; + } + } + + pub fn move_cursor_right(&mut self) { + if self.cursor_position < self.user_value.len() { + self.cursor_position += 1; + } + } +} + +/// The different pages/steps in the stack builder workflow +#[derive(Debug, Clone, PartialEq)] +pub enum StackBuilderPage { + ModuleList, // Page showing list of added modules + VariableConfiguration, // Page to configure variables for each module instance + Preview, // Page to preview the generated YAML +} + +/// State for the stack builder view +#[derive(Debug, Clone)] +pub struct StackBuilderState { + pub showing_stack_builder: bool, + pub current_page: StackBuilderPage, + + // Stack metadata + pub stack_name: String, + pub stack_name_cursor: usize, + pub editing_stack_name: bool, + + // Module selection modal state + pub showing_module_modal: bool, + pub available_modules: Vec, + pub modal_selected_index: usize, + pub modal_scroll_offset: u16, + + // Module instances + pub module_instances: Vec, + pub selected_instance_index: usize, + + // Instance name input (when adding a module) + pub instance_name_input: String, + pub instance_name_cursor: usize, + pub editing_instance_name: bool, + + // Variable configuration state + pub current_instance_index: usize, + pub selected_variable_index: usize, + pub scroll_offset: u16, + + // Reference picker modal (for cross-module references) + pub showing_reference_picker: bool, + pub reference_picker_step: ReferencePickerStep, // 0 = select instance, 1 = select output + pub reference_selected_instance_index: usize, + pub reference_selected_output_index: usize, + pub reference_picker_scroll_offset: u16, + + // Preview state + pub generated_yaml: String, + pub preview_scroll: u16, + + // Individual YAML files (filename, content) + pub generated_files: Vec<(String, String)>, + + // Validation + pub validation_error: Option, +} + +/// Step in the reference picker workflow +#[derive(Debug, Clone, PartialEq)] +pub enum ReferencePickerStep { + SelectInstance, + SelectOutput, +} + +impl StackBuilderState { + pub fn new() -> Self { + Self { + showing_stack_builder: false, + current_page: StackBuilderPage::ModuleList, + stack_name: String::new(), + stack_name_cursor: 0, + editing_stack_name: false, + showing_module_modal: false, + available_modules: Vec::new(), + modal_selected_index: 0, + modal_scroll_offset: 0, + module_instances: Vec::new(), + selected_instance_index: 0, + instance_name_input: String::new(), + instance_name_cursor: 0, + editing_instance_name: false, + current_instance_index: 0, + selected_variable_index: 0, + scroll_offset: 0, + showing_reference_picker: false, + reference_picker_step: ReferencePickerStep::SelectInstance, + reference_selected_instance_index: 0, + reference_selected_output_index: 0, + reference_picker_scroll_offset: 0, + generated_yaml: String::new(), + preview_scroll: 0, + generated_files: Vec::new(), + validation_error: None, + } + } + + /// Open the stack builder + pub fn open(&mut self, available_modules: Vec) { + self.showing_stack_builder = true; + self.current_page = StackBuilderPage::ModuleList; + self.available_modules = available_modules; + self.module_instances.clear(); + self.stack_name.clear(); + self.stack_name_cursor = 0; + self.editing_stack_name = true; // Start by editing stack name + self.showing_module_modal = false; + self.modal_selected_index = 0; + self.instance_name_input.clear(); + self.instance_name_cursor = 0; + self.validation_error = None; + } + + /// Close the stack builder + pub fn close(&mut self) { + self.showing_stack_builder = false; + self.module_instances.clear(); + self.stack_name.clear(); + self.generated_yaml.clear(); + self.current_page = StackBuilderPage::ModuleList; + self.showing_module_modal = false; + self.editing_stack_name = false; + self.editing_instance_name = false; + } + + /// Open the module selection modal + pub fn open_module_modal(&mut self) { + self.showing_module_modal = true; + self.modal_selected_index = 0; + self.modal_scroll_offset = 0; + self.instance_name_input.clear(); + self.instance_name_cursor = 0; + self.editing_instance_name = false; + self.editing_stack_name = false; // Make sure we're not editing stack name + } + + /// Close the module selection modal + pub fn close_module_modal(&mut self) { + self.showing_module_modal = false; + self.instance_name_input.clear(); + self.editing_instance_name = false; + } + + /// Move to next module in modal + pub fn next_modal_module(&mut self) { + if self.modal_selected_index < self.available_modules.len().saturating_sub(1) { + self.modal_selected_index += 1; + } + } + + /// Move to previous module in modal + pub fn previous_modal_module(&mut self) { + if self.modal_selected_index > 0 { + self.modal_selected_index -= 1; + } + } + + /// Jump down by page_size in modal (Page Down) + pub fn page_down_modal(&mut self, page_size: usize) { + let max_index = self.available_modules.len().saturating_sub(1); + self.modal_selected_index = (self.modal_selected_index + page_size).min(max_index); + } + + /// Jump up by page_size in modal (Page Up) + pub fn page_up_modal(&mut self, page_size: usize) { + self.modal_selected_index = self.modal_selected_index.saturating_sub(page_size); + } + + /// Update scroll offset to ensure selected item is visible + /// Should be called during rendering when visible_height is known + pub fn update_modal_scroll(&mut self, visible_height: usize) { + let scroll_offset = self.modal_scroll_offset as usize; + + // If selected item is below visible area, scroll down + if self.modal_selected_index >= scroll_offset + visible_height { + self.modal_scroll_offset = (self.modal_selected_index - visible_height + 1) as u16; + } + // If selected item is above visible area, scroll up + else if self.modal_selected_index < scroll_offset { + self.modal_scroll_offset = self.modal_selected_index as u16; + } + } + + /// Select the current module and prompt for instance name + pub fn select_modal_module(&mut self) { + self.editing_instance_name = true; + // Default to module name as instance name (lowercase) + if let Some(module) = self.available_modules.get(self.modal_selected_index) { + self.instance_name_input = module.module_name.to_lowercase(); + self.instance_name_cursor = self.instance_name_input.len(); + } else { + self.instance_name_input.clear(); + self.instance_name_cursor = 0; + } + } + + /// Add a new module instance to the stack + pub fn add_module_instance(&mut self) -> Result<(), String> { + // Validate inputs + if self.instance_name_input.trim().is_empty() { + return Err("Instance name cannot be empty".to_string()); + } + + // Convert instance name to lowercase + let instance_name = self.instance_name_input.trim().to_lowercase(); + + // Check for duplicate instance names + if self + .module_instances + .iter() + .any(|m| m.instance_name == instance_name) + { + return Err(format!("Instance name '{}' already exists", instance_name)); + } + + if self.modal_selected_index >= self.available_modules.len() { + return Err("Invalid module selection".to_string()); + } + + let selected_module = &self.available_modules[self.modal_selected_index]; + + // Create variable inputs for this module instance + let variable_inputs: Vec<_> = selected_module + .tf_variables + .iter() + .map(VariableInput::from_tf_variable) + .collect(); + + let instance = ModuleInstance { + instance_name, + module: Some(selected_module.clone()), + module_name: selected_module.module_name.clone(), + version: selected_module.version.clone(), // Use the selected module's version + variable_inputs, + }; + + self.module_instances.push(instance); + + // Close the modal and clear inputs + self.close_module_modal(); + + Ok(()) + } + + /// Remove a module instance + pub fn remove_module_instance(&mut self, index: usize) { + if index < self.module_instances.len() { + self.module_instances.remove(index); + } + } + + /// Move to the next page + pub fn next_page(&mut self) -> Result<(), String> { + match self.current_page { + StackBuilderPage::ModuleList => { + if self.module_instances.is_empty() { + return Err("Please add at least one module instance".to_string()); + } + if self.stack_name.trim().is_empty() { + return Err("Stack name cannot be empty".to_string()); + } + + // Ensure stack name starts with a capital letter + let trimmed = self.stack_name.trim().to_string(); + if !trimmed.is_empty() { + let mut chars = trimmed.chars(); + if let Some(first_char) = chars.next() { + self.stack_name = + first_char.to_uppercase().collect::() + chars.as_str(); + } + } + + self.current_page = StackBuilderPage::VariableConfiguration; + self.current_instance_index = 0; + self.selected_variable_index = 0; + } + StackBuilderPage::VariableConfiguration => { + // Check that all required variables are set across all instances + let mut missing_vars = Vec::new(); + + for (idx, instance) in self.module_instances.iter().enumerate() { + for var in &instance.variable_inputs { + if var.is_required + && var.user_value.trim().is_empty() + && var.default_value.is_none() + { + missing_vars.push(format!("{}.{}", instance.instance_name, var.name)); + } + } + } + + if !missing_vars.is_empty() { + return Err(format!( + "Required variables not set: {}", + missing_vars.join(", ") + )); + } + + self.generate_yaml(); + self.current_page = StackBuilderPage::Preview; + } + StackBuilderPage::Preview => { + // Already on last page + } + } + Ok(()) + } + + /// Move to the previous page + pub fn previous_page(&mut self) { + match self.current_page { + StackBuilderPage::ModuleList => { + // Already on first page + } + StackBuilderPage::VariableConfiguration => { + self.current_page = StackBuilderPage::ModuleList; + } + StackBuilderPage::Preview => { + self.current_page = StackBuilderPage::VariableConfiguration; + } + } + } + + /// Move to the next module instance in variable configuration + pub fn next_instance(&mut self) { + if self.current_instance_index < self.module_instances.len().saturating_sub(1) { + self.current_instance_index += 1; + self.selected_variable_index = 0; + self.scroll_offset = 0; + } + } + + /// Move to the previous module instance in variable configuration + pub fn previous_instance(&mut self) { + if self.current_instance_index > 0 { + self.current_instance_index -= 1; + self.selected_variable_index = 0; + self.scroll_offset = 0; + } + } + + /// Move to the next module instance in the module list (for selection/deletion) + pub fn next_selected_instance(&mut self) { + if self.selected_instance_index < self.module_instances.len().saturating_sub(1) { + self.selected_instance_index += 1; + } + } + + /// Move to the previous module instance in the module list (for selection/deletion) + pub fn previous_selected_instance(&mut self) { + if self.selected_instance_index > 0 { + self.selected_instance_index -= 1; + } + } + + /// Move to the next variable field + pub fn next_variable(&mut self) { + if let Some(instance) = self.module_instances.get(self.current_instance_index) { + if self.selected_variable_index < instance.variable_inputs.len().saturating_sub(1) { + self.selected_variable_index += 1; + } + } + } + + /// Move to the previous variable field + pub fn previous_variable(&mut self) { + if self.selected_variable_index > 0 { + self.selected_variable_index -= 1; + } + } + + /// Insert a character at the current field's cursor position + pub fn insert_char(&mut self, c: char) { + self.validation_error = None; + + match self.current_page { + StackBuilderPage::ModuleList => { + if self.editing_stack_name { + self.stack_name.insert(self.stack_name_cursor, c); + self.stack_name_cursor += 1; + } else if self.editing_instance_name { + self.instance_name_input + .insert(self.instance_name_cursor, c); + self.instance_name_cursor += 1; + } + } + StackBuilderPage::VariableConfiguration => { + if let Some(instance) = self.module_instances.get_mut(self.current_instance_index) { + if let Some(var) = instance + .variable_inputs + .get_mut(self.selected_variable_index) + { + var.insert_char(c); + } + } + } + StackBuilderPage::Preview => {} + } + } + + /// Delete the character before the cursor + pub fn backspace(&mut self) { + self.validation_error = None; + + match self.current_page { + StackBuilderPage::ModuleList => { + if self.editing_stack_name { + if self.stack_name_cursor > 0 { + self.stack_name.remove(self.stack_name_cursor - 1); + self.stack_name_cursor -= 1; + } + } else if self.editing_instance_name { + if self.instance_name_cursor > 0 { + self.instance_name_input + .remove(self.instance_name_cursor - 1); + self.instance_name_cursor -= 1; + } + } + } + StackBuilderPage::VariableConfiguration => { + if let Some(instance) = self.module_instances.get_mut(self.current_instance_index) { + if let Some(var) = instance + .variable_inputs + .get_mut(self.selected_variable_index) + { + var.backspace(); + } + } + } + StackBuilderPage::Preview => {} + } + } + + /// Move cursor left + pub fn move_cursor_left(&mut self) { + match self.current_page { + StackBuilderPage::ModuleList => { + if self.editing_stack_name { + if self.stack_name_cursor > 0 { + self.stack_name_cursor -= 1; + } + } else if self.editing_instance_name { + if self.instance_name_cursor > 0 { + self.instance_name_cursor -= 1; + } + } + } + StackBuilderPage::VariableConfiguration => { + if let Some(instance) = self.module_instances.get_mut(self.current_instance_index) { + if let Some(var) = instance + .variable_inputs + .get_mut(self.selected_variable_index) + { + var.move_cursor_left(); + } + } + } + StackBuilderPage::Preview => {} + } + } + + /// Move cursor right + pub fn move_cursor_right(&mut self) { + match self.current_page { + StackBuilderPage::ModuleList => { + if self.editing_stack_name { + if self.stack_name_cursor < self.stack_name.len() { + self.stack_name_cursor += 1; + } + } else if self.editing_instance_name { + if self.instance_name_cursor < self.instance_name_input.len() { + self.instance_name_cursor += 1; + } + } + } + StackBuilderPage::VariableConfiguration => { + if let Some(instance) = self.module_instances.get_mut(self.current_instance_index) { + if let Some(var) = instance + .variable_inputs + .get_mut(self.selected_variable_index) + { + var.move_cursor_right(); + } + } + } + StackBuilderPage::Preview => {} + } + } + + /// Scroll preview up + pub fn scroll_preview_up(&mut self) { + if self.preview_scroll > 0 { + self.preview_scroll -= 1; + } + } + + /// Scroll preview down + pub fn scroll_preview_down(&mut self) { + self.preview_scroll += 1; + } + + /// Page up in preview (jump by page_size lines) + pub fn page_up_preview(&mut self, page_size: u16) { + self.preview_scroll = self.preview_scroll.saturating_sub(page_size); + } + + /// Page down in preview (jump by page_size lines) + pub fn page_down_preview(&mut self, page_size: u16) { + self.preview_scroll = self.preview_scroll.saturating_add(page_size); + } + + /// Generate the stack deployment YAML + pub fn generate_yaml(&mut self) { + let mut yaml_parts = Vec::new(); + self.generated_files.clear(); + + // Add Stack definition at the beginning + let stack_definition = format!( + "apiVersion: infraweave.io/v1 +kind: Stack +metadata: + name: {} +spec: + stackName: {} + version: 0.1.0 + reference: https://github.com/your-org/{} + description: | + Stack containing {} module(s).", + self.stack_name.to_lowercase().replace(" ", "-"), + self.stack_name, + self.stack_name.to_lowercase().replace(" ", "-"), + self.module_instances.len() + ); + yaml_parts.push(stack_definition.clone()); + + // Save Stack definition as stack.yaml + self.generated_files + .push(("stack.yaml".to_string(), stack_definition)); + + for instance in &self.module_instances { + let module_name = &instance.module_name; + + // Build variables map + let mut variables_map = serde_json::Map::new(); + for var in &instance.variable_inputs { + // Skip variables that haven't been set and aren't required + if var.user_value.is_empty() && !var.is_required { + continue; + } + + let value = if var.user_value.is_empty() { + // Use default value + if let Some(default) = &var.default_value { + default.clone() + } else { + continue; + } + } else { + var.user_value.clone() + }; + + // Convert snake_case to camelCase + let camel_name = to_camel_case(&var.name); + + // Parse value if it's JSON, otherwise keep as string + let parsed_value = if value.contains("{{") { + // It's a template reference, keep as string + serde_json::Value::String(value) + } else if let Ok(parsed) = serde_json::from_str::(&value) { + parsed + } else { + serde_json::Value::String(value) + }; + + variables_map.insert(camel_name, parsed_value); + } + + // Create the deployment claim YAML for this instance + let mut claim = String::new(); + claim.push_str(&format!("apiVersion: infraweave.io/v1\n")); + claim.push_str(&format!("kind: {}\n", module_name)); + claim.push_str("metadata:\n"); + claim.push_str(&format!(" name: {}\n", instance.instance_name)); + claim.push_str("spec:\n"); + claim.push_str(&format!(" moduleVersion: {}\n", instance.version)); + claim.push_str(" region: N/A\n"); + + if !variables_map.is_empty() { + claim.push_str(" variables:\n"); + + // Convert the variables map to YAML manually to ensure proper formatting + for (key, value) in variables_map { + let value_str = match value { + serde_json::Value::String(s) => { + if s.contains("{{") { + // Template reference - must be quoted + format!("\"{}\"", s) + } else { + s + } + } + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Null => "null".to_string(), + serde_json::Value::Array(_) | serde_json::Value::Object(_) => { + serde_json::to_string(&value).unwrap_or_default() + } + }; + + claim.push_str(&format!(" {}: {}\n", key, value_str)); + } + } + + yaml_parts.push(claim.clone()); + + // Save claim as separate file using instance name + let claim_filename = format!("{}.yaml", instance.instance_name); + self.generated_files.push((claim_filename, claim)); + } + + self.generated_yaml = yaml_parts.join("\n---\n\n"); + } + + /// Get the current cursor position for the active field + pub fn get_current_cursor_position(&self) -> usize { + match self.current_page { + StackBuilderPage::ModuleList => { + if self.editing_stack_name { + self.stack_name_cursor + } else if self.editing_instance_name { + self.instance_name_cursor + } else { + 0 + } + } + StackBuilderPage::VariableConfiguration => { + if let Some(instance) = self.module_instances.get(self.current_instance_index) { + if let Some(var) = instance.variable_inputs.get(self.selected_variable_index) { + return var.cursor_position; + } + } + 0 + } + StackBuilderPage::Preview => 0, + } + } + + // Modal control helpers + pub fn cancel_modal(&mut self) { + self.close_module_modal(); + } + + pub fn confirm_modal_selection(&mut self) -> Result<(), String> { + self.add_module_instance()?; + self.close_module_modal(); + Ok(()) + } + + // Reference picker methods + pub fn open_reference_picker(&mut self) { + self.showing_reference_picker = true; + self.reference_picker_step = ReferencePickerStep::SelectInstance; + self.reference_selected_instance_index = 0; + self.reference_selected_output_index = 0; + self.reference_picker_scroll_offset = 0; + } + + pub fn close_reference_picker(&mut self) { + self.showing_reference_picker = false; + self.reference_picker_step = ReferencePickerStep::SelectInstance; + } + + /// Get available instances for referencing (excluding current instance) + fn get_available_reference_instances(&self) -> Vec<(usize, &ModuleInstance)> { + self.module_instances + .iter() + .enumerate() + .filter(|(i, _)| *i != self.current_instance_index) + .collect() + } + + pub fn next_reference_instance(&mut self) { + let available = self.get_available_reference_instances(); + if self.reference_selected_instance_index < available.len().saturating_sub(1) { + self.reference_selected_instance_index += 1; + } + } + + pub fn previous_reference_instance(&mut self) { + if self.reference_selected_instance_index > 0 { + self.reference_selected_instance_index -= 1; + } + } + + pub fn select_reference_instance(&mut self) { + self.reference_picker_step = ReferencePickerStep::SelectOutput; + self.reference_selected_output_index = 0; + } + + pub fn next_reference_output(&mut self) { + let available = self.get_available_reference_instances(); + if let Some((actual_idx, instance)) = available.get(self.reference_selected_instance_index) + { + // Find the module to get its outputs + if let Some(module) = self + .available_modules + .iter() + .find(|m| m.module_name == instance.module_name && m.version == instance.version) + { + if self.reference_selected_output_index < module.tf_outputs.len().saturating_sub(1) + { + self.reference_selected_output_index += 1; + } + } + } + } + + pub fn previous_reference_output(&mut self) { + if self.reference_selected_output_index > 0 { + self.reference_selected_output_index -= 1; + } + } + + pub fn back_to_instance_selection(&mut self) { + self.reference_picker_step = ReferencePickerStep::SelectInstance; + self.reference_selected_output_index = 0; + } + + pub fn confirm_reference_selection(&mut self) { + let available = self.get_available_reference_instances(); + if let Some((actual_idx, instance)) = available.get(self.reference_selected_instance_index) + { + if let Some(module) = self + .available_modules + .iter() + .find(|m| m.module_name == instance.module_name && m.version == instance.version) + { + if let Some(output) = module.tf_outputs.get(self.reference_selected_output_index) { + // Insert the reference at the cursor position + let reference = format!( + "{{{{ {}::{}::{} }}}}", + instance.module_name, instance.instance_name, output.name + ); + + if let Some(current_instance) = + self.module_instances.get_mut(self.current_instance_index) + { + if let Some(var) = current_instance + .variable_inputs + .get_mut(self.selected_variable_index) + { + var.user_value.insert_str(var.cursor_position, &reference); + var.cursor_position += reference.len(); + } + } + + self.close_reference_picker(); + } + } + } + } +} diff --git a/cli/src/tui/ui.rs b/cli/src/tui/ui.rs index 4b09604..aa72c21 100644 --- a/cli/src/tui/ui.rs +++ b/cli/src/tui/ui.rs @@ -12,7 +12,7 @@ use super::renderers::{ deployments_renderer, detail_renderer, events_renderer, modules_renderer, policies_renderer, stacks_renderer, }; -use super::widgets::render_claim_builder; +use super::widgets::{render_claim_builder, render_stack_builder}; /// Main render function - orchestrates the entire UI pub fn render(frame: &mut Frame, app: &mut App) { @@ -35,6 +35,18 @@ pub fn render(frame: &mut Frame, app: &mut App) { return; } + // If showing stack builder, render it fullscreen + if app.stack_builder_state.showing_stack_builder { + render_stack_builder(frame, size, &mut app.stack_builder_state); + + // Show loading overlay if loading + if app.is_loading { + render_loading(frame, size, app); + } + + return; + } + // If showing events view, use simplified layout without navigation/header if app.events_state.showing_events { let chunks = Layout::default() diff --git a/cli/src/tui/widgets/mod.rs b/cli/src/tui/widgets/mod.rs index 80fce1b..1663c16 100644 --- a/cli/src/tui/widgets/mod.rs +++ b/cli/src/tui/widgets/mod.rs @@ -3,6 +3,7 @@ pub mod footer; pub mod loading; pub mod modal; pub mod navigation; +pub mod stack_builder; pub mod table; pub use claim_builder::render_claim_builder; @@ -10,4 +11,5 @@ pub use footer::FooterBar; pub use loading::LoadingWidget; pub use modal::{ConfirmationModal, VersionsModal}; pub use navigation::NavigationBar; +pub use stack_builder::render_stack_builder; pub use table::TableWidget; diff --git a/cli/src/tui/widgets/stack_builder.rs b/cli/src/tui/widgets/stack_builder.rs new file mode 100644 index 0000000..f011756 --- /dev/null +++ b/cli/src/tui/widgets/stack_builder.rs @@ -0,0 +1,1003 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + Frame, +}; + +use crate::tui::state::stack_builder_state::{ + ReferencePickerStep, StackBuilderPage, StackBuilderState, +}; + +/// Render the stack builder view +pub fn render_stack_builder(f: &mut Frame, area: Rect, state: &mut StackBuilderState) { + // If reference picker is showing, render it on top + if state.showing_reference_picker { + render_reference_picker_modal(f, area, state); + return; + } + + // If module modal is showing, render it on top + if state.showing_module_modal { + render_module_modal(f, area, state); + return; + } + + match state.current_page { + StackBuilderPage::ModuleList => render_module_list_page(f, area, state), + StackBuilderPage::VariableConfiguration => { + render_variable_configuration_page(f, area, state) + } + StackBuilderPage::Preview => render_preview_page(f, area, state), + } +} + +/// Render the module selection modal +fn render_module_modal(f: &mut Frame, area: Rect, state: &mut StackBuilderState) { + // Create a centered modal + let modal_width = area.width.saturating_sub(10).min(80); + let modal_height = area.height.saturating_sub(4).min(25); + let modal_x = (area.width.saturating_sub(modal_width)) / 2; + let modal_y = (area.height.saturating_sub(modal_height)) / 2; + + let modal_area = Rect { + x: area.x + modal_x, + y: area.y + modal_y, + width: modal_width, + height: modal_height, + }; + + // Clear the background + let bg = Block::default().style(Style::default().bg(Color::Black)); + f.render_widget(bg, area); + + // Modal block + let modal_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(vec![ + Span::raw(" "), + Span::styled("📦 ", Style::default().fg(Color::Yellow)), + Span::styled( + "Select Module", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + ]); + + let inner = modal_block.inner(modal_area); + f.render_widget(modal_block, modal_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Instance name input (when editing) + Constraint::Min(5), // Module list + Constraint::Length(3), // Help + ]) + .split(inner); + + // Instance name input (shown after module is selected) + if state.editing_instance_name { + let input_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title(" Instance Name "); + + let input_inner = input_block.inner(chunks[0]); + f.render_widget(input_block, chunks[0]); + + let input_text = if state.instance_name_input.is_empty() { + Span::styled( + "", + Style::default().fg(Color::DarkGray), + ) + } else { + Span::raw(&state.instance_name_input) + }; + + let para = Paragraph::new(input_text); + f.render_widget(para, input_inner); + } else { + let hint_para = Paragraph::new("Press Enter on a module to select it") + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + f.render_widget(hint_para, chunks[0]); + } + + // Module list + let list_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(format!(" Modules ({}) ", state.available_modules.len())); + + let list_inner = list_block.inner(chunks[1]); + f.render_widget(list_block, chunks[1]); + + if state.available_modules.is_empty() { + let empty_text = + Paragraph::new("No modules available").style(Style::default().fg(Color::DarkGray)); + f.render_widget(empty_text, list_inner); + } else { + // Calculate visible area height for scrolling and update scroll offset + let visible_height = list_inner.height as usize; + state.update_modal_scroll(visible_height); + let scroll_offset = state.modal_scroll_offset as usize; + + let items: Vec = state + .available_modules + .iter() + .enumerate() + .skip(scroll_offset) + .take(visible_height) + .map(|(i, module)| { + let is_selected = i == state.modal_selected_index; + let style = if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + let content = format!( + "{} (v{}) - {}", + module.module_name, module.version, module.module + ); + ListItem::new(content).style(style) + }) + .collect(); + + let list = List::new(items); + f.render_widget(list, list_inner); + } + + // Help text + let help_lines = if state.editing_instance_name { + vec![Line::from(vec![ + Span::styled( + "Enter", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Confirm "), + Span::styled( + "Esc", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": Cancel"), + ])] + } else { + vec![Line::from(vec![ + Span::styled( + "↑/↓", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Navigate "), + Span::styled( + "PgUp/PgDn", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Jump "), + Span::styled( + "Enter", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Select "), + Span::styled( + "Esc", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": Cancel"), + ])] + }; + + let help_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)); + + let help_inner = help_block.inner(chunks[2]); + f.render_widget(help_block, chunks[2]); + + let help = Paragraph::new(help_lines).alignment(Alignment::Center); + f.render_widget(help, help_inner); +} + +/// Render the reference picker modal +fn render_reference_picker_modal(f: &mut Frame, area: Rect, state: &StackBuilderState) { + // Create a centered modal + let modal_width = area.width.saturating_sub(10).min(80); + let modal_height = area.height.saturating_sub(4).min(30); + let modal_x = (area.width.saturating_sub(modal_width)) / 2; + let modal_y = (area.height.saturating_sub(modal_height)) / 2; + + let modal_area = Rect { + x: area.x + modal_x, + y: area.y + modal_y, + width: modal_width, + height: modal_height, + }; + + // Clear the background + let bg = Block::default().style(Style::default().bg(Color::Black)); + f.render_widget(bg, area); + + // Modal block + let title = match state.reference_picker_step { + ReferencePickerStep::SelectInstance => " Select Module Instance ", + ReferencePickerStep::SelectOutput => " Select Output Field ", + }; + + let modal_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)) + .title(vec![ + Span::raw(" "), + Span::styled("🔗 ", Style::default().fg(Color::Yellow)), + Span::styled( + title, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + ]); + + let inner = modal_block.inner(modal_area); + f.render_widget(modal_block, modal_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(5), // List + Constraint::Length(3), // Help + ]) + .split(inner); + + // Render list based on current step + match state.reference_picker_step { + ReferencePickerStep::SelectInstance => { + render_instance_list_for_reference(f, chunks[0], state); + } + ReferencePickerStep::SelectOutput => { + render_output_list_for_reference(f, chunks[0], state); + } + } + + // Help text + let help_lines = match state.reference_picker_step { + ReferencePickerStep::SelectInstance => vec![Line::from(vec![ + Span::styled( + "Tab/Shift+Tab", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Navigate "), + Span::styled( + "Enter", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Select "), + Span::styled( + "Esc", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": Cancel"), + ])], + ReferencePickerStep::SelectOutput => vec![Line::from(vec![ + Span::styled( + "Tab/Shift+Tab", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Navigate "), + Span::styled( + "Enter", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Insert "), + Span::styled( + "Backspace/Esc", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": Back"), + ])], + }; + + let help_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)); + + let help_inner = help_block.inner(chunks[1]); + f.render_widget(help_block, chunks[1]); + + let help = Paragraph::new(help_lines).alignment(Alignment::Center); + f.render_widget(help, help_inner); +} + +fn render_instance_list_for_reference(f: &mut Frame, area: Rect, state: &StackBuilderState) { + // Filter out the current instance (can't reference itself) + let available_instances: Vec<_> = state + .module_instances + .iter() + .enumerate() + .filter(|(i, _)| *i != state.current_instance_index) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(format!( + " Module Instances ({}) ", + available_instances.len() + )); + + let inner = block.inner(area); + f.render_widget(block, area); + + if available_instances.is_empty() { + let empty_text = Paragraph::new("No other module instances available") + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(empty_text, inner); + } else { + let items: Vec = available_instances + .iter() + .enumerate() + .map(|(display_idx, (_, instance))| { + let is_selected = display_idx == state.reference_selected_instance_index; + let style = if is_selected { + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + let content = format!( + "{} ({}) v{}", + instance.instance_name, instance.module_name, instance.version + ); + ListItem::new(content).style(style) + }) + .collect(); + + let list = List::new(items); + f.render_widget(list, inner); + } +} + +fn render_output_list_for_reference(f: &mut Frame, area: Rect, state: &StackBuilderState) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" Output Fields "); + + let inner = block.inner(area); + f.render_widget(block, area); + + // Get filtered instances (excluding current) + let available_instances: Vec<_> = state + .module_instances + .iter() + .enumerate() + .filter(|(i, _)| *i != state.current_instance_index) + .collect(); + + if let Some((_, instance)) = available_instances.get(state.reference_selected_instance_index) { + // Find the module to get its outputs + if let Some(module) = state + .available_modules + .iter() + .find(|m| m.module_name == instance.module_name && m.version == instance.version) + { + if module.tf_outputs.is_empty() { + let empty_text = Paragraph::new("No outputs available for this module") + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(empty_text, inner); + } else { + let items: Vec = module + .tf_outputs + .iter() + .enumerate() + .map(|(i, output)| { + let is_selected = i == state.reference_selected_output_index; + let style = if is_selected { + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + let content = format!( + "{} - {}", + output.name, + if output.description.is_empty() { + "No description" + } else { + &output.description + } + ); + ListItem::new(content).style(style) + }) + .collect(); + + let list = List::new(items); + f.render_widget(list, inner); + } + } + } +} + +/// Render the module list page +fn render_module_list_page(f: &mut Frame, area: Rect, state: &StackBuilderState) { + let main_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(vec![ + Span::raw(" "), + Span::styled("🏗️ ", Style::default().fg(Color::Yellow)), + Span::styled( + "Stack Builder", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + ]); + + let inner = main_block.inner(area); + f.render_widget(main_block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Stack name + Constraint::Min(5), // Module instances list + Constraint::Length(5), // Help text + ]) + .split(inner); + + // Stack name input + render_stack_name_input(f, chunks[0], state); + + // List of added module instances + render_module_instances(f, chunks[1], state); + + // Help text + render_module_list_help(f, chunks[2], state); +} + +fn render_stack_name_input(f: &mut Frame, area: Rect, state: &StackBuilderState) { + let border_style = if state.editing_stack_name { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(" Stack Name "); + + let inner = block.inner(area); + f.render_widget(block, area); + + let text = if state.stack_name.is_empty() { + Span::styled("", Style::default().fg(Color::DarkGray)) + } else { + Span::raw(&state.stack_name) + }; + + let para = Paragraph::new(text); + f.render_widget(para, inner); +} + +fn render_module_instances(f: &mut Frame, area: Rect, state: &StackBuilderState) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(format!( + " Module Instances ({}) ", + state.module_instances.len() + )); + + let inner = block.inner(area); + f.render_widget(block, area); + + if state.module_instances.is_empty() { + let empty_text = Paragraph::new(vec![ + Line::from(Span::styled( + "No module instances added yet.", + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(vec![ + Span::raw("Press "), + Span::styled( + "Ctrl+A", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" to add a module"), + ]), + ]) + .alignment(Alignment::Center); + f.render_widget(empty_text, inner); + } else { + let items: Vec = state + .module_instances + .iter() + .enumerate() + .map(|(i, instance)| { + let is_selected = i == state.selected_instance_index; + let style = if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + let content = format!( + "{}. {} ({}) v{}", + i + 1, + instance.instance_name, + instance.module_name, + instance.version + ); + ListItem::new(content).style(style) + }) + .collect(); + + let list = List::new(items); + f.render_widget(list, inner); + } +} + +fn render_module_list_help(f: &mut Frame, area: Rect, _state: &StackBuilderState) { + let help_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" Help "); + + let inner = help_block.inner(area); + f.render_widget(help_block, area); + + let help_lines = vec![ + Line::from(vec![ + Span::styled( + "Ctrl+A", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Add Module "), + Span::styled( + "Tab", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Toggle Panes "), + Span::styled( + "↑/↓", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Navigate "), + ]), + Line::from(vec![ + Span::styled( + "Ctrl+D", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": Delete Selected "), + Span::styled( + "Ctrl+N", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Next Page "), + Span::styled( + "Esc", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": Cancel"), + ]), + ]; + + let help = Paragraph::new(help_lines).alignment(Alignment::Center); + f.render_widget(help, inner); +} + +/// Render the variable configuration page +fn render_variable_configuration_page(f: &mut Frame, area: Rect, state: &StackBuilderState) { + let main_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(vec![ + Span::raw(" "), + Span::styled("🔧 ", Style::default().fg(Color::Yellow)), + Span::styled( + "Stack Builder - Variable Configuration", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + ]); + + let inner = main_block.inner(area); + f.render_widget(main_block, area); + + if state.module_instances.is_empty() { + let error_text = Paragraph::new("No module instances to configure.") + .style(Style::default().fg(Color::Red)); + f.render_widget(error_text, inner); + return; + } + + // Split into sidebar (instances overview) and main area (variables) + let main_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(35), // Sidebar for instances overview + Constraint::Min(40), // Main area for variables + ]) + .split(inner); + + // Render instances overview sidebar + render_instances_overview(f, main_chunks[0], state); + + // Right side: current instance variables and help + let right_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Current instance header + Constraint::Min(5), // Variables list + Constraint::Length(5), // Help text + ]) + .split(main_chunks[1]); + + // Current instance header + if let Some(instance) = state.module_instances.get(state.current_instance_index) { + let instance_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)) + .title(" Configuring "); + + let instance_inner = instance_block.inner(right_chunks[0]); + f.render_widget(instance_block, right_chunks[0]); + + let instance_text = format!( + "{} ({}) v{}", + instance.instance_name, instance.module_name, instance.version + ); + let para = Paragraph::new(instance_text).style(Style::default().fg(Color::White)); + f.render_widget(para, instance_inner); + } + + // Variables list + render_variables_list(f, right_chunks[1], state); + + // Help text + render_variable_config_help(f, right_chunks[2], state); +} + +fn render_instances_overview(f: &mut Frame, area: Rect, state: &StackBuilderState) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Module Instances "); + + let inner = block.inner(area); + f.render_widget(block, area); + + let items: Vec = state + .module_instances + .iter() + .enumerate() + .map(|(i, instance)| { + let is_current = i == state.current_instance_index; + + // Count configured vs total variables + let total_vars = instance.variable_inputs.len(); + let configured_vars = instance + .variable_inputs + .iter() + .filter(|v| !v.user_value.is_empty() || v.default_value.is_some()) + .count(); + + let (prefix, style) = if is_current { + ( + "▶ ", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ) + } else if configured_vars == total_vars && total_vars > 0 { + ("✓ ", Style::default().fg(Color::Green)) + } else if configured_vars > 0 { + ("◐ ", Style::default().fg(Color::Yellow)) + } else { + ("○ ", Style::default().fg(Color::DarkGray)) + }; + + let content = vec![ + Line::from(vec![ + Span::styled(prefix, style), + Span::styled(&instance.instance_name, style), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("{}/{} vars", configured_vars, total_vars), + Style::default().fg(Color::DarkGray), + ), + ]), + ]; + + ListItem::new(content).style(style) + }) + .collect(); + + let list = List::new(items); + f.render_widget(list, inner); +} + +fn render_variables_list(f: &mut Frame, area: Rect, state: &StackBuilderState) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" Variables "); + + let inner = block.inner(area); + f.render_widget(block, area); + + if let Some(instance) = state.module_instances.get(state.current_instance_index) { + if instance.variable_inputs.is_empty() { + let no_vars = Paragraph::new("No variables to configure.") + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(no_vars, inner); + return; + } + + let items: Vec = instance + .variable_inputs + .iter() + .enumerate() + .map(|(i, var)| { + let is_selected = i == state.selected_variable_index; + let required_marker = if var.is_required { "*" } else { " " }; + + let (value_display, value_style) = if var.user_value.is_empty() { + if let Some(default) = &var.default_value { + ( + format!("(default: {})", default), + Style::default().fg(Color::DarkGray), + ) + } else if var.is_required { + ("".to_string(), Style::default().fg(Color::Red)) + } else { + ( + "".to_string(), + Style::default().fg(Color::DarkGray), + ) + } + } else { + (var.user_value.clone(), Style::default().fg(Color::White)) + }; + + let style = if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + value_style + }; + + let content = format!("{}{}: {}", required_marker, var.name, value_display); + ListItem::new(content).style(style) + }) + .collect(); + + let list = List::new(items); + f.render_widget(list, inner); + } +} + +fn render_variable_config_help(f: &mut Frame, area: Rect, _state: &StackBuilderState) { + let help_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" Help "); + + let inner = help_block.inner(area); + f.render_widget(help_block, area); + + let help_lines = vec![ + Line::from(vec![ + Span::styled( + "↑/↓", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Navigate "), + Span::styled( + "←/→", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Switch Instance "), + Span::styled( + "Ctrl+R", + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Insert Reference "), + ]), + Line::from(vec![ + Span::styled( + "Ctrl+N", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Preview "), + Span::styled( + "Ctrl+B", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Back"), + ]), + ]; + + let help = Paragraph::new(help_lines).alignment(Alignment::Left); + f.render_widget(help, inner); +} + +/// Render the preview page +fn render_preview_page(f: &mut Frame, area: Rect, state: &StackBuilderState) { + let main_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)) + .title(vec![ + Span::raw(" "), + Span::styled("📋 ", Style::default().fg(Color::Yellow)), + Span::styled( + "Stack Builder - Preview", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + ]); + + let inner = main_block.inner(area); + f.render_widget(main_block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(5), // YAML preview + Constraint::Length(5), // Help + ]) + .split(inner); + + // YAML preview + let yaml_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Generated Stack YAML "); + + let yaml_inner = yaml_block.inner(chunks[0]); + f.render_widget(yaml_block, chunks[0]); + + let yaml_lines: Vec = state + .generated_yaml + .lines() + .skip(state.preview_scroll as usize) + .map(|line| { + if line.starts_with("apiVersion:") || line.starts_with("kind:") { + Line::from(Span::styled(line, Style::default().fg(Color::Cyan))) + } else if line.starts_with("metadata:") || line.starts_with("spec:") { + Line::from(Span::styled(line, Style::default().fg(Color::Yellow))) + } else if line.trim().starts_with("{{") { + Line::from(Span::styled(line, Style::default().fg(Color::Magenta))) + } else { + Line::from(line) + } + }) + .collect(); + + let yaml_para = Paragraph::new(yaml_lines).wrap(Wrap { trim: false }); + f.render_widget(yaml_para, yaml_inner); + + // Help + render_preview_help(f, chunks[1], state); +} + +fn render_preview_help(f: &mut Frame, area: Rect, _state: &StackBuilderState) { + let help_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" Help "); + + let inner = help_block.inner(area); + f.render_widget(help_block, area); + + let help_lines = vec![ + Line::from(vec![ + Span::styled( + "↑/↓", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Scroll "), + Span::styled( + "PgUp/PgDn", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Jump "), + Span::styled( + "Ctrl+S", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Save "), + Span::styled( + "Ctrl+Y", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Copy "), + ]), + Line::from(vec![ + Span::styled( + "Ctrl+B", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": Back "), + Span::styled( + "Esc", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": Close"), + ]), + ]; + + let help = Paragraph::new(help_lines).alignment(Alignment::Center); + f.render_widget(help, inner); +}