Files
ostiary/src/app.rs
2026-03-14 03:14:59 +07:00

377 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<MenuRoot>),
ActionCompleted,
Error(String),
StructuredCommand(executor::StructuredCommand), // для structured сессии
StructuredOutput(String), // строка вывода от скрипта
StructuredFinished(i32), // код завершения
}
pub struct App {
pub config: Config,
pub menu: Option<MenuRoot>,
pub error: Option<String>,
// Навигация: стек индексов к текущей категории
pub breadcrumbs: Vec<usize>,
pub selected_index: usize, // индекс в текущем списке
// Состояние popup'а
pub popup: Option<Popup>,
// Канал для отправки событий самому себе (из асинхронных задач)
pub event_tx: UnboundedSender<Event>,
}
pub enum Popup {
Confirming { action: Action, item_title: String },
ExecutingBashTerminal { child: tokio::process::Child },
ExecutingStructured {
reply_tx: tokio::sync::mpsc::UnboundedSender<String>,
log_buffer: Vec<String>,
current_command: Option<executor::StructuredCommand>,
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<Event>) -> 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<bool> {
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<bool> {
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,
}
}
}