//! TUI application state and event loop. pub mod analytics; pub mod blog; pub mod collections; pub mod home; mod input; pub mod item; pub mod keys; mod loading; pub mod project; pub mod promo; pub mod settings; pub mod tiers; pub mod upload; pub mod widgets; use std::collections::HashSet; use std::path::PathBuf; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use tokio::sync::mpsc; use crate::api::{ AnalyticsData, BlogPost, CollectionInfo, CreatorStats, Item, ItemDetail, LicenseKey, MnwApiClient, Project, PromoCode, SshKeyInfo, StorageInfo, TagInfo, TierInfo, Transaction, UserInfo, Version, }; use crate::ssh::terminal::TerminalHandle; use crate::staging::{self, StagedFile}; use input::*; use loading::*; /// Events sent to the TUI event loop. pub enum AppEvent { /// Raw input bytes from the SSH channel. Input(Vec), /// Terminal resize. Resize(u16, u16), /// Data loaded from the API. DataLoaded(DataPayload), } /// Payload variants for async data loading. pub enum DataPayload { Home { projects: Vec, stats: CreatorStats, }, ProjectItems { items: Vec, }, StagedFiles { files: Vec, storage: Option, }, PublishResult { filename: String, success: bool, error: Option, }, ItemDetail { detail: ItemDetail, versions: Vec, }, ItemUpdated { detail: ItemDetail, }, ItemDeleted, ItemActionError { error: String, }, /// Signal to reload the project items list after a mutation. #[allow(dead_code)] ProjectReload { project_idx: usize, }, BlogPosts { posts: Vec, }, BlogCreated, PromoCodes { codes: Vec, }, LicenseKeys { keys: Vec, }, GenericSuccess { message: String, }, GenericError { error: String, }, Analytics { data: AnalyticsData, }, Transactions { txs: Vec, }, ExportCsv { csv: String, row_count: usize, }, Settings { keys: Vec, storage: Option, }, ItemTags { tags: Vec, }, TagSearchResults { results: Vec, }, CollectionsList { collections: Vec, }, TiersList { tiers: Vec, }, BulkActionComplete { message: String, }, } /// Handle for sending events to a running TUI session. #[derive(Clone)] pub struct AppHandle { tx: mpsc::Sender, } impl AppHandle { pub async fn send_input(&self, data: &[u8]) { let _ = self.tx.send(AppEvent::Input(data.to_vec())).await; } pub async fn send_resize(&self, cols: u16, rows: u16) { let _ = self.tx.send(AppEvent::Resize(cols, rows)).await; } } /// Active screen in the TUI. enum Screen { Home, /// Project detail view. Index is into `app.projects`. Project(usize), /// Upload management screen. Upload, /// Item detail view. Stores (project_index, item_id). Item(usize, String), /// Blog post list for a project. Stores (project_index, project_id). Blog(usize, String), /// Promo code management. Promo, /// License key management for an item. Stores (project_index, item_id). Keys(usize, String), /// Analytics dashboard. Analytics, /// Settings screen (profile, storage, SSH keys). Settings, /// Collections management. Collections, /// Subscription tiers for a project. Stores (project_index, project_id). Tiers(usize, String), } /// User-editable metadata for a staged file. #[derive(Debug, Clone, Default)] pub struct FileMetadata { pub title: Option, pub project_idx: Option, pub project_name: Option, pub price_cents: i32, } /// Which field is being edited on the upload screen. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum EditField { Title, Project, Price, } /// Steps for creating a blog post. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum BlogCreateStep { Title, Body, /// Optional scheduling step — enter datetime or leave empty to publish as draft. Schedule, } /// Steps for creating a promo code. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PromoCreateStep { Code, Discount, } /// Pending destructive action awaiting confirmation. #[derive(Debug, Clone)] pub(crate) enum ConfirmAction { DeleteItem, DeleteBlogPost { post_idx: usize }, DeletePromoCode { code_idx: usize }, RevokeLicenseKey { key_idx: usize }, BulkPublish { count: usize }, BulkUnpublish { count: usize }, BulkDelete { count: usize }, } /// Application state shared across screens. pub struct App { pub user: UserInfo, pub projects: Vec, pub stats: Option, pub items: Vec, pub selected_index: usize, pub selected_items: HashSet, pub loading: bool, pub staged_files: Vec, pub storage_info: Option, pub file_metadata: Vec, pub upload_status: Option, pub editing_field: Option, pub edit_buffer: String, pub publishing: bool, pub item_detail: Option, pub item_versions: Vec, pub item_status: Option, pub item_editing: Option, // Blog pub blog_posts: Vec, pub blog_project_title: Option, pub blog_status: Option, pub blog_creating: bool, pub blog_create_step: Option, pub blog_create_title: String, pub blog_create_body: String, // Promo codes pub promo_codes: Vec, pub promo_status: Option, pub promo_editing_step: Option, pub promo_create_code: String, pub promo_create_discount: String, // License keys pub license_keys: Vec, pub keys_item_title: Option, pub keys_status: Option, // Analytics pub analytics_data: Option, pub analytics_range: String, pub analytics_status: Option, pub analytics_show_transactions: bool, pub transactions: Vec, // Settings pub ssh_keys: Vec, pub settings_status: Option, // Tags (on item detail) pub item_tags: Vec, pub tag_search_results: Vec, pub tag_searching: bool, // Collections pub collections: Vec, pub collections_status: Option, // Tiers pub tiers: Vec, pub tiers_project_title: Option, pub tiers_status: Option, // Confirmation dialog pub confirm_action: Option, } impl App { fn new(user: UserInfo) -> Self { Self { user, projects: Vec::new(), stats: None, items: Vec::new(), selected_index: 0, selected_items: HashSet::new(), loading: true, staged_files: Vec::new(), storage_info: None, file_metadata: Vec::new(), upload_status: None, editing_field: None, edit_buffer: String::new(), publishing: false, item_detail: None, item_versions: Vec::new(), item_status: None, item_editing: None, blog_posts: Vec::new(), blog_project_title: None, blog_status: None, blog_creating: false, blog_create_step: None, blog_create_title: String::new(), blog_create_body: String::new(), promo_codes: Vec::new(), promo_status: None, promo_editing_step: None, promo_create_code: String::new(), promo_create_discount: String::new(), license_keys: Vec::new(), keys_item_title: None, keys_status: None, analytics_data: None, analytics_range: "30d".to_string(), analytics_status: None, analytics_show_transactions: false, transactions: Vec::new(), ssh_keys: Vec::new(), settings_status: None, item_tags: Vec::new(), tag_search_results: Vec::new(), tag_searching: false, collections: Vec::new(), collections_status: None, tiers: Vec::new(), tiers_project_title: None, tiers_status: None, confirm_action: None, } } fn list_len(&self, screen: &Screen) -> usize { match screen { Screen::Home => self.projects.len(), Screen::Project(_) => self.items.len(), Screen::Upload => self.staged_files.len(), Screen::Item(..) => self.item_versions.len(), Screen::Blog(..) => self.blog_posts.len(), Screen::Promo => self.promo_codes.len(), Screen::Keys(..) => self.license_keys.len(), Screen::Analytics => self.transactions.len(), Screen::Settings => self.ssh_keys.len(), Screen::Collections => self.collections.len(), Screen::Tiers(..) => self.tiers.len(), } } fn move_up(&mut self, screen: &Screen) { if self.selected_index > 0 { self.selected_index -= 1; } else { // Wrap to bottom let len = self.list_len(screen); if len > 0 { self.selected_index = len - 1; } } } fn move_down(&mut self, screen: &Screen) { let len = self.list_len(screen); if len > 0 { if self.selected_index < len - 1 { self.selected_index += 1; } else { // Wrap to top self.selected_index = 0; } } } /// Ensure file_metadata vec matches staged_files length. fn sync_metadata(&mut self) { while self.file_metadata.len() < self.staged_files.len() { let idx = self.file_metadata.len(); let title = staging::derive_title(&self.staged_files[idx].filename); self.file_metadata.push(FileMetadata { title: Some(title), ..Default::default() }); } self.file_metadata.truncate(self.staged_files.len()); } } /// Launch the TUI event loop in a background task. #[allow(clippy::too_many_arguments)] pub fn launch( writer: TerminalHandle, user: UserInfo, cols: u16, rows: u16, session_handle: russh::server::Handle, channel_id: russh::ChannelId, api: MnwApiClient, staging_dir: PathBuf, ) -> anyhow::Result { let mut writer = writer; // Enter alternate screen, hide cursor, enable raw-like mode use std::io::Write; let _ = writer.write_all(b"\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H"); let _ = writer.flush(); let backend = CrosstermBackend::new(writer); let options = ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new(0, 0, cols, rows)), }; let mut terminal = Terminal::with_options(backend, options)?; let (tx, mut rx) = mpsc::channel::(64); let handle = AppHandle { tx: tx.clone() }; // Kick off initial data load let user_id = user.user_id.clone(); let api_clone = api.clone(); let tx_clone = tx.clone(); tokio::spawn(async move { load_home_data(&api_clone, &user_id, &tx_clone).await; }); tokio::spawn(async move { let mut app = App::new(user); let mut screen = Screen::Home; let staging_dir = staging_dir; /// Write escape codes to leave alternate screen and restore cursor. fn cleanup(terminal: &mut Terminal>) { use std::io::Write; let be = terminal.backend_mut(); let _ = be.write_all(b"\x1b[?25h\x1b[?1049l"); let _ = be.flush(); } // Initial render (loading state) if let Err(e) = terminal.draw(|frame| home::render(frame, &app)) { tracing::error!(error = ?e, "TUI: initial render failed"); cleanup(&mut terminal); return; } while let Some(event) = rx.recv().await { match event { AppEvent::Input(data) => { if let Some(key) = parse_key(&data) { // Global quit if (key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c')) || matches!( (&screen, key.code), (Screen::Home, KeyCode::Char('q') | KeyCode::Char('Q')) ) { tracing::info!(user = %app.user.username, "user quit"); cleanup(&mut terminal); let _ = session_handle.close(channel_id).await; return; } match screen { Screen::Home => { handle_home_input( key, &mut app, &mut screen, &api, &tx, &staging_dir, ) .await; } Screen::Project(_) => { handle_project_input( key, &mut app, &mut screen, &api, &tx, ) .await; } Screen::Upload => { handle_upload_input( key, &mut app, &mut screen, &api, &tx, &staging_dir, ) .await; } Screen::Item(..) => { handle_item_input( key, &mut app, &mut screen, &api, &tx, ) .await; } Screen::Blog(..) => { handle_blog_input( key, &mut app, &mut screen, &api, &tx, ) .await; } Screen::Promo => { handle_promo_input( key, &mut app, &mut screen, &api, &tx, ) .await; } Screen::Keys(..) => { handle_keys_input( key, &mut app, &mut screen, &api, &tx, ) .await; } Screen::Analytics => { handle_analytics_input( key, &mut app, &mut screen, &api, &tx, ) .await; } Screen::Settings => { handle_settings_input( key, &mut app, &mut screen, &api, &tx, ) .await; } Screen::Collections => { handle_collections_input( key, &mut app, &mut screen, &api, &tx, ) .await; } Screen::Tiers(..) => { handle_tiers_input( key, &mut app, &mut screen, ) .await; } } } } AppEvent::Resize(cols, rows) => { let rect = ratatui::layout::Rect::new(0, 0, cols, rows); let _ = terminal.resize(rect); } AppEvent::DataLoaded(payload) => match payload { DataPayload::Home { projects, stats } => { app.projects = projects; app.stats = Some(stats); app.loading = false; app.selected_index = 0; } DataPayload::ProjectItems { items } => { app.items = items; app.loading = false; app.selected_index = 0; app.selected_items.clear(); } DataPayload::StagedFiles { files, storage } => { app.staged_files = files; if let Some(s) = storage { app.storage_info = Some(s); } app.sync_metadata(); app.loading = false; if app.selected_index >= app.staged_files.len() && !app.staged_files.is_empty() { app.selected_index = app.staged_files.len() - 1; } } DataPayload::ItemDetail { detail, versions } => { app.item_detail = Some(detail); app.item_versions = versions; app.loading = false; app.selected_index = 0; } DataPayload::ItemUpdated { detail } => { app.item_detail = Some(detail); app.item_status = Some("Updated".to_string()); app.item_editing = None; app.edit_buffer.clear(); } DataPayload::ItemDeleted => { app.item_status = Some("Deleted".to_string()); // Navigate back to project view if let Screen::Item(project_idx, _) = &screen { let pidx = *project_idx; screen = Screen::Project(pidx); app.item_detail = None; app.item_versions.clear(); app.item_status = None; app.selected_index = 0; app.loading = true; if let Some(p) = app.projects.get(pidx) { let api = api.clone(); let project_id = p.id.clone(); let user_id = app.user.user_id.clone(); let tx = tx.clone(); tokio::spawn(async move { load_project_items(&api, &project_id, &user_id, &tx).await; }); } } } DataPayload::ItemActionError { error } => { app.item_status = Some(format!("Error: {}", error)); } DataPayload::BlogPosts { posts } => { app.blog_posts = posts; app.loading = false; app.selected_index = 0; } DataPayload::BlogCreated => { app.blog_creating = false; app.blog_create_step = None; app.blog_create_title.clear(); app.blog_create_body.clear(); app.edit_buffer.clear(); app.blog_status = Some("Post created".to_string()); // Reload blog posts if let Screen::Blog(_, ref project_id) = screen { let api = api.clone(); let user_id = app.user.user_id.clone(); let project_id = project_id.clone(); let tx = tx.clone(); tokio::spawn(async move { load_blog_posts(&api, &user_id, &project_id, &tx).await; }); } } DataPayload::PromoCodes { codes } => { app.promo_codes = codes; app.loading = false; app.selected_index = 0; } DataPayload::LicenseKeys { keys: k } => { app.license_keys = k; app.loading = false; app.selected_index = 0; } DataPayload::GenericSuccess { message } => { // Use as status on whatever screen is active match screen { Screen::Blog(..) => app.blog_status = Some(message), Screen::Promo => app.promo_status = Some(message), Screen::Keys(..) => app.keys_status = Some(message), _ => {} } } DataPayload::GenericError { error } => { let msg = format!("Error: {}", error); match screen { Screen::Blog(..) => app.blog_status = Some(msg), Screen::Promo => { app.promo_status = Some(msg); app.promo_editing_step = None; } Screen::Keys(..) => app.keys_status = Some(msg), Screen::Analytics => app.analytics_status = Some(msg), Screen::Settings => app.settings_status = Some(msg), _ => app.item_status = Some(msg), } } DataPayload::Analytics { data } => { app.analytics_data = Some(data); app.loading = false; } DataPayload::Transactions { txs } => { app.transactions = txs; app.loading = false; app.selected_index = 0; } DataPayload::ExportCsv { csv, row_count } => { app.analytics_status = Some(format!("Exported {} rows ({} bytes)", row_count, csv.len())); } DataPayload::Settings { keys, storage } => { app.ssh_keys = keys; if let Some(s) = storage { app.storage_info = Some(s); } app.loading = false; app.selected_index = 0; } DataPayload::ItemTags { tags } => { app.item_tags = tags; } DataPayload::TagSearchResults { results } => { app.tag_search_results = results; app.tag_searching = false; } DataPayload::CollectionsList { collections: c } => { app.collections = c; app.loading = false; app.selected_index = 0; } DataPayload::TiersList { tiers: t } => { app.tiers = t; app.loading = false; app.selected_index = 0; } DataPayload::BulkActionComplete { message } => { app.item_status = Some(message); app.selected_items.clear(); // Reload project items if let Screen::Project(pidx) = &screen { if let Some(p) = app.projects.get(*pidx) { app.loading = true; let api = api.clone(); let project_id = p.id.clone(); let user_id = app.user.user_id.clone(); let tx = tx.clone(); tokio::spawn(async move { load_project_items(&api, &project_id, &user_id, &tx).await; }); } } } DataPayload::ProjectReload { project_idx } => { if let Some(p) = app.projects.get(project_idx) { app.loading = true; let api = api.clone(); let project_id = p.id.clone(); let user_id = app.user.user_id.clone(); let tx = tx.clone(); tokio::spawn(async move { load_project_items(&api, &project_id, &user_id, &tx).await; }); } } DataPayload::PublishResult { filename, success, error, } => { app.publishing = false; if success { app.upload_status = Some(format!("Published {}", filename)); // Reload staged files let staging_dir = staging_dir.clone(); let api = api.clone(); let user_id = app.user.user_id.clone(); let tx = tx.clone(); tokio::spawn(async move { load_staged_files(&staging_dir, &api, &user_id, &tx).await; }); } else { app.upload_status = Some(format!( "Error: {}", error.unwrap_or_else(|| "unknown error".to_string()) )); } } }, } // Re-render after every event if let Err(e) = terminal.draw(|frame| match &screen { Screen::Home => home::render(frame, &app), Screen::Project(idx) => { if let Some(p) = app.projects.get(*idx) { project::render(frame, &app, p); } else { home::render(frame, &app); } } Screen::Upload => upload::render(frame, &app), Screen::Item(..) => item::render(frame, &app), Screen::Blog(..) => blog::render(frame, &app), Screen::Promo => promo::render(frame, &app), Screen::Keys(..) => keys::render(frame, &app), Screen::Analytics => analytics::render(frame, &app), Screen::Settings => settings::render(frame, &app), Screen::Collections => collections::render(frame, &app), Screen::Tiers(..) => tiers::render(frame, &app), }) { tracing::error!(error = ?e, "render failed"); cleanup(&mut terminal); return; } } // Event loop ended (channel dropped) cleanup(&mut terminal); }); Ok(handle) } fn format_edit_prompt(field: EditField, buffer: &str) -> String { let field_name = match field { EditField::Title => "Title", EditField::Project => "Project #", EditField::Price => "Price ($)", }; format!("{}: {}_", field_name, buffer) } // NOTE: parse_price, parse_key, and tests are below. // All handle_*_input functions are in input.rs. // All load_* functions and publish_file are in loading.rs. fn parse_price(input: &str) -> i32 { // Accept "5", "5.00", "5.99", "0" etc. if input.is_empty() || input == "0" || input.eq_ignore_ascii_case("free") { return 0; } if let Some((dollars, cents)) = input.split_once('.') { let d: i32 = dollars.parse().unwrap_or(0); let cents_str = cents.get(..2).unwrap_or(cents); let c: i32 = if cents_str.len() == 1 { cents_str.parse::().unwrap_or(0) * 10 } else { cents_str.parse().unwrap_or(0) }; d * 100 + c } else { input.parse::().unwrap_or(0) * 100 } } /// Parse raw SSH input bytes into a crossterm KeyEvent. fn parse_key(data: &[u8]) -> Option { match data { // Ctrl+C [3] => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), // Escape [27] => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), // Enter [13] | [10] => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), // Tab [9] => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), // Backspace [127] | [8] => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)), // Arrow keys [27, 91, 65] => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)), [27, 91, 66] => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), [27, 91, 67] => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)), [27, 91, 68] => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), // Single printable ASCII byte [b] if b.is_ascii_graphic() || *b == b' ' => { Some(KeyEvent::new(KeyCode::Char(*b as char), KeyModifiers::NONE)) } // Ctrl+letter (1-26 maps to a-z) [b] if *b >= 1 && *b <= 26 => Some(KeyEvent::new( KeyCode::Char((b + b'a' - 1) as char), KeyModifiers::CONTROL, )), _ => None, } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_price_whole_dollars() { assert_eq!(parse_price("5"), 500); assert_eq!(parse_price("10"), 1000); } #[test] fn parse_price_with_cents() { assert_eq!(parse_price("5.99"), 599); assert_eq!(parse_price("0.50"), 50); } #[test] fn parse_price_single_digit_cents() { assert_eq!(parse_price("5.5"), 550); assert_eq!(parse_price("1.1"), 110); } #[test] fn parse_price_free() { assert_eq!(parse_price("0"), 0); assert_eq!(parse_price("free"), 0); assert_eq!(parse_price("FREE"), 0); assert_eq!(parse_price(""), 0); } #[test] fn parse_price_truncates_extra_decimals() { assert_eq!(parse_price("5.999"), 599); } #[test] fn parse_key_ctrl_c() { let key = parse_key(&[3]).unwrap(); assert_eq!(key.code, KeyCode::Char('c')); assert!(key.modifiers.contains(KeyModifiers::CONTROL)); } #[test] fn parse_key_enter() { let key = parse_key(&[13]).unwrap(); assert_eq!(key.code, KeyCode::Enter); } #[test] fn parse_key_arrow_up() { let key = parse_key(&[27, 91, 65]).unwrap(); assert_eq!(key.code, KeyCode::Up); } #[test] fn parse_key_printable_char() { let key = parse_key(&[b'a']).unwrap(); assert_eq!(key.code, KeyCode::Char('a')); } #[test] fn parse_key_unknown() { assert!(parse_key(&[27, 91, 100, 100]).is_none()); } }