//! TUI application state and event loop. pub(crate) mod analytics; pub(crate) mod blog; pub(crate) mod collections; pub(crate) mod home; mod input; pub(crate) mod item; pub(crate) mod keys; mod loading; pub(crate) mod project; pub(crate) mod promo; pub(crate) mod settings; pub(crate) mod tiers; pub(crate) mod upload; pub(crate) 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::{ handle_analytics_input, handle_blog_input, handle_collections_input, handle_home_input, handle_item_input, handle_keys_input, handle_project_input, handle_promo_input, handle_settings_input, handle_tiers_input, handle_upload_input, }; use loading::{load_blog_posts, load_home_data, load_project_items, load_staged_files}; mod run; pub(crate) use run::launch; /// Events sent to the TUI event loop. pub(crate) 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(crate) 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(crate) struct AppHandle { tx: mpsc::Sender, } impl AppHandle { pub(crate) async fn send_input(&self, data: &[u8]) { let _ = self.tx.send(AppEvent::Input(data.to_vec())).await; } pub(crate) 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). /// The id is carried for the tier-mutation calls the screen will need; the /// render path only uses the index today. Tiers(usize, #[allow(dead_code)] String), } /// User-editable metadata for a staged file. #[derive(Debug, Clone, Default)] pub(crate) 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(crate) 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()); } } 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()); } }