Skip to main content

max / makenotwork

32.0 KB · 903 lines History Blame Raw
1 //! TUI application state and event loop.
2
3 pub mod analytics;
4 pub mod blog;
5 pub mod collections;
6 pub mod home;
7 mod input;
8 pub mod item;
9 pub mod keys;
10 mod loading;
11 pub mod project;
12 pub mod promo;
13 pub mod settings;
14 pub mod tiers;
15 pub mod upload;
16 pub mod widgets;
17
18 use std::collections::HashSet;
19 use std::path::PathBuf;
20
21 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
22 use ratatui::Terminal;
23 use ratatui::backend::CrosstermBackend;
24 use tokio::sync::mpsc;
25
26 use crate::api::{
27 AnalyticsData, BlogPost, CollectionInfo, CreatorStats, Item, ItemDetail, LicenseKey,
28 MnwApiClient, Project, PromoCode, SshKeyInfo, StorageInfo, TagInfo, TierInfo, Transaction,
29 UserInfo, Version,
30 };
31 use crate::ssh::terminal::TerminalHandle;
32 use crate::staging::{self, StagedFile};
33
34 use input::*;
35 use loading::*;
36
37 /// Events sent to the TUI event loop.
38 pub enum AppEvent {
39 /// Raw input bytes from the SSH channel.
40 Input(Vec<u8>),
41 /// Terminal resize.
42 Resize(u16, u16),
43 /// Data loaded from the API.
44 DataLoaded(DataPayload),
45 }
46
47 /// Payload variants for async data loading.
48 pub enum DataPayload {
49 Home {
50 projects: Vec<Project>,
51 stats: CreatorStats,
52 },
53 ProjectItems {
54 items: Vec<Item>,
55 },
56 StagedFiles {
57 files: Vec<StagedFile>,
58 storage: Option<StorageInfo>,
59 },
60 PublishResult {
61 filename: String,
62 success: bool,
63 error: Option<String>,
64 },
65 ItemDetail {
66 detail: ItemDetail,
67 versions: Vec<Version>,
68 },
69 ItemUpdated {
70 detail: ItemDetail,
71 },
72 ItemDeleted,
73 ItemActionError {
74 error: String,
75 },
76 /// Signal to reload the project items list after a mutation.
77 #[allow(dead_code)]
78 ProjectReload {
79 project_idx: usize,
80 },
81 BlogPosts {
82 posts: Vec<BlogPost>,
83 },
84 BlogCreated,
85 PromoCodes {
86 codes: Vec<PromoCode>,
87 },
88 LicenseKeys {
89 keys: Vec<LicenseKey>,
90 },
91 GenericSuccess {
92 message: String,
93 },
94 GenericError {
95 error: String,
96 },
97 Analytics {
98 data: AnalyticsData,
99 },
100 Transactions {
101 txs: Vec<Transaction>,
102 },
103 ExportCsv {
104 csv: String,
105 row_count: usize,
106 },
107 Settings {
108 keys: Vec<SshKeyInfo>,
109 storage: Option<StorageInfo>,
110 },
111 ItemTags {
112 tags: Vec<TagInfo>,
113 },
114 TagSearchResults {
115 results: Vec<TagInfo>,
116 },
117 CollectionsList {
118 collections: Vec<CollectionInfo>,
119 },
120 TiersList {
121 tiers: Vec<TierInfo>,
122 },
123 BulkActionComplete {
124 message: String,
125 },
126 }
127
128 /// Handle for sending events to a running TUI session.
129 #[derive(Clone)]
130 pub struct AppHandle {
131 tx: mpsc::Sender<AppEvent>,
132 }
133
134 impl AppHandle {
135 pub async fn send_input(&self, data: &[u8]) {
136 let _ = self.tx.send(AppEvent::Input(data.to_vec())).await;
137 }
138
139 pub async fn send_resize(&self, cols: u16, rows: u16) {
140 let _ = self.tx.send(AppEvent::Resize(cols, rows)).await;
141 }
142 }
143
144 /// Active screen in the TUI.
145 enum Screen {
146 Home,
147 /// Project detail view. Index is into `app.projects`.
148 Project(usize),
149 /// Upload management screen.
150 Upload,
151 /// Item detail view. Stores (project_index, item_id).
152 Item(usize, String),
153 /// Blog post list for a project. Stores (project_index, project_id).
154 Blog(usize, String),
155 /// Promo code management.
156 Promo,
157 /// License key management for an item. Stores (project_index, item_id).
158 Keys(usize, String),
159 /// Analytics dashboard.
160 Analytics,
161 /// Settings screen (profile, storage, SSH keys).
162 Settings,
163 /// Collections management.
164 Collections,
165 /// Subscription tiers for a project. Stores (project_index, project_id).
166 Tiers(usize, String),
167 }
168
169 /// User-editable metadata for a staged file.
170 #[derive(Debug, Clone, Default)]
171 pub struct FileMetadata {
172 pub title: Option<String>,
173 pub project_idx: Option<usize>,
174 pub project_name: Option<String>,
175 pub price_cents: i32,
176 }
177
178 /// Which field is being edited on the upload screen.
179 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
180 pub(crate) enum EditField {
181 Title,
182 Project,
183 Price,
184 }
185
186 /// Steps for creating a blog post.
187 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
188 pub(crate) enum BlogCreateStep {
189 Title,
190 Body,
191 /// Optional scheduling step — enter datetime or leave empty to publish as draft.
192 Schedule,
193 }
194
195 /// Steps for creating a promo code.
196 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
197 pub(crate) enum PromoCreateStep {
198 Code,
199 Discount,
200 }
201
202 /// Pending destructive action awaiting confirmation.
203 #[derive(Debug, Clone)]
204 pub(crate) enum ConfirmAction {
205 DeleteItem,
206 DeleteBlogPost { post_idx: usize },
207 DeletePromoCode { code_idx: usize },
208 RevokeLicenseKey { key_idx: usize },
209 BulkPublish { count: usize },
210 BulkUnpublish { count: usize },
211 BulkDelete { count: usize },
212 }
213
214 /// Application state shared across screens.
215 pub struct App {
216 pub user: UserInfo,
217 pub projects: Vec<Project>,
218 pub stats: Option<CreatorStats>,
219 pub items: Vec<Item>,
220 pub selected_index: usize,
221 pub selected_items: HashSet<usize>,
222 pub loading: bool,
223 pub staged_files: Vec<StagedFile>,
224 pub storage_info: Option<StorageInfo>,
225 pub file_metadata: Vec<FileMetadata>,
226 pub upload_status: Option<String>,
227 pub editing_field: Option<EditField>,
228 pub edit_buffer: String,
229 pub publishing: bool,
230 pub item_detail: Option<ItemDetail>,
231 pub item_versions: Vec<Version>,
232 pub item_status: Option<String>,
233 pub item_editing: Option<item::ItemEditField>,
234 // Blog
235 pub blog_posts: Vec<BlogPost>,
236 pub blog_project_title: Option<String>,
237 pub blog_status: Option<String>,
238 pub blog_creating: bool,
239 pub blog_create_step: Option<BlogCreateStep>,
240 pub blog_create_title: String,
241 pub blog_create_body: String,
242 // Promo codes
243 pub promo_codes: Vec<PromoCode>,
244 pub promo_status: Option<String>,
245 pub promo_editing_step: Option<PromoCreateStep>,
246 pub promo_create_code: String,
247 pub promo_create_discount: String,
248 // License keys
249 pub license_keys: Vec<LicenseKey>,
250 pub keys_item_title: Option<String>,
251 pub keys_status: Option<String>,
252 // Analytics
253 pub analytics_data: Option<AnalyticsData>,
254 pub analytics_range: String,
255 pub analytics_status: Option<String>,
256 pub analytics_show_transactions: bool,
257 pub transactions: Vec<Transaction>,
258 // Settings
259 pub ssh_keys: Vec<SshKeyInfo>,
260 pub settings_status: Option<String>,
261 // Tags (on item detail)
262 pub item_tags: Vec<TagInfo>,
263 pub tag_search_results: Vec<TagInfo>,
264 pub tag_searching: bool,
265 // Collections
266 pub collections: Vec<CollectionInfo>,
267 pub collections_status: Option<String>,
268 // Tiers
269 pub tiers: Vec<TierInfo>,
270 pub tiers_project_title: Option<String>,
271 pub tiers_status: Option<String>,
272 // Confirmation dialog
273 pub confirm_action: Option<ConfirmAction>,
274 }
275
276 impl App {
277 fn new(user: UserInfo) -> Self {
278 Self {
279 user,
280 projects: Vec::new(),
281 stats: None,
282 items: Vec::new(),
283 selected_index: 0,
284 selected_items: HashSet::new(),
285 loading: true,
286 staged_files: Vec::new(),
287 storage_info: None,
288 file_metadata: Vec::new(),
289 upload_status: None,
290 editing_field: None,
291 edit_buffer: String::new(),
292 publishing: false,
293 item_detail: None,
294 item_versions: Vec::new(),
295 item_status: None,
296 item_editing: None,
297 blog_posts: Vec::new(),
298 blog_project_title: None,
299 blog_status: None,
300 blog_creating: false,
301 blog_create_step: None,
302 blog_create_title: String::new(),
303 blog_create_body: String::new(),
304 promo_codes: Vec::new(),
305 promo_status: None,
306 promo_editing_step: None,
307 promo_create_code: String::new(),
308 promo_create_discount: String::new(),
309 license_keys: Vec::new(),
310 keys_item_title: None,
311 keys_status: None,
312 analytics_data: None,
313 analytics_range: "30d".to_string(),
314 analytics_status: None,
315 analytics_show_transactions: false,
316 transactions: Vec::new(),
317 ssh_keys: Vec::new(),
318 settings_status: None,
319 item_tags: Vec::new(),
320 tag_search_results: Vec::new(),
321 tag_searching: false,
322 collections: Vec::new(),
323 collections_status: None,
324 tiers: Vec::new(),
325 tiers_project_title: None,
326 tiers_status: None,
327 confirm_action: None,
328 }
329 }
330
331 fn list_len(&self, screen: &Screen) -> usize {
332 match screen {
333 Screen::Home => self.projects.len(),
334 Screen::Project(_) => self.items.len(),
335 Screen::Upload => self.staged_files.len(),
336 Screen::Item(..) => self.item_versions.len(),
337 Screen::Blog(..) => self.blog_posts.len(),
338 Screen::Promo => self.promo_codes.len(),
339 Screen::Keys(..) => self.license_keys.len(),
340 Screen::Analytics => self.transactions.len(),
341 Screen::Settings => self.ssh_keys.len(),
342 Screen::Collections => self.collections.len(),
343 Screen::Tiers(..) => self.tiers.len(),
344 }
345 }
346
347 fn move_up(&mut self, screen: &Screen) {
348 if self.selected_index > 0 {
349 self.selected_index -= 1;
350 } else {
351 // Wrap to bottom
352 let len = self.list_len(screen);
353 if len > 0 {
354 self.selected_index = len - 1;
355 }
356 }
357 }
358
359 fn move_down(&mut self, screen: &Screen) {
360 let len = self.list_len(screen);
361 if len > 0 {
362 if self.selected_index < len - 1 {
363 self.selected_index += 1;
364 } else {
365 // Wrap to top
366 self.selected_index = 0;
367 }
368 }
369 }
370
371 /// Ensure file_metadata vec matches staged_files length.
372 fn sync_metadata(&mut self) {
373 while self.file_metadata.len() < self.staged_files.len() {
374 let idx = self.file_metadata.len();
375 let title = staging::derive_title(&self.staged_files[idx].filename);
376 self.file_metadata.push(FileMetadata {
377 title: Some(title),
378 ..Default::default()
379 });
380 }
381 self.file_metadata.truncate(self.staged_files.len());
382 }
383 }
384
385 /// Launch the TUI event loop in a background task.
386 #[allow(clippy::too_many_arguments)]
387 pub fn launch(
388 writer: TerminalHandle,
389 user: UserInfo,
390 cols: u16,
391 rows: u16,
392 session_handle: russh::server::Handle,
393 channel_id: russh::ChannelId,
394 api: MnwApiClient,
395 staging_dir: PathBuf,
396 ) -> anyhow::Result<AppHandle> {
397 let mut writer = writer;
398 // Enter alternate screen, hide cursor, enable raw-like mode
399 use std::io::Write;
400 let _ = writer.write_all(b"\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H");
401 let _ = writer.flush();
402 let backend = CrosstermBackend::new(writer);
403 let options = ratatui::TerminalOptions {
404 viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new(0, 0, cols, rows)),
405 };
406 let mut terminal = Terminal::with_options(backend, options)?;
407
408 let (tx, mut rx) = mpsc::channel::<AppEvent>(64);
409 let handle = AppHandle { tx: tx.clone() };
410
411 // Kick off initial data load
412 let user_id = user.user_id.clone();
413 let api_clone = api.clone();
414 let tx_clone = tx.clone();
415 tokio::spawn(async move {
416 load_home_data(&api_clone, &user_id, &tx_clone).await;
417 });
418
419 tokio::spawn(async move {
420 let mut app = App::new(user);
421 let mut screen = Screen::Home;
422 let staging_dir = staging_dir;
423
424 /// Write escape codes to leave alternate screen and restore cursor.
425 fn cleanup(terminal: &mut Terminal<CrosstermBackend<TerminalHandle>>) {
426 use std::io::Write;
427 let be = terminal.backend_mut();
428 let _ = be.write_all(b"\x1b[?25h\x1b[?1049l");
429 let _ = be.flush();
430 }
431
432 // Initial render (loading state)
433 if let Err(e) = terminal.draw(|frame| home::render(frame, &app)) {
434 tracing::error!(error = ?e, "TUI: initial render failed");
435 cleanup(&mut terminal);
436 return;
437 }
438
439 while let Some(event) = rx.recv().await {
440 match event {
441 AppEvent::Input(data) => {
442 if let Some(key) = parse_key(&data) {
443 // Global quit
444 if (key.modifiers.contains(KeyModifiers::CONTROL)
445 && key.code == KeyCode::Char('c'))
446 || matches!(
447 (&screen, key.code),
448 (Screen::Home, KeyCode::Char('q') | KeyCode::Char('Q'))
449 )
450 {
451 tracing::info!(user = %app.user.username, "user quit");
452 cleanup(&mut terminal);
453 let _ = session_handle.close(channel_id).await;
454 return;
455 }
456
457 match screen {
458 Screen::Home => {
459 handle_home_input(
460 key, &mut app, &mut screen, &api, &tx, &staging_dir,
461 )
462 .await;
463 }
464 Screen::Project(_) => {
465 handle_project_input(
466 key, &mut app, &mut screen, &api, &tx,
467 )
468 .await;
469 }
470 Screen::Upload => {
471 handle_upload_input(
472 key, &mut app, &mut screen, &api, &tx, &staging_dir,
473 )
474 .await;
475 }
476 Screen::Item(..) => {
477 handle_item_input(
478 key, &mut app, &mut screen, &api, &tx,
479 )
480 .await;
481 }
482 Screen::Blog(..) => {
483 handle_blog_input(
484 key, &mut app, &mut screen, &api, &tx,
485 )
486 .await;
487 }
488 Screen::Promo => {
489 handle_promo_input(
490 key, &mut app, &mut screen, &api, &tx,
491 )
492 .await;
493 }
494 Screen::Keys(..) => {
495 handle_keys_input(
496 key, &mut app, &mut screen, &api, &tx,
497 )
498 .await;
499 }
500 Screen::Analytics => {
501 handle_analytics_input(
502 key, &mut app, &mut screen, &api, &tx,
503 )
504 .await;
505 }
506 Screen::Settings => {
507 handle_settings_input(
508 key, &mut app, &mut screen, &api, &tx,
509 )
510 .await;
511 }
512 Screen::Collections => {
513 handle_collections_input(
514 key, &mut app, &mut screen, &api, &tx,
515 )
516 .await;
517 }
518 Screen::Tiers(..) => {
519 handle_tiers_input(
520 key, &mut app, &mut screen,
521 )
522 .await;
523 }
524 }
525 }
526 }
527 AppEvent::Resize(cols, rows) => {
528 let rect = ratatui::layout::Rect::new(0, 0, cols, rows);
529 let _ = terminal.resize(rect);
530 }
531 AppEvent::DataLoaded(payload) => match payload {
532 DataPayload::Home { projects, stats } => {
533 app.projects = projects;
534 app.stats = Some(stats);
535 app.loading = false;
536 app.selected_index = 0;
537 }
538 DataPayload::ProjectItems { items } => {
539 app.items = items;
540 app.loading = false;
541 app.selected_index = 0;
542 app.selected_items.clear();
543 }
544 DataPayload::StagedFiles { files, storage } => {
545 app.staged_files = files;
546 if let Some(s) = storage {
547 app.storage_info = Some(s);
548 }
549 app.sync_metadata();
550 app.loading = false;
551 if app.selected_index >= app.staged_files.len() && !app.staged_files.is_empty() {
552 app.selected_index = app.staged_files.len() - 1;
553 }
554 }
555 DataPayload::ItemDetail { detail, versions } => {
556 app.item_detail = Some(detail);
557 app.item_versions = versions;
558 app.loading = false;
559 app.selected_index = 0;
560 }
561 DataPayload::ItemUpdated { detail } => {
562 app.item_detail = Some(detail);
563 app.item_status = Some("Updated".to_string());
564 app.item_editing = None;
565 app.edit_buffer.clear();
566 }
567 DataPayload::ItemDeleted => {
568 app.item_status = Some("Deleted".to_string());
569 // Navigate back to project view
570 if let Screen::Item(project_idx, _) = &screen {
571 let pidx = *project_idx;
572 screen = Screen::Project(pidx);
573 app.item_detail = None;
574 app.item_versions.clear();
575 app.item_status = None;
576 app.selected_index = 0;
577 app.loading = true;
578
579 if let Some(p) = app.projects.get(pidx) {
580 let api = api.clone();
581 let project_id = p.id.clone();
582 let user_id = app.user.user_id.clone();
583 let tx = tx.clone();
584 tokio::spawn(async move {
585 load_project_items(&api, &project_id, &user_id, &tx).await;
586 });
587 }
588 }
589 }
590 DataPayload::ItemActionError { error } => {
591 app.item_status = Some(format!("Error: {}", error));
592 }
593 DataPayload::BlogPosts { posts } => {
594 app.blog_posts = posts;
595 app.loading = false;
596 app.selected_index = 0;
597 }
598 DataPayload::BlogCreated => {
599 app.blog_creating = false;
600 app.blog_create_step = None;
601 app.blog_create_title.clear();
602 app.blog_create_body.clear();
603 app.edit_buffer.clear();
604 app.blog_status = Some("Post created".to_string());
605 // Reload blog posts
606 if let Screen::Blog(_, ref project_id) = screen {
607 let api = api.clone();
608 let user_id = app.user.user_id.clone();
609 let project_id = project_id.clone();
610 let tx = tx.clone();
611 tokio::spawn(async move {
612 load_blog_posts(&api, &user_id, &project_id, &tx).await;
613 });
614 }
615 }
616 DataPayload::PromoCodes { codes } => {
617 app.promo_codes = codes;
618 app.loading = false;
619 app.selected_index = 0;
620 }
621 DataPayload::LicenseKeys { keys: k } => {
622 app.license_keys = k;
623 app.loading = false;
624 app.selected_index = 0;
625 }
626 DataPayload::GenericSuccess { message } => {
627 // Use as status on whatever screen is active
628 match screen {
629 Screen::Blog(..) => app.blog_status = Some(message),
630 Screen::Promo => app.promo_status = Some(message),
631 Screen::Keys(..) => app.keys_status = Some(message),
632 _ => {}
633 }
634 }
635 DataPayload::GenericError { error } => {
636 let msg = format!("Error: {}", error);
637 match screen {
638 Screen::Blog(..) => app.blog_status = Some(msg),
639 Screen::Promo => {
640 app.promo_status = Some(msg);
641 app.promo_editing_step = None;
642 }
643 Screen::Keys(..) => app.keys_status = Some(msg),
644 Screen::Analytics => app.analytics_status = Some(msg),
645 Screen::Settings => app.settings_status = Some(msg),
646 _ => app.item_status = Some(msg),
647 }
648 }
649 DataPayload::Analytics { data } => {
650 app.analytics_data = Some(data);
651 app.loading = false;
652 }
653 DataPayload::Transactions { txs } => {
654 app.transactions = txs;
655 app.loading = false;
656 app.selected_index = 0;
657 }
658 DataPayload::ExportCsv { csv, row_count } => {
659 app.analytics_status =
660 Some(format!("Exported {} rows ({} bytes)", row_count, csv.len()));
661 }
662 DataPayload::Settings { keys, storage } => {
663 app.ssh_keys = keys;
664 if let Some(s) = storage {
665 app.storage_info = Some(s);
666 }
667 app.loading = false;
668 app.selected_index = 0;
669 }
670 DataPayload::ItemTags { tags } => {
671 app.item_tags = tags;
672 }
673 DataPayload::TagSearchResults { results } => {
674 app.tag_search_results = results;
675 app.tag_searching = false;
676 }
677 DataPayload::CollectionsList { collections: c } => {
678 app.collections = c;
679 app.loading = false;
680 app.selected_index = 0;
681 }
682 DataPayload::TiersList { tiers: t } => {
683 app.tiers = t;
684 app.loading = false;
685 app.selected_index = 0;
686 }
687 DataPayload::BulkActionComplete { message } => {
688 app.item_status = Some(message);
689 app.selected_items.clear();
690 // Reload project items
691 if let Screen::Project(pidx) = &screen {
692 if let Some(p) = app.projects.get(*pidx) {
693 app.loading = true;
694 let api = api.clone();
695 let project_id = p.id.clone();
696 let user_id = app.user.user_id.clone();
697 let tx = tx.clone();
698 tokio::spawn(async move {
699 load_project_items(&api, &project_id, &user_id, &tx).await;
700 });
701 }
702 }
703 }
704 DataPayload::ProjectReload { project_idx } => {
705 if let Some(p) = app.projects.get(project_idx) {
706 app.loading = true;
707 let api = api.clone();
708 let project_id = p.id.clone();
709 let user_id = app.user.user_id.clone();
710 let tx = tx.clone();
711 tokio::spawn(async move {
712 load_project_items(&api, &project_id, &user_id, &tx).await;
713 });
714 }
715 }
716 DataPayload::PublishResult {
717 filename,
718 success,
719 error,
720 } => {
721 app.publishing = false;
722 if success {
723 app.upload_status = Some(format!("Published {}", filename));
724 // Reload staged files
725 let staging_dir = staging_dir.clone();
726 let api = api.clone();
727 let user_id = app.user.user_id.clone();
728 let tx = tx.clone();
729 tokio::spawn(async move {
730 load_staged_files(&staging_dir, &api, &user_id, &tx).await;
731 });
732 } else {
733 app.upload_status = Some(format!(
734 "Error: {}",
735 error.unwrap_or_else(|| "unknown error".to_string())
736 ));
737 }
738 }
739 },
740 }
741
742 // Re-render after every event
743 if let Err(e) = terminal.draw(|frame| match &screen {
744 Screen::Home => home::render(frame, &app),
745 Screen::Project(idx) => {
746 if let Some(p) = app.projects.get(*idx) {
747 project::render(frame, &app, p);
748 } else {
749 home::render(frame, &app);
750 }
751 }
752 Screen::Upload => upload::render(frame, &app),
753 Screen::Item(..) => item::render(frame, &app),
754 Screen::Blog(..) => blog::render(frame, &app),
755 Screen::Promo => promo::render(frame, &app),
756 Screen::Keys(..) => keys::render(frame, &app),
757 Screen::Analytics => analytics::render(frame, &app),
758 Screen::Settings => settings::render(frame, &app),
759 Screen::Collections => collections::render(frame, &app),
760 Screen::Tiers(..) => tiers::render(frame, &app),
761 }) {
762 tracing::error!(error = ?e, "render failed");
763 cleanup(&mut terminal);
764 return;
765 }
766 }
767 // Event loop ended (channel dropped)
768 cleanup(&mut terminal);
769 });
770
771 Ok(handle)
772 }
773
774 fn format_edit_prompt(field: EditField, buffer: &str) -> String {
775 let field_name = match field {
776 EditField::Title => "Title",
777 EditField::Project => "Project #",
778 EditField::Price => "Price ($)",
779 };
780 format!("{}: {}_", field_name, buffer)
781 }
782
783 // NOTE: parse_price, parse_key, and tests are below.
784 // All handle_*_input functions are in input.rs.
785 // All load_* functions and publish_file are in loading.rs.
786
787 fn parse_price(input: &str) -> i32 {
788 // Accept "5", "5.00", "5.99", "0" etc.
789 if input.is_empty() || input == "0" || input.eq_ignore_ascii_case("free") {
790 return 0;
791 }
792 if let Some((dollars, cents)) = input.split_once('.') {
793 let d: i32 = dollars.parse().unwrap_or(0);
794 let cents_str = cents.get(..2).unwrap_or(cents);
795 let c: i32 = if cents_str.len() == 1 {
796 cents_str.parse::<i32>().unwrap_or(0) * 10
797 } else {
798 cents_str.parse().unwrap_or(0)
799 };
800 d * 100 + c
801 } else {
802 input.parse::<i32>().unwrap_or(0) * 100
803 }
804 }
805
806 /// Parse raw SSH input bytes into a crossterm KeyEvent.
807 fn parse_key(data: &[u8]) -> Option<KeyEvent> {
808 match data {
809 // Ctrl+C
810 [3] => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
811 // Escape
812 [27] => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
813 // Enter
814 [13] | [10] => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
815 // Tab
816 [9] => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
817 // Backspace
818 [127] | [8] => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
819 // Arrow keys
820 [27, 91, 65] => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)),
821 [27, 91, 66] => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
822 [27, 91, 67] => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
823 [27, 91, 68] => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
824 // Single printable ASCII byte
825 [b] if b.is_ascii_graphic() || *b == b' ' => {
826 Some(KeyEvent::new(KeyCode::Char(*b as char), KeyModifiers::NONE))
827 }
828 // Ctrl+letter (1-26 maps to a-z)
829 [b] if *b >= 1 && *b <= 26 => Some(KeyEvent::new(
830 KeyCode::Char((b + b'a' - 1) as char),
831 KeyModifiers::CONTROL,
832 )),
833 _ => None,
834 }
835 }
836
837
838 #[cfg(test)]
839 mod tests {
840 use super::*;
841
842 #[test]
843 fn parse_price_whole_dollars() {
844 assert_eq!(parse_price("5"), 500);
845 assert_eq!(parse_price("10"), 1000);
846 }
847
848 #[test]
849 fn parse_price_with_cents() {
850 assert_eq!(parse_price("5.99"), 599);
851 assert_eq!(parse_price("0.50"), 50);
852 }
853
854 #[test]
855 fn parse_price_single_digit_cents() {
856 assert_eq!(parse_price("5.5"), 550);
857 assert_eq!(parse_price("1.1"), 110);
858 }
859
860 #[test]
861 fn parse_price_free() {
862 assert_eq!(parse_price("0"), 0);
863 assert_eq!(parse_price("free"), 0);
864 assert_eq!(parse_price("FREE"), 0);
865 assert_eq!(parse_price(""), 0);
866 }
867
868 #[test]
869 fn parse_price_truncates_extra_decimals() {
870 assert_eq!(parse_price("5.999"), 599);
871 }
872
873 #[test]
874 fn parse_key_ctrl_c() {
875 let key = parse_key(&[3]).unwrap();
876 assert_eq!(key.code, KeyCode::Char('c'));
877 assert!(key.modifiers.contains(KeyModifiers::CONTROL));
878 }
879
880 #[test]
881 fn parse_key_enter() {
882 let key = parse_key(&[13]).unwrap();
883 assert_eq!(key.code, KeyCode::Enter);
884 }
885
886 #[test]
887 fn parse_key_arrow_up() {
888 let key = parse_key(&[27, 91, 65]).unwrap();
889 assert_eq!(key.code, KeyCode::Up);
890 }
891
892 #[test]
893 fn parse_key_printable_char() {
894 let key = parse_key(&[b'a']).unwrap();
895 assert_eq!(key.code, KeyCode::Char('a'));
896 }
897
898 #[test]
899 fn parse_key_unknown() {
900 assert!(parse_key(&[27, 91, 100, 100]).is_none());
901 }
902 }
903