// src/app.rs use crate::config::Config; use crate::error::Result; use crate::executor; use crate::menu::{Action, InteractionMode, MenuItem, MenuItemKind, MenuRoot}; use crate::network; use crate::updater; use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEventKind}; use tokio::sync::mpsc::UnboundedSender; #[derive(Debug)] pub enum Event { Crossterm(CrosstermEvent), MenuLoaded(Result), ActionCompleted, Error(String), StructuredCommand(executor::StructuredCommand), StructuredOutput(String), StructuredFinished(i32), UpdateAvailable(updater::UpdateInfo), UpdateProgress(u64), /// Carries the exe path captured before the binary was replaced. UpdateDone(std::path::PathBuf), UpdateError(String), } /// A pending request to hand the terminal to an external interactive process. /// Set by the structured-protocol handler; consumed by the main event loop. pub struct PendingExec { pub shell: String, pub reply_tx: tokio::sync::mpsc::UnboundedSender, } pub struct App { pub config: Config, pub menu: Option, pub error: Option, pub breadcrumbs: Vec, pub selected_index: usize, pub popup: Option, pub event_tx: UnboundedSender, /// When set, main loop suspends ratatui, runs the command, then restores. pub pending_exec: Option, /// Exe path to exec after update; captured before the binary was replaced. pub pending_restart: Option, } pub enum Popup { Confirming { action: Action, item_title: String, confirm_message: Option, }, ExecutingBashTerminal { child: tokio::process::Child }, ExecutingStructured { reply_tx: tokio::sync::mpsc::UnboundedSender, /// Fires SIGTERM to the script's process group on Esc. kill_tx: Option>, log_buffer: Vec, current_command: Option, input_buffer: String, /// Cursor position for Menu-type commands. menu_selected_index: usize, /// Index of the first visible line in log_buffer (0 = top of output). log_scroll_pos: usize, /// When true, keep position pinned to the bottom as new output arrives. log_follow_bottom: bool, /// Set when the process has exited; popup stays open until Esc. finished: Option, }, Downloading { progress: f32, message: String }, Message { text: String, level: MessageLevel }, UpdateConfirm { info: updater::UpdateInfo, }, Updating { info: updater::UpdateInfo, downloaded: u64, status: UpdatingStatus, }, } #[derive(Debug, PartialEq)] pub enum UpdatingStatus { Downloading, Applying, Done, Failed(String), } #[derive(Debug, Clone, Copy)] pub enum MessageLevel { Info, Warn, Error, } impl App { pub fn new(config: Config, event_tx: UnboundedSender) -> Self { Self { config, menu: None, error: None, breadcrumbs: Vec::new(), selected_index: 0, popup: None, event_tx, pending_exec: None, pending_restart: None, } } pub fn check_update(&mut self) { let api_base = match &self.config.update_api { Some(url) => url.clone(), None => return, }; let timeout = self.config.timeout_sec; let tx = self.event_tx.clone(); tokio::spawn(async move { if let Ok(Some(info)) = updater::check(&api_base, timeout).await { let _ = tx.send(Event::UpdateAvailable(info)); } }); } pub async fn load_menu(&mut self) { let server_url = self.config.server_url.clone(); let timeout = self.config.timeout_sec; let tx = self.event_tx.clone(); tokio::spawn(async move { let result = network::fetch_menu(&server_url, timeout).await; let _ = tx.send(Event::MenuLoaded(result)); }); } pub fn current_items(&self) -> Vec<&MenuItem> { if self.breadcrumbs.is_empty() { self.menu .as_ref() .map(|root| root.menu.iter().collect()) .unwrap_or_default() } else { let mut items = &self.menu.as_ref().unwrap().menu; for &idx in &self.breadcrumbs { if let MenuItemKind::Category { children } = &items[idx].kind { items = children; } else { return vec![]; } } items.iter().collect() } } pub fn selected_item(&self) -> Option<&MenuItem> { let items = self.current_items(); items.get(self.selected_index).copied() } pub async fn handle_event(&mut self, event: Event) -> Result { match event { Event::Crossterm(evt) => self.handle_crossterm_event(evt).await, Event::MenuLoaded(Ok(menu)) => { self.menu = Some(menu); self.error = None; Ok(false) } Event::MenuLoaded(Err(e)) => { self.error = Some(format!("Ошибка загрузки меню: {}", e)); Ok(false) } Event::Error(msg) => { self.error = Some(msg); Ok(false) } Event::ActionCompleted => { self.popup = None; Ok(false) } Event::StructuredCommand(cmd) => { // Exec hands the terminal to an external process — clone reply_tx, // store as pending_exec, and skip setting current_command so the // main loop can suspend ratatui immediately. if let executor::StructuredCommand::Exec { shell } = &cmd { if let Some(Popup::ExecutingStructured { reply_tx, .. }) = &self.popup { self.pending_exec = Some(PendingExec { shell: shell.clone(), reply_tx: reply_tx.clone(), }); } return Ok(false); } if let Some(Popup::ExecutingStructured { current_command, menu_selected_index, .. }) = &mut self.popup { if matches!(cmd, executor::StructuredCommand::Menu { .. }) { *menu_selected_index = 0; } *current_command = Some(cmd); } Ok(false) } Event::StructuredOutput(line) => { if let Some(Popup::ExecutingStructured { log_buffer, log_follow_bottom, log_scroll_pos, .. }) = &mut self.popup { log_buffer.push(line); // log_follow_bottom: render will pin pos to bottom. // Otherwise pos stays fixed → new line appears off-screen. let _ = (log_follow_bottom, log_scroll_pos); // used by render } Ok(false) } Event::StructuredFinished(exit_code) => { if let Some(Popup::ExecutingStructured { finished, .. }) = &mut self.popup { *finished = Some(exit_code); } // If popup was already closed (user pressed Esc to kill the // script), we silently discard the exit event. Ok(false) } Event::UpdateAvailable(info) => { // Only show if no popup is currently open (don't interrupt running scripts) if self.popup.is_none() { self.popup = Some(Popup::UpdateConfirm { info }); } Ok(false) } Event::UpdateProgress(bytes) => { if let Some(Popup::Updating { downloaded, .. }) = &mut self.popup { *downloaded = bytes; } Ok(false) } Event::UpdateDone(exe_path) => { if let Some(Popup::Updating { status, .. }) = &mut self.popup { *status = UpdatingStatus::Done; } self.pending_restart = Some(exe_path); Ok(false) } Event::UpdateError(msg) => { if let Some(Popup::Updating { status, .. }) = &mut self.popup { *status = UpdatingStatus::Failed(msg); } Ok(false) } } } async fn handle_crossterm_event(&mut self, evt: CrosstermEvent) -> Result { match evt { CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => { if let Some(popup) = &mut self.popup { match popup { Popup::Confirming { action, .. } => { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { let action = action.clone(); self.popup = None; self.run_action(action).await?; } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { self.popup = None; } _ => {} } return Ok(false); } Popup::ExecutingStructured { reply_tx, kill_tx, input_buffer, current_command, menu_selected_index, log_scroll_pos, log_follow_bottom, log_buffer, finished, } => { let is_menu = matches!( current_command, Some(executor::StructuredCommand::Menu { .. }) ); let is_confirm = matches!( current_command, Some(executor::StructuredCommand::Confirm { .. }) ); let has_command = current_command.is_some(); // Vim motions are disabled only when free text input is active. let is_text_input = matches!( current_command, Some(executor::StructuredCommand::Input { .. }) ); match key.code { // ── Log scrolling (PageUp / PageDown always work) ─ KeyCode::PageUp => { *log_scroll_pos = log_scroll_pos.saturating_sub(10); *log_follow_bottom = false; } KeyCode::PageDown => { *log_scroll_pos = log_scroll_pos.saturating_add(10); // Render will clamp to max; if we're at the // bottom, mark as following. *log_follow_bottom = *log_scroll_pos + 1 >= log_buffer.len(); } // ── Arrow keys ──────────────────────────────────── KeyCode::Up if is_menu => { if *menu_selected_index > 0 { *menu_selected_index -= 1; } } KeyCode::Down if is_menu => { if let Some(executor::StructuredCommand::Menu { options, .. }) = current_command { if *menu_selected_index + 1 < options.len() { *menu_selected_index += 1; } } } // Scroll log one line when no interactive command pending KeyCode::Up if !has_command => { *log_scroll_pos = log_scroll_pos.saturating_sub(1); *log_follow_bottom = false; } KeyCode::Down if !has_command => { *log_scroll_pos = log_scroll_pos.saturating_add(1); *log_follow_bottom = *log_scroll_pos + 1 >= log_buffer.len(); } // ── Confirm shortcuts (immediate, no Enter needed) ─ KeyCode::Char('y') | KeyCode::Char('Y') if is_confirm => { let _ = current_command.take(); let _ = reply_tx.send("y".to_string()); input_buffer.clear(); } KeyCode::Char('n') | KeyCode::Char('N') if is_confirm => { let _ = current_command.take(); let _ = reply_tx.send("n".to_string()); input_buffer.clear(); } // ── Text input ──────────────────────────────────── // Vim motion keys (j/k/l/h) are excluded here so they // fall through to the vim-motion arms below. KeyCode::Char(c) if !is_menu && !is_confirm && (is_text_input || !matches!(c, 'j' | 'k' | 'l' | 'h')) => { input_buffer.push(c); } KeyCode::Backspace if !is_menu => { input_buffer.pop(); } // ── Enter: commit response ──────────────────────── KeyCode::Enter => { if let Some(cmd) = current_command.take() { let response = match &cmd { executor::StructuredCommand::Input { .. } => { input_buffer.clone() } executor::StructuredCommand::Confirm { .. } => { if input_buffer.to_lowercase().starts_with('y') { "y".to_string() } else { "n".to_string() } } executor::StructuredCommand::Menu { options, .. } => options .get(*menu_selected_index) .map(|opt| opt.id.clone()) .unwrap_or_default(), // Message / Progress: send empty ack. _ => String::new(), }; let _ = reply_tx.send(response); input_buffer.clear(); *menu_selected_index = 0; } } // ── Esc ────────────────────────────────────────── KeyCode::Esc => { if is_confirm { let _ = current_command.take(); let _ = reply_tx.send("n".to_string()); } else { if let Some(kx) = kill_tx.take() { let _ = kx.send(()); } self.popup = None; } } // ── Vim motions (off during text input) ─────────── KeyCode::Char('k') if !is_text_input => { if is_menu { if *menu_selected_index > 0 { *menu_selected_index -= 1; } } else if !has_command { *log_scroll_pos = log_scroll_pos.saturating_sub(1); *log_follow_bottom = false; } } KeyCode::Char('j') if !is_text_input => { if is_menu { if let Some(executor::StructuredCommand::Menu { options, .. }) = current_command { if *menu_selected_index + 1 < options.len() { *menu_selected_index += 1; } } } else if !has_command { *log_scroll_pos = log_scroll_pos.saturating_add(1); *log_follow_bottom = *log_scroll_pos + 1 >= log_buffer.len(); } } KeyCode::Char('l') if !is_text_input => { // Forward / confirm — same logic as Enter. if let Some(cmd) = current_command.take() { let response = match &cmd { executor::StructuredCommand::Confirm { .. } => { "y".to_string() } executor::StructuredCommand::Menu { options, .. } => options .get(*menu_selected_index) .map(|o| o.id.clone()) .unwrap_or_default(), _ => String::new(), }; let _ = reply_tx.send(response); input_buffer.clear(); *menu_selected_index = 0; } } KeyCode::Char('h') if !is_text_input => { // Back / cancel — same logic as Esc. if is_confirm { let _ = current_command.take(); let _ = reply_tx.send("n".to_string()); } else { if let Some(kx) = kill_tx.take() { let _ = kx.send(()); } self.popup = None; } } _ => {} } return Ok(false); } Popup::Message { .. } => { self.popup = None; return Ok(false); } Popup::UpdateConfirm { info } => { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('l') => { let info = info.clone(); self.popup = Some(Popup::Updating { info: info.clone(), downloaded: 0, status: UpdatingStatus::Downloading, }); let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = self.event_tx.clone(); let info_clone = info.clone(); // Forward progress events tokio::spawn(async move { while let Some(bytes) = progress_rx.recv().await { let _ = tx.send(Event::UpdateProgress(bytes)); } }); // Download and apply let tx2 = self.event_tx.clone(); tokio::spawn(async move { match updater::download_and_apply( &info_clone, progress_tx, ) .await { Ok(exe_path) => { let _ = tx2.send(Event::UpdateDone(exe_path)); } Err(e) => { let _ = tx2.send(Event::UpdateError(e.to_string())); } } }); } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc | KeyCode::Char('h') => { self.popup = None; } _ => {} } return Ok(false); } Popup::Updating { status, .. } => { if key.code == KeyCode::Esc { if matches!(status, UpdatingStatus::Failed(_)) { self.popup = None; } // Ignore Esc while downloading/applying } return Ok(false); } _ => {} } } // ── Main menu navigation ───────────────────────────────────────── match key.code { KeyCode::Char('q') => return Ok(true), KeyCode::Up | KeyCode::Char('k') => { let len = self.current_items().len(); if len > 0 { self.selected_index = (self.selected_index + len - 1) % len; } } KeyCode::Down | KeyCode::Char('j') => { let len = self.current_items().len(); if len > 0 { self.selected_index = (self.selected_index + 1) % len; } } KeyCode::Enter | KeyCode::Char('l') => { if let Some(item) = self.selected_item().cloned() { self.activate_item(item).await?; } } KeyCode::Esc | KeyCode::Char('h') => { if !self.breadcrumbs.is_empty() { self.breadcrumbs.pop(); self.selected_index = 0; } } KeyCode::Char('r') | KeyCode::Char('R') => { self.load_menu().await; } _ => {} } } _ => {} } Ok(false) } async fn activate_item(&mut self, item: MenuItem) -> Result<()> { match item.kind { MenuItemKind::Category { children: _ } => { if let Some(idx) = self.current_items().iter().position(|i| i.id == item.id) { self.breadcrumbs.push(idx); self.selected_index = 0; } } MenuItemKind::Action { action } => { if action.confirm() { let confirm_message = action.confirm_message(); self.popup = Some(Popup::Confirming { action, item_title: item.title, confirm_message, }); } else { self.run_action(action).await?; } } } Ok(()) } async fn run_action(&mut self, action: Action) -> Result<()> { match action { Action::Bash { script, interaction, confirm: _, confirm_message: _, } => match interaction { InteractionMode::Terminal => { self.run_bash_terminal(&script).await?; } InteractionMode::Structured => { self.run_bash_structured(&script).await?; } }, Action::Download { url, confirm: _, .. } => { self.popup = Some(Popup::Message { text: format!("Скачивание {} пока не реализовано", url), level: MessageLevel::Info, }); } Action::DownloadAndRun { .. } => {} Action::HttpRequest { .. } => {} } Ok(()) } async fn run_bash_terminal(&mut self, script: &str) -> Result<()> { use tokio::process::Command; let child = Command::new("bash").arg("-c").arg(script).spawn()?; self.popup = Some(Popup::ExecutingBashTerminal { child }); Ok(()) } async fn run_bash_structured(&mut self, script: &str) -> Result<()> { let (output_rx, command_rx, finished_rx, reply_tx, kill_tx) = executor::structured::spawn(script)?; let tx_output = self.event_tx.clone(); tokio::spawn(async move { let mut rx = output_rx; while let Some(line) = rx.recv().await { if tx_output.send(Event::StructuredOutput(line)).is_err() { break; } } }); let tx_cmd = self.event_tx.clone(); tokio::spawn(async move { let mut rx = command_rx; while let Some(cmd) = rx.recv().await { if tx_cmd.send(Event::StructuredCommand(cmd)).is_err() { break; } } }); let tx_finished = self.event_tx.clone(); tokio::spawn(async move { let code = finished_rx.await.unwrap_or(1); let _ = tx_finished.send(Event::StructuredFinished(code)); }); self.popup = Some(Popup::ExecutingStructured { reply_tx, kill_tx: Some(kill_tx), log_buffer: Vec::new(), current_command: None, input_buffer: String::new(), menu_selected_index: 0, log_scroll_pos: 0, log_follow_bottom: false, finished: None, }); Ok(()) } }