// 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 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), // для structured сессии StructuredOutput(String), // строка вывода от скрипта StructuredFinished(i32), // код завершения } pub struct App { pub config: Config, pub menu: Option, pub error: Option, // Навигация: стек индексов к текущей категории pub breadcrumbs: Vec, pub selected_index: usize, // индекс в текущем списке // Состояние popup'а pub popup: Option, // Канал для отправки событий самому себе (из асинхронных задач) pub event_tx: UnboundedSender, } pub enum Popup { Confirming { action: Action, item_title: String }, ExecutingBashTerminal { child: tokio::process::Child }, ExecutingStructured { reply_tx: tokio::sync::mpsc::UnboundedSender, log_buffer: Vec, current_command: Option, input_buffer: String, }, Downloading { progress: f32, message: String }, Message { text: String, level: MessageLevel }, } #[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, } } 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) => { if let Some(Popup::ExecutingStructured { current_command, .. }) = &mut self.popup { *current_command = Some(cmd); } Ok(false) } Event::StructuredOutput(line) => { if let Some(Popup::ExecutingStructured { log_buffer, .. }) = &mut self.popup { log_buffer.push(line); } Ok(false) } Event::StructuredFinished(exit_code) => { self.popup = Some(Popup::Message { text: format!("Скрипт завершился с кодом {}", exit_code), level: if exit_code == 0 { MessageLevel::Info } else { MessageLevel::Error }, }); Ok(false) } } } async fn handle_crossterm_event(&mut self, evt: CrosstermEvent) -> Result { match evt { CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => { // Если есть popup, передаём ему if let Some(popup) = &mut self.popup { match popup { Popup::Confirming { action, item_title } => { 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, input_buffer, current_command, .. } => { match key.code { KeyCode::Char(c) => { input_buffer.push(c); } KeyCode::Backspace => { input_buffer.pop(); } 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() == "y" { "y".to_string() } else { "n".to_string() } } executor::StructuredCommand::Menu { .. } => { input_buffer.clone() } _ => String::new(), }; let _ = reply_tx.send(response); input_buffer.clear(); } } KeyCode::Esc => { self.popup = None; } _ => {} } return Ok(false); } Popup::Message { .. } => { self.popup = None; return Ok(false); } _ => {} } } // Навигация по меню match key.code { KeyCode::Char('q') => return Ok(true), KeyCode::Up => { let len = self.current_items().len(); if len > 0 { self.selected_index = (self.selected_index + len - 1) % len; } } KeyCode::Down => { let len = self.current_items().len(); if len > 0 { self.selected_index = (self.selected_index + 1) % len; } } KeyCode::Enter => { if let Some(item) = self.selected_item().cloned() { self.activate_item(item).await?; } } KeyCode::Esc => { 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() { self.popup = Some(Popup::Confirming { action, item_title: item.title, }); } else { self.run_action(action).await?; } } } Ok(()) } async fn run_action(&mut self, action: Action) -> Result<()> { match action { Action::Bash { script, interaction, confirm: _, } => match interaction { InteractionMode::Terminal => { // Запуск в PTY (упрощённо) self.run_bash_terminal(&script).await?; } InteractionMode::Structured => { self.run_bash_structured(&script).await?; } }, Action::Download { url, filename: _, target_dir: _, 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; // В реальности здесь нужно создавать PTY через дополнительную библиотеку // Пока заглушка 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) = 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, log_buffer: Vec::new(), current_command: None, input_buffer: String::new(), }); Ok(()) } } impl Action { fn confirm(&self) -> bool { match self { Action::Bash { confirm, .. } => *confirm, Action::Download { confirm, .. } => *confirm, Action::DownloadAndRun { confirm, .. } => *confirm, Action::HttpRequest { confirm, .. } => *confirm, } } }